chore: import upstream snapshot with attribution
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
CI (OpenClaw E2E) / openclaw test (push) Has been cancelled
CI / coverage-report (push) Has been cancelled
CI / test-kubernetes (push) Has been cancelled
CI / should-run-thorough (push) Has been cancelled
CI / test-thorough (cloudwatch-demo) (push) Has been cancelled
CI / test-thorough (flink-ecs) (push) Has been cancelled
CI / test-thorough (upstream-lambda) (push) Has been cancelled
CI / test-thorough (prefect-ecs-fargate) (push) Has been cancelled
Release / build-binaries (zip, opensre.exe, onefile, windows-latest, windows-x64) (push) Has been cancelled
Benchmark image — build + push to ECR (any adapter) / build + push (push) Has been cancelled
CI / quality (ubuntu-latest) (push) Has been cancelled
CI / test (tools-runtime) (push) Has been cancelled
CI / test (e2e-general) (push) Has been cancelled
CI / test (cli-runtime) (push) Has been cancelled
CI / test (e2e-provider-and-openclaw) (push) Has been cancelled
CI / test (integrations-and-misc) (push) Has been cancelled
Release / verify (push) Has been cancelled
Release / build-python-dist (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-15-intel, darwin-x64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, macos-latest, darwin-arm64) (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04, linux-x64) (push) Has been cancelled
Release / publish-release (push) Has been cancelled
Release / publish-main-release (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-checks (no-LLM) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
Interactive Shell Live (PR + post-merge) / turn-live shard ${{ matrix.shard_index }} (push) Has been cancelled
Release / prepare (push) Has been cancelled
Release / build-binaries (tar.gz, opensre, onedir, ubuntu-22.04-arm, linux-arm64) (push) Has been cancelled
Synthetic Deterministic Tests / Synthetic offline (deterministic) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,273 @@
|
||||
"""Shared subprocess executor for `LLMCLIAdapter` implementations."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
from config.llm_reasoning_effort import get_active_reasoning_effort
|
||||
from core.llm.shared.structured_output import StructuredOutputClient
|
||||
from core.llm.types import LLMResponse
|
||||
from integrations.llm_cli.base import CLIProbe, LLMCLIAdapter
|
||||
from integrations.llm_cli.constants import (
|
||||
EX_TEMPFAIL as _EX_TEMPFAIL,
|
||||
)
|
||||
from integrations.llm_cli.constants import (
|
||||
PROBE_CACHE_TTL_SEC as _PROBE_CACHE_TTL_SEC,
|
||||
)
|
||||
from integrations.llm_cli.constants import (
|
||||
TEMPFAIL_BACKOFF_SEC as _TEMPFAIL_BACKOFF_SEC,
|
||||
)
|
||||
from integrations.llm_cli.constants import (
|
||||
TEMPFAIL_MAX_RETRIES as _TEMPFAIL_MAX_RETRIES,
|
||||
)
|
||||
from integrations.llm_cli.errors import (
|
||||
CLIAuthenticationRequired,
|
||||
CLIInterruptedError,
|
||||
CLITimeoutError,
|
||||
)
|
||||
from integrations.llm_cli.subprocess_env import build_cli_subprocess_env
|
||||
from integrations.llm_cli.text import flatten_messages_to_prompt
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_ANSI_ESCAPE = re.compile(r"\x1b\[[0-9;]*m")
|
||||
_REDACTED_PROMPT_ARG = "<redacted-prompt>"
|
||||
# Avoid re-running `detect()` (two subprocess probes) on every invoke during long
|
||||
# investigations. Value is defined in shared constants.
|
||||
# POSIX EX_TEMPFAIL (75): the subprocess hit a transient error and can be retried.
|
||||
# kimi uses this when a session dies mid-flight ("To resume this session: kimi -r …").
|
||||
|
||||
# Back-compat name for tests and imports that expect this symbol on runner.
|
||||
_build_subprocess_env = build_cli_subprocess_env
|
||||
|
||||
|
||||
def _strip_ansi(text: str) -> str:
|
||||
return _ANSI_ESCAPE.sub("", text)
|
||||
|
||||
|
||||
def _sanitize_argv_for_debug(argv: tuple[str, ...], *, prompt: str) -> list[str]:
|
||||
"""Redact prompt text from debug argv logs when passed as a CLI argument."""
|
||||
if not prompt:
|
||||
return list(argv)
|
||||
|
||||
redacted: list[str] = []
|
||||
prompt_equals_form = f"--prompt={prompt}"
|
||||
for arg in argv:
|
||||
if arg == prompt:
|
||||
redacted.append(_REDACTED_PROMPT_ARG)
|
||||
continue
|
||||
if arg == prompt_equals_form:
|
||||
redacted.append(f"--prompt={_REDACTED_PROMPT_ARG}")
|
||||
continue
|
||||
redacted.append(arg)
|
||||
return redacted
|
||||
|
||||
|
||||
class CLIBackedLLMClient:
|
||||
"""Drives any `LLMCLIAdapter` with a single non-interactive subprocess call per invoke."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
adapter: LLMCLIAdapter,
|
||||
*,
|
||||
model: str | None = None,
|
||||
max_tokens: int = 1024,
|
||||
model_type: str = "reasoning",
|
||||
) -> None:
|
||||
self._adapter = adapter
|
||||
self._model = model
|
||||
self._max_tokens = max_tokens
|
||||
self._model_type = model_type
|
||||
self._cached_probe: CLIProbe | None = None
|
||||
self._probe_cached_at: float = 0.0
|
||||
self._probe_lock = threading.Lock()
|
||||
|
||||
def _probe(self) -> CLIProbe:
|
||||
now = time.monotonic()
|
||||
if self._cached_probe is not None and (now - self._probe_cached_at) < _PROBE_CACHE_TTL_SEC:
|
||||
return self._cached_probe
|
||||
with self._probe_lock:
|
||||
locked_now = time.monotonic()
|
||||
if (
|
||||
self._cached_probe is not None
|
||||
and (locked_now - self._probe_cached_at) < _PROBE_CACHE_TTL_SEC
|
||||
):
|
||||
return self._cached_probe
|
||||
probe = self._adapter.detect()
|
||||
self._cached_probe = probe
|
||||
self._probe_cached_at = locked_now
|
||||
return probe
|
||||
|
||||
def with_config(self, **_kwargs: Any) -> CLIBackedLLMClient:
|
||||
return self
|
||||
|
||||
def with_structured_output(self, model: type[BaseModel]) -> Any:
|
||||
"""JSON-schema prompt + parse; same contract as API `StructuredOutputClient`."""
|
||||
return StructuredOutputClient(self, model)
|
||||
|
||||
def bind_tools(self, _tools: list[Any]) -> CLIBackedLLMClient:
|
||||
return self
|
||||
|
||||
def invoke(self, prompt_or_messages: Any) -> LLMResponse:
|
||||
# max_tokens / model_type are stored for API parity but ignored here:
|
||||
# CLI adapters (e.g. codex exec) do not expose a scriptable token limit.
|
||||
_ = self._max_tokens
|
||||
_ = self._model_type
|
||||
|
||||
from platform.guardrails.apply import apply_guardrails_to_text
|
||||
|
||||
flat = flatten_messages_to_prompt(prompt_or_messages)
|
||||
flat = apply_guardrails_to_text(flat)
|
||||
|
||||
probe = self._probe()
|
||||
if not probe.installed or not probe.bin_path:
|
||||
raise RuntimeError(
|
||||
f"{self._adapter.name} CLI not found. {self._adapter.install_hint} "
|
||||
f"or set {self._adapter.binary_env_key} to the full binary path. "
|
||||
f"({probe.detail})"
|
||||
)
|
||||
if probe.logged_in is False:
|
||||
raise CLIAuthenticationRequired(
|
||||
provider=self._adapter.name,
|
||||
auth_hint=self._adapter.auth_hint,
|
||||
detail=probe.detail,
|
||||
)
|
||||
auth_probe_unclear = probe.logged_in is None
|
||||
|
||||
invocation = self._adapter.build(
|
||||
prompt=flat,
|
||||
model=self._model,
|
||||
workspace="",
|
||||
reasoning_effort=get_active_reasoning_effort(),
|
||||
)
|
||||
merged_env = _build_subprocess_env(invocation.env)
|
||||
logger.debug(
|
||||
"cli_llm_spawn",
|
||||
extra={
|
||||
"provider": self._adapter.name,
|
||||
"argv": _sanitize_argv_for_debug(invocation.argv, prompt=flat),
|
||||
},
|
||||
)
|
||||
|
||||
backoff = _TEMPFAIL_BACKOFF_SEC
|
||||
for attempt in range(_TEMPFAIL_MAX_RETRIES + 1):
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
list(invocation.argv),
|
||||
input=invocation.stdin,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
cwd=invocation.cwd,
|
||||
env=merged_env,
|
||||
timeout=invocation.timeout_sec,
|
||||
check=False,
|
||||
)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
raise CLITimeoutError(
|
||||
f"{self._adapter.name} CLI timed out after {invocation.timeout_sec:.0f}s."
|
||||
) from exc
|
||||
except OSError as exc:
|
||||
raise RuntimeError(f"Failed to spawn {self._adapter.name} CLI: {exc}") from exc
|
||||
|
||||
if proc.returncode == _EX_TEMPFAIL and attempt < _TEMPFAIL_MAX_RETRIES:
|
||||
logger.warning(
|
||||
"cli_llm_tempfail_retry",
|
||||
extra={
|
||||
"provider": self._adapter.name,
|
||||
"attempt": attempt + 1,
|
||||
"backoff_sec": backoff,
|
||||
},
|
||||
)
|
||||
time.sleep(backoff)
|
||||
backoff *= 2
|
||||
continue
|
||||
break
|
||||
|
||||
out = _strip_ansi(proc.stdout or "")
|
||||
err = _strip_ansi(proc.stderr or "")
|
||||
|
||||
if proc.returncode != 0:
|
||||
# Exit code 130 = subprocess terminated by SIGINT (Ctrl+C); raise
|
||||
# CLIInterruptedError so callers using `try/except Exception` still
|
||||
# observe the failure (KeyboardInterrupt inherits from BaseException
|
||||
# and would bypass those handlers). Sentry's `ignore_errors` config
|
||||
# filters this type so user-initiated cancellations are not reported
|
||||
# as bugs.
|
||||
if proc.returncode == 130:
|
||||
raise CLIInterruptedError(f"{self._adapter.name} CLI subprocess interrupted.")
|
||||
# Exit code 75 is EX_TEMPFAIL (sysexits.h) — a transient failure
|
||||
# the caller should retry. Raise CLITimeoutError so it is treated as
|
||||
# an expected operational failure and not forwarded to Sentry.
|
||||
if proc.returncode == _EX_TEMPFAIL:
|
||||
hint = (
|
||||
f"{self._adapter.name} reported a temporary failure (exit 75). "
|
||||
"Retry the request or check network connectivity."
|
||||
)
|
||||
if err:
|
||||
hint = f"{hint} {err[:200]}"
|
||||
raise CLITimeoutError(hint)
|
||||
base = self._adapter.explain_failure(
|
||||
stdout=out, stderr=err, returncode=proc.returncode
|
||||
).strip()
|
||||
# When the failure message signals an auth problem raise
|
||||
# CLIAuthenticationRequired so callers (reraise_cli_runtime_error,
|
||||
# server endpoints) get structured, actionable handling instead of
|
||||
# a bare RuntimeError that lands in Sentry as a spurious bug.
|
||||
# Patterns cover all current adapters:
|
||||
# kimi → "not logged in", "api key invalid", "re-authenticate"
|
||||
# cursor → "not logged in"
|
||||
# opencode → "authentication failed", "not authenticated"
|
||||
# claude/gemini/codex pass raw stderr which may contain these phrases too
|
||||
_base_lower = base.lower()
|
||||
if (
|
||||
"not logged in" in _base_lower
|
||||
or "api key invalid" in _base_lower
|
||||
or "re-authenticate" in _base_lower
|
||||
or "authentication failed" in _base_lower
|
||||
or "not authenticated" in _base_lower
|
||||
):
|
||||
raise CLIAuthenticationRequired(
|
||||
provider=self._adapter.name,
|
||||
auth_hint=self._adapter.auth_hint,
|
||||
detail=base,
|
||||
)
|
||||
if auth_probe_unclear:
|
||||
message = (
|
||||
f"{base}\n\n"
|
||||
f"Auth status could not be verified before invocation. "
|
||||
f"{self._adapter.auth_hint} ({probe.detail})"
|
||||
)
|
||||
else:
|
||||
message = base
|
||||
raise RuntimeError(message)
|
||||
|
||||
content = self._adapter.parse(stdout=out, stderr=err, returncode=proc.returncode)
|
||||
content = _strip_ansi(content).strip()
|
||||
if err:
|
||||
logger.debug(
|
||||
"cli_llm_stderr",
|
||||
extra={"provider": self._adapter.name, "stderr": err[:500]},
|
||||
)
|
||||
logger.debug(
|
||||
"cli_llm_invoke",
|
||||
extra={"provider": self._adapter.name, "cli_cost_unknown": True},
|
||||
)
|
||||
return LLMResponse(content=content)
|
||||
|
||||
def invoke_stream(self, prompt_or_messages: Any) -> Iterator[str]:
|
||||
"""Yield the full response as one chunk; real streaming is a follow-up.
|
||||
|
||||
Subprocess CLI adapters ``subprocess.run`` to completion, so this
|
||||
satisfies the protocol contract without faking progressive output.
|
||||
"""
|
||||
yield self.invoke(prompt_or_messages).content
|
||||
Reference in New Issue
Block a user