fix(weak-model-gates): stop three gates from stalling weak stage models

Diagnosed from session 508c8d58 (frontend freestyle run, WorkflowFailed):
three independent weak-model-hostile gates, none a model-capability problem.

- shell: split a collapsed single-string command line (["npm create vite …"])
  into tokens instead of rejecting it as a "collapsed array". The model
  reliably re-emits this shape; rejecting looped bootstrap_frontend until
  stage_loop_break. JSON-escape mangles (quotes/commas in argv[0]) stay rejected.
- recovery: a stage_loop_break route now gets a "Stuck-loop ticket", not the
  "Contract arbitration ticket". The arbitration prompt told the model to read
  and reconcile "the files named below" — but a tool-syntax loop names zero
  files, sending the recovery agent grepping the repo for 40+ turns until the
  repair ladder exhausted.
- plan lint: H3 (unreferenced_prompt_artifact) demoted from hard failure to
  soft finding, and seeds excluded from it. It word-matches artifact IDs in
  free prose and cannot tell a forgotten dep from a descriptive mention, so as
  a hard gate it burned architect retries on words it couldn't reword away
  (analysis/dod). Hard tier is now deterministic graph facts only (H1/H2).

Tests: ShellToolTest, PlanLinterTest, RecoveryRoutingTest green; detekt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Ly2mMnt9TCZbvhcC1JfuV
This commit is contained in:
2026-07-19 22:33:01 +04:00
parent 0a001c42c7
commit 95b16a5047
5 changed files with 84 additions and 19 deletions
@@ -164,15 +164,30 @@ class ShellTool(
}
}
// argv[0] is the program name; embedded quotes/commas/whitespace mean the array collapsed into
// one string element and there is no real executable to run.
private fun checkExecutable(argv: List<String>): ArgvParse =
if (argv[0].any { it == '"' || it == ',' || it.isWhitespace() }) {
// argv[0] is the program name. A single element carrying whitespace is the weak-model "whole
// command line in one string" collapse (["npm create vite@latest frontend"]) — split it into
// tokens so the denylist, allowlist and executor all see a real argv, instead of rejecting it and
// looping the stage on a call the model reliably re-emits. A JSON-escape mangle collapses
// differently — it leaves quotes/commas in argv[0] (["npm\",\"-v"]) — and stays rejected: there is
// no runnable program to recover. (Splitting on whitespace loses quoting of args with spaces —
// same ceiling as the `sh -c` join below; fine for agent shell use, not a general shell API.)
private fun checkExecutable(raw: List<String>): ArgvParse {
val argv = if (raw.size == 1 && raw[0].any { it.isWhitespace() } &&
raw[0].none { it == '"' || it == ',' }
) {
raw[0].trim().split(WHITESPACE_RE)
} else {
raw
}
return if (argv[0].any { it == '"' || it == ',' || it.isWhitespace() }) {
ArgvParse.Bad(
"argv[0] `${argv[0]}` is not a valid executable name — it looks like a collapsed array. " +
"Emit each token as a separate string element, e.g. [\"npm\", \"-v\"].",
)
} else ArgvParse.Ok(argv)
} else {
ArgvParse.Ok(argv)
}
}
override suspend fun execute(request: ToolRequest): ToolResult = withContext(Dispatchers.IO) {
validateRequest(request).run {
@@ -221,6 +236,7 @@ class ShellTool(
// (line 250 of 500) survives the head/tail window instead of being silently dropped.
const val SALIENCE_PATTERN = "error|fail|exception|panic|traceback|✗"
const val EMPTY_MSG = "Missing or empty 'argv' parameter. Expected a JSON array of strings."
val WHITESPACE_RE = Regex("\\s+")
val SHELL_BUILTINS = setOf("cd", "export", "source", ".", "pushd", "popd", "umask", "ulimit")
val SHELL_OPERATORS = setOf("&&", "||", "|", ">", ">>", "<", ";", "&", "2>", "2>&1")
// Operators that chain a new command (its following token is a command position); the rest
@@ -204,6 +204,15 @@ class ShellToolTest {
assertTrue((result as ValidationResult.Invalid).reason.contains("collapsed array"), result.reason)
}
@Test
fun `validateRequest splits a collapsed single-string command line into tokens`() {
// Repro: weak model emitted ["npm create vite@latest frontend"] — the whole command line as
// one whitespace-carrying element (no quotes/commas). Now split into tokens instead of
// rejected as a collapsed array, so the stage doesn't loop on a call the model keeps re-emitting.
val result = ShellTool().validateRequest(rawArgvRequest(listOf("npm create vite@latest frontend")))
assertTrue(result is ValidationResult.Valid, (result as? ValidationResult.Invalid)?.reason ?: "")
}
@Test
fun `execute runs a shell command line via sh -c`(): Unit = runBlocking {
// Repro: gemma called shell with a builtin/command-line ("cd apps && ..."). Now honoured