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.
Files changed (193) hide show
  1. citar/__init__.py +21 -0
  2. citar/agents/__init__.py +20 -0
  3. citar/agents/bot_agent.py +52 -0
  4. citar/agents/llm_agent.py +469 -0
  5. citar/agents/mcp_server.py +116 -0
  6. citar/agents/prompts.py +105 -0
  7. citar/agents/providers/__init__.py +19 -0
  8. citar/agents/providers/anthropic_provider.py +102 -0
  9. citar/agents/providers/base.py +76 -0
  10. citar/agents/providers/dryrun.py +97 -0
  11. citar/agents/providers/openai_provider.py +289 -0
  12. citar/agents/providers/worker_provider.py +129 -0
  13. citar/aggregate.py +171 -0
  14. citar/auth/__init__.py +25 -0
  15. citar/auth/access.py +396 -0
  16. citar/auth/accounts.py +477 -0
  17. citar/auth/audit.py +87 -0
  18. citar/auth/captcha.py +98 -0
  19. citar/auth/deps.py +189 -0
  20. citar/auth/invites.py +149 -0
  21. citar/auth/mailer.py +209 -0
  22. citar/auth/oauth.py +308 -0
  23. citar/auth/passwords.py +113 -0
  24. citar/auth/policy.py +191 -0
  25. citar/auth/ratelimit.py +207 -0
  26. citar/auth/sessions.py +212 -0
  27. citar/auth/tokens.py +70 -0
  28. citar/balance.py +332 -0
  29. citar/bench.py +162 -0
  30. citar/bots/__init__.py +15 -0
  31. citar/bots/basic.py +1894 -0
  32. citar/bots/frozen_0de641a1.py +1325 -0
  33. citar/bots/frozen_27c13cf7.py +1596 -0
  34. citar/bots/frozen_31ef9854.py +1781 -0
  35. citar/bots/frozen_44984a2b.py +1756 -0
  36. citar/bots/frozen_4ce67344.py +1301 -0
  37. citar/bots/frozen_52aec52c.py +1536 -0
  38. citar/bots/frozen_7149efb1.py +1705 -0
  39. citar/bots/frozen_8f953b89.py +1669 -0
  40. citar/bots/frozen_b9e1b21c.py +1707 -0
  41. citar/bots/frozen_c45e5ba2.py +1725 -0
  42. citar/cli.py +145 -0
  43. citar/costing.py +374 -0
  44. citar/data/collectors/collect_hardware.ps1 +87 -0
  45. citar/data/collectors/collect_hardware.sh +149 -0
  46. citar/data/custom/nations.json +21 -0
  47. citar/data/game.json +77 -0
  48. citar/data/ruleset/NOTICE.md +10 -0
  49. citar/data/ruleset/beliefs.json +461 -0
  50. citar/data/ruleset/buildings.json +1792 -0
  51. citar/data/ruleset/city_state_types.json +67 -0
  52. citar/data/ruleset/difficulties.json +281 -0
  53. citar/data/ruleset/eras.json +419 -0
  54. citar/data/ruleset/global_uniques.json +12 -0
  55. citar/data/ruleset/improvements.json +460 -0
  56. citar/data/ruleset/nations.json +2435 -0
  57. citar/data/ruleset/personalities.json +1304 -0
  58. citar/data/ruleset/policies.json +968 -0
  59. citar/data/ruleset/promotions.json +1372 -0
  60. citar/data/ruleset/quests.json +183 -0
  61. citar/data/ruleset/religions.json +13 -0
  62. citar/data/ruleset/resources.json +805 -0
  63. citar/data/ruleset/ruins.json +112 -0
  64. citar/data/ruleset/specialists.json +34 -0
  65. citar/data/ruleset/speeds.json +210 -0
  66. citar/data/ruleset/techs.json +1155 -0
  67. citar/data/ruleset/terrains.json +665 -0
  68. citar/data/ruleset/unit_types.json +166 -0
  69. citar/data/ruleset/units.json +2146 -0
  70. citar/data/ruleset/victories.json +49 -0
  71. citar/db/__init__.py +118 -0
  72. citar/db/models.py +547 -0
  73. citar/doctor.py +357 -0
  74. citar/engine/__init__.py +30 -0
  75. citar/engine/actions.py +225 -0
  76. citar/engine/automation.py +454 -0
  77. citar/engine/barbarians.py +493 -0
  78. citar/engine/briefing.py +660 -0
  79. citar/engine/cities.py +2387 -0
  80. citar/engine/city_states.py +1320 -0
  81. citar/engine/combat.py +1219 -0
  82. citar/engine/conquest.py +288 -0
  83. citar/engine/diplomacy.py +831 -0
  84. citar/engine/economy.py +745 -0
  85. citar/engine/espionage.py +515 -0
  86. citar/engine/game.py +826 -0
  87. citar/engine/great_people.py +369 -0
  88. citar/engine/hexmap.py +152 -0
  89. citar/engine/mapgen.py +1222 -0
  90. citar/engine/maps.py +352 -0
  91. citar/engine/movement.py +663 -0
  92. citar/engine/policies.py +169 -0
  93. citar/engine/religion.py +796 -0
  94. citar/engine/research.py +367 -0
  95. citar/engine/ruins.py +67 -0
  96. citar/engine/rules.py +341 -0
  97. citar/engine/scenario.py +641 -0
  98. citar/engine/state.py +325 -0
  99. citar/engine/tiles.py +391 -0
  100. citar/engine/tools.py +1068 -0
  101. citar/engine/triggers.py +374 -0
  102. citar/engine/turns.py +200 -0
  103. citar/engine/unique_types.py +640 -0
  104. citar/engine/uniques.py +1083 -0
  105. citar/engine/units.py +737 -0
  106. citar/engine/victory.py +485 -0
  107. citar/engine/views.py +757 -0
  108. citar/engine/visibility.py +243 -0
  109. citar/engine/workers.py +640 -0
  110. citar/fsutil.py +35 -0
  111. citar/hwinfo.py +411 -0
  112. citar/keystore.py +228 -0
  113. citar/lab.py +1007 -0
  114. citar/migrations/env.py +74 -0
  115. citar/migrations/script.py.mako +26 -0
  116. citar/migrations/versions/20260920_0939_initial_schema_accounts_servers_sharing.py +427 -0
  117. citar/migrations/versions/20260920_1050_user_seat_limit_game_and_report_.py +39 -0
  118. citar/paths.py +236 -0
  119. citar/pool/__init__.py +344 -0
  120. citar/pool/admission.py +298 -0
  121. citar/pool/budgets.py +306 -0
  122. citar/pool/windows.py +327 -0
  123. citar/probes.py +646 -0
  124. citar/reports/__init__.py +329 -0
  125. citar/reports/analysis.py +229 -0
  126. citar/reports/charts.py +359 -0
  127. citar/reports/data.py +321 -0
  128. citar/reports/render.py +679 -0
  129. citar/server/__init__.py +26 -0
  130. citar/server/__main__.py +60 -0
  131. citar/server/admin_api.py +367 -0
  132. citar/server/admin_cli.py +432 -0
  133. citar/server/app.py +1613 -0
  134. citar/server/auth_api.py +666 -0
  135. citar/server/benchmarks.py +959 -0
  136. citar/server/boot.py +167 -0
  137. citar/server/lmstudio.py +113 -0
  138. citar/server/metrics.py +245 -0
  139. citar/server/ownership.py +192 -0
  140. citar/server/pool_api.py +524 -0
  141. citar/server/scoring.py +212 -0
  142. citar/server/session.py +710 -0
  143. citar/server/setup_api.py +332 -0
  144. citar/server/share_api.py +263 -0
  145. citar/server/workers.py +361 -0
  146. citar/servers.py +1271 -0
  147. citar/settings.py +360 -0
  148. citar/sim.py +75 -0
  149. citar/usage.py +448 -0
  150. citar/web/index.html +16 -0
  151. citar/web/js/account.js +240 -0
  152. citar/web/js/api.js +236 -0
  153. citar/web/js/app.js +116 -0
  154. citar/web/js/auth.js +355 -0
  155. citar/web/js/benchmarks.js +429 -0
  156. citar/web/js/console.js +163 -0
  157. citar/web/js/editor.js +630 -0
  158. citar/web/js/game.js +911 -0
  159. citar/web/js/hex.js +59 -0
  160. citar/web/js/lab.js +154 -0
  161. citar/web/js/landing.js +168 -0
  162. citar/web/js/lobby.js +422 -0
  163. citar/web/js/metrics.js +178 -0
  164. citar/web/js/models.js +73 -0
  165. citar/web/js/nav.js +66 -0
  166. citar/web/js/panels.js +832 -0
  167. citar/web/js/pool.js +517 -0
  168. citar/web/js/probes.js +319 -0
  169. citar/web/js/render.js +763 -0
  170. citar/web/js/replay.js +216 -0
  171. citar/web/js/reports.js +221 -0
  172. citar/web/js/scenario.js +458 -0
  173. citar/web/js/servers.js +759 -0
  174. citar/web/js/setup.js +224 -0
  175. citar/web/js/util.js +113 -0
  176. citar/web/style.css +464 -0
  177. citar/wizard/__init__.py +27 -0
  178. citar/wizard/cli.py +134 -0
  179. citar/wizard/detect.py +215 -0
  180. citar/wizard/local.py +276 -0
  181. citar/wizard/prompts.py +191 -0
  182. citar/wizard/server.py +550 -0
  183. citar/wizard/worker.py +211 -0
  184. citar/worker/__init__.py +11 -0
  185. citar/worker/__main__.py +183 -0
  186. citar/worker/agent.py +355 -0
  187. citar/worker/protocol.py +224 -0
  188. citar-0.1.0.dist-info/METADATA +271 -0
  189. citar-0.1.0.dist-info/RECORD +193 -0
  190. citar-0.1.0.dist-info/WHEEL +4 -0
  191. citar-0.1.0.dist-info/entry_points.txt +5 -0
  192. citar-0.1.0.dist-info/licenses/LICENSE +373 -0
  193. 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__"]
@@ -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))