camel-ai 0.2.75a5__py3-none-any.whl → 0.2.76__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 (103) hide show
  1. camel/__init__.py +1 -1
  2. camel/agents/chat_agent.py +1148 -298
  3. camel/agents/mcp_agent.py +30 -27
  4. camel/configs/__init__.py +9 -0
  5. camel/configs/amd_config.py +70 -0
  6. camel/configs/cometapi_config.py +104 -0
  7. camel/configs/nebius_config.py +103 -0
  8. camel/data_collectors/alpaca_collector.py +15 -6
  9. camel/environments/tic_tac_toe.py +1 -1
  10. camel/interpreters/__init__.py +2 -0
  11. camel/interpreters/docker/Dockerfile +3 -12
  12. camel/interpreters/microsandbox_interpreter.py +395 -0
  13. camel/loaders/__init__.py +11 -2
  14. camel/loaders/chunkr_reader.py +9 -0
  15. camel/memories/__init__.py +2 -1
  16. camel/memories/agent_memories.py +3 -1
  17. camel/memories/blocks/chat_history_block.py +21 -3
  18. camel/memories/records.py +88 -8
  19. camel/messages/base.py +127 -34
  20. camel/models/__init__.py +6 -0
  21. camel/models/amd_model.py +101 -0
  22. camel/models/azure_openai_model.py +0 -6
  23. camel/models/base_model.py +30 -0
  24. camel/models/cometapi_model.py +83 -0
  25. camel/models/model_factory.py +6 -0
  26. camel/models/nebius_model.py +83 -0
  27. camel/models/ollama_model.py +3 -3
  28. camel/models/openai_compatible_model.py +0 -6
  29. camel/models/openai_model.py +0 -6
  30. camel/models/zhipuai_model.py +61 -2
  31. camel/parsers/__init__.py +18 -0
  32. camel/parsers/mcp_tool_call_parser.py +176 -0
  33. camel/retrievers/auto_retriever.py +1 -0
  34. camel/runtimes/daytona_runtime.py +11 -12
  35. camel/societies/workforce/prompts.py +131 -50
  36. camel/societies/workforce/single_agent_worker.py +434 -49
  37. camel/societies/workforce/structured_output_handler.py +30 -18
  38. camel/societies/workforce/task_channel.py +163 -27
  39. camel/societies/workforce/utils.py +105 -12
  40. camel/societies/workforce/workforce.py +1357 -314
  41. camel/societies/workforce/workforce_logger.py +24 -5
  42. camel/storages/key_value_storages/json.py +15 -2
  43. camel/storages/object_storages/google_cloud.py +1 -1
  44. camel/storages/vectordb_storages/oceanbase.py +10 -11
  45. camel/storages/vectordb_storages/tidb.py +8 -6
  46. camel/tasks/task.py +4 -3
  47. camel/toolkits/__init__.py +18 -5
  48. camel/toolkits/aci_toolkit.py +45 -0
  49. camel/toolkits/code_execution.py +28 -1
  50. camel/toolkits/context_summarizer_toolkit.py +684 -0
  51. camel/toolkits/dingtalk.py +1135 -0
  52. camel/toolkits/edgeone_pages_mcp_toolkit.py +11 -31
  53. camel/toolkits/{file_write_toolkit.py → file_toolkit.py} +194 -34
  54. camel/toolkits/function_tool.py +6 -1
  55. camel/toolkits/github_toolkit.py +104 -17
  56. camel/toolkits/google_drive_mcp_toolkit.py +12 -31
  57. camel/toolkits/hybrid_browser_toolkit/config_loader.py +12 -0
  58. camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit.py +79 -2
  59. camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit_ts.py +95 -59
  60. camel/toolkits/hybrid_browser_toolkit/installer.py +203 -0
  61. camel/toolkits/hybrid_browser_toolkit/ts/package-lock.json +5 -612
  62. camel/toolkits/hybrid_browser_toolkit/ts/package.json +0 -1
  63. camel/toolkits/hybrid_browser_toolkit/ts/src/browser-session.ts +619 -95
  64. camel/toolkits/hybrid_browser_toolkit/ts/src/config-loader.ts +7 -2
  65. camel/toolkits/hybrid_browser_toolkit/ts/src/hybrid-browser-toolkit.ts +115 -219
  66. camel/toolkits/hybrid_browser_toolkit/ts/src/parent-child-filter.ts +226 -0
  67. camel/toolkits/hybrid_browser_toolkit/ts/src/snapshot-parser.ts +219 -0
  68. camel/toolkits/hybrid_browser_toolkit/ts/src/som-screenshot-injected.ts +543 -0
  69. camel/toolkits/hybrid_browser_toolkit/ts/src/types.ts +1 -0
  70. camel/toolkits/hybrid_browser_toolkit/ts/websocket-server.js +39 -6
  71. camel/toolkits/hybrid_browser_toolkit/ws_wrapper.py +412 -133
  72. camel/toolkits/hybrid_browser_toolkit_py/hybrid_browser_toolkit.py +9 -5
  73. camel/toolkits/{openai_image_toolkit.py → image_generation_toolkit.py} +98 -31
  74. camel/toolkits/markitdown_toolkit.py +27 -1
  75. camel/toolkits/math_toolkit.py +64 -10
  76. camel/toolkits/mcp_toolkit.py +348 -348
  77. camel/toolkits/message_integration.py +3 -0
  78. camel/toolkits/minimax_mcp_toolkit.py +195 -0
  79. camel/toolkits/note_taking_toolkit.py +18 -8
  80. camel/toolkits/notion_mcp_toolkit.py +16 -26
  81. camel/toolkits/origene_mcp_toolkit.py +8 -49
  82. camel/toolkits/playwright_mcp_toolkit.py +12 -31
  83. camel/toolkits/resend_toolkit.py +168 -0
  84. camel/toolkits/search_toolkit.py +13 -2
  85. camel/toolkits/slack_toolkit.py +50 -1
  86. camel/toolkits/terminal_toolkit/__init__.py +18 -0
  87. camel/toolkits/terminal_toolkit/terminal_toolkit.py +924 -0
  88. camel/toolkits/terminal_toolkit/utils.py +532 -0
  89. camel/toolkits/vertex_ai_veo_toolkit.py +590 -0
  90. camel/toolkits/video_analysis_toolkit.py +17 -11
  91. camel/toolkits/wechat_official_toolkit.py +483 -0
  92. camel/types/enums.py +155 -1
  93. camel/types/unified_model_type.py +10 -0
  94. camel/utils/commons.py +17 -0
  95. camel/utils/context_utils.py +804 -0
  96. camel/utils/mcp.py +136 -2
  97. camel/utils/token_counting.py +25 -17
  98. {camel_ai-0.2.75a5.dist-info → camel_ai-0.2.76.dist-info}/METADATA +158 -67
  99. {camel_ai-0.2.75a5.dist-info → camel_ai-0.2.76.dist-info}/RECORD +101 -80
  100. camel/loaders/pandas_reader.py +0 -368
  101. camel/toolkits/terminal_toolkit.py +0 -1788
  102. {camel_ai-0.2.75a5.dist-info → camel_ai-0.2.76.dist-info}/WHEEL +0 -0
  103. {camel_ai-0.2.75a5.dist-info → camel_ai-0.2.76.dist-info}/licenses/LICENSE +0 -0
@@ -14,13 +14,24 @@
14
14
  from __future__ import annotations
15
15
 
16
16
  import asyncio
17
+ import atexit
18
+ import base64
19
+ import concurrent.futures
20
+ import hashlib
21
+ import inspect
17
22
  import json
18
- import logging
19
- import queue
23
+ import math
24
+ import os
25
+ import random
26
+ import re
27
+ import tempfile
20
28
  import textwrap
21
29
  import threading
22
30
  import time
23
31
  import uuid
32
+ import warnings
33
+ from dataclasses import dataclass
34
+ from datetime import datetime
24
35
  from pathlib import Path
25
36
  from typing import (
26
37
  TYPE_CHECKING,
@@ -40,6 +51,7 @@ from typing import (
40
51
 
41
52
  from openai import (
42
53
  AsyncStream,
54
+ RateLimitError,
43
55
  Stream,
44
56
  )
45
57
  from pydantic import BaseModel, ValidationError
@@ -60,6 +72,7 @@ from camel.memories import (
60
72
  MemoryRecord,
61
73
  ScoreBasedContextCreator,
62
74
  )
75
+ from camel.memories.blocks.chat_history_block import EmptyMemoryWarning
63
76
  from camel.messages import (
64
77
  BaseMessage,
65
78
  FunctionCallingMessage,
@@ -89,16 +102,31 @@ from camel.utils import (
89
102
  model_from_json_schema,
90
103
  )
91
104
  from camel.utils.commons import dependencies_required
105
+ from camel.utils.context_utils import ContextUtility
92
106
 
93
107
  if TYPE_CHECKING:
94
108
  from camel.terminators import ResponseTerminator
95
109
 
96
110
  logger = get_logger(__name__)
97
111
 
112
+ # Cleanup temp files on exit
113
+ _temp_files: Set[str] = set()
114
+ _temp_files_lock = threading.Lock()
115
+
116
+
117
+ def _cleanup_temp_files():
118
+ with _temp_files_lock:
119
+ for path in _temp_files:
120
+ try:
121
+ os.unlink(path)
122
+ except Exception:
123
+ pass
124
+
125
+
126
+ atexit.register(_cleanup_temp_files)
127
+
98
128
  # AgentOps decorator setting
99
129
  try:
100
- import os
101
-
102
130
  if os.getenv("AGENTOPS_API_KEY") is not None:
103
131
  from agentops import track_agent
104
132
  else:
@@ -132,13 +160,60 @@ SIMPLE_FORMAT_PROMPT = TextPrompt(
132
160
  )
133
161
 
134
162
 
163
+ @dataclass
164
+ class _ToolOutputHistoryEntry:
165
+ tool_name: str
166
+ tool_call_id: str
167
+ result_text: str
168
+ record_uuids: List[str]
169
+ record_timestamps: List[float]
170
+ preview_text: str
171
+ cached: bool = False
172
+ cache_id: Optional[str] = None
173
+
174
+
175
+ class _ToolOutputCacheManager:
176
+ r"""Minimal persistent store for caching verbose tool outputs."""
177
+
178
+ def __init__(self, base_dir: Union[str, Path]) -> None:
179
+ self.base_dir = Path(base_dir).expanduser().resolve()
180
+ self.base_dir.mkdir(parents=True, exist_ok=True)
181
+
182
+ def save(
183
+ self,
184
+ tool_name: str,
185
+ tool_call_id: str,
186
+ content: str,
187
+ ) -> Tuple[str, Path]:
188
+ cache_id = uuid.uuid4().hex
189
+ filename = f"{cache_id}.txt"
190
+ path = self.base_dir / filename
191
+ header = (
192
+ f"# Cached tool output\n"
193
+ f"tool_name: {tool_name}\n"
194
+ f"tool_call_id: {tool_call_id}\n"
195
+ f"cache_id: {cache_id}\n"
196
+ f"---\n"
197
+ )
198
+ path.write_text(f"{header}{content}", encoding="utf-8")
199
+ return cache_id, path
200
+
201
+ def load(self, cache_id: str) -> str:
202
+ path = self.base_dir / f"{cache_id}.txt"
203
+ if not path.exists():
204
+ raise FileNotFoundError(
205
+ f"Cached tool output {cache_id} not found at {path}"
206
+ )
207
+ return path.read_text(encoding="utf-8")
208
+
209
+
135
210
  class StreamContentAccumulator:
136
211
  r"""Manages content accumulation across streaming responses to ensure
137
212
  all responses contain complete cumulative content."""
138
213
 
139
214
  def __init__(self):
140
215
  self.base_content = "" # Content before tool calls
141
- self.current_content = "" # Current streaming content
216
+ self.current_content = [] # Accumulated streaming fragments
142
217
  self.tool_status_messages = [] # Accumulated tool status messages
143
218
 
144
219
  def set_base_content(self, content: str):
@@ -147,7 +222,7 @@ class StreamContentAccumulator:
147
222
 
148
223
  def add_streaming_content(self, new_content: str):
149
224
  r"""Add new streaming content."""
150
- self.current_content += new_content
225
+ self.current_content.append(new_content)
151
226
 
152
227
  def add_tool_status(self, status_message: str):
153
228
  r"""Add a tool status message."""
@@ -156,16 +231,18 @@ class StreamContentAccumulator:
156
231
  def get_full_content(self) -> str:
157
232
  r"""Get the complete accumulated content."""
158
233
  tool_messages = "".join(self.tool_status_messages)
159
- return self.base_content + tool_messages + self.current_content
234
+ current = "".join(self.current_content)
235
+ return self.base_content + tool_messages + current
160
236
 
161
237
  def get_content_with_new_status(self, status_message: str) -> str:
162
238
  r"""Get content with a new status message appended."""
163
239
  tool_messages = "".join([*self.tool_status_messages, status_message])
164
- return self.base_content + tool_messages + self.current_content
240
+ current = "".join(self.current_content)
241
+ return self.base_content + tool_messages + current
165
242
 
166
243
  def reset_streaming_content(self):
167
244
  r"""Reset only the streaming content, keep base and tool status."""
168
- self.current_content = ""
245
+ self.current_content = []
169
246
 
170
247
 
171
248
  class StreamingChatAgentResponse:
@@ -186,13 +263,10 @@ class StreamingChatAgentResponse:
186
263
  def _ensure_latest_response(self):
187
264
  r"""Ensure we have the latest response by consuming the generator."""
188
265
  if not self._consumed:
189
- try:
190
- for response in self._generator:
191
- self._responses.append(response)
192
- self._current_response = response
193
- self._consumed = True
194
- except StopIteration:
195
- self._consumed = True
266
+ for response in self._generator:
267
+ self._responses.append(response)
268
+ self._current_response = response
269
+ self._consumed = True
196
270
 
197
271
  @property
198
272
  def msgs(self) -> List[BaseMessage]:
@@ -230,17 +304,14 @@ class StreamingChatAgentResponse:
230
304
  r"""Make this object iterable."""
231
305
  if self._consumed:
232
306
  # If already consumed, iterate over stored responses
233
- return iter(self._responses)
307
+ yield from self._responses
234
308
  else:
235
309
  # If not consumed, consume and yield
236
- try:
237
- for response in self._generator:
238
- self._responses.append(response)
239
- self._current_response = response
240
- yield response
241
- self._consumed = True
242
- except StopIteration:
243
- self._consumed = True
310
+ for response in self._generator:
311
+ self._responses.append(response)
312
+ self._current_response = response
313
+ yield response
314
+ self._consumed = True
244
315
 
245
316
  def __getattr__(self, name):
246
317
  r"""Forward any other attribute access to the latest response."""
@@ -271,13 +342,10 @@ class AsyncStreamingChatAgentResponse:
271
342
  async def _ensure_latest_response(self):
272
343
  r"""Ensure the latest response by consuming the async generator."""
273
344
  if not self._consumed:
274
- try:
275
- async for response in self._async_generator:
276
- self._responses.append(response)
277
- self._current_response = response
278
- self._consumed = True
279
- except StopAsyncIteration:
280
- self._consumed = True
345
+ async for response in self._async_generator:
346
+ self._responses.append(response)
347
+ self._current_response = response
348
+ self._consumed = True
281
349
 
282
350
  async def _get_final_response(self) -> ChatAgentResponse:
283
351
  r"""Get the final response after consuming the entire stream."""
@@ -303,14 +371,11 @@ class AsyncStreamingChatAgentResponse:
303
371
  else:
304
372
  # If not consumed, consume and yield
305
373
  async def _consume_and_yield():
306
- try:
307
- async for response in self._async_generator:
308
- self._responses.append(response)
309
- self._current_response = response
310
- yield response
311
- self._consumed = True
312
- except StopAsyncIteration:
313
- self._consumed = True
374
+ async for response in self._async_generator:
375
+ self._responses.append(response)
376
+ self._current_response = response
377
+ yield response
378
+ self._consumed = True
314
379
 
315
380
  return _consume_and_yield()
316
381
 
@@ -378,14 +443,39 @@ class ChatAgent(BaseAgent):
378
443
  for individual tool execution. If None, wait indefinitely.
379
444
  mask_tool_output (Optional[bool]): Whether to return a sanitized
380
445
  placeholder instead of the raw tool output. (default: :obj:`False`)
381
- pause_event (Optional[asyncio.Event]): Event to signal pause of the
382
- agent's operation. When clear, the agent will pause its execution.
383
- (default: :obj:`None`)
446
+ pause_event (Optional[Union[threading.Event, asyncio.Event]]): Event to
447
+ signal pause of the agent's operation. When clear, the agent will
448
+ pause its execution. Use threading.Event for sync operations or
449
+ asyncio.Event for async operations. (default: :obj:`None`)
384
450
  prune_tool_calls_from_memory (bool): Whether to clean tool
385
451
  call messages from memory after response generation to save token
386
452
  usage. When enabled, removes FUNCTION/TOOL role messages and
387
453
  ASSISTANT messages with tool_calls after each step.
388
454
  (default: :obj:`False`)
455
+ enable_tool_output_cache (bool, optional): Whether to offload verbose
456
+ historical tool outputs to a local cache and replace them with
457
+ lightweight references in memory. Only older tool results whose
458
+ payload length exceeds ``tool_output_cache_threshold`` are cached.
459
+ (default: :obj:`True`)
460
+ tool_output_cache_threshold (int, optional): Minimum character length
461
+ of a tool result before it becomes eligible for caching. Values
462
+ below or equal to zero disable caching regardless of the toggle.
463
+ (default: :obj:`2000`)
464
+ tool_output_cache_dir (Optional[Union[str, Path]], optional): Target
465
+ directory for cached tool outputs. When omitted, a ``tool_cache``
466
+ directory relative to the current working directory is used.
467
+ (default: :obj:`None`)
468
+ retry_attempts (int, optional): Maximum number of retry attempts for
469
+ rate limit errors. (default: :obj:`3`)
470
+ retry_delay (float, optional): Initial delay in seconds between
471
+ retries. Uses exponential backoff. (default: :obj:`1.0`)
472
+ step_timeout (Optional[float], optional): Timeout in seconds for the
473
+ entire step operation. If None, no timeout is applied.
474
+ (default: :obj:`None`)
475
+ stream_accumulate (bool, optional): When True, partial streaming
476
+ updates return accumulated content (current behavior). When False,
477
+ partial updates return only the incremental delta. (default:
478
+ :obj:`True`)
389
479
  """
390
480
 
391
481
  def __init__(
@@ -424,8 +514,15 @@ class ChatAgent(BaseAgent):
424
514
  stop_event: Optional[threading.Event] = None,
425
515
  tool_execution_timeout: Optional[float] = None,
426
516
  mask_tool_output: bool = False,
427
- pause_event: Optional[asyncio.Event] = None,
517
+ pause_event: Optional[Union[threading.Event, asyncio.Event]] = None,
428
518
  prune_tool_calls_from_memory: bool = False,
519
+ enable_tool_output_cache: bool = True,
520
+ tool_output_cache_threshold: int = 2000,
521
+ tool_output_cache_dir: Optional[Union[str, Path]] = None,
522
+ retry_attempts: int = 3,
523
+ retry_delay: float = 1.0,
524
+ step_timeout: Optional[float] = None,
525
+ stream_accumulate: bool = True,
429
526
  ) -> None:
430
527
  if isinstance(model, ModelManager):
431
528
  self.model_backend = model
@@ -441,6 +538,28 @@ class ChatAgent(BaseAgent):
441
538
  # Assign unique ID
442
539
  self.agent_id = agent_id if agent_id else str(uuid.uuid4())
443
540
 
541
+ self._tool_output_cache_enabled = (
542
+ enable_tool_output_cache and tool_output_cache_threshold > 0
543
+ )
544
+ self._tool_output_cache_threshold = max(0, tool_output_cache_threshold)
545
+ self._tool_output_cache_dir: Optional[Path]
546
+ self._tool_output_cache_manager: Optional[_ToolOutputCacheManager]
547
+ if self._tool_output_cache_enabled:
548
+ cache_dir = (
549
+ Path(tool_output_cache_dir).expanduser()
550
+ if tool_output_cache_dir is not None
551
+ else Path("tool_cache")
552
+ )
553
+ self._tool_output_cache_dir = cache_dir
554
+ self._tool_output_cache_manager = _ToolOutputCacheManager(
555
+ cache_dir
556
+ )
557
+ else:
558
+ self._tool_output_cache_dir = None
559
+ self._tool_output_cache_manager = None
560
+ self._tool_output_history: List[_ToolOutputHistoryEntry] = []
561
+ self._cache_lookup_tool_name = "retrieve_cached_tool_output"
562
+
444
563
  # Set up memory
445
564
  context_creator = ScoreBasedContextCreator(
446
565
  self.model_backend.token_counter,
@@ -487,6 +606,8 @@ class ChatAgent(BaseAgent):
487
606
  convert_to_function_tool(tool) for tool in (tools or [])
488
607
  ]
489
608
  }
609
+ if self._tool_output_cache_enabled:
610
+ self._ensure_tool_cache_lookup_tool()
490
611
 
491
612
  # Register agent with toolkits that have RegisteredAgentToolkit mixin
492
613
  if toolkits_to_register_agent:
@@ -509,13 +630,22 @@ class ChatAgent(BaseAgent):
509
630
  self.tool_execution_timeout = tool_execution_timeout
510
631
  self.mask_tool_output = mask_tool_output
511
632
  self._secure_result_store: Dict[str, Any] = {}
633
+ self._secure_result_store_lock = threading.Lock()
512
634
  self.pause_event = pause_event
513
635
  self.prune_tool_calls_from_memory = prune_tool_calls_from_memory
636
+ self.retry_attempts = max(1, retry_attempts)
637
+ self.retry_delay = max(0.0, retry_delay)
638
+ self.step_timeout = step_timeout
639
+ self._context_utility: Optional[ContextUtility] = None
640
+ self._context_summary_agent: Optional["ChatAgent"] = None
641
+ self.stream_accumulate = stream_accumulate
514
642
 
515
643
  def reset(self):
516
644
  r"""Resets the :obj:`ChatAgent` to its initial state."""
517
645
  self.terminated = False
518
646
  self.init_messages()
647
+ if self._tool_output_cache_enabled:
648
+ self._tool_output_history.clear()
519
649
  for terminator in self.response_terminators:
520
650
  terminator.reset()
521
651
 
@@ -699,6 +829,20 @@ class ChatAgent(BaseAgent):
699
829
  # Ensure the new memory has the system message
700
830
  self.init_messages()
701
831
 
832
+ def set_context_utility(
833
+ self, context_utility: Optional[ContextUtility]
834
+ ) -> None:
835
+ r"""Set the context utility for the agent.
836
+
837
+ This allows external components (like SingleAgentWorker) to provide
838
+ a shared context utility instance for workflow management.
839
+
840
+ Args:
841
+ context_utility (ContextUtility, optional): The context utility
842
+ to use. If None, the agent will create its own when needed.
843
+ """
844
+ self._context_utility = context_utility
845
+
702
846
  def _get_full_tool_schemas(self) -> List[Dict[str, Any]]:
703
847
  r"""Returns a list of tool schemas of all tools, including internal
704
848
  and external tools.
@@ -722,6 +866,178 @@ class ChatAgent(BaseAgent):
722
866
  for tool in tools:
723
867
  self.add_tool(tool)
724
868
 
869
+ def retrieve_cached_tool_output(self, cache_id: str) -> str:
870
+ r"""Load a cached tool output by its cache identifier.
871
+
872
+ Args:
873
+ cache_id (str): Identifier provided in cached tool messages.
874
+
875
+ Returns:
876
+ str: The cached content or an explanatory error message.
877
+ """
878
+ if not self._tool_output_cache_manager:
879
+ return "Tool output caching is disabled for this agent instance."
880
+
881
+ normalized_cache_id = cache_id.strip()
882
+ if not normalized_cache_id:
883
+ return "Please provide a non-empty cache_id."
884
+
885
+ try:
886
+ return self._tool_output_cache_manager.load(normalized_cache_id)
887
+ except FileNotFoundError:
888
+ return (
889
+ f"Cache entry '{normalized_cache_id}' was not found. "
890
+ "Verify the identifier and try again."
891
+ )
892
+
893
+ def _ensure_tool_cache_lookup_tool(self) -> None:
894
+ if not self._tool_output_cache_enabled:
895
+ return
896
+ lookup_name = self._cache_lookup_tool_name
897
+ if lookup_name in self._internal_tools:
898
+ return
899
+ lookup_tool = convert_to_function_tool(
900
+ self.retrieve_cached_tool_output
901
+ )
902
+ self._internal_tools[lookup_tool.get_function_name()] = lookup_tool
903
+
904
+ def _serialize_tool_result(self, result: Any) -> str:
905
+ if isinstance(result, str):
906
+ return result
907
+ try:
908
+ return json.dumps(result, ensure_ascii=False)
909
+ except (TypeError, ValueError):
910
+ return str(result)
911
+
912
+ def _summarize_tool_result(self, text: str, limit: int = 160) -> str:
913
+ normalized = re.sub(r"\s+", " ", text).strip()
914
+ if len(normalized) <= limit:
915
+ return normalized
916
+ return normalized[: max(0, limit - 3)].rstrip() + "..."
917
+
918
+ def _register_tool_output_for_cache(
919
+ self,
920
+ func_name: str,
921
+ tool_call_id: str,
922
+ result_text: str,
923
+ records: List[MemoryRecord],
924
+ ) -> None:
925
+ if not records:
926
+ return
927
+
928
+ entry = _ToolOutputHistoryEntry(
929
+ tool_name=func_name,
930
+ tool_call_id=tool_call_id,
931
+ result_text=result_text,
932
+ record_uuids=[str(record.uuid) for record in records],
933
+ record_timestamps=[record.timestamp for record in records],
934
+ preview_text=self._summarize_tool_result(result_text),
935
+ )
936
+ self._tool_output_history.append(entry)
937
+ self._process_tool_output_cache()
938
+
939
+ def _process_tool_output_cache(self) -> None:
940
+ if (
941
+ not self._tool_output_cache_enabled
942
+ or not self._tool_output_history
943
+ or self._tool_output_cache_manager is None
944
+ ):
945
+ return
946
+
947
+ # Only cache older results; keep the latest expanded for immediate use.
948
+ for entry in self._tool_output_history[:-1]:
949
+ if entry.cached:
950
+ continue
951
+ if len(entry.result_text) < self._tool_output_cache_threshold:
952
+ continue
953
+ self._cache_tool_output_entry(entry)
954
+
955
+ def _cache_tool_output_entry(self, entry: _ToolOutputHistoryEntry) -> None:
956
+ if self._tool_output_cache_manager is None or not entry.record_uuids:
957
+ return
958
+
959
+ try:
960
+ cache_id, cache_path = self._tool_output_cache_manager.save(
961
+ entry.tool_name,
962
+ entry.tool_call_id,
963
+ entry.result_text,
964
+ )
965
+ except Exception as exc: # pragma: no cover - defensive
966
+ logger.warning(
967
+ "Failed to persist cached tool output for %s (%s): %s",
968
+ entry.tool_name,
969
+ entry.tool_call_id,
970
+ exc,
971
+ )
972
+ return
973
+
974
+ timestamp = (
975
+ entry.record_timestamps[0]
976
+ if entry.record_timestamps
977
+ else time.time_ns() / 1_000_000_000
978
+ )
979
+ reference_message = FunctionCallingMessage(
980
+ role_name=self.role_name,
981
+ role_type=self.role_type,
982
+ meta_dict={
983
+ "cache_id": cache_id,
984
+ "cached_preview": entry.preview_text,
985
+ "cached_tool_output_path": str(cache_path),
986
+ },
987
+ content="",
988
+ func_name=entry.tool_name,
989
+ result=self._build_cache_reference_text(entry, cache_id),
990
+ tool_call_id=entry.tool_call_id,
991
+ )
992
+
993
+ chat_history_block = getattr(self.memory, "_chat_history_block", None)
994
+ storage = getattr(chat_history_block, "storage", None)
995
+ if storage is None:
996
+ return
997
+
998
+ existing_records = storage.load()
999
+ updated_records = [
1000
+ record
1001
+ for record in existing_records
1002
+ if record["uuid"] not in entry.record_uuids
1003
+ ]
1004
+ new_record = MemoryRecord(
1005
+ message=reference_message,
1006
+ role_at_backend=OpenAIBackendRole.FUNCTION,
1007
+ timestamp=timestamp,
1008
+ agent_id=self.agent_id,
1009
+ )
1010
+ updated_records.append(new_record.to_dict())
1011
+ updated_records.sort(key=lambda record: record["timestamp"])
1012
+ storage.clear()
1013
+ storage.save(updated_records)
1014
+
1015
+ logger.info(
1016
+ "Cached tool output '%s' (%s) to %s with cache_id=%s",
1017
+ entry.tool_name,
1018
+ entry.tool_call_id,
1019
+ cache_path,
1020
+ cache_id,
1021
+ )
1022
+
1023
+ entry.cached = True
1024
+ entry.cache_id = cache_id
1025
+ entry.record_uuids = [str(new_record.uuid)]
1026
+ entry.record_timestamps = [timestamp]
1027
+
1028
+ def _build_cache_reference_text(
1029
+ self, entry: _ToolOutputHistoryEntry, cache_id: str
1030
+ ) -> str:
1031
+ preview = entry.preview_text or "[no preview available]"
1032
+ return (
1033
+ "[cached tool output]\n"
1034
+ f"tool: {entry.tool_name}\n"
1035
+ f"cache_id: {cache_id}\n"
1036
+ f"preview: {preview}\n"
1037
+ f"Use `{self._cache_lookup_tool_name}` with this cache_id to "
1038
+ "retrieve the full content."
1039
+ )
1040
+
725
1041
  def add_external_tool(
726
1042
  self, tool: Union[FunctionTool, Callable, Dict[str, Any]]
727
1043
  ) -> None:
@@ -766,7 +1082,8 @@ class ChatAgent(BaseAgent):
766
1082
  message: BaseMessage,
767
1083
  role: OpenAIBackendRole,
768
1084
  timestamp: Optional[float] = None,
769
- ) -> None:
1085
+ return_records: bool = False,
1086
+ ) -> Optional[List[MemoryRecord]]:
770
1087
  r"""Updates the agent memory with a new message.
771
1088
 
772
1089
  If the single *message* exceeds the model's context window, it will
@@ -786,24 +1103,29 @@ class ChatAgent(BaseAgent):
786
1103
  timestamp (Optional[float], optional): Custom timestamp for the
787
1104
  memory record. If `None`, the current time will be used.
788
1105
  (default: :obj:`None`)
789
- (default: obj:`None`)
1106
+ return_records (bool, optional): When ``True`` the method returns
1107
+ the list of :class:`MemoryRecord` objects written to memory.
1108
+ (default: :obj:`False`)
1109
+
1110
+ Returns:
1111
+ Optional[List[MemoryRecord]]: The records that were written when
1112
+ ``return_records`` is ``True``; otherwise ``None``.
790
1113
  """
791
- import math
792
- import time
793
- import uuid as _uuid
1114
+
1115
+ written_records: List[MemoryRecord] = []
794
1116
 
795
1117
  # 1. Helper to write a record to memory
796
1118
  def _write_single_record(
797
1119
  message: BaseMessage, role: OpenAIBackendRole, timestamp: float
798
1120
  ):
799
- self.memory.write_record(
800
- MemoryRecord(
801
- message=message,
802
- role_at_backend=role,
803
- timestamp=timestamp,
804
- agent_id=self.agent_id,
805
- )
1121
+ record = MemoryRecord(
1122
+ message=message,
1123
+ role_at_backend=role,
1124
+ timestamp=timestamp,
1125
+ agent_id=self.agent_id,
806
1126
  )
1127
+ written_records.append(record)
1128
+ self.memory.write_record(record)
807
1129
 
808
1130
  base_ts = (
809
1131
  timestamp
@@ -818,26 +1140,30 @@ class ChatAgent(BaseAgent):
818
1140
  token_limit = context_creator.token_limit
819
1141
  except AttributeError:
820
1142
  _write_single_record(message, role, base_ts)
821
- return
1143
+ return written_records if return_records else None
822
1144
 
823
1145
  # 3. Check if slicing is necessary
824
1146
  try:
825
1147
  current_tokens = token_counter.count_tokens_from_messages(
826
1148
  [message.to_openai_message(role)]
827
1149
  )
828
- _, ctx_tokens = self.memory.get_context()
1150
+
1151
+ with warnings.catch_warnings():
1152
+ warnings.filterwarnings("ignore", category=EmptyMemoryWarning)
1153
+ _, ctx_tokens = self.memory.get_context()
1154
+
829
1155
  remaining_budget = max(0, token_limit - ctx_tokens)
830
1156
 
831
1157
  if current_tokens <= remaining_budget:
832
1158
  _write_single_record(message, role, base_ts)
833
- return
1159
+ return written_records if return_records else None
834
1160
  except Exception as e:
835
1161
  logger.warning(
836
1162
  f"Token calculation failed before chunking, "
837
1163
  f"writing message as-is. Error: {e}"
838
1164
  )
839
1165
  _write_single_record(message, role, base_ts)
840
- return
1166
+ return written_records if return_records else None
841
1167
 
842
1168
  # 4. Perform slicing
843
1169
  logger.warning(
@@ -858,18 +1184,18 @@ class ChatAgent(BaseAgent):
858
1184
 
859
1185
  if not text_to_chunk or not text_to_chunk.strip():
860
1186
  _write_single_record(message, role, base_ts)
861
- return
1187
+ return written_records if return_records else None
862
1188
  # Encode the entire text to get a list of all token IDs
863
1189
  try:
864
1190
  all_token_ids = token_counter.encode(text_to_chunk)
865
1191
  except Exception as e:
866
1192
  logger.error(f"Failed to encode text for chunking: {e}")
867
1193
  _write_single_record(message, role, base_ts) # Fallback
868
- return
1194
+ return written_records if return_records else None
869
1195
 
870
1196
  if not all_token_ids:
871
1197
  _write_single_record(message, role, base_ts) # Nothing to chunk
872
- return
1198
+ return written_records if return_records else None
873
1199
 
874
1200
  # 1. Base chunk size: one-tenth of the smaller of (a) total token
875
1201
  # limit and (b) current remaining budget. This prevents us from
@@ -894,7 +1220,7 @@ class ChatAgent(BaseAgent):
894
1220
 
895
1221
  # 4. Calculate how many chunks we will need with this body size.
896
1222
  num_chunks = math.ceil(len(all_token_ids) / chunk_body_limit)
897
- group_id = str(_uuid.uuid4())
1223
+ group_id = str(uuid.uuid4())
898
1224
 
899
1225
  for i in range(num_chunks):
900
1226
  start_idx = i * chunk_body_limit
@@ -935,6 +1261,8 @@ class ChatAgent(BaseAgent):
935
1261
  # Increment timestamp slightly to maintain order
936
1262
  _write_single_record(new_msg, role, base_ts + i * 1e-6)
937
1263
 
1264
+ return written_records if return_records else None
1265
+
938
1266
  def load_memory(self, memory: AgentMemory) -> None:
939
1267
  r"""Load the provided memory into the agent.
940
1268
 
@@ -1012,6 +1340,242 @@ class ChatAgent(BaseAgent):
1012
1340
  json_store.save(to_save)
1013
1341
  logger.info(f"Memory saved to {path}")
1014
1342
 
1343
+ def summarize(
1344
+ self,
1345
+ filename: Optional[str] = None,
1346
+ summary_prompt: Optional[str] = None,
1347
+ response_format: Optional[Type[BaseModel]] = None,
1348
+ working_directory: Optional[Union[str, Path]] = None,
1349
+ ) -> Dict[str, Any]:
1350
+ r"""Summarize the agent's current conversation context and persist it
1351
+ to a markdown file.
1352
+
1353
+ Args:
1354
+ filename (Optional[str]): The base filename (without extension) to
1355
+ use for the markdown file. Defaults to a timestamped name when
1356
+ not provided.
1357
+ summary_prompt (Optional[str]): Custom prompt for the summarizer.
1358
+ When omitted, a default prompt highlighting key decisions,
1359
+ action items, and open questions is used.
1360
+ response_format (Optional[Type[BaseModel]]): A Pydantic model
1361
+ defining the expected structure of the response. If provided,
1362
+ the summary will be generated as structured output and included
1363
+ in the result.
1364
+ working_directory (Optional[str|Path]): Optional directory to save
1365
+ the markdown summary file. If provided, overrides the default
1366
+ directory used by ContextUtility.
1367
+
1368
+ Returns:
1369
+ Dict[str, Any]: A dictionary containing the summary text, file
1370
+ path, status message, and optionally structured_summary if
1371
+ response_format was provided.
1372
+ """
1373
+
1374
+ result: Dict[str, Any] = {
1375
+ "summary": "",
1376
+ "file_path": None,
1377
+ "status": "",
1378
+ }
1379
+
1380
+ try:
1381
+ # Use external context if set, otherwise create local one
1382
+ if self._context_utility is None:
1383
+ if working_directory is not None:
1384
+ self._context_utility = ContextUtility(
1385
+ working_directory=str(working_directory)
1386
+ )
1387
+ else:
1388
+ self._context_utility = ContextUtility()
1389
+ context_util = self._context_utility
1390
+
1391
+ # Get conversation directly from agent's memory
1392
+ messages, _ = self.memory.get_context()
1393
+
1394
+ if not messages:
1395
+ status_message = (
1396
+ "No conversation context available to summarize."
1397
+ )
1398
+ result["status"] = status_message
1399
+ return result
1400
+
1401
+ # Convert messages to conversation text
1402
+ conversation_lines = []
1403
+ for message in messages:
1404
+ role = message.get('role', 'unknown')
1405
+ content = message.get('content', '')
1406
+
1407
+ # Handle tool call messages (assistant calling tools)
1408
+ tool_calls = message.get('tool_calls')
1409
+ if tool_calls and isinstance(tool_calls, (list, tuple)):
1410
+ for tool_call in tool_calls:
1411
+ # Handle both dict and object formats
1412
+ if isinstance(tool_call, dict):
1413
+ func_name = tool_call.get('function', {}).get(
1414
+ 'name', 'unknown_tool'
1415
+ )
1416
+ func_args_str = tool_call.get('function', {}).get(
1417
+ 'arguments', '{}'
1418
+ )
1419
+ else:
1420
+ # Handle object format (Pydantic or similar)
1421
+ func_name = getattr(
1422
+ getattr(tool_call, 'function', None),
1423
+ 'name',
1424
+ 'unknown_tool',
1425
+ )
1426
+ func_args_str = getattr(
1427
+ getattr(tool_call, 'function', None),
1428
+ 'arguments',
1429
+ '{}',
1430
+ )
1431
+
1432
+ # Parse and format arguments for readability
1433
+ try:
1434
+ import json
1435
+
1436
+ args_dict = json.loads(func_args_str)
1437
+ args_formatted = ', '.join(
1438
+ f"{k}={v}" for k, v in args_dict.items()
1439
+ )
1440
+ except (json.JSONDecodeError, ValueError, TypeError):
1441
+ args_formatted = func_args_str
1442
+
1443
+ conversation_lines.append(
1444
+ f"[TOOL CALL] {func_name}({args_formatted})"
1445
+ )
1446
+
1447
+ # Handle tool response messages
1448
+ elif role == 'tool':
1449
+ tool_name = message.get('name', 'unknown_tool')
1450
+ if not content:
1451
+ content = str(message.get('content', ''))
1452
+ conversation_lines.append(
1453
+ f"[TOOL RESULT] {tool_name} → {content}"
1454
+ )
1455
+
1456
+ # Handle regular content messages (user/assistant/system)
1457
+ elif content:
1458
+ conversation_lines.append(f"{role}: {content}")
1459
+
1460
+ conversation_text = "\n".join(conversation_lines).strip()
1461
+
1462
+ if not conversation_text:
1463
+ status_message = (
1464
+ "Conversation context is empty; skipping summary."
1465
+ )
1466
+ result["status"] = status_message
1467
+ return result
1468
+
1469
+ if self._context_summary_agent is None:
1470
+ self._context_summary_agent = ChatAgent(
1471
+ system_message=(
1472
+ "You are a helpful assistant that summarizes "
1473
+ "conversations"
1474
+ ),
1475
+ model=self.model_backend,
1476
+ agent_id=f"{self.agent_id}_context_summarizer",
1477
+ )
1478
+ else:
1479
+ self._context_summary_agent.reset()
1480
+
1481
+ if summary_prompt:
1482
+ prompt_text = (
1483
+ f"{summary_prompt.rstrip()}\n\n"
1484
+ f"AGENT CONVERSATION TO BE SUMMARIZED:\n"
1485
+ f"{conversation_text}"
1486
+ )
1487
+ else:
1488
+ prompt_text = (
1489
+ "Summarize the context information in concise markdown "
1490
+ "bullet points highlighting key decisions, action items.\n"
1491
+ f"Context information:\n{conversation_text}"
1492
+ )
1493
+
1494
+ try:
1495
+ # Use structured output if response_format is provided
1496
+ if response_format:
1497
+ response = self._context_summary_agent.step(
1498
+ prompt_text, response_format=response_format
1499
+ )
1500
+ else:
1501
+ response = self._context_summary_agent.step(prompt_text)
1502
+ except Exception as step_exc:
1503
+ error_message = (
1504
+ f"Failed to generate summary using model: {step_exc}"
1505
+ )
1506
+ logger.error(error_message)
1507
+ result["status"] = error_message
1508
+ return result
1509
+
1510
+ if not response.msgs:
1511
+ status_message = (
1512
+ "Failed to generate summary from model response."
1513
+ )
1514
+ result["status"] = status_message
1515
+ return result
1516
+
1517
+ summary_content = response.msgs[-1].content.strip()
1518
+ if not summary_content:
1519
+ status_message = "Generated summary is empty."
1520
+ result["status"] = status_message
1521
+ return result
1522
+
1523
+ base_filename = (
1524
+ filename
1525
+ if filename
1526
+ else f"context_summary_{datetime.now().strftime('%Y%m%d_%H%M%S')}" # noqa: E501
1527
+ )
1528
+ base_filename = Path(base_filename).with_suffix("").name
1529
+
1530
+ metadata = context_util.get_session_metadata()
1531
+ metadata.update(
1532
+ {
1533
+ "agent_id": self.agent_id,
1534
+ "message_count": len(messages),
1535
+ }
1536
+ )
1537
+
1538
+ # Handle structured output if response_format was provided
1539
+ structured_output = None
1540
+ if response_format and response.msgs[-1].parsed:
1541
+ structured_output = response.msgs[-1].parsed
1542
+ # Convert structured output to custom markdown
1543
+ summary_content = context_util.structured_output_to_markdown(
1544
+ structured_data=structured_output, metadata=metadata
1545
+ )
1546
+
1547
+ # Save the markdown (either custom structured or default)
1548
+ save_status = context_util.save_markdown_file(
1549
+ base_filename,
1550
+ summary_content,
1551
+ title="Conversation Summary"
1552
+ if not structured_output
1553
+ else None,
1554
+ metadata=metadata if not structured_output else None,
1555
+ )
1556
+
1557
+ file_path = (
1558
+ context_util.get_working_directory() / f"{base_filename}.md"
1559
+ )
1560
+
1561
+ # Prepare result dictionary
1562
+ result_dict = {
1563
+ "summary": summary_content,
1564
+ "file_path": str(file_path),
1565
+ "status": save_status,
1566
+ "structured_summary": structured_output,
1567
+ }
1568
+
1569
+ result.update(result_dict)
1570
+ logger.info("Conversation summary saved to %s", file_path)
1571
+ return result
1572
+
1573
+ except Exception as exc:
1574
+ error_message = f"Failed to summarize conversation context: {exc}"
1575
+ logger.error(error_message)
1576
+ result["status"] = error_message
1577
+ return result
1578
+
1015
1579
  def clear_memory(self) -> None:
1016
1580
  r"""Clear the agent's memory and reset to initial state.
1017
1581
 
@@ -1019,6 +1583,9 @@ class ChatAgent(BaseAgent):
1019
1583
  None
1020
1584
  """
1021
1585
  self.memory.clear()
1586
+ if self._tool_output_cache_enabled:
1587
+ self._tool_output_history.clear()
1588
+
1022
1589
  if self.system_message is not None:
1023
1590
  self.update_memory(self.system_message, OpenAIBackendRole.SYSTEM)
1024
1591
 
@@ -1054,8 +1621,6 @@ class ChatAgent(BaseAgent):
1054
1621
  r"""Initializes the stored messages list with the current system
1055
1622
  message.
1056
1623
  """
1057
- import time
1058
-
1059
1624
  self.memory.clear()
1060
1625
  # avoid UserWarning: The `ChatHistoryMemory` is empty.
1061
1626
  if self.system_message is not None:
@@ -1068,6 +1633,17 @@ class ChatAgent(BaseAgent):
1068
1633
  )
1069
1634
  )
1070
1635
 
1636
+ def reset_to_original_system_message(self) -> None:
1637
+ r"""Reset system message to original, removing any appended context.
1638
+
1639
+ This method reverts the agent's system message back to its original
1640
+ state, removing any workflow context or other modifications that may
1641
+ have been appended. Useful for resetting agent state in multi-turn
1642
+ scenarios.
1643
+ """
1644
+ self._system_message = self._original_system_message
1645
+ self.init_messages()
1646
+
1071
1647
  def record_message(self, message: BaseMessage) -> None:
1072
1648
  r"""Records the externally provided message into the agent memory as if
1073
1649
  it were an answer of the :obj:`ChatAgent` from the backend. Currently,
@@ -1129,7 +1705,7 @@ class ChatAgent(BaseAgent):
1129
1705
 
1130
1706
  # Create a prompt based on the schema
1131
1707
  format_instruction = (
1132
- "\n\nPlease respond in the following JSON format:\n" "{\n"
1708
+ "\n\nPlease respond in the following JSON format:\n{\n"
1133
1709
  )
1134
1710
 
1135
1711
  properties = schema.get("properties", {})
@@ -1216,6 +1792,33 @@ class ChatAgent(BaseAgent):
1216
1792
  # and True to indicate we used prompt formatting
1217
1793
  return modified_message, None, True
1218
1794
 
1795
+ def _is_called_from_registered_toolkit(self) -> bool:
1796
+ r"""Check if current step/astep call originates from a
1797
+ RegisteredAgentToolkit.
1798
+
1799
+ This method uses stack inspection to detect if the current call
1800
+ is originating from a toolkit that inherits from
1801
+ RegisteredAgentToolkit. When detected, tools should be disabled to
1802
+ prevent recursive calls.
1803
+
1804
+ Returns:
1805
+ bool: True if called from a RegisteredAgentToolkit, False otherwise
1806
+ """
1807
+ from camel.toolkits.base import RegisteredAgentToolkit
1808
+
1809
+ try:
1810
+ for frame_info in inspect.stack():
1811
+ frame_locals = frame_info.frame.f_locals
1812
+ if 'self' in frame_locals:
1813
+ caller_self = frame_locals['self']
1814
+ if isinstance(caller_self, RegisteredAgentToolkit):
1815
+ return True
1816
+
1817
+ except Exception:
1818
+ return False
1819
+
1820
+ return False
1821
+
1219
1822
  def _apply_prompt_based_parsing(
1220
1823
  self,
1221
1824
  response: ModelResponse,
@@ -1232,7 +1835,6 @@ class ChatAgent(BaseAgent):
1232
1835
  try:
1233
1836
  # Try to extract JSON from the response content
1234
1837
  import json
1235
- import re
1236
1838
 
1237
1839
  from pydantic import ValidationError
1238
1840
 
@@ -1271,8 +1873,7 @@ class ChatAgent(BaseAgent):
1271
1873
 
1272
1874
  if not message.parsed:
1273
1875
  logger.warning(
1274
- f"Failed to parse JSON from response: "
1275
- f"{content}"
1876
+ f"Failed to parse JSON from response: {content}"
1276
1877
  )
1277
1878
 
1278
1879
  except Exception as e:
@@ -1365,6 +1966,9 @@ class ChatAgent(BaseAgent):
1365
1966
  a StreamingChatAgentResponse that behaves like
1366
1967
  ChatAgentResponse but can also be iterated for
1367
1968
  streaming updates.
1969
+
1970
+ Raises:
1971
+ TimeoutError: If the step operation exceeds the configured timeout.
1368
1972
  """
1369
1973
 
1370
1974
  stream = self.model_backend.model_config_dict.get("stream", False)
@@ -1374,6 +1978,30 @@ class ChatAgent(BaseAgent):
1374
1978
  generator = self._stream(input_message, response_format)
1375
1979
  return StreamingChatAgentResponse(generator)
1376
1980
 
1981
+ # Execute with timeout if configured
1982
+ if self.step_timeout is not None:
1983
+ with concurrent.futures.ThreadPoolExecutor(
1984
+ max_workers=1
1985
+ ) as executor:
1986
+ future = executor.submit(
1987
+ self._step_impl, input_message, response_format
1988
+ )
1989
+ try:
1990
+ return future.result(timeout=self.step_timeout)
1991
+ except concurrent.futures.TimeoutError:
1992
+ future.cancel()
1993
+ raise TimeoutError(
1994
+ f"Step timed out after {self.step_timeout}s"
1995
+ )
1996
+ else:
1997
+ return self._step_impl(input_message, response_format)
1998
+
1999
+ def _step_impl(
2000
+ self,
2001
+ input_message: Union[BaseMessage, str],
2002
+ response_format: Optional[Type[BaseModel]] = None,
2003
+ ) -> ChatAgentResponse:
2004
+ r"""Implementation of non-streaming step logic."""
1377
2005
  # Set Langfuse session_id using agent_id for trace grouping
1378
2006
  try:
1379
2007
  from camel.utils.langfuse import set_current_agent_session_id
@@ -1382,6 +2010,10 @@ class ChatAgent(BaseAgent):
1382
2010
  except ImportError:
1383
2011
  pass # Langfuse not available
1384
2012
 
2013
+ # Check if this call is from a RegisteredAgentToolkit to prevent tool
2014
+ # use
2015
+ disable_tools = self._is_called_from_registered_toolkit()
2016
+
1385
2017
  # Handle response format compatibility with non-strict tools
1386
2018
  original_response_format = response_format
1387
2019
  input_message, response_format, used_prompt_formatting = (
@@ -1413,8 +2045,13 @@ class ChatAgent(BaseAgent):
1413
2045
 
1414
2046
  while True:
1415
2047
  if self.pause_event is not None and not self.pause_event.is_set():
1416
- while not self.pause_event.is_set():
1417
- time.sleep(0.001)
2048
+ # Use efficient blocking wait for threading.Event
2049
+ if isinstance(self.pause_event, threading.Event):
2050
+ self.pause_event.wait()
2051
+ else:
2052
+ # Fallback for asyncio.Event in sync context
2053
+ while not self.pause_event.is_set():
2054
+ time.sleep(0.001)
1418
2055
 
1419
2056
  try:
1420
2057
  openai_messages, num_tokens = self.memory.get_context()
@@ -1429,7 +2066,9 @@ class ChatAgent(BaseAgent):
1429
2066
  num_tokens=num_tokens,
1430
2067
  current_iteration=iteration_count,
1431
2068
  response_format=response_format,
1432
- tool_schemas=self._get_full_tool_schemas(),
2069
+ tool_schemas=[]
2070
+ if disable_tools
2071
+ else self._get_full_tool_schemas(),
1433
2072
  prev_num_openai_messages=prev_num_openai_messages,
1434
2073
  )
1435
2074
  prev_num_openai_messages = len(openai_messages)
@@ -1444,7 +2083,7 @@ class ChatAgent(BaseAgent):
1444
2083
  if self.stop_event and self.stop_event.is_set():
1445
2084
  # Use the _step_terminate to terminate the agent with reason
1446
2085
  logger.info(
1447
- f"Termination triggered at iteration " f"{iteration_count}"
2086
+ f"Termination triggered at iteration {iteration_count}"
1448
2087
  )
1449
2088
  return self._step_terminate(
1450
2089
  accumulated_context_tokens,
@@ -1467,8 +2106,11 @@ class ChatAgent(BaseAgent):
1467
2106
  self.pause_event is not None
1468
2107
  and not self.pause_event.is_set()
1469
2108
  ):
1470
- while not self.pause_event.is_set():
1471
- time.sleep(0.001)
2109
+ if isinstance(self.pause_event, threading.Event):
2110
+ self.pause_event.wait()
2111
+ else:
2112
+ while not self.pause_event.is_set():
2113
+ time.sleep(0.001)
1472
2114
  result = self._execute_tool(tool_call_request)
1473
2115
  tool_call_records.append(result)
1474
2116
 
@@ -1544,6 +2186,10 @@ class ChatAgent(BaseAgent):
1544
2186
  True, returns an AsyncStreamingChatAgentResponse that can be
1545
2187
  awaited for the final result or async iterated for streaming
1546
2188
  updates.
2189
+
2190
+ Raises:
2191
+ asyncio.TimeoutError: If the step operation exceeds the configured
2192
+ timeout.
1547
2193
  """
1548
2194
 
1549
2195
  try:
@@ -1559,9 +2205,22 @@ class ChatAgent(BaseAgent):
1559
2205
  async_generator = self._astream(input_message, response_format)
1560
2206
  return AsyncStreamingChatAgentResponse(async_generator)
1561
2207
  else:
1562
- return await self._astep_non_streaming_task(
1563
- input_message, response_format
1564
- )
2208
+ if self.step_timeout is not None:
2209
+ try:
2210
+ return await asyncio.wait_for(
2211
+ self._astep_non_streaming_task(
2212
+ input_message, response_format
2213
+ ),
2214
+ timeout=self.step_timeout,
2215
+ )
2216
+ except asyncio.TimeoutError:
2217
+ raise asyncio.TimeoutError(
2218
+ f"Async step timed out after {self.step_timeout}s"
2219
+ )
2220
+ else:
2221
+ return await self._astep_non_streaming_task(
2222
+ input_message, response_format
2223
+ )
1565
2224
 
1566
2225
  async def _astep_non_streaming_task(
1567
2226
  self,
@@ -1577,6 +2236,10 @@ class ChatAgent(BaseAgent):
1577
2236
  except ImportError:
1578
2237
  pass # Langfuse not available
1579
2238
 
2239
+ # Check if this call is from a RegisteredAgentToolkit to prevent tool
2240
+ # use
2241
+ disable_tools = self._is_called_from_registered_toolkit()
2242
+
1580
2243
  # Handle response format compatibility with non-strict tools
1581
2244
  original_response_format = response_format
1582
2245
  input_message, response_format, used_prompt_formatting = (
@@ -1604,7 +2267,12 @@ class ChatAgent(BaseAgent):
1604
2267
  prev_num_openai_messages: int = 0
1605
2268
  while True:
1606
2269
  if self.pause_event is not None and not self.pause_event.is_set():
1607
- await self.pause_event.wait()
2270
+ if isinstance(self.pause_event, asyncio.Event):
2271
+ await self.pause_event.wait()
2272
+ elif isinstance(self.pause_event, threading.Event):
2273
+ # For threading.Event in async context, run in executor
2274
+ loop = asyncio.get_event_loop()
2275
+ await loop.run_in_executor(None, self.pause_event.wait)
1608
2276
  try:
1609
2277
  openai_messages, num_tokens = self.memory.get_context()
1610
2278
  accumulated_context_tokens += num_tokens
@@ -1612,13 +2280,14 @@ class ChatAgent(BaseAgent):
1612
2280
  return self._step_terminate(
1613
2281
  e.args[1], tool_call_records, "max_tokens_exceeded"
1614
2282
  )
1615
-
1616
2283
  response = await self._aget_model_response(
1617
2284
  openai_messages,
1618
2285
  num_tokens=num_tokens,
1619
2286
  current_iteration=iteration_count,
1620
2287
  response_format=response_format,
1621
- tool_schemas=self._get_full_tool_schemas(),
2288
+ tool_schemas=[]
2289
+ if disable_tools
2290
+ else self._get_full_tool_schemas(),
1622
2291
  prev_num_openai_messages=prev_num_openai_messages,
1623
2292
  )
1624
2293
  prev_num_openai_messages = len(openai_messages)
@@ -1633,7 +2302,7 @@ class ChatAgent(BaseAgent):
1633
2302
  if self.stop_event and self.stop_event.is_set():
1634
2303
  # Use the _step_terminate to terminate the agent with reason
1635
2304
  logger.info(
1636
- f"Termination triggered at iteration " f"{iteration_count}"
2305
+ f"Termination triggered at iteration {iteration_count}"
1637
2306
  )
1638
2307
  return self._step_terminate(
1639
2308
  accumulated_context_tokens,
@@ -1656,7 +2325,13 @@ class ChatAgent(BaseAgent):
1656
2325
  self.pause_event is not None
1657
2326
  and not self.pause_event.is_set()
1658
2327
  ):
1659
- await self.pause_event.wait()
2328
+ if isinstance(self.pause_event, asyncio.Event):
2329
+ await self.pause_event.wait()
2330
+ elif isinstance(self.pause_event, threading.Event):
2331
+ loop = asyncio.get_event_loop()
2332
+ await loop.run_in_executor(
2333
+ None, self.pause_event.wait
2334
+ )
1660
2335
  tool_call_record = await self._aexecute_tool(
1661
2336
  tool_call_request
1662
2337
  )
@@ -1776,64 +2451,61 @@ class ChatAgent(BaseAgent):
1776
2451
  tool_schemas: Optional[List[Dict[str, Any]]] = None,
1777
2452
  prev_num_openai_messages: int = 0,
1778
2453
  ) -> ModelResponse:
1779
- r"""Internal function for agent step model response.
1780
- Args:
1781
- openai_messages (List[OpenAIMessage]): The OpenAI
1782
- messages to process.
1783
- num_tokens (int): The number of tokens in the context.
1784
- current_iteration (int): The current iteration of the step.
1785
- response_format (Optional[Type[BaseModel]]): The response
1786
- format to use.
1787
- tool_schemas (Optional[List[Dict[str, Any]]]): The tool
1788
- schemas to use.
1789
- prev_num_openai_messages (int): The number of openai messages
1790
- logged in the previous iteration.
1791
-
1792
- Returns:
1793
- ModelResponse: The model response.
1794
- """
2454
+ r"""Internal function for agent step model response."""
2455
+ last_error = None
1795
2456
 
1796
- response = None
1797
- try:
1798
- response = self.model_backend.run(
1799
- openai_messages, response_format, tool_schemas or None
1800
- )
1801
- except Exception as exc:
1802
- logger.error(
1803
- f"An error occurred while running model "
1804
- f"{self.model_backend.model_type}, "
1805
- f"index: {self.model_backend.current_model_index}",
1806
- exc_info=exc,
1807
- )
1808
- error_info = str(exc)
1809
-
1810
- if not response and self.model_backend.num_models > 1:
1811
- raise ModelProcessingError(
1812
- "Unable to process messages: none of the provided models "
1813
- "run successfully."
1814
- )
1815
- elif not response:
2457
+ for attempt in range(self.retry_attempts):
2458
+ try:
2459
+ response = self.model_backend.run(
2460
+ openai_messages, response_format, tool_schemas or None
2461
+ )
2462
+ if response:
2463
+ break
2464
+ except RateLimitError as e:
2465
+ last_error = e
2466
+ if attempt < self.retry_attempts - 1:
2467
+ delay = min(self.retry_delay * (2**attempt), 60.0)
2468
+ delay = random.uniform(0, delay) # Add jitter
2469
+ logger.warning(
2470
+ f"Rate limit hit (attempt {attempt + 1}"
2471
+ f"/{self.retry_attempts}). Retrying in {delay:.1f}s"
2472
+ )
2473
+ time.sleep(delay)
2474
+ else:
2475
+ logger.error(
2476
+ f"Rate limit exhausted after "
2477
+ f"{self.retry_attempts} attempts"
2478
+ )
2479
+ except Exception:
2480
+ logger.error(
2481
+ f"Model error: {self.model_backend.model_type}",
2482
+ exc_info=True,
2483
+ )
2484
+ raise
2485
+ else:
2486
+ # Loop completed without success
1816
2487
  raise ModelProcessingError(
1817
- f"Unable to process messages: the only provided model "
1818
- f"did not run successfully. Error: {error_info}"
2488
+ f"Unable to process messages: "
2489
+ f"{str(last_error) if last_error else 'Unknown error'}"
1819
2490
  )
1820
2491
 
1821
- sanitized_messages = self._sanitize_messages_for_logging(
2492
+ # Log success
2493
+ sanitized = self._sanitize_messages_for_logging(
1822
2494
  openai_messages, prev_num_openai_messages
1823
2495
  )
1824
2496
  logger.info(
1825
- f"Model {self.model_backend.model_type}, "
1826
- f"index {self.model_backend.current_model_index}, "
1827
- f"iteration {current_iteration}, "
1828
- f"processed these messages: {sanitized_messages}"
2497
+ f"Model {self.model_backend.model_type} "
2498
+ f"[{current_iteration}]: {sanitized}"
1829
2499
  )
2500
+
1830
2501
  if not isinstance(response, ChatCompletion):
1831
2502
  raise TypeError(
1832
- f"Expected response to be a `ChatCompletion` object, but "
1833
- f"got {type(response).__name__} instead."
2503
+ f"Expected ChatCompletion, got {type(response).__name__}"
1834
2504
  )
2505
+
1835
2506
  return self._handle_batch_response(response)
1836
2507
 
2508
+ @observe()
1837
2509
  async def _aget_model_response(
1838
2510
  self,
1839
2511
  openai_messages: List[OpenAIMessage],
@@ -1843,62 +2515,59 @@ class ChatAgent(BaseAgent):
1843
2515
  tool_schemas: Optional[List[Dict[str, Any]]] = None,
1844
2516
  prev_num_openai_messages: int = 0,
1845
2517
  ) -> ModelResponse:
1846
- r"""Internal function for agent async step model response.
1847
- Args:
1848
- openai_messages (List[OpenAIMessage]): The OpenAI messages
1849
- to process.
1850
- num_tokens (int): The number of tokens in the context.
1851
- current_iteration (int): The current iteration of the step.
1852
- response_format (Optional[Type[BaseModel]]): The response
1853
- format to use.
1854
- tool_schemas (Optional[List[Dict[str, Any]]]): The tool schemas
1855
- to use.
1856
- prev_num_openai_messages (int): The number of openai messages
1857
- logged in the previous iteration.
1858
-
1859
- Returns:
1860
- ModelResponse: The model response.
1861
- """
2518
+ r"""Internal function for agent async step model response."""
2519
+ last_error = None
1862
2520
 
1863
- response = None
1864
- try:
1865
- response = await self.model_backend.arun(
1866
- openai_messages, response_format, tool_schemas or None
1867
- )
1868
- except Exception as exc:
1869
- logger.error(
1870
- f"An error occurred while running model "
1871
- f"{self.model_backend.model_type}, "
1872
- f"index: {self.model_backend.current_model_index}",
1873
- exc_info=exc,
1874
- )
1875
- error_info = str(exc)
1876
-
1877
- if not response and self.model_backend.num_models > 1:
1878
- raise ModelProcessingError(
1879
- "Unable to process messages: none of the provided models "
1880
- "run successfully."
1881
- )
1882
- elif not response:
2521
+ for attempt in range(self.retry_attempts):
2522
+ try:
2523
+ response = await self.model_backend.arun(
2524
+ openai_messages, response_format, tool_schemas or None
2525
+ )
2526
+ if response:
2527
+ break
2528
+ except RateLimitError as e:
2529
+ last_error = e
2530
+ if attempt < self.retry_attempts - 1:
2531
+ delay = min(self.retry_delay * (2**attempt), 60.0)
2532
+ delay = random.uniform(0, delay) # Add jitter
2533
+ logger.warning(
2534
+ f"Rate limit hit (attempt {attempt + 1}"
2535
+ f"/{self.retry_attempts}). "
2536
+ f"Retrying in {delay:.1f}s"
2537
+ )
2538
+ await asyncio.sleep(delay)
2539
+ else:
2540
+ logger.error(
2541
+ f"Rate limit exhausted after "
2542
+ f"{self.retry_attempts} attempts"
2543
+ )
2544
+ except Exception:
2545
+ logger.error(
2546
+ f"Model error: {self.model_backend.model_type}",
2547
+ exc_info=True,
2548
+ )
2549
+ raise
2550
+ else:
2551
+ # Loop completed without success
1883
2552
  raise ModelProcessingError(
1884
- f"Unable to process messages: the only provided model "
1885
- f"did not run successfully. Error: {error_info}"
2553
+ f"Unable to process messages: "
2554
+ f"{str(last_error) if last_error else 'Unknown error'}"
1886
2555
  )
1887
2556
 
1888
- sanitized_messages = self._sanitize_messages_for_logging(
2557
+ # Log success
2558
+ sanitized = self._sanitize_messages_for_logging(
1889
2559
  openai_messages, prev_num_openai_messages
1890
2560
  )
1891
2561
  logger.info(
1892
- f"Model {self.model_backend.model_type}, "
1893
- f"index {self.model_backend.current_model_index}, "
1894
- f"iteration {current_iteration}, "
1895
- f"processed these messages: {sanitized_messages}"
2562
+ f"Model {self.model_backend.model_type} "
2563
+ f"[{current_iteration}]: {sanitized}"
1896
2564
  )
2565
+
1897
2566
  if not isinstance(response, ChatCompletion):
1898
2567
  raise TypeError(
1899
- f"Expected response to be a `ChatCompletion` object, but "
1900
- f"got {type(response).__name__} instead."
2568
+ f"Expected ChatCompletion, got {type(response).__name__}"
1901
2569
  )
2570
+
1902
2571
  return self._handle_batch_response(response)
1903
2572
 
1904
2573
  def _sanitize_messages_for_logging(
@@ -1915,11 +2584,6 @@ class ChatAgent(BaseAgent):
1915
2584
  Returns:
1916
2585
  List[OpenAIMessage]: The sanitized OpenAI messages.
1917
2586
  """
1918
- import hashlib
1919
- import os
1920
- import re
1921
- import tempfile
1922
-
1923
2587
  # Create a copy of messages for logging to avoid modifying the
1924
2588
  # original messages
1925
2589
  sanitized_messages = []
@@ -1960,7 +2624,14 @@ class ChatAgent(BaseAgent):
1960
2624
 
1961
2625
  # Save image to temp directory for viewing
1962
2626
  try:
1963
- import base64
2627
+ # Sanitize img_format to prevent path
2628
+ # traversal
2629
+ safe_format = re.sub(
2630
+ r'[^a-zA-Z0-9]', '', img_format
2631
+ )[:10]
2632
+ img_filename = (
2633
+ f"image_{img_hash}.{safe_format}"
2634
+ )
1964
2635
 
1965
2636
  temp_dir = tempfile.gettempdir()
1966
2637
  img_path = os.path.join(
@@ -1975,6 +2646,9 @@ class ChatAgent(BaseAgent):
1975
2646
  base64_data
1976
2647
  )
1977
2648
  )
2649
+ # Register for cleanup
2650
+ with _temp_files_lock:
2651
+ _temp_files.add(img_path)
1978
2652
 
1979
2653
  # Create a file:// URL that can be
1980
2654
  # opened
@@ -2227,7 +2901,8 @@ class ChatAgent(BaseAgent):
2227
2901
  try:
2228
2902
  raw_result = tool(**args)
2229
2903
  if self.mask_tool_output:
2230
- self._secure_result_store[tool_call_id] = raw_result
2904
+ with self._secure_result_store_lock:
2905
+ self._secure_result_store[tool_call_id] = raw_result
2231
2906
  result = (
2232
2907
  "[The tool has been executed successfully, but the output"
2233
2908
  " from the tool is masked. You can move forward]"
@@ -2285,7 +2960,7 @@ class ChatAgent(BaseAgent):
2285
2960
  # Capture the error message to prevent framework crash
2286
2961
  error_msg = f"Error executing async tool '{func_name}': {e!s}"
2287
2962
  result = f"Tool execution failed: {error_msg}"
2288
- logging.warning(error_msg)
2963
+ logger.warning(error_msg)
2289
2964
  return self._record_tool_calling(func_name, args, result, tool_call_id)
2290
2965
 
2291
2966
  def _record_tool_calling(
@@ -2336,20 +3011,22 @@ class ChatAgent(BaseAgent):
2336
3011
  # This ensures the assistant message (tool call) always appears before
2337
3012
  # the function message (tool result) in the conversation context
2338
3013
  # Use time.time_ns() for nanosecond precision to avoid collisions
2339
- import time
2340
-
2341
3014
  current_time_ns = time.time_ns()
2342
3015
  base_timestamp = current_time_ns / 1_000_000_000 # Convert to seconds
2343
3016
 
2344
3017
  self.update_memory(
2345
- assist_msg, OpenAIBackendRole.ASSISTANT, timestamp=base_timestamp
3018
+ assist_msg,
3019
+ OpenAIBackendRole.ASSISTANT,
3020
+ timestamp=base_timestamp,
3021
+ return_records=self._tool_output_cache_enabled,
2346
3022
  )
2347
3023
 
2348
3024
  # Add minimal increment to ensure function message comes after
2349
- self.update_memory(
3025
+ func_records = self.update_memory(
2350
3026
  func_msg,
2351
3027
  OpenAIBackendRole.FUNCTION,
2352
3028
  timestamp=base_timestamp + 1e-6,
3029
+ return_records=self._tool_output_cache_enabled,
2353
3030
  )
2354
3031
 
2355
3032
  # Record information about this tool call
@@ -2360,6 +3037,20 @@ class ChatAgent(BaseAgent):
2360
3037
  tool_call_id=tool_call_id,
2361
3038
  )
2362
3039
 
3040
+ if (
3041
+ self._tool_output_cache_enabled
3042
+ and not mask_output
3043
+ and func_records
3044
+ and self._tool_output_cache_manager is not None
3045
+ ):
3046
+ serialized_result = self._serialize_tool_result(result)
3047
+ self._register_tool_output_for_cache(
3048
+ func_name,
3049
+ tool_call_id,
3050
+ serialized_result,
3051
+ cast(List[MemoryRecord], func_records),
3052
+ )
3053
+
2363
3054
  return tool_record
2364
3055
 
2365
3056
  def _stream(
@@ -2428,7 +3119,7 @@ class ChatAgent(BaseAgent):
2428
3119
  # Check termination condition
2429
3120
  if self.stop_event and self.stop_event.is_set():
2430
3121
  logger.info(
2431
- f"Termination triggered at iteration " f"{iteration_count}"
3122
+ f"Termination triggered at iteration {iteration_count}"
2432
3123
  )
2433
3124
  yield self._step_terminate(
2434
3125
  num_tokens, tool_call_records, "termination_triggered"
@@ -2611,12 +3302,6 @@ class ChatAgent(BaseAgent):
2611
3302
  stream_completed = False
2612
3303
 
2613
3304
  for chunk in stream:
2614
- # Update token usage if available
2615
- if chunk.usage:
2616
- self._update_token_usage_tracker(
2617
- step_token_usage, safe_model_dump(chunk.usage)
2618
- )
2619
-
2620
3305
  # Process chunk delta
2621
3306
  if chunk.choices and len(chunk.choices) > 0:
2622
3307
  choice = chunk.choices[0]
@@ -2649,12 +3334,6 @@ class ChatAgent(BaseAgent):
2649
3334
  # If we have complete tool calls, execute them with
2650
3335
  # sync status updates
2651
3336
  if accumulated_tool_calls:
2652
- # Record assistant message with tool calls first
2653
- self._record_assistant_tool_calls_message(
2654
- accumulated_tool_calls,
2655
- content_accumulator.get_full_content(),
2656
- )
2657
-
2658
3337
  # Execute tools synchronously with
2659
3338
  # optimized status updates
2660
3339
  for (
@@ -2687,7 +3366,49 @@ class ChatAgent(BaseAgent):
2687
3366
  )
2688
3367
 
2689
3368
  self.record_message(final_message)
2690
- break
3369
+ elif chunk.usage and not chunk.choices:
3370
+ # Handle final chunk with usage but empty choices
3371
+ # This happens when stream_options={"include_usage": True}
3372
+ # Update the final usage from this chunk
3373
+ self._update_token_usage_tracker(
3374
+ step_token_usage, safe_model_dump(chunk.usage)
3375
+ )
3376
+
3377
+ # Create final response with final usage
3378
+ final_content = content_accumulator.get_full_content()
3379
+ if final_content.strip():
3380
+ final_message = BaseMessage(
3381
+ role_name=self.role_name,
3382
+ role_type=self.role_type,
3383
+ meta_dict={},
3384
+ content=final_content,
3385
+ )
3386
+
3387
+ if response_format:
3388
+ self._try_format_message(
3389
+ final_message, response_format
3390
+ )
3391
+
3392
+ # Create final response with final usage (not partial)
3393
+ final_response = ChatAgentResponse(
3394
+ msgs=[final_message],
3395
+ terminated=False,
3396
+ info={
3397
+ "id": getattr(chunk, 'id', ''),
3398
+ "usage": step_token_usage.copy(),
3399
+ "finish_reasons": ["stop"],
3400
+ "num_tokens": self._get_token_count(final_content),
3401
+ "tool_calls": tool_call_records or [],
3402
+ "external_tool_requests": None,
3403
+ "streaming": False,
3404
+ "partial": False,
3405
+ },
3406
+ )
3407
+ yield final_response
3408
+ break
3409
+ elif stream_completed:
3410
+ # If we've already seen finish_reason but no usage chunk, exit
3411
+ break
2691
3412
 
2692
3413
  return stream_completed, tool_calls_complete
2693
3414
 
@@ -2767,72 +3488,70 @@ class ChatAgent(BaseAgent):
2767
3488
  accumulated_tool_calls: Dict[str, Any],
2768
3489
  tool_call_records: List[ToolCallingRecord],
2769
3490
  ) -> Generator[ChatAgentResponse, None, None]:
2770
- r"""Execute multiple tools synchronously with
2771
- proper content accumulation, using threads+queue for
2772
- non-blocking status streaming."""
2773
-
2774
- def tool_worker(result_queue, tool_call_data):
2775
- try:
2776
- tool_call_record = self._execute_tool_from_stream_data(
2777
- tool_call_data
2778
- )
2779
- result_queue.put(tool_call_record)
2780
- except Exception as e:
2781
- logger.error(f"Error in threaded tool execution: {e}")
2782
- result_queue.put(None)
3491
+ r"""Execute multiple tools synchronously with proper content
3492
+ accumulation, using ThreadPoolExecutor for better timeout handling."""
2783
3493
 
2784
3494
  tool_calls_to_execute = []
2785
3495
  for _tool_call_index, tool_call_data in accumulated_tool_calls.items():
2786
3496
  if tool_call_data.get('complete', False):
2787
3497
  tool_calls_to_execute.append(tool_call_data)
2788
3498
 
2789
- # Phase 2: Execute tools in threads and yield status while waiting
2790
- for tool_call_data in tool_calls_to_execute:
2791
- function_name = tool_call_data['function']['name']
2792
- try:
2793
- args = json.loads(tool_call_data['function']['arguments'])
2794
- except json.JSONDecodeError:
2795
- args = tool_call_data['function']['arguments']
2796
- result_queue: queue.Queue[Optional[ToolCallingRecord]] = (
2797
- queue.Queue()
2798
- )
2799
- thread = threading.Thread(
2800
- target=tool_worker,
2801
- args=(result_queue, tool_call_data),
2802
- )
2803
- thread.start()
2804
-
2805
- # Log debug info instead of adding to content
2806
- logger.info(
2807
- f"Calling function: {function_name} with arguments: {args}"
2808
- )
2809
-
2810
- # wait for tool thread to finish with optional timeout
2811
- thread.join(self.tool_execution_timeout)
3499
+ if not tool_calls_to_execute:
3500
+ # No tools to execute, return immediately
3501
+ return
3502
+ yield # Make this a generator
3503
+
3504
+ # Execute tools using ThreadPoolExecutor for proper timeout handling
3505
+ # Use max_workers=len() for parallel execution, with min of 1
3506
+ with concurrent.futures.ThreadPoolExecutor(
3507
+ max_workers=max(1, len(tool_calls_to_execute))
3508
+ ) as executor:
3509
+ # Submit all tools first (parallel execution)
3510
+ futures_map = {}
3511
+ for tool_call_data in tool_calls_to_execute:
3512
+ function_name = tool_call_data['function']['name']
3513
+ try:
3514
+ args = json.loads(tool_call_data['function']['arguments'])
3515
+ except json.JSONDecodeError:
3516
+ args = tool_call_data['function']['arguments']
2812
3517
 
2813
- # If timeout occurred, mark as error and continue
2814
- if thread.is_alive():
2815
- # Log timeout info instead of adding to content
2816
- logger.warning(
2817
- f"Function '{function_name}' timed out after "
2818
- f"{self.tool_execution_timeout} seconds"
3518
+ # Log debug info
3519
+ logger.info(
3520
+ f"Calling function: {function_name} with arguments: {args}"
2819
3521
  )
2820
3522
 
2821
- # Detach thread (it may still finish later). Skip recording.
2822
- continue
2823
-
2824
- # Tool finished, get result
2825
- tool_call_record = result_queue.get()
2826
- if tool_call_record:
2827
- tool_call_records.append(tool_call_record)
2828
- raw_result = tool_call_record.result
2829
- result_str = str(raw_result)
3523
+ # Submit tool execution (non-blocking)
3524
+ future = executor.submit(
3525
+ self._execute_tool_from_stream_data, tool_call_data
3526
+ )
3527
+ futures_map[future] = (function_name, tool_call_data)
3528
+
3529
+ # Wait for all futures to complete (or timeout)
3530
+ for future in concurrent.futures.as_completed(
3531
+ futures_map.keys(),
3532
+ timeout=self.tool_execution_timeout
3533
+ if self.tool_execution_timeout
3534
+ else None,
3535
+ ):
3536
+ function_name, tool_call_data = futures_map[future]
2830
3537
 
2831
- # Log debug info instead of adding to content
2832
- logger.info(f"Function output: {result_str}")
2833
- else:
2834
- # Error already logged
2835
- continue
3538
+ try:
3539
+ tool_call_record = future.result()
3540
+ if tool_call_record:
3541
+ tool_call_records.append(tool_call_record)
3542
+ logger.info(
3543
+ f"Function output: {tool_call_record.result}"
3544
+ )
3545
+ except concurrent.futures.TimeoutError:
3546
+ logger.warning(
3547
+ f"Function '{function_name}' timed out after "
3548
+ f"{self.tool_execution_timeout} seconds"
3549
+ )
3550
+ future.cancel()
3551
+ except Exception as e:
3552
+ logger.error(
3553
+ f"Error executing tool '{function_name}': {e}"
3554
+ )
2836
3555
 
2837
3556
  # Ensure this function remains a generator (required by type signature)
2838
3557
  return
@@ -2852,10 +3571,19 @@ class ChatAgent(BaseAgent):
2852
3571
  tool = self._internal_tools[function_name]
2853
3572
  try:
2854
3573
  result = tool(**args)
3574
+ # First, create and record the assistant message with tool
3575
+ # call
3576
+ assist_msg = FunctionCallingMessage(
3577
+ role_name=self.role_name,
3578
+ role_type=self.role_type,
3579
+ meta_dict=None,
3580
+ content="",
3581
+ func_name=function_name,
3582
+ args=args,
3583
+ tool_call_id=tool_call_id,
3584
+ )
2855
3585
 
2856
- # Only record the tool response message, not the assistant
2857
- # message assistant message with tool_calls was already
2858
- # recorded in _record_assistant_tool_calls_message
3586
+ # Then create the tool response message
2859
3587
  func_msg = FunctionCallingMessage(
2860
3588
  role_name=self.role_name,
2861
3589
  role_type=self.role_type,
@@ -2866,7 +3594,23 @@ class ChatAgent(BaseAgent):
2866
3594
  tool_call_id=tool_call_id,
2867
3595
  )
2868
3596
 
2869
- self.update_memory(func_msg, OpenAIBackendRole.FUNCTION)
3597
+ # Record both messages with precise timestamps to ensure
3598
+ # correct ordering
3599
+ current_time_ns = time.time_ns()
3600
+ base_timestamp = (
3601
+ current_time_ns / 1_000_000_000
3602
+ ) # Convert to seconds
3603
+
3604
+ self.update_memory(
3605
+ assist_msg,
3606
+ OpenAIBackendRole.ASSISTANT,
3607
+ timestamp=base_timestamp,
3608
+ )
3609
+ self.update_memory(
3610
+ func_msg,
3611
+ OpenAIBackendRole.FUNCTION,
3612
+ timestamp=base_timestamp + 1e-6,
3613
+ )
2870
3614
 
2871
3615
  return ToolCallingRecord(
2872
3616
  tool_name=function_name,
@@ -2880,7 +3624,7 @@ class ChatAgent(BaseAgent):
2880
3624
  f"Error executing tool '{function_name}': {e!s}"
2881
3625
  )
2882
3626
  result = {"error": error_msg}
2883
- logging.warning(error_msg)
3627
+ logger.warning(error_msg)
2884
3628
 
2885
3629
  # Record error response
2886
3630
  func_msg = FunctionCallingMessage(
@@ -2950,10 +3694,19 @@ class ChatAgent(BaseAgent):
2950
3694
  else:
2951
3695
  # Fallback: synchronous call
2952
3696
  result = tool(**args)
3697
+ # First, create and record the assistant message with tool
3698
+ # call
3699
+ assist_msg = FunctionCallingMessage(
3700
+ role_name=self.role_name,
3701
+ role_type=self.role_type,
3702
+ meta_dict=None,
3703
+ content="",
3704
+ func_name=function_name,
3705
+ args=args,
3706
+ tool_call_id=tool_call_id,
3707
+ )
2953
3708
 
2954
- # Only record the tool response message, not the assistant
2955
- # message assistant message with tool_calls was already
2956
- # recorded in _record_assistant_tool_calls_message
3709
+ # Then create the tool response message
2957
3710
  func_msg = FunctionCallingMessage(
2958
3711
  role_name=self.role_name,
2959
3712
  role_type=self.role_type,
@@ -2964,7 +3717,23 @@ class ChatAgent(BaseAgent):
2964
3717
  tool_call_id=tool_call_id,
2965
3718
  )
2966
3719
 
2967
- self.update_memory(func_msg, OpenAIBackendRole.FUNCTION)
3720
+ # Record both messages with precise timestamps to ensure
3721
+ # correct ordering
3722
+ current_time_ns = time.time_ns()
3723
+ base_timestamp = (
3724
+ current_time_ns / 1_000_000_000
3725
+ ) # Convert to seconds
3726
+
3727
+ self.update_memory(
3728
+ assist_msg,
3729
+ OpenAIBackendRole.ASSISTANT,
3730
+ timestamp=base_timestamp,
3731
+ )
3732
+ self.update_memory(
3733
+ func_msg,
3734
+ OpenAIBackendRole.FUNCTION,
3735
+ timestamp=base_timestamp + 1e-6,
3736
+ )
2968
3737
 
2969
3738
  return ToolCallingRecord(
2970
3739
  tool_name=function_name,
@@ -2978,7 +3747,7 @@ class ChatAgent(BaseAgent):
2978
3747
  f"Error executing async tool '{function_name}': {e!s}"
2979
3748
  )
2980
3749
  result = {"error": error_msg}
2981
- logging.warning(error_msg)
3750
+ logger.warning(error_msg)
2982
3751
 
2983
3752
  # Record error response
2984
3753
  func_msg = FunctionCallingMessage(
@@ -3088,7 +3857,7 @@ class ChatAgent(BaseAgent):
3088
3857
  # Check termination condition
3089
3858
  if self.stop_event and self.stop_event.is_set():
3090
3859
  logger.info(
3091
- f"Termination triggered at iteration " f"{iteration_count}"
3860
+ f"Termination triggered at iteration {iteration_count}"
3092
3861
  )
3093
3862
  yield self._step_terminate(
3094
3863
  num_tokens, tool_call_records, "termination_triggered"
@@ -3315,18 +4084,13 @@ class ChatAgent(BaseAgent):
3315
4084
  response_format: Optional[Type[BaseModel]] = None,
3316
4085
  ) -> AsyncGenerator[Union[ChatAgentResponse, Tuple[bool, bool]], None]:
3317
4086
  r"""Async version of process streaming chunks with
3318
- content accumulator."""
4087
+ content accumulator.
4088
+ """
3319
4089
 
3320
4090
  tool_calls_complete = False
3321
4091
  stream_completed = False
3322
4092
 
3323
4093
  async for chunk in stream:
3324
- # Update token usage if available
3325
- if chunk.usage:
3326
- self._update_token_usage_tracker(
3327
- step_token_usage, safe_model_dump(chunk.usage)
3328
- )
3329
-
3330
4094
  # Process chunk delta
3331
4095
  if chunk.choices and len(chunk.choices) > 0:
3332
4096
  choice = chunk.choices[0]
@@ -3359,13 +4123,6 @@ class ChatAgent(BaseAgent):
3359
4123
  # If we have complete tool calls, execute them with
3360
4124
  # async status updates
3361
4125
  if accumulated_tool_calls:
3362
- # Record assistant message with
3363
- # tool calls first
3364
- self._record_assistant_tool_calls_message(
3365
- accumulated_tool_calls,
3366
- content_accumulator.get_full_content(),
3367
- )
3368
-
3369
4126
  # Execute tools asynchronously with real-time
3370
4127
  # status updates
3371
4128
  async for (
@@ -3400,7 +4157,49 @@ class ChatAgent(BaseAgent):
3400
4157
  )
3401
4158
 
3402
4159
  self.record_message(final_message)
3403
- break
4160
+ elif chunk.usage and not chunk.choices:
4161
+ # Handle final chunk with usage but empty choices
4162
+ # This happens when stream_options={"include_usage": True}
4163
+ # Update the final usage from this chunk
4164
+ self._update_token_usage_tracker(
4165
+ step_token_usage, safe_model_dump(chunk.usage)
4166
+ )
4167
+
4168
+ # Create final response with final usage
4169
+ final_content = content_accumulator.get_full_content()
4170
+ if final_content.strip():
4171
+ final_message = BaseMessage(
4172
+ role_name=self.role_name,
4173
+ role_type=self.role_type,
4174
+ meta_dict={},
4175
+ content=final_content,
4176
+ )
4177
+
4178
+ if response_format:
4179
+ self._try_format_message(
4180
+ final_message, response_format
4181
+ )
4182
+
4183
+ # Create final response with final usage (not partial)
4184
+ final_response = ChatAgentResponse(
4185
+ msgs=[final_message],
4186
+ terminated=False,
4187
+ info={
4188
+ "id": getattr(chunk, 'id', ''),
4189
+ "usage": step_token_usage.copy(),
4190
+ "finish_reasons": ["stop"],
4191
+ "num_tokens": self._get_token_count(final_content),
4192
+ "tool_calls": tool_call_records or [],
4193
+ "external_tool_requests": None,
4194
+ "streaming": False,
4195
+ "partial": False,
4196
+ },
4197
+ )
4198
+ yield final_response
4199
+ break
4200
+ elif stream_completed:
4201
+ # If we've already seen finish_reason but no usage chunk, exit
4202
+ break
3404
4203
 
3405
4204
  # Yield the final status as a tuple
3406
4205
  yield (stream_completed, tool_calls_complete)
@@ -3493,15 +4292,18 @@ class ChatAgent(BaseAgent):
3493
4292
  ) -> ChatAgentResponse:
3494
4293
  r"""Create a streaming response using content accumulator."""
3495
4294
 
3496
- # Add new content to accumulator and get full content
4295
+ # Add new content; only build full content when needed
3497
4296
  accumulator.add_streaming_content(new_content)
3498
- full_content = accumulator.get_full_content()
4297
+ if self.stream_accumulate:
4298
+ message_content = accumulator.get_full_content()
4299
+ else:
4300
+ message_content = new_content
3499
4301
 
3500
4302
  message = BaseMessage(
3501
4303
  role_name=self.role_name,
3502
4304
  role_type=self.role_type,
3503
4305
  meta_dict={},
3504
- content=full_content,
4306
+ content=message_content,
3505
4307
  )
3506
4308
 
3507
4309
  return ChatAgentResponse(
@@ -3511,7 +4313,7 @@ class ChatAgent(BaseAgent):
3511
4313
  "id": response_id,
3512
4314
  "usage": step_token_usage.copy(),
3513
4315
  "finish_reasons": ["streaming"],
3514
- "num_tokens": self._get_token_count(full_content),
4316
+ "num_tokens": self._get_token_count(message_content),
3515
4317
  "tool_calls": tool_call_records or [],
3516
4318
  "external_tool_requests": None,
3517
4319
  "streaming": True,
@@ -3567,10 +4369,12 @@ class ChatAgent(BaseAgent):
3567
4369
  configuration.
3568
4370
  """
3569
4371
  # Create a new instance with the same configuration
3570
- # If with_memory is True, set system_message to None
3571
- # If with_memory is False, use the original system message
4372
+ # If with_memory is True, set system_message to None (it will be
4373
+ # copied from memory below, including any workflow context)
4374
+ # If with_memory is False, use the current system message
4375
+ # (which may include appended workflow context)
3572
4376
  # To avoid duplicated system memory.
3573
- system_message = None if with_memory else self._original_system_message
4377
+ system_message = None if with_memory else self._system_message
3574
4378
 
3575
4379
  # Clone tools and collect toolkits that need registration
3576
4380
  cloned_tools, toolkits_to_register = self._clone_tools()
@@ -3584,7 +4388,7 @@ class ChatAgent(BaseAgent):
3584
4388
  self.memory.get_context_creator(), "token_limit", None
3585
4389
  ),
3586
4390
  output_language=self._output_language,
3587
- tools=cloned_tools,
4391
+ tools=cast(List[Union[FunctionTool, Callable]], cloned_tools),
3588
4392
  toolkits_to_register_agent=toolkits_to_register,
3589
4393
  external_tools=[
3590
4394
  schema for schema in self._external_tool_schemas.values()
@@ -3598,6 +4402,10 @@ class ChatAgent(BaseAgent):
3598
4402
  tool_execution_timeout=self.tool_execution_timeout,
3599
4403
  pause_event=self.pause_event,
3600
4404
  prune_tool_calls_from_memory=self.prune_tool_calls_from_memory,
4405
+ enable_tool_output_cache=self._tool_output_cache_enabled,
4406
+ tool_output_cache_threshold=self._tool_output_cache_threshold,
4407
+ tool_output_cache_dir=self._tool_output_cache_dir,
4408
+ stream_accumulate=self.stream_accumulate,
3601
4409
  )
3602
4410
 
3603
4411
  # Copy memory if requested
@@ -3612,9 +4420,7 @@ class ChatAgent(BaseAgent):
3612
4420
 
3613
4421
  def _clone_tools(
3614
4422
  self,
3615
- ) -> Tuple[
3616
- List[Union[FunctionTool, Callable]], List[RegisteredAgentToolkit]
3617
- ]:
4423
+ ) -> Tuple[List[FunctionTool], List[RegisteredAgentToolkit]]:
3618
4424
  r"""Clone tools and return toolkits that need agent registration.
3619
4425
 
3620
4426
  This method handles stateful toolkits by cloning them if they have
@@ -3666,18 +4472,62 @@ class ChatAgent(BaseAgent):
3666
4472
  # Toolkit doesn't support cloning, use original
3667
4473
  cloned_toolkits[toolkit_id] = toolkit_instance
3668
4474
 
4475
+ if getattr(
4476
+ tool.func, "__message_integration_enhanced__", False
4477
+ ):
4478
+ cloned_tools.append(
4479
+ FunctionTool(
4480
+ func=tool.func,
4481
+ openai_tool_schema=tool.get_openai_tool_schema(),
4482
+ )
4483
+ )
4484
+ continue
4485
+
3669
4486
  # Get the method from the cloned (or original) toolkit
3670
4487
  toolkit = cloned_toolkits[toolkit_id]
3671
4488
  method_name = tool.func.__name__
4489
+
3672
4490
  if hasattr(toolkit, method_name):
3673
4491
  new_method = getattr(toolkit, method_name)
3674
- cloned_tools.append(new_method)
4492
+ # Wrap cloned method into a new FunctionTool,
4493
+ # preserving schema
4494
+ try:
4495
+ new_tool = FunctionTool(
4496
+ func=new_method,
4497
+ openai_tool_schema=tool.get_openai_tool_schema(),
4498
+ )
4499
+ cloned_tools.append(new_tool)
4500
+ except Exception as e:
4501
+ # If wrapping fails, fallback to wrapping the original
4502
+ # function with its schema to maintain consistency
4503
+ logger.warning(
4504
+ f"Failed to wrap cloned toolkit "
4505
+ f"method '{method_name}' "
4506
+ f"with FunctionTool: {e}. Using original "
4507
+ f"function with preserved schema instead."
4508
+ )
4509
+ cloned_tools.append(
4510
+ FunctionTool(
4511
+ func=tool.func,
4512
+ openai_tool_schema=tool.get_openai_tool_schema(),
4513
+ )
4514
+ )
3675
4515
  else:
3676
- # Fallback to original function
3677
- cloned_tools.append(tool.func)
4516
+ # Fallback to original function wrapped in FunctionTool
4517
+ cloned_tools.append(
4518
+ FunctionTool(
4519
+ func=tool.func,
4520
+ openai_tool_schema=tool.get_openai_tool_schema(),
4521
+ )
4522
+ )
3678
4523
  else:
3679
- # Not a toolkit method, just use the original function
3680
- cloned_tools.append(tool.func)
4524
+ # Not a toolkit method, preserve FunctionTool schema directly
4525
+ cloned_tools.append(
4526
+ FunctionTool(
4527
+ func=tool.func,
4528
+ openai_tool_schema=tool.get_openai_tool_schema(),
4529
+ )
4530
+ )
3681
4531
 
3682
4532
  return cloned_tools, toolkits_to_register
3683
4533