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.
- camel/__init__.py +1 -1
- camel/agents/_utils.py +38 -0
- camel/agents/chat_agent.py +1112 -287
- camel/datasets/base_generator.py +39 -10
- camel/environments/single_step.py +28 -3
- camel/memories/__init__.py +1 -2
- camel/memories/agent_memories.py +34 -0
- camel/memories/base.py +26 -0
- camel/memories/blocks/chat_history_block.py +117 -17
- camel/memories/context_creators/score_based.py +25 -384
- camel/messages/base.py +26 -0
- camel/models/aws_bedrock_model.py +1 -17
- camel/models/azure_openai_model.py +113 -67
- camel/models/model_factory.py +17 -1
- camel/models/moonshot_model.py +102 -5
- camel/models/openai_compatible_model.py +62 -32
- camel/models/openai_model.py +61 -35
- camel/models/samba_model.py +34 -15
- camel/models/sglang_model.py +41 -11
- camel/societies/workforce/__init__.py +2 -0
- camel/societies/workforce/events.py +122 -0
- camel/societies/workforce/role_playing_worker.py +15 -11
- camel/societies/workforce/single_agent_worker.py +143 -291
- camel/societies/workforce/utils.py +2 -1
- camel/societies/workforce/workflow_memory_manager.py +772 -0
- camel/societies/workforce/workforce.py +513 -188
- camel/societies/workforce/workforce_callback.py +74 -0
- camel/societies/workforce/workforce_logger.py +144 -140
- camel/societies/workforce/workforce_metrics.py +33 -0
- camel/storages/vectordb_storages/oceanbase.py +5 -4
- camel/toolkits/file_toolkit.py +166 -0
- camel/toolkits/message_integration.py +15 -13
- camel/toolkits/terminal_toolkit/terminal_toolkit.py +112 -79
- camel/types/enums.py +1 -0
- camel/utils/context_utils.py +201 -2
- {camel_ai-0.2.78.dist-info → camel_ai-0.2.79a1.dist-info}/METADATA +14 -13
- {camel_ai-0.2.78.dist-info → camel_ai-0.2.79a1.dist-info}/RECORD +39 -35
- {camel_ai-0.2.78.dist-info → camel_ai-0.2.79a1.dist-info}/WHEEL +0 -0
- {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
|
-
|
|
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
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
|
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
|
-
|
|
405
|
-
|
|
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
|
|
484
|
-
"
|
|
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"""
|
|
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
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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:
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
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
|
|
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
|
-
#
|
|
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
|
|
651
|
-
f"
|
|
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"""
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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
|
|
898
|
-
|
|
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
|
-
|
|
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
camel/utils/context_utils.py
CHANGED
|
@@ -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
|