From d731288ffaabbc8ccce6005e967a7e8f5a9a512a Mon Sep 17 00:00:00 2001 From: oliver Date: Wed, 6 May 2026 22:34:54 +0800 Subject: [PATCH] fix(runtime): harden tool-call pairing and extend run_command timeouts Prevent invalid tool_calls replay by requiring immediate tool-result adjacency in message building, and raise run_command default and tool-level timeout ceilings to reduce install-command timeout failures. Co-authored-by: Cursor --- runtime/chat/agent_messages.py | 21 ++++++++++++++++++++- runtime/tools/local_sdk/adapter.py | 4 ++-- runtime/tools/public/run_command_tool.py | 6 +++--- tests/test_local_public_tools.py | 2 +- 4 files changed, 26 insertions(+), 7 deletions(-) diff --git a/runtime/chat/agent_messages.py b/runtime/chat/agent_messages.py index 5cd77dd9..f9d19c9e 100644 --- a/runtime/chat/agent_messages.py +++ b/runtime/chat/agent_messages.py @@ -514,11 +514,30 @@ def build_llm_messages( tool_calls = None if tool_calls and isinstance(tool_calls, list): - # Guard: only include tool_calls if tool results exist later in this trimmed window. + # Guard: only include assistant tool_calls when paired tool rows are present. + # OpenAI-compatible providers require strict adjacency: + # assistant(tool_calls) must be followed immediately by matching tool rows. want_ids = [str(tc.get("id") or "").strip() for tc in tool_calls if isinstance(tc, dict) and str(tc.get("id") or "").strip()] suffix = tool_ids_after[i] if (i >= 0 and i < len(tool_ids_after)) else set() if want_ids and any(tid not in suffix for tid in want_ids): tool_calls = None + # Stronger guard than suffix-presence: verify immediate following block. + if tool_calls is not None and want_ids: + immediate_ids: set[str] = set() + for j in range(i + 1, len(store_messages)): + nm = store_messages[j] + n_event_type = str(getattr(nm, "event_type", "") or "").strip().lower() + # Ignore reasoning-only rows when checking adjacency. + if n_event_type == "reasoning": + continue + n_role = str(getattr(nm, "role", "") or "") + if n_role != "tool": + break + tcid = _tool_call_id_from_tool_row(getattr(nm, "tool_calls", None)) + if tcid: + immediate_ids.add(str(tcid)) + if any(tid not in immediate_ids for tid in want_ids): + tool_calls = None if tool_calls is None: out.append( _attach_reasoning_content( diff --git a/runtime/tools/local_sdk/adapter.py b/runtime/tools/local_sdk/adapter.py index 161d31fe..94fdddc7 100644 --- a/runtime/tools/local_sdk/adapter.py +++ b/runtime/tools/local_sdk/adapter.py @@ -66,7 +66,7 @@ class LocalAdapter: os.environ[k] = str(value) return {"ok": True, "key": k, "value": os.environ.get(k), "deleted": False} - def run_command(self, *, command: str, cwd: str | None = None, timeout: int = 30) -> dict[str, Any]: + def run_command(self, *, command: str, cwd: str | None = None, timeout: int = 300) -> dict[str, Any]: cmd = str(command or "").strip() if not cmd: return {"ok": False, "error_code": "command_required", "error": "command_required"} @@ -103,7 +103,7 @@ class LocalAdapter: except Exception: return {"ok": False, "error_code": "disabled", "error": "disabled"} try: - timeout_s = max(1, min(int(timeout or 30), 600)) + timeout_s = max(1, min(int(timeout or 300), 600)) # run_command never follows adapter cd state. # It only uses explicit cwd; otherwise defaults to data/workspace. workdir = str(resolve_workspace_path(cwd or "data/workspace")) diff --git a/runtime/tools/public/run_command_tool.py b/runtime/tools/public/run_command_tool.py index 6f231cf9..add179ed 100644 --- a/runtime/tools/public/run_command_tool.py +++ b/runtime/tools/public/run_command_tool.py @@ -12,7 +12,7 @@ def run_command_tool() -> ToolSpec: if not command: return {"ok": False, "error_code": "command_required", "error": "command_required"} cwd = str(args.get("cwd") or "").strip() or None - timeout = int(args.get("timeout") or 30) + timeout = int(args.get("timeout") or 300) return get_local_adapter().run_command(command=command, cwd=cwd, timeout=timeout) return ToolSpec( @@ -23,7 +23,7 @@ def run_command_tool() -> ToolSpec: "properties": { "command": {"type": "string", "description": "Shell command to execute."}, "cwd": {"type": "string", "description": "Optional working directory."}, - "timeout": {"type": "integer", "description": "Timeout in seconds.", "default": 30}, + "timeout": {"type": "integer", "description": "Timeout in seconds.", "default": 300}, }, "required": ["command"], "additionalProperties": False, @@ -31,7 +31,7 @@ def run_command_tool() -> ToolSpec: handler=_handler, tags=frozenset({"public", "exec"}), risk_level="high", - timeout_s=90.0, + timeout_s=620.0, read_only=False, ) diff --git a/tests/test_local_public_tools.py b/tests/test_local_public_tools.py index 499ea72e..b22bdca8 100644 --- a/tests/test_local_public_tools.py +++ b/tests/test_local_public_tools.py @@ -74,7 +74,7 @@ def test_local_adapter_backend_roundtrip(tmp_path: Path, monkeypatch) -> None: def test_run_command_tool_handler(monkeypatch) -> None: class _Adapter: - def run_command(self, *, command: str, cwd: str | None = None, timeout: int = 30): + def run_command(self, *, command: str, cwd: str | None = None, timeout: int = 300): return {"ok": True, "command": command, "cwd": cwd, "timeout": timeout} monkeypatch.setattr("oclaw.runtime.tools.public.run_command_tool.get_local_adapter", lambda: _Adapter())