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.
- 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.
- 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

148
app.py
View file

@ -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: