Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<provider>.baseUrl` — a missing `/v1` suffix is the
most common cause — and verify the endpoint answers `curl $BASEURL/models`.

---

## Docker
Expand Down
47 changes: 44 additions & 3 deletions pythonclaw/core/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import logging
import os
import re
import subprocess
import sys

Expand Down Expand Up @@ -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.

Expand All @@ -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 (
Expand Down
86 changes: 86 additions & 0 deletions tests/test_tool_error_hints.py
Original file line number Diff line number Diff line change
@@ -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 "<string>", line 1, in <module>\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
Loading