From 68ff078c0ad1331ae11a3dea8c9e5313b5b5dac3 Mon Sep 17 00:00:00 2001 From: BhoomiAgrawal12 Date: Wed, 25 Mar 2026 23:13:03 +0530 Subject: [PATCH 1/4] example: Climate negotiation model --- examples/climate_negotiation/README.md | 121 ++++++++++ examples/climate_negotiation/app.py | 178 +++++++++++++++ .../climate_negotiation/__init__.py | 1 + .../climate_negotiation/agents.py | 168 ++++++++++++++ .../climate_negotiation/model.py | 215 ++++++++++++++++++ .../climate_negotiation/tools.py | 186 +++++++++++++++ 6 files changed, 869 insertions(+) create mode 100644 examples/climate_negotiation/README.md create mode 100644 examples/climate_negotiation/app.py create mode 100644 examples/climate_negotiation/climate_negotiation/__init__.py create mode 100644 examples/climate_negotiation/climate_negotiation/agents.py create mode 100644 examples/climate_negotiation/climate_negotiation/model.py create mode 100644 examples/climate_negotiation/climate_negotiation/tools.py diff --git a/examples/climate_negotiation/README.md b/examples/climate_negotiation/README.md new file mode 100644 index 000000000..909dfa8d5 --- /dev/null +++ b/examples/climate_negotiation/README.md @@ -0,0 +1,121 @@ +# 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 — modelling a plenary negotiating room with no spatial grid | + +## Countries and Their Profiles + +| Country | Emissions (tCO₂/capita) | GDP/capita | Stance | +|---------|------------------------|------------|--------| +| USA | 14.5 | $65,000 | Supports action; insists developing nations match commitments | +| EU | 6.4 | $35,000 | High ambition; pushes binding targets and developing-nation finance | +| China | 7.1 | $12,000 | Argues for differentiated responsibility; needs tech transfer | +| India | 1.9 | $2,200 | Energy access first; very low historical per-capita emissions | +| Brazil | 2.2 | $8,600 | Forest conservation must count; wants ecosystem payment | +| Russia | 11.4 | $12,000 | Gradual transition; fossil fuel dependent | + +## Negotiation Tools + +``` +speak_to(listener_ids, message) - diplomatic message to specific parties +make_proposal(reduction%, year, reason) - formal proposal broadcast to all +accept_proposal(proposer_id, %, message) - formal acceptance of a proposal +form_coalition(partner_ids, name) - build an alliance +reject_and_counter(proposer_id, %, reason)- reject + counter-propose +``` + +A **treaty is reached** when at least 2/3 of countries have formally accepted +a common proposal. + +## Setup + +### 1. Install dependencies + +```bash +pip install mesa-llm mesa solara python-dotenv rich +``` + +### 2. Set your API key + +Create a `.env` file in `examples/climate_negotiation/`: + +``` +# For Gemini (free tier available) +GEMINI_API_KEY=your_key_here + +# OR for OpenAI +OPENAI_API_KEY=your_key_here + +# OR for a local model via Ollama +OLLAMA_API_BASE=http://localhost:11434 +``` + +### 3. Run with Solara visualization + +```bash +cd examples/climate_negotiation +solara run app.py +``` + +### 4. Run headless (terminal only) + +```bash +cd examples/climate_negotiation +python -m climate_negotiation.model +``` + +## File Structure + +``` +climate_negotiation/ //example root +├── app.py //Solara visualization 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 + country configs +``` + +## What to Watch For + +- **Round 1–2**: Agents send diplomatic messages, probe positions, and form initial coalitions +- **Round 3–4**: Formal proposals emerge; developing nations counter with differentiated targets +- **Round 5+**: Coalitions pressure outliers; some countries accept, others counter-propose +- **Treaty achieved**: Green bars in the visualization indicate accepted countries + +## Extending This Example + +**Try different LLMs**: Change `llm_model` in `app.py`. Gemini 2.0 Flash is fast and free; +GPT-4o produces richer diplomatic language. + +**Add more countries**: Add a new dict to the `COUNTRIES` list in `model.py`. + +**Change the treaty threshold**: Edit `_treaty_reached()` in `model.py` +(currently requires 2/3 majority). + +**Use CoTReasoning instead**: Replace `ReActReasoning` with `CoTReasoning` in `app.py` +to see step-by-step chain-of-thought reasoning in agent decisions. + +**Swap memory type**: Replace `STLTMemory` (the default) with `EpisodicMemory` for +importance-scored memory retrieval — useful to see which proposals agents +consider most significant. + +## Related Work + +- Duffuant, G. & Weisbuch, G. (2002). *Bounded confidence and social networks.* + — The `deffuant_weisbuch` example shows opinion dynamics without LLM reasoning. + Compare its convergence behaviour with this model's LLM-driven negotiation. +- Park, J. S. et al. (2023). *Generative Agents: Interactive Simulacra of Human Behavior.* + — Inspired the EpisodicMemory system used in Mesa-LLM. diff --git a/examples/climate_negotiation/app.py b/examples/climate_negotiation/app.py new file mode 100644 index 000000000..d68876d8e --- /dev/null +++ b/examples/climate_negotiation/app.py @@ -0,0 +1,178 @@ +import logging +import warnings + +import matplotlib.pyplot as plt +import pandas as pd +import solara +from dotenv import load_dotenv +from mesa.visualization import SolaraViz, make_plot_component +from mesa.visualization.utils import update_counter + +from climate_negotiation.agents import CountryAgent +from climate_negotiation.model import ClimateNegotiationModel +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-3-5-haiku-20241022", + "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/examples/climate_negotiation/climate_negotiation/__init__.py b/examples/climate_negotiation/climate_negotiation/__init__.py new file mode 100644 index 000000000..fa947c50b --- /dev/null +++ b/examples/climate_negotiation/climate_negotiation/__init__.py @@ -0,0 +1 @@ +from . import tools diff --git a/examples/climate_negotiation/climate_negotiation/agents.py b/examples/climate_negotiation/climate_negotiation/agents.py new file mode 100644 index 000000000..b108c0413 --- /dev/null +++ b/examples/climate_negotiation/climate_negotiation/agents.py @@ -0,0 +1,168 @@ +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." + + prompt = ( + f"NEGOTIATION ROUND {self.model.steps}\n\n" + f"YOUR STATUS:\n" + f" Country: {self.country_name}\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)\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/examples/climate_negotiation/climate_negotiation/model.py b/examples/climate_negotiation/climate_negotiation/model.py new file mode 100644 index 000000000..f0bba685b --- /dev/null +++ b/examples/climate_negotiation/climate_negotiation/model.py @@ -0,0 +1,215 @@ +from mesa.datacollection import DataCollector +from mesa.model import Model +from rich import print as rprint + +from .agents import CountryAgent +from mesa_llm.reasoning.reasoning import Reasoning + +COUNTRIES: list[dict] = [ + { + "country_name": "USA", + "emissions_per_capita": 14.5, + "gdp_per_capita": 65_000, + "development_status": "developed", + "system_prompt": ( + "You are the USA's chief climate negotiator. " + "The USA is the largest historical emitter with a $65k 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.4, + "gdp_per_capita": 35_000, + "development_status": "developed", + "system_prompt": ( + "You are the EU's chief climate negotiator. " + "The EU targets a 55% reduction by 2030 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": 7.1, + "gdp_per_capita": 12_000, + "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 $12k 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": 1.9, + "gdp_per_capita": 2_200, + "development_status": "developing", + "system_prompt": ( + "You are India's chief climate negotiator. " + "India has very low per-capita emissions (1.9 tCO2) and a $2.2k 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.2, + "gdp_per_capita": 8_600, + "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": 11.4, + "gdp_per_capita": 12_000, + "development_status": "developed", + "system_prompt": ( + "You are Russia's chief climate negotiator. " + "Russia is a major fossil fuel exporter with 11.4 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], + llm_model: str = "gemini/gemini-2.0-flash", + rng: int = 42, + ): + super().__init__(rng=rng) + + # Global negotiation counters - updated by the tool functions + self.total_proposals: int = 0 + self.total_acceptances: int = 0 + + # Create one CountryAgent per country profile + 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) + rprint( + f"\n[bold cyan]- Climate Summit Round {self.steps} " + f"[/bold cyan]" + ) + self.agents.shuffle_do("step") + + avg = self._average_pledge() + treaty = self._treaty_reached() + rprint( + f"[bold green] End of round {self.steps}: " + f"avg_pledge={avg:.1f}% " + f"total_proposals={self.total_proposals} " + f"treaty_reached={treaty}[/bold green]" + ) + + +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/examples/climate_negotiation/climate_negotiation/tools.py b/examples/climate_negotiation/climate_negotiation/tools.py new file mode 100644 index 000000000..8ce7e0b92 --- /dev/null +++ b/examples/climate_negotiation/climate_negotiation/tools.py @@ -0,0 +1,186 @@ +"""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. +""" + +from typing import TYPE_CHECKING + +from .agents import country_tool_manager +from mesa_llm.tools.tool_decorator import tool + +if TYPE_CHECKING: + from mesa_llm.llm_agent import LLMAgent + + +@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 + + return ( + f"Proposal broadcast to {len(all_others)} parties: " + f"{target_reduction_percent:.1f}% reduction by {target_year}." + ) + + +@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) + 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), None + ) + if proposer: + 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], + ) + + return ( + f"{agent.country_name} accepted proposal from agent {proposer_id}. " + f"Pledged {agreed_reduction_percent:.1f}%." + ) + + +@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. + """ + for pid in (partner_ids or []): + 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 or [])] + + # 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, + ) + + return f"Coalition '{coalition_name}' formed. Members: {member_names}." + + +@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 + + return ( + f"{agent.country_name} rejected {proposer_name}'s proposal. " + f"Counter-proposal ({counter_reduction_percent:.1f}%) broadcast to all." + ) From fd740c9e78456cbf3dacfa38cd207d3f0d06531c Mon Sep 17 00:00:00 2001 From: BhoomiAgrawal12 Date: Mon, 30 Mar 2026 12:33:24 +0530 Subject: [PATCH 2/4] fix: move to llm/ and protection against hallucinated IDs --- examples/climate_negotiation/README.md | 121 ----------- llm/climate_negotiation/README.md | 194 ++++++++++++++++++ {examples => llm}/climate_negotiation/app.py | 4 +- .../climate_negotiation/__init__.py | 0 .../climate_negotiation/agents.py | 12 +- .../climate_negotiation/model.py | 84 ++++++-- .../climate_negotiation/tools.py | 83 ++++++-- 7 files changed, 335 insertions(+), 163 deletions(-) delete mode 100644 examples/climate_negotiation/README.md create mode 100644 llm/climate_negotiation/README.md rename {examples => llm}/climate_negotiation/app.py (98%) rename {examples => llm}/climate_negotiation/climate_negotiation/__init__.py (100%) rename {examples => llm}/climate_negotiation/climate_negotiation/agents.py (92%) rename {examples => llm}/climate_negotiation/climate_negotiation/model.py (72%) rename {examples => llm}/climate_negotiation/climate_negotiation/tools.py (69%) diff --git a/examples/climate_negotiation/README.md b/examples/climate_negotiation/README.md deleted file mode 100644 index 909dfa8d5..000000000 --- a/examples/climate_negotiation/README.md +++ /dev/null @@ -1,121 +0,0 @@ -# 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 — modelling a plenary negotiating room with no spatial grid | - -## Countries and Their Profiles - -| Country | Emissions (tCO₂/capita) | GDP/capita | Stance | -|---------|------------------------|------------|--------| -| USA | 14.5 | $65,000 | Supports action; insists developing nations match commitments | -| EU | 6.4 | $35,000 | High ambition; pushes binding targets and developing-nation finance | -| China | 7.1 | $12,000 | Argues for differentiated responsibility; needs tech transfer | -| India | 1.9 | $2,200 | Energy access first; very low historical per-capita emissions | -| Brazil | 2.2 | $8,600 | Forest conservation must count; wants ecosystem payment | -| Russia | 11.4 | $12,000 | Gradual transition; fossil fuel dependent | - -## Negotiation Tools - -``` -speak_to(listener_ids, message) - diplomatic message to specific parties -make_proposal(reduction%, year, reason) - formal proposal broadcast to all -accept_proposal(proposer_id, %, message) - formal acceptance of a proposal -form_coalition(partner_ids, name) - build an alliance -reject_and_counter(proposer_id, %, reason)- reject + counter-propose -``` - -A **treaty is reached** when at least 2/3 of countries have formally accepted -a common proposal. - -## Setup - -### 1. Install dependencies - -```bash -pip install mesa-llm mesa solara python-dotenv rich -``` - -### 2. Set your API key - -Create a `.env` file in `examples/climate_negotiation/`: - -``` -# For Gemini (free tier available) -GEMINI_API_KEY=your_key_here - -# OR for OpenAI -OPENAI_API_KEY=your_key_here - -# OR for a local model via Ollama -OLLAMA_API_BASE=http://localhost:11434 -``` - -### 3. Run with Solara visualization - -```bash -cd examples/climate_negotiation -solara run app.py -``` - -### 4. Run headless (terminal only) - -```bash -cd examples/climate_negotiation -python -m climate_negotiation.model -``` - -## File Structure - -``` -climate_negotiation/ //example root -├── app.py //Solara visualization 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 + country configs -``` - -## What to Watch For - -- **Round 1–2**: Agents send diplomatic messages, probe positions, and form initial coalitions -- **Round 3–4**: Formal proposals emerge; developing nations counter with differentiated targets -- **Round 5+**: Coalitions pressure outliers; some countries accept, others counter-propose -- **Treaty achieved**: Green bars in the visualization indicate accepted countries - -## Extending This Example - -**Try different LLMs**: Change `llm_model` in `app.py`. Gemini 2.0 Flash is fast and free; -GPT-4o produces richer diplomatic language. - -**Add more countries**: Add a new dict to the `COUNTRIES` list in `model.py`. - -**Change the treaty threshold**: Edit `_treaty_reached()` in `model.py` -(currently requires 2/3 majority). - -**Use CoTReasoning instead**: Replace `ReActReasoning` with `CoTReasoning` in `app.py` -to see step-by-step chain-of-thought reasoning in agent decisions. - -**Swap memory type**: Replace `STLTMemory` (the default) with `EpisodicMemory` for -importance-scored memory retrieval — useful to see which proposals agents -consider most significant. - -## Related Work - -- Duffuant, G. & Weisbuch, G. (2002). *Bounded confidence and social networks.* - — The `deffuant_weisbuch` example shows opinion dynamics without LLM reasoning. - Compare its convergence behaviour with this model's LLM-driven negotiation. -- Park, J. S. et al. (2023). *Generative Agents: Interactive Simulacra of Human Behavior.* - — Inspired the EpisodicMemory system used in Mesa-LLM. 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/examples/climate_negotiation/app.py b/llm/climate_negotiation/app.py similarity index 98% rename from examples/climate_negotiation/app.py rename to llm/climate_negotiation/app.py index d68876d8e..6436dedd9 100644 --- a/examples/climate_negotiation/app.py +++ b/llm/climate_negotiation/app.py @@ -30,7 +30,7 @@ "gemini/gemini-2.0-flash", "openai/gpt-4o-mini", "openai/gpt-4o", - "anthropic/claude-3-5-haiku-20241022", + "anthropic/claude-haiku-4-5-20251001", "ollama/llama3.2", ], "label": "LLM Model", @@ -174,5 +174,5 @@ def PledgeTrajectoriesChart(model): LargestCoalitionPlot, ], model_params=model_params, - name="Climate Negotiation — Mesa-LLM", + name="Climate Negotiation - Mesa-LLM", ) diff --git a/examples/climate_negotiation/climate_negotiation/__init__.py b/llm/climate_negotiation/climate_negotiation/__init__.py similarity index 100% rename from examples/climate_negotiation/climate_negotiation/__init__.py rename to llm/climate_negotiation/climate_negotiation/__init__.py diff --git a/examples/climate_negotiation/climate_negotiation/agents.py b/llm/climate_negotiation/climate_negotiation/agents.py similarity index 92% rename from examples/climate_negotiation/climate_negotiation/agents.py rename to llm/climate_negotiation/climate_negotiation/agents.py index b108c0413..e75f7bc52 100644 --- a/examples/climate_negotiation/climate_negotiation/agents.py +++ b/llm/climate_negotiation/climate_negotiation/agents.py @@ -128,10 +128,18 @@ def step(self): ) 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}\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" @@ -144,7 +152,7 @@ def step(self): 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)\n" + 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" diff --git a/examples/climate_negotiation/climate_negotiation/model.py b/llm/climate_negotiation/climate_negotiation/model.py similarity index 72% rename from examples/climate_negotiation/climate_negotiation/model.py rename to llm/climate_negotiation/climate_negotiation/model.py index f0bba685b..88e88d7a6 100644 --- a/examples/climate_negotiation/climate_negotiation/model.py +++ b/llm/climate_negotiation/climate_negotiation/model.py @@ -1,19 +1,31 @@ +import logging +import os from mesa.datacollection import DataCollector from mesa.model import Model from rich import print as rprint from .agents import CountryAgent +from mesa_llm.reasoning.react import ReActReasoning from mesa_llm.reasoning.reasoning import Reasoning +_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.5, - "gdp_per_capita": 65_000, + "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 $65k GDP per capita. " + "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." @@ -21,12 +33,12 @@ }, { "country_name": "EU", - "emissions_per_capita": 6.4, - "gdp_per_capita": 35_000, + "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 and leads global climate ambition. " + "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." @@ -34,13 +46,13 @@ }, { "country_name": "China", - "emissions_per_capita": 7.1, - "gdp_per_capita": 12_000, + "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 $12k GDP per capita. You argue that developed nations caused " + "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." @@ -48,12 +60,12 @@ }, { "country_name": "India", - "emissions_per_capita": 1.9, - "gdp_per_capita": 2_200, + "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 (1.9 tCO2) and a $2.2k GDP. " + "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. " @@ -62,8 +74,8 @@ }, { "country_name": "Brazil", - "emissions_per_capita": 2.2, - "gdp_per_capita": 8_600, + "emissions_per_capita": 2.8, + "gdp_per_capita": 10_400, "development_status": "developing", "system_prompt": ( "You are Brazil's chief climate negotiator. " @@ -76,12 +88,12 @@ }, { "country_name": "Russia", - "emissions_per_capita": 11.4, - "gdp_per_capita": 12_000, + "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 11.4 tCO2 per capita. " + "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 " @@ -117,17 +129,15 @@ class ClimateNegotiationModel(Model): def __init__( self, - reasoning: type[Reasoning], + reasoning: type[Reasoning] = ReActReasoning, llm_model: str = "gemini/gemini-2.0-flash", rng: int = 42, ): super().__init__(rng=rng) - # Global negotiation counters - updated by the tool functions self.total_proposals: int = 0 self.total_acceptances: int = 0 - # Create one CountryAgent per country profile for config in COUNTRIES: CountryAgent( model=self, @@ -179,20 +189,50 @@ def _largest_coalition(self) -> int: def step(self): self.datacollector.collect(self) + round_num = self.steps rprint( - f"\n[bold cyan]- Climate Summit Round {self.steps} " + f"\n[bold cyan]- Climate Summit Round {round_num} " f"[/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 {self.steps}: " + 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__": diff --git a/examples/climate_negotiation/climate_negotiation/tools.py b/llm/climate_negotiation/climate_negotiation/tools.py similarity index 69% rename from examples/climate_negotiation/climate_negotiation/tools.py rename to llm/climate_negotiation/climate_negotiation/tools.py index 8ce7e0b92..421efff1d 100644 --- a/examples/climate_negotiation/climate_negotiation/tools.py +++ b/llm/climate_negotiation/climate_negotiation/tools.py @@ -1,13 +1,14 @@ """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 +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 .agents import country_tool_manager @@ -16,6 +17,8 @@ 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( @@ -49,10 +52,15 @@ def make_proposal( agent.send_message(broadcast, all_others) agent.model.total_proposals += 1 - return ( + 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) @@ -75,26 +83,41 @@ def accept_proposal( """ 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), None + 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], ) - if proposer: - 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], - ) - return ( + 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) @@ -113,11 +136,29 @@ def form_coalition( Returns: Confirmation of coalition formation and the partners notified. """ - for pid in (partner_ids or []): + 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 or [])] + 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: @@ -138,7 +179,12 @@ def form_coalition( partner_agents, ) - return f"Coalition '{coalition_name}' formed. Members: {member_names}." + 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) @@ -180,7 +226,12 @@ def reject_and_counter( agent.proposals_made += 1 agent.model.total_proposals += 1 - return ( + 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 From ff8c876899af327c8b7168513af4218da32b5d1f Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 30 Mar 2026 10:13:55 +0000 Subject: [PATCH 3/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- llm/climate_negotiation/app.py | 34 +++++++++++-------- .../climate_negotiation/agents.py | 4 ++- .../climate_negotiation/model.py | 20 +++++------ .../climate_negotiation/tools.py | 23 +++++++------ 4 files changed, 43 insertions(+), 38 deletions(-) diff --git a/llm/climate_negotiation/app.py b/llm/climate_negotiation/app.py index 6436dedd9..6921fa645 100644 --- a/llm/climate_negotiation/app.py +++ b/llm/climate_negotiation/app.py @@ -4,12 +4,11 @@ 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 climate_negotiation.agents import CountryAgent -from climate_negotiation.model import ClimateNegotiationModel from mesa_llm.reasoning.react import ReActReasoning warnings.filterwarnings("ignore", category=UserWarning, module="pydantic.main") @@ -78,7 +77,10 @@ def PledgeBarChart(model): bar.get_x() + bar.get_width() / 2, bar.get_height() + 1.2, f"{pledge:.0f}%", - ha="center", va="bottom", fontsize=9, fontweight="bold", + ha="center", + va="bottom", + fontsize=9, + fontweight="bold", ) plt.tight_layout() @@ -105,13 +107,15 @@ def CoalitionStatusPanel(model): 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, - }) + 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)) @@ -133,9 +137,7 @@ def PledgeTrajectoriesChart(model): return solara.FigureMatplotlib(fig) id_to_name = { - a.unique_id: a.country_name - for a in model.agents - if isinstance(a, CountryAgent) + a.unique_id: a.country_name for a in model.agents if isinstance(a, CountryAgent) } if isinstance(df.index, pd.MultiIndex): @@ -146,7 +148,9 @@ def PledgeTrajectoriesChart(model): 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.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) diff --git a/llm/climate_negotiation/climate_negotiation/agents.py b/llm/climate_negotiation/climate_negotiation/agents.py index e75f7bc52..9482d01cf 100644 --- a/llm/climate_negotiation/climate_negotiation/agents.py +++ b/llm/climate_negotiation/climate_negotiation/agents.py @@ -39,7 +39,9 @@ def get_negotiation_history(agent, max_messages: int = 6) -> str: 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}") + 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}") diff --git a/llm/climate_negotiation/climate_negotiation/model.py b/llm/climate_negotiation/climate_negotiation/model.py index 88e88d7a6..7ea80dbdc 100644 --- a/llm/climate_negotiation/climate_negotiation/model.py +++ b/llm/climate_negotiation/climate_negotiation/model.py @@ -1,16 +1,19 @@ 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 -from mesa_llm.reasoning.react import ReActReasoning -from mesa_llm.reasoning.reasoning import Reasoning _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")) +_file_handler.setFormatter( + logging.Formatter("%(asctime)s | %(levelname)s | %(message)s") +) sim_logger = logging.getLogger("climate_negotiation") sim_logger.setLevel(logging.DEBUG) @@ -162,7 +165,6 @@ def __init__( }, ) - def _treaty_reached(self) -> bool: """Return True when at least 2/3 of countries have accepted.""" agents = list(self.agents) @@ -182,18 +184,12 @@ 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 - ) - + 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} " - f"[/bold cyan]" - ) + 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) diff --git a/llm/climate_negotiation/climate_negotiation/tools.py b/llm/climate_negotiation/climate_negotiation/tools.py index 421efff1d..8b7561171 100644 --- a/llm/climate_negotiation/climate_negotiation/tools.py +++ b/llm/climate_negotiation/climate_negotiation/tools.py @@ -11,9 +11,10 @@ import logging from typing import TYPE_CHECKING -from .agents import country_tool_manager from mesa_llm.tools.tool_decorator import tool +from .agents import country_tool_manager + if TYPE_CHECKING: from mesa_llm.llm_agent import LLMAgent @@ -138,10 +139,13 @@ def form_coalition( """ 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(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. @@ -168,10 +172,9 @@ def form_coalition( ): 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] - ) + 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}'. " @@ -207,11 +210,11 @@ def reject_and_counter( """ 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 = 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) + getattr(proposer, "country_name", str(proposer_id)) + if proposer + else str(proposer_id) ) counter_msg = ( From eee2e147db9a0a9cb0b7db26c482f9beaadd5c6b Mon Sep 17 00:00:00 2001 From: BhoomiAgrawal12 Date: Mon, 30 Mar 2026 16:01:41 +0530 Subject: [PATCH 4/4] fix : ruff --- llm/climate_negotiation/climate_negotiation/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/llm/climate_negotiation/climate_negotiation/__init__.py b/llm/climate_negotiation/climate_negotiation/__init__.py index fa947c50b..36315a4c1 100644 --- a/llm/climate_negotiation/climate_negotiation/__init__.py +++ b/llm/climate_negotiation/climate_negotiation/__init__.py @@ -1 +1,3 @@ from . import tools + +__all__ = ["tools"]