diff --git a/lagent/adapters/openclaw.py b/lagent/adapters/openclaw.py index a336512..fd5380b 100644 --- a/lagent/adapters/openclaw.py +++ b/lagent/adapters/openclaw.py @@ -36,10 +36,12 @@ import json import os +import re import shlex import shutil +import subprocess from pathlib import Path -from typing import List, Optional +from typing import List, Optional, Tuple from .cli_adapter import CLIAgentAdapter @@ -116,6 +118,37 @@ def __init__( self.env_vars.setdefault('NO_COLOR', '1') self._cli_session_id: Optional[str] = None self._runtime_config_written = False + self._openclaw_version: Optional[Tuple[int, int, int]] = None + + @staticmethod + def _parse_openclaw_version(text: str) -> Optional[Tuple[int, int, int]]: + match = re.search(r'OpenClaw\s+(\d+)\.(\d+)\.(\d+)', text) + if not match: + return None + return tuple(int(part) for part in match.groups()) + + def _detect_openclaw_version(self) -> Optional[Tuple[int, int, int]]: + if self._openclaw_version is not None: + return self._openclaw_version + try: + proc = subprocess.run( + [self.binary, '--version'], + capture_output=True, + text=True, + timeout=10, + check=False, + ) + text = (proc.stdout or proc.stderr or '').strip() + print(f"openclaw version: {text}") + self._openclaw_version = self._parse_openclaw_version(text) + except (OSError, subprocess.SubprocessError): + self._openclaw_version = None + return self._openclaw_version + + def _supports_provider_timeout_seconds(self) -> bool: + """``models.providers.*.timeoutSeconds`` landed in 2026.4.26.""" + version = self._detect_openclaw_version() + return version is not None and version >= (2026, 4, 26) def setup(self) -> None: if self.nvm_dir: @@ -164,6 +197,32 @@ def reset_session(self) -> None: """Forget the captured session id; the next call starts fresh.""" self._cli_session_id = None + @staticmethod + def _load_openclaw_json(text: str) -> Optional[dict]: + """Load an OpenClaw JSON envelope from stdout or mixed stderr logs.""" + stripped = text.strip() + if not stripped: + return None + try: + data = json.loads(stripped) + return data if isinstance(data, dict) else None + except json.JSONDecodeError: + pass + + decoder = json.JSONDecoder() + fallback = None + for match in re.finditer(r'\{', stripped): + try: + data, _ = decoder.raw_decode(stripped[match.start():]) + except json.JSONDecodeError: + continue + if not isinstance(data, dict): + continue + if 'payloads' in data or 'meta' in data: + return data + fallback = data + return fallback + _IDENTITY_TEMPLATE_MARKER = 'Fill this in during your first conversation' _USER_TEMPLATE_MARKER = '_Learn about the person you' _MINIMAL_IDENTITY = """\ @@ -254,10 +313,10 @@ def _write_openclaw_config(self) -> None: } ], } - # OpenClaw aborts a model request when no response chunks arrive before the idle window. - # https://docs.openclaw.ai/concepts/agent-loop#timeouts + # Provider-scoped HTTP timeout is only valid on OpenClaw >= 2026.4.26. + # Agent run ceiling is handled separately via ``--timeout`` in _build_argv. provider_timeout = os.environ.get('OPENCLAW_PROVIDER_TIMEOUT_SECONDS', "1200") - if provider_timeout: + if provider_timeout and self._supports_provider_timeout_seconds(): provider_config['timeoutSeconds'] = int(provider_timeout) config = { @@ -286,9 +345,14 @@ def _default_parse(self, stdout: str, stderr: str) -> str: if not self.json_output: return stdout.strip() - try: - data = json.loads(stdout.strip()) - except json.JSONDecodeError: + # 低版本 OpenClaw 在某些错误路径会 exit 0、stdout 为空,并把 JSON envelope + # 混在 stderr 日志后面;不能把这种情况静默解析成空字符串。 + data = self._load_openclaw_json(stdout) + parsed_from_stderr = False + if data is None and not stdout.strip(): + data = self._load_openclaw_json(stderr) + parsed_from_stderr = data is not None + if data is None: return stdout.strip() if not isinstance(data, dict): @@ -320,11 +384,15 @@ def _default_parse(self, stdout: str, stderr: str) -> str: ] joined = '\n'.join(p for p in parts if p) if joined: + if parsed_from_stderr and 'isError=true' in stderr: + raise RuntimeError(f'OpenClaw error: {joined}') return joined for key in ('reply', 'message', 'response', 'output', 'text', 'result', 'content'): value = data.get(key) if isinstance(value, str) and value: + if parsed_from_stderr and 'isError=true' in stderr: + raise RuntimeError(f'OpenClaw error: {value}') return value if isinstance(value, list): blocks = [ @@ -333,5 +401,9 @@ def _default_parse(self, stdout: str, stderr: str) -> str: ] joined = '\n'.join(b for b in blocks if b) if joined: + if parsed_from_stderr and 'isError=true' in stderr: + raise RuntimeError(f'OpenClaw error: {joined}') return joined + if parsed_from_stderr and stderr.strip(): + raise RuntimeError(f'OpenClaw error: {stderr.strip()[:2000]}') return json.dumps(data, ensure_ascii=False) diff --git a/lagent/serving/sandbox/client_cli.py b/lagent/serving/sandbox/client_cli.py index 7b05055..47f253e 100644 --- a/lagent/serving/sandbox/client_cli.py +++ b/lagent/serving/sandbox/client_cli.py @@ -32,10 +32,14 @@ def _call_json(sock: str, payload: dict[str, Any]) -> dict[str, Any] | list: obj = json.loads(raw or "{}") except json.JSONDecodeError as exc: raise DaemonCallError(f"invalid daemon response: {exc}: {raw}") from exc - if isinstance(obj, dict) and obj.get("error"): - raise DaemonCallError(str(obj["error"])) if not isinstance(obj, (dict, list)): raise DaemonCallError(f"daemon response must be a JSON object or array, got {type(obj).__name__}") + if isinstance(obj, dict): + if obj.get("error"): + raise DaemonCallError(str(obj["error"])) + extra_info = obj.get("extra_info") + if isinstance(extra_info, dict) and extra_info.get("error"): + raise DaemonCallError(str(extra_info["error"])) return obj