aip-agents-binary 0.5.20__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.
Files changed (280) hide show
  1. aip_agents/__init__.py +65 -0
  2. aip_agents/a2a/__init__.py +19 -0
  3. aip_agents/a2a/server/__init__.py +10 -0
  4. aip_agents/a2a/server/base_executor.py +1086 -0
  5. aip_agents/a2a/server/google_adk_executor.py +198 -0
  6. aip_agents/a2a/server/langflow_executor.py +180 -0
  7. aip_agents/a2a/server/langgraph_executor.py +270 -0
  8. aip_agents/a2a/types.py +232 -0
  9. aip_agents/agent/__init__.py +27 -0
  10. aip_agents/agent/base_agent.py +970 -0
  11. aip_agents/agent/base_langgraph_agent.py +2942 -0
  12. aip_agents/agent/google_adk_agent.py +926 -0
  13. aip_agents/agent/google_adk_constants.py +6 -0
  14. aip_agents/agent/hitl/__init__.py +24 -0
  15. aip_agents/agent/hitl/config.py +28 -0
  16. aip_agents/agent/hitl/langgraph_hitl_mixin.py +515 -0
  17. aip_agents/agent/hitl/manager.py +532 -0
  18. aip_agents/agent/hitl/models.py +18 -0
  19. aip_agents/agent/hitl/prompt/__init__.py +9 -0
  20. aip_agents/agent/hitl/prompt/base.py +42 -0
  21. aip_agents/agent/hitl/prompt/deferred.py +73 -0
  22. aip_agents/agent/hitl/registry.py +149 -0
  23. aip_agents/agent/interface.py +138 -0
  24. aip_agents/agent/interfaces.py +65 -0
  25. aip_agents/agent/langflow_agent.py +464 -0
  26. aip_agents/agent/langgraph_memory_enhancer_agent.py +433 -0
  27. aip_agents/agent/langgraph_react_agent.py +2514 -0
  28. aip_agents/agent/system_instruction_context.py +34 -0
  29. aip_agents/clients/__init__.py +10 -0
  30. aip_agents/clients/langflow/__init__.py +10 -0
  31. aip_agents/clients/langflow/client.py +477 -0
  32. aip_agents/clients/langflow/types.py +18 -0
  33. aip_agents/constants.py +23 -0
  34. aip_agents/credentials/manager.py +132 -0
  35. aip_agents/examples/__init__.py +5 -0
  36. aip_agents/examples/compare_streaming_client.py +783 -0
  37. aip_agents/examples/compare_streaming_server.py +142 -0
  38. aip_agents/examples/demo_memory_recall.py +401 -0
  39. aip_agents/examples/hello_world_a2a_google_adk_client.py +49 -0
  40. aip_agents/examples/hello_world_a2a_google_adk_client_agent.py +48 -0
  41. aip_agents/examples/hello_world_a2a_google_adk_client_streaming.py +60 -0
  42. aip_agents/examples/hello_world_a2a_google_adk_server.py +79 -0
  43. aip_agents/examples/hello_world_a2a_langchain_client.py +39 -0
  44. aip_agents/examples/hello_world_a2a_langchain_client_agent.py +39 -0
  45. aip_agents/examples/hello_world_a2a_langchain_client_lm_invoker.py +37 -0
  46. aip_agents/examples/hello_world_a2a_langchain_client_streaming.py +41 -0
  47. aip_agents/examples/hello_world_a2a_langchain_reference_client_streaming.py +60 -0
  48. aip_agents/examples/hello_world_a2a_langchain_reference_server.py +105 -0
  49. aip_agents/examples/hello_world_a2a_langchain_server.py +79 -0
  50. aip_agents/examples/hello_world_a2a_langchain_server_lm_invoker.py +78 -0
  51. aip_agents/examples/hello_world_a2a_langflow_client.py +83 -0
  52. aip_agents/examples/hello_world_a2a_langflow_server.py +82 -0
  53. aip_agents/examples/hello_world_a2a_langgraph_artifact_client.py +73 -0
  54. aip_agents/examples/hello_world_a2a_langgraph_artifact_client_streaming.py +76 -0
  55. aip_agents/examples/hello_world_a2a_langgraph_artifact_server.py +92 -0
  56. aip_agents/examples/hello_world_a2a_langgraph_client.py +54 -0
  57. aip_agents/examples/hello_world_a2a_langgraph_client_agent.py +54 -0
  58. aip_agents/examples/hello_world_a2a_langgraph_client_agent_lm_invoker.py +32 -0
  59. aip_agents/examples/hello_world_a2a_langgraph_client_streaming.py +50 -0
  60. aip_agents/examples/hello_world_a2a_langgraph_client_streaming_lm_invoker.py +44 -0
  61. aip_agents/examples/hello_world_a2a_langgraph_client_streaming_tool_streaming.py +92 -0
  62. aip_agents/examples/hello_world_a2a_langgraph_server.py +84 -0
  63. aip_agents/examples/hello_world_a2a_langgraph_server_lm_invoker.py +79 -0
  64. aip_agents/examples/hello_world_a2a_langgraph_server_tool_streaming.py +132 -0
  65. aip_agents/examples/hello_world_a2a_mcp_langgraph.py +196 -0
  66. aip_agents/examples/hello_world_a2a_three_level_agent_hierarchy_client.py +244 -0
  67. aip_agents/examples/hello_world_a2a_three_level_agent_hierarchy_server.py +251 -0
  68. aip_agents/examples/hello_world_a2a_with_metadata_langchain_client.py +57 -0
  69. aip_agents/examples/hello_world_a2a_with_metadata_langchain_server_lm_invoker.py +80 -0
  70. aip_agents/examples/hello_world_google_adk.py +41 -0
  71. aip_agents/examples/hello_world_google_adk_mcp_http.py +34 -0
  72. aip_agents/examples/hello_world_google_adk_mcp_http_stream.py +40 -0
  73. aip_agents/examples/hello_world_google_adk_mcp_sse.py +44 -0
  74. aip_agents/examples/hello_world_google_adk_mcp_sse_stream.py +48 -0
  75. aip_agents/examples/hello_world_google_adk_mcp_stdio.py +44 -0
  76. aip_agents/examples/hello_world_google_adk_mcp_stdio_stream.py +48 -0
  77. aip_agents/examples/hello_world_google_adk_stream.py +44 -0
  78. aip_agents/examples/hello_world_langchain.py +28 -0
  79. aip_agents/examples/hello_world_langchain_lm_invoker.py +15 -0
  80. aip_agents/examples/hello_world_langchain_mcp_http.py +34 -0
  81. aip_agents/examples/hello_world_langchain_mcp_http_interactive.py +130 -0
  82. aip_agents/examples/hello_world_langchain_mcp_http_stream.py +42 -0
  83. aip_agents/examples/hello_world_langchain_mcp_multi_server.py +155 -0
  84. aip_agents/examples/hello_world_langchain_mcp_sse.py +34 -0
  85. aip_agents/examples/hello_world_langchain_mcp_sse_stream.py +40 -0
  86. aip_agents/examples/hello_world_langchain_mcp_stdio.py +30 -0
  87. aip_agents/examples/hello_world_langchain_mcp_stdio_stream.py +41 -0
  88. aip_agents/examples/hello_world_langchain_stream.py +36 -0
  89. aip_agents/examples/hello_world_langchain_stream_lm_invoker.py +39 -0
  90. aip_agents/examples/hello_world_langflow_agent.py +163 -0
  91. aip_agents/examples/hello_world_langgraph.py +39 -0
  92. aip_agents/examples/hello_world_langgraph_bosa_twitter.py +41 -0
  93. aip_agents/examples/hello_world_langgraph_mcp_http.py +31 -0
  94. aip_agents/examples/hello_world_langgraph_mcp_http_stream.py +34 -0
  95. aip_agents/examples/hello_world_langgraph_mcp_sse.py +35 -0
  96. aip_agents/examples/hello_world_langgraph_mcp_sse_stream.py +50 -0
  97. aip_agents/examples/hello_world_langgraph_mcp_stdio.py +35 -0
  98. aip_agents/examples/hello_world_langgraph_mcp_stdio_stream.py +50 -0
  99. aip_agents/examples/hello_world_langgraph_stream.py +43 -0
  100. aip_agents/examples/hello_world_langgraph_stream_lm_invoker.py +37 -0
  101. aip_agents/examples/hello_world_model_switch_cli.py +210 -0
  102. aip_agents/examples/hello_world_multi_agent_adk.py +75 -0
  103. aip_agents/examples/hello_world_multi_agent_langchain.py +54 -0
  104. aip_agents/examples/hello_world_multi_agent_langgraph.py +66 -0
  105. aip_agents/examples/hello_world_multi_agent_langgraph_lm_invoker.py +69 -0
  106. aip_agents/examples/hello_world_pii_logger.py +21 -0
  107. aip_agents/examples/hello_world_sentry.py +133 -0
  108. aip_agents/examples/hello_world_step_limits.py +273 -0
  109. aip_agents/examples/hello_world_stock_a2a_server.py +103 -0
  110. aip_agents/examples/hello_world_tool_output_client.py +46 -0
  111. aip_agents/examples/hello_world_tool_output_server.py +114 -0
  112. aip_agents/examples/hitl_demo.py +724 -0
  113. aip_agents/examples/mcp_configs/configs.py +63 -0
  114. aip_agents/examples/mcp_servers/common.py +76 -0
  115. aip_agents/examples/mcp_servers/mcp_name.py +29 -0
  116. aip_agents/examples/mcp_servers/mcp_server_http.py +19 -0
  117. aip_agents/examples/mcp_servers/mcp_server_sse.py +19 -0
  118. aip_agents/examples/mcp_servers/mcp_server_stdio.py +19 -0
  119. aip_agents/examples/mcp_servers/mcp_time.py +10 -0
  120. aip_agents/examples/pii_demo_langgraph_client.py +69 -0
  121. aip_agents/examples/pii_demo_langgraph_server.py +126 -0
  122. aip_agents/examples/pii_demo_multi_agent_client.py +80 -0
  123. aip_agents/examples/pii_demo_multi_agent_server.py +247 -0
  124. aip_agents/examples/todolist_planning_a2a_langchain_client.py +70 -0
  125. aip_agents/examples/todolist_planning_a2a_langgraph_server.py +88 -0
  126. aip_agents/examples/tools/__init__.py +27 -0
  127. aip_agents/examples/tools/adk_arithmetic_tools.py +36 -0
  128. aip_agents/examples/tools/adk_weather_tool.py +60 -0
  129. aip_agents/examples/tools/data_generator_tool.py +103 -0
  130. aip_agents/examples/tools/data_visualization_tool.py +312 -0
  131. aip_agents/examples/tools/image_artifact_tool.py +136 -0
  132. aip_agents/examples/tools/langchain_arithmetic_tools.py +26 -0
  133. aip_agents/examples/tools/langchain_currency_exchange_tool.py +88 -0
  134. aip_agents/examples/tools/langchain_graph_artifact_tool.py +172 -0
  135. aip_agents/examples/tools/langchain_weather_tool.py +48 -0
  136. aip_agents/examples/tools/langgraph_streaming_tool.py +130 -0
  137. aip_agents/examples/tools/mock_retrieval_tool.py +56 -0
  138. aip_agents/examples/tools/pii_demo_tools.py +189 -0
  139. aip_agents/examples/tools/random_chart_tool.py +142 -0
  140. aip_agents/examples/tools/serper_tool.py +202 -0
  141. aip_agents/examples/tools/stock_tools.py +82 -0
  142. aip_agents/examples/tools/table_generator_tool.py +167 -0
  143. aip_agents/examples/tools/time_tool.py +82 -0
  144. aip_agents/examples/tools/weather_forecast_tool.py +38 -0
  145. aip_agents/executor/agent_executor.py +473 -0
  146. aip_agents/executor/base.py +48 -0
  147. aip_agents/mcp/__init__.py +1 -0
  148. aip_agents/mcp/client/__init__.py +14 -0
  149. aip_agents/mcp/client/base_mcp_client.py +369 -0
  150. aip_agents/mcp/client/connection_manager.py +193 -0
  151. aip_agents/mcp/client/google_adk/__init__.py +11 -0
  152. aip_agents/mcp/client/google_adk/client.py +381 -0
  153. aip_agents/mcp/client/langchain/__init__.py +11 -0
  154. aip_agents/mcp/client/langchain/client.py +265 -0
  155. aip_agents/mcp/client/persistent_session.py +359 -0
  156. aip_agents/mcp/client/session_pool.py +351 -0
  157. aip_agents/mcp/client/transports.py +215 -0
  158. aip_agents/mcp/utils/__init__.py +7 -0
  159. aip_agents/mcp/utils/config_validator.py +139 -0
  160. aip_agents/memory/__init__.py +14 -0
  161. aip_agents/memory/adapters/__init__.py +10 -0
  162. aip_agents/memory/adapters/base_adapter.py +717 -0
  163. aip_agents/memory/adapters/mem0.py +84 -0
  164. aip_agents/memory/base.py +84 -0
  165. aip_agents/memory/constants.py +49 -0
  166. aip_agents/memory/factory.py +86 -0
  167. aip_agents/memory/guidance.py +20 -0
  168. aip_agents/memory/simple_memory.py +47 -0
  169. aip_agents/middleware/__init__.py +17 -0
  170. aip_agents/middleware/base.py +88 -0
  171. aip_agents/middleware/manager.py +128 -0
  172. aip_agents/middleware/todolist.py +274 -0
  173. aip_agents/schema/__init__.py +69 -0
  174. aip_agents/schema/a2a.py +56 -0
  175. aip_agents/schema/agent.py +111 -0
  176. aip_agents/schema/hitl.py +157 -0
  177. aip_agents/schema/langgraph.py +37 -0
  178. aip_agents/schema/model_id.py +97 -0
  179. aip_agents/schema/step_limit.py +108 -0
  180. aip_agents/schema/storage.py +40 -0
  181. aip_agents/sentry/__init__.py +11 -0
  182. aip_agents/sentry/sentry.py +151 -0
  183. aip_agents/storage/__init__.py +41 -0
  184. aip_agents/storage/base.py +85 -0
  185. aip_agents/storage/clients/__init__.py +12 -0
  186. aip_agents/storage/clients/minio_client.py +318 -0
  187. aip_agents/storage/config.py +62 -0
  188. aip_agents/storage/providers/__init__.py +15 -0
  189. aip_agents/storage/providers/base.py +106 -0
  190. aip_agents/storage/providers/memory.py +114 -0
  191. aip_agents/storage/providers/object_storage.py +214 -0
  192. aip_agents/tools/__init__.py +33 -0
  193. aip_agents/tools/bosa_tools.py +105 -0
  194. aip_agents/tools/browser_use/__init__.py +82 -0
  195. aip_agents/tools/browser_use/action_parser.py +103 -0
  196. aip_agents/tools/browser_use/browser_use_tool.py +1112 -0
  197. aip_agents/tools/browser_use/llm_config.py +120 -0
  198. aip_agents/tools/browser_use/minio_storage.py +198 -0
  199. aip_agents/tools/browser_use/schemas.py +119 -0
  200. aip_agents/tools/browser_use/session.py +76 -0
  201. aip_agents/tools/browser_use/session_errors.py +132 -0
  202. aip_agents/tools/browser_use/steel_session_recording.py +317 -0
  203. aip_agents/tools/browser_use/streaming.py +813 -0
  204. aip_agents/tools/browser_use/structured_data_parser.py +257 -0
  205. aip_agents/tools/browser_use/structured_data_recovery.py +204 -0
  206. aip_agents/tools/browser_use/types.py +78 -0
  207. aip_agents/tools/code_sandbox/__init__.py +26 -0
  208. aip_agents/tools/code_sandbox/constant.py +13 -0
  209. aip_agents/tools/code_sandbox/e2b_cloud_sandbox_extended.py +257 -0
  210. aip_agents/tools/code_sandbox/e2b_sandbox_tool.py +411 -0
  211. aip_agents/tools/constants.py +165 -0
  212. aip_agents/tools/document_loader/__init__.py +44 -0
  213. aip_agents/tools/document_loader/base_reader.py +302 -0
  214. aip_agents/tools/document_loader/docx_reader_tool.py +68 -0
  215. aip_agents/tools/document_loader/excel_reader_tool.py +171 -0
  216. aip_agents/tools/document_loader/pdf_reader_tool.py +79 -0
  217. aip_agents/tools/document_loader/pdf_splitter.py +169 -0
  218. aip_agents/tools/gl_connector/__init__.py +5 -0
  219. aip_agents/tools/gl_connector/tool.py +351 -0
  220. aip_agents/tools/memory_search/__init__.py +22 -0
  221. aip_agents/tools/memory_search/base.py +200 -0
  222. aip_agents/tools/memory_search/mem0.py +258 -0
  223. aip_agents/tools/memory_search/schema.py +48 -0
  224. aip_agents/tools/memory_search_tool.py +26 -0
  225. aip_agents/tools/time_tool.py +117 -0
  226. aip_agents/tools/tool_config_injector.py +300 -0
  227. aip_agents/tools/web_search/__init__.py +15 -0
  228. aip_agents/tools/web_search/serper_tool.py +187 -0
  229. aip_agents/types/__init__.py +70 -0
  230. aip_agents/types/a2a_events.py +13 -0
  231. aip_agents/utils/__init__.py +79 -0
  232. aip_agents/utils/a2a_connector.py +1757 -0
  233. aip_agents/utils/artifact_helpers.py +502 -0
  234. aip_agents/utils/constants.py +22 -0
  235. aip_agents/utils/datetime/__init__.py +34 -0
  236. aip_agents/utils/datetime/normalization.py +231 -0
  237. aip_agents/utils/datetime/timezone.py +206 -0
  238. aip_agents/utils/env_loader.py +27 -0
  239. aip_agents/utils/event_handler_registry.py +58 -0
  240. aip_agents/utils/file_prompt_utils.py +176 -0
  241. aip_agents/utils/final_response_builder.py +211 -0
  242. aip_agents/utils/formatter_llm_client.py +231 -0
  243. aip_agents/utils/langgraph/__init__.py +19 -0
  244. aip_agents/utils/langgraph/converter.py +128 -0
  245. aip_agents/utils/langgraph/tool_managers/__init__.py +15 -0
  246. aip_agents/utils/langgraph/tool_managers/a2a_tool_manager.py +99 -0
  247. aip_agents/utils/langgraph/tool_managers/base_tool_manager.py +66 -0
  248. aip_agents/utils/langgraph/tool_managers/delegation_tool_manager.py +1071 -0
  249. aip_agents/utils/langgraph/tool_output_management.py +967 -0
  250. aip_agents/utils/logger.py +195 -0
  251. aip_agents/utils/metadata/__init__.py +27 -0
  252. aip_agents/utils/metadata/activity_metadata_helper.py +407 -0
  253. aip_agents/utils/metadata/activity_narrative/__init__.py +35 -0
  254. aip_agents/utils/metadata/activity_narrative/builder.py +817 -0
  255. aip_agents/utils/metadata/activity_narrative/constants.py +51 -0
  256. aip_agents/utils/metadata/activity_narrative/context.py +49 -0
  257. aip_agents/utils/metadata/activity_narrative/formatters.py +230 -0
  258. aip_agents/utils/metadata/activity_narrative/utils.py +35 -0
  259. aip_agents/utils/metadata/schemas/__init__.py +16 -0
  260. aip_agents/utils/metadata/schemas/activity_schema.py +29 -0
  261. aip_agents/utils/metadata/schemas/thinking_schema.py +31 -0
  262. aip_agents/utils/metadata/thinking_metadata_helper.py +38 -0
  263. aip_agents/utils/metadata_helper.py +358 -0
  264. aip_agents/utils/name_preprocessor/__init__.py +17 -0
  265. aip_agents/utils/name_preprocessor/base_name_preprocessor.py +73 -0
  266. aip_agents/utils/name_preprocessor/google_name_preprocessor.py +100 -0
  267. aip_agents/utils/name_preprocessor/name_preprocessor.py +87 -0
  268. aip_agents/utils/name_preprocessor/openai_name_preprocessor.py +48 -0
  269. aip_agents/utils/pii/__init__.py +25 -0
  270. aip_agents/utils/pii/pii_handler.py +397 -0
  271. aip_agents/utils/pii/pii_helper.py +207 -0
  272. aip_agents/utils/pii/uuid_deanonymizer_mapping.py +195 -0
  273. aip_agents/utils/reference_helper.py +273 -0
  274. aip_agents/utils/sse_chunk_transformer.py +831 -0
  275. aip_agents/utils/step_limit_manager.py +265 -0
  276. aip_agents/utils/token_usage_helper.py +156 -0
  277. aip_agents_binary-0.5.20.dist-info/METADATA +681 -0
  278. aip_agents_binary-0.5.20.dist-info/RECORD +280 -0
  279. aip_agents_binary-0.5.20.dist-info/WHEEL +5 -0
  280. aip_agents_binary-0.5.20.dist-info/top_level.txt +1 -0
@@ -0,0 +1,967 @@
1
+ """Production-ready tool output management system for LangGraph ReAct agents.
2
+
3
+ This module provides a comprehensive system for managing tool outputs in ReAct agents,
4
+ including secure reference resolution, lifecycle management, and hybrid storage patterns.
5
+
6
+ Key Features:
7
+ - Automatic and manual tool output storage with configurable lifecycle management
8
+ - Secure reference resolution with validation and sanitization
9
+ - LLM-friendly output summaries with data previews and tool context
10
+ - Production-ready error handling with specialized exceptions
11
+ - Memory management with automatic cleanup based on age and size limits
12
+
13
+ Authors:
14
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
15
+ Fachriza Adhiatma (fachriza.d.adhiatma@gdplabs.id)
16
+ """
17
+
18
+ import json
19
+ import re
20
+ import sys
21
+ import threading
22
+ from dataclasses import dataclass
23
+ from datetime import datetime, timedelta
24
+ from re import Pattern
25
+ from typing import Any
26
+
27
+ from aip_agents.storage.providers.base import BaseStorageProvider, StorageError
28
+ from aip_agents.storage.providers.memory import InMemoryStorageProvider
29
+ from aip_agents.utils.logger import get_logger
30
+
31
+ logger = get_logger(__name__)
32
+
33
+ # Constants for display formatting and reference resolution
34
+ STRING_TRUNCATION_LENGTH = 100
35
+ MAX_TOOL_ARGS_DISPLAY = 5
36
+ DATA_PREVIEW_TRUNCATION_LENGTH = 150
37
+ TOOL_OUTPUT_REFERENCE_PREFIX = "$tool_output."
38
+
39
+
40
+ class ToolReferenceError(Exception):
41
+ """Specialized exception for tool output reference resolution errors.
42
+
43
+ This exception is raised when there are issues with resolving tool output references,
44
+ such as invalid reference syntax, missing outputs, or security violations.
45
+
46
+ Attributes:
47
+ reference: The original reference string that caused the error.
48
+ call_id: The call ID that was attempted to be resolved, if applicable.
49
+ details: Additional error details for debugging.
50
+ """
51
+
52
+ def __init__(
53
+ self,
54
+ message: str,
55
+ reference: str | None = None,
56
+ call_id: str | None = None,
57
+ details: dict[str, Any] | None = None,
58
+ ):
59
+ """Initialize a ToolReferenceError.
60
+
61
+ Args:
62
+ message: Human-readable error message describing what went wrong.
63
+ reference: The original reference string that caused the error, if applicable.
64
+ call_id: The call ID that was attempted to be resolved, if applicable.
65
+ details: Additional error details for debugging, if applicable.
66
+ """
67
+ super().__init__(message)
68
+ self.reference = reference
69
+ self.call_id = call_id
70
+ self.details = details or {}
71
+
72
+ def __str__(self) -> str:
73
+ """Return a detailed string representation of the error.
74
+
75
+ Returns:
76
+ A formatted error message with context information.
77
+ """
78
+ parts = [super().__str__()]
79
+ if self.reference:
80
+ parts.append(f"Reference: {self.reference}")
81
+ if self.call_id:
82
+ parts.append(f"Call ID: {self.call_id}")
83
+ if self.details:
84
+ parts.append(f"Details: {self.details}")
85
+ return " | ".join(parts)
86
+
87
+
88
+ @dataclass
89
+ class ToolOutputConfig:
90
+ """Configuration for tool output management system.
91
+
92
+ This class defines the operational parameters for the tool output management
93
+ system, including storage limits, cleanup intervals, and lifecycle policies.
94
+
95
+ Attributes:
96
+ max_stored_outputs: Maximum number of tool outputs to store simultaneously.
97
+ When this limit is reached, oldest outputs are evicted. Defaults to 100.
98
+ max_age_minutes: Maximum age in minutes for stored outputs before they
99
+ become eligible for cleanup. Defaults to 60 minutes.
100
+ cleanup_interval: Number of tool calls between automatic cleanup operations.
101
+ Set to 0 to disable automatic cleanup. Defaults to 20.
102
+ storage_provider: Optional storage provider for persistent storage.
103
+ If None, uses in-memory storage (backward compatible). Defaults to None.
104
+ """
105
+
106
+ max_stored_outputs: int = 100
107
+ max_age_minutes: int = 60
108
+ cleanup_interval: int = 20
109
+ storage_provider: BaseStorageProvider | None = None
110
+
111
+
112
+ @dataclass
113
+ class ToolOutput:
114
+ """Container for tool outputs with optional data payload.
115
+
116
+ This class represents tool output metadata and optionally the actual data.
117
+ When used as metadata only, the data field is None and must be retrieved
118
+ from storage. When loaded with data, it contains the complete output.
119
+
120
+ Attributes:
121
+ call_id: Unique identifier for this tool call, used for reference resolution.
122
+ tool_name: Name of the tool that produced this output.
123
+ timestamp: When this output was created and stored.
124
+ size_bytes: Approximate size of the stored data in bytes for memory management.
125
+ tool_args: Input arguments that were passed to the tool for this call.
126
+ data: Optional actual output data. None when used as metadata only.
127
+ data_description: Optional human-readable description of the data content,
128
+ typically provided by the tool itself.
129
+ tags: Optional list of tags for categorization and filtering.
130
+ agent_name: Name of the agent that created this output, for multi-agent context.
131
+ """
132
+
133
+ call_id: str
134
+ tool_name: str
135
+ timestamp: datetime
136
+ size_bytes: int
137
+ tool_args: dict[str, Any]
138
+ data: Any | None = None # None when metadata only, populated when retrieved
139
+ data_description: str | None = None
140
+ tags: list[str] | None = None
141
+ agent_name: str | None = None
142
+
143
+ @property
144
+ def is_metadata_only(self) -> bool:
145
+ """Check if this instance contains only metadata without data."""
146
+ return self.data is None
147
+
148
+ def is_expired(self, max_age: timedelta) -> bool:
149
+ """Check if this output has expired based on the given maximum age.
150
+
151
+ Args:
152
+ max_age (timedelta): The maximum age allowed before expiration.
153
+
154
+ Returns:
155
+ bool: True if the output has expired, False otherwise.
156
+ """
157
+ return datetime.now() - self.timestamp > max_age
158
+
159
+ def get_data_preview(
160
+ self, max_length: int = 200, storage_provider: BaseStorageProvider | None = None, thread_id: str | None = None
161
+ ) -> str | None:
162
+ """Get a truncated string representation of the stored data.
163
+
164
+ Args:
165
+ max_length: Maximum length of the preview string.
166
+ storage_provider: Required only if data is not loaded.
167
+ thread_id: Thread ID required for proper storage key generation.
168
+
169
+ Returns:
170
+ A string representation of the data, truncated if necessary. None if data is not found.
171
+ """
172
+ # If we have data, use it directly
173
+ if self.data is not None:
174
+ data_str = str(self.data)
175
+ if len(data_str) <= max_length:
176
+ return data_str
177
+ return data_str[:max_length] + "..."
178
+
179
+ if storage_provider is None or thread_id is None:
180
+ return None
181
+
182
+ try:
183
+ # Use proper storage key format: thread_id:call_id
184
+ storage_key = f"{thread_id}:{self.call_id}"
185
+ actual_data = storage_provider.retrieve(storage_key)
186
+ data_str = str(actual_data)
187
+ if len(data_str) <= max_length:
188
+ return data_str
189
+ return data_str[:max_length] + "..."
190
+ except Exception:
191
+ return None
192
+
193
+ def with_data(self, data: Any) -> "ToolOutput":
194
+ """Create a new instance with data populated.
195
+
196
+ Returns a new ToolOutput instance with the same metadata but with
197
+ data field populated. Useful for converting metadata-only instances
198
+ to complete instances.
199
+
200
+ Args:
201
+ data (Any): The actual output data to populate.
202
+
203
+ Returns:
204
+ ToolOutput: A new instance with data populated.
205
+ """
206
+ return ToolOutput(
207
+ call_id=self.call_id,
208
+ tool_name=self.tool_name,
209
+ timestamp=self.timestamp,
210
+ size_bytes=self.size_bytes,
211
+ tool_args=self.tool_args,
212
+ data=data,
213
+ data_description=self.data_description,
214
+ tags=self.tags,
215
+ agent_name=self.agent_name,
216
+ )
217
+
218
+
219
+ @dataclass
220
+ class StoreOutputParams:
221
+ """Parameters for storing tool outputs.
222
+
223
+ Reduces the number of arguments passed to store_output method.
224
+
225
+ Attributes:
226
+ call_id: Unique identifier for this tool call.
227
+ tool_name: Name of the tool that produced the output.
228
+ data: The actual output data to store.
229
+ tool_args: Input arguments used for the tool call.
230
+ thread_id: Thread/conversation ID to organize outputs by conversation.
231
+ description: Optional human-readable description of the output.
232
+ tags: Optional list of tags for categorization.
233
+ agent_name: Name of the agent that created this output.
234
+ """
235
+
236
+ call_id: str
237
+ tool_name: str
238
+ data: Any
239
+ tool_args: dict[str, Any]
240
+ thread_id: str
241
+ description: str | None = None
242
+ tags: list[str] | None = None
243
+ agent_name: str | None = None
244
+
245
+
246
+ class ToolOutputManager:
247
+ """Production-ready tool output manager with comprehensive lifecycle management.
248
+
249
+ This class provides centralized management of tool outputs including storage,
250
+ retrieval, lifecycle management, and LLM-friendly summarization. It handles
251
+ memory management through configurable cleanup policies and provides secure
252
+ access to stored outputs.
253
+
254
+ Key Features:
255
+ - Automatic and manual storage of tool outputs with metadata
256
+ - Configurable lifecycle management with age and size-based eviction
257
+ - LLM-friendly summary generation with data previews and context
258
+ - Memory management with size tracking and cleanup
259
+ - Thread-safe operations with proper error handling and locking
260
+ - Concurrent access support for multi-agent and parallel processing scenarios
261
+
262
+ Attributes:
263
+ config: Configuration object defining operational parameters.
264
+ """
265
+
266
+ def __init__(self, config: ToolOutputConfig):
267
+ """Initialize the ToolOutputManager with the given configuration.
268
+
269
+ Args:
270
+ config: Configuration object defining storage limits and cleanup policies.
271
+ """
272
+ self.config = config
273
+ self._outputs: dict[str, dict[str, ToolOutput]] = {} # thread_id -> {call_id -> ToolOutput}
274
+ self._call_count = 0
275
+ self._total_size_bytes = 0
276
+ self._lock = threading.RLock() # Reentrant lock for nested operations
277
+
278
+ # Initialize storage provider (backward compatible)
279
+ self._storage_provider = config.storage_provider or InMemoryStorageProvider()
280
+
281
+ def _get_storage_key(self, thread_id: str, call_id: str) -> str:
282
+ """Generate storage key for thread-scoped tool output storage.
283
+
284
+ Args:
285
+ thread_id: The thread/conversation ID.
286
+ call_id: The tool call ID.
287
+
288
+ Returns:
289
+ Storage key in the format "thread_id:call_id".
290
+ """
291
+ return f"{thread_id}:{call_id}"
292
+
293
+ def _is_external_storage(self) -> bool:
294
+ """Check if the current storage provider is external (not in-memory).
295
+
296
+ Returns:
297
+ True if using external storage, False if using in-memory storage.
298
+ """
299
+ return not isinstance(self._storage_provider, InMemoryStorageProvider)
300
+
301
+ def store_output(
302
+ self,
303
+ params: StoreOutputParams,
304
+ ) -> None:
305
+ """Store a tool output with automatic cleanup and size management.
306
+
307
+ This method stores a tool output along with its metadata, automatically
308
+ handling size limits, cleanup, and memory management. If the same call_id
309
+ is used multiple times within the same thread, the previous output will be overwritten.
310
+
311
+ Thread-safe: This method uses internal locking to ensure safe concurrent access.
312
+
313
+ Args:
314
+ params: StoreOutputParams containing all necessary parameters including thread_id.
315
+
316
+ Raises:
317
+ Exception: If storage operation fails for any reason.
318
+ """
319
+ with self._lock:
320
+ try:
321
+ # Initialize thread storage if it doesn't exist
322
+ if params.thread_id not in self._outputs:
323
+ self._outputs[params.thread_id] = {}
324
+
325
+ if params.call_id in self._outputs[params.thread_id]:
326
+ logger.warning(
327
+ f"Overwriting existing tool output for call_id: {params.call_id} in thread: {params.thread_id}"
328
+ )
329
+ self._cleanup_single_output(params.call_id, params.thread_id)
330
+
331
+ # Ensure thread still exists after cleanup (in case it became empty)
332
+ if params.thread_id not in self._outputs:
333
+ self._outputs[params.thread_id] = {}
334
+
335
+ thread_outputs = self._outputs[params.thread_id]
336
+
337
+ size_bytes = self._calculate_size(params.data)
338
+
339
+ # Check if we need to evict oldest across all threads
340
+ total_outputs = sum(len(thread_outputs) for thread_outputs in self._outputs.values())
341
+ if total_outputs >= self.config.max_stored_outputs:
342
+ self._evict_oldest()
343
+
344
+ try:
345
+ # Use thread_id + call_id as storage key to avoid conflicts across threads
346
+ storage_key = self._get_storage_key(params.thread_id, params.call_id)
347
+ logger.debug(f"Storing data with key: {storage_key}")
348
+ self._storage_provider.store(storage_key, params.data)
349
+
350
+ logger.debug(
351
+ f"Stored in {type(self._storage_provider).__name__} for {params.call_id} "
352
+ f"(thread: {params.thread_id}): {size_bytes} bytes"
353
+ )
354
+
355
+ except StorageError as e:
356
+ logger.error(f"Storage error for {params.call_id} in thread {params.thread_id}: {e}")
357
+ raise
358
+ except Exception as e:
359
+ logger.error(
360
+ f"Unexpected error storing output for {params.call_id} in thread {params.thread_id}: {e}"
361
+ )
362
+ raise
363
+
364
+ # Store metadata only (actual data is in storage provider)
365
+ thread_outputs[params.call_id] = ToolOutput(
366
+ call_id=params.call_id,
367
+ tool_name=params.tool_name,
368
+ timestamp=datetime.now(),
369
+ size_bytes=size_bytes,
370
+ tool_args=params.tool_args,
371
+ data=None, # Metadata only - data stays in storage provider
372
+ data_description=params.description,
373
+ tags=params.tags,
374
+ agent_name=params.agent_name,
375
+ )
376
+
377
+ # Update size tracking and call count
378
+ self._total_size_bytes += size_bytes
379
+ self._call_count += 1
380
+
381
+ # Perform periodic cleanup if configured
382
+ if self.config.cleanup_interval > 0 and self._call_count % self.config.cleanup_interval == 0:
383
+ self._cleanup_expired()
384
+
385
+ storage_type = type(self._storage_provider).__name__
386
+ total_outputs_after = sum(len(thread_outputs) for thread_outputs in self._outputs.values())
387
+ logger.debug(
388
+ f"Stored output for {params.call_id} (thread: {params.thread_id}): {size_bytes} bytes "
389
+ f"in {storage_type}, total: {total_outputs_after} outputs ({self._total_size_bytes} bytes)"
390
+ )
391
+
392
+ except Exception as e:
393
+ logger.error(f"Failed to store tool output for {params.call_id} in thread {params.thread_id}: {e}")
394
+ raise
395
+
396
+ def get_output(self, call_id: str, thread_id: str) -> ToolOutput | None:
397
+ """Retrieve a stored tool output by its call ID and thread ID.
398
+
399
+ Thread-safe: This method uses internal locking to ensure safe concurrent access.
400
+
401
+ Args:
402
+ call_id: The unique identifier for the tool call.
403
+ thread_id: The thread/conversation ID to search in.
404
+
405
+ Returns:
406
+ The ToolOutput object with data if found, None otherwise.
407
+ """
408
+ with self._lock:
409
+ thread_outputs = self._outputs.get(thread_id, {})
410
+ if call_id not in thread_outputs:
411
+ logger.debug(f"Call ID {call_id} not found in thread {thread_id}")
412
+ return None
413
+
414
+ try:
415
+ # Get metadata
416
+ metadata = thread_outputs[call_id]
417
+
418
+ # Retrieve data using thread-specific storage key
419
+ storage_key = self._get_storage_key(thread_id, call_id)
420
+ logger.debug(f"Retrieving data with key: {storage_key}")
421
+ data = self._storage_provider.retrieve(storage_key)
422
+ return metadata.with_data(data)
423
+
424
+ except KeyError:
425
+ # Data missing from storage, clean up metadata
426
+ logger.warning(f"Data missing for {call_id} in thread {thread_id}, cleaning up metadata")
427
+ thread_outputs.pop(call_id, None)
428
+ return None
429
+ except StorageError as e:
430
+ logger.error(f"Storage error retrieving {call_id} from thread {thread_id}: {e}")
431
+ return None
432
+ except Exception as e:
433
+ logger.error(f"Unexpected error retrieving {call_id} from thread {thread_id}: {e}")
434
+ return None
435
+
436
+ def has_outputs(self, thread_id: str | None = None) -> bool:
437
+ """Check if any outputs are currently stored.
438
+
439
+ Thread-safe: This method uses internal locking to ensure safe concurrent access.
440
+
441
+ Args:
442
+ thread_id: Optional thread ID to check for outputs in a specific thread.
443
+ If None, checks across all threads.
444
+
445
+ Returns:
446
+ True if there are stored outputs, False otherwise.
447
+ """
448
+ with self._lock:
449
+ if thread_id:
450
+ return len(self._outputs.get(thread_id, {})) > 0
451
+ else:
452
+ return any(len(thread_outputs) > 0 for thread_outputs in self._outputs.values())
453
+
454
+ def generate_summary(self, thread_id: str, max_entries: int = 10) -> str:
455
+ """Generate an LLM-friendly structured summary as JSON.
456
+
457
+ This method creates a comprehensive, structured summary of stored outputs that
458
+ can be easily parsed by LLMs and other systems. The JSON format provides rich
459
+ metadata and context about each tool output.
460
+
461
+ Thread-safe: This method uses internal locking to ensure safe concurrent access.
462
+
463
+ Args:
464
+ thread_id: Thread ID to generate summary for.
465
+ max_entries: Maximum number of entries to include in the summary.
466
+ Defaults to 10.
467
+
468
+ Returns:
469
+ A JSON string containing structured data about tool outputs. Always returns
470
+ valid JSON, even when no outputs are stored (empty entries list).
471
+ """
472
+ return self._generate_json_summary(thread_id, max_entries)
473
+
474
+ def _generate_json_summary(self, thread_id: str, max_entries: int) -> str:
475
+ """Generate simplified JSON summary optimized for LLM prompts.
476
+
477
+ Args:
478
+ thread_id: Thread ID to generate summary for.
479
+ max_entries: Maximum number of entries to include in the summary.
480
+ Defaults to 10.
481
+
482
+ Returns:
483
+ A JSON string containing structured data about tool outputs. Always returns
484
+ valid JSON, even when no outputs are stored (empty entries list).
485
+ """
486
+ with self._lock:
487
+ thread_outputs = self._outputs.get(thread_id, {})
488
+
489
+ sorted_outputs = sorted(
490
+ thread_outputs.values(),
491
+ key=lambda x: x.timestamp,
492
+ reverse=True,
493
+ )[:max_entries]
494
+
495
+ outputs = []
496
+ for output in sorted_outputs:
497
+ data_preview = output.get_data_preview(
498
+ DATA_PREVIEW_TRUNCATION_LENGTH, self._storage_provider, thread_id
499
+ )
500
+
501
+ entry = {
502
+ "reference": f"{TOOL_OUTPUT_REFERENCE_PREFIX}{output.call_id}",
503
+ "tool": output.tool_name,
504
+ }
505
+ if output.data_description:
506
+ entry["description"] = output.data_description
507
+ if output.agent_name:
508
+ entry["agent"] = output.agent_name
509
+ if data_preview:
510
+ entry["data_preview"] = data_preview
511
+ outputs.append(entry)
512
+
513
+ return json.dumps(outputs, indent=2)
514
+
515
+ def _format_tool_args(self, tool_args: dict[str, Any]) -> dict[str, Any]:
516
+ """Format tool arguments with preview values for display in LLM context.
517
+
518
+ Creates a dictionary with truncated values that provides a preview of tool
519
+ arguments without overwhelming the LLM context. Limits to first 3 arguments
520
+ and recursively truncates large values within those arguments.
521
+
522
+ Args:
523
+ tool_args: Dictionary of tool arguments to format.
524
+
525
+ Returns:
526
+ Dictionary with same keys but truncated/preview values. If more than
527
+ 3 arguments provided, includes "..." key with count of remaining args.
528
+ None if tool_args is empty.
529
+
530
+ Examples:
531
+ Simple arguments (no truncation):
532
+ >>> args = {"x": 5, "y": 10, "operation": "add"}
533
+ >>> manager._format_tool_args(args)
534
+ {'x': 5, 'y': 10, 'operation': 'add'}
535
+
536
+ Arguments with large data:
537
+ >>> args = {"data": list(range(100)), "format": "json"}
538
+ >>> manager._format_tool_args(args)
539
+ {'data': [0, 1, 2, '... and 97 more items'], 'format': 'json'}
540
+
541
+ Many arguments (limited to 3):
542
+ >>> args = {f"param_{i}": f"value_{i}" for i in range(5)}
543
+ >>> manager._format_tool_args(args)
544
+ {'param_0': 'value_0', 'param_1': 'value_1', 'param_2': 'value_2', '...': 'and 2 more args'}
545
+
546
+ Empty arguments:
547
+ >>> manager._format_tool_args({})
548
+ {}
549
+
550
+ Mixed argument types:
551
+ >>> args = {"query": ("very long search query that definitely exceeds the "
552
+ ... "fifty character limit"), "limit": 100,
553
+ ... "filters": {"status": "active", "type": ["user", "admin", "guest"]}}
554
+ >>> manager._format_tool_args(args)
555
+ {'query': 'very long search query that definitely ex...', 'limit': 100,
556
+ 'filters': {'status': 'active', 'type': ['user', 'admin', 'guest']}}
557
+ """
558
+ if not tool_args:
559
+ return {}
560
+
561
+ formatted_args = {}
562
+
563
+ # Show first 3 arguments with truncation for long values
564
+ for key, value in list(tool_args.items())[:MAX_TOOL_ARGS_DISPLAY]:
565
+ formatted_args[key] = self._truncate_value(value)
566
+
567
+ # Add indicator if there are more arguments
568
+ if len(tool_args) > MAX_TOOL_ARGS_DISPLAY:
569
+ formatted_args["..."] = f"and {len(tool_args) - MAX_TOOL_ARGS_DISPLAY} more args"
570
+
571
+ return formatted_args
572
+
573
+ def _truncate_string(self, value: str) -> str:
574
+ """Truncate a string if it exceeds the maximum length.
575
+
576
+ Args:
577
+ value: The string to potentially truncate.
578
+
579
+ Returns:
580
+ The truncated string with "..." suffix if needed, otherwise the original string.
581
+ """
582
+ if len(value) > STRING_TRUNCATION_LENGTH:
583
+ return value[: STRING_TRUNCATION_LENGTH - 3] + "..."
584
+ return value
585
+
586
+ def _truncate_collection(self, collection: list | tuple, item_type: str) -> list:
587
+ """Truncate a collection (list or tuple) if it exceeds the maximum display size.
588
+
589
+ Args:
590
+ collection: The collection to truncate.
591
+ item_type: Type name for the collection items (used in truncation message).
592
+
593
+ Returns:
594
+ Truncated collection with recursive value truncation.
595
+ """
596
+ if len(collection) > MAX_TOOL_ARGS_DISPLAY:
597
+ truncated_items = [self._truncate_value(item) for item in collection[:MAX_TOOL_ARGS_DISPLAY]]
598
+ truncated_items.append(f"... and {len(collection) - MAX_TOOL_ARGS_DISPLAY} more {item_type}")
599
+ return truncated_items
600
+ return [self._truncate_value(item) for item in collection]
601
+
602
+ def _truncate_dict(self, value: dict) -> dict:
603
+ """Truncate a dictionary if it exceeds the maximum display size.
604
+
605
+ Args:
606
+ value: The dictionary to truncate.
607
+
608
+ Returns:
609
+ Truncated dictionary with recursive value truncation.
610
+ """
611
+ if len(value) > MAX_TOOL_ARGS_DISPLAY:
612
+ truncated = {}
613
+ for key in list(value.keys())[:MAX_TOOL_ARGS_DISPLAY]:
614
+ truncated[key] = self._truncate_value(value[key])
615
+ truncated["..."] = f"and {len(value) - MAX_TOOL_ARGS_DISPLAY} more keys"
616
+ return truncated
617
+ return {key: self._truncate_value(val) for key, val in value.items()}
618
+
619
+ def _truncate_value(self, value: Any) -> Any:
620
+ """Truncate a single value for preview display in LLM context.
621
+
622
+ Recursively truncates values to prevent overwhelming the LLM context with
623
+ large data structures. Uses configurable limits for strings (50 chars) and
624
+ collections (3 items/keys). Preserves data structure while limiting size.
625
+
626
+ Args:
627
+ value: The value to truncate. Can be any type (str, list, dict, etc.).
628
+
629
+ Returns:
630
+ Truncated version of the value maintaining the same type where possible.
631
+ Large strings get "..." suffix, large collections show first 3 items
632
+ with count of remaining items.
633
+
634
+ Examples:
635
+ String truncation:
636
+ >>> manager._truncate_value("This is a very long string that definitely "
637
+ ... "exceeds the fifty character limit we have set")
638
+ 'This is a very long string that definitely ex...'
639
+
640
+ >>> manager._truncate_value("short string")
641
+ 'short string'
642
+
643
+ List truncation:
644
+ >>> manager._truncate_value([1, 2, 3, 4, 5, 6, 7])
645
+ [1, 2, 3, '... and 4 more items']
646
+
647
+ >>> manager._truncate_value([1, 2])
648
+ [1, 2]
649
+
650
+ Dictionary truncation:
651
+ >>> data = {"a": 1, "b": 2, "c": 3, "d": 4, "e": 5}
652
+ >>> manager._truncate_value(data)
653
+ {'a': 1, 'b': 2, 'c': 3, '...': 'and 2 more keys'}
654
+
655
+ >>> manager._truncate_value({"x": 1, "y": 2})
656
+ {'x': 1, 'y': 2}
657
+
658
+ Nested structure truncation:
659
+ >>> nested = {"data": [1, 2, 3, 4, 5], "meta": {"type": "test", "count": 100}}
660
+ >>> manager._truncate_value(nested)
661
+ {'data': [1, 2, 3, '... and 2 more items'], 'meta': {'type': 'test', 'count': 100}}
662
+
663
+ Non-collection types:
664
+ >>> manager._truncate_value(42)
665
+ 42
666
+
667
+ >>> manager._truncate_value(None)
668
+ None
669
+ """
670
+ if isinstance(value, str):
671
+ return self._truncate_string(value)
672
+ elif isinstance(value, list | tuple):
673
+ return self._truncate_collection(value, "items")
674
+ elif isinstance(value, dict):
675
+ return self._truncate_dict(value)
676
+ else:
677
+ # For other types, convert to string and truncate if needed
678
+ str_value = str(value)
679
+ if len(str_value) > STRING_TRUNCATION_LENGTH:
680
+ return str_value[: STRING_TRUNCATION_LENGTH - 3] + "..."
681
+ return value
682
+
683
+ def _cleanup_single_output(self, call_id: str, thread_id: str) -> None:
684
+ """Clean up a single output from all storage.
685
+
686
+ Args:
687
+ call_id: The call ID to clean up
688
+ thread_id: The thread ID containing the output
689
+ """
690
+ thread_outputs = self._outputs.get(thread_id, {})
691
+ if call_id in thread_outputs:
692
+ output = thread_outputs.pop(call_id)
693
+ self._total_size_bytes -= output.size_bytes
694
+
695
+ # Clean up empty thread
696
+ if not thread_outputs:
697
+ self._outputs.pop(thread_id, None)
698
+
699
+ try:
700
+ storage_key = self._get_storage_key(thread_id, call_id)
701
+ self._storage_provider.delete(storage_key)
702
+ except Exception as e:
703
+ logger.warning(f"Failed to delete {call_id} from thread {thread_id} storage: {e}")
704
+
705
+ def _cleanup_expired(self) -> None:
706
+ """Remove expired outputs based on the configured maximum age.
707
+
708
+ This method is called periodically to remove outputs that have exceeded
709
+ the maximum age configured in the system. It helps prevent unbounded
710
+ memory growth in long-running agent sessions.
711
+
712
+ Note: This method assumes it's called within a lock context from store_output.
713
+ """
714
+ max_age = timedelta(minutes=self.config.max_age_minutes)
715
+ expired_count = 0
716
+
717
+ # Iterate through all threads and find expired outputs
718
+ for thread_id, thread_outputs in list(self._outputs.items()):
719
+ expired_call_ids = [call_id for call_id, output in thread_outputs.items() if output.is_expired(max_age)]
720
+
721
+ for call_id in expired_call_ids:
722
+ self._cleanup_single_output(call_id, thread_id)
723
+ logger.debug(f"Cleaned up expired output: {call_id} from thread {thread_id}")
724
+ expired_count += 1
725
+
726
+ if expired_count > 0:
727
+ logger.debug(f"Cleaned up {expired_count} expired outputs across all threads")
728
+
729
+ def _evict_oldest(self) -> None:
730
+ """Evict the oldest output to make room for a new one.
731
+
732
+ This method is called when the storage limit is reached and removes
733
+ the output with the earliest timestamp to maintain the size limit.
734
+
735
+ Note: This method assumes it's called within a lock context from store_output.
736
+ """
737
+ oldest_output = None
738
+ oldest_thread_id = None
739
+ oldest_call_id = None
740
+
741
+ # Find the oldest output across all threads
742
+ for thread_id, thread_outputs in self._outputs.items():
743
+ for call_id, output in thread_outputs.items():
744
+ if oldest_output is None or output.timestamp < oldest_output.timestamp:
745
+ oldest_output = output
746
+ oldest_thread_id = thread_id
747
+ oldest_call_id = call_id
748
+
749
+ if oldest_output and oldest_thread_id and oldest_call_id:
750
+ # Clean up the oldest output
751
+ self._cleanup_single_output(oldest_call_id, oldest_thread_id)
752
+ logger.debug(f"Evicted oldest output: {oldest_call_id} from thread {oldest_thread_id}")
753
+
754
+ def _calculate_size(self, data: Any) -> int:
755
+ """Calculate the approximate size of data in bytes for memory management.
756
+
757
+ This method provides a simple approximation of data size for memory
758
+ management purposes. It handles common data types efficiently without
759
+ the overhead of serialization libraries like pickle.
760
+
761
+ Args:
762
+ data: The data to calculate size for.
763
+
764
+ Returns:
765
+ Approximate size in bytes.
766
+ """
767
+ try:
768
+ if isinstance(data, str):
769
+ return len(data.encode("utf-8"))
770
+ elif isinstance(data, dict | list):
771
+ return len(json.dumps(data, default=str).encode("utf-8"))
772
+ else:
773
+ return sys.getsizeof(str(data))
774
+ except Exception:
775
+ # Fallback for any JSON serialization issues
776
+ return sys.getsizeof(str(data))
777
+
778
+ def clear_all(self) -> None:
779
+ """Clear all stored outputs from both metadata and storage.
780
+
781
+ Warning:
782
+ This operation is irreversible and will remove all stored tool outputs.
783
+ """
784
+ # Clear storage
785
+ try:
786
+ self._storage_provider.clear()
787
+ except NotImplementedError:
788
+ # Fall back to individual deletes for storage providers that don't support clear
789
+ for thread_id, thread_outputs in self._outputs.items():
790
+ for call_id in thread_outputs.keys():
791
+ try:
792
+ storage_key = self._get_storage_key(thread_id, call_id)
793
+ self._storage_provider.delete(storage_key)
794
+ except Exception as e:
795
+ logger.warning(f"Failed to delete {call_id} from thread {thread_id} during clear_all: {e}")
796
+ except Exception as e:
797
+ logger.error(f"Failed to clear storage: {e}")
798
+
799
+ # Clear metadata
800
+ self._outputs.clear()
801
+ self._total_size_bytes = 0
802
+
803
+ logger.info("Cleared all stored outputs")
804
+
805
+ def get_storage_stats(self) -> dict[str, Any]:
806
+ """Get storage statistics for monitoring and debugging.
807
+
808
+ Returns:
809
+ Dictionary containing storage statistics.
810
+ """
811
+ # Count total outputs across all threads
812
+ total_outputs = sum(len(thread_outputs) for thread_outputs in self._outputs.values())
813
+
814
+ # All outputs use the same storage type, so calculate based on storage provider
815
+ external_count = total_outputs if self._is_external_storage() else 0
816
+
817
+ return {
818
+ "total_outputs": total_outputs,
819
+ "total_size_bytes": self._total_size_bytes,
820
+ "external_storage_count": external_count,
821
+ "memory_storage_count": total_outputs - external_count,
822
+ "storage_provider_type": type(self._storage_provider).__name__,
823
+ }
824
+
825
+
826
+ class ToolReferenceResolver:
827
+ """Secure and efficient tool output reference resolution system.
828
+
829
+ This class handles the resolution of tool output references in a secure manner,
830
+ preventing injection attacks while providing simple and reliable access to
831
+ stored tool outputs. It uses a whitelist approach with regex validation to
832
+ ensure only safe references are processed.
833
+
834
+ Security Features:
835
+ - Strict regex pattern matching for reference syntax
836
+ - Whitelist-based validation to prevent injection attacks
837
+ - Fail-fast error handling with detailed error messages
838
+ - Input sanitization and validation at multiple levels
839
+
840
+ Supported Reference Syntax:
841
+ - $tool_output.<call_id> - Direct reference to a tool output by call ID
842
+
843
+ Attributes:
844
+ config: Configuration object for operational parameters.
845
+ """
846
+
847
+ def __init__(self, config: ToolOutputConfig):
848
+ """Initialize the ToolReferenceResolver with security configuration.
849
+
850
+ Args:
851
+ config: Configuration object defining operational parameters.
852
+ """
853
+ self.config = config
854
+
855
+ # Compile regex pattern for performance
856
+ self._pattern: Pattern = re.compile(r"^\$tool_output\.([a-zA-Z0-9_-]{1,50})$")
857
+
858
+ def resolve_references(
859
+ self,
860
+ args: dict[str, Any],
861
+ manager: ToolOutputManager,
862
+ thread_id: str | None = None,
863
+ ) -> dict[str, Any]:
864
+ """Resolve all tool output references in the given arguments dictionary.
865
+
866
+ This method recursively processes a dictionary of tool arguments, finding
867
+ and resolving any tool output references. It supports nested dictionaries
868
+ and lists, providing comprehensive reference resolution.
869
+
870
+ Args:
871
+ args: Dictionary of tool arguments that may contain references.
872
+ manager: ToolOutputManager instance to resolve references against.
873
+ thread_id: Optional thread ID for context-aware resolution.
874
+
875
+ Returns:
876
+ New dictionary with all references resolved to their actual values.
877
+
878
+ Raises:
879
+ ToolReferenceError: If any reference is invalid or cannot be resolved.
880
+ """
881
+ resolved = {}
882
+
883
+ for key, value in args.items():
884
+ resolved[key] = self._resolve_value(value, manager, thread_id)
885
+
886
+ return resolved
887
+
888
+ def _resolve_value(self, value: Any, manager: ToolOutputManager, thread_id: str | None = None) -> Any:
889
+ """Resolve a single value that may be a reference, dict, list, or primitive.
890
+
891
+ Args:
892
+ value: The value to resolve.
893
+ manager: ToolOutputManager instance to resolve references against.
894
+ thread_id: Optional thread ID for context-aware resolution.
895
+
896
+ Returns:
897
+ The resolved value.
898
+ """
899
+ if isinstance(value, str) and value.startswith(TOOL_OUTPUT_REFERENCE_PREFIX):
900
+ logger.debug(f"Resolved reference {value}")
901
+ return self._resolve_single_reference(value, manager, thread_id)
902
+ elif isinstance(value, dict):
903
+ return self.resolve_references(value, manager, thread_id)
904
+ elif isinstance(value, list):
905
+ return self._resolve_list(value, manager, thread_id)
906
+ else:
907
+ return value
908
+
909
+ def _resolve_list(self, items: list[Any], manager: ToolOutputManager, thread_id: str | None = None) -> list[Any]:
910
+ """Resolve all items in a list that may contain references.
911
+
912
+ Args:
913
+ items: List of items to resolve.
914
+ manager: ToolOutputManager instance to resolve references against.
915
+ thread_id: Optional thread ID for context-aware resolution.
916
+
917
+ Returns:
918
+ List with all references resolved.
919
+ """
920
+ resolved_list = []
921
+ for item in items:
922
+ resolved_list.append(self._resolve_value(item, manager, thread_id))
923
+ return resolved_list
924
+
925
+ def _resolve_single_reference(
926
+ self,
927
+ reference: str,
928
+ manager: ToolOutputManager,
929
+ thread_id: str | None = None,
930
+ ) -> Any:
931
+ """Resolve a single tool output reference with comprehensive validation.
932
+
933
+ This method handles the resolution of individual references with full
934
+ security validation and error handling.
935
+
936
+ Args:
937
+ reference: The reference string to resolve (e.g., "$tool_output.abc123").
938
+ manager: ToolOutputManager instance to resolve against.
939
+ thread_id: Optional thread ID for context-aware resolution.
940
+
941
+ Returns:
942
+ The actual data stored for the referenced call ID.
943
+
944
+ Raises:
945
+ ToolReferenceError: If the reference is invalid or cannot be resolved.
946
+ """
947
+ # Validate reference format with regex
948
+ match = self._pattern.match(reference)
949
+ if not match:
950
+ raise ToolReferenceError(
951
+ f"Invalid reference format: {reference}. Expected: $tool_output.<call_id>",
952
+ reference=reference,
953
+ )
954
+
955
+ call_id = match.group(1)
956
+
957
+ # Retrieve the stored output with thread context
958
+ stored_output = manager.get_output(call_id, thread_id)
959
+ if not stored_output:
960
+ context_msg = f" in thread {thread_id}" if thread_id else ""
961
+ raise ToolReferenceError(
962
+ f"Tool output not found for call ID: {call_id}{context_msg}",
963
+ reference=reference,
964
+ call_id=call_id,
965
+ )
966
+
967
+ return stored_output.data