diff --git a/README.md b/README.md index 06715d5..4dc6cb7 100644 --- a/README.md +++ b/README.md @@ -270,6 +270,20 @@ LLM_PROVIDER=ollama pythonclaw start # daemon + web dashboard Pick a different model with `OLLAMA_MODEL=qwen3`, or point at a remote Ollama with `OLLAMA_BASE_URL=http://gpu-box:11434/v1`. +### Troubleshooting: "timeout" errors after binding your API key + +Not every error containing the word *timeout* is an API/network timeout. A common +case: a script the agent generated (or a downloaded skill) calls +`subprocess.Popen(cmd, timeout=N)` — `Popen` has no `timeout` parameter, so Python +raises `TypeError: Popen.__init__() got an unexpected keyword argument 'timeout'`. +Your API key and connection are fine; the script just needs +`subprocess.run(cmd, timeout=N)` instead. PythonClaw now detects this and attaches +a hint to the tool output so the agent fixes the script on the next round. + +If you hit a *real* network timeout (request hangs ~300 s, error mentions your +provider host), check `llm..baseUrl` — a missing `/v1` suffix is the +most common cause — and verify the endpoint answers `curl $BASEURL/models`. + --- ## Docker diff --git a/pythonclaw/core/tools.py b/pythonclaw/core/tools.py index 4dd95c3..ec360a7 100644 --- a/pythonclaw/core/tools.py +++ b/pythonclaw/core/tools.py @@ -18,6 +18,7 @@ import logging import os +import re import subprocess import sys @@ -180,6 +181,43 @@ def truncate_output(text: str, label: str = "output", limit: int | None = None) return head + note + tail +# Scripts run through run_command are often generated on the fly (by the +# agent or a downloaded skill). A classic mistake in such code is +# ``subprocess.Popen(cmd, timeout=N)`` — Popen has no *timeout* parameter, +# so Python raises ``TypeError: Popen.__init__() got an unexpected keyword +# argument 'timeout'``. The only salient word in that message is "timeout", +# which users read as a network/API timeout and go debugging their LLM key +# instead of the script (issue #3). +_POPEN_BAD_KWARG_RE = re.compile( + r"Popen\.__init__\(\) got an unexpected keyword argument '([^']+)'" +) + + +def _script_error_hint(output: str) -> str: + """Return an actionable hint for known-confusing errors in script output. + + The hint is appended to the tool result so the model can fix the script + on the next round instead of relaying a misleading bare error to the user. + Returns an empty string when no known pattern matches. + """ + m = _POPEN_BAD_KWARG_RE.search(output) + if not m: + return "" + kwarg = m.group(1) + hint = ( + f"\n\n[hint] This TypeError comes from the executed script itself — " + f"subprocess.Popen() does not accept a `{kwarg}` keyword argument." + ) + if kwarg == "timeout": + hint += ( + " It is NOT a network/API timeout and NOT an LLM key problem. " + "Fix the script: use `subprocess.run(cmd, timeout=N)` or " + "`proc = subprocess.Popen(cmd); proc.communicate(timeout=N)` " + "instead of passing `timeout=` to Popen(), then rerun it." + ) + return hint + + def run_command(command: str, timeout: int = 120) -> str: """Execute a shell command and return combined stdout/stderr. @@ -198,10 +236,13 @@ def run_command(command: str, timeout: int = 120) -> str: out = result.stdout if result.stderr.strip(): out += f"\n[stderr]\n{result.stderr}" - return truncate_output(out, label="run_command") + # Scripts sometimes swallow the exception and exit 0 — still hint. + return truncate_output( + out + _script_error_hint(out), label="run_command", + ) + err = f"Error (exit {result.returncode}):\n{result.stderr}\n{result.stdout}" return truncate_output( - f"Error (exit {result.returncode}):\n{result.stderr}\n{result.stdout}", - label="run_command", + err + _script_error_hint(err), label="run_command", ) except subprocess.TimeoutExpired: return ( diff --git a/tests/test_tool_error_hints.py b/tests/test_tool_error_hints.py new file mode 100644 index 0000000..7453c7a --- /dev/null +++ b/tests/test_tool_error_hints.py @@ -0,0 +1,86 @@ +""" +Regression tests for issue #3: a script executed via run_command that calls +``subprocess.Popen(cmd, timeout=N)`` crashes with + + TypeError: Popen.__init__() got an unexpected keyword argument 'timeout' + +The only salient word in that message is "timeout", which users read as a +network/API timeout ("PythonClaw keeps giving me timeout errors even after +I've bound the API"). run_command must attach a hint that names the real +cause so the agent can fix the script and the user is not misled. +""" +import sys + +import pytest + +from pythonclaw.core import tools +from pythonclaw.core.tools import _script_error_hint, run_command + + +@pytest.fixture(autouse=True) +def _isolate_dirs(tmp_path, monkeypatch): + monkeypatch.setattr(tools, "_files_dir", lambda: str(tmp_path)) + monkeypatch.setattr(tools, "_spill_dir", lambda: str(tmp_path)) + + +# ── _script_error_hint unit tests ──────────────────────────────────────────── + +def test_hint_for_popen_timeout_typeerror(): + err = ( + "Traceback (most recent call last):\n" + ' File "", line 1, in \n' + "TypeError: Popen.__init__() got an unexpected keyword argument 'timeout'" + ) + hint = _script_error_hint(err) + assert "NOT a network/API timeout" in hint + assert "subprocess.run(cmd, timeout=N)" in hint + assert "communicate(timeout=N)" in hint + + +def test_hint_matches_bare_exception_message(): + # Scripts with a catch-all `except Exception as exc: print(exc)` surface + # the message without the "TypeError:" prefix — the hint must still fire. + err = "execution failed: Popen.__init__() got an unexpected keyword argument 'timeout'" + assert "NOT a network/API timeout" in _script_error_hint(err) + + +def test_hint_for_other_popen_kwarg_is_generic(): + err = "TypeError: Popen.__init__() got an unexpected keyword argument 'capture_output'" + hint = _script_error_hint(err) + assert "`capture_output`" in hint + assert "NOT a network/API timeout" not in hint + + +def test_no_hint_for_unrelated_errors(): + assert _script_error_hint("Error (exit 1):\nNameError: name 'x' is not defined") == "" + assert _script_error_hint("") == "" + + +# ── run_command integration ────────────────────────────────────────────────── + +def test_run_command_appends_hint_on_popen_timeout_crash(): + code = "import subprocess; subprocess.Popen(['echo', 'hi'], timeout=5)" + out = run_command(f'{sys.executable} -c "{code}"') + assert "unexpected keyword argument 'timeout'" in out + assert "[hint]" in out + assert "NOT a network/API timeout" in out + + +def test_run_command_appends_hint_when_script_swallows_error(): + # Script catches the TypeError, prints it, and exits 0. + code = ( + "import subprocess\n" + "try:\n" + " subprocess.Popen(['echo', 'hi'], timeout=5)\n" + "except Exception as exc:\n" + " print(f'execution failed: {exc}')\n" + ) + out = run_command(f'{sys.executable} -c "{code}"') + assert "[hint]" in out + assert "NOT a network/API timeout" in out + + +def test_run_command_no_hint_on_clean_output(): + out = run_command(f'{sys.executable} -c "print(42)"') + assert "42" in out + assert "[hint]" not in out