feat: intelligent agent basics
This commit is contained in:
parent
a2b7feec16
commit
278cb7a8ec
6 changed files with 273 additions and 8 deletions
13
.vscode/settings.json
vendored
13
.vscode/settings.json
vendored
|
|
@ -1,3 +1,14 @@
|
||||||
{
|
{
|
||||||
"wolf.disableHotModeWarning": true
|
"wolf.disableHotModeWarning": true,
|
||||||
|
"cSpell.words": [
|
||||||
|
"behaviours",
|
||||||
|
"dotenv",
|
||||||
|
"fixedstrategy",
|
||||||
|
"intelligentcommunication",
|
||||||
|
"litellm",
|
||||||
|
"MISTLE",
|
||||||
|
"Mudbot",
|
||||||
|
"telnetclient",
|
||||||
|
"telnetlib"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
10
README.md
10
README.md
|
|
@ -8,7 +8,7 @@ Python-based Telnet helper for connecting to MUD servers, handling login flows,
|
||||||
- Loads credentials and connection settings from a local `.env` file.
|
- Loads credentials and connection settings from a local `.env` file.
|
||||||
- Interactive console session that mirrors server output and lets you type commands directly.
|
- Interactive console session that mirrors server output and lets you type commands directly.
|
||||||
- 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`) 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 (`SimpleTool`, `ExploreTool`, `CommunicationTool`, `MovementTool`, `IntelligentCommunicationTool`) with a pluggable interface for custom behaviours.
|
- Built-in tools (`SimpleTool`, `ExploreTool`, `CommunicationTool`, `MovementTool`, `IntelligentCommunicationTool`) with a pluggable interface for custom behaviours.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
@ -58,6 +58,14 @@ Python-based Telnet helper for connecting to MUD servers, handling login flows,
|
||||||
|
|
||||||
Append `:delay` to pause between iterations, e.g. `#agent loop move,explore:2.5`.
|
Append `:delay` to pause between iterations, e.g. `#agent loop move,explore:2.5`.
|
||||||
|
|
||||||
|
8. To let the LLM decide the next action, use the intelligent agent:
|
||||||
|
|
||||||
|
```text
|
||||||
|
#agent intelligent
|
||||||
|
```
|
||||||
|
|
||||||
|
Add guidance text after the type and optional modifiers separated by `|`, e.g. `#agent intelligent explore carefully|model=mistral/mistral-large-2407|delay=2`.
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
All variables can be placed in the `.env` file (one `KEY=value` per line) or provided through the shell environment.
|
All variables can be placed in the `.env` file (one `KEY=value` per line) or provided through the shell environment.
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from tools import Tool
|
||||||
|
|
||||||
ToolBuilder = Callable[[str], Tool]
|
ToolBuilder = Callable[[str], Tool]
|
||||||
ToolRunner = Callable[[Tool], None]
|
ToolRunner = Callable[[Tool], None]
|
||||||
|
CommandRunner = Callable[[str], None]
|
||||||
|
|
||||||
|
|
||||||
def run_agent(
|
def run_agent(
|
||||||
|
|
@ -16,6 +17,7 @@ def run_agent(
|
||||||
*,
|
*,
|
||||||
build_tool: ToolBuilder,
|
build_tool: ToolBuilder,
|
||||||
run_tool: ToolRunner,
|
run_tool: ToolRunner,
|
||||||
|
send_command: CommandRunner,
|
||||||
stop_event: Event,
|
stop_event: Event,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Execute *agent* by wiring it to the tool runtime."""
|
"""Execute *agent* by wiring it to the tool runtime."""
|
||||||
|
|
@ -34,4 +36,12 @@ def run_agent(
|
||||||
run_tool(tool)
|
run_tool(tool)
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
try:
|
||||||
|
agent.run(
|
||||||
|
invoke_tool=invoke_tool,
|
||||||
|
send_command=send_command,
|
||||||
|
stop_event=stop_event,
|
||||||
|
)
|
||||||
|
except TypeError:
|
||||||
|
# Backwards compatibility for agents that only accept invoke_tool
|
||||||
agent.run(invoke_tool=invoke_tool, stop_event=stop_event)
|
agent.run(invoke_tool=invoke_tool, stop_event=stop_event)
|
||||||
|
|
|
||||||
53
agents.py
53
agents.py
|
|
@ -1,21 +1,28 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import itertools
|
||||||
import sys
|
import sys
|
||||||
from abc import ABC, abstractmethod
|
from abc import ABC, abstractmethod
|
||||||
import itertools
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from threading import Event
|
from threading import Event
|
||||||
from typing import Callable
|
from typing import Callable, Optional
|
||||||
|
|
||||||
|
|
||||||
ToolInvoker = Callable[[str], bool]
|
ToolInvoker = Callable[[str], bool]
|
||||||
|
CommandExecutor = Callable[[str], None]
|
||||||
|
|
||||||
|
|
||||||
class Agent(ABC):
|
class Agent(ABC):
|
||||||
"""Interface for higher-level behaviours that orchestrate tools."""
|
"""Interface for higher-level behaviours that orchestrate tools."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def run(self, *, invoke_tool: ToolInvoker, stop_event: Event) -> None:
|
def run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
invoke_tool: ToolInvoker,
|
||||||
|
stop_event: Event,
|
||||||
|
send_command: Optional[CommandExecutor] = None,
|
||||||
|
) -> None:
|
||||||
"""Execute the agent strategy until finished or *stop_event* is set."""
|
"""Execute the agent strategy until finished or *stop_event* is set."""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -25,7 +32,13 @@ class FixedStrategyAgent(Agent):
|
||||||
|
|
||||||
plan: str
|
plan: str
|
||||||
|
|
||||||
def run(self, *, invoke_tool: ToolInvoker, stop_event: Event) -> None:
|
def run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
invoke_tool: ToolInvoker,
|
||||||
|
stop_event: Event,
|
||||||
|
send_command: Optional[CommandExecutor] = None,
|
||||||
|
) -> None:
|
||||||
steps = [part.strip() for part in self.plan.split(",") if part.strip()]
|
steps = [part.strip() for part in self.plan.split(",") if part.strip()]
|
||||||
if not steps:
|
if not steps:
|
||||||
print("[Agent] No tools configured for fixed strategy", file=sys.stderr)
|
print("[Agent] No tools configured for fixed strategy", file=sys.stderr)
|
||||||
|
|
@ -47,7 +60,13 @@ class LoopAgent(Agent):
|
||||||
plan: str
|
plan: str
|
||||||
delay: float = 0.0
|
delay: float = 0.0
|
||||||
|
|
||||||
def run(self, *, invoke_tool: ToolInvoker, stop_event: Event) -> None:
|
def run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
invoke_tool: ToolInvoker,
|
||||||
|
stop_event: Event,
|
||||||
|
send_command: Optional[CommandExecutor] = None,
|
||||||
|
) -> None:
|
||||||
steps = [part.strip() for part in self.plan.split(",") if part.strip()]
|
steps = [part.strip() for part in self.plan.split(",") if part.strip()]
|
||||||
if not steps:
|
if not steps:
|
||||||
print("[Agent] No tools configured for loop strategy", file=sys.stderr)
|
print("[Agent] No tools configured for loop strategy", file=sys.stderr)
|
||||||
|
|
@ -85,5 +104,29 @@ def build_agent(spec: str) -> Agent:
|
||||||
except ValueError:
|
except ValueError:
|
||||||
print(f"[Agent] Invalid delay '{delay_str}', defaulting to 0")
|
print(f"[Agent] Invalid delay '{delay_str}', defaulting to 0")
|
||||||
return LoopAgent(config, delay=delay)
|
return LoopAgent(config, delay=delay)
|
||||||
|
if kind in {"intelligent", "llm"}:
|
||||||
|
from intelligent_agent import IntelligentAgent
|
||||||
|
|
||||||
|
instruction = ""
|
||||||
|
model = None
|
||||||
|
turn_delay = 0.0
|
||||||
|
if config:
|
||||||
|
segments = [segment.strip() for segment in config.split("|") if segment.strip()]
|
||||||
|
for segment in segments:
|
||||||
|
if segment.startswith("model="):
|
||||||
|
model = segment.split("=", 1)[1].strip()
|
||||||
|
elif segment.startswith("delay="):
|
||||||
|
try:
|
||||||
|
turn_delay = float(segment.split("=", 1)[1].strip())
|
||||||
|
except ValueError:
|
||||||
|
print(f"[Agent] Invalid delay '{segment}'", file=sys.stderr)
|
||||||
|
else:
|
||||||
|
instruction = segment
|
||||||
|
|
||||||
|
agent = IntelligentAgent(instruction=instruction)
|
||||||
|
if model:
|
||||||
|
agent.model = model
|
||||||
|
agent.turn_delay = turn_delay if turn_delay > 0 else 1.0
|
||||||
|
return agent
|
||||||
|
|
||||||
raise RuntimeError(f"Unknown agent type '{kind}'")
|
raise RuntimeError(f"Unknown agent type '{kind}'")
|
||||||
|
|
|
||||||
18
app.py
18
app.py
|
|
@ -402,6 +402,15 @@ def main() -> int:
|
||||||
print(f"[Agent] Failed to configure '{spec}': {exc}", file=sys.stderr)
|
print(f"[Agent] Failed to configure '{spec}': {exc}", file=sys.stderr)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
last_output = state.snapshot_output()
|
||||||
|
if last_output:
|
||||||
|
observe = getattr(temp_agent, "observe", None)
|
||||||
|
if callable(observe):
|
||||||
|
try:
|
||||||
|
observe(last_output)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
print(f"[Agent] observe failed: {exc}", file=sys.stderr)
|
||||||
|
|
||||||
def run_tool_instance(tool: Tool) -> None:
|
def run_tool_instance(tool: Tool) -> None:
|
||||||
run_tool_loop(
|
run_tool_loop(
|
||||||
client,
|
client,
|
||||||
|
|
@ -412,6 +421,14 @@ def main() -> int:
|
||||||
auto_stop=True,
|
auto_stop=True,
|
||||||
auto_stop_idle=2.0,
|
auto_stop_idle=2.0,
|
||||||
)
|
)
|
||||||
|
output_after = state.snapshot_output()
|
||||||
|
if output_after:
|
||||||
|
observe = getattr(temp_agent, "observe", None)
|
||||||
|
if callable(observe):
|
||||||
|
try:
|
||||||
|
observe(output_after)
|
||||||
|
except Exception as exc: # pragma: no cover - defensive
|
||||||
|
print(f"[Agent] observe failed: {exc}", file=sys.stderr)
|
||||||
|
|
||||||
thread = Thread(
|
thread = Thread(
|
||||||
target=run_agent,
|
target=run_agent,
|
||||||
|
|
@ -419,6 +436,7 @@ def main() -> int:
|
||||||
kwargs={
|
kwargs={
|
||||||
"build_tool": build_tool,
|
"build_tool": build_tool,
|
||||||
"run_tool": run_tool_instance,
|
"run_tool": run_tool_instance,
|
||||||
|
"send_command": lambda cmd: state.send(client, cmd),
|
||||||
"stop_event": stop_event,
|
"stop_event": stop_event,
|
||||||
},
|
},
|
||||||
daemon=True,
|
daemon=True,
|
||||||
|
|
|
||||||
175
intelligent_agent.py
Normal file
175
intelligent_agent.py
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from threading import Event
|
||||||
|
from typing import Callable, Deque
|
||||||
|
|
||||||
|
from collections import deque
|
||||||
|
|
||||||
|
try:
|
||||||
|
from litellm import completion
|
||||||
|
except ImportError: # pragma: no cover
|
||||||
|
completion = None # type: ignore[assignment]
|
||||||
|
|
||||||
|
from agents import Agent
|
||||||
|
|
||||||
|
ToolInvoker = Callable[[str], bool]
|
||||||
|
CommandExecutor = Callable[[str], None]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class IntelligentAgent(Agent):
|
||||||
|
"""LLM-driven agent that decides between tools and raw commands."""
|
||||||
|
|
||||||
|
model: str = "mistral/mistral-large-2407"
|
||||||
|
system_prompt: str = (
|
||||||
|
"You are Mistle, a helpful MUD assistant. "
|
||||||
|
"You can either call tools or send plain commands to the MUD."
|
||||||
|
)
|
||||||
|
temperature: float = 0.7
|
||||||
|
max_output_tokens: int = 200
|
||||||
|
instruction: str = ""
|
||||||
|
turn_delay: float = 0.0
|
||||||
|
history: Deque[dict[str, str]] = field(default_factory=deque, init=False)
|
||||||
|
|
||||||
|
def observe(self, message: str) -> None:
|
||||||
|
content = message.strip()
|
||||||
|
if not content:
|
||||||
|
return
|
||||||
|
self.history.append({"role": "user", "content": content})
|
||||||
|
self._trim_history()
|
||||||
|
|
||||||
|
def run(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
invoke_tool: ToolInvoker,
|
||||||
|
send_command: CommandExecutor | None,
|
||||||
|
stop_event: Event,
|
||||||
|
) -> None:
|
||||||
|
if send_command is None:
|
||||||
|
raise RuntimeError("IntelligentAgent requires send_command support")
|
||||||
|
if completion is None:
|
||||||
|
print("[Agent] litellm not available; intelligent agent disabled", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
messages = [{"role": "system", "content": self.system_prompt}]
|
||||||
|
if self.instruction:
|
||||||
|
messages.append({"role": "system", "content": self.instruction})
|
||||||
|
messages.extend(self.history)
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
"Respond with JSON only. Schema: {\n"
|
||||||
|
" \"type\": \"tool\" or \"command\",\n"
|
||||||
|
" \"value\": string (tool name or raw command),\n"
|
||||||
|
" \"notes\": optional string explanation\n}"""
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not self.history or self.history[-1]["role"] != "assistant":
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "I will decide the next action now.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "What is the next action you will take?",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
cycle = 0
|
||||||
|
while not stop_event.is_set():
|
||||||
|
cycle += 1
|
||||||
|
print(f"[Agent] LLM cycle {cycle}...")
|
||||||
|
try:
|
||||||
|
response = completion(
|
||||||
|
model=self.model,
|
||||||
|
messages=messages,
|
||||||
|
temperature=self.temperature,
|
||||||
|
max_tokens=self.max_output_tokens,
|
||||||
|
)
|
||||||
|
except Exception as exc: # pragma: no cover
|
||||||
|
print(f"[Agent] LLM call failed: {exc}", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
content = response["choices"][0]["message"]["content"].strip()
|
||||||
|
print(f"[Agent] LLM raw output: {content}")
|
||||||
|
if content.startswith("```"):
|
||||||
|
content = content.strip("` ")
|
||||||
|
if "\n" in content:
|
||||||
|
content = content.split("\n", 1)[1]
|
||||||
|
payload = json.loads(content)
|
||||||
|
except (KeyError, IndexError, TypeError, json.JSONDecodeError) as exc:
|
||||||
|
print(f"[Agent] Invalid LLM response: {exc}", file=sys.stderr)
|
||||||
|
print(f"[Agent] Raw content: {content}", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
action_type = payload.get("type")
|
||||||
|
value = (payload.get("value") or "").strip()
|
||||||
|
notes = payload.get("notes")
|
||||||
|
if notes:
|
||||||
|
self.history.append({"role": "assistant", "content": f"NOTE: {notes}"})
|
||||||
|
self._trim_history()
|
||||||
|
if not value:
|
||||||
|
print("[Agent] LLM returned empty action", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
if action_type == "tool":
|
||||||
|
success = invoke_tool(value)
|
||||||
|
print(f"[Agent] Executed tool: {value} (success={success})")
|
||||||
|
self.history.append({"role": "assistant", "content": f"TOOL {value}"})
|
||||||
|
self._trim_history()
|
||||||
|
if not success:
|
||||||
|
return
|
||||||
|
elif action_type == "command":
|
||||||
|
send_command(value)
|
||||||
|
print(f"[Agent] Sent command: {value}")
|
||||||
|
self.history.append({"role": "assistant", "content": f"COMMAND {value}"})
|
||||||
|
self._trim_history()
|
||||||
|
else:
|
||||||
|
print(f"[Agent] Unknown action type '{action_type}'", file=sys.stderr)
|
||||||
|
return
|
||||||
|
|
||||||
|
if self.turn_delay > 0 and stop_event.wait(self.turn_delay):
|
||||||
|
break
|
||||||
|
|
||||||
|
messages = [{"role": "system", "content": self.system_prompt}]
|
||||||
|
if self.instruction:
|
||||||
|
messages.append({"role": "system", "content": self.instruction})
|
||||||
|
messages.extend(self.history)
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"role": "system",
|
||||||
|
"content": (
|
||||||
|
"Respond with JSON only. Schema: {\n"
|
||||||
|
" \"type\": \"tool\" or \"command\",\n"
|
||||||
|
" \"value\": string,\n"
|
||||||
|
" \"notes\": optional string\n}"""
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
if not self.history or self.history[-1]["role"] != "assistant":
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"role": "assistant",
|
||||||
|
"content": "I will decide the next action now.",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
messages.append(
|
||||||
|
{
|
||||||
|
"role": "user",
|
||||||
|
"content": "What is the next action you will take?",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
print("[Agent] Intelligent agent finished.")
|
||||||
|
|
||||||
|
def _trim_history(self) -> None:
|
||||||
|
while len(self.history) > 50:
|
||||||
|
self.history.popleft()
|
||||||
Loading…
Add table
Reference in a new issue