feat: sideload additional tools to be always on

This commit is contained in:
Daniel Eder 2025-09-30 07:53:24 +02:00
parent 96fcfd12e5
commit f8512ad91e
2 changed files with 124 additions and 27 deletions

View file

@ -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 <tool>` escape hatch for ad-hoc automations. - Optional always-on tool mode plus an on-demand `#execute <tool>` escape hatch for ad-hoc automations.
- Higher-level agents (`fixed`, `loop`, `intelligent`) that can string multiple tools together via `#agent <spec>`. - Higher-level agents (`fixed`, `loop`, `intelligent`) that can string multiple tools together via `#agent <spec>`.
- Built-in tools (`LookTool`, `ExploreTool`, `CommunicationTool`, `MovementTool`, `IntelligentCommunicationTool`) with a pluggable interface for custom behaviours. - 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 ## 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_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`. |
| `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`). | | `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. | | `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`. - `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). - `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.
## On-Demand Tools ## On-Demand Tools

148
app.py
View file

@ -2,10 +2,11 @@ import os
import select import select
import sys import sys
import time import time
from collections import deque
from importlib import import_module from importlib import import_module
from pathlib import Path from pathlib import Path
from threading import Event, Lock, Thread 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 tools import Tool
from agents import Agent, build_agent from agents import Agent, build_agent
@ -52,7 +53,7 @@ TOOL_REGISTRY = {
"module": "intelligent_tool", "module": "intelligent_tool",
"class": "IntelligentCommunicationTool", "class": "IntelligentCommunicationTool",
"kwargs": { "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.", "description": "Uses an LLM to craft a polite reply to private tells.",
}, },
@ -60,7 +61,7 @@ TOOL_REGISTRY = {
"module": "intelligent_tool", "module": "intelligent_tool",
"class": "IntelligentCommunicationTool", "class": "IntelligentCommunicationTool",
"kwargs": { "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.", "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: class SessionState:
"""Share Telnet session state safely across threads.""" """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: def __init__(self) -> None:
self._send_lock = Lock() self._send_lock = Lock()
self._output_lock = Lock() self._output_lock = Lock()
self._output_event = Event() self._output_event = Event()
self._last_output = "" self._last_output = ""
self._last_tool_send = 0.0 self._last_tool_send = 0.0
self._listeners: set[SessionState._OutputListener] = set()
self._listeners_lock = Lock()
def send(self, client: TelnetClient, message: str) -> None: def send(self, client: TelnetClient, message: str) -> None:
with self._send_lock: with self._send_lock:
@ -116,12 +145,34 @@ class SessionState:
return return
with self._output_lock: with self._output_lock:
self._last_output = text self._last_output = text
with self._listeners_lock:
listeners = list(self._listeners)
for listener in listeners:
listener.publish(text)
self._output_event.set() self._output_event.set()
def snapshot_output(self) -> str: def snapshot_output(self) -> str:
with self._output_lock: with self._output_lock:
return self._last_output 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: def wait_for_output(self, timeout: float) -> bool:
return self._output_event.wait(timeout) return self._output_event.wait(timeout)
@ -142,6 +193,7 @@ def run_tool_loop(
) -> None: ) -> None:
"""Invoke *tool* whenever new output arrives and send its response.""" """Invoke *tool* whenever new output arrives and send its response."""
idle_started: Optional[float] = None idle_started: Optional[float] = None
listener = state.register_listener()
def maybe_send() -> None: def maybe_send() -> None:
nonlocal idle_started nonlocal idle_started
@ -162,31 +214,41 @@ def run_tool_loop(
return return
idle_started = None idle_started = None
while not stop_event.is_set(): try:
maybe_send() while not stop_event.is_set():
triggered = state.wait_for_output(timeout=idle_delay) maybe_send()
if stop_event.is_set(): if stop_event.is_set():
break 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
idle_started = None triggered = listener.wait(timeout=idle_delay)
state.clear_output_event() if stop_event.is_set():
last_output = state.snapshot_output() break
if not last_output:
continue if not triggered:
try: if auto_stop:
tool.observe(last_output) now = time.time()
except Exception as exc: # pragma: no cover - defensive logging if idle_started is None:
print(f"[Tool] Failed during observe: {exc}", file=sys.stderr) idle_started = now
continue elif now - idle_started >= auto_stop_idle:
maybe_send() 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: def load_env_file(path: str = ".env") -> None:
"""Populate ``os.environ`` with key/value pairs from a dotenv file.""" """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_mode = tool_mode_env.lower() in {"1", "true", "yes", "on"}
tool_spec = os.environ.get("MISTLE_TOOL", "") 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() state = SessionState()
stop_event = Event() stop_event = Event()
tool_thread: Optional[Thread] = None tool_thread: Optional[Thread] = None
tool: Optional[Tool] = None tool: Optional[Tool] = None
ephemeral_tools: list[Thread] = [] ephemeral_tools: list[Thread] = []
sidecar_threads: list[Thread] = []
agent_threads: list[Thread] = [] agent_threads: list[Thread] = []
with TelnetClient(host=host, port=port, timeout=10.0) as client: with TelnetClient(host=host, port=port, timeout=10.0) as client:
@ -408,6 +486,20 @@ def main() -> int:
) )
tool_thread.start() 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: def run_ephemeral_tool(spec: str) -> None:
spec = spec.strip() spec = spec.strip()
if not spec: if not spec:
@ -504,6 +596,8 @@ def main() -> int:
stop_event.set() stop_event.set()
if tool_thread: if tool_thread:
tool_thread.join(timeout=1.0) tool_thread.join(timeout=1.0)
for thread in sidecar_threads:
thread.join(timeout=1.0)
for thread in ephemeral_tools: for thread in ephemeral_tools:
thread.join(timeout=1.0) thread.join(timeout=1.0)
for thread in agent_threads: for thread in agent_threads: