aip-agents-binary 0.0.0__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 (447) hide show
  1. aip_agents/__init__.py +66 -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 +1125 -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/a2ui/__init__.py +26 -0
  10. aip_agents/a2ui/catalog_provider.py +109 -0
  11. aip_agents/a2ui/examples/0.8/booking_form.json +1 -0
  12. aip_agents/a2ui/examples/0.8/confirmation.json +1 -0
  13. aip_agents/a2ui/examples/0.8/single_column_list.json +1 -0
  14. aip_agents/a2ui/examples/0.9/booking_form.json +1 -0
  15. aip_agents/a2ui/examples/0.9/confirmation.json +1 -0
  16. aip_agents/a2ui/examples/0.9/single_column_list.json +1 -0
  17. aip_agents/a2ui/middleware.py +754 -0
  18. aip_agents/a2ui/models.py +153 -0
  19. aip_agents/a2ui/public_url.py +138 -0
  20. aip_agents/a2ui/stream_filter.py +67 -0
  21. aip_agents/agent/__init__.py +72 -0
  22. aip_agents/agent/base_agent.py +1437 -0
  23. aip_agents/agent/base_langgraph_agent.py +4048 -0
  24. aip_agents/agent/google_adk_agent.py +932 -0
  25. aip_agents/agent/google_adk_constants.py +11 -0
  26. aip_agents/agent/graph_runtime.py +185 -0
  27. aip_agents/agent/hitl/__init__.py +24 -0
  28. aip_agents/agent/hitl/config.py +28 -0
  29. aip_agents/agent/hitl/langgraph_hitl_mixin.py +515 -0
  30. aip_agents/agent/hitl/manager.py +532 -0
  31. aip_agents/agent/hitl/models.py +18 -0
  32. aip_agents/agent/hitl/prompt/__init__.py +9 -0
  33. aip_agents/agent/hitl/prompt/base.py +42 -0
  34. aip_agents/agent/hitl/prompt/deferred.py +73 -0
  35. aip_agents/agent/hitl/registry.py +149 -0
  36. aip_agents/agent/interface.py +138 -0
  37. aip_agents/agent/interfaces.py +65 -0
  38. aip_agents/agent/langflow_agent.py +464 -0
  39. aip_agents/agent/langgraph_react_agent.py +3741 -0
  40. aip_agents/agent/model_invocation_adapter.py +346 -0
  41. aip_agents/agent/system_instruction_context.py +34 -0
  42. aip_agents/agent/tool_execution_engine.py +587 -0
  43. aip_agents/audio_interface/__init__.py +30 -0
  44. aip_agents/audio_interface/audio_agent_adapter.py +254 -0
  45. aip_agents/audio_interface/config.py +146 -0
  46. aip_agents/audio_interface/errors.py +15 -0
  47. aip_agents/audio_interface/livekit_audio_session.py +504 -0
  48. aip_agents/audio_interface/livekit_realtime_audio_session.py +142 -0
  49. aip_agents/audio_interface/session_factory.py +40 -0
  50. aip_agents/clients/__init__.py +10 -0
  51. aip_agents/clients/langflow/__init__.py +10 -0
  52. aip_agents/clients/langflow/client.py +477 -0
  53. aip_agents/clients/langflow/types.py +18 -0
  54. aip_agents/common/__init__.py +5 -0
  55. aip_agents/common/callable_introspection.py +17 -0
  56. aip_agents/constants.py +23 -0
  57. aip_agents/context/__init__.py +5 -0
  58. aip_agents/context/budget.py +237 -0
  59. aip_agents/context/compaction.py +339 -0
  60. aip_agents/context/metadata.py +169 -0
  61. aip_agents/context/offload.py +449 -0
  62. aip_agents/context/preparation.py +196 -0
  63. aip_agents/context/prompt.py +61 -0
  64. aip_agents/context/runtime.py +149 -0
  65. aip_agents/context/session_recall.py +69 -0
  66. aip_agents/context/state.py +48 -0
  67. aip_agents/context/utils.py +44 -0
  68. aip_agents/credentials/manager.py +132 -0
  69. aip_agents/examples/__init__.py +5 -0
  70. aip_agents/examples/audio_livekit_session.py +116 -0
  71. aip_agents/examples/compare_streaming_client.py +783 -0
  72. aip_agents/examples/compare_streaming_server.py +141 -0
  73. aip_agents/examples/gl_connectors_smoke.py +148 -0
  74. aip_agents/examples/hello_world_a2a_google_adk_client.py +49 -0
  75. aip_agents/examples/hello_world_a2a_google_adk_client_agent.py +48 -0
  76. aip_agents/examples/hello_world_a2a_google_adk_client_streaming.py +60 -0
  77. aip_agents/examples/hello_world_a2a_google_adk_server.py +78 -0
  78. aip_agents/examples/hello_world_a2a_langchain_client.py +39 -0
  79. aip_agents/examples/hello_world_a2a_langchain_client_agent.py +39 -0
  80. aip_agents/examples/hello_world_a2a_langchain_client_lm_invoker.py +37 -0
  81. aip_agents/examples/hello_world_a2a_langchain_client_streaming.py +41 -0
  82. aip_agents/examples/hello_world_a2a_langchain_reference_client_streaming.py +60 -0
  83. aip_agents/examples/hello_world_a2a_langchain_reference_server.py +104 -0
  84. aip_agents/examples/hello_world_a2a_langchain_server.py +78 -0
  85. aip_agents/examples/hello_world_a2a_langchain_server_lm_invoker.py +77 -0
  86. aip_agents/examples/hello_world_a2a_langflow_client.py +83 -0
  87. aip_agents/examples/hello_world_a2a_langflow_server.py +80 -0
  88. aip_agents/examples/hello_world_a2a_langgraph_artifact_client.py +73 -0
  89. aip_agents/examples/hello_world_a2a_langgraph_artifact_client_streaming.py +76 -0
  90. aip_agents/examples/hello_world_a2a_langgraph_artifact_server.py +91 -0
  91. aip_agents/examples/hello_world_a2a_langgraph_client.py +54 -0
  92. aip_agents/examples/hello_world_a2a_langgraph_client_agent.py +54 -0
  93. aip_agents/examples/hello_world_a2a_langgraph_client_agent_lm_invoker.py +32 -0
  94. aip_agents/examples/hello_world_a2a_langgraph_client_streaming.py +50 -0
  95. aip_agents/examples/hello_world_a2a_langgraph_client_streaming_lm_invoker.py +44 -0
  96. aip_agents/examples/hello_world_a2a_langgraph_client_streaming_tool_streaming.py +92 -0
  97. aip_agents/examples/hello_world_a2a_langgraph_server.py +83 -0
  98. aip_agents/examples/hello_world_a2a_langgraph_server_lm_invoker.py +78 -0
  99. aip_agents/examples/hello_world_a2a_langgraph_server_tool_streaming.py +131 -0
  100. aip_agents/examples/hello_world_a2a_mcp_langgraph.py +196 -0
  101. aip_agents/examples/hello_world_a2a_three_level_agent_hierarchy_client.py +244 -0
  102. aip_agents/examples/hello_world_a2a_three_level_agent_hierarchy_server.py +250 -0
  103. aip_agents/examples/hello_world_a2a_with_metadata_langchain_client.py +57 -0
  104. aip_agents/examples/hello_world_a2a_with_metadata_langchain_server_lm_invoker.py +79 -0
  105. aip_agents/examples/hello_world_filesystem.py +162 -0
  106. aip_agents/examples/hello_world_google_adk.py +41 -0
  107. aip_agents/examples/hello_world_google_adk_mcp_http.py +34 -0
  108. aip_agents/examples/hello_world_google_adk_mcp_http_stream.py +40 -0
  109. aip_agents/examples/hello_world_google_adk_mcp_sse.py +44 -0
  110. aip_agents/examples/hello_world_google_adk_mcp_sse_stream.py +48 -0
  111. aip_agents/examples/hello_world_google_adk_mcp_stdio.py +44 -0
  112. aip_agents/examples/hello_world_google_adk_mcp_stdio_stream.py +48 -0
  113. aip_agents/examples/hello_world_google_adk_stream.py +44 -0
  114. aip_agents/examples/hello_world_langchain.py +28 -0
  115. aip_agents/examples/hello_world_langchain_lm_invoker.py +15 -0
  116. aip_agents/examples/hello_world_langchain_mcp_http.py +34 -0
  117. aip_agents/examples/hello_world_langchain_mcp_http_interactive.py +130 -0
  118. aip_agents/examples/hello_world_langchain_mcp_http_stream.py +42 -0
  119. aip_agents/examples/hello_world_langchain_mcp_multi_server.py +155 -0
  120. aip_agents/examples/hello_world_langchain_mcp_sse.py +34 -0
  121. aip_agents/examples/hello_world_langchain_mcp_sse_stream.py +40 -0
  122. aip_agents/examples/hello_world_langchain_mcp_stdio.py +30 -0
  123. aip_agents/examples/hello_world_langchain_mcp_stdio_stream.py +41 -0
  124. aip_agents/examples/hello_world_langchain_stream.py +36 -0
  125. aip_agents/examples/hello_world_langchain_stream_lm_invoker.py +39 -0
  126. aip_agents/examples/hello_world_langflow_agent.py +163 -0
  127. aip_agents/examples/hello_world_langgraph.py +39 -0
  128. aip_agents/examples/hello_world_langgraph_gl_connector_twitter.py +44 -0
  129. aip_agents/examples/hello_world_langgraph_mcp_http.py +31 -0
  130. aip_agents/examples/hello_world_langgraph_mcp_http_stream.py +34 -0
  131. aip_agents/examples/hello_world_langgraph_mcp_sse.py +35 -0
  132. aip_agents/examples/hello_world_langgraph_mcp_sse_stream.py +50 -0
  133. aip_agents/examples/hello_world_langgraph_mcp_stdio.py +35 -0
  134. aip_agents/examples/hello_world_langgraph_mcp_stdio_stream.py +50 -0
  135. aip_agents/examples/hello_world_langgraph_skills.py +62 -0
  136. aip_agents/examples/hello_world_langgraph_stream.py +43 -0
  137. aip_agents/examples/hello_world_langgraph_stream_lm_invoker.py +37 -0
  138. aip_agents/examples/hello_world_model_switch_cli.py +210 -0
  139. aip_agents/examples/hello_world_multi_agent_adk.py +75 -0
  140. aip_agents/examples/hello_world_multi_agent_langchain.py +54 -0
  141. aip_agents/examples/hello_world_multi_agent_langgraph.py +66 -0
  142. aip_agents/examples/hello_world_multi_agent_langgraph_lm_invoker.py +69 -0
  143. aip_agents/examples/hello_world_multi_agent_tool_output_client.py +57 -0
  144. aip_agents/examples/hello_world_multi_agent_tool_output_server.py +131 -0
  145. aip_agents/examples/hello_world_pii_logger.py +21 -0
  146. aip_agents/examples/hello_world_ptc.py +49 -0
  147. aip_agents/examples/hello_world_ptc_custom_tools.py +83 -0
  148. aip_agents/examples/hello_world_sentry.py +133 -0
  149. aip_agents/examples/hello_world_step_limits.py +273 -0
  150. aip_agents/examples/hello_world_stock_a2a_server.py +100 -0
  151. aip_agents/examples/hello_world_tool_output_client.py +55 -0
  152. aip_agents/examples/hello_world_tool_output_server.py +113 -0
  153. aip_agents/examples/hitl_demo.py +724 -0
  154. aip_agents/examples/mcp_configs/configs.py +63 -0
  155. aip_agents/examples/mcp_servers/common.py +76 -0
  156. aip_agents/examples/mcp_servers/mcp_name.py +29 -0
  157. aip_agents/examples/mcp_servers/mcp_server_http.py +19 -0
  158. aip_agents/examples/mcp_servers/mcp_server_sse.py +19 -0
  159. aip_agents/examples/mcp_servers/mcp_server_stdio.py +19 -0
  160. aip_agents/examples/mcp_servers/mcp_time.py +10 -0
  161. aip_agents/examples/pii_demo_langgraph_client.py +69 -0
  162. aip_agents/examples/pii_demo_langgraph_server.py +125 -0
  163. aip_agents/examples/pii_demo_multi_agent_client.py +80 -0
  164. aip_agents/examples/pii_demo_multi_agent_server.py +246 -0
  165. aip_agents/examples/todolist_planning_a2a_langchain_client.py +70 -0
  166. aip_agents/examples/todolist_planning_a2a_langgraph_server.py +87 -0
  167. aip_agents/examples/tools/__init__.py +27 -0
  168. aip_agents/examples/tools/adk_arithmetic_tools.py +36 -0
  169. aip_agents/examples/tools/adk_weather_tool.py +60 -0
  170. aip_agents/examples/tools/data_generator_tool.py +103 -0
  171. aip_agents/examples/tools/data_visualization_tool.py +312 -0
  172. aip_agents/examples/tools/image_artifact_tool.py +136 -0
  173. aip_agents/examples/tools/langchain_arithmetic_tools.py +26 -0
  174. aip_agents/examples/tools/langchain_currency_exchange_tool.py +88 -0
  175. aip_agents/examples/tools/langchain_graph_artifact_tool.py +172 -0
  176. aip_agents/examples/tools/langchain_weather_tool.py +48 -0
  177. aip_agents/examples/tools/langgraph_streaming_tool.py +130 -0
  178. aip_agents/examples/tools/mock_retrieval_tool.py +56 -0
  179. aip_agents/examples/tools/multiply_tool.py +43 -0
  180. aip_agents/examples/tools/pii_demo_tools.py +189 -0
  181. aip_agents/examples/tools/random_chart_tool.py +142 -0
  182. aip_agents/examples/tools/serper_tool.py +202 -0
  183. aip_agents/examples/tools/stock_tools.py +82 -0
  184. aip_agents/examples/tools/table_generator_tool.py +167 -0
  185. aip_agents/examples/tools/time_tool.py +82 -0
  186. aip_agents/examples/tools/weather_forecast_tool.py +38 -0
  187. aip_agents/guardrails/__init__.py +83 -0
  188. aip_agents/guardrails/engines/__init__.py +69 -0
  189. aip_agents/guardrails/engines/base.py +90 -0
  190. aip_agents/guardrails/engines/nemo.py +101 -0
  191. aip_agents/guardrails/engines/phrase_matcher.py +113 -0
  192. aip_agents/guardrails/exceptions.py +39 -0
  193. aip_agents/guardrails/manager.py +163 -0
  194. aip_agents/guardrails/middleware.py +199 -0
  195. aip_agents/guardrails/schemas.py +63 -0
  196. aip_agents/guardrails/utils.py +45 -0
  197. aip_agents/integration/__init__.py +43 -0
  198. aip_agents/integration/agent.py +58 -0
  199. aip_agents/integration/guardrails.py +52 -0
  200. aip_agents/integration/hitl.py +71 -0
  201. aip_agents/integration/ptc.py +95 -0
  202. aip_agents/integration/skills.py +37 -0
  203. aip_agents/integration/storage.py +81 -0
  204. aip_agents/integration/version.py +103 -0
  205. aip_agents/mcp/__init__.py +1 -0
  206. aip_agents/mcp/client/__init__.py +60 -0
  207. aip_agents/mcp/client/base_mcp_client.py +377 -0
  208. aip_agents/mcp/client/connection_manager.py +231 -0
  209. aip_agents/mcp/client/errors.py +119 -0
  210. aip_agents/mcp/client/google_adk/__init__.py +45 -0
  211. aip_agents/mcp/client/google_adk/client.py +383 -0
  212. aip_agents/mcp/client/langchain/__init__.py +11 -0
  213. aip_agents/mcp/client/langchain/client.py +265 -0
  214. aip_agents/mcp/client/persistent_session.py +704 -0
  215. aip_agents/mcp/client/session_pool.py +358 -0
  216. aip_agents/mcp/client/transports.py +288 -0
  217. aip_agents/mcp/utils/__init__.py +7 -0
  218. aip_agents/mcp/utils/config_validator.py +139 -0
  219. aip_agents/memory/__init__.py +14 -0
  220. aip_agents/memory/adapters/__init__.py +10 -0
  221. aip_agents/memory/adapters/base_adapter.py +815 -0
  222. aip_agents/memory/adapters/mem0.py +286 -0
  223. aip_agents/memory/base.py +84 -0
  224. aip_agents/memory/constants.py +49 -0
  225. aip_agents/memory/factory.py +86 -0
  226. aip_agents/memory/guidance.py +20 -0
  227. aip_agents/memory/simple_memory.py +47 -0
  228. aip_agents/middleware/__init__.py +65 -0
  229. aip_agents/middleware/backends/__init__.py +72 -0
  230. aip_agents/middleware/backends/in_memory.py +578 -0
  231. aip_agents/middleware/backends/local_disk.py +1203 -0
  232. aip_agents/middleware/backends/protocol.py +353 -0
  233. aip_agents/middleware/backends/sandbox.py +1431 -0
  234. aip_agents/middleware/backends/sandbox_files.py +113 -0
  235. aip_agents/middleware/backends/utils.py +293 -0
  236. aip_agents/middleware/base.py +239 -0
  237. aip_agents/middleware/filesystem.py +690 -0
  238. aip_agents/middleware/manager.py +291 -0
  239. aip_agents/middleware/memory.py +532 -0
  240. aip_agents/middleware/pii_middleware.py +192 -0
  241. aip_agents/middleware/schema.py +19 -0
  242. aip_agents/middleware/skills.py +872 -0
  243. aip_agents/middleware/task_delegation.py +1656 -0
  244. aip_agents/middleware/todolist.py +276 -0
  245. aip_agents/middleware/tools/__init__.py +23 -0
  246. aip_agents/middleware/tools/edit_file.py +70 -0
  247. aip_agents/middleware/tools/execute.py +428 -0
  248. aip_agents/middleware/tools/grep_file.py +79 -0
  249. aip_agents/middleware/tools/ls.py +71 -0
  250. aip_agents/middleware/tools/read_file.py +239 -0
  251. aip_agents/middleware/tools/skill_load.py +193 -0
  252. aip_agents/middleware/tools/skill_resource.py +338 -0
  253. aip_agents/middleware/tools/write_file.py +114 -0
  254. aip_agents/middleware/vision.py +322 -0
  255. aip_agents/ptc/__init__.py +87 -0
  256. aip_agents/ptc/custom_tools.py +511 -0
  257. aip_agents/ptc/custom_tools_payload.py +499 -0
  258. aip_agents/ptc/custom_tools_templates/__init__.py +1 -0
  259. aip_agents/ptc/custom_tools_templates/custom_build_function.py.template +24 -0
  260. aip_agents/ptc/custom_tools_templates/custom_init.py.template +26 -0
  261. aip_agents/ptc/custom_tools_templates/custom_invoke.py.template +149 -0
  262. aip_agents/ptc/custom_tools_templates/custom_registry.py.template +112 -0
  263. aip_agents/ptc/custom_tools_templates/custom_sources_init.py.template +7 -0
  264. aip_agents/ptc/custom_tools_templates/custom_wrapper.py.template +87 -0
  265. aip_agents/ptc/doc_gen.py +122 -0
  266. aip_agents/ptc/exceptions.py +57 -0
  267. aip_agents/ptc/executor.py +627 -0
  268. aip_agents/ptc/mcp/__init__.py +45 -0
  269. aip_agents/ptc/mcp/sandbox_bridge.py +679 -0
  270. aip_agents/ptc/mcp/templates/__init__.py +1 -0
  271. aip_agents/ptc/mcp/templates/mcp_client.py.template +240 -0
  272. aip_agents/ptc/naming.py +430 -0
  273. aip_agents/ptc/payload.py +26 -0
  274. aip_agents/ptc/prompt_builder.py +674 -0
  275. aip_agents/ptc/ptc_helper.py +16 -0
  276. aip_agents/ptc/sandbox_bridge.py +297 -0
  277. aip_agents/ptc/template_utils.py +33 -0
  278. aip_agents/ptc/templates/__init__.py +1 -0
  279. aip_agents/ptc/templates/ptc_helper.py.template +134 -0
  280. aip_agents/ptc/tool_def_helpers.py +101 -0
  281. aip_agents/ptc/tool_enrichment.py +163 -0
  282. aip_agents/resilience/__init__.py +10 -0
  283. aip_agents/resilience/base.py +46 -0
  284. aip_agents/resilience/tool.py +1661 -0
  285. aip_agents/sandbox/__init__.py +62 -0
  286. aip_agents/sandbox/defaults.py +220 -0
  287. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-1-datasaur.Dockerfile +120 -0
  288. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-1.Dockerfile +100 -0
  289. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-2-datasaur.Dockerfile +143 -0
  290. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-2.Dockerfile +119 -0
  291. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-3-datasaur.Dockerfile +173 -0
  292. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-3.Dockerfile +130 -0
  293. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-4-runner-managed.Dockerfile +164 -0
  294. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-4-runner-managed.patch_js_context.py +41 -0
  295. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2-4-runner-managed.requirements.txt +29 -0
  296. aip_agents/sandbox/dockerfiles/aip-agents-sandbox-v2.Dockerfile +87 -0
  297. aip_agents/sandbox/e2b_runtime.py +27 -0
  298. aip_agents/sandbox/image_registry.py +98 -0
  299. aip_agents/sandbox/providers/__init__.py +76 -0
  300. aip_agents/sandbox/providers/base.py +392 -0
  301. aip_agents/sandbox/providers/bedrock.py +28 -0
  302. aip_agents/sandbox/providers/e2b.py +230 -0
  303. aip_agents/sandbox/providers/opensandbox.py +163 -0
  304. aip_agents/sandbox/sandbox_runtime.py +967 -0
  305. aip_agents/sandbox/template_builder.py +240 -0
  306. aip_agents/sandbox/types.py +141 -0
  307. aip_agents/sandbox/validation.py +50 -0
  308. aip_agents/schema/__init__.py +106 -0
  309. aip_agents/schema/a2a.py +58 -0
  310. aip_agents/schema/agent.py +117 -0
  311. aip_agents/schema/artifacts.py +75 -0
  312. aip_agents/schema/attachments.py +425 -0
  313. aip_agents/schema/hitl.py +157 -0
  314. aip_agents/schema/langgraph.py +47 -0
  315. aip_agents/schema/model_id.py +103 -0
  316. aip_agents/schema/model_usage.py +50 -0
  317. aip_agents/schema/runtime_metadata.py +78 -0
  318. aip_agents/schema/step_limit.py +108 -0
  319. aip_agents/schema/storage.py +40 -0
  320. aip_agents/schema/tool_payloads.py +68 -0
  321. aip_agents/sentry/__init__.py +11 -0
  322. aip_agents/sentry/sentry.py +267 -0
  323. aip_agents/skills/__init__.py +30 -0
  324. aip_agents/skills/errors.py +110 -0
  325. aip_agents/skills/installer.py +479 -0
  326. aip_agents/skills/models.py +769 -0
  327. aip_agents/skills/stager.py +224 -0
  328. aip_agents/skills/validation.py +53 -0
  329. aip_agents/storage/__init__.py +41 -0
  330. aip_agents/storage/base.py +85 -0
  331. aip_agents/storage/clients/__init__.py +12 -0
  332. aip_agents/storage/clients/minio_client.py +318 -0
  333. aip_agents/storage/config.py +62 -0
  334. aip_agents/storage/providers/__init__.py +15 -0
  335. aip_agents/storage/providers/base.py +106 -0
  336. aip_agents/storage/providers/memory.py +114 -0
  337. aip_agents/storage/providers/object_storage.py +214 -0
  338. aip_agents/tools/__init__.py +65 -0
  339. aip_agents/tools/agent_schedule_tool.py +392 -0
  340. aip_agents/tools/browser_use/__init__.py +83 -0
  341. aip_agents/tools/browser_use/action_parser.py +104 -0
  342. aip_agents/tools/browser_use/browser_use_tool.py +1118 -0
  343. aip_agents/tools/browser_use/llm_config.py +120 -0
  344. aip_agents/tools/browser_use/minio_storage.py +203 -0
  345. aip_agents/tools/browser_use/schemas.py +121 -0
  346. aip_agents/tools/browser_use/session.py +13 -0
  347. aip_agents/tools/browser_use/session_errors.py +132 -0
  348. aip_agents/tools/browser_use/steel_session_recording.py +321 -0
  349. aip_agents/tools/browser_use/streaming.py +263 -0
  350. aip_agents/tools/browser_use/structured_data_parser.py +257 -0
  351. aip_agents/tools/browser_use/structured_data_recovery.py +204 -0
  352. aip_agents/tools/browser_use/types.py +37 -0
  353. aip_agents/tools/code_sandbox/__init__.py +48 -0
  354. aip_agents/tools/code_sandbox/code_sandbox_tool.py +350 -0
  355. aip_agents/tools/code_sandbox/constant.py +13 -0
  356. aip_agents/tools/code_sandbox/e2b_cloud_sandbox_extended.py +346 -0
  357. aip_agents/tools/code_sandbox/e2b_sandbox_tool.py +65 -0
  358. aip_agents/tools/computer_use/__init__.py +75 -0
  359. aip_agents/tools/computer_use/computer_use_tool.py +910 -0
  360. aip_agents/tools/computer_use/schemas.py +78 -0
  361. aip_agents/tools/computer_use/streaming.py +198 -0
  362. aip_agents/tools/computer_use/types.py +32 -0
  363. aip_agents/tools/constants.py +177 -0
  364. aip_agents/tools/date_range_tool.py +554 -0
  365. aip_agents/tools/document_loader/__init__.py +44 -0
  366. aip_agents/tools/document_loader/base_reader.py +302 -0
  367. aip_agents/tools/document_loader/docx_reader_tool.py +68 -0
  368. aip_agents/tools/document_loader/excel_reader_tool.py +171 -0
  369. aip_agents/tools/document_loader/pdf_reader_tool.py +79 -0
  370. aip_agents/tools/document_loader/pdf_splitter.py +169 -0
  371. aip_agents/tools/execute_ptc_code.py +582 -0
  372. aip_agents/tools/gl_connector/__init__.py +5 -0
  373. aip_agents/tools/gl_connector/tool.py +518 -0
  374. aip_agents/tools/gl_connector_tools.py +170 -0
  375. aip_agents/tools/memory_search/__init__.py +29 -0
  376. aip_agents/tools/memory_search/base.py +213 -0
  377. aip_agents/tools/memory_search/mem0.py +424 -0
  378. aip_agents/tools/memory_search/schema.py +81 -0
  379. aip_agents/tools/memory_search_tool.py +34 -0
  380. aip_agents/tools/streaming_common.py +577 -0
  381. aip_agents/tools/time_tool.py +117 -0
  382. aip_agents/tools/tool_config_injector.py +300 -0
  383. aip_agents/tools/web_search/__init__.py +15 -0
  384. aip_agents/tools/web_search/serper_tool.py +187 -0
  385. aip_agents/types/__init__.py +73 -0
  386. aip_agents/types/a2a_events.py +13 -0
  387. aip_agents/utils/__init__.py +91 -0
  388. aip_agents/utils/a2a_connector.py +1758 -0
  389. aip_agents/utils/artifact_helpers.py +542 -0
  390. aip_agents/utils/artifacts/__init__.py +20 -0
  391. aip_agents/utils/artifacts/tracker.py +367 -0
  392. aip_agents/utils/attachment_helpers.py +178 -0
  393. aip_agents/utils/constants.py +22 -0
  394. aip_agents/utils/datetime/__init__.py +34 -0
  395. aip_agents/utils/datetime/normalization.py +231 -0
  396. aip_agents/utils/datetime/timezone.py +206 -0
  397. aip_agents/utils/env_loader.py +27 -0
  398. aip_agents/utils/event_handler_registry.py +58 -0
  399. aip_agents/utils/file_prompt_utils.py +276 -0
  400. aip_agents/utils/file_watchers/__init__.py +33 -0
  401. aip_agents/utils/file_watchers/base.py +46 -0
  402. aip_agents/utils/file_watchers/e2b.py +128 -0
  403. aip_agents/utils/file_watchers/factory.py +39 -0
  404. aip_agents/utils/file_watchers/ls_diff.py +88 -0
  405. aip_agents/utils/file_watchers/sandbox.py +66 -0
  406. aip_agents/utils/file_watchers/watchdog.py +97 -0
  407. aip_agents/utils/final_response_builder.py +211 -0
  408. aip_agents/utils/formatter_llm_client.py +258 -0
  409. aip_agents/utils/langgraph/__init__.py +19 -0
  410. aip_agents/utils/langgraph/converter.py +399 -0
  411. aip_agents/utils/langgraph/delegation_context.py +22 -0
  412. aip_agents/utils/langgraph/tool_managers/__init__.py +15 -0
  413. aip_agents/utils/langgraph/tool_managers/a2a_tool_manager.py +99 -0
  414. aip_agents/utils/langgraph/tool_managers/base_tool_manager.py +66 -0
  415. aip_agents/utils/langgraph/tool_managers/delegation_tool_manager.py +1329 -0
  416. aip_agents/utils/langgraph/tool_output_management.py +1047 -0
  417. aip_agents/utils/logger.py +195 -0
  418. aip_agents/utils/metadata/__init__.py +27 -0
  419. aip_agents/utils/metadata/activity_metadata_helper.py +407 -0
  420. aip_agents/utils/metadata/activity_narrative/__init__.py +35 -0
  421. aip_agents/utils/metadata/activity_narrative/builder.py +822 -0
  422. aip_agents/utils/metadata/activity_narrative/constants.py +51 -0
  423. aip_agents/utils/metadata/activity_narrative/context.py +49 -0
  424. aip_agents/utils/metadata/activity_narrative/formatters.py +230 -0
  425. aip_agents/utils/metadata/activity_narrative/utils.py +35 -0
  426. aip_agents/utils/metadata/schemas/__init__.py +16 -0
  427. aip_agents/utils/metadata/schemas/activity_schema.py +29 -0
  428. aip_agents/utils/metadata/schemas/thinking_schema.py +31 -0
  429. aip_agents/utils/metadata/thinking_metadata_helper.py +38 -0
  430. aip_agents/utils/metadata_helper.py +363 -0
  431. aip_agents/utils/name_preprocessor/__init__.py +17 -0
  432. aip_agents/utils/name_preprocessor/base_name_preprocessor.py +73 -0
  433. aip_agents/utils/name_preprocessor/google_name_preprocessor.py +100 -0
  434. aip_agents/utils/name_preprocessor/name_preprocessor.py +87 -0
  435. aip_agents/utils/name_preprocessor/openai_name_preprocessor.py +48 -0
  436. aip_agents/utils/pii/__init__.py +29 -0
  437. aip_agents/utils/pii/pii_handler.py +555 -0
  438. aip_agents/utils/pii/pii_helper.py +225 -0
  439. aip_agents/utils/pii/uuid_deanonymizer_mapping.py +195 -0
  440. aip_agents/utils/reference_helper.py +273 -0
  441. aip_agents/utils/sse_chunk_transformer.py +836 -0
  442. aip_agents/utils/step_limit_manager.py +265 -0
  443. aip_agents/utils/token_usage_helper.py +531 -0
  444. aip_agents_binary-0.0.0.dist-info/METADATA +825 -0
  445. aip_agents_binary-0.0.0.dist-info/RECORD +447 -0
  446. aip_agents_binary-0.0.0.dist-info/WHEEL +5 -0
  447. aip_agents_binary-0.0.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,4048 @@
1
+ """Base class for LangGraph-based agent implementations.
2
+
3
+ This class provides the core LangGraph machinery including graph compilation,
4
+ state handling, and I/O mapping for LangGraph agents.
5
+
6
+ Authors:
7
+ Christian Trisno Sen Long Chen (christian.t.s.l.chen@gdplabs.id)
8
+ Putu Ravindra Wiguna (putu.r.wiguna@gdplabs.id)
9
+ Fachriza Adhiatma (fachriza.d.adhiatma@gdplabs.id)
10
+ Raymond Christopher (raymond.christopher@gdplabs.id)
11
+ Reinhart Linanda (reinhart.linanda@gdplabs.id)
12
+ """
13
+
14
+ import asyncio
15
+ import copy
16
+ import hashlib
17
+ import inspect
18
+ import json
19
+ import uuid
20
+ from abc import abstractmethod
21
+ from collections.abc import AsyncGenerator, Sequence
22
+ from contextlib import suppress
23
+ from contextvars import ContextVar
24
+ from dataclasses import dataclass
25
+ from typing import Any, cast
26
+
27
+ from a2a.types import AgentCard
28
+ from aiostream import stream as astream
29
+ from gllm_core.event import EventEmitter # type: ignore[import-untyped]
30
+ from gllm_core.event.handler import StreamEventHandler # type: ignore[import-untyped]
31
+ from gllm_core.schema import Chunk, Event # type: ignore[import-untyped]
32
+ from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, SystemMessage, ToolMessage
33
+ from langchain_core.tools import BaseTool
34
+ from langgraph.graph import StateGraph
35
+ from langgraph.graph.state import CompiledStateGraph
36
+ from langgraph.types import Checkpointer, StreamWriter
37
+ from pydantic import ValidationError
38
+
39
+ from aip_agents.agent.base_agent import BaseAgent
40
+ from aip_agents.agent.graph_runtime import CONFIGURABLE_KEY as _CONFIGURABLE_KEY
41
+ from aip_agents.agent.graph_runtime import GraphRuntime
42
+ from aip_agents.agent.system_instruction_context import get_current_date_context
43
+ from aip_agents.context.prompt import PromptBuildContext
44
+ from aip_agents.mcp.client import LangchainMCPClient
45
+ from aip_agents.mcp.client.errors import MCPServerStartupError
46
+ from aip_agents.memory import BaseMemory, MemoryFactory, MemoryMethod
47
+ from aip_agents.memory.constants import MemoryDefaults
48
+ from aip_agents.middleware.manager import MiddlewareManager
49
+ from aip_agents.schema.agent import StreamMode
50
+ from aip_agents.schema.hitl import HitlMetadata
51
+ from aip_agents.schema.step_limit import StepLimitConfig
52
+ from aip_agents.skills.errors import SkillInstallError
53
+ from aip_agents.tools.tool_config_injector import (
54
+ CONFIG_SCHEMA_ATTR,
55
+ TOOL_CONFIG_SCHEMA_ATTR,
56
+ inject_config_methods_into_tool,
57
+ )
58
+ from aip_agents.types import A2AEvent, A2AStreamEventType
59
+ from aip_agents.utils import augment_query_with_file_paths, build_input_attachment_registry, validate_references
60
+ from aip_agents.utils.attachment_helpers import attach_attachments_to_state, merge_input_attachments
61
+ from aip_agents.utils.langgraph.converter import (
62
+ THINKINGS_METADATA_KEY,
63
+ convert_langchain_messages_to_gllm_messages,
64
+ convert_lm_output_to_langchain_message,
65
+ )
66
+ from aip_agents.utils.langgraph.delegation_context import is_delegated_agent_execution
67
+ from aip_agents.utils.langgraph.tool_managers.a2a_tool_manager import A2AToolManager
68
+ from aip_agents.utils.langgraph.tool_managers.delegation_tool_manager import (
69
+ DelegationToolManager,
70
+ )
71
+ from aip_agents.utils.logger import get_logger
72
+ from aip_agents.utils.metadata.activity_metadata_helper import create_tool_activity_info
73
+ from aip_agents.utils.metadata_helper import (
74
+ DefaultStepMessages,
75
+ Kind,
76
+ MetadataFieldKeys,
77
+ Status,
78
+ end_step_counter_scope,
79
+ get_next_step_number,
80
+ start_step_counter_scope,
81
+ )
82
+ from aip_agents.utils.pii import deanonymize_final_response_content
83
+ from aip_agents.utils.sse_chunk_transformer import SSEChunkTransformer
84
+ from aip_agents.utils.step_limit_manager import _STEP_LIMIT_CONFIG_CVAR
85
+ from aip_agents.utils.token_usage_helper import (
86
+ MODEL_USAGE_DELTA_KEY,
87
+ MODEL_USAGE_KEY,
88
+ STEP_USAGE_KEY,
89
+ TOTAL_USAGE_KEY,
90
+ USAGE_METADATA_KEY,
91
+ )
92
+
93
+ logger = get_logger(__name__)
94
+
95
+ # Fixed bounded timeout (in seconds) for cancellation-safe auto-cleanup.
96
+ # This value is intentionally not configurable via env-var or per-call override
97
+ # in this iteration; see spec for rationale.
98
+ _AUTO_CLEANUP_TIMEOUT_S: float = 1.0
99
+
100
+ # Context variable to access current thread_id during streaming callbacks
101
+ _THREAD_ID_CVAR: ContextVar[str | None] = ContextVar("aip_agents_thread_id", default=None)
102
+
103
+ # Context variable to track operation mode for dependency tracking
104
+ # "parallel" = include all completed steps (default for backward compatibility)
105
+ # "sequential" = include only the most recent completed step
106
+ _OPERATION_MODE_CVAR: ContextVar[str] = ContextVar("aip_agents_operation_mode", default="parallel")
107
+
108
+
109
+ @dataclass
110
+ class _StreamingContext:
111
+ """Context object for managing streaming state and configuration."""
112
+
113
+ original_query: str
114
+ graph_input: dict[str, Any]
115
+ config: dict[str, Any]
116
+ memory_user_id: str | None
117
+ current_thread_id: str
118
+ token: Any
119
+ enable_token_streaming: bool
120
+ a2ui_stream_filter: Any | None = None
121
+
122
+ # Streaming state
123
+ final_event_yielded: bool = False
124
+ pending_artifacts: list | None = None
125
+ seen_artifact_hashes: set | None = None
126
+ processed_message_count: int = 0
127
+ final_state: dict[str, Any] | None = None
128
+ last_final_content: str | None = None
129
+ saved_memory: bool = False
130
+ is_token_streaming: bool = False
131
+
132
+ def __post_init__(self):
133
+ """Initialize mutable defaults."""
134
+ if self.pending_artifacts is None:
135
+ self.pending_artifacts = []
136
+ if self.seen_artifact_hashes is None:
137
+ self.seen_artifact_hashes = set()
138
+ if self.final_state is None:
139
+ self.final_state = {}
140
+
141
+
142
+ MCP_INIT_TIMEOUT = 60.0
143
+
144
+
145
+ class BaseLangGraphAgent(BaseAgent):
146
+ """Base class for LangGraph-based agents with unified tool approach.
147
+
148
+ Provides core LangGraph functionality including:
149
+ - Graph compilation and execution
150
+ - State schema management
151
+ - I/O mapping between user inputs and graph states
152
+ - Event emission support
153
+ - Tool resolution and handling
154
+ - A2A communication capabilities via tools
155
+ - Agent delegation capabilities via tools
156
+ - MCP server integration via tools
157
+ - Enhanced output extraction from various state formats
158
+ - Automatic resource cleanup after each run (arun/arun_stream/arun_a2a_stream)
159
+
160
+ Tool Management:
161
+ - regular_tools: Standard LangChain tools provided during initialization
162
+ - mcp_tools: Tools retrieved from MCP servers
163
+ - resolved_tools: Combined collection of all tools for graph execution
164
+
165
+ Subclasses must implement:
166
+ - define_graph(): Define the specific graph structure
167
+ - _prepare_graph_input(): Convert user input to graph state
168
+ - _format_graph_output(): Convert final graph state to user output
169
+ """
170
+
171
+ def __init__( # noqa: PLR0913
172
+ self,
173
+ name: str,
174
+ instruction: str,
175
+ description: str | None = None,
176
+ model: Any | None = None,
177
+ tools: Sequence[BaseTool] | None = None,
178
+ state_schema: type | None = None,
179
+ thread_id_key: str = "thread_id",
180
+ event_emitter: EventEmitter | None = None,
181
+ checkpointer: Checkpointer | None = None,
182
+ enable_a2a_token_streaming: bool = False,
183
+ recursion_limit: int | None = None,
184
+ graph_config: dict[str, Any] | None = None,
185
+ **kwargs: Any,
186
+ ):
187
+ """Initialize the BaseLangGraphAgent.
188
+
189
+ Args:
190
+ name: The name of the agent.
191
+ instruction: The system instruction for the agent.
192
+ description: Human-readable description of the agent.
193
+ model: The model to use (lm_invoker, LangChain model, string, etc.).
194
+ tools: Sequence of regular LangChain tools (not A2A or delegation tools).
195
+ state_schema: The state schema for the LangGraph. Defaults to basic message state.
196
+ thread_id_key: Key for thread ID in configuration.
197
+ event_emitter: Optional event emitter for streaming updates.
198
+ checkpointer: Optional checkpointer for conversation persistence.
199
+ enable_a2a_token_streaming: Enable token-level streaming for A2A responses.
200
+ - False (default): Stream message-level events only
201
+ - True: Stream individual tokens plus message-level events
202
+ recursion_limit: Optional maximum number of graph steps before LangGraph raises
203
+ ``GraphRecursionError``. Must be a positive integer when provided.
204
+ ``None`` (default) auto-derives ``StepLimitConfig().max_steps * 2``
205
+ (200 by default) via ``_effective_recursion_limit``; subclasses may
206
+ override ``_effective_recursion_limit`` to use a different source (e.g.
207
+ ``LangGraphReactAgent`` uses ``step_limit_config.max_steps * 2``).
208
+ A per-call value passed to ``_create_graph_config`` takes precedence.
209
+ graph_config: Optional baseline ``RunnableConfig`` keys (excluding
210
+ ``"configurable"``) applied to every graph invocation. Keys are merged at
211
+ the lowest priority — the named ``recursion_limit`` param and any per-call
212
+ kwargs override them. Passing ``"configurable"`` raises ``ValueError``
213
+ immediately because thread state is managed internally.
214
+ **kwargs: Additional keyword arguments passed to BaseAgent (including tool_configs and memory settings).
215
+ Memory settings include:
216
+ - memory_backend: Memory backend (e.g., "mem0")
217
+ - agent_id: Agent identifier for memory scoping
218
+ - memory_namespace: Memory namespace
219
+ - save_interaction_to_memory: Whether to save interactions (default True)
220
+
221
+ Raises:
222
+ ValueError: If ``recursion_limit`` is not ``None`` and is less than or equal to 0.
223
+ ValueError: If ``graph_config`` contains the key ``"configurable"``.
224
+ """
225
+ if recursion_limit is not None and recursion_limit <= 0:
226
+ raise ValueError(f"recursion_limit must be a positive integer, got {recursion_limit}")
227
+ if _CONFIGURABLE_KEY in (graph_config or {}):
228
+ raise ValueError(
229
+ "'configurable' cannot be set via graph_config. "
230
+ "Thread ID and checkpoint state are managed internally by the agent."
231
+ )
232
+
233
+ super().__init__(
234
+ name=name,
235
+ instruction=instruction,
236
+ description=description,
237
+ model=model,
238
+ tools=list(tools) if tools else [],
239
+ **kwargs,
240
+ )
241
+
242
+ self._prompt_build_context = PromptBuildContext(
243
+ base_instruction=instruction,
244
+ date_time_context=get_current_date_context(),
245
+ )
246
+
247
+ self.state_schema = state_schema
248
+ self.thread_id_key = thread_id_key
249
+ self.enable_a2a_token_streaming = enable_a2a_token_streaming
250
+ self.event_emitter = event_emitter
251
+ self.checkpointer = checkpointer
252
+ self.recursion_limit: int | None = recursion_limit
253
+ self.graph_config: dict[str, Any] = graph_config or {}
254
+ self._graph_runtime = GraphRuntime(
255
+ agent_name=lambda: self.name,
256
+ thread_id_key=lambda: self.thread_id_key,
257
+ graph_config=lambda: self.graph_config,
258
+ recursion_limit=lambda: self.recursion_limit,
259
+ effective_recursion_limit=lambda: self._effective_recursion_limit,
260
+ logger_=logger,
261
+ )
262
+ self.tool_output_manager = None
263
+
264
+ self._mem0_client: Any | None = None
265
+ self.memory: BaseMemory | None = None
266
+ self._initialize_memory_from_kwargs(name, kwargs)
267
+
268
+ self.a2a_tool_manager = A2AToolManager()
269
+ self.delegation_tool_manager = DelegationToolManager(parent_agent=self)
270
+
271
+ # Always initialize middleware manager (subclasses can override via _setup_middleware)
272
+ self._middleware_manager = MiddlewareManager([])
273
+
274
+ self.regular_tools: list[BaseTool] = self._resolve_and_validate_tools()
275
+ self.mcp_tools: list[BaseTool] = []
276
+ self.resolved_tools: list[BaseTool] = self.regular_tools.copy()
277
+
278
+ self._compiled_graph = self._build_and_compile_graph()
279
+
280
+ self._tool_parent_map_by_thread: dict[str, dict[str, str]] = {}
281
+ self._completed_tool_steps_by_thread: dict[str, list[str]] = {}
282
+ self._last_status_step_id_by_thread: dict[str, str] = {}
283
+ self._coordinator_completed_tool_steps_by_thread: dict[str, list[str]] = {}
284
+ self._emitted_tool_calls_by_thread: dict[str, set[str]] = {}
285
+ self._last_compaction_preparation: Any = None
286
+
287
+ def _create_default_event_emitter(self) -> EventEmitter:
288
+ """Create default event emitter for token streaming.
289
+
290
+ Returns:
291
+ EventEmitter with StreamEventHandler configured for token streaming.
292
+ """
293
+ stream_handler = StreamEventHandler(name=f"{self.name}_A2AStreamHandler")
294
+ logger.info(f"Agent '{self.name}': Auto-created event emitter for token streaming")
295
+ return EventEmitter(handlers=[stream_handler])
296
+
297
+ def _log_streaming_event_debug(self, source: str, event: dict[str, Any]) -> None:
298
+ """Log the raw streaming event for debugging purposes.
299
+
300
+ Args:
301
+ source: A short label describing where the event originated.
302
+ event: The event payload emitted by the streaming pipeline.
303
+ """
304
+ try:
305
+ logger.info("Streaming event (%s): %s", source, event)
306
+ except Exception as exc: # noqa: BLE001
307
+ logger.debug("Failed to log streaming event: %s", exc, exc_info=True)
308
+
309
+ def _record_emitted_tool_calls(self, tool_calls_details: list[dict[str, Any]]) -> None:
310
+ """Track tool call IDs that have already been emitted to avoid duplicates.
311
+
312
+ Args:
313
+ tool_calls_details: Tool call metadata emitted by the tool_call event.
314
+ """
315
+ thread_id = _THREAD_ID_CVAR.get()
316
+ if not thread_id or not tool_calls_details:
317
+ return
318
+
319
+ emitted = self._emitted_tool_calls_by_thread.setdefault(thread_id, set())
320
+ for details in tool_calls_details:
321
+ call_id = details.get("id")
322
+ if isinstance(call_id, str) and call_id:
323
+ emitted.add(call_id)
324
+ logger.info(
325
+ "Registered tool call event: agent=%s thread=%s call_id=%s payload=%s",
326
+ self.name,
327
+ thread_id,
328
+ call_id,
329
+ details,
330
+ )
331
+
332
+ def _discard_emitted_tool_call(self, tool_call_id: str | None) -> None:
333
+ """Remove a tool call ID from the emitted tracker.
334
+
335
+ Args:
336
+ tool_call_id: Identifier of the tool call to remove from cache.
337
+ """
338
+ if not tool_call_id:
339
+ return
340
+ thread_id = _THREAD_ID_CVAR.get()
341
+ if not thread_id:
342
+ return
343
+ emitted = self._emitted_tool_calls_by_thread.get(thread_id)
344
+ if emitted:
345
+ emitted.discard(tool_call_id)
346
+ logger.info(
347
+ "Cleared recorded tool call: agent=%s thread=%s call_id=%s",
348
+ self.name,
349
+ thread_id,
350
+ tool_call_id,
351
+ )
352
+
353
+ def _get_stream_handler(self) -> StreamEventHandler | None:
354
+ """Get StreamEventHandler from event_emitter if available.
355
+
356
+ Returns:
357
+ StreamEventHandler instance if found, None otherwise.
358
+ """
359
+ if not self.event_emitter or not self.event_emitter.handlers:
360
+ return None
361
+
362
+ for handler in self.event_emitter.handlers:
363
+ if isinstance(handler, StreamEventHandler):
364
+ return handler
365
+ return None
366
+
367
+ def set_operation_mode(self, mode: str) -> None:
368
+ """Set the operation mode for dependency tracking.
369
+
370
+ Args:
371
+ mode: Operation mode - "parallel" (default) or "sequential"
372
+ """
373
+ if mode not in ["parallel", "sequential"]:
374
+ raise ValueError(f"Invalid operation mode: {mode}. Must be 'parallel' or 'sequential'")
375
+ _OPERATION_MODE_CVAR.set(mode)
376
+
377
+ def _default_memory_agent_id(self, name: str) -> str:
378
+ """Create a stable identifier for memory scoping.
379
+
380
+ Args:
381
+ name: The agent's human-readable name.
382
+
383
+ Returns:
384
+ str: A deterministic ID derived from the class and name, suitable for scoping memory per agent.
385
+ """
386
+ base = f"{self.__class__.__name__}:{name}"
387
+ return f"{MemoryDefaults.AGENT_ID_PREFIX}{hashlib.sha256(base.encode()).hexdigest()}"
388
+
389
+ @staticmethod
390
+ def _parse_bool_value(value: Any) -> bool:
391
+ """Parse a value to boolean with string handling for "true"/"false".
392
+
393
+ Treats string "false" as False, "true" as True.
394
+ For other values, uses standard bool() conversion.
395
+
396
+ Args:
397
+ value: The value to parse.
398
+
399
+ Returns:
400
+ bool: The parsed boolean value.
401
+ """
402
+ if isinstance(value, str):
403
+ lower_value = value.lower().strip()
404
+ if lower_value == "false":
405
+ return False
406
+ elif lower_value == "true":
407
+ return True
408
+ return bool(value)
409
+
410
+ def _memory_enabled(self) -> bool:
411
+ """Check whether memory is enabled for this agent.
412
+
413
+ Returns:
414
+ bool: True when a memory adapter is set.
415
+ """
416
+ return self.memory is not None
417
+
418
+ def _has_lm_invoker(self) -> bool:
419
+ """Check whether lm_invoker is available for this agent.
420
+
421
+ Returns:
422
+ bool: True when lm_invoker attribute exists and is not None.
423
+ """
424
+ return self.lm_invoker is not None
425
+
426
+ def _memory_search(self, query: str, override_user_id: str | None = None) -> list[dict[str, Any]]:
427
+ """Search for relevant memories using the configured adapter.
428
+
429
+ Args:
430
+ query: The user query to retrieve relevant memories for.
431
+ override_user_id: Optional per-call override for the memory scope.
432
+
433
+ Returns:
434
+ list[dict[str, Any]]: Memory hits; empty list on failure or when disabled.
435
+ """
436
+ if not (self._memory_enabled() and isinstance(query, str)):
437
+ return []
438
+ try:
439
+ user_id = override_user_id or self.memory_agent_id
440
+ if hasattr(self.memory, MemoryMethod.SEARCH):
441
+ results = self.memory.search( # type: ignore[attr-defined]
442
+ query=query,
443
+ user_id=user_id,
444
+ limit=self.memory_retrieval_limit,
445
+ )
446
+ return results if isinstance(results, list) else []
447
+ return []
448
+ except Exception as e: # noqa: BLE001
449
+ logger.debug(f"Memory: search ignored error: {e}")
450
+ return []
451
+
452
+ def _resolve_and_validate_tools(self) -> list[BaseTool]:
453
+ """Resolve and validate regular tools for LangGraph usage.
454
+
455
+ Also configures tools with injected configuration capabilities
456
+ from agent-level tool_configs.
457
+
458
+ Returns:
459
+ List of resolved LangChain BaseTool instances.
460
+ """
461
+ resolved = []
462
+ for tool in self.tools:
463
+ if isinstance(tool, BaseTool):
464
+ self._configure_injected_tool(tool)
465
+ resolved.append(tool)
466
+ else:
467
+ logger.warning(f"Agent '{self.name}': Tool {tool} is not a LangChain BaseTool, skipping")
468
+
469
+ logger.info(f"Agent '{self.name}': Resolved {len(resolved)} regular tools for LangGraph")
470
+ return resolved
471
+
472
+ def _initialize_memory_from_kwargs(self, agent_name: str, kwargs: dict[str, Any]) -> None:
473
+ """Initialize memory-related settings and adapter.
474
+
475
+ Extracts known memory kwargs, sets defaults, and initializes the adapter when enabled.
476
+ Keeps ``__init__`` concise and improves DX.
477
+
478
+ Args:
479
+ agent_name: The name of the agent, used to derive a default memory id.
480
+ kwargs: Keyword arguments from the agent constructor; consumed keys are removed.
481
+ Supported memory kwargs:
482
+ - memory_backend: str - Memory backend to use (e.g., "mem0")
483
+ - agent_id: str - Agent identifier for memory scoping
484
+ - memory_namespace: str - Memory namespace
485
+ - memory_retrieval_limit: int - Max memories to retrieve
486
+ - memory_max_chars: int - Max characters per memory
487
+ - save_interaction_to_memory: bool (default True) - Whether to save interactions to memory
488
+ """
489
+ # Initialize memory configuration settings
490
+ self.memory_backend: str | None = kwargs.pop("memory_backend", None)
491
+ self.memory_agent_id: str = str(kwargs.pop("agent_id", self._default_memory_agent_id(agent_name)))
492
+ self.memory_namespace: str | None = kwargs.pop("memory_namespace", None)
493
+ self.memory_retrieval_limit: int = int(kwargs.pop("memory_retrieval_limit", MemoryDefaults.RETRIEVAL_LIMIT))
494
+ self.memory_max_chars: int = int(kwargs.pop("memory_max_chars", MemoryDefaults.MAX_CHARS))
495
+
496
+ # Initialize memory interaction saving flag with proper bool conversion
497
+ save_raw = kwargs.pop("save_interaction_to_memory", True)
498
+ self.save_interaction_to_memory: bool = self._parse_bool_value(save_raw)
499
+
500
+ if self.memory_backend:
501
+ memory_kwargs = {
502
+ "limit": self.memory_retrieval_limit,
503
+ "max_chars": self.memory_max_chars,
504
+ "agent_id": self.memory_agent_id,
505
+ }
506
+ if self.memory_namespace:
507
+ memory_kwargs["namespace"] = self.memory_namespace
508
+
509
+ self._mem0_client = MemoryFactory.create(
510
+ self.memory_backend,
511
+ **memory_kwargs,
512
+ )
513
+ self.memory = self._mem0_client
514
+
515
+ def _configure_injected_tool(self, tool: BaseTool) -> None:
516
+ """Configure a tool with automatic configuration injection using agent-level defaults.
517
+
518
+ Args:
519
+ tool: The tool instance to configure.
520
+ """
521
+ if self._should_auto_inject_config(tool):
522
+ self._auto_inject_config_capabilities(tool)
523
+ self._apply_agent_config_to_tool(tool)
524
+
525
+ def _should_auto_inject_config(self, tool: BaseTool) -> bool:
526
+ """Check if tool needs auto-injection of configuration capabilities.
527
+
528
+ Args:
529
+ tool: The tool instance to check.
530
+
531
+ Returns:
532
+ True if tool needs auto-injection of configuration capabilities, False otherwise.
533
+ """
534
+ return (
535
+ hasattr(tool, TOOL_CONFIG_SCHEMA_ATTR)
536
+ and getattr(tool, TOOL_CONFIG_SCHEMA_ATTR) is not None
537
+ and not hasattr(tool, CONFIG_SCHEMA_ATTR)
538
+ )
539
+
540
+ def _auto_inject_config_capabilities(self, tool: BaseTool) -> None:
541
+ """Inject configuration capabilities into a tool with tool_config_schema.
542
+
543
+ Args:
544
+ tool: The tool instance to configure.
545
+ """
546
+ try:
547
+ tool_config_schema = getattr(tool, TOOL_CONFIG_SCHEMA_ATTR)
548
+ inject_config_methods_into_tool(tool, tool_config_schema)
549
+ logger.info(f"Agent '{self.name}': Auto-injected config capabilities into tool '{tool.name}'")
550
+ except Exception as e:
551
+ logger.warning(f"Agent '{self.name}': Failed to auto-inject config into tool '{tool.name}': {e}")
552
+
553
+ def _apply_agent_config_to_tool(self, tool: BaseTool) -> None:
554
+ """Apply agent-level configuration to a tool.
555
+
556
+ Args:
557
+ tool: The tool instance to configure.
558
+ """
559
+ tool_config_data = self._get_agent_config_for_tool(tool.name)
560
+
561
+ if tool_config_data is None:
562
+ return
563
+
564
+ try:
565
+ set_tool_config = getattr(tool, "set_tool_config", None)
566
+ if callable(set_tool_config):
567
+ set_tool_config(tool_config_data)
568
+ else:
569
+ raise AttributeError("set_tool_config not available")
570
+ logger.info(f"Agent '{self.name}': Configured tool '{tool.name}' with agent defaults: {tool_config_data}")
571
+ except Exception as e:
572
+ logger.warning(f"Agent '{self.name}': Failed to configure tool '{tool.name}': {e}")
573
+
574
+ def _get_agent_config_for_tool(self, tool_name: str) -> dict[str, Any] | None:
575
+ """Get agent-level configuration data for a specific tool.
576
+
577
+ This method intentionally returns only per-tool configuration and does NOT include
578
+ global agent configuration. Global configuration merging is handled separately
579
+ in the metadata resolution process during tool execution.
580
+
581
+ Args:
582
+ tool_name: The name of the tool to get configuration for.
583
+
584
+ Returns:
585
+ The configuration data for the tool, or None if no configuration is found.
586
+ """
587
+ if not isinstance(self.tool_configs, dict):
588
+ return None
589
+
590
+ return self.tool_configs.get(tool_name)
591
+
592
+ def _sanitize_tool_names(self) -> None:
593
+ """Correct resolved_tools' names that will be used for the agent according to the model provider's rules."""
594
+ for tool in self.resolved_tools:
595
+ sanitized_name = self.name_preprocessor.sanitize_tool_name(tool.name)
596
+ tool.name = sanitized_name
597
+
598
+ def _build_and_compile_graph(self) -> CompiledStateGraph:
599
+ """Build and compile the LangGraph while ensuring tool names are valid.
600
+
601
+ Returns:
602
+ Compiled LangGraph ready for execution.
603
+ """
604
+ self._sanitize_tool_names()
605
+ try:
606
+ compiled_graph = self._graph_runtime.build_and_compile_graph(self.state_schema, self.define_graph)
607
+ logger.info(
608
+ f"Agent '{self.name}': Successfully compiled LangGraph with {len(self.resolved_tools)} total tools"
609
+ )
610
+ return compiled_graph
611
+
612
+ except Exception as e:
613
+ logger.error(f"Agent '{self.name}': Failed to build LangGraph: {e}")
614
+ raise RuntimeError(f"Failed to build LangGraph for agent '{self.name}': {e}") from e
615
+
616
+ @abstractmethod
617
+ def define_graph(self, graph_builder: StateGraph) -> CompiledStateGraph:
618
+ """Define the specific graph structure for this agent type.
619
+
620
+ Subclasses must implement this method to:
621
+ 1. Add nodes to the graph_builder
622
+ 2. Add edges and conditional edges
623
+ 3. Set entry points
624
+ 4. Return the compiled graph
625
+
626
+ Args:
627
+ graph_builder: The StateGraph builder to define nodes and edges on.
628
+
629
+ Returns:
630
+ The compiled graph ready for execution.
631
+ """
632
+ raise NotImplementedError(f"Agent '{self.name}': Subclasses must implement define_graph method")
633
+
634
+ @abstractmethod
635
+ def _prepare_graph_input(self, input_data: Any, **kwargs: Any) -> dict[str, Any]:
636
+ """Convert user input to graph state format.
637
+
638
+ Args:
639
+ input_data: The user's input (query string, structured data, etc.).
640
+ **kwargs: Additional keyword arguments from the user.
641
+
642
+ Returns:
643
+ Dictionary representing the initial graph state.
644
+ """
645
+ raise NotImplementedError(f"Agent '{self.name}': Subclasses must implement _prepare_graph_input method")
646
+
647
+ @abstractmethod
648
+ def _format_graph_output(self, final_state_result: dict[str, Any]) -> Any:
649
+ """Convert final graph state to user-friendly output.
650
+
651
+ Args:
652
+ final_state_result: The final state from graph execution.
653
+
654
+ Returns:
655
+ Formatted output for the user.
656
+ """
657
+ raise NotImplementedError(f"Agent '{self.name}': Subclasses must implement _format_graph_output method")
658
+
659
+ def _extract_metadata_from_kwargs(self, **kwargs: Any) -> dict[str, Any]:
660
+ """Extract metadata from kwargs for agent implementations.
661
+
662
+ Supports both flat and mixed metadata schemas:
663
+ - Flat dict (legacy): all keys applied to all tools and model calls
664
+ - Mixed dict (new): top-level keys applied to all tools, 'tool_configs' section per-tool
665
+
666
+ Args:
667
+ **kwargs: Keyword arguments that may contain metadata.
668
+
669
+ Returns:
670
+ dict[str, Any]: The metadata dictionary, or an empty dict if no metadata was provided.
671
+ """
672
+ return kwargs.get("metadata", {})
673
+
674
+ def _extract_output_from_dict_state(self, dict_state: dict[str, Any]) -> str | None:
675
+ """Extract output from a dictionary state (migrated from BaseLangChainAgent).
676
+
677
+ Args:
678
+ dict_state: A dictionary containing agent state information.
679
+
680
+ Returns:
681
+ The extracted output string or None if no valid output found.
682
+ """
683
+ output_content: str | None = None
684
+ messages = dict_state.get("messages")
685
+ if messages and isinstance(messages, list) and messages:
686
+ last_message = messages[-1]
687
+ if isinstance(last_message, AIMessage):
688
+ candidate_content = getattr(last_message, "content", None)
689
+ if not candidate_content:
690
+ output_content = ""
691
+ else:
692
+ output_content = candidate_content
693
+ elif hasattr(last_message, "content"):
694
+ output_content = getattr(last_message, "content", None)
695
+ if output_content is None:
696
+ candidate_output_from_key = dict_state.get("output")
697
+ if isinstance(candidate_output_from_key, str):
698
+ output_content = candidate_output_from_key
699
+ return output_content
700
+
701
+ def _extract_output_from_list_state(self, list_state: list[Any]) -> str | None:
702
+ """Extract output from a list state (migrated from BaseLangChainAgent).
703
+
704
+ Args:
705
+ list_state: A list containing agent state information.
706
+
707
+ Returns:
708
+ The extracted output string or None if no valid output found.
709
+ """
710
+ output_content: str | None = None
711
+ if not list_state:
712
+ return None
713
+ last_item = list_state[-1]
714
+ if isinstance(last_item, AIMessage) and getattr(last_item, "content", None) is not None:
715
+ output_content = self._normalize_event_content(last_item.content)
716
+ elif isinstance(last_item, str):
717
+ output_content = last_item
718
+ return output_content
719
+
720
+ def _extract_output_from_final_state(self, final_state_result: Any) -> str:
721
+ """Enhanced output extraction from final state (migrated from BaseLangChainAgent).
722
+
723
+ Args:
724
+ final_state_result: The final state from graph execution.
725
+
726
+ Returns:
727
+ Extracted text content.
728
+ """
729
+ output_content: str | None = None
730
+ if isinstance(final_state_result, dict):
731
+ output_content = self._extract_output_from_dict_state(final_state_result)
732
+ elif isinstance(final_state_result, str):
733
+ output_content = final_state_result
734
+ elif isinstance(final_state_result, list):
735
+ output_content = self._extract_output_from_list_state(final_state_result)
736
+
737
+ if output_content is None:
738
+ return "Error: Could not extract output from agent's final state."
739
+ return output_content
740
+
741
+ def register_a2a_agents(self, agent_cards: list[AgentCard]) -> None:
742
+ """Register A2A communication capabilities using the A2A tool manager.
743
+
744
+ Args:
745
+ agent_cards (list[AgentCard]): List of AgentCard instances for external communication.
746
+ """
747
+ if not agent_cards:
748
+ logger.info(f"Agent '{self.name}': No A2A agents to register")
749
+ return
750
+
751
+ a2a_tools = self.a2a_tool_manager.register_resources(agent_cards)
752
+ self.resolved_tools.extend(a2a_tools)
753
+
754
+ logger.info(f"Agent '{self.name}': Registered {len(agent_cards)} A2A agents as tools")
755
+ self._rebuild_graph()
756
+
757
+ def register_delegation_agents(self, agents: list[BaseAgent]) -> None:
758
+ """Register internal agent delegation capabilities using the delegation tool manager.
759
+
760
+ Args:
761
+ agents: List of BaseAgent instances for internal task delegation.
762
+ """
763
+ if not agents:
764
+ logger.info(f"Agent '{self.name}': No delegation agents to register")
765
+ return
766
+
767
+ delegation_tools = self.delegation_tool_manager.register_resources(agents)
768
+ self.resolved_tools.extend(delegation_tools)
769
+ logger.info(f"Agent '{self.name}': Registered {len(agents)} delegation agents as streaming tools")
770
+
771
+ self._rebuild_graph()
772
+
773
+ def update_regular_tools(self, new_tools: list[BaseTool], rebuild_graph: bool | None = None) -> None:
774
+ """Update regular tools (not capabilities).
775
+
776
+ Args:
777
+ new_tools: New list of regular tools to use.
778
+ rebuild_graph: Whether to rebuild graph. If None, uses auto_rebuild_graph setting.
779
+ """
780
+ logger.info(f"Agent '{self.name}': Updating regular tools from {len(self.tools)} to {len(new_tools)}")
781
+
782
+ self.tools = list(new_tools)
783
+ old_resolved_count = len(self.regular_tools)
784
+ self.regular_tools = self._resolve_and_validate_tools()
785
+
786
+ logger.info(
787
+ f"Agent '{self.name}': Regular tools changed from {old_resolved_count} to {len(self.regular_tools)}"
788
+ )
789
+
790
+ self._rebuild_resolved_tools()
791
+
792
+ should_rebuild = rebuild_graph if rebuild_graph is not None else True
793
+ if should_rebuild:
794
+ try:
795
+ logger.info(f"Agent '{self.name}': Rebuilding graph with updated tools")
796
+ self._compiled_graph = self._build_and_compile_graph()
797
+ except Exception as e:
798
+ logger.error(f"Agent '{self.name}': Failed to rebuild graph after tool update: {e}")
799
+ raise
800
+
801
+ def _rebuild_resolved_tools(self) -> None:
802
+ """Rebuild resolved tools combining regular tools with capability tools."""
803
+ self.resolved_tools = self.regular_tools.copy()
804
+
805
+ if self.a2a_tool_manager:
806
+ a2a_tools = self.a2a_tool_manager.get_tools()
807
+ self.resolved_tools.extend(a2a_tools)
808
+ logger.info(f"Agent '{self.name}': Added {len(a2a_tools)} A2A tools")
809
+
810
+ if self.delegation_tool_manager:
811
+ delegation_tools = self.delegation_tool_manager.get_tools()
812
+ self.resolved_tools.extend(delegation_tools)
813
+ logger.info(f"Agent '{self.name}': Added {len(delegation_tools)} delegation tools")
814
+
815
+ if self.mcp_tools:
816
+ self.resolved_tools.extend(self.mcp_tools)
817
+ logger.info(f"Agent '{self.name}': Added {len(self.mcp_tools)} MCP tools")
818
+
819
+ logger.info(f"Agent '{self.name}': Rebuilt resolved tools: {len(self.resolved_tools)} total tools")
820
+
821
+ def _rebuild_graph(self) -> None:
822
+ """Rebuilds and recompiles the graph using the current set of tools.
823
+
824
+ Raises:
825
+ RuntimeError: If the graph rebuilding or compilation process fails.
826
+ """
827
+ try:
828
+ self._rebuild_resolved_tools()
829
+ self._compiled_graph = self._build_and_compile_graph()
830
+ logger.info(f"Agent '{self.name}': Successfully rebuilt graph")
831
+ except Exception as e:
832
+ logger.error(f"Agent '{self.name}': Failed to rebuild graph: {e}")
833
+ raise RuntimeError(f"Failed to rebuild graph for agent '{self.name}': {e}") from e
834
+
835
+ def run(self, query: str, **kwargs: Any) -> dict[str, Any]:
836
+ """Synchronously run the LangGraph agent.
837
+
838
+ Args:
839
+ query: The input query for the agent.
840
+ **kwargs: Additional keyword arguments.
841
+
842
+ Returns:
843
+ Dictionary containing the agent's response.
844
+ """
845
+ try:
846
+ return asyncio.run(self.arun(query, **kwargs))
847
+ except RuntimeError as e:
848
+ raise RuntimeError(f"Agent '{self.name}': Error in sync 'run'. Original: {e}") from e
849
+
850
+ async def _auto_cleanup(self, entrypoint: str) -> None:
851
+ """Best-effort auto-cleanup with stable warning marker.
852
+
853
+ This internal helper performs cleanup with the following guarantees:
854
+ - Cleanup exceptions are logged and suppressed (never raised)
855
+ - Cleanup failures are logged at WARNING level with stable marker format
856
+ - The marker format is: 'auto-cleanup failed entrypoint=<method>'
857
+ - Cancellation-safe: uses bounded 1.0s timeout via asyncio.wait_for
858
+ so cleanup does not hang indefinitely; CancelledError is re-raised
859
+
860
+ Args:
861
+ entrypoint: The public method name that initiated the cleanup (e.g., 'arun').
862
+ """
863
+ try:
864
+ await asyncio.wait_for(self.cleanup(), timeout=_AUTO_CLEANUP_TIMEOUT_S)
865
+ except asyncio.CancelledError:
866
+ logger.warning(f"Agent '{self.name}': auto-cleanup failed entrypoint={entrypoint}: cleanup cancelled")
867
+ raise
868
+ except TimeoutError:
869
+ logger.warning(
870
+ f"Agent '{self.name}': auto-cleanup failed entrypoint={entrypoint}: cleanup cancelled or timed out"
871
+ )
872
+ except Exception as e:
873
+ logger.warning(f"Agent '{self.name}': auto-cleanup failed entrypoint={entrypoint}: {e}")
874
+
875
+ async def arun(self, query: str, **kwargs: Any) -> dict[str, Any]:
876
+ """Asynchronously run the LangGraph agent with lazy MCP initialization.
877
+
878
+ This method automatically handles cleanup after execution completes,
879
+ fails, or is cancelled. Cleanup is best-effort and any cleanup errors
880
+ are logged but do not override the primary result/error.
881
+
882
+ Args:
883
+ query: The input query for the agent.
884
+ **kwargs: Additional keyword arguments including configurable for LangGraph.
885
+
886
+ Returns:
887
+ Dictionary containing the agent's response and full final state.
888
+ """
889
+ try:
890
+ await self._ensure_mcp_tools_initialized()
891
+ return await self._arun(query, **kwargs)
892
+ finally:
893
+ await self._auto_cleanup("arun")
894
+
895
+ async def _arun(self, query: str, **kwargs: Any) -> dict[str, Any]:
896
+ """Internal implementation of arun without MCP handling.
897
+
898
+ Args:
899
+ query: The input query for the agent.
900
+ **kwargs: Additional keyword arguments including configurable for LangGraph.
901
+
902
+ Returns:
903
+ Dictionary containing the agent's response and full final state.
904
+ """
905
+ files = kwargs.pop("files", [])
906
+ if files is None:
907
+ files = []
908
+ query, input_attachments = self._prepare_file_runtime_context(query, files, kwargs)
909
+
910
+ # Create config first to ensure thread_id is generated
911
+ config = self._create_graph_config(**kwargs)
912
+ thread_id = self._get_thread_id_from_config(config)
913
+
914
+ graph_input = self._prepare_graph_input(query, thread_id=thread_id, **kwargs)
915
+ self._attach_input_attachments_to_graph_input(graph_input, input_attachments)
916
+
917
+ try:
918
+ before_updates = await self._middleware_manager.abefore_run(graph_input, config)
919
+ if before_updates:
920
+ self._apply_before_run_updates(graph_input, before_updates)
921
+ except SkillInstallError:
922
+ raise
923
+ except Exception: # pragma: no cover
924
+ # Best-effort: middleware hooks must not block agent execution.
925
+ pass # pragma: no cover
926
+
927
+ try:
928
+ final_state_result = await self._graph_runtime.invoke(self._compiled_graph, graph_input, config)
929
+ formatted_output = self._format_graph_output(final_state_result)
930
+
931
+ try:
932
+ await self._middleware_manager.aafter_run(
933
+ final_state=final_state_result,
934
+ output=formatted_output,
935
+ config=config,
936
+ error=None,
937
+ )
938
+ except Exception: # pragma: no cover
939
+ # Best-effort: middleware hooks must not block agent execution.
940
+ pass # pragma: no cover
941
+
942
+ return {"output": formatted_output, "full_final_state": final_state_result}
943
+
944
+ except Exception as e:
945
+ try:
946
+ await self._middleware_manager.aafter_run(
947
+ final_state={},
948
+ output=None,
949
+ config=config,
950
+ error=e,
951
+ )
952
+ except Exception: # pragma: no cover
953
+ pass # pragma: no cover
954
+ logger.error(f"Agent '{self.name}': Error during graph execution: {e}")
955
+ raise RuntimeError(f"Agent '{self.name}': Graph execution failed: {e}") from e
956
+
957
+ @staticmethod
958
+ def _apply_before_run_updates(graph_input: dict[str, Any], before_updates: dict[str, Any]) -> None:
959
+ """Merge run-level before updates while preserving existing message history."""
960
+ message_updates = before_updates.get("messages")
961
+ if isinstance(message_updates, list) and message_updates:
962
+ existing_messages = graph_input.get("messages")
963
+ if isinstance(existing_messages, list):
964
+ graph_input["messages"] = [*existing_messages, *message_updates]
965
+ else:
966
+ graph_input["messages"] = list(message_updates)
967
+
968
+ non_message_updates = {k: v for k, v in before_updates.items() if k != "messages"}
969
+ if non_message_updates:
970
+ graph_input.update(non_message_updates)
971
+
972
+ source_errors = graph_input.get("skill_source_errors")
973
+ if isinstance(source_errors, list) and source_errors:
974
+ raise SkillInstallError(
975
+ "Skill source readiness failed before model execution.", source_errors=source_errors
976
+ )
977
+
978
+ def _prepare_file_runtime_context(
979
+ self,
980
+ query: str,
981
+ files: list[str | dict[str, object]],
982
+ kwargs: dict[str, Any],
983
+ ) -> tuple[str, list[dict[str, Any]]]:
984
+ """Augment query and metadata with safe image attachment references."""
985
+ augmented_query = augment_query_with_file_paths(query=query, files=files)
986
+ registry = build_input_attachment_registry(files)
987
+ attachments = registry.as_list()
988
+ if not attachments:
989
+ return augmented_query, []
990
+
991
+ kwargs["metadata"] = merge_input_attachments(kwargs.get("metadata"), attachments)
992
+ return augmented_query, attachments
993
+
994
+ @staticmethod
995
+ def _attach_input_attachments_to_graph_input(
996
+ graph_input: dict[str, Any], input_attachments: list[dict[str, Any]]
997
+ ) -> None:
998
+ """Attach input image metadata to runtime state when present."""
999
+ attach_attachments_to_state(graph_input, input_attachments)
1000
+
1001
+ async def _stream_with_lm_invoker(self, query: str, **kwargs: Any) -> AsyncGenerator[str | dict[str, Any], None]:
1002
+ """Handle streaming for LM Invoker using StreamEventHandler.
1003
+
1004
+ Args:
1005
+ query: The input query for the agent.
1006
+ **kwargs: Additional keyword arguments.
1007
+
1008
+ Yields:
1009
+ Chunks of output (strings or dicts) from the streaming response.
1010
+ """
1011
+ stream_handler = StreamEventHandler(name=f"{self.name}_StreamHandler")
1012
+ event_emitter = EventEmitter(handlers=[stream_handler])
1013
+
1014
+ async def run_and_cleanup() -> None:
1015
+ """Runs the agent and ensures event emitter cleanup."""
1016
+ try:
1017
+ await self.arun(
1018
+ query=query,
1019
+ event_emitter=event_emitter,
1020
+ **kwargs,
1021
+ )
1022
+ finally:
1023
+ await event_emitter.close()
1024
+
1025
+ execution_task = asyncio.create_task(run_and_cleanup())
1026
+
1027
+ try:
1028
+ async for event in stream_handler.stream():
1029
+ chunk_data = json.loads(event)
1030
+ chunk_value = chunk_data.get("value", "")
1031
+ if not chunk_value:
1032
+ continue
1033
+ if isinstance(chunk_value, str) or isinstance(chunk_value, dict):
1034
+ yield chunk_value
1035
+
1036
+ await execution_task
1037
+
1038
+ except asyncio.CancelledError:
1039
+ execution_task.cancel()
1040
+ await event_emitter.close()
1041
+ with suppress(asyncio.CancelledError):
1042
+ await execution_task
1043
+ raise
1044
+ except Exception as e:
1045
+ execution_task.cancel()
1046
+ await event_emitter.close()
1047
+ with suppress(asyncio.CancelledError):
1048
+ await execution_task
1049
+ logger.error(f"Agent '{self.name}': Error during LM Invoker streaming: {e}")
1050
+ yield {"error": f"Streaming failed: {e}"}
1051
+
1052
+ @property
1053
+ def _effective_recursion_limit(self) -> int:
1054
+ """Return the recursion limit to apply to every graph invocation.
1055
+
1056
+ When ``self.recursion_limit`` is ``None``, returns
1057
+ ``StepLimitConfig().max_steps * 2`` (200 by default) so that LangGraph's
1058
+ hard ``GraphRecursionError`` never fires before the AIP graceful error path.
1059
+
1060
+ Subclasses may override this to derive the limit from additional runtime
1061
+ parameters (e.g. ``step_limit_config.max_steps``).
1062
+
1063
+ Returns:
1064
+ The recursion limit to apply. Always returns a positive integer.
1065
+ """
1066
+ if self.recursion_limit is not None:
1067
+ return self.recursion_limit
1068
+ return StepLimitConfig().max_steps * 2
1069
+
1070
+ def _resolve_configurable(self, kwargs: dict[str, Any]) -> dict[str, Any]:
1071
+ """Return the configurable dict with a guaranteed thread ID entry.
1072
+
1073
+ Copies the incoming ``configurable`` value so the caller's dict is never
1074
+ mutated. Generates and logs a new thread ID when the key is absent.
1075
+
1076
+ Args:
1077
+ kwargs: The raw keyword arguments passed to ``_create_graph_config``.
1078
+
1079
+ Returns:
1080
+ Configurable dict with a thread ID guaranteed to be present.
1081
+ """
1082
+ return self._graph_runtime.resolve_configurable(kwargs) # pragma: no cover
1083
+
1084
+ def _build_config_metadata(self, kwargs: dict[str, Any]) -> dict[str, Any] | None:
1085
+ """Build the ``metadata`` entry from kwargs and graph_config baseline.
1086
+
1087
+ Per the spec, per-call ``metadata`` **replaces** the ``graph_config`` baseline
1088
+ entirely for that invocation. Internal keys (``pii_mapping``, ``memory_user_id``)
1089
+ are then applied on top and always win.
1090
+
1091
+ Returns ``None`` when no metadata sources are present.
1092
+
1093
+ Args:
1094
+ kwargs: The raw keyword arguments passed to ``_create_graph_config``.
1095
+
1096
+ Returns:
1097
+ Merged metadata dict, or ``None`` if no metadata-related sources.
1098
+ """
1099
+ return self._graph_runtime.build_config_metadata(kwargs) # pragma: no cover
1100
+
1101
+ def _apply_config_layers(self, config: dict[str, Any], kwargs: dict[str, Any]) -> None:
1102
+ """Apply construction-time and per-call config layers onto ``config`` in place.
1103
+
1104
+ Layers are applied in priority order (each layer wins over the previous):
1105
+
1106
+ - **Layer 1** — ``self.graph_config`` baseline (excludes ``configurable``).
1107
+ - **Layer 2** — ``_effective_recursion_limit``; only applied when
1108
+ ``self.recursion_limit`` is explicitly set (explicit param wins over
1109
+ ``graph_config``). When not explicitly set, the auto-derived value
1110
+ (``step_limit_config.max_steps * 2``, 200 if no ``step_limit_config``) is
1111
+ written only when ``graph_config`` did not already supply
1112
+ ``"recursion_limit"``.
1113
+ - **Layer 3** — per-call ``_RUNNABLE_CONFIG_KEYS`` kwargs; ``metadata`` is
1114
+ intentionally skipped because ``_build_config_metadata`` handles it with
1115
+ internal-key guards that a plain overwrite would silently discard.
1116
+
1117
+ Args:
1118
+ config: The partially-assembled config dict; mutated in place.
1119
+ kwargs: The raw keyword arguments passed to ``_create_graph_config``.
1120
+ """
1121
+ self._graph_runtime.apply_config_layers(config, kwargs) # pragma: no cover
1122
+
1123
+ def _create_graph_config(self, **kwargs: Any) -> dict[str, Any]:
1124
+ """Assemble the final ``RunnableConfig``-compatible dict for a graph invocation.
1125
+
1126
+ Args:
1127
+ **kwargs: Per-call overrides; accepted keys are ``configurable``,
1128
+ ``metadata``, ``pii_mapping``, ``memory_user_id``, and any key in
1129
+ ``_RUNNABLE_CONFIG_KEYS``.
1130
+
1131
+ Returns:
1132
+ Config dict with a guaranteed thread ID and all applicable layers merged.
1133
+ """
1134
+ return self._graph_runtime.create_graph_config(**kwargs)
1135
+
1136
+ def _get_thread_id_from_config(self, config: dict[str, Any]) -> str | None:
1137
+ """Extract thread_id from graph configuration.
1138
+
1139
+ Args:
1140
+ config: Graph configuration dict with 'configurable' key.
1141
+
1142
+ Returns:
1143
+ The thread_id value or None if not found.
1144
+ """
1145
+ return self._graph_runtime.get_thread_id_from_config(config)
1146
+
1147
+ def _process_langgraph_event(self, event: Any) -> str | dict[str, Any] | A2AEvent | None:
1148
+ """Process a single LangGraph streaming event.
1149
+
1150
+ Args:
1151
+ event: Event from LangGraph's astream_events.
1152
+
1153
+ Returns:
1154
+ Processed output or None if event should be skipped.
1155
+ """
1156
+ event_type = event.get("event")
1157
+ event_data = event.get("data")
1158
+
1159
+ if event_type == "on_chat_model_stream" and event_data:
1160
+ chunk = event_data.get("chunk")
1161
+ if chunk and hasattr(chunk, "content") and chunk.content:
1162
+ return chunk.content
1163
+
1164
+ elif event_type == "on_tool_end" and event_data:
1165
+ output = event_data.get("output")
1166
+ if output:
1167
+ return {"tool_output": str(output)}
1168
+
1169
+ return None
1170
+
1171
+ def _should_yield_a2a_event(self, event_data: A2AEvent) -> bool:
1172
+ """Check if A2A event should be yielded based on event type.
1173
+
1174
+ Args:
1175
+ event_data: A2AEvent with semantic type information.
1176
+
1177
+ Returns:
1178
+ True if event should be yielded, False otherwise.
1179
+ """
1180
+ event_type = event_data.get("event_type")
1181
+
1182
+ if event_type in {
1183
+ A2AStreamEventType.TOOL_CALL,
1184
+ A2AStreamEventType.TOOL_RESULT,
1185
+ A2AStreamEventType.CONTENT_CHUNK,
1186
+ A2AStreamEventType.FINAL_RESPONSE,
1187
+ A2AStreamEventType.ERROR,
1188
+ }:
1189
+ return True
1190
+
1191
+ if event_type == A2AStreamEventType.STATUS_UPDATE:
1192
+ content = event_data.get("content", "")
1193
+ return bool(content.strip())
1194
+
1195
+ return True
1196
+
1197
+ @staticmethod
1198
+ def _normalize_custom_stream_event(event: A2AEvent) -> A2AEvent:
1199
+ """Normalize CUSTOM stream mode events that lack required A2AEvent fields.
1200
+
1201
+ CUSTOM mode events bypass the _create_a2a_event factory and may arrive as
1202
+ raw dicts without event_type or metadata. This method wraps those events
1203
+ into a valid STATUS_UPDATE event with the available content preserved.
1204
+
1205
+ Args:
1206
+ event: Incoming CUSTOM stream event, potentially missing A2AEvent fields.
1207
+
1208
+ Returns:
1209
+ Normalized A2AEvent with guaranteed event_type and metadata fields.
1210
+ """
1211
+ normalized_event = dict(event)
1212
+ existing_metadata = normalized_event.get("metadata")
1213
+ metadata = dict(existing_metadata) if isinstance(existing_metadata, dict) else {}
1214
+
1215
+ if "event_type" in normalized_event:
1216
+ normalized_event["metadata"] = metadata
1217
+ return normalized_event
1218
+
1219
+ logger.warning(
1220
+ "CUSTOM stream event missing A2AEvent fields (has keys: %s); wrapping as STATUS_UPDATE",
1221
+ list(normalized_event.keys()),
1222
+ )
1223
+
1224
+ metadata["original_event_keys"] = list(normalized_event.keys())
1225
+
1226
+ content = normalized_event.get("content") or normalized_event.get("status") or str(normalized_event)
1227
+ return {
1228
+ "event_type": A2AStreamEventType.STATUS_UPDATE,
1229
+ "content": BaseLangGraphAgent._normalize_event_content(content),
1230
+ "metadata": metadata,
1231
+ "tool_info": normalized_event.get("tool_info"),
1232
+ "is_final": normalized_event.get("is_final", False),
1233
+ "artifacts": normalized_event.get("artifacts"),
1234
+ "references": normalized_event.get("references"),
1235
+ "step_usage": normalized_event.get("step_usage"),
1236
+ "total_usage": normalized_event.get("total_usage"),
1237
+ "model_usage_delta": normalized_event.get("model_usage_delta"),
1238
+ "model_usage": normalized_event.get("model_usage"),
1239
+ "thinking_and_activity_info": normalized_event.get("thinking_and_activity_info"),
1240
+ }
1241
+
1242
+ @staticmethod
1243
+ def _normalize_usage_metadata(usage: Any | None) -> dict[str, Any] | None:
1244
+ """Normalize usage metadata to a dictionary when possible.
1245
+
1246
+ Args:
1247
+ usage: Usage metadata from LangChain messages.
1248
+
1249
+ Returns:
1250
+ A dictionary copy when usage is available, otherwise None.
1251
+ """
1252
+ if usage is None:
1253
+ return None
1254
+ if isinstance(usage, dict):
1255
+ return dict(usage)
1256
+ return cast(dict[str, Any], usage)
1257
+
1258
+ @staticmethod
1259
+ def _normalize_event_content(content: Any) -> str:
1260
+ """Normalize event content to a string payload.
1261
+
1262
+ Args:
1263
+ content: Raw content payload from LangChain/LangGraph.
1264
+
1265
+ Returns:
1266
+ String representation suitable for A2A events.
1267
+ """
1268
+ if isinstance(content, str):
1269
+ return content
1270
+ try:
1271
+ return json.dumps(content)
1272
+ except (TypeError, ValueError):
1273
+ return str(content)
1274
+
1275
+ async def _stream_with_langgraph(self, query: str, **kwargs: Any) -> AsyncGenerator[str | dict[str, Any], None]:
1276
+ """Handle streaming for LangChain models using LangGraph's native streaming.
1277
+
1278
+ Args:
1279
+ query: The input query for the agent.
1280
+ **kwargs: Additional keyword arguments.
1281
+
1282
+ Yields:
1283
+ Chunks of output (strings or dicts) from the streaming response.
1284
+ """
1285
+ # Create config first to ensure thread_id is generated
1286
+ config = self._create_graph_config(**kwargs)
1287
+ thread_id = self._get_thread_id_from_config(config)
1288
+
1289
+ graph_input = self._prepare_graph_input(query, thread_id=thread_id, **kwargs)
1290
+
1291
+ try:
1292
+ async for event in self._graph_runtime.stream_events(self._compiled_graph, graph_input, config):
1293
+ processed_output = self._process_langgraph_event(event)
1294
+ if processed_output is not None:
1295
+ yield processed_output
1296
+
1297
+ except Exception as e:
1298
+ logger.error(f"Agent '{self.name}': Error during graph streaming: {e}")
1299
+ yield {"error": f"Streaming failed: {e}"}
1300
+
1301
+ async def arun_stream(self, query: str, **kwargs: Any) -> AsyncGenerator[str | dict[str, Any], None]:
1302
+ """Asynchronously stream the LangGraph agent's response.
1303
+
1304
+ If MCP configuration exists, connects to the MCP server and registers tools before streaming.
1305
+ This method properly handles both LM Invoker and LangChain model streaming:
1306
+ - For LM Invoker: Uses StreamEventHandler to capture streaming events
1307
+ - For LangChain models: Uses LangGraph's native streaming implementation
1308
+
1309
+ This method automatically handles cleanup after streaming completes,
1310
+ fails, is cancelled, or the consumer stops early (break/aclose).
1311
+
1312
+ Args:
1313
+ query: The input query for the agent.
1314
+ **kwargs: Additional keyword arguments.
1315
+
1316
+ Yields:
1317
+ Chunks of output (strings or dicts) from the streaming response.
1318
+ """
1319
+ try:
1320
+ await self._ensure_mcp_tools_initialized()
1321
+ async for chunk in self._arun_stream(query, **kwargs):
1322
+ yield chunk
1323
+ finally:
1324
+ await self._auto_cleanup("arun_stream")
1325
+
1326
+ async def _arun_stream(self, query: str, **kwargs: Any) -> AsyncGenerator[str | dict[str, Any], None]:
1327
+ """Internal implementation of arun_stream without MCP handling.
1328
+
1329
+ This method properly handles both LM Invoker and LangChain model streaming:
1330
+ - For LM Invoker: Uses StreamEventHandler to capture streaming events
1331
+ - For LangChain models: Uses LangGraph's native streaming implementation
1332
+
1333
+ Args:
1334
+ query: The input query for the agent.
1335
+ **kwargs: Additional keyword arguments.
1336
+
1337
+ Yields:
1338
+ Chunks of output (strings or dicts) from the streaming response.
1339
+ """
1340
+ if self._has_lm_invoker():
1341
+ async for chunk in self._stream_with_lm_invoker(query, **kwargs):
1342
+ yield chunk
1343
+ else:
1344
+ async for chunk in self._stream_with_langgraph(query, **kwargs):
1345
+ yield chunk
1346
+
1347
+ def _initialize_mcp_client(self) -> None:
1348
+ """Initialize/recreate MCP client with current config safely disposing previous.
1349
+
1350
+ This method creates a new LangchainMCPClient if MCP configuration exists,
1351
+ and safely disposes of any existing client before setting the new one.
1352
+ """
1353
+ new_client = LangchainMCPClient(self.mcp_config) if self.mcp_config else None
1354
+ self._set_mcp_client_safely(new_client)
1355
+
1356
+ async def _register_mcp_tools(self) -> None:
1357
+ """Initialize MCP tools once during agent setup using persistent sessions.
1358
+
1359
+ This method connects to MCP servers, retrieves available tools, and integrates
1360
+ them into the agent's tool collection. It includes timeout handling to prevent
1361
+ hanging operations.
1362
+
1363
+ Raises:
1364
+ RuntimeError: If MCP initialization times out after 30 seconds.
1365
+ Exception: If MCP tool initialization fails for other reasons.
1366
+ """
1367
+ try:
1368
+ logger.info(f"Agent '{self.name}': Initializing MCP tools with persistent sessions.")
1369
+
1370
+ # Add timeout for initialization to prevent hanging
1371
+ mcp_client = self.mcp_client
1372
+ if mcp_client is None:
1373
+ return
1374
+
1375
+ await asyncio.wait_for(mcp_client.initialize(), timeout=MCP_INIT_TIMEOUT)
1376
+
1377
+ mcp_tools = await mcp_client.get_tools()
1378
+
1379
+ if not mcp_tools:
1380
+ logger.warning(f"Agent '{self.name}': No MCP tools retrieved from configured servers.")
1381
+ return
1382
+
1383
+ self.mcp_tools.extend(mcp_tools)
1384
+ logger.info(f"Agent '{self.name}': Added {len(mcp_tools)} persistent MCP tools to graph.")
1385
+ self._rebuild_graph()
1386
+
1387
+ except TimeoutError as err:
1388
+ logger.error(f"Agent '{self.name}': MCP initialization timed out")
1389
+ raise RuntimeError(f"Agent '{self.name}': MCP initialization timed out after 30 seconds") from err
1390
+ except MCPServerStartupError:
1391
+ raise
1392
+ except Exception as e:
1393
+ logger.error(f"Agent '{self.name}': Failed to initialize persistent MCP tools: {e}", exc_info=True)
1394
+ raise
1395
+
1396
+ async def cleanup(self) -> None:
1397
+ """Cleanup MCP resources including persistent sessions.
1398
+
1399
+ This method is called automatically after each ``arun``, ``arun_stream``,
1400
+ and ``arun_a2a_stream`` invocation (including on error or cancellation).
1401
+ Explicit calls are safe but redundant for one-shot usage patterns.
1402
+
1403
+ This method performs best-effort cleanup of MCP client resources
1404
+ and resets lifecycle state so the next run can lazily re-initialize.
1405
+
1406
+ Post-cleanup invariants:
1407
+ - _mcp_tools_initialized is False (allows lazy re-init on next call)
1408
+ - mcp_tools list is cleared (prevents duplicate accumulation)
1409
+ - resolved_tools and compiled graph are rebuilt without stale MCP tools
1410
+
1411
+ Errors during cleanup are logged but do not raise exceptions to ensure
1412
+ the cleanup process completes gracefully. This method is idempotent.
1413
+ """
1414
+ if hasattr(self, "mcp_client") and self.mcp_client:
1415
+ try:
1416
+ await self.mcp_client.cleanup()
1417
+ logger.debug(f"Agent '{self.name}': MCP client cleanup completed")
1418
+ except Exception as e:
1419
+ logger.warning(f"Agent '{self.name}': Error during MCP client cleanup: {e}")
1420
+ # Don't re-raise - cleanup should be best-effort
1421
+
1422
+ # Reset MCP lifecycle state for safe re-initialization on next run
1423
+ self._mcp_tools_initialized = False
1424
+ if hasattr(self, "mcp_tools") and self.mcp_tools:
1425
+ self.mcp_tools.clear()
1426
+ try:
1427
+ self._rebuild_resolved_tools()
1428
+ self._compiled_graph = self._build_and_compile_graph()
1429
+ except Exception as e:
1430
+ logger.warning(f"Agent '{self.name}': Error rebuilding tools/graph during cleanup: {e}")
1431
+ # Don't re-raise - cleanup should be best-effort
1432
+
1433
+ async def arun_a2a_stream(self, query: str, **kwargs: Any) -> AsyncGenerator[A2AEvent, None]:
1434
+ """Asynchronously streams the agent's response in A2A format.
1435
+
1436
+ This method automatically handles cleanup after streaming completes,
1437
+ fails, is cancelled, or the consumer stops early (break/aclose).
1438
+
1439
+ Args:
1440
+ query: The input query for the agent.
1441
+ **kwargs: Additional keyword arguments.
1442
+
1443
+ Yields:
1444
+ Dictionaries with "status" and "content" keys.
1445
+ Possible statuses: "working", "completed", "failed", "canceled".
1446
+ """
1447
+ try:
1448
+ await self._ensure_mcp_tools_initialized()
1449
+ async for chunk in self._arun_a2a_stream(query, **kwargs):
1450
+ yield chunk
1451
+ finally:
1452
+ await self._auto_cleanup("arun_a2a_stream")
1453
+
1454
+ async def arun_sse_stream(
1455
+ self,
1456
+ query: str,
1457
+ task_id: str | None = None,
1458
+ context_id: str | None = None,
1459
+ **kwargs: Any,
1460
+ ) -> AsyncGenerator[A2AEvent, None]:
1461
+ """Stream agent response as SSE-compatible chunks.
1462
+
1463
+ This method wraps arun_a2a_stream and transforms output to the normalized
1464
+ dict format matching A2AConnector.astream_to_agent output, enabling direct
1465
+ streaming without A2A server overhead.
1466
+
1467
+ Args:
1468
+ query: The input query for the agent.
1469
+ task_id: Optional task identifier for the stream.
1470
+ context_id: Optional context identifier for the stream.
1471
+ **kwargs: Additional arguments passed to arun_a2a_stream.
1472
+
1473
+ Yields:
1474
+ SSEChunk dicts with normalized structure:
1475
+ - status: "success" | "error"
1476
+ - task_state: "working" | "completed" | "failed" | "canceled"
1477
+ - content: Text content or None
1478
+ - event_type: Always string (never enum)
1479
+ - final: True for terminal events
1480
+ - metadata: Normalized metadata dict
1481
+ - artifacts: Only present when non-empty
1482
+ """
1483
+ if task_id is None:
1484
+ task_id = str(uuid.uuid4())
1485
+ if context_id is None:
1486
+ context_id = str(uuid.uuid4())
1487
+
1488
+ # Extract pii_mapping from kwargs to pass to transformer (matching A2A executor behavior)
1489
+ pii_mapping = kwargs.get("pii_mapping")
1490
+ transformer = SSEChunkTransformer(task_id=task_id, context_id=context_id, pii_mapping=pii_mapping)
1491
+ try:
1492
+ stream = self.arun_a2a_stream(query, **kwargs)
1493
+ async for chunk in transformer.transform_stream(stream):
1494
+ yield chunk
1495
+ except Exception as e:
1496
+ # Lazy import to support optional guardrails dependency
1497
+ from aip_agents.guardrails.exceptions import GuardrailViolationError
1498
+
1499
+ if isinstance(e, GuardrailViolationError):
1500
+ # Re-raise guardrail violations without modification
1501
+ raise
1502
+ logger.error(f"Agent '{self.name}': Error in arun_sse_stream: {e}", exc_info=True)
1503
+ yield SSEChunkTransformer._create_error_chunk(f"Error during streaming: {e}")
1504
+
1505
+ def _get_tool_processing_content(self, tool_names: list[str]) -> str:
1506
+ """Generate appropriate content prefix for tool processing messages.
1507
+
1508
+ Args:
1509
+ tool_names: List of tool names to process.
1510
+
1511
+ Returns:
1512
+ Formatted content string with appropriate prefix.
1513
+ """
1514
+ unique_tool_names = list(dict.fromkeys(tool_names))
1515
+ has_delegation_tools = any(name.startswith("delegate_to") for name in unique_tool_names)
1516
+ content_prefix = "Processing with sub-agents:" if has_delegation_tools else "Processing with tools:"
1517
+ return f"{content_prefix} {', '.join(unique_tool_names)}"
1518
+
1519
+ def _get_tool_completion_content(self, tool_names: list[str]) -> str:
1520
+ """Generate completion message for finished tool executions.
1521
+
1522
+ Args:
1523
+ tool_names: List of tool names to summarize.
1524
+
1525
+ Returns:
1526
+ Content string indicating completion.
1527
+ """
1528
+ unique_tool_names = list(dict.fromkeys(tool_names))
1529
+ has_delegation_tools = any(name.startswith("delegate_to") for name in unique_tool_names)
1530
+ content_prefix = "Completed sub-agents:" if has_delegation_tools else "Completed tools:"
1531
+ return f"{content_prefix} {', '.join(unique_tool_names)}"
1532
+
1533
+ def _parse_a2a_stream_message(
1534
+ self, message: BaseMessage, state: dict[str, Any] | None = None
1535
+ ) -> tuple[A2AEvent | None, bool]:
1536
+ """Parse LangChain messages into semantically meaningful A2A events.
1537
+
1538
+ This method converts LangChain message types (AIMessage, ToolMessage) into
1539
+ structured A2AEvent objects that preserve semantic information and eliminate
1540
+ the need for string parsing downstream.
1541
+
1542
+ Args:
1543
+ message: The LangChain message to parse (AIMessage, ToolMessage, etc.).
1544
+ state: Optional state dictionary containing pii_mapping and other data.
1545
+
1546
+ Returns:
1547
+ A tuple containing:
1548
+ - A2AEvent | None: The parsed A2A event, or None if message should be skipped.
1549
+ - bool: True if this is a final event that should terminate the stream.
1550
+ """
1551
+ if isinstance(message, AIMessage) and message.tool_calls:
1552
+ return self._create_tool_call_event(message), False
1553
+
1554
+ elif isinstance(message, ToolMessage):
1555
+ return self._create_tool_result_event(message), False
1556
+
1557
+ elif isinstance(message, AIMessage) and message.content:
1558
+ return self._create_ai_message_event(message, state)
1559
+
1560
+ return None, False
1561
+
1562
+ def _link_tool_call_to_previous_status(self, event: A2AEvent) -> None:
1563
+ """Link the tool call event to completed tool steps or the most recent status step.
1564
+
1565
+ Supports both parallel and sequential operation modes:
1566
+ - "parallel": Links to ALL completed tool steps (default for backward compatibility)
1567
+ - "sequential": Links to only the most recent completed tool step
1568
+
1569
+ Args:
1570
+ event: The A2AEvent to link to previous step.
1571
+ """
1572
+ try:
1573
+ thread_id = _THREAD_ID_CVAR.get()
1574
+ if thread_id:
1575
+ metadata = event.get("metadata", {})
1576
+ existing_step_ids = metadata.get("previous_step_ids") or []
1577
+
1578
+ if existing_step_ids:
1579
+ return
1580
+
1581
+ operation_mode = _OPERATION_MODE_CVAR.get() or "parallel"
1582
+
1583
+ coord_completed_steps = self._coordinator_completed_tool_steps_by_thread.get(thread_id, [])
1584
+ completed_steps = coord_completed_steps or self._completed_tool_steps_by_thread.get(thread_id, [])
1585
+ if completed_steps:
1586
+ if operation_mode == "sequential":
1587
+ metadata["previous_step_ids"] = [completed_steps[-1]]
1588
+ else:
1589
+ metadata["previous_step_ids"] = completed_steps
1590
+ event["metadata"] = metadata
1591
+ return
1592
+
1593
+ last_status_id = self._last_status_step_id_by_thread.get(thread_id)
1594
+ if last_status_id:
1595
+ metadata["previous_step_ids"] = [last_status_id]
1596
+ event["metadata"] = metadata
1597
+ except Exception as e:
1598
+ logger.warning("Failed linking tool call to previous step: %s", e, exc_info=True)
1599
+
1600
+ def _register_tool_call_parent_steps(self, event: A2AEvent, tool_calls_details: list[dict]) -> None:
1601
+ """Register parent step IDs for each tool call ID.
1602
+
1603
+ Args:
1604
+ event: The A2AEvent containing the parent step.
1605
+ tool_calls_details: List of tool call details.
1606
+ """
1607
+ try:
1608
+ thread_id = _THREAD_ID_CVAR.get()
1609
+ if thread_id:
1610
+ parent_step_id = event["metadata"].get("step_id")
1611
+ if parent_step_id:
1612
+ parent_map = self._tool_parent_map_by_thread.setdefault(thread_id, {})
1613
+ for tool_call in tool_calls_details:
1614
+ tool_call_id = tool_call.get("id")
1615
+ if tool_call_id:
1616
+ parent_map[str(tool_call_id)] = str(parent_step_id)
1617
+ except Exception as e:
1618
+ logger.warning("Registering tool call parent steps failed: %s", e, exc_info=True)
1619
+
1620
+ def _create_tool_call_event(self, message: AIMessage) -> A2AEvent:
1621
+ """Create an A2AEvent for tool invocation from AIMessage.
1622
+
1623
+ Args:
1624
+ message: AIMessage containing tool calls.
1625
+
1626
+ Returns:
1627
+ A2AEvent with TOOL_CALL event type and structured tool information.
1628
+ """
1629
+ tool_calls_details: list[dict[str, Any]] = []
1630
+ manager = getattr(self, "tool_output_manager", None)
1631
+ thread_id = _THREAD_ID_CVAR.get()
1632
+ for tool_call in message.tool_calls:
1633
+ args = tool_call["args"]
1634
+ if manager and thread_id and isinstance(args, dict):
1635
+ args = manager.rewrite_args_with_latest_reference(args, thread_id)
1636
+ tool_calls_details.append(
1637
+ {
1638
+ "id": tool_call.get("id"),
1639
+ "name": tool_call["name"],
1640
+ "args": args,
1641
+ }
1642
+ )
1643
+ tool_names = [details["name"] for details in tool_calls_details]
1644
+
1645
+ event = self._create_a2a_event(
1646
+ event_type=A2AStreamEventType.TOOL_CALL,
1647
+ content=self._get_tool_processing_content(tool_names),
1648
+ tool_info={"tool_calls": tool_calls_details, "status": "running"},
1649
+ metadata={"status": Status.RUNNING},
1650
+ is_final=False,
1651
+ step_usage=self._normalize_usage_metadata(message.usage_metadata),
1652
+ )
1653
+
1654
+ self._record_emitted_tool_calls(tool_calls_details)
1655
+
1656
+ self._link_tool_call_to_previous_status(event)
1657
+ self._register_tool_call_parent_steps(event, tool_calls_details)
1658
+
1659
+ return event
1660
+
1661
+ def _get_sub_agent_previous_steps(self, message: ToolMessage) -> list[str] | None:
1662
+ """Extract previous step IDs from sub-agent response metadata.
1663
+
1664
+ Args:
1665
+ message: ToolMessage containing response metadata.
1666
+
1667
+ Returns:
1668
+ List of previous step IDs or None if not available.
1669
+ """
1670
+ try:
1671
+ if not hasattr(message, "response_metadata") or not isinstance(message.response_metadata, dict):
1672
+ return None
1673
+
1674
+ sub_prev = message.response_metadata.get("previous_step_ids")
1675
+ if isinstance(sub_prev, list) and sub_prev:
1676
+ return [str(x) for x in sub_prev if isinstance(x, str | int)]
1677
+ return None
1678
+ except Exception as e:
1679
+ logger.warning("Failed extracting sub-agent previous steps: %s", e, exc_info=True)
1680
+ return None
1681
+
1682
+ def _determine_previous_step_ids(self, message: ToolMessage, sub_prev: list[str] | None) -> list[str]:
1683
+ """Determine which previous step IDs to use for the event.
1684
+
1685
+ Args:
1686
+ message: ToolMessage for the tool call.
1687
+ sub_prev: Previous step IDs from sub-agent, if available.
1688
+
1689
+ Returns:
1690
+ List of previous step IDs to use.
1691
+ """
1692
+ if sub_prev:
1693
+ return sub_prev
1694
+
1695
+ try:
1696
+ thread_id = _THREAD_ID_CVAR.get()
1697
+ if thread_id:
1698
+ parent_map = self._tool_parent_map_by_thread.get(thread_id, {})
1699
+ parent_step = parent_map.get(str(message.tool_call_id))
1700
+ if parent_step:
1701
+ return [parent_step]
1702
+ except Exception as e:
1703
+ logger.warning("Determining previous step IDs failed: %s", e, exc_info=True)
1704
+
1705
+ return []
1706
+
1707
+ def _record_tool_completion(self, message: ToolMessage, event: A2AEvent) -> None:
1708
+ """Record tool completion for final event dependency tracking.
1709
+
1710
+ Args:
1711
+ message: ToolMessage for the completed tool.
1712
+ event: The A2AEvent for the tool result.
1713
+ """
1714
+ try:
1715
+ thread_id = _THREAD_ID_CVAR.get()
1716
+ if not thread_id:
1717
+ return
1718
+
1719
+ completed_list = self._completed_tool_steps_by_thread.setdefault(thread_id, [])
1720
+ coord_completed_list = self._coordinator_completed_tool_steps_by_thread.setdefault(thread_id, [])
1721
+
1722
+ event_sid = (event.get("metadata") or {}).get("step_id")
1723
+ if isinstance(event_sid, str) and event_sid:
1724
+ completed_list.append(event_sid)
1725
+ coord_completed_list.append(event_sid)
1726
+
1727
+ sub_prev = self._get_sub_agent_previous_steps(message) or []
1728
+ completed_list.extend(step_id for step_id in sub_prev if isinstance(step_id, str) and step_id)
1729
+
1730
+ self._completed_tool_steps_by_thread[thread_id] = list(dict.fromkeys(completed_list))
1731
+ self._coordinator_completed_tool_steps_by_thread[thread_id] = list(dict.fromkeys(coord_completed_list))
1732
+ except Exception as e:
1733
+ logger.warning("Recording tool completion failed: %s", e, exc_info=True)
1734
+
1735
+ def _create_tool_result_event(self, message: ToolMessage) -> A2AEvent:
1736
+ """Create an A2AEvent for tool completion from ToolMessage.
1737
+
1738
+ Args:
1739
+ message: ToolMessage containing tool execution results.
1740
+
1741
+ Returns:
1742
+ A2AEvent with TOOL_RESULT event type and execution details.
1743
+ """
1744
+ tool_info = self._extract_tool_info_from_message(message)
1745
+ previous_ids = self._determine_previous_step_ids(
1746
+ message,
1747
+ self._get_sub_agent_previous_steps(message),
1748
+ )
1749
+
1750
+ event = self._create_a2a_event(
1751
+ event_type=A2AStreamEventType.TOOL_RESULT,
1752
+ content=self._build_tool_event_content(tool_info["name"], tool_info["output"], message),
1753
+ tool_info={
1754
+ "id": message.tool_call_id,
1755
+ "name": tool_info["name"],
1756
+ "args": tool_info["args"],
1757
+ "output": tool_info["output"],
1758
+ "execution_time": tool_info["execution_time"],
1759
+ },
1760
+ metadata=self._build_tool_event_metadata(tool_info["execution_time"], previous_ids),
1761
+ is_final=False,
1762
+ step_usage=message.response_metadata.get(USAGE_METADATA_KEY),
1763
+ model_usage_delta=message.response_metadata.get(MODEL_USAGE_DELTA_KEY),
1764
+ )
1765
+
1766
+ self._propagate_hitl_metadata(message, event)
1767
+ self._propagate_tool_execution_metadata(message, event)
1768
+ self._record_tool_completion(message, event)
1769
+ self._discard_emitted_tool_call(getattr(message, "tool_call_id", None))
1770
+
1771
+ return event
1772
+
1773
+ def _extract_tool_info_from_message(self, message: ToolMessage) -> dict[str, Any]:
1774
+ """Extract tool details from a ToolMessage.
1775
+
1776
+ Args:
1777
+ message: The ToolMessage to extract information from.
1778
+
1779
+ Returns:
1780
+ Dictionary containing tool name, args, output, and execution time.
1781
+ """
1782
+ tool_call_info = getattr(message, "tool_calls", {})
1783
+ tool_name = getattr(message, "name", None) or tool_call_info.get("name", "unknown")
1784
+ return {
1785
+ "name": tool_name,
1786
+ "args": tool_call_info.get("args", {}),
1787
+ "output": tool_call_info.get("output", message.content),
1788
+ "execution_time": tool_call_info.get("time"),
1789
+ }
1790
+
1791
+ def _build_tool_event_content(self, tool_name: str, tool_output: Any, message: ToolMessage) -> str:
1792
+ """Determine event content for a tool result.
1793
+
1794
+ Args:
1795
+ tool_name: Name of the tool that was executed.
1796
+ tool_output: The output returned by the tool.
1797
+ message: The ToolMessage containing response metadata and tool call information.
1798
+
1799
+ Returns:
1800
+ String content for the tool result event.
1801
+ """
1802
+ response_metadata = getattr(message, "response_metadata", None) or {}
1803
+ hitl_meta = response_metadata.get(MetadataFieldKeys.HITL) if isinstance(response_metadata, dict) else None
1804
+
1805
+ if hitl_meta and hitl_meta.get("required"):
1806
+ return str(tool_output) if tool_output else self._get_tool_processing_content([tool_name])
1807
+
1808
+ return self._get_tool_completion_content([tool_name])
1809
+
1810
+ def _build_tool_event_metadata(
1811
+ self,
1812
+ execution_time: Any,
1813
+ previous_ids: list[str] | None,
1814
+ ) -> dict[str, Any]:
1815
+ """Build metadata payload for tool result events.
1816
+
1817
+ Args:
1818
+ execution_time: Time taken to execute the tool.
1819
+ previous_ids: Optional list of previous step IDs this tool depends on.
1820
+
1821
+ Returns:
1822
+ Dictionary containing status, execution time, and previous step IDs.
1823
+ """
1824
+ return {
1825
+ "status": Status.FINISHED,
1826
+ "time": execution_time,
1827
+ "previous_step_ids": previous_ids,
1828
+ }
1829
+
1830
+ def _propagate_hitl_metadata(self, message: ToolMessage, event: A2AEvent) -> None:
1831
+ """Copy HITL metadata from ToolMessage into the event if available.
1832
+
1833
+ Args:
1834
+ message: The ToolMessage containing response metadata with HITL information.
1835
+ event: The A2AEvent to update with HITL metadata if present.
1836
+ """
1837
+ response_metadata = getattr(message, "response_metadata", None)
1838
+ if not isinstance(response_metadata, dict):
1839
+ return
1840
+
1841
+ hitl_meta = response_metadata.get(MetadataFieldKeys.HITL)
1842
+ if hitl_meta is None:
1843
+ return
1844
+
1845
+ try:
1846
+ hitl_model = HitlMetadata.model_validate(hitl_meta)
1847
+ except ValidationError as exc:
1848
+ raise ValueError("Invalid HITL metadata payload encountered") from exc
1849
+
1850
+ metadata = event.get("metadata")
1851
+ if isinstance(metadata, dict):
1852
+ try:
1853
+ metadata[MetadataFieldKeys.HITL] = hitl_model.as_payload()
1854
+ except Exception as exc: # noqa: BLE001
1855
+ logger.warning("Failed to propagate HITL metadata to event: %s", exc)
1856
+
1857
+ def _propagate_tool_execution_metadata(self, message: ToolMessage, event: A2AEvent) -> None:
1858
+ """Copy tool execution metadata into TOOL_RESULT event payload when available.
1859
+
1860
+ Args:
1861
+ message: The ToolMessage containing response metadata.
1862
+ event: The A2AEvent to update with tool execution metadata.
1863
+ """
1864
+ response_metadata = getattr(message, "response_metadata", None)
1865
+ if not isinstance(response_metadata, dict):
1866
+ return
1867
+
1868
+ tool_execution = response_metadata.get("tool_execution")
1869
+ if not isinstance(tool_execution, dict) or not tool_execution:
1870
+ return
1871
+
1872
+ metadata = event.get("metadata")
1873
+ if not isinstance(metadata, dict):
1874
+ return
1875
+
1876
+ metadata["tool_execution"] = dict(tool_execution)
1877
+
1878
+ def _should_emit_provider_reasoning(self, message: BaseMessage) -> bool:
1879
+ """Check if an AIMessage has provider reasoning to emit.
1880
+
1881
+ Args:
1882
+ message: The LangChain message to check.
1883
+
1884
+ Returns:
1885
+ True if the message is an AIMessage with serialized thinking content.
1886
+ """
1887
+ return isinstance(message, AIMessage) and bool(message.additional_kwargs.get(THINKINGS_METADATA_KEY))
1888
+
1889
+ def _get_reasoning_previous_step_ids(self) -> list[str]:
1890
+ """Get parent step IDs for a provider reasoning event."""
1891
+ previous_step_ids = self._get_previous_step_ids() or []
1892
+ if previous_step_ids:
1893
+ return previous_step_ids
1894
+
1895
+ thread_id = _THREAD_ID_CVAR.get()
1896
+ if not thread_id:
1897
+ return [] # pragma: no cover
1898
+
1899
+ last_status_id = self._last_status_step_id_by_thread.get(thread_id)
1900
+ return [last_status_id] if last_status_id else []
1901
+
1902
+ def _create_provider_reasoning_event(self, message: AIMessage) -> A2AEvent | None:
1903
+ """Create an A2AEvent for provider reasoning/thinking content.
1904
+
1905
+ Extracts serialized thinking blocks from ``gllm_thinkings`` metadata
1906
+ and emits them as a completed ``agent_thinking_step`` status update
1907
+ using the existing ``thinking_and_activity_info`` contract.
1908
+
1909
+ Args:
1910
+ message: AIMessage containing ``gllm_thinkings`` in additional_kwargs.
1911
+
1912
+ Returns:
1913
+ A2AEvent for the provider reasoning step, or None if no
1914
+ non-empty thinking text could be extracted.
1915
+ """
1916
+ raw_thinkings = message.additional_kwargs.get(THINKINGS_METADATA_KEY)
1917
+ if not isinstance(raw_thinkings, list) or not raw_thinkings:
1918
+ return None
1919
+
1920
+ thinking_texts: list[str] = []
1921
+ for entry in raw_thinkings:
1922
+ if not isinstance(entry, dict):
1923
+ continue
1924
+ text = entry.get("thinking", "") or ""
1925
+ if isinstance(text, str) and text.strip():
1926
+ thinking_texts.append(text)
1927
+
1928
+ combined = "\n\n".join(thinking_texts)
1929
+ if not combined.strip():
1930
+ return None
1931
+
1932
+ content_hash = hashlib.sha256(combined.encode("utf-8")).hexdigest()[:8]
1933
+ step_id = f"provider_reasoning_{content_hash}"
1934
+
1935
+ thinking_and_activity_info = {
1936
+ "id": step_id,
1937
+ "data_type": "activity",
1938
+ "data_value": json.dumps({"type": "reasoning", "message": combined}),
1939
+ }
1940
+
1941
+ return self._create_a2a_event(
1942
+ event_type=A2AStreamEventType.STATUS_UPDATE,
1943
+ content="",
1944
+ tool_info=None,
1945
+ metadata={
1946
+ MetadataFieldKeys.KIND: Kind.AGENT_THINKING_STEP,
1947
+ MetadataFieldKeys.STATUS: Status.FINISHED,
1948
+ "step_id": step_id,
1949
+ "previous_step_ids": self._get_reasoning_previous_step_ids(),
1950
+ MetadataFieldKeys.THINKING_AND_ACTIVITY_INFO: thinking_and_activity_info,
1951
+ },
1952
+ is_final=False,
1953
+ )
1954
+
1955
+ def _create_ai_message_event(
1956
+ self, message: AIMessage, state: dict[str, Any] | None = None
1957
+ ) -> tuple[A2AEvent, bool]:
1958
+ """Create an A2AEvent for AI-generated content from AIMessage.
1959
+
1960
+ Args:
1961
+ message: AIMessage containing AI-generated content.
1962
+ state: Optional state dictionary containing pii_mapping and other data.
1963
+
1964
+ Returns:
1965
+ A tuple containing:
1966
+ - A2AEvent: Either CONTENT_CHUNK or FINAL_RESPONSE event.
1967
+ - bool: True if this is a final response, False for streaming content.
1968
+ """
1969
+ is_final_response = self._is_final_response(message)
1970
+ metadata = self._build_metadata_for_final_response(is_final_response, state)
1971
+ raw_content = message.content
1972
+ content = deanonymize_final_response_content(
1973
+ content=raw_content if isinstance(raw_content, str) else json.dumps(raw_content),
1974
+ is_final_response=is_final_response,
1975
+ metadata=metadata,
1976
+ )
1977
+ event = self._create_a2a_event(
1978
+ event_type=A2AStreamEventType.FINAL_RESPONSE if is_final_response else A2AStreamEventType.CONTENT_CHUNK,
1979
+ content=content,
1980
+ tool_info=None,
1981
+ metadata=metadata,
1982
+ is_final=is_final_response,
1983
+ step_usage=self._normalize_usage_metadata(message.usage_metadata),
1984
+ model_usage_delta=(message.response_metadata or {}).get(MODEL_USAGE_DELTA_KEY),
1985
+ )
1986
+ return event, is_final_response
1987
+
1988
+ def _is_final_response(self, message: AIMessage) -> bool:
1989
+ """Check if the message represents a final response.
1990
+
1991
+ Args:
1992
+ message: AIMessage to check.
1993
+
1994
+ Returns:
1995
+ True if this is a final response, False otherwise.
1996
+ """
1997
+ return bool(message.response_metadata) and message.response_metadata.get("finish_reason") == "stop"
1998
+
1999
+ def _build_metadata_for_final_response(
2000
+ self, is_final_response: bool, state: dict[str, Any] | None = None
2001
+ ) -> dict[str, Any]:
2002
+ """Build metadata for final response including previous_step_ids and pii_mapping.
2003
+
2004
+ Args:
2005
+ is_final_response: Whether this is a final response.
2006
+ state: Optional state dictionary containing pii_mapping and other data.
2007
+
2008
+ Returns:
2009
+ Metadata dictionary with previous_step_ids and pii_mapping if applicable.
2010
+ """
2011
+ metadata: dict[str, Any] = {}
2012
+
2013
+ if not is_final_response:
2014
+ return metadata
2015
+
2016
+ try:
2017
+ previous_step_ids = self._get_previous_step_ids()
2018
+ if previous_step_ids:
2019
+ metadata["previous_step_ids"] = previous_step_ids
2020
+ except Exception as e:
2021
+ logger.warning("Attaching previous_step_ids to final response failed: %s", e, exc_info=True)
2022
+
2023
+ # Add PII mapping if present in state or nested metadata
2024
+ if state:
2025
+ pii_mapping = state.get("pii_mapping") or state.get("metadata", {}).get("pii_mapping")
2026
+ if pii_mapping:
2027
+ metadata[MetadataFieldKeys.PII_MAPPING] = pii_mapping
2028
+ a2ui_content = state.get("a2ui_content")
2029
+ if isinstance(a2ui_content, dict):
2030
+ metadata["a2ui_content"] = a2ui_content
2031
+
2032
+ return metadata
2033
+
2034
+ def _get_previous_step_ids(self) -> list[str] | None:
2035
+ """Get the list of previous step IDs based on thread context and operation mode.
2036
+
2037
+ Returns:
2038
+ List of step IDs or None if no thread context or steps found.
2039
+ """
2040
+ thread_id = _THREAD_ID_CVAR.get()
2041
+ if not thread_id:
2042
+ return None
2043
+
2044
+ operation_mode = _OPERATION_MODE_CVAR.get() or "parallel"
2045
+
2046
+ coord_ids = self._coordinator_completed_tool_steps_by_thread.get(thread_id, [])
2047
+ if coord_ids:
2048
+ return self._filter_step_ids_by_mode(coord_ids, operation_mode)
2049
+
2050
+ completed_ids = self._completed_tool_steps_by_thread.get(thread_id, [])
2051
+ if completed_ids:
2052
+ return self._filter_step_ids_by_mode(completed_ids, operation_mode)
2053
+
2054
+ return None
2055
+
2056
+ def _filter_step_ids_by_mode(self, step_ids: list[str], operation_mode: str) -> list[str]:
2057
+ """Filter step IDs based on operation mode.
2058
+
2059
+ Args:
2060
+ step_ids: List of step IDs to filter.
2061
+ operation_mode: Either "sequential" or "parallel".
2062
+
2063
+ Returns:
2064
+ Filtered list of step IDs.
2065
+ """
2066
+ if operation_mode == "sequential":
2067
+ return [step_ids[-1]] if step_ids else []
2068
+ else:
2069
+ return step_ids
2070
+
2071
+ def _process_artifacts(
2072
+ self,
2073
+ item: dict[str, Any],
2074
+ pending_artifacts: list,
2075
+ seen_artifact_hashes: set,
2076
+ ) -> None:
2077
+ """Process artifacts from a graph stream item.
2078
+
2079
+ Args:
2080
+ item: The event item from the graph stream.
2081
+ pending_artifacts: A list of artifacts waiting to be attached to a message.
2082
+ seen_artifact_hashes: A set of hashes of artifacts already processed.
2083
+ """
2084
+ if "artifacts" not in item or not item["artifacts"]:
2085
+ return
2086
+
2087
+ logger.info(f"Agent '{self.name}': Artifacts: {len(item['artifacts'])}")
2088
+ for artifact in item["artifacts"]:
2089
+ artifact_data = str(artifact.get("data", ""))
2090
+ artifact_name = artifact.get("name", "")
2091
+ artifact_hash = hashlib.sha256(f"{artifact_data}:{artifact_name}".encode()).hexdigest()
2092
+
2093
+ if artifact_hash not in seen_artifact_hashes:
2094
+ pending_artifacts.append(artifact)
2095
+ seen_artifact_hashes.add(artifact_hash)
2096
+
2097
+ def _process_a2a_stream_item(
2098
+ self,
2099
+ item: dict[str, Any],
2100
+ pending_artifacts: list,
2101
+ seen_artifact_hashes: set,
2102
+ processed_message_count: int,
2103
+ ) -> tuple[list[A2AEvent], bool, int]:
2104
+ """Process a single item from the graph stream, handling artifacts and messages.
2105
+
2106
+ This method processes LangGraph stream items and converts them into A2AEvent objects
2107
+ with proper metadata merging, artifact attachment, and reference handling.
2108
+
2109
+ Args:
2110
+ item: The event item from the graph stream containing messages and metadata.
2111
+ pending_artifacts: List of artifacts waiting to be attached to messages.
2112
+ seen_artifact_hashes: Set of hashes of artifacts already processed.
2113
+ processed_message_count: Number of messages already processed from the stream.
2114
+
2115
+ Returns:
2116
+ A tuple containing:
2117
+ - list[A2AEvent]: List of A2A events to yield to the executor.
2118
+ - bool: True if a final event was encountered.
2119
+ - int: Updated count of processed messages.
2120
+ """
2121
+ events_to_yield = []
2122
+ is_final_event = False
2123
+
2124
+ self._process_artifacts(item, pending_artifacts, seen_artifact_hashes)
2125
+ references = item.get("references", [])
2126
+
2127
+ if "messages" not in item or not item["messages"]:
2128
+ return [], False, processed_message_count
2129
+
2130
+ new_messages = item["messages"][processed_message_count:]
2131
+ updated_message_count = len(item["messages"])
2132
+ for message in new_messages:
2133
+ message_events, message_is_final = self._process_stream_message_item(
2134
+ message,
2135
+ item,
2136
+ pending_artifacts,
2137
+ references,
2138
+ )
2139
+ events_to_yield.extend(message_events)
2140
+ if message_is_final:
2141
+ is_final_event = True
2142
+
2143
+ return events_to_yield, is_final_event, updated_message_count
2144
+
2145
+ def _process_stream_message_item(
2146
+ self,
2147
+ message: BaseMessage,
2148
+ item: dict[str, Any],
2149
+ pending_artifacts: list,
2150
+ references: list[Any],
2151
+ ) -> tuple[list[A2AEvent], bool]:
2152
+ """Process a single message from the graph stream.
2153
+
2154
+ Returns:
2155
+ A tuple of (events to yield, is_final).
2156
+ """
2157
+ event_data, is_final = self._parse_a2a_stream_message(message, item)
2158
+ events: list[A2AEvent] = []
2159
+ reasoning_step_id = self._emit_provider_reasoning_if_present(message, events)
2160
+
2161
+ if event_data and self._should_yield_a2a_event(event_data):
2162
+ self._enhance_event_with_context(event_data, item, pending_artifacts, references, is_final)
2163
+ if reasoning_step_id:
2164
+ event_data.setdefault("metadata", {})["previous_step_ids"] = [reasoning_step_id]
2165
+ events.append(event_data)
2166
+
2167
+ return events, is_final
2168
+
2169
+ def _emit_provider_reasoning_if_present(
2170
+ self,
2171
+ message: BaseMessage,
2172
+ events: list[A2AEvent],
2173
+ ) -> str | None:
2174
+ """Emit a provider reasoning event if the message carries thinkings.
2175
+
2176
+ Returns:
2177
+ The reasoning step ID if an event was emitted, else None.
2178
+ """
2179
+ if not self._should_emit_provider_reasoning(message):
2180
+ return None
2181
+ reasoning_event = self._create_provider_reasoning_event(message)
2182
+ if not reasoning_event:
2183
+ return None
2184
+ events.append(reasoning_event)
2185
+ return (reasoning_event.get("metadata") or {}).get("step_id")
2186
+
2187
+ def _enhance_event_with_context(
2188
+ self,
2189
+ event_data: A2AEvent,
2190
+ stream_item: dict[str, Any],
2191
+ pending_artifacts: list,
2192
+ references: list[Any],
2193
+ is_final: bool,
2194
+ ) -> None:
2195
+ """Enhance A2AEvent with context from the stream item.
2196
+
2197
+ This method adds metadata, artifacts, and references to the A2AEvent
2198
+ based on the current stream item context.
2199
+
2200
+ Args:
2201
+ event_data: The A2AEvent to enhance.
2202
+ stream_item: The stream item containing context information.
2203
+ pending_artifacts: List of artifacts to attach to the event.
2204
+ references: List of references to attach to final events.
2205
+ is_final: Whether this is a final event.
2206
+ """
2207
+ self._merge_event_metadata(event_data, stream_item)
2208
+ self._attach_pending_artifacts(event_data, pending_artifacts)
2209
+
2210
+ if is_final and references:
2211
+ self._attach_references_to_final_event(event_data, references)
2212
+
2213
+ if is_final and stream_item.get(TOTAL_USAGE_KEY):
2214
+ event_data[TOTAL_USAGE_KEY] = stream_item[TOTAL_USAGE_KEY]
2215
+ if is_final and stream_item.get(MODEL_USAGE_KEY):
2216
+ event_data[MODEL_USAGE_KEY] = stream_item[MODEL_USAGE_KEY]
2217
+
2218
+ def _merge_previous_step_ids(
2219
+ self,
2220
+ state_prev: list[str | int | None] | None,
2221
+ event_prev: list[str | int | None] | None,
2222
+ ) -> list[str | int] | None:
2223
+ """Merge previous_step_ids from state and event metadata.
2224
+
2225
+ Args:
2226
+ state_prev: Previous step IDs from state metadata.
2227
+ event_prev: Previous step IDs from event metadata.
2228
+
2229
+ Returns:
2230
+ Combined list of previous step IDs, or None if no lists to merge.
2231
+ """
2232
+ if (state_prev is None and event_prev is None) or (
2233
+ not isinstance(state_prev, list)
2234
+ and state_prev is not None
2235
+ and not isinstance(event_prev, list)
2236
+ and event_prev is not None
2237
+ ):
2238
+ return None
2239
+
2240
+ state_list = state_prev if isinstance(state_prev, list) else []
2241
+ event_list = event_prev if isinstance(event_prev, list) else []
2242
+
2243
+ combined: list[Any] = []
2244
+
2245
+ for step_id in event_list:
2246
+ if step_id is not None and step_id not in combined:
2247
+ combined.append(step_id)
2248
+
2249
+ for step_id in state_list:
2250
+ if step_id is not None and step_id not in combined:
2251
+ combined.append(step_id)
2252
+
2253
+ return combined
2254
+
2255
+ def _merge_event_metadata(self, event_data: A2AEvent, stream_item: dict[str, Any]) -> None:
2256
+ """Merge metadata from stream item into the A2AEvent.
2257
+
2258
+ Args:
2259
+ event_data: The A2AEvent to update with merged metadata.
2260
+ stream_item: The stream item containing state metadata.
2261
+ """
2262
+ state_metadata = stream_item.get("metadata", {})
2263
+ existing_metadata = event_data.get("metadata", {})
2264
+ if isinstance(existing_metadata, dict) and isinstance(state_metadata, dict):
2265
+ merged_metadata = {**state_metadata, **existing_metadata}
2266
+
2267
+ state_prev = state_metadata.get("previous_step_ids") or []
2268
+ event_prev = existing_metadata.get("previous_step_ids") or []
2269
+ combined_ids = self._merge_previous_step_ids(state_prev, event_prev)
2270
+ if combined_ids is not None:
2271
+ merged_metadata["previous_step_ids"] = combined_ids
2272
+ else:
2273
+ merged_metadata = state_metadata or existing_metadata
2274
+
2275
+ event_data["metadata"] = merged_metadata
2276
+
2277
+ def _attach_pending_artifacts(self, event_data: A2AEvent, pending_artifacts: list) -> None:
2278
+ """Attach pending artifacts to the A2AEvent and clear the pending list.
2279
+
2280
+ Args:
2281
+ event_data: The A2AEvent to attach artifacts to.
2282
+ pending_artifacts: List of artifacts to attach and clear.
2283
+ """
2284
+ if pending_artifacts:
2285
+ event_data["artifacts"] = pending_artifacts.copy()
2286
+ pending_artifacts.clear()
2287
+
2288
+ def _attach_references_to_final_event(self, event_data: A2AEvent, references: list[Any]) -> None:
2289
+ """Attach references to final events.
2290
+
2291
+ Args:
2292
+ event_data: The final A2AEvent to attach references to.
2293
+ references: List of references to attach.
2294
+ """
2295
+ if references:
2296
+ event_data["references"] = references
2297
+
2298
+ def _setup_thread_context(self, config: dict[str, Any]) -> tuple[str | None, Any]:
2299
+ """Set up thread context for step linkage during streaming.
2300
+
2301
+ Args:
2302
+ config: Graph configuration
2303
+
2304
+ Returns:
2305
+ Tuple of (thread_id, context_token)
2306
+ """
2307
+ current_thread_id: str | None = None
2308
+ try:
2309
+ configurable = config.get("configurable", {})
2310
+ thread_key = self.thread_id_key or "thread_id"
2311
+ current_thread_id = str(configurable.get(thread_key)) if configurable.get(thread_key) else None
2312
+ except Exception:
2313
+ current_thread_id = None
2314
+
2315
+ token = None
2316
+
2317
+ try:
2318
+ start_step_counter_scope()
2319
+ except Exception as exc:
2320
+ logger.debug("Starting step counter scope failed: %s", exc)
2321
+
2322
+ if current_thread_id:
2323
+ token = _THREAD_ID_CVAR.set(current_thread_id)
2324
+ self._tool_parent_map_by_thread[current_thread_id] = {}
2325
+ self._completed_tool_steps_by_thread[current_thread_id] = []
2326
+ self._emitted_tool_calls_by_thread[current_thread_id] = set()
2327
+
2328
+ return current_thread_id, token
2329
+
2330
+ def _cleanup_thread_context(self, current_thread_id: str | None, token: Any) -> None:
2331
+ """Clean up thread context and reset context variables.
2332
+
2333
+ Args:
2334
+ current_thread_id: The thread ID to clean up
2335
+ token: The context token to reset
2336
+ """
2337
+ try:
2338
+ end_step_counter_scope()
2339
+ except Exception as exc:
2340
+ logger.debug("Ending step counter scope failed: %s", exc)
2341
+
2342
+ if current_thread_id:
2343
+ self._tool_parent_map_by_thread.pop(current_thread_id, None)
2344
+ self._completed_tool_steps_by_thread.pop(current_thread_id, None)
2345
+ self._last_status_step_id_by_thread.pop(current_thread_id, None)
2346
+ self._emitted_tool_calls_by_thread.pop(current_thread_id, None)
2347
+
2348
+ if token is not None:
2349
+ try:
2350
+ _THREAD_ID_CVAR.reset(token)
2351
+ except ValueError as e:
2352
+ logger.debug("Context variable token from different context, skipping reset: %s", e)
2353
+ except Exception as e:
2354
+ logger.error("Resetting _THREAD_ID_CVAR failed: %s", e, exc_info=True)
2355
+ try:
2356
+ _STEP_LIMIT_CONFIG_CVAR.set(None)
2357
+ except Exception:
2358
+ logger.debug("Failed to reset step limit config context; continuing cleanup.")
2359
+
2360
+ def _handle_stream_item(
2361
+ self, item: tuple, pending_artifacts: list, seen_artifact_hashes: set, processed_message_count: int
2362
+ ) -> tuple[list[A2AEvent], bool, int]:
2363
+ """Handle a single stream item.
2364
+
2365
+ Args:
2366
+ item: Stream item tuple (mode, data)
2367
+ pending_artifacts: List of pending artifacts
2368
+ seen_artifact_hashes: Set of seen artifact hashes
2369
+ processed_message_count: Current message count
2370
+
2371
+ Returns:
2372
+ Tuple of (events_to_yield, is_final, updated_message_count)
2373
+ """
2374
+ mode, data = item
2375
+
2376
+ if mode == StreamMode.CUSTOM:
2377
+ delegation_event: A2AEvent = data
2378
+ delegation_event = self._normalize_custom_stream_event(delegation_event)
2379
+ if self._should_yield_a2a_event(delegation_event):
2380
+ return [delegation_event], False, processed_message_count
2381
+ return [], False, processed_message_count
2382
+ elif mode == StreamMode.VALUES:
2383
+ stream_data = data
2384
+ else:
2385
+ return [], False, processed_message_count
2386
+
2387
+ events, is_final, updated_message_count = self._process_a2a_stream_item(
2388
+ stream_data, pending_artifacts, seen_artifact_hashes, processed_message_count
2389
+ )
2390
+ return events, is_final, updated_message_count
2391
+
2392
+ async def _arun_a2a_stream(self, query: str, **kwargs: Any) -> AsyncGenerator[A2AEvent, None]:
2393
+ """Internal implementation of arun_a2a_stream without MCP handling.
2394
+
2395
+ Args:
2396
+ query: The input query for the agent.
2397
+ **kwargs: Additional keyword arguments.
2398
+
2399
+ Yields:
2400
+ Dictionaries with "status" and "content" keys for status events.
2401
+ Status events may include "artifacts" field when tools generate artifacts.
2402
+ Possible statuses: "working", "completed", "failed", "canceled".
2403
+ """
2404
+ context = self._initialize_streaming_context(query, **kwargs)
2405
+ await self._run_stream_before_run_hook(context)
2406
+
2407
+ try:
2408
+ async for event in self._handle_streaming_process(context):
2409
+ yield event
2410
+
2411
+ await self._run_stream_after_run_success_hook(context)
2412
+
2413
+ async for event in self._ensure_final_completion(context):
2414
+ yield event
2415
+
2416
+ except Exception as e:
2417
+ await self._run_stream_after_run_error_hook(context, e)
2418
+ async for event in self._handle_streaming_error(context, e):
2419
+ yield event
2420
+ finally:
2421
+ self._cleanup_thread_context(context.current_thread_id, context.token)
2422
+
2423
+ async def _run_stream_before_run_hook(self, context: "_StreamingContext") -> None:
2424
+ """Apply run-level middleware before streaming graph execution."""
2425
+ try:
2426
+ before_updates = await self._middleware_manager.abefore_run(context.graph_input, context.config)
2427
+ if before_updates:
2428
+ self._apply_before_run_updates(context.graph_input, before_updates)
2429
+ except SkillInstallError:
2430
+ raise
2431
+ except Exception: # pragma: no cover
2432
+ pass # pragma: no cover
2433
+
2434
+ def _resolve_stream_final_text(self, context: "_StreamingContext") -> str:
2435
+ """Resolve final text used for run-level completion middleware hook."""
2436
+ if isinstance(context.last_final_content, str):
2437
+ return context.last_final_content
2438
+ if isinstance(context.final_state, dict):
2439
+ return self._extract_output_from_final_state(context.final_state)
2440
+ return ""
2441
+
2442
+ async def _run_stream_after_run_success_hook(self, context: "_StreamingContext") -> None:
2443
+ """Run completion middleware hook for successful streaming runs."""
2444
+ try:
2445
+ final_text = self._resolve_stream_final_text(context)
2446
+ await self._middleware_manager.aafter_run(
2447
+ final_state=context.final_state or {},
2448
+ output=final_text or None,
2449
+ config=context.config,
2450
+ error=None,
2451
+ )
2452
+ except Exception: # pragma: no cover
2453
+ pass # pragma: no cover
2454
+
2455
+ async def _run_stream_after_run_error_hook(self, context: "_StreamingContext", error: Exception) -> None:
2456
+ """Run completion middleware hook for failed streaming runs."""
2457
+ try:
2458
+ await self._middleware_manager.aafter_run(
2459
+ final_state=context.final_state or {},
2460
+ output=None,
2461
+ config=context.config,
2462
+ error=error,
2463
+ )
2464
+ except Exception: # pragma: no cover
2465
+ pass # pragma: no cover
2466
+
2467
+ def _initialize_streaming_context(self, query: str, **kwargs: Any) -> "_StreamingContext":
2468
+ """Initialize the streaming context with all necessary setup.
2469
+
2470
+ Args:
2471
+ query: The user's input query to process.
2472
+ **kwargs: Additional keyword arguments including optional metadata and configuration.
2473
+
2474
+ Returns:
2475
+ Configured _StreamingContext object ready for streaming execution.
2476
+ """
2477
+ files = kwargs.pop("files", [])
2478
+ if files is None:
2479
+ files = []
2480
+ augmented_query, input_attachments = self._prepare_file_runtime_context(query, files, kwargs)
2481
+
2482
+ memory_user_id: str | None = kwargs.get("memory_user_id")
2483
+
2484
+ # Create config first to ensure thread_id is generated
2485
+ config = self._create_graph_config(**kwargs)
2486
+ thread_id = self._get_thread_id_from_config(config)
2487
+
2488
+ graph_input = self._prepare_graph_input(augmented_query, thread_id=thread_id, **kwargs)
2489
+ self._attach_input_attachments_to_graph_input(graph_input, input_attachments)
2490
+
2491
+ current_thread_id, token = self._setup_thread_context(config)
2492
+
2493
+ if self.enable_a2a_token_streaming and self.model:
2494
+ self.model.disable_streaming = False
2495
+
2496
+ a2ui_stream_filter = (
2497
+ self._create_a2ui_stream_filter() if self._is_a2ui_enabled(getattr(self, "config", None)) else None
2498
+ )
2499
+
2500
+ return _StreamingContext(
2501
+ original_query=query,
2502
+ graph_input=graph_input,
2503
+ config=config,
2504
+ memory_user_id=memory_user_id,
2505
+ current_thread_id=current_thread_id,
2506
+ token=token,
2507
+ enable_token_streaming=self.enable_a2a_token_streaming,
2508
+ a2ui_stream_filter=a2ui_stream_filter,
2509
+ )
2510
+
2511
+ @staticmethod
2512
+ def _is_a2ui_enabled(config: Any | None) -> bool:
2513
+ """Return True when agent config enables A2UI."""
2514
+ if config is None:
2515
+ return False
2516
+ if isinstance(config, dict):
2517
+ raw_a2ui_config = config.get("a2ui")
2518
+ else:
2519
+ raw_a2ui_config = getattr(config, "a2ui", None)
2520
+ return isinstance(raw_a2ui_config, dict) and bool(raw_a2ui_config.get("enabled"))
2521
+
2522
+ def _create_a2ui_stream_filter(self) -> Any:
2523
+ """Create an incremental A2UI markup filter for token streaming."""
2524
+ from aip_agents.a2ui.stream_filter import A2UIStreamTextFilter
2525
+
2526
+ return A2UIStreamTextFilter()
2527
+
2528
+ @staticmethod
2529
+ def _filter_a2ui_token_stream_content(context: "_StreamingContext | None", content: str) -> str | None:
2530
+ """Return sanitized visible token text, or None when nothing should be emitted."""
2531
+ stream_filter = None if context is None else context.a2ui_stream_filter
2532
+ if stream_filter is None:
2533
+ return content if content else None
2534
+ filtered = stream_filter.feed(content)
2535
+ return filtered if filtered else None
2536
+
2537
+ async def _handle_streaming_process(self, context: "_StreamingContext") -> AsyncGenerator[A2AEvent, None]:
2538
+ """Handle the main streaming process including initial status and event processing.
2539
+
2540
+ Args:
2541
+ context: The streaming context containing query, config, and thread information.
2542
+
2543
+ Yields:
2544
+ Streaming events including initial status and processed streaming items.
2545
+ """
2546
+ if self._should_emit_initial_status_event():
2547
+ initial_status_event = self._create_initial_status_event()
2548
+ self._log_streaming_event_debug("initial_status", initial_status_event)
2549
+ yield initial_status_event
2550
+
2551
+ async for event in self._process_streaming_items(context):
2552
+ self._log_streaming_event_debug("process_stream_item", event)
2553
+ yield event
2554
+
2555
+ def _should_emit_initial_status_event(self) -> bool:
2556
+ """Return whether this stream should emit the default initial status."""
2557
+ return not is_delegated_agent_execution()
2558
+
2559
+ def _create_initial_status_event(self) -> A2AEvent:
2560
+ """Create and setup the initial status event."""
2561
+ initial_status_event = self._create_a2a_event(
2562
+ event_type=A2AStreamEventType.STATUS_UPDATE, content=DefaultStepMessages.EN.value
2563
+ )
2564
+
2565
+ try:
2566
+ thread_id = _THREAD_ID_CVAR.get()
2567
+ if thread_id:
2568
+ step_id = initial_status_event.get("metadata", {}).get("step_id")
2569
+ if step_id:
2570
+ self._last_status_step_id_by_thread[thread_id] = str(step_id)
2571
+ except Exception:
2572
+ pass
2573
+
2574
+ return initial_status_event
2575
+
2576
+ async def _process_streaming_items(self, context: "_StreamingContext") -> AsyncGenerator[A2AEvent, None]:
2577
+ """Process individual streaming items from the LangGraph execution.
2578
+
2579
+ Handles the core streaming logic by iterating through items produced by
2580
+ the compiled LangGraph, processing both VALUES and CUSTOM stream modes,
2581
+ and managing final event generation.
2582
+
2583
+ Args:
2584
+ context: The streaming context containing graph input, configuration,
2585
+ and state tracking information.
2586
+
2587
+ Yields:
2588
+ dict[str, Any]: A2A events generated from the stream processing,
2589
+ including status updates, final responses, and completion events.
2590
+ """
2591
+ if context.enable_token_streaming:
2592
+ if self.event_emitter is None:
2593
+ self.event_emitter = self._create_default_event_emitter()
2594
+ elif not self._get_stream_handler():
2595
+ logger.warning(
2596
+ "Agent '%s': No StreamEventHandler found in event_emitter. "
2597
+ "Reinitializing event_emitter using default emitter.",
2598
+ self.name,
2599
+ )
2600
+ self.event_emitter = self._create_default_event_emitter()
2601
+
2602
+ async for event in self._process_a2a_streaming_with_tokens(context):
2603
+ yield event
2604
+ else:
2605
+ enhanced_input = context.graph_input
2606
+ async for event in self._create_graph_stream_events(enhanced_input, context):
2607
+ yield event
2608
+
2609
+ async def _process_a2a_streaming_with_tokens(self, context: "_StreamingContext") -> AsyncGenerator[A2AEvent, None]:
2610
+ """Process A2A streaming with token streaming support using aiostream.
2611
+
2612
+ Supports both LM Invoker and LangChain models by detecting the appropriate
2613
+ token source and merging with graph events.
2614
+
2615
+ Uses aiostream to merge token streaming and graph execution streams,
2616
+ yielding events in real-time order as they arrive.
2617
+
2618
+ Args:
2619
+ context: The streaming context containing graph input, configuration,
2620
+ and state tracking information.
2621
+
2622
+ Yields:
2623
+ dict[str, Any]: A2A events generated from the stream processing,
2624
+ including status updates, final responses, and completion events.
2625
+
2626
+ Raises:
2627
+ RuntimeError: If token streaming is requested but event_emitter is not available.
2628
+ """
2629
+ if not self.event_emitter:
2630
+ raise RuntimeError(f"Agent '{self.name}': Event emitter required for token streaming")
2631
+ if astream is None:
2632
+ raise RuntimeError(
2633
+ "aiostream is required for token streaming support. "
2634
+ "Install the 'aiostream' dependency or disable token streaming."
2635
+ )
2636
+
2637
+ try:
2638
+ if self._has_lm_invoker():
2639
+ token_stream, enhanced_input = self._create_token_stream(context)
2640
+ graph_stream = self._graph_stream_events_with_token_emitter_cleanup(
2641
+ enhanced_input,
2642
+ context,
2643
+ )
2644
+
2645
+ if token_stream is None:
2646
+ raise RuntimeError(f"Agent '{self.name}': Token stream not available for LM invoker.")
2647
+
2648
+ merged = astream.merge(token_stream, graph_stream)
2649
+ async with merged.stream() as merged_stream:
2650
+ async for event in merged_stream:
2651
+ yield event
2652
+ else:
2653
+ _, enhanced_input = self._create_token_stream(context)
2654
+ async for event in self._create_graph_stream_events(enhanced_input, context):
2655
+ yield event
2656
+
2657
+ except Exception as e:
2658
+ if self.event_emitter is not None:
2659
+ await self.event_emitter.close()
2660
+ self.event_emitter = None
2661
+ logger.error(f"Agent '{self.name}': Error during A2A token streaming: {e}")
2662
+ raise
2663
+
2664
+ async def _graph_stream_events_with_token_emitter_cleanup(
2665
+ self,
2666
+ enhanced_input: dict[str, Any],
2667
+ context: "_StreamingContext",
2668
+ ) -> AsyncGenerator[A2AEvent, None]:
2669
+ """Run graph streaming and close the token emitter when graph execution completes."""
2670
+ try: # pragma: no cover
2671
+ async for event in self._create_graph_stream_events(enhanced_input, context): # pragma: no cover
2672
+ yield event # pragma: no cover
2673
+ finally:
2674
+ if self.event_emitter is not None: # pragma: no cover
2675
+ await self.event_emitter.close() # pragma: no cover
2676
+ self.event_emitter = None # pragma: no cover
2677
+
2678
+ async def _create_lm_invoker_token_stream(self, context: "_StreamingContext") -> AsyncGenerator[A2AEvent, None]:
2679
+ """Generate A2A events from LM Invoker token stream.
2680
+
2681
+ Uses StreamEventHandler to capture tokens emitted by LM Invoker.
2682
+
2683
+ Yields:
2684
+ A2A events generated from LM Invoker token stream.
2685
+
2686
+ Raises:
2687
+ RuntimeError: If no StreamEventHandler is found in event_emitter.
2688
+ """
2689
+ stream_handler = self._get_stream_handler()
2690
+ if stream_handler is None:
2691
+ raise RuntimeError(f"Agent '{self.name}': StreamEventHandler is required for token streaming.")
2692
+
2693
+ try:
2694
+ async for event in stream_handler.stream():
2695
+ if event is None:
2696
+ break
2697
+
2698
+ token_event = self._convert_raw_token_to_a2a_event(event, context)
2699
+ if token_event:
2700
+ yield token_event
2701
+ except Exception as e:
2702
+ logger.error(f"Agent '{self.name}': LM Invoker token stream error: {e}")
2703
+
2704
+ def _create_token_stream(
2705
+ self,
2706
+ context: "_StreamingContext",
2707
+ ) -> tuple[AsyncGenerator[A2AEvent, None] | None, dict[str, Any]]:
2708
+ """Create appropriate token stream and enhanced input for the active model backend.
2709
+
2710
+ Args:
2711
+ context: Streaming context containing graph input and configuration.
2712
+
2713
+ Returns:
2714
+ Tuple of (token_stream, enhanced_input) where token_stream yields A2A token
2715
+ events and enhanced_input is the graph input dictionary (augmented with event
2716
+ emitter when required by LM Invoker backends).
2717
+ """
2718
+ if self._has_lm_invoker():
2719
+ token_stream = self._create_lm_invoker_token_stream(context)
2720
+ enhanced_input = {**context.graph_input, "event_emitter": self.event_emitter}
2721
+ else:
2722
+ token_stream = None
2723
+ enhanced_input = context.graph_input
2724
+
2725
+ return token_stream, enhanced_input
2726
+
2727
+ async def _create_graph_stream_events(
2728
+ self, enhanced_input: dict[str, Any], context: "_StreamingContext"
2729
+ ) -> AsyncGenerator[A2AEvent, None]:
2730
+ """Generate A2A events from graph execution.
2731
+
2732
+ Args:
2733
+ enhanced_input: The enhanced input for the graph execution.
2734
+ context: The streaming context containing state tracking information.
2735
+
2736
+ Yields:
2737
+ A2A events generated from graph execution.
2738
+ """
2739
+ try:
2740
+ stream_modes = self._get_stream_modes(context)
2741
+ graph_execution = self._graph_runtime.stream(
2742
+ self._compiled_graph,
2743
+ enhanced_input,
2744
+ context.config,
2745
+ stream_modes,
2746
+ )
2747
+
2748
+ async for item in graph_execution:
2749
+ stream_mode, stream_data = item
2750
+
2751
+ if stream_mode == StreamMode.MESSAGES.value:
2752
+ message_data = cast(tuple[Any, dict[str, Any]], stream_data)
2753
+ async for token_event in self._process_message_stream_item(message_data, context):
2754
+ yield token_event
2755
+ continue
2756
+
2757
+ async for event in self._process_graph_stream_item(item, stream_mode, stream_data, context):
2758
+ yield event
2759
+ except Exception as e:
2760
+ logger.exception("Agent '%s': Graph processing error: %s", self.name, e)
2761
+ raise
2762
+
2763
+ def _get_stream_modes(self, context: "_StreamingContext") -> list[str]:
2764
+ """Determine stream modes based on token streaming configuration.
2765
+
2766
+ Args:
2767
+ context: Streaming context containing token streaming configuration.
2768
+
2769
+ Returns:
2770
+ List of stream modes to use for graph execution.
2771
+ """
2772
+ stream_modes = [StreamMode.VALUES.value, StreamMode.CUSTOM.value]
2773
+
2774
+ if context.enable_token_streaming and not self._has_lm_invoker():
2775
+ stream_modes.append(StreamMode.MESSAGES.value)
2776
+
2777
+ return stream_modes
2778
+
2779
+ async def _process_graph_stream_item(
2780
+ self,
2781
+ item: tuple[str, Any],
2782
+ stream_mode: str,
2783
+ stream_data: Any,
2784
+ context: "_StreamingContext",
2785
+ ) -> AsyncGenerator[A2AEvent, None]:
2786
+ """Process a single graph stream item and yield A2A events.
2787
+
2788
+ Args:
2789
+ item: The stream item tuple (mode, data).
2790
+ stream_mode: The stream mode of this item.
2791
+ stream_data: The data from the stream item.
2792
+ context: Streaming context for state tracking.
2793
+
2794
+ Yields:
2795
+ A2A events generated from the stream item.
2796
+ """
2797
+ context.final_state = copy.copy(stream_data) if stream_mode == StreamMode.VALUES.value else context.final_state
2798
+
2799
+ pending_artifacts = context.pending_artifacts if context.pending_artifacts is not None else []
2800
+ seen_artifact_hashes = context.seen_artifact_hashes if context.seen_artifact_hashes is not None else set()
2801
+ events, is_final, context.processed_message_count = self._handle_stream_item(
2802
+ item, pending_artifacts, seen_artifact_hashes, context.processed_message_count
2803
+ )
2804
+
2805
+ if is_final:
2806
+ context.final_event_yielded = True
2807
+
2808
+ for event in events:
2809
+ await self._capture_final_content_if_needed(context, event)
2810
+ if (
2811
+ isinstance(event, dict)
2812
+ and event.get("event_type") in (A2AStreamEventType.FINAL_RESPONSE, "final_response")
2813
+ and isinstance(context.final_state, dict)
2814
+ ):
2815
+ event_metadata = event.get("metadata")
2816
+ if isinstance(event_metadata, dict):
2817
+ a2ui_content = context.final_state.get("a2ui_content")
2818
+ if isinstance(a2ui_content, dict):
2819
+ event_metadata["a2ui_content"] = a2ui_content
2820
+ processed_event = self._update_final_response_for_streaming(context, event)
2821
+ yield processed_event
2822
+
2823
+ async def _process_message_stream_item(
2824
+ self,
2825
+ message_data: tuple[Any, dict[str, Any]],
2826
+ context: "_StreamingContext | None" = None,
2827
+ ) -> AsyncGenerator[A2AEvent, None]:
2828
+ """Process message stream items to extract token events.
2829
+
2830
+ The "messages" stream mode yields tuples of (AIMessageChunk, metadata).
2831
+ This method extracts token content from AIMessageChunk and converts it
2832
+ to A2A CONTENT_CHUNK events with TOKEN kind.
2833
+
2834
+ Args:
2835
+ message_data: Tuple of (message_chunk, metadata) from messages stream
2836
+ context: Streaming context containing optional A2UI token filter state
2837
+
2838
+ Yields:
2839
+ A2A CONTENT_CHUNK events with TOKEN kind
2840
+ """
2841
+ try:
2842
+ message_chunk, _ = message_data
2843
+
2844
+ # Filter out events with response_metadata.finish_reason attribute
2845
+ # since it is a response from subagent
2846
+ if hasattr(message_chunk, "response_metadata") and message_chunk.response_metadata:
2847
+ if "finish_reason" in message_chunk.response_metadata:
2848
+ return
2849
+
2850
+ is_tool_call_event = self._is_message_stream_tool_call_chunk(message_chunk)
2851
+ is_has_content_event = hasattr(message_chunk, "content") and message_chunk.content
2852
+
2853
+ if (
2854
+ is_has_content_event
2855
+ and not is_tool_call_event
2856
+ and not self._is_message_stream_reasoning_chunk(message_chunk)
2857
+ ):
2858
+ token_content = self._extract_visible_message_stream_token_content(message_chunk.content)
2859
+ if token_content is None:
2860
+ return
2861
+ token_content = self._filter_a2ui_token_stream_content(context, token_content)
2862
+ if token_content is None:
2863
+ return # pragma: no cover
2864
+ token_event = self._create_a2a_event(
2865
+ event_type=A2AStreamEventType.CONTENT_CHUNK,
2866
+ content=token_content,
2867
+ metadata={MetadataFieldKeys.KIND: Kind.TOKEN},
2868
+ )
2869
+ yield token_event
2870
+
2871
+ except Exception as e:
2872
+ logger.error(f"Agent '{self.name}': Error processing message stream item: {e}")
2873
+
2874
+ @staticmethod
2875
+ def _is_message_stream_tool_call_chunk(message_chunk: Any) -> bool:
2876
+ """Return True when a LangGraph message-stream chunk is part of tool-call assembly."""
2877
+ return bool(
2878
+ (hasattr(message_chunk, "tool_calls") and message_chunk.tool_calls)
2879
+ or (hasattr(message_chunk, "tool_call_chunks") and message_chunk.tool_call_chunks)
2880
+ )
2881
+
2882
+ @staticmethod
2883
+ def _is_message_stream_reasoning_chunk(message_chunk: Any) -> bool:
2884
+ """Return True for provider reasoning chunks that should use activity events, not answer tokens."""
2885
+ additional_kwargs = getattr(message_chunk, "additional_kwargs", None)
2886
+ if not isinstance(additional_kwargs, dict):
2887
+ return False
2888
+
2889
+ raw_thinkings = additional_kwargs.get(THINKINGS_METADATA_KEY)
2890
+ if not isinstance(raw_thinkings, list) or not raw_thinkings:
2891
+ return False
2892
+
2893
+ content = getattr(message_chunk, "content", None)
2894
+ if not isinstance(content, str):
2895
+ return False # pragma: no cover
2896
+
2897
+ content_text = content.strip()
2898
+ if not content_text:
2899
+ return False # pragma: no cover
2900
+
2901
+ thinking_texts = [
2902
+ entry.get("thinking", "").strip()
2903
+ for entry in raw_thinkings
2904
+ if isinstance(entry, dict) and isinstance(entry.get("thinking"), str)
2905
+ ]
2906
+ combined_thinking = "\n\n".join(text for text in thinking_texts if text)
2907
+ return content_text in {*thinking_texts, combined_thinking}
2908
+
2909
+ @staticmethod
2910
+ def _extract_visible_message_stream_block_text(block: Any) -> str:
2911
+ """Extract visible text from a single LangGraph content block."""
2912
+ if not isinstance(block, str | dict):
2913
+ return "" # pragma: no cover
2914
+
2915
+ if isinstance(block, str):
2916
+ return block # pragma: no cover
2917
+
2918
+ block_type = str(block.get("type") or block.get("kind") or "").lower()
2919
+ if block_type in {"reasoning", "reasoning_content", "thinking", "redacted_thinking"}:
2920
+ return ""
2921
+
2922
+ if block_type not in {"text", "output_text"}:
2923
+ return "" # pragma: no cover
2924
+
2925
+ text = block.get("text")
2926
+ if isinstance(text, str):
2927
+ return text
2928
+
2929
+ text = block.get("content") # pragma: no cover
2930
+ return text if isinstance(text, str) else "" # pragma: no cover
2931
+
2932
+ @staticmethod
2933
+ def _extract_visible_message_stream_token_content(content: Any) -> str | None:
2934
+ """Extract user-visible answer text from a LangGraph message-stream chunk."""
2935
+ if isinstance(content, str):
2936
+ return content if content else None
2937
+
2938
+ if not isinstance(content, list):
2939
+ return None
2940
+
2941
+ visible_parts: list[str] = []
2942
+ for block in content:
2943
+ visible_text = BaseLangGraphAgent._extract_visible_message_stream_block_text(block)
2944
+ if visible_text:
2945
+ visible_parts.append(visible_text)
2946
+
2947
+ return "".join(visible_parts) if visible_parts else None
2948
+
2949
+ def _update_final_response_for_streaming(self, context: "_StreamingContext", event: A2AEvent) -> A2AEvent:
2950
+ """Update final response events with appropriate streaming configuration.
2951
+
2952
+ For FINAL_RESPONSE events, this method updates the metadata while preserving
2953
+ the canonical final response content for consumers that need transformed output.
2954
+
2955
+ Args:
2956
+ context: The streaming context containing streaming configuration
2957
+ event: The event dictionary to process
2958
+
2959
+ Returns:
2960
+ The processed event dictionary with updated metadata and content
2961
+ """
2962
+ if event.get("event_type") == A2AStreamEventType.FINAL_RESPONSE:
2963
+ metadata = event.get("metadata")
2964
+ if not isinstance(metadata, dict):
2965
+ metadata = {}
2966
+ event["metadata"] = metadata
2967
+ metadata[MetadataFieldKeys.TOKEN_STREAMING] = bool(context.enable_token_streaming)
2968
+ if context.enable_token_streaming:
2969
+ metadata[MetadataFieldKeys.CANONICAL] = True
2970
+ return event
2971
+
2972
+ def _convert_raw_token_to_a2a_event(
2973
+ self, raw_event: str, context: "_StreamingContext | None" = None
2974
+ ) -> A2AEvent | None:
2975
+ """Parse raw token event into A2A event.
2976
+
2977
+ Args:
2978
+ raw_event: The raw event containing the raw event.
2979
+ context: Streaming context containing optional A2UI token filter state.
2980
+
2981
+ Returns:
2982
+ dict[str, Any]: A2A event generated from the stream processing,
2983
+ including status updates, final responses, and completion events.
2984
+ """
2985
+ try:
2986
+ event_data = json.loads(raw_event)
2987
+ content = event_data.get("value", "")
2988
+ event_type = event_data.get("type")
2989
+ metadata = event_data.get("metadata")
2990
+ if event_type == A2AStreamEventType.STATUS_UPDATE.value and isinstance(metadata, dict):
2991
+ return self._create_a2a_event(
2992
+ event_type=A2AStreamEventType.STATUS_UPDATE,
2993
+ content=content if isinstance(content, str) else "",
2994
+ metadata=metadata,
2995
+ )
2996
+ if isinstance(content, str) and content:
2997
+ filtered_content = self._filter_a2ui_token_stream_content(context, content)
2998
+ if filtered_content is None:
2999
+ return None # pragma: no cover
3000
+ return self._create_a2a_event(
3001
+ event_type=A2AStreamEventType.CONTENT_CHUNK,
3002
+ content=filtered_content,
3003
+ metadata={MetadataFieldKeys.KIND: Kind.TOKEN},
3004
+ )
3005
+ except Exception as e:
3006
+ logger.debug(f"Agent '{self.name}': Error parsing token event: {e}")
3007
+ return None
3008
+
3009
+ async def _capture_final_content_if_needed(self, context: "_StreamingContext", event: A2AEvent) -> None:
3010
+ """Capture final content from A2A events for streaming hooks.
3011
+
3012
+ Monitors A2A events for final response content and triggers early memory
3013
+ persistence to ensure conversation content is saved even if consumers
3014
+ stop reading the stream after receiving the final response.
3015
+
3016
+ Args:
3017
+ context: The streaming context containing memory state and user
3018
+ identification information.
3019
+ event: The A2A event dictionary that may contain final response content.
3020
+ """
3021
+ try:
3022
+ if not (isinstance(event, dict) and event.get("event_type") == A2AStreamEventType.FINAL_RESPONSE):
3023
+ return
3024
+
3025
+ content = event.get("content")
3026
+ if not isinstance(content, str) or not content:
3027
+ return # pragma: no cover
3028
+
3029
+ context.last_final_content = content
3030
+
3031
+ # Invoke middleware streaming hook (best-effort) for early persistence.
3032
+ hook_context: dict[str, Any] = {
3033
+ "agent_name": self.name,
3034
+ "thread_id": context.current_thread_id,
3035
+ "original_query": context.original_query,
3036
+ "memory_user_id": context.memory_user_id,
3037
+ "config": context.config,
3038
+ "event": event,
3039
+ "final_state": context.final_state,
3040
+ "memory_persisted": context.saved_memory,
3041
+ "a2ui_repair_handler": self._repair_a2ui_final_response,
3042
+ }
3043
+ await self._middleware_manager.aon_final_response(content, hook_context)
3044
+
3045
+ # Propagate persisted flag for dedupe.
3046
+ if hook_context.get("memory_persisted"):
3047
+ context.saved_memory = True
3048
+ if isinstance(context.final_state, dict):
3049
+ context.final_state["memory_persisted"] = True
3050
+
3051
+ except Exception:
3052
+ # Best-effort hook.
3053
+ return
3054
+
3055
+ async def _repair_a2ui_final_response(
3056
+ self,
3057
+ *,
3058
+ content: str,
3059
+ context: dict[str, Any],
3060
+ validation: dict[str, Any] | None,
3061
+ schema_version: str,
3062
+ ) -> str | None:
3063
+ """Ask the configured model for one corrected A2UI final-response payload."""
3064
+ prompt = self._build_a2ui_repair_prompt(
3065
+ content=content,
3066
+ original_query=context.get("original_query"),
3067
+ validation=validation,
3068
+ schema_version=schema_version,
3069
+ )
3070
+ messages = [
3071
+ SystemMessage(
3072
+ content=(
3073
+ "You repair malformed A2UI final responses. Return only corrected "
3074
+ "A2UI content wrapped in <a2ui-json>...</a2ui-json> tags."
3075
+ )
3076
+ ),
3077
+ HumanMessage(content=prompt),
3078
+ ]
3079
+
3080
+ try:
3081
+ if self.lm_invoker is not None:
3082
+ previous_tools = None
3083
+ restore_tools = hasattr(self.lm_invoker, "set_tools")
3084
+ if restore_tools and hasattr(self.lm_invoker, "tools"):
3085
+ previous_tools = list(self.lm_invoker.tools)
3086
+ if restore_tools:
3087
+ self.lm_invoker.set_tools([])
3088
+ try:
3089
+ lm_messages = convert_langchain_messages_to_gllm_messages(messages, "")
3090
+ lm_output = await self.lm_invoker.invoke(messages=lm_messages)
3091
+ return self._normalize_repair_content(convert_lm_output_to_langchain_message(lm_output).content)
3092
+ finally:
3093
+ if restore_tools and previous_tools is not None:
3094
+ self.lm_invoker.set_tools(previous_tools)
3095
+
3096
+ model = getattr(self, "model", None)
3097
+ if model is None or not hasattr(model, "ainvoke"):
3098
+ return None # pragma: no cover
3099
+ ai_message = await model.ainvoke(messages)
3100
+ return self._normalize_repair_content(getattr(ai_message, "content", ai_message))
3101
+ except Exception as exc:
3102
+ logger.warning("Agent '%s': A2UI repair model call failed: %s", self.name, exc, exc_info=True)
3103
+ return None
3104
+
3105
+ def _build_a2ui_repair_prompt(
3106
+ self,
3107
+ *,
3108
+ content: str,
3109
+ original_query: Any,
3110
+ validation: dict[str, Any] | None,
3111
+ schema_version: str,
3112
+ ) -> str:
3113
+ """Build a bounded A2UI repair prompt from validation errors and original output."""
3114
+ errors = []
3115
+ if isinstance(validation, dict) and isinstance(validation.get("errors"), list):
3116
+ errors = validation["errors"]
3117
+
3118
+ return (
3119
+ "The previous final response contained invalid A2UI output.\n"
3120
+ f"A2UI schema version: {schema_version}\n"
3121
+ f"Original user request: {original_query or ''}\n"
3122
+ f"Validation errors: {json.dumps(errors, ensure_ascii=False)}\n\n"
3123
+ "Repair instructions:\n"
3124
+ "- Return corrected A2UI only.\n"
3125
+ "- Wrap the corrected JSON in <a2ui-json> and </a2ui-json>.\n"
3126
+ "- Do not include explanations, markdown, diagnostics, or plain text.\n"
3127
+ "- Preserve the user's intended UI as much as possible.\n\n"
3128
+ "Original final response:\n"
3129
+ f"{content}"
3130
+ )
3131
+
3132
+ @staticmethod
3133
+ def _normalize_repair_content(content: Any) -> str:
3134
+ """Normalize repair model output to a string, handling structured content blocks."""
3135
+ if isinstance(content, str):
3136
+ return content
3137
+ if isinstance(content, list):
3138
+ parts = []
3139
+ for block in content:
3140
+ if isinstance(block, dict):
3141
+ parts.append(block.get("text", ""))
3142
+ else:
3143
+ parts.append(str(block))
3144
+ return "".join(parts)
3145
+ return str(content)
3146
+
3147
+ async def _ensure_final_completion(self, context: "_StreamingContext") -> AsyncGenerator[A2AEvent, None]:
3148
+ """Ensure final completion events are yielded if not already done.
3149
+
3150
+ Args:
3151
+ context: The streaming context containing pending artifacts and
3152
+ other state information.
3153
+
3154
+ Yields:
3155
+ dict[str, Any]: The final completion event.
3156
+ """
3157
+ if not context.final_event_yielded:
3158
+ pending_artifacts = context.pending_artifacts if context.pending_artifacts is not None else []
3159
+ final_state = context.final_state or {}
3160
+ completion_event = self._create_completion_event(pending_artifacts, final_state)
3161
+ self._log_streaming_event_debug("final_completion", completion_event)
3162
+ yield completion_event
3163
+
3164
+ async def _handle_streaming_error(
3165
+ self,
3166
+ context: "_StreamingContext",
3167
+ error: Exception,
3168
+ ) -> AsyncGenerator[A2AEvent, None]:
3169
+ """Handle streaming errors gracefully.
3170
+
3171
+ Provides error handling for the A2A streaming process, ensuring errors
3172
+ are properly logged and communicated to the client while preserving
3173
+ any pending artifacts generated before the error occurred.
3174
+
3175
+ Args:
3176
+ context: The streaming context containing pending artifacts and
3177
+ other state information.
3178
+ error: The exception that occurred during streaming.
3179
+
3180
+ Yields:
3181
+ dict[str, Any]: An error event containing the failure status and
3182
+ error message, optionally including any pending artifacts.
3183
+ """
3184
+ logger.error(f"Error in agent stream: {error}", exc_info=True)
3185
+ error_event = self._create_a2a_event(
3186
+ event_type=A2AStreamEventType.ERROR,
3187
+ content=f"Error: {str(error)}",
3188
+ metadata={"status": "failed"},
3189
+ artifacts=context.pending_artifacts,
3190
+ is_final=True,
3191
+ )
3192
+ error_event["status"] = "failed"
3193
+ self._log_streaming_event_debug("error_event", error_event)
3194
+ yield error_event
3195
+
3196
+ def _extract_references_from_state(self, final_state: dict[str, Any] | None) -> list[Chunk] | None:
3197
+ """Extract and validate references from final state.
3198
+
3199
+ Args:
3200
+ final_state: The final state of the agent.
3201
+
3202
+ Returns:
3203
+ Validated references or None if not available.
3204
+ """
3205
+ if final_state and isinstance(final_state, dict) and final_state.get("references"):
3206
+ try:
3207
+ return validate_references(final_state["references"])
3208
+ except Exception:
3209
+ pass
3210
+ return None
3211
+
3212
+ def _extract_total_usage_from_state(self, final_state: dict[str, Any] | None) -> dict[str, Any] | None:
3213
+ """Extract total usage from final state.
3214
+
3215
+ Args:
3216
+ final_state: The final state of the agent.
3217
+
3218
+ Returns:
3219
+ Total usage metadata or None if not available.
3220
+ """
3221
+ if final_state and isinstance(final_state, dict) and final_state.get(TOTAL_USAGE_KEY):
3222
+ return final_state[TOTAL_USAGE_KEY]
3223
+ return None
3224
+
3225
+ def _extract_model_usage_from_state(self, final_state: dict[str, Any] | None) -> list[dict[str, Any]] | None:
3226
+ """Extract model usage from final state when available."""
3227
+ if final_state and isinstance(final_state, dict) and final_state.get(MODEL_USAGE_KEY):
3228
+ value = final_state[MODEL_USAGE_KEY]
3229
+ if isinstance(value, list):
3230
+ return value
3231
+ return None
3232
+
3233
+ def _build_completion_metadata(self, final_state: dict[str, Any] | None) -> dict[str, Any]:
3234
+ """Build metadata for completion event.
3235
+
3236
+ Args:
3237
+ final_state: The final state of the agent.
3238
+
3239
+ Returns:
3240
+ Metadata dictionary with previous_step_ids and pii_mapping if available.
3241
+ """
3242
+ metadata: dict[str, Any] = {}
3243
+
3244
+ if final_state and isinstance(final_state, dict):
3245
+ a2ui_content = final_state.get("a2ui_content")
3246
+ if isinstance(a2ui_content, dict):
3247
+ metadata["a2ui_content"] = a2ui_content
3248
+
3249
+ # Add previous step IDs if available
3250
+ try:
3251
+ thread_id = _THREAD_ID_CVAR.get()
3252
+ if thread_id and thread_id in self._completed_tool_steps_by_thread:
3253
+ completed_ids = self._completed_tool_steps_by_thread[thread_id]
3254
+ if completed_ids:
3255
+ metadata["previous_step_ids"] = list(completed_ids)
3256
+ except Exception as e:
3257
+ logger.warning("Attaching previous_step_ids to completion event failed: %s", e, exc_info=True)
3258
+
3259
+ return metadata
3260
+
3261
+ def _create_completion_event(self, pending_artifacts: list, final_state: dict[str, Any]) -> A2AEvent:
3262
+ """Helper to create the completion event with artifacts and references if available.
3263
+
3264
+ This method is used to create the completion event with artifacts and references if available.
3265
+
3266
+ Args:
3267
+ pending_artifacts: List of artifacts waiting to be attached to a message.
3268
+ final_state: The final state of the agent.
3269
+
3270
+ Returns:
3271
+ A dictionary with "status" and "content" keys
3272
+ Additional keys may include "artifacts" and "references" if available
3273
+ """
3274
+ artifacts = pending_artifacts if pending_artifacts else None
3275
+ references = self._extract_references_from_state(final_state)
3276
+ total_usage = self._extract_total_usage_from_state(final_state)
3277
+ model_usage = self._extract_model_usage_from_state(final_state)
3278
+ metadata = self._build_completion_metadata(final_state)
3279
+
3280
+ return self._create_a2a_event(
3281
+ event_type=A2AStreamEventType.FINAL_RESPONSE,
3282
+ content="Stream finished.",
3283
+ tool_info=None,
3284
+ metadata=metadata,
3285
+ is_final=True,
3286
+ artifacts=artifacts,
3287
+ references=references,
3288
+ step_usage=None,
3289
+ total_usage=total_usage,
3290
+ model_usage=model_usage,
3291
+ )
3292
+
3293
+ def _extract_tool_name_prefix(self, tool_name: str) -> str:
3294
+ """Extract a meaningful prefix from a tool name.
3295
+
3296
+ Args:
3297
+ tool_name: The name of the tool.
3298
+
3299
+ Returns:
3300
+ A meaningful prefix.
3301
+ """
3302
+ if tool_name.startswith("delegate_to_"):
3303
+ agent_name = tool_name[12:]
3304
+ if agent_name.endswith("Agent"):
3305
+ agent_name = agent_name[:-5]
3306
+ return agent_name.lower()[:4]
3307
+
3308
+ if "_" in tool_name:
3309
+ parts = tool_name.split("_")
3310
+ for part in parts:
3311
+ if part not in ["tool", "generator", "calculator", "forecast"]:
3312
+ return part[:4]
3313
+ return parts[0][:4]
3314
+ else:
3315
+ return tool_name[:4]
3316
+
3317
+ def _generate_tool_call_step_id(self, tool_info: dict[str, Any] | None, counter: int) -> str:
3318
+ """Generate step_id for tool call events.
3319
+
3320
+ Args:
3321
+ tool_info: Tool information
3322
+ counter: Step counter
3323
+
3324
+ Returns:
3325
+ Generated step_id
3326
+ """
3327
+ if not tool_info or not tool_info.get("tool_calls"):
3328
+ return f"tool_start_{counter:03d}"
3329
+
3330
+ tool_calls = tool_info["tool_calls"]
3331
+ if not tool_calls:
3332
+ return f"tool_start_{counter:03d}"
3333
+
3334
+ prefixes = [self._extract_tool_name_prefix(tc.get("name", "")) or "unkn" for tc in tool_calls]
3335
+ delegation_flags = self._get_delegation_info_from_tool_calls(tool_calls)
3336
+
3337
+ if len(tool_calls) == 1:
3338
+ category = "agent" if delegation_flags[0] else "tool"
3339
+ return f"{category}_{prefixes[0]}_start_{counter:03d}"
3340
+
3341
+ combined_name = "".join(prefixes).strip()[:6]
3342
+ combined_name = combined_name or "multi"
3343
+
3344
+ if all(delegation_flags):
3345
+ category = "agent"
3346
+ elif any(delegation_flags):
3347
+ category = "mixed"
3348
+ else:
3349
+ category = "tool"
3350
+
3351
+ return f"{category}_{combined_name}_parent_{counter:03d}"
3352
+
3353
+ def _generate_tool_result_step_id(self, tool_info: dict[str, Any] | None, counter: int) -> str:
3354
+ """Generate step_id for tool result events.
3355
+
3356
+ Args:
3357
+ tool_info: Tool information
3358
+ counter: Step counter
3359
+
3360
+ Returns:
3361
+ Generated step_id
3362
+ """
3363
+ if not tool_info:
3364
+ return f"tool_done_{counter:03d}"
3365
+
3366
+ tool_name = tool_info.get("name", "")
3367
+ prefix = self._extract_tool_name_prefix(tool_name) or "unkn"
3368
+ category = "agent" if self._is_delegation_tool_from_info(tool_info) else "tool"
3369
+ return f"{category}_{prefix}_done_{counter:03d}"
3370
+
3371
+ @staticmethod
3372
+ def _is_delegation_tool_name(tool_name: str) -> bool:
3373
+ """Check if a tool name corresponds to a delegation (sub-agent) tool.
3374
+
3375
+ This method maintains backward compatibility by checking the tool name pattern.
3376
+ For new tools created by DelegationToolManager, use _is_delegation_tool() instead.
3377
+
3378
+ Args:
3379
+ tool_name: The name of the tool to check.
3380
+
3381
+ Returns:
3382
+ bool: True if the tool name indicates a delegation tool.
3383
+ """
3384
+ return isinstance(tool_name, str) and tool_name.startswith("delegate_to_")
3385
+
3386
+ @staticmethod
3387
+ def _is_delegation_tool(tool_instance: Any) -> bool:
3388
+ """Check delegation status based on metadata when available.
3389
+
3390
+ Args:
3391
+ tool_instance: The tool instance to check for delegation metadata.
3392
+
3393
+ Returns:
3394
+ True if the tool is marked as a delegation tool, False otherwise.
3395
+ """
3396
+ metadata = getattr(tool_instance, "metadata", None)
3397
+ if not metadata or not hasattr(metadata, "get"):
3398
+ return False
3399
+
3400
+ return bool(metadata.get("is_delegation_tool"))
3401
+
3402
+ def _get_delegation_info_from_tool_calls(self, tool_calls: list[dict[str, Any]] | None) -> list[bool]:
3403
+ """Return delegation flags for each tool call using hybrid detection.
3404
+
3405
+ Args:
3406
+ tool_calls: List of tool call dictionaries containing tool information.
3407
+
3408
+ Returns:
3409
+ List of boolean flags indicating delegation status for each tool call.
3410
+ """
3411
+ if not tool_calls:
3412
+ return []
3413
+
3414
+ delegation_flags: list[bool] = []
3415
+ for tc in tool_calls:
3416
+ if not isinstance(tc, dict):
3417
+ logger.warning("Unexpected tool call payload type: %s", type(tc))
3418
+ delegation_flags.append(False)
3419
+ continue
3420
+
3421
+ delegation_flags.append(self._is_delegation_tool_from_info(tc))
3422
+
3423
+ return delegation_flags
3424
+
3425
+ def _is_delegation_tool_from_info(self, tool_info: dict[str, Any] | None) -> bool:
3426
+ """Check delegation status from tool metadata, fallback to name pattern.
3427
+
3428
+ Args:
3429
+ tool_info: Dictionary containing tool information including name and instance.
3430
+
3431
+ Returns:
3432
+ True if the tool is identified as a delegation tool, False otherwise.
3433
+ """
3434
+ if not isinstance(tool_info, dict):
3435
+ logger.warning("Unexpected tool info payload type: %s", type(tool_info))
3436
+ return False
3437
+
3438
+ tool_instance = tool_info.get("tool_instance")
3439
+ if tool_instance and self._is_delegation_tool(tool_instance):
3440
+ return True
3441
+
3442
+ return self._is_delegation_tool_name(tool_info.get("name", ""))
3443
+
3444
+ def _generate_meaningful_step_id(
3445
+ self, event_type: A2AStreamEventType, tool_info: dict[str, Any] | None = None
3446
+ ) -> str:
3447
+ """Generate a meaningful step_id based on event type and tool information.
3448
+
3449
+ Args:
3450
+ event_type: The type of event (tool_call, tool_result, final_response, etc.)
3451
+ tool_info: Tool information containing tool names and IDs
3452
+
3453
+ Returns:
3454
+ A meaningful step_id string
3455
+ """
3456
+ try:
3457
+ counter = get_next_step_number()
3458
+
3459
+ step_id_generators = {
3460
+ A2AStreamEventType.TOOL_CALL: lambda: self._generate_tool_call_step_id(tool_info, counter),
3461
+ A2AStreamEventType.TOOL_RESULT: lambda: self._generate_tool_result_step_id(tool_info, counter),
3462
+ A2AStreamEventType.FINAL_RESPONSE: lambda: f"final_{counter:03d}",
3463
+ A2AStreamEventType.CONTENT_CHUNK: lambda: f"content_{counter:03d}",
3464
+ }
3465
+
3466
+ generator = step_id_generators.get(event_type)
3467
+ if generator:
3468
+ return generator()
3469
+
3470
+ event_value = event_type.value if hasattr(event_type, "value") else str(event_type)
3471
+ return f"{event_value}_{counter:03d}"
3472
+
3473
+ except Exception:
3474
+ return f"stp_{uuid.uuid4().hex[:8]}"
3475
+
3476
+ def _create_a2a_event( # noqa: PLR0913
3477
+ self,
3478
+ event_type: A2AStreamEventType,
3479
+ content: Any,
3480
+ metadata: dict[str, Any] | None = None,
3481
+ tool_info: dict[str, Any] | None = None,
3482
+ thinking_and_activity_info: dict[str, Any] | None = None,
3483
+ is_final: bool = False,
3484
+ artifacts: list | None = None,
3485
+ references: list | None = None,
3486
+ step_usage: dict[str, Any] | None = None,
3487
+ total_usage: dict[str, Any] | None = None,
3488
+ model_usage_delta: dict[str, Any] | None = None,
3489
+ model_usage: list[dict[str, Any]] | None = None,
3490
+ ) -> A2AEvent:
3491
+ """Create a structured A2AEvent dictionary.
3492
+
3493
+ Args:
3494
+ event_type: The semantic type of the event.
3495
+ content: The main text content of the event.
3496
+ metadata: Additional metadata.
3497
+ tool_info: Tool-specific information.
3498
+ thinking_and_activity_info: Thinking and activity info from the model.
3499
+ is_final: Whether this is a final event.
3500
+ artifacts: List of artifacts to attach to the event.
3501
+ references: List of references to attach to the event.
3502
+ step_usage: Step-level token usage information.
3503
+ total_usage: Total token usage information.
3504
+ model_usage_delta: Per-call model usage delta.
3505
+ model_usage: Final aggregated model usage.
3506
+
3507
+ Returns:
3508
+ A dictionary conforming to the A2AEvent TypedDict.
3509
+ """
3510
+ enriched_metadata: dict[str, Any] = metadata.copy() if isinstance(metadata, dict) else {}
3511
+ if "agent_name" not in enriched_metadata:
3512
+ enriched_metadata["agent_name"] = self.name
3513
+ if "step_id" not in enriched_metadata:
3514
+ enriched_metadata["step_id"] = self._generate_meaningful_step_id(event_type, tool_info)
3515
+ if "previous_step_ids" not in enriched_metadata:
3516
+ enriched_metadata["previous_step_ids"] = []
3517
+
3518
+ # Inject cumulative time since the first STATUS_UPDATE for this thread
3519
+ # Do not set cumulative time here; server executor enforces it for all SSE events
3520
+
3521
+ normalized_content = self._normalize_event_content(content)
3522
+
3523
+ event = {
3524
+ "event_type": event_type,
3525
+ "content": normalized_content,
3526
+ "metadata": enriched_metadata,
3527
+ "tool_info": tool_info,
3528
+ "is_final": is_final,
3529
+ "artifacts": artifacts,
3530
+ "references": references,
3531
+ STEP_USAGE_KEY: step_usage,
3532
+ TOTAL_USAGE_KEY: total_usage,
3533
+ MODEL_USAGE_DELTA_KEY: model_usage_delta,
3534
+ MODEL_USAGE_KEY: model_usage,
3535
+ }
3536
+
3537
+ if thinking_and_activity_info is not None:
3538
+ event["thinking_and_activity_info"] = thinking_and_activity_info
3539
+
3540
+ try:
3541
+ content_preview = normalized_content
3542
+ logger.info(
3543
+ "A2A emitting event: type=%s step_id=%s final=%s preview=%s",
3544
+ getattr(event_type, "value", event_type),
3545
+ enriched_metadata.get("step_id"),
3546
+ is_final,
3547
+ content_preview[:120].replace("\n", " "),
3548
+ )
3549
+ except Exception:
3550
+ logger.debug("A2A emitting event (logging preview failed)", exc_info=True)
3551
+
3552
+ return event
3553
+
3554
+ def _resolve_tool_event_type(self, event_type_raw: Any) -> A2AStreamEventType | None:
3555
+ """Normalize a raw event type to ``A2AStreamEventType``.
3556
+
3557
+ Args:
3558
+ event_type_raw: Raw ``event_type`` value from a streaming chunk.
3559
+
3560
+ Returns:
3561
+ The resolved ``A2AStreamEventType`` when supported, otherwise ``None``.
3562
+ """
3563
+ if isinstance(event_type_raw, A2AStreamEventType):
3564
+ return event_type_raw
3565
+ if isinstance(event_type_raw, str):
3566
+ try:
3567
+ return A2AStreamEventType(event_type_raw)
3568
+ except ValueError:
3569
+ return None
3570
+ return None
3571
+
3572
+ @staticmethod
3573
+ def _is_supported_tool_event(event_type: A2AStreamEventType) -> bool:
3574
+ """Return True when the event type is a tool-related streaming event.
3575
+
3576
+ Args:
3577
+ event_type: Candidate event type to evaluate.
3578
+
3579
+ Returns:
3580
+ True when the event type should be forwarded to the client.
3581
+ """
3582
+ return event_type in {
3583
+ A2AStreamEventType.TOOL_CALL,
3584
+ A2AStreamEventType.TOOL_RESULT,
3585
+ A2AStreamEventType.STATUS_UPDATE,
3586
+ }
3587
+
3588
+ def _build_tool_activity_payload(
3589
+ self,
3590
+ event_type: A2AStreamEventType,
3591
+ metadata: dict[str, Any] | None,
3592
+ tool_info: dict[str, Any] | None,
3593
+ activity_info: dict[str, Any] | None,
3594
+ ) -> dict[str, Any] | None:
3595
+ """Ensure tool events carry activity payloads per the streaming contract.
3596
+
3597
+ Args:
3598
+ event_type: Stream event type emitted by the tool.
3599
+ metadata: Optional metadata accompanying the chunk.
3600
+ tool_info: Tool details provided by the emitting runner.
3601
+ activity_info: Pre-built activity payload to reuse when present.
3602
+
3603
+ Returns:
3604
+ Activity dictionary ready to be serialized with the tool chunk.
3605
+ """
3606
+ if event_type not in (A2AStreamEventType.TOOL_CALL, A2AStreamEventType.TOOL_RESULT):
3607
+ return activity_info
3608
+
3609
+ if activity_info:
3610
+ return activity_info
3611
+
3612
+ activity_context = self._compose_tool_activity_context(metadata, tool_info)
3613
+ return create_tool_activity_info(activity_context)
3614
+
3615
+ def _compose_tool_activity_context(
3616
+ self,
3617
+ metadata: dict[str, Any] | None,
3618
+ tool_info: dict[str, Any] | None,
3619
+ ) -> dict[str, Any] | None:
3620
+ """Create a context dictionary for downstream activity message generation.
3621
+
3622
+ Args:
3623
+ metadata: Metadata payload extracted from the streaming chunk.
3624
+ tool_info: Tool descriptor containing ids and display names.
3625
+
3626
+ Returns:
3627
+ A merged context dictionary or None when no data was provided.
3628
+ """
3629
+ activity_context: dict[str, Any] | None = None
3630
+ if isinstance(metadata, dict):
3631
+ activity_context = metadata.copy()
3632
+ if isinstance(tool_info, dict):
3633
+ if activity_context is None:
3634
+ activity_context = {"tool_info": tool_info}
3635
+ else:
3636
+ activity_context.setdefault("tool_info", tool_info)
3637
+ return activity_context
3638
+
3639
+ def _create_tool_streaming_event(self, chunk: dict[str, Any], writer: StreamWriter, tool_name: str) -> None:
3640
+ """Create and emit tool streaming events.
3641
+
3642
+ Only processes TOOL_CALL and TOOL_RESULT event types.
3643
+
3644
+ Args:
3645
+ chunk: Streaming chunk from the tool.
3646
+ writer: Stream writer to emit events.
3647
+ tool_name: Name of the tool producing the chunk.
3648
+ """
3649
+ event_type = self._resolve_tool_event_type(chunk.get("event_type"))
3650
+ if not event_type or not self._is_supported_tool_event(event_type):
3651
+ return
3652
+
3653
+ tool_info = chunk.get("tool_info")
3654
+ metadata = chunk.get("metadata")
3655
+
3656
+ if (
3657
+ event_type == A2AStreamEventType.TOOL_RESULT
3658
+ and isinstance(tool_info, dict)
3659
+ and not tool_info.get("id")
3660
+ and isinstance(tool_info.get("tool_calls"), list)
3661
+ and tool_info.get("tool_calls")
3662
+ ):
3663
+ logger.info(
3664
+ "A2A skipping streaming tool_result without id (tool=%s)",
3665
+ tool_info.get("name"),
3666
+ )
3667
+ return
3668
+
3669
+ activity_info = self._build_tool_activity_payload(
3670
+ event_type,
3671
+ metadata if isinstance(metadata, dict) else None,
3672
+ tool_info if isinstance(tool_info, dict) else None,
3673
+ chunk.get("thinking_and_activity_info"),
3674
+ )
3675
+
3676
+ a2a_event = self._create_a2a_event(
3677
+ event_type=event_type,
3678
+ content=chunk.get("content", f"Processing with tools: {tool_name}"),
3679
+ metadata=metadata,
3680
+ tool_info=tool_info,
3681
+ thinking_and_activity_info=activity_info,
3682
+ )
3683
+ writer(a2a_event)
3684
+
3685
+ # ------------------------------------------------------------------
3686
+ # Context compaction integration point (feature-flagged, SLICE-001)
3687
+ # ------------------------------------------------------------------
3688
+
3689
+ @property
3690
+ def _compaction_config(self) -> dict[str, Any] | None:
3691
+ """Return compaction config if enabled, else None.
3692
+
3693
+ Feature flag: returns None by default until #830 SDK config lands.
3694
+ Subclasses override to enable context compaction at the model
3695
+ invocation boundary.
3696
+ """
3697
+ return None
3698
+
3699
+ async def _emit_compaction_event(
3700
+ self,
3701
+ stage: str,
3702
+ *,
3703
+ content: str = "",
3704
+ metadata: dict[str, Any] | None = None,
3705
+ ) -> None:
3706
+ """Emit a compaction lifecycle A2A STATUS_UPDATE event if streaming.
3707
+
3708
+ Uses the LangGraph stream writer when available (streaming path),
3709
+ falls back to the agent event_emitter (connector streaming path),
3710
+ and silently no-ops during non-streaming invoke where neither is
3711
+ available.
3712
+
3713
+ Args:
3714
+ stage: Compaction lifecycle stage (started, completed, skipped, failed).
3715
+ content: Short safe content text for the event.
3716
+ metadata: Safe metadata dict from build_compaction_event_metadata.
3717
+ """
3718
+ from aip_agents.context.metadata import build_compaction_event_metadata
3719
+
3720
+ event_metadata: dict[str, Any] = {
3721
+ **build_compaction_event_metadata(stage=stage),
3722
+ **dict(metadata or {}),
3723
+ }
3724
+ event_metadata.setdefault("step_type", "context_compaction")
3725
+ event_metadata.setdefault("stage", stage)
3726
+
3727
+ try:
3728
+ from langgraph.config import get_stream_writer
3729
+
3730
+ writer = get_stream_writer()
3731
+ except Exception:
3732
+ writer = None
3733
+
3734
+ if writer is not None:
3735
+ a2a_event = self._create_a2a_event(
3736
+ event_type=A2AStreamEventType.STATUS_UPDATE,
3737
+ content=content,
3738
+ metadata=event_metadata,
3739
+ )
3740
+ try:
3741
+ writer(a2a_event)
3742
+ except Exception:
3743
+ logger.debug("Compaction event writer failed (non-streaming path expected)", exc_info=True)
3744
+ return
3745
+
3746
+ if self.event_emitter is not None:
3747
+ try:
3748
+ emitted = self.event_emitter.emit(
3749
+ Event(
3750
+ type=A2AStreamEventType.STATUS_UPDATE.value,
3751
+ value=content,
3752
+ metadata=event_metadata,
3753
+ )
3754
+ )
3755
+ if inspect.isawaitable(emitted):
3756
+ await emitted
3757
+ except Exception:
3758
+ logger.debug("Compaction event_emitter failed (non-streaming path expected)", exc_info=True)
3759
+
3760
+ async def _apply_compaction(
3761
+ self,
3762
+ messages: Sequence[Any],
3763
+ *,
3764
+ recall: Any | None = None,
3765
+ provider_overflow: bool = False,
3766
+ preserve_topic: str | None = None,
3767
+ ) -> tuple[list[Any], Any | None]:
3768
+ """Apply context compaction before model invocation.
3769
+
3770
+ No-op when compaction is not configured. Subclasses may override
3771
+ to wire the full prepare_context() pipeline.
3772
+
3773
+ Args:
3774
+ messages: Current message sequence (OpenAI-format dicts).
3775
+ recall: Optional session recall snapshot.
3776
+ provider_overflow: If True, force compaction even below
3777
+ the normal threshold (provider-overflow retry path).
3778
+ preserve_topic: Optional topic to preserve through compaction.
3779
+
3780
+ Returns:
3781
+ Tuple of (messages, preparation_result_or_None).
3782
+ """
3783
+ cfg = self._compaction_config
3784
+ if cfg is None:
3785
+ return list(messages), None
3786
+
3787
+ from aip_agents.context.budget import ContextBudgetConfig
3788
+ from aip_agents.context.metadata import (
3789
+ build_compaction_metadata,
3790
+ safe_compaction_content,
3791
+ )
3792
+ from aip_agents.context.preparation import prepare_context
3793
+
3794
+ budget_config = ContextBudgetConfig(
3795
+ threshold_ratio=cfg.get("threshold_ratio", 0.85),
3796
+ fallback_threshold_tokens=cfg.get("fallback_threshold_tokens", 170_000),
3797
+ min_tail_tokens=cfg.get("min_tail_tokens", 6_000),
3798
+ target_tail_ratio=cfg.get("target_tail_ratio", 0.10),
3799
+ min_head_messages=cfg.get("min_head_messages", 2),
3800
+ )
3801
+
3802
+ await self._emit_compaction_event("started", content=safe_compaction_content("started"))
3803
+
3804
+ effective_focus = preserve_topic or (getattr(recall, "active_task", None) if recall else None)
3805
+ result = prepare_context(
3806
+ messages,
3807
+ config=budget_config,
3808
+ focus_topic=effective_focus,
3809
+ preserve_topic=preserve_topic,
3810
+ provider_overflow=provider_overflow,
3811
+ )
3812
+
3813
+ await self._emit_compaction_result_event(result)
3814
+ self._last_compaction_preparation = build_compaction_metadata(
3815
+ result.state,
3816
+ tokens_before=result.summary.prompt_tokens_before if result.summary else None,
3817
+ tokens_after=result.summary.prompt_tokens_after if result.summary else None,
3818
+ summary_id_for_state=result.summary.summary_id if result.summary else None,
3819
+ )
3820
+ return result.messages, result
3821
+
3822
+ async def _emit_compaction_result_event(
3823
+ self,
3824
+ result: Any,
3825
+ ) -> None:
3826
+ """Emit compaction lifecycle event based on preparation result state."""
3827
+ from aip_agents.context.metadata import build_compaction_event_metadata, safe_compaction_content
3828
+
3829
+ state = result.state
3830
+ summary = result.summary
3831
+ decision = result.decision
3832
+
3833
+ if state is None:
3834
+ metadata = build_compaction_event_metadata(stage="failed", summary_status="none")
3835
+ await self._emit_compaction_event("failed", content=safe_compaction_content("failed"), metadata=metadata)
3836
+ return
3837
+
3838
+ if state.compaction_count > 0:
3839
+ stage = "failed" if state.last_summary_status == "failed" else "completed"
3840
+ stage_content = safe_compaction_content(stage)
3841
+ tokens_before = (
3842
+ summary.prompt_tokens_before if summary is not None and summary.prompt_tokens_before > 0 else None
3843
+ )
3844
+ tokens_after = (
3845
+ summary.prompt_tokens_after if summary is not None and summary.prompt_tokens_after > 0 else None
3846
+ )
3847
+ metadata = build_compaction_event_metadata(
3848
+ stage=stage,
3849
+ trigger_reason=state.last_reason,
3850
+ summary_status=state.last_summary_status,
3851
+ tokens_before=tokens_before,
3852
+ tokens_after=tokens_after,
3853
+ offloaded_ref_count=state.offloaded_ref_count,
3854
+ summary_id=summary.summary_id if summary is not None else None,
3855
+ )
3856
+ await self._emit_compaction_event(stage, content=stage_content, metadata=metadata)
3857
+ return
3858
+
3859
+ metadata = build_compaction_event_metadata(
3860
+ stage="skipped",
3861
+ trigger_reason=decision.reason if decision else None,
3862
+ summary_status=state.last_summary_status,
3863
+ )
3864
+ await self._emit_compaction_event("skipped", content=safe_compaction_content("skipped"), metadata=metadata)
3865
+
3866
+ @staticmethod
3867
+ def _append_recall_guidance(instruction: str, recall_guidance: str | None) -> str:
3868
+ """Append session recall guidance to the current instruction.
3869
+
3870
+ Preserves any middleware edits already applied to the instruction.
3871
+ No-op when recall_guidance is empty or None.
3872
+ """
3873
+ if not recall_guidance:
3874
+ return instruction
3875
+ return f"{instruction}\n\n{recall_guidance}"
3876
+
3877
+ @staticmethod
3878
+ def _base_message_to_dict(message: BaseMessage) -> dict[str, Any]:
3879
+ """Convert a LangChain BaseMessage to an OpenAI-format dict."""
3880
+ role_map = {
3881
+ "human": "user",
3882
+ "ai": "assistant",
3883
+ "tool": "tool",
3884
+ "system": "system",
3885
+ }
3886
+ d: dict[str, Any] = {
3887
+ "role": role_map.get(message.type, "user"),
3888
+ "content": message.content,
3889
+ "type": message.type,
3890
+ }
3891
+ if message.additional_kwargs is not None:
3892
+ d["additional_kwargs"] = message.additional_kwargs
3893
+ if message.response_metadata is not None:
3894
+ d["response_metadata"] = message.response_metadata
3895
+ if message.id is not None:
3896
+ d["id"] = message.id
3897
+ if message.name is not None:
3898
+ d["name"] = message.name
3899
+ if getattr(message, "usage_metadata", None) is not None:
3900
+ d["usage_metadata"] = message.usage_metadata
3901
+ if getattr(message, "invalid_tool_calls", None) is not None:
3902
+ d["invalid_tool_calls"] = message.invalid_tool_calls
3903
+ if getattr(message, "artifact", None) is not None:
3904
+ d["artifact"] = message.artifact
3905
+ if getattr(message, "status", None) is not None:
3906
+ d["status"] = message.status
3907
+ if isinstance(message, ToolMessage):
3908
+ d["tool_call_id"] = message.tool_call_id
3909
+ if isinstance(message, AIMessage) and message.tool_calls:
3910
+ d["tool_calls"] = [
3911
+ {
3912
+ "id": tc.get("id", ""),
3913
+ "type": "function",
3914
+ "function": {
3915
+ "name": tc.get("name", ""),
3916
+ "arguments": json.dumps(tc.get("args", {}), ensure_ascii=False),
3917
+ },
3918
+ }
3919
+ for tc in message.tool_calls
3920
+ ]
3921
+ return d
3922
+
3923
+ @staticmethod
3924
+ def _parse_tool_call_dicts(tool_calls: list[dict[str, Any]]) -> list[dict[str, Any]]:
3925
+ """Parse OpenAI-format tool call dicts into LangChain tool call format."""
3926
+ lc_tool_calls: list[dict[str, Any]] = []
3927
+ for tc in tool_calls:
3928
+ func = tc.get("function", {})
3929
+ args_raw = func.get("arguments", "{}")
3930
+ if isinstance(args_raw, str):
3931
+ try:
3932
+ args_parsed = json.loads(args_raw)
3933
+ except (json.JSONDecodeError, TypeError):
3934
+ args_parsed = {}
3935
+ else:
3936
+ args_parsed = args_raw
3937
+ lc_tool_calls.append(
3938
+ {
3939
+ "id": tc.get("id", ""),
3940
+ "name": func.get("name", ""),
3941
+ "args": args_parsed,
3942
+ }
3943
+ )
3944
+ return lc_tool_calls
3945
+
3946
+ @staticmethod
3947
+ def _message_constructor_kwargs(
3948
+ message_cls: type[BaseMessage],
3949
+ d: dict[str, Any],
3950
+ ) -> dict[str, Any]:
3951
+ """Return constructor kwargs shared by message classes."""
3952
+ kwargs: dict[str, Any] = {"content": d.get("content", "")}
3953
+ allowed_fields = set(message_cls.model_fields.keys())
3954
+ for key in (
3955
+ "additional_kwargs",
3956
+ "response_metadata",
3957
+ "id",
3958
+ "name",
3959
+ "usage_metadata",
3960
+ "invalid_tool_calls",
3961
+ "artifact",
3962
+ "status",
3963
+ ):
3964
+ if key not in allowed_fields:
3965
+ continue
3966
+ if key in d:
3967
+ kwargs[key] = d[key]
3968
+ return kwargs
3969
+
3970
+ @staticmethod
3971
+ def _build_message(message_cls: type[BaseMessage], **kwargs: Any) -> BaseMessage:
3972
+ """Build a message, preserving provider payloads LangChain rejects at init."""
3973
+ if isinstance(kwargs.get("content"), dict):
3974
+ return cast(BaseMessage, message_cls.model_construct(**kwargs))
3975
+ try:
3976
+ return message_cls(**kwargs)
3977
+ except ValidationError:
3978
+ return cast(BaseMessage, message_cls.model_construct(**kwargs))
3979
+
3980
+ @staticmethod
3981
+ def _dict_to_base_message(d: dict[str, Any]) -> BaseMessage:
3982
+ """Convert an OpenAI-format dict back to a LangChain BaseMessage."""
3983
+ role = d.get("role", "user")
3984
+ if role == "tool":
3985
+ return BaseLangGraphAgent._build_message(
3986
+ ToolMessage,
3987
+ **BaseLangGraphAgent._message_constructor_kwargs(ToolMessage, d),
3988
+ tool_call_id=d.get("tool_call_id", ""),
3989
+ )
3990
+ if role == "assistant":
3991
+ tool_calls = d.get("tool_calls")
3992
+ if tool_calls:
3993
+ return BaseLangGraphAgent._build_message(
3994
+ AIMessage,
3995
+ **BaseLangGraphAgent._message_constructor_kwargs(AIMessage, d),
3996
+ tool_calls=BaseLangGraphAgent._parse_tool_call_dicts(tool_calls),
3997
+ )
3998
+ return BaseLangGraphAgent._build_message(
3999
+ AIMessage,
4000
+ **BaseLangGraphAgent._message_constructor_kwargs(AIMessage, d),
4001
+ )
4002
+ if role == "system":
4003
+ return BaseLangGraphAgent._build_message(
4004
+ SystemMessage,
4005
+ **BaseLangGraphAgent._message_constructor_kwargs(SystemMessage, d),
4006
+ )
4007
+ return BaseLangGraphAgent._build_message(
4008
+ HumanMessage,
4009
+ **BaseLangGraphAgent._message_constructor_kwargs(HumanMessage, d),
4010
+ )
4011
+
4012
+ async def _compact_messages(
4013
+ self,
4014
+ message_dicts: list[dict[str, Any]],
4015
+ system_content: str | None,
4016
+ recall: Any,
4017
+ ) -> list[dict[str, Any]]:
4018
+ """Apply compaction to message dicts; return conversation-only messages.
4019
+
4020
+ The system prompt may be included for budget estimation, but is always
4021
+ stripped from the result — the caller owns the system prompt separately.
4022
+ """
4023
+ budget_messages = (
4024
+ [{"role": "system", "content": system_content}] + message_dicts if system_content else message_dicts
4025
+ )
4026
+ compacted_dicts, _ = await self._apply_compaction(budget_messages, recall=recall)
4027
+ return [d for d in compacted_dicts if d.get("role") != "system"]
4028
+
4029
+ async def _compact_conversation_with_recall(
4030
+ self,
4031
+ message_dicts: list[dict[str, Any]],
4032
+ system_content: str,
4033
+ ) -> tuple[list[dict[str, Any]], str]:
4034
+ """Apply recall guidance and compaction to conversation messages."""
4035
+ from aip_agents.context.runtime import derive_session_recall
4036
+ from aip_agents.context.session_recall import build_session_recall_guidance
4037
+
4038
+ recall = derive_session_recall(message_dicts)
4039
+ recall_guidance = build_session_recall_guidance(recall)
4040
+ if recall_guidance:
4041
+ system_content = self._append_recall_guidance(system_content, recall_guidance)
4042
+
4043
+ compacted_conversation = await self._compact_messages(
4044
+ message_dicts,
4045
+ system_content,
4046
+ recall,
4047
+ )
4048
+ return compacted_conversation, system_content