213 lines
8.9 KiB
Python
213 lines
8.9 KiB
Python
"""Output-pattern failure hints for the terminal tool.
|
|
|
|
Extends the exit-code semantics table in ``terminal_tool`` with an
|
|
output-pattern tier: a bounded scan of failed-command output mapped to one
|
|
short, actionable recovery hint.
|
|
|
|
Rules (keep when adding patterns): only fires on non-zero exit; at most ONE
|
|
hint, first match wins, patterns ordered by observed production frequency;
|
|
scans only the first ``_SCAN_CHARS`` so hints key on error headers, not deep
|
|
context; hints state the *next action* in 1-2 sentences, not a diagnosis;
|
|
pure function, no I/O or config reads.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import re
|
|
from typing import Callable, Optional
|
|
|
|
# Bounded scan window: error headers appear early; deep output is noise.
|
|
_SCAN_CHARS = 4000
|
|
|
|
|
|
def _regex_hint(pattern: str, message: str | Callable[[str], str], flags: int = 0) -> Callable[[str, str], Optional[str]]:
|
|
"""Build a hint that fires when ``pattern`` matches; ``{0}`` = first capture group,
|
|
or ``message(group1)`` when a callable is given."""
|
|
rx = re.compile(pattern, flags)
|
|
|
|
def hint(command: str, output: str) -> Optional[str]:
|
|
m = rx.search(output)
|
|
if not m:
|
|
return None
|
|
return message(m.group(1)) if callable(message) else message.format(*m.groups())
|
|
|
|
return hint
|
|
|
|
|
|
# Most `command not found` hits are bare `python` on python3-only distros.
|
|
_MISSING_COMMAND_HINTS = {
|
|
"python": (
|
|
"This system has no bare `python` — use `python3`, or the "
|
|
"project venv's interpreter (e.g. .venv/bin/python)."
|
|
),
|
|
"pip": (
|
|
"This system has no bare `pip` — use `pip3`, `python3 -m pip`, "
|
|
"or the project venv's pip (e.g. .venv/bin/pip)."
|
|
),
|
|
}
|
|
|
|
|
|
def _missing_command_hint(missing: str) -> str:
|
|
return _MISSING_COMMAND_HINTS.get(missing) or (
|
|
f"`{missing}` is not installed or not on PATH. Verify with "
|
|
f"`which {missing}`; install it or use an absolute path instead of "
|
|
"retrying the same command."
|
|
)
|
|
|
|
|
|
# Ordered by production frequency — first match wins.
|
|
_OUTPUT_HINTS: list[Callable[[str, str], Optional[str]]] = [
|
|
# gh version drift; gh already prints the valid field list.
|
|
_regex_hint(
|
|
r'Unknown JSON field: "?(\w+)',
|
|
"The installed gh does not support the JSON field '{0}'. "
|
|
"The valid field list is printed in the output above — retry using "
|
|
"only fields from that list.",
|
|
),
|
|
_regex_hint(
|
|
r"^CONFLICT |Automatic merge failed|needs merge",
|
|
"Git merge conflict. Do not retry this command. Resolve the "
|
|
"conflicted files listed above (edit, then `git add`), then continue "
|
|
"(`git rebase --continue` / commit the merge) — or abort with "
|
|
"`--abort`.",
|
|
re.M,
|
|
),
|
|
_regex_hint(
|
|
r"(?:bash: line \d+: |bash: |sh: \d*:? ?)?([\w.+-]+): command not found",
|
|
_missing_command_hint,
|
|
),
|
|
# Almost always a venv-activation slip, not a missing dependency.
|
|
_regex_hint(
|
|
r"(?:ModuleNotFoundError|ImportError): No module named '?([\w.]+)",
|
|
"Python cannot import '{0}'. Most often the wrong "
|
|
"interpreter is running: activate the project venv (e.g. `source "
|
|
".venv/bin/activate`) or invoke its python directly. Only pip "
|
|
"install if the package is genuinely absent from that venv.",
|
|
),
|
|
_regex_hint(
|
|
r"(?:fatal|error):.*?'([^']+)' already exists",
|
|
"'{0}' already exists — retrying unchanged will keep "
|
|
"failing. Reuse it, choose another name, or delete it first if it is "
|
|
"genuinely stale.",
|
|
),
|
|
_regex_hint(
|
|
r"API rate limit|was submitted too quickly",
|
|
"GitHub API rate limit hit — immediate retries will keep failing. "
|
|
"Continue with other work and retry this operation later.",
|
|
),
|
|
_regex_hint(
|
|
r"Permission denied|EACCES",
|
|
"Permission denied. Check ownership/mode of the target path "
|
|
"(`ls -la`); prefer a user-writable location. Only escalate to sudo "
|
|
"if the task genuinely requires it.",
|
|
),
|
|
]
|
|
|
|
# Exit-code-only hints for codes the semantics table in terminal_tool does
|
|
# not cover per-command. Checked after output patterns.
|
|
_EXIT_CODE_HINTS: dict[int, str] = {
|
|
126: "Exit 126: the file was found but is not executable — `chmod +x` it or invoke it via its interpreter (e.g. `bash script.sh`).",
|
|
137: "Exit 137: the process was SIGKILLed — usually out-of-memory or an external kill. Reduce memory use or check `dmesg | tail` before retrying.",
|
|
124: "Exit 124: the command hit its timeout. Raise timeout= (foreground max 600s) or run it with background=true and notify_on_complete=true.",
|
|
}
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Masked-success detection (exit 0 that probably isn't a success)
|
|
# ---------------------------------------------------------------------------
|
|
# `cargo build 2>&1 | tail -20` exits with tail's 0 (no pipefail) and
|
|
# `cargo build || echo FAILED` exits with echo's 0, so the model can conclude
|
|
# a build passed while the output says it failed. Conservative: BOTH the
|
|
# command shape (top-level pipe into a passthrough consumer, or `|| <cheap
|
|
# fallback>`) AND a strong tool-specific failure shape must hold, and
|
|
# read-only heads (`grep ... | head`) are excluded because their output
|
|
# legitimately contains error text. Advisory only — exit_code is never changed.
|
|
|
|
# Consumers whose exit status says nothing about the upstream command.
|
|
_PASSTHROUGH_CONSUMERS = r"(?:tail|head|cat|tee|less|more|wc|sort|uniq)"
|
|
|
|
# Command shapes that swallow an upstream status -> warning, checked in order.
|
|
_MASKING_SHAPES: list[tuple[re.Pattern[str], str]] = [
|
|
# Top-level `... | tail -20` (not `||`); consumer must be the LAST segment.
|
|
(
|
|
re.compile(r"(?<!\|)\|(?!\|)\s*" + _PASSTHROUGH_CONSUMERS + r"\b[^|]*$"),
|
|
"exit_code 0 here is the status of the last pipeline command "
|
|
"(tail/head/cat/...), NOT of the command before the pipe — and "
|
|
"the output contains failure indicators. Treat this run as "
|
|
"FAILED until proven otherwise: re-run the command WITHOUT the "
|
|
"pipe (output is auto-truncated and the full text is saved to a "
|
|
"file, so piping through tail/head is never needed) to get the "
|
|
"real exit code.",
|
|
),
|
|
# `cmd || echo ...` / `cmd || true` — fallback swallows the failure status.
|
|
(
|
|
re.compile(r"\|\|\s*(?:echo\b|printf\b|true\b|:\s|:$)"),
|
|
"exit_code 0 here is the status of the `||` fallback (echo/true), "
|
|
"NOT of the command before it — and the output contains failure "
|
|
"indicators. Treat this run as FAILED until proven otherwise: "
|
|
"re-run the command bare to get its real exit code.",
|
|
),
|
|
]
|
|
|
|
_READONLY_HEADS = frozenset({
|
|
"grep", "rg", "ag", "find", "ls", "cat", "head", "tail", "jq", "awk",
|
|
"sed", "strings", "zcat", "journalctl", "dmesg", "echo", "printf",
|
|
})
|
|
|
|
# Strong failure shapes keyed to specific tools so that error-mentioning
|
|
# *content* (diffs, logs, commit messages) rarely matches.
|
|
_FAILURE_SHAPES = re.compile(
|
|
r"(?:"
|
|
r"error\[E\d+\]" # rustc
|
|
r"|error: could not compile" # cargo
|
|
r"|error: aborting due to" # rustc summary
|
|
r"|Traceback \(most recent call last\)" # python
|
|
r"|(?m:^(?:=+ )?\d+ failed)" # pytest summary
|
|
r"|(?m:^FAILED (?:\S+::|\S+\.py))" # pytest per-test lines
|
|
r"|compilation terminated\." # gcc/clang
|
|
r"|npm ERR!" # npm
|
|
r"|BUILD FAILED|Build FAILED" # gradle/msbuild/echoed fallbacks
|
|
r"|FAILED: " # ninja
|
|
r"|(?m:^make(?:\[\d+\])?: \*\*\*)" # make
|
|
r")"
|
|
)
|
|
|
|
|
|
def _first_token(command: str) -> str:
|
|
for tok in (command or "").strip().split():
|
|
# Skip env-var assignments and common wrappers.
|
|
if "=" in tok and not tok.startswith(("=", "./", "/")):
|
|
continue
|
|
return tok.rsplit("/", 1)[-1]
|
|
return ""
|
|
|
|
|
|
def annotate_masked_success(command: str, output: str) -> Optional[str]:
|
|
"""Return a warning note when an exit-0 result likely masks a failure.
|
|
|
|
Caller gates on exit_code == 0. Advisory only; returns None otherwise.
|
|
"""
|
|
cmd = command or ""
|
|
window = (output or "")[:_SCAN_CHARS]
|
|
if not cmd or not window or _first_token(cmd) in _READONLY_HEADS:
|
|
return None
|
|
if not _FAILURE_SHAPES.search(window):
|
|
return None
|
|
return next((note for rx, note in _MASKING_SHAPES if rx.search(cmd)), None)
|
|
|
|
|
|
def annotate_failure(command: str, exit_code: int, output: str) -> Optional[str]:
|
|
"""Return one short recovery hint for a failed command, or None (exit 0)."""
|
|
if exit_code == 0:
|
|
return None
|
|
window = (output or "")[:_SCAN_CHARS]
|
|
if window:
|
|
for fn in _OUTPUT_HINTS:
|
|
try:
|
|
hint = fn(command or "", window)
|
|
except Exception:
|
|
continue
|
|
if hint:
|
|
return hint
|
|
return _EXIT_CODE_HINTS.get(exit_code)
|