File size: 10,669 Bytes
b8e5043
a7c4301
b8e5043
a7c4301
 
 
b8e5043
 
 
6d49dc7
b8e5043
 
 
411f347
6d49dc7
b8e5043
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
411f347
 
 
b8e5043
 
 
 
 
411f347
b8e5043
411f347
b8e5043
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a7c4301
 
 
b8e5043
a7c4301
 
 
f6e9077
b8e5043
 
 
 
 
 
411f347
a7c4301
b8e5043
 
 
 
 
 
 
411f347
a7c4301
b8e5043
 
a7c4301
 
b8e5043
a7c4301
 
b8e5043
a7c4301
b8e5043
 
 
 
 
 
 
 
 
 
 
 
f6e9077
b8e5043
f6e9077
b8e5043
 
 
 
f6e9077
b8e5043
a7c4301
 
b8e5043
 
 
 
 
f6e9077
 
 
 
 
b8e5043
 
 
 
 
f6e9077
b8e5043
 
 
 
 
f6e9077
b8e5043
 
 
 
 
 
 
a7c4301
 
b8e5043
a7c4301
 
f6e9077
a7c4301
 
 
b8e5043
a7c4301
 
 
 
b8e5043
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6d49dc7
 
 
b8e5043
 
6d49dc7
b8e5043
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
286
287
288
289
290
"""Agent loop - core LLM call → tool execution → result cycle."""

from __future__ import annotations

from pathlib import Path

from loguru import logger

from agent.context_manager import ContextManager
from agent.llm_client import BaseLLMClient
from tools.bash_tool import run_bash
from tools.file_tools import run_read, run_write, run_edit
from tools.code_executor import run_create_plugin
from tools.browser_tool import run_browser
from tools.memory_tools import run_recall, run_remember


# Tool definitions sent to the API
TOOLS = [
    {
        "name": "bash",
        "description": "Execute a shell command. Use for: ls, find, grep, git, python, npm, etc.",
        "input_schema": {
            "type": "object",
            "properties": {
                "command": {"type": "string", "description": "The shell command to execute"}
            },
            "required": ["command"],
        },
    },
    {
        "name": "read_file",
        "description": "Read file contents. Returns UTF-8 text.",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "File path"},
                "limit": {"type": "integer", "description": "Max lines to read (default: all)"},
            },
            "required": ["path"],
        },
    },
    {
        "name": "write_file",
        "description": "Write content to a file. Creates parent directories if needed.",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "File path"},
                "content": {"type": "string", "description": "Content to write"},
            },
            "required": ["path", "content"],
        },
    },
    {
        "name": "edit_file",
        "description": "Replace exact text in a file. Finds old_text and replaces with new_text.",
        "input_schema": {
            "type": "object",
            "properties": {
                "path": {"type": "string", "description": "File path"},
                "old_text": {"type": "string", "description": "Exact text to find"},
                "new_text": {"type": "string", "description": "Replacement text"},
            },
            "required": ["path", "old_text", "new_text"],
        },
    },
    {
        "name": "remember",
        "description": (
            "Store information to long-term memory.\n"
            "Use for: important information, preferences, decisions, lessons learned.\n"
            "Organize by category (e.g., 'python', 'ai', 'project-x')."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "content": {"type": "string", "description": "Information to store"},
                "category": {"type": "string", "description": "Category for organizing memories"},
            },
            "required": ["content"],
        },
    },
    {
        "name": "recall",
        "description": "Search long-term memory for relevant information.",
        "input_schema": {
            "type": "object",
            "properties": {
                "query": {"type": "string", "description": "Search query"},
            },
            "required": ["query"],
        },
    },
    {
        "name": "create_plugin",
        "description": (
            "Create a new plugin with Python code. The code must define a class "
            "inheriting from BasePlugin. Use when you identify repeatable patterns."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "code": {"type": "string", "description": "Python plugin code"},
                "name": {"type": "string", "description": "Plugin name"},
                "description": {"type": "string", "description": "Plugin description"},
            },
            "required": ["code", "name", "description"],
        },
    },
    {
        "name": "browser",
        "description": (
            "Browser automation. Actions: navigate, click, type, screenshot, extract.\n"
            "Maintains cookies and login state across calls."
        ),
        "input_schema": {
            "type": "object",
            "properties": {
                "action": {
                    "type": "string",
                    "enum": ["navigate", "click", "type", "screenshot", "extract"],
                    "description": "Browser action to perform",
                },
                "url": {"type": "string", "description": "URL (for navigate)"},
                "selector": {"type": "string", "description": "CSS selector (for click/type)"},
                "text": {"type": "string", "description": "Text to type (for type action)"},
            },
            "required": ["action"],
        },
    },
]


class AgentLoop:
    """Core agent loop: LLM call → tool execution → result append → continue."""

    def __init__(
        self,
        client: BaseLLMClient,
        system_prompt: str,
        model: str = "claude-sonnet-4-5-20250929",
        max_tokens: int = 8000,
        context_manager: ContextManager | None = None,
        workdir: Path | None = None,
        plugin_manager=None,
        core_context_provider=None,
    ):
        self.client = client
        self.system_prompt = system_prompt
        self.model = model
        self.max_tokens = max_tokens
        self.context_manager = context_manager
        self.workdir = workdir
        self.plugin_manager = plugin_manager
        self.core_context_provider = core_context_provider

    async def run(self, messages: list[dict]) -> list[dict]:
        """Run the agent loop until the model stops calling tools.

        Args:
            messages: Conversation history (modified in place).

        Returns:
            Updated messages list.
        """
        while True:
            # Context compaction
            if self.context_manager:
                messages = await self.context_manager.maybe_compact(messages)

            # Gather all tools (built-in + plugin)
            all_tools = list(TOOLS)
            if self.plugin_manager:
                all_tools.extend(self.plugin_manager.get_all_tools())

            # Call LLM
            try:
                response = await self.client.create_message(
                    model=self.model,
                    system_prompt=self.system_prompt,
                    messages=messages,
                    tools=all_tools,
                    max_tokens=self.max_tokens,
                )
            except Exception as e:
                logger.error(f"API error: {e}")
                messages.append({
                    "role": "assistant",
                    "content": [{"type": "text", "text": f"[API Error: {e}]"}],
                })
                break

            # Extract text and tool calls
            for block in response.content_blocks:
                if isinstance(block, dict) and block.get("type") == "text":
                    text = block.get("text", "")
                    if text:
                        logger.info(f"Assistant: {text[:200]}")

            # If no tool calls, conversation turn is done
            if response.stop_reason != "tool_use":
                messages.append({
                    "role": "assistant",
                    "content": response.content_blocks,
                })
                break

            # Execute tools and collect results
            results = []
            for tc in response.tool_calls:
                logger.info(f"Tool: {tc.name}({_preview_args(tc.input)})")
                output = await self._execute_tool(tc.name, tc.input)
                logger.debug(f"Result: {output[:200]}")
                results.append({
                    "type": "tool_result",
                    "tool_use_id": tc.id,
                    "content": output,
                })

            # Append assistant message and tool results
            messages.append({
                "role": "assistant",
                "content": response.content_blocks,
            })
            messages.append({
                "role": "user",
                "content": results,
            })

        return messages

    async def _execute_tool(self, name: str, args: dict) -> str:
        """Dispatch a tool call to the appropriate handler."""
        try:
            if name == "bash":
                return await run_bash(args["command"], workdir=self.workdir)
            elif name == "read_file":
                return await run_read(
                    args["path"], workdir=self.workdir, limit=args.get("limit")
                )
            elif name == "write_file":
                return await run_write(args["path"], args["content"], workdir=self.workdir)
            elif name == "edit_file":
                return await run_edit(
                    args["path"], args["old_text"], args["new_text"], workdir=self.workdir
                )
            elif name == "remember":
                category = args.get("category")
                if category is not None and not isinstance(category, str):
                    return "Error: category must be a string"
                return await run_remember(
                    args["content"],
                    category=category,
                )
            elif name == "recall":
                return await run_recall(args["query"])
            elif name == "create_plugin":
                return await run_create_plugin(
                    args["code"], args["name"], args["description"]
                )
            elif name == "browser":
                return await run_browser(
                    args["action"],
                    url=args.get("url", ""),
                    selector=args.get("selector", ""),
                    text=args.get("text", ""),
                )
            else:
                # Try plugin tools
                if self.plugin_manager:
                    result = await self.plugin_manager.execute_plugin_tool(name, args)
                    if result is not None:
                        return result
                return f"Unknown tool: {name}"
        except Exception as e:
            logger.error(f"Tool execution error ({name}): {e}")
            return f"Error executing {name}: {e}"


def _preview_args(args: dict, max_len: int = 100) -> str:
    """Create a short preview of tool arguments for logging."""
    parts = []
    for k, v in args.items():
        s = str(v)
        if len(s) > max_len:
            s = s[:max_len] + "..."
        parts.append(f"{k}={s}")
    return ", ".join(parts)