diff --git a/examples/sugarscrap_g1mt/Readme.md b/examples/sugarscrap_g1mt/Readme.md new file mode 100644 index 0000000..d14493f --- /dev/null +++ b/examples/sugarscrap_g1mt/Readme.md @@ -0,0 +1,123 @@ +# Sugarscape Constant Growback Model with Traders + +## Summary + +This model is based on Epstein & Axtell's classic "Sugarscape" simulation from Growing Artificial Societies (1996), specifically the G1MT (Growback 1, Metabolism, Trade) variation. Trader agents wander a grid populated with two unevenly distributed resources: Sugar and Spice. Agents are endowed with individual metabolic rates for each resource and a vision range; there are also Resource agents, which represent the landscape and regenerate food over time. + +The model generates emergent economic dynamics through decentralized interactions. Traders must constantly harvest resources to satisfy their metabolic needs; if they run out of either sugar or spice, they starve. Crucially, agents can trade with neighbors. Decisions are governed by the Marginal Rate of Substitution (MRS); agents rich in sugar but poor in spice will trade sugar to acquire spice, and vice versa. Over time, this decentralized trading allows for the emergence of a price equilibrium and wealth distribution patterns. + +This model is implemented using Mesa-LLM, unlike the original deterministic versions. All Trader agents use Large Language Models to "think" about their survival. They observe their internal inventory and MRS, then autonomously decide to use tools to move to high-value resource tiles or propose trades to neighbors to ensure their continued existence. + +## Technical Details + +### Agents + +- `Trader (LLMAgent):` The primary actor equipped with STLTMemory and ReActReasoning. + + Internal State: Dynamically updates a context string with current inventory (Sugar, Spice) and hunger warnings to guide the LLM. + + Metabolism: Consumes a fixed amount of resources per step. Zero inventory results in agent removal (death). + + MRS Calculation: Computes the Marginal Rate of Substitution (MRS) using the Cobb-Douglas formula to value Sugar vs. Spice relative to biological needs. + +- `Resource (CellAgent):` A passive environmental agent that acts as a container for resources. It regenerates its current_amount by 1 unit per step up to a max_capacity. + +### Tools + +- `move_to_best_resource:` + + Function: Scans the local grid within the agent's vision radius. + + Action: Identifies the cell with the highest current_amount of resources, moves the agent there, and automatically harvests the full amount into the agent's inventory. + +- `propose_trade:` + + Function: Targets a specific neighbor by unique_id. + + Logic: Executes a trade only if the partner's MRS is higher than the proposer's (indicating the partner values Sugar more highly). This ensures trades are mathematically rational and mutually beneficial. + + +### Movement Rule (Rule M) + +A Trader agent moves to a new location and harvests resources if the following logic, executed by the *move_to_best_resource tool*, is satisfied: + +Scan: The agent inspects all cells within its *vision range* (von Neumann neighborhood). + +Identify: It identifies the cell containing a *Resource* agent with the highest *current_amount* of sugar/spice. + +Harvest: The agent moves to that cell, sets the resource's amount to 0, and adds the harvested amount to its own inventory. + +### Trade Rule (Rule T) + +Agents determine whether to trade based on their Marginal Rate of Substitution (MRS). A trade is proposed via the propose_trade tool and accepted if it is mutually beneficial: + +``` +Trade occurs if: Partner_MRS > Agent_MRS +``` + +Where the MRS is calculated using the agent's inventory and metabolism: + +``` +MRS = (spice_inventory / spice_metabolism) / (sugar_inventory / sugar_metabolism) +``` + +In this implementation: + +- `Agent (Proposer):` Gives Sugar, Receives Spice. + +- `Partner (Receiver):` Receives Sugar, Gives Spice. + +- This flow ensures resources move from agents who value them less to agents who value them more. + +### Resource Behavior + +Resource Agents represent the landscape. They are passive agents that regenerate wealth over time: + +- `Growback:` At every step, a Resource agent increases its current_amount by growback (default: 1). + +- `Capacity:` This growth is capped at the agent's max_capacity. + +### LLM-Powered Agents + +Both Traders and the simulation logic are driven by LLM-powered agents, meaning: + +- Their actions (e.g., `move_to_best_resource`, `propose_trade`) are determined by a ReAct reasoning module. + +- This module takes as input: + + The agent’s internal state (current inventory, metabolic warnings, and calculated MRS). + + Local observations of the grid. + +- A set of available tools defined in `tools.py`. + + + +## How to Run + +If you have cloned the repo into your local machine, ensure you run the following command from the root of the library: ``pip install -e . ``. Then, you will need an api key of an LLM-provider of your choice. (This model in particular makes a large amount of calls per minute and we therefore recommend getting a paid version of an api-key that can offer high rate-limits). Once you have obtained the api-key follow the below steps to set it up for this model. +1) Ensure the dotenv package is installed. If not, run ``pip install python-dotenv``. +2) In the root folder of the project, create a file named .env. +3) If you are using openAI's api key, add the following command in the .env file: ``OPENAI_API_KEY=sk-xxxxxxxxxxxxxxxxxxxxxxxx``. If you have the paid version of Gemini, use this line instead: ``GEMINI_API_KEY=your-gemini-api-key-here``(the free ones tend to not work with this model). +4) Change the ``api_key`` specification in app.py according to the provider you have chosen. +5) Similarly change the ``llm_model`` attribute as well in app.py to the name of a model you have access to. Ensure it is in the form of {provider}/{model_name}. For e.g. ``openai/gpt-4o-mini``. + +Once you have set up the api-key in your system, run the following command from this directory: + +``` + $ solara run app.py +``` + + +## Files + +* ``model.py``: Core model code. +* ``agents.py``: Agent classes. +* ``app.py``: Sets up the interactive visualization. +* ``tools.py``: Tools for the llm-agents to use. + + +## Further Reading + +[Growing Artificial Societies](https://mitpress.mit.edu/9780262550253/growing-artificial-societies/) +[Complexity Explorer Sugarscape with Traders Tutorial](https://www.complexityexplorer.org/courses/172-agent-based-models-with-python-an-introduction-to-mesa#gsc.tab=0) \ No newline at end of file diff --git a/examples/sugarscrap_g1mt/__init__.py b/examples/sugarscrap_g1mt/__init__.py new file mode 100644 index 0000000..3f3e1f2 --- /dev/null +++ b/examples/sugarscrap_g1mt/__init__.py @@ -0,0 +1 @@ +import examples.sugarscrap_g1mt.tools # noqa: F401, to register tools diff --git a/examples/sugarscrap_g1mt/agents.py b/examples/sugarscrap_g1mt/agents.py new file mode 100644 index 0000000..47bc630 --- /dev/null +++ b/examples/sugarscrap_g1mt/agents.py @@ -0,0 +1,162 @@ +from enum import Enum + +import mesa + +from mesa_llm.llm_agent import LLMAgent +from mesa_llm.memory.st_lt_memory import STLTMemory +from mesa_llm.tools.tool_manager import ToolManager + +trader_tool_manager = ToolManager() +resource_tool_manager = ToolManager() + + +class TraderState(Enum): + Total_Spice = 4 + Total_Sugar = 6 + Total_Count = 10 + + +class Trader(LLMAgent, mesa.discrete_space.CellAgent): + def __init__( + self, + model, + reasoning, + llm_model, + system_prompt, + vision, + internal_state, + step_prompt, + sugar=0, + spice=0, + metabolism_sugar=1, + metabolism_spice=1, + ): + super().__init__( + model=model, + reasoning=reasoning, + llm_model=llm_model, + system_prompt=system_prompt, + vision=vision, + internal_state=internal_state, + step_prompt=step_prompt, + ) + self.sugar = sugar + self.spice = spice + self.metabolism_sugar = metabolism_sugar + self.metabolism_spice = metabolism_spice + + self.memory = STLTMemory( + agent=self, + display=True, + llm_model="openai/gpt-4o-mini", + ) + + self.tool_manager = trader_tool_manager + + self.system_prompt = ( + "You are a Trader agent in a Sugarscape simulation. " + "You need Sugar and Spice to survive. " + "If your MRS (Marginal Rate of Substitution) is high, you desperately need Sugar. " + "If MRS is low, you need Spice. " + "You can move to harvest resources or trade with neighbors." + ) + + self.update_internal_metrics() + + def calculate_mrs(self): + if self.sugar == 0: + return 100.0 + + if self.metabolism_sugar == 0: + return 100.0 + + if self.metabolism_spice == 0: + return 0.0 + + return (self.spice / self.metabolism_spice) / ( + self.sugar / self.metabolism_sugar + ) + + def update_internal_metrics(self): + mrs = self.calculate_mrs() + + self.internal_state = [ + s + for s in self.internal_state + if not any(x in s for x in ["Sugar", "Spice", "MRS", "WARNING_"]) + ] + + self.internal_state.append(f"My Sugar inventory is: {self.sugar}") + self.internal_state.append(f"My Spice inventory is: {self.spice}") + self.internal_state.append( + f"My Marginal Rate of Substitution (MRS) is {mrs:.2f}" + ) + + if self.sugar < self.metabolism_sugar * 2: + self.internal_state.append( + "WARNING: I am in danger of starvation from lack of sugar" + ) + if self.spice < self.metabolism_spice * 2: + self.internal_state.append( + "WARNING: I am in danger of starvation from lack of spice" + ) + + def step(self): + self.sugar -= self.metabolism_sugar + self.spice -= self.metabolism_spice + + if self.sugar <= 0 or self.spice <= 0: + self.model.grid.remove_agent(self) + self.remove() + return + + self.update_internal_metrics() + + observation = self.generate_obs() + + plan = self.reasoning.plan( + obs=observation, + selected_tools=["move_to_best_resource", "propose_trade"], + ) + + self.apply_plan(plan) + + async def astep(self): + self.sugar -= self.metabolism_sugar + self.spice -= self.metabolism_spice + + if self.sugar <= 0 or self.spice <= 0: + self.model.grid.remove_agent(self) + self.remove() + return + + self.update_internal_metrics() + observation = self.generate_obs() + + plan = await self.reasoning.aplan( + obs=observation, + selected_tools=["move_to_best_resource", "propose_trade"], + ) + self.apply_plan(plan) + + +class Resource(mesa.discrete_space.CellAgent): + def __init__(self, model, max_capacity=10, current_amount=10, growback=1): + super().__init__(model=model) + + self.max_capacity = max_capacity + self.current_amount = current_amount + self.growback = growback + + self.internal_state = [] + + self.tool_manager = resource_tool_manager + + def step(self): + if self.current_amount < self.max_capacity: + self.current_amount += self.growback + if self.current_amount > self.max_capacity: + self.current_amount = self.max_capacity + + async def astep(self): + self.step() diff --git a/examples/sugarscrap_g1mt/app.py b/examples/sugarscrap_g1mt/app.py new file mode 100644 index 0000000..53b05a2 --- /dev/null +++ b/examples/sugarscrap_g1mt/app.py @@ -0,0 +1,118 @@ +# app.py +import logging +import warnings + +from dotenv import load_dotenv +from mesa.visualization import ( + SolaraViz, + make_plot_component, + make_space_component, +) + +from examples.sugarscrap_g1mt.agents import Resource, Trader +from examples.sugarscrap_g1mt.model import SugarScapeModel +from mesa_llm.reasoning.react import ReActReasoning + +# Suppress Pydantic serialization warnings +warnings.filterwarnings( + "ignore", + category=UserWarning, + module="pydantic.main", + message=r".*Pydantic serializer warnings.*", +) + +# Also suppress through logging +logging.getLogger("pydantic").setLevel(logging.ERROR) + +# enable_automatic_parallel_stepping(mode="threading") + +load_dotenv() + + +model_params = { + "seed": { + "type": "InputText", + "value": 42, + "label": "Random Seed", + }, + "initial_traders": 2, + "initial_resources": 10, + "width": 10, + "height": 10, + "reasoning": ReActReasoning, + "llm_model": "ollama/gemma3:1b", + "vision": 5, + "parallel_stepping": True, +} + +model = SugarScapeModel( + initial_traders=model_params["initial_traders"], + initial_resources=model_params["initial_resources"], + width=model_params["width"], + height=model_params["height"], + reasoning=model_params["reasoning"], + llm_model=model_params["llm_model"], + vision=model_params["vision"], + seed=model_params["seed"]["value"], + parallel_stepping=model_params["parallel_stepping"], +) + + +def trader_portrayal(agent): + if agent is None: + return + + portrayal = { + "shape": "circle", + "Filled": "true", + "r": 0.5, + "Layer": 1, + "text_color": "black", + } + + if isinstance(agent, Trader): + portrayal["Color"] = "red" + portrayal["r"] = 0.8 + portrayal["text"] = f"S:{agent.sugar} Sp:{agent.spice}" + + elif isinstance(agent, Resource): + portrayal["Color"] = "green" + portrayal["r"] = 0.4 + portrayal["Layer"] = 0 + if agent.current_amount > 0: + portrayal["alpha"] = agent.current_amount / agent.max_capacity + else: + portrayal["Color"] = "white" + + return portrayal + + +def post_process(ax): + ax.set_aspect("equal") + ax.set_xticks([]) + ax.set_yticks([]) + ax.get_figure().set_size_inches(10, 10) + + +space_component = make_space_component( + trader_portrayal, post_process=post_process, draw_grid=False +) + +chart_component = make_plot_component({"Total_Sugar": "blue", "Total_Spice": "red"}) + +if __name__ == "__main__": + page = SolaraViz( + model, + components=[ + space_component, + chart_component, + ], + model_params=model_params, + name="SugarScape G1MT Example", + ) + + """ + run with + cd examples/sugarscrap_g1mt + conda activate mesa-llm && solara run app.py + """ diff --git a/examples/sugarscrap_g1mt/model.py b/examples/sugarscrap_g1mt/model.py new file mode 100644 index 0000000..225fbe1 --- /dev/null +++ b/examples/sugarscrap_g1mt/model.py @@ -0,0 +1,130 @@ +import random + +from mesa.datacollection import DataCollector +from mesa.model import Model +from mesa.space import MultiGrid +from rich import print + +from examples.sugarscrap_g1mt.agents import Resource, Trader +from mesa_llm.reasoning.reasoning import Reasoning +from mesa_llm.recording.record_model import record_model + + +@record_model(output_dir="recordings") +class SugarScapeModel(Model): + def __init__( + self, + initial_traders: int, + initial_resources: int, + width: int, + height: int, + reasoning: type[Reasoning], + llm_model: str, + vision: int, + parallel_stepping=True, + seed=None, + ): + super().__init__(seed=seed) + self.width = width + self.height = height + self.parallel_stepping = parallel_stepping + self.grid = MultiGrid(self.width, self.height, torus=False) + + model_reporters = { + "Trader_Count": lambda m: sum(1 for a in m.agents if isinstance(a, Trader)), + "Total_Sugar": lambda m: sum( + a.sugar for a in m.agents if isinstance(a, Trader) + ), + "Total_Spice": lambda m: sum( + a.spice for a in m.agents if isinstance(a, Trader) + ), + } + + agent_reporters = { + "sugar": lambda a: getattr(a, "sugar", None), + "spice": lambda a: getattr(a, "spice", None), + "mrs": lambda a: a.calculate_mrs() if isinstance(a, Trader) else None, + } + + self.datacollector = DataCollector( + model_reporters=model_reporters, agent_reporters=agent_reporters + ) + + for _i in range(initial_resources): + max_cap = random.randint(2, 5) + resource = Resource( + model=self, max_capacity=max_cap, current_amount=max_cap, growback=1 + ) + + x = random.randrange(self.width) + y = random.randrange(self.height) + + self.grid.place_agent(resource, (x, y)) + + trader_system_prompt = ( + "You are a Trader agent in a Sugarscape simulation. " + "You need Sugar and Spice to survive. " + "If your MRS (Marginal Rate of Substitution) is high, you desperately need Sugar. " + "If MRS is low, you need Spice. " + "You can move to harvest resources or trade with neighbors." + ) + + agents = Trader.create_agents( + self, + n=initial_traders, + reasoning=reasoning, + llm_model=llm_model, + system_prompt=trader_system_prompt, + vision=vision, + internal_state=None, + step_prompt="Observe your inventory and MRS. Move to the best resource or propose a trade.", + ) + + x_pos = self.rng.integers(0, self.grid.width, size=(initial_traders,)) + y_pos = self.rng.integers(0, self.grid.height, size=(initial_traders,)) + + for agent, i, j in zip(agents, x_pos, y_pos): + agent.sugar = random.randint(5, 25) + agent.spice = random.randint(5, 25) + agent.metabolism_sugar = random.randint(1, 4) + agent.metabolism_spice = random.randint(1, 4) + + agent.update_internal_metrics() + + self.grid.place_agent(agent, (i, j)) + + def step(self): + """ + Execute one step of the model. + """ + print( + f"\n[bold purple] step {self.steps} ────────────────────────────────────────────────────────────────────────────────[/bold purple]" + ) + + self.agents.shuffle_do("step") + + self.datacollector.collect(self) + + +# =============================================================== +# RUN WITHOUT GRAPHICS +# =============================================================== + +if __name__ == "__main__": + """ + Run the model without the solara integration + """ + from mesa_llm.reasoning.reasoning import Reasoning + + model = SugarScapeModel( + initial_traders=5, + initial_resources=20, + width=10, + height=10, + reasoning=Reasoning(), + llm_model="openai/gpt-4o-mini", + vision=2, + ) + + for _ in range(5): + model.step() diff --git a/examples/sugarscrap_g1mt/tools.py b/examples/sugarscrap_g1mt/tools.py new file mode 100644 index 0000000..6d9acea --- /dev/null +++ b/examples/sugarscrap_g1mt/tools.py @@ -0,0 +1,102 @@ +from typing import TYPE_CHECKING + +from examples.sugarscrap_g1mt.agents import ( + Resource, + Trader, + trader_tool_manager, +) +from mesa_llm.tools.tool_decorator import tool + +if TYPE_CHECKING: + from mesa_llm.llm_agent import LLMAgent + + +@tool(tool_manager=trader_tool_manager) +def move_to_best_resource(agent: "LLMAgent") -> str: + """ + Move the agent to the best resource cell within its vision range. + + Args: + agent: Provided automatically + + Returns: + A string confirming the new position of the agent. + """ + + best_cell = None + best_amount = -1 + + x, y = agent.pos + vision = agent.vision + + for dx in range(-vision, vision + 1): + for dy in range(-vision, vision + 1): + nx, ny = x + dx, y + dy + if not agent.model.grid.out_of_bounds((nx, ny)): + cell_contents = agent.model.grid.get_cell_list_contents((nx, ny)) + for obj in cell_contents: + if isinstance(obj, Resource) and obj.current_amount > best_amount: + best_amount = obj.current_amount + best_cell = (nx, ny) + + if best_cell: + agent.model.grid.move_agent(agent, best_cell) + + harvested = 0 + cell_contents = agent.model.grid.get_cell_list_contents(best_cell) + for obj in cell_contents: + if isinstance(obj, Resource): + harvested = obj.current_amount + obj.current_amount = 0 # Harvest all available resource + + agent.sugar += harvested + agent.spice += harvested + return f"agent {agent.unique_id} moved to {best_cell}. and harvested {harvested} resources." + else: + return f"agent {agent.unique_id} found no resources to move to." + + +@tool(tool_manager=trader_tool_manager) +def propose_trade( + agent: "LLMAgent", other_agent_id: int, sugar_amount: int, spice_amount: int +) -> str: + """ + Propose a trade to another agent. + + Args: + other_agent_id: The unique id of the other agent to trade with. + sugar_amount: The amount of sugar to offer. + spice_amount: The amount of spice to offer. + agent: Provided automatically + + Returns: + A string confirming the trade proposal. + """ + other_agent = next( + (a for a in agent.model.agents if a.unique_id == other_agent_id), None + ) + + if other_agent is None: + return f"Agent {other_agent_id} not found." + + if not isinstance(other_agent, Trader): + return f"agent {other_agent_id} is not a valid trader." + + # Simple trade acceptance logic for demonstration + if sugar_amount <= 0 or spice_amount <= 0: + return "sugar_amount and spice_amount must be positive." + + if agent.sugar < sugar_amount or other_agent.spice < spice_amount: + return ( + f"agent {agent.unique_id} or agent {other_agent_id} " + "does not have enough resources for this trade." + ) + + if other_agent.calculate_mrs() > agent.calculate_mrs(): + agent.sugar -= sugar_amount + agent.spice += spice_amount + other_agent.sugar += sugar_amount + other_agent.spice -= spice_amount + return f"agent {agent.unique_id} traded {sugar_amount} sugar for {spice_amount} spice with agent {other_agent_id}." + else: + return f"agent {other_agent_id} rejected the trade proposal from agent {agent.unique_id}."