feat: explore tool can examine individual items

This commit is contained in:
Daniel Eder 2026-02-09 10:32:27 +01:00
parent 348398616a
commit 5c4d00fb2a
No known key found for this signature in database
GPG key ID: CE7446DFCE599F32
5 changed files with 138 additions and 16 deletions

View file

@ -65,7 +65,7 @@ Python-based Telnet helper for connecting to MUD servers, handling login flows,
#agent intelligent #agent intelligent
``` ```
Add guidance text after the type and optional modifiers separated by `|`, e.g. `#agent intelligent explore carefully|model=mistral/mistral-large-2407|delay=2`. The agent only calls built-in tools (`look`, `move`, `movement`, `explore`, `communication`, `intelligentcommunication`) and refuses unknown names. Add guidance text after the type and optional modifiers separated by `|`, e.g. `#agent intelligent explore carefully|model=mistral/mistral-large-2407|delay=2`. The agent only calls built-in tools (`look`, `move`, `movement`, `explore`, `communication`, `intelligentcommunication`) and refuses unknown names. It can call `explore` in both forms: `explore` and `explore <target1>,<target2>`.
9. Prefer a TUI? Run `python textual_ui.py` to open a two-pane interface with a bottom input field: 9. Prefer a TUI? Run `python textual_ui.py` to open a two-pane interface with a bottom input field:
- Left pane: live MUD stream (server output plus outgoing actions from you, tools, and agents). - Left pane: live MUD stream (server output plus outgoing actions from you, tools, and agents).
@ -85,7 +85,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_TOOL_MODE` | ❌ | Enable full-time tool thread when set to truthy values (`1`, `true`, `yes`, `on`). Defaults to interactive-only mode. | | `MISTLE_TOOL_MODE` | ❌ | Enable full-time tool thread when set to truthy values (`1`, `true`, `yes`, `on`). Defaults to interactive-only mode. |
| `MISTLE_TOOL` | ❌ | Select which tool class to instantiate when tool mode is active. Accepted values: `look` (default, alias `simple`), `explore`, `communication`, `movement`, `intelligent`/`intelligentcommunication` (LLM-backed), or custom spec `module:ClassName`. | | `MISTLE_TOOL` | ❌ | Select which tool class to instantiate when tool mode is active. Accepted values: `look` (default, alias `simple`), `explore`, `communication`, `movement`, `intelligent`/`intelligentcommunication` (LLM-backed), or custom spec `module:ClassName`. `explore` accepts optional targets via `explore <target1>,<target2>` (or `explore:<target1>,<target2>`). |
| `MISTLE_SIDELOAD_TOOL` | ❌ | Comma-separated list of tool specs that should always run in the background (same syntax as `MISTLE_TOOL`). Useful for running `intelligent` alongside another primary tool. | | `MISTLE_SIDELOAD_TOOL` | ❌ | Comma-separated list of tool specs that should always run in the background (same syntax as `MISTLE_TOOL`). Useful for running `intelligent` alongside another primary tool. |
| `MISTLE_AUTOPLAY` | ❌ | When truthy (`1`, `true`, `yes`, `on`), starts an intelligent agent automatically at startup and injects instructions from `GOAL.md`. | | `MISTLE_AUTOPLAY` | ❌ | When truthy (`1`, `true`, `yes`, `on`), starts an intelligent agent automatically at startup and injects instructions from `GOAL.md`. |
| `MISTLE_LLM_MODEL` | ❌ | Override the `litellm` model used by the intelligent tool (defaults to `mistral/mistral-small-2407`). | | `MISTLE_LLM_MODEL` | ❌ | Override the `litellm` model used by the intelligent tool (defaults to `mistral/mistral-small-2407`). |
@ -111,7 +111,7 @@ All variables can be placed in the `.env` file (one `KEY=value` per line) or pro
- Placing the class elsewhere and configuring `MISTLE_TOOL` to `your_module:YourTool`. - Placing the class elsewhere and configuring `MISTLE_TOOL` to `your_module:YourTool`.
- `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 tool are throttled to one per second so manual commands can still interleave smoothly. - Commands issued by the tool are throttled to one per second so manual commands can still interleave smoothly.
- `ExploreTool` showcases a richer workflow: it sends `schau`, identifies German nouns, inspects each with `untersuche`, and prints `[Tool]` progress updates like `Explored 3/7 — untersuche Tisch`. - `ExploreTool` supports two modes: room scan mode (`schau` + noun discovery) and targeted mode (`explore <target1>,<target2>` or `explore:<target1>,<target2>`) which inspects only explicit targets.
- `MovementTool` parses room descriptions/exits and issues a single direction command, preferring unvisited exits and randomising choices to avoid oscillation. Trigger it via `#execute move` (or set `MISTLE_TOOL=movement` for continuous roaming). - `MovementTool` parses room descriptions/exits and issues a single direction command, preferring unvisited exits and randomising choices to avoid oscillation. Trigger it via `#execute move` (or set `MISTLE_TOOL=movement` for continuous roaming).
- `CommunicationTool` auto-replies to every direct tell with a canned greeting, while `IntelligentCommunicationTool` routes each tell through `litellm` (default model `mistral/mistral-small-2407`) to craft a contextual answer via the configured LLM. - `CommunicationTool` auto-replies to every direct tell with a canned greeting, while `IntelligentCommunicationTool` routes each tell through `litellm` (default model `mistral/mistral-small-2407`) to craft a contextual answer via the configured LLM.
- To keep specific helpers always-on (for example, the intelligent communication tool), set `MISTLE_SIDELOAD_TOOL=intelligent`. Multiple specs can be separated with commas; each runs in its own listener thread in parallel with the primary tool or interactive session. - To keep specific helpers always-on (for example, the intelligent communication tool), set `MISTLE_SIDELOAD_TOOL=intelligent`. Multiple specs can be separated with commas; each runs in its own listener thread in parallel with the primary tool or interactive session.
@ -119,7 +119,7 @@ All variables can be placed in the `.env` file (one `KEY=value` per line) or pro
## On-Demand Tools ## On-Demand Tools
- When `MISTLE_TOOL_MODE` is **off**, you can trigger an ephemeral tool at any time with `#execute <tool_spec>`. - When `MISTLE_TOOL_MODE` is **off**, you can trigger an ephemeral tool at any time with `#execute <tool_spec>`.
- The syntax accepts the same values as `MISTLE_TOOL` and reuses the `build_tool` helper, so `#execute look`, `#execute explore`, `#execute move`, `#execute intelligent`, or `#execute mypackage.mymodule:CustomTool` are all valid. - The syntax accepts the same values as `MISTLE_TOOL` and reuses the `build_tool` helper, so `#execute look`, `#execute explore`, `#execute explore Tisch,Truhe`, `#execute explore:Tisch,Truhe`, `#execute move`, `#execute intelligent`, or `#execute mypackage.mymodule:CustomTool` 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

View file

@ -11,6 +11,9 @@ Your goal is to level up as much as possible and explore the world.
Use tools only when appropriate. Think how you can solve problems without Use tools only when appropriate. Think how you can solve problems without
using a tool. Only use the "explore" tool as a last resort to gather information using a tool. Only use the "explore" tool as a last resort to gather information
as it requires a lot of time to run. as it requires a lot of time to run.
When using "explore", prefer targeted calls such as `explore Tisch,Truhe`
if you only need specific details. Use plain `explore` only when full-room
inspection is actually needed.
## Mud Commands ## Mud Commands
The following MUD commands may be helpful to you The following MUD commands may be helpful to you
@ -19,3 +22,4 @@ info - examine your own stats
hilfe - get additional help in general and about specific commands hilfe - get additional help in general and about specific commands
untersuche - examine a single object or detail in the current room or your inventory untersuche - examine a single object or detail in the current room or your inventory
inv - show your current inventory inv - show your current inventory
toete - attacks a NPC

View file

@ -83,7 +83,7 @@ class IntelligentAgent(Agent):
"content": ( "content": (
"Respond with JSON only. Schema: {\n" "Respond with JSON only. Schema: {\n"
" \"type\": \"tool\" or \"command\",\n" " \"type\": \"tool\" or \"command\",\n"
" \"value\": string (tool name or raw command),\n" " \"value\": string (tool name or raw command; for targeted explore use 'explore <target1>,<target2>'),\n"
" \"notes\": optional string explanation\n}""" " \"notes\": optional string explanation\n}"""
), ),
} }
@ -142,17 +142,25 @@ class IntelligentAgent(Agent):
} }
if action_type == "tool": if action_type == "tool":
lower = value.lower() base_name, args = self._split_tool_value(value)
lower = base_name.lower()
if allowed_map and lower not in allowed_map: if allowed_map and lower not in allowed_map:
print( print(
f"[Agent] Tool '{value}' not in allowed list {list(self.allowed_tools)}", f"[Agent] Tool '{value}' not in allowed list {list(self.allowed_tools)}",
file=sys.stderr, file=sys.stderr,
) )
return return
canonical, _ = allowed_map.get(lower, (value, "")) canonical, _ = allowed_map.get(lower, (base_name, ""))
success = invoke_tool(canonical) if args and canonical.lower() != "explore":
print(f"[Agent] Executed tool: {canonical} (success={success})") print(
self.history.append({"role": "assistant", "content": f"TOOL {canonical}"}) f"[Agent] Tool '{canonical}' does not support arguments: {args!r}",
file=sys.stderr,
)
return
tool_spec = canonical if not args else f"{canonical} {args}"
success = invoke_tool(tool_spec)
print(f"[Agent] Executed tool: {tool_spec} (success={success})")
self.history.append({"role": "assistant", "content": f"TOOL {tool_spec}"})
self._trim_history() self._trim_history()
if not success: if not success:
return return
@ -197,7 +205,7 @@ class IntelligentAgent(Agent):
"content": ( "content": (
"Respond with JSON only. Schema: {\n" "Respond with JSON only. Schema: {\n"
" \"type\": \"tool\" or \"command\" or \"end\",\n" " \"type\": \"tool\" or \"command\" or \"end\",\n"
" \"value\": string,\n" " \"value\": string (for targeted explore use 'explore <target1>,<target2>'),\n"
" \"notes\": optional string\n}""" " \"notes\": optional string\n}"""
), ),
} }
@ -233,3 +241,15 @@ class IntelligentAgent(Agent):
if start == -1 or end == -1 or end < start: if start == -1 or end == -1 or end < start:
raise json.JSONDecodeError("No JSON object found", text, 0) raise json.JSONDecodeError("No JSON object found", text, 0)
return text[start : end + 1] return text[start : end + 1]
def _split_tool_value(self, value: str) -> tuple[str, str]:
raw = value.strip()
if not raw:
return "", ""
if " " in raw:
base, args = raw.split(" ", 1)
return base.strip(), args.strip()
if ":" in raw:
base, args = raw.split(":", 1)
return base.strip(), args.strip()
return raw, ""

View file

@ -36,7 +36,10 @@ TOOL_REGISTRY = {
"module": "tools", "module": "tools",
"class": "ExploreTool", "class": "ExploreTool",
"kwargs": {}, "kwargs": {},
"description": "Sends 'schau' once, then 'untersuche <noun>' for each noun found in the room description.", "description": (
"Sends 'schau' once, then 'untersuche <noun>' for each noun found in the "
"room description. Supports targeted mode: 'explore <target1>,<target2>'."
),
}, },
"communication": { "communication": {
"module": "tools", "module": "tools",
@ -70,13 +73,14 @@ TOOL_DESCRIPTIONS = {
def build_tool(spec: str) -> Tool: def build_tool(spec: str) -> Tool:
"""Instantiate a tool based on configuration.""" """Instantiate a tool based on configuration."""
normalized = spec.strip() or "look" normalized = spec.strip() or "look"
key = normalized.lower() key, args = _parse_builtin_spec(normalized)
if key in TOOL_REGISTRY: if key in TOOL_REGISTRY:
meta = TOOL_REGISTRY[key] meta = TOOL_REGISTRY[key]
module_name = meta["module"] module_name = meta["module"]
class_name = meta["class"] class_name = meta["class"]
kwargs = meta.get("kwargs", {}) kwargs = dict(meta.get("kwargs", {}))
kwargs = _apply_builtin_args(key, args, kwargs)
try: try:
module = import_module(module_name) module = import_module(module_name)
tool_cls = getattr(module, class_name) tool_cls = getattr(module, class_name)
@ -99,6 +103,60 @@ def build_tool(spec: str) -> Tool:
raise RuntimeError(f"Unknown tool spec '{spec}'.") raise RuntimeError(f"Unknown tool spec '{spec}'.")
def _parse_builtin_spec(spec: str) -> tuple[str, Optional[str]]:
if ":" in spec:
prefix, suffix = spec.split(":", 1)
key = prefix.lower()
if key in {"explore"}:
return key, suffix.strip()
parts = spec.split(maxsplit=1)
key = parts[0].lower()
if key not in TOOL_REGISTRY:
return spec.lower(), None
args = parts[1].strip() if len(parts) > 1 else None
return key, args
def _apply_builtin_args(
key: str, args: Optional[str], kwargs: dict
) -> dict:
if not args:
return kwargs
if key in {"explore"}:
targets = _parse_targets(args)
if not targets:
raise RuntimeError("Explore targets cannot be empty")
kwargs["targets"] = tuple(targets)
return kwargs
raise RuntimeError(
f"Tool '{key}' does not accept arguments. Received extra input: {args!r}"
)
def _parse_targets(raw: str) -> list[str]:
value = raw.strip()
if not value:
return []
if "," in value:
parts = [segment.strip() for segment in value.split(",") if segment.strip()]
else:
parts = [value]
deduped: list[str] = []
seen: set[str] = set()
for part in parts:
lowered = part.lower()
if lowered in seen:
continue
seen.add(lowered)
deduped.append(part)
return deduped
def _instantiate_tool( def _instantiate_tool(
tool_cls: Type[Tool], tool_spec: str, kwargs: Optional[dict] = None tool_cls: Type[Tool], tool_spec: str, kwargs: Optional[dict] = None
) -> Tool: ) -> Tool:

View file

@ -43,10 +43,11 @@ class LookTool(Tool):
@dataclass @dataclass
class ExploreTool(Tool): class ExploreTool(Tool):
"""Tool that inspects every noun it discovers in the room description.""" """Tool that inspects room nouns, or only explicit targets when provided."""
look_command: str = "schau" look_command: str = "schau"
inspect_command: str = "untersuche" inspect_command: str = "untersuche"
targets: Tuple[str, ...] = ()
nouns_pattern: Pattern[str] = field( nouns_pattern: Pattern[str] = field(
default_factory=lambda: re.compile(r"\b[A-ZÄÖÜ][A-Za-zÄÖÜäöüß-]*\b") default_factory=lambda: re.compile(r"\b[A-ZÄÖÜ][A-Za-zÄÖÜäöüß-]*\b")
) )
@ -56,10 +57,31 @@ class ExploreTool(Tool):
seen_targets: Set[str] = field(default_factory=set, init=False) seen_targets: Set[str] = field(default_factory=set, init=False)
inspected_targets: Set[str] = field(default_factory=set, init=False) inspected_targets: Set[str] = field(default_factory=set, init=False)
def __post_init__(self) -> None:
normalized: list[str] = []
seen: set[str] = set()
for raw_target in self.targets:
target = raw_target.strip()
key = target.lower()
if not target or key in seen:
continue
seen.add(key)
normalized.append(target)
self.targets = tuple(normalized)
for target in self.targets:
self.seen_targets.add(target.lower())
self.pending_targets.append(target)
@property
def targeted_mode(self) -> bool:
return bool(self.targets)
def observe(self, output: str) -> None: def observe(self, output: str) -> None:
if not output: if not output:
return return
self.last_output = output self.last_output = output
if self.targeted_mode:
return
if not self.look_sent: if not self.look_sent:
return return
@ -72,6 +94,24 @@ class ExploreTool(Tool):
self.pending_targets.append(target) self.pending_targets.append(target)
def decide(self) -> Optional[str]: def decide(self) -> Optional[str]:
if self.targeted_mode:
if not self.look_sent:
self.look_sent = True
print(
f"[Tool] Targeted explore mode, inspecting {len(self.targets)} object(s)"
)
if not self.pending_targets:
return None
target = self.pending_targets.popleft()
key = target.lower()
self.inspected_targets.add(key)
progress = (
f"[Tool] Targeted {len(self.inspected_targets)}/{len(self.targets)} "
f"- untersuche {target}"
)
print(progress)
return f"{self.inspect_command} {target}"
if not self.look_sent: if not self.look_sent:
self.look_sent = True self.look_sent = True
print("[Tool] Exploring room, sending 'schau'") print("[Tool] Exploring room, sending 'schau'")