Harden macroscope spawn failures and cover agent-spec loading

Two consistency fixes from an audit against the sibling capabilities:

- A binary that passes the `shutil.which` check can still fail to exec (lost
  +x, bad interpreter, a race). Surface that `OSError` to the model as a
  retryable setup error instead of letting it crash the run, matching how the
  other failure paths (missing binary, timeout, no review id) already behave.
- Add an agent-spec roundtrip test so the `custom_capability_types=[Macroscope]`
  loading the docs document stays verified.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bill Easton
2026-07-20 15:22:35 -05:00
co-authored by Claude Opus 4.8
parent 31a34ebae0
commit eb556ece56
2 changed files with 36 additions and 7 deletions
+13 -7
View File
@@ -159,13 +159,19 @@ class MacroscopeToolset(FunctionToolset[AgentDepsT]):
Spawns the CLI in its own session so a hung review can be killed by process
group when it exceeds `timeout`, rather than leaking a background process.
"""
proc = await anyio.open_process(
args,
cwd=self._cwd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True,
)
try:
proc = await anyio.open_process(
args,
cwd=self._cwd,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
start_new_session=True,
)
except OSError as e:
# `shutil.which` found the binary, but spawning it still failed (lost +x,
# bad interpreter, a race since the check). Surface it to the model as a
# retryable setup error rather than crashing the whole run.
raise ModelRetry(f'Failed to launch the macroscope CLI ({self._command!r}): {e}') from e
stdout_chunks: list[bytes] = []
stderr_chunks: list[bytes] = []
try:
+23
View File
@@ -7,6 +7,7 @@ import time
from collections.abc import Sequence
from pathlib import Path
import anyio
import pytest
from pydantic_ai import Agent
from pydantic_ai.exceptions import ModelRetry
@@ -139,6 +140,17 @@ class TestRunReview:
await _toolset(command, tmp_path, base=None).run_macroscope_review()
assert _recorded_args(command) == ['codereview', '--raw']
async def test_spawn_failure_raises_model_retry(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
# Binary passes the `which` check but fails to exec (lost +x, bad interpreter, TOCTOU).
command = _fake_cli(tmp_path, ['review_id=rev-6', 'issue_status=completed'])
async def _boom(*args: object, **kwargs: object) -> object:
raise PermissionError('exec denied')
monkeypatch.setattr(anyio, 'open_process', _boom)
with pytest.raises(ModelRetry, match='Failed to launch'):
await _toolset(command, tmp_path).run_macroscope_review()
class TestCapability:
def test_default_instructions_mention_validation(self) -> None:
@@ -153,6 +165,17 @@ class TestCapability:
def test_empty_guidance_disables_instructions(self) -> None:
assert Macroscope(guidance='').get_instructions() is None
def test_agent_spec_roundtrip(self) -> None:
# The docs promise Macroscope loads from an agent spec via `custom_capability_types`.
cap = Macroscope.from_spec(base='release', timeout=900.0)
assert isinstance(cap, Macroscope)
assert (cap.base, cap.timeout) == ('release', 900.0)
agent = Agent.from_spec(
{'model': 'test', 'capabilities': [{'Macroscope': {'base': 'main'}}]},
custom_capability_types=[Macroscope],
)
assert isinstance(agent, Agent)
async def test_tool_runs_through_agent(self, tmp_path: Path) -> None:
command = _fake_cli(tmp_path, ['review_id=rev-9', _ISSUE_LINE, 'issue_status=completed'])
agent = Agent(TestModel(), capabilities=[Macroscope(command=command, cwd=tmp_path, base='main')])