citar 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- citar/__init__.py +21 -0
- citar/agents/__init__.py +20 -0
- citar/agents/bot_agent.py +52 -0
- citar/agents/llm_agent.py +469 -0
- citar/agents/mcp_server.py +116 -0
- citar/agents/prompts.py +105 -0
- citar/agents/providers/__init__.py +19 -0
- citar/agents/providers/anthropic_provider.py +102 -0
- citar/agents/providers/base.py +76 -0
- citar/agents/providers/dryrun.py +97 -0
- citar/agents/providers/openai_provider.py +289 -0
- citar/agents/providers/worker_provider.py +129 -0
- citar/aggregate.py +171 -0
- citar/auth/__init__.py +25 -0
- citar/auth/access.py +396 -0
- citar/auth/accounts.py +477 -0
- citar/auth/audit.py +87 -0
- citar/auth/captcha.py +98 -0
- citar/auth/deps.py +189 -0
- citar/auth/invites.py +149 -0
- citar/auth/mailer.py +209 -0
- citar/auth/oauth.py +308 -0
- citar/auth/passwords.py +113 -0
- citar/auth/policy.py +191 -0
- citar/auth/ratelimit.py +207 -0
- citar/auth/sessions.py +212 -0
- citar/auth/tokens.py +70 -0
- citar/balance.py +332 -0
- citar/bench.py +162 -0
- citar/bots/__init__.py +15 -0
- citar/bots/basic.py +1894 -0
- citar/bots/frozen_0de641a1.py +1325 -0
- citar/bots/frozen_27c13cf7.py +1596 -0
- citar/bots/frozen_31ef9854.py +1781 -0
- citar/bots/frozen_44984a2b.py +1756 -0
- citar/bots/frozen_4ce67344.py +1301 -0
- citar/bots/frozen_52aec52c.py +1536 -0
- citar/bots/frozen_7149efb1.py +1705 -0
- citar/bots/frozen_8f953b89.py +1669 -0
- citar/bots/frozen_b9e1b21c.py +1707 -0
- citar/bots/frozen_c45e5ba2.py +1725 -0
- citar/cli.py +145 -0
- citar/costing.py +374 -0
- citar/data/collectors/collect_hardware.ps1 +87 -0
- citar/data/collectors/collect_hardware.sh +149 -0
- citar/data/custom/nations.json +21 -0
- citar/data/game.json +77 -0
- citar/data/ruleset/NOTICE.md +10 -0
- citar/data/ruleset/beliefs.json +461 -0
- citar/data/ruleset/buildings.json +1792 -0
- citar/data/ruleset/city_state_types.json +67 -0
- citar/data/ruleset/difficulties.json +281 -0
- citar/data/ruleset/eras.json +419 -0
- citar/data/ruleset/global_uniques.json +12 -0
- citar/data/ruleset/improvements.json +460 -0
- citar/data/ruleset/nations.json +2435 -0
- citar/data/ruleset/personalities.json +1304 -0
- citar/data/ruleset/policies.json +968 -0
- citar/data/ruleset/promotions.json +1372 -0
- citar/data/ruleset/quests.json +183 -0
- citar/data/ruleset/religions.json +13 -0
- citar/data/ruleset/resources.json +805 -0
- citar/data/ruleset/ruins.json +112 -0
- citar/data/ruleset/specialists.json +34 -0
- citar/data/ruleset/speeds.json +210 -0
- citar/data/ruleset/techs.json +1155 -0
- citar/data/ruleset/terrains.json +665 -0
- citar/data/ruleset/unit_types.json +166 -0
- citar/data/ruleset/units.json +2146 -0
- citar/data/ruleset/victories.json +49 -0
- citar/db/__init__.py +118 -0
- citar/db/models.py +547 -0
- citar/doctor.py +357 -0
- citar/engine/__init__.py +30 -0
- citar/engine/actions.py +225 -0
- citar/engine/automation.py +454 -0
- citar/engine/barbarians.py +493 -0
- citar/engine/briefing.py +660 -0
- citar/engine/cities.py +2387 -0
- citar/engine/city_states.py +1320 -0
- citar/engine/combat.py +1219 -0
- citar/engine/conquest.py +288 -0
- citar/engine/diplomacy.py +831 -0
- citar/engine/economy.py +745 -0
- citar/engine/espionage.py +515 -0
- citar/engine/game.py +826 -0
- citar/engine/great_people.py +369 -0
- citar/engine/hexmap.py +152 -0
- citar/engine/mapgen.py +1222 -0
- citar/engine/maps.py +352 -0
- citar/engine/movement.py +663 -0
- citar/engine/policies.py +169 -0
- citar/engine/religion.py +796 -0
- citar/engine/research.py +367 -0
- citar/engine/ruins.py +67 -0
- citar/engine/rules.py +341 -0
- citar/engine/scenario.py +641 -0
- citar/engine/state.py +325 -0
- citar/engine/tiles.py +391 -0
- citar/engine/tools.py +1068 -0
- citar/engine/triggers.py +374 -0
- citar/engine/turns.py +200 -0
- citar/engine/unique_types.py +640 -0
- citar/engine/uniques.py +1083 -0
- citar/engine/units.py +737 -0
- citar/engine/victory.py +485 -0
- citar/engine/views.py +757 -0
- citar/engine/visibility.py +243 -0
- citar/engine/workers.py +640 -0
- citar/fsutil.py +35 -0
- citar/hwinfo.py +411 -0
- citar/keystore.py +228 -0
- citar/lab.py +1007 -0
- citar/migrations/env.py +74 -0
- citar/migrations/script.py.mako +26 -0
- citar/migrations/versions/20260920_0939_initial_schema_accounts_servers_sharing.py +427 -0
- citar/migrations/versions/20260920_1050_user_seat_limit_game_and_report_.py +39 -0
- citar/paths.py +236 -0
- citar/pool/__init__.py +344 -0
- citar/pool/admission.py +298 -0
- citar/pool/budgets.py +306 -0
- citar/pool/windows.py +327 -0
- citar/probes.py +646 -0
- citar/reports/__init__.py +329 -0
- citar/reports/analysis.py +229 -0
- citar/reports/charts.py +359 -0
- citar/reports/data.py +321 -0
- citar/reports/render.py +679 -0
- citar/server/__init__.py +26 -0
- citar/server/__main__.py +60 -0
- citar/server/admin_api.py +367 -0
- citar/server/admin_cli.py +432 -0
- citar/server/app.py +1613 -0
- citar/server/auth_api.py +666 -0
- citar/server/benchmarks.py +959 -0
- citar/server/boot.py +167 -0
- citar/server/lmstudio.py +113 -0
- citar/server/metrics.py +245 -0
- citar/server/ownership.py +192 -0
- citar/server/pool_api.py +524 -0
- citar/server/scoring.py +212 -0
- citar/server/session.py +710 -0
- citar/server/setup_api.py +332 -0
- citar/server/share_api.py +263 -0
- citar/server/workers.py +361 -0
- citar/servers.py +1271 -0
- citar/settings.py +360 -0
- citar/sim.py +75 -0
- citar/usage.py +448 -0
- citar/web/index.html +16 -0
- citar/web/js/account.js +240 -0
- citar/web/js/api.js +236 -0
- citar/web/js/app.js +116 -0
- citar/web/js/auth.js +355 -0
- citar/web/js/benchmarks.js +429 -0
- citar/web/js/console.js +163 -0
- citar/web/js/editor.js +630 -0
- citar/web/js/game.js +911 -0
- citar/web/js/hex.js +59 -0
- citar/web/js/lab.js +154 -0
- citar/web/js/landing.js +168 -0
- citar/web/js/lobby.js +422 -0
- citar/web/js/metrics.js +178 -0
- citar/web/js/models.js +73 -0
- citar/web/js/nav.js +66 -0
- citar/web/js/panels.js +832 -0
- citar/web/js/pool.js +517 -0
- citar/web/js/probes.js +319 -0
- citar/web/js/render.js +763 -0
- citar/web/js/replay.js +216 -0
- citar/web/js/reports.js +221 -0
- citar/web/js/scenario.js +458 -0
- citar/web/js/servers.js +759 -0
- citar/web/js/setup.js +224 -0
- citar/web/js/util.js +113 -0
- citar/web/style.css +464 -0
- citar/wizard/__init__.py +27 -0
- citar/wizard/cli.py +134 -0
- citar/wizard/detect.py +215 -0
- citar/wizard/local.py +276 -0
- citar/wizard/prompts.py +191 -0
- citar/wizard/server.py +550 -0
- citar/wizard/worker.py +211 -0
- citar/worker/__init__.py +11 -0
- citar/worker/__main__.py +183 -0
- citar/worker/agent.py +355 -0
- citar/worker/protocol.py +224 -0
- citar-0.1.0.dist-info/METADATA +271 -0
- citar-0.1.0.dist-info/RECORD +193 -0
- citar-0.1.0.dist-info/WHEEL +4 -0
- citar-0.1.0.dist-info/entry_points.txt +5 -0
- citar-0.1.0.dist-info/licenses/LICENSE +373 -0
- citar-0.1.0.dist-info/licenses/NOTICE.md +54 -0
citar/__init__.py
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""CITAR — Civ Inspired Tool for AI Research.
|
|
2
|
+
|
|
3
|
+
A turn-based 4X game with a Civilization V ruleset (derived from UnCiv, MPL-2.0) whose players can
|
|
4
|
+
be humans in a browser, scripted bots, or language models driven over MCP, the Anthropic API, or any
|
|
5
|
+
OpenAI-compatible endpoint. It exists to measure how models play a long, stateful game with
|
|
6
|
+
imperfect information: benchmarks, scenario probes, per-turn metrics and costed reports are part of
|
|
7
|
+
the program rather than bolted on.
|
|
8
|
+
|
|
9
|
+
Start here
|
|
10
|
+
----------
|
|
11
|
+
``citar.engine`` the rules, with no I/O — the map, cities, combat, diplomacy, victory
|
|
12
|
+
``citar.agents`` the adapters that let a model take a seat
|
|
13
|
+
``citar.server`` the FastAPI app, session manager and benchmark scheduler
|
|
14
|
+
``citar.paths`` where files live, whether run from a checkout or an installed wheel
|
|
15
|
+
|
|
16
|
+
The command line is ``citar`` (see :mod:`citar.cli`); ``citar serve`` starts the game server and
|
|
17
|
+
``citar setup`` walks through first-time configuration.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
__version__ = "0.1.0"
|
|
21
|
+
__all__ = ["__version__"]
|
citar/agents/__init__.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Adapters that let something other than a human hold a seat.
|
|
2
|
+
|
|
3
|
+
An agent's whole job is to take a turn: read the situation, issue orders, stop. What differs
|
|
4
|
+
between them is only *who decides*.
|
|
5
|
+
|
|
6
|
+
:mod:`citar.agents.llm_agent` a language model, driven by the server
|
|
7
|
+
:mod:`citar.agents.bot_agent` the scripted bot, wrapped in the same interface
|
|
8
|
+
:mod:`citar.agents.mcp_server` the bridge for an external agent over MCP
|
|
9
|
+
:mod:`citar.agents.prompts` what a model is told, and how the turn is framed
|
|
10
|
+
:mod:`citar.agents.providers` the clients that actually reach a model
|
|
11
|
+
|
|
12
|
+
The division that matters is between the **turn loop** and the **provider**. The loop —
|
|
13
|
+
:mod:`citar.agents.llm_agent` — owns the briefing, the guard rails, the limits and the metrics. A
|
|
14
|
+
provider turns one request into one completion and knows nothing about the game. Everything that
|
|
15
|
+
would bias a comparison between models therefore lives in the loop, which is identical for all of
|
|
16
|
+
them; swapping Anthropic for a local endpoint changes how the text is fetched and nothing else.
|
|
17
|
+
|
|
18
|
+
None of this is privileged. An agent calls the same tools a browser does, through the same
|
|
19
|
+
registry in :mod:`citar.engine.tools`, and is refused by the same rules.
|
|
20
|
+
"""
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Adapter that lets the scripted BasicBot occupy a seat in a live session."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
from ..bots.basic import BasicBot
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class BotAgent:
|
|
10
|
+
"""Wraps the scripted bot in the same interface a model uses.
|
|
11
|
+
|
|
12
|
+
So that a bot seat and a model seat are driven identically by the session: same turn loop, same
|
|
13
|
+
negotiation handling, same metrics. A benchmark comparing a model against the bot is then comparing
|
|
14
|
+
two players of the same game rather than two code paths.
|
|
15
|
+
"""
|
|
16
|
+
def __init__(self, aggression: float = 0.4):
|
|
17
|
+
self.bot = BasicBot(aggression=float(aggression))
|
|
18
|
+
|
|
19
|
+
def _bind(self, session, pid):
|
|
20
|
+
"""Give the bot a way to call tools against this session."""
|
|
21
|
+
def ex(g, p, _tool, **args):
|
|
22
|
+
"""Execute one tool call on the bot's behalf."""
|
|
23
|
+
res = session.call_tool(p, _tool, args)
|
|
24
|
+
return res["result"] if res["ok"] else None
|
|
25
|
+
self.bot.ex = ex
|
|
26
|
+
|
|
27
|
+
def play_turn(self, session, pid: int):
|
|
28
|
+
"""Play the bot's turn."""
|
|
29
|
+
self._bind(session, pid)
|
|
30
|
+
with session.lock:
|
|
31
|
+
g = session.game
|
|
32
|
+
if g.s.current != pid:
|
|
33
|
+
return
|
|
34
|
+
self.bot.play_turn(g, pid, end_turn=False)
|
|
35
|
+
# give counterparts a chance to answer negotiations we opened
|
|
36
|
+
deadline = time.time() + 90
|
|
37
|
+
while time.time() < deadline:
|
|
38
|
+
with session.lock:
|
|
39
|
+
g = session.game
|
|
40
|
+
mine = [n for n in g.s.negotiations if n["status"] == "open" and pid in (n["initiator"], n["responder"])]
|
|
41
|
+
if not mine:
|
|
42
|
+
break
|
|
43
|
+
for n in mine:
|
|
44
|
+
if n["awaiting"] == pid:
|
|
45
|
+
self.bot.respond(g, pid, n["id"])
|
|
46
|
+
session.cond.wait(timeout=1.0)
|
|
47
|
+
|
|
48
|
+
def respond_negotiation(self, session, pid: int, nid: int):
|
|
49
|
+
"""Answer a negotiation as the bot."""
|
|
50
|
+
self._bind(session, pid)
|
|
51
|
+
with session.lock:
|
|
52
|
+
self.bot.respond(session.game, pid, nid)
|
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"""Drives a language model through a turn (or a diplomatic interrupt) using the shared tool registry.
|
|
2
|
+
|
|
3
|
+
Includes guard rails for weaker models: identical actions are not re-executed within a turn, unchanged queries are
|
|
4
|
+
flagged, a short "turn progress" note follows every batch of actions, and stalled or runaway turns are ended.
|
|
5
|
+
Every model step and tool call is recorded in the session metrics.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
import time
|
|
11
|
+
import traceback
|
|
12
|
+
|
|
13
|
+
from ..engine import tools as toolreg
|
|
14
|
+
from ..server.metrics import call_signature, ACTION_REPEAT_EXEMPT
|
|
15
|
+
from .prompts import system_prompt, TURN_START, FIRST_TURN_NOTE, NEGOTIATION_PROMPT
|
|
16
|
+
from .providers import make_conversation
|
|
17
|
+
|
|
18
|
+
NEGOTIATION_TOOLS = {"get_diplomacy", "get_empire", "get_players", "get_map", "get_tile", "get_tech_tree", "get_rules",
|
|
19
|
+
"read_notes", "write_notes", "log_thought", "send_message", "respond_negotiation",
|
|
20
|
+
"get_victory_status", "get_cities", "get_units"}
|
|
21
|
+
MAX_RESULT_CHARS = 14000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class _Halted(Exception):
|
|
25
|
+
"""Raised internally when the game is closed or the seat's controller changes mid-turn."""
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class _TimeUp(Exception):
|
|
29
|
+
"""Raised internally when the turn's time budget runs out, including while a model request is in progress."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _tool_defs(names: set | None = None) -> list[dict]:
|
|
33
|
+
"""The tool definitions sent to a model, from the one registry."""
|
|
34
|
+
out = []
|
|
35
|
+
for t in toolreg.REGISTRY.values():
|
|
36
|
+
if names is not None and t.name not in names:
|
|
37
|
+
continue
|
|
38
|
+
out.append({"name": t.name, "description": t.description, "input_schema": t.schema()})
|
|
39
|
+
return out
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _serialize(result) -> str:
|
|
43
|
+
"""A tool result as the text a model sees."""
|
|
44
|
+
if isinstance(result, str):
|
|
45
|
+
text = result
|
|
46
|
+
else:
|
|
47
|
+
text = json.dumps(result, ensure_ascii=False, default=str)
|
|
48
|
+
if len(text) > MAX_RESULT_CHARS:
|
|
49
|
+
text = text[:MAX_RESULT_CHARS] + "\n...(truncated; ask for something more specific)"
|
|
50
|
+
return text
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class _TurnState:
|
|
54
|
+
"""What has happened during one turn: calls made, errors, repeats and progress.
|
|
55
|
+
|
|
56
|
+
The state the guard rails consult. Per turn rather than per game, because a model that loops within
|
|
57
|
+
a turn and behaves across the game is a specific, fixable problem.
|
|
58
|
+
"""
|
|
59
|
+
def __init__(self):
|
|
60
|
+
self.done: dict[str, int] = {} # signature -> successful executions
|
|
61
|
+
self.queries: dict[str, tuple] = {} # signature -> (session version, times asked, result text)
|
|
62
|
+
self.steps_without_progress = 0
|
|
63
|
+
self.nudged_stall = False
|
|
64
|
+
self.unit_moves: dict[str, int] = {}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class LLMAgent:
|
|
68
|
+
"""Drives one seat with a language model: briefing, tool calls, limits and metrics.
|
|
69
|
+
|
|
70
|
+
Everything that shapes play lives here rather than in a provider, which is what makes comparing two
|
|
71
|
+
models a comparison of the models: the briefing, the guard rails, the step and time limits and the
|
|
72
|
+
measurement are identical whether the model is local or hosted.
|
|
73
|
+
|
|
74
|
+
The guard rails exist because weak models fail in characteristic ways - repeating a successful
|
|
75
|
+
action, re-asking an unchanged question, emitting a tool call as prose. Each is handled rather than
|
|
76
|
+
punished, so that a score reflects how well the model played and not how well it formatted.
|
|
77
|
+
"""
|
|
78
|
+
def __init__(self, cfg: dict):
|
|
79
|
+
self.cfg = dict(cfg)
|
|
80
|
+
self.max_calls = int(self.cfg.get("max_tool_calls_per_turn") or 150)
|
|
81
|
+
self.max_steps = int(self.cfg.get("max_steps_per_turn") or 60)
|
|
82
|
+
self.max_turn_seconds = float(self.cfg.get("max_turn_seconds") if self.cfg.get("max_turn_seconds") is not None else 1800)
|
|
83
|
+
self.stall_steps = int(self.cfg.get("stall_steps") or 10)
|
|
84
|
+
self.max_negotiation_calls = int(self.cfg.get("max_negotiation_calls") or 20)
|
|
85
|
+
self.negotiation_wait = float(self.cfg.get("negotiation_wait_seconds") or 240)
|
|
86
|
+
self.usage_total: dict = {}
|
|
87
|
+
self.last_error: str | None = None
|
|
88
|
+
self.cancelled = False
|
|
89
|
+
self._active: set = set()
|
|
90
|
+
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
def cancel(self):
|
|
93
|
+
"""Abort any turn or negotiation in progress, including in-flight model requests."""
|
|
94
|
+
self.cancelled = True
|
|
95
|
+
for conv in list(self._active):
|
|
96
|
+
try:
|
|
97
|
+
conv.close()
|
|
98
|
+
except Exception:
|
|
99
|
+
pass
|
|
100
|
+
|
|
101
|
+
def _halted(self, session) -> bool:
|
|
102
|
+
"""Whether the game has stopped and this turn should be abandoned."""
|
|
103
|
+
return self.cancelled or session.stopped
|
|
104
|
+
|
|
105
|
+
def _step(self, session, pid: int, conv, deadline: float | None = None):
|
|
106
|
+
"""One model call (timed and metered), retrying transient connection problems before giving up.
|
|
107
|
+
With a deadline (end of the turn's time budget) the request may only use the time left, and running out of
|
|
108
|
+
time raises _TimeUp instead of an error."""
|
|
109
|
+
delays = [3, 10, 30]
|
|
110
|
+
for attempt in range(len(delays) + 1):
|
|
111
|
+
if self._halted(session):
|
|
112
|
+
raise _Halted()
|
|
113
|
+
if deadline is not None:
|
|
114
|
+
remaining = deadline - time.time()
|
|
115
|
+
if remaining <= 5:
|
|
116
|
+
raise _TimeUp()
|
|
117
|
+
conv.request_timeout = remaining
|
|
118
|
+
before = dict(getattr(conv, "usage", {}))
|
|
119
|
+
t0 = time.perf_counter()
|
|
120
|
+
try:
|
|
121
|
+
step = conv.step()
|
|
122
|
+
usage = getattr(conv, "usage", {})
|
|
123
|
+
d = {k: usage.get(k, 0) - before.get(k, 0) for k in usage}
|
|
124
|
+
seconds = time.perf_counter() - t0
|
|
125
|
+
session.metrics.model_step(
|
|
126
|
+
pid, seconds, input_tokens=d.get("input_tokens", 0), output_tokens=d.get("output_tokens", 0),
|
|
127
|
+
reasoning_tokens=d.get("reasoning_tokens", 0), malformed=step.malformed)
|
|
128
|
+
self._meter(session, conv, seconds, d)
|
|
129
|
+
return step
|
|
130
|
+
except Exception as e:
|
|
131
|
+
if self._halted(session):
|
|
132
|
+
raise _Halted()
|
|
133
|
+
name = type(e).__name__
|
|
134
|
+
self._meter(session, conv, time.perf_counter() - t0, {})
|
|
135
|
+
if deadline is not None and time.time() >= deadline - 5:
|
|
136
|
+
session.metrics.model_step(pid, time.perf_counter() - t0)
|
|
137
|
+
raise _TimeUp()
|
|
138
|
+
transient = any(k in name for k in ("Connection", "Timeout", "RateLimit", "InternalServer", "ServiceUnavailable"))
|
|
139
|
+
if not transient or attempt == len(delays):
|
|
140
|
+
raise
|
|
141
|
+
if deadline is not None and time.time() + delays[attempt] >= deadline - 5:
|
|
142
|
+
raise _TimeUp()
|
|
143
|
+
self._thought(session, pid, f"(model call failed: {name}: {e}; retrying in {delays[attempt]}s)", "system")
|
|
144
|
+
for _ in range(delays[attempt] * 2):
|
|
145
|
+
if self._halted(session):
|
|
146
|
+
raise _Halted()
|
|
147
|
+
time.sleep(0.5)
|
|
148
|
+
|
|
149
|
+
def _meter(self, session, conv, seconds: float, d: dict):
|
|
150
|
+
"""Record a model call (time and tokens) in the usage ledger, which reports turn into costs."""
|
|
151
|
+
from .. import usage
|
|
152
|
+
usage.tracker().llm(getattr(session, "usage_act", None), self.cfg.get("server_id"),
|
|
153
|
+
getattr(conv, "served_model", None) or self.cfg.get("model"), seconds,
|
|
154
|
+
input_tokens=d.get("input_tokens", 0), output_tokens=d.get("output_tokens", 0),
|
|
155
|
+
reasoning_tokens=d.get("reasoning_tokens", 0), cache_read=d.get("cache_read_input_tokens", 0),
|
|
156
|
+
cache_write=d.get("cache_creation_input_tokens", 0))
|
|
157
|
+
|
|
158
|
+
def _ensure_loaded(self, session, pid: int):
|
|
159
|
+
"""Once per agent: load the seat's model with its load profile on a server CITAR manages (lobby games; the
|
|
160
|
+
benchmark and probe runners load models themselves before starting)."""
|
|
161
|
+
if getattr(self, "_load_checked", False) or not self.cfg.get("server_id") or not self.cfg.get("load"):
|
|
162
|
+
return
|
|
163
|
+
self._load_checked = True
|
|
164
|
+
from .. import servers
|
|
165
|
+
try:
|
|
166
|
+
sv = servers.get(self.cfg["server_id"])
|
|
167
|
+
secs = servers.ensure_model(sv, self.cfg["model"], self.cfg["load"])
|
|
168
|
+
if secs:
|
|
169
|
+
self._thought(session, pid, f"(loaded {self.cfg['model']} on {sv['name']} with profile "
|
|
170
|
+
f"'{self.cfg['load'].get('name')}' in {secs:.0f}s)", "system")
|
|
171
|
+
except Exception as e:
|
|
172
|
+
self._thought(session, pid, f"(could not load {self.cfg.get('model')}: {e}; trying anyway)", "system")
|
|
173
|
+
|
|
174
|
+
def _report_failure(self, session, pid: int, e: Exception):
|
|
175
|
+
"""Record a failure against the seat, where the lobby will show it."""
|
|
176
|
+
where = self.cfg.get("base_url") or self.cfg.get("provider") or "the model"
|
|
177
|
+
self.last_error = f"{type(e).__name__}: {e}"
|
|
178
|
+
low = str(e).lower()
|
|
179
|
+
if any(k in low for k in ("terminated", "context", "too long", "n_ctx", "exceeds")):
|
|
180
|
+
self.last_error += (" — this usually means the prompt outgrew the model's context window; load the model "
|
|
181
|
+
"with a larger context length (32k+ recommended).")
|
|
182
|
+
self._thought(session, pid, f"(AI error: {self.last_error})", "system")
|
|
183
|
+
with session.lock:
|
|
184
|
+
g = session.game
|
|
185
|
+
g.emit("agent_error", f"{g.player(pid).name}'s AI could not play ({self.cfg.get('model') or '?'} at {where}): "
|
|
186
|
+
f"{self.last_error[:300]}. Its turn was skipped — check the seat settings.", None, player=pid)
|
|
187
|
+
|
|
188
|
+
def _conversation(self, names=None):
|
|
189
|
+
"""Start a conversation with the provider, with the tools this turn may use."""
|
|
190
|
+
return make_conversation(self.cfg, system_prompt(self.cfg.get("persona")), _tool_defs(names))
|
|
191
|
+
|
|
192
|
+
def _record_usage(self, conv):
|
|
193
|
+
"""Write this turn's token and timing usage to the ledger."""
|
|
194
|
+
for k, v in getattr(conv, "usage", {}).items():
|
|
195
|
+
self.usage_total[k] = self.usage_total.get(k, 0) + v
|
|
196
|
+
|
|
197
|
+
def _thought(self, session, pid: int, text: str, kind: str = "reasoning"):
|
|
198
|
+
"""Record the model's reasoning for spectators and the replay."""
|
|
199
|
+
text = (text or "").strip()
|
|
200
|
+
if not text:
|
|
201
|
+
return
|
|
202
|
+
with session.lock:
|
|
203
|
+
g = session.game
|
|
204
|
+
g.s.thoughts.append({"turn": g.turn, "player": pid, "text": text[:6000], "kind": kind})
|
|
205
|
+
session._broadcast({"type": "thought", "player": pid, "turn": session.game.turn, "text": text[:6000], "kind": kind})
|
|
206
|
+
|
|
207
|
+
def _end_reason(self, session, pid: int, reason: str):
|
|
208
|
+
"""Record how the turn ended, which is half of what the metrics are for."""
|
|
209
|
+
session.metrics.set_end_reason(pid, reason)
|
|
210
|
+
|
|
211
|
+
# ------------------------------------------------------------------
|
|
212
|
+
def _run_calls(self, session, pid: int, conv, calls, state: _TurnState) -> tuple[bool, int, int]:
|
|
213
|
+
"""Execute tool calls. Returns (ended_turn, calls_executed, successful_new_actions)."""
|
|
214
|
+
results = []
|
|
215
|
+
ended = False
|
|
216
|
+
progress = 0
|
|
217
|
+
for c in calls:
|
|
218
|
+
if self._halted(session):
|
|
219
|
+
raise _Halted()
|
|
220
|
+
spec = toolreg.REGISTRY.get(c.name)
|
|
221
|
+
if "__invalid_json__" in c.args:
|
|
222
|
+
results.append((c.id, "Error: your tool arguments were not valid JSON.", True))
|
|
223
|
+
session.metrics.tool_call(pid, c.name, {}, spec.kind if spec else "unknown", False, 0.0, "invalid JSON arguments")
|
|
224
|
+
continue
|
|
225
|
+
sig = call_signature(c.name, c.args)
|
|
226
|
+
# --- repeated identical action: don't redo it ---------------------------------------
|
|
227
|
+
if spec and spec.kind == "action" and c.name not in ACTION_REPEAT_EXEMPT and state.done.get(sig):
|
|
228
|
+
n = state.done[sig] = state.done[sig] + 1
|
|
229
|
+
msg = (f"Skipped: you already did exactly this earlier this turn (it succeeded; this is attempt {n}). "
|
|
230
|
+
f"Nothing needs redoing — check the TURN PROGRESS note and give different orders, or call end_turn.")
|
|
231
|
+
results.append((c.id, msg, True))
|
|
232
|
+
session.metrics.tool_call(pid, c.name, c.args, "action", False, 0.0, "blocked identical repeat",
|
|
233
|
+
blocked_repeat=True)
|
|
234
|
+
self._thought(session, pid, f"{c.name} {json.dumps(c.args, ensure_ascii=False)[:200]} → blocked repeat #{n}", "action")
|
|
235
|
+
continue
|
|
236
|
+
# --- repeated identical query with no state change: answer from cache, flag it ---------
|
|
237
|
+
if spec and spec.kind == "query" and sig in state.queries:
|
|
238
|
+
version, times, text = state.queries[sig]
|
|
239
|
+
if version == session.version:
|
|
240
|
+
state.queries[sig] = (version, times + 1, text)
|
|
241
|
+
session.metrics.tool_call(pid, c.name, c.args, "query", True, 0.0)
|
|
242
|
+
results.append((c.id, f"(Unchanged — you already asked this {times} time(s) this turn and nothing has "
|
|
243
|
+
f"changed since.)\n{text}", False))
|
|
244
|
+
continue
|
|
245
|
+
# --- many different move orders for the same unit in one turn: it's probably stuck -----------
|
|
246
|
+
if c.name == "move_unit" and "unit_id" in c.args:
|
|
247
|
+
uid = str(c.args.get("unit_id"))
|
|
248
|
+
state.unit_moves[uid] = state.unit_moves.get(uid, 0) + 1
|
|
249
|
+
if state.unit_moves[uid] > 4:
|
|
250
|
+
msg = (f"Skipped: this is move order #{state.unit_moves[uid]} for unit {uid} this turn. Stop retrying "
|
|
251
|
+
f"moves for it — call get_unit({uid}) to see its remaining moves and reachable tiles, give it a "
|
|
252
|
+
f"different order (fortify/sleep/explore), or leave it until next turn.")
|
|
253
|
+
results.append((c.id, msg, True))
|
|
254
|
+
session.metrics.tool_call(pid, c.name, c.args, "action", False, 0.0, "blocked: too many move orders",
|
|
255
|
+
blocked_repeat=True)
|
|
256
|
+
continue
|
|
257
|
+
wait = self.negotiation_wait if c.name in ("open_negotiation", "respond_negotiation") else 0
|
|
258
|
+
res = session.call_tool(pid, c.name, c.args, wait_negotiation=wait)
|
|
259
|
+
if res["ok"]:
|
|
260
|
+
text = _serialize(res["result"])
|
|
261
|
+
results.append((c.id, text, False))
|
|
262
|
+
if c.name == "end_turn":
|
|
263
|
+
ended = True
|
|
264
|
+
if spec and spec.kind == "query":
|
|
265
|
+
state.queries[sig] = (session.version, 1, text)
|
|
266
|
+
elif spec and spec.kind == "action":
|
|
267
|
+
state.done[sig] = state.done.get(sig, 0) + 1
|
|
268
|
+
r = res["result"]
|
|
269
|
+
no_op = c.name == "move_unit" and isinstance(r, dict) and r.get("from") == r.get("to") and "rebased_to" not in r
|
|
270
|
+
if c.name not in ("log_thought", "write_notes") and not no_op:
|
|
271
|
+
progress += 1
|
|
272
|
+
else:
|
|
273
|
+
results.append((c.id, "Error: " + res["error"], True))
|
|
274
|
+
if c.name != "log_thought" and (spec is None or spec.kind == "action"):
|
|
275
|
+
args = json.dumps(c.args, ensure_ascii=False)[:200]
|
|
276
|
+
outcome = "ok" if res["ok"] else f"ERROR: {res['error'][:200]}"
|
|
277
|
+
self._thought(session, pid, f"{c.name} {args} → {outcome}", "action")
|
|
278
|
+
conv.add_tool_results(results)
|
|
279
|
+
return ended, len(calls), progress
|
|
280
|
+
|
|
281
|
+
def _progress_note(self, session, pid: int) -> str:
|
|
282
|
+
"""What is still unhandled this turn, told to the model after each batch of actions.
|
|
283
|
+
|
|
284
|
+
The single most effective guard rail: models forget what they have not done, and a short reminder
|
|
285
|
+
of the remaining idle units and cities turns a wasted turn into a played one.
|
|
286
|
+
"""
|
|
287
|
+
from ..engine.briefing import turn_progress
|
|
288
|
+
with session.lock:
|
|
289
|
+
return turn_progress(session.game, pid)
|
|
290
|
+
|
|
291
|
+
# ------------------------------------------------------------------
|
|
292
|
+
def play_turn(self, session, pid: int):
|
|
293
|
+
"""Play one turn with the model, from briefing to end of turn."""
|
|
294
|
+
from ..engine.briefing import briefing
|
|
295
|
+
self._ensure_loaded(session, pid)
|
|
296
|
+
with session.lock:
|
|
297
|
+
g = session.game
|
|
298
|
+
if g.s.current != pid or g.s.phase != "playing":
|
|
299
|
+
return
|
|
300
|
+
turn = g.turn
|
|
301
|
+
text = TURN_START.format(briefing=briefing(g, pid), turn=turn,
|
|
302
|
+
first_turn_note=FIRST_TURN_NOTE if not g.player(pid).founded_city and turn <= 2 else "")
|
|
303
|
+
conv = self._conversation()
|
|
304
|
+
self._active.add(conv)
|
|
305
|
+
conv.add_user_text(text)
|
|
306
|
+
self._check_context(session, pid, conv)
|
|
307
|
+
state = _TurnState()
|
|
308
|
+
calls_made = steps = nudges = 0
|
|
309
|
+
started = time.time()
|
|
310
|
+
try:
|
|
311
|
+
while True:
|
|
312
|
+
if self._halted(session):
|
|
313
|
+
self._end_reason(session, pid, "cancelled")
|
|
314
|
+
return
|
|
315
|
+
with session.lock:
|
|
316
|
+
if session.game.s.current != pid or session.game.s.phase != "playing" or session.game.turn != turn:
|
|
317
|
+
return
|
|
318
|
+
if steps >= self.max_steps:
|
|
319
|
+
self._limit(session, pid, "step_limit", f"reached {self.max_steps} model steps")
|
|
320
|
+
return
|
|
321
|
+
if calls_made >= self.max_calls:
|
|
322
|
+
self._limit(session, pid, "tool_limit", f"reached {self.max_calls} tool calls")
|
|
323
|
+
return
|
|
324
|
+
if self.max_turn_seconds and time.time() - started > self.max_turn_seconds:
|
|
325
|
+
self._limit(session, pid, "time_limit", f"exceeded {int(self.max_turn_seconds)}s")
|
|
326
|
+
return
|
|
327
|
+
step = self._step(session, pid, conv,
|
|
328
|
+
deadline=started + self.max_turn_seconds if self.max_turn_seconds else None)
|
|
329
|
+
steps += 1
|
|
330
|
+
self.last_error = None
|
|
331
|
+
self._thought(session, pid, step.thinking, "thinking")
|
|
332
|
+
self._thought(session, pid, step.text, "reasoning")
|
|
333
|
+
if step.stop_reason == "refusal":
|
|
334
|
+
self._thought(session, pid, "(model declined to continue this turn)", "system")
|
|
335
|
+
self._end_reason(session, pid, "refusal")
|
|
336
|
+
return
|
|
337
|
+
if not step.tool_calls:
|
|
338
|
+
if step.stop_reason == "max_tokens":
|
|
339
|
+
continue
|
|
340
|
+
nudges += 1
|
|
341
|
+
if nudges > 2:
|
|
342
|
+
self._end_reason(session, pid, "no_tool_calls")
|
|
343
|
+
return
|
|
344
|
+
conv.add_user_text("You did not call any tool. " + self._progress_note(session, pid))
|
|
345
|
+
continue
|
|
346
|
+
ended, n, progress = self._run_calls(session, pid, conv, step.tool_calls, state)
|
|
347
|
+
calls_made += n
|
|
348
|
+
if ended:
|
|
349
|
+
self._end_reason(session, pid, "end_turn")
|
|
350
|
+
return
|
|
351
|
+
if progress:
|
|
352
|
+
state.steps_without_progress = 0
|
|
353
|
+
conv.add_user_text(self._progress_note(session, pid))
|
|
354
|
+
else:
|
|
355
|
+
state.steps_without_progress += 1
|
|
356
|
+
if state.steps_without_progress >= self.stall_steps:
|
|
357
|
+
self._limit(session, pid, "stalled", f"{self.stall_steps} model steps without a new successful order")
|
|
358
|
+
return
|
|
359
|
+
if state.steps_without_progress == max(3, self.stall_steps // 2):
|
|
360
|
+
session.metrics.bump(pid, "stall_nudges")
|
|
361
|
+
conv.add_user_text("You seem to be going in circles: your last several steps produced no new "
|
|
362
|
+
"successful orders. Stop re-checking things. " + self._progress_note(session, pid)
|
|
363
|
+
+ " Give a new order now, or call end_turn.")
|
|
364
|
+
if calls_made >= self.max_calls - 5:
|
|
365
|
+
conv.add_user_text(f"You are near this turn's tool call limit ({self.max_calls}). Finish up and call end_turn.")
|
|
366
|
+
except _Halted:
|
|
367
|
+
self._end_reason(session, pid, "cancelled")
|
|
368
|
+
return
|
|
369
|
+
except _TimeUp:
|
|
370
|
+
self._limit(session, pid, "time_limit", f"exceeded {int(self.max_turn_seconds)}s (the model was still "
|
|
371
|
+
"generating when time ran out)")
|
|
372
|
+
return
|
|
373
|
+
except Exception as e:
|
|
374
|
+
if self._halted(session):
|
|
375
|
+
return
|
|
376
|
+
session.errors.append({"t": time.time(), "player": pid, "where": "llm play_turn", "trace": traceback.format_exc()})
|
|
377
|
+
self._end_reason(session, pid, "error")
|
|
378
|
+
self._report_failure(session, pid, e)
|
|
379
|
+
finally:
|
|
380
|
+
self._active.discard(conv)
|
|
381
|
+
self._record_usage(conv)
|
|
382
|
+
|
|
383
|
+
def _check_context(self, session, pid: int, conv):
|
|
384
|
+
"""Record the model's context window (LM Studio) and warn once if it is too small for CITAR's prompts."""
|
|
385
|
+
info_fn = getattr(conv, "context_info", None)
|
|
386
|
+
info = info_fn() if info_fn else None
|
|
387
|
+
if not info:
|
|
388
|
+
return
|
|
389
|
+
rec = session.metrics.current(pid)
|
|
390
|
+
if rec is not None:
|
|
391
|
+
rec["context_length"] = info.get("loaded_context")
|
|
392
|
+
rec["model_loaded_at_start"] = info.get("state") == "loaded"
|
|
393
|
+
ctx = info.get("loaded_context")
|
|
394
|
+
if ctx and ctx < 24000 and not getattr(self, "_warned_context", False):
|
|
395
|
+
self._warned_context = True
|
|
396
|
+
msg = (f"{self.cfg.get('model')} is loaded in LM Studio with only {ctx:,} tokens of context. CITAR turns need "
|
|
397
|
+
f"roughly 20-40k; the model will lose track of its briefing and repeat itself. Reload it with a larger "
|
|
398
|
+
f"context length (max {info.get('max_context') or '?'}).")
|
|
399
|
+
self._thought(session, pid, "(warning) " + msg, "system")
|
|
400
|
+
with session.lock:
|
|
401
|
+
session.game.emit("agent_error", f"{session.game.player(pid).name}'s AI: {msg}", None, player=pid)
|
|
402
|
+
|
|
403
|
+
def _limit(self, session, pid: int, reason: str, detail: str):
|
|
404
|
+
"""End the turn because a limit was reached, recording which."""
|
|
405
|
+
self._end_reason(session, pid, reason)
|
|
406
|
+
self._thought(session, pid, f"(turn ended by the game: {detail})", "system")
|
|
407
|
+
|
|
408
|
+
# ------------------------------------------------------------------
|
|
409
|
+
def respond_negotiation(self, session, pid: int, nid: int):
|
|
410
|
+
"""Answer a negotiation with the model, out of turn."""
|
|
411
|
+
from ..engine.diplomacy import get_negotiation, negotiation_view
|
|
412
|
+
from ..engine.views import empire_info
|
|
413
|
+
with session.lock:
|
|
414
|
+
g = session.game
|
|
415
|
+
n = get_negotiation(g, nid)
|
|
416
|
+
if n["status"] != "open" or n["awaiting"] != pid:
|
|
417
|
+
return
|
|
418
|
+
view = negotiation_view(g, n, pid)
|
|
419
|
+
emp = empire_info(g, pid)
|
|
420
|
+
summary = {k: emp[k] for k in ("gold", "per_turn", "era", "happiness", "score", "strategic_resources",
|
|
421
|
+
"luxuries", "policies")}
|
|
422
|
+
summary["cities"] = len(g.player_cities(pid))
|
|
423
|
+
summary["at_war_with"] = [g.player(q).name for q in g.player(pid).met if g.at_war(pid, q)]
|
|
424
|
+
summary["notebook"] = g.player(pid).notes[-2000:]
|
|
425
|
+
text = NEGOTIATION_PROMPT.format(other=view["with_name"], nid=nid, negotiation=json.dumps(view, indent=1),
|
|
426
|
+
summary=json.dumps(summary, default=str))
|
|
427
|
+
history_len = len(n["history"])
|
|
428
|
+
conv = self._conversation(NEGOTIATION_TOOLS)
|
|
429
|
+
self._active.add(conv)
|
|
430
|
+
conv.add_user_text(text)
|
|
431
|
+
calls = steps = 0
|
|
432
|
+
t0 = time.time()
|
|
433
|
+
try:
|
|
434
|
+
while calls < self.max_negotiation_calls and steps < self.max_negotiation_calls:
|
|
435
|
+
if self._halted(session):
|
|
436
|
+
return
|
|
437
|
+
with session.lock:
|
|
438
|
+
n = get_negotiation(session.game, nid)
|
|
439
|
+
if n["status"] != "open" or n["awaiting"] != pid or len(n["history"]) != history_len:
|
|
440
|
+
return
|
|
441
|
+
step = self._step(session, pid, conv)
|
|
442
|
+
steps += 1
|
|
443
|
+
self._thought(session, pid, step.thinking, "thinking")
|
|
444
|
+
self._thought(session, pid, step.text, "diplomacy")
|
|
445
|
+
if not step.tool_calls:
|
|
446
|
+
conv.add_user_text("Respond with respond_negotiation (accept, reject, counter, or reply).")
|
|
447
|
+
continue
|
|
448
|
+
# never block waiting for replies inside an interrupt
|
|
449
|
+
results = []
|
|
450
|
+
for c in step.tool_calls:
|
|
451
|
+
if c.name not in NEGOTIATION_TOOLS:
|
|
452
|
+
results.append((c.id, f"Tool '{c.name}' is not available during a diplomatic interrupt.", True))
|
|
453
|
+
continue
|
|
454
|
+
res = session.call_tool(pid, c.name, c.args)
|
|
455
|
+
results.append((c.id, _serialize(res["result"]) if res["ok"] else "Error: " + res["error"], not res["ok"]))
|
|
456
|
+
conv.add_tool_results(results)
|
|
457
|
+
calls += len(step.tool_calls)
|
|
458
|
+
except _Halted:
|
|
459
|
+
return
|
|
460
|
+
except Exception as e:
|
|
461
|
+
if self._halted(session):
|
|
462
|
+
return
|
|
463
|
+
self.last_error = f"{type(e).__name__}: {e}"
|
|
464
|
+
session.errors.append({"t": time.time(), "player": pid, "where": "llm negotiation", "trace": traceback.format_exc()})
|
|
465
|
+
finally:
|
|
466
|
+
self._active.discard(conv)
|
|
467
|
+
self._record_usage(conv)
|
|
468
|
+
usage = getattr(conv, "usage", {})
|
|
469
|
+
session.metrics.negotiation(pid, time.time() - t0, steps, usage.get("input_tokens", 0), usage.get("output_tokens", 0))
|