| from __future__ import annotations |
| import time |
| from openai import AsyncOpenAI |
| from .llm_client import get_llm_client |
| from typing import TYPE_CHECKING |
| if TYPE_CHECKING: |
| from ..environment.config import GameConfig |
|
|
| ToolCall = dict |
|
|
|
|
| class Agent: |
| def __init__( |
| self, |
| agent_id: str, |
| model: str = "google/gemma-4-26B-A4B-it", |
| provider: str = "modal", |
| system_prompt: str | None = None, |
| config: GameConfig | None = None, |
| ): |
| self.id = agent_id |
| self.model = model |
| self.provider = provider |
| self.client: AsyncOpenAI = get_llm_client(provider) |
| |
| if system_prompt: |
| self.system_prompt = system_prompt |
| else: |
| self.system_prompt = self.get_default_system_prompt(config) |
|
|
| @staticmethod |
| def get_default_system_prompt(config: GameConfig | None = None, grid_size: int | None = None) -> str: |
| from backend.environment.config import GameConfig |
| cfg = config or GameConfig() |
| gs = grid_size if grid_size is not None else cfg.grid_size |
| kill_sc = cfg.kill_score |
| srv_sc = cfg.survival_score_per_turn |
| att_dmg = cfg.attack_damage |
| att_rng = cfg.attack_range |
| |
| return ( |
| "You are a competitive AI agent in a grid-based battle royale game on a " |
| f"{gs}x{gs} grid. " |
| "Your goal is to maximize your score and be the last agent standing by eliminating others, collecting loot, and surviving.\n" |
| "CRITICAL STRATEGIC RULES:\n" |
| "1. ALWAYS call observe() at the start of your turn to get details on your HP, abilities, cooldowns, nearby tiles with loot, and visible agents.\n" |
| f"2. SCORING: Eliminating an agent yields +{kill_sc} points. Surviving only gives +{srv_sc} point(s) per turn. Passive play will lose. Actively hunt other agents, especially those with low HP.\n" |
| "3. ABILITIES: Walk over tiles marked with '[has loot]' to automatically collect them. Once acquired, activate them using activate_ability(ability, args):\n" |
| f" - 'attack': args={{'x': target_x, 'y': target_y}}. Attack an agent within {att_rng} tiles (Manhattan distance <= {att_rng}) for {att_dmg} damage (3 uses). If they are shielded, their shield breaks instead. Focus fire to eliminate them!\n" |
| " - 'dash': args={{'dx': dx, 'dy': dy}}. Teleport up to 3 tiles (dx/dy between -3 and 3) to chase agents, escape, or grab loot (3 uses).\n" |
| " - 'shield': args={}. Activate a shield to completely block the next incoming attack (2 uses).\n" |
| " - 'heal': args={}. Restore 40 HP when your health is low (2 uses).\n" |
| "4. MOVE: Use move(dx, dy) to move 1 step to an adjacent tile (dx/dy in -1, 0, 1) and automatically pick up any loot on that tile." |
| ) |
|
|
| async def decide(self, messages: list, tools: list[dict]) -> list[ToolCall]: |
| import json |
| system_msg = {"role": "system", "content": self.system_prompt} |
| full_messages = [system_msg] + messages |
|
|
| t0 = time.time() |
| response = await self.client.chat.completions.create( |
| model=self.model, |
| messages=full_messages, |
| tools=tools, |
| tool_choice="auto", |
| ) |
| elapsed = round((time.time() - t0) * 1000) |
|
|
| choice = response.choices[0] |
| if not choice.message.tool_calls: |
| return {"calls": [], "time_ms": elapsed, "raw": response.usage.model_dump() if response.usage else None} |
|
|
| results = [] |
| for tc in choice.message.tool_calls: |
| try: |
| args = json.loads(tc.function.arguments) |
| except json.JSONDecodeError: |
| args = {} |
| results.append({"name": tc.function.name, "args": args}) |
|
|
| return { |
| "calls": results, |
| "time_ms": elapsed, |
| "raw": response.usage.model_dump() if response.usage else None, |
| } |
|
|