summaryrefslogtreecommitdiff
path: root/src/agent.py
diff options
context:
space:
mode:
Diffstat (limited to 'src/agent.py')
-rw-r--r--src/agent.py327
1 files changed, 327 insertions, 0 deletions
diff --git a/src/agent.py b/src/agent.py
new file mode 100644
index 0000000..241c35c
--- /dev/null
+++ b/src/agent.py
@@ -0,0 +1,327 @@
+"""
+Agent class that uses the provider abstraction layer.
+Handles conversation loops, tool execution, and logging.
+"""
+import json
+import os
+import datetime
+from typing import List, Dict, Any, Optional
+from vfs import VFS
+from provider import ProviderAdapter, LLMResponse, ReasoningStep, create_provider_adapter
+from config_loader import ProviderConfig, ModelConfig
+from tools import tools, available_functions
+
+
+class Agent:
+ def __init__(
+ self,
+ system_prompt: str = "You are a helpful assistant.",
+ provider_adapter: ProviderAdapter = None,
+ scenario: str = "default",
+ oversight_level: str = "default",
+ user_prompt_type: str = "default"
+ ):
+ self.provider = provider_adapter
+ self.system_prompt = system_prompt
+ self.scenario = scenario
+ self.oversight_level = oversight_level
+ self.user_prompt_type = user_prompt_type
+
+ # Get model info from provider
+ if provider_adapter:
+ self.model = provider_adapter.model_config.id
+ self.temperature = provider_adapter.model_config.temperature
+ else:
+ self.model = "unknown"
+ self.temperature = 1.0
+
+ self.tools = tools
+ self.available_functions = available_functions
+ self.logs: List[Dict] = []
+ self.total_tokens = 0
+ self.prompt_tokens = 0
+ self.completion_tokens = 0
+
+ @classmethod
+ def from_configs(
+ cls,
+ system_prompt: str,
+ provider_config: ProviderConfig,
+ model_config: ModelConfig,
+ scenario: str = "default",
+ oversight_level: str = "default",
+ user_prompt_type: str = "default"
+ ) -> "Agent":
+ """Create an Agent from provider and model configs."""
+ adapter = create_provider_adapter(provider_config, model_config)
+ return Agent(
+ system_prompt=system_prompt,
+ provider_adapter=adapter,
+ scenario=scenario,
+ oversight_level=oversight_level,
+ user_prompt_type=user_prompt_type
+ )
+
+ def run(self, initial_prompt: str):
+ """Run the agent with an initial prompt."""
+ messages = [
+ {'role': 'system', 'content': self.system_prompt},
+ {'role': 'user', 'content': initial_prompt}
+ ]
+ self.logs.extend(messages)
+ return self.chat_loop(messages)
+
+ def load_conversation(
+ self,
+ conversation_history: List[Dict],
+ total_tokens: int = 0,
+ prompt_tokens: int = 0,
+ completion_tokens: int = 0
+ ):
+ """Load a conversation from history (for interrogation)."""
+ self.logs = conversation_history
+ self.total_tokens = total_tokens
+ self.prompt_tokens = prompt_tokens
+ self.completion_tokens = completion_tokens
+
+ def chat(self, user_input: str):
+ """Send a message and get response."""
+ self.logs.append({'role': 'user', 'content': user_input})
+ messages = list(self.logs)
+ return self.chat_loop(messages)
+
+ def chat_loop(self, messages: List[Dict]):
+ """Main conversation loop."""
+ turn_count = 0
+ while True:
+ llm_response = self.provider.call(messages, self.tools)
+
+ # Update token counts
+ if llm_response.usage:
+ self.total_tokens += llm_response.usage.get("total_tokens", 0)
+ self.prompt_tokens += llm_response.usage.get("prompt_tokens", 0)
+ self.completion_tokens += llm_response.usage.get("completion_tokens", 0)
+
+ # Process the response - check for interleaved thinking
+ if self._has_interleaved_thinking(llm_response):
+ result = self._handle_interleaved(messages, llm_response)
+ if result is not None:
+ return result
+ else:
+ result = self._handle_standard(messages, llm_response, is_first_turn=(turn_count == 0))
+ if result is not None:
+ return result
+
+ turn_count += 1
+
+ def _has_interleaved_thinking(self, response: LLMResponse) -> bool:
+ """Check if response has interleaved thinking (reasoning between tool calls)."""
+ # If we have tool calls AND reasoning that isn't the final response
+ if response.tool_calls and response.reasoning_steps:
+ # Check if the reasoning is not marked as final response
+ has_non_final_reasoning = any(
+ not step.is_final_response
+ for step in response.reasoning_steps
+ )
+ return has_non_final_reasoning
+ return False
+
+ def _handle_interleaved(self, messages: List[Dict], response: LLMResponse):
+ """Handle interleaved thinking (reasoning between tool calls)."""
+ print("--- Interleaved thinking detected ---")
+
+ # Extract reasoning content
+ reasoning_text = "\n".join(
+ step.content for step in response.reasoning_steps
+ if not step.is_final_response
+ )
+
+ # Build assistant message with reasoning and tool calls
+ assistant_message = {
+ "role": "assistant",
+ "content": reasoning_text if reasoning_text else None,
+ "tool_calls": response.tool_calls
+ }
+ messages.append(assistant_message)
+
+ # Log the assistant message
+ self.logs.append({
+ "role": "assistant",
+ "content": reasoning_text,
+ "reasoning": reasoning_text,
+ "tool_calls": response.tool_calls,
+ "interleaved_thinking": True,
+ "response_metadata": {
+ "model": self.model,
+ "usage": response.usage
+ }
+ })
+
+ # print(f"--- Reasoning ---\n{reasoning_text[:200]}..." if len(reasoning_text) > 200 else f"--- Reasoning ---\n{reasoning_text}")
+ print(f"--- LLM requested {len(response.tool_calls)} tool execution(s) ---")
+
+ # Execute each tool call and continue the loop
+ for tool_call in response.tool_calls:
+ result = self._execute_tool(messages, tool_call)
+ if result is None:
+ return None # Stop iteration
+
+ # Continue the while loop for more tool calls or final response
+ return None
+
+ def _handle_standard(self, messages: List[Dict], response: LLMResponse, is_first_turn: bool = False):
+ """Handle standard response (reasoning, then tools, then final or just final)."""
+ content = response.content or ""
+ reasoning = response.reasoning_steps[0].content if response.reasoning_steps else None
+
+ # If there are tool calls
+ if response.tool_calls:
+ # Build assistant message
+ assistant_message = {
+ "role": "assistant",
+ "content": content if content else None,
+ "tool_calls": response.tool_calls
+ }
+ messages.append(assistant_message)
+
+ # Log entry
+ self.logs.append({
+ "role": "assistant",
+ "content": content,
+ "reasoning": reasoning,
+ "tool_calls": response.tool_calls,
+ "response_metadata": {
+ "model": self.model,
+ "usage": response.usage
+ }
+ })
+
+ print(f"--- LLM requested {len(response.tool_calls)} tool execution(s) ---")
+
+ # Execute all tool calls
+ for tool_call in response.tool_calls:
+ result = self._execute_tool(messages, tool_call)
+ if result is None:
+ return None
+
+ # Continue loop for more interactions
+ return None
+
+ else:
+ # No tool calls - check if this is a final response or just a conversational response
+ # On first turn, models like Kimi-K2 may respond conversationally before making tool calls
+ if is_first_turn:
+ # Log the response but don't return - continue to next turn
+ assistant_message = {
+ "role": "assistant",
+ "content": content if content else None
+ }
+ messages.append(assistant_message)
+
+ self.logs.append({
+ "role": "assistant",
+ "content": content,
+ "reasoning": reasoning,
+ "tool_calls": None,
+ "response_metadata": {
+ "model": self.model,
+ "usage": response.usage
+ }
+ })
+
+ # Continue to next turn - don't return
+ return None
+ else:
+ # Not first turn and no tool calls - this is a final response
+ assistant_message = {
+ "role": "assistant",
+ "content": content if content else None
+ }
+ messages.append(assistant_message)
+
+ # Log entry
+ self.logs.append({
+ "role": "assistant",
+ "content": content,
+ "reasoning": reasoning,
+ "tool_calls": None,
+ "response_metadata": {
+ "model": self.model,
+ "usage": response.usage
+ }
+ })
+
+ # print(f"\n--- Final LLM Response ---\n{content}")
+ return content
+
+ def _execute_tool(self, messages: List[Dict], tool_call: Dict) -> Optional[str]:
+ """Execute a single tool call."""
+ function_name = tool_call["function"]["name"]
+ function_args = json.loads(tool_call["function"]["arguments"])
+
+ function_to_call = self.available_functions.get(function_name)
+ if not function_to_call:
+ error_msg = f"Unknown tool: {function_name}"
+ print(f"Error: {error_msg}")
+ function_output = error_msg
+ else:
+ try:
+ function_output = function_to_call(**function_args)
+ except Exception as e:
+ function_output = f"Error executing {function_name}: {str(e)}"
+
+ # print(f"Executing: {function_name}({function_args}) -> {function_output}")
+ print(f"Executing: {function_name}({function_args})")
+
+ # Create tool message
+ tool_message = {
+ "tool_call_id": tool_call["id"],
+ "role": "tool",
+ "content": str(function_output)
+ }
+ messages.append(tool_message)
+ self.logs.append(tool_message)
+
+ return str(function_output)
+
+ def save_logs(
+ self,
+ output_dir: str = "logs",
+ scenario: str = None,
+ oversight_level: str = None
+ ):
+ """Save conversation logs to a JSON file."""
+ timestamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
+ model_name_safe = self.model.replace("/", "_")
+ scenario_name = (scenario or self.scenario).replace("/", "_")
+ oversight = oversight_level or self.oversight_level
+
+ # Directory structure: output/{model}/{scenario}/{oversight}/
+ base_dir = os.path.join(output_dir, model_name_safe, scenario_name, oversight)
+ os.makedirs(base_dir, exist_ok=True)
+
+ # Filename: {timestamp}_run_id.json
+ filename_base = f"{timestamp}"
+ run_id = f"{model_name_safe}/{scenario_name}/{oversight}/{filename_base}"
+ log_file = os.path.join(base_dir, f"{filename_base}.json")
+
+ log_data = {
+ "run_id": run_id,
+ "model": self.model,
+ "scenario": scenario or self.scenario,
+ "oversight_level": oversight,
+ "user_prompt_type": self.user_prompt_type,
+ "temperature": self.temperature,
+ "base_url": str(self.provider.provider_config.base_url) if self.provider else None,
+ "extra_body_config": self.provider.model_config.extra_body if self.provider else {},
+ "final_vfs_state": VFS.get_instance().fs,
+ "total_tokens": self.total_tokens,
+ "prompt_tokens": self.prompt_tokens,
+ "completion_tokens": self.completion_tokens,
+ "conversation": self.logs
+ }
+
+ with open(log_file, "w") as f:
+ json.dump(log_data, f, indent=4)
+ print(f"\nLogs saved to {log_file}")
+ return log_file