feat: new agent to move around
This commit is contained in:
parent
bcc453ba37
commit
9b8584561e
4 changed files with 230 additions and 17 deletions
|
|
@ -8,7 +8,7 @@ Python-based Telnet helper for connecting to MUD servers, handling login flows,
|
||||||
- Loads credentials and connection settings from a local `.env` file.
|
- Loads credentials and connection settings from a local `.env` file.
|
||||||
- Interactive console session that mirrors server output and lets you type commands directly.
|
- Interactive console session that mirrors server output and lets you type commands directly.
|
||||||
- Optional always-on agent mode plus an on-demand `#execute <agent>` escape hatch for ad-hoc automations.
|
- Optional always-on agent mode plus an on-demand `#execute <agent>` escape hatch for ad-hoc automations.
|
||||||
- Built-in agents (`SimpleAgent`, `ExploreAgent`, `CommunicationAgent`, `IntelligentCommunicationAgent`) with a pluggable interface for custom behaviours.
|
- Built-in agents (`SimpleAgent`, `ExploreAgent`, `CommunicationAgent`, `MovementAgent`, `IntelligentCommunicationAgent`) with a pluggable interface for custom behaviours.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
|
|
@ -54,7 +54,7 @@ All variables can be placed in the `.env` file (one `KEY=value` per line) or pro
|
||||||
| `MISTLE_LOGIN_PROMPT` | ❌ | Prompt string that signals the client to send credentials (e.g., `"Name:"`). When omitted, the client just waits for the initial banner. |
|
| `MISTLE_LOGIN_PROMPT` | ❌ | Prompt string that signals the client to send credentials (e.g., `"Name:"`). When omitted, the client just waits for the initial banner. |
|
||||||
| `MISTLE_EXIT_COMMAND` | ❌ | Command issued during graceful shutdown (after pressing `Ctrl-C`). Useful for `quit`/`save` macros. |
|
| `MISTLE_EXIT_COMMAND` | ❌ | Command issued during graceful shutdown (after pressing `Ctrl-C`). Useful for `quit`/`save` macros. |
|
||||||
| `MISTLE_AGENT_MODE` | ❌ | Enable full-time agent thread when set to truthy values (`1`, `true`, `yes`, `on`). Defaults to interactive-only mode. |
|
| `MISTLE_AGENT_MODE` | ❌ | Enable full-time agent thread when set to truthy values (`1`, `true`, `yes`, `on`). Defaults to interactive-only mode. |
|
||||||
| `MISTLE_AGENT` | ❌ | Select which agent class to instantiate when agent mode is active. Accepted values: `simple` (default), `explore`, `communication`, `intelligent`/`intelligentcommunication` (LLM-backed), or custom spec `module:ClassName`. |
|
| `MISTLE_AGENT` | ❌ | Select which agent class to instantiate when agent mode is active. Accepted values: `simple` (default), `explore`, `communication`, `movement`, `intelligent`/`intelligentcommunication` (LLM-backed), or custom spec `module:ClassName`. |
|
||||||
| `MISTLE_LLM_MODEL` | ❌ | Override the `litellm` model used by the intelligent agent (defaults to `mistral/mistral-small-2407`). |
|
| `MISTLE_LLM_MODEL` | ❌ | Override the `litellm` model used by the intelligent agent (defaults to `mistral/mistral-small-2407`). |
|
||||||
| `MISTRAL_API_KEY` | ❌ | API key used by `IntelligentCommunicationAgent` (via `litellm`) when calling the `mistral/mistral-small-2407` model. |
|
| `MISTRAL_API_KEY` | ❌ | API key used by `IntelligentCommunicationAgent` (via `litellm`) when calling the `mistral/mistral-small-2407` model. |
|
||||||
|
|
||||||
|
|
@ -67,12 +67,13 @@ All variables can be placed in the `.env` file (one `KEY=value` per line) or pro
|
||||||
- `observe(output)` receives the latest server text; `decide()` returns the next command string or `None` to stay idle.
|
- `observe(output)` receives the latest server text; `decide()` returns the next command string or `None` to stay idle.
|
||||||
- Commands issued by the agent are throttled to one per second so manual commands can still interleave smoothly.
|
- Commands issued by the agent are throttled to one per second so manual commands can still interleave smoothly.
|
||||||
- `ExploreAgent` showcases a richer workflow: it sends `schau`, identifies German nouns, inspects each with `untersuche`, and prints `[Agent]` progress updates like `Explored 3/7 — untersuche Tisch`.
|
- `ExploreAgent` showcases a richer workflow: it sends `schau`, identifies German nouns, inspects each with `untersuche`, and prints `[Agent]` progress updates like `Explored 3/7 — untersuche Tisch`.
|
||||||
|
- `MovementAgent` parses room descriptions/exits and issues direction commands (prefers `n`, `e`, `s`, `w`, diagonals, then vertical moves) while tracking which exits were already tried. Use `#execute move` for ad-hoc pathing or set `MISTLE_AGENT=movement` for continuous roaming.
|
||||||
- `CommunicationAgent` auto-replies to every direct tell with a canned greeting, while `IntelligentCommunicationAgent` routes each tell through `litellm` (default model `mistral/mistral-small-2407`) to craft a contextual answer.
|
- `CommunicationAgent` auto-replies to every direct tell with a canned greeting, while `IntelligentCommunicationAgent` routes each tell through `litellm` (default model `mistral/mistral-small-2407`) to craft a contextual answer.
|
||||||
|
|
||||||
## On-Demand Agents
|
## On-Demand Agents
|
||||||
|
|
||||||
- When `MISTLE_AGENT_MODE` is **off**, you can trigger an ephemeral agent at any time with `#execute <agent_spec>`.
|
- When `MISTLE_AGENT_MODE` is **off**, you can trigger an ephemeral agent at any time with `#execute <agent_spec>`.
|
||||||
- The syntax accepts the same values as `MISTLE_AGENT` and reuses the `build_agent` helper, so `#execute simple`, `#execute explore`, `#execute intelligent`, or `#execute mypackage.mymodule:CustomAgent` are all valid.
|
- The syntax accepts the same values as `MISTLE_AGENT` and reuses the `build_agent` helper, so `#execute simple`, `#execute explore`, `#execute move`, `#execute intelligent`, or `#execute mypackage.mymodule:CustomAgent` are all valid.
|
||||||
- On-demand runs share the current session, respect the one-command-per-second limit, and stop automatically after a few seconds of inactivity.
|
- On-demand runs share the current session, respect the one-command-per-second limit, and stop automatically after a few seconds of inactivity.
|
||||||
|
|
||||||
## Danger Zone
|
## Danger Zone
|
||||||
|
|
|
||||||
1
agent.py
1
agent.py
|
|
@ -2,6 +2,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Deque, Optional, Pattern, Set, Tuple
|
from typing import Deque, Optional, Pattern, Set, Tuple
|
||||||
|
|
|
||||||
40
app.py
40
app.py
|
|
@ -81,7 +81,27 @@ def run_agent_loop(
|
||||||
"""Invoke *agent* whenever new output arrives and send its response."""
|
"""Invoke *agent* whenever new output arrives and send its response."""
|
||||||
idle_started: Optional[float] = None
|
idle_started: Optional[float] = None
|
||||||
|
|
||||||
|
def maybe_send() -> None:
|
||||||
|
nonlocal idle_started
|
||||||
|
try:
|
||||||
|
command = agent.decide()
|
||||||
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
|
print(f"Agent failed: {exc}", file=sys.stderr)
|
||||||
|
return
|
||||||
|
if not command:
|
||||||
|
return
|
||||||
|
sent = state.agent_send(
|
||||||
|
client,
|
||||||
|
command,
|
||||||
|
min_interval=min_send_interval,
|
||||||
|
stop_event=stop_event,
|
||||||
|
)
|
||||||
|
if not sent:
|
||||||
|
return
|
||||||
|
idle_started = None
|
||||||
|
|
||||||
while not stop_event.is_set():
|
while not stop_event.is_set():
|
||||||
|
maybe_send()
|
||||||
triggered = state.wait_for_output(timeout=idle_delay)
|
triggered = state.wait_for_output(timeout=idle_delay)
|
||||||
if stop_event.is_set():
|
if stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
@ -101,20 +121,10 @@ def run_agent_loop(
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
agent.observe(last_output)
|
agent.observe(last_output)
|
||||||
command = agent.decide()
|
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
print(f"Agent failed: {exc}", file=sys.stderr)
|
print(f"Agent failed during observe: {exc}", file=sys.stderr)
|
||||||
continue
|
continue
|
||||||
if not command:
|
maybe_send()
|
||||||
continue
|
|
||||||
sent = state.agent_send(
|
|
||||||
client,
|
|
||||||
command,
|
|
||||||
min_interval=min_send_interval,
|
|
||||||
stop_event=stop_event,
|
|
||||||
)
|
|
||||||
if not sent:
|
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
def load_env_file(path: str = ".env") -> None:
|
def load_env_file(path: str = ".env") -> None:
|
||||||
|
|
@ -155,15 +165,17 @@ def build_agent(agent_spec: str) -> Agent:
|
||||||
builtin_agents = {
|
builtin_agents = {
|
||||||
"explore": ("agent", "ExploreAgent", {}),
|
"explore": ("agent", "ExploreAgent", {}),
|
||||||
"communication": ("agent", "CommunicationAgent", {}),
|
"communication": ("agent", "CommunicationAgent", {}),
|
||||||
|
"movement": ("movement_agent", "MovementAgent", {}),
|
||||||
|
"move": ("movement_agent", "MovementAgent", {}),
|
||||||
"intelligent": (
|
"intelligent": (
|
||||||
"intelligent_agent",
|
"intelligent_agent",
|
||||||
"IntelligentCommunicationAgent",
|
"IntelligentCommunicationAgent",
|
||||||
{"model": os.environ.get("MISTLE_LLM_MODEL", "mistral/mistral-tiny")},
|
{"model": os.environ.get("MISTLE_LLM_MODEL", "mistral/mistral-small-2407")},
|
||||||
),
|
),
|
||||||
"intelligentcommunication": (
|
"intelligentcommunication": (
|
||||||
"intelligent_agent",
|
"intelligent_agent",
|
||||||
"IntelligentCommunicationAgent",
|
"IntelligentCommunicationAgent",
|
||||||
{"model": os.environ.get("MISTLE_LLM_MODEL", "mistral/mistral-tiny")},
|
{"model": os.environ.get("MISTLE_LLM_MODEL", "mistral/mistral-small-2407")},
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
199
movement_agent.py
Normal file
199
movement_agent.py
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Dict, Optional, Pattern, Sequence, Set, Tuple
|
||||||
|
|
||||||
|
from agent import Agent
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MovementAgent(Agent):
|
||||||
|
"""Agent that chooses movement commands based on room descriptions."""
|
||||||
|
|
||||||
|
look_command: str = "schau"
|
||||||
|
command_format: str = "{direction}"
|
||||||
|
preferred_order: Tuple[str, ...] = (
|
||||||
|
"n",
|
||||||
|
"ne",
|
||||||
|
"e",
|
||||||
|
"se",
|
||||||
|
"s",
|
||||||
|
"sw",
|
||||||
|
"w",
|
||||||
|
"nw",
|
||||||
|
"u",
|
||||||
|
"d",
|
||||||
|
)
|
||||||
|
last_output: str = field(default="", init=False)
|
||||||
|
current_room_key: Optional[str] = field(default=None, init=False)
|
||||||
|
visited_directions: Dict[str, Set[str]] = field(default_factory=dict, init=False)
|
||||||
|
executed: bool = field(default=False, init=False)
|
||||||
|
needs_look: bool = field(default=True, init=False)
|
||||||
|
selected_direction: Optional[str] = field(default=None, init=False)
|
||||||
|
random_choice: bool = True
|
||||||
|
|
||||||
|
_direction_aliases: Dict[str, str] = field(
|
||||||
|
default_factory=lambda: {
|
||||||
|
"n": "n",
|
||||||
|
"north": "n",
|
||||||
|
"norden": "n",
|
||||||
|
"nord": "n",
|
||||||
|
"no": "ne",
|
||||||
|
"ne": "ne",
|
||||||
|
"nordost": "ne",
|
||||||
|
"nordosten": "ne",
|
||||||
|
"e": "e",
|
||||||
|
"east": "e",
|
||||||
|
"osten": "e",
|
||||||
|
"ost": "e",
|
||||||
|
"so": "se",
|
||||||
|
"se": "se",
|
||||||
|
"südost": "se",
|
||||||
|
"südosten": "se",
|
||||||
|
"s": "s",
|
||||||
|
"south": "s",
|
||||||
|
"süden": "s",
|
||||||
|
"süd": "s",
|
||||||
|
"sw": "sw",
|
||||||
|
"südwest": "sw",
|
||||||
|
"südwesten": "sw",
|
||||||
|
"w": "w",
|
||||||
|
"west": "w",
|
||||||
|
"westen": "w",
|
||||||
|
"nw": "nw",
|
||||||
|
"nordwest": "nw",
|
||||||
|
"nordwesten": "nw",
|
||||||
|
"u": "u",
|
||||||
|
"oben": "u",
|
||||||
|
"up": "u",
|
||||||
|
"rauf": "u",
|
||||||
|
"d": "d",
|
||||||
|
"down": "d",
|
||||||
|
"hinunter": "d",
|
||||||
|
"unten": "d",
|
||||||
|
},
|
||||||
|
init=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
_exit_line_pattern: Pattern[str] = field(
|
||||||
|
default_factory=lambda: re.compile(
|
||||||
|
r"(?:ausg(?:ä|a)nge?|exits?)[:\s]+(.+)", re.IGNORECASE
|
||||||
|
),
|
||||||
|
init=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
def observe(self, output: str) -> None:
|
||||||
|
if not output or self.executed:
|
||||||
|
return
|
||||||
|
self.last_output = output
|
||||||
|
if self.needs_look:
|
||||||
|
return
|
||||||
|
room_key = self._room_key(output)
|
||||||
|
if self.selected_direction is None:
|
||||||
|
direction = self._select_direction(output, room_key)
|
||||||
|
if direction:
|
||||||
|
self.current_room_key = room_key
|
||||||
|
self.selected_direction = direction
|
||||||
|
|
||||||
|
def decide(self) -> Optional[str]:
|
||||||
|
if self.executed:
|
||||||
|
return None
|
||||||
|
if self.needs_look:
|
||||||
|
self.needs_look = False
|
||||||
|
print("[Agent] Requesting room description via look command")
|
||||||
|
return self.look_command
|
||||||
|
if self.selected_direction is None:
|
||||||
|
return None
|
||||||
|
direction = self.selected_direction
|
||||||
|
if self.current_room_key is not None:
|
||||||
|
self.visited_directions.setdefault(self.current_room_key, set()).add(
|
||||||
|
direction
|
||||||
|
)
|
||||||
|
command = self.command_format.format(direction=direction)
|
||||||
|
print(f"[Agent] Moving via {direction}")
|
||||||
|
self.executed = True
|
||||||
|
self.selected_direction = None
|
||||||
|
return command
|
||||||
|
|
||||||
|
def _room_key(self, text: str) -> str:
|
||||||
|
return re.sub(r"\s+", " ", text.strip())
|
||||||
|
|
||||||
|
def _select_direction(self, text: str, room_key: str) -> Optional[str]:
|
||||||
|
exits = self._parse_exit_lines(text)
|
||||||
|
if not exits:
|
||||||
|
exits = self._scan_for_directions(text)
|
||||||
|
if not exits:
|
||||||
|
return None
|
||||||
|
|
||||||
|
ordered = self._prioritized_exits(exits, room_key)
|
||||||
|
if not ordered:
|
||||||
|
return None
|
||||||
|
|
||||||
|
visited = self.visited_directions.setdefault(room_key, set())
|
||||||
|
candidates = [direction for direction in ordered if direction not in visited]
|
||||||
|
pool = candidates or ordered
|
||||||
|
return self._choose_direction(pool)
|
||||||
|
|
||||||
|
def _prioritized_exits(
|
||||||
|
self, exits: Sequence[Tuple[str, Optional[str]]], room_key: str
|
||||||
|
) -> list[str]:
|
||||||
|
ordered: list[str] = []
|
||||||
|
seen: Set[str] = set()
|
||||||
|
|
||||||
|
for pref in self.preferred_order:
|
||||||
|
for raw, canonical in exits:
|
||||||
|
if canonical == pref and raw not in seen:
|
||||||
|
ordered.append(raw)
|
||||||
|
seen.add(raw)
|
||||||
|
|
||||||
|
for raw, _ in exits:
|
||||||
|
if raw not in seen:
|
||||||
|
ordered.append(raw)
|
||||||
|
seen.add(raw)
|
||||||
|
|
||||||
|
return ordered
|
||||||
|
|
||||||
|
def _choose_direction(self, pool: Sequence[str]) -> str:
|
||||||
|
if not pool:
|
||||||
|
raise ValueError("No exits available to choose from")
|
||||||
|
if self.random_choice and len(pool) > 1:
|
||||||
|
return random.choice(list(pool))
|
||||||
|
return pool[0]
|
||||||
|
|
||||||
|
def _parse_exit_lines(self, text: str) -> list[Tuple[str, Optional[str]]]:
|
||||||
|
exits: list[Tuple[str, Optional[str]]] = []
|
||||||
|
for line in text.splitlines():
|
||||||
|
match = self._exit_line_pattern.search(line)
|
||||||
|
if not match:
|
||||||
|
continue
|
||||||
|
candidates = re.split(r"[,;/]| und | oder |\\s", match.group(1))
|
||||||
|
for candidate in candidates:
|
||||||
|
normalized = self._normalize_raw(candidate)
|
||||||
|
if not normalized:
|
||||||
|
continue
|
||||||
|
exits.append(normalized)
|
||||||
|
return exits
|
||||||
|
|
||||||
|
def _scan_for_directions(self, text: str) -> list[Tuple[str, Optional[str]]]:
|
||||||
|
exits: list[Tuple[str, Optional[str]]] = []
|
||||||
|
lowered = text.lower()
|
||||||
|
for word, direction in self._direction_aliases.items():
|
||||||
|
if re.search(rf"\b{re.escape(word)}\b", lowered):
|
||||||
|
normalized = self._normalize_raw(word)
|
||||||
|
if normalized:
|
||||||
|
exits.append((normalized[0], direction))
|
||||||
|
return exits
|
||||||
|
|
||||||
|
def _map_direction(self, raw: str) -> Optional[str]:
|
||||||
|
cleaned = re.sub(r"[^a-zäöüß]", "", raw.lower())
|
||||||
|
if not cleaned:
|
||||||
|
return None
|
||||||
|
return self._direction_aliases.get(cleaned)
|
||||||
|
|
||||||
|
def _normalize_raw(self, raw: str) -> Optional[Tuple[str, Optional[str]]]:
|
||||||
|
cleaned = re.sub(r"[\s\.,;:!?-]+$", "", raw.strip())
|
||||||
|
if not cleaned:
|
||||||
|
return None
|
||||||
|
return cleaned, self._map_direction(cleaned)
|
||||||
Loading…
Add table
Reference in a new issue