summaryrefslogtreecommitdiff
path: root/src/agent.py
blob: 0afa61643d8df350e42619d5a07a75b51b20efc4 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
"""
Simple agent using OpenAI SDK directly.
"""
import json
import os
import re
import datetime
from typing import List, Dict
from vfs import VFS
from openai import OpenAI
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.",
        model: str = "unknown",
        base_url: str = None,
        api_key: str = None,
        temperature: float = 1.0,
        extra_body: dict = None,
        scenario: str = "default",
        oversight_level: str = "default",
        user_prompt_type: str = "default",
        save_vfs_state: bool = True
    ):
        self.client = OpenAI(base_url=base_url, api_key=api_key) if base_url and api_key else None
        self.model = model
        self.temperature = temperature
        self.extra_body = extra_body
        self.system_prompt = system_prompt
        self.scenario = scenario
        self.oversight_level = oversight_level
        self.user_prompt_type = user_prompt_type
        self.save_vfs_state = save_vfs_state

        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",
        save_vfs_state: bool = True
    ) -> "Agent":
        """Create an Agent from provider and model configs."""
        return Agent(
            system_prompt=system_prompt,
            model=model_config.id,
            base_url=provider_config.base_url,
            api_key=provider_config.api_key,
            temperature=model_config.temperature,
            extra_body=model_config.extra_body,
            scenario=scenario,
            oversight_level=oversight_level,
            user_prompt_type=user_prompt_type,
            save_vfs_state=save_vfs_state
        )

    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], max_turns: int = 20):
        """Main conversation loop using OpenAI SDK directly."""
        turn_count = 0
        while True:
            turn_count += 1
            if turn_count > max_turns:
                print(f"\n--- MAX TURNS REACHED ({max_turns}) ---")
                return None

            try:
                response = self.client.chat.completions.create(
                    model=self.model,
                    messages=messages,
                    tools=self.tools,
                    temperature=self.temperature,
                    extra_body=self.extra_body if self.extra_body else None,
                )
            except Exception as e:
                print(f"ERROR: API call failed: {e}")
                raise

            # Handle malformed responses
            if not response.choices:
                print(f"ERROR: Empty response from API. Response: {response}")
                raise Exception("Empty response from API")

            # Update token counts
            if response.usage:
                self.total_tokens += response.usage.total_tokens
                self.prompt_tokens += response.usage.prompt_tokens
                self.completion_tokens += response.usage.completion_tokens

            choice = response.choices[0]
            response_message = choice.message
            finish_reason = choice.finish_reason

            # Extract reasoning from raw response
            content = response_message.content or ""
            reasoning = None

            # Try to get reasoning from different sources
            # 1. Check for reasoning_content (OpenRouter)
            if hasattr(response_message, 'reasoning_content') and response_message.reasoning_content:
                reasoning = response_message.reasoning_content
            # 2. Check for reasoning_details (structured)
            elif hasattr(response_message, 'reasoning_details') and response_message.reasoning_details:
                reasoning_parts = []
                for item in response_message.reasoning_details:
                    if item.get("type") == "reasoning.text":
                        reasoning_parts.append(item.get("text", ""))
                reasoning = "\n".join(reasoning_parts).strip()
            # 3. Regex fallback for <thinking> tags
            else:
                thought_match = re.search(r"<(thinking|thought)>(.*?)</\1>", content, re.DOTALL)
                if thought_match:
                    reasoning = thought_match.group(2).strip()
                    content = content.replace(thought_match.group(0), "").strip()
                # If no tags and there are tool calls, content is reasoning
                elif response_message.tool_calls:
                    reasoning = content
                    content = None

            # Print reasoning if available
            if reasoning:
                print(f"\n--- REASONING ---\n{reasoning[:500]}..." if len(reasoning) > 500 else f"\n--- REASONING ---\n{reasoning}")

            # Append raw response message to preserve extra_content (Google thoughtSignature)
            messages.append(response_message)

            # Log entry
            log_entry = {
                "role": "assistant",
                "content": content,
                "reasoning": reasoning,
                "tool_calls": [
                    {
                        "id": tc.id,
                        "type": tc.type,
                        "function": {
                            "name": tc.function.name,
                            "arguments": tc.function.arguments
                        },
                        "extra_content": getattr(tc, "extra_content", None)
                    }
                    for tc in response_message.tool_calls
                ] if response_message.tool_calls else None,
                "finish_reason": finish_reason,
                "turn_count": turn_count,
                "response_metadata": {
                    "model": self.model,
                    "usage": {
                        "completion_tokens": response.usage.completion_tokens,
                        "prompt_tokens": response.usage.prompt_tokens,
                        "total_tokens": response.usage.total_tokens,
                    }
                }
            }
            self.logs.append(log_entry)

            # Check finish_reason to determine if we should continue or stop
            # "tool_calls" means model wants to call tools (continue)
            # "stop" means model wants to end conversation
            if finish_reason == "tool_calls":
                print(f"--- LLM requested {len(response_message.tool_calls)} tool execution(s) ---")
                for tool_call in response_message.tool_calls:
                    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})")

                    tool_message = {
                        "tool_call_id": tool_call.id,
                        "role": "tool",
                        "content": str(function_output),
                    }
                    messages.append(tool_message)
                    self.logs.append(tool_message)
            elif finish_reason == "stop":
                print(f"\n--- FINAL RESPONSE ---\n{content}")
                return content
            else:
                # Handle other finish reasons (length, content_filter, etc.)
                print(f"\n--- FINISH REASON: {finish_reason} ---")
                print(f"Content: {content[:200]}..." if len(content) > 200 else f"\nContent: {content}")
                return content

    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

        base_dir = os.path.join(output_dir, model_name_safe, scenario_name, oversight)
        os.makedirs(base_dir, exist_ok=True)

        log_file = os.path.join(base_dir, f"{timestamp}.json")

        log_data = {
            "run_id": f"{model_name_safe}/{scenario_name}/{oversight}/{timestamp}",
            "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.client.base_url) if self.client else None,
            "extra_body_config": self.extra_body or {},
            "total_tokens": self.total_tokens,
            "prompt_tokens": self.prompt_tokens,
            "completion_tokens": self.completion_tokens,
            "conversation": self.logs,
        }

        if self.save_vfs_state:
            log_data["final_vfs_state"] = VFS.get_instance().fs

        # Atomic write: write to temp file first, then rename.
        # This prevents corrupt log files if the process crashes mid-write.
        import tempfile
        fd, tmp_path = tempfile.mkstemp(dir=base_dir, suffix=".json.tmp")
        try:
            with os.fdopen(fd, "w") as f:
                json.dump(log_data, f, indent=4)
            os.rename(tmp_path, log_file)
        except BaseException:
            # Clean up temp file on any failure
            if os.path.exists(tmp_path):
                os.unlink(tmp_path)
            raise
        print(f"\nLogs saved to {log_file}")
        return log_file