camel-ai 0.2.76a9__py3-none-any.whl → 0.2.76a13__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.

camel/__init__.py CHANGED
@@ -14,7 +14,7 @@
14
14
 
15
15
  from camel.logger import disable_logging, enable_logging, set_log_level
16
16
 
17
- __version__ = '0.2.76a9'
17
+ __version__ = '0.2.76a13'
18
18
 
19
19
  __all__ = [
20
20
  '__version__',
camel/agents/mcp_agent.py CHANGED
@@ -15,16 +15,25 @@
15
15
  import asyncio
16
16
  import json
17
17
  import platform
18
- import re
19
- from typing import Any, Callable, Dict, List, Optional, Union, cast
18
+ from typing import (
19
+ TYPE_CHECKING,
20
+ Any,
21
+ Callable,
22
+ Dict,
23
+ List,
24
+ Optional,
25
+ Union,
26
+ cast,
27
+ )
20
28
 
21
- from camel.agents import ChatAgent
29
+ from camel.agents.chat_agent import ChatAgent
22
30
  from camel.logger import get_logger
23
31
  from camel.messages import BaseMessage
24
- from camel.models import BaseModelBackend, ModelFactory
32
+ from camel.models.base_model import BaseModelBackend
33
+ from camel.models.model_factory import ModelFactory
25
34
  from camel.prompts import TextPrompt
26
35
  from camel.responses import ChatAgentResponse
27
- from camel.toolkits import FunctionTool, MCPToolkit
36
+ from camel.toolkits.function_tool import FunctionTool
28
37
  from camel.types import (
29
38
  BaseMCPRegistryConfig,
30
39
  MCPRegistryType,
@@ -33,6 +42,9 @@ from camel.types import (
33
42
  RoleType,
34
43
  )
35
44
 
45
+ if TYPE_CHECKING:
46
+ from camel.toolkits.mcp_toolkit import MCPToolkit
47
+
36
48
  # AgentOps decorator setting
37
49
  try:
38
50
  import os
@@ -44,6 +56,8 @@ try:
44
56
  except (ImportError, AttributeError):
45
57
  from camel.utils import track_agent
46
58
 
59
+ from camel.parsers.mcp_tool_call_parser import extract_tool_calls_from_text
60
+
47
61
  logger = get_logger(__name__)
48
62
 
49
63
 
@@ -168,8 +182,10 @@ class MCPAgent(ChatAgent):
168
182
  **kwargs,
169
183
  )
170
184
 
171
- def _initialize_mcp_toolkit(self) -> MCPToolkit:
185
+ def _initialize_mcp_toolkit(self) -> "MCPToolkit":
172
186
  r"""Initialize the MCP toolkit from the provided configuration."""
187
+ from camel.toolkits.mcp_toolkit import MCPToolkit
188
+
173
189
  config_dict = {}
174
190
  for registry_config in self.registry_configs:
175
191
  config_dict.update(registry_config.get_config())
@@ -334,27 +350,14 @@ class MCPAgent(ChatAgent):
334
350
  task = f"## Task:\n {input_message}"
335
351
  input_message = str(self._text_tools) + task
336
352
  response = await super().astep(input_message, *args, **kwargs)
337
- content = response.msgs[0].content.lower()
338
-
339
- tool_calls = []
340
- while "```json" in content:
341
- json_match = re.search(r'```json', content)
342
- if not json_match:
343
- break
344
- json_start = json_match.span()[1]
345
-
346
- end_match = re.search(r'```', content[json_start:])
347
- if not end_match:
348
- break
349
- json_end = end_match.span()[0] + json_start
350
-
351
- tool_json = content[json_start:json_end].strip('\n')
352
- try:
353
- tool_calls.append(json.loads(tool_json))
354
- except json.JSONDecodeError:
355
- logger.warning(f"Failed to parse JSON: {tool_json}")
356
- continue
357
- content = content[json_end:]
353
+ raw_content = response.msgs[0].content if response.msgs else ""
354
+ content = (
355
+ raw_content
356
+ if isinstance(raw_content, str)
357
+ else str(raw_content)
358
+ )
359
+
360
+ tool_calls = extract_tool_calls_from_text(content)
358
361
 
359
362
  if not tool_calls:
360
363
  return response
camel/loaders/__init__.py CHANGED
@@ -21,7 +21,6 @@ from .jina_url_reader import JinaURLReader
21
21
  from .markitdown import MarkItDownLoader
22
22
  from .mineru_extractor import MinerU
23
23
  from .mistral_reader import MistralReader
24
- from .pandas_reader import PandasReader
25
24
  from .scrapegraph_reader import ScrapeGraphAI
26
25
  from .unstructured_io import UnstructuredIO
27
26
 
@@ -33,7 +32,6 @@ __all__ = [
33
32
  'JinaURLReader',
34
33
  'Firecrawl',
35
34
  'Apify',
36
- 'PandasReader',
37
35
  'ChunkrReader',
38
36
  'ChunkrReaderConfig',
39
37
  'MinerU',
@@ -42,3 +40,14 @@ __all__ = [
42
40
  'ScrapeGraphAI',
43
41
  'MistralReader',
44
42
  ]
43
+
44
+
45
+ def __getattr__(name: str):
46
+ if name == 'PandasReader':
47
+ raise ImportError(
48
+ "PandasReader has been removed from camel.loaders. "
49
+ "The pandasai dependency limited pandas to version 1.5.3. "
50
+ "Please use ExcelToolkit from camel.toolkits instead for "
51
+ "handling structured data."
52
+ )
53
+ raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
@@ -34,6 +34,12 @@ class ChunkrReaderConfig:
34
34
  high_resolution (bool, optional): Whether to use high resolution OCR.
35
35
  (default: :obj:`True`)
36
36
  ocr_strategy (str, optional): The OCR strategy. Defaults to 'Auto'.
37
+ **kwargs: Additional keyword arguments to pass to the Chunkr
38
+ Configuration. This accepts all other Configuration parameters
39
+ such as expires_in, pipeline, segment_processing,
40
+ segmentation_strategy, etc.
41
+ See: https://github.com/lumina-ai-inc/chunkr/blob/main/core/src/
42
+ models/task.rs#L749
37
43
  """
38
44
 
39
45
  def __init__(
@@ -41,10 +47,12 @@ class ChunkrReaderConfig:
41
47
  chunk_processing: int = 512,
42
48
  high_resolution: bool = True,
43
49
  ocr_strategy: str = "Auto",
50
+ **kwargs,
44
51
  ):
45
52
  self.chunk_processing = chunk_processing
46
53
  self.high_resolution = high_resolution
47
54
  self.ocr_strategy = ocr_strategy
55
+ self.kwargs = kwargs
48
56
 
49
57
 
50
58
  class ChunkrReader:
@@ -190,4 +198,5 @@ class ChunkrReader:
190
198
  "Auto": OcrStrategy.AUTO,
191
199
  "All": OcrStrategy.ALL,
192
200
  }.get(chunkr_config.ocr_strategy, OcrStrategy.ALL),
201
+ **chunkr_config.kwargs,
193
202
  )
camel/memories/records.py CHANGED
@@ -94,6 +94,42 @@ class MemoryRecord(BaseModel):
94
94
  if "role_type" in data and isinstance(data["role_type"], str):
95
95
  data["role_type"] = RoleType(data["role_type"])
96
96
 
97
+ # Deserialize image_list from base64 strings/URLs back to PIL Images/
98
+ # URLs
99
+ if "image_list" in data and data["image_list"] is not None:
100
+ import base64
101
+ from io import BytesIO
102
+
103
+ from PIL import Image
104
+
105
+ image_objects = []
106
+ for img_item in data["image_list"]:
107
+ if isinstance(img_item, dict):
108
+ # New format with type indicator
109
+ if img_item["type"] == "url":
110
+ # URL string, keep as-is
111
+ image_objects.append(img_item["data"])
112
+ else: # type == "base64"
113
+ # Base64 encoded image, convert to PIL Image
114
+ img_bytes = base64.b64decode(img_item["data"])
115
+ img = Image.open(BytesIO(img_bytes))
116
+ # Restore the format attribute if it was saved
117
+ if "format" in img_item:
118
+ img.format = img_item["format"]
119
+ image_objects.append(img)
120
+ else:
121
+ # Legacy format: assume it's a base64 string
122
+ img_bytes = base64.b64decode(img_item)
123
+ img = Image.open(BytesIO(img_bytes))
124
+ image_objects.append(img)
125
+ data["image_list"] = image_objects
126
+
127
+ # Deserialize video_bytes from base64 string
128
+ if "video_bytes" in data and data["video_bytes"] is not None:
129
+ import base64
130
+
131
+ data["video_bytes"] = base64.b64decode(data["video_bytes"])
132
+
97
133
  # Get valid constructor parameters (cached)
98
134
  valid_params = cls._get_constructor_params(message_cls)
99
135
 
camel/messages/base.py CHANGED
@@ -64,8 +64,9 @@ class BaseMessage:
64
64
  content (str): The content of the message.
65
65
  video_bytes (Optional[bytes]): Optional bytes of a video associated
66
66
  with the message. (default: :obj:`None`)
67
- image_list (Optional[List[Image.Image]]): Optional list of PIL Image
68
- objects associated with the message. (default: :obj:`None`)
67
+ image_list (Optional[List[Union[Image.Image, str]]]): Optional list of
68
+ PIL Image objects or image URLs (strings) associated with the
69
+ message. (default: :obj:`None`)
69
70
  image_detail (Literal["auto", "low", "high"]): Detail level of the
70
71
  images associated with the message. (default: :obj:`auto`)
71
72
  video_detail (Literal["auto", "low", "high"]): Detail level of the
@@ -80,7 +81,7 @@ class BaseMessage:
80
81
  content: str
81
82
 
82
83
  video_bytes: Optional[bytes] = None
83
- image_list: Optional[List[Image.Image]] = None
84
+ image_list: Optional[List[Union[Image.Image, str]]] = None
84
85
  image_detail: Literal["auto", "low", "high"] = "auto"
85
86
  video_detail: Literal["auto", "low", "high"] = "auto"
86
87
  parsed: Optional[Union[BaseModel, dict]] = None
@@ -92,7 +93,7 @@ class BaseMessage:
92
93
  content: str,
93
94
  meta_dict: Optional[Dict[str, str]] = None,
94
95
  video_bytes: Optional[bytes] = None,
95
- image_list: Optional[List[Image.Image]] = None,
96
+ image_list: Optional[List[Union[Image.Image, str]]] = None,
96
97
  image_detail: Union[
97
98
  OpenAIVisionDetailType, str
98
99
  ] = OpenAIVisionDetailType.AUTO,
@@ -109,8 +110,9 @@ class BaseMessage:
109
110
  dictionary for the message.
110
111
  video_bytes (Optional[bytes]): Optional bytes of a video
111
112
  associated with the message.
112
- image_list (Optional[List[Image.Image]]): Optional list of PIL
113
- Image objects associated with the message.
113
+ image_list (Optional[List[Union[Image.Image, str]]]): Optional list
114
+ of PIL Image objects or image URLs (strings) associated with
115
+ the message.
114
116
  image_detail (Union[OpenAIVisionDetailType, str]): Detail level of
115
117
  the images associated with the message.
116
118
  video_detail (Union[OpenAIVisionDetailType, str]): Detail level of
@@ -137,7 +139,7 @@ class BaseMessage:
137
139
  content: str,
138
140
  meta_dict: Optional[Dict[str, str]] = None,
139
141
  video_bytes: Optional[bytes] = None,
140
- image_list: Optional[List[Image.Image]] = None,
142
+ image_list: Optional[List[Union[Image.Image, str]]] = None,
141
143
  image_detail: Union[
142
144
  OpenAIVisionDetailType, str
143
145
  ] = OpenAIVisionDetailType.AUTO,
@@ -154,8 +156,9 @@ class BaseMessage:
154
156
  dictionary for the message.
155
157
  video_bytes (Optional[bytes]): Optional bytes of a video
156
158
  associated with the message.
157
- image_list (Optional[List[Image.Image]]): Optional list of PIL
158
- Image objects associated with the message.
159
+ image_list (Optional[List[Union[Image.Image, str]]]): Optional list
160
+ of PIL Image objects or image URLs (strings) associated with
161
+ the message.
159
162
  image_detail (Union[OpenAIVisionDetailType, str]): Detail level of
160
163
  the images associated with the message.
161
164
  video_detail (Union[OpenAIVisionDetailType, str]): Detail level of
@@ -436,31 +439,64 @@ class BaseMessage:
436
439
  )
437
440
  if self.image_list and len(self.image_list) > 0:
438
441
  for image in self.image_list:
439
- if image.format is None:
440
- # Set default format to PNG as fallback
441
- image.format = 'PNG'
442
-
443
- image_type: str = image.format.lower()
444
- if image_type not in OpenAIImageType:
445
- raise ValueError(
446
- f"Image type {image.format} "
447
- f"is not supported by OpenAI vision model"
442
+ # Check if image is a URL string or PIL Image
443
+ if isinstance(image, str):
444
+ # Image is a URL string
445
+ hybrid_content.append(
446
+ {
447
+ "type": "image_url",
448
+ "image_url": {
449
+ "url": image,
450
+ "detail": self.image_detail,
451
+ },
452
+ }
448
453
  )
449
- with io.BytesIO() as buffer:
450
- image.save(fp=buffer, format=image.format)
451
- encoded_image = base64.b64encode(buffer.getvalue()).decode(
452
- "utf-8"
454
+ else:
455
+ # Image is a PIL Image object
456
+ if image.format is None:
457
+ # Set default format to PNG as fallback
458
+ image.format = 'PNG'
459
+
460
+ image_type: str = image.format.lower()
461
+ if image_type not in OpenAIImageType:
462
+ raise ValueError(
463
+ f"Image type {image.format} "
464
+ f"is not supported by OpenAI vision model"
465
+ )
466
+
467
+ # Convert RGBA to RGB for formats that don't support
468
+ # transparency or when the image has transparency channel
469
+ img_to_save = image
470
+ if image.mode in ('RGBA', 'LA', 'P') and image_type in (
471
+ 'jpeg',
472
+ 'jpg',
473
+ ):
474
+ # JPEG doesn't support transparency, convert to RGB
475
+ img_to_save = image.convert('RGB')
476
+ elif (
477
+ image.mode in ('RGBA', 'LA', 'P')
478
+ and image_type == 'png'
479
+ ):
480
+ # For PNG with transparency, convert to RGBA if needed
481
+ if image.mode in ('LA', 'P'):
482
+ img_to_save = image.convert('RGBA')
483
+ # else: RGBA mode, keep as-is
484
+
485
+ with io.BytesIO() as buffer:
486
+ img_to_save.save(fp=buffer, format=image.format)
487
+ encoded_image = base64.b64encode(
488
+ buffer.getvalue()
489
+ ).decode("utf-8")
490
+ image_prefix = f"data:image/{image_type};base64,"
491
+ hybrid_content.append(
492
+ {
493
+ "type": "image_url",
494
+ "image_url": {
495
+ "url": f"{image_prefix}{encoded_image}",
496
+ "detail": self.image_detail,
497
+ },
498
+ }
453
499
  )
454
- image_prefix = f"data:image/{image_type};base64,"
455
- hybrid_content.append(
456
- {
457
- "type": "image_url",
458
- "image_url": {
459
- "url": f"{image_prefix}{encoded_image}",
460
- "detail": self.image_detail,
461
- },
462
- }
463
- )
464
500
 
465
501
  if self.video_bytes:
466
502
  import imageio.v3 as iio
@@ -552,9 +588,66 @@ class BaseMessage:
552
588
  Returns:
553
589
  dict: The converted dictionary.
554
590
  """
555
- return {
591
+ result = {
556
592
  "role_name": self.role_name,
557
593
  "role_type": self.role_type.value,
558
594
  **(self.meta_dict or {}),
559
595
  "content": self.content,
560
596
  }
597
+
598
+ # Include image/video fields if present
599
+ if self.image_list is not None:
600
+ # Handle both PIL Images and URL strings
601
+ import base64
602
+ from io import BytesIO
603
+
604
+ image_data_list = []
605
+ for img in self.image_list:
606
+ if isinstance(img, str):
607
+ # Image is a URL string, store as-is
608
+ image_data_list.append({"type": "url", "data": img})
609
+ else:
610
+ # Image is a PIL Image, convert to base64
611
+ # Preserve format, default to PNG if not set
612
+ img_format = img.format if img.format else "PNG"
613
+
614
+ # Handle transparency for different formats
615
+ img_to_save = img
616
+ if img.mode in (
617
+ 'RGBA',
618
+ 'LA',
619
+ 'P',
620
+ ) and img_format.upper() in ('JPEG', 'JPG'):
621
+ # JPEG doesn't support transparency, convert to RGB
622
+ img_to_save = img.convert('RGB')
623
+ elif (
624
+ img.mode in ('LA', 'P') and img_format.upper() == 'PNG'
625
+ ):
626
+ # For PNG with transparency, convert to RGBA if needed
627
+ img_to_save = img.convert('RGBA')
628
+ # else: keep as-is for other combinations
629
+
630
+ buffered = BytesIO()
631
+ img_to_save.save(buffered, format=img_format)
632
+ img_str = base64.b64encode(buffered.getvalue()).decode()
633
+ image_data_list.append(
634
+ {
635
+ "type": "base64",
636
+ "data": img_str,
637
+ "format": img_format, # Preserve format
638
+ }
639
+ )
640
+ result["image_list"] = image_data_list
641
+
642
+ if self.video_bytes is not None:
643
+ import base64
644
+
645
+ result["video_bytes"] = base64.b64encode(self.video_bytes).decode()
646
+
647
+ if self.image_detail is not None:
648
+ result["image_detail"] = self.image_detail
649
+
650
+ if self.video_detail is not None:
651
+ result["video_detail"] = self.video_detail
652
+
653
+ return result
@@ -0,0 +1,18 @@
1
+ # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+ # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
14
+ """Helper parsers used across the CAMEL project."""
15
+
16
+ from .mcp_tool_call_parser import extract_tool_calls_from_text
17
+
18
+ __all__ = ["extract_tool_calls_from_text"]
@@ -0,0 +1,176 @@
1
+ # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
2
+ # Licensed under the Apache License, Version 2.0 (the "License");
3
+ # you may not use this file except in compliance with the License.
4
+ # You may obtain a copy of the License at
5
+ #
6
+ # http://www.apache.org/licenses/LICENSE-2.0
7
+ #
8
+ # Unless required by applicable law or agreed to in writing, software
9
+ # distributed under the License is distributed on an "AS IS" BASIS,
10
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
11
+ # See the License for the specific language governing permissions and
12
+ # limitations under the License.
13
+ # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
14
+ """Utility functions for parsing MCP tool calls from model output."""
15
+
16
+ import ast
17
+ import json
18
+ import logging
19
+ import re
20
+ from typing import Any, Dict, List, Optional
21
+
22
+ try: # pragma: no cover - optional dependency
23
+ import yaml
24
+ except ImportError: # pragma: no cover
25
+ yaml = None # type: ignore[assignment]
26
+
27
+
28
+ CODE_BLOCK_PATTERN = re.compile(
29
+ r"```(?:[a-z0-9_-]+)?\s*([\s\S]+?)\s*```",
30
+ re.IGNORECASE,
31
+ )
32
+
33
+ JSON_START_PATTERN = re.compile(r"[{\[]")
34
+ JSON_TOKEN_PATTERN = re.compile(
35
+ r"""
36
+ (?P<double>"(?:\\.|[^"\\])*")
37
+ |
38
+ (?P<single>'(?:\\.|[^'\\])*')
39
+ |
40
+ (?P<brace>[{}\[\]])
41
+ """,
42
+ re.VERBOSE,
43
+ )
44
+
45
+ logger = logging.getLogger(__name__)
46
+
47
+
48
+ def extract_tool_calls_from_text(content: str) -> List[Dict[str, Any]]:
49
+ """Extract tool call dictionaries from raw text output."""
50
+
51
+ if not content:
52
+ return []
53
+
54
+ tool_calls: List[Dict[str, Any]] = []
55
+ seen_ranges: List[tuple[int, int]] = []
56
+
57
+ for match in CODE_BLOCK_PATTERN.finditer(content):
58
+ snippet = match.group(1).strip()
59
+ if not snippet:
60
+ continue
61
+
62
+ parsed = _try_parse_json_like(snippet)
63
+ if parsed is None:
64
+ logger.warning(
65
+ "Failed to parse JSON payload from fenced block: %s",
66
+ snippet,
67
+ )
68
+ continue
69
+
70
+ _collect_tool_calls(parsed, tool_calls)
71
+ seen_ranges.append((match.start(1), match.end(1)))
72
+
73
+ for start_match in JSON_START_PATTERN.finditer(content):
74
+ start_idx = start_match.start()
75
+
76
+ if any(start <= start_idx < stop for start, stop in seen_ranges):
77
+ continue
78
+
79
+ segment = _find_json_candidate(content, start_idx)
80
+ if segment is None:
81
+ continue
82
+
83
+ end_idx = start_idx + len(segment)
84
+ if any(start <= start_idx < stop for start, stop in seen_ranges):
85
+ continue
86
+
87
+ parsed = _try_parse_json_like(segment)
88
+ if parsed is None:
89
+ logger.debug(
90
+ "Unable to parse JSON-like candidate: %s",
91
+ _truncate_snippet(segment),
92
+ )
93
+ continue
94
+
95
+ _collect_tool_calls(parsed, tool_calls)
96
+ seen_ranges.append((start_idx, end_idx))
97
+
98
+ return tool_calls
99
+
100
+
101
+ def _collect_tool_calls(
102
+ payload: Any, accumulator: List[Dict[str, Any]]
103
+ ) -> None:
104
+ """Collect valid tool call dictionaries from parsed payloads."""
105
+
106
+ if isinstance(payload, dict):
107
+ if payload.get("tool_name") is None:
108
+ return
109
+ accumulator.append(payload)
110
+ elif isinstance(payload, list):
111
+ for item in payload:
112
+ _collect_tool_calls(item, accumulator)
113
+
114
+
115
+ def _try_parse_json_like(snippet: str) -> Optional[Any]:
116
+ """Parse a JSON or JSON-like snippet into Python data."""
117
+
118
+ try:
119
+ return json.loads(snippet)
120
+ except json.JSONDecodeError as exc:
121
+ logger.debug(
122
+ "json.loads failed: %s | snippet=%s",
123
+ exc,
124
+ _truncate_snippet(snippet),
125
+ )
126
+
127
+ if yaml is not None:
128
+ try:
129
+ return yaml.safe_load(snippet)
130
+ except yaml.YAMLError:
131
+ pass
132
+
133
+ try:
134
+ return ast.literal_eval(snippet)
135
+ except (ValueError, SyntaxError):
136
+ return None
137
+
138
+
139
+ def _find_json_candidate(content: str, start_idx: int) -> Optional[str]:
140
+ """Locate a balanced JSON-like segment starting at ``start_idx``."""
141
+
142
+ opening = content[start_idx]
143
+ if opening not in "{[":
144
+ return None
145
+
146
+ stack = ["}" if opening == "{" else "]"]
147
+
148
+ for token in JSON_TOKEN_PATTERN.finditer(content, start_idx + 1):
149
+ if token.lastgroup in {"double", "single"}:
150
+ continue
151
+
152
+ brace = token.group("brace")
153
+ if brace in "{[":
154
+ stack.append("}" if brace == "{" else "]")
155
+ continue
156
+
157
+ if not stack:
158
+ return None
159
+
160
+ expected = stack.pop()
161
+ if brace != expected:
162
+ return None
163
+
164
+ if not stack:
165
+ return content[start_idx : token.end()]
166
+
167
+ return None
168
+
169
+
170
+ def _truncate_snippet(snippet: str, limit: int = 120) -> str:
171
+ """Return a truncated representation suitable for logging."""
172
+
173
+ compact = " ".join(snippet.strip().split())
174
+ if len(compact) <= limit:
175
+ return compact
176
+ return f"{compact[: limit - 3]}..."
@@ -16,8 +16,11 @@ from collections import defaultdict, deque
16
16
  from enum import Enum
17
17
  from typing import Dict, List, Optional, Set
18
18
 
19
+ from camel.logger import get_logger
19
20
  from camel.tasks import Task
20
21
 
22
+ logger = get_logger(__name__)
23
+
21
24
 
22
25
  class PacketStatus(Enum):
23
26
  r"""The status of a packet. The packet can be in one of the following
@@ -269,6 +272,46 @@ class TaskChannel:
269
272
  async with self._condition:
270
273
  return list(self._task_by_status[PacketStatus.ARCHIVED])
271
274
 
275
+ async def get_in_flight_tasks(self, publisher_id: str) -> List[Task]:
276
+ r"""Get all tasks that are currently in-flight (SENT, RETURNED
277
+ or PROCESSING) published by the given publisher.
278
+
279
+ Args:
280
+ publisher_id (str): The ID of the publisher whose
281
+ in-flight tasks to retrieve.
282
+
283
+ Returns:
284
+ List[Task]: List of tasks that are currently in-flight.
285
+ """
286
+ async with self._condition:
287
+ in_flight_tasks = []
288
+ seen_task_ids = set() # Track seen IDs for duplicate detection
289
+
290
+ # Get tasks with SENT, RETURNED or PROCESSING
291
+ # status published by this publisher
292
+ for status in [
293
+ PacketStatus.SENT,
294
+ PacketStatus.PROCESSING,
295
+ PacketStatus.RETURNED,
296
+ ]:
297
+ for task_id in self._task_by_status[status]:
298
+ if task_id in self._task_dict:
299
+ packet = self._task_dict[task_id]
300
+ if packet.publisher_id == publisher_id:
301
+ # Defensive check: detect if task appears in
302
+ # multiple status sets (should never happen)
303
+ if task_id in seen_task_ids:
304
+ logger.warning(
305
+ f"Task {task_id} found in multiple "
306
+ f"status sets. This indicates a bug in "
307
+ f"status management."
308
+ )
309
+ continue
310
+ in_flight_tasks.append(packet.task)
311
+ seen_task_ids.add(task_id)
312
+
313
+ return in_flight_tasks
314
+
272
315
  async def get_task_by_id(self, task_id: str) -> Task:
273
316
  r"""Get a task from the channel by its ID."""
274
317
  async with self._condition: