from __future__ import annotations import re import sys from abc import ABC, abstractmethod from collections import deque from dataclasses import dataclass, field from typing import Deque, Optional, Pattern, Set, Tuple try: from litellm import completion except ImportError: # pragma: no cover - optional dependency completion = None # type: ignore[assignment] class Agent(ABC): """Interface for autonomous Telnet actors.""" @abstractmethod def observe(self, output: str) -> None: """Receive the latest text emitted by the server.""" @abstractmethod def decide(self) -> Optional[str]: """Return the next command to send, or ``None`` to stay idle.""" @dataclass class SimpleAgent(Agent): """Minimal agent that always returns the same command.""" default_command: str = "schau" last_output: str = field(default="", init=False) def observe(self, output: str) -> None: if output: self.last_output = output def decide(self) -> Optional[str]: return self.default_command @dataclass class ExploreAgent(Agent): """Agent that inspects every noun it discovers in the room description.""" look_command: str = "schau" inspect_command: str = "untersuche" nouns_pattern: Pattern[str] = field( default_factory=lambda: re.compile(r"\b[A-ZÄÖÜ][A-Za-zÄÖÜäöüß-]*\b") ) last_output: str = field(default="", init=False) look_sent: bool = field(default=False, init=False) pending_targets: Deque[str] = field(default_factory=deque, init=False) seen_targets: Set[str] = field(default_factory=set, init=False) inspected_targets: Set[str] = field(default_factory=set, init=False) def observe(self, output: str) -> None: if not output: return self.last_output = output if not self.look_sent: return for noun in self.nouns_pattern.findall(output): target = noun.strip() key = target.lower() if not target or key in self.seen_targets: continue self.seen_targets.add(key) self.pending_targets.append(target) def decide(self) -> Optional[str]: if not self.look_sent: self.look_sent = True print("[Agent] Exploring room, sending 'schau'") return self.look_command if self.pending_targets: target = self.pending_targets.popleft() key = target.lower() self.inspected_targets.add(key) progress = f"[Agent] Explored {len(self.inspected_targets)}/{len(self.seen_targets)} — untersuche {target}" print(progress) return f"{self.inspect_command} {target}" return None @dataclass class CommunicationAgent(Agent): """Agent that replies to private tells.""" reply_template: str = "teile {player} mit Hallo! Ich bin Mistle und ein Bot." tell_pattern: Pattern[str] = field( default_factory=lambda: re.compile( r"^(?P[^\s]+) teilt (d|D)ir mit: (?P.+)$", re.MULTILINE, ) ) last_output: str = field(default="", init=False) pending_replies: Deque[Tuple[str, str]] = field(default_factory=deque, init=False) def observe(self, output: str) -> None: if not output: return self.last_output = output for match in self.tell_pattern.finditer(output): player = match.group("player").strip() message = match.group("message").strip() if not player: continue self.pending_replies.append((player, message)) print(f"[Agent] Received message from {player}: {message}") def decide(self) -> Optional[str]: if not self.pending_replies: return None player, _ = self.pending_replies.popleft() reply = self.reply_template.format(player=player) print(f"[Agent] Replying to {player}") return reply @dataclass class IntelligentCommunicationAgent(Agent): """Agent that uses a language model to answer private tells.""" model: str = "mistral/mistral-tiny" system_prompt: str = ( "Du bist Mistle, ein hilfsbereiter MUD-Bot. " "Antworte freundlich und knapp in deutscher Sprache." ) temperature: float = 0.7 max_output_tokens: int = 120 fallback_reply: str = "Hallo! Ich bin Mistle und ein Bot." tell_pattern: Pattern[str] = field( default_factory=lambda: re.compile( r"^(?P[^\s]+) teilt (d|D)ir mit: (?P.+)$", re.MULTILINE, ) ) last_output: str = field(default="", init=False) pending_replies: Deque[Tuple[str, str]] = field(default_factory=deque, init=False) def observe(self, output: str) -> None: if not output: return self.last_output = output for match in self.tell_pattern.finditer(output): player = match.group("player").strip() message = match.group("message").strip() if not player: continue self.pending_replies.append((player, message)) print(f"[Agent] Received message from {player}: {message}") def decide(self) -> Optional[str]: if not self.pending_replies: return None player, message = self.pending_replies.popleft() reply_text = self._generate_reply(player, message) reply = f"teile {player} mit {reply_text}" print(f"[Agent] Replying to {player} with model output") return reply def _generate_reply(self, player: str, message: str) -> str: if completion is None: print( "[Agent] litellm is not installed; falling back to default reply", file=sys.stderr, ) return self.fallback_reply try: response = completion( model=self.model, messages=[ {"role": "system", "content": self.system_prompt}, { "role": "user", "content": ( f"Spieler {player} schreibt: {message}\n" "Formuliere eine kurze, freundliche Antwort." ), }, ], temperature=self.temperature, max_tokens=self.max_output_tokens, ) except Exception as exc: # pragma: no cover - network/runtime errors print(f"[Agent] Model call failed: {exc}", file=sys.stderr) return self.fallback_reply try: content = response["choices"][0]["message"]["content"].strip() except (KeyError, IndexError, TypeError): # pragma: no cover - defensive return self.fallback_reply return content or self.fallback_reply