vibesurf 0.1.10__py3-none-any.whl → 0.1.12__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of vibesurf might be problematic. Click here for more details.

Files changed (51) hide show
  1. vibe_surf/_version.py +2 -2
  2. vibe_surf/agents/browser_use_agent.py +68 -45
  3. vibe_surf/agents/prompts/report_writer_prompt.py +73 -0
  4. vibe_surf/agents/prompts/vibe_surf_prompt.py +85 -172
  5. vibe_surf/agents/report_writer_agent.py +380 -226
  6. vibe_surf/agents/vibe_surf_agent.py +880 -825
  7. vibe_surf/agents/views.py +130 -0
  8. vibe_surf/backend/api/activity.py +3 -1
  9. vibe_surf/backend/api/browser.py +9 -5
  10. vibe_surf/backend/api/config.py +8 -5
  11. vibe_surf/backend/api/files.py +59 -50
  12. vibe_surf/backend/api/models.py +2 -2
  13. vibe_surf/backend/api/task.py +46 -13
  14. vibe_surf/backend/database/manager.py +24 -18
  15. vibe_surf/backend/database/queries.py +199 -192
  16. vibe_surf/backend/database/schemas.py +1 -1
  17. vibe_surf/backend/main.py +4 -2
  18. vibe_surf/backend/shared_state.py +28 -35
  19. vibe_surf/backend/utils/encryption.py +3 -1
  20. vibe_surf/backend/utils/llm_factory.py +41 -36
  21. vibe_surf/browser/agent_browser_session.py +0 -4
  22. vibe_surf/browser/browser_manager.py +14 -8
  23. vibe_surf/browser/utils.py +5 -3
  24. vibe_surf/browser/watchdogs/dom_watchdog.py +0 -45
  25. vibe_surf/chrome_extension/background.js +4 -0
  26. vibe_surf/chrome_extension/scripts/api-client.js +13 -0
  27. vibe_surf/chrome_extension/scripts/file-manager.js +27 -71
  28. vibe_surf/chrome_extension/scripts/session-manager.js +21 -3
  29. vibe_surf/chrome_extension/scripts/ui-manager.js +831 -48
  30. vibe_surf/chrome_extension/sidepanel.html +21 -4
  31. vibe_surf/chrome_extension/styles/activity.css +365 -5
  32. vibe_surf/chrome_extension/styles/input.css +139 -0
  33. vibe_surf/cli.py +5 -22
  34. vibe_surf/common.py +35 -0
  35. vibe_surf/llm/openai_compatible.py +217 -99
  36. vibe_surf/logger.py +99 -0
  37. vibe_surf/{controller/vibesurf_tools.py → tools/browser_use_tools.py} +233 -219
  38. vibe_surf/tools/file_system.py +437 -0
  39. vibe_surf/{controller → tools}/mcp_client.py +4 -3
  40. vibe_surf/tools/report_writer_tools.py +21 -0
  41. vibe_surf/tools/vibesurf_tools.py +657 -0
  42. vibe_surf/tools/views.py +120 -0
  43. {vibesurf-0.1.10.dist-info → vibesurf-0.1.12.dist-info}/METADATA +6 -2
  44. {vibesurf-0.1.10.dist-info → vibesurf-0.1.12.dist-info}/RECORD +49 -43
  45. vibe_surf/controller/file_system.py +0 -53
  46. vibe_surf/controller/views.py +0 -37
  47. /vibe_surf/{controller → tools}/__init__.py +0 -0
  48. {vibesurf-0.1.10.dist-info → vibesurf-0.1.12.dist-info}/WHEEL +0 -0
  49. {vibesurf-0.1.10.dist-info → vibesurf-0.1.12.dist-info}/entry_points.txt +0 -0
  50. {vibesurf-0.1.10.dist-info → vibesurf-0.1.12.dist-info}/licenses/LICENSE +0 -0
  51. {vibesurf-0.1.10.dist-info → vibesurf-0.1.12.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,657 @@
1
+ from typing import Any, Generic, TypeVar
2
+ from browser_use.tools.registry.service import Registry
3
+ from pydantic import BaseModel
4
+ import pdb
5
+ import os
6
+ import asyncio
7
+ import json
8
+ import enum
9
+ import base64
10
+ import mimetypes
11
+ from datetime import datetime
12
+ from typing import Optional, Type, Callable, Dict, Any, Union, Awaitable, TypeVar
13
+ from pydantic import BaseModel
14
+ from browser_use.tools.service import Controller, Tools, handle_browser_error
15
+ import logging
16
+ from browser_use.agent.views import ActionModel, ActionResult
17
+ from browser_use.utils import time_execution_sync
18
+ from browser_use.filesystem.file_system import FileSystem
19
+ from browser_use.browser import BrowserSession
20
+ from browser_use.llm.base import BaseChatModel
21
+ from browser_use.llm.messages import UserMessage, ContentPartTextParam, ContentPartImageParam, ImageURL
22
+ from browser_use.dom.service import EnhancedDOMTreeNode
23
+ from browser_use.browser.views import BrowserError
24
+ from browser_use.mcp.client import MCPClient
25
+
26
+ from vibe_surf.browser.agent_browser_session import AgentBrowserSession
27
+ from vibe_surf.tools.views import HoverAction, ExtractionAction, FileExtractionAction, BrowserUseAgentExecution, \
28
+ ReportWriterTask, TodoGenerateAction, TodoModifyAction, VibeSurfDoneAction
29
+ from vibe_surf.tools.mcp_client import CustomMCPClient
30
+ from vibe_surf.tools.file_system import CustomFileSystem
31
+ from vibe_surf.browser.browser_manager import BrowserManager
32
+
33
+ from vibe_surf.logger import get_logger
34
+
35
+ logger = get_logger(__name__)
36
+
37
+ Context = TypeVar('Context')
38
+
39
+ T = TypeVar('T', bound=BaseModel)
40
+
41
+
42
+ class VibeSurfTools:
43
+ def __init__(self, exclude_actions: list[str] = [], mcp_server_config: Optional[Dict[str, Any]] = None):
44
+ self.registry = Registry(exclude_actions)
45
+ self._register_file_actions()
46
+ self._register_browser_use_agent()
47
+ self._register_report_writer_agent()
48
+ self._register_todo_actions()
49
+ self._register_done_action()
50
+ self.mcp_server_config = mcp_server_config
51
+ self.mcp_clients: Dict[str, MCPClient] = {}
52
+
53
+ def _register_browser_use_agent(self):
54
+ @self.registry.action(
55
+ 'Execute browser_use agent tasks. Supports both single task execution (list length=1) and '
56
+ 'parallel execution of multiple tasks for improved efficiency. '
57
+ 'Accepts a list of tasks where each task can specify a tab_id (optional), '
58
+ 'task description (focusing on goals and expected returns), and task_files (optional). '
59
+ 'Browser_use agent has strong planning and execution capabilities, only needs task descriptions and desired outcomes.',
60
+ param_model=BrowserUseAgentExecution,
61
+ )
62
+ async def execute_browser_use_agent(
63
+ params: BrowserUseAgentExecution,
64
+ ):
65
+ """
66
+ Execute browser_use agent tasks in parallel for improved efficiency.
67
+
68
+ Args:
69
+ params: BrowserUseAgentExecution containing list of tasks to execute
70
+ browser_manager: Browser manager instance
71
+ llm: Language model instance
72
+ file_system: File system instance
73
+
74
+ Returns:
75
+ ActionResult with execution results
76
+ """
77
+ # TODO: Implement parallel execution of browser_use agent tasks
78
+ # This is a placeholder implementation
79
+ pass
80
+
81
+ def _register_report_writer_agent(self):
82
+ @self.registry.action(
83
+ 'Execute report writer agent to generate HTML reports. '
84
+ 'Task should describe report requirements, goals, insights observed, and any hints or tips for generating the report.',
85
+ param_model=ReportWriterTask,
86
+ )
87
+ async def execute_report_writer_agent(
88
+ params: ReportWriterTask,
89
+ ):
90
+ """
91
+ Execute report writer agent to generate HTML reports.
92
+
93
+ Args:
94
+ params: ReportWriterTask containing task description with requirements and insights
95
+ browser_manager: Browser manager instance
96
+ llm: Language model instance
97
+ file_system: File system instance
98
+
99
+ Returns:
100
+ ActionResult with generated report path
101
+ """
102
+ # TODO: Implement report writer agent execution
103
+ # This is a placeholder implementation
104
+ pass
105
+
106
+ def _register_todo_actions(self):
107
+ @self.registry.action(
108
+ 'Generate a new todo.md file with the provided todo items in markdown checkbox format.'
109
+ )
110
+ async def generate_todos(todo_items: list[str], file_system: CustomFileSystem):
111
+ """Generate a new todo.md file with todo items in markdown format"""
112
+ try:
113
+ # Format todo items as markdown checkboxes
114
+ formatted_items = []
115
+ for item in todo_items:
116
+ # Clean item and ensure it doesn't already have checkbox format
117
+ clean_item = item.strip()
118
+ if clean_item.startswith('- ['):
119
+ formatted_items.append(clean_item)
120
+ else:
121
+ formatted_items.append(f'- [ ] {clean_item}')
122
+
123
+ # Create content for todo.md
124
+ content = '\n'.join(formatted_items) + '\n'
125
+
126
+ # Write to todo.md file
127
+ todo_path = file_system.get_dir() / 'todo.md'
128
+ if todo_path.exists():
129
+ timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
130
+ await file_system.move_file('todo.md', f'todos/todo-{timestamp}.md')
131
+ result = await file_system.write_file('todo.md', content)
132
+
133
+ logger.info(f'📝 Generated todo.md with {len(todo_items)} items')
134
+ return ActionResult(
135
+ extracted_content=f'Todo file generated successfully with {len(todo_items)} items:\n{content}',
136
+ long_term_memory=f'Generated todo.md with {len(todo_items)} items',
137
+ )
138
+
139
+ except Exception as e:
140
+ logger.error(f'❌ Failed to generate todo file: {e}')
141
+ raise RuntimeError(f'Failed to generate todo file: {str(e)}')
142
+
143
+ @self.registry.action(
144
+ 'Read the current todo.md file content.'
145
+ )
146
+ async def read_todos(file_system: CustomFileSystem):
147
+ """Read the current todo.md file content"""
148
+ try:
149
+ # Read todo.md file
150
+ result = await file_system.read_file('todo.md')
151
+
152
+ logger.info(f'📖 Read todo.md file')
153
+ return ActionResult(
154
+ extracted_content=result,
155
+ long_term_memory='Read current todo list',
156
+ include_in_memory=True,
157
+ )
158
+
159
+ except Exception as e:
160
+ logger.error(f'❌ Failed to read todo file: {e}')
161
+ return ActionResult(
162
+ extracted_content='Error: todo.md file not found or could not be read',
163
+ long_term_memory='Failed to read todo file',
164
+ )
165
+
166
+ @self.registry.action(
167
+ 'Modify existing todo items in todo.md file. Supports add, remove, complete, and uncomplete operations.',
168
+ param_model=TodoModifyAction,
169
+ )
170
+ async def modify_todos(params: TodoModifyAction, file_system: CustomFileSystem):
171
+ """Modify existing todo items using various operations"""
172
+ try:
173
+ # First read current content
174
+ current_content = await file_system.read_file('todo.md')
175
+
176
+ # Extract just the content part (remove the "Read from file..." prefix)
177
+ if '<content>' in current_content and '</content>' in current_content:
178
+ start = current_content.find('<content>') + len('<content>')
179
+ end = current_content.find('</content>')
180
+ content = current_content[start:end].strip()
181
+ else:
182
+ content = current_content.strip()
183
+
184
+ modified_content = content
185
+ changes_made = []
186
+
187
+ # Process each modification
188
+ for modification in params.modifications:
189
+ action = modification.action
190
+ item = modification.item.strip()
191
+
192
+ if action == 'add':
193
+ # Add new item
194
+ if item:
195
+ # Format as checkbox if not already formatted
196
+ if not item.startswith('- ['):
197
+ item = f'- [ ] {item}'
198
+ modified_content += f'\n{item}'
199
+ changes_made.append(f'Added: {item}')
200
+
201
+ elif action == 'remove':
202
+ # Remove item
203
+ if item:
204
+ # Try to find and remove the item (with some flexibility)
205
+ lines = modified_content.split('\n')
206
+ new_lines = []
207
+ removed = False
208
+ for line in lines:
209
+ if item in line or line.strip().endswith(item):
210
+ removed = True
211
+ changes_made.append(f'Removed: {line.strip()}')
212
+ else:
213
+ new_lines.append(line)
214
+ modified_content = '\n'.join(new_lines)
215
+ if not removed:
216
+ changes_made.append(f'Item not found for removal: {item}')
217
+
218
+ elif action == 'complete':
219
+ # Mark item as complete: - [ ] → - [x]
220
+ if item:
221
+ lines = modified_content.split('\n')
222
+ completed = False
223
+ for i, line in enumerate(lines):
224
+ if item in line and '- [ ]' in line:
225
+ lines[i] = line.replace('- [ ]', '- [x]')
226
+ completed = True
227
+ changes_made.append(f'Completed: {line.strip()} → {lines[i].strip()}')
228
+ break
229
+ modified_content = '\n'.join(lines)
230
+ if not completed:
231
+ changes_made.append(f'Item not found for completion: {item}')
232
+
233
+ elif action == 'uncompleted':
234
+ # Mark item as uncomplete: - [x] → - [ ]
235
+ if item:
236
+ lines = modified_content.split('\n')
237
+ uncompleted = False
238
+ for i, line in enumerate(lines):
239
+ if item in line and '- [x]' in line:
240
+ lines[i] = line.replace('- [x]', '- [ ]')
241
+ uncompleted = True
242
+ changes_made.append(f'Uncompleted: {line.strip()} → {lines[i].strip()}')
243
+ break
244
+ modified_content = '\n'.join(lines)
245
+ if not uncompleted:
246
+ changes_made.append(f'Item not found for uncompletion: {item}')
247
+
248
+ # If we made any add/remove/complete/uncomplete changes, write the updated content
249
+ if any(change.startswith(('Added:', 'Removed:', 'Completed:', 'Uncompleted:')) for change in
250
+ changes_made):
251
+ await file_system.write_file('todo.md', modified_content + '\n')
252
+
253
+ changes_summary = '\n'.join(changes_made) if changes_made else 'No changes made'
254
+
255
+ logger.info(f'✏️ Modified todo.md: {len(changes_made)} changes')
256
+ return ActionResult(
257
+ extracted_content=f'Todo modifications completed:\n{changes_summary}\n\nUpdated content:\n{modified_content}',
258
+ long_term_memory=f'Modified todo list: {len(changes_made)} changes made',
259
+ )
260
+
261
+ except Exception as e:
262
+ logger.error(f'❌ Failed to modify todo file: {e}')
263
+ raise RuntimeError(f'Failed to modify todo file: {str(e)}')
264
+
265
+ def _register_done_action(self):
266
+ @self.registry.action(
267
+ 'Complete task and output final response. Use for simple responses or comprehensive markdown summaries with optional follow-up task suggestions.',
268
+ param_model=VibeSurfDoneAction,
269
+ )
270
+ async def task_done(
271
+ params: VibeSurfDoneAction,
272
+ ):
273
+ """
274
+ Complete task execution and provide final response.
275
+
276
+ """
277
+ pass
278
+
279
+ def _register_file_actions(self):
280
+ @self.registry.action(
281
+ 'Replace old_str with new_str in file_name. old_str must exactly match the string to replace in original text. Recommended tool to mark completed items in todo.md or change specific contents in a file.'
282
+ )
283
+ async def replace_file_str(file_name: str, old_str: str, new_str: str, file_system: CustomFileSystem):
284
+ result = await file_system.replace_file_str(file_name, old_str, new_str)
285
+ logger.info(f'💾 {result}')
286
+ return ActionResult(extracted_content=result, long_term_memory=result)
287
+
288
+ @self.registry.action(
289
+ 'Read file content from file system. If this is a file not in current file system, please provide an absolute path.')
290
+ async def read_file(file_path: str, file_system: CustomFileSystem):
291
+ if os.path.exists(file_path):
292
+ external_file = True
293
+ else:
294
+ external_file = False
295
+ result = await file_system.read_file(file_path, external_file=external_file)
296
+
297
+ MAX_MEMORY_SIZE = 1000
298
+ if len(result) > MAX_MEMORY_SIZE:
299
+ lines = result.splitlines()
300
+ display = ''
301
+ lines_count = 0
302
+ for line in lines:
303
+ if len(display) + len(line) < MAX_MEMORY_SIZE:
304
+ display += line + '\n'
305
+ lines_count += 1
306
+ else:
307
+ break
308
+ remaining_lines = len(lines) - lines_count
309
+ memory = f'{display}{remaining_lines} more lines...' if remaining_lines > 0 else display
310
+ else:
311
+ memory = result
312
+ logger.info(f'💾 {memory}')
313
+ return ActionResult(
314
+ extracted_content=result,
315
+ include_in_memory=True,
316
+ long_term_memory=memory,
317
+ include_extracted_content_only_once=True,
318
+ )
319
+
320
+ @self.registry.action(
321
+ 'Extract content from a file. Support image files, pdf, markdown, txt, json, csv.',
322
+ param_model=FileExtractionAction,
323
+ )
324
+ async def extract_content_from_file(
325
+ params: FileExtractionAction,
326
+ page_extraction_llm: BaseChatModel,
327
+ file_system: CustomFileSystem,
328
+ ):
329
+ try:
330
+ # Get file path
331
+ file_path = params.file_path
332
+ full_file_path = file_path
333
+ # Check if file exists
334
+ if not os.path.exists(full_file_path):
335
+ full_file_path = os.path.join(str(file_system.get_dir()), file_path)
336
+
337
+ # Determine if file is an image based on MIME type
338
+ mime_type, _ = mimetypes.guess_type(file_path)
339
+ is_image = mime_type and mime_type.startswith('image/')
340
+
341
+ if is_image:
342
+ # Handle image files with LLM vision
343
+ try:
344
+ # Read image file and encode to base64
345
+ with open(full_file_path, 'rb') as image_file:
346
+ image_data = image_file.read()
347
+ image_base64 = base64.b64encode(image_data).decode('utf-8')
348
+
349
+ # Create content parts similar to the user's example
350
+ content_parts: list[ContentPartTextParam | ContentPartImageParam] = [
351
+ ContentPartTextParam(text=f"Query: {params.query}")
352
+ ]
353
+
354
+ # Add the image
355
+ content_parts.append(
356
+ ContentPartImageParam(
357
+ image_url=ImageURL(
358
+ url=f'data:{mime_type};base64,{image_base64}',
359
+ media_type=mime_type,
360
+ detail='high',
361
+ ),
362
+ )
363
+ )
364
+
365
+ # Create user message and invoke LLM
366
+ user_message = UserMessage(content=content_parts, cache=True)
367
+ response = await asyncio.wait_for(
368
+ page_extraction_llm.ainvoke([user_message]),
369
+ timeout=120.0,
370
+ )
371
+
372
+ extracted_content = f'File: {file_path}\nQuery: {params.query}\nExtracted Content:\n{response.completion}'
373
+
374
+ except Exception as e:
375
+ raise Exception(f'Failed to process image file {file_path}: {str(e)}')
376
+
377
+ else:
378
+ # Handle non-image files by reading content
379
+ try:
380
+ file_content = await file_system.read_file(full_file_path, external_file=True)
381
+
382
+ # Create a simple prompt for text extraction
383
+ prompt = f"""Extract the requested information from this file content.
384
+
385
+ Query: {params.query}
386
+
387
+ File: {file_path}
388
+ File Content:
389
+ {file_content}
390
+
391
+ Provide the extracted information in a clear, structured format."""
392
+
393
+ response = await asyncio.wait_for(
394
+ page_extraction_llm.ainvoke([UserMessage(content=prompt)]),
395
+ timeout=120.0,
396
+ )
397
+
398
+ extracted_content = f'File: {file_path}\nQuery: {params.query}\nExtracted Content:\n{response.completion}'
399
+
400
+ except Exception as e:
401
+ raise Exception(f'Failed to read file {file_path}: {str(e)}')
402
+
403
+ # Handle memory storage
404
+ if len(extracted_content) < 1000:
405
+ memory = extracted_content
406
+ include_extracted_content_only_once = False
407
+ else:
408
+ save_result = await file_system.save_extracted_content(extracted_content)
409
+ memory = (
410
+ f'Extracted content from file {file_path} for query: {params.query}\nContent saved to file system: {save_result}'
411
+ )
412
+ include_extracted_content_only_once = True
413
+
414
+ logger.info(f'📄 Extracted content from file: {file_path}')
415
+ return ActionResult(
416
+ extracted_content=extracted_content,
417
+ include_extracted_content_only_once=include_extracted_content_only_once,
418
+ long_term_memory=memory,
419
+ )
420
+
421
+ except Exception as e:
422
+ logger.debug(f'Error extracting content from file: {e}')
423
+ raise RuntimeError(str(e))
424
+
425
+ @self.registry.action(
426
+ 'Write or append content to file_path in file system. Allowed extensions are .md, .txt, .json, .csv, .pdf. For .pdf files, write the content in markdown format and it will automatically be converted to a properly formatted PDF document.'
427
+ )
428
+ async def write_file(
429
+ file_path: str,
430
+ content: str,
431
+ file_system: FileSystem,
432
+ append: bool = False,
433
+ trailing_newline: bool = True,
434
+ leading_newline: bool = False,
435
+ ):
436
+ if trailing_newline:
437
+ content += '\n'
438
+ if leading_newline:
439
+ content = '\n' + content
440
+ if append:
441
+ result = await file_system.append_file(file_path, content)
442
+ else:
443
+ result = await file_system.write_file(file_path, content)
444
+ logger.info(f'💾 {result}')
445
+ return ActionResult(extracted_content=result, long_term_memory=result)
446
+
447
+ @self.registry.action(
448
+ 'Copy a file to the FileSystem. Set external_src=True to copy from external file(absolute path)to FileSystem, False to copy within FileSystem.'
449
+ )
450
+ async def copy_file(src_file_path: str, dst_file_path: str, file_system: CustomFileSystem,
451
+ external_src: bool = False):
452
+ result = await file_system.copy_file(src_file_path, dst_file_path, external_src)
453
+ logger.info(f'📁 {result}')
454
+ return ActionResult(
455
+ extracted_content=result,
456
+ include_in_memory=True,
457
+ long_term_memory=result,
458
+ )
459
+
460
+ @self.registry.action(
461
+ 'Rename a file to new_filename. src_file_path is a relative path to the FileSystem.'
462
+ )
463
+ async def rename_file(src_file_path: str, new_filename: str, file_system: CustomFileSystem):
464
+ result = await file_system.rename_file(src_file_path, new_filename)
465
+ logger.info(f'📁 {result}')
466
+ return ActionResult(
467
+ extracted_content=result,
468
+ include_in_memory=True,
469
+ long_term_memory=result,
470
+ )
471
+
472
+ @self.registry.action(
473
+ 'Move a file within the FileSystem from old_filename to new_filename.'
474
+ )
475
+ async def move_file(old_file_path: str, new_file_path: str, file_system: CustomFileSystem):
476
+ result = await file_system.move_file(old_file_path, new_file_path)
477
+ logger.info(f'📁 {result}')
478
+ return ActionResult(
479
+ extracted_content=result,
480
+ include_in_memory=True,
481
+ long_term_memory=result,
482
+ )
483
+
484
+ @self.registry.action(
485
+ 'Check file exist or not.'
486
+ )
487
+ async def file_exist(file_path: str, file_system: CustomFileSystem):
488
+ if os.path.exists(file_path):
489
+ result = f"{file_path} is a external file and it exists."
490
+ else:
491
+ is_file_exist = await file_system.file_exist(file_path)
492
+ if is_file_exist:
493
+ result = f"{file_path} is in file system and it exists."
494
+ else:
495
+ result = f"{file_path} does not exists."
496
+
497
+ logger.info(f'📁 {result}')
498
+ return ActionResult(
499
+ extracted_content=result,
500
+ include_in_memory=True,
501
+ long_term_memory=result,
502
+ )
503
+
504
+ @self.registry.action(
505
+ 'List contents of a directory within the FileSystem. Use empty string "" or "." to list the root data_dir, or provide relative path for subdirectory.'
506
+ )
507
+ async def list_directory(directory_path: str, file_system: CustomFileSystem):
508
+ result = await file_system.list_directory(directory_path)
509
+ logger.info(f'📁 {result}')
510
+ return ActionResult(
511
+ extracted_content=result,
512
+ include_in_memory=True,
513
+ long_term_memory=result,
514
+ )
515
+
516
+ @self.registry.action(
517
+ 'Create a directory within the FileSystem.'
518
+ )
519
+ async def create_directory(directory_path: str, file_system: CustomFileSystem):
520
+ result = await file_system.create_directory(directory_path)
521
+ logger.info(f'📁 {result}')
522
+ return ActionResult(
523
+ extracted_content=result,
524
+ include_in_memory=True,
525
+ long_term_memory=result,
526
+ )
527
+
528
+ async def register_mcp_clients(self, mcp_server_config: Optional[Dict[str, Any]] = None):
529
+ self.mcp_server_config = mcp_server_config or self.mcp_server_config
530
+ if self.mcp_server_config:
531
+ await self.unregister_mcp_clients()
532
+ await self.register_mcp_tools()
533
+
534
+ async def register_mcp_tools(self):
535
+ """
536
+ Register the MCP tools used by this tools.
537
+ """
538
+ if not self.mcp_server_config:
539
+ return
540
+
541
+ # Handle both formats: with or without "mcpServers" key
542
+ mcp_servers = self.mcp_server_config.get('mcpServers', self.mcp_server_config)
543
+
544
+ if not mcp_servers:
545
+ return
546
+
547
+ for server_name, server_config in mcp_servers.items():
548
+ try:
549
+ logger.info(f'Connecting to MCP server: {server_name}')
550
+
551
+ # Create MCP client
552
+ client = CustomMCPClient(
553
+ server_name=server_name,
554
+ command=server_config['command'],
555
+ args=server_config['args'],
556
+ env=server_config.get('env', None)
557
+ )
558
+
559
+ # Connect to the MCP server
560
+ await client.connect(timeout=200)
561
+
562
+ # Register tools to tools with prefix
563
+ prefix = f"mcp.{server_name}."
564
+ await client.register_to_tools(
565
+ tools=self,
566
+ prefix=prefix
567
+ )
568
+
569
+ # Store client for later cleanup
570
+ self.mcp_clients[server_name] = client
571
+
572
+ logger.info(f'Successfully registered MCP server: {server_name} with prefix: {prefix}')
573
+
574
+ except Exception as e:
575
+ logger.error(f'Failed to register MCP server {server_name}: {str(e)}')
576
+ # Continue with other servers even if one fails
577
+
578
+ async def unregister_mcp_clients(self):
579
+ """
580
+ Unregister and disconnect all MCP clients.
581
+ """
582
+ # Disconnect all MCP clients
583
+ for server_name, client in self.mcp_clients.items():
584
+ try:
585
+ logger.info(f'Disconnecting MCP server: {server_name}')
586
+ await client.disconnect()
587
+ except Exception as e:
588
+ logger.error(f'Failed to disconnect MCP server {server_name}: {str(e)}')
589
+
590
+ # Remove MCP tools from registry
591
+ try:
592
+ # Get all registered actions
593
+ actions_to_remove = []
594
+ for action_name in list(self.registry.registry.actions.keys()):
595
+ if action_name.startswith('mcp.'):
596
+ actions_to_remove.append(action_name)
597
+
598
+ # Remove MCP actions from registry
599
+ for action_name in actions_to_remove:
600
+ if action_name in self.registry.registry.actions:
601
+ del self.registry.registry.actions[action_name]
602
+ logger.info(f'Removed MCP action: {action_name}')
603
+
604
+ except Exception as e:
605
+ logger.error(f'Failed to remove MCP actions from registry: {str(e)}')
606
+
607
+ # Clear the clients dictionary
608
+ self.mcp_clients.clear()
609
+ logger.info('All MCP clients unregistered and disconnected')
610
+
611
+ @time_execution_sync('--act')
612
+ async def act(
613
+ self,
614
+ action: ActionModel,
615
+ browser_manager: BrowserManager | None = None,
616
+ llm: BaseChatModel | None = None,
617
+ file_system: CustomFileSystem | None = None,
618
+ ) -> ActionResult:
619
+ """Execute an action"""
620
+
621
+ for action_name, params in action.model_dump(exclude_unset=True).items():
622
+ if params is not None:
623
+ try:
624
+ if action_name not in self.registry.registry.actions:
625
+ raise ValueError(f'Action {action_name} not found')
626
+ action = self.registry.registry.actions[action_name]
627
+ special_context = {
628
+ 'browser_manager': browser_manager,
629
+ 'page_extraction_llm': llm,
630
+ 'file_system': file_system,
631
+ }
632
+ try:
633
+ validated_params = action.param_model(**params)
634
+ except Exception as e:
635
+ raise ValueError(f'Invalid parameters {params} for action {action_name}: {type(e)}: {e}') from e
636
+
637
+ result = await action.function(params=validated_params, **special_context)
638
+ except BrowserError as e:
639
+ logger.error(f'❌ Action {action_name} failed with BrowserError: {str(e)}')
640
+ result = handle_browser_error(e)
641
+ except TimeoutError as e:
642
+ logger.error(f'❌ Action {action_name} failed with TimeoutError: {str(e)}')
643
+ result = ActionResult(error=f'{action_name} was not executed due to timeout.')
644
+ except Exception as e:
645
+ # Log the original exception with traceback for observability
646
+ logger.error(f"Action '{action_name}' failed with error: {str(e)}")
647
+ result = ActionResult(error=str(e))
648
+
649
+ if isinstance(result, str):
650
+ return ActionResult(extracted_content=result)
651
+ elif isinstance(result, ActionResult):
652
+ return result
653
+ elif result is None:
654
+ return ActionResult()
655
+ else:
656
+ raise ValueError(f'Invalid action result type: {type(result)} of {result}')
657
+ return ActionResult()