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
31 changes: 28 additions & 3 deletions researchclaw/health.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import json
import logging
import os
import shutil
import socket
import sys
import urllib.error
Expand Down Expand Up @@ -512,6 +513,23 @@ def check_experiment_mode(mode: str) -> CheckResult:
)


def check_acp_agent(agent_command: str) -> CheckResult:
"""Check that the ACP agent CLI is available on PATH."""
resolved = shutil.which(agent_command)
if resolved:
return CheckResult(
name="acp_agent",
status="pass",
detail=f"ACP agent found: {resolved}",
)
return CheckResult(
name="acp_agent",
status="fail",
detail=f"ACP agent '{agent_command}' not found on PATH",
fix=f"Install {agent_command} or update llm.acp.agent in config",
)


def check_docker_runtime(config: RCConfig) -> CheckResult:
"""Check Docker daemon, image availability, and optional NVIDIA runtime."""
from researchclaw.experiment.docker_sandbox import DockerSandbox
Expand Down Expand Up @@ -555,21 +573,28 @@ def run_doctor(config_path: str | Path) -> DoctorReport:
fallback_models: tuple[str, ...] = ()
sandbox_python_path = ""
experiment_mode = ""
provider = ""
acp_agent_command = "claude"

try:
config = RCConfig.load(path, check_paths=False)
provider = config.llm.provider
base_url = config.llm.base_url
api_key = config.llm.api_key or os.environ.get(config.llm.api_key_env, "")
model = config.llm.primary_model
fallback_models = config.llm.fallback_models
sandbox_python_path = config.experiment.sandbox.python_path
experiment_mode = config.experiment.mode
acp_agent_command = config.llm.acp.agent
except (FileNotFoundError, OSError, ValueError, yaml.YAMLError) as exc:
logger.debug("Could not fully load config for doctor checks: %s", exc)

checks.append(check_llm_connectivity(base_url))
checks.append(check_api_key_valid(base_url, api_key))
checks.append(check_model_chain(base_url, api_key, model, fallback_models))
if provider == "acp":
checks.append(check_acp_agent(acp_agent_command))
else:
checks.append(check_llm_connectivity(base_url))
checks.append(check_api_key_valid(base_url, api_key))
checks.append(check_model_chain(base_url, api_key, model, fallback_models))
checks.append(check_sandbox_python(sandbox_python_path))
checks.append(check_matplotlib())
checks.append(check_experiment_mode(experiment_mode))
Expand Down
124 changes: 123 additions & 1 deletion tests/test_rc_health.py
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ def test_check_experiment_mode_sandbox() -> None:
assert result.status == "pass"


def test_run_doctor_all_pass(tmp_path: Path) -> None:
def test_run_doctor_all_pass_openai(tmp_path: Path) -> None:
config_path = tmp_path / "config.yaml"
_ = config_path.write_text("project: {}\n", encoding="utf-8")
with (
Expand Down Expand Up @@ -451,3 +451,125 @@ def test_print_doctor_report_fail(capsys: pytest.CaptureFixture[str]) -> None:
assert "❌" in out
assert "⚠️" in out
assert "Result: FAIL (1 errors, 1 warnings)" in out


# --- ACP agent checks ---


def test_check_acp_agent_found() -> None:
with patch("shutil.which", return_value="/usr/local/bin/claude"):
result = health.check_acp_agent("claude")
assert result.status == "pass"
assert "/usr/local/bin/claude" in result.detail


def test_check_acp_agent_missing() -> None:
with patch("shutil.which", return_value=None):
result = health.check_acp_agent("claude")
assert result.status == "fail"
assert "'claude' not found" in result.detail
assert "Install claude" in result.fix


def _write_acp_config(path: Path) -> None:
_ = path.write_text(
"""\
project:
name: demo
research:
topic: ACP test
runtime:
timezone: UTC
notifications:
channel: test
knowledge_base:
root: kb
llm:
provider: acp
acp:
agent: claude
""",
encoding="utf-8",
)


def test_run_doctor_acp_skips_http_checks(tmp_path: Path) -> None:
config_path = tmp_path / "config.yaml"
_write_acp_config(config_path)
with (
patch.object(
health, "check_python_version",
return_value=health.CheckResult("python_version", "pass", "ok"),
),
patch.object(
health, "check_yaml_import",
return_value=health.CheckResult("yaml_import", "pass", "ok"),
),
patch.object(
health, "check_config_valid",
return_value=health.CheckResult("config_valid", "pass", "ok"),
),
patch.object(
health, "check_acp_agent",
return_value=health.CheckResult("acp_agent", "pass", "ok"),
),
patch.object(
health, "check_sandbox_python",
return_value=health.CheckResult("sandbox_python", "pass", "ok"),
),
patch.object(
health, "check_matplotlib",
return_value=health.CheckResult("matplotlib", "pass", "ok"),
),
patch.object(
health, "check_experiment_mode",
return_value=health.CheckResult("experiment_mode", "pass", "ok"),
),
):
report = health.run_doctor(config_path)

check_names = [c.name for c in report.checks]
assert "llm_connectivity" not in check_names
assert "api_key_valid" not in check_names
assert "model_chain" not in check_names


def test_run_doctor_acp_includes_agent_check(tmp_path: Path) -> None:
config_path = tmp_path / "config.yaml"
_write_acp_config(config_path)
with (
patch.object(
health, "check_python_version",
return_value=health.CheckResult("python_version", "pass", "ok"),
),
patch.object(
health, "check_yaml_import",
return_value=health.CheckResult("yaml_import", "pass", "ok"),
),
patch.object(
health, "check_config_valid",
return_value=health.CheckResult("config_valid", "pass", "ok"),
),
patch.object(
health, "check_acp_agent",
return_value=health.CheckResult("acp_agent", "pass", "ok"),
),
patch.object(
health, "check_sandbox_python",
return_value=health.CheckResult("sandbox_python", "pass", "ok"),
),
patch.object(
health, "check_matplotlib",
return_value=health.CheckResult("matplotlib", "pass", "ok"),
),
patch.object(
health, "check_experiment_mode",
return_value=health.CheckResult("experiment_mode", "pass", "ok"),
),
):
report = health.run_doctor(config_path)

check_names = [c.name for c in report.checks]
assert "acp_agent" in check_names
assert report.overall == "pass"
assert len(report.checks) == 7