camel-ai 0.2.78__py3-none-any.whl → 0.2.79a1__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.

Files changed (39) hide show
  1. camel/__init__.py +1 -1
  2. camel/agents/_utils.py +38 -0
  3. camel/agents/chat_agent.py +1112 -287
  4. camel/datasets/base_generator.py +39 -10
  5. camel/environments/single_step.py +28 -3
  6. camel/memories/__init__.py +1 -2
  7. camel/memories/agent_memories.py +34 -0
  8. camel/memories/base.py +26 -0
  9. camel/memories/blocks/chat_history_block.py +117 -17
  10. camel/memories/context_creators/score_based.py +25 -384
  11. camel/messages/base.py +26 -0
  12. camel/models/aws_bedrock_model.py +1 -17
  13. camel/models/azure_openai_model.py +113 -67
  14. camel/models/model_factory.py +17 -1
  15. camel/models/moonshot_model.py +102 -5
  16. camel/models/openai_compatible_model.py +62 -32
  17. camel/models/openai_model.py +61 -35
  18. camel/models/samba_model.py +34 -15
  19. camel/models/sglang_model.py +41 -11
  20. camel/societies/workforce/__init__.py +2 -0
  21. camel/societies/workforce/events.py +122 -0
  22. camel/societies/workforce/role_playing_worker.py +15 -11
  23. camel/societies/workforce/single_agent_worker.py +143 -291
  24. camel/societies/workforce/utils.py +2 -1
  25. camel/societies/workforce/workflow_memory_manager.py +772 -0
  26. camel/societies/workforce/workforce.py +513 -188
  27. camel/societies/workforce/workforce_callback.py +74 -0
  28. camel/societies/workforce/workforce_logger.py +144 -140
  29. camel/societies/workforce/workforce_metrics.py +33 -0
  30. camel/storages/vectordb_storages/oceanbase.py +5 -4
  31. camel/toolkits/file_toolkit.py +166 -0
  32. camel/toolkits/message_integration.py +15 -13
  33. camel/toolkits/terminal_toolkit/terminal_toolkit.py +112 -79
  34. camel/types/enums.py +1 -0
  35. camel/utils/context_utils.py +201 -2
  36. {camel_ai-0.2.78.dist-info → camel_ai-0.2.79a1.dist-info}/METADATA +14 -13
  37. {camel_ai-0.2.78.dist-info → camel_ai-0.2.79a1.dist-info}/RECORD +39 -35
  38. {camel_ai-0.2.78.dist-info → camel_ai-0.2.79a1.dist-info}/WHEEL +0 -0
  39. {camel_ai-0.2.78.dist-info → camel_ai-0.2.79a1.dist-info}/licenses/LICENSE +0 -0
@@ -148,12 +148,10 @@ class ToolkitMessageIntegration:
148
148
  """
149
149
  return FunctionTool(self.send_message_to_user)
150
150
 
151
- def register_toolkits(
152
- self, toolkit: BaseToolkit, tool_names: Optional[List[str]] = None
153
- ) -> BaseToolkit:
154
- r"""Add messaging capabilities to toolkit methods.
151
+ def register_toolkits(self, toolkit: BaseToolkit) -> BaseToolkit:
152
+ r"""Add messaging capabilities to all toolkit methods.
155
153
 
156
- This method modifies a toolkit so that specified tools can send
154
+ This method modifies a toolkit so that all its tools can send
157
155
  status messages to users while executing their primary function.
158
156
  The tools will accept optional messaging parameters:
159
157
  - message_title: Title of the status message
@@ -162,20 +160,18 @@ class ToolkitMessageIntegration:
162
160
 
163
161
  Args:
164
162
  toolkit: The toolkit to add messaging capabilities to
165
- tool_names: List of specific tool names to modify.
166
- If None, messaging is added to all tools.
167
163
 
168
164
  Returns:
169
- The toolkit with messaging capabilities added
165
+ The same toolkit instance with messaging capabilities added to
166
+ all methods.
170
167
  """
171
168
  original_tools = toolkit.get_tools()
172
169
  enhanced_methods = {}
173
170
  for tool in original_tools:
174
171
  method_name = tool.func.__name__
175
- if tool_names is None or method_name in tool_names:
176
- enhanced_func = self._add_messaging_to_tool(tool.func)
177
- enhanced_methods[method_name] = enhanced_func
178
- setattr(toolkit, method_name, enhanced_func)
172
+ enhanced_func = self._add_messaging_to_tool(tool.func)
173
+ enhanced_methods[method_name] = enhanced_func
174
+ setattr(toolkit, method_name, enhanced_func)
179
175
  original_get_tools_method = toolkit.get_tools
180
176
 
181
177
  def enhanced_get_tools() -> List[FunctionTool]:
@@ -201,7 +197,7 @@ class ToolkitMessageIntegration:
201
197
  def enhanced_clone_for_new_session(new_session_id=None):
202
198
  cloned_toolkit = original_clone_method(new_session_id)
203
199
  return message_integration_instance.register_toolkits(
204
- cloned_toolkit, tool_names
200
+ cloned_toolkit
205
201
  )
206
202
 
207
203
  toolkit.clone_for_new_session = enhanced_clone_for_new_session
@@ -300,6 +296,12 @@ class ToolkitMessageIntegration:
300
296
  This internal method modifies the function signature and docstring
301
297
  to include optional messaging parameters that trigger status updates.
302
298
  """
299
+ if getattr(func, "__message_integration_enhanced__", False):
300
+ logger.debug(
301
+ f"Function {func.__name__} already enhanced, skipping"
302
+ )
303
+ return func
304
+
303
305
  # Get the original signature
304
306
  original_sig = inspect.signature(func)
305
307
 
@@ -11,7 +11,6 @@
11
11
  # See the License for the specific language governing permissions and
12
12
  # limitations under the License.
13
13
  # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
14
- import atexit
15
14
  import os
16
15
  import platform
17
16
  import select
@@ -42,12 +41,10 @@ logger = get_logger(__name__)
42
41
  try:
43
42
  import docker
44
43
  from docker.errors import APIError, NotFound
45
- from docker.models.containers import Container
46
44
  except ImportError:
47
45
  docker = None
48
46
  NotFound = None
49
47
  APIError = None
50
- Container = None
51
48
 
52
49
 
53
50
  def _to_plain(text: str) -> str:
@@ -149,8 +146,6 @@ class TerminalToolkit(BaseToolkit):
149
146
  self.initial_env_path: Optional[str] = None
150
147
  self.python_executable = sys.executable
151
148
 
152
- atexit.register(self.__del__)
153
-
154
149
  self.log_dir = os.path.abspath(
155
150
  session_logs_dir or os.path.join(self.working_dir, "terminal_logs")
156
151
  )
@@ -400,9 +395,11 @@ class TerminalToolkit(BaseToolkit):
400
395
  with self._session_lock:
401
396
  if session_id in self.shell_sessions:
402
397
  self.shell_sessions[session_id]["error"] = str(e)
403
- except Exception:
404
- # Swallow any secondary errors during cleanup
405
- pass
398
+ except Exception as cleanup_error:
399
+ logger.warning(
400
+ f"[SESSION {session_id}] Failed to store error state: "
401
+ f"{cleanup_error}"
402
+ )
406
403
  finally:
407
404
  try:
408
405
  with self._session_lock:
@@ -480,39 +477,40 @@ class TerminalToolkit(BaseToolkit):
480
477
 
481
478
  warning_message = (
482
479
  "\n--- WARNING: Process is still actively outputting "
483
- "after max wait time. Consider using shell_wait() "
484
- "before sending the next command. ---"
480
+ "after max wait time. Consider waiting before "
481
+ "sending the next command. ---"
485
482
  )
486
483
  return "".join(output_parts) + warning_message
487
484
 
488
485
  def shell_exec(self, id: str, command: str, block: bool = True) -> str:
489
- r"""This function executes a shell command. The command can run in
490
- blocking mode (waits for completion) or non-blocking mode
491
- (runs in the background). A unique session ID is created for
492
- each session.
486
+ r"""Executes a shell command in blocking or non-blocking mode.
493
487
 
494
488
  Args:
495
- command (str): The command to execute.
496
- block (bool): If True, the command runs synchronously,
497
- waiting for it to complete or time out, and returns
498
- its full output. If False, the command runs
499
- asynchronously in the background.
500
- id (Optional[str]): A specific ID for the session. If not provided,
501
- a unique ID is generated for non-blocking sessions.
489
+ id (str): A unique identifier for the command's session. This ID is
490
+ used to interact with non-blocking processes.
491
+ command (str): The shell command to execute.
492
+ block (bool, optional): Determines the execution mode. Defaults to
493
+ True. If `True` (blocking mode), the function waits for the
494
+ command to complete and returns the full output. Use this for
495
+ most commands . If `False` (non-blocking mode), the function
496
+ starts the command in the background. Use this only for
497
+ interactive sessions or long-running tasks, or servers.
502
498
 
503
499
  Returns:
504
- str: If block is True, returns the complete stdout and stderr.
505
- If block is False, returns a message containing the new
506
- session ID and the initial output from the command after
507
- it goes idle.
500
+ str: The output of the command execution, which varies by mode.
501
+ In blocking mode, returns the complete standard output and
502
+ standard error from the command.
503
+ In non-blocking mode, returns a confirmation message with the
504
+ session `id`. To interact with the background process, use
505
+ other functions: `shell_view(id)` to see output,
506
+ `shell_write_to_process(id, "input")` to send input, and
507
+ `shell_kill_process(id)` to terminate.
508
508
  """
509
509
  if self.safe_mode:
510
510
  is_safe, message = self._sanitize_command(command)
511
511
  if not is_safe:
512
512
  return f"Error: {message}"
513
513
  command = message
514
- else:
515
- command = command
516
514
 
517
515
  if self.use_docker_backend:
518
516
  # For Docker, we always run commands in a shell
@@ -570,7 +568,10 @@ class TerminalToolkit(BaseToolkit):
570
568
  log_entry += f"--- Error ---\n{error_msg}\n"
571
569
  return error_msg
572
570
  except Exception as e:
573
- if "Read timed out" in str(e):
571
+ if (
572
+ isinstance(e, (subprocess.TimeoutExpired, TimeoutError))
573
+ or "timed out" in str(e).lower()
574
+ ):
574
575
  error_msg = (
575
576
  f"Error: Command timed out after "
576
577
  f"{self.timeout} seconds."
@@ -593,6 +594,14 @@ class TerminalToolkit(BaseToolkit):
593
594
  f"> {command}\n",
594
595
  )
595
596
 
597
+ # PYTHONUNBUFFERED=1 for real-time output
598
+ # Without this, Python subprocesses buffer output (4KB buffer)
599
+ # and shell_view() won't see output until buffer fills or process
600
+ # exits
601
+ env_vars = os.environ.copy()
602
+ env_vars["PYTHONUNBUFFERED"] = "1"
603
+ docker_env = {"PYTHONUNBUFFERED": "1"}
604
+
596
605
  with self._session_lock:
597
606
  self.shell_sessions[session_id] = {
598
607
  "id": session_id,
@@ -606,6 +615,8 @@ class TerminalToolkit(BaseToolkit):
606
615
  else "local",
607
616
  }
608
617
 
618
+ process = None
619
+ exec_socket = None
609
620
  try:
610
621
  if not self.use_docker_backend:
611
622
  process = subprocess.Popen(
@@ -617,6 +628,7 @@ class TerminalToolkit(BaseToolkit):
617
628
  text=True,
618
629
  cwd=self.working_dir,
619
630
  encoding="utf-8",
631
+ env=env_vars,
620
632
  )
621
633
  with self._session_lock:
622
634
  self.shell_sessions[session_id]["process"] = process
@@ -630,6 +642,7 @@ class TerminalToolkit(BaseToolkit):
630
642
  stdin=True,
631
643
  tty=True,
632
644
  workdir=self.docker_workdir,
645
+ environment=docker_env,
633
646
  )
634
647
  exec_id = exec_instance['Id']
635
648
  exec_socket = self.docker_api_client.exec_start(
@@ -643,15 +656,29 @@ class TerminalToolkit(BaseToolkit):
643
656
 
644
657
  self._start_output_reader_thread(session_id)
645
658
 
646
- # time.sleep(0.1)
647
- initial_output = self._collect_output_until_idle(session_id)
648
-
659
+ # Return immediately with session ID and instructions
649
660
  return (
650
- f"Session started with ID: {session_id}\n\n"
651
- f"[Initial Output]:\n{initial_output}"
661
+ f"Session '{session_id}' started.\n\n"
662
+ f"You could use:\n"
663
+ f" - shell_view('{session_id}') - get output\n"
664
+ f" - shell_write_to_process('{session_id}', '<input>')"
665
+ f" - send input\n"
666
+ f" - shell_kill_process('{session_id}') - terminate"
652
667
  )
653
668
 
654
669
  except Exception as e:
670
+ # Clean up resources on failure
671
+ if process is not None:
672
+ try:
673
+ process.terminate()
674
+ except Exception:
675
+ pass
676
+ if exec_socket is not None:
677
+ try:
678
+ exec_socket.close()
679
+ except Exception:
680
+ pass
681
+
655
682
  with self._session_lock:
656
683
  if session_id in self.shell_sessions:
657
684
  self.shell_sessions[session_id]["running"] = False
@@ -714,18 +741,16 @@ class TerminalToolkit(BaseToolkit):
714
741
  return f"Error writing to session '{id}': {e}"
715
742
 
716
743
  def shell_view(self, id: str) -> str:
717
- r"""This function retrieves any new output from a non-blocking session
718
- since the last time this function was called. If the process has
719
- terminated, it drains the output queue and appends a termination
720
- message. If the process is still running, it simply returns any
721
- new output.
744
+ r"""Retrieves new output from a non-blocking session.
745
+
746
+ This function returns only NEW output since the last call. It does NOT
747
+ wait or block - it returns immediately with whatever is available.
722
748
 
723
749
  Args:
724
750
  id (str): The unique session ID of the non-blocking process.
725
751
 
726
752
  Returns:
727
- str: The new output from the process's stdout and stderr. Returns
728
- an empty string if there is no new output.
753
+ str: New output if available, or a status message.
729
754
  """
730
755
  with self._session_lock:
731
756
  if id not in self.shell_sessions:
@@ -734,7 +759,6 @@ class TerminalToolkit(BaseToolkit):
734
759
  is_running = session["running"]
735
760
 
736
761
  # If session is terminated, drain the queue and return
737
- # with a status message.
738
762
  if not is_running:
739
763
  final_output = []
740
764
  try:
@@ -742,9 +766,13 @@ class TerminalToolkit(BaseToolkit):
742
766
  final_output.append(session["output_stream"].get_nowait())
743
767
  except Empty:
744
768
  pass
745
- return "".join(final_output) + "\n--- SESSION TERMINATED ---"
746
769
 
747
- # Otherwise, just drain the queue for a live session.
770
+ if final_output:
771
+ return "".join(final_output) + "\n\n--- SESSION TERMINATED ---"
772
+ else:
773
+ return "--- SESSION TERMINATED (no new output) ---"
774
+
775
+ # For running session, check for new output
748
776
  output = []
749
777
  try:
750
778
  while True:
@@ -752,38 +780,18 @@ class TerminalToolkit(BaseToolkit):
752
780
  except Empty:
753
781
  pass
754
782
 
755
- return "".join(output)
756
-
757
- def shell_wait(self, id: str, wait_seconds: float = 5.0) -> str:
758
- r"""This function waits for a specified duration for a
759
- non-blocking process to produce more output or terminate.
760
-
761
- Args:
762
- id (str): The unique session ID of the non-blocking process.
763
- wait_seconds (float): The maximum number of seconds to wait.
764
-
765
- Returns:
766
- str: All output collected during the wait period.
767
- """
768
- with self._session_lock:
769
- if id not in self.shell_sessions:
770
- return f"Error: No session found with ID '{id}'."
771
- session = self.shell_sessions[id]
772
- if not session["running"]:
773
- return (
774
- "Session is no longer running. "
775
- "Use shell_view to get final output."
776
- )
777
-
778
- output_collected = []
779
- end_time = time.time() + wait_seconds
780
- while time.time() < end_time and session["running"]:
781
- new_output = self.shell_view(id)
782
- if new_output:
783
- output_collected.append(new_output)
784
- time.sleep(0.2)
785
-
786
- return "".join(output_collected)
783
+ if output:
784
+ return "".join(output)
785
+ else:
786
+ # No new output - guide the agent
787
+ return (
788
+ "[No new output]\n"
789
+ "Session is running but idle. Actions could take:\n"
790
+ " - For interactive sessions: Send input "
791
+ "with shell_write_to_process()\n"
792
+ " - For long tasks: Check again later (don't poll "
793
+ "too frequently)"
794
+ )
787
795
 
788
796
  def shell_kill_process(self, id: str) -> str:
789
797
  r"""This function forcibly terminates a running non-blocking process.
@@ -894,8 +902,17 @@ class TerminalToolkit(BaseToolkit):
894
902
  except EOFError:
895
903
  return f"User input interrupted for session '{id}'."
896
904
 
897
- def __del__(self):
898
- # Clean up any sessions
905
+ def __enter__(self):
906
+ r"""Context manager entry."""
907
+ return self
908
+
909
+ def __exit__(self, exc_type, exc_val, exc_tb):
910
+ r"""Context manager exit - clean up all sessions."""
911
+ self.cleanup()
912
+ return False
913
+
914
+ def cleanup(self):
915
+ r"""Clean up all active sessions."""
899
916
  with self._session_lock:
900
917
  session_ids = list(self.shell_sessions.keys())
901
918
  for session_id in session_ids:
@@ -904,7 +921,24 @@ class TerminalToolkit(BaseToolkit):
904
921
  "running", False
905
922
  )
906
923
  if is_running:
907
- self.shell_kill_process(session_id)
924
+ try:
925
+ self.shell_kill_process(session_id)
926
+ except Exception as e:
927
+ logger.warning(
928
+ f"Failed to kill session '{session_id}' "
929
+ f"during cleanup: {e}"
930
+ )
931
+
932
+ cleanup._manual_timeout = True # type: ignore[attr-defined]
933
+
934
+ def __del__(self):
935
+ r"""Fallback cleanup in destructor."""
936
+ try:
937
+ self.cleanup()
938
+ except Exception:
939
+ pass
940
+
941
+ __del__._manual_timeout = True # type: ignore[attr-defined]
908
942
 
909
943
  def get_tools(self) -> List[FunctionTool]:
910
944
  r"""Returns a list of FunctionTool objects representing the functions
@@ -917,7 +951,6 @@ class TerminalToolkit(BaseToolkit):
917
951
  return [
918
952
  FunctionTool(self.shell_exec),
919
953
  FunctionTool(self.shell_view),
920
- FunctionTool(self.shell_wait),
921
954
  FunctionTool(self.shell_write_to_process),
922
955
  FunctionTool(self.shell_kill_process),
923
956
  FunctionTool(self.shell_ask_user_for_help),
camel/types/enums.py CHANGED
@@ -24,6 +24,7 @@ logger = get_logger(__name__)
24
24
  class RoleType(Enum):
25
25
  ASSISTANT = "assistant"
26
26
  USER = "user"
27
+ SYSTEM = "system"
27
28
  CRITIC = "critic"
28
29
  EMBODIMENT = "embodiment"
29
30
  DEFAULT = "default"
@@ -13,6 +13,7 @@
13
13
  # ========= Copyright 2023-2024 @ CAMEL-AI.org. All Rights Reserved. =========
14
14
 
15
15
  import os
16
+ import re
16
17
  from datetime import datetime
17
18
  from pathlib import Path
18
19
  from typing import TYPE_CHECKING, Any, ClassVar, Dict, List, Optional
@@ -90,6 +91,17 @@ class WorkflowSummary(BaseModel):
90
91
  "mid-task by using the HumanToolkit.",
91
92
  default="",
92
93
  )
94
+ tags: List[str] = Field(
95
+ description="3-10 categorization tags that describe the workflow "
96
+ "type, domain, and key capabilities. Use lowercase with hyphens. "
97
+ "Tags should be broad, reusable categories to help with semantic "
98
+ "matching to similar tasks. "
99
+ "Examples: 'data-analysis', 'web-scraping', 'api-integration', "
100
+ "'code-generation', 'file-processing', 'database-query', "
101
+ "'text-processing', 'image-manipulation', 'email-automation', "
102
+ "'report-generation'.",
103
+ default_factory=list,
104
+ )
93
105
 
94
106
  @classmethod
95
107
  def get_instruction_prompt(cls) -> str:
@@ -111,7 +123,12 @@ class WorkflowSummary(BaseModel):
111
123
  'about a simple math problem, the workflow must be short, '
112
124
  'e.g. <60 words. By contrast, if the task is complex and '
113
125
  'multi-step, such as finding particular job applications based '
114
- 'on user CV, the workflow must be longer, e.g. about 120 words.'
126
+ 'on user CV, the workflow must be longer, e.g. about 120 words. '
127
+ 'For tags, provide 3-5 broad categorization tags using lowercase '
128
+ 'with hyphens (e.g., "data-analysis", "web-scraping") that '
129
+ 'describe the workflow domain, type, and key capabilities to '
130
+ 'help future agents discover this workflow when working on '
131
+ 'similar tasks.'
115
132
  )
116
133
 
117
134
 
@@ -131,6 +148,10 @@ class ContextUtility:
131
148
  - Shared session management for workforce workflows
132
149
  """
133
150
 
151
+ # maximum filename length for workflow files (chosen for filesystem
152
+ # compatibility and readability)
153
+ MAX_WORKFLOW_FILENAME_LENGTH: ClassVar[int] = 50
154
+
134
155
  # Class variables for shared session management
135
156
  _shared_sessions: ClassVar[Dict[str, 'ContextUtility']] = {}
136
157
  _default_workforce_session: ClassVar[Optional['ContextUtility']] = None
@@ -191,6 +212,54 @@ class ContextUtility:
191
212
  timestamp = datetime.now().strftime('%Y%m%d_%H%M%S_%f')
192
213
  return f"session_{timestamp}"
193
214
 
215
+ @staticmethod
216
+ def sanitize_workflow_filename(
217
+ name: str,
218
+ max_length: Optional[int] = None,
219
+ ) -> str:
220
+ r"""Sanitize a name string for use as a workflow filename.
221
+
222
+ Converts the input string to a safe filename by:
223
+ - converting to lowercase
224
+ - replacing spaces with underscores
225
+ - removing special characters (keeping only alphanumeric and
226
+ underscores)
227
+ - truncating to maximum length if specified
228
+
229
+ Args:
230
+ name (str): The name string to sanitize (e.g., role_name or
231
+ task_title).
232
+ max_length (Optional[int]): Maximum length for the sanitized
233
+ filename. If None, uses MAX_WORKFLOW_FILENAME_LENGTH.
234
+ (default: :obj:`None`)
235
+
236
+ Returns:
237
+ str: Sanitized filename string suitable for filesystem use.
238
+ Returns "agent" if sanitization results in empty string.
239
+
240
+ Example:
241
+ >>> ContextUtility.sanitize_workflow_filename("Data Analyst!")
242
+ 'data_analyst'
243
+ >>> ContextUtility.sanitize_workflow_filename("Test@123", 5)
244
+ 'test1'
245
+ """
246
+ if max_length is None:
247
+ max_length = ContextUtility.MAX_WORKFLOW_FILENAME_LENGTH
248
+
249
+ # sanitize: lowercase, spaces to underscores, remove special chars
250
+ clean_name = name.lower().replace(" ", "_")
251
+ clean_name = re.sub(r'[^a-z0-9_]', '', clean_name)
252
+
253
+ # truncate if too long
254
+ if len(clean_name) > max_length:
255
+ clean_name = clean_name[:max_length]
256
+
257
+ # ensure it's not empty after sanitization
258
+ if not clean_name:
259
+ clean_name = "agent"
260
+
261
+ return clean_name
262
+
194
263
  # ========= GENERIC FILE MANAGEMENT METHODS =========
195
264
 
196
265
  def _ensure_directory_exists(self) -> None:
@@ -480,7 +549,6 @@ class ContextUtility:
480
549
  'session_id': self.session_id,
481
550
  'working_directory': str(self.working_directory),
482
551
  'created_at': datetime.now().isoformat(),
483
- 'base_directory': str(self.working_directory.parent),
484
552
  }
485
553
 
486
554
  def list_sessions(self, base_dir: Optional[str] = None) -> List[str]:
@@ -741,6 +809,137 @@ class ContextUtility:
741
809
  result = '\n'.join(filtered_lines).strip()
742
810
  return result
743
811
 
812
+ # ========= WORKFLOW INFO METHODS =========
813
+
814
+ def extract_workflow_info(self, file_path: str) -> Dict[str, Any]:
815
+ r"""Extract info from a workflow markdown file.
816
+
817
+ This method reads only the essential info from a workflow file
818
+ (title, description, tags) for use in workflow selection without
819
+ loading the entire workflow content.
820
+
821
+ Args:
822
+ file_path (str): Full path to the workflow markdown file.
823
+
824
+ Returns:
825
+ Dict[str, Any]: Workflow info including title, description,
826
+ tags, and file_path. Returns empty dict on error.
827
+ """
828
+ import re
829
+
830
+ try:
831
+ with open(file_path, 'r', encoding='utf-8') as f:
832
+ content = f.read()
833
+
834
+ metadata: Dict[str, Any] = {'file_path': file_path}
835
+
836
+ # extract task title
837
+ title_match = re.search(
838
+ r'### Task Title\s*\n(.+?)(?:\n###|\n\n|$)', content, re.DOTALL
839
+ )
840
+ if title_match:
841
+ metadata['title'] = title_match.group(1).strip()
842
+ else:
843
+ metadata['title'] = ""
844
+
845
+ # extract task description
846
+ desc_match = re.search(
847
+ r'### Task Description\s*\n(.+?)(?:\n###|\n\n|$)',
848
+ content,
849
+ re.DOTALL,
850
+ )
851
+ if desc_match:
852
+ metadata['description'] = desc_match.group(1).strip()
853
+ else:
854
+ metadata['description'] = ""
855
+
856
+ # extract tags
857
+ tags_match = re.search(
858
+ r'### Tags\s*\n(.+?)(?:\n###|\n\n|$)', content, re.DOTALL
859
+ )
860
+ if tags_match:
861
+ tags_section = tags_match.group(1).strip()
862
+ # Parse bullet list of tags
863
+ tags = [
864
+ line.strip().lstrip('- ')
865
+ for line in tags_section.split('\n')
866
+ if line.strip().startswith('-')
867
+ ]
868
+ metadata['tags'] = tags
869
+ else:
870
+ metadata['tags'] = []
871
+
872
+ return metadata
873
+
874
+ except Exception as e:
875
+ logger.warning(
876
+ f"Error extracting workflow info from {file_path}: {e}"
877
+ )
878
+ return {}
879
+
880
+ def get_all_workflows_info(
881
+ self, session_id: Optional[str] = None
882
+ ) -> List[Dict[str, Any]]:
883
+ r"""Get info from all workflow files in workforce_workflows.
884
+
885
+ This method scans the workforce_workflows directory for workflow
886
+ markdown files and extracts their info for use in workflow
887
+ selection.
888
+
889
+ Args:
890
+ session_id (Optional[str]): If provided, only return workflows
891
+ from this specific session. If None, returns workflows from
892
+ all sessions.
893
+
894
+ Returns:
895
+ List[Dict[str, Any]]: List of workflow info dicts, sorted
896
+ by session timestamp (newest first).
897
+ """
898
+ import glob
899
+ import re
900
+
901
+ workflows_metadata = []
902
+
903
+ # Determine base directory for workforce workflows
904
+ camel_workdir = os.environ.get("CAMEL_WORKDIR")
905
+ if camel_workdir:
906
+ base_dir = os.path.join(camel_workdir, "workforce_workflows")
907
+ else:
908
+ base_dir = "workforce_workflows"
909
+
910
+ # Build search pattern
911
+ if session_id:
912
+ search_pattern = os.path.join(
913
+ base_dir, session_id, "*_workflow.md"
914
+ )
915
+ else:
916
+ search_pattern = os.path.join(base_dir, "*", "*_workflow.md")
917
+
918
+ # Find all workflow files
919
+ workflow_files = glob.glob(search_pattern)
920
+
921
+ if not workflow_files:
922
+ logger.info(f"No workflow files found in {base_dir}")
923
+ return []
924
+
925
+ # Sort by session timestamp (newest first)
926
+ def extract_session_timestamp(filepath: str) -> str:
927
+ match = re.search(r'session_(\d{8}_\d{6}_\d{6})', filepath)
928
+ return match.group(1) if match else ""
929
+
930
+ workflow_files.sort(key=extract_session_timestamp, reverse=True)
931
+
932
+ # Extract info from each file
933
+ for file_path in workflow_files:
934
+ metadata = self.extract_workflow_info(file_path)
935
+ if metadata: # Only add if extraction succeeded
936
+ workflows_metadata.append(metadata)
937
+
938
+ logger.info(
939
+ f"Found {len(workflows_metadata)} workflow file(s) with info"
940
+ )
941
+ return workflows_metadata
942
+
744
943
  # ========= SHARED SESSION MANAGEMENT METHODS =========
745
944
 
746
945
  @classmethod