camel-ai 0.2.70__py3-none-any.whl → 0.2.71a2__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 camel-ai might be problematic. Click here for more details.

@@ -31,6 +31,16 @@ class Crawl4AIToolkit(BaseToolkit):
31
31
  timeout: Optional[float] = None,
32
32
  ):
33
33
  super().__init__(timeout=timeout)
34
+ self._client = None
35
+
36
+ async def _get_client(self):
37
+ r"""Get or create the AsyncWebCrawler client."""
38
+ if self._client is None:
39
+ from crawl4ai import AsyncWebCrawler
40
+
41
+ self._client = AsyncWebCrawler()
42
+ await self._client.__aenter__()
43
+ return self._client
34
44
 
35
45
  async def scrape(self, url: str) -> str:
36
46
  r"""Scrapes a webpage and returns its content.
@@ -47,19 +57,29 @@ class Crawl4AIToolkit(BaseToolkit):
47
57
  str: The scraped content of the webpage as a string. If the
48
58
  scraping fails, it will return an error message.
49
59
  """
50
- from crawl4ai import AsyncWebCrawler, CrawlerRunConfig
60
+ from crawl4ai import CrawlerRunConfig
51
61
 
52
62
  try:
53
- async with AsyncWebCrawler() as client:
54
- config = CrawlerRunConfig(
55
- only_text=True,
56
- )
57
- content = await client.arun(url, crawler_config=config)
58
- return str(content.markdown) if content.markdown else ""
63
+ client = await self._get_client()
64
+ config = CrawlerRunConfig(
65
+ only_text=True,
66
+ )
67
+ content = await client.arun(url, crawler_config=config)
68
+ return str(content.markdown) if content.markdown else ""
59
69
  except Exception as e:
60
70
  logger.error(f"Error scraping {url}: {e}")
61
71
  return f"Error scraping {url}: {e}"
62
72
 
73
+ async def __aenter__(self):
74
+ """Async context manager entry."""
75
+ return self
76
+
77
+ async def __aexit__(self, exc_type, exc_val, exc_tb):
78
+ """Async context manager exit - cleanup the client."""
79
+ if self._client is not None:
80
+ await self._client.__aexit__(exc_type, exc_val, exc_tb)
81
+ self._client = None
82
+
63
83
  def get_tools(self) -> List[FunctionTool]:
64
84
  r"""Returns a list of FunctionTool objects representing the
65
85
  functions in the toolkit.
@@ -146,23 +146,25 @@ class FileWriteToolkit(BaseToolkit):
146
146
  document.save(str(file_path))
147
147
  logger.debug(f"Wrote DOCX to {file_path} with default formatting")
148
148
 
149
- @dependencies_required('pylatex', 'fpdf')
149
+ @dependencies_required('pylatex', 'pymupdf')
150
150
  def _write_pdf_file(
151
- self, file_path: Path, content: str, use_latex: bool = False
151
+ self,
152
+ file_path: Path,
153
+ title: str,
154
+ content: str,
155
+ use_latex: bool = False,
152
156
  ) -> None:
153
157
  r"""Write text content to a PDF file with default formatting.
154
158
 
155
159
  Args:
156
160
  file_path (Path): The target file path.
161
+ title (str): The title of the document.
157
162
  content (str): The text content to write.
158
163
  use_latex (bool): Whether to use LaTeX for rendering. (requires
159
- LaTeX toolchain). If False, uses FPDF for simpler PDF
164
+ LaTeX toolchain). If False, uses PyMuPDF for simpler PDF
160
165
  generation. (default: :obj:`False`)
161
-
162
- Raises:
163
- RuntimeError: If the 'pylatex' or 'fpdf' library is not installed
164
- when use_latex=True.
165
166
  """
167
+ # TODO: table generation need to be improved
166
168
  if use_latex:
167
169
  from pylatex import (
168
170
  Command,
@@ -213,30 +215,105 @@ class FileWriteToolkit(BaseToolkit):
213
215
 
214
216
  logger.info(f"Wrote PDF (with LaTeX) to {file_path}")
215
217
  else:
216
- from fpdf import FPDF
217
-
218
- # Use default formatting values
219
- font_family = 'Arial'
220
- font_size = 12
221
- font_style = ''
222
- line_height = 10
223
- margin = 10
224
-
225
- pdf = FPDF()
226
- pdf.set_margins(margin, margin, margin)
227
-
228
- pdf.add_page()
229
- pdf.set_font(font_family, style=font_style, size=font_size)
230
-
231
- # Split content into paragraphs and add them
232
- for para in content.split('\n'):
233
- if para.strip(): # Skip empty paragraphs
234
- pdf.multi_cell(0, line_height, para)
218
+ import pymupdf
219
+
220
+ # Create a new PDF document
221
+ doc = pymupdf.open()
222
+
223
+ # Add a page
224
+ page = doc.new_page()
225
+
226
+ # Process the content
227
+ lines = content.strip().split('\n')
228
+ document_title = title
229
+
230
+ # Create a TextWriter for writing text to the page
231
+ text_writer = pymupdf.TextWriter(page.rect)
232
+
233
+ # Define fonts
234
+ normal_font = pymupdf.Font(
235
+ "helv"
236
+ ) # Standard font with multilingual support
237
+ bold_font = pymupdf.Font("helv")
238
+
239
+ # Start position for text
240
+ y_pos = 50
241
+ x_pos = 50
242
+
243
+ # Add title
244
+ text_writer.fill_textbox(
245
+ pymupdf.Rect(
246
+ x_pos, y_pos, page.rect.width - x_pos, y_pos + 30
247
+ ),
248
+ document_title,
249
+ fontsize=16,
250
+ )
251
+ y_pos += 40
252
+
253
+ # Process content
254
+ for line in lines:
255
+ stripped_line = line.strip()
256
+
257
+ # Skip empty lines but add some space
258
+ if not stripped_line:
259
+ y_pos += 10
260
+ continue
261
+
262
+ # Handle headers
263
+ if stripped_line.startswith('## '):
264
+ text_writer.fill_textbox(
265
+ pymupdf.Rect(
266
+ x_pos, y_pos, page.rect.width - x_pos, y_pos + 20
267
+ ),
268
+ stripped_line[3:].strip(),
269
+ font=bold_font,
270
+ fontsize=14,
271
+ )
272
+ y_pos += 25
273
+ elif stripped_line.startswith('# '):
274
+ text_writer.fill_textbox(
275
+ pymupdf.Rect(
276
+ x_pos, y_pos, page.rect.width - x_pos, y_pos + 25
277
+ ),
278
+ stripped_line[2:].strip(),
279
+ font=bold_font,
280
+ fontsize=16,
281
+ )
282
+ y_pos += 30
283
+ # Handle horizontal rule
284
+ elif stripped_line == '---':
285
+ page.draw_line(
286
+ pymupdf.Point(x_pos, y_pos + 5),
287
+ pymupdf.Point(page.rect.width - x_pos, y_pos + 5),
288
+ )
289
+ y_pos += 15
290
+ # Regular text
235
291
  else:
236
- pdf.ln(line_height) # Add empty line
237
-
238
- pdf.output(str(file_path))
239
- logger.debug(f"Wrote PDF to {file_path} with custom formatting")
292
+ # Check if we need a new page
293
+ if y_pos > page.rect.height - 50:
294
+ text_writer.write_text(page)
295
+ page = doc.new_page()
296
+ text_writer = pymupdf.TextWriter(page.rect)
297
+ y_pos = 50
298
+
299
+ # Add text to the current page
300
+ text_writer.fill_textbox(
301
+ pymupdf.Rect(
302
+ x_pos, y_pos, page.rect.width - x_pos, y_pos + 15
303
+ ),
304
+ stripped_line,
305
+ font=normal_font,
306
+ )
307
+ y_pos += 15
308
+
309
+ # Write the accumulated text to the last page
310
+ text_writer.write_text(page)
311
+
312
+ # Save the PDF
313
+ doc.save(str(file_path))
314
+ doc.close()
315
+
316
+ logger.debug(f"Wrote PDF to {file_path} with PyMuPDF formatting")
240
317
 
241
318
  def _write_csv_file(
242
319
  self,
@@ -338,6 +415,7 @@ class FileWriteToolkit(BaseToolkit):
338
415
 
339
416
  def write_to_file(
340
417
  self,
418
+ title: str,
341
419
  content: Union[str, List[List[str]]],
342
420
  filename: str,
343
421
  encoding: Optional[str] = None,
@@ -351,6 +429,7 @@ class FileWriteToolkit(BaseToolkit):
351
429
  and HTML (.html, .htm).
352
430
 
353
431
  Args:
432
+ title (str): The title of the document.
354
433
  content (Union[str, List[List[str]]]): The content to write to the
355
434
  file. Content format varies by file type:
356
435
  - Text formats (txt, md, html, yaml): string
@@ -388,7 +467,7 @@ class FileWriteToolkit(BaseToolkit):
388
467
  self._write_docx_file(file_path, str(content))
389
468
  elif extension == ".pdf":
390
469
  self._write_pdf_file(
391
- file_path, str(content), use_latex=use_latex
470
+ file_path, title, str(content), use_latex=use_latex
392
471
  )
393
472
  elif extension == ".csv":
394
473
  self._write_csv_file(
@@ -22,16 +22,29 @@ logger = logging.getLogger(__name__)
22
22
 
23
23
 
24
24
  class HumanToolkit(BaseToolkit):
25
- r"""A class representing a toolkit for human interaction."""
25
+ r"""A class representing a toolkit for human interaction.
26
+
27
+ Note:
28
+ This toolkit should be called to send a tidy message to the user to
29
+ keep them informed.
30
+ """
26
31
 
27
32
  def ask_human_via_console(self, question: str) -> str:
28
- r"""Ask a question to the human via the console.
33
+ r"""Use this tool to ask a question to the user when you are stuck,
34
+ need clarification, or require a decision to be made. This is a
35
+ two-way communication channel that will wait for the user's response.
36
+ You should use it to:
37
+ - Clarify ambiguous instructions or requirements.
38
+ - Request missing information that you cannot find (e.g., login
39
+ credentials, file paths).
40
+ - Ask for a decision when there are multiple viable options.
41
+ - Seek help when you encounter an error you cannot resolve on your own.
29
42
 
30
43
  Args:
31
- question (str): The question to ask the human.
44
+ question (str): The question to ask the user.
32
45
 
33
46
  Returns:
34
- str: The answer from the human.
47
+ str: The user's response to the question.
35
48
  """
36
49
  print(f"Question: {question}")
37
50
  logger.info(f"Question: {question}")
@@ -40,14 +53,21 @@ class HumanToolkit(BaseToolkit):
40
53
  return reply
41
54
 
42
55
  def send_message_to_user(self, message: str) -> None:
43
- r"""Send a message to the user, without waiting for
44
- a response. This will send to stdout in a noticeable way.
56
+ r"""Use this tool to send a tidy message to the user in one short
57
+ sentence.
45
58
 
46
- This is guaranteed to reach the user regardless of
47
- actual user interface.
59
+ This one-way tool keeps the user informed about your progress,
60
+ decisions, or actions. It does not require a response.
61
+ You should use it to:
62
+ - Announce what you are about to do (e.g., "I will now search for
63
+ papers on GUI Agents.").
64
+ - Report the result of an action (e.g., "I have found 15 relevant
65
+ papers.").
66
+ - State a decision (e.g., "I will now analyze the top 10 papers.").
67
+ - Give a status update during a long-running task.
48
68
 
49
69
  Args:
50
- message (str): The message to send to the user.
70
+ message (str): The tidy and informative message for the user.
51
71
  """
52
72
  print(f"\nAgent Message:\n{message}")
53
73
  logger.info(f"\nAgent Message:\n{message}")
@@ -34,7 +34,7 @@ class JinaRerankerToolkit(BaseToolkit):
34
34
  def __init__(
35
35
  self,
36
36
  timeout: Optional[float] = None,
37
- model_name: Optional[str] = "jinaai/jina-reranker-m0",
37
+ model_name: str = "jinaai/jina-reranker-m0",
38
38
  device: Optional[str] = None,
39
39
  use_api: bool = True,
40
40
  ) -> None:
@@ -44,9 +44,8 @@ class JinaRerankerToolkit(BaseToolkit):
44
44
  timeout (Optional[float]): The timeout value for API requests
45
45
  in seconds. If None, no timeout is applied.
46
46
  (default: :obj:`None`)
47
- model_name (Optional[str]): The reranker model name. If None,
48
- will use the default model.
49
- (default: :obj:`None`)
47
+ model_name (str): The reranker model name.
48
+ (default: :obj:`"jinaai/jina-reranker-m0"`)
50
49
  device (Optional[str]): Device to load the model on. If None,
51
50
  will use CUDA if available, otherwise CPU.
52
51
  Only effective when use_api=False.
@@ -80,7 +80,28 @@ class BrowserNonVisualToolkit(BaseToolkit):
80
80
  return
81
81
 
82
82
  if loop.is_closed():
83
- # Event loop already closed cannot run async cleanup
83
+ # The default loop is closed, create a *temporary* loop just
84
+ # for cleanup so that Playwright / asyncio transports are
85
+ # gracefully shut down. This avoids noisy warnings such as
86
+ # "RuntimeError: Event loop is closed" when the program
87
+ # exits.
88
+ try:
89
+ tmp_loop = asyncio.new_event_loop()
90
+ try:
91
+ asyncio.set_event_loop(tmp_loop)
92
+ tmp_loop.run_until_complete(self.close_browser())
93
+ finally:
94
+ # Best-effort shutdown of async generators and loop
95
+ # itself (Python ≥3.6).
96
+ if hasattr(tmp_loop, "shutdown_asyncgens"):
97
+ tmp_loop.run_until_complete(
98
+ tmp_loop.shutdown_asyncgens()
99
+ )
100
+ tmp_loop.close()
101
+ finally:
102
+ # Ensure no subsequent get_event_loop() picks up a now
103
+ # closed temporary loop.
104
+ asyncio.set_event_loop(None)
84
105
  return
85
106
 
86
107
  if loop.is_running():
@@ -155,7 +176,7 @@ class BrowserNonVisualToolkit(BaseToolkit):
155
176
  self._agent = None
156
177
 
157
178
  # Close session
158
- await self._session.close()
179
+ await NVBrowserSession.close_all_sessions()
159
180
  return "Browser session closed."
160
181
 
161
182
  async def visit_page(self, url: str) -> Dict[str, str]:
@@ -13,8 +13,11 @@
13
13
  # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
14
14
  from __future__ import annotations
15
15
 
16
+ import asyncio
16
17
  from pathlib import Path
17
- from typing import TYPE_CHECKING, Any, Optional
18
+ from typing import TYPE_CHECKING, Any, ClassVar, Dict, Optional
19
+
20
+ from camel.logger import get_logger
18
21
 
19
22
  from .actions import ActionExecutor
20
23
  from .snapshot import PageSnapshot
@@ -28,6 +31,9 @@ if TYPE_CHECKING:
28
31
  )
29
32
 
30
33
 
34
+ logger = get_logger(__name__)
35
+
36
+
31
37
  class NVBrowserSession:
32
38
  """Lightweight wrapper around Playwright for non-visual (headless)
33
39
  browsing.
@@ -35,15 +41,37 @@ class NVBrowserSession:
35
41
  It provides a single *Page* instance plus helper utilities (snapshot &
36
42
  executor). Multiple toolkits or agents can reuse this class without
37
43
  duplicating Playwright setup code.
44
+
45
+ This class is a singleton per event-loop.
38
46
  """
39
47
 
40
48
  # Configuration constants
41
49
  DEFAULT_NAVIGATION_TIMEOUT = 10000 # 10 seconds
42
50
  NETWORK_IDLE_TIMEOUT = 5000 # 5 seconds
43
51
 
52
+ _sessions: ClassVar[
53
+ Dict[asyncio.AbstractEventLoop, "NVBrowserSession"]
54
+ ] = {}
55
+
56
+ _initialized: bool
57
+
58
+ def __new__(
59
+ cls, *, headless: bool = True, user_data_dir: Optional[str] = None
60
+ ):
61
+ loop = asyncio.get_running_loop()
62
+ if loop not in cls._sessions:
63
+ instance = super().__new__(cls)
64
+ instance._initialized = False
65
+ cls._sessions[loop] = instance
66
+ return cls._sessions[loop]
67
+
44
68
  def __init__(
45
69
  self, *, headless: bool = True, user_data_dir: Optional[str] = None
46
70
  ):
71
+ if self._initialized:
72
+ return
73
+ self._initialized = True
74
+
47
75
  self._headless = headless
48
76
  self._user_data_dir = user_data_dir
49
77
 
@@ -56,8 +84,6 @@ class NVBrowserSession:
56
84
  self.executor: Optional[ActionExecutor] = None
57
85
 
58
86
  # Protect browser initialisation against concurrent calls
59
- import asyncio
60
-
61
87
  self._ensure_lock: "asyncio.Lock" = asyncio.Lock()
62
88
 
63
89
  # ------------------------------------------------------------------
@@ -93,10 +119,6 @@ class NVBrowserSession:
93
119
  self._browser = await pl.chromium.launch(headless=self._headless)
94
120
  self._context = await self._browser.new_context()
95
121
 
96
- from camel.logger import get_logger
97
-
98
- _dbg_logger = get_logger(__name__)
99
-
100
122
  # Reuse an already open page (persistent context may restore last
101
123
  # session)
102
124
  if self._context.pages:
@@ -105,7 +127,7 @@ class NVBrowserSession:
105
127
  self._page = await self._context.new_page()
106
128
 
107
129
  # Debug information to help trace concurrency issues
108
- _dbg_logger.debug(
130
+ logger.debug(
109
131
  "Session %s created browser=%s context=%s page=%s (url=%s)",
110
132
  hex(id(self)),
111
133
  hex(id(self._browser)) if self._browser else None,
@@ -122,6 +144,11 @@ class NVBrowserSession:
122
144
  r"""Close all browser resources, ensuring cleanup even if some
123
145
  operations fail.
124
146
  """
147
+ # The close method will now only close the *current* event-loop's
148
+ # browser instance. Use `close_all_sessions` for a full cleanup.
149
+ await self._close_session()
150
+
151
+ async def _close_session(self) -> None:
125
152
  errors: list[str] = []
126
153
 
127
154
  # Close context first (which closes pages)
@@ -151,13 +178,28 @@ class NVBrowserSession:
151
178
 
152
179
  # Log errors if any occurred during cleanup
153
180
  if errors:
154
- from camel.logger import get_logger
155
-
156
- logger = get_logger(__name__)
157
181
  logger.warning(
158
182
  "Errors during browser session cleanup: %s", "; ".join(errors)
159
183
  )
160
184
 
185
+ @classmethod
186
+ async def close_all_sessions(cls) -> None:
187
+ r"""Iterate over all stored sessions and close them."""
188
+ for loop, session in cls._sessions.items():
189
+ if loop.is_running():
190
+ await session._close_session()
191
+ else:
192
+ try:
193
+ if not loop.is_closed():
194
+ loop.run_until_complete(session._close_session())
195
+ except Exception as e:
196
+ logger.warning(
197
+ "Failed to close session for loop %s: %s",
198
+ hex(id(loop)),
199
+ e,
200
+ )
201
+ cls._sessions.clear()
202
+
161
203
  # ------------------------------------------------------------------
162
204
  # Convenience wrappers around common actions
163
205
  # ------------------------------------------------------------------