feat: sideload additional tools to be always on
This commit is contained in:
parent
96fcfd12e5
commit
f8512ad91e
2 changed files with 124 additions and 27 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
112
app.py
112
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,11 +214,16 @@ def run_tool_loop(
|
|||
return
|
||||
idle_started = None
|
||||
|
||||
try:
|
||||
while not stop_event.is_set():
|
||||
maybe_send()
|
||||
triggered = state.wait_for_output(timeout=idle_delay)
|
||||
if stop_event.is_set():
|
||||
break
|
||||
|
||||
triggered = listener.wait(timeout=idle_delay)
|
||||
if stop_event.is_set():
|
||||
break
|
||||
|
||||
if not triggered:
|
||||
if auto_stop:
|
||||
now = time.time()
|
||||
|
|
@ -176,17 +233,22 @@ def run_tool_loop(
|
|||
break
|
||||
continue
|
||||
|
||||
outputs = listener.drain()
|
||||
if not outputs:
|
||||
continue
|
||||
|
||||
idle_started = None
|
||||
state.clear_output_event()
|
||||
last_output = state.snapshot_output()
|
||||
if not last_output:
|
||||
for chunk in outputs:
|
||||
if not chunk:
|
||||
continue
|
||||
try:
|
||||
tool.observe(last_output)
|
||||
tool.observe(chunk)
|
||||
except Exception as exc: # pragma: no cover - defensive logging
|
||||
print(f"[Tool] Failed during observe: {exc}", file=sys.stderr)
|
||||
continue
|
||||
|
||||
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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue