Skip to content
Merged
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
5 changes: 5 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ RUN pip install "agentenv-framework-protocol==0.1.275" "mcp>=1.25,<2" "pydantic>
"imageio-ffmpeg==0.6.0" \
&& ln -s "$(python -c 'import imageio_ffmpeg; print(imageio_ffmpeg.get_ffmpeg_exe())')" /usr/local/bin/ffmpeg \
&& ffmpeg -hide_banner -version >/dev/null
# agentenv-game-env (the lobby and the match), its env side alone, from the archive pyproject.toml pins; agent-env
# openciv3 setup passes it.
ARG GAME_ENV
RUN test -n "$GAME_ENV" || { echo "build with --build-arg GAME_ENV=<agentenv-game-env archive URL>" >&2; exit 1; } \
&& pip install --no-deps "$GAME_ENV"
COPY --from=bridge /out /opt/civbridge
COPY LICENSE NOTICE THIRD_PARTY_NOTICES.md /opt/civbridge/licenses/
COPY pyproject.toml README.md LICENSE NOTICE /tmp/pkg/
Expand Down
123 changes: 74 additions & 49 deletions README.md

Large diffs are not rendered by default.

11 changes: 5 additions & 6 deletions agents/claude-player/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,9 @@ def model_env(environ: dict[str, str], model: str) -> dict[str, str]:
return env


def mcp_config(servers: dict, seat: str | None = None) -> dict:
def mcp_config(servers: dict) -> dict:
def server(s: dict) -> dict:
headers = mcp_headers(s, seat)
headers = mcp_headers(s)
return {"type": "http", "url": s["url"], **({"headers": headers} if headers else {})}
return {"mcpServers": {name: server(s) for name, s in servers.items()}}

Expand Down Expand Up @@ -96,12 +96,11 @@ def tokens(result: dict) -> tuple[int, int]:
class ClaudeCode(Harness):
name, code, trajectory = "Claude Code", "claude", "claude-code-stream-json/v1"

def __init__(self, config: ClaudeConfig, servers: dict, seat: str | None, environ: dict[str, str],
workdir: Path):
def __init__(self, config: ClaudeConfig, servers: dict, environ: dict[str, str], workdir: Path):
config = config.model_copy(update={"model": model_id(environ, config.model)})
super().__init__(config, servers, seat, environ, workdir)
super().__init__(config, servers, environ, workdir)
mcp_file = workdir / "mcp.json"
mcp_file.write_text(json.dumps(mcp_config(servers, seat)))
mcp_file.write_text(json.dumps(mcp_config(servers)))
self.cmd = claude_cmd(config, mcp_file, list(servers))
self.env = {**environ, **model_env(environ, config.model), "MCP_TOOL_TIMEOUT": str(TOOL_TIMEOUT_SECONDS * 1000)}
self.proc: asyncio.subprocess.Process | None = None
Expand Down
10 changes: 5 additions & 5 deletions agents/codex-player/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@ def toml(value: str) -> str:
return json.dumps(value)


def codex_config(environ: dict[str, str], servers: dict, seat: str | None) -> str:
def codex_config(environ: dict[str, str], servers: dict) -> str:
"""CODEX_HOME/config.toml: the model endpoint and the env's MCP servers, whose calls may wait for other agents."""
base = endpoint(environ)
url = f"{base}/openai/v1" if base and "api.openai.com" not in base else "https://api.openai.com/v1"
Expand All @@ -53,7 +53,7 @@ def codex_config(environ: dict[str, str], servers: dict, seat: str | None) -> st
for name, s in servers.items():
lines += ["", f"[mcp_servers.{toml(name)}]", f"url = {toml(s['url'])}", "required = true",
"startup_timeout_sec = 60", f"tool_timeout_sec = {TOOL_TIMEOUT_SECONDS}"]
if headers := mcp_headers(s, seat):
if headers := mcp_headers(s):
lines.append("http_headers = { " + ", ".join(f"{toml(k)} = {toml(v)}" for k, v in headers.items()) + " }")
return "\n".join(lines) + "\n"

Expand All @@ -77,11 +77,11 @@ def tool_text(item: dict) -> str:
class Codex(Harness):
name, code, trajectory = "Codex", "codex", "codex-exec-json/v1"

def __init__(self, config: CodexConfig, servers: dict, seat: str | None, environ: dict[str, str], workdir: Path):
super().__init__(config, servers, seat, environ, workdir)
def __init__(self, config: CodexConfig, servers: dict, environ: dict[str, str], workdir: Path):
super().__init__(config, servers, environ, workdir)
home = workdir / "codex"
home.mkdir(exist_ok=True)
(home / "config.toml").write_text(codex_config(environ, servers, seat))
(home / "config.toml").write_text(codex_config(environ, servers))
self.env = {**environ, "CODEX_HOME": str(home)}
self.thread: str | None = None

Expand Down
22 changes: 8 additions & 14 deletions agents/common/openciv3_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,9 @@
nudged to keep playing until the game reaches that turn or GAME OVER, so a task can split a long game into steps. Any
other prompt plays the whole game in fresh sessions of OPENCIV3_SESSION_TURNS turns (75; 0 for a single session), which
bounds the model's context: each session picks the game up from the brief and the plan tool. The model endpoint comes
from agent-env as LITELLM_BASE_URL and LITELLM_API_KEY. In a game with several agents, the X-OpenCiv3-Seat header tells
the env which civilization this agent plays: OPENCIV3_SEAT, or else the name deploy_agent gave the agent. end_turn may
wait minutes for the others, so tool calls get a longer timeout.
from agent-env as LITELLM_BASE_URL and LITELLM_API_KEY. In a game with several players, the MCP server agent-env hands
the agent is its player slot's (add_player_slot), so its calls play its civilization. end_turn may wait minutes for
the others, so tool calls get a longer timeout.
"""

from __future__ import annotations
Expand Down Expand Up @@ -42,7 +42,6 @@
SESSION_TURNS = 75
FOOTER = re.compile(r"\[(GAME OVER )?T(\d+)/(\d+)")
GAME_OVER = re.compile(r"^GAME OVER\b", re.MULTILINE)
SEAT_HEADER = "X-OpenCiv3-Seat"
TOOL_TIMEOUT_SECONDS = 1800
STOP_AT = re.compile(r"\buntil turn (\d+)", re.IGNORECASE)
STREAM_LIMIT = 64 * 1024 * 1024
Expand All @@ -54,13 +53,8 @@ class PlayerConfig(AgentConfig):
max_nudges: int = 30


def seat_name(environ: dict[str, str], config: AgentConfig) -> str | None:
"""The seat this agent plays: OPENCIV3_SEAT, or else the name deploy_agent gave it, which the env maps to a seat."""
return environ.get("OPENCIV3_SEAT") or config.name


def mcp_headers(server: dict, seat: str | None) -> dict[str, str]:
return {**(server.get("headers") or {}), **({SEAT_HEADER: seat} if seat else {})}
def mcp_headers(server: dict) -> dict[str, str]:
return dict(server.get("headers") or {})


def endpoint(environ: dict[str, str]) -> str:
Expand Down Expand Up @@ -164,8 +158,8 @@ class Harness:
code: ClassVar[str]
trajectory: ClassVar[str]

def __init__(self, config: PlayerConfig, servers: dict, seat: str | None, environ: dict[str, str], workdir: Path):
self.config, self.servers, self.seat, self.environ, self.workdir = config, servers, seat, environ, workdir
def __init__(self, config: PlayerConfig, servers: dict, environ: dict[str, str], workdir: Path):
self.config, self.servers, self.environ, self.workdir = config, servers, environ, workdir

async def reply(self, text: str, session: Session) -> bool:
"""Send `text` and feed `session` the model's reply; False when the conversation cannot go on."""
Expand Down Expand Up @@ -249,7 +243,7 @@ async def play(cli: type[Harness], request: TaskRequest) -> TaskResult:
return TaskResult.failure("no_mcp_server", "No MCP server was configured for this agent.")
with tempfile.TemporaryDirectory(prefix=f"openciv3-{cli.code}-") as tmp:
def start() -> Harness:
return cli(config, dict(request.mcp_servers), seat_name(environ, config), environ, Path(tmp))
return cli(config, dict(request.mcp_servers), environ, Path(tmp))

sessions: list[Session] = []
try:
Expand Down
11 changes: 5 additions & 6 deletions agents/gemini-player/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@ class GeminiConfig(PlayerConfig):
model: str = "gemini-3.1-pro-preview"


def gemini_settings(servers: dict, seat: str | None) -> dict:
def gemini_settings(servers: dict) -> dict:
"""~/.gemini/settings.json: the env's MCP servers, whose calls may wait for other agents, and no other tools.
Loop detection is off: a game repeats the same calls turn after turn."""
def server(s: dict) -> dict:
headers = mcp_headers(s, seat)
headers = mcp_headers(s)
return {"httpUrl": s["url"], "timeout": TOOL_TIMEOUT_SECONDS * 1000, "trust": True,
**({"headers": headers} if headers else {})}
return {"mcpServers": {name: server(s) for name, s in servers.items()},
Expand Down Expand Up @@ -74,13 +74,12 @@ def tool_text(event: dict) -> str:
class GeminiCli(Harness):
name, code, trajectory = "Gemini CLI", "gemini", "gemini-cli-stream-json/v1"

def __init__(self, config: GeminiConfig, servers: dict, seat: str | None, environ: dict[str, str],
workdir: Path):
super().__init__(config, servers, seat, environ, workdir)
def __init__(self, config: GeminiConfig, servers: dict, environ: dict[str, str], workdir: Path):
super().__init__(config, servers, environ, workdir)
home, self.game = workdir / "home", workdir / "game"
(home / ".gemini").mkdir(parents=True, exist_ok=True)
self.game.mkdir(exist_ok=True)
(home / ".gemini" / "settings.json").write_text(json.dumps(gemini_settings(servers, seat), indent=1))
(home / ".gemini" / "settings.json").write_text(json.dumps(gemini_settings(servers), indent=1))
(self.game / "GEMINI.md").write_text(config.system_prompt or SYSTEM_PROMPT)
self.env = {**environ, **gemini_env(environ), "HOME": str(home)}
self.session_id: str | None = None
Expand Down
64 changes: 36 additions & 28 deletions docs/play.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,47 +7,54 @@ the env, the task steps and the play UI.

## 1. In a task

`openciv3_match` takes `humans` next to the agents:
A person plays a player slot of the env's lobby (agentenv-game-env), `"player_kind": "human"`, next to the agents':

```jsonc
{"id": "match", "type": "openciv3_match", "env_id": "openciv3", "turns": 100,
"civs": {"opus": "Greece"}, // agents (deployed with deploy_agent) and their civs
"humans": {"you": "Rome"}, // people: a name (the seat's label) and a civ; a list of names picks civs
"human_turn_seconds": 900} // a human seat idle this long while others wait has its turn ended; 0: never
{"id": "lobby", "type": "open_lobby", "env_id": "openciv3", "depends_on": ["deploy"],
"game_settings": {"turn_limit": 100,
"human_turn_seconds": 900}}, // a human seat idle this long while others wait has its turn ended; 0: never
{"id": "slot-opus", "type": "add_player_slot", "env_id": "openciv3", "player_id": "opus", "player_kind": "agent",
"player_name": "opus", "game_settings": {"civ": "Greece"}, "depends_on": ["lobby", "agent-opus"]},
{"id": "slot-you", "type": "add_player_slot", "env_id": "openciv3", "player_id": "you", "player_kind": "human",
"player_name": "you", "game_settings": {"civ": "Rome"}, "depends_on": ["lobby"]}, // the next free civ without one
{"id": "match", "type": "close_lobby", "env_id": "openciv3", "depends_on": ["slot-opus", "slot-you"]},
{"id": "await", "type": "await_match", "env_id": "openciv3", "timeout_seconds": 36000, "fail_task_on_error": false,
"depends_on": ["match"]},
{"id": "finish", "type": "finish_match", "env_id": "openciv3", "depends_on": ["await", "opus"]}
```

- **The play link:** the step logs one per human, `PLAY Rome (you): http://127.0.0.1:<port>/play#token=<token>`, and
stores them in `context.metadata["openciv3_match"]["play"]` as `{name: {"civ", "url"}}`. The token is the seat's:
whoever has the link plays that civ. `agent-env openciv3 play --open` finds a running env's links and opens them.
- **Waiting for the game:** a human plays no `prompt_agent` step, so a match with humans adds `openciv3_await_game`
(below) and makes grading and the recording depend on it.
- **The play link:** a person's player slot gets a play token, 32 hex characters, as the slot's secret headers
(`x-openciv3-token`; agentenv-game-env's `@player_slot_headers`), which only the lobby's `fill` returns.
`add_player_slot` logs the slot's page with the token in its fragment,
`PLAY player slot you (you): http://127.0.0.1:<port>/players/you/play#x-openciv3-token=<token>`, and keeps the slot
in `context.metadata["game_slots"]["you"]` (its `interfaces` and `headers`). Whoever has the link plays that civ.
`agent-env openciv3 play --open` finds a running env's links and opens them.
- **Waiting for the game:** a human plays no `prompt_agent` step, so a match with humans adds `await_match` (below)
and makes `finish_match`, and so grading and the recording, depend on it.
- **Idle humans:** `human_turn_seconds: 0` lets a person take as long as they like, but an agent's `end_turn` then
waits on them, past the agents' own tool timeout if need be; keep a limit in matches with agents.
- **Agents can't play a human's seat:** a tool call whose `X-OpenCiv3-Seat` names a human seat fails with
`human_seat`.
- **Agents can't play a human's seat:** a tool call at a person's player slot fails with `human_seat`.
- **Grading:** `grade_match` grades every agent by default; `human-vs-agents` grades the person with a second
`grade_match` step (`"player_ids": ["you"]`, no `max_missed`), so their idle turns count against no one.

`openciv3_await_game` waits until the game is over (`data/get` reports `game_over`), logging the turn as it goes:
`await_match` (agentenv-game-env) waits until the match ends on its own, reading it every `poll_seconds` and trying
again when a read fails:

| Field | Default | Meaning |
|---|---|---|
| `env_id` | required | The env the match runs in |
| `timeout_seconds` | `36000` | Fail the step if the game is not over by then |
| `timeout_seconds` | `36000` | Fail the step if the match has not ended by then |
| `poll_seconds` | `10` | How often it asks |

## 2. The new-game extension
## 2. The lobby and the env

`urn:openciv3:new-game/v1` takes two more args:

- `humans`: civs (among `civ` and `seats`) that people play.
- `human_turn_seconds` (default `900`): replaces `SEAT_STALL_SECONDS` (300) for human seats; `0` never ends a
human's turn for them.

Its result adds `"play": {civ: "/play#token=<token>"}`, one per human seat (`{}` without humans); the token is 32
hex characters, new for every game. The env logs `NEW GAME <id>` when any game starts, then each link at INFO
`human_turn_seconds` (default `900`) replaces `SEAT_STALL_SECONDS` (300) for human seats; `0` never ends a human's
turn for them. The env starts a game from the lobby with each person's civ as a human seat, and the token its slot
was given. It logs `NEW GAME <id>` when any game starts, then each link at INFO
(`PLAY <civ> (<label>) game <id> /play#token=...`), which is how `agent-env openciv3 play` finds the newest game's
links. `data/get`'s `seats[]` gains `"human": true|false` (a one-seat game a person plays has a top-level `"human"`
and lists its seat), and so do `/live`'s seats and the viewer's players. Leaving `humans` out of a new game means no
humans.
and lists its seat), and so do `/live`'s seats and the viewer's players. A game without a human player slot has no
humans; the env's default game (`urn:openciv3:new-game/v1`) never has one.

## 3. The bridge: `known_map`

Expand Down Expand Up @@ -131,14 +138,15 @@ The city screen and the advisors read two more commands' fields:

## 4. The play API (the env, next to `/mcp`)

Every route but the page needs the seat's token, as the header `X-OpenCiv3-Token` or `?token=`; a wrong or missing
token gets 401. Errors are JSON: `{"ok": false, "error": {"code", "message", "alternatives", "suggest"}}`, with 400 for
The routes are the env's, at `/play`; under a person's player slot (`/players/<player_id>/play`, the page its slot's
env card lists) they are the same routes. Every route but the page needs the seat's token, as the header
`X-OpenCiv3-Token` or `?token=`; a wrong or missing token gets 401. Errors are JSON: `{"ok": false, "error": {"code", "message", "alternatives", "suggest"}}`, with 400 for
a malformed request (`bad_request`, `unknown_tool`, `bad_args`) and 200 for what the game refuses. The GET routes but
`view` and `status` answer with the bridge's result as it is.

| Route | What it does |
|---|---|
| `GET /play` | The play UI. It reads the token from the URL's `#token=` |
| `GET /play` | The play UI. It reads the token from the URL's fragment, `#token=` or `#x-openciv3-token=` |
| `GET /play/api/view` | Everything the UI draws (below) |
| `GET /play/api/status` | `{"game", "turn", "game_over", "ended", "waiting_for": [civ], "seats": [...]}`, cheap, for polling |
| `GET /play/api/city?city=c1` | The bridge's `city` (with `options`) |
Expand Down
4 changes: 2 additions & 2 deletions docs/protocol.md
Original file line number Diff line number Diff line change
Expand Up @@ -598,8 +598,8 @@ events, decisions and plan state are its own. An unknown seat fails with `unknow
change hands between seats.
- **Victory** (see Conventions): conquest also when one seat's civilization is the last an agent still plays
(the other seats are defeated), whatever AI civs are left. The game is then over for every seat. A victory on
score at the turn limit may be an AI civ's; the victor verifier then still ranks the seats by score, while an AI
civ's conquest, domination or cultural victory leaves the match without a victor.
score at the turn limit may be an AI civ's, as may a conquest, domination or cultural victory; the env's match then
counts it as that AI player slot's win and a loss for every seat (tools.md, Several agents in one game).
- `autoplay` fails with `multi_seat`. The autosave (format 2) keeps every seat, the victory, the battles
`known_map` lists and the standing peace and trade offers (`trade_offers`; a save without them loads with none);
`load` restores them.
Expand Down
9 changes: 7 additions & 2 deletions docs/recording.md
Original file line number Diff line number Diff line change
Expand Up @@ -129,7 +129,12 @@ point. Its fields:
- It logs one line per file, saying where the file is.
- It works with any env that advertises an extension returning that shape, so it can later move to agent-env core unchanged.

Every task in the `openciv3` bundle ends with this step, after the game and in parallel with grading.
The bundle's default-game tasks (`smoke`, `play`, `full-game`) end with this step, after the game and in parallel
with grading. A match from the lobby keeps the same recording through agentenv-game-env's `save_match_files`, after
`finish_match`: the env's match files are the recording by kind, `map_video` (the mp4, or the gif without ffmpeg),
`html_replay`, `last_turn` (the png) and `client_video` (one per seat, in an image built with the client), all but
`last_turn` by default; each is stored as a `file` artifact and listed in
`context.metadata["match_files"][<step id>]`.

## 4. Playtests and replays

Expand Down Expand Up @@ -189,7 +194,7 @@ seconds after GAME OVER, or a minute after the env is gone. `--record DIR` also
one RTMP server fails), and `--offline` only records. The stream keys come from agent-env's secret store (X's server
URL too, `OPENCIV3_X_SERVER`), and the streamer replaces them in everything ffmpeg prints.

When the task's `openciv3_match` turns on the casters (its `broadcast`, [tools.md](tools.md)), or with `--cast`,
When the task's lobby turns on the casters (its `broadcast`, [tools.md](tools.md)), or with `--cast`,
`streamer/caster.py` runs next to the browser: two AI casters, by default Max (play-by-play) and Ada (the analyst);
the task may rename them and pick their voices, styles and models, and `--no-cast` keeps them off. It reads `GET /live/data.json` every 2 s, as the viewer does, and talks only about what that data shows:
the intro (the players, the turns, that they can message each other), the biggest new events first (an elimination,
Expand Down
Loading
Loading