agentengine-sdk-python 0.6.9__py3-none-any.whl → 0.8.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 (384) hide show
  1. {agentengine_sdk_python-0.6.9.dist-info → agentengine_sdk_python-0.8.0.dist-info}/METADATA +41 -23
  2. agentengine_sdk_python-0.8.0.dist-info/RECORD +578 -0
  3. ksadk/a2a/__init__.py +178 -11
  4. ksadk/a2a/bootstrap.py +334 -0
  5. ksadk/a2a/card.py +120 -0
  6. ksadk/a2a/context_store.py +203 -0
  7. ksadk/a2a/control_plane.py +818 -0
  8. ksadk/a2a/credential.py +99 -0
  9. ksadk/a2a/event_adapter.py +149 -0
  10. ksadk/a2a/executor.py +406 -89
  11. ksadk/a2a/external_transport.py +516 -0
  12. ksadk/a2a/identity.py +304 -0
  13. ksadk/a2a/ids.py +22 -0
  14. ksadk/a2a/langgraph.py +285 -0
  15. ksadk/a2a/resume_store.py +201 -0
  16. ksadk/a2a/routes.py +105 -0
  17. ksadk/a2a/server.py +107 -60
  18. ksadk/a2a/space_client.py +1101 -0
  19. ksadk/a2a/task_adapter.py +423 -0
  20. ksadk/a2a/task_event_dispatcher.py +167 -0
  21. ksadk/a2a/task_event_outbox.py +326 -0
  22. ksadk/a2a/task_store.py +206 -0
  23. ksadk/a2ui/__init__.py +27 -0
  24. ksadk/a2ui/core.py +186 -0
  25. ksadk/a2ui/models.py +166 -0
  26. ksadk/a2ui/renderer.py +198 -0
  27. ksadk/agui/__init__.py +9 -0
  28. ksadk/agui/a2ui_projection.py +92 -0
  29. ksadk/agui/agent.py +857 -0
  30. ksadk/agui/config.py +86 -0
  31. ksadk/agui/routes.py +55 -0
  32. ksadk/api/client.py +252 -191
  33. ksadk/builders/__init__.py +2 -0
  34. ksadk/builders/base.py +16 -14
  35. ksadk/builders/code_builder.py +318 -236
  36. ksadk/builders/container_builder.py +187 -167
  37. ksadk/builders/framework_requirements.py +29 -11
  38. ksadk/builders/ks3_uploader.py +58 -27
  39. ksadk/builders/managed_runtime_builder.py +145 -0
  40. ksadk/builders/mcp_builder.py +91 -92
  41. ksadk/builders/requirements_utils.py +1 -5
  42. ksadk/cli/__init__.py +89 -171
  43. ksadk/cli/cmd_a2a.py +254 -70
  44. ksadk/cli/cmd_agent.py +19 -5
  45. ksadk/cli/cmd_build.py +158 -29
  46. ksadk/cli/cmd_completion.py +47 -35
  47. ksadk/cli/cmd_config.py +267 -198
  48. ksadk/cli/cmd_create.py +533 -726
  49. ksadk/cli/cmd_dashboard.py +120 -45
  50. ksadk/cli/cmd_deploy.py +117 -58
  51. ksadk/cli/cmd_destroy.py +44 -14
  52. ksadk/cli/cmd_files.py +63 -24
  53. ksadk/cli/cmd_hermes.py +173 -90
  54. ksadk/cli/cmd_invoke.py +388 -159
  55. ksadk/cli/cmd_launch.py +64 -28
  56. ksadk/cli/cmd_mcp.py +103 -64
  57. ksadk/cli/cmd_model.py +14 -5
  58. ksadk/cli/cmd_openclaw.py +488 -228
  59. ksadk/cli/cmd_replay.py +71 -0
  60. ksadk/cli/cmd_run.py +44 -14
  61. ksadk/cli/cmd_status.py +80 -31
  62. ksadk/cli/cmd_version.py +20 -13
  63. ksadk/cli/cmd_web.py +89 -13
  64. ksadk/cli/deploy_utils.py +20 -20
  65. ksadk/cli/dry_run.py +5 -3
  66. ksadk/cli/error_utils.py +37 -12
  67. ksadk/cli/global_options.py +1 -2
  68. ksadk/cli/local_runtime.py +1 -2
  69. ksadk/cli/model_catalog.py +6 -2
  70. ksadk/cli/network_options.py +38 -16
  71. ksadk/cli/resource_common.py +6 -2
  72. ksadk/cli/storage.py +0 -1
  73. ksadk/cli/ui.py +5 -3
  74. ksadk/cli/workflow_common.py +51 -17
  75. ksadk/codex/__init__.py +12 -0
  76. ksadk/codex/client.py +408 -0
  77. ksadk/codex/phase.py +143 -0
  78. ksadk/codex/runtime.py +449 -0
  79. ksadk/common/aicp_env.py +0 -1
  80. ksadk/common/auth.py +2 -2
  81. ksadk/common/constants.py +1 -3
  82. ksadk/common/llm_utils.py +17 -13
  83. ksadk/compat/__init__.py +5 -0
  84. ksadk/compat/adk_compat.py +200 -0
  85. ksadk/compat/copilotkit_a2ui.py +330 -0
  86. ksadk/configs/__init__.py +12 -12
  87. ksadk/configs/env_registry.py +501 -100
  88. ksadk/configs/global_config.py +2 -1
  89. ksadk/configs/settings.py +151 -124
  90. ksadk/conversations/__init__.py +7 -5
  91. ksadk/conversations/attachment_storage.py +16 -11
  92. ksadk/conversations/attachments.py +44 -36
  93. ksadk/conversations/compaction_pipeline.py +12 -12
  94. ksadk/conversations/compaction_prompt.py +9 -5
  95. ksadk/conversations/context.py +221 -6
  96. ksadk/conversations/message_projection.py +656 -0
  97. ksadk/conversations/model_context.py +22 -6
  98. ksadk/conversations/model_options.py +0 -1
  99. ksadk/conversations/normalize.py +20 -16
  100. ksadk/conversations/reasoning_markup.py +1 -1
  101. ksadk/conversations/runtime.py +339 -5187
  102. ksadk/conversations/runtime_compaction.py +333 -0
  103. ksadk/conversations/runtime_constants.py +27 -0
  104. ksadk/conversations/runtime_governance.py +185 -0
  105. ksadk/conversations/runtime_input.py +652 -0
  106. ksadk/conversations/runtime_invocation.py +395 -0
  107. ksadk/conversations/runtime_metadata.py +475 -0
  108. ksadk/conversations/runtime_observability.py +355 -0
  109. ksadk/conversations/runtime_payloads.py +272 -0
  110. ksadk/conversations/runtime_persistence.py +440 -0
  111. ksadk/conversations/runtime_preparation.py +418 -0
  112. ksadk/conversations/runtime_resume.py +816 -0
  113. ksadk/conversations/runtime_stream_events.py +902 -0
  114. ksadk/conversations/runtime_streaming.py +547 -0
  115. ksadk/conversations/semantic_summary.py +17 -5
  116. ksadk/conversations/session_title.py +6 -3
  117. ksadk/deployment/__init__.py +15 -16
  118. ksadk/deployment/agent_access.py +12 -23
  119. ksadk/deployment/base.py +52 -39
  120. ksadk/deployment/manager.py +63 -65
  121. ksadk/deployment/providers/serverless.py +326 -209
  122. ksadk/deployment/registry.py +38 -32
  123. ksadk/deployment/state.py +11 -11
  124. ksadk/deployment/ui_config.py +1 -1
  125. ksadk/detection/__init__.py +1 -1
  126. ksadk/detection/detector.py +146 -83
  127. ksadk/detection/mcp_detector.py +9 -6
  128. ksadk/events/__init__.py +25 -0
  129. ksadk/events/parser.py +191 -0
  130. ksadk/events/replay.py +37 -0
  131. ksadk/events/runtime_event.py +264 -0
  132. ksadk/events/store.py +317 -0
  133. ksadk/harness/__init__.py +34 -0
  134. ksadk/harness/app.py +278 -0
  135. ksadk/harness/config.py +181 -0
  136. ksadk/harness/reasoner.py +105 -0
  137. ksadk/harness/runner.py +211 -0
  138. ksadk/harness/sandbox.py +141 -0
  139. ksadk/harness/tools.py +222 -0
  140. ksadk/hermes_terminal.py +23 -12
  141. ksadk/identity/resolver.py +15 -9
  142. ksadk/ids.py +32 -0
  143. ksadk/knowledge_base/adk_tool.py +1 -1
  144. ksadk/knowledge_base/client.py +23 -29
  145. ksadk/managed_runtime.py +188 -0
  146. ksadk/markdown.py +5 -2
  147. ksadk/mcp_runtime/__init__.py +9 -6
  148. ksadk/memory/__init__.py +1 -1
  149. ksadk/memory/adk/__init__.py +2 -0
  150. ksadk/memory/adk/backends/base_ltm_backend.py +2 -6
  151. ksadk/memory/adk/backends/http_ltm_backend.py +13 -21
  152. ksadk/memory/adk/backends/inmemory_ltm_backend.py +7 -13
  153. ksadk/memory/adk/backends/sdk_ltm_backend.py +26 -33
  154. ksadk/memory/adk/long_term_memory.py +31 -35
  155. ksadk/memory/adk/resilient_session_service.py +267 -0
  156. ksadk/memory/adk/short_term_memory.py +33 -27
  157. ksadk/memory/adk_tool.py +3 -2
  158. ksadk/memory/backends/base.py +19 -19
  159. ksadk/memory/backends/memory.py +24 -25
  160. ksadk/memory/backends/redis.py +31 -30
  161. ksadk/memory/langchain_tool.py +2 -1
  162. ksadk/memory/manager.py +52 -47
  163. ksadk/memory/service.py +10 -8
  164. ksadk/model_policy.py +15 -7
  165. ksadk/model_proxy/__init__.py +51 -0
  166. ksadk/model_proxy/bootstrap.py +80 -0
  167. ksadk/model_proxy/cache.py +165 -0
  168. ksadk/model_proxy/config.py +45 -0
  169. ksadk/model_proxy/demo_webui.py +115 -0
  170. ksadk/model_proxy/detect.py +145 -0
  171. ksadk/model_proxy/gate.py +68 -0
  172. ksadk/model_proxy/namespace.py +173 -0
  173. ksadk/model_proxy/protocol_proxy.py +22 -0
  174. ksadk/model_proxy/server.py +300 -0
  175. ksadk/model_proxy/transform.py +691 -0
  176. ksadk/openclaw_gateway.py +29 -14
  177. ksadk/runners/adk_runner.py +1248 -164
  178. ksadk/runners/base_runner.py +46 -29
  179. ksadk/runners/codex_runner.py +132 -0
  180. ksadk/runners/deepagents_runner.py +0 -1
  181. ksadk/runners/factory.py +17 -5
  182. ksadk/runners/langchain_runner.py +29 -530
  183. ksadk/runners/langgraph_runner.py +490 -95
  184. ksadk/runners/patch_langchain.py +16 -16
  185. ksadk/runners/remote_runner.py +264 -38
  186. ksadk/runners/usage_accumulator.py +1 -0
  187. ksadk/runners/utils/langfuse.py +3 -3
  188. ksadk/runners/utils/loader.py +7 -7
  189. ksadk/runtime/__init__.py +41 -0
  190. ksadk/runtime/adapter.py +345 -0
  191. ksadk/runtime/framework_adapters.py +124 -0
  192. ksadk/runtime/preprocessing.py +118 -0
  193. ksadk/runtime/runner_adapter.py +873 -0
  194. ksadk/runtime_context.py +18 -3
  195. ksadk/runtime_state.py +0 -1
  196. ksadk/sandbox/__init__.py +4 -1
  197. ksadk/sandbox/backends/__init__.py +4 -1
  198. ksadk/sandbox/backends/e2b.py +13 -5
  199. ksadk/sandbox/backends/local_process.py +11 -3
  200. ksadk/sandbox/base.py +10 -15
  201. ksadk/sandbox/factory.py +14 -4
  202. ksadk/sandbox/registry.py +57 -6
  203. ksadk/server/api_models.py +15 -5
  204. ksadk/server/app.py +455 -3743
  205. ksadk/server/composition.py +21 -0
  206. ksadk/server/factory.py +624 -0
  207. ksadk/server/routes/__init__.py +1 -0
  208. ksadk/server/routes/checkpoint_resolution.py +116 -0
  209. ksadk/server/routes/common.py +713 -0
  210. ksadk/server/routes/control.py +327 -0
  211. ksadk/server/routes/dependencies.py +98 -0
  212. ksadk/server/routes/misc.py +448 -0
  213. ksadk/server/routes/models.py +443 -0
  214. ksadk/server/routes/openai_compat.py +207 -0
  215. ksadk/server/routes/projection.py +795 -0
  216. ksadk/server/routes/routers.py +52 -0
  217. ksadk/server/routes/run.py +726 -0
  218. ksadk/server/routes/sessions.py +649 -0
  219. ksadk/server/routes/streaming.py +223 -0
  220. ksadk/server/routes/workspace.py +381 -0
  221. ksadk/server/static/assets/{ArtifactsPanel-C0meuOdi.js → ArtifactsPanel-Cjnqt-8W.js} +1 -1
  222. ksadk/server/static/assets/CodeBlock-BqyqZfuB.js +9 -0
  223. ksadk/server/static/assets/MathMessageMarkdown-BpH7jiFM.js +8 -0
  224. ksadk/server/static/assets/MermaidBlock-DWuxETaJ.js +303 -0
  225. ksadk/server/static/assets/NativeTerminalPanel-MDTopWMO.js +1 -0
  226. ksadk/server/static/assets/{arc-bj2rZnP5.js → arc-CFKwSk_B.js} +1 -1
  227. ksadk/server/static/assets/architecture-7EHR7CIX-CynqOWjZ.js +1 -0
  228. ksadk/server/static/assets/{architectureDiagram-3BPJPVTR-2u1ltDIj.js → architectureDiagram-3BPJPVTR-Dej8jX8c.js} +1 -1
  229. ksadk/server/static/assets/{blockDiagram-GPEHLZMM-D6fvdK20.js → blockDiagram-GPEHLZMM-1F64jqvy.js} +1 -1
  230. ksadk/server/static/assets/{c4Diagram-AAUBKEIU-BK82L5n5.js → c4Diagram-AAUBKEIU-9q7AUAKb.js} +1 -1
  231. ksadk/server/static/assets/channel-iUGv2N24.js +1 -0
  232. ksadk/server/static/assets/{chunk-2J33WTMH-DLVdkeyQ.js → chunk-2J33WTMH-6qgwJm7Q.js} +1 -1
  233. ksadk/server/static/assets/{chunk-4BX2VUAB-BbwsYcbW.js → chunk-4BX2VUAB-CsWIQRhc.js} +1 -1
  234. ksadk/server/static/assets/{chunk-55IACEB6-DOairxzw.js → chunk-55IACEB6-BK8paA20.js} +1 -1
  235. ksadk/server/static/assets/{chunk-727SXJPM-hCO487gs.js → chunk-727SXJPM-B-JEhy7h.js} +1 -1
  236. ksadk/server/static/assets/{chunk-AQP2D5EJ-XU8vDOuG.js → chunk-AQP2D5EJ-DYBvMHAa.js} +1 -1
  237. ksadk/server/static/assets/{chunk-FMBD7UC4-PrapyfMu.js → chunk-FMBD7UC4-BGhvzDYt.js} +1 -1
  238. ksadk/server/static/assets/{chunk-ND2GUHAM-CboKhTZ3.js → chunk-ND2GUHAM-D98vPd-E.js} +1 -1
  239. ksadk/server/static/assets/chunk-QZHKN3VN-YQIEXeF6.js +1 -0
  240. ksadk/server/static/assets/classDiagram-4FO5ZUOK-CDAUuoXK.js +1 -0
  241. ksadk/server/static/assets/classDiagram-v2-Q7XG4LA2-CDAUuoXK.js +1 -0
  242. ksadk/server/static/assets/{cose-bilkent-S5V4N54A-_N8bJ-EU.js → cose-bilkent-S5V4N54A-CX3yotHq.js} +1 -1
  243. ksadk/server/static/assets/{dagre-BM42HDAG-FoDlo5J2.js → dagre-BM42HDAG-C7TguAWo.js} +1 -1
  244. ksadk/server/static/assets/{diagram-2AECGRRQ-BBp6ebBw.js → diagram-2AECGRRQ-Dd17_kws.js} +1 -1
  245. ksadk/server/static/assets/{diagram-5GNKFQAL-CqOvG1PR.js → diagram-5GNKFQAL-DVIClIil.js} +1 -1
  246. ksadk/server/static/assets/{diagram-KO2AKTUF-O9T3t6ba.js → diagram-KO2AKTUF-Df_3gYQa.js} +1 -1
  247. ksadk/server/static/assets/{diagram-LMA3HP47-CGG8ekBO.js → diagram-LMA3HP47-BhLk6ZtA.js} +1 -1
  248. ksadk/server/static/assets/{diagram-OG6HWLK6-4BmH1EEg.js → diagram-OG6HWLK6-D4GNGvuu.js} +1 -1
  249. ksadk/server/static/assets/{dist-CiZHB6ZW.js → dist-6CVKuTAe.js} +1 -1
  250. ksadk/server/static/assets/{dist-BMKM66B4.js → dist-BUHRxvDG.js} +1 -1
  251. ksadk/server/static/assets/{dist-CZ6C3x8B.js → dist-BZ9n9AjO.js} +1 -1
  252. ksadk/server/static/assets/{dist-DbaH3p5h.js → dist-BibcG1V9.js} +1 -1
  253. ksadk/server/static/assets/{dist-BVzcWa6D.js → dist-Bs-jQcRX.js} +1 -1
  254. ksadk/server/static/assets/{dist-D99wf6_X.js → dist-C2xjE7cO.js} +1 -1
  255. ksadk/server/static/assets/{dist-DLMjL01k.js → dist-C7-3jInC.js} +1 -1
  256. ksadk/server/static/assets/{dist-DI-oVAXw.js → dist-C99THtIA.js} +1 -1
  257. ksadk/server/static/assets/{dist-BkJjNih3.js → dist-CGcp2ex8.js} +1 -1
  258. ksadk/server/static/assets/{dist-D5JGcxBx.js → dist-CVQRZjPu.js} +1 -1
  259. ksadk/server/static/assets/{dist-CWiTDN_V.js → dist-Cf23NY4L.js} +1 -1
  260. ksadk/server/static/assets/{dist-DLeM1OCK.js → dist-CgF3fj15.js} +1 -1
  261. ksadk/server/static/assets/{dist-BmuTWcm7.js → dist-Cwt7koer.js} +1 -1
  262. ksadk/server/static/assets/{dist-C2DKRFVK.js → dist-D82mtD2s.js} +1 -1
  263. ksadk/server/static/assets/{dist-B67uF2uK.js → dist-DF06ORQo.js} +1 -1
  264. ksadk/server/static/assets/{dist-Cwhk4lwI.js → dist-DHn6nBzr.js} +1 -1
  265. ksadk/server/static/assets/{dist-S4klkK5W.js → dist-Djgl2wVZ.js} +1 -1
  266. ksadk/server/static/assets/{dist-DCpZrjaK.js → dist-DlzCRy0S.js} +1 -1
  267. ksadk/server/static/assets/{dist-Cp1_jKwf.js → dist-DqNnNFrw.js} +1 -1
  268. ksadk/server/static/assets/{dist-6kSZvCZh.js → dist-DyvYLMW9.js} +1 -1
  269. ksadk/server/static/assets/{dist-DM0rEQPk.js → dist-eoW6tZXA.js} +1 -1
  270. ksadk/server/static/assets/{dist-Bk1l05Ni.js → dist-ivOya1LD.js} +1 -1
  271. ksadk/server/static/assets/{erDiagram-TEJ5UH35-oJhaci7S.js → erDiagram-TEJ5UH35-CnfRcY2M.js} +1 -1
  272. ksadk/server/static/assets/eventmodeling-FCH6USID-ChYO-ijM.js +1 -0
  273. ksadk/server/static/assets/{flowDiagram-I6XJVG4X-DtTmUan-.js → flowDiagram-I6XJVG4X-DGaJjGNJ.js} +1 -1
  274. ksadk/server/static/assets/{ganttDiagram-6RSMTGT7-BSsb_eij.js → ganttDiagram-6RSMTGT7-DpCoB2g9.js} +1 -1
  275. ksadk/server/static/assets/gitGraph-WXDBUCRP-BN_0Fk-j.js +1 -0
  276. ksadk/server/static/assets/{gitGraphDiagram-PVQCEYII-BR5s6Pws.js → gitGraphDiagram-PVQCEYII-9IKVFBVT.js} +1 -1
  277. ksadk/server/static/assets/index-QSwCHuB8.js +177 -0
  278. ksadk/server/static/assets/index-jOPmTOZH.css +2 -0
  279. ksadk/server/static/assets/info-J43DQDTF-Cp6o3vId.js +1 -0
  280. ksadk/server/static/assets/{infoDiagram-5YYISTIA-DYPc5cvh.js → infoDiagram-5YYISTIA-CYIIsb8y.js} +1 -1
  281. ksadk/server/static/assets/{ishikawaDiagram-YF4QCWOH-MoBdALDg.js → ishikawaDiagram-YF4QCWOH-DLOHGvWw.js} +1 -1
  282. ksadk/server/static/assets/{journeyDiagram-JHISSGLW-DgLiQT8l.js → journeyDiagram-JHISSGLW-BIzKeejT.js} +1 -1
  283. ksadk/server/static/assets/{kanban-definition-UN3LZRKU-CsFx112F.js → kanban-definition-UN3LZRKU-BWHoFSw2.js} +1 -1
  284. ksadk/server/static/assets/{katex-DRsiwpM2.js → katex-DATssjp1.js} +1 -1
  285. ksadk/server/static/assets/{linear-TNRQ5Orv.js → linear-CvMBw9cA.js} +1 -1
  286. ksadk/server/static/assets/{mermaid-parser.core-D2eMVOgo.js → mermaid-parser.core-mGAcEwcq.js} +2 -2
  287. ksadk/server/static/assets/{mindmap-definition-RKZ34NQL-BtByYFhL.js → mindmap-definition-RKZ34NQL-CSAQxEAe.js} +1 -1
  288. ksadk/server/static/assets/packet-YPE3B663-wQzct3cT.js +1 -0
  289. ksadk/server/static/assets/pie-LRSECV5Y-BgTQiv-9.js +1 -0
  290. ksadk/server/static/assets/{pieDiagram-4H26LBE5-DPnuCB8c.js → pieDiagram-4H26LBE5-By-ra07M.js} +1 -1
  291. ksadk/server/static/assets/{quadrantDiagram-W4KKPZXB-miFpCPv4.js → quadrantDiagram-W4KKPZXB-CxIR4JXI.js} +1 -1
  292. ksadk/server/static/assets/radar-GUYGQ44K-BIq5fXsn.js +1 -0
  293. ksadk/server/static/assets/{requirementDiagram-4Y6WPE33-mjB_yYkW.js → requirementDiagram-4Y6WPE33-DpW90iu3.js} +1 -1
  294. ksadk/server/static/assets/{sankeyDiagram-5OEKKPKP-D7hSlZZZ.js → sankeyDiagram-5OEKKPKP-DpGa6EAV.js} +1 -1
  295. ksadk/server/static/assets/{sequenceDiagram-3UESZ5HK-D9Cx6hC3.js → sequenceDiagram-3UESZ5HK-NTKNGGe5.js} +1 -1
  296. ksadk/server/static/assets/{stateDiagram-AJRCARHV-D_zrg47o.js → stateDiagram-AJRCARHV-DGpd9yFG.js} +1 -1
  297. ksadk/server/static/assets/stateDiagram-v2-BHNVJYJU-B3o579IQ.js +1 -0
  298. ksadk/server/static/assets/{timeline-definition-PNZ67QCA-CwSec0Wa.js → timeline-definition-PNZ67QCA-jqoYs3jQ.js} +1 -1
  299. ksadk/server/static/assets/treeView-BLDUP644-Dqi-E0EM.js +1 -0
  300. ksadk/server/static/assets/treemap-LRROVOQU-ClQP9CJ9.js +1 -0
  301. ksadk/server/static/assets/{vennDiagram-CIIHVFJN-agCVobiF.js → vennDiagram-CIIHVFJN-l3Z7-wbP.js} +1 -1
  302. ksadk/server/static/assets/wardley-L42UT6IY-8XLvnNIs.js +1 -0
  303. ksadk/server/static/assets/{wardleyDiagram-YWT4CUSO-TmrlhWNt.js → wardleyDiagram-YWT4CUSO-BR54GPmr.js} +1 -1
  304. ksadk/server/static/assets/{xychartDiagram-2RQKCTM6-DV71V-ah.js → xychartDiagram-2RQKCTM6-SkiH8GsK.js} +1 -1
  305. ksadk/server/static/index.html +2 -2
  306. ksadk/server/terminal_sessions.py +111 -58
  307. ksadk/sessions/__init__.py +86 -40
  308. ksadk/sessions/base.py +31 -11
  309. ksadk/sessions/continuity.py +28 -5
  310. ksadk/sessions/in_memory.py +46 -2
  311. ksadk/sessions/local_service.py +148 -17
  312. ksadk/sessions/postgres_service.py +216 -35
  313. ksadk/sessions/resilience.py +52 -0
  314. ksadk/sessions/resilient.py +388 -0
  315. ksadk/skills/models.py +17 -5
  316. ksadk/skills/package_store.py +5 -2
  317. ksadk/skills/runtime/agent.py +20 -5
  318. ksadk/skills/runtime/artifacts.py +1 -5
  319. ksadk/skills/runtime/backends/e2b.py +10 -4
  320. ksadk/skills/runtime/backends/local.py +15 -3
  321. ksadk/skills/runtime/base.py +1 -2
  322. ksadk/skills/runtime/executor.py +10 -3
  323. ksadk/skills/runtime/factory.py +4 -2
  324. ksadk/skills/runtime/loader.py +1 -2
  325. ksadk/skills/runtime/registry.py +15 -10
  326. ksadk/skills/runtime/request.py +3 -1
  327. ksadk/skills/service_client.py +38 -17
  328. ksadk/skills/tool_defs.py +15 -10
  329. ksadk/terminal_exec_policy.py +3 -4
  330. ksadk/tools/gateway.py +92 -15
  331. ksadk/tools/result_budget.py +12 -4
  332. ksadk/toolsets/__init__.py +136 -36
  333. ksadk/toolsets/platform.py +21 -5
  334. ksadk/toolsets/sandbox.py +82 -16
  335. ksadk/toolsets/skills.py +200 -39
  336. ksadk/toolsets/web.py +123 -26
  337. ksadk/toolsets/workspace.py +264 -114
  338. ksadk/tracing/exporters/inmemory_exporter.py +62 -51
  339. ksadk/tracing/exporters/langfuse_exporter.py +91 -76
  340. ksadk/tracing/setup.py +132 -53
  341. ksadk/tracing/span_utils.py +5 -2
  342. ksadk/tui/__init__.py +22 -7
  343. ksadk/tui/loop.py +1925 -0
  344. ksadk/tui/stream_render.py +69 -0
  345. ksadk/ui_config.py +5 -3
  346. ksadk/version.py +1 -1
  347. ksadk_runtime_common/schemas/runtime_event_v1.json +95 -0
  348. agentengine_sdk_python-0.6.9.dist-info/RECORD +0 -487
  349. ksadk/server/static/assets/CodeBlock-BbI4HPtJ.js +0 -9
  350. ksadk/server/static/assets/MathMessageMarkdown-DMLQYoLp.js +0 -8
  351. ksadk/server/static/assets/MermaidBlock-DCR8WZ17.js +0 -303
  352. ksadk/server/static/assets/NativeTerminalPanel-CKUvWhUz.js +0 -1
  353. ksadk/server/static/assets/architecture-7EHR7CIX-Cr6Xz1_c.js +0 -1
  354. ksadk/server/static/assets/channel-BSSOwFIb.js +0 -1
  355. ksadk/server/static/assets/chunk-QZHKN3VN-XXpdiwUL.js +0 -1
  356. ksadk/server/static/assets/classDiagram-4FO5ZUOK-C5HvGiyK.js +0 -1
  357. ksadk/server/static/assets/classDiagram-v2-Q7XG4LA2-C5HvGiyK.js +0 -1
  358. ksadk/server/static/assets/eventmodeling-FCH6USID-CIgwHJLL.js +0 -1
  359. ksadk/server/static/assets/gitGraph-WXDBUCRP-BVAJ7uo0.js +0 -1
  360. ksadk/server/static/assets/index-BH7-pC0B.js +0 -165
  361. ksadk/server/static/assets/index-DAURbCsP.css +0 -1
  362. ksadk/server/static/assets/info-J43DQDTF-MT80V4ge.js +0 -1
  363. ksadk/server/static/assets/packet-YPE3B663-BLKhbXZB.js +0 -1
  364. ksadk/server/static/assets/pie-LRSECV5Y-DFOikjSY.js +0 -1
  365. ksadk/server/static/assets/radar-GUYGQ44K-095b1Ikw.js +0 -1
  366. ksadk/server/static/assets/stateDiagram-v2-BHNVJYJU-J9kCb0nC.js +0 -1
  367. ksadk/server/static/assets/treeView-BLDUP644-yinfJEY6.js +0 -1
  368. ksadk/server/static/assets/treemap-LRROVOQU-DosUqN1Q.js +0 -1
  369. ksadk/server/static/assets/wardley-L42UT6IY-Bvp7-5uH.js +0 -1
  370. ksadk/tui/app.py +0 -400
  371. ksadk/tui/clipboard.py +0 -100
  372. ksadk/tui/widgets/__init__.py +0 -22
  373. ksadk/tui/widgets/assistant.py +0 -89
  374. ksadk/tui/widgets/base.py +0 -54
  375. ksadk/tui/widgets/chat_input.py +0 -246
  376. ksadk/tui/widgets/history.py +0 -156
  377. ksadk/tui/widgets/system.py +0 -87
  378. ksadk/tui/widgets/thinking.py +0 -110
  379. ksadk/tui/widgets/tool_call.py +0 -161
  380. ksadk/tui/widgets/user.py +0 -37
  381. {agentengine_sdk_python-0.6.9.dist-info → agentengine_sdk_python-0.8.0.dist-info}/WHEEL +0 -0
  382. {agentengine_sdk_python-0.6.9.dist-info → agentengine_sdk_python-0.8.0.dist-info}/entry_points.txt +0 -0
  383. {agentengine_sdk_python-0.6.9.dist-info → agentengine_sdk_python-0.8.0.dist-info}/licenses/LICENSE +0 -0
  384. {agentengine_sdk_python-0.6.9.dist-info → agentengine_sdk_python-0.8.0.dist-info}/top_level.txt +0 -0
@@ -5,16 +5,21 @@ ADKRunner - Google ADK 框架运行时
5
5
  支持通过环境变量配置记忆体 (ShortTermMemory / LongTermMemory)。
6
6
  """
7
7
 
8
+ import asyncio
8
9
  import base64
9
10
  import inspect
10
11
  import logging
11
12
  import os
12
13
  import sys
14
+ from dataclasses import dataclass
15
+ from importlib.metadata import PackageNotFoundError
16
+ from importlib.metadata import version as _pkg_version
13
17
  from pathlib import Path
14
- from typing import Any, AsyncIterator, Dict, Mapping, Optional
18
+ from typing import Any, AsyncIterator, Dict, Mapping, Optional, cast
15
19
 
16
20
  from opentelemetry import trace
17
21
 
22
+ from ksadk.compat.adk_compat import genai_types as types
18
23
  from ksadk.conversations.attachments import classify_attachment_kind, read_attachment_uri_bytes
19
24
  from ksadk.conversations.model_context import supports_native_image_input
20
25
  from ksadk.runners.base_runner import BaseRunner
@@ -26,24 +31,48 @@ logger = logging.getLogger(__name__)
26
31
  tracer = trace.get_tracer(__name__)
27
32
 
28
33
 
34
+ def _part_metadata_flag(part: Any, key: str) -> bool:
35
+ """读取 A2A→GenAI Part 保留下来的扩展 metadata 标记。"""
36
+ metadata = getattr(part, "part_metadata", None)
37
+ if isinstance(metadata, Mapping):
38
+ return bool(metadata.get(key))
39
+ getter = getattr(metadata, "get", None)
40
+ if callable(getter):
41
+ try:
42
+ return bool(getter(key))
43
+ except (KeyError, TypeError, ValueError):
44
+ return False
45
+ return False
46
+
47
+
29
48
  class ADKRunner(BaseRunner):
30
49
  """ADK 框架运行时"""
31
50
 
32
51
  def __init__(self, detection_result: Any, project_dir: str):
33
52
  super().__init__(detection_result, project_dir)
34
- self._runner = None
35
- self._session_service = None
53
+ self._runner: Any = None
54
+ self._session_service: Any = None
36
55
  # Map external session_ids (e.g. from run_interactive or web) to ADK internal session IDs
37
56
  self._session_map: Dict[str, str] = {}
38
57
  # Fallback default session
39
58
  self._default_session_id: Optional[str] = None
40
59
  # Memory integration
41
- self._short_term_memory = None
42
- self._long_term_memory = None
60
+ self._short_term_memory: Any = None
61
+ self._long_term_memory: Any = None
43
62
  # Knowledge base integration
44
- self._knowledge_base = None
63
+ self._knowledge_base: Any = None
45
64
  # Keep runtime toolsets alive for the lifetime of the runner.
46
65
  self._runtime_toolsets: list[Any] = []
66
+ # ADK resumability state
67
+ self._resumable: bool = False
68
+ self._resume_disabled_reason: Optional[str] = None
69
+ # P1.1 sub-issue: guard invocation_map read-modify-write so concurrent
70
+ # invocations on the same session don't lose each other's mappings.
71
+ self._invocation_map_lock = asyncio.Lock()
72
+ self._module: Any = None
73
+ self._adk_resume_min_version = os.environ.get(
74
+ "GOOGLE_ADK_RESUME_MIN_VERSION", "1.16.0"
75
+ ).strip()
47
76
 
48
77
  async def close(self) -> None:
49
78
  """Close runtime toolsets owned by this runner."""
@@ -61,48 +90,149 @@ class ADKRunner(BaseRunner):
61
90
  logger.warning("Failed to close runtime toolset %r: %s", toolset, exc)
62
91
 
63
92
  def _apply_json_patch(self):
64
- """Monkey patch google.adk.models.lite_llm to handle invalid JSON safely"""
93
+ """Patch ADK LiteLlm to handle malformed JSON in tool-call arguments.
94
+
95
+ The previous RobustJson approach replaced the entire json module inside
96
+ lite_llm.py, which broke ADK's streaming args-completeness detection:
97
+ ADK uses ``try: json.loads(args) except json.JSONDecodeError: pass``
98
+ to decide whether accumulated streaming args form a complete JSON
99
+ object. When RobustJson.loads swallowed the exception (or even when
100
+ it re-raised after json_repair "fixed" incomplete fragments like
101
+ ``{`` into ``{}``), fallback_index incremented on every args fragment,
102
+ causing a single tool call to fragment into multiple entries with
103
+ empty names -- the "Tool '' not found" error.
104
+
105
+ The new approach is surgical: we do NOT replace the json module at
106
+ all. Instead, we patch _message_to_generate_content_response to
107
+ tolerate malformed JSON in the FINAL assembled arguments only.
108
+ The streaming args detection continues using stdlib json.loads and
109
+ gets proper JSONDecodeError for incomplete fragments.
110
+ """
65
111
  try:
66
- import json
112
+ import inspect as _inspect
113
+ import json as _stdlib_json
67
114
 
68
- import google.adk.models.lite_llm as adk_lite_llm
115
+ from ksadk.compat.adk_compat import lite_llm_module
69
116
 
70
- # Create a proxy for the json module
71
- class RobustJson:
72
- def __getattr__(self, name):
73
- return getattr(json, name)
117
+ adk_lite_llm = lite_llm_module()
74
118
 
75
- def loads(self, s, **kwargs):
76
- result = {}
77
- try:
78
- result = json.loads(s, **kwargs)
79
- except json.JSONDecodeError:
80
- # Try json_repair if available
81
- try:
82
- import json_repair
83
-
84
- result = json_repair.loads(s)
85
- except ImportError:
86
- # Fallback: return empty dict to prevent crash
87
- print(
88
- f"\n⚠️ [KSADK] Warning: Captured invalid JSON from LLM: {s[:50]}..."
89
- )
90
- result = {}
119
+ _original_fn = adk_lite_llm._message_to_generate_content_response
120
+ if getattr(_original_fn, "__ksadk_json_patch__", False):
121
+ return
122
+ # Determine which kwargs the original function actually accepts,
123
+ # so the patch is compatible across ADK versions that may have
124
+ # added or removed `model_version` / `thought_parts`.
125
+ _orig_params = set(_inspect.signature(_original_fn).parameters.keys())
91
126
 
92
- # Ensure result is a dict (Google GenAI FunctionCall requires dict args)
93
- if not isinstance(result, dict):
94
- return {}
95
- return result
127
+ def _patched_message_to_generate_content_response(
128
+ message, *, is_partial=False, model_version=None, thought_parts=None
129
+ ):
130
+ """Wrapper that catches JSONDecodeError in final args parsing."""
131
+ # Only forward kwargs that the original function accepts,
132
+ # avoiding TypeError on older ADK versions that lack
133
+ # `model_version` or `thought_parts`.
134
+ forward_kwargs = {"is_partial": is_partial}
135
+ if "model_version" in _orig_params:
136
+ forward_kwargs["model_version"] = model_version
137
+ if "thought_parts" in _orig_params:
138
+ forward_kwargs["thought_parts"] = thought_parts
139
+ try:
140
+ result = _original_fn(message, **forward_kwargs)
141
+ except _stdlib_json.JSONDecodeError:
142
+ logger.warning(
143
+ "ADKRunner: caught JSONDecodeError in args parsing, "
144
+ "returning empty response"
145
+ )
146
+ from ksadk.compat.adk_compat import LlmResponse
147
+ from ksadk.compat.adk_compat import genai_types as _genai_types
96
148
 
97
- # Replace the 'json' module reference INSIDE lite_llm module
98
- # This is safer than patching json.loads globally
99
- adk_lite_llm.json = RobustJson()
149
+ return LlmResponse(
150
+ content=_genai_types.Content(role="model", parts=[]),
151
+ partial=is_partial,
152
+ model_version=model_version,
153
+ )
154
+ # If the response has function_call parts with empty args,
155
+ # fill in {} as fallback (this was what RobustJson used to do,
156
+ # but now only at the final output stage, not during streaming).
157
+ # Also strip phantom function_calls whose name is empty or
158
+ # whitespace-only — these are streaming artifacts produced by
159
+ # the old ADK fallback_index mechanism when parallel tool-call
160
+ # fragments are mis-assembled.
161
+ phantom_indices = set()
162
+ if result.content and result.content.parts:
163
+ for i, part in enumerate(result.content.parts):
164
+ if part.function_call and part.function_call.args is None:
165
+ part.function_call.args = {}
166
+ if part.function_call and not (part.function_call.name or "").strip():
167
+ phantom_indices.add(i)
168
+ if phantom_indices:
169
+ result.content.parts = [
170
+ p for i, p in enumerate(result.content.parts) if i not in phantom_indices
171
+ ]
172
+ return result
173
+
174
+ _patched_message_to_generate_content_response.__ksadk_json_patch__ = True
175
+ adk_lite_llm._message_to_generate_content_response = (
176
+ _patched_message_to_generate_content_response
177
+ )
178
+
179
+ # Don't patch _model_response_to_generate_content_response since
180
+ # it calls _message_to_generate_content_response internally, and
181
+ # that's already patched. Double-patching would cause recursion.
182
+
183
+ logger.info(
184
+ "ADKRunner: Applied surgical args-parsing patch "
185
+ "(stdlib json preserved, streaming detection intact)"
186
+ )
100
187
 
101
188
  except ImportError:
102
189
  pass # ADK not installed
103
190
  except Exception:
104
191
  pass
105
192
 
193
+ def _apply_mcp_result_patch(self):
194
+ """Patch ADK McpTool to convert CallToolResult to dict.
195
+
196
+ Old ADK (1.14.1) McpTool._run_async_impl returns the raw
197
+ CallToolResult Pydantic object from session.call_tool(), which
198
+ cannot be JSON-serialized when ADK builds the FunctionResponse.
199
+ New ADK (1.34.0) added response.model_dump(exclude_none=True,
200
+ mode="json") before returning. This patch replicates that fix
201
+ for the old version.
202
+ """
203
+ try:
204
+ from ksadk.compat.adk_compat import McpTool
205
+
206
+ _original_run_async = McpTool._run_async_impl
207
+ if getattr(_original_run_async, "__ksadk_mcp_result_patch__", False):
208
+ return
209
+
210
+ async def _patched_run_async_impl(self, *, args, tool_context, credential):
211
+ response = await _original_run_async(
212
+ self,
213
+ args=args,
214
+ tool_context=tool_context,
215
+ credential=credential,
216
+ )
217
+ # If response is a Pydantic model (e.g. CallToolResult),
218
+ # convert to dict so it can be JSON-serialized downstream.
219
+ if hasattr(response, "model_dump"):
220
+ return response.model_dump(exclude_none=True, mode="json")
221
+ return response
222
+
223
+ _patched_run_async_impl.__ksadk_mcp_result_patch__ = True
224
+ McpTool._run_async_impl = _patched_run_async_impl
225
+
226
+ logger.info(
227
+ "ADKRunner: Applied MCP result serialization patch "
228
+ "(CallToolResult -> dict via model_dump)"
229
+ )
230
+
231
+ except ImportError:
232
+ pass # ADK or MCP not installed
233
+ except Exception as exc:
234
+ logger.debug("ADKRunner: MCP result patch failed: %s", exc)
235
+
106
236
  def _init_short_term_memory(self):
107
237
  """从环境变量初始化短期记忆
108
238
 
@@ -144,6 +274,33 @@ class ADKRunner(BaseRunner):
144
274
  return ADKSessionAdapter()
145
275
 
146
276
  def describe_checkpoint_capability(self) -> dict[str, Any]:
277
+ resumable = getattr(self, "_resumable", False)
278
+ stm_backend = (
279
+ getattr(self._short_term_memory, "backend", None) if self._short_term_memory else None
280
+ )
281
+ if resumable:
282
+ backend = "adk_invocation"
283
+ if stm_backend == "sqlite":
284
+ backend = "adk_invocation+sqlite"
285
+ elif stm_backend == "database":
286
+ backend = "adk_invocation+postgres"
287
+ shared_across_pods = stm_backend == "database"
288
+ return {
289
+ "Supported": shared_across_pods,
290
+ "Backend": backend,
291
+ "Scope": "invocation",
292
+ "Durable": stm_backend is not None and stm_backend != "local",
293
+ "SharedAcrossPods": shared_across_pods,
294
+ "LocalOnly": not shared_across_pods,
295
+ "ResumeMode": "invocation_id",
296
+ "Reason": (
297
+ "ADK ResumabilityConfig and shared database session backend enabled; "
298
+ "resume via invocation_id"
299
+ if shared_across_pods
300
+ else "ADK ResumabilityConfig enabled, but the session backend is "
301
+ "process-local or SQLite and cannot support cross-pod recovery"
302
+ ),
303
+ }
147
304
  return {
148
305
  "Supported": False,
149
306
  "Backend": "none",
@@ -151,37 +308,208 @@ class ADKRunner(BaseRunner):
151
308
  "Durable": False,
152
309
  "SharedAcrossPods": False,
153
310
  "ResumeMode": "forward_only",
154
- "Reason": "ADK native session can continue conversation context, but KSADK does not expose ADK framework checkpoint restore points",
311
+ "Reason": (
312
+ self._resume_disabled_reason
313
+ or "ADK ResumabilityConfig not enabled; set "
314
+ "KSADK_ADK_RESUMABLE=1 or configure App with resumability_config"
315
+ ),
155
316
  }
156
317
 
157
318
  def get_runtime_capabilities(self) -> dict[str, Any]:
158
319
  capabilities = super().get_runtime_capabilities()
159
- has_native_session = bool(getattr(self, "_short_term_memory", None)) or any(
160
- str(os.getenv(name) or "").strip()
161
- for name in (
162
- "KSADK_ADK_SESSION_BACKEND",
163
- "KSADK_ADK_SESSION_PATH",
164
- "KSADK_ADK_SESSION_URL",
165
- "KSADK_STM_BACKEND",
166
- "KSADK_STM_PATH",
167
- "KSADK_STM_URL",
168
- "KSADK_STM_DB_PATH",
169
- "KSADK_STM_DB_URL",
170
- "KSADK_SESSION_BACKEND",
171
- "KSADK_SESSION_DSN",
172
- )
320
+ resumable = getattr(self, "_resumable", False)
321
+ stm_backend = (
322
+ getattr(self._short_term_memory, "backend", None) if self._short_term_memory else None
173
323
  )
174
- capabilities["SessionContinuity"] = {
175
- "Supported": True,
176
- "Type": "native_session" if has_native_session else "semantic_replay",
177
- "Level": "semantic",
178
- "Reason": "ADK native session can continue conversation context"
179
- if has_native_session
180
- else "conversation transcript can be replayed",
181
- }
324
+ is_durable = stm_backend is not None and stm_backend != "local"
325
+ if resumable:
326
+ # P1.3: Level must degrade with backend — in_memory session state
327
+ # cannot survive pod restarts, so "runtime" is misleading.
328
+ level = "runtime" if is_durable else "semantic"
329
+ capabilities["SessionContinuity"] = {
330
+ "Supported": True,
331
+ "Type": "adk_invocation",
332
+ "Level": level,
333
+ "Reason": (
334
+ "ADK ResumabilityConfig enabled with durable session backend, "
335
+ "invocation_id-based checkpoint resume available"
336
+ if is_durable
337
+ else "ADK ResumabilityConfig enabled but session state is in-memory; "
338
+ "resume only works within the same process lifetime"
339
+ ),
340
+ }
341
+ else:
342
+ has_native_session = bool(getattr(self, "_short_term_memory", None)) or any(
343
+ str(os.getenv(name) or "").strip()
344
+ for name in (
345
+ "KSADK_ADK_SESSION_BACKEND",
346
+ "KSADK_ADK_SESSION_PATH",
347
+ "KSADK_ADK_SESSION_URL",
348
+ "KSADK_STM_BACKEND",
349
+ "KSADK_STM_PATH",
350
+ "KSADK_STM_URL",
351
+ "KSADK_STM_DB_PATH",
352
+ "KSADK_STM_DB_URL",
353
+ "KSADK_SESSION_BACKEND",
354
+ "KSADK_SESSION_DSN",
355
+ )
356
+ )
357
+ capabilities["SessionContinuity"] = {
358
+ "Supported": True,
359
+ "Type": "native_session" if has_native_session else "semantic_replay",
360
+ "Level": "semantic",
361
+ "Reason": (
362
+ "ADK native session can continue conversation context"
363
+ if has_native_session
364
+ else "conversation transcript can be replayed"
365
+ ),
366
+ }
182
367
  capabilities["ResumeRun"]["Reason"] = capabilities["Checkpoint"]["Reason"]
183
368
  return capabilities
184
369
 
370
+ @dataclass
371
+ class _ResolvabilityResult:
372
+ enabled: bool
373
+ source: str # "agent_module" | "env" | "auto_persistent_session" | "default"
374
+ app: Any # User module exported App object (if any)
375
+
376
+ def _resolve_resumability(self) -> "_ResolvabilityResult":
377
+ """从环境变量或 agent 模块推断是否启用 ADK 可恢复性。"""
378
+ # Priority 1: agent module exports app object with resumability_config
379
+ module = getattr(self, "_module", None)
380
+ if module is not None:
381
+ try:
382
+ from ksadk.compat.adk_compat import App
383
+
384
+ for attr_name in ("app", "application"):
385
+ candidate = getattr(module, attr_name, None)
386
+ if isinstance(candidate, App) and candidate.resumability_config:
387
+ if candidate.resumability_config.is_resumable:
388
+ return self._ResolvabilityResult(
389
+ enabled=True, source="agent_module", app=candidate
390
+ )
391
+ except ImportError:
392
+ pass
393
+
394
+ # Priority 2: environment variable explicit control
395
+ env_val = os.environ.get("KSADK_ADK_RESUMABLE", "").strip().lower()
396
+ if env_val in ("1", "true", "yes"):
397
+ return self._ResolvabilityResult(enabled=True, source="env", app=None)
398
+
399
+ # Priority 3: persistent session backend auto-enable
400
+ stm_backend = (
401
+ getattr(self._short_term_memory, "backend", None) if self._short_term_memory else None
402
+ )
403
+ if stm_backend and stm_backend != "local":
404
+ return self._ResolvabilityResult(
405
+ enabled=True, source="auto_persistent_session", app=None
406
+ )
407
+
408
+ return self._ResolvabilityResult(enabled=False, source="default", app=None)
409
+
410
+ @staticmethod
411
+ def _get_adk_version() -> Optional[str]:
412
+ """返回当前安装的 google-adk 版本号,无法获取时返回 None。"""
413
+ try:
414
+ return _pkg_version("google-adk")
415
+ except PackageNotFoundError:
416
+ return None
417
+
418
+ def _check_adk_resume_compatibility(self) -> tuple[bool, str]:
419
+ """检查当前 google-adk 版本是否支持恢复 (invocation_id)。
420
+
421
+ Returns:
422
+ (compatible, reason) — compatible=True 表示版本足够;reason 为不兼容时的说明文本。
423
+ """
424
+ adk_ver = self._get_adk_version()
425
+ if adk_ver is None:
426
+ # 无法获取版本信息,保守地认为不兼容
427
+ return False, "google-adk version unknown, cannot guarantee invocation_id support"
428
+ try:
429
+ from packaging.version import Version
430
+
431
+ if Version(adk_ver) < Version(self._adk_resume_min_version):
432
+ return (
433
+ False,
434
+ f"google-adk {adk_ver} < {self._adk_resume_min_version}, "
435
+ f"run_async() does not accept invocation_id",
436
+ )
437
+ except Exception:
438
+ # packaging 不可用时,保守地认为不兼容
439
+ return False, "cannot compare google-adk version, assuming incompatible"
440
+ return True, ""
441
+
442
+ def _build_runner(self) -> None:
443
+ """构造 ADK Runner,优先使用 App 对象以启用 ResumabilityConfig。"""
444
+ from ksadk.compat.adk_compat import Runner
445
+
446
+ resumable = self._resolve_resumability()
447
+ resumability_enabled = resumable.enabled
448
+
449
+ # 版本兼容性检查:低于最低版本时强制关闭恢复
450
+ resume_compatible, resume_reason = self._check_adk_resume_compatibility()
451
+ if not resume_compatible and resumable.enabled:
452
+ logger.warning("ADKRunner: resumability disabled — %s", resume_reason)
453
+ resumable = self._ResolvabilityResult(enabled=False, source="version_check", app=None)
454
+ resumability_enabled = False
455
+ self._resume_disabled_reason = resume_reason
456
+
457
+ if resumable.app is not None:
458
+ runner_kwargs = dict(
459
+ app=resumable.app,
460
+ session_service=self._session_service,
461
+ )
462
+ elif resumable.enabled:
463
+ try:
464
+ from ksadk.compat.adk_compat import App, ResumabilityConfig
465
+
466
+ app = App(
467
+ name=self._agent.name,
468
+ root_agent=self._agent,
469
+ resumability_config=ResumabilityConfig(is_resumable=True),
470
+ )
471
+ runner_kwargs = dict(
472
+ app=app,
473
+ session_service=self._session_service,
474
+ )
475
+ except ImportError:
476
+ logger.warning(
477
+ "ADK ResumabilityConfig not available (requires google-adk >= 1.14.0); "
478
+ "falling back to non-resumable Runner"
479
+ )
480
+ runner_kwargs = dict(
481
+ agent=self._agent,
482
+ session_service=self._session_service,
483
+ app_name=self._agent.name,
484
+ )
485
+ resumability_enabled = False
486
+ self._resume_disabled_reason = "ADK ResumabilityConfig is unavailable"
487
+ else:
488
+ runner_kwargs = dict(
489
+ agent=self._agent,
490
+ session_service=self._session_service,
491
+ app_name=self._agent.name,
492
+ )
493
+
494
+ if self._long_term_memory:
495
+ runner_kwargs["memory_service"] = self._long_term_memory
496
+ logger.info("ADKRunner: LongTermMemory injected as memory_service")
497
+
498
+ self._runner = Runner(**runner_kwargs)
499
+ self._resumable = resumability_enabled
500
+
501
+ if resumability_enabled:
502
+ stm_backend = (
503
+ getattr(self._short_term_memory, "backend", None)
504
+ if self._short_term_memory
505
+ else None
506
+ )
507
+ logger.info(
508
+ "ADKRunner: resumability enabled (source=%s, backend=%s)",
509
+ resumable.source,
510
+ stm_backend or "in_memory",
511
+ )
512
+
185
513
  def _init_long_term_memory(self):
186
514
  """从环境变量初始化长期记忆
187
515
 
@@ -202,10 +530,7 @@ class ADKRunner(BaseRunner):
202
530
 
203
531
  agent_name = self._agent.name if self._agent else "default"
204
532
  ltm = LongTermMemory.from_env(app_name=agent_name)
205
- logger.info(
206
- f"LongTermMemory initialized: backend={backend}, "
207
- f"app_name={agent_name}"
208
- )
533
+ logger.info(f"LongTermMemory initialized: backend={backend}, " f"app_name={agent_name}")
209
534
  return ltm
210
535
  except Exception as e:
211
536
  logger.warning(f"Failed to init LongTermMemory: {e}.")
@@ -229,8 +554,7 @@ class ADKRunner(BaseRunner):
229
554
 
230
555
  kb = KnowledgeBaseClient.from_env()
231
556
  logger.info(
232
- f"KnowledgeBase initialized: dataset_id={kb.dataset_id}, "
233
- f"region={kb.region}"
557
+ f"KnowledgeBase initialized: dataset_id={kb.dataset_id}, " f"region={kb.region}"
234
558
  )
235
559
  return kb
236
560
  except ImportError:
@@ -265,7 +589,7 @@ class ADKRunner(BaseRunner):
265
589
  def _inject_load_memory_tool(self):
266
590
  """自动注入 load_memory 工具到 Agent"""
267
591
  try:
268
- from google.adk.tools import load_memory
592
+ from ksadk.compat.adk_compat import load_memory
269
593
 
270
594
  added = self._append_tools_by_name([load_memory])
271
595
  if added:
@@ -391,7 +715,9 @@ class ADKRunner(BaseRunner):
391
715
  backend = (os.environ.get("KSADK_SANDBOX_BACKEND") or "").strip().lower()
392
716
  if backend and backend not in {"disabled", "none", "off"}:
393
717
  return "sandbox"
394
- if os.environ.get("KSADK_SANDBOX_TEMPLATE_ID") or os.environ.get("KSADK_SKILL_RUNTIME_TEMPLATE_ID"):
718
+ if os.environ.get("KSADK_SANDBOX_TEMPLATE_ID") or os.environ.get(
719
+ "KSADK_SKILL_RUNTIME_TEMPLATE_ID"
720
+ ):
395
721
  return "sandbox"
396
722
  skills_dir = Path(
397
723
  os.environ.get("KSADK_LOCAL_SKILLS_DIR")
@@ -458,13 +784,18 @@ class ADKRunner(BaseRunner):
458
784
  return
459
785
  added = self._append_tools_by_name(tools)
460
786
  if added:
461
- logger.info("Injected ksadk built-in tools into agent (added: %s)", ", ".join(added))
787
+ logger.info(
788
+ "Injected ksadk built-in tools into agent (added: %s)",
789
+ ", ".join(added),
790
+ )
462
791
  else:
463
792
  logger.debug("ksadk built-in tools already present")
464
793
  except Exception as exc:
465
794
  logger.warning("Failed to inject ksadk built-in tools: %s", exc)
466
795
 
467
- def inject_deferred_tools_for_request(self, tool_names: list[str] | tuple[str, ...]) -> list[str]:
796
+ def inject_deferred_tools_for_request(
797
+ self, tool_names: list[str] | tuple[str, ...]
798
+ ) -> list[str]:
468
799
  """Append direct built-in tools selected by deferred tool search."""
469
800
  names = [str(name or "").strip() for name in tool_names or [] if str(name or "").strip()]
470
801
  if not names:
@@ -475,7 +806,10 @@ class ADKRunner(BaseRunner):
475
806
  tools = get_agentengine_tools(include=names, profile="coding", mode="direct")
476
807
  added = self._append_tools_by_name(tools)
477
808
  if added:
478
- logger.info("Injected deferred ksadk tools for request (added: %s)", ", ".join(added))
809
+ logger.info(
810
+ "Injected deferred ksadk tools for request (added: %s)",
811
+ ", ".join(added),
812
+ )
479
813
  return added
480
814
  except Exception as exc:
481
815
  logger.warning("Failed to inject deferred ksadk tools for request: %s", exc)
@@ -483,7 +817,7 @@ class ADKRunner(BaseRunner):
483
817
 
484
818
  @staticmethod
485
819
  def _tool_name(tool: Any) -> str:
486
- return getattr(tool, "name", None) or getattr(tool, "__name__", "")
820
+ return str(getattr(tool, "name", None) or getattr(tool, "__name__", ""))
487
821
 
488
822
  def _append_tools_by_name(self, tools: list[Any]) -> list[str]:
489
823
  if not hasattr(self._agent, "tools"):
@@ -494,9 +828,7 @@ class ADKRunner(BaseRunner):
494
828
  self._agent.tools = []
495
829
 
496
830
  existing_names = {
497
- self._tool_name(tool)
498
- for tool in self._agent.tools
499
- if self._tool_name(tool)
831
+ self._tool_name(tool) for tool in self._agent.tools if self._tool_name(tool)
500
832
  }
501
833
  added_names: list[str] = []
502
834
  for tool in tools:
@@ -518,9 +850,7 @@ class ADKRunner(BaseRunner):
518
850
  self._agent.tools = []
519
851
 
520
852
  existing_keys = {
521
- getattr(tool, key_attr)
522
- for tool in self._agent.tools
523
- if getattr(tool, key_attr, None)
853
+ getattr(tool, key_attr) for tool in self._agent.tools if getattr(tool, key_attr, None)
524
854
  }
525
855
  added_keys: list[str] = []
526
856
  for toolset in toolsets:
@@ -533,6 +863,44 @@ class ADKRunner(BaseRunner):
533
863
  added_keys.append(key)
534
864
  return added_keys
535
865
 
866
+ def _invalid_agent_name_load_error(self, exc: Exception) -> ValueError | None:
867
+ """Convert ADK's import-time agent-name validation into an actionable error."""
868
+ errors = getattr(exc, "errors", None)
869
+ if not callable(errors):
870
+ return None
871
+ try:
872
+ details = errors()
873
+ except Exception:
874
+ return None
875
+ if not isinstance(details, list):
876
+ return None
877
+ for detail in details:
878
+ if not isinstance(detail, Mapping):
879
+ continue
880
+ location = detail.get("loc")
881
+ message = str(detail.get("msg") or "")
882
+ name = detail.get("input")
883
+ if location not in (("name",), ["name"]) or not isinstance(name, str):
884
+ continue
885
+ # adk 1.34 报 "...valid identifier";adk 2.x 报 "...valid Python identifier"。
886
+ # 放宽匹配以同时覆盖两种措辞,保证两版都给出 actionable hint。
887
+ msg_lower = message.lower()
888
+ if "identifier" not in msg_lower or "valid" not in msg_lower:
889
+ continue
890
+ safe_name = "".join(
891
+ char if char.isascii() and (char.isalnum() or char == "_") else "_" for char in name
892
+ )
893
+ if not safe_name or safe_name[0].isdigit():
894
+ safe_name = f"agent_{safe_name}"
895
+ return ValueError(
896
+ "ADK Agent 名称不合法: "
897
+ f"{name!r}。Google ADK 要求 `Agent(name=...)` 以英文字母或下划线开头,"
898
+ "且仅包含英文字母、数字和下划线。"
899
+ f"请在 {self.detection_result.entry_point} 中创建 "
900
+ f"{self.detection_result.agent_variable} 时改为,例如 {safe_name!r}。"
901
+ )
902
+ return None
903
+
536
904
  def load_agent(self) -> None:
537
905
  """加载 ADK Agent"""
538
906
  import warnings
@@ -540,6 +908,7 @@ class ADKRunner(BaseRunner):
540
908
  warnings.filterwarnings("ignore", category=UserWarning, module="pydantic.main")
541
909
 
542
910
  self._apply_json_patch()
911
+ self._apply_mcp_result_patch()
543
912
 
544
913
  # 添加项目目录到 Python 路径
545
914
  project_path = Path(self.project_dir).resolve()
@@ -559,6 +928,7 @@ class ADKRunner(BaseRunner):
559
928
 
560
929
  try:
561
930
  module = __import__(module_name, fromlist=[self.detection_result.agent_variable])
931
+ self._module = module
562
932
  self._agent = getattr(module, self.detection_result.agent_variable)
563
933
 
564
934
  # Inject safety instruction for DeepSeek/LLMs to prevent empty tool names
@@ -575,6 +945,11 @@ class ADKRunner(BaseRunner):
575
945
  raise AttributeError(
576
946
  f"模块 {module_name} 中未找到 {self.detection_result.agent_variable}"
577
947
  )
948
+ except Exception as exc:
949
+ name_error = self._invalid_agent_name_load_error(exc)
950
+ if name_error is not None:
951
+ raise name_error from exc
952
+ raise
578
953
 
579
954
  # 验证是否为 ADK Agent
580
955
  if not hasattr(self._agent, "name"):
@@ -590,8 +965,7 @@ class ADKRunner(BaseRunner):
590
965
  self._inject_search_knowledge_tool()
591
966
 
592
967
  # 初始化 SessionService
593
- from google.adk.runners import Runner
594
- from google.adk.sessions import InMemorySessionService
968
+ from ksadk.compat.adk_compat import InMemorySessionService
595
969
 
596
970
  if self._short_term_memory:
597
971
  self._session_service = self._short_term_memory.session_service
@@ -608,25 +982,13 @@ class ADKRunner(BaseRunner):
608
982
  self._inject_builtin_tools()
609
983
  self._inject_mcp_toolsets()
610
984
 
611
- # 初始化 Runner (传入 memory_service)
612
- runner_kwargs = dict(
613
- agent=self._agent,
614
- session_service=self._session_service,
615
- app_name=self._agent.name,
616
- )
617
- if self._long_term_memory:
618
- runner_kwargs["memory_service"] = self._long_term_memory
619
- logger.info("ADKRunner: LongTermMemory injected as memory_service")
620
-
621
- self._runner = Runner(**runner_kwargs)
985
+ # 初始化 Runner (使用 _build_runner 以支持 ResumabilityConfig)
986
+ self._build_runner()
622
987
  self._default_model_name = self.normalize_requested_model(
623
988
  os.getenv("OPENAI_MODEL_NAME") or os.getenv("MODEL_NAME")
624
989
  )
625
990
  self._default_model_reference = self._discover_model_reference(self._agent)
626
- self._active_model_name = (
627
- self._default_model_reference
628
- or self._default_model_name
629
- )
991
+ self._active_model_name = self._default_model_reference or self._default_model_name
630
992
 
631
993
  def _discover_model_reference(self, agent: Any) -> Optional[str]:
632
994
  visited: set[int] = set()
@@ -698,18 +1060,14 @@ class ADKRunner(BaseRunner):
698
1060
  def prepare_for_request(self, model: str | None) -> None:
699
1061
  normalized = self.sync_process_model_env(model)
700
1062
  if normalized is None:
701
- default_model_name = (
702
- getattr(self, "_default_model_name", None)
703
- or self.normalize_requested_model(
704
- os.getenv("OPENAI_MODEL_NAME") or os.getenv("MODEL_NAME")
705
- )
1063
+ default_model_name = getattr(
1064
+ self, "_default_model_name", None
1065
+ ) or self.normalize_requested_model(
1066
+ os.getenv("OPENAI_MODEL_NAME") or os.getenv("MODEL_NAME")
706
1067
  )
707
1068
  if default_model_name:
708
1069
  self.sync_process_model_env(default_model_name)
709
- target_reference = (
710
- getattr(self, "_default_model_reference", None)
711
- or default_model_name
712
- )
1070
+ target_reference = getattr(self, "_default_model_reference", None) or default_model_name
713
1071
  if target_reference and self._agent is not None:
714
1072
  current_reference = self._discover_model_reference(self._agent)
715
1073
  if current_reference != target_reference:
@@ -730,18 +1088,18 @@ class ADKRunner(BaseRunner):
730
1088
  return
731
1089
  if self._agent is not None:
732
1090
  self._apply_model_to_agent_tree(self._agent, normalized)
733
- self._active_model_name = self._discover_model_reference(self._agent) or target_reference
1091
+ discovered = self._discover_model_reference(self._agent)
1092
+ self._active_model_name = discovered or target_reference
734
1093
  return
735
1094
  self._active_model_name = target_reference
736
1095
 
737
- def _prepare_trace_metadata(self, session_id: str):
1096
+ def _prepare_trace_metadata(self, session_id: Optional[str]):
738
1097
  """准备 Trace 元数据 (Tags, UserID, etc.)"""
739
1098
  from ksadk.tracing.span_utils import prepare_trace_metadata
740
- return prepare_trace_metadata(
741
- detection_result=getattr(self, "detection_result", None)
742
- )
743
1099
 
744
- async def _ensure_session(self, external_session_id: str = None) -> str:
1100
+ return prepare_trace_metadata(detection_result=getattr(self, "detection_result", None))
1101
+
1102
+ async def _ensure_session(self, external_session_id: Optional[str] = None) -> str:
745
1103
  """Get or create ADK session ID based on external ID
746
1104
 
747
1105
  When ShortTermMemory is configured, uses its create_session method
@@ -760,11 +1118,13 @@ class ADKRunner(BaseRunner):
760
1118
  session_id=external_session_id,
761
1119
  )
762
1120
  else:
1121
+ if self._session_service is None:
1122
+ raise RuntimeError("ADK session service is not initialized")
763
1123
  session = await self._session_service.create_session(
764
1124
  app_name=self._agent.name, user_id="ksadk_user"
765
1125
  )
766
1126
  self._session_map[external_session_id] = session.id
767
- return session.id
1127
+ return str(session.id)
768
1128
 
769
1129
  # Case 2: No external ID (use default singleton)
770
1130
  if self._default_session_id is None:
@@ -774,11 +1134,13 @@ class ADKRunner(BaseRunner):
774
1134
  user_id="ksadk_user",
775
1135
  )
776
1136
  else:
1137
+ if self._session_service is None:
1138
+ raise RuntimeError("ADK session service is not initialized")
777
1139
  session = await self._session_service.create_session(
778
1140
  app_name=self._agent.name, user_id="ksadk_user"
779
1141
  )
780
1142
  self._default_session_id = session.id
781
- return self._default_session_id
1143
+ return str(self._default_session_id)
782
1144
 
783
1145
  async def save_session_to_long_term_memory(
784
1146
  self, session_id: str, user_id: str = "ksadk_user"
@@ -819,8 +1181,7 @@ class ADKRunner(BaseRunner):
819
1181
  attachments: list[Dict[str, Any]],
820
1182
  *,
821
1183
  model_metadata: Dict[str, Any] | None = None,
822
- ) -> "types.Content":
823
- from google.genai import types
1184
+ ) -> types.Content:
824
1185
  parts = []
825
1186
  if text:
826
1187
  parts.append(types.Part(text=text))
@@ -829,7 +1190,10 @@ class ADKRunner(BaseRunner):
829
1190
  for att in attachments:
830
1191
  mime_type = att.get("mime_type", "application/octet-stream")
831
1192
  display_name = att.get("display_name", "")
832
- if classify_attachment_kind(str(mime_type), str(display_name)) == "image" and not image_input_supported:
1193
+ if (
1194
+ classify_attachment_kind(str(mime_type), str(display_name)) == "image"
1195
+ and not image_input_supported
1196
+ ):
833
1197
  skipped_images.append(str(display_name or "未命名图片"))
834
1198
  continue
835
1199
 
@@ -840,7 +1204,8 @@ class ADKRunner(BaseRunner):
840
1204
  try:
841
1205
  data = base64.b64decode(str(inline_data).strip() + "===")
842
1206
  except Exception as e:
843
- logger.warning(f"Failed to decode inline attachment {att.get('display_name', 'uploaded_file')}: {e}")
1207
+ att_name = att.get("display_name", "uploaded_file")
1208
+ logger.warning(f"Failed to decode inline attachment {att_name}: {e}")
844
1209
 
845
1210
  if data is None:
846
1211
  file_uri = att.get("file_uri")
@@ -853,7 +1218,10 @@ class ADKRunner(BaseRunner):
853
1218
  file_uri = att.get("file_uri", "")
854
1219
  if file_uri.startswith("local:"):
855
1220
  logger.warning(
856
- "Ignoring direct local attachment reference %s; only resolved storage paths are allowed.",
1221
+ (
1222
+ "Ignoring direct local attachment reference %s; "
1223
+ "only resolved storage paths are allowed."
1224
+ ),
857
1225
  file_uri,
858
1226
  )
859
1227
 
@@ -952,8 +1320,16 @@ class ADKRunner(BaseRunner):
952
1320
 
953
1321
  input_tokens = int(usage_metadata.get("prompt_token_count") or 0)
954
1322
  output_tokens = int(usage_metadata.get("candidates_token_count") or 0)
955
- total_tokens = int(usage_metadata.get("total_token_count") or (input_tokens + output_tokens))
956
- if not (input_tokens or output_tokens or total_tokens or input_token_details or output_token_details):
1323
+ total_tokens = int(
1324
+ usage_metadata.get("total_token_count") or (input_tokens + output_tokens)
1325
+ )
1326
+ if not (
1327
+ input_tokens
1328
+ or output_tokens
1329
+ or total_tokens
1330
+ or input_token_details
1331
+ or output_token_details
1332
+ ):
957
1333
  return {}
958
1334
  return {
959
1335
  "input_tokens": input_tokens,
@@ -967,13 +1343,497 @@ class ADKRunner(BaseRunner):
967
1343
  def _extract_event_usage(cls, event: Any) -> dict[str, Any]:
968
1344
  return cls._normalize_usage_metadata(getattr(event, "usage_metadata", None))
969
1345
 
1346
+ # --- ADK invocation_id & checkpoint helpers ---
1347
+
1348
+ async def _get_max_checkpoint_seq_for_run(
1349
+ self,
1350
+ *,
1351
+ session_id: str,
1352
+ run_id: str,
1353
+ ) -> int:
1354
+ """扫描已有 run_checkpoint 事件,返回同一 run_id 下的最大 checkpoint_seq。
1355
+
1356
+ 恢复模式下 checkpoint_seq 从已有最大值继续递增,避免从 0 重启导致
1357
+ checkpoint_id 碰撞并被 append_run_checkpoint_event 的去重逻辑静默丢弃。
1358
+ """
1359
+ if not run_id:
1360
+ return 0
1361
+ try:
1362
+ from ksadk.sessions import resolve_session_service
1363
+
1364
+ service = resolve_session_service()
1365
+ events = await service.get_events(session_id)
1366
+ max_seq = 0
1367
+ for event in reversed(events):
1368
+ if event.event_type != "run_checkpoint":
1369
+ continue
1370
+ metadata = event.metadata or {}
1371
+ if str(metadata.get("run_id") or "") != str(run_id):
1372
+ continue
1373
+ if str(metadata.get("framework") or "") != "adk":
1374
+ continue
1375
+ checkpoint_id = str(metadata.get("checkpoint_id") or "")
1376
+ if checkpoint_id.startswith("adk-ckpt-"):
1377
+ try:
1378
+ seq = int(checkpoint_id.removeprefix("adk-ckpt-"))
1379
+ if seq > max_seq:
1380
+ max_seq = seq
1381
+ except ValueError:
1382
+ pass
1383
+ return max_seq
1384
+ except Exception as exc:
1385
+ logger.warning(
1386
+ "ADKRunner: failed to query max checkpoint_seq " "for run_id=%s: %s",
1387
+ run_id,
1388
+ exc,
1389
+ )
1390
+ return 0
1391
+
1392
+ async def _collect_adk_invocation_id(
1393
+ self,
1394
+ events_async,
1395
+ *,
1396
+ ksadk_invocation_id: str,
1397
+ session_id: str,
1398
+ checkpoint_run_id: str = "",
1399
+ ):
1400
+ """包装 event 迭代器,在首个 event 到达时采集 ADK invocation_id 并存储映射,
1401
+ 同时在可恢复边界写入 checkpoint 事件。
1402
+
1403
+ checkpoint_run_id 用于 checkpoint 的 run_id 字段,恢复模式下沿用原始 RunId
1404
+ 以保证同一长任务的 checkpoint 时间线连贯;ksadk_invocation_id 用于
1405
+ invocation_id 映射和 event 级 invocation_id 字段。
1406
+ """
1407
+ effective_run_id = checkpoint_run_id or ksadk_invocation_id
1408
+ first_event_captured = False
1409
+ captured_adk_invocation_id = ""
1410
+ # 从已有 checkpoint 的最大 seq 继续,避免恢复模式下 ID 碰撞被去重丢弃
1411
+ checkpoint_seq = await self._get_max_checkpoint_seq_for_run(
1412
+ session_id=session_id,
1413
+ run_id=effective_run_id,
1414
+ )
1415
+ async for event in events_async:
1416
+ if not first_event_captured and hasattr(event, "invocation_id") and event.invocation_id:
1417
+ first_event_captured = True
1418
+ # P1.1: Use local variable instead of self._last_adk_invocation_id
1419
+ # to avoid cross-session corruption under concurrent runner access.
1420
+ captured_adk_invocation_id = event.invocation_id
1421
+ await self._persist_invocation_mapping(
1422
+ session_id=session_id,
1423
+ ksadk_invocation_id=ksadk_invocation_id,
1424
+ adk_invocation_id=captured_adk_invocation_id,
1425
+ )
1426
+ # 每个 resumable boundary 都立即写 checkpoint,用递增 seq 保证
1427
+ # 即使程序崩溃也有最新恢复点。
1428
+ if self._resumable and first_event_captured and self._is_resumable_boundary(event):
1429
+ checkpoint_seq += 1
1430
+ await self._maybe_write_checkpoint(
1431
+ event=event,
1432
+ session_id=session_id,
1433
+ ksadk_invocation_id=ksadk_invocation_id,
1434
+ adk_invocation_id=captured_adk_invocation_id,
1435
+ checkpoint_seq=checkpoint_seq,
1436
+ checkpoint_run_id=effective_run_id,
1437
+ )
1438
+ yield event
1439
+
1440
+ async def _collect_adk_invocation_id_if_present(
1441
+ self,
1442
+ events_async,
1443
+ *,
1444
+ session_id: str,
1445
+ ksadk_invocation_id: str,
1446
+ ):
1447
+ """轻量版 invocation_id 采集:仅捕获 ID 并持久化映射,不写 checkpoint。"""
1448
+ first_event_captured = False
1449
+ async for event in events_async:
1450
+ if not first_event_captured and hasattr(event, "invocation_id") and event.invocation_id:
1451
+ first_event_captured = True
1452
+ # P1.1: Local variable — self._last_adk_invocation_id was removed
1453
+ # to prevent cross-session corruption under concurrent runner access.
1454
+ local_adk_invocation_id = event.invocation_id
1455
+ if ksadk_invocation_id:
1456
+ await self._persist_invocation_mapping(
1457
+ session_id=session_id,
1458
+ ksadk_invocation_id=ksadk_invocation_id,
1459
+ adk_invocation_id=local_adk_invocation_id,
1460
+ )
1461
+ yield event
1462
+
1463
+ async def _persist_invocation_mapping(
1464
+ self,
1465
+ *,
1466
+ session_id: str,
1467
+ ksadk_invocation_id: str,
1468
+ adk_invocation_id: str,
1469
+ ) -> None:
1470
+ """将 ksadk invocation_id → ADK invocation_id 映射持久化到 session binding state。
1471
+
1472
+ 存储位置:ksadk_states 表,scope = "runner_binding:adk"
1473
+ state_json 内新增字段 invocation_map: { ksadk_inv_id: adk_inv_id, ... }
1474
+ 无需新增数据库表或字段,复用现有 state_json 的 JSON 存储。
1475
+ """
1476
+ try:
1477
+ from ksadk.sessions import resolve_session_service
1478
+ from ksadk.sessions.continuity import ConversationSessionCore
1479
+
1480
+ service = resolve_session_service()
1481
+ core = ConversationSessionCore(service)
1482
+ # P1.1 sub-issue: hold the lock across the entire read-modify-write
1483
+ # so concurrent invocations on the same session can't lose mappings.
1484
+ async with self._invocation_map_lock:
1485
+ binding = await core.get_binding_by_session_id(session_id, "adk")
1486
+ invocation_map = dict(binding.get("invocation_map") or {})
1487
+ invocation_map[ksadk_invocation_id] = adk_invocation_id
1488
+
1489
+ await core.set_binding_by_session_id(
1490
+ session_id,
1491
+ "adk",
1492
+ {
1493
+ "external_session_id": str(session_id),
1494
+ "internal_session_id": str(session_id),
1495
+ "invocation_map": invocation_map,
1496
+ },
1497
+ )
1498
+ except Exception as exc:
1499
+ logger.warning("Failed to persist ADK invocation mapping: %s", exc)
1500
+
1501
+ async def _resolve_adk_invocation_id(
1502
+ self,
1503
+ *,
1504
+ session_id: str,
1505
+ ksadk_invocation_id: str,
1506
+ ) -> str | None:
1507
+ """从 session binding state 读取 ADK invocation_id 映射。"""
1508
+ try:
1509
+ from ksadk.sessions import resolve_session_service
1510
+ from ksadk.sessions.continuity import ConversationSessionCore
1511
+
1512
+ service = resolve_session_service()
1513
+ core = ConversationSessionCore(service)
1514
+ binding = await core.get_binding_by_session_id(session_id, "adk")
1515
+ invocation_map = dict(binding.get("invocation_map") or {})
1516
+ return invocation_map.get(ksadk_invocation_id)
1517
+ except Exception:
1518
+ return None
1519
+
1520
+ def _is_resumable_boundary(self, event: Any) -> bool:
1521
+ """判断 ADK event 是否为可恢复边界。"""
1522
+ # 工具调用请求
1523
+ if hasattr(event, "get_function_calls") and event.get_function_calls():
1524
+ return True
1525
+ # 自定义 Agent 状态保存
1526
+ if hasattr(event, "actions") and event.actions:
1527
+ if getattr(event.actions, "agent_state", None) is not None:
1528
+ return True
1529
+ if getattr(event.actions, "end_of_agent", False):
1530
+ return True
1531
+ return False
1532
+
1533
+ def _extract_approval_signals(self, event: Any) -> list[dict[str, Any]]:
1534
+ """从 ADK event 提取原生审批信号,翻译成统一 interrupt_info(供 runtime:4645 消费)。
1535
+
1536
+ 覆盖 ADK 两套原生 HITL 机制,经 ``adk_compat`` 能力探测,兼容 1.34.x → 2.x:
1537
+
1538
+ 1. **tool-confirmation**(``actions.requested_tool_confirmations``):工具执行前
1539
+ yes/no 审批,1.34+ 均有。→ ``kind="tool"``。
1540
+ 2. **workflow HITL**(v2.0+):``event.interrupted=True`` + ``RequestInput`` 信号
1541
+ (message/payload/response_schema)。→ ``kind="input"``。
1542
+
1543
+ 命中任一即返回 ``[{"approval_request_id","tool_name","args","kind","message"}, ...]``,
1544
+ 否则返回 ``[]``。不引入对 v2.0 API 的硬依赖——字段缺失时跳过,不报错。
1545
+ """
1546
+ signals: list[dict[str, Any]] = []
1547
+ actions = getattr(event, "actions", None)
1548
+
1549
+ # 1) tool-confirmation:requested_tool_confirmations(1.34+,经 getattr 容错)
1550
+ if actions is not None:
1551
+ confirmations = getattr(actions, "requested_tool_confirmations", None)
1552
+ if confirmations:
1553
+ for conf in confirmations:
1554
+ if conf is None:
1555
+ continue
1556
+ conf_id = str(
1557
+ getattr(conf, "id", "") or getattr(conf, "approval_request_id", "") or ""
1558
+ )
1559
+ if not conf_id:
1560
+ continue
1561
+ signals.append(
1562
+ {
1563
+ "approval_request_id": conf_id,
1564
+ "tool_name": str(
1565
+ getattr(conf, "tool_name", "")
1566
+ or getattr(conf, "name", "")
1567
+ or "tool"
1568
+ ),
1569
+ "args": dict(getattr(conf, "args", None) or {}),
1570
+ "kind": "tool",
1571
+ "message": str(
1572
+ getattr(conf, "message", "") or getattr(conf, "hint", "") or ""
1573
+ ),
1574
+ }
1575
+ )
1576
+
1577
+ # 2) workflow HITL(v2.0+):event.interrupted + RequestInput 信号
1578
+ # 仅当 adk_compat 探测到 v2.0+ 时检查,避免在 1.34 上访问不存在字段。
1579
+ try:
1580
+ from ksadk.compat.adk_compat import adk_version_at_least
1581
+
1582
+ if adk_version_at_least("2.0.0") and getattr(event, "interrupted", False):
1583
+ # RequestInput 信号可能挂在 actions 或 event 上(字段名跨小版本未冻结,
1584
+ # 用 getattr 容错,缺失即视为纯 interrupt 无 payload)。
1585
+ req_input = None
1586
+ if actions is not None:
1587
+ req_input = getattr(actions, "requested_input", None) or getattr(
1588
+ actions, "request_input", None
1589
+ )
1590
+ if req_input is None:
1591
+ req_input = getattr(event, "requested_input", None)
1592
+ interrupt_id = "adk-hitl-" + str(getattr(event, "invocation_id", "") or "unknown")
1593
+ signals.append(
1594
+ {
1595
+ "approval_request_id": interrupt_id,
1596
+ "tool_name": "RequestInput",
1597
+ "args": dict(getattr(req_input, "payload", None) or {}),
1598
+ "kind": "input",
1599
+ "message": str(
1600
+ getattr(req_input, "message", "") or "ADK workflow 请求人工输入"
1601
+ ),
1602
+ }
1603
+ )
1604
+ except Exception:
1605
+ # 能力探测失败时不阻塞主流(审批 surface 是 best-effort,不降级为报错)。
1606
+ pass
1607
+
1608
+ return signals
1609
+
1610
+ async def _maybe_write_checkpoint(
1611
+ self,
1612
+ *,
1613
+ event: Any,
1614
+ session_id: str,
1615
+ ksadk_invocation_id: str,
1616
+ adk_invocation_id: str,
1617
+ checkpoint_seq: int,
1618
+ checkpoint_run_id: str = "",
1619
+ ) -> None:
1620
+ """Write a run_checkpoint event at a resumable boundary.
1621
+
1622
+ Each boundary gets its own incrementing checkpoint_id so the latest
1623
+ checkpoint always reflects the latest state for crash recovery.
1624
+ """
1625
+ if not self._is_resumable_boundary(event):
1626
+ return
1627
+
1628
+ from ksadk.conversations.runtime import append_run_checkpoint_event
1629
+
1630
+ metadata = self._extract_checkpoint_metadata(event)
1631
+
1632
+ effective_run_id = checkpoint_run_id or ksadk_invocation_id
1633
+ await append_run_checkpoint_event(
1634
+ session_id=session_id,
1635
+ author=self._agent.name,
1636
+ run_id=effective_run_id,
1637
+ checkpoint_id=f"adk-ckpt-{checkpoint_seq}",
1638
+ framework="adk",
1639
+ framework_ref={
1640
+ "adk": {
1641
+ "invocation_id": adk_invocation_id,
1642
+ "checkpoint_seq": checkpoint_seq,
1643
+ "event_id": getattr(event, "id", ""),
1644
+ "author": getattr(event, "author", ""),
1645
+ }
1646
+ },
1647
+ phase=(
1648
+ "tool_call"
1649
+ if (hasattr(event, "get_function_calls") and event.get_function_calls())
1650
+ else "agent_state"
1651
+ ),
1652
+ invocation_id=ksadk_invocation_id,
1653
+ metadata=metadata,
1654
+ )
1655
+ logger.debug(
1656
+ "ADKRunner: wrote checkpoint adk-ckpt-%d at boundary " "(session=%s, invocation_id=%s)",
1657
+ checkpoint_seq,
1658
+ session_id,
1659
+ adk_invocation_id,
1660
+ )
1661
+
1662
+ def _extract_checkpoint_metadata(self, event: Any) -> dict[str, Any]:
1663
+ """从 ADK event 中提取 checkpoint 元数据。"""
1664
+ metadata: dict[str, Any] = {}
1665
+
1666
+ # 工具调用信息
1667
+ if hasattr(event, "get_function_calls") and event.get_function_calls():
1668
+ fcs = event.get_function_calls()
1669
+ metadata["tool_names"] = [fc.name for fc in fcs if hasattr(fc, "name")]
1670
+ metadata["tool_call_ids"] = [fc.id for fc in fcs if hasattr(fc, "id")]
1671
+
1672
+ # Agent 状态信息
1673
+ if hasattr(event, "actions") and event.actions:
1674
+ agent_state = getattr(event.actions, "agent_state", None)
1675
+ if agent_state is not None:
1676
+ if isinstance(agent_state, dict):
1677
+ metadata["agent_state_keys"] = list(agent_state.keys())
1678
+ else:
1679
+ metadata["agent_state_keys"] = []
1680
+ if getattr(event.actions, "end_of_agent", False):
1681
+ metadata["is_terminal"] = True
1682
+ metadata["end_of_agent"] = True
1683
+
1684
+ # 是否可恢复
1685
+ stm_backend = (
1686
+ getattr(self._short_term_memory, "backend", None) if self._short_term_memory else None
1687
+ )
1688
+ shared_across_pods = stm_backend == "database"
1689
+ platform_resumable = self._resumable and shared_across_pods
1690
+ metadata["is_resumable"] = platform_resumable
1691
+ metadata["resume_status"] = "resumable" if platform_resumable else "disabled"
1692
+ metadata["backend"] = stm_backend or "in_memory"
1693
+ metadata["scope"] = "invocation"
1694
+ metadata["durable"] = stm_backend is not None and stm_backend != "local"
1695
+ metadata["shared_across_pods"] = shared_across_pods
1696
+ if not platform_resumable:
1697
+ metadata["resume_disabled_reason"] = (
1698
+ self._resume_disabled_reason
1699
+ if not self._resumable and self._resume_disabled_reason
1700
+ else "ADK checkpoint uses an in-memory or local-only session backend; "
1701
+ "cross-pod resume is unavailable"
1702
+ )
1703
+ # P1.4: ADK only supports invocation-level (forward-only) resume, not
1704
+ # arbitrary checkpoint rollback like LangGraph time_travel. Consumers
1705
+ # should treat only the latest checkpoint as independently resumable.
1706
+ metadata["resume_mode"] = "invocation_id"
1707
+ metadata["only_latest_resumable"] = True
1708
+
1709
+ return metadata
1710
+
1711
+ async def _resolve_resume_invocation_id(
1712
+ self,
1713
+ *,
1714
+ input_data: Dict[str, Any],
1715
+ session_id: str,
1716
+ ksadk_invocation_id: str,
1717
+ ) -> str:
1718
+ """Resolve the ADK invocation_id needed for resume.
1719
+
1720
+ Looks up from framework_ref or session binding mapping.
1721
+ Raises ValueError when the resume reference is missing (P1.2:
1722
+ never silently downgrade to a new invocation).
1723
+ """
1724
+ adk_invocation_id = None
1725
+ framework_ref = input_data.get("framework_ref") or {}
1726
+ if isinstance(framework_ref, dict):
1727
+ adk_ref = framework_ref.get("adk") or {}
1728
+ if isinstance(adk_ref, dict):
1729
+ adk_invocation_id = adk_ref.get("invocation_id")
1730
+ # Fallback: resolve from session binding
1731
+ if not adk_invocation_id and ksadk_invocation_id:
1732
+ adk_invocation_id = await self._resolve_adk_invocation_id(
1733
+ session_id=session_id,
1734
+ ksadk_invocation_id=ksadk_invocation_id,
1735
+ )
1736
+ if not adk_invocation_id:
1737
+ logger.error(
1738
+ "Resume requested but ADK invocation_id not found for " "session=%s ksadk_inv=%s",
1739
+ session_id,
1740
+ ksadk_invocation_id,
1741
+ )
1742
+ raise ValueError(
1743
+ f"checkpoint_not_resumable: ADK invocation_id not found for "
1744
+ f"session={session_id}, ksadk_invocation_id={ksadk_invocation_id}. "
1745
+ f"The checkpoint data may have been lost or the invocation was "
1746
+ f"never persisted."
1747
+ )
1748
+ return str(adk_invocation_id)
1749
+
1750
+ async def _prepare_run_events(
1751
+ self,
1752
+ *,
1753
+ input_data: Dict[str, Any],
1754
+ session_id: str,
1755
+ user_input: str,
1756
+ is_resume: bool,
1757
+ run_config: Optional[Any] = None,
1758
+ ) -> AsyncIterator[Any]:
1759
+ """Prepare the wrapped event stream shared by invoke() and stream().
1760
+
1761
+ Handles new_message construction, resume invocation_id resolution,
1762
+ run_async (with optional run_config for streaming), and event wrapping
1763
+ for checkpoint writing. Returns the wrapped async iterator.
1764
+ """
1765
+ if not is_resume:
1766
+ new_message = self._build_adk_content(
1767
+ user_input,
1768
+ input_data.get("attachments", []),
1769
+ model_metadata=input_data.get("model_metadata"),
1770
+ )
1771
+ else:
1772
+ new_message = None
1773
+
1774
+ ksadk_invocation_id = str(input_data.get("invocation_id") or input_data.get("run_id") or "")
1775
+
1776
+ checkpoint_run_id = (
1777
+ str(input_data.get("run_id") or "").strip()
1778
+ if is_resume and str(input_data.get("run_id") or "").strip()
1779
+ else ""
1780
+ )
1781
+
1782
+ run_kwargs: Dict[str, Any] = {
1783
+ "session_id": session_id,
1784
+ "user_id": "ksadk_user",
1785
+ }
1786
+ if run_config is not None:
1787
+ run_kwargs["run_config"] = run_config
1788
+
1789
+ if is_resume:
1790
+ if not self._resumable:
1791
+ raise ValueError(
1792
+ "checkpoint_not_resumable: ADK resumability is disabled for "
1793
+ f"session={session_id}, ksadk_invocation_id={ksadk_invocation_id}."
1794
+ )
1795
+ adk_invocation_id = await self._resolve_resume_invocation_id(
1796
+ input_data=input_data,
1797
+ session_id=session_id,
1798
+ ksadk_invocation_id=ksadk_invocation_id,
1799
+ )
1800
+ logger.info("Resuming ADK run with adk_invocation_id: %s", adk_invocation_id)
1801
+ run_kwargs["invocation_id"] = adk_invocation_id
1802
+ else:
1803
+ run_kwargs["new_message"] = new_message
1804
+ run_kwargs["state_delta"] = self._build_state_delta(input_data) or None
1805
+
1806
+ if self._runner is None:
1807
+ raise RuntimeError("ADK runner is not initialized")
1808
+ events_async = self._runner.run_async(**run_kwargs)
1809
+
1810
+ if ksadk_invocation_id and self._resumable:
1811
+ wrapped_async = self._collect_adk_invocation_id(
1812
+ events_async,
1813
+ ksadk_invocation_id=ksadk_invocation_id,
1814
+ session_id=session_id,
1815
+ checkpoint_run_id=checkpoint_run_id,
1816
+ )
1817
+ else:
1818
+ wrapped_async = self._collect_adk_invocation_id_if_present(
1819
+ events_async,
1820
+ session_id=session_id,
1821
+ ksadk_invocation_id=ksadk_invocation_id,
1822
+ )
1823
+ return cast(AsyncIterator[Any], wrapped_async)
1824
+
970
1825
  async def invoke(self, input_data: Dict[str, Any]) -> Dict[str, Any]:
971
1826
  """调用 ADK Agent"""
972
- from google.genai import types
973
1827
 
974
- user_input = input_data.get("input", "")
1828
+ # 判断是否为恢复调用 — 提前判断以避免将 resume_input dict 当作 string 处理
1829
+ is_resume = bool(input_data.get("checkpoint_resume"))
1830
+ raw_input = input_data.get("input")
1831
+ if is_resume and isinstance(raw_input, dict):
1832
+ user_input = "[checkpoint resume]"
1833
+ else:
1834
+ user_input = str(raw_input or "")
975
1835
  instructions = str(input_data.get("instructions") or "").strip()
976
- if instructions:
1836
+ if instructions and not is_resume:
977
1837
  user_input = f"{instructions}\n\nCurrent user input:\n{user_input or '[empty message]'}"
978
1838
 
979
1839
  # 1. 准备 Metadata (提前以此获取 Agent Name)
@@ -983,7 +1843,10 @@ class ADKRunner(BaseRunner):
983
1843
  with tracer.start_as_current_span(trace_name) as span:
984
1844
  # Set input.value for Langfuse top-level input display
985
1845
  span.set_attribute("input.value", user_input)
986
- span.set_attribute("user.input", user_input[:200])
1846
+ truncated_input = (
1847
+ user_input[:200] if isinstance(user_input, str) else str(user_input)[:200]
1848
+ )
1849
+ span.set_attribute("user.input", truncated_input)
987
1850
 
988
1851
  # Use external session ID if provided
989
1852
  req_session_id = input_data.get("session_id")
@@ -999,25 +1862,24 @@ class ADKRunner(BaseRunner):
999
1862
  if tags:
1000
1863
  span.set_attribute("langfuse.tags", ",".join(tags))
1001
1864
 
1002
- new_message = self._build_adk_content(
1003
- user_input,
1004
- input_data.get("attachments", []),
1005
- model_metadata=input_data.get("model_metadata"),
1865
+ wrapped_async = await self._prepare_run_events(
1866
+ input_data=input_data,
1867
+ session_id=session_id,
1868
+ user_input=user_input,
1869
+ is_resume=is_resume,
1006
1870
  )
1007
- state_delta = self._build_state_delta(input_data)
1008
1871
 
1009
1872
  final_response = ""
1010
1873
 
1011
1874
  events_list = []
1875
+ # P1.4: Track last_event to avoid UnboundLocalError when ADK returns
1876
+ # zero events (e.g. resuming a completed invocation).
1877
+ last_event = None
1012
1878
  usage: dict[str, Any] = {}
1013
1879
  last_usage: dict[str, Any] = {}
1014
- async for event in self._runner.run_async(
1015
- session_id=session_id,
1016
- user_id="ksadk_user",
1017
- new_message=new_message,
1018
- state_delta=state_delta or None,
1019
- ):
1880
+ async for event in wrapped_async:
1020
1881
  events_list.append(event)
1882
+ last_event = event
1021
1883
  event_usage = self._extract_event_usage(event)
1022
1884
  if event_usage:
1023
1885
  usage = accumulate_usage(usage, event_usage)
@@ -1028,12 +1890,33 @@ class ADKRunner(BaseRunner):
1028
1890
  # 过滤掉思考内容 (thought=True),只保留最终答案
1029
1891
  is_thought = getattr(part, "thought", False)
1030
1892
  if hasattr(part, "text") and part.text and not is_thought:
1031
- final_response = part.text
1893
+ final_response += part.text
1894
+
1895
+ # Prefer the last event's text (usually the complete answer).
1896
+ # When the loop aborts early (MCP error, phantom tool-call),
1897
+ # final_response may hold only intermediate fragments.
1898
+ last_event_text = ""
1899
+ if last_event is not None and hasattr(last_event, "content") and last_event.content:
1900
+ for part in last_event.content.parts or []:
1901
+ is_thought = getattr(part, "thought", False)
1902
+ if hasattr(part, "text") and part.text and not is_thought:
1903
+ last_event_text += part.text
1904
+ if last_event_text:
1905
+ final_response = last_event_text
1906
+
1907
+ if not final_response and events_list:
1908
+ logger.warning(
1909
+ "ADK invoke finished with events but no final text — "
1910
+ "likely mid-loop abort or tool-call error"
1911
+ )
1032
1912
 
1033
1913
  # Set output.value for Langfuse top-level output display
1034
1914
  span.set_attribute("output.value", final_response[:5000] if final_response else "")
1035
1915
  span.set_attribute("agent.output", final_response[:500] if final_response else "")
1036
- result = {"output": final_response, "events": events_list}
1916
+ result: dict[str, Any] = {
1917
+ "output": final_response,
1918
+ "events": events_list,
1919
+ }
1037
1920
  if usage:
1038
1921
  result["usage"] = usage
1039
1922
  # last_usage = 最后一次 LLM 调用快照(input_tokens = 当前上下文窗口占用)
@@ -1045,12 +1928,17 @@ class ADKRunner(BaseRunner):
1045
1928
 
1046
1929
  使用 StreamingMode.SSE 启用真正的流式 token 输出
1047
1930
  """
1048
- from google.adk.agents.run_config import RunConfig, StreamingMode
1049
- from google.genai import types
1931
+ from ksadk.compat.adk_compat import RunConfig, StreamingMode
1050
1932
 
1051
- user_input = input_data.get("input", "")
1933
+ # 判断是否为恢复调用 — 提前判断以避免将 resume_input dict 当作 string 处理
1934
+ is_resume = bool(input_data.get("checkpoint_resume"))
1935
+ raw_input = input_data.get("input")
1936
+ if is_resume and isinstance(raw_input, dict):
1937
+ user_input = "[checkpoint resume]"
1938
+ else:
1939
+ user_input = str(raw_input or "")
1052
1940
  instructions = str(input_data.get("instructions") or "").strip()
1053
- if instructions:
1941
+ if instructions and not is_resume:
1054
1942
  user_input = f"{instructions}\n\nCurrent user input:\n{user_input or '[empty message]'}"
1055
1943
 
1056
1944
  # 1. 准备 Metadata (提前以此获取 Agent Name)
@@ -1060,7 +1948,10 @@ class ADKRunner(BaseRunner):
1060
1948
  with tracer.start_as_current_span(trace_name) as span:
1061
1949
  # Set input.value for Langfuse top-level input display
1062
1950
  span.set_attribute("input.value", user_input)
1063
- span.set_attribute("user.input", user_input[:200])
1951
+ truncated_input = (
1952
+ user_input[:200] if isinstance(user_input, str) else str(user_input)[:200]
1953
+ )
1954
+ span.set_attribute("user.input", truncated_input)
1064
1955
 
1065
1956
  # Use external session ID if provided
1066
1957
  req_session_id = input_data.get("session_id")
@@ -1075,27 +1966,27 @@ class ADKRunner(BaseRunner):
1075
1966
  if tags:
1076
1967
  span.set_attribute("langfuse.tags", ",".join(tags))
1077
1968
 
1078
- new_message = self._build_adk_content(
1079
- user_input,
1080
- input_data.get("attachments", []),
1081
- model_metadata=input_data.get("model_metadata"),
1969
+ run_config = RunConfig(streaming_mode=StreamingMode.SSE)
1970
+ wrapped_async = await self._prepare_run_events(
1971
+ input_data=input_data,
1972
+ session_id=session_id,
1973
+ user_input=user_input,
1974
+ is_resume=is_resume,
1975
+ run_config=run_config,
1082
1976
  )
1083
- state_delta = self._build_state_delta(input_data)
1084
1977
 
1085
1978
  accumulated_text = ""
1086
1979
  usage: dict[str, Any] = {}
1087
1980
  last_usage: dict[str, Any] = {}
1088
-
1089
- # 使用 StreamingMode.SSE 启用真正的流式输出
1090
- run_config = RunConfig(streaming_mode=StreamingMode.SSE)
1091
-
1092
- async for event in self._runner.run_async(
1093
- session_id=session_id,
1094
- user_id="ksadk_user",
1095
- new_message=new_message,
1096
- state_delta=state_delta or None,
1097
- run_config=run_config,
1098
- ):
1981
+ # handoff/sub-agent(如 RemoteA2aAgent)回复事件 partial=None,不进上面
1982
+ # partial 分支;且远端 agent 经 A2A 流式返回时,ADK 会产出多个"累积快照"
1983
+ # 事件(后一个含前一个内容)。按 author 记录已输出快照,只补发增量去重。
1984
+ sub_agent_snapshots: dict[str, str] = {}
1985
+ sub_agent_thought_snapshots: dict[str, str] = {}
1986
+ sub_agent_last_output_kind: dict[str, str] = {}
1987
+ top_agent_name = getattr(self._agent, "name", None)
1988
+
1989
+ async for event in wrapped_async:
1099
1990
  event_usage = self._extract_event_usage(event)
1100
1991
  if event_usage:
1101
1992
  usage = accumulate_usage(usage, event_usage)
@@ -1103,27 +1994,220 @@ class ADKRunner(BaseRunner):
1103
1994
  # Only yield text delta if event is partial to avoid duplication of final summary
1104
1995
  if hasattr(event, "content") and event.content and getattr(event, "partial", False):
1105
1996
  if hasattr(event.content, "parts"):
1997
+ author = getattr(event, "author", None)
1998
+ is_sub_agent = bool(author and top_agent_name and author != top_agent_name)
1999
+ author_key = str(author)
1106
2000
  for part in event.content.parts:
1107
2001
  if hasattr(part, "text") and part.text:
1108
2002
  is_thought = getattr(part, "thought", False)
1109
- accumulated_text += part.text
2003
+ replace_snapshot = bool(
2004
+ is_sub_agent
2005
+ and _part_metadata_flag(part, "ksadk_output_snapshot")
2006
+ )
2007
+ output_delta = part.text
2008
+ # 思考内容只作为 thinking delta 流出,不计入最终输出,
2009
+ # 否则最终回复会把思考过程再重复一遍(与 invoke() 语义对齐)。
2010
+ if is_thought and is_sub_agent:
2011
+ if sub_agent_last_output_kind.get(author_key) == "text":
2012
+ # 正文之后的 thought 是一个新的 reasoning segment。
2013
+ sub_agent_thought_snapshots[author_key] = ""
2014
+ previous_thought = sub_agent_thought_snapshots.get(
2015
+ author_key, ""
2016
+ )
2017
+ if part.text.startswith(previous_thought):
2018
+ output_delta = part.text[len(previous_thought) :]
2019
+ sub_agent_thought_snapshots[author_key] = part.text
2020
+ else:
2021
+ sub_agent_thought_snapshots[author_key] = (
2022
+ previous_thought + part.text
2023
+ )
2024
+ elif not is_thought:
2025
+ accumulated_text = (
2026
+ part.text
2027
+ if replace_snapshot
2028
+ else accumulated_text + part.text
2029
+ )
2030
+ # sub-agent(RemoteA2aAgent)增量也累积进快照,供后续
2031
+ # completed 全文消息(同一份结果)在 handoff 分支去重。
2032
+ if is_sub_agent:
2033
+ sub_agent_snapshots[author_key] = (
2034
+ part.text
2035
+ if replace_snapshot
2036
+ else sub_agent_snapshots.get(author_key, "") + part.text
2037
+ )
1110
2038
  # 标记思考内容,前端可以选择是否展示
1111
- yield {
1112
- "delta": part.text,
1113
- "type": "thinking" if is_thought else "text",
1114
- }
1115
-
1116
- # 处理工具调用事件
2039
+ if output_delta:
2040
+ output_chunk: dict[str, Any] = {
2041
+ "delta": output_delta,
2042
+ "type": "thinking" if is_thought else "text",
2043
+ }
2044
+ if replace_snapshot and not is_thought:
2045
+ output_chunk["replace"] = True
2046
+ yield output_chunk
2047
+ if is_sub_agent:
2048
+ sub_agent_last_output_kind[author_key] = (
2049
+ "thinking" if is_thought else "text"
2050
+ )
2051
+ # handoff/sub-agent 回复:partial 为 None/False,上面分支跳过,这里补上。
2052
+ elif hasattr(event, "content") and event.content:
2053
+ author = getattr(event, "author", None)
2054
+ if (
2055
+ author
2056
+ and top_agent_name
2057
+ and author != top_agent_name
2058
+ and hasattr(event.content, "parts")
2059
+ ):
2060
+ author_key = str(author)
2061
+ for part in event.content.parts:
2062
+ if (
2063
+ hasattr(part, "text")
2064
+ and part.text
2065
+ and getattr(part, "thought", False)
2066
+ ):
2067
+ # A2A's terminal artifact is converted by ADK to
2068
+ # partial=False. Once that sub-agent has already
2069
+ # emitted response text, a trailing thought here is
2070
+ # a terminal snapshot/replacement (not a new
2071
+ # interleaved reasoning step). Showing it makes
2072
+ # the UI appear to end at "thinking" and hides the
2073
+ # just-emitted final response.
2074
+ if sub_agent_last_output_kind.get(author_key) == "text":
2075
+ continue
2076
+ # RemoteA2aAgent 将 last_chunk=True 映射成
2077
+ # partial=False;这可能是一个此前 partial thought
2078
+ # 的终态快照。只补发新增内容,避免正文之后再显示一遍
2079
+ # 相同的思考块;若此前没有 partial thought,仍完整透传。
2080
+ previous_thought = sub_agent_thought_snapshots.get(
2081
+ author_key, ""
2082
+ )
2083
+ if part.text.startswith(previous_thought):
2084
+ thought_delta = part.text[len(previous_thought) :]
2085
+ sub_agent_thought_snapshots[author_key] = part.text
2086
+ elif previous_thought.startswith(part.text):
2087
+ thought_delta = ""
2088
+ else:
2089
+ thought_delta = part.text
2090
+ sub_agent_thought_snapshots[author_key] = (
2091
+ previous_thought + part.text
2092
+ )
2093
+ if thought_delta:
2094
+ yield {"delta": thought_delta, "type": "thinking"}
2095
+ snapshot = ""
2096
+ replace_snapshot = False
2097
+ for part in event.content.parts:
2098
+ if hasattr(part, "text") and part.text and not getattr(
2099
+ part, "thought", False
2100
+ ):
2101
+ snapshot += part.text
2102
+ replace_snapshot = replace_snapshot or _part_metadata_flag(
2103
+ part,
2104
+ "ksadk_output_snapshot",
2105
+ )
2106
+ if snapshot:
2107
+ prev = sub_agent_snapshots.get(author_key, "")
2108
+ if replace_snapshot:
2109
+ delta = snapshot
2110
+ sub_agent_snapshots[author_key] = snapshot
2111
+ accumulated_text = snapshot
2112
+ elif snapshot.startswith(prev):
2113
+ # 累计快照(如 completed 全文):只补发超出已累积的部分;
2114
+ # 若增量已发全,delta 为空,避免 completed 再渲染一遍。
2115
+ delta = snapshot[len(prev) :]
2116
+ sub_agent_snapshots[author_key] = snapshot
2117
+ else:
2118
+ # final 增量(小块):追加进累积,不覆盖。
2119
+ delta = snapshot
2120
+ sub_agent_snapshots[author_key] = prev + snapshot
2121
+ if delta:
2122
+ if not replace_snapshot:
2123
+ accumulated_text += delta
2124
+ output_chunk = {"delta": delta, "type": "text"}
2125
+ if replace_snapshot:
2126
+ output_chunk["replace"] = True
2127
+ yield output_chunk
2128
+
2129
+ # 处理工具调用事件 — ADK 通过 event.content.parts[].function_call
2130
+ # 发出工具调用(即 event.get_function_calls()),而非
2131
+ # event.actions.tool_calls。此处需同时检测两种路径:
2132
+ # (a) get_function_calls() — ADK 标准 Event 模型
2133
+ # (b) actions.tool_calls — 某些旧版或自定义 runner 可能使用
2134
+ emitted_tool_call_ids: set[str] = set()
2135
+ if hasattr(event, "get_function_calls") and event.get_function_calls():
2136
+ for fc in event.get_function_calls():
2137
+ fc_id = getattr(fc, "id", "") or getattr(fc, "name", "unknown")
2138
+ if fc_id in emitted_tool_call_ids:
2139
+ continue
2140
+ emitted_tool_call_ids.add(fc_id)
2141
+ # fc.args 可能是 dict 或 None;确保可序列化
2142
+ fc_args = getattr(fc, "args", None) or {}
2143
+ if not isinstance(fc_args, dict):
2144
+ try:
2145
+ import json as _json
2146
+
2147
+ fc_args = _json.loads(fc_args) if isinstance(fc_args, str) else {}
2148
+ except Exception:
2149
+ fc_args = {}
2150
+ yield {
2151
+ "type": "tool_call",
2152
+ "tool_name": getattr(fc, "name", "unknown"),
2153
+ "tool_args": fc_args,
2154
+ }
1117
2155
  if hasattr(event, "actions") and event.actions:
1118
2156
  tool_calls = getattr(event.actions, "tool_calls", None)
1119
2157
  if tool_calls:
1120
2158
  for tool_call in tool_calls:
2159
+ tc_name = getattr(tool_call, "name", "unknown")
2160
+ tc_id = getattr(tool_call, "id", "") or tc_name
2161
+ if tc_id in emitted_tool_call_ids:
2162
+ continue
2163
+ emitted_tool_call_ids.add(tc_id)
1121
2164
  yield {
1122
2165
  "type": "tool_call",
1123
- "tool_name": getattr(tool_call, "name", "unknown"),
2166
+ "tool_name": tc_name,
1124
2167
  "tool_args": getattr(tool_call, "input", {}),
1125
2168
  }
1126
2169
 
2170
+ # ADK 原生审批 surface(tool-confirmation + v2.0 workflow HITL):
2171
+ # 翻译成统一 interrupt chunk,经 runtime:4645 既有 approval_request 通道
2172
+ # 消费 → 审批卡。能力探测兼容 1.34.x→2.x,缺字段则跳过。
2173
+ emitted_approval_ids: set[str] = set()
2174
+ for signal in self._extract_approval_signals(event):
2175
+ aid = signal.get("approval_request_id", "")
2176
+ if not aid or aid in emitted_approval_ids:
2177
+ continue
2178
+ emitted_approval_ids.add(aid)
2179
+ yield {
2180
+ "type": "interrupt",
2181
+ "interrupt_info": signal,
2182
+ "session_id": session_id,
2183
+ }
2184
+
2185
+ # 处理工具返回结果 — ADK 通过 event.content.parts[].function_response
2186
+ # 发出工具执行结果。当工具执行完毕后 ADK 会产生一个包含
2187
+ # function_response 的事件,此处将其转为 tool_result 语义事件,
2188
+ # 让前端可以感知"某个工具已完成执行"并展示结果。
2189
+ if hasattr(event, "content") and event.content and hasattr(event.content, "parts"):
2190
+ for part in event.content.parts:
2191
+ fr = getattr(part, "function_response", None)
2192
+ if fr is not None:
2193
+ fr_name = getattr(fr, "name", "unknown")
2194
+ fr_output = getattr(fr, "response", None) or {}
2195
+ if not isinstance(fr_output, dict):
2196
+ try:
2197
+ import json as _json2
2198
+
2199
+ if isinstance(fr_output, str):
2200
+ fr_output = _json2.loads(fr_output)
2201
+ else:
2202
+ fr_output = {"raw": str(fr_output)}
2203
+ except Exception:
2204
+ fr_output = {"raw": str(fr_output)}
2205
+ yield {
2206
+ "type": "tool_result",
2207
+ "tool_name": fr_name,
2208
+ "tool_output": fr_output,
2209
+ }
2210
+
1127
2211
  # Set output.value for Langfuse top-level output display
1128
2212
  span.set_attribute("output.value", accumulated_text[:5000] if accumulated_text else "")
1129
2213
  span.set_attribute("agent.output", accumulated_text[:500])