agentengine-sdk-python 0.6.4__py3-none-any.whl → 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (486) hide show
  1. agentengine_sdk_python-0.7.0.dist-info/METADATA +181 -0
  2. agentengine_sdk_python-0.7.0.dist-info/RECORD +482 -0
  3. {agentengine_sdk_python-0.6.4.dist-info → agentengine_sdk_python-0.7.0.dist-info}/WHEEL +1 -1
  4. agentengine_sdk_python-0.7.0.dist-info/entry_points.txt +3 -0
  5. agentengine_sdk_python-0.7.0.dist-info/licenses/LICENSE +175 -0
  6. agentengine_sdk_python-0.7.0.dist-info/top_level.txt +2 -0
  7. ksadk/__init__.py +13 -0
  8. ksadk/__main__.py +4 -0
  9. ksadk/a2a/__init__.py +15 -0
  10. ksadk/a2a/card_builder.py +64 -0
  11. ksadk/a2a/client.py +309 -0
  12. ksadk/a2a/executor.py +201 -0
  13. ksadk/a2a/server.py +79 -0
  14. ksadk/agents/__init__.py +20 -0
  15. ksadk/agents/base.py +370 -0
  16. ksadk/agents/context.py +38 -0
  17. ksadk/agents/event.py +35 -0
  18. ksadk/agents/loop_agent.py +71 -0
  19. ksadk/agents/parallel_agent.py +109 -0
  20. ksadk/agents/runner_adapter.py +24 -0
  21. ksadk/agents/sequential_agent.py +34 -0
  22. ksadk/api/__init__.py +3 -0
  23. ksadk/api/client.py +2246 -0
  24. ksadk/builders/__init__.py +20 -0
  25. ksadk/builders/base.py +66 -0
  26. ksadk/builders/code_builder.py +1990 -0
  27. ksadk/builders/container_builder.py +786 -0
  28. ksadk/builders/framework_requirements.py +59 -0
  29. ksadk/builders/ks3_uploader.py +389 -0
  30. ksadk/builders/mcp_builder.py +476 -0
  31. ksadk/builders/requirements_utils.py +75 -0
  32. ksadk/cli/__init__.py +531 -0
  33. ksadk/cli/__main__.py +4 -0
  34. ksadk/cli/agent_ref.py +204 -0
  35. ksadk/cli/cmd_a2a.py +167 -0
  36. ksadk/cli/cmd_agent.py +201 -0
  37. ksadk/cli/cmd_build.py +270 -0
  38. ksadk/cli/cmd_completion.py +304 -0
  39. ksadk/cli/cmd_config.py +758 -0
  40. ksadk/cli/cmd_create.py +2304 -0
  41. ksadk/cli/cmd_dashboard.py +900 -0
  42. ksadk/cli/cmd_deploy.py +704 -0
  43. ksadk/cli/cmd_destroy.py +345 -0
  44. ksadk/cli/cmd_files.py +1227 -0
  45. ksadk/cli/cmd_hermes.py +1263 -0
  46. ksadk/cli/cmd_invoke.py +1503 -0
  47. ksadk/cli/cmd_launch.py +564 -0
  48. ksadk/cli/cmd_mcp.py +1179 -0
  49. ksadk/cli/cmd_model.py +302 -0
  50. ksadk/cli/cmd_openclaw.py +3922 -0
  51. ksadk/cli/cmd_run.py +239 -0
  52. ksadk/cli/cmd_status.py +588 -0
  53. ksadk/cli/cmd_version.py +522 -0
  54. ksadk/cli/cmd_web.py +288 -0
  55. ksadk/cli/deploy_utils.py +87 -0
  56. ksadk/cli/dry_run.py +214 -0
  57. ksadk/cli/env_options.py +82 -0
  58. ksadk/cli/error_utils.py +525 -0
  59. ksadk/cli/global_options.py +31 -0
  60. ksadk/cli/local_runtime.py +97 -0
  61. ksadk/cli/model_catalog.py +136 -0
  62. ksadk/cli/network_options.py +412 -0
  63. ksadk/cli/resource_common.py +621 -0
  64. ksadk/cli/storage.py +84 -0
  65. ksadk/cli/ui.py +431 -0
  66. ksadk/cli/workflow_common.py +552 -0
  67. ksadk/common/__init__.py +14 -0
  68. ksadk/common/aicp_env.py +128 -0
  69. ksadk/common/auth.py +190 -0
  70. ksadk/common/constants.py +84 -0
  71. ksadk/common/llm_utils.py +61 -0
  72. ksadk/configs/__init__.py +61 -0
  73. ksadk/configs/env_registry.py +343 -0
  74. ksadk/configs/global_config.py +200 -0
  75. ksadk/configs/settings.py +706 -0
  76. ksadk/conversations/__init__.py +95 -0
  77. ksadk/conversations/attachment_storage.py +379 -0
  78. ksadk/conversations/attachments.py +774 -0
  79. ksadk/conversations/compaction_pipeline.py +387 -0
  80. ksadk/conversations/compaction_prompt.py +128 -0
  81. ksadk/conversations/context.py +303 -0
  82. ksadk/conversations/message_projection.py +269 -0
  83. ksadk/conversations/model_context.py +370 -0
  84. ksadk/conversations/model_options.py +136 -0
  85. ksadk/conversations/normalize.py +446 -0
  86. ksadk/conversations/reasoning_markup.py +91 -0
  87. ksadk/conversations/run_kinds.py +61 -0
  88. ksadk/conversations/run_status.py +53 -0
  89. ksadk/conversations/runtime.py +5276 -0
  90. ksadk/conversations/semantic_summary.py +359 -0
  91. ksadk/conversations/session_title.py +254 -0
  92. ksadk/deployment/__init__.py +83 -0
  93. ksadk/deployment/agent_access.py +226 -0
  94. ksadk/deployment/base.py +229 -0
  95. ksadk/deployment/manager.py +332 -0
  96. ksadk/deployment/providers/__init__.py +10 -0
  97. ksadk/deployment/providers/serverless.py +1115 -0
  98. ksadk/deployment/registry.py +120 -0
  99. ksadk/deployment/state.py +78 -0
  100. ksadk/deployment/ui_config.py +21 -0
  101. ksadk/detection/__init__.py +7 -0
  102. ksadk/detection/detector.py +431 -0
  103. ksadk/detection/mcp_detector.py +250 -0
  104. ksadk/hermes_terminal.py +467 -0
  105. ksadk/identity/__init__.py +17 -0
  106. ksadk/identity/resolver.py +385 -0
  107. ksadk/knowledge_base/__init__.py +62 -0
  108. ksadk/knowledge_base/adk_tool.py +47 -0
  109. ksadk/knowledge_base/client.py +297 -0
  110. ksadk/knowledge_base/langchain_tool.py +55 -0
  111. ksadk/knowledge_base/service.py +66 -0
  112. ksadk/knowledge_base/tool.py +50 -0
  113. ksadk/markdown.py +169 -0
  114. ksadk/mcp_runtime/__init__.py +231 -0
  115. ksadk/memory/__init__.py +57 -0
  116. ksadk/memory/adk/__init__.py +51 -0
  117. ksadk/memory/adk/backends/__init__.py +5 -0
  118. ksadk/memory/adk/backends/base_ltm_backend.py +52 -0
  119. ksadk/memory/adk/backends/http_ltm_backend.py +183 -0
  120. ksadk/memory/adk/backends/inmemory_ltm_backend.py +96 -0
  121. ksadk/memory/adk/backends/sdk_ltm_backend.py +456 -0
  122. ksadk/memory/adk/long_term_memory.py +273 -0
  123. ksadk/memory/adk/resilient_session_service.py +268 -0
  124. ksadk/memory/adk/short_term_memory.py +304 -0
  125. ksadk/memory/adk_tool.py +31 -0
  126. ksadk/memory/backends/__init__.py +6 -0
  127. ksadk/memory/backends/base.py +103 -0
  128. ksadk/memory/backends/memory.py +112 -0
  129. ksadk/memory/backends/redis.py +146 -0
  130. ksadk/memory/langchain_tool.py +39 -0
  131. ksadk/memory/ltm_backend_factory.py +24 -0
  132. ksadk/memory/manager.py +215 -0
  133. ksadk/memory/service.py +172 -0
  134. ksadk/memory/tool.py +96 -0
  135. ksadk/model_policy.py +223 -0
  136. ksadk/openclaw_gateway.py +378 -0
  137. ksadk/runners/__init__.py +8 -0
  138. ksadk/runners/adk_runner.py +1994 -0
  139. ksadk/runners/base_runner.py +305 -0
  140. ksadk/runners/deepagents_runner.py +13 -0
  141. ksadk/runners/factory.py +73 -0
  142. ksadk/runners/langchain_runner.py +751 -0
  143. ksadk/runners/langgraph_runner.py +985 -0
  144. ksadk/runners/patch_langchain.py +187 -0
  145. ksadk/runners/remote_runner.py +792 -0
  146. ksadk/runners/usage_accumulator.py +38 -0
  147. ksadk/runners/utils/__init__.py +19 -0
  148. ksadk/runners/utils/langfuse.py +230 -0
  149. ksadk/runners/utils/loader.py +62 -0
  150. ksadk/runtime_context.py +166 -0
  151. ksadk/runtime_state.py +25 -0
  152. ksadk/sandbox/__init__.py +28 -0
  153. ksadk/sandbox/backends/__init__.py +9 -0
  154. ksadk/sandbox/backends/e2b.py +154 -0
  155. ksadk/sandbox/backends/local_process.py +131 -0
  156. ksadk/sandbox/base.py +101 -0
  157. ksadk/sandbox/factory.py +53 -0
  158. ksadk/sandbox/registry.py +187 -0
  159. ksadk/server/__init__.py +7 -0
  160. ksadk/server/api_models.py +73 -0
  161. ksadk/server/app.py +3931 -0
  162. ksadk/server/static/assets/ArtifactsPanel-D-SSn2ee.js +1 -0
  163. ksadk/server/static/assets/CodeBlock-P16VOLYi.js +9 -0
  164. ksadk/server/static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  165. ksadk/server/static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  166. ksadk/server/static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  167. ksadk/server/static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  168. ksadk/server/static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  169. ksadk/server/static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  170. ksadk/server/static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  171. ksadk/server/static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  172. ksadk/server/static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  173. ksadk/server/static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  174. ksadk/server/static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  175. ksadk/server/static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  176. ksadk/server/static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  177. ksadk/server/static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  178. ksadk/server/static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  179. ksadk/server/static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  180. ksadk/server/static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  181. ksadk/server/static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  182. ksadk/server/static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  183. ksadk/server/static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  184. ksadk/server/static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  185. ksadk/server/static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  186. ksadk/server/static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  187. ksadk/server/static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  188. ksadk/server/static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  189. ksadk/server/static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  190. ksadk/server/static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  191. ksadk/server/static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  192. ksadk/server/static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  193. ksadk/server/static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  194. ksadk/server/static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  195. ksadk/server/static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  196. ksadk/server/static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  197. ksadk/server/static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  198. ksadk/server/static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  199. ksadk/server/static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  200. ksadk/server/static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  201. ksadk/server/static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  202. ksadk/server/static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  203. ksadk/server/static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  204. ksadk/server/static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  205. ksadk/server/static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  206. ksadk/server/static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  207. ksadk/server/static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  208. ksadk/server/static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  209. ksadk/server/static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  210. ksadk/server/static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  211. ksadk/server/static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  212. ksadk/server/static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  213. ksadk/server/static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  214. ksadk/server/static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  215. ksadk/server/static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  216. ksadk/server/static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  217. ksadk/server/static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  218. ksadk/server/static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  219. ksadk/server/static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  220. ksadk/server/static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  221. ksadk/server/static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  222. ksadk/server/static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  223. ksadk/server/static/assets/MathMessageMarkdown-CdGiQWHR.js +8 -0
  224. ksadk/server/static/assets/MathMessageMarkdown-DeYh8QPr.css +1 -0
  225. ksadk/server/static/assets/MermaidBlock-IOcal8cm.js +303 -0
  226. ksadk/server/static/assets/NativeTerminalPanel-CHOd7Rch.css +1 -0
  227. ksadk/server/static/assets/NativeTerminalPanel-DGSDWsei.js +1 -0
  228. ksadk/server/static/assets/addon-fit-DthTIhi3.js +1 -0
  229. ksadk/server/static/assets/apl-CnwPGSsG.js +1 -0
  230. ksadk/server/static/assets/arc-BA13sHc9.js +1 -0
  231. ksadk/server/static/assets/architecture-7EHR7CIX-BvUlsA6l.js +1 -0
  232. ksadk/server/static/assets/architectureDiagram-3BPJPVTR-DzJzgVoL.js +36 -0
  233. ksadk/server/static/assets/asciiarmor-qTkVPQu6.js +1 -0
  234. ksadk/server/static/assets/asn1-Dr8qZg38.js +1 -0
  235. ksadk/server/static/assets/asterisk-Bja0s6e1.js +1 -0
  236. ksadk/server/static/assets/blockDiagram-GPEHLZMM-UTdVcZhn.js +132 -0
  237. ksadk/server/static/assets/brainfuck-D5EjA2JK.js +1 -0
  238. ksadk/server/static/assets/c4Diagram-AAUBKEIU-Dix0GD4m.js +10 -0
  239. ksadk/server/static/assets/channel-CTuydARX.js +1 -0
  240. ksadk/server/static/assets/chunk-2J33WTMH-C2JxuZ42.js +1 -0
  241. ksadk/server/static/assets/chunk-4BX2VUAB-Be57QPgD.js +1 -0
  242. ksadk/server/static/assets/chunk-55IACEB6-WjSuy4nv.js +1 -0
  243. ksadk/server/static/assets/chunk-727SXJPM-BOovP-b5.js +206 -0
  244. ksadk/server/static/assets/chunk-AQP2D5EJ-DOmZNmWj.js +231 -0
  245. ksadk/server/static/assets/chunk-FMBD7UC4-DzwlodkU.js +15 -0
  246. ksadk/server/static/assets/chunk-ND2GUHAM-DMqAikEU.js +1 -0
  247. ksadk/server/static/assets/chunk-QZHKN3VN-BnCydivS.js +1 -0
  248. ksadk/server/static/assets/classDiagram-4FO5ZUOK-BNHg45Be.js +1 -0
  249. ksadk/server/static/assets/classDiagram-v2-Q7XG4LA2-BNHg45Be.js +1 -0
  250. ksadk/server/static/assets/clike-DHH8Ad3s.js +1 -0
  251. ksadk/server/static/assets/clojure-BflJGmDX.js +1 -0
  252. ksadk/server/static/assets/cmake-CyVbuzPu.js +1 -0
  253. ksadk/server/static/assets/cobol-B_OZ4V-R.js +1 -0
  254. ksadk/server/static/assets/coffeescript-B2pKbFk8.js +1 -0
  255. ksadk/server/static/assets/commonlisp-CcllspGY.js +1 -0
  256. ksadk/server/static/assets/cose-bilkent-S5V4N54A-Zhy2GBnP.js +1 -0
  257. ksadk/server/static/assets/crystal-M5-qzICs.js +1 -0
  258. ksadk/server/static/assets/css-CYpP4FRV.js +1 -0
  259. ksadk/server/static/assets/cypher-p9eYesGn.js +1 -0
  260. ksadk/server/static/assets/cytoscape.esm-DelgaX4f.js +321 -0
  261. ksadk/server/static/assets/d-BhMjBjQP.js +1 -0
  262. ksadk/server/static/assets/dagre-BM42HDAG-CbKrtIUz.js +4 -0
  263. ksadk/server/static/assets/dagre-Bx709z4p.js +1 -0
  264. ksadk/server/static/assets/defaultLocale-C8Fc0cco.js +1 -0
  265. ksadk/server/static/assets/diagram-2AECGRRQ-BRApgQRK.js +43 -0
  266. ksadk/server/static/assets/diagram-5GNKFQAL-D0xbnDiK.js +10 -0
  267. ksadk/server/static/assets/diagram-KO2AKTUF-CVugVI9L.js +3 -0
  268. ksadk/server/static/assets/diagram-LMA3HP47-DozUGgee.js +24 -0
  269. ksadk/server/static/assets/diagram-OG6HWLK6-ZOYsKlru.js +24 -0
  270. ksadk/server/static/assets/diff-ChtP43wD.js +1 -0
  271. ksadk/server/static/assets/dist-2hInLS_J.js +9 -0
  272. ksadk/server/static/assets/dist-8EEyGgDx.js +1 -0
  273. ksadk/server/static/assets/dist-8_SfK1IP.js +1 -0
  274. ksadk/server/static/assets/dist-B0NP_Z0z.js +23 -0
  275. ksadk/server/static/assets/dist-BIJVDMuB.js +1 -0
  276. ksadk/server/static/assets/dist-BmlMPsCc.js +2 -0
  277. ksadk/server/static/assets/dist-BrCcQ47T.js +1 -0
  278. ksadk/server/static/assets/dist-BssVmjIo.js +6 -0
  279. ksadk/server/static/assets/dist-C3HIIaN5.js +1 -0
  280. ksadk/server/static/assets/dist-CPBtqRTW.js +1 -0
  281. ksadk/server/static/assets/dist-CkEKJFtk.js +1 -0
  282. ksadk/server/static/assets/dist-D-JMdWPQ.js +1 -0
  283. ksadk/server/static/assets/dist-D-xOD048.js +1 -0
  284. ksadk/server/static/assets/dist-D57wnGWd.js +1 -0
  285. ksadk/server/static/assets/dist-DL_NKc_z.js +1 -0
  286. ksadk/server/static/assets/dist-DMezvBGJ.js +1 -0
  287. ksadk/server/static/assets/dist-DmFaq_Iy.js +1 -0
  288. ksadk/server/static/assets/dist-Dp0k7bWI.js +1 -0
  289. ksadk/server/static/assets/dist-DpKtt4Bo.js +1 -0
  290. ksadk/server/static/assets/dist-DyOkZmze.js +11 -0
  291. ksadk/server/static/assets/dist-DyWsb78Q.js +1 -0
  292. ksadk/server/static/assets/dist-gX6GOLKq.js +1 -0
  293. ksadk/server/static/assets/dockerfile-BLkNEvjs.js +1 -0
  294. ksadk/server/static/assets/dtd-DTF72YNO.js +1 -0
  295. ksadk/server/static/assets/dylan-C6jEEEk-.js +1 -0
  296. ksadk/server/static/assets/ebnf-D4c0_ac3.js +1 -0
  297. ksadk/server/static/assets/ecl-BSZaIHnp.js +1 -0
  298. ksadk/server/static/assets/eiffel-BEjRio4Q.js +1 -0
  299. ksadk/server/static/assets/elm-Cshzl8qu.js +1 -0
  300. ksadk/server/static/assets/erDiagram-TEJ5UH35-Bk-9bgE5.js +85 -0
  301. ksadk/server/static/assets/erlang-DaB2Rkuy.js +1 -0
  302. ksadk/server/static/assets/eventmodeling-FCH6USID-DsAgWnlf.js +1 -0
  303. ksadk/server/static/assets/factor-D7LVTn2l.js +1 -0
  304. ksadk/server/static/assets/fcl-ClOhbFR7.js +1 -0
  305. ksadk/server/static/assets/flowDiagram-I6XJVG4X-Dn9kZVuZ.js +162 -0
  306. ksadk/server/static/assets/forth-Cezjo90N.js +1 -0
  307. ksadk/server/static/assets/fortran-Bt6PBEDR.js +1 -0
  308. ksadk/server/static/assets/ganttDiagram-6RSMTGT7-BGxXlJqm.js +292 -0
  309. ksadk/server/static/assets/gas-BHEdbvp9.js +1 -0
  310. ksadk/server/static/assets/gherkin-oBAE_ms0.js +1 -0
  311. ksadk/server/static/assets/gitGraph-WXDBUCRP-BIao9Yjg.js +1 -0
  312. ksadk/server/static/assets/gitGraphDiagram-PVQCEYII-C7Q0X-wi.js +106 -0
  313. ksadk/server/static/assets/graphlib-B8gBHxth.js +1 -0
  314. ksadk/server/static/assets/groovy-BC3IOsC8.js +1 -0
  315. ksadk/server/static/assets/haskell-BRsoo5mP.js +1 -0
  316. ksadk/server/static/assets/haxe-DIz0ZZqd.js +1 -0
  317. ksadk/server/static/assets/http-CLVgA2GD.js +1 -0
  318. ksadk/server/static/assets/idl-BUZw3wgd.js +1 -0
  319. ksadk/server/static/assets/index-C00Lwg3L.js +165 -0
  320. ksadk/server/static/assets/index-DAURbCsP.css +1 -0
  321. ksadk/server/static/assets/info-J43DQDTF-CyipqGxU.js +1 -0
  322. ksadk/server/static/assets/infoDiagram-5YYISTIA-BO_CSqq5.js +2 -0
  323. ksadk/server/static/assets/init-D6jRqBbL.js +1 -0
  324. ksadk/server/static/assets/ishikawaDiagram-YF4QCWOH-eYLPLd46.js +70 -0
  325. ksadk/server/static/assets/javascript-9Tg8ixDm.js +1 -0
  326. ksadk/server/static/assets/journeyDiagram-JHISSGLW-De9geSb0.js +139 -0
  327. ksadk/server/static/assets/julia-CQfgDRfP.js +1 -0
  328. ksadk/server/static/assets/kanban-definition-UN3LZRKU-Cs80kQto.js +89 -0
  329. ksadk/server/static/assets/katex-DSrCFD8n.js +265 -0
  330. ksadk/server/static/assets/linear-CN9sbKTF.js +1 -0
  331. ksadk/server/static/assets/livescript-BXxG0Yva.js +1 -0
  332. ksadk/server/static/assets/lua-CiA1ziua.js +1 -0
  333. ksadk/server/static/assets/mathematica-u8YMmbU0.js +1 -0
  334. ksadk/server/static/assets/mbox-BEMVcqjs.js +1 -0
  335. ksadk/server/static/assets/mermaid-parser.core-DlgSBU8l.js +161 -0
  336. ksadk/server/static/assets/mindmap-definition-RKZ34NQL-9afz7Oug.js +96 -0
  337. ksadk/server/static/assets/mirc-C9zpzAZU.js +1 -0
  338. ksadk/server/static/assets/mllike-B45xgp2S.js +1 -0
  339. ksadk/server/static/assets/modelica-CqHI_q-D.js +1 -0
  340. ksadk/server/static/assets/mscgen-qgSLujhx.js +1 -0
  341. ksadk/server/static/assets/mumps-CW_TBmxz.js +1 -0
  342. ksadk/server/static/assets/nginx-BMaDbqW3.js +1 -0
  343. ksadk/server/static/assets/nsis-AQ_alPln.js +1 -0
  344. ksadk/server/static/assets/ntriples-d28e9m0E.js +1 -0
  345. ksadk/server/static/assets/octave-D2Q8cUp1.js +1 -0
  346. ksadk/server/static/assets/ordinal-hYBb2elL.js +1 -0
  347. ksadk/server/static/assets/oz-BwTct_5T.js +1 -0
  348. ksadk/server/static/assets/packet-YPE3B663-CGkyRxlg.js +1 -0
  349. ksadk/server/static/assets/pascal-Dp_KdFgX.js +1 -0
  350. ksadk/server/static/assets/perl-CdEHsPfU.js +1 -0
  351. ksadk/server/static/assets/pie-LRSECV5Y-M0uWAf4n.js +1 -0
  352. ksadk/server/static/assets/pieDiagram-4H26LBE5-DPv_VstN.js +30 -0
  353. ksadk/server/static/assets/pig-AF4Hwhju.js +1 -0
  354. ksadk/server/static/assets/powershell-CzG71I4L.js +1 -0
  355. ksadk/server/static/assets/properties-C357ku5U.js +1 -0
  356. ksadk/server/static/assets/protobuf-D1ij_kNL.js +1 -0
  357. ksadk/server/static/assets/pug-Bugr-47h.js +1 -0
  358. ksadk/server/static/assets/puppet-B4hC2X4m.js +1 -0
  359. ksadk/server/static/assets/python-BqYw9LYJ.js +1 -0
  360. ksadk/server/static/assets/q-B9NhS7L_.js +1 -0
  361. ksadk/server/static/assets/quadrantDiagram-W4KKPZXB-o77wZ1XK.js +7 -0
  362. ksadk/server/static/assets/r-DADKenSm.js +1 -0
  363. ksadk/server/static/assets/radar-GUYGQ44K-Bdbm24-x.js +1 -0
  364. ksadk/server/static/assets/requirementDiagram-4Y6WPE33-BYVw81VC.js +84 -0
  365. ksadk/server/static/assets/rpm-gpDK5sbt.js +1 -0
  366. ksadk/server/static/assets/ruby-DsYpTuWg.js +1 -0
  367. ksadk/server/static/assets/sankeyDiagram-5OEKKPKP-B7nZFMKT.js +40 -0
  368. ksadk/server/static/assets/sas-C2OyD2Y6.js +1 -0
  369. ksadk/server/static/assets/scheme-CyDdOh7e.js +1 -0
  370. ksadk/server/static/assets/sequenceDiagram-3UESZ5HK-BGRRrFw5.js +162 -0
  371. ksadk/server/static/assets/shell-Du5Qg9im.js +1 -0
  372. ksadk/server/static/assets/sieve-BAxa3IjF.js +1 -0
  373. ksadk/server/static/assets/simple-mode-DRpGK0lJ.js +1 -0
  374. ksadk/server/static/assets/smalltalk-BLp5-jwC.js +1 -0
  375. ksadk/server/static/assets/solr-BBopId9w.js +1 -0
  376. ksadk/server/static/assets/sparql-Dz-mlCAC.js +1 -0
  377. ksadk/server/static/assets/spreadsheet-CTIncYxj.js +1 -0
  378. ksadk/server/static/assets/sql-DcCJ9jop.js +1 -0
  379. ksadk/server/static/assets/stateDiagram-AJRCARHV-BUfHCoPs.js +1 -0
  380. ksadk/server/static/assets/stateDiagram-v2-BHNVJYJU-8xBN_gLM.js +1 -0
  381. ksadk/server/static/assets/stex-sDrdk2BW.js +1 -0
  382. ksadk/server/static/assets/stylus-DLLZ8dgm.js +1 -0
  383. ksadk/server/static/assets/swift-DKB6_1j6.js +1 -0
  384. ksadk/server/static/assets/tcl-BGfruO1u.js +1 -0
  385. ksadk/server/static/assets/textile-DWBqthVk.js +1 -0
  386. ksadk/server/static/assets/tiddlywiki-VlUiK86J.js +1 -0
  387. ksadk/server/static/assets/tiki-CxVlf5KZ.js +1 -0
  388. ksadk/server/static/assets/timeline-definition-PNZ67QCA-FbCTGrf9.js +120 -0
  389. ksadk/server/static/assets/toml-LvoKBwCs.js +1 -0
  390. ksadk/server/static/assets/treeView-BLDUP644-npMo0ak1.js +1 -0
  391. ksadk/server/static/assets/treemap-LRROVOQU-CHdzrrdo.js +1 -0
  392. ksadk/server/static/assets/troff-C2dAgh7P.js +1 -0
  393. ksadk/server/static/assets/ttcn-BePYh-hG.js +1 -0
  394. ksadk/server/static/assets/ttcn-cfg-CY0aot7Q.js +1 -0
  395. ksadk/server/static/assets/turtle-BDLWE2cD.js +1 -0
  396. ksadk/server/static/assets/vb-BthdM_4N.js +1 -0
  397. ksadk/server/static/assets/vbscript-D9f6rSsU.js +1 -0
  398. ksadk/server/static/assets/velocity-CqftRsjg.js +1 -0
  399. ksadk/server/static/assets/vennDiagram-CIIHVFJN-DWgwtPsJ.js +34 -0
  400. ksadk/server/static/assets/verilog-D-x6ne0Z.js +1 -0
  401. ksadk/server/static/assets/vhdl-CMrOq2q1.js +1 -0
  402. ksadk/server/static/assets/wardley-L42UT6IY-BEQz7wyy.js +1 -0
  403. ksadk/server/static/assets/wardleyDiagram-YWT4CUSO-2x8G-tTM.js +78 -0
  404. ksadk/server/static/assets/webidl-D5jiJvOU.js +1 -0
  405. ksadk/server/static/assets/xquery-fj_R-kMw.js +1 -0
  406. ksadk/server/static/assets/xterm-DooSxjI5.js +36 -0
  407. ksadk/server/static/assets/xychartDiagram-2RQKCTM6-Drma3AYl.js +7 -0
  408. ksadk/server/static/assets/yacas-CXXgtw0p.js +1 -0
  409. ksadk/server/static/assets/z80-CL1naaMV.js +1 -0
  410. ksadk/server/static/favicon.svg +1 -0
  411. ksadk/server/static/icons.svg +24 -0
  412. ksadk/server/static/index.html +14 -0
  413. ksadk/server/terminal_sessions.py +551 -0
  414. ksadk/sessions/__init__.py +279 -0
  415. ksadk/sessions/base.py +354 -0
  416. ksadk/sessions/continuity.py +337 -0
  417. ksadk/sessions/errors.py +7 -0
  418. ksadk/sessions/in_memory.py +273 -0
  419. ksadk/sessions/local_service.py +949 -0
  420. ksadk/sessions/postgres_service.py +901 -0
  421. ksadk/sessions/resilience.py +52 -0
  422. ksadk/sessions/resilient.py +360 -0
  423. ksadk/sessions/sqlite_service.py +13 -0
  424. ksadk/skills/__init__.py +9 -0
  425. ksadk/skills/loader.py +46 -0
  426. ksadk/skills/models.py +120 -0
  427. ksadk/skills/package_store.py +104 -0
  428. ksadk/skills/runtime/__init__.py +17 -0
  429. ksadk/skills/runtime/agent.py +183 -0
  430. ksadk/skills/runtime/artifacts.py +39 -0
  431. ksadk/skills/runtime/backends/__init__.py +1 -0
  432. ksadk/skills/runtime/backends/disabled.py +8 -0
  433. ksadk/skills/runtime/backends/e2b.py +168 -0
  434. ksadk/skills/runtime/backends/local.py +95 -0
  435. ksadk/skills/runtime/base.py +103 -0
  436. ksadk/skills/runtime/executor.py +219 -0
  437. ksadk/skills/runtime/factory.py +37 -0
  438. ksadk/skills/runtime/loader.py +131 -0
  439. ksadk/skills/runtime/registry.py +150 -0
  440. ksadk/skills/runtime/request.py +67 -0
  441. ksadk/skills/service_client.py +239 -0
  442. ksadk/skills/service_env.py +58 -0
  443. ksadk/skills/tool_defs.py +221 -0
  444. ksadk/terminal_client.py +69 -0
  445. ksadk/terminal_exec_policy.py +229 -0
  446. ksadk/tools/__init__.py +17 -0
  447. ksadk/tools/gateway.py +230 -0
  448. ksadk/tools/result_budget.py +154 -0
  449. ksadk/toolsets/__init__.py +854 -0
  450. ksadk/toolsets/_langchain.py +11 -0
  451. ksadk/toolsets/platform.py +93 -0
  452. ksadk/toolsets/sandbox.py +310 -0
  453. ksadk/toolsets/skills.py +217 -0
  454. ksadk/toolsets/web.py +266 -0
  455. ksadk/toolsets/workspace.py +917 -0
  456. ksadk/toolsets/workspace_state.py +44 -0
  457. ksadk/tracing/__init__.py +37 -0
  458. ksadk/tracing/exporters/__init__.py +1 -0
  459. ksadk/tracing/exporters/inmemory_exporter.py +116 -0
  460. ksadk/tracing/exporters/langfuse_exporter.py +445 -0
  461. ksadk/tracing/setup.py +892 -0
  462. ksadk/tracing/span_utils.py +114 -0
  463. ksadk/tui/__init__.py +27 -0
  464. ksadk/tui/loop.py +1877 -0
  465. ksadk/tui/stream_render.py +68 -0
  466. ksadk/ui_config.py +168 -0
  467. ksadk/version.py +4 -0
  468. ksadk_runtime_common/__init__.py +20 -0
  469. ksadk_runtime_common/memory_backend/__init__.py +29 -0
  470. ksadk_runtime_common/memory_backend/manifest.py +79 -0
  471. ksadk_runtime_common/memory_backend/providers/__init__.py +6 -0
  472. ksadk_runtime_common/memory_backend/providers/lancedb.py +151 -0
  473. ksadk_runtime_common/memory_backend/providers/mem0.py +74 -0
  474. ksadk_runtime_common/memory_backend/registry.py +56 -0
  475. ksadk_runtime_common/memory_backend/render.py +71 -0
  476. ksadk_runtime_common/schemas/memory_backend_manifest.schema.json +148 -0
  477. ksadk_runtime_common/workspace_files/__init__.py +29 -0
  478. ksadk_runtime_common/workspace_files/bootstrap.py +56 -0
  479. ksadk_runtime_common/workspace_files/constants.py +9 -0
  480. ksadk_runtime_common/workspace_files/path_utils.py +71 -0
  481. ksadk_runtime_common/workspace_files/preview.py +96 -0
  482. ksadk_runtime_common/workspace_files/router.py +254 -0
  483. agentengine_sdk_python/__init__.py +0 -3
  484. agentengine_sdk_python-0.6.4.dist-info/METADATA +0 -48
  485. agentengine_sdk_python-0.6.4.dist-info/RECORD +0 -5
  486. agentengine_sdk_python-0.6.4.dist-info/top_level.txt +0 -1
@@ -0,0 +1,1994 @@
1
+ """
2
+ ADKRunner - Google ADK 框架运行时
3
+
4
+ 参考 adk-python 原生实现,缓存 Runner 和 SessionService。
5
+ 支持通过环境变量配置记忆体 (ShortTermMemory / LongTermMemory)。
6
+ """
7
+
8
+ import asyncio
9
+ import base64
10
+ import inspect
11
+ import logging
12
+ import os
13
+ import sys
14
+ from dataclasses import dataclass
15
+ from importlib.metadata import PackageNotFoundError
16
+ from importlib.metadata import version as _pkg_version
17
+ from pathlib import Path
18
+ from typing import Any, AsyncIterator, Dict, Mapping, Optional
19
+
20
+ from google.genai import types
21
+ from opentelemetry import trace
22
+
23
+ from ksadk.conversations.attachments import classify_attachment_kind, read_attachment_uri_bytes
24
+ from ksadk.conversations.model_context import supports_native_image_input
25
+ from ksadk.runners.base_runner import BaseRunner
26
+ from ksadk.runners.usage_accumulator import accumulate_usage
27
+ from ksadk.sessions.continuity import ADKSessionAdapter
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ tracer = trace.get_tracer(__name__)
32
+
33
+
34
+ class ADKRunner(BaseRunner):
35
+ """ADK 框架运行时"""
36
+
37
+ def __init__(self, detection_result: Any, project_dir: str):
38
+ super().__init__(detection_result, project_dir)
39
+ self._runner = None
40
+ self._session_service = None
41
+ # Map external session_ids (e.g. from run_interactive or web) to ADK internal session IDs
42
+ self._session_map: Dict[str, str] = {}
43
+ # Fallback default session
44
+ self._default_session_id: Optional[str] = None
45
+ # Memory integration
46
+ self._short_term_memory = None
47
+ self._long_term_memory = None
48
+ # Knowledge base integration
49
+ self._knowledge_base = None
50
+ # Keep runtime toolsets alive for the lifetime of the runner.
51
+ self._runtime_toolsets: list[Any] = []
52
+ # ADK resumability state
53
+ self._resumable: bool = False
54
+ self._resume_disabled_reason: Optional[str] = None
55
+ # P1.1 sub-issue: guard invocation_map read-modify-write so concurrent
56
+ # invocations on the same session don't lose each other's mappings.
57
+ self._invocation_map_lock = asyncio.Lock()
58
+ self._module = None
59
+ self._adk_resume_min_version = os.environ.get(
60
+ "GOOGLE_ADK_RESUME_MIN_VERSION", "1.16.0"
61
+ ).strip()
62
+
63
+ async def close(self) -> None:
64
+ """Close runtime toolsets owned by this runner."""
65
+ toolsets = list(self._runtime_toolsets)
66
+ self._runtime_toolsets.clear()
67
+ for toolset in toolsets:
68
+ close = getattr(toolset, "aclose", None) or getattr(toolset, "close", None)
69
+ if not callable(close):
70
+ continue
71
+ try:
72
+ result = close()
73
+ if inspect.isawaitable(result):
74
+ await result
75
+ except Exception as exc:
76
+ logger.warning("Failed to close runtime toolset %r: %s", toolset, exc)
77
+
78
+ def _apply_json_patch(self):
79
+ """Patch ADK LiteLlm to handle malformed JSON in tool-call arguments.
80
+
81
+ The previous RobustJson approach replaced the entire json module inside
82
+ lite_llm.py, which broke ADK's streaming args-completeness detection:
83
+ ADK uses ``try: json.loads(args) except json.JSONDecodeError: pass``
84
+ to decide whether accumulated streaming args form a complete JSON
85
+ object. When RobustJson.loads swallowed the exception (or even when
86
+ it re-raised after json_repair "fixed" incomplete fragments like
87
+ ``{`` into ``{}``), fallback_index incremented on every args fragment,
88
+ causing a single tool call to fragment into multiple entries with
89
+ empty names -- the "Tool '' not found" error.
90
+
91
+ The new approach is surgical: we do NOT replace the json module at
92
+ all. Instead, we patch _message_to_generate_content_response to
93
+ tolerate malformed JSON in the FINAL assembled arguments only.
94
+ The streaming args detection continues using stdlib json.loads and
95
+ gets proper JSONDecodeError for incomplete fragments.
96
+ """
97
+ try:
98
+ import inspect as _inspect
99
+ import json as _stdlib_json
100
+
101
+ import google.adk.models.lite_llm as adk_lite_llm
102
+
103
+ _original_fn = adk_lite_llm._message_to_generate_content_response
104
+ if getattr(_original_fn, "__ksadk_json_patch__", False):
105
+ return
106
+ # Determine which kwargs the original function actually accepts,
107
+ # so the patch is compatible across ADK versions that may have
108
+ # added or removed `model_version` / `thought_parts`.
109
+ _orig_params = set(
110
+ _inspect.signature(_original_fn).parameters.keys()
111
+ )
112
+
113
+ def _patched_message_to_generate_content_response(
114
+ message, *, is_partial=False, model_version=None, thought_parts=None
115
+ ):
116
+ """Wrapper that catches JSONDecodeError in final args parsing."""
117
+ # Only forward kwargs that the original function accepts,
118
+ # avoiding TypeError on older ADK versions that lack
119
+ # `model_version` or `thought_parts`.
120
+ forward_kwargs = {"is_partial": is_partial}
121
+ if "model_version" in _orig_params:
122
+ forward_kwargs["model_version"] = model_version
123
+ if "thought_parts" in _orig_params:
124
+ forward_kwargs["thought_parts"] = thought_parts
125
+ try:
126
+ result = _original_fn(message, **forward_kwargs)
127
+ except _stdlib_json.JSONDecodeError:
128
+ logger.warning(
129
+ "ADKRunner: caught JSONDecodeError in args parsing, "
130
+ "returning empty response"
131
+ )
132
+ from google.adk.models import LlmResponse
133
+ from google.genai import types as _genai_types
134
+
135
+ return LlmResponse(
136
+ content=_genai_types.Content(role="model", parts=[]),
137
+ partial=is_partial,
138
+ model_version=model_version,
139
+ )
140
+ # If the response has function_call parts with empty args,
141
+ # fill in {} as fallback (this was what RobustJson used to do,
142
+ # but now only at the final output stage, not during streaming).
143
+ # Also strip phantom function_calls whose name is empty or
144
+ # whitespace-only — these are streaming artifacts produced by
145
+ # the old ADK fallback_index mechanism when parallel tool-call
146
+ # fragments are mis-assembled.
147
+ phantom_indices = set()
148
+ if result.content and result.content.parts:
149
+ for i, part in enumerate(result.content.parts):
150
+ if part.function_call and part.function_call.args is None:
151
+ part.function_call.args = {}
152
+ if (
153
+ part.function_call
154
+ and not (part.function_call.name or "").strip()
155
+ ):
156
+ phantom_indices.add(i)
157
+ if phantom_indices:
158
+ result.content.parts = [
159
+ p for i, p in enumerate(result.content.parts)
160
+ if i not in phantom_indices
161
+ ]
162
+ return result
163
+
164
+ _patched_message_to_generate_content_response.__ksadk_json_patch__ = True
165
+ adk_lite_llm._message_to_generate_content_response = (
166
+ _patched_message_to_generate_content_response
167
+ )
168
+
169
+ # Don't patch _model_response_to_generate_content_response since
170
+ # it calls _message_to_generate_content_response internally, and
171
+ # that's already patched. Double-patching would cause recursion.
172
+
173
+ logger.info(
174
+ "ADKRunner: Applied surgical args-parsing patch "
175
+ "(stdlib json preserved, streaming detection intact)"
176
+ )
177
+
178
+ except ImportError:
179
+ pass # ADK not installed
180
+ except Exception:
181
+ pass
182
+
183
+
184
+ def _apply_mcp_result_patch(self):
185
+ """Patch ADK McpTool to convert CallToolResult to dict.
186
+
187
+ Old ADK (1.14.1) McpTool._run_async_impl returns the raw
188
+ CallToolResult Pydantic object from session.call_tool(), which
189
+ cannot be JSON-serialized when ADK builds the FunctionResponse.
190
+ New ADK (1.34.0) added response.model_dump(exclude_none=True,
191
+ mode="json") before returning. This patch replicates that fix
192
+ for the old version.
193
+ """
194
+ try:
195
+ from google.adk.tools.mcp_tool.mcp_tool import McpTool
196
+
197
+ _original_run_async = McpTool._run_async_impl
198
+ if getattr(_original_run_async, "__ksadk_mcp_result_patch__", False):
199
+ return
200
+
201
+ async def _patched_run_async_impl(
202
+ self, *, args, tool_context, credential
203
+ ):
204
+ response = await _original_run_async(
205
+ self, args=args, tool_context=tool_context,
206
+ credential=credential,
207
+ )
208
+ # If response is a Pydantic model (e.g. CallToolResult),
209
+ # convert to dict so it can be JSON-serialized downstream.
210
+ if hasattr(response, "model_dump"):
211
+ return response.model_dump(exclude_none=True, mode="json")
212
+ return response
213
+
214
+ _patched_run_async_impl.__ksadk_mcp_result_patch__ = True
215
+ McpTool._run_async_impl = _patched_run_async_impl
216
+
217
+ logger.info(
218
+ "ADKRunner: Applied MCP result serialization patch "
219
+ "(CallToolResult -> dict via model_dump)"
220
+ )
221
+
222
+ except ImportError:
223
+ pass # ADK or MCP not installed
224
+ except Exception as exc:
225
+ logger.debug("ADKRunner: MCP result patch failed: %s", exc)
226
+
227
+
228
+ def _init_short_term_memory(self):
229
+ """从环境变量初始化短期记忆
230
+
231
+ 环境变量:
232
+ KSADK_ADK_SESSION_BACKEND / PATH / URL: ADK 专用 session 配置
233
+ KSADK_STM_BACKEND / PATH / URL: 旧平台级 STM 配置
234
+ KSADK_SESSION_BACKEND / DSN: 统一 session 配置 fallback
235
+ """
236
+ configured_names = (
237
+ "KSADK_ADK_SESSION_BACKEND",
238
+ "KSADK_ADK_SESSION_PATH",
239
+ "KSADK_ADK_SESSION_URL",
240
+ "KSADK_STM_BACKEND",
241
+ "KSADK_STM_PATH",
242
+ "KSADK_STM_URL",
243
+ "KSADK_STM_DB_PATH",
244
+ "KSADK_STM_DB_URL",
245
+ "KSADK_SESSION_BACKEND",
246
+ "KSADK_SESSION_DSN",
247
+ )
248
+ if not any(str(os.environ.get(name, "")).strip() for name in configured_names):
249
+ return None
250
+
251
+ try:
252
+ from ksadk.memory.adk import ShortTermMemory
253
+
254
+ stm = ShortTermMemory.from_env()
255
+ logger.info(
256
+ "ShortTermMemory initialized: backend=%s path=%s",
257
+ stm.backend,
258
+ stm.local_database_path,
259
+ )
260
+ return stm
261
+ except Exception as e:
262
+ logger.warning(f"Failed to init ShortTermMemory: {e}. Using default.")
263
+ return None
264
+
265
+ def get_session_adapter(self):
266
+ return ADKSessionAdapter()
267
+
268
+ def describe_checkpoint_capability(self) -> dict[str, Any]:
269
+ resumable = getattr(self, "_resumable", False)
270
+ stm_backend = (
271
+ getattr(self._short_term_memory, "backend", None)
272
+ if self._short_term_memory else None
273
+ )
274
+ if resumable:
275
+ backend = "adk_invocation"
276
+ if stm_backend == "sqlite":
277
+ backend = "adk_invocation+sqlite"
278
+ elif stm_backend == "database":
279
+ backend = "adk_invocation+postgres"
280
+ shared_across_pods = stm_backend == "database"
281
+ return {
282
+ "Supported": shared_across_pods,
283
+ "Backend": backend,
284
+ "Scope": "invocation",
285
+ "Durable": stm_backend is not None and stm_backend != "local",
286
+ "SharedAcrossPods": shared_across_pods,
287
+ "LocalOnly": not shared_across_pods,
288
+ "ResumeMode": "invocation_id",
289
+ "Reason": (
290
+ "ADK ResumabilityConfig and shared database session backend enabled; "
291
+ "resume via invocation_id"
292
+ if shared_across_pods
293
+ else "ADK ResumabilityConfig enabled, but the session backend is "
294
+ "process-local or SQLite and cannot support cross-pod recovery"
295
+ ),
296
+ }
297
+ return {
298
+ "Supported": False,
299
+ "Backend": "none",
300
+ "Scope": "unknown",
301
+ "Durable": False,
302
+ "SharedAcrossPods": False,
303
+ "ResumeMode": "forward_only",
304
+ "Reason": (
305
+ self._resume_disabled_reason
306
+ or "ADK ResumabilityConfig not enabled; set "
307
+ "KSADK_ADK_RESUMABLE=1 or configure App with resumability_config"
308
+ ),
309
+ }
310
+
311
+ def get_runtime_capabilities(self) -> dict[str, Any]:
312
+ capabilities = super().get_runtime_capabilities()
313
+ resumable = getattr(self, "_resumable", False)
314
+ stm_backend = (
315
+ getattr(self._short_term_memory, "backend", None)
316
+ if self._short_term_memory else None
317
+ )
318
+ is_durable = stm_backend is not None and stm_backend != "local"
319
+ if resumable:
320
+ # P1.3: Level must degrade with backend — in_memory session state
321
+ # cannot survive pod restarts, so "runtime" is misleading.
322
+ level = "runtime" if is_durable else "semantic"
323
+ capabilities["SessionContinuity"] = {
324
+ "Supported": True,
325
+ "Type": "adk_invocation",
326
+ "Level": level,
327
+ "Reason": (
328
+ "ADK ResumabilityConfig enabled with durable session backend, "
329
+ "invocation_id-based checkpoint resume available"
330
+ if is_durable
331
+ else "ADK ResumabilityConfig enabled but session state is in-memory; "
332
+ "resume only works within the same process lifetime"
333
+ ),
334
+ }
335
+ else:
336
+ has_native_session = bool(getattr(self, "_short_term_memory", None)) or any(
337
+ str(os.getenv(name) or "").strip()
338
+ for name in (
339
+ "KSADK_ADK_SESSION_BACKEND",
340
+ "KSADK_ADK_SESSION_PATH",
341
+ "KSADK_ADK_SESSION_URL",
342
+ "KSADK_STM_BACKEND",
343
+ "KSADK_STM_PATH",
344
+ "KSADK_STM_URL",
345
+ "KSADK_STM_DB_PATH",
346
+ "KSADK_STM_DB_URL",
347
+ "KSADK_SESSION_BACKEND",
348
+ "KSADK_SESSION_DSN",
349
+ )
350
+ )
351
+ capabilities["SessionContinuity"] = {
352
+ "Supported": True,
353
+ "Type": "native_session" if has_native_session else "semantic_replay",
354
+ "Level": "semantic",
355
+ "Reason": "ADK native session can continue conversation context"
356
+ if has_native_session
357
+ else "conversation transcript can be replayed",
358
+ }
359
+ capabilities["ResumeRun"]["Reason"] = capabilities["Checkpoint"]["Reason"]
360
+ return capabilities
361
+
362
+ @dataclass
363
+ class _ResolvabilityResult:
364
+ enabled: bool
365
+ source: str # "agent_module" | "env" | "auto_persistent_session" | "default"
366
+ app: Any # User module exported App object (if any)
367
+
368
+ def _resolve_resumability(self) -> "_ResolvabilityResult":
369
+ """从环境变量或 agent 模块推断是否启用 ADK 可恢复性。"""
370
+ # Priority 1: agent module exports app object with resumability_config
371
+ module = getattr(self, "_module", None)
372
+ if module is not None:
373
+ try:
374
+ from google.adk.apps import App
375
+ for attr_name in ("app", "application"):
376
+ candidate = getattr(module, attr_name, None)
377
+ if isinstance(candidate, App) and candidate.resumability_config:
378
+ if candidate.resumability_config.is_resumable:
379
+ return self._ResolvabilityResult(
380
+ enabled=True, source="agent_module", app=candidate)
381
+ except ImportError:
382
+ pass
383
+
384
+ # Priority 2: environment variable explicit control
385
+ env_val = os.environ.get("KSADK_ADK_RESUMABLE", "").strip().lower()
386
+ if env_val in ("1", "true", "yes"):
387
+ return self._ResolvabilityResult(enabled=True, source="env", app=None)
388
+
389
+ # Priority 3: persistent session backend auto-enable
390
+ stm_backend = (
391
+ getattr(self._short_term_memory, "backend", None)
392
+ if self._short_term_memory else None
393
+ )
394
+ if stm_backend and stm_backend != "local":
395
+ return self._ResolvabilityResult(
396
+ enabled=True, source="auto_persistent_session", app=None)
397
+
398
+ return self._ResolvabilityResult(enabled=False, source="default", app=None)
399
+
400
+ @staticmethod
401
+ def _get_adk_version() -> Optional[str]:
402
+ """返回当前安装的 google-adk 版本号,无法获取时返回 None。"""
403
+ try:
404
+ return _pkg_version("google-adk")
405
+ except PackageNotFoundError:
406
+ return None
407
+
408
+ def _check_adk_resume_compatibility(self) -> tuple[bool, str]:
409
+ """检查当前 google-adk 版本是否支持恢复 (invocation_id)。
410
+
411
+ Returns:
412
+ (compatible, reason) — compatible=True 表示版本足够;reason 为不兼容时的说明文本。
413
+ """
414
+ adk_ver = self._get_adk_version()
415
+ if adk_ver is None:
416
+ # 无法获取版本信息,保守地认为不兼容
417
+ return False, "google-adk version unknown, cannot guarantee invocation_id support"
418
+ try:
419
+ from packaging.version import Version
420
+ if Version(adk_ver) < Version(self._adk_resume_min_version):
421
+ return (
422
+ False,
423
+ f"google-adk {adk_ver} < {self._adk_resume_min_version}, "
424
+ f"run_async() does not accept invocation_id",
425
+ )
426
+ except Exception:
427
+ # packaging 不可用时,保守地认为不兼容
428
+ return False, "cannot compare google-adk version, assuming incompatible"
429
+ return True, ""
430
+
431
+ def _build_runner(self) -> None:
432
+ """构造 ADK Runner,优先使用 App 对象以启用 ResumabilityConfig。"""
433
+ from google.adk.runners import Runner
434
+
435
+ resumable = self._resolve_resumability()
436
+ resumability_enabled = resumable.enabled
437
+
438
+ # 版本兼容性检查:低于最低版本时强制关闭恢复
439
+ resume_compatible, resume_reason = self._check_adk_resume_compatibility()
440
+ if not resume_compatible and resumable.enabled:
441
+ logger.warning(
442
+ "ADKRunner: resumability disabled — %s", resume_reason
443
+ )
444
+ resumable = self._ResolvabilityResult(enabled=False, source="version_check", app=None)
445
+ resumability_enabled = False
446
+ self._resume_disabled_reason = resume_reason
447
+
448
+ if resumable.app is not None:
449
+ runner_kwargs = dict(
450
+ app=resumable.app,
451
+ session_service=self._session_service,
452
+ )
453
+ elif resumable.enabled:
454
+ try:
455
+ from google.adk.apps import App, ResumabilityConfig
456
+ app = App(
457
+ name=self._agent.name,
458
+ root_agent=self._agent,
459
+ resumability_config=ResumabilityConfig(is_resumable=True),
460
+ )
461
+ runner_kwargs = dict(
462
+ app=app,
463
+ session_service=self._session_service,
464
+ )
465
+ except ImportError:
466
+ logger.warning(
467
+ "ADK ResumabilityConfig not available (requires google-adk >= 1.14.0); "
468
+ "falling back to non-resumable Runner"
469
+ )
470
+ runner_kwargs = dict(
471
+ agent=self._agent,
472
+ session_service=self._session_service,
473
+ app_name=self._agent.name,
474
+ )
475
+ resumability_enabled = False
476
+ self._resume_disabled_reason = "ADK ResumabilityConfig is unavailable"
477
+ else:
478
+ runner_kwargs = dict(
479
+ agent=self._agent,
480
+ session_service=self._session_service,
481
+ app_name=self._agent.name,
482
+ )
483
+
484
+ if self._long_term_memory:
485
+ runner_kwargs["memory_service"] = self._long_term_memory
486
+ logger.info("ADKRunner: LongTermMemory injected as memory_service")
487
+
488
+ self._runner = Runner(**runner_kwargs)
489
+ self._resumable = resumability_enabled
490
+
491
+ if resumability_enabled:
492
+ stm_backend = (
493
+ getattr(self._short_term_memory, "backend", None)
494
+ if self._short_term_memory else None
495
+ )
496
+ logger.info(
497
+ "ADKRunner: resumability enabled (source=%s, backend=%s)",
498
+ resumable.source,
499
+ stm_backend or "in_memory",
500
+ )
501
+
502
+ def _init_long_term_memory(self):
503
+ """从环境变量初始化长期记忆
504
+
505
+ 环境变量:
506
+ KSADK_LTM_BACKEND: local / http / sdk
507
+ KSADK_LTM_HTTP_URL: HTTP 记忆服务地址
508
+ KSADK_LTM_HTTP_TOKEN: 认证 Token
509
+ KSADK_LTM_ACCESS_KEY: SDK AK (fallback to KSYUN_ACCESS_KEY)
510
+ KSADK_LTM_SECRET_KEY: SDK SK (fallback to KSYUN_SECRET_KEY)
511
+ KSADK_LTM_TOP_K: 检索数量
512
+ """
513
+ backend = os.environ.get("KSADK_LTM_BACKEND", "")
514
+ if not backend:
515
+ return None
516
+
517
+ try:
518
+ from ksadk.memory.adk import LongTermMemory
519
+
520
+ agent_name = self._agent.name if self._agent else "default"
521
+ ltm = LongTermMemory.from_env(app_name=agent_name)
522
+ logger.info(
523
+ f"LongTermMemory initialized: backend={backend}, "
524
+ f"app_name={agent_name}"
525
+ )
526
+ return ltm
527
+ except Exception as e:
528
+ logger.warning(f"Failed to init LongTermMemory: {e}.")
529
+ return None
530
+
531
+ def _init_knowledge_base(self):
532
+ """从环境变量初始化知识库
533
+
534
+ 环境变量:
535
+ KSADK_KB_DATASET_ID: 知识库 ID (必填,存在即启用)
536
+ KSADK_KB_ACCESS_KEY: AK (可选)
537
+ KSADK_KB_SECRET_KEY: SK (可选)
538
+ KSADK_KB_REGION: 区域 (默认 cn-beijing-6)
539
+ KSADK_KB_TOP_K: 返回结果数 (默认 5)
540
+ """
541
+ try:
542
+ from ksadk.knowledge_base.client import KnowledgeBaseClient
543
+
544
+ if not KnowledgeBaseClient.is_configured():
545
+ return None
546
+
547
+ kb = KnowledgeBaseClient.from_env()
548
+ logger.info(
549
+ f"KnowledgeBase initialized: dataset_id={kb.dataset_id}, "
550
+ f"region={kb.region}"
551
+ )
552
+ return kb
553
+ except ImportError:
554
+ logger.warning(
555
+ "kingsoftcloud-sdk-python not installed, "
556
+ "knowledge base disabled. "
557
+ "Install with: pip install kingsoftcloud-sdk-python"
558
+ )
559
+ return None
560
+ except Exception as e:
561
+ logger.warning(f"Failed to init KnowledgeBase: {e}.")
562
+ return None
563
+
564
+ def _inject_search_knowledge_tool(self):
565
+ """自动注入 search_knowledge_base 工具到 Agent"""
566
+ try:
567
+ from ksadk.knowledge_base.adk_tool import search_knowledge_base
568
+
569
+ added = self._append_tools_by_name([search_knowledge_base])
570
+ if added:
571
+ logger.info(
572
+ "Injected 'search_knowledge_base' tool into agent "
573
+ f"(total tools: {len(self._agent.tools)})"
574
+ )
575
+ else:
576
+ logger.debug("Agent already has 'search_knowledge_base' tool")
577
+ except ImportError as e:
578
+ logger.warning(f"Failed to import knowledge base tool: {e}")
579
+ except Exception as e:
580
+ logger.warning(f"Failed to inject search_knowledge_base tool: {e}")
581
+
582
+ def _inject_load_memory_tool(self):
583
+ """自动注入 load_memory 工具到 Agent"""
584
+ try:
585
+ from google.adk.tools import load_memory
586
+
587
+ added = self._append_tools_by_name([load_memory])
588
+ if added:
589
+ logger.info(
590
+ "Injected 'load_memory' tool into agent "
591
+ f"(total tools: {len(self._agent.tools)})"
592
+ )
593
+ else:
594
+ logger.debug("Agent already has 'load_memory' tool")
595
+ except ImportError:
596
+ logger.warning(
597
+ "google.adk.tools.load_memory not available. "
598
+ "Ensure google-adk >= 1.0.0 is installed."
599
+ )
600
+ except Exception as e:
601
+ logger.warning(f"Failed to inject load_memory tool: {e}")
602
+
603
+ def _inject_save_memory_tool(self):
604
+ """自动注入 save_memory 工具到 Agent"""
605
+ try:
606
+ from ksadk.memory.adk_tool import create_adk_tool
607
+
608
+ save_memory_tool = create_adk_tool()
609
+ added = self._append_tools_by_name([save_memory_tool])
610
+ if added:
611
+ logger.info(
612
+ "Injected 'save_memory' tool into agent "
613
+ f"(total tools: {len(self._agent.tools)})"
614
+ )
615
+ else:
616
+ logger.debug("Agent already has 'save_memory' tool")
617
+ except ImportError as e:
618
+ logger.warning(f"Failed to import save_memory tool: {e}")
619
+ except Exception as e:
620
+ logger.warning(f"Failed to inject save_memory tool: {e}")
621
+
622
+ def _inject_skill_runtime_tools(self):
623
+ """Inject Skill Runtime tools when skills are configured for sandbox mode."""
624
+ mode = self._resolve_skills_mode()
625
+ if mode == "local":
626
+ self._inject_local_skill_tools()
627
+ return
628
+ if mode != "sandbox":
629
+ return
630
+
631
+ try:
632
+ from ksadk.skills.runtime import create_skill_runtime_backend
633
+ from ksadk.skills.tool_defs import (
634
+ build_execute_skills_tool,
635
+ build_skill_manifest_instruction,
636
+ load_remote_skill_manifests,
637
+ resolve_skill_space_ids,
638
+ resolve_user_skill_space_ids,
639
+ )
640
+
641
+ skill_space_ids = resolve_skill_space_ids()
642
+ backend = create_skill_runtime_backend()
643
+ execute_skills = build_execute_skills_tool(
644
+ backend=backend,
645
+ skill_space_ids=resolve_user_skill_space_ids(),
646
+ session_id=getattr(self._agent, "name", None) or self.detection_result.name,
647
+ )
648
+ added = self._append_tools_by_name([execute_skills])
649
+ try:
650
+ manifest_instruction = build_skill_manifest_instruction(
651
+ load_remote_skill_manifests(skill_space_ids)
652
+ )
653
+ if manifest_instruction:
654
+ self._append_agent_instruction(manifest_instruction)
655
+ except Exception as exc:
656
+ logger.warning("Failed to inject remote Skill manifest: %s", exc)
657
+ if added:
658
+ logger.info("Injected Skill Runtime tools into agent (added: %s)", ", ".join(added))
659
+ else:
660
+ logger.debug("Skill Runtime tools already present")
661
+ except Exception as exc:
662
+ logger.warning("Failed to inject Skill Runtime tools: %s", exc)
663
+
664
+ def _append_agent_instruction(self, extra_instruction: str) -> None:
665
+ if not extra_instruction or not hasattr(self._agent, "instruction"):
666
+ return
667
+ current = str(getattr(self._agent, "instruction") or "")
668
+ if extra_instruction in current:
669
+ return
670
+ self._agent.instruction = f"{current.rstrip()}\n{extra_instruction}".strip()
671
+
672
+ def _inject_local_skill_tools(self):
673
+ try:
674
+ from ksadk.skills.loader import load_local_skill
675
+ from ksadk.skills.tool_defs import build_skills_tool
676
+
677
+ skills_dir = Path(
678
+ os.environ.get("KSADK_LOCAL_SKILLS_DIR")
679
+ or os.environ.get("KSADK_SKILL_CACHE_DIR")
680
+ or Path(self.project_dir) / "skills"
681
+ )
682
+ if not skills_dir.exists():
683
+ logger.info("ADKRunner: local skills directory does not exist: %s", skills_dir)
684
+ return
685
+ skills = [
686
+ load_local_skill(path)
687
+ for path in sorted(skills_dir.iterdir())
688
+ if path.is_dir() and (path / "SKILL.md").exists()
689
+ ]
690
+ if not skills:
691
+ return
692
+ tool = build_skills_tool(skills)
693
+ added = self._append_tools_by_name([tool])
694
+ if added:
695
+ logger.info("Injected local Skill tools into agent (added: %s)", ", ".join(added))
696
+ except Exception as exc:
697
+ logger.warning("Failed to inject local Skill tools: %s", exc)
698
+
699
+ def _resolve_skills_mode(self) -> str:
700
+ mode = os.environ.get("KSADK_SKILLS_MODE", "auto").strip().lower()
701
+ if mode != "auto":
702
+ return mode
703
+ runtime_backend = os.environ.get("KSADK_SKILL_RUNTIME_BACKEND")
704
+ if runtime_backend is not None:
705
+ backend = runtime_backend.strip().lower()
706
+ return "sandbox" if backend and backend not in {"disabled", "none", "off"} else "auto"
707
+
708
+ backend = (os.environ.get("KSADK_SANDBOX_BACKEND") or "").strip().lower()
709
+ if backend and backend not in {"disabled", "none", "off"}:
710
+ return "sandbox"
711
+ if os.environ.get("KSADK_SANDBOX_TEMPLATE_ID") or os.environ.get(
712
+ "KSADK_SKILL_RUNTIME_TEMPLATE_ID"
713
+ ):
714
+ return "sandbox"
715
+ skills_dir = Path(
716
+ os.environ.get("KSADK_LOCAL_SKILLS_DIR")
717
+ or os.environ.get("KSADK_SKILL_CACHE_DIR")
718
+ or Path(self.project_dir) / "skills"
719
+ )
720
+ if self._has_local_skills(skills_dir):
721
+ return "local"
722
+ return "auto"
723
+
724
+ @staticmethod
725
+ def _has_local_skills(skills_dir: Path) -> bool:
726
+ if not skills_dir.exists():
727
+ return False
728
+ return any(path.is_dir() and (path / "SKILL.md").exists() for path in skills_dir.iterdir())
729
+
730
+ def _inject_mcp_toolsets(self):
731
+ """默认注入远端 MCP toolset。"""
732
+ try:
733
+ from ksadk.mcp_runtime import (
734
+ MCP_TOOLSET_KEY_ATTR,
735
+ load_mcp_toolsets_from_env,
736
+ mcp_tools_enabled,
737
+ register_mcp_toolset_descriptors,
738
+ )
739
+
740
+ if not mcp_tools_enabled():
741
+ logger.info("ADKRunner: MCP tools disabled via KSADK_ENABLE_MCP_TOOLS=0")
742
+ return
743
+
744
+ toolsets = load_mcp_toolsets_from_env()
745
+ if not toolsets:
746
+ return
747
+
748
+ added = self._append_toolsets_by_key(
749
+ toolsets,
750
+ key_attr=MCP_TOOLSET_KEY_ATTR,
751
+ )
752
+ if not added:
753
+ logger.debug("ADKRunner: MCP toolsets already present")
754
+ return
755
+
756
+ for toolset in toolsets:
757
+ key = getattr(toolset, MCP_TOOLSET_KEY_ATTR, None)
758
+ if key in added:
759
+ self._runtime_toolsets.append(toolset)
760
+ register_mcp_toolset_descriptors(toolset)
761
+ logger.info("Injected MCP toolsets into agent (added: %s)", ", ".join(added))
762
+ except ImportError as exc:
763
+ logger.warning(f"Failed to import MCP runtime helpers: {exc}")
764
+ except Exception as exc:
765
+ logger.warning(f"Failed to inject MCP toolsets: {exc}")
766
+
767
+ def _inject_builtin_tools(self):
768
+ """Inject ksadk built-in tools according to the runtime profile."""
769
+ try:
770
+ from ksadk.toolsets import builtin_tools_for_runtime, builtin_tools_mode
771
+
772
+ mode = builtin_tools_mode(default="off")
773
+ if mode == "off":
774
+ return
775
+ tools = builtin_tools_for_runtime(mode=mode)
776
+ if not tools:
777
+ return
778
+ added = self._append_tools_by_name(tools)
779
+ if added:
780
+ logger.info(
781
+ "Injected ksadk built-in tools into agent (added: %s)",
782
+ ", ".join(added),
783
+ )
784
+ else:
785
+ logger.debug("ksadk built-in tools already present")
786
+ except Exception as exc:
787
+ logger.warning("Failed to inject ksadk built-in tools: %s", exc)
788
+
789
+ def inject_deferred_tools_for_request(
790
+ self, tool_names: list[str] | tuple[str, ...]
791
+ ) -> list[str]:
792
+ """Append direct built-in tools selected by deferred tool search."""
793
+ names = [str(name or "").strip() for name in tool_names or [] if str(name or "").strip()]
794
+ if not names:
795
+ return []
796
+ try:
797
+ from ksadk.toolsets import get_agentengine_tools
798
+
799
+ tools = get_agentengine_tools(include=names, profile="coding", mode="direct")
800
+ added = self._append_tools_by_name(tools)
801
+ if added:
802
+ logger.info(
803
+ "Injected deferred ksadk tools for request (added: %s)",
804
+ ", ".join(added),
805
+ )
806
+ return added
807
+ except Exception as exc:
808
+ logger.warning("Failed to inject deferred ksadk tools for request: %s", exc)
809
+ return []
810
+
811
+ @staticmethod
812
+ def _tool_name(tool: Any) -> str:
813
+ return getattr(tool, "name", None) or getattr(tool, "__name__", "")
814
+
815
+ def _append_tools_by_name(self, tools: list[Any]) -> list[str]:
816
+ if not hasattr(self._agent, "tools"):
817
+ logger.warning("Agent has no 'tools' attribute, cannot inject runtime tools")
818
+ return []
819
+
820
+ if self._agent.tools is None:
821
+ self._agent.tools = []
822
+
823
+ existing_names = {
824
+ self._tool_name(tool)
825
+ for tool in self._agent.tools
826
+ if self._tool_name(tool)
827
+ }
828
+ added_names: list[str] = []
829
+ for tool in tools:
830
+ tool_name = self._tool_name(tool)
831
+ if tool_name and tool_name in existing_names:
832
+ continue
833
+ self._agent.tools.append(tool)
834
+ if tool_name:
835
+ existing_names.add(tool_name)
836
+ added_names.append(tool_name)
837
+ return added_names
838
+
839
+ def _append_toolsets_by_key(self, toolsets: list[Any], *, key_attr: str) -> list[str]:
840
+ if not hasattr(self._agent, "tools"):
841
+ logger.warning("Agent has no 'tools' attribute, cannot inject MCP toolsets")
842
+ return []
843
+
844
+ if self._agent.tools is None:
845
+ self._agent.tools = []
846
+
847
+ existing_keys = {
848
+ getattr(tool, key_attr)
849
+ for tool in self._agent.tools
850
+ if getattr(tool, key_attr, None)
851
+ }
852
+ added_keys: list[str] = []
853
+ for toolset in toolsets:
854
+ key = getattr(toolset, key_attr, None)
855
+ if key and key in existing_keys:
856
+ continue
857
+ self._agent.tools.append(toolset)
858
+ if key:
859
+ existing_keys.add(key)
860
+ added_keys.append(key)
861
+ return added_keys
862
+
863
+ def _invalid_agent_name_load_error(self, exc: Exception) -> ValueError | None:
864
+ """Convert ADK's import-time agent-name validation into an actionable error."""
865
+ errors = getattr(exc, "errors", None)
866
+ if not callable(errors):
867
+ return None
868
+ try:
869
+ details = errors()
870
+ except Exception:
871
+ return None
872
+ if not isinstance(details, list):
873
+ return None
874
+ for detail in details:
875
+ if not isinstance(detail, Mapping):
876
+ continue
877
+ location = detail.get("loc")
878
+ message = str(detail.get("msg") or "")
879
+ name = detail.get("input")
880
+ if location not in (("name",), ["name"]) or not isinstance(name, str):
881
+ continue
882
+ if "valid identifier" not in message.lower():
883
+ continue
884
+ safe_name = "".join(
885
+ char if char.isascii() and (char.isalnum() or char == "_") else "_"
886
+ for char in name
887
+ )
888
+ if not safe_name or safe_name[0].isdigit():
889
+ safe_name = f"agent_{safe_name}"
890
+ return ValueError(
891
+ "ADK Agent 名称不合法: "
892
+ f"{name!r}。Google ADK 要求 `Agent(name=...)` 以英文字母或下划线开头,"
893
+ "且仅包含英文字母、数字和下划线。"
894
+ f"请在 {self.detection_result.entry_point} 中创建 "
895
+ f"{self.detection_result.agent_variable} 时改为,例如 {safe_name!r}。"
896
+ )
897
+ return None
898
+
899
+ def load_agent(self) -> None:
900
+ """加载 ADK Agent"""
901
+ import warnings
902
+
903
+ warnings.filterwarnings("ignore", category=UserWarning, module="pydantic.main")
904
+
905
+ self._apply_json_patch()
906
+ self._apply_mcp_result_patch()
907
+
908
+ # 添加项目目录到 Python 路径
909
+ project_path = Path(self.project_dir).resolve()
910
+ if str(project_path) not in sys.path:
911
+ sys.path.insert(0, str(project_path))
912
+
913
+ # 确定模块名: 从 entry_point 获取
914
+ # (e.g. "smart_assistant_adk/agent.py" -> "smart_assistant_adk.agent")
915
+ entry_point = self.detection_result.entry_point
916
+ if entry_point.endswith(".py"):
917
+ module_name = entry_point[:-3] # 移除 .py 后缀
918
+ else:
919
+ module_name = entry_point
920
+
921
+ # 转换路径为模块路径 (e.g., "subdir/agent" -> "subdir.agent")
922
+ module_name = module_name.replace("/", ".").replace("\\", ".")
923
+
924
+ try:
925
+ module = __import__(module_name, fromlist=[self.detection_result.agent_variable])
926
+ self._module = module
927
+ self._agent = getattr(module, self.detection_result.agent_variable)
928
+
929
+ # Inject safety instruction for DeepSeek/LLMs to prevent empty tool names
930
+ if hasattr(self._agent, "instruction"):
931
+ safety_prompt = "\nIMPORTANT: Do NOT output tool calls with empty names."
932
+ if self._agent.instruction:
933
+ self._agent.instruction += safety_prompt
934
+ else:
935
+ self._agent.instruction = safety_prompt
936
+
937
+ except ImportError as e:
938
+ raise ImportError(f"无法导入模块 {module_name}: {e}")
939
+ except AttributeError:
940
+ raise AttributeError(
941
+ f"模块 {module_name} 中未找到 {self.detection_result.agent_variable}"
942
+ )
943
+ except Exception as exc:
944
+ name_error = self._invalid_agent_name_load_error(exc)
945
+ if name_error is not None:
946
+ raise name_error from exc
947
+ raise
948
+
949
+ # 验证是否为 ADK Agent
950
+ if not hasattr(self._agent, "name"):
951
+ raise TypeError("加载的对象不是有效的 ADK Agent")
952
+
953
+ # 初始化记忆体 (从环境变量读取配置)
954
+ self._short_term_memory = self._init_short_term_memory()
955
+ self._long_term_memory = self._init_long_term_memory()
956
+
957
+ # 初始化知识库 (从环境变量读取配置)
958
+ self._knowledge_base = self._init_knowledge_base()
959
+ if self._knowledge_base:
960
+ self._inject_search_knowledge_tool()
961
+
962
+ # 初始化 SessionService
963
+ from google.adk.sessions import InMemorySessionService
964
+
965
+ if self._short_term_memory:
966
+ self._session_service = self._short_term_memory.session_service
967
+ logger.info("ADKRunner: using ShortTermMemory session service")
968
+ else:
969
+ self._session_service = InMemorySessionService()
970
+
971
+ # 如果配置了长期记忆,自动注入 load_memory 工具到 agent
972
+ if self._long_term_memory:
973
+ self._inject_load_memory_tool()
974
+ self._inject_save_memory_tool()
975
+
976
+ self._inject_skill_runtime_tools()
977
+ self._inject_builtin_tools()
978
+ self._inject_mcp_toolsets()
979
+
980
+ # 初始化 Runner (使用 _build_runner 以支持 ResumabilityConfig)
981
+ self._build_runner()
982
+ self._default_model_name = self.normalize_requested_model(
983
+ os.getenv("OPENAI_MODEL_NAME") or os.getenv("MODEL_NAME")
984
+ )
985
+ self._default_model_reference = self._discover_model_reference(self._agent)
986
+ self._active_model_name = (
987
+ self._default_model_reference
988
+ or self._default_model_name
989
+ )
990
+
991
+ def _discover_model_reference(self, agent: Any) -> Optional[str]:
992
+ visited: set[int] = set()
993
+
994
+ def _visit(node: Any) -> Optional[str]:
995
+ if node is None:
996
+ return None
997
+ node_id = id(node)
998
+ if node_id in visited:
999
+ return None
1000
+ visited.add(node_id)
1001
+
1002
+ current_model = getattr(node, "model", None)
1003
+ if hasattr(current_model, "model"):
1004
+ candidate = str(getattr(current_model, "model", None) or "").strip()
1005
+ if candidate:
1006
+ return candidate
1007
+ elif isinstance(current_model, str):
1008
+ candidate = current_model.strip()
1009
+ if candidate:
1010
+ return candidate
1011
+
1012
+ for child in getattr(node, "sub_agents", []) or []:
1013
+ discovered = _visit(child)
1014
+ if discovered:
1015
+ return discovered
1016
+ return None
1017
+
1018
+ return _visit(agent)
1019
+
1020
+ @staticmethod
1021
+ def _resolve_model_reference(existing_model: Any, requested_model: str) -> str:
1022
+ existing = str(existing_model or "").strip()
1023
+ requested = requested_model.strip()
1024
+ if "/" in requested:
1025
+ return requested
1026
+ if "/" in existing:
1027
+ provider_prefix = existing.split("/", 1)[0]
1028
+ return f"{provider_prefix}/{requested}"
1029
+ return requested
1030
+
1031
+ def _apply_model_to_agent_tree(self, agent: Any, requested_model: str) -> None:
1032
+ visited: set[int] = set()
1033
+
1034
+ def _visit(node: Any) -> None:
1035
+ if node is None:
1036
+ return
1037
+ node_id = id(node)
1038
+ if node_id in visited:
1039
+ return
1040
+ visited.add(node_id)
1041
+
1042
+ current_model = getattr(node, "model", None)
1043
+ if hasattr(current_model, "model"):
1044
+ current_reference = getattr(current_model, "model", None)
1045
+ next_reference = self._resolve_model_reference(current_reference, requested_model)
1046
+ if current_reference != next_reference:
1047
+ setattr(current_model, "model", next_reference)
1048
+ elif isinstance(current_model, str):
1049
+ next_reference = self._resolve_model_reference(current_model, requested_model)
1050
+ if current_model != next_reference:
1051
+ setattr(node, "model", next_reference)
1052
+
1053
+ for child in getattr(node, "sub_agents", []) or []:
1054
+ _visit(child)
1055
+
1056
+ _visit(agent)
1057
+
1058
+ def prepare_for_request(self, model: str | None) -> None:
1059
+ normalized = self.sync_process_model_env(model)
1060
+ if normalized is None:
1061
+ default_model_name = (
1062
+ getattr(self, "_default_model_name", None)
1063
+ or self.normalize_requested_model(
1064
+ os.getenv("OPENAI_MODEL_NAME") or os.getenv("MODEL_NAME")
1065
+ )
1066
+ )
1067
+ if default_model_name:
1068
+ self.sync_process_model_env(default_model_name)
1069
+ target_reference = (
1070
+ getattr(self, "_default_model_reference", None)
1071
+ or default_model_name
1072
+ )
1073
+ if target_reference and self._agent is not None:
1074
+ current_reference = self._discover_model_reference(self._agent)
1075
+ if current_reference != target_reference:
1076
+ self._apply_model_to_agent_tree(self._agent, target_reference)
1077
+ self._active_model_name = target_reference
1078
+ return
1079
+ target_reference = (
1080
+ self._resolve_model_reference(
1081
+ self._discover_model_reference(self._agent)
1082
+ or getattr(self, "_default_model_reference", None)
1083
+ or normalized,
1084
+ normalized,
1085
+ )
1086
+ if self._agent is not None
1087
+ else normalized
1088
+ )
1089
+ if target_reference == getattr(self, "_active_model_name", None):
1090
+ return
1091
+ if self._agent is not None:
1092
+ self._apply_model_to_agent_tree(self._agent, normalized)
1093
+ discovered = self._discover_model_reference(self._agent)
1094
+ self._active_model_name = discovered or target_reference
1095
+ return
1096
+ self._active_model_name = target_reference
1097
+
1098
+ def _prepare_trace_metadata(self, session_id: str):
1099
+ """准备 Trace 元数据 (Tags, UserID, etc.)"""
1100
+ from ksadk.tracing.span_utils import prepare_trace_metadata
1101
+ return prepare_trace_metadata(
1102
+ detection_result=getattr(self, "detection_result", None)
1103
+ )
1104
+
1105
+ async def _ensure_session(self, external_session_id: str = None) -> str:
1106
+ """Get or create ADK session ID based on external ID
1107
+
1108
+ When ShortTermMemory is configured, uses its create_session method
1109
+ which supports session retrieval (if session_id already exists).
1110
+ """
1111
+ # Case 1: External ID provided
1112
+ if external_session_id:
1113
+ if external_session_id in self._session_map:
1114
+ return self._session_map[external_session_id]
1115
+
1116
+ # Create new ADK session and map it
1117
+ if self._short_term_memory:
1118
+ session = await self._short_term_memory.create_session(
1119
+ app_name=self._agent.name,
1120
+ user_id="ksadk_user",
1121
+ session_id=external_session_id,
1122
+ )
1123
+ else:
1124
+ session = await self._session_service.create_session(
1125
+ app_name=self._agent.name, user_id="ksadk_user"
1126
+ )
1127
+ self._session_map[external_session_id] = session.id
1128
+ return session.id
1129
+
1130
+ # Case 2: No external ID (use default singleton)
1131
+ if self._default_session_id is None:
1132
+ if self._short_term_memory:
1133
+ session = await self._short_term_memory.create_session(
1134
+ app_name=self._agent.name,
1135
+ user_id="ksadk_user",
1136
+ )
1137
+ else:
1138
+ session = await self._session_service.create_session(
1139
+ app_name=self._agent.name, user_id="ksadk_user"
1140
+ )
1141
+ self._default_session_id = session.id
1142
+ return self._default_session_id
1143
+
1144
+ async def save_session_to_long_term_memory(
1145
+ self, session_id: str, user_id: str = "ksadk_user"
1146
+ ) -> bool:
1147
+ """将指定 session 保存到长期记忆
1148
+
1149
+ Args:
1150
+ session_id: ADK 内部 session ID
1151
+ user_id: 用户 ID
1152
+
1153
+ Returns:
1154
+ 是否保存成功
1155
+ """
1156
+ if not self._long_term_memory:
1157
+ logger.warning("LongTermMemory not configured, cannot save session.")
1158
+ return False
1159
+
1160
+ try:
1161
+ session = await self._session_service.get_session(
1162
+ app_name=self._agent.name,
1163
+ user_id=user_id,
1164
+ session_id=session_id,
1165
+ )
1166
+ if not session:
1167
+ logger.error(f"Session {session_id} not found, cannot save.")
1168
+ return False
1169
+
1170
+ await self._long_term_memory.add_session_to_memory(session)
1171
+ logger.info(f"Session {session_id} saved to long-term memory.")
1172
+ return True
1173
+ except Exception as e:
1174
+ logger.error(f"Error saving session to long-term memory: {e}")
1175
+ return False
1176
+
1177
+ def _build_adk_content(
1178
+ self,
1179
+ text: str,
1180
+ attachments: list[Dict[str, Any]],
1181
+ *,
1182
+ model_metadata: Dict[str, Any] | None = None,
1183
+ ) -> types.Content:
1184
+ parts = []
1185
+ if text:
1186
+ parts.append(types.Part(text=text))
1187
+ skipped_images: list[str] = []
1188
+ image_input_supported = supports_native_image_input(model_metadata)
1189
+ for att in attachments:
1190
+ mime_type = att.get("mime_type", "application/octet-stream")
1191
+ display_name = att.get("display_name", "")
1192
+ if (
1193
+ classify_attachment_kind(str(mime_type), str(display_name)) == "image"
1194
+ and not image_input_supported
1195
+ ):
1196
+ skipped_images.append(str(display_name or "未命名图片"))
1197
+ continue
1198
+
1199
+ data: Optional[bytes] = None
1200
+
1201
+ inline_data = att.get("data")
1202
+ if att.get("transport") == "inline" and inline_data:
1203
+ try:
1204
+ data = base64.b64decode(str(inline_data).strip() + "===")
1205
+ except Exception as e:
1206
+ att_name = att.get("display_name", "uploaded_file")
1207
+ logger.warning(f"Failed to decode inline attachment {att_name}: {e}")
1208
+
1209
+ if data is None:
1210
+ file_uri = att.get("file_uri")
1211
+ if file_uri:
1212
+ data = read_attachment_uri_bytes(file_uri)
1213
+ if data is None:
1214
+ logger.warning("Failed to load stored attachment %s", file_uri)
1215
+
1216
+ if data is None:
1217
+ file_uri = att.get("file_uri", "")
1218
+ if file_uri.startswith("local:"):
1219
+ logger.warning(
1220
+ (
1221
+ "Ignoring direct local attachment reference %s; "
1222
+ "only resolved storage paths are allowed."
1223
+ ),
1224
+ file_uri,
1225
+ )
1226
+
1227
+ if data is not None:
1228
+ parts.append(types.Part.from_bytes(data=data, mime_type=mime_type))
1229
+
1230
+ if skipped_images:
1231
+ image_list = "、".join(skipped_images)
1232
+ parts.append(
1233
+ types.Part(
1234
+ text=(
1235
+ "系统提示:当前模型不支持图片输入,"
1236
+ f"无法直接分析图片附件({image_list})。"
1237
+ "请切换到支持视觉的模型后重试。"
1238
+ )
1239
+ )
1240
+ )
1241
+
1242
+ # If no parts were found at all (e.g. empty message), fallback to prevent crash
1243
+ if not parts:
1244
+ parts.append(types.Part(text="[empty message]"))
1245
+
1246
+ return types.Content(role="user", parts=parts)
1247
+
1248
+ def _build_state_delta(self, input_data: Dict[str, Any]) -> dict[str, Any]:
1249
+ state_delta: dict[str, Any] = {}
1250
+ for key in (
1251
+ "input_parts",
1252
+ "attachments",
1253
+ "attachment_results",
1254
+ "current_attachments",
1255
+ "current_attachment_results",
1256
+ "has_current_files",
1257
+ ):
1258
+ if key in input_data:
1259
+ state_delta[key] = input_data.get(key)
1260
+ return state_delta
1261
+
1262
+ @staticmethod
1263
+ def _normalize_usage_metadata(usage_metadata: Any) -> dict[str, Any]:
1264
+ if usage_metadata is None:
1265
+ return {}
1266
+ if hasattr(usage_metadata, "model_dump"):
1267
+ try:
1268
+ usage_metadata = usage_metadata.model_dump(exclude_none=True)
1269
+ except Exception:
1270
+ usage_metadata = None
1271
+ elif hasattr(usage_metadata, "dict"):
1272
+ try:
1273
+ usage_metadata = usage_metadata.dict()
1274
+ except Exception:
1275
+ usage_metadata = None
1276
+ if not isinstance(usage_metadata, Mapping):
1277
+ return {}
1278
+
1279
+ reasoning_tokens = usage_metadata.get("thoughts_token_count")
1280
+ output_token_details = {}
1281
+ if reasoning_tokens is not None:
1282
+ try:
1283
+ output_token_details["reasoning"] = int(reasoning_tokens)
1284
+ except (TypeError, ValueError):
1285
+ pass
1286
+
1287
+ input_token_details: dict[str, Any] = {}
1288
+ cached_tokens = usage_metadata.get("cached_content_token_count")
1289
+ if cached_tokens is not None:
1290
+ try:
1291
+ input_token_details["cached"] = int(cached_tokens)
1292
+ except (TypeError, ValueError):
1293
+ pass
1294
+ tool_use_tokens = usage_metadata.get("tool_use_prompt_token_count")
1295
+ if tool_use_tokens is not None:
1296
+ try:
1297
+ input_token_details["tool_use"] = int(tool_use_tokens)
1298
+ except (TypeError, ValueError):
1299
+ pass
1300
+
1301
+ if "input_tokens" in usage_metadata or "output_tokens" in usage_metadata:
1302
+ input_tokens = int(usage_metadata.get("input_tokens") or 0)
1303
+ output_tokens = int(usage_metadata.get("output_tokens") or 0)
1304
+ total_tokens = int(usage_metadata.get("total_tokens") or (input_tokens + output_tokens))
1305
+ direct_input_details = usage_metadata.get("input_token_details")
1306
+ if isinstance(direct_input_details, Mapping):
1307
+ input_token_details.update(dict(direct_input_details))
1308
+ normalized = {
1309
+ "input_tokens": input_tokens,
1310
+ "output_tokens": output_tokens,
1311
+ "total_tokens": total_tokens,
1312
+ "input_token_details": input_token_details,
1313
+ "output_token_details": output_token_details,
1314
+ }
1315
+ direct_output_details = usage_metadata.get("output_token_details")
1316
+ if isinstance(direct_output_details, Mapping):
1317
+ normalized["output_token_details"] = dict(direct_output_details)
1318
+ return normalized
1319
+
1320
+ input_tokens = int(usage_metadata.get("prompt_token_count") or 0)
1321
+ output_tokens = int(usage_metadata.get("candidates_token_count") or 0)
1322
+ total_tokens = int(
1323
+ usage_metadata.get("total_token_count") or (input_tokens + output_tokens)
1324
+ )
1325
+ if not (
1326
+ input_tokens or output_tokens or total_tokens
1327
+ or input_token_details or output_token_details
1328
+ ):
1329
+ return {}
1330
+ return {
1331
+ "input_tokens": input_tokens,
1332
+ "output_tokens": output_tokens,
1333
+ "total_tokens": total_tokens,
1334
+ "input_token_details": input_token_details,
1335
+ "output_token_details": output_token_details,
1336
+ }
1337
+
1338
+ @classmethod
1339
+ def _extract_event_usage(cls, event: Any) -> dict[str, Any]:
1340
+ return cls._normalize_usage_metadata(getattr(event, "usage_metadata", None))
1341
+
1342
+
1343
+ # --- ADK invocation_id & checkpoint helpers ---
1344
+
1345
+ async def _get_max_checkpoint_seq_for_run(
1346
+ self,
1347
+ *,
1348
+ session_id: str,
1349
+ run_id: str,
1350
+ ) -> int:
1351
+ """扫描已有 run_checkpoint 事件,返回同一 run_id 下的最大 checkpoint_seq。
1352
+
1353
+ 恢复模式下 checkpoint_seq 从已有最大值继续递增,避免从 0 重启导致
1354
+ checkpoint_id 碰撞并被 append_run_checkpoint_event 的去重逻辑静默丢弃。
1355
+ """
1356
+ if not run_id:
1357
+ return 0
1358
+ try:
1359
+ from ksadk.sessions import resolve_session_service
1360
+
1361
+ service = resolve_session_service()
1362
+ events = await service.get_events(session_id)
1363
+ max_seq = 0
1364
+ for event in reversed(events):
1365
+ if event.event_type != "run_checkpoint":
1366
+ continue
1367
+ metadata = event.metadata or {}
1368
+ if str(metadata.get("run_id") or "") != str(run_id):
1369
+ continue
1370
+ if str(metadata.get("framework") or "") != "adk":
1371
+ continue
1372
+ checkpoint_id = str(metadata.get("checkpoint_id") or "")
1373
+ if checkpoint_id.startswith("adk-ckpt-"):
1374
+ try:
1375
+ seq = int(checkpoint_id.removeprefix("adk-ckpt-"))
1376
+ if seq > max_seq:
1377
+ max_seq = seq
1378
+ except ValueError:
1379
+ pass
1380
+ return max_seq
1381
+ except Exception as exc:
1382
+ logger.warning(
1383
+ "ADKRunner: failed to query max checkpoint_seq "
1384
+ "for run_id=%s: %s",
1385
+ run_id, exc,
1386
+ )
1387
+ return 0
1388
+
1389
+ async def _collect_adk_invocation_id(
1390
+ self,
1391
+ events_async,
1392
+ *,
1393
+ ksadk_invocation_id: str,
1394
+ session_id: str,
1395
+ checkpoint_run_id: str = "",
1396
+ ):
1397
+ """包装 event 迭代器,在首个 event 到达时采集 ADK invocation_id 并存储映射,
1398
+ 同时在可恢复边界写入 checkpoint 事件。
1399
+
1400
+ checkpoint_run_id 用于 checkpoint 的 run_id 字段,恢复模式下沿用原始 RunId
1401
+ 以保证同一长任务的 checkpoint 时间线连贯;ksadk_invocation_id 用于
1402
+ invocation_id 映射和 event 级 invocation_id 字段。
1403
+ """
1404
+ effective_run_id = checkpoint_run_id or ksadk_invocation_id
1405
+ first_event_captured = False
1406
+ captured_adk_invocation_id = ""
1407
+ # 从已有 checkpoint 的最大 seq 继续,避免恢复模式下 ID 碰撞被去重丢弃
1408
+ checkpoint_seq = await self._get_max_checkpoint_seq_for_run(
1409
+ session_id=session_id, run_id=effective_run_id,
1410
+ )
1411
+ async for event in events_async:
1412
+ if not first_event_captured and hasattr(event, "invocation_id") and event.invocation_id:
1413
+ first_event_captured = True
1414
+ # P1.1: Use local variable instead of self._last_adk_invocation_id
1415
+ # to avoid cross-session corruption under concurrent runner access.
1416
+ captured_adk_invocation_id = event.invocation_id
1417
+ await self._persist_invocation_mapping(
1418
+ session_id=session_id,
1419
+ ksadk_invocation_id=ksadk_invocation_id,
1420
+ adk_invocation_id=captured_adk_invocation_id,
1421
+ )
1422
+ # 每个 resumable boundary 都立即写 checkpoint,用递增 seq 保证
1423
+ # 即使程序崩溃也有最新恢复点。
1424
+ if self._resumable and first_event_captured and self._is_resumable_boundary(event):
1425
+ checkpoint_seq += 1
1426
+ await self._maybe_write_checkpoint(
1427
+ event=event,
1428
+ session_id=session_id,
1429
+ ksadk_invocation_id=ksadk_invocation_id,
1430
+ adk_invocation_id=captured_adk_invocation_id,
1431
+ checkpoint_seq=checkpoint_seq,
1432
+ checkpoint_run_id=effective_run_id,
1433
+ )
1434
+ yield event
1435
+
1436
+ async def _collect_adk_invocation_id_if_present(
1437
+ self,
1438
+ events_async,
1439
+ *,
1440
+ session_id: str,
1441
+ ksadk_invocation_id: str,
1442
+ ):
1443
+ """轻量版 invocation_id 采集:仅捕获 ID 并持久化映射,不写 checkpoint。"""
1444
+ first_event_captured = False
1445
+ async for event in events_async:
1446
+ if not first_event_captured and hasattr(event, "invocation_id") and event.invocation_id:
1447
+ first_event_captured = True
1448
+ # P1.1: Local variable — self._last_adk_invocation_id was removed
1449
+ # to prevent cross-session corruption under concurrent runner access.
1450
+ local_adk_invocation_id = event.invocation_id
1451
+ if ksadk_invocation_id:
1452
+ await self._persist_invocation_mapping(
1453
+ session_id=session_id,
1454
+ ksadk_invocation_id=ksadk_invocation_id,
1455
+ adk_invocation_id=local_adk_invocation_id,
1456
+ )
1457
+ yield event
1458
+
1459
+ async def _persist_invocation_mapping(
1460
+ self,
1461
+ *,
1462
+ session_id: str,
1463
+ ksadk_invocation_id: str,
1464
+ adk_invocation_id: str,
1465
+ ) -> None:
1466
+ """将 ksadk invocation_id → ADK invocation_id 映射持久化到 session binding state。
1467
+
1468
+ 存储位置:ksadk_states 表,scope = "runner_binding:adk"
1469
+ state_json 内新增字段 invocation_map: { ksadk_inv_id: adk_inv_id, ... }
1470
+ 无需新增数据库表或字段,复用现有 state_json 的 JSON 存储。
1471
+ """
1472
+ try:
1473
+ from ksadk.sessions import resolve_session_service
1474
+ from ksadk.sessions.continuity import ConversationSessionCore
1475
+
1476
+ service = resolve_session_service()
1477
+ core = ConversationSessionCore(service)
1478
+ # P1.1 sub-issue: hold the lock across the entire read-modify-write
1479
+ # so concurrent invocations on the same session can't lose mappings.
1480
+ async with self._invocation_map_lock:
1481
+ binding = await core.get_binding_by_session_id(session_id, "adk")
1482
+ invocation_map = dict(binding.get("invocation_map") or {})
1483
+ invocation_map[ksadk_invocation_id] = adk_invocation_id
1484
+
1485
+ await core.set_binding_by_session_id(
1486
+ session_id,
1487
+ "adk",
1488
+ {
1489
+ "external_session_id": str(session_id),
1490
+ "internal_session_id": str(session_id),
1491
+ "invocation_map": invocation_map,
1492
+ },
1493
+ )
1494
+ except Exception as exc:
1495
+ logger.warning("Failed to persist ADK invocation mapping: %s", exc)
1496
+
1497
+ async def _resolve_adk_invocation_id(
1498
+ self,
1499
+ *,
1500
+ session_id: str,
1501
+ ksadk_invocation_id: str,
1502
+ ) -> str | None:
1503
+ """从 session binding state 读取 ADK invocation_id 映射。"""
1504
+ try:
1505
+ from ksadk.sessions import resolve_session_service
1506
+ from ksadk.sessions.continuity import ConversationSessionCore
1507
+
1508
+ service = resolve_session_service()
1509
+ core = ConversationSessionCore(service)
1510
+ binding = await core.get_binding_by_session_id(session_id, "adk")
1511
+ invocation_map = dict(binding.get("invocation_map") or {})
1512
+ return invocation_map.get(ksadk_invocation_id)
1513
+ except Exception:
1514
+ return None
1515
+
1516
+
1517
+ def _is_resumable_boundary(self, event: Any) -> bool:
1518
+ """判断 ADK event 是否为可恢复边界。"""
1519
+ # 工具调用请求
1520
+ if hasattr(event, "get_function_calls") and event.get_function_calls():
1521
+ return True
1522
+ # 自定义 Agent 状态保存
1523
+ if hasattr(event, "actions") and event.actions:
1524
+ if getattr(event.actions, "agent_state", None) is not None:
1525
+ return True
1526
+ if getattr(event.actions, "end_of_agent", False):
1527
+ return True
1528
+ return False
1529
+
1530
+ async def _maybe_write_checkpoint(
1531
+ self,
1532
+ *,
1533
+ event: Any,
1534
+ session_id: str,
1535
+ ksadk_invocation_id: str,
1536
+ adk_invocation_id: str,
1537
+ checkpoint_seq: int,
1538
+ checkpoint_run_id: str = "",
1539
+ ) -> None:
1540
+ """Write a run_checkpoint event at a resumable boundary.
1541
+
1542
+ Each boundary gets its own incrementing checkpoint_id so the latest
1543
+ checkpoint always reflects the latest state for crash recovery.
1544
+ """
1545
+ if not self._is_resumable_boundary(event):
1546
+ return
1547
+
1548
+ from ksadk.conversations.runtime import append_run_checkpoint_event
1549
+
1550
+ metadata = self._extract_checkpoint_metadata(event)
1551
+
1552
+ effective_run_id = checkpoint_run_id or ksadk_invocation_id
1553
+ await append_run_checkpoint_event(
1554
+ session_id=session_id,
1555
+ author=self._agent.name,
1556
+ run_id=effective_run_id,
1557
+ checkpoint_id=f"adk-ckpt-{checkpoint_seq}",
1558
+ framework="adk",
1559
+ framework_ref={
1560
+ "adk": {
1561
+ "invocation_id": adk_invocation_id,
1562
+ "checkpoint_seq": checkpoint_seq,
1563
+ "event_id": getattr(event, "id", ""),
1564
+ "author": getattr(event, "author", ""),
1565
+ }
1566
+ },
1567
+ phase=(
1568
+ "tool_call"
1569
+ if (hasattr(event, "get_function_calls") and event.get_function_calls())
1570
+ else "agent_state"
1571
+ ),
1572
+ invocation_id=ksadk_invocation_id,
1573
+ metadata=metadata,
1574
+ )
1575
+ logger.debug(
1576
+ "ADKRunner: wrote checkpoint adk-ckpt-%d at boundary "
1577
+ "(session=%s, invocation_id=%s)",
1578
+ checkpoint_seq, session_id, adk_invocation_id,
1579
+ )
1580
+
1581
+ def _extract_checkpoint_metadata(self, event: Any) -> dict[str, Any]:
1582
+ """从 ADK event 中提取 checkpoint 元数据。"""
1583
+ metadata: dict[str, Any] = {}
1584
+
1585
+ # 工具调用信息
1586
+ if hasattr(event, "get_function_calls") and event.get_function_calls():
1587
+ fcs = event.get_function_calls()
1588
+ metadata["tool_names"] = [fc.name for fc in fcs if hasattr(fc, "name")]
1589
+ metadata["tool_call_ids"] = [fc.id for fc in fcs if hasattr(fc, "id")]
1590
+
1591
+ # Agent 状态信息
1592
+ if hasattr(event, "actions") and event.actions:
1593
+ agent_state = getattr(event.actions, "agent_state", None)
1594
+ if agent_state is not None:
1595
+ if isinstance(agent_state, dict):
1596
+ metadata["agent_state_keys"] = list(agent_state.keys())
1597
+ else:
1598
+ metadata["agent_state_keys"] = []
1599
+ if getattr(event.actions, "end_of_agent", False):
1600
+ metadata["is_terminal"] = True
1601
+ metadata["end_of_agent"] = True
1602
+
1603
+ # 是否可恢复
1604
+ stm_backend = (
1605
+ getattr(self._short_term_memory, "backend", None)
1606
+ if self._short_term_memory else None
1607
+ )
1608
+ shared_across_pods = stm_backend == "database"
1609
+ platform_resumable = self._resumable and shared_across_pods
1610
+ metadata["is_resumable"] = platform_resumable
1611
+ metadata["resume_status"] = "resumable" if platform_resumable else "disabled"
1612
+ metadata["backend"] = stm_backend or "in_memory"
1613
+ metadata["scope"] = "invocation"
1614
+ metadata["durable"] = stm_backend is not None and stm_backend != "local"
1615
+ metadata["shared_across_pods"] = shared_across_pods
1616
+ if not platform_resumable:
1617
+ metadata["resume_disabled_reason"] = (
1618
+ self._resume_disabled_reason
1619
+ if not self._resumable and self._resume_disabled_reason
1620
+ else "ADK checkpoint uses an in-memory or local-only session backend; "
1621
+ "cross-pod resume is unavailable"
1622
+ )
1623
+ # P1.4: ADK only supports invocation-level (forward-only) resume, not
1624
+ # arbitrary checkpoint rollback like LangGraph time_travel. Consumers
1625
+ # should treat only the latest checkpoint as independently resumable.
1626
+ metadata["resume_mode"] = "invocation_id"
1627
+ metadata["only_latest_resumable"] = True
1628
+
1629
+ return metadata
1630
+
1631
+ async def _resolve_resume_invocation_id(
1632
+ self,
1633
+ *,
1634
+ input_data: Dict[str, Any],
1635
+ session_id: str,
1636
+ ksadk_invocation_id: str,
1637
+ ) -> str:
1638
+ """Resolve the ADK invocation_id needed for resume.
1639
+
1640
+ Looks up from framework_ref or session binding mapping.
1641
+ Raises ValueError when the resume reference is missing (P1.2:
1642
+ never silently downgrade to a new invocation).
1643
+ """
1644
+ adk_invocation_id = None
1645
+ framework_ref = input_data.get("framework_ref") or {}
1646
+ if isinstance(framework_ref, dict):
1647
+ adk_ref = framework_ref.get("adk") or {}
1648
+ if isinstance(adk_ref, dict):
1649
+ adk_invocation_id = adk_ref.get("invocation_id")
1650
+ # Fallback: resolve from session binding
1651
+ if not adk_invocation_id and ksadk_invocation_id:
1652
+ adk_invocation_id = await self._resolve_adk_invocation_id(
1653
+ session_id=session_id,
1654
+ ksadk_invocation_id=ksadk_invocation_id,
1655
+ )
1656
+ if not adk_invocation_id:
1657
+ logger.error(
1658
+ "Resume requested but ADK invocation_id not found for "
1659
+ "session=%s ksadk_inv=%s",
1660
+ session_id, ksadk_invocation_id,
1661
+ )
1662
+ raise ValueError(
1663
+ f"checkpoint_not_resumable: ADK invocation_id not found for "
1664
+ f"session={session_id}, ksadk_invocation_id={ksadk_invocation_id}. "
1665
+ f"The checkpoint data may have been lost or the invocation was "
1666
+ f"never persisted."
1667
+ )
1668
+ return adk_invocation_id
1669
+
1670
+ async def _prepare_run_events(
1671
+ self,
1672
+ *,
1673
+ input_data: Dict[str, Any],
1674
+ session_id: str,
1675
+ user_input: str,
1676
+ is_resume: bool,
1677
+ run_config: Optional[Any] = None,
1678
+ ) -> AsyncIterator[Any]:
1679
+ """Prepare the wrapped event stream shared by invoke() and stream().
1680
+
1681
+ Handles new_message construction, resume invocation_id resolution,
1682
+ run_async (with optional run_config for streaming), and event wrapping
1683
+ for checkpoint writing. Returns the wrapped async iterator.
1684
+ """
1685
+ if not is_resume:
1686
+ new_message = self._build_adk_content(
1687
+ user_input,
1688
+ input_data.get("attachments", []),
1689
+ model_metadata=input_data.get("model_metadata"),
1690
+ )
1691
+ else:
1692
+ new_message = None
1693
+
1694
+ ksadk_invocation_id = str(
1695
+ input_data.get("invocation_id") or input_data.get("run_id") or ""
1696
+ )
1697
+
1698
+ checkpoint_run_id = (
1699
+ str(input_data.get("run_id") or "").strip()
1700
+ if is_resume and str(input_data.get("run_id") or "").strip()
1701
+ else ""
1702
+ )
1703
+
1704
+ run_kwargs: Dict[str, Any] = {
1705
+ "session_id": session_id,
1706
+ "user_id": "ksadk_user",
1707
+ }
1708
+ if run_config is not None:
1709
+ run_kwargs["run_config"] = run_config
1710
+
1711
+ if is_resume:
1712
+ if not self._resumable:
1713
+ raise ValueError(
1714
+ "checkpoint_not_resumable: ADK resumability is disabled for "
1715
+ f"session={session_id}, ksadk_invocation_id={ksadk_invocation_id}."
1716
+ )
1717
+ adk_invocation_id = await self._resolve_resume_invocation_id(
1718
+ input_data=input_data,
1719
+ session_id=session_id,
1720
+ ksadk_invocation_id=ksadk_invocation_id,
1721
+ )
1722
+ logger.info("Resuming ADK run with adk_invocation_id: %s", adk_invocation_id)
1723
+ run_kwargs["invocation_id"] = adk_invocation_id
1724
+ else:
1725
+ run_kwargs["new_message"] = new_message
1726
+ run_kwargs["state_delta"] = self._build_state_delta(input_data) or None
1727
+
1728
+ events_async = self._runner.run_async(**run_kwargs)
1729
+
1730
+ if ksadk_invocation_id and self._resumable:
1731
+ wrapped_async = self._collect_adk_invocation_id(
1732
+ events_async,
1733
+ ksadk_invocation_id=ksadk_invocation_id,
1734
+ session_id=session_id,
1735
+ checkpoint_run_id=checkpoint_run_id,
1736
+ )
1737
+ else:
1738
+ wrapped_async = self._collect_adk_invocation_id_if_present(
1739
+ events_async,
1740
+ session_id=session_id,
1741
+ ksadk_invocation_id=ksadk_invocation_id,
1742
+ )
1743
+ return wrapped_async
1744
+
1745
+ async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]:
1746
+ """调用 ADK Agent"""
1747
+
1748
+ # 判断是否为恢复调用 — 提前判断以避免将 resume_input dict 当作 string 处理
1749
+ is_resume = bool(input_data.get("checkpoint_resume"))
1750
+ raw_input = input_data.get("input")
1751
+ if is_resume and isinstance(raw_input, dict):
1752
+ user_input = "[checkpoint resume]"
1753
+ else:
1754
+ user_input = str(raw_input or "")
1755
+ instructions = str(input_data.get("instructions") or "").strip()
1756
+ if instructions and not is_resume:
1757
+ user_input = f"{instructions}\n\nCurrent user input:\n{user_input or '[empty message]'}"
1758
+
1759
+ # 1. 准备 Metadata (提前以此获取 Agent Name)
1760
+ _, _, _, agent_name = self._prepare_trace_metadata(None)
1761
+ trace_name = agent_name or "adk.invoke"
1762
+
1763
+ with tracer.start_as_current_span(trace_name) as span:
1764
+ # Set input.value for Langfuse top-level input display
1765
+ span.set_attribute("input.value", user_input)
1766
+ truncated_input = (
1767
+ user_input[:200]
1768
+ if isinstance(user_input, str)
1769
+ else str(user_input)[:200]
1770
+ )
1771
+ span.set_attribute("user.input", truncated_input)
1772
+
1773
+ # Use external session ID if provided
1774
+ req_session_id = input_data.get("session_id")
1775
+ session_id = await self._ensure_session(req_session_id)
1776
+
1777
+ # 准备 Metadata 并设置 Span Attributes
1778
+ # Langfuse Exporter 会读取这些 span attributes
1779
+ agent_user_id, tags, _, _ = self._prepare_trace_metadata(session_id)
1780
+
1781
+ span.set_attribute("langfuse.session_id", session_id)
1782
+ if agent_user_id:
1783
+ span.set_attribute("langfuse.user_id", agent_user_id)
1784
+ if tags:
1785
+ span.set_attribute("langfuse.tags", ",".join(tags))
1786
+
1787
+ wrapped_async = await self._prepare_run_events(
1788
+ input_data=input_data,
1789
+ session_id=session_id,
1790
+ user_input=user_input,
1791
+ is_resume=is_resume,
1792
+ )
1793
+
1794
+ final_response = ""
1795
+
1796
+ events_list = []
1797
+ # P1.4: Track last_event to avoid UnboundLocalError when ADK returns
1798
+ # zero events (e.g. resuming a completed invocation).
1799
+ last_event = None
1800
+ usage: dict[str, Any] = {}
1801
+ last_usage: dict[str, Any] = {}
1802
+ async for event in wrapped_async:
1803
+ events_list.append(event)
1804
+ last_event = event
1805
+ event_usage = self._extract_event_usage(event)
1806
+ if event_usage:
1807
+ usage = accumulate_usage(usage, event_usage)
1808
+ last_usage = event_usage # 保留最后一个非空(窗口占用=最后一次 input)
1809
+ if hasattr(event, "content") and event.content:
1810
+ if hasattr(event.content, "parts"):
1811
+ for part in event.content.parts:
1812
+ # 过滤掉思考内容 (thought=True),只保留最终答案
1813
+ is_thought = getattr(part, "thought", False)
1814
+ if hasattr(part, "text") and part.text and not is_thought:
1815
+ final_response += part.text
1816
+
1817
+ # Prefer the last event's text (usually the complete answer).
1818
+ # When the loop aborts early (MCP error, phantom tool-call),
1819
+ # final_response may hold only intermediate fragments.
1820
+ last_event_text = ""
1821
+ if last_event is not None and hasattr(last_event, "content") and last_event.content:
1822
+ for part in (last_event.content.parts or []):
1823
+ is_thought = getattr(part, "thought", False)
1824
+ if hasattr(part, "text") and part.text and not is_thought:
1825
+ last_event_text += part.text
1826
+ if last_event_text:
1827
+ final_response = last_event_text
1828
+
1829
+ if not final_response and events_list:
1830
+ logger.warning(
1831
+ "ADK invoke finished with events but no final text — "
1832
+ "likely mid-loop abort or tool-call error"
1833
+ )
1834
+
1835
+ # Set output.value for Langfuse top-level output display
1836
+ span.set_attribute("output.value", final_response[:5000] if final_response else "")
1837
+ span.set_attribute("agent.output", final_response[:500] if final_response else "")
1838
+ result = {"output": final_response, "events": events_list}
1839
+ if usage:
1840
+ result["usage"] = usage
1841
+ # last_usage = 最后一次 LLM 调用快照(input_tokens = 当前上下文窗口占用)
1842
+ result.setdefault("metadata", {})["last_usage"] = last_usage
1843
+ return result
1844
+
1845
+ async def stream(self, input_data: Dict[str, Any]) -> AsyncIterator[Dict[str, Any]]:
1846
+ """流式调用 ADK Agent
1847
+
1848
+ 使用 StreamingMode.SSE 启用真正的流式 token 输出
1849
+ """
1850
+ from google.adk.agents.run_config import RunConfig, StreamingMode
1851
+
1852
+ # 判断是否为恢复调用 — 提前判断以避免将 resume_input dict 当作 string 处理
1853
+ is_resume = bool(input_data.get("checkpoint_resume"))
1854
+ raw_input = input_data.get("input")
1855
+ if is_resume and isinstance(raw_input, dict):
1856
+ user_input = "[checkpoint resume]"
1857
+ else:
1858
+ user_input = str(raw_input or "")
1859
+ instructions = str(input_data.get("instructions") or "").strip()
1860
+ if instructions and not is_resume:
1861
+ user_input = f"{instructions}\n\nCurrent user input:\n{user_input or '[empty message]'}"
1862
+
1863
+ # 1. 准备 Metadata (提前以此获取 Agent Name)
1864
+ _, _, _, agent_name = self._prepare_trace_metadata(None)
1865
+ trace_name = agent_name or "adk.stream"
1866
+
1867
+ with tracer.start_as_current_span(trace_name) as span:
1868
+ # Set input.value for Langfuse top-level input display
1869
+ span.set_attribute("input.value", user_input)
1870
+ truncated_input = (
1871
+ user_input[:200]
1872
+ if isinstance(user_input, str)
1873
+ else str(user_input)[:200]
1874
+ )
1875
+ span.set_attribute("user.input", truncated_input)
1876
+
1877
+ # Use external session ID if provided
1878
+ req_session_id = input_data.get("session_id")
1879
+ session_id = await self._ensure_session(req_session_id)
1880
+
1881
+ # 准备 Metadata 并设置 Span Attributes
1882
+ agent_user_id, tags, _, _ = self._prepare_trace_metadata(session_id)
1883
+
1884
+ span.set_attribute("langfuse.session_id", session_id)
1885
+ if agent_user_id:
1886
+ span.set_attribute("langfuse.user_id", agent_user_id)
1887
+ if tags:
1888
+ span.set_attribute("langfuse.tags", ",".join(tags))
1889
+
1890
+ run_config = RunConfig(streaming_mode=StreamingMode.SSE)
1891
+ wrapped_async = await self._prepare_run_events(
1892
+ input_data=input_data,
1893
+ session_id=session_id,
1894
+ user_input=user_input,
1895
+ is_resume=is_resume,
1896
+ run_config=run_config,
1897
+ )
1898
+
1899
+ accumulated_text = ""
1900
+ usage: dict[str, Any] = {}
1901
+ last_usage: dict[str, Any] = {}
1902
+
1903
+ async for event in wrapped_async:
1904
+ event_usage = self._extract_event_usage(event)
1905
+ if event_usage:
1906
+ usage = accumulate_usage(usage, event_usage)
1907
+ last_usage = event_usage # 保留最后一个非空
1908
+ # Only yield text delta if event is partial to avoid duplication of final summary
1909
+ if hasattr(event, "content") and event.content and getattr(event, "partial", False):
1910
+ if hasattr(event.content, "parts"):
1911
+ for part in event.content.parts:
1912
+ if hasattr(part, "text") and part.text:
1913
+ is_thought = getattr(part, "thought", False)
1914
+ accumulated_text += part.text
1915
+ # 标记思考内容,前端可以选择是否展示
1916
+ yield {
1917
+ "delta": part.text,
1918
+ "type": "thinking" if is_thought else "text",
1919
+ }
1920
+
1921
+ # 处理工具调用事件 — ADK 通过 event.content.parts[].function_call
1922
+ # 发出工具调用(即 event.get_function_calls()),而非
1923
+ # event.actions.tool_calls。此处需同时检测两种路径:
1924
+ # (a) get_function_calls() — ADK 标准 Event 模型
1925
+ # (b) actions.tool_calls — 某些旧版或自定义 runner 可能使用
1926
+ emitted_tool_call_ids: set[str] = set()
1927
+ if hasattr(event, "get_function_calls") and event.get_function_calls():
1928
+ for fc in event.get_function_calls():
1929
+ fc_id = getattr(fc, "id", "") or getattr(fc, "name", "unknown")
1930
+ if fc_id in emitted_tool_call_ids:
1931
+ continue
1932
+ emitted_tool_call_ids.add(fc_id)
1933
+ # fc.args 可能是 dict 或 None;确保可序列化
1934
+ fc_args = getattr(fc, "args", None) or {}
1935
+ if not isinstance(fc_args, dict):
1936
+ try:
1937
+ import json as _json
1938
+ fc_args = _json.loads(fc_args) if isinstance(fc_args, str) else {}
1939
+ except Exception:
1940
+ fc_args = {}
1941
+ yield {
1942
+ "type": "tool_call",
1943
+ "tool_name": getattr(fc, "name", "unknown"),
1944
+ "tool_args": fc_args,
1945
+ }
1946
+ if hasattr(event, "actions") and event.actions:
1947
+ tool_calls = getattr(event.actions, "tool_calls", None)
1948
+ if tool_calls:
1949
+ for tool_call in tool_calls:
1950
+ tc_name = getattr(tool_call, "name", "unknown")
1951
+ tc_id = getattr(tool_call, "id", "") or tc_name
1952
+ if tc_id in emitted_tool_call_ids:
1953
+ continue
1954
+ emitted_tool_call_ids.add(tc_id)
1955
+ yield {
1956
+ "type": "tool_call",
1957
+ "tool_name": tc_name,
1958
+ "tool_args": getattr(tool_call, "input", {}),
1959
+ }
1960
+
1961
+ # 处理工具返回结果 — ADK 通过 event.content.parts[].function_response
1962
+ # 发出工具执行结果。当工具执行完毕后 ADK 会产生一个包含
1963
+ # function_response 的事件,此处将其转为 tool_result 语义事件,
1964
+ # 让前端可以感知"某个工具已完成执行"并展示结果。
1965
+ if hasattr(event, "content") and event.content and hasattr(event.content, "parts"):
1966
+ for part in event.content.parts:
1967
+ fr = getattr(part, "function_response", None)
1968
+ if fr is not None:
1969
+ fr_name = getattr(fr, "name", "unknown")
1970
+ fr_output = getattr(fr, "response", None) or {}
1971
+ if not isinstance(fr_output, dict):
1972
+ try:
1973
+ import json as _json2
1974
+ if isinstance(fr_output, str):
1975
+ fr_output = _json2.loads(fr_output)
1976
+ else:
1977
+ fr_output = {"raw": str(fr_output)}
1978
+ except Exception:
1979
+ fr_output = {"raw": str(fr_output)}
1980
+ yield {
1981
+ "type": "tool_result",
1982
+ "tool_name": fr_name,
1983
+ "tool_output": fr_output,
1984
+ }
1985
+
1986
+ # Set output.value for Langfuse top-level output display
1987
+ span.set_attribute("output.value", accumulated_text[:5000] if accumulated_text else "")
1988
+ span.set_attribute("agent.output", accumulated_text[:500])
1989
+ final_chunk: dict[str, Any] = {"output": accumulated_text, "type": "final"}
1990
+ if usage:
1991
+ final_chunk["usage"] = usage
1992
+ # last_usage = 最后一次 LLM 调用快照(input_tokens = 当前上下文窗口占用)
1993
+ final_chunk.setdefault("metadata", {})["last_usage"] = last_usage
1994
+ yield final_chunk