diff --git a/README.md b/README.md index 038fcaa..3daa58a 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Python-based Telnet helper for connecting to MUD servers, handling login flows, #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 ,`. 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). @@ -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_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` | ❌ | 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 ,` (or `explore:,`). | | `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_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`. - `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. -- `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 ,` or `explore:,`) 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). - `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. @@ -119,7 +119,7 @@ All variables can be placed in the `.env` file (one `KEY=value` per line) or pro ## On-Demand Tools - When `MISTLE_TOOL_MODE` is **off**, you can trigger an ephemeral tool at any time with `#execute `. -- 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. ## Danger Zone diff --git a/SYSTEM.md b/SYSTEM.md index 031d469..99ab037 100644 --- a/SYSTEM.md +++ b/SYSTEM.md @@ -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 using a tool. Only use the "explore" tool as a last resort to gather information 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 The following MUD commands may be helpful to you @@ -18,4 +21,5 @@ schau - get a description of the current environment info - examine your own stats hilfe - get additional help in general and about specific commands untersuche - examine a single object or detail in the current room or your inventory -inv - show your current inventory \ No newline at end of file +inv - show your current inventory +toete - attacks a NPC diff --git a/intelligent_agent.py b/intelligent_agent.py index 30baba2..e1d5d4f 100644 --- a/intelligent_agent.py +++ b/intelligent_agent.py @@ -83,7 +83,7 @@ class IntelligentAgent(Agent): "content": ( "Respond with JSON only. Schema: {\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 ,'),\n" " \"notes\": optional string explanation\n}""" ), } @@ -142,17 +142,25 @@ class IntelligentAgent(Agent): } 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: print( f"[Agent] Tool '{value}' not in allowed list {list(self.allowed_tools)}", file=sys.stderr, ) return - canonical, _ = allowed_map.get(lower, (value, "")) - success = invoke_tool(canonical) - print(f"[Agent] Executed tool: {canonical} (success={success})") - self.history.append({"role": "assistant", "content": f"TOOL {canonical}"}) + canonical, _ = allowed_map.get(lower, (base_name, "")) + if args and canonical.lower() != "explore": + print( + 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() if not success: return @@ -197,7 +205,7 @@ class IntelligentAgent(Agent): "content": ( "Respond with JSON only. Schema: {\n" " \"type\": \"tool\" or \"command\" or \"end\",\n" - " \"value\": string,\n" + " \"value\": string (for targeted explore use 'explore ,'),\n" " \"notes\": optional string\n}""" ), } @@ -233,3 +241,15 @@ class IntelligentAgent(Agent): if start == -1 or end == -1 or end < start: raise json.JSONDecodeError("No JSON object found", text, 0) 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, "" diff --git a/mud_tools.py b/mud_tools.py index 25ecef3..557e6a3 100644 --- a/mud_tools.py +++ b/mud_tools.py @@ -36,7 +36,10 @@ TOOL_REGISTRY = { "module": "tools", "class": "ExploreTool", "kwargs": {}, - "description": "Sends 'schau' once, then 'untersuche ' for each noun found in the room description.", + "description": ( + "Sends 'schau' once, then 'untersuche ' for each noun found in the " + "room description. Supports targeted mode: 'explore ,'." + ), }, "communication": { "module": "tools", @@ -70,13 +73,14 @@ TOOL_DESCRIPTIONS = { def build_tool(spec: str) -> Tool: """Instantiate a tool based on configuration.""" normalized = spec.strip() or "look" - key = normalized.lower() + key, args = _parse_builtin_spec(normalized) if key in TOOL_REGISTRY: meta = TOOL_REGISTRY[key] module_name = meta["module"] class_name = meta["class"] - kwargs = meta.get("kwargs", {}) + kwargs = dict(meta.get("kwargs", {})) + kwargs = _apply_builtin_args(key, args, kwargs) try: module = import_module(module_name) tool_cls = getattr(module, class_name) @@ -99,6 +103,60 @@ def build_tool(spec: str) -> Tool: 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( tool_cls: Type[Tool], tool_spec: str, kwargs: Optional[dict] = None ) -> Tool: diff --git a/tools.py b/tools.py index 5799cb8..0c4cebf 100644 --- a/tools.py +++ b/tools.py @@ -43,10 +43,11 @@ class LookTool(Tool): @dataclass 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" inspect_command: str = "untersuche" + targets: Tuple[str, ...] = () nouns_pattern: Pattern[str] = field( 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) 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: if not output: return self.last_output = output + if self.targeted_mode: + return if not self.look_sent: return @@ -72,6 +94,24 @@ class ExploreTool(Tool): self.pending_targets.append(target) 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: self.look_sent = True print("[Tool] Exploring room, sending 'schau'")