Files
cleveragents-core/benchmarks/container_tool_exec_bench.py
T
CoreRasurae 7ac3f1352c
CI / benchmark-publish (pull_request) Has been skipped
CI / build (pull_request) Successful in 17s
CI / quality (pull_request) Successful in 20s
CI / lint (pull_request) Successful in 26s
CI / security (pull_request) Successful in 37s
CI / typecheck (pull_request) Successful in 57s
CI / unit_tests (pull_request) Successful in 3m14s
CI / integration_tests (pull_request) Successful in 3m33s
CI / docker (pull_request) Successful in 39s
CI / coverage (pull_request) Successful in 5m29s
CI / lint (push) Successful in 16s
CI / quality (push) Successful in 16s
CI / build (push) Successful in 16s
CI / security (push) Successful in 35s
CI / typecheck (push) Successful in 47s
CI / benchmark-regression (push) Has been skipped
CI / unit_tests (push) Successful in 3m1s
CI / integration_tests (push) Successful in 3m31s
CI / docker (push) Successful in 52s
CI / coverage (push) Successful in 5m47s
CI / benchmark-publish (push) Successful in 19m3s
CI / benchmark-regression (pull_request) Successful in 35m19s
feat(devcontainer): add container-aware tool execution and I/O forwarding
Implement ContainerToolExecutor for delegating tool invocations to
devcontainer environments with full I/O forwarding. Add PathMapper for
bidirectional host/container path translation. Wire container routing
into ToolRunner with graceful fallback when no executor is configured.
Add container_metadata field to ToolInvocation for tracking execution
context.

New modules:
- tool/container_executor.py: ContainerToolExecutor, ContainerConfig,
  ContainerMetadata, ContainerExecutionError, ContainerTimeoutError
- tool/path_mapper.py: PathMapper with host_to_container/container_to_host

Modified:
- tool/runner.py: container execution routing via ExecutionEnvironment
- domain/models/core/change.py: container_metadata on ToolInvocation
- tool/__init__.py: new public exports

Review fixes applied:
- Add Alembic migration m6_004 for container_metadata_json column
- Enforce _MAX_OUTPUT_BYTES (50 MiB) truncation in _run_command()
- Fix path traversal in sync_results_to_host (Path.is_relative_to)
- Allow spaces in _looks_like_path() for valid filesystem paths
- Preserve negative exit codes from signal kills in metadata
- Add default=str to json.dumps(invocation.arguments) safety net
- Log warnings when path mapping recursion depth exceeded
- Warn when devcontainer binary not found on PATH
- Use default allow_nan for host-path JSON validation in runner.py
  (only container path requires RFC 7159 strict mode)
- Reject URL-like patterns in _looks_like_path() to avoid false
  positives on API routes, protocol-relative URIs, and query strings
- Add extract_container_metadata() static helper on
  ContainerToolExecutor as bridge for ToolInvocation wiring
- Use raw_stdout bytes in sync_results_to_host to prevent
  binary file corruption from text-mode decode/re-encode
- Apply posixpath.normpath() in workspace_folder validator and
  reject path components containing '..'
- Check result.timed_out in sync_results_to_host and raise
  ContainerTimeoutError instead of always raising ContainerExecutionError
- Detect overlapping host_root/container_root in PathMapper and
  raise ValueError to prevent corrupt bidirectional mappings
- Wrap host-side I/O in sync_results_to_host with try/except
  OSError to produce ContainerExecutionError on write failure
- Enforce int(timeout) in _build_exec_command to prevent shell
  injection via malicious objects with __str__ methods
- Change ToolResult validator from 'not self.error' to
  'self.error is None' so empty-string errors are accepted
- Iterate required list directly in ToolRunner schema validation
  to detect fields listed in required but absent from properties

Closes #515
2026-03-12 10:31:37 +00:00

154 lines
4.7 KiB
Python

"""ASV benchmarks for container tool execution overhead (#515).
Measures the performance cost of path mapping, input/output
translation, and container metadata creation — the operations
that happen on every container-routed tool call.
"""
from __future__ import annotations
import json
from cleveragents.tool.container_executor import (
ContainerConfig,
ContainerMetadata,
ContainerToolExecutor,
)
from cleveragents.tool.path_mapper import PathMapper
class PathMapperBench:
"""Benchmark PathMapper host-to-container and container-to-host translations."""
def setup(self) -> None:
self.mapper = PathMapper(
host_root="/tmp/sandbox/project-abc",
container_root="/workspace",
)
self.host_path = "/tmp/sandbox/project-abc/src/cleveragents/tool/runner.py"
self.container_path = "/workspace/src/cleveragents/tool/runner.py"
self.external_path = (
"/usr/local/lib/python3.13/site-packages/pydantic/__init__.py"
)
def time_host_to_container(self) -> None:
self.mapper.host_to_container(self.host_path)
def time_container_to_host(self) -> None:
self.mapper.container_to_host(self.container_path)
def time_external_path_passthrough(self) -> None:
self.mapper.host_to_container(self.external_path)
def time_is_host_path_check(self) -> None:
self.mapper.is_host_path(self.host_path)
def time_is_container_path_check(self) -> None:
self.mapper.is_container_path(self.container_path)
class InputPathMappingBench:
"""Benchmark input path mapping with various nesting depths."""
def setup(self) -> None:
config = ContainerConfig(
workspace_folder="/workspace",
host_sandbox_path="/tmp/sandbox",
container_id="bench-ctr",
)
self.executor = ContainerToolExecutor(config)
self.flat_inputs = {
"path": "/tmp/sandbox/src/main.py",
"output_dir": "/tmp/sandbox/build",
"name": "my-tool",
"count": 42,
}
self.nested_inputs = {
"paths": {
"source": "/tmp/sandbox/src/main.py",
"config": "/tmp/sandbox/.config/settings.json",
"nested": {
"deep": "/tmp/sandbox/deep/path.txt",
},
},
"list_paths": [
"/tmp/sandbox/a.py",
"/tmp/sandbox/b.py",
"/tmp/sandbox/c.py",
],
}
def time_flat_input_mapping(self) -> None:
self.executor._map_input_paths(self.flat_inputs)
def time_nested_input_mapping(self) -> None:
self.executor._map_input_paths(self.nested_inputs)
class OutputPathMappingBench:
"""Benchmark output path mapping."""
def setup(self) -> None:
config = ContainerConfig(
workspace_folder="/workspace",
host_sandbox_path="/tmp/sandbox",
container_id="bench-ctr",
)
self.executor = ContainerToolExecutor(config)
self.output = {
"created_file": "/workspace/build/output.txt",
"log_file": "/workspace/logs/run.log",
"status": "success",
"count": 10,
}
def time_output_mapping(self) -> None:
self.executor._map_output_paths(self.output)
class ContainerMetadataBench:
"""Benchmark container metadata creation and serialisation."""
def setup(self) -> None:
self.metadata = ContainerMetadata(
container_id="abc123def456",
image="ghcr.io/org/devimage:latest",
workspace_folder="/workspace",
exec_time_ms=1500.0,
exit_code=0,
timed_out=False,
)
def time_metadata_creation(self) -> None:
ContainerMetadata(
container_id="abc123def456",
image="ghcr.io/org/devimage:latest",
workspace_folder="/workspace",
exec_time_ms=1500.0,
exit_code=0,
timed_out=False,
)
def time_metadata_to_dict(self) -> None:
self.metadata.model_dump()
class OutputParsingBench:
"""Benchmark output parsing (JSON vs plain text)."""
def setup(self) -> None:
self.json_output = json.dumps(
{"files": [f"file_{i}.py" for i in range(50)], "errors": 0}
)
self.plain_output = "Build completed successfully\n" * 20
self.empty_output = ""
def time_parse_json_output(self) -> None:
ContainerToolExecutor._parse_output(self.json_output)
def time_parse_plain_output(self) -> None:
ContainerToolExecutor._parse_output(self.plain_output)
def time_parse_empty_output(self) -> None:
ContainerToolExecutor._parse_output(self.empty_output)