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.
- camel/__init__.py +1 -1
- camel/agents/chat_agent.py +1148 -298
- camel/agents/mcp_agent.py +30 -27
- camel/configs/__init__.py +9 -0
- camel/configs/amd_config.py +70 -0
- camel/configs/cometapi_config.py +104 -0
- camel/configs/nebius_config.py +103 -0
- camel/data_collectors/alpaca_collector.py +15 -6
- camel/environments/tic_tac_toe.py +1 -1
- camel/interpreters/__init__.py +2 -0
- camel/interpreters/docker/Dockerfile +3 -12
- camel/interpreters/microsandbox_interpreter.py +395 -0
- camel/loaders/__init__.py +11 -2
- camel/loaders/chunkr_reader.py +9 -0
- camel/memories/__init__.py +2 -1
- camel/memories/agent_memories.py +3 -1
- camel/memories/blocks/chat_history_block.py +21 -3
- camel/memories/records.py +88 -8
- camel/messages/base.py +127 -34
- camel/models/__init__.py +6 -0
- camel/models/amd_model.py +101 -0
- camel/models/azure_openai_model.py +0 -6
- camel/models/base_model.py +30 -0
- camel/models/cometapi_model.py +83 -0
- camel/models/model_factory.py +6 -0
- camel/models/nebius_model.py +83 -0
- camel/models/ollama_model.py +3 -3
- camel/models/openai_compatible_model.py +0 -6
- camel/models/openai_model.py +0 -6
- camel/models/zhipuai_model.py +61 -2
- camel/parsers/__init__.py +18 -0
- camel/parsers/mcp_tool_call_parser.py +176 -0
- camel/retrievers/auto_retriever.py +1 -0
- camel/runtimes/daytona_runtime.py +11 -12
- camel/societies/workforce/prompts.py +131 -50
- camel/societies/workforce/single_agent_worker.py +434 -49
- camel/societies/workforce/structured_output_handler.py +30 -18
- camel/societies/workforce/task_channel.py +163 -27
- camel/societies/workforce/utils.py +105 -12
- camel/societies/workforce/workforce.py +1357 -314
- camel/societies/workforce/workforce_logger.py +24 -5
- camel/storages/key_value_storages/json.py +15 -2
- camel/storages/object_storages/google_cloud.py +1 -1
- camel/storages/vectordb_storages/oceanbase.py +10 -11
- camel/storages/vectordb_storages/tidb.py +8 -6
- camel/tasks/task.py +4 -3
- camel/toolkits/__init__.py +18 -5
- camel/toolkits/aci_toolkit.py +45 -0
- camel/toolkits/code_execution.py +28 -1
- camel/toolkits/context_summarizer_toolkit.py +684 -0
- camel/toolkits/dingtalk.py +1135 -0
- camel/toolkits/edgeone_pages_mcp_toolkit.py +11 -31
- camel/toolkits/{file_write_toolkit.py → file_toolkit.py} +194 -34
- camel/toolkits/function_tool.py +6 -1
- camel/toolkits/github_toolkit.py +104 -17
- camel/toolkits/google_drive_mcp_toolkit.py +12 -31
- camel/toolkits/hybrid_browser_toolkit/config_loader.py +12 -0
- camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit.py +79 -2
- camel/toolkits/hybrid_browser_toolkit/hybrid_browser_toolkit_ts.py +95 -59
- camel/toolkits/hybrid_browser_toolkit/installer.py +203 -0
- camel/toolkits/hybrid_browser_toolkit/ts/package-lock.json +5 -612
- camel/toolkits/hybrid_browser_toolkit/ts/package.json +0 -1
- camel/toolkits/hybrid_browser_toolkit/ts/src/browser-session.ts +619 -95
- camel/toolkits/hybrid_browser_toolkit/ts/src/config-loader.ts +7 -2
- camel/toolkits/hybrid_browser_toolkit/ts/src/hybrid-browser-toolkit.ts +115 -219
- camel/toolkits/hybrid_browser_toolkit/ts/src/parent-child-filter.ts +226 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/snapshot-parser.ts +219 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/som-screenshot-injected.ts +543 -0
- camel/toolkits/hybrid_browser_toolkit/ts/src/types.ts +1 -0
- camel/toolkits/hybrid_browser_toolkit/ts/websocket-server.js +39 -6
- camel/toolkits/hybrid_browser_toolkit/ws_wrapper.py +412 -133
- camel/toolkits/hybrid_browser_toolkit_py/hybrid_browser_toolkit.py +9 -5
- camel/toolkits/{openai_image_toolkit.py → image_generation_toolkit.py} +98 -31
- camel/toolkits/markitdown_toolkit.py +27 -1
- camel/toolkits/math_toolkit.py +64 -10
- camel/toolkits/mcp_toolkit.py +348 -348
- camel/toolkits/message_integration.py +3 -0
- camel/toolkits/minimax_mcp_toolkit.py +195 -0
- camel/toolkits/note_taking_toolkit.py +18 -8
- camel/toolkits/notion_mcp_toolkit.py +16 -26
- camel/toolkits/origene_mcp_toolkit.py +8 -49
- camel/toolkits/playwright_mcp_toolkit.py +12 -31
- camel/toolkits/resend_toolkit.py +168 -0
- camel/toolkits/search_toolkit.py +13 -2
- camel/toolkits/slack_toolkit.py +50 -1
- camel/toolkits/terminal_toolkit/__init__.py +18 -0
- camel/toolkits/terminal_toolkit/terminal_toolkit.py +924 -0
- camel/toolkits/terminal_toolkit/utils.py +532 -0
- camel/toolkits/vertex_ai_veo_toolkit.py +590 -0
- camel/toolkits/video_analysis_toolkit.py +17 -11
- camel/toolkits/wechat_official_toolkit.py +483 -0
- camel/types/enums.py +155 -1
- camel/types/unified_model_type.py +10 -0
- camel/utils/commons.py +17 -0
- camel/utils/context_utils.py +804 -0
- camel/utils/mcp.py +136 -2
- camel/utils/token_counting.py +25 -17
- {camel_ai-0.2.75a5.dist-info → camel_ai-0.2.76.dist-info}/METADATA +158 -67
- {camel_ai-0.2.75a5.dist-info → camel_ai-0.2.76.dist-info}/RECORD +101 -80
- camel/loaders/pandas_reader.py +0 -368
- camel/toolkits/terminal_toolkit.py +0 -1788
- {camel_ai-0.2.75a5.dist-info → camel_ai-0.2.76.dist-info}/WHEEL +0 -0
- {camel_ai-0.2.75a5.dist-info → camel_ai-0.2.76.dist-info}/licenses/LICENSE +0 -0
camel/agents/chat_agent.py
CHANGED
|
@@ -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
|
|
19
|
-
import
|
|
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 =
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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
|
-
|
|
307
|
+
yield from self._responses
|
|
234
308
|
else:
|
|
235
309
|
# If not consumed, consume and yield
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
|
382
|
-
agent's operation. When clear, the agent will
|
|
383
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
792
|
-
|
|
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
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
1417
|
-
|
|
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=
|
|
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
|
|
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
|
-
|
|
1471
|
-
|
|
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
|
-
|
|
1563
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
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:
|
|
1818
|
-
f"
|
|
2488
|
+
f"Unable to process messages: "
|
|
2489
|
+
f"{str(last_error) if last_error else 'Unknown error'}"
|
|
1819
2490
|
)
|
|
1820
2491
|
|
|
1821
|
-
|
|
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"
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
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:
|
|
1885
|
-
f"
|
|
2553
|
+
f"Unable to process messages: "
|
|
2554
|
+
f"{str(last_error) if last_error else 'Unknown error'}"
|
|
1886
2555
|
)
|
|
1887
2556
|
|
|
1888
|
-
|
|
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"
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
|
|
2802
|
-
|
|
2803
|
-
|
|
2804
|
-
|
|
2805
|
-
|
|
2806
|
-
|
|
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
|
-
|
|
2814
|
-
|
|
2815
|
-
|
|
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
|
-
#
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
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
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
4295
|
+
# Add new content; only build full content when needed
|
|
3497
4296
|
accumulator.add_streaming_content(new_content)
|
|
3498
|
-
|
|
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=
|
|
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(
|
|
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
|
-
#
|
|
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.
|
|
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
|
-
|
|
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(
|
|
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,
|
|
3680
|
-
cloned_tools.append(
|
|
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
|
|