camel-ai 0.2.75a6__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 (97) hide show
  1. camel/__init__.py +1 -1
  2. camel/agents/chat_agent.py +1001 -205
  3. camel/agents/mcp_agent.py +30 -27
  4. camel/configs/__init__.py +6 -0
  5. camel/configs/amd_config.py +70 -0
  6. camel/configs/cometapi_config.py +104 -0
  7. camel/data_collectors/alpaca_collector.py +15 -6
  8. camel/environments/tic_tac_toe.py +1 -1
  9. camel/interpreters/__init__.py +2 -0
  10. camel/interpreters/docker/Dockerfile +3 -12
  11. camel/interpreters/microsandbox_interpreter.py +395 -0
  12. camel/loaders/__init__.py +11 -2
  13. camel/loaders/chunkr_reader.py +9 -0
  14. camel/memories/__init__.py +2 -1
  15. camel/memories/agent_memories.py +3 -1
  16. camel/memories/blocks/chat_history_block.py +21 -3
  17. camel/memories/records.py +88 -8
  18. camel/messages/base.py +127 -34
  19. camel/models/__init__.py +4 -0
  20. camel/models/amd_model.py +101 -0
  21. camel/models/azure_openai_model.py +0 -6
  22. camel/models/base_model.py +30 -0
  23. camel/models/cometapi_model.py +83 -0
  24. camel/models/model_factory.py +4 -0
  25. camel/models/openai_compatible_model.py +0 -6
  26. camel/models/openai_model.py +0 -6
  27. camel/models/zhipuai_model.py +61 -2
  28. camel/parsers/__init__.py +18 -0
  29. camel/parsers/mcp_tool_call_parser.py +176 -0
  30. camel/retrievers/auto_retriever.py +1 -0
  31. camel/runtimes/daytona_runtime.py +11 -12
  32. camel/societies/workforce/prompts.py +131 -50
  33. camel/societies/workforce/single_agent_worker.py +434 -49
  34. camel/societies/workforce/structured_output_handler.py +30 -18
  35. camel/societies/workforce/task_channel.py +43 -0
  36. camel/societies/workforce/utils.py +105 -12
  37. camel/societies/workforce/workforce.py +1322 -311
  38. camel/societies/workforce/workforce_logger.py +24 -5
  39. camel/storages/key_value_storages/json.py +15 -2
  40. camel/storages/object_storages/google_cloud.py +1 -1
  41. camel/storages/vectordb_storages/oceanbase.py +10 -11
  42. camel/storages/vectordb_storages/tidb.py +8 -6
  43. camel/tasks/task.py +4 -3
  44. camel/toolkits/__init__.py +18 -5
  45. camel/toolkits/aci_toolkit.py +45 -0
  46. camel/toolkits/code_execution.py +28 -1
  47. camel/toolkits/context_summarizer_toolkit.py +684 -0
  48. camel/toolkits/dingtalk.py +1135 -0
  49. camel/toolkits/edgeone_pages_mcp_toolkit.py +11 -31
  50. camel/toolkits/{file_write_toolkit.py → file_toolkit.py} +194 -34
  51. camel/toolkits/function_tool.py +6 -1
  52. camel/toolkits/google_drive_mcp_toolkit.py +12 -31
  53. camel/toolkits/hybrid_browser_toolkit/config_loader.py +12 -0
  54. camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit.py +79 -2
  55. camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit_ts.py +95 -59
  56. camel/toolkits/hybrid_browser_toolkit/installer.py +203 -0
  57. camel/toolkits/hybrid_browser_toolkit/ts/package-lock.json +5 -612
  58. camel/toolkits/hybrid_browser_toolkit/ts/package.json +0 -1
  59. camel/toolkits/hybrid_browser_toolkit/ts/src/browser-session.ts +619 -95
  60. camel/toolkits/hybrid_browser_toolkit/ts/src/config-loader.ts +7 -2
  61. camel/toolkits/hybrid_browser_toolkit/ts/src/hybrid-browser-toolkit.ts +115 -219
  62. camel/toolkits/hybrid_browser_toolkit/ts/src/parent-child-filter.ts +226 -0
  63. camel/toolkits/hybrid_browser_toolkit/ts/src/snapshot-parser.ts +219 -0
  64. camel/toolkits/hybrid_browser_toolkit/ts/src/som-screenshot-injected.ts +543 -0
  65. camel/toolkits/hybrid_browser_toolkit/ts/src/types.ts +1 -0
  66. camel/toolkits/hybrid_browser_toolkit/ts/websocket-server.js +39 -6
  67. camel/toolkits/hybrid_browser_toolkit/ws_wrapper.py +405 -131
  68. camel/toolkits/hybrid_browser_toolkit_py/hybrid_browser_toolkit.py +9 -5
  69. camel/toolkits/{openai_image_toolkit.py → image_generation_toolkit.py} +98 -31
  70. camel/toolkits/markitdown_toolkit.py +27 -1
  71. camel/toolkits/mcp_toolkit.py +348 -348
  72. camel/toolkits/message_integration.py +3 -0
  73. camel/toolkits/minimax_mcp_toolkit.py +195 -0
  74. camel/toolkits/note_taking_toolkit.py +18 -8
  75. camel/toolkits/notion_mcp_toolkit.py +16 -26
  76. camel/toolkits/origene_mcp_toolkit.py +8 -49
  77. camel/toolkits/playwright_mcp_toolkit.py +12 -31
  78. camel/toolkits/resend_toolkit.py +168 -0
  79. camel/toolkits/slack_toolkit.py +50 -1
  80. camel/toolkits/terminal_toolkit/__init__.py +18 -0
  81. camel/toolkits/terminal_toolkit/terminal_toolkit.py +924 -0
  82. camel/toolkits/terminal_toolkit/utils.py +532 -0
  83. camel/toolkits/vertex_ai_veo_toolkit.py +590 -0
  84. camel/toolkits/video_analysis_toolkit.py +17 -11
  85. camel/toolkits/wechat_official_toolkit.py +483 -0
  86. camel/types/enums.py +124 -1
  87. camel/types/unified_model_type.py +5 -0
  88. camel/utils/commons.py +17 -0
  89. camel/utils/context_utils.py +804 -0
  90. camel/utils/mcp.py +136 -2
  91. camel/utils/token_counting.py +25 -17
  92. {camel_ai-0.2.75a6.dist-info → camel_ai-0.2.76.dist-info}/METADATA +158 -59
  93. {camel_ai-0.2.75a6.dist-info → camel_ai-0.2.76.dist-info}/RECORD +95 -76
  94. camel/loaders/pandas_reader.py +0 -368
  95. camel/toolkits/terminal_toolkit.py +0 -1788
  96. {camel_ai-0.2.75a6.dist-info → camel_ai-0.2.76.dist-info}/WHEEL +0 -0
  97. {camel_ai-0.2.75a6.dist-info → camel_ai-0.2.76.dist-info}/licenses/LICENSE +0 -0
@@ -14,15 +14,24 @@
14
14
  from __future__ import annotations
15
15
 
16
16
  import asyncio
17
+ import atexit
18
+ import base64
17
19
  import concurrent.futures
20
+ import hashlib
21
+ import inspect
18
22
  import json
19
- import logging
20
- import queue
23
+ import math
24
+ import os
21
25
  import random
26
+ import re
27
+ import tempfile
22
28
  import textwrap
23
29
  import threading
24
30
  import time
25
31
  import uuid
32
+ import warnings
33
+ from dataclasses import dataclass
34
+ from datetime import datetime
26
35
  from pathlib import Path
27
36
  from typing import (
28
37
  TYPE_CHECKING,
@@ -63,6 +72,7 @@ from camel.memories import (
63
72
  MemoryRecord,
64
73
  ScoreBasedContextCreator,
65
74
  )
75
+ from camel.memories.blocks.chat_history_block import EmptyMemoryWarning
66
76
  from camel.messages import (
67
77
  BaseMessage,
68
78
  FunctionCallingMessage,
@@ -92,16 +102,31 @@ from camel.utils import (
92
102
  model_from_json_schema,
93
103
  )
94
104
  from camel.utils.commons import dependencies_required
105
+ from camel.utils.context_utils import ContextUtility
95
106
 
96
107
  if TYPE_CHECKING:
97
108
  from camel.terminators import ResponseTerminator
98
109
 
99
110
  logger = get_logger(__name__)
100
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
+
101
128
  # AgentOps decorator setting
102
129
  try:
103
- import os
104
-
105
130
  if os.getenv("AGENTOPS_API_KEY") is not None:
106
131
  from agentops import track_agent
107
132
  else:
@@ -135,13 +160,60 @@ SIMPLE_FORMAT_PROMPT = TextPrompt(
135
160
  )
136
161
 
137
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
+
138
210
  class StreamContentAccumulator:
139
211
  r"""Manages content accumulation across streaming responses to ensure
140
212
  all responses contain complete cumulative content."""
141
213
 
142
214
  def __init__(self):
143
215
  self.base_content = "" # Content before tool calls
144
- self.current_content = "" # Current streaming content
216
+ self.current_content = [] # Accumulated streaming fragments
145
217
  self.tool_status_messages = [] # Accumulated tool status messages
146
218
 
147
219
  def set_base_content(self, content: str):
@@ -150,7 +222,7 @@ class StreamContentAccumulator:
150
222
 
151
223
  def add_streaming_content(self, new_content: str):
152
224
  r"""Add new streaming content."""
153
- self.current_content += new_content
225
+ self.current_content.append(new_content)
154
226
 
155
227
  def add_tool_status(self, status_message: str):
156
228
  r"""Add a tool status message."""
@@ -159,16 +231,18 @@ class StreamContentAccumulator:
159
231
  def get_full_content(self) -> str:
160
232
  r"""Get the complete accumulated content."""
161
233
  tool_messages = "".join(self.tool_status_messages)
162
- return self.base_content + tool_messages + self.current_content
234
+ current = "".join(self.current_content)
235
+ return self.base_content + tool_messages + current
163
236
 
164
237
  def get_content_with_new_status(self, status_message: str) -> str:
165
238
  r"""Get content with a new status message appended."""
166
239
  tool_messages = "".join([*self.tool_status_messages, status_message])
167
- return self.base_content + tool_messages + self.current_content
240
+ current = "".join(self.current_content)
241
+ return self.base_content + tool_messages + current
168
242
 
169
243
  def reset_streaming_content(self):
170
244
  r"""Reset only the streaming content, keep base and tool status."""
171
- self.current_content = ""
245
+ self.current_content = []
172
246
 
173
247
 
174
248
  class StreamingChatAgentResponse:
@@ -189,13 +263,10 @@ class StreamingChatAgentResponse:
189
263
  def _ensure_latest_response(self):
190
264
  r"""Ensure we have the latest response by consuming the generator."""
191
265
  if not self._consumed:
192
- try:
193
- for response in self._generator:
194
- self._responses.append(response)
195
- self._current_response = response
196
- self._consumed = True
197
- except StopIteration:
198
- self._consumed = True
266
+ for response in self._generator:
267
+ self._responses.append(response)
268
+ self._current_response = response
269
+ self._consumed = True
199
270
 
200
271
  @property
201
272
  def msgs(self) -> List[BaseMessage]:
@@ -233,17 +304,14 @@ class StreamingChatAgentResponse:
233
304
  r"""Make this object iterable."""
234
305
  if self._consumed:
235
306
  # If already consumed, iterate over stored responses
236
- return iter(self._responses)
307
+ yield from self._responses
237
308
  else:
238
309
  # If not consumed, consume and yield
239
- try:
240
- for response in self._generator:
241
- self._responses.append(response)
242
- self._current_response = response
243
- yield response
244
- self._consumed = True
245
- except StopIteration:
246
- 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
247
315
 
248
316
  def __getattr__(self, name):
249
317
  r"""Forward any other attribute access to the latest response."""
@@ -274,13 +342,10 @@ class AsyncStreamingChatAgentResponse:
274
342
  async def _ensure_latest_response(self):
275
343
  r"""Ensure the latest response by consuming the async generator."""
276
344
  if not self._consumed:
277
- try:
278
- async for response in self._async_generator:
279
- self._responses.append(response)
280
- self._current_response = response
281
- self._consumed = True
282
- except StopAsyncIteration:
283
- 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
284
349
 
285
350
  async def _get_final_response(self) -> ChatAgentResponse:
286
351
  r"""Get the final response after consuming the entire stream."""
@@ -306,14 +371,11 @@ class AsyncStreamingChatAgentResponse:
306
371
  else:
307
372
  # If not consumed, consume and yield
308
373
  async def _consume_and_yield():
309
- try:
310
- async for response in self._async_generator:
311
- self._responses.append(response)
312
- self._current_response = response
313
- yield response
314
- self._consumed = True
315
- except StopAsyncIteration:
316
- 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
317
379
 
318
380
  return _consume_and_yield()
319
381
 
@@ -381,14 +443,28 @@ class ChatAgent(BaseAgent):
381
443
  for individual tool execution. If None, wait indefinitely.
382
444
  mask_tool_output (Optional[bool]): Whether to return a sanitized
383
445
  placeholder instead of the raw tool output. (default: :obj:`False`)
384
- pause_event (Optional[asyncio.Event]): Event to signal pause of the
385
- agent's operation. When clear, the agent will pause its execution.
386
- (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`)
387
450
  prune_tool_calls_from_memory (bool): Whether to clean tool
388
451
  call messages from memory after response generation to save token
389
452
  usage. When enabled, removes FUNCTION/TOOL role messages and
390
453
  ASSISTANT messages with tool_calls after each step.
391
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`)
392
468
  retry_attempts (int, optional): Maximum number of retry attempts for
393
469
  rate limit errors. (default: :obj:`3`)
394
470
  retry_delay (float, optional): Initial delay in seconds between
@@ -396,6 +472,10 @@ class ChatAgent(BaseAgent):
396
472
  step_timeout (Optional[float], optional): Timeout in seconds for the
397
473
  entire step operation. If None, no timeout is applied.
398
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`)
399
479
  """
400
480
 
401
481
  def __init__(
@@ -434,11 +514,15 @@ class ChatAgent(BaseAgent):
434
514
  stop_event: Optional[threading.Event] = None,
435
515
  tool_execution_timeout: Optional[float] = None,
436
516
  mask_tool_output: bool = False,
437
- pause_event: Optional[asyncio.Event] = None,
517
+ pause_event: Optional[Union[threading.Event, asyncio.Event]] = None,
438
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,
439
522
  retry_attempts: int = 3,
440
523
  retry_delay: float = 1.0,
441
524
  step_timeout: Optional[float] = None,
525
+ stream_accumulate: bool = True,
442
526
  ) -> None:
443
527
  if isinstance(model, ModelManager):
444
528
  self.model_backend = model
@@ -454,6 +538,28 @@ class ChatAgent(BaseAgent):
454
538
  # Assign unique ID
455
539
  self.agent_id = agent_id if agent_id else str(uuid.uuid4())
456
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
+
457
563
  # Set up memory
458
564
  context_creator = ScoreBasedContextCreator(
459
565
  self.model_backend.token_counter,
@@ -500,6 +606,8 @@ class ChatAgent(BaseAgent):
500
606
  convert_to_function_tool(tool) for tool in (tools or [])
501
607
  ]
502
608
  }
609
+ if self._tool_output_cache_enabled:
610
+ self._ensure_tool_cache_lookup_tool()
503
611
 
504
612
  # Register agent with toolkits that have RegisteredAgentToolkit mixin
505
613
  if toolkits_to_register_agent:
@@ -522,16 +630,22 @@ class ChatAgent(BaseAgent):
522
630
  self.tool_execution_timeout = tool_execution_timeout
523
631
  self.mask_tool_output = mask_tool_output
524
632
  self._secure_result_store: Dict[str, Any] = {}
633
+ self._secure_result_store_lock = threading.Lock()
525
634
  self.pause_event = pause_event
526
635
  self.prune_tool_calls_from_memory = prune_tool_calls_from_memory
527
636
  self.retry_attempts = max(1, retry_attempts)
528
637
  self.retry_delay = max(0.0, retry_delay)
529
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
530
642
 
531
643
  def reset(self):
532
644
  r"""Resets the :obj:`ChatAgent` to its initial state."""
533
645
  self.terminated = False
534
646
  self.init_messages()
647
+ if self._tool_output_cache_enabled:
648
+ self._tool_output_history.clear()
535
649
  for terminator in self.response_terminators:
536
650
  terminator.reset()
537
651
 
@@ -715,6 +829,20 @@ class ChatAgent(BaseAgent):
715
829
  # Ensure the new memory has the system message
716
830
  self.init_messages()
717
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
+
718
846
  def _get_full_tool_schemas(self) -> List[Dict[str, Any]]:
719
847
  r"""Returns a list of tool schemas of all tools, including internal
720
848
  and external tools.
@@ -738,6 +866,178 @@ class ChatAgent(BaseAgent):
738
866
  for tool in tools:
739
867
  self.add_tool(tool)
740
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
+
741
1041
  def add_external_tool(
742
1042
  self, tool: Union[FunctionTool, Callable, Dict[str, Any]]
743
1043
  ) -> None:
@@ -782,7 +1082,8 @@ class ChatAgent(BaseAgent):
782
1082
  message: BaseMessage,
783
1083
  role: OpenAIBackendRole,
784
1084
  timestamp: Optional[float] = None,
785
- ) -> None:
1085
+ return_records: bool = False,
1086
+ ) -> Optional[List[MemoryRecord]]:
786
1087
  r"""Updates the agent memory with a new message.
787
1088
 
788
1089
  If the single *message* exceeds the model's context window, it will
@@ -802,24 +1103,29 @@ class ChatAgent(BaseAgent):
802
1103
  timestamp (Optional[float], optional): Custom timestamp for the
803
1104
  memory record. If `None`, the current time will be used.
804
1105
  (default: :obj:`None`)
805
- (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``.
806
1113
  """
807
- import math
808
- import time
809
- import uuid as _uuid
1114
+
1115
+ written_records: List[MemoryRecord] = []
810
1116
 
811
1117
  # 1. Helper to write a record to memory
812
1118
  def _write_single_record(
813
1119
  message: BaseMessage, role: OpenAIBackendRole, timestamp: float
814
1120
  ):
815
- self.memory.write_record(
816
- MemoryRecord(
817
- message=message,
818
- role_at_backend=role,
819
- timestamp=timestamp,
820
- agent_id=self.agent_id,
821
- )
1121
+ record = MemoryRecord(
1122
+ message=message,
1123
+ role_at_backend=role,
1124
+ timestamp=timestamp,
1125
+ agent_id=self.agent_id,
822
1126
  )
1127
+ written_records.append(record)
1128
+ self.memory.write_record(record)
823
1129
 
824
1130
  base_ts = (
825
1131
  timestamp
@@ -834,26 +1140,30 @@ class ChatAgent(BaseAgent):
834
1140
  token_limit = context_creator.token_limit
835
1141
  except AttributeError:
836
1142
  _write_single_record(message, role, base_ts)
837
- return
1143
+ return written_records if return_records else None
838
1144
 
839
1145
  # 3. Check if slicing is necessary
840
1146
  try:
841
1147
  current_tokens = token_counter.count_tokens_from_messages(
842
1148
  [message.to_openai_message(role)]
843
1149
  )
844
- _, 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
+
845
1155
  remaining_budget = max(0, token_limit - ctx_tokens)
846
1156
 
847
1157
  if current_tokens <= remaining_budget:
848
1158
  _write_single_record(message, role, base_ts)
849
- return
1159
+ return written_records if return_records else None
850
1160
  except Exception as e:
851
1161
  logger.warning(
852
1162
  f"Token calculation failed before chunking, "
853
1163
  f"writing message as-is. Error: {e}"
854
1164
  )
855
1165
  _write_single_record(message, role, base_ts)
856
- return
1166
+ return written_records if return_records else None
857
1167
 
858
1168
  # 4. Perform slicing
859
1169
  logger.warning(
@@ -874,18 +1184,18 @@ class ChatAgent(BaseAgent):
874
1184
 
875
1185
  if not text_to_chunk or not text_to_chunk.strip():
876
1186
  _write_single_record(message, role, base_ts)
877
- return
1187
+ return written_records if return_records else None
878
1188
  # Encode the entire text to get a list of all token IDs
879
1189
  try:
880
1190
  all_token_ids = token_counter.encode(text_to_chunk)
881
1191
  except Exception as e:
882
1192
  logger.error(f"Failed to encode text for chunking: {e}")
883
1193
  _write_single_record(message, role, base_ts) # Fallback
884
- return
1194
+ return written_records if return_records else None
885
1195
 
886
1196
  if not all_token_ids:
887
1197
  _write_single_record(message, role, base_ts) # Nothing to chunk
888
- return
1198
+ return written_records if return_records else None
889
1199
 
890
1200
  # 1. Base chunk size: one-tenth of the smaller of (a) total token
891
1201
  # limit and (b) current remaining budget. This prevents us from
@@ -910,7 +1220,7 @@ class ChatAgent(BaseAgent):
910
1220
 
911
1221
  # 4. Calculate how many chunks we will need with this body size.
912
1222
  num_chunks = math.ceil(len(all_token_ids) / chunk_body_limit)
913
- group_id = str(_uuid.uuid4())
1223
+ group_id = str(uuid.uuid4())
914
1224
 
915
1225
  for i in range(num_chunks):
916
1226
  start_idx = i * chunk_body_limit
@@ -951,6 +1261,8 @@ class ChatAgent(BaseAgent):
951
1261
  # Increment timestamp slightly to maintain order
952
1262
  _write_single_record(new_msg, role, base_ts + i * 1e-6)
953
1263
 
1264
+ return written_records if return_records else None
1265
+
954
1266
  def load_memory(self, memory: AgentMemory) -> None:
955
1267
  r"""Load the provided memory into the agent.
956
1268
 
@@ -1028,6 +1340,242 @@ class ChatAgent(BaseAgent):
1028
1340
  json_store.save(to_save)
1029
1341
  logger.info(f"Memory saved to {path}")
1030
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
+
1031
1579
  def clear_memory(self) -> None:
1032
1580
  r"""Clear the agent's memory and reset to initial state.
1033
1581
 
@@ -1035,6 +1583,9 @@ class ChatAgent(BaseAgent):
1035
1583
  None
1036
1584
  """
1037
1585
  self.memory.clear()
1586
+ if self._tool_output_cache_enabled:
1587
+ self._tool_output_history.clear()
1588
+
1038
1589
  if self.system_message is not None:
1039
1590
  self.update_memory(self.system_message, OpenAIBackendRole.SYSTEM)
1040
1591
 
@@ -1070,8 +1621,6 @@ class ChatAgent(BaseAgent):
1070
1621
  r"""Initializes the stored messages list with the current system
1071
1622
  message.
1072
1623
  """
1073
- import time
1074
-
1075
1624
  self.memory.clear()
1076
1625
  # avoid UserWarning: The `ChatHistoryMemory` is empty.
1077
1626
  if self.system_message is not None:
@@ -1084,6 +1633,17 @@ class ChatAgent(BaseAgent):
1084
1633
  )
1085
1634
  )
1086
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
+
1087
1647
  def record_message(self, message: BaseMessage) -> None:
1088
1648
  r"""Records the externally provided message into the agent memory as if
1089
1649
  it were an answer of the :obj:`ChatAgent` from the backend. Currently,
@@ -1145,7 +1705,7 @@ class ChatAgent(BaseAgent):
1145
1705
 
1146
1706
  # Create a prompt based on the schema
1147
1707
  format_instruction = (
1148
- "\n\nPlease respond in the following JSON format:\n" "{\n"
1708
+ "\n\nPlease respond in the following JSON format:\n{\n"
1149
1709
  )
1150
1710
 
1151
1711
  properties = schema.get("properties", {})
@@ -1232,6 +1792,33 @@ class ChatAgent(BaseAgent):
1232
1792
  # and True to indicate we used prompt formatting
1233
1793
  return modified_message, None, True
1234
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
+
1235
1822
  def _apply_prompt_based_parsing(
1236
1823
  self,
1237
1824
  response: ModelResponse,
@@ -1248,7 +1835,6 @@ class ChatAgent(BaseAgent):
1248
1835
  try:
1249
1836
  # Try to extract JSON from the response content
1250
1837
  import json
1251
- import re
1252
1838
 
1253
1839
  from pydantic import ValidationError
1254
1840
 
@@ -1287,8 +1873,7 @@ class ChatAgent(BaseAgent):
1287
1873
 
1288
1874
  if not message.parsed:
1289
1875
  logger.warning(
1290
- f"Failed to parse JSON from response: "
1291
- f"{content}"
1876
+ f"Failed to parse JSON from response: {content}"
1292
1877
  )
1293
1878
 
1294
1879
  except Exception as e:
@@ -1425,6 +2010,10 @@ class ChatAgent(BaseAgent):
1425
2010
  except ImportError:
1426
2011
  pass # Langfuse not available
1427
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
+
1428
2017
  # Handle response format compatibility with non-strict tools
1429
2018
  original_response_format = response_format
1430
2019
  input_message, response_format, used_prompt_formatting = (
@@ -1456,8 +2045,13 @@ class ChatAgent(BaseAgent):
1456
2045
 
1457
2046
  while True:
1458
2047
  if self.pause_event is not None and not self.pause_event.is_set():
1459
- while not self.pause_event.is_set():
1460
- 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)
1461
2055
 
1462
2056
  try:
1463
2057
  openai_messages, num_tokens = self.memory.get_context()
@@ -1472,7 +2066,9 @@ class ChatAgent(BaseAgent):
1472
2066
  num_tokens=num_tokens,
1473
2067
  current_iteration=iteration_count,
1474
2068
  response_format=response_format,
1475
- tool_schemas=self._get_full_tool_schemas(),
2069
+ tool_schemas=[]
2070
+ if disable_tools
2071
+ else self._get_full_tool_schemas(),
1476
2072
  prev_num_openai_messages=prev_num_openai_messages,
1477
2073
  )
1478
2074
  prev_num_openai_messages = len(openai_messages)
@@ -1487,7 +2083,7 @@ class ChatAgent(BaseAgent):
1487
2083
  if self.stop_event and self.stop_event.is_set():
1488
2084
  # Use the _step_terminate to terminate the agent with reason
1489
2085
  logger.info(
1490
- f"Termination triggered at iteration " f"{iteration_count}"
2086
+ f"Termination triggered at iteration {iteration_count}"
1491
2087
  )
1492
2088
  return self._step_terminate(
1493
2089
  accumulated_context_tokens,
@@ -1510,8 +2106,11 @@ class ChatAgent(BaseAgent):
1510
2106
  self.pause_event is not None
1511
2107
  and not self.pause_event.is_set()
1512
2108
  ):
1513
- while not self.pause_event.is_set():
1514
- 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)
1515
2114
  result = self._execute_tool(tool_call_request)
1516
2115
  tool_call_records.append(result)
1517
2116
 
@@ -1637,6 +2236,10 @@ class ChatAgent(BaseAgent):
1637
2236
  except ImportError:
1638
2237
  pass # Langfuse not available
1639
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
+
1640
2243
  # Handle response format compatibility with non-strict tools
1641
2244
  original_response_format = response_format
1642
2245
  input_message, response_format, used_prompt_formatting = (
@@ -1664,7 +2267,12 @@ class ChatAgent(BaseAgent):
1664
2267
  prev_num_openai_messages: int = 0
1665
2268
  while True:
1666
2269
  if self.pause_event is not None and not self.pause_event.is_set():
1667
- 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)
1668
2276
  try:
1669
2277
  openai_messages, num_tokens = self.memory.get_context()
1670
2278
  accumulated_context_tokens += num_tokens
@@ -1672,13 +2280,14 @@ class ChatAgent(BaseAgent):
1672
2280
  return self._step_terminate(
1673
2281
  e.args[1], tool_call_records, "max_tokens_exceeded"
1674
2282
  )
1675
-
1676
2283
  response = await self._aget_model_response(
1677
2284
  openai_messages,
1678
2285
  num_tokens=num_tokens,
1679
2286
  current_iteration=iteration_count,
1680
2287
  response_format=response_format,
1681
- tool_schemas=self._get_full_tool_schemas(),
2288
+ tool_schemas=[]
2289
+ if disable_tools
2290
+ else self._get_full_tool_schemas(),
1682
2291
  prev_num_openai_messages=prev_num_openai_messages,
1683
2292
  )
1684
2293
  prev_num_openai_messages = len(openai_messages)
@@ -1693,7 +2302,7 @@ class ChatAgent(BaseAgent):
1693
2302
  if self.stop_event and self.stop_event.is_set():
1694
2303
  # Use the _step_terminate to terminate the agent with reason
1695
2304
  logger.info(
1696
- f"Termination triggered at iteration " f"{iteration_count}"
2305
+ f"Termination triggered at iteration {iteration_count}"
1697
2306
  )
1698
2307
  return self._step_terminate(
1699
2308
  accumulated_context_tokens,
@@ -1716,7 +2325,13 @@ class ChatAgent(BaseAgent):
1716
2325
  self.pause_event is not None
1717
2326
  and not self.pause_event.is_set()
1718
2327
  ):
1719
- 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
+ )
1720
2335
  tool_call_record = await self._aexecute_tool(
1721
2336
  tool_call_request
1722
2337
  )
@@ -1969,11 +2584,6 @@ class ChatAgent(BaseAgent):
1969
2584
  Returns:
1970
2585
  List[OpenAIMessage]: The sanitized OpenAI messages.
1971
2586
  """
1972
- import hashlib
1973
- import os
1974
- import re
1975
- import tempfile
1976
-
1977
2587
  # Create a copy of messages for logging to avoid modifying the
1978
2588
  # original messages
1979
2589
  sanitized_messages = []
@@ -2014,7 +2624,14 @@ class ChatAgent(BaseAgent):
2014
2624
 
2015
2625
  # Save image to temp directory for viewing
2016
2626
  try:
2017
- 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
+ )
2018
2635
 
2019
2636
  temp_dir = tempfile.gettempdir()
2020
2637
  img_path = os.path.join(
@@ -2029,6 +2646,9 @@ class ChatAgent(BaseAgent):
2029
2646
  base64_data
2030
2647
  )
2031
2648
  )
2649
+ # Register for cleanup
2650
+ with _temp_files_lock:
2651
+ _temp_files.add(img_path)
2032
2652
 
2033
2653
  # Create a file:// URL that can be
2034
2654
  # opened
@@ -2281,7 +2901,8 @@ class ChatAgent(BaseAgent):
2281
2901
  try:
2282
2902
  raw_result = tool(**args)
2283
2903
  if self.mask_tool_output:
2284
- 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
2285
2906
  result = (
2286
2907
  "[The tool has been executed successfully, but the output"
2287
2908
  " from the tool is masked. You can move forward]"
@@ -2339,7 +2960,7 @@ class ChatAgent(BaseAgent):
2339
2960
  # Capture the error message to prevent framework crash
2340
2961
  error_msg = f"Error executing async tool '{func_name}': {e!s}"
2341
2962
  result = f"Tool execution failed: {error_msg}"
2342
- logging.warning(error_msg)
2963
+ logger.warning(error_msg)
2343
2964
  return self._record_tool_calling(func_name, args, result, tool_call_id)
2344
2965
 
2345
2966
  def _record_tool_calling(
@@ -2390,20 +3011,22 @@ class ChatAgent(BaseAgent):
2390
3011
  # This ensures the assistant message (tool call) always appears before
2391
3012
  # the function message (tool result) in the conversation context
2392
3013
  # Use time.time_ns() for nanosecond precision to avoid collisions
2393
- import time
2394
-
2395
3014
  current_time_ns = time.time_ns()
2396
3015
  base_timestamp = current_time_ns / 1_000_000_000 # Convert to seconds
2397
3016
 
2398
3017
  self.update_memory(
2399
- 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,
2400
3022
  )
2401
3023
 
2402
3024
  # Add minimal increment to ensure function message comes after
2403
- self.update_memory(
3025
+ func_records = self.update_memory(
2404
3026
  func_msg,
2405
3027
  OpenAIBackendRole.FUNCTION,
2406
3028
  timestamp=base_timestamp + 1e-6,
3029
+ return_records=self._tool_output_cache_enabled,
2407
3030
  )
2408
3031
 
2409
3032
  # Record information about this tool call
@@ -2414,6 +3037,20 @@ class ChatAgent(BaseAgent):
2414
3037
  tool_call_id=tool_call_id,
2415
3038
  )
2416
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
+
2417
3054
  return tool_record
2418
3055
 
2419
3056
  def _stream(
@@ -2482,7 +3119,7 @@ class ChatAgent(BaseAgent):
2482
3119
  # Check termination condition
2483
3120
  if self.stop_event and self.stop_event.is_set():
2484
3121
  logger.info(
2485
- f"Termination triggered at iteration " f"{iteration_count}"
3122
+ f"Termination triggered at iteration {iteration_count}"
2486
3123
  )
2487
3124
  yield self._step_terminate(
2488
3125
  num_tokens, tool_call_records, "termination_triggered"
@@ -2665,12 +3302,6 @@ class ChatAgent(BaseAgent):
2665
3302
  stream_completed = False
2666
3303
 
2667
3304
  for chunk in stream:
2668
- # Update token usage if available
2669
- if chunk.usage:
2670
- self._update_token_usage_tracker(
2671
- step_token_usage, safe_model_dump(chunk.usage)
2672
- )
2673
-
2674
3305
  # Process chunk delta
2675
3306
  if chunk.choices and len(chunk.choices) > 0:
2676
3307
  choice = chunk.choices[0]
@@ -2703,12 +3334,6 @@ class ChatAgent(BaseAgent):
2703
3334
  # If we have complete tool calls, execute them with
2704
3335
  # sync status updates
2705
3336
  if accumulated_tool_calls:
2706
- # Record assistant message with tool calls first
2707
- self._record_assistant_tool_calls_message(
2708
- accumulated_tool_calls,
2709
- content_accumulator.get_full_content(),
2710
- )
2711
-
2712
3337
  # Execute tools synchronously with
2713
3338
  # optimized status updates
2714
3339
  for (
@@ -2741,7 +3366,49 @@ class ChatAgent(BaseAgent):
2741
3366
  )
2742
3367
 
2743
3368
  self.record_message(final_message)
2744
- 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
2745
3412
 
2746
3413
  return stream_completed, tool_calls_complete
2747
3414
 
@@ -2821,72 +3488,70 @@ class ChatAgent(BaseAgent):
2821
3488
  accumulated_tool_calls: Dict[str, Any],
2822
3489
  tool_call_records: List[ToolCallingRecord],
2823
3490
  ) -> Generator[ChatAgentResponse, None, None]:
2824
- r"""Execute multiple tools synchronously with
2825
- proper content accumulation, using threads+queue for
2826
- non-blocking status streaming."""
2827
-
2828
- def tool_worker(result_queue, tool_call_data):
2829
- try:
2830
- tool_call_record = self._execute_tool_from_stream_data(
2831
- tool_call_data
2832
- )
2833
- result_queue.put(tool_call_record)
2834
- except Exception as e:
2835
- logger.error(f"Error in threaded tool execution: {e}")
2836
- result_queue.put(None)
3491
+ r"""Execute multiple tools synchronously with proper content
3492
+ accumulation, using ThreadPoolExecutor for better timeout handling."""
2837
3493
 
2838
3494
  tool_calls_to_execute = []
2839
3495
  for _tool_call_index, tool_call_data in accumulated_tool_calls.items():
2840
3496
  if tool_call_data.get('complete', False):
2841
3497
  tool_calls_to_execute.append(tool_call_data)
2842
3498
 
2843
- # Phase 2: Execute tools in threads and yield status while waiting
2844
- for tool_call_data in tool_calls_to_execute:
2845
- function_name = tool_call_data['function']['name']
2846
- try:
2847
- args = json.loads(tool_call_data['function']['arguments'])
2848
- except json.JSONDecodeError:
2849
- args = tool_call_data['function']['arguments']
2850
- result_queue: queue.Queue[Optional[ToolCallingRecord]] = (
2851
- queue.Queue()
2852
- )
2853
- thread = threading.Thread(
2854
- target=tool_worker,
2855
- args=(result_queue, tool_call_data),
2856
- )
2857
- thread.start()
2858
-
2859
- # Log debug info instead of adding to content
2860
- logger.info(
2861
- f"Calling function: {function_name} with arguments: {args}"
2862
- )
2863
-
2864
- # wait for tool thread to finish with optional timeout
2865
- 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']
2866
3517
 
2867
- # If timeout occurred, mark as error and continue
2868
- if thread.is_alive():
2869
- # Log timeout info instead of adding to content
2870
- logger.warning(
2871
- f"Function '{function_name}' timed out after "
2872
- f"{self.tool_execution_timeout} seconds"
3518
+ # Log debug info
3519
+ logger.info(
3520
+ f"Calling function: {function_name} with arguments: {args}"
2873
3521
  )
2874
3522
 
2875
- # Detach thread (it may still finish later). Skip recording.
2876
- continue
2877
-
2878
- # Tool finished, get result
2879
- tool_call_record = result_queue.get()
2880
- if tool_call_record:
2881
- tool_call_records.append(tool_call_record)
2882
- raw_result = tool_call_record.result
2883
- 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]
2884
3537
 
2885
- # Log debug info instead of adding to content
2886
- logger.info(f"Function output: {result_str}")
2887
- else:
2888
- # Error already logged
2889
- 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
+ )
2890
3555
 
2891
3556
  # Ensure this function remains a generator (required by type signature)
2892
3557
  return
@@ -2906,10 +3571,19 @@ class ChatAgent(BaseAgent):
2906
3571
  tool = self._internal_tools[function_name]
2907
3572
  try:
2908
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
+ )
2909
3585
 
2910
- # Only record the tool response message, not the assistant
2911
- # message assistant message with tool_calls was already
2912
- # recorded in _record_assistant_tool_calls_message
3586
+ # Then create the tool response message
2913
3587
  func_msg = FunctionCallingMessage(
2914
3588
  role_name=self.role_name,
2915
3589
  role_type=self.role_type,
@@ -2920,7 +3594,23 @@ class ChatAgent(BaseAgent):
2920
3594
  tool_call_id=tool_call_id,
2921
3595
  )
2922
3596
 
2923
- 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
+ )
2924
3614
 
2925
3615
  return ToolCallingRecord(
2926
3616
  tool_name=function_name,
@@ -2934,7 +3624,7 @@ class ChatAgent(BaseAgent):
2934
3624
  f"Error executing tool '{function_name}': {e!s}"
2935
3625
  )
2936
3626
  result = {"error": error_msg}
2937
- logging.warning(error_msg)
3627
+ logger.warning(error_msg)
2938
3628
 
2939
3629
  # Record error response
2940
3630
  func_msg = FunctionCallingMessage(
@@ -3004,10 +3694,19 @@ class ChatAgent(BaseAgent):
3004
3694
  else:
3005
3695
  # Fallback: synchronous call
3006
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
+ )
3007
3708
 
3008
- # Only record the tool response message, not the assistant
3009
- # message assistant message with tool_calls was already
3010
- # recorded in _record_assistant_tool_calls_message
3709
+ # Then create the tool response message
3011
3710
  func_msg = FunctionCallingMessage(
3012
3711
  role_name=self.role_name,
3013
3712
  role_type=self.role_type,
@@ -3018,7 +3717,23 @@ class ChatAgent(BaseAgent):
3018
3717
  tool_call_id=tool_call_id,
3019
3718
  )
3020
3719
 
3021
- 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
+ )
3022
3737
 
3023
3738
  return ToolCallingRecord(
3024
3739
  tool_name=function_name,
@@ -3032,7 +3747,7 @@ class ChatAgent(BaseAgent):
3032
3747
  f"Error executing async tool '{function_name}': {e!s}"
3033
3748
  )
3034
3749
  result = {"error": error_msg}
3035
- logging.warning(error_msg)
3750
+ logger.warning(error_msg)
3036
3751
 
3037
3752
  # Record error response
3038
3753
  func_msg = FunctionCallingMessage(
@@ -3142,7 +3857,7 @@ class ChatAgent(BaseAgent):
3142
3857
  # Check termination condition
3143
3858
  if self.stop_event and self.stop_event.is_set():
3144
3859
  logger.info(
3145
- f"Termination triggered at iteration " f"{iteration_count}"
3860
+ f"Termination triggered at iteration {iteration_count}"
3146
3861
  )
3147
3862
  yield self._step_terminate(
3148
3863
  num_tokens, tool_call_records, "termination_triggered"
@@ -3369,18 +4084,13 @@ class ChatAgent(BaseAgent):
3369
4084
  response_format: Optional[Type[BaseModel]] = None,
3370
4085
  ) -> AsyncGenerator[Union[ChatAgentResponse, Tuple[bool, bool]], None]:
3371
4086
  r"""Async version of process streaming chunks with
3372
- content accumulator."""
4087
+ content accumulator.
4088
+ """
3373
4089
 
3374
4090
  tool_calls_complete = False
3375
4091
  stream_completed = False
3376
4092
 
3377
4093
  async for chunk in stream:
3378
- # Update token usage if available
3379
- if chunk.usage:
3380
- self._update_token_usage_tracker(
3381
- step_token_usage, safe_model_dump(chunk.usage)
3382
- )
3383
-
3384
4094
  # Process chunk delta
3385
4095
  if chunk.choices and len(chunk.choices) > 0:
3386
4096
  choice = chunk.choices[0]
@@ -3413,13 +4123,6 @@ class ChatAgent(BaseAgent):
3413
4123
  # If we have complete tool calls, execute them with
3414
4124
  # async status updates
3415
4125
  if accumulated_tool_calls:
3416
- # Record assistant message with
3417
- # tool calls first
3418
- self._record_assistant_tool_calls_message(
3419
- accumulated_tool_calls,
3420
- content_accumulator.get_full_content(),
3421
- )
3422
-
3423
4126
  # Execute tools asynchronously with real-time
3424
4127
  # status updates
3425
4128
  async for (
@@ -3454,7 +4157,49 @@ class ChatAgent(BaseAgent):
3454
4157
  )
3455
4158
 
3456
4159
  self.record_message(final_message)
3457
- 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
3458
4203
 
3459
4204
  # Yield the final status as a tuple
3460
4205
  yield (stream_completed, tool_calls_complete)
@@ -3547,15 +4292,18 @@ class ChatAgent(BaseAgent):
3547
4292
  ) -> ChatAgentResponse:
3548
4293
  r"""Create a streaming response using content accumulator."""
3549
4294
 
3550
- # Add new content to accumulator and get full content
4295
+ # Add new content; only build full content when needed
3551
4296
  accumulator.add_streaming_content(new_content)
3552
- 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
3553
4301
 
3554
4302
  message = BaseMessage(
3555
4303
  role_name=self.role_name,
3556
4304
  role_type=self.role_type,
3557
4305
  meta_dict={},
3558
- content=full_content,
4306
+ content=message_content,
3559
4307
  )
3560
4308
 
3561
4309
  return ChatAgentResponse(
@@ -3565,7 +4313,7 @@ class ChatAgent(BaseAgent):
3565
4313
  "id": response_id,
3566
4314
  "usage": step_token_usage.copy(),
3567
4315
  "finish_reasons": ["streaming"],
3568
- "num_tokens": self._get_token_count(full_content),
4316
+ "num_tokens": self._get_token_count(message_content),
3569
4317
  "tool_calls": tool_call_records or [],
3570
4318
  "external_tool_requests": None,
3571
4319
  "streaming": True,
@@ -3621,10 +4369,12 @@ class ChatAgent(BaseAgent):
3621
4369
  configuration.
3622
4370
  """
3623
4371
  # Create a new instance with the same configuration
3624
- # If with_memory is True, set system_message to None
3625
- # 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)
3626
4376
  # To avoid duplicated system memory.
3627
- system_message = None if with_memory else self._original_system_message
4377
+ system_message = None if with_memory else self._system_message
3628
4378
 
3629
4379
  # Clone tools and collect toolkits that need registration
3630
4380
  cloned_tools, toolkits_to_register = self._clone_tools()
@@ -3638,7 +4388,7 @@ class ChatAgent(BaseAgent):
3638
4388
  self.memory.get_context_creator(), "token_limit", None
3639
4389
  ),
3640
4390
  output_language=self._output_language,
3641
- tools=cloned_tools,
4391
+ tools=cast(List[Union[FunctionTool, Callable]], cloned_tools),
3642
4392
  toolkits_to_register_agent=toolkits_to_register,
3643
4393
  external_tools=[
3644
4394
  schema for schema in self._external_tool_schemas.values()
@@ -3652,6 +4402,10 @@ class ChatAgent(BaseAgent):
3652
4402
  tool_execution_timeout=self.tool_execution_timeout,
3653
4403
  pause_event=self.pause_event,
3654
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,
3655
4409
  )
3656
4410
 
3657
4411
  # Copy memory if requested
@@ -3666,9 +4420,7 @@ class ChatAgent(BaseAgent):
3666
4420
 
3667
4421
  def _clone_tools(
3668
4422
  self,
3669
- ) -> Tuple[
3670
- List[Union[FunctionTool, Callable]], List[RegisteredAgentToolkit]
3671
- ]:
4423
+ ) -> Tuple[List[FunctionTool], List[RegisteredAgentToolkit]]:
3672
4424
  r"""Clone tools and return toolkits that need agent registration.
3673
4425
 
3674
4426
  This method handles stateful toolkits by cloning them if they have
@@ -3720,18 +4472,62 @@ class ChatAgent(BaseAgent):
3720
4472
  # Toolkit doesn't support cloning, use original
3721
4473
  cloned_toolkits[toolkit_id] = toolkit_instance
3722
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
+
3723
4486
  # Get the method from the cloned (or original) toolkit
3724
4487
  toolkit = cloned_toolkits[toolkit_id]
3725
4488
  method_name = tool.func.__name__
4489
+
3726
4490
  if hasattr(toolkit, method_name):
3727
4491
  new_method = getattr(toolkit, method_name)
3728
- 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
+ )
3729
4515
  else:
3730
- # Fallback to original function
3731
- 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
+ )
3732
4523
  else:
3733
- # Not a toolkit method, just use the original function
3734
- 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
+ )
3735
4531
 
3736
4532
  return cloned_tools, toolkits_to_register
3737
4533