Skip to content
Open
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
86 changes: 79 additions & 7 deletions lagent/adapters/openclaw.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 = """\
Expand Down Expand Up @@ -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 = {
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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 = [
Expand All @@ -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)
8 changes: 6 additions & 2 deletions lagent/serving/sandbox/client_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down