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.
|
- 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
|
||||||
|
|
||||||
|
|
|
||||||
112
app.py
112
app.py
|
|
@ -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,11 +214,16 @@ def run_tool_loop(
|
||||||
return
|
return
|
||||||
idle_started = None
|
idle_started = None
|
||||||
|
|
||||||
|
try:
|
||||||
while not stop_event.is_set():
|
while not stop_event.is_set():
|
||||||
maybe_send()
|
maybe_send()
|
||||||
triggered = state.wait_for_output(timeout=idle_delay)
|
|
||||||
if stop_event.is_set():
|
if stop_event.is_set():
|
||||||
break
|
break
|
||||||
|
|
||||||
|
triggered = listener.wait(timeout=idle_delay)
|
||||||
|
if stop_event.is_set():
|
||||||
|
break
|
||||||
|
|
||||||
if not triggered:
|
if not triggered:
|
||||||
if auto_stop:
|
if auto_stop:
|
||||||
now = time.time()
|
now = time.time()
|
||||||
|
|
@ -176,17 +233,22 @@ def run_tool_loop(
|
||||||
break
|
break
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
outputs = listener.drain()
|
||||||
|
if not outputs:
|
||||||
|
continue
|
||||||
|
|
||||||
idle_started = None
|
idle_started = None
|
||||||
state.clear_output_event()
|
for chunk in outputs:
|
||||||
last_output = state.snapshot_output()
|
if not chunk:
|
||||||
if not last_output:
|
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
tool.observe(last_output)
|
tool.observe(chunk)
|
||||||
except Exception as exc: # pragma: no cover - defensive logging
|
except Exception as exc: # pragma: no cover - defensive logging
|
||||||
print(f"[Tool] Failed during observe: {exc}", file=sys.stderr)
|
print(f"[Tool] Failed during observe: {exc}", file=sys.stderr)
|
||||||
continue
|
|
||||||
maybe_send()
|
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:
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue