From fe888c4844f10b5bb71839fad9874aa66f739464 Mon Sep 17 00:00:00 2001 From: jecanore Date: Mon, 16 Mar 2026 17:38:23 -0500 Subject: [PATCH] feat: ACP-aware doctor checks - Add check_acp_agent() using shutil.which to verify agent CLI on PATH - Skip HTTP checks (llm_connectivity, api_key_valid, model_chain) for ACP provider - ACP configs get 7 checks instead of 9; non-ACP behavior unchanged --- researchclaw/health.py | 31 +++++++++- tests/test_rc_health.py | 124 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 151 insertions(+), 4 deletions(-) diff --git a/researchclaw/health.py b/researchclaw/health.py index c567e51..ac2555f 100644 --- a/researchclaw/health.py +++ b/researchclaw/health.py @@ -4,6 +4,7 @@ import json import logging import os +import shutil import socket import sys import urllib.error @@ -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 @@ -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)) diff --git a/tests/test_rc_health.py b/tests/test_rc_health.py index aa446c8..ceef6b5 100644 --- a/tests/test_rc_health.py +++ b/tests/test_rc_health.py @@ -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 ( @@ -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