strix/tests/tools/test_batch_tools.py
seanturner83 ce391d76bc
feat(tools): batch_view_files / batch_terminal_execute — sandbox-side concurrency
make_model_settings sets parallel_tool_calls=False (one tool call per turn), so
an agent mapping a codebase pays a full model round-trip per file read / command.
These two function tools let it fan out in a SINGLE call: the reads/execs run
concurrently inside the sandbox (asyncio.gather, semaphore-bounded to 8) and the
combined results come back in one tool response — a real wall-clock win on the
recon-heavy phase of a scan.

- batch_view_files(paths): concurrent read of up to 50 files; per-file result
  {path, content|error, truncated}, order preserved (results[i] ↔ paths[i]).
- batch_terminal_execute(commands): concurrent run of up to 50 INDEPENDENT shell
  commands; per-command {command, exit_code, stdout, stderr}.

Both are session-injected at the core (_view_files_impl / _exec_impl) with a thin
@function_tool shell, so the concurrency + isolation logic is directly testable
without the SDK tool-call layer. Per-item error isolation: one failing read/exec
returns its own error entry, never aborts the batch. Concurrency is bounded so a
large batch can't swamp the sandbox; batch size capped at 50.

Registered in _BASE_TOOLS so root + child agents get them. Session accessed via
the same ctx.context["sandbox_session"] convention the existing tools use. Tools
self-describe; no prompt change — models reach for them unprompted.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-16 21:59:24 +01:00

111 lines
3.8 KiB
Python

"""batch_view_files / batch_terminal_execute — sandbox-side concurrency tools.
Exercised with a fake session (no sandbox) covering: order preservation,
per-item error isolation, the batch-size cap, missing-session guard, and that
the reads/execs actually fan out concurrently (bounded).
"""
from __future__ import annotations
import asyncio
from strix.tools.batch import tools as bt
class _Result:
def __init__(self, stdout="", exit_code=0, stderr=""):
self.stdout = stdout
self.exit_code = exit_code
self.stderr = stderr
class _FakeSession:
def __init__(self, handler):
self._handler = handler
self.max_in_flight = 0
self._in_flight = 0
async def exec(self, *args, timeout=60): # noqa: ARG002 — matches session.exec signature
self._in_flight += 1
self.max_in_flight = max(self.max_in_flight, self._in_flight)
await asyncio.sleep(0.01) # force overlap so concurrency is observable
try:
return self._handler(args)
finally:
self._in_flight -= 1
def _view(session, paths):
return asyncio.run(bt._view_files_impl(session, paths))
def _exec(session, commands):
return asyncio.run(bt._exec_impl(session, commands))
# ---- batch_view_files -----------------------------------------------------
def test_view_files_reads_all_in_order():
sess = _FakeSession(lambda args: _Result(stdout=f"content of {args[-1]}"))
res = _view(sess, ["a.py", "b.py", "c.py"])
assert res["success"] is True
assert [r["path"] for r in res["results"]] == ["a.py", "b.py", "c.py"]
assert "content of /workspace/a.py" in res["results"][0]["content"]
def test_view_files_error_isolated():
def handler(args):
if "bad" in args[-1]:
return _Result(exit_code=1, stderr="No such file")
return _Result(stdout="ok")
res = _view(_FakeSession(handler), ["good.py", "bad.py"])
assert "content" in res["results"][0] # good read succeeded
assert "error" in res["results"][1] # bad read isolated
assert res["success"] is True # batch didn't abort
def test_view_files_runs_concurrently():
sess = _FakeSession(lambda _a: _Result(stdout="x"))
_view(sess, [f"f{i}.py" for i in range(6)])
assert sess.max_in_flight > 1 # actually overlapped
def test_view_files_cap_enforced():
res = _view(_FakeSession(lambda _a: _Result()), [f"f{i}" for i in range(51)])
assert res["success"] is False and "too large" in res["error"]
def test_view_files_no_session():
res = _view(None, ["a"])
assert res["success"] is False and "no sandbox session" in res["error"]
def test_view_files_empty_is_ok():
res = _view(_FakeSession(lambda _a: _Result()), [])
assert res["success"] is True and res["results"] == []
# ---- batch_terminal_execute -----------------------------------------------
def test_exec_runs_all_in_order():
sess = _FakeSession(lambda args: _Result(stdout=f"ran: {args[-1]}", exit_code=0))
res = _exec(sess, ["echo a", "echo b"])
assert res["success"] is True
assert [r["command"] for r in res["results"]] == ["echo a", "echo b"]
assert res["results"][0]["exit_code"] == 0
assert "ran: echo a" in res["results"][0]["stdout"]
def test_exec_error_isolated():
def handler(args):
cmd = args[-1]
if "fail" in cmd:
return _Result(exit_code=1, stderr="boom")
return _Result(stdout="fine", exit_code=0)
res = _exec(_FakeSession(handler), ["true", "fail-cmd"])
assert res["results"][0]["exit_code"] == 0
assert res["results"][1]["exit_code"] == 1 and res["results"][1]["stderr"] == "boom"
def test_exec_cap_enforced():
res = _exec(_FakeSession(lambda _a: _Result()), [f"echo {i}" for i in range(51)])
assert res["success"] is False and "too large" in res["error"]