Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
__pycache__/
.smoke/
.pytest_cache/
_site/
docs/games/results_payload/
13 changes: 13 additions & 0 deletions conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
"""Pytest bootstrap: put the src-layout package on sys.path without an editable
install, so `from agentbench_frame... import ...` resolves in tests and so the
`agentbench_frame.*` modules can be imported by helper scripts.

Run tests with the Python 3.11+ interpreter (the framework imports `tomllib`):
py -3.13 -m pytest tests/
"""
import sys
from pathlib import Path

_SRC = Path(__file__).resolve().parent / "src"
if str(_SRC) not in sys.path:
sys.path.insert(0, str(_SRC))
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ dependencies = []

[project.optional-dependencies]
tracking = ["psutil"]
miracle = ["psutil"]
rl = ["torch", "numpy"]
report = ["jinja2"]
all = ["psutil", "torch", "numpy", "jinja2"]
Expand Down
4 changes: 4 additions & 0 deletions src/agentbench_frame/games/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
"""Game-specific adapters under the AgentBench framework.

Each subpackage adapts one Saiblo game. `miracle` adapts the 24th Miracle game
via the original external Judge (no in-process reimplementation)."""
45 changes: 45 additions & 0 deletions src/agentbench_frame/games/miracle/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
"""Isolated adapter for the external 24_miracle Judge runtime.

The package wraps subprocess/runtime behavior and normalizes results without
reimplementing Judge rules in process.
"""

from agentbench_frame.games.miracle.result import (
DRAW,
ERROR,
LOSS,
VALID_RESULTS,
WIN,
GameOutcome,
compute_h2h,
compute_win_rate,
derive_raw_winner,
finalize,
normalize,
outcome_counts,
read_replay_header,
select_games_to_run,
sha256_file,
to_event_record,
would_rerun_successful,
)

__all__ = [
"GameOutcome",
"WIN",
"LOSS",
"DRAW",
"ERROR",
"VALID_RESULTS",
"derive_raw_winner",
"finalize",
"normalize",
"compute_win_rate",
"compute_h2h",
"outcome_counts",
"read_replay_header",
"sha256_file",
"select_games_to_run",
"would_rerun_successful",
"to_event_record",
]
57 changes: 57 additions & 0 deletions src/agentbench_frame/games/miracle/atomicio.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"""Atomic JSON file writer: temp file + fsync + os.replace.

Guarantees the final path is never partially written. The new content is
written to a same-directory temp file, fsync'd, then atomically renamed over
the target with ``os.replace``. A crash anywhere before the rename leaves the
target with its prior complete content (or absent); a leftover temp file
(``.<name>.*.tmp``) is the only audit trace. Used for vendor result-json so a
force-killed vendor can never leave a half-written result.
"""
from __future__ import annotations

import json
import os
import tempfile
import time
from pathlib import Path
from typing import Any

_SENTINEL = object()
_WINDOWS_SHARING_WINERRORS = {5, 32}


def _is_windows() -> bool:
"""Return the host platform without making tests mutate global ``os``."""
return os.name == "nt"


def _replace_with_windows_retry(tmp: str, target: str) -> None:
"""Retry only transient Windows sharing/access failures within 150ms."""
for i, delay in enumerate((0.01, 0.02, 0.04, 0.08), start=1):
try:
os.replace(tmp, target)
return
except PermissionError as exc:
if not _is_windows() or getattr(exc, "winerror", None) not in _WINDOWS_SHARING_WINERRORS or i == 4:
raise
time.sleep(delay)


def atomic_write_json(path, obj: Any, *, encoding: str = "utf-8",
indent: int = 2, default=_SENTINEL) -> Path:
p = Path(path)
p.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(prefix=f".{p.name}.", suffix=".tmp", dir=str(p.parent))
try:
with os.fdopen(fd, "w", encoding=encoding) as f:
if default is _SENTINEL:
json.dump(obj, f, ensure_ascii=False, indent=indent)
else:
json.dump(obj, f, ensure_ascii=False, indent=indent, default=default)
f.flush()
os.fsync(f.fileno())
_replace_with_windows_retry(tmp, str(p))
except BaseException:
# never leave the target half-written; leave the temp for audit (do NOT unlink)
raise
return p
65 changes: 65 additions & 0 deletions src/agentbench_frame/games/miracle/driver.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""Glue between finalized Miracle :class:`GameOutcome` instances and the
framework's :class:`Run`.

This module is the single place that defeats framework risks #2 / #3 / #5
(see docs/games/24_miracle_adapter_status.md):

* #2 / #3 — ``Match``/``Run`` count ``raw_winner == 0`` as a win. We feed
``Run.log_episode`` the **normalized** winner (0 = evaluated agent won),
so ``Run._build_summary`` persists a win_rate that is correct even after
side-swapping.
* #5 — ``BaseRunner.run()`` writes ``summary.json`` before merging the
subclass result. We never use ``BaseRunner``; the caller drives ``Run``
directly and everything is injected *before* ``finish()``.

Error games are recorded as ``game`` audit events but are NOT logged as
episodes, so they fall out of ``total_episodes`` and ``win_rate`` (有效对局).
"""
from __future__ import annotations

from typing import Dict, Sequence

from agentbench_frame.games.miracle.result import (
DRAW,
LOSS,
WIN,
GameOutcome,
compute_h2h,
compute_win_rate,
to_event_record,
)
from agentbench_frame.tracking.run import Run

#: map normalized result -> the integer winner Run.log_episode expects.
#: Run counts ``winner == 0`` as a win, so 0 = evaluated-agent win.
_EPISODE_WINNER = {WIN: 0, LOSS: 1, DRAW: -1}


def feed_outcomes_to_run(run: Run, outcomes: Sequence[GameOutcome]) -> Dict:
"""Write every outcome as a ``game`` audit event and log the valid ones as
episodes with normalized winners + auditable step counts.

Does **not** call ``run.finish()`` — the caller owns the run lifecycle so it
can set ``run_type`` / ``data_dir`` correctly (risk #4 / #6) before any
summary is written.
"""
for o in outcomes:
run.write(**to_event_record(o))
if o.valid:
evaluated_score = o.score0 if o.evaluated_agent_camp == 0 else o.score1
run.log_episode(
reward=float(evaluated_score or 0.0),
steps=int(o.steps),
winner=_EPISODE_WINNER[o.normalized_result],
info={
"game_id": o.game_id,
"opponent": o.opponent,
"evaluated_agent_camp": o.evaluated_agent_camp,
"raw_winner": o.raw_winner,
},
)
run.log_h2h(compute_h2h(outcomes))
return {
"win_rate": compute_win_rate(outcomes),
"valid_games": sum(1 for o in outcomes if o.valid),
}
72 changes: 72 additions & 0 deletions src/agentbench_frame/games/miracle/entry.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Cross-platform AI entry resolution for the 24_miracle runner.

Resolves the argv list used to launch an AI subprocess via ``subprocess.Popen``
(``shell=False``). The executable is returned as an **ABSOLUTE** path because
Windows ``CreateProcess`` does NOT search the ``cwd=`` argument's directory for a
bare executable name (``Popen(["main.exe"], cwd=dir)`` raises FileNotFoundError);
an absolute path is found regardless of the child's working directory.

Precedence:
1. ``explicit`` (a list is kept as-is; a string is kept WHOLE as a single
executable path — never whitespace-split, so paths with spaces survive)
takes priority over auto-detection.
2. Auto-detection (deterministic and documented):
Windows (``os.name == 'nt'``): ``main.exe`` > ``main.py`` > ``main``
POSIX: ``main`` > ``main.py`` > ``main.exe``
When ``main.exe`` and ``main`` coexist, Windows picks ``main.exe`` and
POSIX picks ``main`` (recorded behaviour).
``main.py`` is launched via ``sys.executable`` + absolute script path.

This never modifies the strategy directory and never reads strategy source.
"""
from __future__ import annotations

import os
import sys
from pathlib import Path
from typing import List, Optional, Union

#: platform flag read at call time (not the global ``os.name``), so tests can
#: patch it via ``monkeypatch.setattr(entry, "_IS_NT", ...)`` without disturbing
#: pathlib or other stdlib code that reads the real ``os.name``.
_IS_NT = os.name == "nt"


def resolve_ai_command(
ai_dir: Union[str, Path],
explicit: Optional[Union[str, List[str]]] = None,
) -> List[str]:
"""Return the argv list for the AI subprocess (executable as an ABSOLUTE path).

Raises ``FileNotFoundError`` (mentioning ``ai_dir``) when no entry exists.
"""
# 1. explicit takes priority; a string is kept whole (paths with spaces!)
if explicit is not None and explicit != "" and not (
isinstance(explicit, (list, tuple)) and len(explicit) == 0
):
if isinstance(explicit, (list, tuple)):
return [str(x) for x in explicit]
return [str(explicit)]

ai_dir = Path(ai_dir).resolve() # absolute, so Popen(cwd=...) finds the exe on Windows
is_nt = _IS_NT

# 2. auto-detection — absolute path to the entry
if is_nt:
if (ai_dir / "main.exe").exists():
return [str(ai_dir / "main.exe")]
if (ai_dir / "main.py").exists():
return [sys.executable, str(ai_dir / "main.py")]
if (ai_dir / "main").exists(): # unusual on Windows (no extension)
return [str(ai_dir / "main")]
else:
if (ai_dir / "main").exists():
return [str(ai_dir / "main")]
if (ai_dir / "main.py").exists():
return [sys.executable, str(ai_dir / "main.py")]
if (ai_dir / "main.exe").exists(): # cross-mounted POSIX
return [str(ai_dir / "main.exe")]

raise FileNotFoundError(
f"no AI entry found (main.exe on Windows / main on POSIX / main.py) in {ai_dir}"
)
Loading
Loading