From f8512ad91e910f339b25ee79afa37ca70fc89b9f Mon Sep 17 00:00:00 2001 From: Daniel Eder Date: Tue, 30 Sep 2025 07:53:24 +0200 Subject: [PATCH] feat: sideload additional tools to be always on --- README.md | 3 ++ app.py | 148 ++++++++++++++++++++++++++++++++++++++++++++---------- 2 files changed, 124 insertions(+), 27 deletions(-) diff --git a/README.md b/README.md index 8e8abe0..023f0ea 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ Python-based Telnet helper for connecting to MUD servers, handling login flows, - Optional always-on tool mode plus an on-demand `#execute ` escape hatch for ad-hoc automations. - Higher-level agents (`fixed`, `loop`, `intelligent`) that can string multiple tools together via `#agent `. - Built-in tools (`LookTool`, `ExploreTool`, `CommunicationTool`, `MovementTool`, `IntelligentCommunicationTool`) with a pluggable interface for custom behaviours. +- Background "sideload" channel so helper tools (e.g. intelligent replies) can keep watching chat while other work continues. ## Requirements @@ -82,6 +83,7 @@ All variables can be placed in the `.env` file (one `KEY=value` per line) or pro | `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_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_LLM_MODEL` | ❌ | Override the `litellm` model used by the intelligent tool (defaults to `mistral/mistral-small-2407`). | | `MISTRAL_API_KEY` | ❌ | API key used by `IntelligentCommunicationTool` (via `litellm`) when calling the `mistral/mistral-small-2407` model. | @@ -96,6 +98,7 @@ All variables can be placed in the `.env` file (one `KEY=value` per line) or pro - `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`. - `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. ## On-Demand Tools diff --git a/app.py b/app.py index b61e674..72d0711 100644 --- a/app.py +++ b/app.py @@ -2,10 +2,11 @@ import os import select import sys import time +from collections import deque from importlib import import_module from pathlib import Path from threading import Event, Lock, Thread -from typing import Callable, Optional, Type +from typing import Callable, Deque, List, Optional, Type from tools import Tool from agents import Agent, build_agent @@ -52,7 +53,7 @@ TOOL_REGISTRY = { "module": "intelligent_tool", "class": "IntelligentCommunicationTool", "kwargs": { - "model": os.environ.get("MISTLE_LLM_MODEL", "mistral/mistral-small-2407") + "model": os.environ.get("MISTLE_LLM_MODEL", "mistral/mistral-small") }, "description": "Uses an LLM to craft a polite reply to private tells.", }, @@ -60,7 +61,7 @@ TOOL_REGISTRY = { "module": "intelligent_tool", "class": "IntelligentCommunicationTool", "kwargs": { - "model": os.environ.get("MISTLE_LLM_MODEL", "mistral/mistral-small-2407") + "model": os.environ.get("MISTLE_LLM_MODEL", "mistral/mistral-small") }, "description": "Alias of 'intelligent'. Uses an LLM to craft a polite reply to private tells.", }, @@ -76,12 +77,40 @@ from telnetclient import TelnetClient class SessionState: """Share Telnet session state safely across threads.""" + class _OutputListener: + def __init__(self) -> None: + self._queue: Deque[str] = deque() + self._lock = Lock() + self._event = Event() + + def publish(self, text: str) -> None: + with self._lock: + self._queue.append(text) + self._event.set() + + def wait(self, timeout: float) -> bool: + return self._event.wait(timeout) + + def drain(self) -> List[str]: + with self._lock: + items = list(self._queue) + self._queue.clear() + self._event.clear() + return items + + def close(self) -> None: + with self._lock: + self._queue.clear() + self._event.set() + def __init__(self) -> None: self._send_lock = Lock() self._output_lock = Lock() self._output_event = Event() self._last_output = "" self._last_tool_send = 0.0 + self._listeners: set[SessionState._OutputListener] = set() + self._listeners_lock = Lock() def send(self, client: TelnetClient, message: str) -> None: with self._send_lock: @@ -116,12 +145,34 @@ class SessionState: return with self._output_lock: self._last_output = text + with self._listeners_lock: + listeners = list(self._listeners) + for listener in listeners: + listener.publish(text) self._output_event.set() def snapshot_output(self) -> str: with self._output_lock: return self._last_output + def register_listener(self) -> "SessionState._OutputListener": + listener = SessionState._OutputListener() + with self._listeners_lock: + self._listeners.add(listener) + with self._output_lock: + last_output = self._last_output + if last_output: + listener.publish(last_output) + return listener + + def remove_listener(self, listener: "SessionState._OutputListener") -> None: + with self._listeners_lock: + existed = listener in self._listeners + if existed: + self._listeners.remove(listener) + if existed: + listener.close() + def wait_for_output(self, timeout: float) -> bool: return self._output_event.wait(timeout) @@ -142,6 +193,7 @@ def run_tool_loop( ) -> None: """Invoke *tool* whenever new output arrives and send its response.""" idle_started: Optional[float] = None + listener = state.register_listener() def maybe_send() -> None: nonlocal idle_started @@ -162,31 +214,41 @@ def run_tool_loop( return idle_started = None - while not stop_event.is_set(): - maybe_send() - triggered = state.wait_for_output(timeout=idle_delay) - if stop_event.is_set(): - break - if not triggered: - if auto_stop: - now = time.time() - if idle_started is None: - idle_started = now - elif now - idle_started >= auto_stop_idle: - break - continue + try: + while not stop_event.is_set(): + maybe_send() + if stop_event.is_set(): + break - idle_started = None - state.clear_output_event() - last_output = state.snapshot_output() - if not last_output: - continue - try: - tool.observe(last_output) - except Exception as exc: # pragma: no cover - defensive logging - print(f"[Tool] Failed during observe: {exc}", file=sys.stderr) - continue - maybe_send() + triggered = listener.wait(timeout=idle_delay) + if stop_event.is_set(): + break + + if not triggered: + if auto_stop: + now = time.time() + if idle_started is None: + idle_started = now + elif now - idle_started >= auto_stop_idle: + break + continue + + outputs = listener.drain() + if not outputs: + continue + + idle_started = None + for chunk in outputs: + if not chunk: + continue + try: + tool.observe(chunk) + except Exception as exc: # pragma: no cover - defensive logging + print(f"[Tool] Failed during observe: {exc}", file=sys.stderr) + + maybe_send() + finally: + state.remove_listener(listener) def load_env_file(path: str = ".env") -> None: """Populate ``os.environ`` with key/value pairs from a dotenv file.""" @@ -381,12 +443,28 @@ def main() -> int: tool_mode = tool_mode_env.lower() in {"1", "true", "yes", "on"} tool_spec = os.environ.get("MISTLE_TOOL", "") + sideload_env = os.environ.get("MISTLE_SIDELOAD_TOOL", "") + sideload_specs = [spec.strip() for spec in sideload_env.split(",") if spec.strip()] + sideload_tools: list[tuple[str, Tool]] = [] + seen_sideloads: set[str] = set() + for spec in sideload_specs: + lowered = spec.lower() + if lowered in seen_sideloads: + continue + seen_sideloads.add(lowered) + try: + tool_instance = build_tool(spec) + except RuntimeError as exc: + print(f"[Tool] Failed to load sideload '{spec}': {exc}", file=sys.stderr) + continue + sideload_tools.append((spec, tool_instance)) state = SessionState() stop_event = Event() tool_thread: Optional[Thread] = None tool: Optional[Tool] = None ephemeral_tools: list[Thread] = [] + sidecar_threads: list[Thread] = [] agent_threads: list[Thread] = [] with TelnetClient(host=host, port=port, timeout=10.0) as client: @@ -408,6 +486,20 @@ def main() -> int: ) tool_thread.start() + if sideload_tools: + for sidecar_spec, sidecar_tool in sideload_tools: + thread = Thread( + target=run_tool_loop, + args=(client, state, sidecar_tool, stop_event), + kwargs={"min_send_interval": 1.0}, + daemon=True, + ) + sidecar_threads.append(thread) + print( + f"[Tool] Sideloading '{sidecar_spec}' ({sidecar_tool.__class__.__name__})" + ) + thread.start() + def run_ephemeral_tool(spec: str) -> None: spec = spec.strip() if not spec: @@ -504,6 +596,8 @@ def main() -> int: stop_event.set() if tool_thread: tool_thread.join(timeout=1.0) + for thread in sidecar_threads: + thread.join(timeout=1.0) for thread in ephemeral_tools: thread.join(timeout=1.0) for thread in agent_threads: