diff --git a/llm/climate_negotiation/README.md b/llm/climate_negotiation/README.md new file mode 100644 index 000000000..49debb406 --- /dev/null +++ b/llm/climate_negotiation/README.md @@ -0,0 +1,194 @@ +# Climate Negotiation - Mesa-LLM Example + +A multi-agent simulation of international climate treaty negotiations where six +country agents, each powered by an LLM negotiate a shared emissions-reduction +target over multiple rounds. + +## What This Model Demonstrates + +| Mesa-LLM feature | How it appears in this model | +|---|---| +| `STLTMemory` | Short-term memory stores recent proposals and messages; long-term memory consolidates committed positions across rounds | +| `ReActReasoning` | Agents reason about their economic interests and negotiating position, then act | +| `speak_to` (inbuilt tool) | Direct diplomatic messaging between specific countries | +| Custom `@tool` functions | `make_proposal`, `accept_proposal`, `form_coalition`, `reject_and_counter` | +| `vision=-1` | Each agent observes all others and model a negotiating room with no spatial grid | + +## Countries and Their Profiles + +Data sources: IEA 2022 (emissions), World Bank 2023 (GDP). + +| Country | Emissions (tCO₂/capita) | GDP/capita | Stance | +|---------|------------------------|------------|--------| +| USA | 14.0 | $76,000 | Supports action; insists all major economies especially China and India to match commitments; prefers market-based mechanisms | +| EU | 6.0 | $37,000 | High ambition (Fit for 55, 55% by 2030); pushes legally binding targets and developing-nation finance | +| China | 8.0 | $12,700 | Argues developed nations bear historical responsibility; supports long-term goals contingent on tech transfer and green finance | +| India | 2.0 | $2,500 | Defends common but differentiated responsibilities; energy access for 1.4 billion people is non-negotiable | +| Brazil | 2.8 | $10,400 | Emissions driven by deforestation, not fossil fuels; demands forest conservation credits count in treaty text | +| Russia | 12.5 | $15,000 | Accepts climate science; resists near-term targets that threaten fossil fuel revenues; open to long timelines | + +## Negotiation Tools + +``` +speak_to(listener_ids, message) - targeted diplomatic message to specific parties +make_proposal(reduction%, year, reason) - formal proposal broadcast to all +accept_proposal(proposer_id, %, message) - formal acceptance; marks agent as treaty signatory +form_coalition(partner_ids, name) - build or expand an alliance +reject_and_counter(proposer_id, %, reason) - reject a proposal and broadcast a counter-offer +``` + +A **treaty is reached** when at least 2/3 of countries have formally called `accept_proposal`. + +## Sample Run Results + +**With `openai/gpt-4o` - treaty reached in 6 rounds (~4 minutes)** + +``` +Round 1 Coalition-building. India forms "Developing Nations Unity" (China, Brazil). + EU builds a cross-bloc coalition. USA anchors EU + Russia. +Round 2 First proposals. USA: 30% (market mechanisms). EU: 40% by 2040. India: 20%. +Round 3 Counters. India rejects EU 40% -> 20%. EU meets India at 30%. + China: 20% contingent on tech transfer. Brazil rejects USA -> 25% + forest credits. +Round 4 EU accepts Brazil (25%). Russia accepts China (20%). 2/6 accepted. +Round 5 India accepts Brazil (25%). China updates to 25%. 3/6 accepted. +Round 6 USA accepts EU. Russia upgrades to 25%. TREATY REACHED - 4/6 ≥ 2/3 + +Final: USA 30% EU 30% India 25% Russia 25% (accepted) + China 25% Brazil 25% (pledged but held out for concessions) +``` + +**With `ollama/llama3.2` (local 3B model)** + +The simulation loop runs without errors but smaller local models produce weaker +emergent behaviour: repeated proposals with empty justifications, and attempts to +use non-existent agent IDs. The code guards against both, +but `llama3.2` is best used for testing the simulation loop rather than observing realistic diplomacy. Use `gpt-4o-mini` or `gemini/gemini-2.0-flash` for meaningful negotiations. + +## Robustness Against LLM Hallucinations + +Two common failure modes are guarded in code: + +- **Phantom agent IDs in `form_coalition`** - `partner_ids` are filtered against + the live agent set before being stored. Invalid IDs are dropped and logged as + `WARNING` in `climate_negotiation.log`. +- **Invalid `proposer_id` in `accept_proposal`** - the ID is validated before + recording an acceptance. If it doesn't match any agent, an error string listing + valid IDs is returned to the LLM so it can self-correct on its next step. + +Every agent's step prompt also includes an explicit `VALID COUNTRY IDs` block so +models are less likely to invent IDs in the first place. + +## Run Log + +Each run writes a structured trace to `climate_negotiation.log` (configurable via +the `CLIMATE_LOG_FILE` environment variable). The log records: + +- Round start/end with average pledge, total proposals, and treaty status +- Per-agent state (pledge, accepted, coalition) at the start and end of each round +- Every tool call with its arguments and outcome +- `WARNING` entries for any hallucinated IDs that were dropped + +## Setup + +### 1. Install dependencies + +```bash +pip install mesa-llm mesa solara python-dotenv rich +``` + +### 2. Set your API key + +Create a `.env` file in `llm/climate_negotiation/`: + +``` +# For Gemini (free tier available) +GEMINI_API_KEY=your_key_here + +# OR for OpenAI +OPENAI_API_KEY=your_key_here + +# OR for Anthropic +ANTHROPIC_API_KEY=your_key_here + +# OR for a local model via Ollama (loop testing only) +OLLAMA_API_BASE=http://localhost:11434 +``` + +### 3. Run with Solara visualisation + +```bash +cd llm/climate_negotiation +solara run app.py +``` + +### 4. Run headless (terminal only) + +```bash +cd llm/climate_negotiation +python -m climate_negotiation.model +``` + +## Supported LLMs + +Works with any LiteLLM-compatible model string: + +| Model string | Notes | +|---|---| +| `gemini/gemini-2.0-flash` | Default; free tier, fast | +| `openai/gpt-4o` | Best emergent behaviour (tested ✓) | +| `openai/gpt-4o-mini` | Good balance of quality and cost | +| `anthropic/claude-haiku-4-5-20251001` | Capable, low latency | +| `ollama/llama3.2` | Local; suitable for loop testing only | + +## File Structure + +``` +llm/climate_negotiation/ # example root +├── app.py # Solara visualisation entry point +├── README.md +└── climate_negotiation/ # Python package + ├── __init__.py # triggers tool registration on import + ├── agents.py # CountryAgent, country_tool_manager + ├── tools.py # four custom @tool functions + └── model.py # ClimateNegotiationModel + COUNTRIES configs +``` + +## Visualisation + +The Solara dashboard shows: + +- **Pledge bar chart** - each country's current reduction commitment; bars turn green when a country accepts the treaty +- **Coalition status panel** - live table of pledge, acceptance status, coalition members, and proposals made +- **Pledge trajectories** - line chart of all countries' pledges across rounds +- **Time-series plots** - TotalProposals, AveragePledge, LargestCoalitionSize + +## What to Watch For + +- **Round 1–2**: Coalition-building; agents probe positions before making formal proposals +- **Round 3–4**: Formal proposals emerge; developing nations counter with differentiated targets and conditions +- **Round 5+**: Coalition pressure on holdouts; some countries accept, others counter-propose +- **Treaty achieved**: Green bars in the visualisation, `treaty_reached=True` in the log + +## Extending This Example To Try Different Possibilities + +**Try different LLMs**: Change `llm_model` in `app.py`. `gpt-4o` produces richer diplomatic +language; `gemini-2.0-flash` is faster and free. + +**Add more countries**: Add a new dict to the `COUNTRIES` list in `model.py` and assign it +a system prompt encoding that country's real-world stance. + +**Change the treaty threshold**: Edit `_treaty_reached()` in `model.py` +(currently requires a 2/3 majority). + +**Use CoTReasoning**: Replace `ReActReasoning` with `CoTReasoning` in `app.py` to see +step-by-step chain-of-thought reasoning printed alongside each agent action. + +**Swap memory type**: Replace `STLTMemory` (default) with `EpisodicMemory` for +importance-scored memory retrieval - useful to observe which proposals agents +consider most significant across many rounds. + +## Related Work + +- Duffuant, G. & Weisbuch, G. (2002). *Bounded confidence and social networks.* + - The `deffuant_weisbuch` example shows opinion convergence without LLM reasoning. + Compare its convergence speed with this model's negotiated consensus. diff --git a/llm/climate_negotiation/app.py b/llm/climate_negotiation/app.py new file mode 100644 index 000000000..6921fa645 --- /dev/null +++ b/llm/climate_negotiation/app.py @@ -0,0 +1,182 @@ +import logging +import warnings + +import matplotlib.pyplot as plt +import pandas as pd +import solara +from climate_negotiation.agents import CountryAgent +from climate_negotiation.model import ClimateNegotiationModel +from dotenv import load_dotenv +from mesa.visualization import SolaraViz, make_plot_component +from mesa.visualization.utils import update_counter +from mesa_llm.reasoning.react import ReActReasoning + +warnings.filterwarnings("ignore", category=UserWarning, module="pydantic.main") +logging.getLogger("pydantic").setLevel(logging.ERROR) + +load_dotenv() + +model_params = { + "rng": { + "type": "InputText", + "value": 42, + "label": "Random Seed", + }, + "llm_model": { + "type": "Select", + "value": "gemini/gemini-2.0-flash", + "values": [ + "gemini/gemini-2.0-flash", + "openai/gpt-4o-mini", + "openai/gpt-4o", + "anthropic/claude-haiku-4-5-20251001", + "ollama/llama3.2", + ], + "label": "LLM Model", + }, + "reasoning": ReActReasoning, +} + +model = ClimateNegotiationModel( + reasoning=model_params["reasoning"], + llm_model=model_params["llm_model"]["value"], + rng=model_params["rng"]["value"], +) + + +def PledgeBarChart(model): + """Bar chart of each country's current reduction pledge.""" + update_counter.get() + + countries = [a for a in model.agents if isinstance(a, CountryAgent)] + + fig, ax = plt.subplots(figsize=(8, 4)) + + if not countries or all(a.current_pledge == 0 for a in countries): + ax.set_title("No pledges yet — click Step to begin") + ax.set_ylim(0, 100) + return solara.FigureMatplotlib(fig) + + names = [a.country_name for a in countries] + pledges = [a.current_pledge for a in countries] + colors = ["#27ae60" if a.accepted_treaty else "#2980b9" for a in countries] + + bars = ax.bar(names, pledges, color=colors, edgecolor="white", linewidth=0.8) + ax.axhline(y=30, color="#e67e22", linestyle="--", linewidth=1.4, label="30% target") + ax.axhline(y=50, color="#e74c3c", linestyle="--", linewidth=1.4, label="50% target") + ax.set_ylabel("Reduction Pledge (%)", fontsize=11) + ax.set_title( + f"Country Pledges (green = accepted treaty) — Round {model.steps}", fontsize=12 + ) + ax.set_ylim(0, 100) + ax.legend(loc="upper right", fontsize=9) + + for bar, pledge in zip(bars, pledges): + if pledge > 0: + ax.text( + bar.get_x() + bar.get_width() / 2, + bar.get_height() + 1.2, + f"{pledge:.0f}%", + ha="center", + va="bottom", + fontsize=9, + fontweight="bold", + ) + + plt.tight_layout() + return solara.FigureMatplotlib(fig) + + +@solara.component +def CoalitionStatusPanel(model): + update_counter.get() + + countries = [a for a in model.agents if isinstance(a, CountryAgent)] + id_to_name = {a.unique_id: a.country_name for a in countries} + treaty_count = sum(1 for a in countries if a.accepted_treaty) + treaty_reached = model._treaty_reached() + + solara.Text( + f"Round {model.steps} · " + f"Accepted: {treaty_count}/{len(countries)} · " + f"Treaty: {'YES ✓' if treaty_reached else 'not yet'} · " + f"Proposals: {model.total_proposals} · " + f"Avg pledge: {model._average_pledge():.1f}%" + ) + + rows = [] + for a in sorted(countries, key=lambda x: x.country_name): + coalition = [id_to_name.get(i, str(i)) for i in a.coalition_members] + rows.append( + { + "Country": a.country_name, + "Pledge": f"{a.current_pledge:.1f}%", + "Accepted": "✓" if a.accepted_treaty else "—", + "Coalition": ", ".join(coalition) or "—", + "Proposals": a.proposals_made, + } + ) + + solara.DataFrame(pd.DataFrame(rows)) + + +def PledgeTrajectoriesChart(model): + """Line chart of pledge trajectories over rounds.""" + update_counter.get() + + fig, ax = plt.subplots(figsize=(8, 4)) + + try: + df = model.datacollector.get_agent_vars_dataframe() + except Exception: + ax.set_title("No trajectory data yet") + return solara.FigureMatplotlib(fig) + + if df.empty or "CurrentPledge" not in df.columns: + ax.set_title("No trajectory data yet — run a few steps") + return solara.FigureMatplotlib(fig) + + id_to_name = { + a.unique_id: a.country_name for a in model.agents if isinstance(a, CountryAgent) + } + + if isinstance(df.index, pd.MultiIndex): + pledge_df = df["CurrentPledge"].unstack(level=1) + pledge_df.columns = [id_to_name.get(c, str(c)) for c in pledge_df.columns] + else: + ax.set_title("Run more steps to see trajectories") + return solara.FigureMatplotlib(fig) + + for country in pledge_df.columns: + ax.plot( + pledge_df.index, pledge_df[country], marker="o", label=country, linewidth=2 + ) + + ax.set_xlabel("Round", fontsize=11) + ax.set_ylabel("Reduction Pledge (%)", fontsize=11) + ax.set_title("Pledge Trajectories by Country", fontsize=12) + ax.legend(loc="upper left", fontsize=9) + ax.set_ylim(0, 100) + plt.tight_layout() + return solara.FigureMatplotlib(fig) + + +TotalProposalsPlot = make_plot_component("TotalProposals") +AveragePledgePlot = make_plot_component("AveragePledge") +LargestCoalitionPlot = make_plot_component("LargestCoalitionSize") + +# renderer=None: no spatial grid in this model, so we skip the default space view +page = SolaraViz( + model, + renderer=None, + components=[ + PledgeBarChart, + CoalitionStatusPanel, + PledgeTrajectoriesChart, + TotalProposalsPlot, + AveragePledgePlot, + LargestCoalitionPlot, + ], + model_params=model_params, + name="Climate Negotiation - Mesa-LLM", +) diff --git a/llm/climate_negotiation/climate_negotiation/__init__.py b/llm/climate_negotiation/climate_negotiation/__init__.py new file mode 100644 index 000000000..36315a4c1 --- /dev/null +++ b/llm/climate_negotiation/climate_negotiation/__init__.py @@ -0,0 +1,3 @@ +from . import tools + +__all__ = ["tools"] diff --git a/llm/climate_negotiation/climate_negotiation/agents.py b/llm/climate_negotiation/climate_negotiation/agents.py new file mode 100644 index 000000000..9482d01cf --- /dev/null +++ b/llm/climate_negotiation/climate_negotiation/agents.py @@ -0,0 +1,178 @@ +from mesa_llm.llm_agent import LLMAgent +from mesa_llm.tools.tool_manager import ToolManager + +# One shared ToolManager for all CountryAgents. +# Custom negotiation tools (make_proposal, etc.) are registered into this +# manager by tools.py at import time via @tool(tool_manager=country_tool_manager). +# The global inbuilt tools (speak_to, move_one_step, …) are automatically +# copied into every ToolManager at construction time. +country_tool_manager = ToolManager() + + +def get_negotiation_history(agent, max_messages: int = 6) -> str: + """Extract the most recent negotiation messages from an agent's short-term memory. + + Args: + agent: The CountryAgent whose memory to read. + max_messages: Maximum number of messages to return. + + Returns: + A formatted string of recent messages, oldest first. + """ + messages = [] + + memory_source = None + if hasattr(agent.memory, "short_term_memory"): + memory_source = agent.memory.short_term_memory + elif hasattr(agent.memory, "memory_entries"): + memory_source = agent.memory.memory_entries + + if memory_source: + entries_to_check = min(len(memory_source), max_messages * 2) + for entry in reversed(list(memory_source)[-entries_to_check:]): + if len(messages) >= max_messages: + break + if isinstance(entry.content, dict) and "message" in entry.content: + sender_id = entry.content.get("sender", "Unknown") + msg = entry.content.get("message", "") + try: + sender_agent = next( + a for a in agent.model.agents if a.unique_id == sender_id + ) + sender_name = getattr( + sender_agent, "country_name", f"Agent {sender_id}" + ) + except StopIteration: + sender_name = f"Agent {sender_id}" + messages.append(f" {sender_name}: {msg}") + + messages.reverse() + return "\n".join(messages) if messages else " No messages yet." + + +class CountryAgent(LLMAgent): + """An LLM-powered country agent in a climate negotiation simulation. + + Each agent represents a country with unique economic and environmental + characteristics. Agents communicate via speak_to and four custom + negotiation tools (make_proposal, accept_proposal, form_coalition, + reject_and_counter). vision=-1 means every agent observes all other + agents — modelling a plenary negotiating room. + + Attributes: + country_name: Human-readable country label (e.g. "EU"). + emissions_per_capita: Annual CO₂ emissions per person in tonnes. + gdp_per_capita: GDP per person in USD. + development_status: "developed" or "developing". + current_pledge: Percentage emissions reduction the agent has pledged. + coalition_members: unique_ids of countries in the same coalition. + accepted_treaty: True when the agent has formally accepted a treaty. + proposals_made: Total proposals and counter-proposals made. + proposals_accepted: Total proposals formally accepted. + """ + + def __init__( + self, + model, + reasoning, + llm_model: str, + country_name: str, + system_prompt: str, + emissions_per_capita: float, + gdp_per_capita: float, + development_status: str, + ): + super().__init__( + model=model, + reasoning=reasoning, + llm_model=llm_model, + system_prompt=system_prompt, + vision=-1, + internal_state=[ + f"country:{country_name}", + f"emissions_per_capita_tCO2:{emissions_per_capita}", + f"gdp_per_capita_usd:{gdp_per_capita}", + f"development_status:{development_status}", + ], + ) + self.tool_manager = country_tool_manager + + self.country_name = country_name + self.emissions_per_capita = emissions_per_capita + self.gdp_per_capita = gdp_per_capita + self.development_status = development_status + + self.current_pledge: float = 0.0 + self.coalition_members: list[int] = [] + self.accepted_treaty: bool = False + self.proposals_made: int = 0 + self.proposals_accepted: int = 0 + + def step(self): + observation = self.generate_obs() + negotiation_history = get_negotiation_history(self) + + id_to_name = { + a.unique_id: getattr(a, "country_name", f"Agent {a.unique_id}") + for a in self.model.agents + } + other_status_lines = [] + for a in self.model.agents: + if a is not self and isinstance(a, CountryAgent): + coalition_names = [ + id_to_name.get(i, str(i)) for i in a.coalition_members + ] + other_status_lines.append( + f" {a.country_name} (ID {a.unique_id}): " + f"pledge={float(a.current_pledge):.1f}%, " + f"coalition={coalition_names or 'none'}, " + f"accepted={a.accepted_treaty}" + ) + other_status = "\n".join(other_status_lines) or " No data yet." + + # Build a concise ID→name reference to help smaller LLMs avoid hallucinating IDs. + id_reference = ", ".join( + f"{a.unique_id}={getattr(a, 'country_name', str(a.unique_id))}" + for a in self.model.agents + ) + + prompt = ( + f"NEGOTIATION ROUND {self.model.steps}\n\n" + f"VALID COUNTRY IDs — use ONLY these integers for partner_ids and proposer_id:\n" + f" {id_reference}\n\n" + f"YOUR STATUS:\n" + f" Country: {self.country_name} (your ID: {self.unique_id})\n" + f" Emissions per capita: {self.emissions_per_capita} tCO2/year\n" + f" GDP per capita: ${self.gdp_per_capita:,.0f}\n" + f" Development status: {self.development_status}\n" + f" Current pledge: {self.current_pledge:.1f}% reduction by 2035\n" + f" Your coalition members (their IDs): {self.coalition_members or 'none'}\n" + f" Treaty accepted: {self.accepted_treaty}\n\n" + f"OTHER COUNTRIES AT THE TABLE:\n{other_status}\n\n" + f"RECENT NEGOTIATIONS:\n{negotiation_history}\n\n" + f"INSTRUCTIONS:\n" + f"You are {self.country_name}'s chief climate negotiator at the Global " + f"Climate Summit. Choose ONE action this round:\n" + f" speak_to send a targeted diplomatic message to one or more " + f"countries (use their integer IDs from the VALID COUNTRY IDs list above)\n" + f" make_proposal formally propose a reduction target to all parties\n" + f" accept_proposal accept another country's proposal if it is acceptable\n" + f" form_coalition build an alliance with countries that share your stance\n" + f" reject_and_counter reject an unreasonable proposal with a counter-offer\n\n" + f"A treaty is reached when at least 2/3 of countries have accepted a common " + f"proposal. Balance your national economic interests with global climate " + f"responsibility. Be strategic: build alliances before making proposals." + ) + + plan = self.reasoning.plan( + prompt=prompt, + obs=observation, + selected_tools=[ + "speak_to", + "make_proposal", + "accept_proposal", + "form_coalition", + "reject_and_counter", + ], + ) + self.apply_plan(plan) diff --git a/llm/climate_negotiation/climate_negotiation/model.py b/llm/climate_negotiation/climate_negotiation/model.py new file mode 100644 index 000000000..7ea80dbdc --- /dev/null +++ b/llm/climate_negotiation/climate_negotiation/model.py @@ -0,0 +1,251 @@ +import logging +import os + +from mesa.datacollection import DataCollector +from mesa.model import Model +from mesa_llm.reasoning.react import ReActReasoning +from mesa_llm.reasoning.reasoning import Reasoning +from rich import print as rprint + +from .agents import CountryAgent + +_log_path = os.environ.get("CLIMATE_LOG_FILE", "climate_negotiation.log") +_file_handler = logging.FileHandler(_log_path, mode="w", encoding="utf-8") +_file_handler.setFormatter( + logging.Formatter("%(asctime)s | %(levelname)s | %(message)s") +) + +sim_logger = logging.getLogger("climate_negotiation") +sim_logger.setLevel(logging.DEBUG) +sim_logger.addHandler(_file_handler) +sim_logger.propagate = False + +COUNTRIES: list[dict] = [ + { + "country_name": "USA", + "emissions_per_capita": 14.0, + "gdp_per_capita": 76_000, + "development_status": "developed", + "system_prompt": ( + "You are the USA's chief climate negotiator. " + "The USA is the largest historical emitter with a $76k GDP per capita. " + "You support climate action but insist all major economies — especially " + "China and India — commit to comparable reductions. You prefer " + "market-based mechanisms and oppose unilateral economic disadvantages." + ), + }, + { + "country_name": "EU", + "emissions_per_capita": 6.0, + "gdp_per_capita": 37_000, + "development_status": "developed", + "system_prompt": ( + "You are the EU's chief climate negotiator. " + "The EU targets a 55% reduction by 2030 (Fit for 55) and leads global climate ambition. " + "You push for legally binding targets for all developed nations and " + "substantial financial support for developing nations. " + "Actively build coalitions with high-ambition partners." + ), + }, + { + "country_name": "China", + "emissions_per_capita": 8.0, + "gdp_per_capita": 12_700, + "development_status": "developing", + "system_prompt": ( + "You are China's chief climate negotiator. " + "China is the world's largest current emitter but still a developing " + "economy at $12.7k GDP per capita. You argue that developed nations caused " + "most historical emissions and must bear greater financial burdens. " + "You support long-term goals but resist targets that constrain development " + "without adequate technology transfer and green finance from rich countries." + ), + }, + { + "country_name": "India", + "emissions_per_capita": 2.0, + "gdp_per_capita": 2_500, + "development_status": "developing", + "system_prompt": ( + "You are India's chief climate negotiator. " + "India has very low per-capita emissions (2.0 tCO2) and a $2.5k GDP. " + "You firmly defend common but differentiated responsibilities. " + "India needs fossil fuel access to develop. You support renewables " + "but reject targets that deny energy access to 1.4 billion people. " + "Form strong coalitions with other developing nations." + ), + }, + { + "country_name": "Brazil", + "emissions_per_capita": 2.8, + "gdp_per_capita": 10_400, + "development_status": "developing", + "system_prompt": ( + "You are Brazil's chief climate negotiator. " + "Brazil's emissions are driven by deforestation, not fossil fuels. " + "You argue that forest conservation and biodiversity protection must " + "count formally as climate action. You seek payment for ecosystem " + "services and will only accept ambitious targets if forest credits " + "are explicitly recognised in the treaty text." + ), + }, + { + "country_name": "Russia", + "emissions_per_capita": 12.5, + "gdp_per_capita": 15_000, + "development_status": "developed", + "system_prompt": ( + "You are Russia's chief climate negotiator. " + "Russia is a major fossil fuel exporter with 12.5 tCO2 per capita. " + "You accept climate science but argue for gradual, technology-led " + "transitions. You resist aggressive near-term targets that threaten " + "your fossil fuel revenues. You may agree to modest pledges if given " + "long timelines and substantial technology support." + ), + }, +] + + +class ClimateNegotiationModel(Model): + """Multi-agent LLM climate treaty negotiation model. + + Six country agents negotiate a shared emissions-reduction target over + multiple rounds. The model tracks proposal counts, acceptance rates, + coalition formation, and whether a treaty consensus has emerged. + + Mesa-LLM features demonstrated + - STLTMemory : short-term stores recent proposals; long-term consolidates + committed positions across rounds. + - ReActReasoning: agents reason then act within each simulation step. + - speak_to : inbuilt diplomatic messaging tool between agents. + - Custom tools : make_proposal, accept_proposal, form_coalition, + reject_and_counter. + - vision=-1 : agents observe all other parties (full-room awareness, + no spatial grid needed). + + Args: + reasoning: Reasoning strategy class (e.g. ReActReasoning, CoTReasoning). + llm_model: LiteLLM model string, e.g. ``"gemini/gemini-2.0-flash"`` + or ``"openai/gpt-4o-mini"``. + rng: Random seed for reproducibility. + """ + + def __init__( + self, + reasoning: type[Reasoning] = ReActReasoning, + llm_model: str = "gemini/gemini-2.0-flash", + rng: int = 42, + ): + super().__init__(rng=rng) + + self.total_proposals: int = 0 + self.total_acceptances: int = 0 + + for config in COUNTRIES: + CountryAgent( + model=self, + reasoning=reasoning, + llm_model=llm_model, + **config, + ) + + self.datacollector = DataCollector( + model_reporters={ + "TotalProposals": lambda m: m.total_proposals, + "TotalAcceptances": lambda m: m.total_acceptances, + "TreatyReached": lambda m: int(m._treaty_reached()), + "AveragePledge": lambda m: m._average_pledge(), + "LargestCoalitionSize": lambda m: m._largest_coalition(), + }, + agent_reporters={ + "CurrentPledge": "current_pledge", + "AcceptedTreaty": lambda a: int(a.accepted_treaty), + "CoalitionSize": lambda a: len(a.coalition_members), + "ProposalsMade": "proposals_made", + }, + ) + + def _treaty_reached(self) -> bool: + """Return True when at least 2/3 of countries have accepted.""" + agents = list(self.agents) + if not agents: + return False + accepted = sum(1 for a in agents if getattr(a, "accepted_treaty", False)) + return accepted >= len(agents) * 2 / 3 + + def _average_pledge(self) -> float: + """Mean reduction pledge across all countries (percent).""" + agents = list(self.agents) + if not agents: + return 0.0 + return sum(getattr(a, "current_pledge", 0.0) for a in agents) / len(agents) + + def _largest_coalition(self) -> int: + """Size (including self) of the largest active coalition.""" + if not self.agents: + return 0 + return max(len(getattr(a, "coalition_members", [])) + 1 for a in self.agents) + + def step(self): + self.datacollector.collect(self) + round_num = self.steps + rprint(f"\n[bold cyan]- Climate Summit Round {round_num} [/bold cyan]") + sim_logger.info("=" * 60) + sim_logger.info(f"ROUND {round_num} START") + sim_logger.info("=" * 60) + + # Log each agent's state at the start of the round + for a in self.agents: + sim_logger.debug( + f" Agent {a.unique_id} ({getattr(a, 'country_name', '?')}): " + f"pledge={getattr(a, 'current_pledge', 0):.1f}% " + f"accepted={getattr(a, 'accepted_treaty', False)} " + f"coalition={getattr(a, 'coalition_members', [])}" + ) + + self.agents.shuffle_do("step") + + avg = self._average_pledge() + treaty = self._treaty_reached() + rprint( + f"[bold green] End of round {round_num}: " + f"avg_pledge={avg:.1f}% " + f"total_proposals={self.total_proposals} " + f"treaty_reached={treaty}[/bold green]" + ) + sim_logger.info( + f"ROUND {round_num} END | " + f"avg_pledge={avg:.1f}% " + f"total_proposals={self.total_proposals} " + f"total_acceptances={self.total_acceptances} " + f"treaty_reached={treaty}" + ) + # Log final state of each agent after the round + for a in self.agents: + sim_logger.info( + f" [{getattr(a, 'country_name', a.unique_id)}] " + f"pledge={getattr(a, 'current_pledge', 0):.1f}% " + f"accepted={getattr(a, 'accepted_treaty', False)} " + f"proposals_made={getattr(a, 'proposals_made', 0)} " + f"coalition={getattr(a, 'coalition_members', [])}" + ) + + +if __name__ == "__main__": + """ + Run without visualization: + cd examples/climate_negotiation + python -m climate_negotiation.model + """ + from mesa_llm.reasoning.react import ReActReasoning + + m = ClimateNegotiationModel( + reasoning=ReActReasoning, + llm_model="gemini/gemini-2.0-flash", + rng=42, + ) + for _ in range(5): + m.step() + if m._treaty_reached(): + rprint("[bold yellow] *** Treaty consensus reached! ***[/bold yellow]") + break diff --git a/llm/climate_negotiation/climate_negotiation/tools.py b/llm/climate_negotiation/climate_negotiation/tools.py new file mode 100644 index 000000000..8b7561171 --- /dev/null +++ b/llm/climate_negotiation/climate_negotiation/tools.py @@ -0,0 +1,240 @@ +"""Custom negotiation tools for the Climate Negotiation example. + +These four tools are registered into `country_tool_manager` (from agents.py) +via the @tool(tool_manager=...) decorator. They are not added to the global +tool registry, so only CountryAgents can call them. + +The inbuilt `speak_to` tool (global registry) is also available to every +CountryAgent because ToolManager copies global tools at construction time. +""" + +import logging +from typing import TYPE_CHECKING + +from mesa_llm.tools.tool_decorator import tool + +from .agents import country_tool_manager + +if TYPE_CHECKING: + from mesa_llm.llm_agent import LLMAgent + +sim_logger = logging.getLogger("climate_negotiation") + + +@tool(tool_manager=country_tool_manager) +def make_proposal( + agent: "LLMAgent", + target_reduction_percent: float, + target_year: int, + justification: str, +) -> str: + """Formally propose an emissions reduction target to all negotiating parties. + + Args: + target_reduction_percent: Proposed percentage emissions reduction (0-100). + target_year: The year by which the reduction should be achieved (e.g. 2035). + justification: The diplomatic argument or reasoning behind this proposal. + agent: Provided automatically. + + Returns: + Confirmation string describing the proposal that was broadcast. + """ + target_reduction_percent = float(target_reduction_percent or 25.0) + target_year = int(target_year or 2035) + agent.current_pledge = target_reduction_percent + agent.proposals_made += 1 + + broadcast = ( + f"[FORMAL PROPOSAL] {agent.country_name} proposes a " + f"{target_reduction_percent:.1f}% emissions reduction by {target_year}. " + f"Justification: {justification}" + ) + all_others = [a for a in agent.model.agents if a is not agent] + agent.send_message(broadcast, all_others) + agent.model.total_proposals += 1 + + result = ( + f"Proposal broadcast to {len(all_others)} parties: " + f"{target_reduction_percent:.1f}% reduction by {target_year}." + ) + sim_logger.info( + f"TOOL make_proposal | {agent.country_name} | " + f"{target_reduction_percent:.1f}% by {target_year} | {broadcast}" + ) + return result + + +@tool(tool_manager=country_tool_manager) +def accept_proposal( + agent: "LLMAgent", + proposer_id: int, + agreed_reduction_percent: float, + acceptance_message: str, +) -> str: + """Formally accept another country's emissions reduction proposal. + + Args: + proposer_id: Unique ID of the country whose proposal you are accepting. + agreed_reduction_percent: The reduction percentage you commit to. + acceptance_message: A diplomatic statement explaining your acceptance. + agent: Provided automatically. + + Returns: + Confirmation of the acceptance and updated pledge. + """ + agreed_reduction_percent = float(agreed_reduction_percent or 25.0) + proposer_id = int(proposer_id or 0) + + valid_ids = {a.unique_id for a in agent.model.agents} + if proposer_id not in valid_ids: + sim_logger.warning( + f"TOOL accept_proposal | {agent.country_name} tried to accept unknown " + f"agent {proposer_id} (valid IDs: {sorted(valid_ids)}) - ignored" + ) + return ( + f"Error: agent {proposer_id} does not exist. " + f"Valid country IDs are: {sorted(valid_ids)}. " + "Use accept_proposal again with a valid proposer_id." + ) + + agent.accepted_treaty = True + agent.current_pledge = max(float(agent.current_pledge), agreed_reduction_percent) + agent.proposals_accepted += 1 + agent.model.total_acceptances += 1 + + proposer = next(a for a in agent.model.agents if a.unique_id == proposer_id) + agent.send_message( + f"[ACCEPTANCE] {agent.country_name} formally accepts the proposal. " + f"We commit to {agreed_reduction_percent:.1f}% reduction. " + f"{acceptance_message}", + [proposer], + ) + + result = ( + f"{agent.country_name} accepted proposal from agent {proposer_id}. " + f"Pledged {agreed_reduction_percent:.1f}%." + ) + sim_logger.info( + f"TOOL accept_proposal | {agent.country_name} accepted agent {proposer_id} " + f"| pledge={agreed_reduction_percent:.1f}%" + ) + return result + + +@tool(tool_manager=country_tool_manager) +def form_coalition( + agent: "LLMAgent", + partner_ids: list[int], + coalition_name: str, +) -> str: + """Form or join a coalition with other countries to strengthen a shared position. + + Args: + partner_ids: List of unique IDs of countries to invite into the coalition. + coalition_name: Short descriptive label (e.g. 'High Ambition Coalition'). + agent: Provided automatically. + + Returns: + Confirmation of coalition formation and the partners notified. + """ + if isinstance(partner_ids, str): + import json + + try: + partner_ids = json.loads(partner_ids) + except (json.JSONDecodeError, ValueError): + partner_ids = [ + int(x.strip()) for x in partner_ids.strip("[]").split(",") if x.strip() + ] + partner_ids = [int(pid) for pid in (partner_ids or [])] + + # Filter out hallucinated IDs - only keep IDs that map to real agents. + valid_ids = {a.unique_id for a in agent.model.agents} + phantom_ids = [pid for pid in partner_ids if pid not in valid_ids] + partner_ids = [pid for pid in partner_ids if pid in valid_ids] + if phantom_ids: + sim_logger.warning( + f"TOOL form_coalition | {agent.country_name} passed unknown agent IDs " + f"{phantom_ids} - dropped (valid IDs: {sorted(valid_ids)})" + ) + + for pid in partner_ids: + if pid not in agent.coalition_members: + agent.coalition_members.append(pid) + + partner_agents = [a for a in agent.model.agents if a.unique_id in partner_ids] + + # Mutually add the initiating agent to each partner's coalition list + for partner in partner_agents: + if ( + hasattr(partner, "coalition_members") + and agent.unique_id not in partner.coalition_members + ): + partner.coalition_members.append(agent.unique_id) + + member_names = [ + getattr(a, "country_name", str(a.unique_id)) for a in partner_agents + ] + [agent.country_name] + + agent.send_message( + f"[COALITION] {agent.country_name} proposes the '{coalition_name}'. " + f"Current members: {member_names}", + partner_agents, + ) + + result = f"Coalition '{coalition_name}' formed. Members: {member_names}." + sim_logger.info( + f"TOOL form_coalition | {agent.country_name} | " + f"coalition='{coalition_name}' | members={member_names}" + ) + return result + + +@tool(tool_manager=country_tool_manager) +def reject_and_counter( + agent: "LLMAgent", + proposer_id: int, + counter_reduction_percent: float, + reason: str, +) -> str: + """Reject another country's proposal and broadcast a counter-proposal to all parties. + + Args: + proposer_id: Unique ID of the country whose proposal you are rejecting. + counter_reduction_percent: Your alternative reduction percentage. + reason: Explanation of why you reject the original and what you offer instead. + agent: Provided automatically. + + Returns: + Confirmation of the rejection and counter-proposal broadcast. + """ + counter_reduction_percent = float(counter_reduction_percent or 20.0) + proposer_id = int(proposer_id or 0) + proposer = next((a for a in agent.model.agents if a.unique_id == proposer_id), None) + proposer_name = ( + getattr(proposer, "country_name", str(proposer_id)) + if proposer + else str(proposer_id) + ) + + counter_msg = ( + f"[COUNTER-PROPOSAL] {agent.country_name} cannot accept {proposer_name}'s proposal. " + f"Counter-offer: {counter_reduction_percent:.1f}% reduction. " + f"Reason: {reason}" + ) + all_others = [a for a in agent.model.agents if a is not agent] + agent.send_message(counter_msg, all_others) + + agent.current_pledge = counter_reduction_percent + agent.proposals_made += 1 + agent.model.total_proposals += 1 + + result = ( + f"{agent.country_name} rejected {proposer_name}'s proposal. " + f"Counter-proposal ({counter_reduction_percent:.1f}%) broadcast to all." + ) + sim_logger.info( + f"TOOL reject_and_counter | {agent.country_name} rejected {proposer_name} " + f"| counter={counter_reduction_percent:.1f}% | reason={reason}" + ) + return result