solace-agent-mesh 1.6.1__py3-none-any.whl → 1.13.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of solace-agent-mesh might be problematic. Click here for more details.

Files changed (481) hide show
  1. solace_agent_mesh/agent/adk/alembic/README +74 -0
  2. solace_agent_mesh/agent/adk/alembic/env.py +77 -0
  3. solace_agent_mesh/agent/adk/alembic/script.py.mako +28 -0
  4. solace_agent_mesh/agent/adk/alembic/versions/e2902798564d_adk_session_db_upgrade.py +52 -0
  5. solace_agent_mesh/agent/adk/alembic.ini +112 -0
  6. solace_agent_mesh/agent/adk/app_llm_agent.py +26 -0
  7. solace_agent_mesh/agent/adk/artifacts/filesystem_artifact_service.py +165 -1
  8. solace_agent_mesh/agent/adk/artifacts/s3_artifact_service.py +163 -0
  9. solace_agent_mesh/agent/adk/callbacks.py +852 -109
  10. solace_agent_mesh/agent/adk/embed_resolving_mcp_toolset.py +234 -36
  11. solace_agent_mesh/agent/adk/intelligent_mcp_callbacks.py +52 -5
  12. solace_agent_mesh/agent/adk/mcp_content_processor.py +1 -1
  13. solace_agent_mesh/agent/adk/models/lite_llm.py +77 -21
  14. solace_agent_mesh/agent/adk/models/oauth2_token_manager.py +24 -137
  15. solace_agent_mesh/agent/adk/runner.py +85 -20
  16. solace_agent_mesh/agent/adk/schema_migration.py +88 -0
  17. solace_agent_mesh/agent/adk/services.py +94 -18
  18. solace_agent_mesh/agent/adk/setup.py +281 -65
  19. solace_agent_mesh/agent/adk/stream_parser.py +231 -37
  20. solace_agent_mesh/agent/adk/tool_wrapper.py +3 -0
  21. solace_agent_mesh/agent/protocol/event_handlers.py +472 -137
  22. solace_agent_mesh/agent/proxies/a2a/app.py +3 -2
  23. solace_agent_mesh/agent/proxies/a2a/component.py +572 -75
  24. solace_agent_mesh/agent/proxies/a2a/config.py +80 -4
  25. solace_agent_mesh/agent/proxies/base/app.py +3 -2
  26. solace_agent_mesh/agent/proxies/base/component.py +188 -22
  27. solace_agent_mesh/agent/proxies/base/proxy_task_context.py +3 -1
  28. solace_agent_mesh/agent/sac/app.py +91 -3
  29. solace_agent_mesh/agent/sac/component.py +591 -157
  30. solace_agent_mesh/agent/sac/patch_adk.py +8 -16
  31. solace_agent_mesh/agent/sac/task_execution_context.py +146 -4
  32. solace_agent_mesh/agent/tools/__init__.py +3 -0
  33. solace_agent_mesh/agent/tools/audio_tools.py +3 -3
  34. solace_agent_mesh/agent/tools/builtin_artifact_tools.py +710 -171
  35. solace_agent_mesh/agent/tools/deep_research_tools.py +2161 -0
  36. solace_agent_mesh/agent/tools/dynamic_tool.py +2 -0
  37. solace_agent_mesh/agent/tools/peer_agent_tool.py +82 -15
  38. solace_agent_mesh/agent/tools/time_tools.py +126 -0
  39. solace_agent_mesh/agent/tools/tool_config_types.py +57 -2
  40. solace_agent_mesh/agent/tools/web_search_tools.py +279 -0
  41. solace_agent_mesh/agent/tools/web_tools.py +125 -17
  42. solace_agent_mesh/agent/utils/artifact_helpers.py +248 -6
  43. solace_agent_mesh/agent/utils/context_helpers.py +17 -0
  44. solace_agent_mesh/assets/docs/404.html +6 -6
  45. solace_agent_mesh/assets/docs/assets/css/{styles.906a1503.css → styles.8162edfb.css} +1 -1
  46. solace_agent_mesh/assets/docs/assets/js/05749d90.19ac4f35.js +1 -0
  47. solace_agent_mesh/assets/docs/assets/js/15ba94aa.e186750d.js +1 -0
  48. solace_agent_mesh/assets/docs/assets/js/15e40e79.434bb30f.js +1 -0
  49. solace_agent_mesh/assets/docs/assets/js/17896441.e612dfb4.js +1 -0
  50. solace_agent_mesh/assets/docs/assets/js/2279.550aa580.js +2 -0
  51. solace_agent_mesh/assets/docs/assets/js/{17896441.a5e82f9b.js.LICENSE.txt → 2279.550aa580.js.LICENSE.txt} +6 -0
  52. solace_agent_mesh/assets/docs/assets/js/240a0364.83e37aa8.js +1 -0
  53. solace_agent_mesh/assets/docs/assets/js/2987107d.a80604f9.js +1 -0
  54. solace_agent_mesh/assets/docs/assets/js/2e32b5e0.2f0db237.js +1 -0
  55. solace_agent_mesh/assets/docs/assets/js/3a6c6137.7e61915d.js +1 -0
  56. solace_agent_mesh/assets/docs/assets/js/3ac1795d.7f7ab1c1.js +1 -0
  57. solace_agent_mesh/assets/docs/assets/js/3ff0015d.e53c9b78.js +1 -0
  58. solace_agent_mesh/assets/docs/assets/js/41adc471.0e95b87c.js +1 -0
  59. solace_agent_mesh/assets/docs/assets/js/4667dc50.bf2ad456.js +1 -0
  60. solace_agent_mesh/assets/docs/assets/js/49eed117.493d6f99.js +1 -0
  61. solace_agent_mesh/assets/docs/assets/js/{509e993c.4c7a1a6d.js → 509e993c.a1fbf45a.js} +1 -1
  62. solace_agent_mesh/assets/docs/assets/js/547e15cc.8e6da617.js +1 -0
  63. solace_agent_mesh/assets/docs/assets/js/55b7b518.29d6e75d.js +1 -0
  64. solace_agent_mesh/assets/docs/assets/js/5b8d9c11.d4eb37b8.js +1 -0
  65. solace_agent_mesh/assets/docs/assets/js/5c2bd65f.1ee87753.js +1 -0
  66. solace_agent_mesh/assets/docs/assets/js/60702c0e.a8bdd79b.js +1 -0
  67. solace_agent_mesh/assets/docs/assets/js/631738c7.fa471607.js +1 -0
  68. solace_agent_mesh/assets/docs/assets/js/64195356.09dbd087.js +1 -0
  69. solace_agent_mesh/assets/docs/assets/js/66d4869e.30340bd3.js +1 -0
  70. solace_agent_mesh/assets/docs/assets/js/6a520c9d.b6e3f2ce.js +1 -0
  71. solace_agent_mesh/assets/docs/assets/js/6aaedf65.7253541d.js +1 -0
  72. solace_agent_mesh/assets/docs/assets/js/6ad8f0bd.a5b36a60.js +1 -0
  73. solace_agent_mesh/assets/docs/assets/js/6d84eae0.fd23ba4a.js +1 -0
  74. solace_agent_mesh/assets/docs/assets/js/71da7b71.374b9d54.js +1 -0
  75. solace_agent_mesh/assets/docs/assets/js/729898df.7249e9fd.js +1 -0
  76. solace_agent_mesh/assets/docs/assets/js/7e294c01.7c5f6906.js +1 -0
  77. solace_agent_mesh/assets/docs/assets/js/8024126c.e3467286.js +1 -0
  78. solace_agent_mesh/assets/docs/assets/js/81a99df0.7ed65d45.js +1 -0
  79. solace_agent_mesh/assets/docs/assets/js/82fbfb93.161823a5.js +1 -0
  80. solace_agent_mesh/assets/docs/assets/js/8b032486.91a91afc.js +1 -0
  81. solace_agent_mesh/assets/docs/assets/js/924ffdeb.975e428a.js +1 -0
  82. solace_agent_mesh/assets/docs/assets/js/94e8668d.16083b3f.js +1 -0
  83. solace_agent_mesh/assets/docs/assets/js/9bb13469.4523ae20.js +1 -0
  84. solace_agent_mesh/assets/docs/assets/js/a7d42657.a956689d.js +1 -0
  85. solace_agent_mesh/assets/docs/assets/js/a94703ab.3e5fbcb3.js +1 -0
  86. solace_agent_mesh/assets/docs/assets/js/ab9708a8.3e563275.js +1 -0
  87. solace_agent_mesh/assets/docs/assets/js/ad87452a.9d73dad6.js +1 -0
  88. solace_agent_mesh/assets/docs/assets/js/c93cbaa0.0e0d8baf.js +1 -0
  89. solace_agent_mesh/assets/docs/assets/js/cab03b5b.6a073091.js +1 -0
  90. solace_agent_mesh/assets/docs/assets/js/cbe2e9ea.07e170dd.js +1 -0
  91. solace_agent_mesh/assets/docs/assets/js/da0b5bad.b62f7b08.js +1 -0
  92. solace_agent_mesh/assets/docs/assets/js/dd817ffc.c37a755e.js +1 -0
  93. solace_agent_mesh/assets/docs/assets/js/dd81e2b8.b682e9c2.js +1 -0
  94. solace_agent_mesh/assets/docs/assets/js/de915948.44a432bc.js +1 -0
  95. solace_agent_mesh/assets/docs/assets/js/e04b235d.06d23db6.js +1 -0
  96. solace_agent_mesh/assets/docs/assets/js/e1b6eeb4.deb2b62e.js +1 -0
  97. solace_agent_mesh/assets/docs/assets/js/e3d9abda.1476f570.js +1 -0
  98. solace_agent_mesh/assets/docs/assets/js/e6f9706b.acc800d3.js +1 -0
  99. solace_agent_mesh/assets/docs/assets/js/e92d0134.c147a429.js +1 -0
  100. solace_agent_mesh/assets/docs/assets/js/ee0c2fe7.94d0a351.js +1 -0
  101. solace_agent_mesh/assets/docs/assets/js/f284c35a.cc97854c.js +1 -0
  102. solace_agent_mesh/assets/docs/assets/js/ff4d71f2.74710fc1.js +1 -0
  103. solace_agent_mesh/assets/docs/assets/js/main.d634009f.js +2 -0
  104. solace_agent_mesh/assets/docs/assets/js/runtime~main.27bb82a7.js +1 -0
  105. solace_agent_mesh/assets/docs/docs/documentation/components/agents/index.html +68 -68
  106. solace_agent_mesh/assets/docs/docs/documentation/components/builtin-tools/artifact-management/index.html +50 -50
  107. solace_agent_mesh/assets/docs/docs/documentation/components/builtin-tools/audio-tools/index.html +42 -42
  108. solace_agent_mesh/assets/docs/docs/documentation/components/builtin-tools/data-analysis-tools/index.html +55 -55
  109. solace_agent_mesh/assets/docs/docs/documentation/components/builtin-tools/embeds/index.html +82 -68
  110. solace_agent_mesh/assets/docs/docs/documentation/components/builtin-tools/image-tools/index.html +81 -0
  111. solace_agent_mesh/assets/docs/docs/documentation/components/builtin-tools/index.html +67 -50
  112. solace_agent_mesh/assets/docs/docs/documentation/components/builtin-tools/research-tools/index.html +136 -0
  113. solace_agent_mesh/assets/docs/docs/documentation/components/cli/index.html +178 -144
  114. solace_agent_mesh/assets/docs/docs/documentation/components/gateways/index.html +43 -42
  115. solace_agent_mesh/assets/docs/docs/documentation/components/index.html +20 -18
  116. solace_agent_mesh/assets/docs/docs/documentation/components/orchestrator/index.html +23 -23
  117. solace_agent_mesh/assets/docs/docs/documentation/components/platform-service/index.html +33 -0
  118. solace_agent_mesh/assets/docs/docs/documentation/components/plugins/index.html +45 -45
  119. solace_agent_mesh/assets/docs/docs/documentation/components/projects/index.html +182 -0
  120. solace_agent_mesh/assets/docs/docs/documentation/components/prompts/index.html +147 -0
  121. solace_agent_mesh/assets/docs/docs/documentation/components/proxies/index.html +208 -125
  122. solace_agent_mesh/assets/docs/docs/documentation/components/speech/index.html +52 -0
  123. solace_agent_mesh/assets/docs/docs/documentation/deploying/debugging/index.html +28 -49
  124. solace_agent_mesh/assets/docs/docs/documentation/deploying/deployment-options/index.html +29 -30
  125. solace_agent_mesh/assets/docs/docs/documentation/deploying/index.html +14 -14
  126. solace_agent_mesh/assets/docs/docs/documentation/deploying/kubernetes/index.html +47 -0
  127. solace_agent_mesh/assets/docs/docs/documentation/deploying/kubernetes/kubernetes-deployment-guide/index.html +197 -0
  128. solace_agent_mesh/assets/docs/docs/documentation/deploying/logging/index.html +90 -0
  129. solace_agent_mesh/assets/docs/docs/documentation/deploying/observability/index.html +17 -16
  130. solace_agent_mesh/assets/docs/docs/documentation/deploying/proxy_configuration/index.html +49 -0
  131. solace_agent_mesh/assets/docs/docs/documentation/developing/create-agents/index.html +38 -38
  132. solace_agent_mesh/assets/docs/docs/documentation/developing/create-gateways/index.html +162 -171
  133. solace_agent_mesh/assets/docs/docs/documentation/developing/creating-python-tools/index.html +67 -49
  134. solace_agent_mesh/assets/docs/docs/documentation/developing/creating-service-providers/index.html +17 -17
  135. solace_agent_mesh/assets/docs/docs/documentation/developing/evaluations/index.html +51 -51
  136. solace_agent_mesh/assets/docs/docs/documentation/developing/index.html +22 -22
  137. solace_agent_mesh/assets/docs/docs/documentation/developing/structure/index.html +27 -27
  138. solace_agent_mesh/assets/docs/docs/documentation/developing/tutorials/bedrock-agents/index.html +135 -135
  139. solace_agent_mesh/assets/docs/docs/documentation/developing/tutorials/custom-agent/index.html +66 -66
  140. solace_agent_mesh/assets/docs/docs/documentation/developing/tutorials/event-mesh-gateway/index.html +51 -51
  141. solace_agent_mesh/assets/docs/docs/documentation/developing/tutorials/mcp-integration/index.html +50 -38
  142. solace_agent_mesh/assets/docs/docs/documentation/developing/tutorials/mongodb-integration/index.html +86 -86
  143. solace_agent_mesh/assets/docs/docs/documentation/developing/tutorials/rag-integration/index.html +51 -51
  144. solace_agent_mesh/assets/docs/docs/documentation/developing/tutorials/rest-gateway/index.html +24 -24
  145. solace_agent_mesh/assets/docs/docs/documentation/developing/tutorials/slack-integration/index.html +30 -30
  146. solace_agent_mesh/assets/docs/docs/documentation/developing/tutorials/sql-database/index.html +44 -44
  147. solace_agent_mesh/assets/docs/docs/documentation/developing/tutorials/teams-integration/index.html +115 -0
  148. solace_agent_mesh/assets/docs/docs/documentation/enterprise/agent-builder/index.html +86 -0
  149. solace_agent_mesh/assets/docs/docs/documentation/enterprise/connectors/index.html +67 -0
  150. solace_agent_mesh/assets/docs/docs/documentation/enterprise/index.html +23 -19
  151. solace_agent_mesh/assets/docs/docs/documentation/enterprise/installation/index.html +40 -37
  152. solace_agent_mesh/assets/docs/docs/documentation/enterprise/openapi-tools/index.html +324 -0
  153. solace_agent_mesh/assets/docs/docs/documentation/enterprise/rbac-setup-guide/index.html +112 -87
  154. solace_agent_mesh/assets/docs/docs/documentation/enterprise/secure-user-delegated-access/index.html +440 -0
  155. solace_agent_mesh/assets/docs/docs/documentation/enterprise/single-sign-on/index.html +87 -64
  156. solace_agent_mesh/assets/docs/docs/documentation/enterprise/wheel-installation/index.html +62 -0
  157. solace_agent_mesh/assets/docs/docs/documentation/getting-started/architecture/index.html +44 -44
  158. solace_agent_mesh/assets/docs/docs/documentation/getting-started/index.html +39 -37
  159. solace_agent_mesh/assets/docs/docs/documentation/getting-started/introduction/index.html +30 -30
  160. solace_agent_mesh/assets/docs/docs/documentation/getting-started/try-agent-mesh/index.html +18 -18
  161. solace_agent_mesh/assets/docs/docs/documentation/getting-started/vibe_coding/index.html +62 -0
  162. solace_agent_mesh/assets/docs/docs/documentation/installing-and-configuring/artifact-storage/index.html +311 -0
  163. solace_agent_mesh/assets/docs/docs/documentation/installing-and-configuring/configurations/index.html +39 -42
  164. solace_agent_mesh/assets/docs/docs/documentation/installing-and-configuring/index.html +14 -14
  165. solace_agent_mesh/assets/docs/docs/documentation/installing-and-configuring/installation/index.html +27 -25
  166. solace_agent_mesh/assets/docs/docs/documentation/installing-and-configuring/large_language_models/index.html +69 -69
  167. solace_agent_mesh/assets/docs/docs/documentation/installing-and-configuring/run-project/index.html +72 -72
  168. solace_agent_mesh/assets/docs/docs/documentation/installing-and-configuring/session-storage/index.html +251 -0
  169. solace_agent_mesh/assets/docs/docs/documentation/installing-and-configuring/user-feedback/index.html +88 -0
  170. solace_agent_mesh/assets/docs/docs/documentation/migrations/a2a-upgrade/a2a-gateway-upgrade-to-0.3.0/index.html +42 -42
  171. solace_agent_mesh/assets/docs/docs/documentation/migrations/a2a-upgrade/a2a-technical-migration-map/index.html +20 -20
  172. solace_agent_mesh/assets/docs/docs/documentation/migrations/platform-service-split/index.html +85 -0
  173. solace_agent_mesh/assets/docs/lunr-index-1768329217460.json +1 -0
  174. solace_agent_mesh/assets/docs/lunr-index.json +1 -1
  175. solace_agent_mesh/assets/docs/search-doc-1768329217460.json +1 -0
  176. solace_agent_mesh/assets/docs/search-doc.json +1 -1
  177. solace_agent_mesh/assets/docs/sitemap.xml +1 -1
  178. solace_agent_mesh/cli/__init__.py +1 -1
  179. solace_agent_mesh/cli/commands/add_cmd/__init__.py +3 -1
  180. solace_agent_mesh/cli/commands/add_cmd/agent_cmd.py +6 -1
  181. solace_agent_mesh/cli/commands/add_cmd/proxy_cmd.py +100 -0
  182. solace_agent_mesh/cli/commands/docs_cmd.py +4 -1
  183. solace_agent_mesh/cli/commands/eval_cmd.py +1 -1
  184. solace_agent_mesh/cli/commands/init_cmd/__init__.py +15 -0
  185. solace_agent_mesh/cli/commands/init_cmd/directory_step.py +1 -1
  186. solace_agent_mesh/cli/commands/init_cmd/env_step.py +30 -3
  187. solace_agent_mesh/cli/commands/init_cmd/orchestrator_step.py +3 -4
  188. solace_agent_mesh/cli/commands/init_cmd/platform_service_step.py +85 -0
  189. solace_agent_mesh/cli/commands/init_cmd/webui_gateway_step.py +16 -3
  190. solace_agent_mesh/cli/commands/plugin_cmd/add_cmd.py +2 -1
  191. solace_agent_mesh/cli/commands/plugin_cmd/catalog_cmd.py +1 -0
  192. solace_agent_mesh/cli/commands/plugin_cmd/create_cmd.py +3 -3
  193. solace_agent_mesh/cli/commands/run_cmd.py +64 -49
  194. solace_agent_mesh/cli/commands/tools_cmd.py +315 -0
  195. solace_agent_mesh/cli/main.py +15 -0
  196. solace_agent_mesh/client/webui/frontend/static/assets/{authCallback-BTf6dqwp.js → authCallback-KnKMP_vb.js} +1 -1
  197. solace_agent_mesh/client/webui/frontend/static/assets/client-DpBL2stg.js +25 -0
  198. solace_agent_mesh/client/webui/frontend/static/assets/main-Cd498TV2.js +435 -0
  199. solace_agent_mesh/client/webui/frontend/static/assets/main-rSf8Vu29.css +1 -0
  200. solace_agent_mesh/client/webui/frontend/static/assets/vendor-CGk8Suyh.js +565 -0
  201. solace_agent_mesh/client/webui/frontend/static/auth-callback.html +3 -3
  202. solace_agent_mesh/client/webui/frontend/static/index.html +4 -4
  203. solace_agent_mesh/client/webui/frontend/static/mockServiceWorker.js +336 -0
  204. solace_agent_mesh/client/webui/frontend/static/ui-version.json +6 -0
  205. solace_agent_mesh/common/a2a/events.py +2 -1
  206. solace_agent_mesh/common/a2a/protocol.py +5 -0
  207. solace_agent_mesh/common/a2a/types.py +2 -1
  208. solace_agent_mesh/common/a2a_spec/schemas/artifact_creation_progress.json +23 -6
  209. solace_agent_mesh/common/a2a_spec/schemas/feedback_event.json +51 -0
  210. solace_agent_mesh/common/agent_registry.py +38 -11
  211. solace_agent_mesh/common/data_parts.py +144 -4
  212. solace_agent_mesh/common/error_handlers.py +83 -0
  213. solace_agent_mesh/common/exceptions.py +24 -0
  214. solace_agent_mesh/common/oauth/__init__.py +17 -0
  215. solace_agent_mesh/common/oauth/oauth_client.py +408 -0
  216. solace_agent_mesh/common/oauth/utils.py +50 -0
  217. solace_agent_mesh/common/rag_dto.py +156 -0
  218. solace_agent_mesh/common/sac/sam_component_base.py +97 -19
  219. solace_agent_mesh/common/sam_events/event_service.py +2 -2
  220. solace_agent_mesh/common/services/employee_service.py +1 -1
  221. solace_agent_mesh/common/utils/embeds/constants.py +1 -0
  222. solace_agent_mesh/common/utils/embeds/converter.py +1 -8
  223. solace_agent_mesh/common/utils/embeds/modifiers.py +4 -28
  224. solace_agent_mesh/common/utils/embeds/resolver.py +152 -31
  225. solace_agent_mesh/common/utils/embeds/types.py +9 -0
  226. solace_agent_mesh/common/utils/log_formatters.py +20 -0
  227. solace_agent_mesh/common/utils/mime_helpers.py +12 -5
  228. solace_agent_mesh/common/utils/pydantic_utils.py +90 -3
  229. solace_agent_mesh/common/utils/rbac_utils.py +69 -0
  230. solace_agent_mesh/common/utils/templates/__init__.py +8 -0
  231. solace_agent_mesh/common/utils/templates/liquid_renderer.py +210 -0
  232. solace_agent_mesh/common/utils/templates/template_resolver.py +161 -0
  233. solace_agent_mesh/config_portal/backend/common.py +12 -0
  234. solace_agent_mesh/config_portal/frontend/static/client/assets/_index-CljP4_mv.js +103 -0
  235. solace_agent_mesh/config_portal/frontend/static/client/assets/{components-Rk0n-9cK.js → components-CaC6hG8d.js} +22 -22
  236. solace_agent_mesh/config_portal/frontend/static/client/assets/{entry.client-mvZjNKiz.js → entry.client-H_TM0YBt.js} +3 -3
  237. solace_agent_mesh/config_portal/frontend/static/client/assets/{index-DzNKzXrc.js → index-CnFykb2v.js} +16 -16
  238. solace_agent_mesh/config_portal/frontend/static/client/assets/manifest-f8439d40.js +1 -0
  239. solace_agent_mesh/config_portal/frontend/static/client/assets/root-BIMqslJB.css +1 -0
  240. solace_agent_mesh/config_portal/frontend/static/client/assets/root-mJmTIdIk.js +10 -0
  241. solace_agent_mesh/config_portal/frontend/static/client/index.html +3 -3
  242. solace_agent_mesh/core_a2a/service.py +3 -2
  243. solace_agent_mesh/gateway/adapter/__init__.py +1 -0
  244. solace_agent_mesh/gateway/adapter/base.py +170 -0
  245. solace_agent_mesh/gateway/adapter/types.py +230 -0
  246. solace_agent_mesh/gateway/base/app.py +39 -2
  247. solace_agent_mesh/gateway/base/auth_interface.py +103 -0
  248. solace_agent_mesh/gateway/base/component.py +1027 -151
  249. solace_agent_mesh/gateway/generic/__init__.py +1 -0
  250. solace_agent_mesh/gateway/generic/app.py +50 -0
  251. solace_agent_mesh/gateway/generic/component.py +894 -0
  252. solace_agent_mesh/gateway/http_sse/alembic/env.py +0 -7
  253. solace_agent_mesh/gateway/http_sse/alembic/versions/20251023_add_project_users_table.py +72 -0
  254. solace_agent_mesh/gateway/http_sse/alembic/versions/20251023_add_soft_delete_and_search.py +109 -0
  255. solace_agent_mesh/gateway/http_sse/alembic/versions/20251024_add_default_agent_to_projects.py +26 -0
  256. solace_agent_mesh/gateway/http_sse/alembic/versions/20251024_add_projects_table.py +135 -0
  257. solace_agent_mesh/gateway/http_sse/alembic/versions/20251108_create_prompt_tables_with_sharing.py +154 -0
  258. solace_agent_mesh/gateway/http_sse/alembic/versions/20251115_add_parent_task_id.py +32 -0
  259. solace_agent_mesh/gateway/http_sse/alembic/versions/20251126_add_background_task_fields.py +47 -0
  260. solace_agent_mesh/gateway/http_sse/alembic/versions/20251202_add_versioned_fields_to_prompts.py +52 -0
  261. solace_agent_mesh/gateway/http_sse/alembic.ini +0 -36
  262. solace_agent_mesh/gateway/http_sse/app.py +40 -11
  263. solace_agent_mesh/gateway/http_sse/component.py +285 -160
  264. solace_agent_mesh/gateway/http_sse/dependencies.py +149 -114
  265. solace_agent_mesh/gateway/http_sse/main.py +68 -450
  266. solace_agent_mesh/gateway/http_sse/repository/__init__.py +19 -1
  267. solace_agent_mesh/gateway/http_sse/repository/chat_task_repository.py +2 -2
  268. solace_agent_mesh/gateway/http_sse/repository/entities/project.py +81 -0
  269. solace_agent_mesh/gateway/http_sse/repository/entities/project_user.py +47 -0
  270. solace_agent_mesh/gateway/http_sse/repository/entities/session.py +26 -3
  271. solace_agent_mesh/gateway/http_sse/repository/entities/task.py +7 -0
  272. solace_agent_mesh/gateway/http_sse/repository/feedback_repository.py +47 -0
  273. solace_agent_mesh/gateway/http_sse/repository/interfaces.py +114 -6
  274. solace_agent_mesh/gateway/http_sse/repository/models/__init__.py +13 -0
  275. solace_agent_mesh/gateway/http_sse/repository/models/project_model.py +51 -0
  276. solace_agent_mesh/gateway/http_sse/repository/models/project_user_model.py +75 -0
  277. solace_agent_mesh/gateway/http_sse/repository/models/prompt_model.py +159 -0
  278. solace_agent_mesh/gateway/http_sse/repository/models/session_model.py +8 -2
  279. solace_agent_mesh/gateway/http_sse/repository/models/task_model.py +8 -1
  280. solace_agent_mesh/gateway/http_sse/repository/project_repository.py +172 -0
  281. solace_agent_mesh/gateway/http_sse/repository/project_user_repository.py +186 -0
  282. solace_agent_mesh/gateway/http_sse/repository/session_repository.py +177 -11
  283. solace_agent_mesh/gateway/http_sse/repository/task_repository.py +86 -2
  284. solace_agent_mesh/gateway/http_sse/routers/agent_cards.py +38 -7
  285. solace_agent_mesh/gateway/http_sse/routers/artifacts.py +256 -58
  286. solace_agent_mesh/gateway/http_sse/routers/auth.py +168 -134
  287. solace_agent_mesh/gateway/http_sse/routers/config.py +302 -8
  288. solace_agent_mesh/gateway/http_sse/routers/dto/project_dto.py +69 -0
  289. solace_agent_mesh/gateway/http_sse/routers/dto/prompt_dto.py +255 -0
  290. solace_agent_mesh/gateway/http_sse/routers/dto/requests/project_requests.py +48 -0
  291. solace_agent_mesh/gateway/http_sse/routers/dto/requests/session_requests.py +14 -1
  292. solace_agent_mesh/gateway/http_sse/routers/dto/responses/base_responses.py +1 -1
  293. solace_agent_mesh/gateway/http_sse/routers/dto/responses/project_responses.py +31 -0
  294. solace_agent_mesh/gateway/http_sse/routers/dto/responses/session_responses.py +5 -2
  295. solace_agent_mesh/gateway/http_sse/routers/dto/responses/version_responses.py +31 -0
  296. solace_agent_mesh/gateway/http_sse/routers/feedback.py +133 -2
  297. solace_agent_mesh/gateway/http_sse/routers/people.py +2 -2
  298. solace_agent_mesh/gateway/http_sse/routers/projects.py +768 -0
  299. solace_agent_mesh/gateway/http_sse/routers/prompts.py +1416 -0
  300. solace_agent_mesh/gateway/http_sse/routers/sessions.py +167 -7
  301. solace_agent_mesh/gateway/http_sse/routers/speech.py +355 -0
  302. solace_agent_mesh/gateway/http_sse/routers/sse.py +131 -8
  303. solace_agent_mesh/gateway/http_sse/routers/tasks.py +670 -18
  304. solace_agent_mesh/gateway/http_sse/routers/users.py +1 -1
  305. solace_agent_mesh/gateway/http_sse/routers/version.py +343 -0
  306. solace_agent_mesh/gateway/http_sse/routers/visualization.py +92 -9
  307. solace_agent_mesh/gateway/http_sse/services/audio_service.py +1227 -0
  308. solace_agent_mesh/gateway/http_sse/services/background_task_monitor.py +186 -0
  309. solace_agent_mesh/gateway/http_sse/services/data_retention_service.py +1 -1
  310. solace_agent_mesh/gateway/http_sse/services/feedback_service.py +1 -1
  311. solace_agent_mesh/gateway/http_sse/services/project_service.py +930 -0
  312. solace_agent_mesh/gateway/http_sse/services/prompt_builder_assistant.py +303 -0
  313. solace_agent_mesh/gateway/http_sse/services/session_service.py +361 -12
  314. solace_agent_mesh/gateway/http_sse/services/task_logger_service.py +354 -4
  315. solace_agent_mesh/gateway/http_sse/session_manager.py +15 -15
  316. solace_agent_mesh/gateway/http_sse/sse_manager.py +286 -166
  317. solace_agent_mesh/gateway/http_sse/utils/artifact_copy_utils.py +370 -0
  318. solace_agent_mesh/gateway/http_sse/utils/stim_utils.py +41 -1
  319. solace_agent_mesh/services/__init__.py +0 -0
  320. solace_agent_mesh/services/platform/__init__.py +29 -0
  321. solace_agent_mesh/services/platform/alembic/env.py +85 -0
  322. solace_agent_mesh/services/platform/alembic/script.py.mako +28 -0
  323. solace_agent_mesh/services/platform/alembic.ini +109 -0
  324. solace_agent_mesh/services/platform/api/__init__.py +3 -0
  325. solace_agent_mesh/services/platform/api/dependencies.py +154 -0
  326. solace_agent_mesh/services/platform/api/main.py +314 -0
  327. solace_agent_mesh/services/platform/api/middleware.py +51 -0
  328. solace_agent_mesh/services/platform/api/routers/__init__.py +33 -0
  329. solace_agent_mesh/services/platform/api/routers/health_router.py +31 -0
  330. solace_agent_mesh/services/platform/app.py +215 -0
  331. solace_agent_mesh/services/platform/component.py +777 -0
  332. solace_agent_mesh/shared/__init__.py +14 -0
  333. solace_agent_mesh/shared/api/__init__.py +42 -0
  334. solace_agent_mesh/shared/auth/__init__.py +26 -0
  335. solace_agent_mesh/shared/auth/dependencies.py +204 -0
  336. solace_agent_mesh/shared/auth/middleware.py +347 -0
  337. solace_agent_mesh/shared/database/__init__.py +20 -0
  338. solace_agent_mesh/{gateway/http_sse/shared → shared/database}/base_repository.py +1 -1
  339. solace_agent_mesh/{gateway/http_sse/shared → shared/database}/database_exceptions.py +1 -1
  340. solace_agent_mesh/{gateway/http_sse/shared → shared/database}/database_helpers.py +1 -1
  341. solace_agent_mesh/shared/exceptions/__init__.py +36 -0
  342. solace_agent_mesh/{gateway/http_sse/shared → shared/exceptions}/exception_handlers.py +19 -5
  343. solace_agent_mesh/shared/utils/__init__.py +21 -0
  344. solace_agent_mesh/templates/logging_config_template.yaml +48 -0
  345. solace_agent_mesh/templates/main_orchestrator.yaml +12 -1
  346. solace_agent_mesh/templates/platform.yaml +49 -0
  347. solace_agent_mesh/templates/plugin_readme_template.md +3 -25
  348. solace_agent_mesh/templates/plugin_tool_config_template.yaml +109 -0
  349. solace_agent_mesh/templates/proxy_template.yaml +62 -0
  350. solace_agent_mesh/templates/webui.yaml +148 -6
  351. solace_agent_mesh/tools/web_search/__init__.py +18 -0
  352. solace_agent_mesh/tools/web_search/base.py +84 -0
  353. solace_agent_mesh/tools/web_search/google_search.py +247 -0
  354. solace_agent_mesh/tools/web_search/models.py +99 -0
  355. {solace_agent_mesh-1.6.1.dist-info → solace_agent_mesh-1.13.2.dist-info}/METADATA +31 -12
  356. solace_agent_mesh-1.13.2.dist-info/RECORD +591 -0
  357. {solace_agent_mesh-1.6.1.dist-info → solace_agent_mesh-1.13.2.dist-info}/WHEEL +1 -1
  358. solace_agent_mesh/agent/adk/adk_llm.txt +0 -232
  359. solace_agent_mesh/agent/adk/adk_llm_detail.txt +0 -566
  360. solace_agent_mesh/agent/adk/artifacts/artifacts_llm.txt +0 -171
  361. solace_agent_mesh/agent/adk/models/models_llm.txt +0 -142
  362. solace_agent_mesh/agent/agent_llm.txt +0 -378
  363. solace_agent_mesh/agent/agent_llm_detail.txt +0 -1702
  364. solace_agent_mesh/agent/protocol/protocol_llm.txt +0 -81
  365. solace_agent_mesh/agent/protocol/protocol_llm_detail.txt +0 -92
  366. solace_agent_mesh/agent/sac/sac_llm.txt +0 -189
  367. solace_agent_mesh/agent/sac/sac_llm_detail.txt +0 -200
  368. solace_agent_mesh/agent/testing/testing_llm.txt +0 -57
  369. solace_agent_mesh/agent/testing/testing_llm_detail.txt +0 -68
  370. solace_agent_mesh/agent/tools/tools_llm.txt +0 -263
  371. solace_agent_mesh/agent/tools/tools_llm_detail.txt +0 -274
  372. solace_agent_mesh/agent/utils/utils_llm.txt +0 -138
  373. solace_agent_mesh/agent/utils/utils_llm_detail.txt +0 -149
  374. solace_agent_mesh/assets/docs/assets/js/15ba94aa.932dd2db.js +0 -1
  375. solace_agent_mesh/assets/docs/assets/js/17896441.a5e82f9b.js +0 -2
  376. solace_agent_mesh/assets/docs/assets/js/240a0364.7eac6021.js +0 -1
  377. solace_agent_mesh/assets/docs/assets/js/2e32b5e0.33f5d75b.js +0 -1
  378. solace_agent_mesh/assets/docs/assets/js/3a6c6137.f5940cfa.js +0 -1
  379. solace_agent_mesh/assets/docs/assets/js/3ac1795d.76654dd9.js +0 -1
  380. solace_agent_mesh/assets/docs/assets/js/3ff0015d.2be20244.js +0 -1
  381. solace_agent_mesh/assets/docs/assets/js/547e15cc.2cbb060a.js +0 -1
  382. solace_agent_mesh/assets/docs/assets/js/55b7b518.f2b1d1ba.js +0 -1
  383. solace_agent_mesh/assets/docs/assets/js/5c2bd65f.eda4bcb2.js +0 -1
  384. solace_agent_mesh/assets/docs/assets/js/631738c7.a8b1ef8b.js +0 -1
  385. solace_agent_mesh/assets/docs/assets/js/6a520c9d.ba015d81.js +0 -1
  386. solace_agent_mesh/assets/docs/assets/js/6ad8f0bd.f4b15f3b.js +0 -1
  387. solace_agent_mesh/assets/docs/assets/js/6d84eae0.4a5fbf39.js +0 -1
  388. solace_agent_mesh/assets/docs/assets/js/71da7b71.38583438.js +0 -1
  389. solace_agent_mesh/assets/docs/assets/js/8024126c.56e59919.js +0 -1
  390. solace_agent_mesh/assets/docs/assets/js/81a99df0.07034dd9.js +0 -1
  391. solace_agent_mesh/assets/docs/assets/js/82fbfb93.139a1a1f.js +0 -1
  392. solace_agent_mesh/assets/docs/assets/js/924ffdeb.8095e148.js +0 -1
  393. solace_agent_mesh/assets/docs/assets/js/94e8668d.b5ddb7a1.js +0 -1
  394. solace_agent_mesh/assets/docs/assets/js/9bb13469.dd1c9b54.js +0 -1
  395. solace_agent_mesh/assets/docs/assets/js/a94703ab.0438dbc2.js +0 -1
  396. solace_agent_mesh/assets/docs/assets/js/ab9708a8.3e6dd091.js +0 -1
  397. solace_agent_mesh/assets/docs/assets/js/c93cbaa0.eaff365e.js +0 -1
  398. solace_agent_mesh/assets/docs/assets/js/da0b5bad.d08a9466.js +0 -1
  399. solace_agent_mesh/assets/docs/assets/js/dd817ffc.0aa9630a.js +0 -1
  400. solace_agent_mesh/assets/docs/assets/js/dd81e2b8.d590bc9e.js +0 -1
  401. solace_agent_mesh/assets/docs/assets/js/de915948.27d6b065.js +0 -1
  402. solace_agent_mesh/assets/docs/assets/js/e3d9abda.6b9493d0.js +0 -1
  403. solace_agent_mesh/assets/docs/assets/js/e6f9706b.e74a984d.js +0 -1
  404. solace_agent_mesh/assets/docs/assets/js/e92d0134.cf6d6522.js +0 -1
  405. solace_agent_mesh/assets/docs/assets/js/f284c35a.42f59cdd.js +0 -1
  406. solace_agent_mesh/assets/docs/assets/js/ff4d71f2.15b02f97.js +0 -1
  407. solace_agent_mesh/assets/docs/assets/js/main.b12eac43.js +0 -2
  408. solace_agent_mesh/assets/docs/assets/js/runtime~main.e268214e.js +0 -1
  409. solace_agent_mesh/assets/docs/lunr-index-1761248203150.json +0 -1
  410. solace_agent_mesh/assets/docs/search-doc-1761248203150.json +0 -1
  411. solace_agent_mesh/cli/commands/add_cmd/add_cmd_llm.txt +0 -250
  412. solace_agent_mesh/cli/commands/init_cmd/init_cmd_llm.txt +0 -365
  413. solace_agent_mesh/cli/commands/plugin_cmd/plugin_cmd_llm.txt +0 -305
  414. solace_agent_mesh/client/webui/frontend/static/assets/client-CaY59VuC.js +0 -25
  415. solace_agent_mesh/client/webui/frontend/static/assets/main-B32noGmR.js +0 -342
  416. solace_agent_mesh/client/webui/frontend/static/assets/main-DHJKSW1S.css +0 -1
  417. solace_agent_mesh/client/webui/frontend/static/assets/vendor-BEmvJSYz.js +0 -405
  418. solace_agent_mesh/common/a2a/a2a_llm.txt +0 -182
  419. solace_agent_mesh/common/a2a/a2a_llm_detail.txt +0 -193
  420. solace_agent_mesh/common/a2a_spec/a2a_spec_llm.txt +0 -407
  421. solace_agent_mesh/common/a2a_spec/a2a_spec_llm_detail.txt +0 -736
  422. solace_agent_mesh/common/a2a_spec/schemas/schemas_llm.txt +0 -313
  423. solace_agent_mesh/common/common_llm.txt +0 -251
  424. solace_agent_mesh/common/common_llm_detail.txt +0 -2562
  425. solace_agent_mesh/common/middleware/middleware_llm.txt +0 -174
  426. solace_agent_mesh/common/middleware/middleware_llm_detail.txt +0 -185
  427. solace_agent_mesh/common/sac/sac_llm.txt +0 -71
  428. solace_agent_mesh/common/sac/sac_llm_detail.txt +0 -82
  429. solace_agent_mesh/common/sam_events/sam_events_llm.txt +0 -104
  430. solace_agent_mesh/common/sam_events/sam_events_llm_detail.txt +0 -115
  431. solace_agent_mesh/common/services/providers/providers_llm.txt +0 -80
  432. solace_agent_mesh/common/services/services_llm.txt +0 -363
  433. solace_agent_mesh/common/services/services_llm_detail.txt +0 -459
  434. solace_agent_mesh/common/utils/embeds/embeds_llm.txt +0 -220
  435. solace_agent_mesh/common/utils/utils_llm.txt +0 -336
  436. solace_agent_mesh/common/utils/utils_llm_detail.txt +0 -572
  437. solace_agent_mesh/config_portal/frontend/static/client/assets/_index-ByU1X1HD.js +0 -98
  438. solace_agent_mesh/config_portal/frontend/static/client/assets/manifest-61038fc6.js +0 -1
  439. solace_agent_mesh/config_portal/frontend/static/client/assets/root-BWvk5-gF.js +0 -10
  440. solace_agent_mesh/config_portal/frontend/static/client/assets/root-DxRwaWiE.css +0 -1
  441. solace_agent_mesh/core_a2a/core_a2a_llm.txt +0 -90
  442. solace_agent_mesh/core_a2a/core_a2a_llm_detail.txt +0 -101
  443. solace_agent_mesh/gateway/base/base_llm.txt +0 -224
  444. solace_agent_mesh/gateway/base/base_llm_detail.txt +0 -235
  445. solace_agent_mesh/gateway/gateway_llm.txt +0 -373
  446. solace_agent_mesh/gateway/gateway_llm_detail.txt +0 -3885
  447. solace_agent_mesh/gateway/http_sse/alembic/alembic_llm.txt +0 -295
  448. solace_agent_mesh/gateway/http_sse/alembic/versions/versions_llm.txt +0 -155
  449. solace_agent_mesh/gateway/http_sse/components/components_llm.txt +0 -105
  450. solace_agent_mesh/gateway/http_sse/http_sse_llm.txt +0 -299
  451. solace_agent_mesh/gateway/http_sse/http_sse_llm_detail.txt +0 -3278
  452. solace_agent_mesh/gateway/http_sse/repository/entities/entities_llm.txt +0 -263
  453. solace_agent_mesh/gateway/http_sse/repository/models/models_llm.txt +0 -266
  454. solace_agent_mesh/gateway/http_sse/repository/repository_llm.txt +0 -340
  455. solace_agent_mesh/gateway/http_sse/routers/dto/dto_llm.txt +0 -346
  456. solace_agent_mesh/gateway/http_sse/routers/dto/requests/requests_llm.txt +0 -83
  457. solace_agent_mesh/gateway/http_sse/routers/dto/responses/responses_llm.txt +0 -107
  458. solace_agent_mesh/gateway/http_sse/routers/routers_llm.txt +0 -314
  459. solace_agent_mesh/gateway/http_sse/services/services_llm.txt +0 -297
  460. solace_agent_mesh/gateway/http_sse/shared/__init__.py +0 -146
  461. solace_agent_mesh/gateway/http_sse/shared/shared_llm.txt +0 -285
  462. solace_agent_mesh/gateway/http_sse/utils/utils_llm.txt +0 -47
  463. solace_agent_mesh/llm.txt +0 -228
  464. solace_agent_mesh/llm_detail.txt +0 -2835
  465. solace_agent_mesh/solace_agent_mesh_llm.txt +0 -362
  466. solace_agent_mesh/solace_agent_mesh_llm_detail.txt +0 -8599
  467. solace_agent_mesh/templates/logging_config_template.ini +0 -45
  468. solace_agent_mesh/templates/templates_llm.txt +0 -147
  469. solace_agent_mesh-1.6.1.dist-info/RECORD +0 -525
  470. /solace_agent_mesh/assets/docs/assets/js/{main.b12eac43.js.LICENSE.txt → main.d634009f.js.LICENSE.txt} +0 -0
  471. /solace_agent_mesh/{gateway/http_sse/shared → shared/api}/auth_utils.py +0 -0
  472. /solace_agent_mesh/{gateway/http_sse/shared → shared/api}/pagination.py +0 -0
  473. /solace_agent_mesh/{gateway/http_sse/shared → shared/api}/response_utils.py +0 -0
  474. /solace_agent_mesh/{gateway/http_sse/shared → shared/exceptions}/error_dto.py +0 -0
  475. /solace_agent_mesh/{gateway/http_sse/shared → shared/exceptions}/exceptions.py +0 -0
  476. /solace_agent_mesh/{gateway/http_sse/shared → shared/utils}/enums.py +0 -0
  477. /solace_agent_mesh/{gateway/http_sse/shared → shared/utils}/timestamp_utils.py +0 -0
  478. /solace_agent_mesh/{gateway/http_sse/shared → shared/utils}/types.py +0 -0
  479. /solace_agent_mesh/{gateway/http_sse/shared → shared/utils}/utils.py +0 -0
  480. {solace_agent_mesh-1.6.1.dist-info → solace_agent_mesh-1.13.2.dist-info}/entry_points.txt +0 -0
  481. {solace_agent_mesh-1.6.1.dist-info → solace_agent_mesh-1.13.2.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,2161 @@
1
+ """
2
+ Deep Research Tools for Solace Agent Mesh
3
+
4
+ Provides comprehensive, iterative research capabilities using web search
5
+
6
+ This module implements:
7
+ - Iterative research with LLM-powered reflection and query refinement
8
+ - Multi-source search coordination
9
+ - Citation tracking and management
10
+ - Progress updates to frontend
11
+ - Comprehensive report generation
12
+ """
13
+
14
+ import asyncio
15
+ import json
16
+ import re
17
+ import uuid
18
+ from datetime import datetime, timezone
19
+ from typing import Any, Dict, List, Optional, Tuple, Union
20
+ from dataclasses import dataclass, field
21
+
22
+ from google.adk.tools import ToolContext
23
+ from google.genai import types as adk_types
24
+ from google.adk.models import LlmRequest
25
+ from solace_ai_connector.common.log import log
26
+
27
+ from .tool_definition import BuiltinTool
28
+ from .registry import tool_registry
29
+ from .web_search_tools import web_search_google
30
+ from .web_tools import web_request
31
+ from ...common import a2a
32
+ from ...common.rag_dto import create_rag_source, create_rag_search_result
33
+
34
+
35
+ # Category information
36
+ CATEGORY_NAME = "Research & Analysis"
37
+ CATEGORY_DESCRIPTION = "Advanced research tools for comprehensive information gathering"
38
+
39
+
40
+ def _extract_text_from_llm_response(response: Any, log_identifier: str = "[LLM]") -> str:
41
+ """
42
+ Extract text from various LLM response formats.
43
+
44
+ Handles multiple response structures:
45
+ - Direct text attribute (response.text)
46
+ - Parts attribute for streaming responses (response.parts)
47
+ - Content attribute with parts for LlmResponse objects (response.content.parts)
48
+
49
+ Args:
50
+ response: The LLM response object
51
+ log_identifier: Identifier for logging
52
+
53
+ Returns:
54
+ Extracted text string, or empty string if extraction fails
55
+ """
56
+ response_text = ""
57
+
58
+ # Method 1: Direct text attribute
59
+ if hasattr(response, 'text') and response.text:
60
+ response_text = response.text
61
+ # Method 2: Parts attribute (for streaming responses)
62
+ elif hasattr(response, 'parts') and response.parts:
63
+ response_text = "".join([part.text for part in response.parts if hasattr(part, 'text') and part.text])
64
+ # Method 3: Content attribute with parts (for LlmResponse objects from Gemini 2.5 Pro)
65
+ elif hasattr(response, 'content') and response.content:
66
+ content = response.content
67
+ if hasattr(content, 'parts') and content.parts:
68
+ response_text = "".join([part.text for part in content.parts if hasattr(part, 'text') and part.text])
69
+ elif hasattr(content, 'text') and content.text:
70
+ response_text = content.text
71
+ elif isinstance(content, str):
72
+ response_text = content
73
+
74
+ if not response_text or not response_text.strip():
75
+ log.warning("%s Could not extract text from LLM response. Response type: %s",
76
+ log_identifier, type(response).__name__)
77
+ if response:
78
+ log.debug("%s Response attributes: text=%s, parts=%s, content=%s",
79
+ log_identifier,
80
+ hasattr(response, 'text'),
81
+ hasattr(response, 'parts'),
82
+ hasattr(response, 'content'))
83
+
84
+ return response_text
85
+
86
+
87
+ def _parse_json_from_llm_response(
88
+ response_text: str,
89
+ log_identifier: str = "[LLM]",
90
+ fallback_key: Optional[str] = None
91
+ ) -> Optional[Dict[str, Any]]:
92
+ """
93
+ Parse JSON from LLM response text, handling markdown code blocks.
94
+
95
+ Gemini 2.5 Pro and other models often wrap JSON in markdown code blocks
96
+ (```json ... ```) even when response_mime_type="application/json" is set.
97
+ This function handles that case.
98
+
99
+ Args:
100
+ response_text: The raw response text from the LLM
101
+ log_identifier: Identifier for logging
102
+ fallback_key: Optional key to search for in regex fallback (e.g., "queries", "selected_sources")
103
+
104
+ Returns:
105
+ Parsed JSON dict, or None if parsing fails
106
+ """
107
+ if not response_text or not response_text.strip():
108
+ log.warning("%s Empty response text, cannot parse JSON", log_identifier)
109
+ return None
110
+
111
+ # Strip markdown code block wrapper if present (common with Gemini 2.5 Pro)
112
+ clean_text = response_text.strip()
113
+ if clean_text.startswith('```'):
114
+ # Remove opening ```json or ``` and closing ```
115
+ if clean_text.startswith('```json'):
116
+ clean_text = clean_text[7:] # len('```json') = 7
117
+ else:
118
+ clean_text = clean_text[3:] # len('```') = 3
119
+ clean_text = clean_text.lstrip()
120
+
121
+ # Remove closing ``` and trailing whitespace
122
+ clean_text = clean_text.rstrip()
123
+ if clean_text.endswith('```'):
124
+ clean_text = clean_text[:-3] # Remove trailing ```
125
+ clean_text = clean_text.rstrip() # Remove any whitespace before closing ```
126
+ log.debug("%s Stripped markdown code block wrapper", log_identifier)
127
+
128
+ # Try to parse JSON directly
129
+ try:
130
+ return json.loads(clean_text)
131
+ except json.JSONDecodeError as je:
132
+ log.warning("%s Failed to parse LLM JSON response: %s. Response text: %s",
133
+ log_identifier, str(je), clean_text[:200])
134
+
135
+ # Fallback: Try to extract JSON from markdown code blocks (in case stripping didn't work)
136
+ json_match = re.search(r'```(?:json)?\s*(\{.*?\})\s*```', response_text, re.DOTALL)
137
+ if json_match:
138
+ try:
139
+ result = json.loads(json_match.group(1))
140
+ log.info("%s Extracted JSON from markdown code block", log_identifier)
141
+ return result
142
+ except json.JSONDecodeError:
143
+ log.warning("%s Failed to parse extracted JSON from code block", log_identifier)
144
+
145
+ # Fallback: Try to find any JSON object with the specified key
146
+ if fallback_key:
147
+ # Build a regex pattern to find JSON with the specified key
148
+ json_match = re.search(rf'\{{[^{{}}]*"{fallback_key}"[^{{}}]*\}}', response_text, re.DOTALL)
149
+ if json_match:
150
+ try:
151
+ result = json.loads(json_match.group(0))
152
+ log.info("%s Extracted JSON object with key '%s' from response", log_identifier, fallback_key)
153
+ return result
154
+ except json.JSONDecodeError:
155
+ log.warning("%s Failed to parse extracted JSON object with key '%s'", log_identifier, fallback_key)
156
+
157
+ log.warning("%s No valid JSON found in response", log_identifier)
158
+ return None
159
+
160
+
161
+ @dataclass
162
+ class SearchResult:
163
+ """Represents a single search result from any source (web-only version)"""
164
+ source_type: str # "web" only, for now
165
+ title: str
166
+ content: str
167
+ url: Optional[str] = None
168
+ relevance_score: float = 0.0
169
+ metadata: Dict[str, Any] = field(default_factory=dict)
170
+ citation_id: Optional[str] = None
171
+
172
+
173
+ @dataclass
174
+ class ReflectionResult:
175
+ """Result of reflecting on current research findings"""
176
+ quality_score: float # 0-1 score of information completeness
177
+ gaps: List[str] # Identified knowledge gaps
178
+ should_continue: bool # Whether more research is needed
179
+ suggested_queries: List[str] # New queries to explore gaps
180
+ reasoning: str # Explanation of the reflection
181
+
182
+
183
+ def _get_model_for_phase(
184
+ phase: str,
185
+ tool_context: ToolContext,
186
+ tool_config: Optional[Dict[str, Any]]
187
+ ):
188
+ """
189
+ Get the appropriate model for a specific research phase.
190
+
191
+ Supports phase-specific model configuration for cost optimization,
192
+ speed tuning, and quality control.
193
+
194
+ Args:
195
+ phase: One of 'query_generation', 'reflection', 'source_selection', 'report_generation'
196
+ tool_context: Tool context for accessing agent
197
+ tool_config: Tool configuration with optional phase-specific models
198
+
199
+ Returns:
200
+ BaseLlm instance for the phase (either phase-specific or agent default)
201
+
202
+ Configuration Examples:
203
+ # Simple model names:
204
+ tool_config:
205
+ models:
206
+ query_generation: "gpt-4o-mini"
207
+ report_generation: "claude-3-5-sonnet-20241022"
208
+
209
+ # Full model configs with parameters:
210
+ tool_config:
211
+ model_configs:
212
+ report_generation:
213
+ model: "claude-3-5-sonnet-20241022"
214
+ temperature: 0.7
215
+ max_tokens: 16000
216
+ """
217
+ log_identifier = f"[DeepResearch:ModelSelection:{phase}]"
218
+
219
+ # Get agent's default model
220
+ inv_context = tool_context._invocation_context
221
+ agent = getattr(inv_context, 'agent', None)
222
+ default_model = agent.canonical_model if agent else None
223
+
224
+ # If canonical_model is not available, try to get model from host_component config
225
+ if not default_model:
226
+ host_component = getattr(agent, "host_component", None) if agent else None
227
+ if host_component:
228
+ model_config_from_component = host_component.get_config("model")
229
+ if model_config_from_component:
230
+ log.info(
231
+ "%s canonical_model not available, falling back to host_component model config",
232
+ log_identifier,
233
+ )
234
+ from ...agent.adk.models.lite_llm import LiteLlm
235
+ if isinstance(model_config_from_component, str):
236
+ default_model = LiteLlm(model=model_config_from_component)
237
+ elif isinstance(model_config_from_component, dict):
238
+ default_model = LiteLlm(**model_config_from_component)
239
+
240
+ if not default_model:
241
+ raise ValueError(f"{log_identifier} No default model available")
242
+
243
+ # Check for phase-specific configuration
244
+ if not tool_config:
245
+ log.debug("%s No tool_config, using agent default model", log_identifier)
246
+ return default_model
247
+
248
+ # Helper function to copy base config from default model
249
+ def _get_base_config_from_default():
250
+ """Extract base configuration from default model to inherit API keys and settings"""
251
+ base_config = {}
252
+ if hasattr(default_model, '_additional_args') and default_model._additional_args:
253
+ # Copy relevant config from default model (API keys, timeouts, custom endpoints, etc.)
254
+ # Exclude model-specific params that shouldn't be inherited
255
+ # Also exclude max_completion_tokens to avoid conflicts with max_tokens
256
+ exclude_keys = {'model', 'messages', 'tools', 'stream', 'temperature', 'max_tokens',
257
+ 'max_output_tokens', 'max_completion_tokens', 'top_p', 'top_k'}
258
+ base_config = {k: v for k, v in default_model._additional_args.items()
259
+ if k not in exclude_keys}
260
+
261
+ # Log inherited configuration for debugging
262
+ if base_config:
263
+ log.debug("%s Inheriting base config from default model: api_base=%s, api_key=%s",
264
+ log_identifier,
265
+ base_config.get('api_base', 'default'),
266
+ 'present' if base_config.get('api_key') else 'missing')
267
+ return base_config
268
+
269
+ # Option 1: Simple model name string
270
+ models_config = tool_config.get("models", {})
271
+ if phase in models_config:
272
+ model_name = models_config[phase]
273
+ if isinstance(model_name, str):
274
+ log.info("%s Using phase-specific model: %s", log_identifier, model_name)
275
+ from ...agent.adk.models.lite_llm import LiteLlm
276
+ # Inherit base config from default model (API keys, etc.)
277
+ base_config = _get_base_config_from_default()
278
+ return LiteLlm(model=model_name, **base_config)
279
+
280
+ # Option 2: Full model configuration dict
281
+ model_configs = tool_config.get("model_configs", {})
282
+ if phase in model_configs:
283
+ model_config = model_configs[phase]
284
+ if isinstance(model_config, dict):
285
+ model_name = model_config.get("model")
286
+ log.info("%s Using phase-specific model config: %s (temp=%.1f, max_tokens=%s)",
287
+ log_identifier, model_name,
288
+ model_config.get("temperature", 0.7),
289
+ model_config.get("max_tokens", "default"))
290
+ from ...agent.adk.models.lite_llm import LiteLlm
291
+ # Inherit base config from default model, but allow override
292
+ base_config = _get_base_config_from_default()
293
+ # Merge: base_config first, then model_config (model_config takes precedence)
294
+ merged_config = {**base_config, **model_config}
295
+
296
+ # Additional safety: if max_tokens is specified, ensure max_completion_tokens is not present
297
+ if 'max_tokens' in merged_config and 'max_completion_tokens' in merged_config:
298
+ log.debug("%s Removing max_completion_tokens to avoid conflict with max_tokens", log_identifier)
299
+ del merged_config['max_completion_tokens']
300
+
301
+ return LiteLlm(**merged_config)
302
+
303
+ # Fallback to agent default
304
+ log.debug("%s No phase-specific model configured, using agent default", log_identifier)
305
+ return default_model
306
+
307
+
308
+ class ResearchCitationTracker:
309
+ """Tracks citations throughout the research process"""
310
+
311
+ def __init__(self, research_question: str):
312
+ self.research_question = research_question
313
+ self.citations: Dict[str, Dict[str, Any]] = {}
314
+ self.citation_counter = 0
315
+ self.source_to_citation: Dict[str, str] = {} # Map URL to citation_id for updates
316
+ self.queries: List[Dict[str, Any]] = [] # Track queries and their sources
317
+ self.current_query: Optional[str] = None
318
+ self.current_query_sources: List[str] = []
319
+ self.generated_title: Optional[str] = None # LLM-generated human-readable title
320
+
321
+ def set_title(self, title: str) -> None:
322
+ """Set the LLM-generated title for this research"""
323
+ self.generated_title = title
324
+
325
+ def start_query(self, query: str):
326
+ """Start tracking a new query"""
327
+ # Save previous query if it exists
328
+ if self.current_query:
329
+ self.queries.append({
330
+ "query": self.current_query,
331
+ "timestamp": datetime.now(timezone.utc).isoformat(),
332
+ "source_citation_ids": self.current_query_sources.copy()
333
+ })
334
+
335
+ # Start new query
336
+ self.current_query = query
337
+ self.current_query_sources = []
338
+
339
+ def add_citation(self, result: SearchResult, query: Optional[str] = None) -> str:
340
+ """Add citation and return citation ID"""
341
+ # Use 'search' prefix to match the citation rendering system
342
+ citation_id = f"search{self.citation_counter}"
343
+ log.info("[DeepResearch:Citation] Creating citation_id=%s (counter=%d) for: %s",
344
+ citation_id, self.citation_counter, result.title[:50])
345
+ self.citation_counter += 1
346
+
347
+ # Create citation using DTO helper for camelCase conversion
348
+ citation_dict = create_rag_source(
349
+ citation_id=citation_id,
350
+ file_id=f"deep_research_{self.citation_counter}",
351
+ filename=result.title,
352
+ title=result.title,
353
+ source_url=result.url or "N/A",
354
+ url=result.url,
355
+ content_preview=result.content[:200] + "..." if len(result.content) > 200 else result.content,
356
+ relevance_score=result.relevance_score,
357
+ source_type=result.source_type,
358
+ retrieved_at=datetime.now(timezone.utc).isoformat(),
359
+ metadata={
360
+ "title": result.title,
361
+ "link": result.url,
362
+ "type": "web_search",
363
+ "source_type": result.source_type,
364
+ "favicon": f"https://www.google.com/s2/favicons?domain={result.url}&sz=32" if result.url else "",
365
+ **result.metadata
366
+ }
367
+ )
368
+
369
+ self.citations[citation_id] = citation_dict
370
+ result.citation_id = citation_id
371
+
372
+ # Track URL to citation_id mapping for later updates
373
+ if result.url:
374
+ self.source_to_citation[result.url] = citation_id
375
+
376
+ # Track this citation for the current query
377
+ if self.current_query:
378
+ self.current_query_sources.append(citation_id)
379
+
380
+ return citation_id
381
+
382
+ def update_citation_after_fetch(self, result: SearchResult) -> None:
383
+ """Update citation with fetched content and metadata"""
384
+ if not result.url or result.url not in self.source_to_citation:
385
+ return
386
+
387
+ citation_id = self.source_to_citation[result.url]
388
+ if citation_id in self.citations:
389
+ # Update content preview with fetched content
390
+ self.citations[citation_id]["content_preview"] = result.content[:500] + "..." if len(result.content) > 500 else result.content
391
+ # Update metadata with fetched flag
392
+ self.citations[citation_id]["metadata"]["fetched"] = result.metadata.get("fetched", False)
393
+ self.citations[citation_id]["metadata"]["fetch_status"] = result.metadata.get("fetch_status", "")
394
+ log.info("[DeepResearch:Citation] Updated citation %s with fetched content", citation_id)
395
+
396
+ def get_rag_metadata(self, artifact_filename: Optional[str] = None) -> Dict[str, Any]:
397
+ """Format citations for RAG system with camelCase conversion"""
398
+ # Save the last query if it exists
399
+ if self.current_query:
400
+ self.queries.append({
401
+ "query": self.current_query,
402
+ "timestamp": datetime.now(timezone.utc).isoformat(),
403
+ "source_citation_ids": self.current_query_sources.copy()
404
+ })
405
+ self.current_query = None
406
+ self.current_query_sources = []
407
+
408
+ # Build metadata dict
409
+ metadata_dict: Dict[str, Any] = {
410
+ "queries": self.queries # Include query breakdown for timeline
411
+ }
412
+
413
+ # Include artifact filename if provided (for matching after page refresh)
414
+ if artifact_filename:
415
+ metadata_dict["artifactFilename"] = artifact_filename
416
+
417
+ # Return single search result with all sources using DTO for camelCase conversion
418
+ return create_rag_search_result(
419
+ query=self.research_question,
420
+ search_type="deep_research",
421
+ timestamp=datetime.now(timezone.utc).isoformat(),
422
+ sources=list(self.citations.values()),
423
+ metadata=metadata_dict,
424
+ title=self.generated_title
425
+ )
426
+
427
+
428
+ async def _send_research_progress(
429
+ message: str,
430
+ tool_context: ToolContext,
431
+ phase: str = "",
432
+ progress_percentage: int = 0,
433
+ current_iteration: int = 0,
434
+ total_iterations: int = 0,
435
+ sources_found: int = 0,
436
+ current_query: str = "",
437
+ fetching_urls: Optional[List[Dict[str, str]]] = None,
438
+ elapsed_seconds: int = 0,
439
+ max_runtime_seconds: int = 0
440
+ ) -> None:
441
+ """Send research progress update to frontend via SSE with structured data"""
442
+ log_identifier = "[DeepResearch:Progress]"
443
+
444
+ try:
445
+ # Get a2a context from tool context state
446
+ a2a_context = tool_context.state.get("a2a_context")
447
+ if not a2a_context:
448
+ log.warning("%s No a2a_context found, cannot send progress update", log_identifier)
449
+ return
450
+
451
+ # Get the host component from invocation context
452
+ invocation_context = getattr(tool_context, '_invocation_context', None)
453
+ if not invocation_context:
454
+ log.warning("%s No invocation context found", log_identifier)
455
+ return
456
+
457
+ agent = getattr(invocation_context, 'agent', None)
458
+ if not agent:
459
+ log.warning("%s No agent found in invocation context", log_identifier)
460
+ return
461
+
462
+ host_component = getattr(agent, 'host_component', None)
463
+ if not host_component:
464
+ log.warning("%s No host component found on agent", log_identifier)
465
+ return
466
+
467
+ log.info("%s Sending progress: %s", log_identifier, message)
468
+
469
+ # Use structured DeepResearchProgressData if phase is provided, otherwise simple text
470
+ from ...common.data_parts import DeepResearchProgressData, AgentProgressUpdateData
471
+
472
+ if phase:
473
+ # Send structured progress data for UI visualization
474
+ progress_data = DeepResearchProgressData(
475
+ phase=phase,
476
+ status_text=message,
477
+ progress_percentage=progress_percentage,
478
+ current_iteration=current_iteration,
479
+ total_iterations=total_iterations,
480
+ sources_found=sources_found,
481
+ current_query=current_query,
482
+ fetching_urls=fetching_urls or [],
483
+ elapsed_seconds=elapsed_seconds,
484
+ max_runtime_seconds=max_runtime_seconds
485
+ )
486
+ else:
487
+ # Fallback to simple text progress
488
+ progress_data = AgentProgressUpdateData(status_text=message)
489
+
490
+ # Use the host component's helper method to publish the data signal
491
+ host_component.publish_data_signal_from_thread(
492
+ a2a_context=a2a_context,
493
+ signal_data=progress_data,
494
+ skip_buffer_flush=False,
495
+ log_identifier=log_identifier,
496
+ )
497
+
498
+ except Exception as e:
499
+ log.error("%s Error sending progress update: %s", log_identifier, str(e))
500
+
501
+
502
+ async def _send_rag_info_update(
503
+ citation_tracker: 'ResearchCitationTracker',
504
+ tool_context: ToolContext,
505
+ is_complete: bool = False
506
+ ) -> None:
507
+ """
508
+ Send RAG info update to frontend via SSE for the RAG info panel.
509
+
510
+ This sends the title and sources early so the UI can display them
511
+ while research is still in progress.
512
+
513
+ Args:
514
+ citation_tracker: The citation tracker with title and sources
515
+ tool_context: Tool context for accessing agent
516
+ is_complete: Whether the research is complete
517
+ """
518
+ log_identifier = "[DeepResearch:RAGInfo]"
519
+
520
+ try:
521
+ # Get a2a context from tool context state
522
+ a2a_context = tool_context.state.get("a2a_context")
523
+ if not a2a_context:
524
+ log.warning("%s No a2a_context found, cannot send RAG info update", log_identifier)
525
+ return
526
+
527
+ # Get the host component from invocation context
528
+ invocation_context = getattr(tool_context, '_invocation_context', None)
529
+ if not invocation_context:
530
+ log.warning("%s No invocation context found", log_identifier)
531
+ return
532
+
533
+ agent = getattr(invocation_context, 'agent', None)
534
+ if not agent:
535
+ log.warning("%s No agent found in invocation context", log_identifier)
536
+ return
537
+
538
+ host_component = getattr(agent, 'host_component', None)
539
+ if not host_component:
540
+ log.warning("%s No host component found on agent", log_identifier)
541
+ return
542
+
543
+ # Get title (use research question as fallback)
544
+ title = citation_tracker.generated_title or citation_tracker.research_question
545
+
546
+ # Get sources in camelCase format for frontend
547
+ sources = list(citation_tracker.citations.values())
548
+
549
+ log.info("%s Sending RAG info update: title='%s', sources=%d, is_complete=%s",
550
+ log_identifier, title[:50], len(sources), is_complete)
551
+
552
+ # Import and create the RAG info update data
553
+ from ...common.data_parts import RAGInfoUpdateData
554
+
555
+ rag_info_data = RAGInfoUpdateData(
556
+ title=title,
557
+ query=citation_tracker.research_question,
558
+ search_type="deep_research",
559
+ sources=sources,
560
+ is_complete=is_complete,
561
+ timestamp=datetime.now(timezone.utc).isoformat()
562
+ )
563
+
564
+ # Use the host component's helper method to publish the data signal
565
+ host_component.publish_data_signal_from_thread(
566
+ a2a_context=a2a_context,
567
+ signal_data=rag_info_data,
568
+ skip_buffer_flush=False,
569
+ log_identifier=log_identifier,
570
+ )
571
+
572
+ except Exception as e:
573
+ log.error("%s Error sending RAG info update: %s", log_identifier, str(e))
574
+
575
+
576
+ async def _send_deep_research_report_signal(
577
+ artifact_filename: str,
578
+ artifact_version: int,
579
+ title: str,
580
+ sources_count: int,
581
+ tool_context: ToolContext
582
+ ) -> None:
583
+ """
584
+ Send DeepResearchReportData signal directly to frontend.
585
+
586
+ This bypasses the LLM response entirely, ensuring the report is displayed
587
+ via the DeepResearchReportBubble component without duplication.
588
+
589
+ The frontend will receive this signal and render the report using the
590
+ artifact viewer, suppressing any text content from the LLM response.
591
+
592
+ Args:
593
+ artifact_filename: The filename of the research report artifact
594
+ artifact_version: The version number of the artifact
595
+ title: Human-readable title for the research
596
+ sources_count: Number of sources analyzed
597
+ tool_context: Tool context for accessing agent
598
+ """
599
+ log_identifier = "[DeepResearch:ReportSignal]"
600
+
601
+ try:
602
+ # Get a2a context from tool context state
603
+ a2a_context = tool_context.state.get("a2a_context")
604
+ if not a2a_context:
605
+ log.warning("%s No a2a_context found, cannot send report signal", log_identifier)
606
+ return
607
+
608
+ # Get the host component from invocation context
609
+ invocation_context = getattr(tool_context, '_invocation_context', None)
610
+ if not invocation_context:
611
+ log.warning("%s No invocation context found", log_identifier)
612
+ return
613
+
614
+ agent = getattr(invocation_context, 'agent', None)
615
+ if not agent:
616
+ log.warning("%s No agent found in invocation context", log_identifier)
617
+ return
618
+
619
+ host_component = getattr(agent, 'host_component', None)
620
+ if not host_component:
621
+ log.warning("%s No host component found on agent", log_identifier)
622
+ return
623
+
624
+ # Build the artifact URI for the frontend
625
+ # Format: artifact://{session_id}/{filename}?version={version}
626
+ # This matches the format expected by parseArtifactUri in download.ts
627
+ from ..utils.context_helpers import get_original_session_id
628
+ session_id = get_original_session_id(invocation_context)
629
+ artifact_uri = f"artifact://{session_id}/{artifact_filename}?version={artifact_version}"
630
+
631
+ log.info("%s Sending deep research report signal: filename='%s', version=%d, uri='%s'",
632
+ log_identifier, artifact_filename, artifact_version, artifact_uri)
633
+
634
+ # Import and create the DeepResearchReportData
635
+ from ...common.data_parts import DeepResearchReportData
636
+
637
+ report_data = DeepResearchReportData(
638
+ filename=artifact_filename,
639
+ version=artifact_version,
640
+ uri=artifact_uri,
641
+ title=title,
642
+ sources_count=sources_count
643
+ )
644
+
645
+ # Use the host component's helper method to publish the data signal
646
+ host_component.publish_data_signal_from_thread(
647
+ a2a_context=a2a_context,
648
+ signal_data=report_data,
649
+ skip_buffer_flush=False,
650
+ log_identifier=log_identifier,
651
+ )
652
+
653
+ log.info("%s Successfully sent deep research report signal", log_identifier)
654
+
655
+ except Exception as e:
656
+ log.error("%s Error sending deep research report signal: %s", log_identifier, str(e))
657
+
658
+
659
+ async def _search_web(
660
+ query: str,
661
+ max_results: int,
662
+ tool_context: ToolContext,
663
+ tool_config: Optional[Dict[str, Any]],
664
+ send_progress: bool = True
665
+ ) -> List[SearchResult]:
666
+ """Search web using Google Custom Search API.
667
+
668
+ Note: For other search providers (Tavily, Exa, Brave), use the corresponding
669
+ plugins from the solace-agent-mesh-plugins repository.
670
+ """
671
+ log_identifier = "[DeepResearch:WebSearch]"
672
+
673
+ if send_progress:
674
+ await _send_research_progress(
675
+ f"Searching web for: {query[:60]}...",
676
+ tool_context
677
+ )
678
+
679
+ try:
680
+ log.info("%s Attempting Google search", log_identifier)
681
+ result = await web_search_google(
682
+ query=query,
683
+ max_results=max_results,
684
+ tool_context=tool_context,
685
+ tool_config=tool_config
686
+ )
687
+
688
+ if isinstance(result, dict) and result.get("result"):
689
+ result_data = json.loads(result["result"])
690
+ search_results = []
691
+
692
+ for item in result_data.get("organic", []):
693
+ search_results.append(SearchResult(
694
+ source_type="web",
695
+ title=item.get("title", ""),
696
+ content=item.get("snippet", ""),
697
+ url=item.get("link", ""),
698
+ relevance_score=0.85,
699
+ metadata={"provider": "google"}
700
+ ))
701
+
702
+ log.info("%s Found %d Google results", log_identifier, len(search_results))
703
+ return search_results
704
+ except Exception as e:
705
+ log.error("%s Google search failed: %s", log_identifier, str(e))
706
+
707
+ log.warning("%s No web search results available - Google search failed or not configured", log_identifier)
708
+ return []
709
+
710
+ # TODO: will add other sources such as knowledgebases
711
+ async def _multi_source_search(
712
+ query: str,
713
+ sources: List[str],
714
+ max_results_per_source: int,
715
+ kb_ids: Optional[List[str]],
716
+ tool_context: ToolContext,
717
+ tool_config: Optional[Dict[str, Any]]
718
+ ) -> List[SearchResult]:
719
+ """Execute search across various sources in parallel (web-only version)"""
720
+ log_identifier = "[DeepResearch:MultiSearch]"
721
+ log.info("%s Searching across sources: %s", log_identifier, sources)
722
+
723
+ tasks = []
724
+
725
+ # Web-only version - only web search
726
+ if "web" in sources:
727
+ tasks.append(_search_web(query, max_results_per_source, tool_context, tool_config, send_progress=False))
728
+
729
+ # Execute all searches in parallel
730
+ results = await asyncio.gather(*tasks, return_exceptions=True)
731
+
732
+ # Flatten and filter results
733
+ all_results = []
734
+ for result in results:
735
+ if isinstance(result, list):
736
+ all_results.extend(result)
737
+ elif isinstance(result, Exception):
738
+ log.warning("%s Search task failed: %s", log_identifier, str(result))
739
+
740
+ # Deduplicate by URL/title
741
+ seen = set()
742
+ unique_results = []
743
+ for result in all_results:
744
+ # For web sources, use URL or title as the key
745
+ key = result.url or f"web:{result.title}"
746
+
747
+ if key not in seen:
748
+ seen.add(key)
749
+ unique_results.append(result)
750
+
751
+ # Sort by relevance score
752
+ unique_results.sort(key=lambda x: x.relevance_score, reverse=True)
753
+
754
+ log.info("%s Found %d unique results across all sources", log_identifier, len(unique_results))
755
+ return unique_results
756
+
757
+
758
+ async def _generate_initial_queries(
759
+ research_question: str,
760
+ tool_context: ToolContext,
761
+ tool_config: Optional[Dict[str, Any]] = None
762
+ ) -> List[str]:
763
+ """
764
+ Generate 3-5 initial search queries using LLM.
765
+ The LLM breaks down the research question into effective search queries.
766
+
767
+ Supports phase-specific model via tool_config.
768
+ """
769
+ log_identifier = "[DeepResearch:QueryGen]"
770
+
771
+ try:
772
+ # Get phase-specific or default model
773
+ llm = _get_model_for_phase("query_generation", tool_context, tool_config)
774
+
775
+ query_prompt = f"""You are a research query specialist. Generate 3-5 effective search queries to comprehensively research this question:
776
+
777
+ Research Question: {research_question}
778
+
779
+ Generate queries that:
780
+ 1. Cover different aspects of the topic
781
+ 2. Use varied terminology and perspectives
782
+ 3. Range from broad to specific
783
+ 4. Are optimized for search engines
784
+
785
+ Respond in JSON format:
786
+ {{
787
+ "queries": ["query1", "query2", "query3", "query4", "query5"]
788
+ }}"""
789
+
790
+ log.info("%s Calling LLM for query generation", log_identifier)
791
+
792
+ # Create LLM request
793
+ # Note: max_output_tokens=8192 to ensure complete JSON responses with "thinking" models
794
+ llm_request = LlmRequest(
795
+ model=llm.model,
796
+ contents=[adk_types.Content(role="user", parts=[adk_types.Part(text=query_prompt)])],
797
+ config=adk_types.GenerateContentConfig(
798
+ response_mime_type="application/json",
799
+ temperature=0.7,
800
+ max_output_tokens=8192
801
+ )
802
+ )
803
+
804
+ # Call LLM
805
+ if hasattr(llm, 'generate_content_async'):
806
+ async for response_event in llm.generate_content_async(llm_request):
807
+ response = response_event
808
+ break
809
+ else:
810
+ response = llm.generate_content(request=llm_request)
811
+
812
+ # Extract text from response using helper function
813
+ response_text = _extract_text_from_llm_response(response, log_identifier)
814
+ if not response_text or not response_text.strip():
815
+ return [research_question]
816
+
817
+ log.debug("%s LLM response text (first 200 chars): %s", log_identifier, response_text[:200])
818
+
819
+ # Parse JSON using helper function with fallback key
820
+ query_data = _parse_json_from_llm_response(response_text, log_identifier, fallback_key="queries")
821
+ if query_data is None:
822
+ return [research_question]
823
+
824
+ queries = query_data.get("queries", [research_question])[:5]
825
+
826
+ log.info("%s Generated %d queries via LLM", log_identifier, len(queries))
827
+ return queries
828
+
829
+ except Exception as e:
830
+ log.error("%s LLM query generation failed: %s, using fallback", log_identifier, str(e), exc_info=True)
831
+ return [research_question]
832
+
833
+
834
+ async def _generate_research_title(
835
+ research_question: str,
836
+ tool_context: ToolContext,
837
+ tool_config: Optional[Dict[str, Any]] = None
838
+ ) -> str:
839
+ """
840
+ Generate a concise, human-readable title for the research using LLM.
841
+
842
+ The LLM converts the research question into a short, descriptive title
843
+ suitable for display in the UI.
844
+
845
+ Args:
846
+ research_question: The original research question
847
+ tool_context: Tool context for accessing agent
848
+ tool_config: Optional tool configuration
849
+
850
+ Returns:
851
+ A concise title string (typically 5-10 words)
852
+ """
853
+ log_identifier = "[DeepResearch:TitleGen]"
854
+
855
+ try:
856
+ # Get phase-specific or default model (use query_generation model for efficiency)
857
+ llm = _get_model_for_phase("query_generation", tool_context, tool_config)
858
+
859
+ title_prompt = f"""Generate a concise, human-readable title for this research topic.
860
+
861
+ Research Question: {research_question}
862
+
863
+ Requirements:
864
+ 1. The title should be 5-10 words maximum
865
+ 2. It should capture the essence of the research topic
866
+ 3. It should be suitable for display as a heading
867
+ 4. Do NOT include quotes around the title
868
+ 5. Do NOT include "Research:" or similar prefixes
869
+
870
+ Respond with ONLY the title, nothing else."""
871
+
872
+ # Note: max_output_tokens=2048 to ensure complete responses with "thinking" models
873
+ llm_request = LlmRequest(
874
+ model=llm.model,
875
+ contents=[adk_types.Content(role="user", parts=[adk_types.Part(text=title_prompt)])],
876
+ config=adk_types.GenerateContentConfig(
877
+ temperature=0.3,
878
+ max_output_tokens=2048
879
+ )
880
+ )
881
+
882
+ # Call LLM
883
+ response = None
884
+ if hasattr(llm, 'generate_content_async'):
885
+ async for response_event in llm.generate_content_async(llm_request):
886
+ response = response_event
887
+ break
888
+ else:
889
+ response = llm.generate_content(request=llm_request)
890
+
891
+ # Extract text from response using helper function
892
+ response_text = _extract_text_from_llm_response(response, log_identifier)
893
+
894
+ # Clean up the title
895
+ title = response_text.strip().strip('"').strip("'")
896
+
897
+ # Fallback if title is too long or empty
898
+ if not title or len(title) > 100:
899
+ # Use first 60 chars of research question as fallback
900
+ title = research_question[:60] + "..." if len(research_question) > 60 else research_question
901
+
902
+ log.info("%s Generated title: '%s'", log_identifier, title)
903
+ return title
904
+
905
+ except Exception as e:
906
+ log.error("%s LLM title generation failed: %s, using fallback", log_identifier, str(e))
907
+ # Fallback: use truncated research question
908
+ return research_question[:60] + "..." if len(research_question) > 60 else research_question
909
+
910
+
911
+ def _prepare_findings_summary(findings: List[SearchResult], max_findings: int = 20) -> str:
912
+ """Prepare a concise summary of findings for LLM reflection"""
913
+ if not findings:
914
+ return "No findings yet."
915
+
916
+ # Group by source type
917
+ by_type = {}
918
+ for finding in findings:
919
+ if finding.source_type not in by_type:
920
+ by_type[finding.source_type] = []
921
+ by_type[finding.source_type].append(finding)
922
+
923
+ summary_parts = []
924
+ summary_parts.append(f"Total Sources: {len(findings)}")
925
+ summary_parts.append(f"Source Types: {', '.join(by_type.keys())}")
926
+ summary_parts.append("")
927
+
928
+ # Add top findings from each source type
929
+ for source_type, type_findings in by_type.items():
930
+ summary_parts.append(f"{source_type.upper()} Sources ({len(type_findings)}):")
931
+
932
+ # Show top 5 from each type
933
+ for i, finding in enumerate(sorted(type_findings, key=lambda x: x.relevance_score, reverse=True)[:5], 1):
934
+ title = finding.title[:80] + "..." if len(finding.title) > 80 else finding.title
935
+ content = finding.content[:150] + "..." if len(finding.content) > 150 else finding.content
936
+ summary_parts.append(f" {i}. {title}")
937
+ summary_parts.append(f" {content}")
938
+ summary_parts.append(f" Relevance: {finding.relevance_score:.2f}")
939
+ summary_parts.append("")
940
+
941
+ return "\n".join(summary_parts)
942
+
943
+
944
+ async def _reflect_on_findings(
945
+ research_question: str,
946
+ findings: List[SearchResult],
947
+ iteration: int,
948
+ tool_context: ToolContext,
949
+ max_iterations: int = 10,
950
+ tool_config: Optional[Dict[str, Any]] = None
951
+ ) -> ReflectionResult:
952
+ """
953
+ Reflect on current findings using LLM to determine next steps.
954
+
955
+ The LLM analyzes the research findings to:
956
+ 1. Assess information completeness and quality
957
+ 2. Identify knowledge gaps
958
+ 3. Determine if more research is needed
959
+ 4. Generate refined search queries
960
+
961
+ Supports phase-specific model via tool_config.
962
+ """
963
+ log_identifier = "[DeepResearch:Reflection]"
964
+
965
+ try:
966
+ # Get phase-specific or default model
967
+ llm = _get_model_for_phase("reflection", tool_context, tool_config)
968
+
969
+ # Prepare findings summary for LLM
970
+ findings_summary = _prepare_findings_summary(findings)
971
+
972
+ # Create reflection prompt
973
+ reflection_prompt = f"""You are a research quality analyst. Analyze the current research findings and provide guidance for the next research iteration.
974
+
975
+ Research Question: {research_question}
976
+
977
+ Current Iteration: {iteration}
978
+
979
+ Findings Summary:
980
+ {findings_summary}
981
+
982
+ Please analyze these findings and provide:
983
+
984
+ 1. **Quality Score** (0.0 to 1.0): How complete and comprehensive is the current research?
985
+ - 0.0-0.3: Very incomplete, major gaps
986
+ - 0.4-0.6: Partial coverage, significant gaps remain
987
+ - 0.7-0.8: Good coverage, minor gaps
988
+ - 0.9-1.0: Comprehensive, excellent coverage
989
+
990
+ 2. **Knowledge Gaps**: What important aspects are missing or under-covered?
991
+
992
+ 3. **Should Continue**: Should we conduct another research iteration? (yes/no)
993
+ - Consider: quality score, iteration number, diminishing returns
994
+ - Maximum iterations allowed: {max_iterations}
995
+
996
+ 4. **Suggested Queries**: If continuing, what 3-5 specific search queries would fill the gaps?
997
+
998
+ Respond in JSON format:
999
+ {{
1000
+ "quality_score": 0.0-1.0,
1001
+ "gaps": ["gap1", "gap2", ...],
1002
+ "should_continue": true/false,
1003
+ "suggested_queries": ["query1", "query2", ...],
1004
+ "reasoning": "Brief explanation of your assessment"
1005
+ }}"""
1006
+
1007
+ log.info("%s Calling LLM for reflection analysis", log_identifier)
1008
+
1009
+ # Create LLM request
1010
+ # Note: max_output_tokens=8192 to ensure complete JSON responses with "thinking" models
1011
+ llm_request = LlmRequest(
1012
+ model=llm.model,
1013
+ contents=[adk_types.Content(role="user", parts=[adk_types.Part(text=reflection_prompt)])],
1014
+ config=adk_types.GenerateContentConfig(
1015
+ response_mime_type="application/json",
1016
+ temperature=0.3,
1017
+ max_output_tokens=8192
1018
+ )
1019
+ )
1020
+
1021
+ # Call LLM
1022
+ if hasattr(llm, 'generate_content_async'):
1023
+ async for response_event in llm.generate_content_async(llm_request):
1024
+ response = response_event
1025
+ break
1026
+ else:
1027
+ response = llm.generate_content(request=llm_request)
1028
+
1029
+ # Extract text from response using helper function
1030
+ response_text = _extract_text_from_llm_response(response, log_identifier)
1031
+ if not response_text or not response_text.strip():
1032
+ log.warning("%s LLM returned empty response for reflection", log_identifier)
1033
+ # Continue research if we have few findings
1034
+ should_continue = len(findings) < 15 and iteration < 3
1035
+ return ReflectionResult(
1036
+ quality_score=0.6,
1037
+ gaps=["Need more sources"],
1038
+ should_continue=should_continue,
1039
+ suggested_queries=[f"{research_question} detailed analysis", f"{research_question} comprehensive overview"],
1040
+ reasoning="LLM returned empty response, using fallback logic"
1041
+ )
1042
+
1043
+ # Parse JSON using helper function
1044
+ reflection_data = _parse_json_from_llm_response(response_text, log_identifier, fallback_key="quality_score")
1045
+ if reflection_data is None:
1046
+ should_continue = len(findings) < 15 and iteration < 3
1047
+ return ReflectionResult(
1048
+ quality_score=0.6,
1049
+ gaps=["Need more sources"],
1050
+ should_continue=should_continue,
1051
+ suggested_queries=[f"{research_question} comprehensive", f"{research_question} detailed"],
1052
+ reasoning="Could not parse LLM response, using fallback"
1053
+ )
1054
+
1055
+ quality_score = float(reflection_data.get("quality_score", 0.5))
1056
+ gaps = reflection_data.get("gaps", [])
1057
+ should_continue = reflection_data.get("should_continue", False) and iteration < max_iterations
1058
+ suggested_queries = reflection_data.get("suggested_queries", [])
1059
+ reasoning = reflection_data.get("reasoning", "LLM reflection completed")
1060
+
1061
+ log.info("%s LLM Reflection - Quality: %.2f, Continue: %s",
1062
+ log_identifier, quality_score, should_continue)
1063
+ log.info("%s Reasoning: %s", log_identifier, reasoning)
1064
+
1065
+ return ReflectionResult(
1066
+ quality_score=quality_score,
1067
+ gaps=gaps,
1068
+ should_continue=should_continue,
1069
+ suggested_queries=suggested_queries[:5] if suggested_queries else [research_question],
1070
+ reasoning=reasoning
1071
+ )
1072
+
1073
+ except Exception as e:
1074
+ log.error("%s LLM reflection failed: %s", log_identifier, str(e))
1075
+ # Fallback: continue if we don't have many findings yet
1076
+ should_continue = len(findings) < 15 and iteration < 3
1077
+ return ReflectionResult(
1078
+ quality_score=0.5,
1079
+ gaps=["LLM reflection error"],
1080
+ should_continue=should_continue,
1081
+ suggested_queries=[f"{research_question} overview"] if should_continue else [],
1082
+ reasoning=f"Error during reflection: {str(e)}"
1083
+ )
1084
+
1085
+
1086
+ async def _select_sources_to_fetch(
1087
+ research_question: str,
1088
+ findings: List[SearchResult],
1089
+ max_to_fetch: int,
1090
+ tool_context: ToolContext,
1091
+ tool_config: Optional[Dict[str, Any]] = None
1092
+ ) -> List[SearchResult]:
1093
+ """
1094
+ Use LLM to intelligently select which sources to fetch based on quality and relevance.
1095
+
1096
+ Supports phase-specific model via tool_config.
1097
+ """
1098
+ log_identifier = "[DeepResearch:SelectSources]"
1099
+
1100
+ try:
1101
+ # Get phase-specific or default model
1102
+ llm = _get_model_for_phase("source_selection", tool_context, tool_config)
1103
+
1104
+ # Prepare source list for LLM - only web sources can be fetched for full content
1105
+ web_findings = [f for f in findings if f.source_type == "web" and f.url]
1106
+ if not web_findings:
1107
+ return []
1108
+
1109
+ sources_summary = []
1110
+ for i, finding in enumerate(web_findings[:20], 1): # Limit to top 20 for LLM
1111
+ sources_summary.append(f"{i}. {finding.title}")
1112
+ sources_summary.append(f" URL: {finding.url}")
1113
+ sources_summary.append(f" Snippet: {finding.content[:150]}...")
1114
+ sources_summary.append(f" Relevance: {finding.relevance_score:.2f}")
1115
+ sources_summary.append("")
1116
+
1117
+ selection_prompt = f"""You are a research quality analyst. Select the {max_to_fetch} BEST sources to fetch full content from for this research question:
1118
+
1119
+ Research Question: {research_question}
1120
+
1121
+ Available Sources:
1122
+ {chr(10).join(sources_summary)}
1123
+
1124
+ Select the {max_to_fetch} sources that are most likely to provide:
1125
+ 1. Authoritative, credible information (e.g., .edu, .gov, established organizations)
1126
+ 2. Comprehensive coverage of the topic
1127
+ 3. Unique perspectives or data
1128
+ 4. Academic or expert analysis
1129
+
1130
+ You MUST respond with ONLY valid JSON in this exact format:
1131
+ {{
1132
+ "selected_sources": [1, 3, 5],
1133
+ "reasoning": "Brief explanation"
1134
+ }}
1135
+
1136
+ Do not include any other text, markdown formatting, or explanations outside the JSON."""
1137
+
1138
+ # Note: max_output_tokens=8192 to ensure complete JSON responses with "thinking" models
1139
+ llm_request = LlmRequest(
1140
+ model=llm.model,
1141
+ contents=[adk_types.Content(role="user", parts=[adk_types.Part(text=selection_prompt)])],
1142
+ config=adk_types.GenerateContentConfig(
1143
+ response_mime_type="application/json",
1144
+ temperature=0.3,
1145
+ max_output_tokens=8192
1146
+ )
1147
+ )
1148
+
1149
+ if hasattr(llm, 'generate_content_async'):
1150
+ async for response_event in llm.generate_content_async(llm_request):
1151
+ response = response_event
1152
+ break
1153
+ else:
1154
+ response = llm.generate_content(request=llm_request)
1155
+
1156
+ # Extract text from response using helper function
1157
+ response_text = _extract_text_from_llm_response(response, log_identifier)
1158
+ if not response_text or not response_text.strip():
1159
+ log.warning("%s LLM returned empty response, using fallback selection", log_identifier)
1160
+ web_findings = [f for f in findings if f.source_type == "web" and f.url]
1161
+ return sorted(web_findings, key=lambda x: x.relevance_score, reverse=True)[:max_to_fetch]
1162
+
1163
+ log.debug("%s LLM response text: %s", log_identifier, response_text[:200])
1164
+
1165
+ # Parse JSON using helper function
1166
+ selection_data = _parse_json_from_llm_response(response_text, log_identifier, fallback_key="selected_sources")
1167
+ if selection_data is None:
1168
+ log.warning("%s Failed to parse JSON, using fallback selection", log_identifier)
1169
+ web_findings = [f for f in findings if f.source_type == "web" and f.url]
1170
+ return sorted(web_findings, key=lambda x: x.relevance_score, reverse=True)[:max_to_fetch]
1171
+
1172
+ selected_indices = selection_data.get("selected_sources", [])
1173
+ reasoning = selection_data.get("reasoning", "")
1174
+
1175
+ if not selected_indices:
1176
+ log.warning("%s LLM returned empty selection, using fallback", log_identifier)
1177
+ web_findings = [f for f in findings if f.source_type == "web" and f.url]
1178
+ return sorted(web_findings, key=lambda x: x.relevance_score, reverse=True)[:max_to_fetch]
1179
+
1180
+ log.info("%s LLM selected %d sources: %s", log_identifier, len(selected_indices), reasoning)
1181
+
1182
+ # Convert 1-based indices to actual findings
1183
+ selected_sources = []
1184
+ for idx in selected_indices:
1185
+ if 1 <= idx <= len(web_findings):
1186
+ selected_sources.append(web_findings[idx - 1])
1187
+
1188
+ return selected_sources[:max_to_fetch]
1189
+
1190
+ except Exception as e:
1191
+ log.error("%s LLM source selection failed: %s, using fallback", log_identifier, str(e), exc_info=True)
1192
+ web_findings = [f for f in findings if f.source_type == "web" and f.url]
1193
+ return sorted(web_findings, key=lambda x: x.relevance_score, reverse=True)[:max_to_fetch]
1194
+
1195
+
1196
+ async def _fetch_selected_sources(
1197
+ selected_sources: List[SearchResult],
1198
+ tool_context: ToolContext,
1199
+ tool_config: Optional[Dict[str, Any]],
1200
+ citation_tracker: ResearchCitationTracker,
1201
+ start_time: float = 0,
1202
+ max_runtime_seconds: Optional[int] = None
1203
+ ) -> Dict[str, int]:
1204
+ """Fetch full content from LLM-selected sources and return success/failure stats"""
1205
+ log_identifier = "[DeepResearch:FetchSources]"
1206
+
1207
+ if not selected_sources:
1208
+ log.info("%s No sources selected to fetch", log_identifier)
1209
+ return {"success": 0, "failed": 0}
1210
+
1211
+ log.info("%s Fetching full content from %d selected sources", log_identifier, len(selected_sources))
1212
+
1213
+ # Fetch sources in parallel with progress updates
1214
+ fetch_tasks = []
1215
+ for i, source in enumerate(selected_sources, 1):
1216
+ # Prepare current URL being fetched for structured progress
1217
+ current_url_info = {
1218
+ "url": source.url,
1219
+ "title": source.title,
1220
+ "favicon": f"https://www.google.com/s2/favicons?domain={source.url}&sz=32" if source.url else ""
1221
+ }
1222
+
1223
+ # Send progress for each source being fetched with phase info
1224
+ await _send_research_progress(
1225
+ f"Reading content from: {source.title[:50]}... ({i}/{len(selected_sources)})",
1226
+ tool_context,
1227
+ phase="analyzing"
1228
+ )
1229
+ fetch_tasks.append(web_request(
1230
+ url=source.url,
1231
+ method="GET",
1232
+ tool_context=tool_context,
1233
+ tool_config=tool_config
1234
+ ))
1235
+
1236
+ results = await asyncio.gather(*fetch_tasks, return_exceptions=True)
1237
+
1238
+ # Track success/failure stats
1239
+ success_count = 0
1240
+ failed_count = 0
1241
+
1242
+ # Update findings with fetched content
1243
+ for source, result in zip(selected_sources, results):
1244
+ if isinstance(result, dict) and result.get("status") == "success":
1245
+ # Extract preview from result
1246
+ preview = result.get("result_preview", "")
1247
+ if preview:
1248
+ # Append fetched content to existing snippet
1249
+ source.content = f"{source.content}\n\n[Full Content Fetched]\n{preview}"
1250
+ source.metadata["fetched"] = True
1251
+ source.metadata["fetch_status"] = "success"
1252
+ success_count += 1
1253
+ log.info("%s Successfully fetched content from %s", log_identifier, source.url)
1254
+
1255
+ # Update citation tracker with fetched metadata
1256
+ citation_tracker.update_citation_after_fetch(source)
1257
+ else:
1258
+ source.metadata["fetched"] = False
1259
+ source.metadata["fetch_error"] = "No content in response"
1260
+ failed_count += 1
1261
+ log.warning("%s No content returned from %s", log_identifier, source.url)
1262
+ elif isinstance(result, Exception):
1263
+ log.warning("%s Failed to fetch %s: %s", log_identifier, source.url, str(result))
1264
+ source.metadata["fetched"] = False
1265
+ source.metadata["fetch_error"] = str(result)
1266
+ failed_count += 1
1267
+ else:
1268
+ error_msg = result.get("message", "Unknown error") if isinstance(result, dict) else "Unknown error"
1269
+ log.warning("%s Failed to fetch %s: %s", log_identifier, source.url, error_msg)
1270
+ source.metadata["fetched"] = False
1271
+ source.metadata["fetch_error"] = error_msg
1272
+ failed_count += 1
1273
+
1274
+ # Log summary
1275
+ log.info("%s Fetch complete: %d succeeded, %d failed out of %d total",
1276
+ log_identifier, success_count, failed_count, len(selected_sources))
1277
+
1278
+ # Send summary progress update
1279
+ if failed_count > 0:
1280
+ await _send_research_progress(
1281
+ f"Content fetched: {success_count} succeeded, {failed_count} failed",
1282
+ tool_context,
1283
+ phase="analyzing"
1284
+ )
1285
+
1286
+ return {"success": success_count, "failed": failed_count}
1287
+
1288
+
1289
+ def _prepare_findings_for_report(findings: List[SearchResult], max_findings: int = 30) -> str:
1290
+ """Prepare findings text for LLM report generation with enhanced content"""
1291
+ sorted_findings = sorted(findings, key=lambda x: x.relevance_score, reverse=True)[:max_findings]
1292
+
1293
+ findings_text = []
1294
+ findings_text.append("# Research Findings\n")
1295
+
1296
+ # Group findings by whether they have full content
1297
+ fetched_findings = [f for f in sorted_findings if f.metadata.get('fetched')]
1298
+ snippet_findings = [f for f in sorted_findings if not f.metadata.get('fetched')]
1299
+
1300
+ # Prioritize fetched content (full articles)
1301
+ if fetched_findings:
1302
+ findings_text.append("## Detailed Sources (Full Content Retrieved)\n")
1303
+ for finding in fetched_findings[:15]: # Top 15 fetched sources
1304
+ findings_text.append(f"\n### {finding.title}")
1305
+ findings_text.append(f"**Citation ID:** {finding.citation_id}")
1306
+ findings_text.append(f"**URL:** {finding.url or 'N/A'}")
1307
+ findings_text.append(f"**Relevance:** {finding.relevance_score:.2f}\n")
1308
+
1309
+ # Include substantial content from fetched sources (up to 5000 chars for comprehensive analysis)
1310
+ content_to_include = finding.content[:5000] if len(finding.content) > 5000 else finding.content
1311
+ if len(finding.content) > 5000:
1312
+ content_to_include += "\n\n[Content continues but truncated for length...]"
1313
+ findings_text.append(f"**Content:**\n{content_to_include}\n")
1314
+ findings_text.append("---\n")
1315
+
1316
+ # Add snippet-only sources
1317
+ if snippet_findings:
1318
+ findings_text.append("\n## Additional Sources (Snippets)\n")
1319
+ for finding in snippet_findings[:15]: # Top 15 snippet sources
1320
+ findings_text.append(f"\n### {finding.title}")
1321
+ findings_text.append(f"**Citation ID:** {finding.citation_id}")
1322
+ findings_text.append(f"**URL:** {finding.url or 'N/A'}")
1323
+ findings_text.append(f"**Snippet:** {finding.content}")
1324
+ findings_text.append(f"**Relevance:** {finding.relevance_score:.2f}\n")
1325
+ findings_text.append("---\n")
1326
+
1327
+ return "\n".join(findings_text)
1328
+
1329
+
1330
+ def _generate_sources_section(all_findings: List[SearchResult]) -> str:
1331
+ """Generate references section with ALL cited sources (both fetched and snippet-only)"""
1332
+ # Include ALL sources that have citation IDs (all findings that were cited)
1333
+ cited_sources = [f for f in all_findings if f.citation_id]
1334
+
1335
+ if not cited_sources:
1336
+ return ""
1337
+
1338
+ # Separate fetched vs snippet-only for better organization
1339
+ fetched_sources = [f for f in cited_sources if f.metadata.get('fetched')]
1340
+ snippet_sources = [f for f in cited_sources if not f.metadata.get('fetched')]
1341
+
1342
+ section = "\n\n---\n\n## References\n\n"
1343
+
1344
+ # Group by source type
1345
+ web_sources = [f for f in cited_sources if f.source_type == "web"]
1346
+ kb_sources = [f for f in cited_sources if f.source_type == "kb"]
1347
+
1348
+ if web_sources:
1349
+ for i, source in enumerate(web_sources, 1):
1350
+ if source.citation_id and source.url:
1351
+ # Extract citation number from citation_id (e.g., "search0" -> 0)
1352
+ citation_num = int(source.citation_id.replace("search", "").replace("file", "").replace("ref", ""))
1353
+ display_num = citation_num + 1 # Convert 0-based to 1-based for display
1354
+
1355
+ # DEBUG: Log citation mapping
1356
+ log.info("[DeepResearch:References] Mapping citation_id=%s to reference number [%d]", source.citation_id, display_num)
1357
+
1358
+ # Indicate if this was read in full or just a snippet
1359
+ fetch_indicator = " *(read in full)*" if source.metadata.get('fetched') else " *(search result)*"
1360
+ section += f"**[{display_num}]** {source.title}{fetch_indicator} \n{source.url}\n\n"
1361
+
1362
+ if kb_sources:
1363
+ for source in kb_sources:
1364
+ if source.citation_id:
1365
+ # Extract citation number from citation_id
1366
+ citation_num = int(source.citation_id.replace("search", "").replace("file", "").replace("ref", ""))
1367
+ display_num = citation_num + 1 # Convert 0-based to 1-based for display
1368
+
1369
+ fetch_indicator = " *(read in full)*" if source.metadata.get('fetched') else " *(search result)*"
1370
+ section += f"**[{display_num}]** {source.title}{fetch_indicator}\n\n"
1371
+
1372
+ return section
1373
+
1374
+
1375
+ def _generate_methodology_section(all_findings: List[SearchResult]) -> str:
1376
+ """Generate research methodology section with statistics"""
1377
+ web_sources = [f for f in all_findings if f.source_type == "web"]
1378
+ kb_sources = [f for f in all_findings if f.source_type == "kb"]
1379
+
1380
+ # Count fetched vs snippet-only sources
1381
+ fetched_sources = [f for f in all_findings if f.metadata.get('fetched')]
1382
+ snippet_sources = [f for f in all_findings if not f.metadata.get('fetched')]
1383
+
1384
+ section = "## Research Methodology\n\n"
1385
+ section += f"This research analyzed **{len(all_findings)} sources** across multiple iterations:\n\n"
1386
+ section += f"- **{len(fetched_sources)} sources** were read in full detail (cited in References above)\n"
1387
+ section += f"- **{len(snippet_sources)} additional sources** were consulted via search snippets\n"
1388
+ section += f"- Source types: {len(web_sources)} web, {len(kb_sources)} knowledge base\n\n"
1389
+ section += "The research process involved:\n"
1390
+ section += "1. Generating targeted search queries using AI\n"
1391
+ section += "2. Searching across multiple information sources\n"
1392
+ section += "3. Selecting the most authoritative and relevant sources\n"
1393
+ section += "4. Retrieving and analyzing full content from selected sources\n"
1394
+ section += "5. Synthesizing findings into a comprehensive report\n"
1395
+
1396
+ return section
1397
+
1398
+
1399
+ async def _generate_research_report(
1400
+ research_question: str,
1401
+ all_findings: List[SearchResult],
1402
+ citation_tracker: ResearchCitationTracker,
1403
+ tool_context: ToolContext,
1404
+ tool_config: Optional[Dict[str, Any]] = None
1405
+ ) -> str:
1406
+ """
1407
+ Generate comprehensive research report using LLM.
1408
+ The LLM synthesizes findings into a coherent narrative with proper citations.
1409
+
1410
+ Supports phase-specific model via tool_config.
1411
+ """
1412
+ log_identifier = "[DeepResearch:ReportGen]"
1413
+ log.info("%s Generating report from %d findings", log_identifier, len(all_findings))
1414
+
1415
+ try:
1416
+ # Get phase-specific or default model
1417
+ llm = _get_model_for_phase("report_generation", tool_context, tool_config)
1418
+
1419
+ # Prepare findings for LLM
1420
+ findings_text = _prepare_findings_for_report(all_findings)
1421
+
1422
+ # Create report generation prompt - emphasizing synthesis over copying
1423
+ report_prompt = f"""You are an expert research analyst. Your task is to SYNTHESIZE information from multiple sources into an original, comprehensive research report.
1424
+
1425
+ Research Question: {research_question}
1426
+
1427
+ You have access to {len(all_findings)} sources below. Your job is to READ ALL OF THEM, extract key information, and create a well-written report.
1428
+
1429
+ Source Materials:
1430
+ {findings_text}
1431
+
1432
+ CRITICAL INSTRUCTIONS:
1433
+
1434
+ ⚠️ DO NOT COPY: You must NOT copy text directly from any single source. You must SYNTHESIZE information from MULTIPLE sources.
1435
+
1436
+ ⚠️ ORIGINAL WRITING: Write in your own words, combining insights from different sources.
1437
+
1438
+ ⚠️ DO NOT INCLUDE WORD COUNTS: Do NOT include word count targets (like "300-500 words") in your section headings or anywhere in the output. These are internal guidelines for you only.
1439
+
1440
+ REPORT STRUCTURE GUIDELINES (aim for 3000-5000 words total, but DO NOT mention word counts in output):
1441
+
1442
+ Write the following sections WITHOUT including word count targets in headings:
1443
+
1444
+ ## Executive Summary
1445
+ Synthesize the MOST IMPORTANT insights from ALL sources. Highlight key findings that answer the research question. Provide context for why this topic matters. DO NOT copy from any single source.
1446
+
1447
+ ## Introduction
1448
+ Explain the research question and its significance. Provide historical or contextual background. Outline what the report will cover. Draw context from multiple sources [[cite:searchX]].
1449
+
1450
+ ## Main Analysis
1451
+ Organize into 5-8 thematic sections with descriptive headings (###). For EACH section:
1452
+ - Create a descriptive heading like "### Historical Development" or "### Economic Impact" (NO word counts)
1453
+ - Draw information from multiple sources
1454
+ - Start each paragraph with a topic sentence
1455
+ - Support claims with citations from different sources.[[cite:searchX]][[cite:searchY]]
1456
+ - Explain implications and connections
1457
+ - Compare and contrast different perspectives
1458
+ - NEVER copy paragraphs from a single source
1459
+
1460
+ ## Comparative Analysis
1461
+ Compare different perspectives across sources. Identify agreements and contradictions. Analyze why sources might differ. Synthesize a balanced view. Cite multiple sources for each point.
1462
+
1463
+ ## Implications
1464
+ Discuss practical implications. Identify applications or consequences. Suggest areas needing further research. Draw from multiple sources.
1465
+
1466
+ ## Conclusion
1467
+ Synthesize the key takeaways from ALL sources. Provide final analytical insights. Suggest future directions.
1468
+
1469
+ ⚠️ DO NOT CREATE A REFERENCES SECTION: The system will automatically append a properly formatted References section with all cited sources. Your report should end with the Conclusion section.
1470
+
1471
+ CITATION RULES:
1472
+ - Use [[cite:searchN]] format where N is the citation number from sources above
1473
+ - Place citations AFTER the period at the end of sentences (e.g., "This is a fact.[[cite:search0]]")
1474
+ - Use multiple citations when multiple sources support a point: .[[cite:search0]][[cite:search2]]
1475
+ - Cite sources even when paraphrasing
1476
+
1477
+ QUALITY CHECKS:
1478
+ ✓ Have I synthesized from MULTIPLE sources (not just one)?
1479
+ ✓ Have I written in my OWN words (not copied)?
1480
+ ✓ Have I cited ALL factual claims?
1481
+ ✓ Have I organized information thematically (not source-by-source)?
1482
+ ✓ Have I avoided including word count targets in my output?
1483
+
1484
+ Write your research report now. Format in Markdown. Remember: NO word counts in section headings or anywhere in the output.
1485
+ """
1486
+
1487
+ log.info("%s Calling LLM for report generation", log_identifier)
1488
+
1489
+ # Create LLM request with reasonable max tokens for faster generation
1490
+ # Reduced from 32000 to 8000 for better performance while still allowing comprehensive reports
1491
+ llm_request = LlmRequest(
1492
+ model=llm.model,
1493
+ contents=[adk_types.Content(role="user", parts=[adk_types.Part(text=report_prompt)])],
1494
+ config=adk_types.GenerateContentConfig(
1495
+ temperature=1.0,
1496
+ max_output_tokens=8000 # Reduced from 32000 for faster generation
1497
+ )
1498
+ )
1499
+
1500
+ # Call LLM with streaming and progress updates
1501
+ report_body = ""
1502
+ response_count = 0
1503
+ last_progress_update = 0
1504
+ import time as time_module
1505
+ stream_start_time = time_module.time()
1506
+
1507
+ try:
1508
+ # IMPORTANT: Pass stream=True to enable streaming mode
1509
+ # Without this, the LLM call waits for the entire response before yielding,
1510
+ # which can cause timeouts with large prompts or slow models
1511
+ #
1512
+ # NOTE: LiteLlm streaming yields:
1513
+ # 1. Multiple partial responses (is_partial=True) with delta text chunks
1514
+ # 2. One final aggregated response (is_partial=False) with the FULL accumulated text
1515
+ #
1516
+ # We ONLY process partial responses to avoid duplication. The final aggregated
1517
+ # response contains the same text we've already accumulated from the partials.
1518
+ async for response_event in llm.generate_content_async(llm_request, stream=True):
1519
+ response_count += 1
1520
+
1521
+ # Check if this is a partial (streaming chunk) or final (aggregated) response
1522
+ # LiteLlm sets partial=True for streaming chunks, partial=False for final
1523
+ is_partial = getattr(response_event, 'partial', None)
1524
+
1525
+ # Skip non-partial (final aggregated) responses - they contain duplicate content
1526
+ # The final response has the full accumulated text which we've already collected
1527
+ if is_partial is False:
1528
+ continue
1529
+
1530
+ # Try different extraction methods
1531
+ extracted_text = ""
1532
+ if hasattr(response_event, 'text') and response_event.text:
1533
+ extracted_text = response_event.text
1534
+ elif hasattr(response_event, 'parts') and response_event.parts:
1535
+ extracted_text = "".join([part.text for part in response_event.parts if hasattr(part, 'text') and part.text])
1536
+ elif hasattr(response_event, 'content') and response_event.content:
1537
+ if hasattr(response_event.content, 'parts') and response_event.content.parts:
1538
+ extracted_text = "".join([part.text for part in response_event.content.parts if hasattr(part, 'text') and part.text])
1539
+
1540
+ if extracted_text:
1541
+ # For partial responses, always append (they are delta chunks)
1542
+ report_body += extracted_text
1543
+
1544
+ # Send progress update every 500 characters to show activity and reset peer timeout
1545
+ if len(report_body) - last_progress_update >= 500:
1546
+ last_progress_update = len(report_body)
1547
+ progress_pct = min(95, 85 + int((len(report_body) / 3000) * 10)) # 85-95%
1548
+
1549
+ # Send progress update to reset orchestrator's peer timeout
1550
+ await _send_research_progress(
1551
+ f"Writing report... ({len(report_body)} characters)",
1552
+ tool_context,
1553
+ phase="writing",
1554
+ progress_percentage=progress_pct,
1555
+ sources_found=len(all_findings)
1556
+ )
1557
+
1558
+ log.info("%s Report generation complete: %d chars", log_identifier, len(report_body))
1559
+
1560
+ except Exception as stream_error:
1561
+ log.error("%s Error during LLM streaming: %s", log_identifier, str(stream_error))
1562
+ raise
1563
+
1564
+ # Add sources section
1565
+ sources_section = _generate_sources_section(all_findings)
1566
+ report_body += "\n\n" + sources_section
1567
+
1568
+ # Add methodology section
1569
+ methodology_section = _generate_methodology_section(all_findings)
1570
+ report_body += "\n\n" + methodology_section
1571
+
1572
+ return report_body
1573
+
1574
+ except Exception as e:
1575
+ log.error("%s LLM report generation failed: %s", log_identifier, str(e))
1576
+ return f"# Research Report: {research_question}\n\nError generating report: {str(e)}"
1577
+
1578
+
1579
+ async def deep_research(
1580
+ research_question: str,
1581
+ research_type: str = "quick",
1582
+ sources: Optional[List[str]] = None,
1583
+ max_iterations: Optional[int] = None,
1584
+ max_sources_per_iteration: int = 5,
1585
+ kb_ids: Optional[List[str]] = None,
1586
+ max_runtime_minutes: Optional[int] = None,
1587
+ max_runtime_seconds: Optional[int] = None,
1588
+ tool_context: ToolContext = None,
1589
+ tool_config: Optional[Dict[str, Any]] = None,
1590
+ ) -> Dict[str, Any]:
1591
+ """
1592
+ Performs comprehensive, iterative research across multiple sources.
1593
+
1594
+ Configuration Priority (highest to lowest):
1595
+ 1. Explicit parameters (max_iterations, max_runtime_minutes/max_runtime_seconds)
1596
+ 2. Tool config (tool_config.max_iterations, tool_config.max_runtime_seconds)
1597
+ 3. Research type translation ("quick" or "in-depth")
1598
+
1599
+ Args:
1600
+ research_question: The research question or topic to investigate
1601
+ research_type: Type of research - "quick" (5min, 3 iter) or "in-depth" (10min, 10 iter)
1602
+ sources: Sources to search (default from tool_config or ["web"])
1603
+ max_iterations: Maximum research iterations (overrides tool_config and research_type)
1604
+ max_sources_per_iteration: Max results per source per iteration (default: 5)
1605
+ kb_ids: Specific knowledge base IDs to search
1606
+ max_runtime_minutes: Maximum runtime in minutes (1-10). Converted to seconds internally.
1607
+ max_runtime_seconds: Maximum runtime in seconds (60-600). Overrides tool_config and research_type.
1608
+ tool_context: ADK tool context
1609
+ tool_config: Tool configuration with optional max_iterations, max_runtime_seconds, sources
1610
+
1611
+ Returns:
1612
+ Dictionary with research report and metadata
1613
+ """
1614
+ log_identifier = "[DeepResearch]"
1615
+ log.info("%s Starting deep research: %s", log_identifier, research_question)
1616
+
1617
+ # Resolve configuration with priority: explicit params > tool_config > research_type
1618
+ config = tool_config or {}
1619
+
1620
+ # Resolve max_iterations
1621
+ if max_iterations is None:
1622
+ max_iterations = config.get("max_iterations")
1623
+ if max_iterations is not None:
1624
+ log.info("%s Using max_iterations from tool_config: %d", log_identifier, max_iterations)
1625
+ else:
1626
+ # Fallback to research_type translation
1627
+ if research_type.lower() in ["in-depth", "indepth", "in_depth", "deep", "comprehensive"]:
1628
+ max_iterations = 10
1629
+ log.info("%s Using max_iterations from research_type 'in-depth': %d", log_identifier, max_iterations)
1630
+ else:
1631
+ max_iterations = 3
1632
+ log.info("%s Using max_iterations from research_type 'quick': %d", log_identifier, max_iterations)
1633
+ else:
1634
+ log.info("%s Using explicit max_iterations parameter: %d", log_identifier, max_iterations)
1635
+
1636
+ # Resolve max_runtime_seconds (with priority: max_runtime_minutes > max_runtime_seconds > tool_config > research_type)
1637
+ # First, check if max_runtime_minutes was provided (LLM-friendly parameter)
1638
+ if max_runtime_minutes is not None:
1639
+ max_runtime_seconds = max_runtime_minutes * 60
1640
+ log.info("%s Using explicit max_runtime_minutes parameter: %d minutes (%d seconds)",
1641
+ log_identifier, max_runtime_minutes, max_runtime_seconds)
1642
+ elif max_runtime_seconds is not None:
1643
+ log.info("%s Using explicit max_runtime_seconds parameter: %d", log_identifier, max_runtime_seconds)
1644
+ else:
1645
+ # Check tool_config (support both seconds and minutes)
1646
+ config_duration = config.get("max_runtime_seconds") or config.get("duration_seconds")
1647
+ config_duration_minutes = config.get("duration_minutes")
1648
+
1649
+ if config_duration is not None:
1650
+ max_runtime_seconds = config_duration
1651
+ log.info("%s Using max_runtime_seconds from tool_config: %d", log_identifier, max_runtime_seconds)
1652
+ elif config_duration_minutes is not None:
1653
+ max_runtime_seconds = config_duration_minutes * 60
1654
+ log.info("%s Using duration_minutes from tool_config: %d minutes (%d seconds)",
1655
+ log_identifier, config_duration_minutes, max_runtime_seconds)
1656
+ else:
1657
+ # Fallback to research_type translation
1658
+ if research_type.lower() in ["in-depth", "indepth", "in_depth", "deep", "comprehensive"]:
1659
+ max_runtime_seconds = 600 # 10 minutes
1660
+ log.info("%s Using max_runtime_seconds from research_type 'in-depth': %d seconds",
1661
+ log_identifier, max_runtime_seconds)
1662
+ else:
1663
+ max_runtime_seconds = 300 # 5 minutes
1664
+ log.info("%s Using max_runtime_seconds from research_type 'quick': %d seconds",
1665
+ log_identifier, max_runtime_seconds)
1666
+
1667
+ # Resolve sources
1668
+ if sources is None:
1669
+ sources = config.get("sources", ["web"])
1670
+ log.info("%s Using sources from config: %s", log_identifier, sources)
1671
+
1672
+ if not tool_context:
1673
+ return {"status": "error", "message": "ToolContext is missing"}
1674
+
1675
+ # Default sources - web only
1676
+ if sources is None:
1677
+ sources = ["web"]
1678
+
1679
+ # Track start time for runtime limit
1680
+ import time
1681
+ start_time = time.time()
1682
+
1683
+ # Validate and filter sources
1684
+ if sources:
1685
+ # Validate and filter sources - only allow web and kb
1686
+ allowed_sources = {"web", "kb"}
1687
+ sources = [s for s in sources if s in allowed_sources]
1688
+
1689
+ # If no valid sources after filtering, use default
1690
+ if not sources:
1691
+ log.warning("%s No valid sources provided, using default: ['web']", log_identifier)
1692
+ sources = ["web"]
1693
+ else:
1694
+ log.info("%s Using validated sources: %s", log_identifier, sources)
1695
+
1696
+ # Validate iterations and runtime
1697
+ max_iterations = max(1, min(max_iterations, 10))
1698
+ if max_runtime_seconds:
1699
+ max_runtime_seconds = max(60, min(max_runtime_seconds, 600)) # 1-10 minutes
1700
+ log.info("%s Runtime limit set to %d seconds", log_identifier, max_runtime_seconds)
1701
+
1702
+ try:
1703
+ # Initialize citation tracker
1704
+ citation_tracker = ResearchCitationTracker(research_question)
1705
+
1706
+ # Send initial progress with structured data
1707
+ await _send_research_progress(
1708
+ "Planning research strategy and generating search queries...",
1709
+ tool_context,
1710
+ phase="planning",
1711
+ progress_percentage=5,
1712
+ current_iteration=0,
1713
+ total_iterations=max_iterations,
1714
+ sources_found=0,
1715
+ elapsed_seconds=int(time.time() - start_time),
1716
+ max_runtime_seconds=max_runtime_seconds or 0
1717
+ )
1718
+
1719
+ # Generate initial queries using LLM (with phase-specific model support)
1720
+ queries = await _generate_initial_queries(research_question, tool_context, tool_config)
1721
+ log.info("%s Generated %d initial queries", log_identifier, len(queries))
1722
+
1723
+ # Generate human-readable title for the research using LLM
1724
+ log.info("%s Generating LLM title for research question: %s", log_identifier, research_question[:100])
1725
+ research_title = await _generate_research_title(research_question, tool_context, tool_config)
1726
+ citation_tracker.set_title(research_title)
1727
+ log.info("%s LLM-generated research title: '%s' (original query: '%s')",
1728
+ log_identifier, research_title, research_question[:50])
1729
+
1730
+ # Send initial RAG info update with title (no sources yet)
1731
+ # This allows the UI to display the title in the RAG info panel immediately
1732
+ await _send_rag_info_update(citation_tracker, tool_context, is_complete=False)
1733
+
1734
+ # Iterative research loop
1735
+ all_findings: List[SearchResult] = []
1736
+ seen_sources_global = set() # Track seen sources across ALL iterations
1737
+
1738
+ for iteration in range(1, max_iterations + 1):
1739
+ # Check runtime limit - only applies to research iterations, not report generation
1740
+ if max_runtime_seconds:
1741
+ elapsed = time.time() - start_time
1742
+ if elapsed >= max_runtime_seconds:
1743
+ log.info("%s Runtime limit reached (%d seconds), stopping research iterations. Will proceed to generate report from %d sources.",
1744
+ log_identifier, max_runtime_seconds, len(all_findings))
1745
+ await _send_research_progress(
1746
+ f"Research time limit reached ({int(elapsed)}s). Proceeding to generate report from {len(all_findings)} sources...",
1747
+ tool_context,
1748
+ phase="writing",
1749
+ progress_percentage=80,
1750
+ current_iteration=iteration,
1751
+ total_iterations=max_iterations,
1752
+ sources_found=len(all_findings),
1753
+ elapsed_seconds=int(elapsed),
1754
+ max_runtime_seconds=max_runtime_seconds
1755
+ )
1756
+ break
1757
+
1758
+ log.info("%s === Iteration %d/%d ===", log_identifier, iteration, max_iterations)
1759
+
1760
+ # Calculate progress percentage for this iteration
1761
+ iteration_progress_base = 10 + ((iteration - 1) / max_iterations) * 70 # 10-80% for iterations
1762
+
1763
+ # Search with current queries
1764
+ iteration_findings = []
1765
+ for query_idx, query in enumerate(queries, 1):
1766
+ # Start tracking this query in citation tracker
1767
+ citation_tracker.start_query(query)
1768
+
1769
+ # Calculate sub-progress within iteration
1770
+ query_progress = iteration_progress_base + (query_idx / len(queries)) * (70 / max_iterations) * 0.3
1771
+
1772
+ # Send progress for each query with structured data
1773
+ await _send_research_progress(
1774
+ f"{query[:60]}...",
1775
+ tool_context,
1776
+ phase="searching",
1777
+ progress_percentage=int(query_progress),
1778
+ current_iteration=iteration,
1779
+ total_iterations=max_iterations,
1780
+ sources_found=len(all_findings),
1781
+ current_query=query,
1782
+ elapsed_seconds=int(time.time() - start_time),
1783
+ max_runtime_seconds=max_runtime_seconds or 0
1784
+ )
1785
+ results = await _multi_source_search(
1786
+ query, sources, max_sources_per_iteration,
1787
+ kb_ids, tool_context, tool_config
1788
+ )
1789
+
1790
+ # Deduplicate against ALL previously seen sources (web-only version)
1791
+ query_findings = []
1792
+ for result in results:
1793
+ # For web sources, use URL or title as unique key
1794
+ key = result.url or f"web:{result.title}"
1795
+
1796
+ # Only add if not seen before
1797
+ if key not in seen_sources_global:
1798
+ seen_sources_global.add(key)
1799
+ query_findings.append(result)
1800
+ iteration_findings.append(result)
1801
+
1802
+ # Add citations for this query's findings
1803
+ for finding in query_findings:
1804
+ citation_tracker.add_citation(finding, query)
1805
+
1806
+ all_findings.extend(iteration_findings)
1807
+
1808
+ log.info("%s Iteration %d found %d new sources (total: %d)",
1809
+ log_identifier, iteration, len(iteration_findings), len(all_findings))
1810
+
1811
+ # Send RAG info update with new sources after each iteration
1812
+ # This allows the UI to display sources as they are discovered
1813
+ if iteration_findings:
1814
+ await _send_rag_info_update(citation_tracker, tool_context, is_complete=False)
1815
+
1816
+ # Select and fetch full content from best sources in THIS iteration
1817
+ # This allows the LLM to reflect on full content, not just snippets
1818
+ selection_progress = iteration_progress_base + (70 / max_iterations) * 0.4
1819
+
1820
+ # Prepare URL list early for the entire analyzing phase
1821
+ fetching_url_list = []
1822
+
1823
+ # Select top 2-3 sources from this iteration to fetch/analyze
1824
+ sources_to_display_count = min(3, len(all_findings))
1825
+
1826
+ # For web sources: select and fetch full content from current iteration (with phase-specific model support)
1827
+ selected_sources = []
1828
+ if len(iteration_findings) > 0:
1829
+ sources_to_fetch_count = min(3, len(iteration_findings))
1830
+ selected_sources = await _select_sources_to_fetch(
1831
+ research_question, iteration_findings, max_to_fetch=sources_to_fetch_count,
1832
+ tool_context=tool_context, tool_config=tool_config
1833
+ )
1834
+
1835
+ # Prepare display list for UI - show ONLY NEW sources being analyzed (not duplicates)
1836
+ # Use iteration_findings which contains only NEW sources after deduplication
1837
+ if selected_sources:
1838
+ # Web sources that will be fetched (only new ones)
1839
+ fetching_url_list = [
1840
+ {
1841
+ "url": src.url,
1842
+ "title": src.title,
1843
+ "favicon": f"https://www.google.com/s2/favicons?domain={src.url}&sz=32" if src.url else "",
1844
+ "source_type": src.source_type
1845
+ }
1846
+ for src in selected_sources
1847
+ ]
1848
+ else:
1849
+ # Web-only version - no other sources to display
1850
+ fetching_url_list = []
1851
+
1852
+ # Start unified "analyzing" phase - covers selecting, fetching, and analyzing
1853
+ # Skip if no sources found
1854
+ if len(all_findings) > 0:
1855
+ analyze_progress = iteration_progress_base + (70 / max_iterations) * 0.4
1856
+ await _send_research_progress(
1857
+ f"Analyzing {len(all_findings)} sources (reading {len(fetching_url_list)} in detail)...",
1858
+ tool_context,
1859
+ phase="analyzing",
1860
+ progress_percentage=int(analyze_progress),
1861
+ current_iteration=iteration,
1862
+ total_iterations=max_iterations,
1863
+ sources_found=len(all_findings),
1864
+ fetching_urls=fetching_url_list,
1865
+ elapsed_seconds=int(time.time() - start_time),
1866
+ max_runtime_seconds=max_runtime_seconds or 0
1867
+ )
1868
+
1869
+ # Fetch selected sources (still within analyzing phase) - only for web sources
1870
+ if selected_sources:
1871
+ fetch_stats = await _fetch_selected_sources(selected_sources, tool_context, tool_config, citation_tracker, start_time, max_runtime_seconds)
1872
+ log.info("%s Iteration %d fetch stats: %s", log_identifier, iteration, fetch_stats)
1873
+
1874
+ # Continue analyzing phase - reflect on findings
1875
+ # Skip if no sources found
1876
+ if len(all_findings) > 0:
1877
+ reflect_progress = iteration_progress_base + (70 / max_iterations) * 0.9
1878
+ await _send_research_progress(
1879
+ f"Analyzing {len(all_findings)} sources and identifying knowledge gaps...",
1880
+ tool_context,
1881
+ phase="analyzing",
1882
+ progress_percentage=int(reflect_progress),
1883
+ current_iteration=iteration,
1884
+ total_iterations=max_iterations,
1885
+ sources_found=len(all_findings),
1886
+ fetching_urls=fetching_url_list, # Keep URLs visible during reflection
1887
+ elapsed_seconds=int(time.time() - start_time),
1888
+ max_runtime_seconds=max_runtime_seconds or 0
1889
+ )
1890
+
1891
+ reflection = await _reflect_on_findings(
1892
+ research_question, all_findings, iteration, tool_context, max_iterations, tool_config
1893
+ )
1894
+
1895
+ log.info("%s Reflection: %s", log_identifier, reflection.reasoning)
1896
+
1897
+ # Check if we should continue
1898
+ if not reflection.should_continue or iteration >= max_iterations:
1899
+ log.info("%s Research complete after %d iterations", log_identifier, iteration)
1900
+ break
1901
+
1902
+ # Generate new queries for next iteration based on reflection
1903
+ queries = reflection.suggested_queries
1904
+
1905
+ # Generate final report
1906
+ await _send_research_progress(
1907
+ f"Writing comprehensive research report from {len(all_findings)} sources...",
1908
+ tool_context,
1909
+ phase="writing",
1910
+ progress_percentage=85,
1911
+ current_iteration=max_iterations,
1912
+ total_iterations=max_iterations,
1913
+ sources_found=len(all_findings),
1914
+ elapsed_seconds=int(time.time() - start_time),
1915
+ max_runtime_seconds=max_runtime_seconds or 0
1916
+ )
1917
+
1918
+ report = await _generate_research_report(
1919
+ research_question, all_findings, citation_tracker, tool_context, tool_config
1920
+ )
1921
+
1922
+ log.info("%s Research complete: %d total sources, report length: %d chars",
1923
+ log_identifier, len(all_findings), len(report))
1924
+
1925
+ from ..utils.artifact_helpers import (
1926
+ save_artifact_with_metadata,
1927
+ decode_and_get_bytes,
1928
+ sanitize_to_filename,
1929
+ )
1930
+ from ..utils.context_helpers import get_original_session_id
1931
+
1932
+ # Generate filename from research question using utility function
1933
+ artifact_filename = sanitize_to_filename(
1934
+ research_question,
1935
+ max_length=50,
1936
+ suffix="_report.md"
1937
+ )
1938
+ # Get artifact service from invocation context
1939
+ inv_context = tool_context._invocation_context
1940
+ artifact_service = inv_context.artifact_service
1941
+ if not artifact_service:
1942
+ log.warning("%s ArtifactService not available, cannot save research report artifact", log_identifier)
1943
+ artifact_result = {"status": "error", "message": "ArtifactService not available"}
1944
+ else:
1945
+ # Prepare content bytes and metadata
1946
+ try:
1947
+ artifact_bytes, final_mime_type = decode_and_get_bytes(
1948
+ report, "text/markdown", f"{log_identifier}[CreateArtifact]"
1949
+ )
1950
+ except Exception as decode_error:
1951
+ log.error("%s Error preparing artifact bytes: %s", log_identifier, str(decode_error))
1952
+ raise
1953
+
1954
+ # Get timestamp from session
1955
+ session_last_update_time = inv_context.session.last_update_time
1956
+ if isinstance(session_last_update_time, datetime):
1957
+ timestamp_for_artifact = session_last_update_time
1958
+ elif isinstance(session_last_update_time, (int, float)):
1959
+ try:
1960
+ timestamp_for_artifact = datetime.fromtimestamp(session_last_update_time, timezone.utc)
1961
+ except Exception:
1962
+ timestamp_for_artifact = datetime.now(timezone.utc)
1963
+ else:
1964
+ timestamp_for_artifact = datetime.now(timezone.utc)
1965
+
1966
+ # Save artifact directly using artifact service
1967
+ try:
1968
+ artifact_result = await save_artifact_with_metadata(
1969
+ artifact_service=artifact_service,
1970
+ app_name=inv_context.app_name,
1971
+ user_id=inv_context.user_id,
1972
+ session_id=get_original_session_id(inv_context),
1973
+ filename=artifact_filename,
1974
+ content_bytes=artifact_bytes,
1975
+ mime_type=final_mime_type,
1976
+ metadata_dict={"description": f"Deep research report on: {research_question}"},
1977
+ timestamp=timestamp_for_artifact,
1978
+ schema_max_keys=50, # Default schema max keys
1979
+ tool_context=tool_context,
1980
+ )
1981
+ except Exception as save_error:
1982
+ log.error("%s Error saving artifact: %s", log_identifier, str(save_error))
1983
+ artifact_result = {"status": "error", "message": str(save_error)}
1984
+
1985
+ if artifact_result.get("status") not in ["success", "partial_success"]:
1986
+ log.error("%s Failed to create artifact for research report. Status: %s, Message: %s",
1987
+ log_identifier, artifact_result.get("status"), artifact_result.get("message"))
1988
+ artifact_version = None
1989
+ else:
1990
+ artifact_version = artifact_result.get("data_version", 1)
1991
+ log.info("%s Successfully created artifact '%s' v%d",
1992
+ log_identifier, artifact_filename, artifact_version)
1993
+
1994
+ # Send final progress update
1995
+ try:
1996
+ await _send_research_progress(
1997
+ f"✅ Research complete! Report saved as '{artifact_filename}'",
1998
+ tool_context,
1999
+ phase="writing",
2000
+ progress_percentage=100,
2001
+ current_iteration=max_iterations,
2002
+ total_iterations=max_iterations,
2003
+ sources_found=len(all_findings),
2004
+ elapsed_seconds=int(time.time() - start_time),
2005
+ max_runtime_seconds=max_runtime_seconds or 0
2006
+ )
2007
+ except Exception as progress_error:
2008
+ log.error("%s Error sending final progress update: %s", log_identifier, str(progress_error))
2009
+
2010
+ # Emit DeepResearchReportData signal directly to frontend
2011
+ # This bypasses the LLM response entirely, ensuring the report is displayed
2012
+ # via the DeepResearchReportBubble component
2013
+ try:
2014
+ await _send_deep_research_report_signal(
2015
+ artifact_filename=artifact_filename,
2016
+ artifact_version=artifact_version,
2017
+ title=citation_tracker.generated_title or research_question,
2018
+ sources_count=len(all_findings),
2019
+ tool_context=tool_context
2020
+ )
2021
+
2022
+ except Exception as signal_error:
2023
+ log.error("%s Error sending deep research report signal: %s", log_identifier, str(signal_error))
2024
+
2025
+ # Send final RAG info update marking research as complete
2026
+ try:
2027
+ await _send_rag_info_update(citation_tracker, tool_context, is_complete=True)
2028
+ except Exception as rag_error:
2029
+ log.error("%s Error sending final RAG info update: %s", log_identifier, str(rag_error))
2030
+
2031
+ # Build the response - NO EMBED since we already sent the DeepResearchReportData signal
2032
+ artifact_save_success = artifact_result.get("status") in ["success", "partial_success"]
2033
+ artifact_version = artifact_result.get("data_version", 1) if artifact_save_success else None
2034
+
2035
+ result_dict = {
2036
+ "status": "success",
2037
+ "total_sources": len(all_findings),
2038
+ "iterations_completed": min(iteration, max_iterations),
2039
+ "rag_metadata": citation_tracker.get_rag_metadata(artifact_filename=artifact_filename if artifact_save_success else None),
2040
+ }
2041
+
2042
+ # Only include artifact info if save was successful
2043
+ if artifact_save_success:
2044
+ result_dict["artifact_filename"] = artifact_filename
2045
+ result_dict["artifact_version"] = artifact_version
2046
+ result_dict["response_artifact"] = {
2047
+ "filename": artifact_filename,
2048
+ "version": artifact_version
2049
+ }
2050
+ result_dict["message"] = (
2051
+ f"Deep research complete: analyzed {len(all_findings)} sources. "
2052
+ f"The comprehensive report '{artifact_filename}' (version {artifact_version}) "
2053
+ f"has been sent to the user and will be displayed automatically. "
2054
+ f"Do NOT include any artifact embeds or summarize the report - it is already being displayed."
2055
+ )
2056
+ else:
2057
+ result_dict["artifact_error"] = artifact_result.get("message", "Failed to save artifact")
2058
+ result_dict["message"] = f"Research complete but failed to save artifact: {artifact_result.get('message', 'Unknown error')}. Analyzed {len(all_findings)} sources."
2059
+
2060
+ return result_dict
2061
+
2062
+ except Exception as e:
2063
+ log.exception("%s Unexpected error: %s", log_identifier, e)
2064
+ return {
2065
+ "status": "error",
2066
+ "message": f"Research failed: {str(e)}"
2067
+ }
2068
+
2069
+
2070
+ # Tool Definition
2071
+ deep_research_tool_def = BuiltinTool(
2072
+ name="deep_research",
2073
+ implementation=deep_research,
2074
+ description="""
2075
+ Performs comprehensive, iterative research across multiple sources.
2076
+
2077
+ This tool conducts deep research by:
2078
+ 1. Breaking down the research question into searchable queries
2079
+ 2. Searching across web and knowledge base sources
2080
+ 3. Reflecting on findings to identify gaps
2081
+ 4. Refining queries and conducting additional searches
2082
+ 5. Synthesizing findings into a comprehensive report with citations
2083
+
2084
+ Use this tool when you need to:
2085
+ - Gather comprehensive information on a complex topic
2086
+ - Research across multiple information sources
2087
+ - Provide well-cited, authoritative answers
2088
+ - Explore a topic in depth with multiple perspectives
2089
+
2090
+ The tool provides real-time progress updates and generates a detailed
2091
+ research report with proper citations for all sources.
2092
+
2093
+ IMPORTANT - Returning Results:
2094
+ The tool automatically sends the research report directly to the user's interface
2095
+ via a special signal. The report will be displayed automatically in a dedicated
2096
+ component. Do NOT include any artifact embeds or summarize the report content -
2097
+ it is already being displayed to the user. Simply acknowledge that the research
2098
+ is complete and the report has been delivered.
2099
+
2100
+ Configuration:
2101
+ - Can be configured via tool_config in agent YAML (max_iterations, max_runtime_seconds, sources)
2102
+ - Can be overridden via explicit parameters
2103
+ - Supports research_type for backward compatibility ("quick" or "in-depth")
2104
+ """,
2105
+ category="research",
2106
+ category_name=CATEGORY_NAME,
2107
+ category_description=CATEGORY_DESCRIPTION,
2108
+ required_scopes=["tool:research:deep_research"],
2109
+ parameters=adk_types.Schema(
2110
+ type=adk_types.Type.OBJECT,
2111
+ properties={
2112
+ "research_question": adk_types.Schema(
2113
+ type=adk_types.Type.STRING,
2114
+ description="The research question or topic to investigate"
2115
+ ),
2116
+ "research_type": adk_types.Schema(
2117
+ type=adk_types.Type.STRING,
2118
+ description="Type of research: 'quick' (5min, 3 iterations) or 'in-depth' (10min, 10 iterations). Can be overridden by tool_config or explicit parameters. Default: 'quick'",
2119
+ enum=["quick", "in-depth"],
2120
+ nullable=True
2121
+ ),
2122
+ "max_iterations": adk_types.Schema(
2123
+ type=adk_types.Type.INTEGER,
2124
+ description="Maximum number of research iterations (1-10). Overrides tool_config and research_type if provided.",
2125
+ nullable=True
2126
+ ),
2127
+ "max_runtime_minutes": adk_types.Schema(
2128
+ type=adk_types.Type.INTEGER,
2129
+ description="Maximum runtime in minutes (1-10). The software converts this to seconds internally. Overrides tool_config and research_type if provided.",
2130
+ nullable=True
2131
+ ),
2132
+ "max_runtime_seconds": adk_types.Schema(
2133
+ type=adk_types.Type.INTEGER,
2134
+ description="Maximum runtime in seconds (60-600). Use max_runtime_minutes instead for easier specification. Overrides tool_config and research_type if provided.",
2135
+ nullable=True
2136
+ ),
2137
+ "sources": adk_types.Schema(
2138
+ type=adk_types.Type.ARRAY,
2139
+ items=adk_types.Schema(
2140
+ type=adk_types.Type.STRING,
2141
+ enum=["web", "kb"]
2142
+ ),
2143
+ description="Sources to search. Default from tool_config or ['web']. Web search requires Google Custom Search API key (GOOGLE_API_KEY and GOOGLE_CSE_ID). For other search providers (Tavily, Exa, Brave), use the corresponding plugins from solace-agent-mesh-plugins.",
2144
+ nullable=True
2145
+ ),
2146
+ "kb_ids": adk_types.Schema(
2147
+ type=adk_types.Type.ARRAY,
2148
+ items=adk_types.Schema(type=adk_types.Type.STRING),
2149
+ description="Specific knowledge base IDs to search (only used if 'kb' is in sources)",
2150
+ nullable=True
2151
+ )
2152
+ },
2153
+ required=["research_question"]
2154
+ ),
2155
+ examples=[]
2156
+ )
2157
+
2158
+ # Register tool
2159
+ tool_registry.register(deep_research_tool_def)
2160
+
2161
+ log.info("Deep research tool registered: deep_research")