Skip to content

Commit bb08d06

Browse files
authored
Merge pull request #22 from deeppavlov/f/concurrency
F/concurrency
2 parents baad34e + 76739e6 commit bb08d06

9 files changed

Lines changed: 260 additions & 165 deletions

File tree

src/mcp_evals/_internal/runner/_domain_runner.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from pathlib import Path
55
from typing import Any
66

7+
from loguru import logger
78
from pydantic_ai.agent import Agent
89
from pydantic_ai.usage import UsageLimits
910
from pydantic_evals.reporting import EvaluationReport
@@ -28,6 +29,7 @@ def __init__( # noqa: PLR0913
2829
deps_maker: DepsMaker | None = None,
2930
*,
3031
max_tasks: int | None = None,
32+
max_concurrency: int = 1,
3133
use_self_correction: bool = False,
3234
max_self_correction_retries: int = 3,
3335
start_training: TrainingTestingCallback | None = None,
@@ -42,6 +44,10 @@ def __init__( # noqa: PLR0913
4244
self.agent = agent
4345
self.deps_maker = deps_maker
4446
self.max_tasks = max_tasks
47+
if max_concurrency < 1:
48+
msg = "max_concurrency must be >= 1"
49+
raise ValueError(msg)
50+
self.max_concurrency = max_concurrency
4551
self.run_result_processor = run_result_processor
4652
self.usage_limits = usage_limits
4753
self.grouper = grouper
@@ -56,6 +62,15 @@ def __init__( # noqa: PLR0913
5662

5763
async def run(self, domain: Domain[Any], experiment_name: str) -> EvaluationReport:
5864
deps_maker = self.deps_maker or default_deps_maker()
65+
if self.max_concurrency > 1 and not domain.supports_concurrency:
66+
msg = (
67+
f"Domain '{domain.name}' does not support concurrency "
68+
f"(supports_concurrency={domain.supports_concurrency}). "
69+
"Set max_concurrency=1, or set the domain flag to True after making tasks parallel-safe."
70+
)
71+
raise ValueError(msg)
72+
if self.max_concurrency > 1 and domain.supports_concurrency:
73+
logger.debug(f"[{domain.name}] Running with max_concurrency={self.max_concurrency}")
5974

6075
async with domain:
6176
if self.use_self_correction:
@@ -147,7 +162,7 @@ async def _run_train_phase(
147162
train_dataset = tasks_to_dataset(train_tasks)
148163
await train_dataset.evaluate(
149164
evaluated_fn,
150-
max_concurrency=1,
165+
max_concurrency=self.max_concurrency,
151166
case_context_manager=make_task_lifecycle(state, split_idx, "train"),
152167
progress=False,
153168
name=f"{base_name}_train_{split_idx}_",
@@ -175,7 +190,7 @@ async def _run_test_phase(
175190
test_dataset = tasks_to_dataset(test_tasks)
176191
return await test_dataset.evaluate(
177192
evaluated_fn,
178-
max_concurrency=1,
193+
max_concurrency=self.max_concurrency,
179194
case_context_manager=make_task_lifecycle(state, split_idx, "test"),
180195
progress=False,
181196
name=f"{base_name}_test_{split_idx}" if experiment_name else None,

src/mcp_evals/_internal/runner/_run_state.py

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,7 @@ def __init__(self, path: AnyioPath | str) -> None:
100100
self._split_finished: set[int] = set()
101101
self._task_finished: set[TaskFinishedKey] = set()
102102
self._header_written = False
103+
self._write_lock = anyio.Lock()
103104

104105
@classmethod
105106
async def load(
@@ -192,12 +193,13 @@ async def mark_task_finished(self, split_idx: int, phase: Phase, task_name: str)
192193
self._task_finished.add(TaskFinishedKey(split_idx, phase, task_name))
193194

194195
async def _append_event(self, event: RunStateEvent) -> None:
195-
await self._ensure_header()
196-
line = event.model_dump_json(exclude_none=True)
197-
async with await anyio.open_file(self._path, "a") as f:
198-
await f.write(line + "\n")
196+
async with self._write_lock:
197+
await self._ensure_header_unlocked()
198+
line = event.model_dump_json(exclude_none=True)
199+
async with await anyio.open_file(self._path, "a") as f:
200+
await f.write(line + "\n")
199201

200-
async def _ensure_header(self) -> None:
202+
async def _ensure_header_unlocked(self) -> None:
201203
if self._header_written:
202204
return
203205
if self._n_tasks is None or self._fingerprint is None:
@@ -211,8 +213,9 @@ async def _ensure_header(self) -> None:
211213

212214
async def clear(self) -> None:
213215
"""Remove the state file if it exists (e.g. after a full run)."""
214-
with contextlib.suppress(FileNotFoundError):
215-
await self._path.unlink()
216+
async with self._write_lock:
217+
with contextlib.suppress(FileNotFoundError):
218+
await self._path.unlink()
216219

217220

218221
async def run_state_path(

src/mcp_evals/contrib/filesystem/domain.py

Lines changed: 12 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77

88
import aiofiles
99
from loguru import logger
10-
from pydantic_ai.mcp import MCPServerStdio
10+
from pydantic_ai.mcp import MCPServer
1111

1212
from mcp_evals import Domain
1313
from mcp_evals.contrib.filesystem.utils import Fixture
@@ -51,10 +51,14 @@
5151
class FilesystemDomain(Domain[DomainSecrets]):
5252
"""Domain for filesystem tasks from MCP Universe.
5353
54-
Provides MCP filesystem server and groups related filesystem tasks.
54+
Provides a shared temp root directory and groups related filesystem tasks.
55+
56+
Each task owns its own MCP filesystem server + workspace directory to allow
57+
safe parallel execution.
5558
"""
5659

5760
name = "filesystem"
61+
supports_concurrency = True
5862

5963
def __init__(self, tool_retries: int = 1) -> None:
6064
"""Init."""
@@ -66,25 +70,12 @@ async def setup(self, stack: AsyncExitStack[Any]) -> None:
6670
tmpdir_ctx = aiofiles.tempfile.TemporaryDirectory(prefix="mcp-filesystem-")
6771
self._tmp_dir = Path(await stack.enter_async_context(tmpdir_ctx))
6872

69-
def mcp_servers(self) -> Sequence[MCPServerStdio]:
70-
"""Return MCP filesystem server configuration."""
71-
return [
72-
MCPServerStdio(
73-
"docker",
74-
[
75-
"run",
76-
"-i",
77-
"--rm",
78-
"--mount",
79-
f"type=bind,src={self._tmp_dir},dst=/projects",
80-
"-w",
81-
"/projects",
82-
"mcp-filesystem-server:mcp-evals",
83-
"/projects",
84-
],
85-
max_retries=self.tool_retries,
86-
)
87-
]
73+
def mcp_servers(self) -> Sequence[MCPServer]:
74+
"""Return domain-scoped MCP servers (none for filesystem).
75+
76+
Filesystem tasks run with per-task MCP servers to support parallelism.
77+
"""
78+
return []
8879

8980
def tasks(self) -> Sequence[FilesystemTask]:
9081
"""Return all filesystem tasks."""

src/mcp_evals/contrib/filesystem/task.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
"""Base class for all filesystem tasks."""
22

3+
from collections.abc import Sequence
34
from contextlib import AsyncExitStack
45
from pathlib import Path
56
from typing import Any
67

78
from pydantic import BaseModel, Field
9+
from pydantic_ai.mcp import MCPServerStdio
810

911
from mcp_evals.contrib.filesystem.utils import Fixture, download_fixture, prepare_workspace
1012
from mcp_evals.secrets import TaskSecrets
@@ -26,9 +28,31 @@ def __init__(self, work_dir: Path, fixture: Fixture, tool_retries: int = 1) -> N
2628
"""Init."""
2729
super().__init__(tool_retries=tool_retries)
2830

29-
self.work_dir = work_dir
31+
self.root_dir = work_dir
32+
# Per-task workspace (enables safe parallel execution)
33+
self.work_dir = work_dir / self.name
3034
self.fixture = fixture
3135

36+
def mcp_servers(self) -> Sequence[MCPServerStdio]:
37+
"""Return task-scoped MCP filesystem server configuration."""
38+
return [
39+
MCPServerStdio(
40+
"docker",
41+
[
42+
"run",
43+
"-i",
44+
"--rm",
45+
"--mount",
46+
f"type=bind,src={self.work_dir},dst=/projects",
47+
"-w",
48+
"/projects",
49+
"mcp-filesystem-server:mcp-evals",
50+
"/projects",
51+
],
52+
max_retries=self.tool_retries,
53+
)
54+
]
55+
3256
async def setup(self, stack: AsyncExitStack[Any]) -> None:
3357
"""Set up the task environment."""
3458
# Download fixture

src/mcp_evals/contrib/filesystem/utils.py

Lines changed: 70 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
from enum import StrEnum
99
from pathlib import Path
1010

11+
import anyio
1112
from dotenv import load_dotenv
1213
from loguru import logger
1314

@@ -69,6 +70,16 @@ class Fixture(StrEnum):
6970

7071
load_dotenv()
7172

73+
_fixture_locks: dict[Fixture, anyio.Lock] = {}
74+
75+
76+
def _fixture_lock(category: Fixture) -> anyio.Lock:
77+
lock = _fixture_locks.get(category)
78+
if lock is None:
79+
lock = anyio.Lock()
80+
_fixture_locks[category] = lock
81+
return lock
82+
7283

7384
async def download_fixture(category: Fixture) -> Path:
7485
"""Download and cache a filesystem test fixture.
@@ -97,69 +108,70 @@ async def download_fixture(category: Fixture) -> Path:
97108
cache_dir = Path(user_cache_dir("mcp-evals", "mcp-evals")) / "fixtures"
98109
fixture_path = cache_dir / category
99110

100-
# Return cached fixture if it exists
101-
if fixture_path.exists() and fixture_path.is_dir():
102-
logger.debug(f"Using cached fixture '{category.value}'")
103-
return fixture_path
111+
async with _fixture_lock(category):
112+
# Return cached fixture if it exists
113+
if fixture_path.exists() and fixture_path.is_dir():
114+
logger.debug(f"Using cached fixture '{category.value}'")
115+
return fixture_path
104116

105-
# Download fixture
106-
logger.debug(f"Downloading fixture '{category.value}'")
107-
url = FIXTURE_URL_MAPPING[category]
108-
zip_path = cache_dir / f"{category}.zip"
117+
# Download fixture
118+
logger.debug(f"Downloading fixture '{category.value}'")
119+
url = FIXTURE_URL_MAPPING[category]
120+
zip_path = cache_dir / f"{category}.zip"
109121

110-
# Ensure cache directory exists
111-
cache_dir.mkdir(parents=True, exist_ok=True)
122+
# Ensure cache directory exists
123+
cache_dir.mkdir(parents=True, exist_ok=True)
112124

113-
try:
114-
# Download using httpx with streaming
115-
timeout = httpx.Timeout(connect=5.0, read=5.0, write=10.0, pool=5.0)
116-
proxy_url = os.getenv("DOWNLOAD_PROXY")
117-
async with (
118-
httpx.AsyncClient(timeout=timeout, proxy=proxy_url) as client,
119-
client.stream("GET", url, follow_redirects=True) as response,
120-
):
121-
response.raise_for_status()
122-
total_size = int(response.headers.get("content-length", 0)) or None
123-
async with aiofiles.open(zip_path, "wb") as f:
124-
with tqdm(
125-
total=total_size,
126-
unit="B",
127-
unit_scale=True,
128-
unit_divisor=1024,
129-
desc=f"Downloading {category}",
130-
) as pbar:
131-
async for chunk in response.aiter_bytes():
132-
await f.write(chunk)
133-
pbar.update(len(chunk))
134-
135-
# Extract ZIP file
136-
with zipfile.ZipFile(zip_path) as zip_file:
137-
zip_file.extractall(cache_dir)
138-
139-
# Clean up macOS metadata if present
140-
macosx_path = cache_dir / "__MACOSX"
141-
if macosx_path.exists():
142-
shutil.rmtree(macosx_path)
143-
144-
# Clean up ZIP file
145-
zip_path.unlink(missing_ok=True)
146-
147-
except httpx.HTTPError as e:
148-
msg = f"Failed to download fixture from {url}: {e}"
149-
raise RuntimeError(msg) from e
150-
except zipfile.BadZipFile as e:
151-
msg = f"Invalid ZIP file for category {category}: {e}"
152-
raise RuntimeError(msg) from e
153-
except Exception as e:
154-
msg = f"Failed to download or extract fixture for category {category}: {e}"
155-
raise RuntimeError(msg) from e
125+
try:
126+
# Download using httpx with streaming
127+
timeout = httpx.Timeout(connect=5.0, read=5.0, write=10.0, pool=5.0)
128+
proxy_url = os.getenv("DOWNLOAD_PROXY")
129+
async with (
130+
httpx.AsyncClient(timeout=timeout, proxy=proxy_url) as client,
131+
client.stream("GET", url, follow_redirects=True) as response,
132+
):
133+
response.raise_for_status()
134+
total_size = int(response.headers.get("content-length", 0)) or None
135+
async with aiofiles.open(zip_path, "wb") as f:
136+
with tqdm(
137+
total=total_size,
138+
unit="B",
139+
unit_scale=True,
140+
unit_divisor=1024,
141+
desc=f"Downloading {category}",
142+
) as pbar:
143+
async for chunk in response.aiter_bytes():
144+
await f.write(chunk)
145+
pbar.update(len(chunk))
146+
147+
# Extract ZIP file
148+
with zipfile.ZipFile(zip_path) as zip_file:
149+
zip_file.extractall(cache_dir)
150+
151+
# Clean up macOS metadata if present
152+
macosx_path = cache_dir / "__MACOSX"
153+
if macosx_path.exists():
154+
shutil.rmtree(macosx_path)
155+
156+
# Clean up ZIP file
157+
zip_path.unlink(missing_ok=True)
158+
159+
except httpx.HTTPError as e:
160+
msg = f"Failed to download fixture from {url}: {e}"
161+
raise RuntimeError(msg) from e
162+
except zipfile.BadZipFile as e:
163+
msg = f"Invalid ZIP file for category {category}: {e}"
164+
raise RuntimeError(msg) from e
165+
except Exception as e:
166+
msg = f"Failed to download or extract fixture for category {category}: {e}"
167+
raise RuntimeError(msg) from e
156168

157-
# Verify extraction
158-
if not fixture_path.exists():
159-
msg = f"Extracted directory not found: {fixture_path}"
160-
raise RuntimeError(msg)
169+
# Verify extraction
170+
if not fixture_path.exists():
171+
msg = f"Extracted directory not found: {fixture_path}"
172+
raise RuntimeError(msg)
161173

162-
return fixture_path
174+
return fixture_path
163175

164176

165177
@asynccontextmanager

src/mcp_evals/contrib/postgres/domain.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ class PostgresDomain(Domain[PgConfig]):
5050

5151
name = "postgres"
5252
secrets_type = PgConfig
53+
supports_concurrency = True
5354

5455
_container: DockerContainer | None = None
5556
_docker: aiodocker.Docker | None = None

0 commit comments

Comments
 (0)