414 lines
14 KiB
Python
414 lines
14 KiB
Python
|
|
"""
|
||
|
|
AUTARCH Agent System
|
||
|
|
Autonomous agent that uses LLM to accomplish tasks with tools
|
||
|
|
"""
|
||
|
|
|
||
|
|
import re
|
||
|
|
import json
|
||
|
|
from typing import Optional, List, Dict, Any, Callable
|
||
|
|
from dataclasses import dataclass, field
|
||
|
|
from enum import Enum
|
||
|
|
|
||
|
|
from .llm import get_llm, LLM, LLMError
|
||
|
|
from .tools import get_tool_registry, ToolRegistry
|
||
|
|
from .banner import Colors
|
||
|
|
|
||
|
|
|
||
|
|
class AgentState(Enum):
|
||
|
|
"""Agent execution states."""
|
||
|
|
IDLE = "idle"
|
||
|
|
THINKING = "thinking"
|
||
|
|
EXECUTING = "executing"
|
||
|
|
WAITING_USER = "waiting_user"
|
||
|
|
COMPLETE = "complete"
|
||
|
|
ERROR = "error"
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class AgentStep:
|
||
|
|
"""Record of a single agent step."""
|
||
|
|
thought: str
|
||
|
|
tool_name: Optional[str] = None
|
||
|
|
tool_args: Optional[Dict[str, Any]] = None
|
||
|
|
tool_result: Optional[str] = None
|
||
|
|
error: Optional[str] = None
|
||
|
|
|
||
|
|
|
||
|
|
@dataclass
|
||
|
|
class AgentResult:
|
||
|
|
"""Result of an agent task execution."""
|
||
|
|
success: bool
|
||
|
|
summary: str
|
||
|
|
steps: List[AgentStep] = field(default_factory=list)
|
||
|
|
error: Optional[str] = None
|
||
|
|
|
||
|
|
|
||
|
|
class Agent:
|
||
|
|
"""Autonomous agent that uses LLM and tools to accomplish tasks."""
|
||
|
|
|
||
|
|
SYSTEM_PROMPT = """You are AUTARCH, an autonomous AI agent created by darkHal and Setec Security Labs.
|
||
|
|
|
||
|
|
Your purpose is to accomplish tasks using the tools available to you. You think step by step, use tools to gather information and take actions, then continue until the task is complete.
|
||
|
|
|
||
|
|
## How to respond
|
||
|
|
|
||
|
|
You MUST respond in the following format for EVERY response:
|
||
|
|
|
||
|
|
THOUGHT: [Your reasoning about what to do next]
|
||
|
|
ACTION: [tool_name]
|
||
|
|
PARAMS: {"param1": "value1", "param2": "value2"}
|
||
|
|
|
||
|
|
OR when the task is complete:
|
||
|
|
|
||
|
|
THOUGHT: [Summary of what was accomplished]
|
||
|
|
ACTION: task_complete
|
||
|
|
PARAMS: {"summary": "Description of completed work"}
|
||
|
|
|
||
|
|
OR when you need user input:
|
||
|
|
|
||
|
|
THOUGHT: [Why you need to ask the user]
|
||
|
|
ACTION: ask_user
|
||
|
|
PARAMS: {"question": "Your question"}
|
||
|
|
|
||
|
|
## Rules
|
||
|
|
1. Always start with THOUGHT to explain your reasoning
|
||
|
|
2. Always specify exactly one ACTION
|
||
|
|
3. Always provide PARAMS as valid JSON (even if empty: {})
|
||
|
|
4. Use tools to verify your work - don't assume success
|
||
|
|
5. If a tool fails, analyze the error and try a different approach
|
||
|
|
6. Only use task_complete when the task is fully done
|
||
|
|
|
||
|
|
{tools_description}
|
||
|
|
"""
|
||
|
|
|
||
|
|
def __init__(
|
||
|
|
self,
|
||
|
|
llm: LLM = None,
|
||
|
|
tools: ToolRegistry = None,
|
||
|
|
max_steps: int = 20,
|
||
|
|
verbose: bool = True
|
||
|
|
):
|
||
|
|
"""Initialize the agent.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
llm: LLM instance to use. Uses global if not provided.
|
||
|
|
tools: Tool registry to use. Uses global if not provided.
|
||
|
|
max_steps: Maximum steps before stopping.
|
||
|
|
verbose: Whether to print progress.
|
||
|
|
"""
|
||
|
|
self.llm = llm or get_llm()
|
||
|
|
self.tools = tools or get_tool_registry()
|
||
|
|
self.max_steps = max_steps
|
||
|
|
self.verbose = verbose
|
||
|
|
|
||
|
|
self.state = AgentState.IDLE
|
||
|
|
self.current_task: Optional[str] = None
|
||
|
|
self.steps: List[AgentStep] = []
|
||
|
|
self.conversation: List[Dict[str, str]] = []
|
||
|
|
|
||
|
|
# Callbacks
|
||
|
|
self.on_step: Optional[Callable[[AgentStep], None]] = None
|
||
|
|
self.on_state_change: Optional[Callable[[AgentState], None]] = None
|
||
|
|
|
||
|
|
def _set_state(self, state: AgentState):
|
||
|
|
"""Update agent state and notify callback."""
|
||
|
|
self.state = state
|
||
|
|
if self.on_state_change:
|
||
|
|
self.on_state_change(state)
|
||
|
|
|
||
|
|
def _log(self, message: str, level: str = "info"):
|
||
|
|
"""Log a message if verbose mode is on."""
|
||
|
|
if not self.verbose:
|
||
|
|
return
|
||
|
|
|
||
|
|
colors = {
|
||
|
|
"info": Colors.CYAN,
|
||
|
|
"success": Colors.GREEN,
|
||
|
|
"warning": Colors.YELLOW,
|
||
|
|
"error": Colors.RED,
|
||
|
|
"thought": Colors.MAGENTA,
|
||
|
|
"action": Colors.BLUE,
|
||
|
|
"result": Colors.WHITE,
|
||
|
|
}
|
||
|
|
symbols = {
|
||
|
|
"info": "*",
|
||
|
|
"success": "+",
|
||
|
|
"warning": "!",
|
||
|
|
"error": "X",
|
||
|
|
"thought": "?",
|
||
|
|
"action": ">",
|
||
|
|
"result": "<",
|
||
|
|
}
|
||
|
|
|
||
|
|
color = colors.get(level, Colors.WHITE)
|
||
|
|
symbol = symbols.get(level, "*")
|
||
|
|
print(f"{color}[{symbol}] {message}{Colors.RESET}")
|
||
|
|
|
||
|
|
def _build_system_prompt(self) -> str:
|
||
|
|
"""Build the system prompt with tools description."""
|
||
|
|
tools_desc = self.tools.get_tools_prompt()
|
||
|
|
return self.SYSTEM_PROMPT.format(tools_description=tools_desc)
|
||
|
|
|
||
|
|
def _parse_response(self, response: str) -> tuple[str, str, Dict[str, Any]]:
|
||
|
|
"""Parse LLM response into thought, action, and params.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
response: The raw LLM response.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Tuple of (thought, action_name, params_dict)
|
||
|
|
|
||
|
|
Raises:
|
||
|
|
ValueError: If response cannot be parsed.
|
||
|
|
"""
|
||
|
|
# Extract THOUGHT
|
||
|
|
thought_match = re.search(r'THOUGHT:\s*(.+?)(?=ACTION:|$)', response, re.DOTALL)
|
||
|
|
thought = thought_match.group(1).strip() if thought_match else ""
|
||
|
|
|
||
|
|
# Extract ACTION
|
||
|
|
action_match = re.search(r'ACTION:\s*(\w+)', response)
|
||
|
|
if not action_match:
|
||
|
|
raise ValueError("No ACTION found in response")
|
||
|
|
action = action_match.group(1).strip()
|
||
|
|
|
||
|
|
# Extract PARAMS
|
||
|
|
params_match = re.search(r'PARAMS:\s*(\{.*?\})', response, re.DOTALL)
|
||
|
|
if params_match:
|
||
|
|
try:
|
||
|
|
params = json.loads(params_match.group(1))
|
||
|
|
except json.JSONDecodeError:
|
||
|
|
# Try to fix common JSON issues
|
||
|
|
params_str = params_match.group(1)
|
||
|
|
# Replace single quotes with double quotes
|
||
|
|
params_str = params_str.replace("'", '"')
|
||
|
|
try:
|
||
|
|
params = json.loads(params_str)
|
||
|
|
except json.JSONDecodeError:
|
||
|
|
params = {}
|
||
|
|
else:
|
||
|
|
params = {}
|
||
|
|
|
||
|
|
return thought, action, params
|
||
|
|
|
||
|
|
def _execute_tool(self, tool_name: str, params: Dict[str, Any]) -> str:
|
||
|
|
"""Execute a tool and return the result.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
tool_name: Name of the tool to execute.
|
||
|
|
params: Parameters for the tool.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
Tool result string.
|
||
|
|
"""
|
||
|
|
result = self.tools.execute(tool_name, **params)
|
||
|
|
|
||
|
|
if result["success"]:
|
||
|
|
return str(result["result"])
|
||
|
|
else:
|
||
|
|
return f"[Error]: {result['error']}"
|
||
|
|
|
||
|
|
def run(self, task: str, user_input_handler: Callable[[str], str] = None,
|
||
|
|
step_callback: Optional[Callable[['AgentStep'], None]] = None) -> AgentResult:
|
||
|
|
"""Run the agent on a task.
|
||
|
|
|
||
|
|
Args:
|
||
|
|
task: The task description.
|
||
|
|
user_input_handler: Callback for handling ask_user actions.
|
||
|
|
If None, uses default input().
|
||
|
|
step_callback: Optional per-step callback invoked after each step completes.
|
||
|
|
Overrides self.on_step for this run if provided.
|
||
|
|
|
||
|
|
Returns:
|
||
|
|
AgentResult with execution details.
|
||
|
|
"""
|
||
|
|
if step_callback is not None:
|
||
|
|
self.on_step = step_callback
|
||
|
|
self.current_task = task
|
||
|
|
self.steps = []
|
||
|
|
self.conversation = []
|
||
|
|
|
||
|
|
# Ensure model is loaded
|
||
|
|
if not self.llm.is_loaded:
|
||
|
|
self._log("Loading model...", "info")
|
||
|
|
try:
|
||
|
|
self.llm.load_model(verbose=self.verbose)
|
||
|
|
except LLMError as e:
|
||
|
|
self._set_state(AgentState.ERROR)
|
||
|
|
return AgentResult(
|
||
|
|
success=False,
|
||
|
|
summary="Failed to load model",
|
||
|
|
error=str(e)
|
||
|
|
)
|
||
|
|
|
||
|
|
self._set_state(AgentState.THINKING)
|
||
|
|
self._log(f"Starting task: {task}", "info")
|
||
|
|
|
||
|
|
# Build initial prompt
|
||
|
|
system_prompt = self._build_system_prompt()
|
||
|
|
self.conversation.append({"role": "system", "content": system_prompt})
|
||
|
|
self.conversation.append({"role": "user", "content": f"Task: {task}"})
|
||
|
|
|
||
|
|
step_count = 0
|
||
|
|
|
||
|
|
while step_count < self.max_steps:
|
||
|
|
step_count += 1
|
||
|
|
self._log(f"Step {step_count}/{self.max_steps}", "info")
|
||
|
|
|
||
|
|
# Generate response
|
||
|
|
self._set_state(AgentState.THINKING)
|
||
|
|
try:
|
||
|
|
prompt = self._build_prompt()
|
||
|
|
response = self.llm.generate(
|
||
|
|
prompt,
|
||
|
|
stop=["OBSERVATION:", "\nUser:", "\nTask:"],
|
||
|
|
temperature=0.3, # Lower temperature for more focused responses
|
||
|
|
)
|
||
|
|
except LLMError as e:
|
||
|
|
self._set_state(AgentState.ERROR)
|
||
|
|
return AgentResult(
|
||
|
|
success=False,
|
||
|
|
summary="LLM generation failed",
|
||
|
|
steps=self.steps,
|
||
|
|
error=str(e)
|
||
|
|
)
|
||
|
|
|
||
|
|
# Parse response
|
||
|
|
try:
|
||
|
|
thought, action, params = self._parse_response(response)
|
||
|
|
except ValueError as e:
|
||
|
|
self._log(f"Failed to parse response: {e}", "error")
|
||
|
|
self._log(f"Raw response: {response[:200]}...", "warning")
|
||
|
|
# Add error feedback and continue
|
||
|
|
self.conversation.append({
|
||
|
|
"role": "assistant",
|
||
|
|
"content": response
|
||
|
|
})
|
||
|
|
self.conversation.append({
|
||
|
|
"role": "user",
|
||
|
|
"content": "Error: Could not parse your response. Please use the exact format:\nTHOUGHT: [reasoning]\nACTION: [tool_name]\nPARAMS: {\"param\": \"value\"}"
|
||
|
|
})
|
||
|
|
continue
|
||
|
|
|
||
|
|
self._log(f"Thought: {thought[:100]}..." if len(thought) > 100 else f"Thought: {thought}", "thought")
|
||
|
|
self._log(f"Action: {action}", "action")
|
||
|
|
|
||
|
|
step = AgentStep(thought=thought, tool_name=action, tool_args=params)
|
||
|
|
|
||
|
|
# Handle task_complete
|
||
|
|
if action == "task_complete":
|
||
|
|
summary = params.get("summary", thought)
|
||
|
|
step.tool_result = summary
|
||
|
|
self.steps.append(step)
|
||
|
|
|
||
|
|
if self.on_step:
|
||
|
|
self.on_step(step)
|
||
|
|
|
||
|
|
self._set_state(AgentState.COMPLETE)
|
||
|
|
self._log(f"Task complete: {summary}", "success")
|
||
|
|
|
||
|
|
return AgentResult(
|
||
|
|
success=True,
|
||
|
|
summary=summary,
|
||
|
|
steps=self.steps
|
||
|
|
)
|
||
|
|
|
||
|
|
# Handle ask_user
|
||
|
|
if action == "ask_user":
|
||
|
|
question = params.get("question", "What should I do?")
|
||
|
|
self._set_state(AgentState.WAITING_USER)
|
||
|
|
self._log(f"Agent asks: {question}", "info")
|
||
|
|
|
||
|
|
if user_input_handler:
|
||
|
|
user_response = user_input_handler(question)
|
||
|
|
else:
|
||
|
|
print(f"\n{Colors.YELLOW}Agent question: {question}{Colors.RESET}")
|
||
|
|
user_response = input(f"{Colors.GREEN}Your answer: {Colors.RESET}").strip()
|
||
|
|
|
||
|
|
step.tool_result = f"User response: {user_response}"
|
||
|
|
self.steps.append(step)
|
||
|
|
|
||
|
|
if self.on_step:
|
||
|
|
self.on_step(step)
|
||
|
|
|
||
|
|
# Add to conversation
|
||
|
|
self.conversation.append({
|
||
|
|
"role": "assistant",
|
||
|
|
"content": f"THOUGHT: {thought}\nACTION: {action}\nPARAMS: {json.dumps(params)}"
|
||
|
|
})
|
||
|
|
self.conversation.append({
|
||
|
|
"role": "user",
|
||
|
|
"content": f"OBSERVATION: User responded: {user_response}"
|
||
|
|
})
|
||
|
|
continue
|
||
|
|
|
||
|
|
# Execute tool
|
||
|
|
self._set_state(AgentState.EXECUTING)
|
||
|
|
self._log(f"Executing: {action}({params})", "action")
|
||
|
|
|
||
|
|
result = self._execute_tool(action, params)
|
||
|
|
step.tool_result = result
|
||
|
|
self.steps.append(step)
|
||
|
|
|
||
|
|
if self.on_step:
|
||
|
|
self.on_step(step)
|
||
|
|
|
||
|
|
# Truncate long results for display
|
||
|
|
display_result = result[:200] + "..." if len(result) > 200 else result
|
||
|
|
self._log(f"Result: {display_result}", "result")
|
||
|
|
|
||
|
|
# Add to conversation
|
||
|
|
self.conversation.append({
|
||
|
|
"role": "assistant",
|
||
|
|
"content": f"THOUGHT: {thought}\nACTION: {action}\nPARAMS: {json.dumps(params)}"
|
||
|
|
})
|
||
|
|
self.conversation.append({
|
||
|
|
"role": "user",
|
||
|
|
"content": f"OBSERVATION: {result}"
|
||
|
|
})
|
||
|
|
|
||
|
|
# Max steps reached
|
||
|
|
self._set_state(AgentState.ERROR)
|
||
|
|
self._log(f"Max steps ({self.max_steps}) reached", "warning")
|
||
|
|
|
||
|
|
return AgentResult(
|
||
|
|
success=False,
|
||
|
|
summary="Max steps reached without completing task",
|
||
|
|
steps=self.steps,
|
||
|
|
error=f"Exceeded maximum of {self.max_steps} steps"
|
||
|
|
)
|
||
|
|
|
||
|
|
def _build_prompt(self) -> str:
|
||
|
|
"""Build the full prompt from conversation history."""
|
||
|
|
parts = []
|
||
|
|
for msg in self.conversation:
|
||
|
|
role = msg["role"]
|
||
|
|
content = msg["content"]
|
||
|
|
|
||
|
|
if role == "system":
|
||
|
|
parts.append(f"<|im_start|>system\n{content}<|im_end|>")
|
||
|
|
elif role == "user":
|
||
|
|
parts.append(f"<|im_start|>user\n{content}<|im_end|>")
|
||
|
|
elif role == "assistant":
|
||
|
|
parts.append(f"<|im_start|>assistant\n{content}<|im_end|>")
|
||
|
|
|
||
|
|
parts.append("<|im_start|>assistant\n")
|
||
|
|
return "\n".join(parts)
|
||
|
|
|
||
|
|
def get_steps_summary(self) -> str:
|
||
|
|
"""Get a formatted summary of all steps taken."""
|
||
|
|
if not self.steps:
|
||
|
|
return "No steps executed"
|
||
|
|
|
||
|
|
lines = []
|
||
|
|
for i, step in enumerate(self.steps, 1):
|
||
|
|
lines.append(f"Step {i}:")
|
||
|
|
lines.append(f" Thought: {step.thought[:80]}...")
|
||
|
|
if step.tool_name:
|
||
|
|
lines.append(f" Action: {step.tool_name}")
|
||
|
|
if step.tool_result:
|
||
|
|
result_preview = step.tool_result[:80] + "..." if len(step.tool_result) > 80 else step.tool_result
|
||
|
|
lines.append(f" Result: {result_preview}")
|
||
|
|
lines.append("")
|
||
|
|
|
||
|
|
return "\n".join(lines)
|