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:
+21
-5
@@ -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
|
||||
|
||||
+9
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user