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
ksadk/server/app.py ADDED
@@ -0,0 +1,3931 @@
1
+ # ksadk/server/app.py
2
+ """
3
+ FastAPI 应用 - 提供 HTTP API 接口 (ADK Web 兼容)
4
+ """
5
+
6
+ import asyncio
7
+ import base64
8
+ import io
9
+ import json
10
+ import logging
11
+ import os
12
+ import time
13
+ import uuid
14
+ import zipfile
15
+ from contextlib import asynccontextmanager
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path, PurePosixPath
18
+ from typing import Any, AsyncIterator, Dict, List, Mapping, Optional
19
+ from urllib.parse import quote
20
+
21
+ import httpx
22
+ from fastapi import FastAPI, File, Form, HTTPException, Query, Request, UploadFile
23
+ from fastapi.middleware.cors import CORSMiddleware
24
+ from fastapi.responses import FileResponse, Response, StreamingResponse
25
+ from pydantic import BaseModel, Field
26
+
27
+ import ksadk.conversations as conversation
28
+ from ksadk.conversations.attachment_storage import AttachmentStorageService
29
+ from ksadk.conversations.attachments import compact_attachment_result_for_session
30
+ from ksadk.conversations.model_context import normalize_model_metadata
31
+ from ksadk.conversations.run_kinds import (
32
+ RUN_MODE_BACKGROUND,
33
+ RUN_MODE_FOREGROUND,
34
+ RUN_MODE_UNKNOWN,
35
+ RUN_TRIGGER_CHECKPOINT_RESUME,
36
+ RUN_TRIGGER_NEW_RUN,
37
+ RUN_TRIGGER_UNKNOWN,
38
+ trigger_from_resume_input,
39
+ )
40
+ from ksadk.conversations.run_status import RUN_STATUS_ACTIVE, RUN_STATUS_TERMINAL
41
+ from ksadk.conversations.session_title import (
42
+ HEURISTIC_SESSION_TITLE_SOURCE,
43
+ build_fallback_title,
44
+ build_heuristic_title,
45
+ )
46
+ from ksadk.runners.base_runner import BaseRunner
47
+ from ksadk.runtime_state import load_state as load_runtime_state
48
+ from ksadk.server.api_models import AgentRunRequest
49
+ from ksadk.server.terminal_sessions import (
50
+ TerminalSessionManager,
51
+ native_terminal_supported,
52
+ register_terminal_routes,
53
+ )
54
+ from ksadk.sessions import (
55
+ ConversationSessionCore,
56
+ Session,
57
+ SessionEvent,
58
+ describe_session_backend,
59
+ resolve_session_service,
60
+ )
61
+ from ksadk.sessions.errors import SessionBackendUnavailable
62
+ from ksadk.sessions.local_service import resolve_local_session_dir
63
+ from ksadk.toolsets import describe_agentengine_tools
64
+ from ksadk.tracing import get_memory_exporter
65
+ from ksadk.ui_config import UI_PROFILE_CUSTOM, resolve_ui_config
66
+ from ksadk_runtime_common.workspace_files import (
67
+ build_workspace_files_bootstrap,
68
+ create_workspace_files_router,
69
+ workspace_files_enabled,
70
+ )
71
+ from ksadk_runtime_common.workspace_files.preview import (
72
+ build_workspace_file_base_href,
73
+ build_workspace_preview_csp,
74
+ inject_workspace_html_preview,
75
+ )
76
+
77
+ logger = logging.getLogger(__name__)
78
+
79
+
80
+ # Global Runner instance
81
+ runner: BaseRunner = None
82
+ _runner_loaded = False
83
+ _DETACHED_STREAMS: set[asyncio.Task[Any]] = set()
84
+ _DETACHED_STREAMS_BY_INVOCATION: dict[str, "_DetachedSSEStream"] = {}
85
+ _DETACHED_RESUME_KEYS_BY_INVOCATION: dict[str, tuple[str, str]] = {}
86
+ _ACTIVE_DETACHED_RESUME_INVOCATION_BY_KEY: dict[tuple[str, str], str] = {}
87
+ # run_status 事件状态集合:canonical 定义在 ksadk.conversations.run_status,
88
+ # 这里保留旧名做兼容别名(RUN_STATUS_TERMINAL 已含 resume_failed)。
89
+ _RUN_TERMINAL_STATUSES = RUN_STATUS_TERMINAL
90
+ _RUN_ACTIVE_STATUSES = RUN_STATUS_ACTIVE
91
+
92
+
93
+ def _parse_iso_datetime(value: Any) -> datetime | None:
94
+ raw = str(value or "").strip()
95
+ if not raw:
96
+ return None
97
+ if raw.endswith("Z"):
98
+ raw = f"{raw[:-1]}+00:00"
99
+ try:
100
+ parsed = datetime.fromisoformat(raw)
101
+ except ValueError:
102
+ return None
103
+ if parsed.tzinfo is None:
104
+ return parsed.replace(tzinfo=timezone.utc)
105
+ return parsed.astimezone(timezone.utc)
106
+
107
+
108
+ _RESERVED_UI_PATHS = {"/", "/chat", "/build", "/deploy"}
109
+ _CUSTOM_API_PROXY_ENV_KEYS = ("KSADK_USER_BACKEND_URL", "LUOLUO_USER_BACKEND_URL")
110
+ _HOP_BY_HOP_HEADERS = {
111
+ "connection",
112
+ "keep-alive",
113
+ "proxy-authenticate",
114
+ "proxy-authorization",
115
+ "te",
116
+ "trailer",
117
+ "transfer-encoding",
118
+ "upgrade",
119
+ "host",
120
+ "content-length",
121
+ }
122
+
123
+
124
+ class _DetachedSSEStream:
125
+ _MAX_BACKLOG_CHUNKS = 256
126
+
127
+ def __init__(
128
+ self,
129
+ source: AsyncIterator[str],
130
+ *,
131
+ invocation_id: str | None = None,
132
+ session_id: str | None = None,
133
+ run_mode: str = "unknown",
134
+ run_trigger: str = "unknown",
135
+ ):
136
+ self._source = source
137
+ self.invocation_id = invocation_id
138
+ self.session_id = session_id
139
+ self._run_mode = run_mode
140
+ self._run_trigger = run_trigger
141
+ self._subscribers: set[asyncio.Queue[str | None]] = set()
142
+ self._backlog: list[str] = []
143
+ self._done = False
144
+ self._task = asyncio.create_task(self._consume())
145
+ _DETACHED_STREAMS.add(self._task)
146
+ self._task.add_done_callback(_DETACHED_STREAMS.discard)
147
+ if self.invocation_id:
148
+ _DETACHED_STREAMS_BY_INVOCATION[self.invocation_id] = self
149
+ self._task.add_done_callback(
150
+ lambda _task: _DETACHED_STREAMS_BY_INVOCATION.pop(self.invocation_id or "", None)
151
+ )
152
+
153
+ async def _has_terminal_run_status(self) -> bool:
154
+ if not self.session_id or not self.invocation_id:
155
+ return False
156
+ service = resolve_session_service()
157
+ for event in reversed(await service.get_events(self.session_id)):
158
+ if event.invocation_id != self.invocation_id or event.event_type != "run_status":
159
+ continue
160
+ status = str((event.content or {}).get("status") or "").strip().lower()
161
+ return status in _RUN_TERMINAL_STATUSES
162
+ return False
163
+
164
+ async def _consume(self) -> None:
165
+ terminal_fallback_status: str | None = None
166
+ try:
167
+ async for chunk in self._source:
168
+ self._backlog.append(chunk)
169
+ if len(self._backlog) > self._MAX_BACKLOG_CHUNKS:
170
+ self._backlog = self._backlog[-self._MAX_BACKLOG_CHUNKS :]
171
+ subscribers = list(self._subscribers)
172
+ if not subscribers:
173
+ continue
174
+ await asyncio.gather(
175
+ *(subscriber.put(chunk) for subscriber in subscribers),
176
+ return_exceptions=True,
177
+ )
178
+ except asyncio.CancelledError:
179
+ terminal_fallback_status = "cancelled"
180
+ raise
181
+ except Exception:
182
+ terminal_fallback_status = "failed"
183
+ logger.exception("Detached SSE stream failed")
184
+ raise
185
+ finally:
186
+ self._done = True
187
+ subscribers = list(self._subscribers)
188
+ if subscribers:
189
+ await asyncio.gather(
190
+ *(subscriber.put(None) for subscriber in subscribers),
191
+ return_exceptions=True,
192
+ )
193
+ if terminal_fallback_status and self.session_id:
194
+ try:
195
+ if not await self._has_terminal_run_status():
196
+ await conversation.append_run_status_event(
197
+ session_id=self.session_id,
198
+ author="system",
199
+ status=terminal_fallback_status,
200
+ invocation_id=self.invocation_id or "",
201
+ detail=(
202
+ f"background_{terminal_fallback_status}:{self.invocation_id or ''}"
203
+ ),
204
+ session_service_provider=resolve_session_service,
205
+ run_mode=self._run_mode,
206
+ run_trigger=self._run_trigger,
207
+ )
208
+ except Exception:
209
+ logger.exception("failed to write background terminal status fallback")
210
+
211
+ def subscribe(self) -> asyncio.Queue[str | None]:
212
+ queue: asyncio.Queue[str | None] = asyncio.Queue()
213
+ for chunk in self._backlog:
214
+ queue.put_nowait(chunk)
215
+ if self._done:
216
+ queue.put_nowait(None)
217
+ else:
218
+ self._subscribers.add(queue)
219
+ return queue
220
+
221
+ def unsubscribe(self, queue: asyncio.Queue[str | None]) -> None:
222
+ self._subscribers.discard(queue)
223
+
224
+ def cancel(self) -> bool:
225
+ if self._task.done():
226
+ return False
227
+ return self._task.cancel()
228
+
229
+ async def iter_for_client(self) -> AsyncIterator[str]:
230
+ queue = self.subscribe()
231
+ try:
232
+ while True:
233
+ chunk = await queue.get()
234
+ if chunk is None:
235
+ break
236
+ yield chunk
237
+ finally:
238
+ self.unsubscribe(queue)
239
+
240
+
241
+ def _detached_streaming_response(
242
+ source: AsyncIterator[str],
243
+ *,
244
+ invocation_id: str | None = None,
245
+ session_id: str | None = None,
246
+ resume_key: tuple[str, str] | None = None,
247
+ run_mode: str = "unknown",
248
+ run_trigger: str = "unknown",
249
+ ) -> StreamingResponse:
250
+ detached = _DetachedSSEStream(
251
+ source,
252
+ invocation_id=invocation_id,
253
+ session_id=session_id,
254
+ run_mode=run_mode,
255
+ run_trigger=run_trigger,
256
+ )
257
+ if invocation_id and resume_key:
258
+ _DETACHED_RESUME_KEYS_BY_INVOCATION[invocation_id] = resume_key
259
+ _ACTIVE_DETACHED_RESUME_INVOCATION_BY_KEY[resume_key] = invocation_id
260
+ detached._task.add_done_callback(
261
+ lambda _task: _clear_detached_resume_key(invocation_id, resume_key)
262
+ )
263
+ return StreamingResponse(detached.iter_for_client(), media_type="text/event-stream")
264
+
265
+
266
+ async def _cancel_detached_streams_for_session(session_id: str) -> None:
267
+ target_session_id = str(session_id or "").strip()
268
+ if not target_session_id:
269
+ return
270
+ detached_streams = [
271
+ detached
272
+ for detached in list(_DETACHED_STREAMS_BY_INVOCATION.values())
273
+ if detached.session_id == target_session_id
274
+ ]
275
+ for detached in detached_streams:
276
+ detached.cancel()
277
+ if detached_streams:
278
+ await asyncio.gather(
279
+ *(detached._task for detached in detached_streams),
280
+ return_exceptions=True,
281
+ )
282
+
283
+
284
+ def _clear_detached_resume_key(invocation_id: str, resume_key: tuple[str, str]) -> None:
285
+ _DETACHED_RESUME_KEYS_BY_INVOCATION.pop(invocation_id, None)
286
+ if _ACTIVE_DETACHED_RESUME_INVOCATION_BY_KEY.get(resume_key) == invocation_id:
287
+ _ACTIVE_DETACHED_RESUME_INVOCATION_BY_KEY.pop(resume_key, None)
288
+
289
+
290
+ def _detached_resume_key_from_input(
291
+ session_id: str | None,
292
+ resume_input: Mapping[str, Any] | None,
293
+ ) -> tuple[str, str] | None:
294
+ if not isinstance(resume_input, Mapping):
295
+ return None
296
+ if str(resume_input.get("type") or "").strip() != "agentengine.resume_checkpoint":
297
+ return None
298
+ normalized_session_id = str(session_id or "").strip()
299
+ run_id = str(resume_input.get("run_id") or "").strip()
300
+ if not normalized_session_id or not run_id:
301
+ return None
302
+ return normalized_session_id, run_id
303
+
304
+
305
+ def _reject_if_detached_resume_active(resume_key: tuple[str, str] | None) -> None:
306
+ if resume_key is None:
307
+ return
308
+ active_resume_invocation_id = _ACTIVE_DETACHED_RESUME_INVOCATION_BY_KEY.get(resume_key)
309
+ if not active_resume_invocation_id:
310
+ return
311
+ raise HTTPException(
312
+ status_code=409,
313
+ detail={
314
+ "code": "resume_already_running",
315
+ "message": "A checkpoint resume is already running for this session and run.",
316
+ "invocation_id": active_resume_invocation_id,
317
+ "session_id": resume_key[0],
318
+ "run_id": resume_key[1],
319
+ },
320
+ )
321
+
322
+
323
+ async def _shutdown_runner_resources():
324
+ terminal_manager.reset_for_tests()
325
+ pending_streams = list(_DETACHED_STREAMS)
326
+ for task in pending_streams:
327
+ task.cancel()
328
+ if pending_streams:
329
+ await asyncio.gather(*pending_streams, return_exceptions=True)
330
+ _DETACHED_STREAMS.clear()
331
+ _DETACHED_STREAMS_BY_INVOCATION.clear()
332
+ _DETACHED_RESUME_KEYS_BY_INVOCATION.clear()
333
+ _ACTIVE_DETACHED_RESUME_INVOCATION_BY_KEY.clear()
334
+
335
+ active_runner = runner
336
+ if active_runner is None:
337
+ return
338
+ close = getattr(active_runner, "close", None)
339
+ if callable(close):
340
+ await close()
341
+
342
+ # 释放 sandbox registry 占用的 E2B sandbox,避免进程退出后仍按秒计费到 E2B 服务端 timeout。
343
+ # clear() 幂等,与模块级 atexit 重复调用安全。
344
+ try:
345
+ from ksadk.sandbox.registry import GLOBAL_SANDBOX_REGISTRY
346
+
347
+ GLOBAL_SANDBOX_REGISTRY.clear()
348
+ except Exception:
349
+ logger.exception("failed to clear sandbox registry on shutdown")
350
+
351
+
352
+ @asynccontextmanager
353
+ async def _lifespan(_app: FastAPI) -> AsyncIterator[None]:
354
+ try:
355
+ yield
356
+ finally:
357
+ await _shutdown_runner_resources()
358
+
359
+
360
+ # Create and configure the FastAPI application
361
+ app = FastAPI(
362
+ title="ADK Core API",
363
+ description="Agent Development Kit HTTP API",
364
+ version="1.0.0",
365
+ lifespan=_lifespan,
366
+ )
367
+
368
+
369
+ # Middleware for disabling cache on frontend entry points
370
+ @app.middleware("http")
371
+ async def no_cache_frontend(request: Request, call_next):
372
+ response = await call_next(request)
373
+ path = request.url.path
374
+ if path == "/" or path.endswith(".html"):
375
+ response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
376
+ response.headers["Pragma"] = "no-cache"
377
+ response.headers["Expires"] = "0"
378
+ return response
379
+
380
+
381
+ # Configure CORS (permissive by default for ADK tools)
382
+ app.add_middleware(
383
+ CORSMiddleware,
384
+ allow_origins=["*"],
385
+ allow_credentials=True,
386
+ allow_methods=["*"],
387
+ allow_headers=["*"],
388
+ )
389
+
390
+ _TEXT_MIME_PREFIXES = ("text/",)
391
+ _TEXT_MIME_TYPES = {
392
+ "application/json",
393
+ "application/pdf",
394
+ "application/xml",
395
+ "application/yaml",
396
+ "application/x-yaml",
397
+ "application/x-ndjson",
398
+ }
399
+ _TEXT_FILE_EXTENSIONS = {
400
+ ".txt",
401
+ ".md",
402
+ ".markdown",
403
+ ".json",
404
+ ".yaml",
405
+ ".yml",
406
+ ".csv",
407
+ ".tsv",
408
+ ".log",
409
+ ".py",
410
+ ".js",
411
+ ".ts",
412
+ ".jsx",
413
+ ".tsx",
414
+ ".html",
415
+ ".css",
416
+ ".sql",
417
+ ".xml",
418
+ ".sh",
419
+ }
420
+ _MAX_INLINE_BASE64_CHARS = 4_000_000
421
+ _MAX_INLINE_TEXT_CHARS = 20_000
422
+ _MAX_REFERENCE_TEXT_BYTES = 3_000_000
423
+ _UPLOAD_URI_SCHEME = "ksadk-upload://"
424
+
425
+
426
+ def _workspace_root_dir() -> Path:
427
+ return resolve_local_session_dir() / "workspace"
428
+
429
+
430
+ _NATIVE_TUI_FRAMEWORKS = {"hermes", "openclaw"}
431
+
432
+
433
+ def _current_framework() -> str:
434
+ if not runner:
435
+ return ""
436
+ detection_type = getattr(getattr(runner, "detection_result", None), "type", None)
437
+ return str(getattr(detection_type, "value", detection_type) or "").strip().lower()
438
+
439
+
440
+ def _build_native_terminal_capability(framework: str) -> dict[str, Any]:
441
+ enabled = (
442
+ native_terminal_supported()
443
+ and str(framework or "").strip().lower() in _NATIVE_TUI_FRAMEWORKS
444
+ )
445
+ return {
446
+ "Enabled": enabled,
447
+ "Mode": "tui" if enabled else None,
448
+ "Protocol": "ks-terminal.v1",
449
+ "Path": "/_ksadk/terminal/ws" if enabled else None,
450
+ }
451
+
452
+
453
+ terminal_manager = TerminalSessionManager(
454
+ workspace_root_getter=_workspace_root_dir,
455
+ framework_getter=_current_framework,
456
+ )
457
+
458
+ app.include_router(
459
+ create_workspace_files_router(
460
+ root_getter=_workspace_root_dir,
461
+ enabled_getter=lambda: workspace_files_enabled(default=True),
462
+ )
463
+ )
464
+ register_terminal_routes(app, terminal_manager)
465
+
466
+
467
+ @app.exception_handler(SessionBackendUnavailable)
468
+ async def session_backend_unavailable_handler(
469
+ _request: Request,
470
+ exc: SessionBackendUnavailable,
471
+ ):
472
+ return Response(
473
+ content=json.dumps(
474
+ {
475
+ "detail": {
476
+ "code": "session_backend_unavailable",
477
+ "message": str(exc),
478
+ }
479
+ },
480
+ ensure_ascii=False,
481
+ ),
482
+ status_code=503,
483
+ media_type="application/json",
484
+ )
485
+
486
+
487
+ def set_runner(r: BaseRunner):
488
+ global runner, _runner_loaded
489
+ runner = r
490
+ _runner_loaded = False
491
+
492
+
493
+ def _ensure_runner_loaded() -> BaseRunner:
494
+ global _runner_loaded
495
+ if not runner:
496
+ raise HTTPException(status_code=500, detail="Runner 未初始化")
497
+ if _runner_loaded:
498
+ return runner
499
+
500
+ runner.load_agent()
501
+ _runner_loaded = True
502
+ return runner
503
+
504
+
505
+ def _resolve_active_runner() -> BaseRunner:
506
+ try:
507
+ return _ensure_runner_loaded()
508
+ except HTTPException:
509
+ raise
510
+ except Exception as exc:
511
+ logger.warning("Runner 加载失败: %s", exc)
512
+ raise HTTPException(status_code=500, detail=str(exc) or "Runner 加载失败") from exc
513
+
514
+
515
+ def _prepare_runner_for_model(active_runner: BaseRunner, model: Optional[str]) -> None:
516
+ try:
517
+ active_runner.prepare_for_request(model)
518
+ except Exception as exc:
519
+ logger.warning("Runner 模型切换失败: %s", exc)
520
+ raise HTTPException(status_code=500, detail=str(exc) or "Runner 模型切换失败") from exc
521
+
522
+
523
+ def _resolve_current_model() -> tuple[Optional[str], Optional[str]]:
524
+ candidates = (
525
+ ("OPENAI_MODEL_NAME", os.getenv("OPENAI_MODEL_NAME")),
526
+ ("MODEL_NAME", os.getenv("MODEL_NAME")),
527
+ ("COZE_MODEL_NAME", os.getenv("COZE_MODEL_NAME")),
528
+ )
529
+ for source, value in candidates:
530
+ model = str(value or "").strip()
531
+ if model:
532
+ return model, source
533
+ return None, None
534
+
535
+
536
+ def _build_bootstrap_model_payload() -> Optional[dict[str, Any]]:
537
+ current_model, source = _resolve_current_model()
538
+ if not current_model:
539
+ return None
540
+
541
+ payload = normalize_model_metadata({"id": current_model})
542
+ payload["source"] = source
543
+ return payload
544
+
545
+
546
+ def _runner_project_dir() -> Path:
547
+ if runner and getattr(runner, "project_dir", None):
548
+ try:
549
+ return Path(str(runner.project_dir)).resolve()
550
+ except Exception:
551
+ pass
552
+ return Path(".").resolve()
553
+
554
+
555
+ def _default_custom_ui_bundle_dir(project_dir: Path) -> Path:
556
+ return project_dir / "research-ui" / "dist"
557
+
558
+
559
+ def _ui_state_with_env_fallback(state: dict[str, Any]) -> dict[str, Any]:
560
+ merged = dict(state or {})
561
+ env_fallbacks = {
562
+ "ui_profile": os.environ.get("KSADK_UI_PROFILE"),
563
+ "ui_path": os.environ.get("KSADK_UI_PATH"),
564
+ "ui_url": os.environ.get("KSADK_UI_URL"),
565
+ "ui_bundle_path": os.environ.get("KSADK_UI_BUNDLE_PATH"),
566
+ }
567
+ for key, value in env_fallbacks.items():
568
+ if value and not merged.get(key):
569
+ merged[key] = value
570
+ return merged
571
+
572
+
573
+ def _resolve_agent_ui_spec() -> dict[str, Any]:
574
+ project_dir = _runner_project_dir()
575
+ state = _ui_state_with_env_fallback(load_runtime_state(project_dir))
576
+ framework = _current_framework()
577
+ auto_custom_bundle_dir = _default_custom_ui_bundle_dir(project_dir)
578
+ if (
579
+ not state.get("ui_profile")
580
+ and not state.get("ui_path")
581
+ and not state.get("ui_url")
582
+ and (auto_custom_bundle_dir / "index.html").exists()
583
+ ):
584
+ state["ui_profile"] = UI_PROFILE_CUSTOM
585
+ state["ui_path"] = "/"
586
+ state["ui_bundle_path"] = str(auto_custom_bundle_dir)
587
+ config = resolve_ui_config(
588
+ framework=framework,
589
+ state=state,
590
+ cli_profile=None,
591
+ cli_path=None,
592
+ cli_url=None,
593
+ )
594
+
595
+ if config.profile == UI_PROFILE_CUSTOM:
596
+ bundle_dir_value = state.get("ui_bundle_path") or state.get("ui_bundle_dir")
597
+ bundle_dir = None
598
+ if bundle_dir_value:
599
+ candidate = Path(str(bundle_dir_value))
600
+ if not candidate.is_absolute():
601
+ candidate = project_dir / candidate
602
+ if candidate.exists():
603
+ bundle_dir = candidate.resolve()
604
+ if bundle_dir is None:
605
+ bundle_dir = _default_custom_ui_bundle_dir(project_dir)
606
+ index_file = bundle_dir / "index.html"
607
+ enabled = bundle_dir.exists() and index_file.exists()
608
+ return {
609
+ "enabled": enabled,
610
+ "profile": config.profile,
611
+ "ui_profile": config.profile,
612
+ "path": config.path,
613
+ "ui_path": config.path,
614
+ "url": config.url,
615
+ "ui_url": config.url,
616
+ "bundle_path": str(bundle_dir),
617
+ "ui_bundle_path": str(bundle_dir),
618
+ "index_path": str(index_file),
619
+ "source": "custom",
620
+ }
621
+
622
+ bundle_dir = STATIC_DIR
623
+ index_file = bundle_dir / "index.html"
624
+ enabled = bundle_dir.exists() and index_file.exists()
625
+ return {
626
+ "enabled": enabled,
627
+ "profile": config.profile,
628
+ "ui_profile": config.profile,
629
+ "path": config.path,
630
+ "ui_path": config.path,
631
+ "url": config.url,
632
+ "ui_url": config.url,
633
+ "bundle_path": str(bundle_dir),
634
+ "ui_bundle_path": str(bundle_dir),
635
+ "index_path": str(index_file),
636
+ "source": "builtin",
637
+ }
638
+
639
+
640
+ def _normalize_request_ui_path(request_path: str) -> str:
641
+ path = "/" + str(request_path or "").lstrip("/")
642
+ return path if path != "//" else "/"
643
+
644
+
645
+ def _is_custom_ui_static_asset_path(relative_path: str) -> bool:
646
+ path = str(relative_path or "").strip("/")
647
+ if not path:
648
+ return False
649
+ first_segment = path.split("/", 1)[0]
650
+ return first_segment == "assets" or bool(Path(path).suffix)
651
+
652
+
653
+ def _resolve_ui_static_response(request_path: str) -> Optional[FileResponse]:
654
+ spec = _resolve_agent_ui_spec()
655
+ if not spec.get("enabled"):
656
+ return None
657
+
658
+ bundle_dir = Path(str(spec["bundle_path"]))
659
+ index_file = Path(str(spec["index_path"]))
660
+ path = _normalize_request_ui_path(request_path)
661
+
662
+ if spec.get("source") == "custom":
663
+ ui_path = _normalize_request_ui_path(str(spec.get("path") or "/")).rstrip("/") or "/"
664
+ if path == ui_path or path == f"{ui_path}/":
665
+ return FileResponse(index_file)
666
+ if ui_path != "/" and not path.startswith(f"{ui_path}/"):
667
+ return None
668
+ relative = path[len(ui_path) :].lstrip("/") if ui_path != "/" else path.lstrip("/")
669
+ if not relative:
670
+ return FileResponse(index_file)
671
+ candidate = bundle_dir / relative
672
+ if candidate.exists() and candidate.is_file():
673
+ return FileResponse(candidate)
674
+ if not _is_custom_ui_static_asset_path(relative):
675
+ return FileResponse(index_file)
676
+ return None
677
+
678
+ if path in _RESERVED_UI_PATHS:
679
+ return FileResponse(index_file)
680
+
681
+ candidate = bundle_dir / path.lstrip("/")
682
+ if candidate.exists() and candidate.is_file():
683
+ return FileResponse(candidate)
684
+ return None
685
+
686
+
687
+ def _is_textual_mime(mime_type: str) -> bool:
688
+ mime = (mime_type or "").lower()
689
+ if not mime:
690
+ return False
691
+ return mime.startswith(_TEXT_MIME_PREFIXES) or mime in _TEXT_MIME_TYPES
692
+
693
+
694
+ def _looks_like_textual_attachment(mime_type: str, display_name: str) -> bool:
695
+ suffix = Path(display_name or "").suffix.lower()
696
+ return _is_textual_mime(mime_type) or suffix in _TEXT_FILE_EXTENSIONS
697
+
698
+
699
+ def _extract_pdf_text(raw: bytes) -> str:
700
+ try:
701
+ from pypdf import PdfReader
702
+ except Exception:
703
+ return ""
704
+
705
+ try:
706
+ reader = PdfReader(io.BytesIO(raw))
707
+ except Exception:
708
+ return ""
709
+
710
+ segments: List[str] = []
711
+ for page in reader.pages[:10]:
712
+ try:
713
+ page_text = page.extract_text() or ""
714
+ except Exception:
715
+ page_text = ""
716
+ if page_text:
717
+ segments.append(page_text)
718
+
719
+ return "\n".join(segments).strip()
720
+
721
+
722
+ def _decode_inline_data(data_b64: str) -> bytes:
723
+ return base64.b64decode((data_b64 or "").strip() + "===")
724
+
725
+
726
+ def _resolve_uploads_dir() -> Path:
727
+ uploads_dir = resolve_local_session_dir() / "files"
728
+ uploads_dir.mkdir(parents=True, exist_ok=True)
729
+ return uploads_dir
730
+
731
+
732
+ def _resolve_attachment_storage_path(file_uri: str) -> Optional[Path]:
733
+ normalized_uri = (file_uri or "").strip()
734
+ if not normalized_uri:
735
+ return None
736
+
737
+ if normalized_uri.startswith("local:"):
738
+ path = Path(normalized_uri[6:]).expanduser()
739
+ return path.resolve()
740
+
741
+ if normalized_uri.startswith(_UPLOAD_URI_SCHEME):
742
+ file_id = normalized_uri.removeprefix(_UPLOAD_URI_SCHEME).strip("/")
743
+ if not file_id:
744
+ return None
745
+
746
+ for candidate in sorted(_resolve_uploads_dir().glob(f"{file_id}*")):
747
+ if candidate.is_file():
748
+ return candidate.resolve()
749
+
750
+ return None
751
+
752
+
753
+ def _custom_api_proxy_base_url() -> str:
754
+ for key in _CUSTOM_API_PROXY_ENV_KEYS:
755
+ value = os.environ.get(key)
756
+ if value and value.strip():
757
+ return value.strip().rstrip("/")
758
+ return ""
759
+
760
+
761
+ def _proxy_headers(headers: Mapping[str, str]) -> dict[str, str]:
762
+ return {key: value for key, value in headers.items() if key.lower() not in _HOP_BY_HOP_HEADERS}
763
+
764
+
765
+ def _response_headers(headers: Mapping[str, str]) -> dict[str, str]:
766
+ return {key: value for key, value in headers.items() if key.lower() not in _HOP_BY_HOP_HEADERS}
767
+
768
+
769
+ @app.api_route(
770
+ "/api/{proxy_path:path}",
771
+ methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
772
+ include_in_schema=False,
773
+ )
774
+ async def custom_api_proxy(proxy_path: str, request: Request):
775
+ base_url = _custom_api_proxy_base_url()
776
+ if not base_url:
777
+ raise HTTPException(status_code=404, detail="Custom API backend is not configured")
778
+
779
+ path = f"/api/{proxy_path.lstrip('/')}"
780
+ query = request.url.query
781
+ target_url = f"{base_url}{path}"
782
+ if query:
783
+ target_url = f"{target_url}?{query}"
784
+
785
+ try:
786
+ async with httpx.AsyncClient(timeout=60.0) as client:
787
+ upstream = await client.request(
788
+ request.method,
789
+ target_url,
790
+ content=await request.body(),
791
+ headers=_proxy_headers(request.headers),
792
+ )
793
+ except httpx.HTTPError as exc:
794
+ raise HTTPException(
795
+ status_code=502, detail=f"Custom API backend unavailable: {exc}"
796
+ ) from exc
797
+
798
+ return Response(
799
+ content=upstream.content,
800
+ status_code=upstream.status_code,
801
+ headers=_response_headers(upstream.headers),
802
+ media_type=upstream.headers.get("content-type"),
803
+ )
804
+
805
+
806
+ def _read_attachment_bytes(
807
+ storage_path: Optional[Path], *, size_limit: Optional[int] = None
808
+ ) -> Optional[bytes]:
809
+ if storage_path is None or not storage_path.is_file():
810
+ return None
811
+
812
+ try:
813
+ if size_limit is not None and storage_path.stat().st_size > size_limit:
814
+ return None
815
+ return storage_path.read_bytes()
816
+ except OSError:
817
+ return None
818
+
819
+
820
+ def _extract_inline_attachment_text(*, display_name: str, mime_type: str, raw: bytes) -> str:
821
+ if mime_type == "application/pdf" or display_name.lower().endswith(".pdf"):
822
+ text = _extract_pdf_text(raw)
823
+ if not text:
824
+ return ""
825
+ if len(text) > _MAX_INLINE_TEXT_CHARS:
826
+ return text[:_MAX_INLINE_TEXT_CHARS] + "\n...[内容已截断]"
827
+ return text
828
+
829
+ if _looks_like_textual_attachment(mime_type, display_name):
830
+ text = raw.decode("utf-8", errors="ignore")
831
+ if len(text) > _MAX_INLINE_TEXT_CHARS:
832
+ return text[:_MAX_INLINE_TEXT_CHARS] + "\n...[内容已截断]"
833
+ return text
834
+
835
+ return ""
836
+
837
+
838
+ def _attachment_prompt_text(attachment: Dict[str, Any]) -> str:
839
+ display_name = str(attachment.get("display_name") or "uploaded_file")
840
+ mime_type = str(attachment.get("mime_type") or "application/octet-stream")
841
+ transport = str(attachment.get("transport") or "")
842
+
843
+ if transport == "inline":
844
+ data_b64 = str(attachment.get("data") or "").strip()
845
+ if len(data_b64) > _MAX_INLINE_BASE64_CHARS:
846
+ return (
847
+ f"[上传文件: {display_name}, mime={mime_type or 'unknown'}, 内容过大,未直接展开]"
848
+ )
849
+
850
+ try:
851
+ raw = _decode_inline_data(data_b64)
852
+ except Exception:
853
+ return f"[上传文件: {display_name}, 内容解码失败]"
854
+
855
+ text = _extract_inline_attachment_text(
856
+ display_name=display_name,
857
+ mime_type=mime_type,
858
+ raw=raw,
859
+ )
860
+ if text:
861
+ return f"[上传文件: {display_name}]\n{text}"
862
+ return (
863
+ "[上传文件: "
864
+ f"{display_name}, "
865
+ f"mime={mime_type or 'application/octet-stream'}, "
866
+ f"bytes={len(raw)}]"
867
+ )
868
+
869
+ storage_path_value = attachment.get("storage_path")
870
+ storage_path = Path(str(storage_path_value)) if storage_path_value else None
871
+ size_bytes = attachment.get("size_bytes")
872
+ if size_bytes is None and storage_path is not None and storage_path.exists():
873
+ try:
874
+ size_bytes = storage_path.stat().st_size
875
+ except OSError:
876
+ size_bytes = None
877
+
878
+ raw = _read_attachment_bytes(storage_path, size_limit=_MAX_REFERENCE_TEXT_BYTES)
879
+ if raw is not None:
880
+ text = _extract_inline_attachment_text(
881
+ display_name=display_name,
882
+ mime_type=mime_type,
883
+ raw=raw,
884
+ )
885
+ if text:
886
+ return f"[上传文件: {display_name}]\n{text}"
887
+ return (
888
+ "[上传文件: "
889
+ f"{display_name}, "
890
+ f"mime={mime_type or 'application/octet-stream'}, "
891
+ f"bytes={len(raw)}]"
892
+ )
893
+
894
+ if size_bytes and size_bytes > _MAX_REFERENCE_TEXT_BYTES:
895
+ return (
896
+ "[上传文件: "
897
+ f"{display_name}, "
898
+ f"mime={mime_type or 'unknown'}, "
899
+ f"bytes={size_bytes}, "
900
+ "内容过大,未直接展开]"
901
+ )
902
+
903
+ file_uri = attachment.get("file_uri") or ""
904
+ return f"[上传文件引用: {display_name or file_uri}, mime={mime_type or 'unknown'}]"
905
+
906
+
907
+ def _extract_user_input_from_parts(parts: List[Any]) -> str:
908
+ """兼容旧测试/旧调用点,统一复用 conversations 层的规范化逻辑。"""
909
+
910
+ return conversation.extract_user_input_from_parts(parts)
911
+
912
+
913
+ def _attachment_from_part(part: Any) -> Optional[Dict[str, Any]]:
914
+ """兼容旧入口,真实实现已经收口到 conversations.normalize。"""
915
+
916
+ return conversation.attachment_from_part(part)
917
+
918
+
919
+ async def _hydrate_session(session: Optional[Session]) -> Optional[Session]:
920
+ if not session:
921
+ return None
922
+ session.events = await resolve_session_service().get_events(session.id)
923
+ return session
924
+
925
+
926
+ async def _ensure_session(agent_id: str, user_id: str, session_id: Optional[str]) -> Session:
927
+ service = resolve_session_service()
928
+ if session_id:
929
+ existing = await service.get_session(session_id)
930
+ if existing:
931
+ if existing.agent_id != agent_id or existing.user_id != user_id:
932
+ raise HTTPException(
933
+ status_code=409,
934
+ detail="Session id belongs to a different agent or user",
935
+ )
936
+ return await _hydrate_session(existing) or existing
937
+ created = await service.create_session(agent_id, user_id, session_id=session_id)
938
+ return await _hydrate_session(created) or created
939
+
940
+ created = await service.create_session(agent_id, user_id)
941
+ return await _hydrate_session(created) or created
942
+
943
+
944
+ def _sanitize_session_state_for_action(state: Mapping[str, Any] | None) -> dict[str, Any]:
945
+ sanitized = dict(state or {})
946
+ attachment_context = sanitized.get(conversation.runtime.ATTACHMENT_CONTEXT_STATE_KEY)
947
+ if not isinstance(attachment_context, Mapping):
948
+ return sanitized
949
+
950
+ attachments = [
951
+ conversation.compact_attachment_for_session(item)
952
+ for item in attachment_context.get("attachments") or []
953
+ if isinstance(item, dict)
954
+ ]
955
+ attachment_results = [
956
+ compact_attachment_result_for_session(item)
957
+ for item in attachment_context.get("attachment_results") or []
958
+ if isinstance(item, dict)
959
+ ]
960
+ sanitized[conversation.runtime.ATTACHMENT_CONTEXT_STATE_KEY] = {
961
+ "attachments": attachments,
962
+ "attachment_results": attachment_results,
963
+ }
964
+ return sanitized
965
+
966
+
967
+ def _request_id() -> str:
968
+ return f"req-{uuid.uuid4().hex[:12]}"
969
+
970
+
971
+ def _action_response(
972
+ action: str, data: Any, *, request_id: Optional[str] = None, message: str = "Success"
973
+ ) -> dict:
974
+ payload = {
975
+ "Code": 0,
976
+ "Message": message,
977
+ "RequestId": request_id or _request_id(),
978
+ "Data": data,
979
+ }
980
+ if action:
981
+ payload["Action"] = action
982
+ return payload
983
+
984
+
985
+ async def _workspace_runtime_request(
986
+ method: str,
987
+ runtime_path: str,
988
+ *,
989
+ params: Optional[Dict[str, Any]] = None,
990
+ files: Optional[Dict[str, Any]] = None,
991
+ ) -> httpx.Response:
992
+ transport = httpx.ASGITransport(app=app)
993
+ async with httpx.AsyncClient(transport=transport, base_url="http://ksadk.local") as client:
994
+ response = await client.request(
995
+ method,
996
+ runtime_path,
997
+ params=params,
998
+ files=files,
999
+ )
1000
+
1001
+ if response.status_code >= 400:
1002
+ detail = response.text
1003
+ try:
1004
+ payload = response.json()
1005
+ except Exception:
1006
+ payload = None
1007
+ if isinstance(payload, dict):
1008
+ detail = str(payload.get("detail") or detail)
1009
+ raise HTTPException(
1010
+ status_code=response.status_code, detail=detail or "Workspace request failed"
1011
+ )
1012
+ return response
1013
+
1014
+
1015
+ # ============================================================
1016
+ # Core ADK API Endpoints
1017
+ # ============================================================
1018
+
1019
+
1020
+ @app.get("/health")
1021
+ async def health_check():
1022
+ framework = "unknown"
1023
+ agent_name = "unknown"
1024
+ if runner and hasattr(runner, "detection_result"):
1025
+ framework = runner.detection_result.type.value # langgraph, langchain, adk
1026
+ agent_name = runner.detection_result.name
1027
+ return {"status": "ok", "framework": framework, "agent": agent_name}
1028
+
1029
+
1030
+ @app.get("/list-apps")
1031
+ async def list_apps(relative_path: str = "./"):
1032
+ """Return available apps. For KsADK single-agent mode, returns the current agent."""
1033
+ name = runner.detection_result.name if runner else "default_agent"
1034
+ return [name]
1035
+
1036
+
1037
+ class UiBootstrapRequest(BaseModel):
1038
+ AgentId: Optional[str] = None
1039
+ SessionId: Optional[str] = None
1040
+
1041
+
1042
+ class CreateSessionActionRequest(BaseModel):
1043
+ AgentId: str
1044
+ UserId: Optional[str] = "user"
1045
+ SessionId: Optional[str] = None
1046
+
1047
+
1048
+ class ListSessionsActionRequest(BaseModel):
1049
+ AgentId: str
1050
+ UserId: Optional[str] = "user"
1051
+ Page: int = Field(1, ge=1)
1052
+ PageSize: int = Field(20, ge=1, le=200)
1053
+
1054
+
1055
+ class SessionIdRequest(BaseModel):
1056
+ SessionId: str
1057
+
1058
+
1059
+ class ListSessionEventsActionRequest(BaseModel):
1060
+ SessionId: str
1061
+ Offset: Optional[int] = Field(None, ge=0)
1062
+ Limit: Optional[int] = Field(None, ge=1)
1063
+ AfterSeqId: Optional[int] = Field(None, ge=0)
1064
+ BeforeSeqId: Optional[int] = Field(None, ge=1)
1065
+
1066
+
1067
+ class ListSessionMessagesActionRequest(BaseModel):
1068
+ SessionId: str
1069
+ AfterSeqId: Optional[int] = Field(None, ge=0)
1070
+ BeforeSeqId: Optional[int] = Field(None, ge=1)
1071
+ Limit: int = Field(50, ge=1, le=200)
1072
+ IncludeReasoning: bool = False
1073
+ IncludeToolEvents: bool = False
1074
+ IncludeAttachments: bool = True
1075
+
1076
+
1077
+ class ListSessionCheckpointsActionRequest(BaseModel):
1078
+ AgentId: str
1079
+ SessionId: str
1080
+ RunId: Optional[str] = None
1081
+ OnlyResumable: bool = False
1082
+ Framework: Optional[str] = None
1083
+ Offset: Optional[int] = Field(None, ge=0)
1084
+ Limit: Optional[int] = Field(None, ge=1, le=500)
1085
+
1086
+
1087
+ class ListToolReceiptsActionRequest(BaseModel):
1088
+ AgentId: str
1089
+ SessionId: str
1090
+ RunId: Optional[str] = None
1091
+ CheckpointId: Optional[str] = None
1092
+
1093
+
1094
+ class ResumeRunActionRequest(BaseModel):
1095
+ AgentId: str
1096
+ SessionId: str
1097
+ RunId: str
1098
+ CheckpointId: str
1099
+ ResumeAttemptId: Optional[str] = None
1100
+ InvocationId: Optional[str] = None
1101
+ Stream: bool = False
1102
+ Model: Optional[str] = None
1103
+ ModelMetadata: Optional[Dict[str, Any]] = None
1104
+ ModelOptions: Optional[Dict[str, Any]] = None
1105
+ ResumeInstructionEnabled: bool = False
1106
+ ResumeInstruction: Optional[str] = None
1107
+
1108
+
1109
+ class GetCheckpointResumePreviewActionRequest(BaseModel):
1110
+ AgentId: str
1111
+ SessionId: str
1112
+ RunId: str
1113
+ CheckpointId: str
1114
+
1115
+
1116
+ class RunAgentActionRequest(BaseModel):
1117
+ AgentId: str
1118
+ Messages: List[Dict[str, Any]] = Field(default_factory=list)
1119
+ UserId: Optional[str] = "user"
1120
+ AccountId: Optional[str] = None
1121
+ SessionId: Optional[str] = None
1122
+ InvocationId: Optional[str] = None
1123
+ ApiFormat: str = "responses"
1124
+ Stream: bool = False
1125
+ Background: bool = False # 立即返回 job 句柄,后台执行,进度走 SubscribeRunEvents
1126
+ Model: Optional[str] = None
1127
+ ModelMetadata: Optional[Dict[str, Any]] = None
1128
+ ModelOptions: Optional[Dict[str, Any]] = None
1129
+ ResponsesInput: Optional[Any] = None
1130
+ PreviousResponseId: Optional[str] = None
1131
+
1132
+
1133
+ class ResponseFeedbackRefActionRequest(BaseModel):
1134
+ AgentId: str
1135
+ SessionId: str
1136
+ ResponseId: str
1137
+
1138
+
1139
+ class UpsertResponseFeedbackActionRequest(ResponseFeedbackRefActionRequest):
1140
+ Rating: str
1141
+ Comment: Optional[str] = ""
1142
+ EventId: Optional[str] = None
1143
+ TraceId: Optional[str] = None
1144
+ RootSpanId: Optional[str] = None
1145
+
1146
+
1147
+ class ResponsesRequest(BaseModel):
1148
+ input: Any
1149
+ model: Optional[str] = None
1150
+ model_metadata: Optional[Dict[str, Any]] = None
1151
+ model_options: Optional[Dict[str, Any]] = None
1152
+ instructions: Optional[str] = None
1153
+ metadata: Optional[Dict[str, Any]] = None
1154
+ conversation: Optional[Any] = None
1155
+ safety_identifier: Optional[str] = None
1156
+ prompt_cache_key: Optional[str] = None
1157
+ user: Optional[str] = None
1158
+ account_id: Optional[str] = None
1159
+ store: Optional[bool] = None
1160
+ previous_response_id: Optional[str] = None
1161
+ stream: bool = False
1162
+ session_id: Optional[str] = None
1163
+
1164
+
1165
+ class WorkspaceListActionRequest(BaseModel):
1166
+ AgentId: Optional[str] = None
1167
+ Path: str = "."
1168
+ Recursive: bool = False
1169
+
1170
+
1171
+ def _clean_optional_string(value: Any) -> str | None:
1172
+ if value is None:
1173
+ return None
1174
+ text = str(value).strip()
1175
+ return text or None
1176
+
1177
+
1178
+ def _resolve_responses_conversation_id(conversation_value: Any) -> str | None:
1179
+ if conversation_value is None:
1180
+ return None
1181
+ if isinstance(conversation_value, str):
1182
+ return _clean_optional_string(conversation_value)
1183
+ if isinstance(conversation_value, Mapping):
1184
+ return _clean_optional_string(conversation_value.get("id"))
1185
+ raise HTTPException(
1186
+ status_code=400,
1187
+ detail="Responses field 'conversation' must be a string or an object with an 'id'.",
1188
+ )
1189
+
1190
+
1191
+ def _resolve_responses_session_and_user(request: ResponsesRequest) -> tuple[str | None, str]:
1192
+ conversation_id = _resolve_responses_conversation_id(request.conversation)
1193
+ legacy_session_id = _clean_optional_string(request.session_id)
1194
+
1195
+ if conversation_id and legacy_session_id and conversation_id != legacy_session_id:
1196
+ raise HTTPException(
1197
+ status_code=400,
1198
+ detail=(
1199
+ "Responses field 'conversation' conflicts with ksadk legacy field "
1200
+ "'session_id'. Use 'conversation' for OpenAI-compatible calls."
1201
+ ),
1202
+ )
1203
+ if conversation_id and request.previous_response_id:
1204
+ raise HTTPException(
1205
+ status_code=400,
1206
+ detail=(
1207
+ "Responses fields 'conversation' and 'previous_response_id' cannot be "
1208
+ "used together."
1209
+ ),
1210
+ )
1211
+
1212
+ resolved_session_id = conversation_id or legacy_session_id
1213
+ resolved_user_id = (
1214
+ _clean_optional_string(request.safety_identifier)
1215
+ or _clean_optional_string(request.user)
1216
+ or "user"
1217
+ )
1218
+ return resolved_session_id, resolved_user_id
1219
+
1220
+
1221
+ def _runtime_agent_id(active_runner: BaseRunner) -> str:
1222
+ runtime_id = _clean_optional_string(os.getenv("AGENT_RUNTIME_ID"))
1223
+ if runtime_id:
1224
+ return runtime_id
1225
+ return str(getattr(active_runner.detection_result, "name", "") or "agent")
1226
+
1227
+
1228
+ def _metadata_invocation_id(metadata: Mapping[str, Any] | None) -> str | None:
1229
+ if not isinstance(metadata, Mapping):
1230
+ return None
1231
+ agentengine_metadata = metadata.get("agentengine")
1232
+ if not isinstance(agentengine_metadata, Mapping):
1233
+ return None
1234
+ return _clean_optional_string(agentengine_metadata.get("invocation_id"))
1235
+
1236
+
1237
+ def _event_text(event: SessionEvent) -> str:
1238
+ parts = (event.content or {}).get("parts")
1239
+ if isinstance(parts, list):
1240
+ text_parts: list[str] = []
1241
+ for part in parts:
1242
+ if isinstance(part, Mapping):
1243
+ text_parts.append(str(part.get("text") or ""))
1244
+ else:
1245
+ text_parts.append(str(part or ""))
1246
+ text = "".join(text_parts).strip()
1247
+ if text:
1248
+ return text
1249
+ return str((event.content or {}).get("text") or "").strip()
1250
+
1251
+
1252
+ def _truncate_session_text(text: str, limit: int = 512) -> str:
1253
+ normalized = " ".join(str(text or "").strip().split())
1254
+ if len(normalized) <= limit:
1255
+ return normalized
1256
+ return f"{normalized[: max(limit - 1, 0)].rstrip()}…"
1257
+
1258
+
1259
+ def _session_user_prompt_from_event(event: SessionEvent) -> str:
1260
+ metadata = event.metadata or {}
1261
+ content = event.content or {}
1262
+ return str(
1263
+ metadata.get("agent_input")
1264
+ or metadata.get("user_input")
1265
+ or content.get("agent_input")
1266
+ or _event_text(event)
1267
+ or ""
1268
+ ).strip()
1269
+
1270
+
1271
+ def _run_status_payload_status(event: SessionEvent) -> str:
1272
+ return str(
1273
+ (event.metadata or {}).get("status")
1274
+ or (event.metadata or {}).get("run_status")
1275
+ or (event.content or {}).get("status")
1276
+ or ""
1277
+ ).strip()
1278
+
1279
+
1280
+ def _event_run_id(event: SessionEvent) -> str:
1281
+ return str(
1282
+ (event.metadata or {}).get("run_id")
1283
+ or (event.metadata or {}).get("invocation_id")
1284
+ or event.invocation_id
1285
+ or ""
1286
+ ).strip()
1287
+
1288
+
1289
+ def _session_topic_from_events(events: list[SessionEvent]) -> str:
1290
+ for event in reversed(events):
1291
+ metadata = event.metadata or {}
1292
+ tool_output = metadata.get("tool_output")
1293
+ if isinstance(tool_output, Mapping):
1294
+ topic = str(tool_output.get("topic") or tool_output.get("research_title") or "").strip()
1295
+ if topic:
1296
+ return topic
1297
+ topic = str(metadata.get("research_title") or metadata.get("task_title") or "").strip()
1298
+ if topic:
1299
+ return topic
1300
+ return ""
1301
+
1302
+
1303
+ def _latest_session_run_status(events: list[SessionEvent]) -> tuple[str, str]:
1304
+ latest_by_invocation: dict[str, tuple[str, SessionEvent]] = {}
1305
+ for event in reversed(events):
1306
+ if event.event_type != "run_status":
1307
+ continue
1308
+ status = _run_status_payload_status(event)
1309
+ invocation_id = _event_run_id(event)
1310
+ if status or invocation_id:
1311
+ latest_by_invocation.setdefault(invocation_id, (status, event))
1312
+ for invocation_id, (status, _) in latest_by_invocation.items():
1313
+ if status in _RUN_ACTIVE_STATUSES:
1314
+ return invocation_id, status
1315
+ for invocation_id, (status, _) in latest_by_invocation.items():
1316
+ if status not in _RUN_TERMINAL_STATUSES:
1317
+ return invocation_id, status
1318
+ if latest_by_invocation:
1319
+ invocation_id, (status, _) = next(iter(latest_by_invocation.items()))
1320
+ return invocation_id, status
1321
+ for event in reversed(events):
1322
+ invocation_id = _event_run_id(event)
1323
+ if invocation_id:
1324
+ return invocation_id, ""
1325
+ return "", ""
1326
+
1327
+
1328
+ def _latest_session_run_metadata(
1329
+ events: list[SessionEvent],
1330
+ ) -> tuple[str, str, str, str]:
1331
+ """返回 (invocation_id, status, run_mode, run_trigger)。
1332
+
1333
+ 与 _latest_session_run_status 同语义,但额外从最新 run_status 事件的 metadata
1334
+ 读取 run_mode/run_trigger。旧事件缺字段降级 unknown。原 _latest_session_run_status
1335
+ 不动,保护现有 ActiveInvocationId/ActiveRunStatus 契约。
1336
+ """
1337
+ invocation_id, status = _latest_session_run_status(events)
1338
+ run_mode = RUN_MODE_UNKNOWN
1339
+ run_trigger = RUN_TRIGGER_UNKNOWN
1340
+ if invocation_id:
1341
+ for event in reversed(events):
1342
+ if event.event_type != "run_status" or _event_run_id(event) != invocation_id:
1343
+ continue
1344
+ metadata = event.metadata or {}
1345
+ run_mode = str(metadata.get("run_mode") or RUN_MODE_UNKNOWN)
1346
+ run_trigger = str(metadata.get("run_trigger") or RUN_TRIGGER_UNKNOWN)
1347
+ break
1348
+ return invocation_id, status, run_mode, run_trigger
1349
+
1350
+
1351
+ class WorkspaceDeleteActionRequest(BaseModel):
1352
+ AgentId: Optional[str] = None
1353
+ Path: str
1354
+
1355
+
1356
+ class CancelRunActionRequest(BaseModel):
1357
+ AgentId: Optional[str] = None
1358
+ InvocationId: str
1359
+
1360
+
1361
+ async def _session_to_action_payload(session: Session) -> dict[str, Any]:
1362
+ events = list(session.events or [])
1363
+ if not events:
1364
+ try:
1365
+ events = await resolve_session_service().get_events(session.id)
1366
+ except Exception as exc:
1367
+ logger.debug("Failed to hydrate events for session %s: %s", session.id, exc)
1368
+ events = []
1369
+ event_prompts = [
1370
+ _session_user_prompt_from_event(event)
1371
+ for event in events
1372
+ if event.event_type == "user_message"
1373
+ ]
1374
+ event_prompts = [prompt for prompt in event_prompts if prompt]
1375
+ first_prompt = session.first_prompt or (event_prompts[0] if event_prompts else "")
1376
+ last_prompt = session.last_prompt or (event_prompts[-1] if event_prompts else "")
1377
+ (
1378
+ active_invocation_id,
1379
+ active_run_status,
1380
+ active_run_mode,
1381
+ active_run_trigger,
1382
+ ) = _latest_session_run_metadata(events)
1383
+ title = session.title
1384
+ title_source = session.title_source
1385
+ if not title:
1386
+ title_seed = _session_topic_from_events(events) or first_prompt
1387
+ if title_seed:
1388
+ title = build_fallback_title(title_seed)
1389
+ title_source = "fallback_first_prompt"
1390
+ if title_source == "fallback_first_prompt":
1391
+ heuristic = build_heuristic_title(
1392
+ first_prompt=first_prompt or title,
1393
+ assistant_text=session.summary or "",
1394
+ )
1395
+ if heuristic and heuristic != title:
1396
+ title = heuristic
1397
+ title_source = HEURISTIC_SESSION_TITLE_SOURCE
1398
+ payload = {
1399
+ "SessionId": session.id,
1400
+ "AgentId": session.agent_id,
1401
+ "UserId": session.user_id,
1402
+ "Title": title,
1403
+ "TitleSource": title_source,
1404
+ "Summary": session.summary,
1405
+ "FirstPrompt": _truncate_session_text(first_prompt),
1406
+ "LastPrompt": _truncate_session_text(last_prompt),
1407
+ "ActiveInvocationId": active_invocation_id,
1408
+ "ActiveRunStatus": active_run_status,
1409
+ "ActiveRunMode": active_run_mode,
1410
+ "ActiveRunTrigger": active_run_trigger,
1411
+ "State": _sanitize_session_state_for_action(session.state),
1412
+ "CreatedAt": session.created_at,
1413
+ "UpdatedAt": session.updated_at,
1414
+ "Version": session.version,
1415
+ }
1416
+ if runner is not None:
1417
+ try:
1418
+ continuity = await runner.get_session_adapter().describe_continuity(
1419
+ runner=runner,
1420
+ session=session,
1421
+ core=ConversationSessionCore(resolve_session_service()),
1422
+ )
1423
+ payload["Continuity"] = continuity.to_payload()
1424
+ except Exception as exc:
1425
+ logger.debug("Failed to describe continuity for session %s: %s", session.id, exc)
1426
+ return payload
1427
+
1428
+
1429
+ def _event_to_action_payload(event: SessionEvent) -> dict[str, Any]:
1430
+ payload = {
1431
+ "EventId": event.id,
1432
+ "SessionId": event.session_id,
1433
+ "Author": event.author,
1434
+ "EventType": event.event_type,
1435
+ "Content": event.content,
1436
+ "Timestamp": event.timestamp,
1437
+ "SeqId": event.seq_id,
1438
+ "Metadata": event.metadata,
1439
+ }
1440
+ if event.invocation_id:
1441
+ payload["InvocationId"] = event.invocation_id
1442
+ return payload
1443
+
1444
+
1445
+ def _checkpoint_event_to_action_payload(event: SessionEvent) -> dict[str, Any] | None:
1446
+ if event.event_type != "run_checkpoint":
1447
+ return None
1448
+ metadata = event.metadata or {}
1449
+ run_id = str(metadata.get("run_id") or "").strip()
1450
+ checkpoint_id = str(metadata.get("checkpoint_id") or "").strip()
1451
+ framework = str(metadata.get("framework") or "").strip()
1452
+ framework_ref = metadata.get("framework_ref")
1453
+ if not run_id or not checkpoint_id or not framework or not isinstance(framework_ref, Mapping):
1454
+ return None
1455
+ next_node = str(metadata.get("next_node") or "").strip()
1456
+ if not next_node:
1457
+ langgraph_ref = framework_ref.get("langgraph")
1458
+ if isinstance(langgraph_ref, Mapping):
1459
+ next_node = str(langgraph_ref.get("next_node") or "").strip()
1460
+ is_terminal = bool(metadata.get("is_terminal", False))
1461
+ is_resumable_raw = metadata.get("is_resumable")
1462
+ is_resumable = is_resumable_raw if isinstance(is_resumable_raw, bool) else None
1463
+ backend = str(metadata.get("backend") or "unknown").strip() or "unknown"
1464
+ scope = str(metadata.get("scope") or "unknown").strip() or "unknown"
1465
+ durable = bool(metadata.get("durable", False))
1466
+ disabled_reason = str(metadata.get("resume_disabled_reason") or "").strip()
1467
+ if is_terminal:
1468
+ is_resumable = False
1469
+ disabled_reason = disabled_reason or "该 checkpoint 已是终态;可选择更早恢复点重跑"
1470
+ if backend == "memory" or scope == "process_local":
1471
+ is_resumable = False
1472
+ disabled_reason = disabled_reason or "进程内 checkpoint 不能跨实例恢复"
1473
+ resume_status = str(metadata.get("resume_status") or "").strip()
1474
+ if not resume_status:
1475
+ if is_resumable is True:
1476
+ resume_status = "resumable"
1477
+ elif is_resumable is False:
1478
+ resume_status = "disabled"
1479
+ else:
1480
+ resume_status = "unknown"
1481
+ if resume_status == "disabled" and not disabled_reason:
1482
+ disabled_reason = "该 checkpoint 不可恢复"
1483
+ artifact_preview = metadata.get("artifact_preview")
1484
+ if not isinstance(artifact_preview, Mapping):
1485
+ artifact_preview = {}
1486
+ resume_count_raw = metadata.get("resume_count")
1487
+ try:
1488
+ resume_count = int(resume_count_raw) if resume_count_raw is not None else 0
1489
+ except (TypeError, ValueError):
1490
+ resume_count = 0
1491
+ last_resumed_at = metadata.get("last_resumed_at")
1492
+ replay_allowed_raw = metadata.get("replay_allowed")
1493
+ replay_allowed = replay_allowed_raw if isinstance(replay_allowed_raw, bool) else True
1494
+ expires_at = metadata.get("expires_at")
1495
+ checkpoint_status = str(metadata.get("checkpoint_status") or "").strip()
1496
+ if not checkpoint_status:
1497
+ if is_terminal:
1498
+ checkpoint_status = "resumed" if resume_count else "terminal"
1499
+ elif is_resumable is False:
1500
+ checkpoint_status = "disabled"
1501
+ else:
1502
+ checkpoint_status = "active"
1503
+ payload = {
1504
+ "EventId": event.id,
1505
+ "SessionId": event.session_id,
1506
+ "InvocationId": event.invocation_id,
1507
+ "SeqId": event.seq_id,
1508
+ "Timestamp": event.timestamp,
1509
+ "RunId": run_id,
1510
+ "CheckpointId": checkpoint_id,
1511
+ "Framework": framework,
1512
+ "FrameworkRef": dict(framework_ref),
1513
+ "Phase": str(metadata.get("phase") or ""),
1514
+ "Metadata": metadata,
1515
+ "IsResumable": is_resumable,
1516
+ "ResumeStatus": resume_status,
1517
+ "IsTerminal": is_terminal,
1518
+ "ResumeDisabledReason": disabled_reason,
1519
+ "NextNode": next_node,
1520
+ "StageKey": str(metadata.get("stage_key") or ""),
1521
+ "StageName": str(
1522
+ metadata.get("stage_name") or metadata.get("stage") or metadata.get("title") or ""
1523
+ ),
1524
+ "StageIndex": metadata.get("stage_index"),
1525
+ "TotalStages": metadata.get("total_stages"),
1526
+ "Backend": backend,
1527
+ "Scope": scope,
1528
+ "Durable": durable,
1529
+ "CreatedAt": event.timestamp,
1530
+ "ArtifactPreview": dict(artifact_preview),
1531
+ "LastResumedAt": last_resumed_at,
1532
+ "ResumeCount": resume_count,
1533
+ "ReplayAllowed": replay_allowed,
1534
+ "ExpiresAt": expires_at,
1535
+ "CheckpointStatus": checkpoint_status,
1536
+ }
1537
+ stage = str(metadata.get("stage") or metadata.get("title") or "").strip()
1538
+ summary = str(metadata.get("summary") or metadata.get("description") or "").strip()
1539
+ next_action = str(metadata.get("next_action") or metadata.get("nextAction") or "").strip()
1540
+ status = str(metadata.get("status") or "").strip()
1541
+ if stage:
1542
+ payload["Stage"] = stage
1543
+ if summary:
1544
+ payload["Summary"] = summary
1545
+ if next_action:
1546
+ payload["NextAction"] = next_action
1547
+ if status:
1548
+ payload["Status"] = status
1549
+ return payload
1550
+
1551
+
1552
+ def _resume_audit_by_checkpoint(
1553
+ events: list[SessionEvent],
1554
+ ) -> dict[tuple[str, str], dict[str, Any]]:
1555
+ audit: dict[tuple[str, str], dict[str, Any]] = {}
1556
+ for event in events:
1557
+ if event.event_type != "run_resume":
1558
+ continue
1559
+ metadata = event.metadata or {}
1560
+ run_id = str(metadata.get("run_id") or "").strip()
1561
+ checkpoint_id = str(metadata.get("checkpoint_id") or "").strip()
1562
+ if not run_id or not checkpoint_id:
1563
+ continue
1564
+ key = (run_id, checkpoint_id)
1565
+ item = audit.setdefault(key, {"resume_count": 0, "last_resumed_at": None})
1566
+ item["resume_count"] = int(item["resume_count"]) + 1
1567
+ item["last_resumed_at"] = event.timestamp
1568
+ return audit
1569
+
1570
+
1571
+ def _apply_checkpoint_resume_audit(
1572
+ checkpoint: dict[str, Any],
1573
+ audit_by_checkpoint: Mapping[tuple[str, str], Mapping[str, Any]],
1574
+ ) -> dict[str, Any]:
1575
+ audit = audit_by_checkpoint.get(
1576
+ (
1577
+ str(checkpoint.get("RunId") or ""),
1578
+ str(checkpoint.get("CheckpointId") or ""),
1579
+ ),
1580
+ {},
1581
+ )
1582
+ metadata = dict(checkpoint.get("Metadata") or {})
1583
+ resume_count = int(audit.get("resume_count") or checkpoint.get("ResumeCount") or 0)
1584
+ last_resumed_at = audit.get("last_resumed_at") or checkpoint.get("LastResumedAt")
1585
+ checkpoint["ResumeCount"] = resume_count
1586
+ checkpoint["LastResumedAt"] = last_resumed_at
1587
+ metadata["resume_count"] = resume_count
1588
+ metadata["last_resumed_at"] = last_resumed_at
1589
+ if resume_count and checkpoint.get("CheckpointStatus") in {"", "active"}:
1590
+ checkpoint["CheckpointStatus"] = "resumed"
1591
+ expires_at = checkpoint.get("ExpiresAt")
1592
+ expires_at_dt = _parse_iso_datetime(expires_at)
1593
+ if expires_at_dt is not None and expires_at_dt <= datetime.now(timezone.utc):
1594
+ checkpoint["IsResumable"] = False
1595
+ checkpoint["ResumeStatus"] = "disabled"
1596
+ checkpoint["CheckpointStatus"] = "expired"
1597
+ checkpoint["ResumeDisabledReason"] = "该 checkpoint 已过期"
1598
+ elif resume_count and checkpoint.get("ReplayAllowed") is False:
1599
+ checkpoint["IsResumable"] = False
1600
+ checkpoint["ResumeStatus"] = "disabled"
1601
+ checkpoint["ResumeDisabledReason"] = "该 checkpoint 已恢复过,当前策略不允许重复恢复"
1602
+ metadata["checkpoint_status"] = checkpoint.get("CheckpointStatus")
1603
+ metadata["resume_status"] = checkpoint.get("ResumeStatus")
1604
+ metadata["resume_disabled_reason"] = checkpoint.get("ResumeDisabledReason")
1605
+ checkpoint["Metadata"] = metadata
1606
+ return checkpoint
1607
+
1608
+
1609
+ def _apply_adk_only_latest_resumable(
1610
+ checkpoints: list[dict[str, Any]],
1611
+ ) -> list[dict[str, Any]]:
1612
+ """P1.4: For ADK invocation_id resume mode, only the latest checkpoint per
1613
+ RunId is independently resumable. Older checkpoints get IsResumable=False."""
1614
+ latest_by_run: dict[str, int] = {}
1615
+ for cp in checkpoints:
1616
+ metadata = cp.get("Metadata") or {}
1617
+ if not metadata.get("only_latest_resumable"):
1618
+ continue
1619
+ run_id = str(cp.get("RunId") or "")
1620
+ seq_id = int(cp.get("SeqId") or 0)
1621
+ if run_id not in latest_by_run or seq_id > latest_by_run[run_id]:
1622
+ latest_by_run[run_id] = seq_id
1623
+
1624
+ for cp in checkpoints:
1625
+ metadata = cp.get("Metadata") or {}
1626
+ if not metadata.get("only_latest_resumable"):
1627
+ continue
1628
+ run_id = str(cp.get("RunId") or "")
1629
+ seq_id = int(cp.get("SeqId") or 0)
1630
+ if seq_id < latest_by_run.get(run_id, 0):
1631
+ if cp.get("IsResumable") is True:
1632
+ cp["IsResumable"] = False
1633
+ cp["ResumeStatus"] = "disabled"
1634
+ cp["ResumeDisabledReason"] = (
1635
+ "新的恢复点已生成,此恢复点暂停恢复能力"
1636
+ )
1637
+ metadata["resume_disabled_reason"] = (
1638
+ "新的恢复点已生成,此恢复点暂停恢复能力"
1639
+ )
1640
+ metadata["resume_status"] = "disabled"
1641
+ cp["Metadata"] = metadata
1642
+
1643
+ return checkpoints
1644
+
1645
+
1646
+ def _check_adk_latest_resumable(
1647
+ checkpoint: dict[str, Any],
1648
+ events: list,
1649
+ ) -> dict[str, Any]:
1650
+ """P1.4: For a single ADK only_latest_resumable checkpoint, verify it is
1651
+ the latest for its RunId. If not, mark IsResumable=False."""
1652
+ metadata = checkpoint.get("Metadata") or {}
1653
+ if not metadata.get("only_latest_resumable"):
1654
+ return checkpoint
1655
+
1656
+ run_id = str(checkpoint.get("RunId") or "")
1657
+ my_seq_id = int(checkpoint.get("SeqId") or 0)
1658
+
1659
+ max_seq_id = my_seq_id
1660
+ for event in events:
1661
+ if event.event_type != "run_checkpoint":
1662
+ continue
1663
+ ev_meta = event.metadata or {}
1664
+ if str(ev_meta.get("run_id") or "") != run_id:
1665
+ continue
1666
+ seq_id = int(event.seq_id or 0)
1667
+ if seq_id > max_seq_id:
1668
+ max_seq_id = seq_id
1669
+
1670
+ if my_seq_id < max_seq_id and checkpoint.get("IsResumable") is True:
1671
+ checkpoint["IsResumable"] = False
1672
+ checkpoint["ResumeStatus"] = "disabled"
1673
+ checkpoint["ResumeDisabledReason"] = (
1674
+ "新的恢复点已生成,此恢复点暂停恢复能力"
1675
+ )
1676
+ metadata["resume_disabled_reason"] = (
1677
+ "新的恢复点已生成,此恢复点暂停恢复能力"
1678
+ )
1679
+ metadata["resume_status"] = "disabled"
1680
+ checkpoint["Metadata"] = metadata
1681
+
1682
+ return checkpoint
1683
+
1684
+
1685
+ _SIDE_EFFECT_TOOL_NAMES = {
1686
+ "write_workspace_file",
1687
+ "write_workspace_files",
1688
+ "delete_workspace_file",
1689
+ "execute_skills",
1690
+ "run_command",
1691
+ "run_code",
1692
+ }
1693
+
1694
+
1695
+ def _tool_receipt_event_to_action_payload(event: SessionEvent) -> dict[str, Any] | None:
1696
+ if event.event_type != "tool_result":
1697
+ return None
1698
+ metadata = event.metadata or {}
1699
+ receipt = metadata.get("tool_receipt")
1700
+ if not isinstance(receipt, Mapping):
1701
+ return None
1702
+ tool_name = str(receipt.get("tool_name") or metadata.get("tool_name") or "").strip()
1703
+ if not tool_name:
1704
+ return None
1705
+ return {
1706
+ "EventId": event.id,
1707
+ "SessionId": event.session_id,
1708
+ "InvocationId": event.invocation_id,
1709
+ "SeqId": event.seq_id,
1710
+ "Timestamp": event.timestamp,
1711
+ "ReceiptId": str(receipt.get("receipt_id") or ""),
1712
+ "IdempotencyKey": str(receipt.get("idempotency_key") or ""),
1713
+ "ToolName": tool_name,
1714
+ "ToolCallId": str(receipt.get("tool_call_id") or ""),
1715
+ "RunId": str(receipt.get("run_id") or metadata.get("run_id") or ""),
1716
+ "CheckpointId": str(receipt.get("checkpoint_id") or ""),
1717
+ "Status": str(receipt.get("status") or ""),
1718
+ "Replayed": bool(receipt.get("replayed") or metadata.get("replayed")),
1719
+ "Metadata": dict(metadata),
1720
+ }
1721
+
1722
+
1723
+ def _build_checkpoint_resume_preview(
1724
+ *,
1725
+ checkpoint: Mapping[str, Any],
1726
+ events: list[SessionEvent],
1727
+ ) -> dict[str, Any]:
1728
+ checkpoint_seq_id = int(checkpoint.get("SeqId") or 0)
1729
+ run_id = str(checkpoint.get("RunId") or "")
1730
+ receipts: list[dict[str, Any]] = []
1731
+ for event in events:
1732
+ if checkpoint_seq_id and int(event.seq_id or 0) > checkpoint_seq_id:
1733
+ continue
1734
+ receipt = _tool_receipt_event_to_action_payload(event)
1735
+ if receipt is None:
1736
+ continue
1737
+ if run_id and receipt["RunId"] and receipt["RunId"] != run_id:
1738
+ continue
1739
+ receipts.append(receipt)
1740
+
1741
+ side_effect_receipts = [
1742
+ receipt for receipt in receipts if receipt["ToolName"] in _SIDE_EFFECT_TOOL_NAMES
1743
+ ]
1744
+ risk_level = "low"
1745
+ if side_effect_receipts:
1746
+ risk_level = "medium"
1747
+ if any(receipt["Status"] == "failed" for receipt in receipts):
1748
+ risk_level = "high"
1749
+
1750
+ return {
1751
+ "Checkpoint": dict(checkpoint),
1752
+ "Capabilities": {
1753
+ "Checkpoints": True,
1754
+ "CheckpointResume": checkpoint.get("IsResumable") is not False,
1755
+ "ToolReceipts": True,
1756
+ "IdempotentToolReplay": True,
1757
+ },
1758
+ "CanResume": checkpoint.get("IsResumable") is not False,
1759
+ "Reason": str(checkpoint.get("ResumeDisabledReason") or ""),
1760
+ "NextNode": str(checkpoint.get("NextNode") or ""),
1761
+ "ExpectedAction": (
1762
+ "resume_from_checkpoint"
1763
+ if checkpoint.get("IsResumable") is True
1764
+ else ("preview_required" if checkpoint.get("ResumeStatus") == "unknown" else "disabled")
1765
+ ),
1766
+ "ToolReceipts": receipts,
1767
+ "Risk": {
1768
+ "Level": risk_level,
1769
+ "DuplicateSideEffectRisk": bool(side_effect_receipts),
1770
+ "SideEffectReceiptCount": len(side_effect_receipts),
1771
+ "FailedReceiptCount": len(
1772
+ [receipt for receipt in receipts if receipt["Status"] == "failed"]
1773
+ ),
1774
+ },
1775
+ "Summary": {
1776
+ "RunId": run_id,
1777
+ "CheckpointId": str(checkpoint.get("CheckpointId") or ""),
1778
+ "Phase": str(checkpoint.get("Phase") or ""),
1779
+ "ToolReceiptCount": len(receipts),
1780
+ },
1781
+ }
1782
+
1783
+
1784
+ def _checkpoint_resume_disabled_detail(checkpoint: Mapping[str, Any]) -> dict[str, Any] | None:
1785
+ if checkpoint.get("IsResumable") is not False:
1786
+ return None
1787
+ reason = (
1788
+ str(checkpoint.get("ResumeDisabledReason") or "").strip() or "Checkpoint is not resumable"
1789
+ )
1790
+ return {
1791
+ "code": "checkpoint_not_resumable",
1792
+ "reason": reason,
1793
+ "checkpoint_id": str(checkpoint.get("CheckpointId") or ""),
1794
+ "run_id": str(checkpoint.get("RunId") or ""),
1795
+ "resume_status": str(checkpoint.get("ResumeStatus") or "disabled"),
1796
+ "is_terminal": bool(checkpoint.get("IsTerminal")),
1797
+ }
1798
+
1799
+
1800
+ async def _find_session_checkpoint(
1801
+ *,
1802
+ service: Any,
1803
+ session_id: str,
1804
+ run_id: str,
1805
+ checkpoint_id: str,
1806
+ ) -> dict[str, Any] | None:
1807
+ events = await service.get_events(session_id)
1808
+ resume_audit = _resume_audit_by_checkpoint(events)
1809
+ for event in reversed(events):
1810
+ checkpoint = _checkpoint_event_to_action_payload(event)
1811
+ if checkpoint is None:
1812
+ continue
1813
+ checkpoint = _apply_checkpoint_resume_audit(checkpoint, resume_audit)
1814
+ if checkpoint["RunId"] != run_id:
1815
+ continue
1816
+ if checkpoint["CheckpointId"] != checkpoint_id:
1817
+ continue
1818
+ checkpoint = _check_adk_latest_resumable(checkpoint, events)
1819
+ return checkpoint
1820
+ return None
1821
+
1822
+
1823
+ async def _resolve_checkpoint_resume_input_from_session(
1824
+ *,
1825
+ service: Any,
1826
+ agent_id: str,
1827
+ session_id: str | None,
1828
+ resume_input: Mapping[str, Any] | None,
1829
+ ) -> dict[str, Any] | None:
1830
+ if not isinstance(resume_input, Mapping):
1831
+ return None
1832
+ if str(resume_input.get("type") or "").strip() != "agentengine.resume_checkpoint":
1833
+ return dict(resume_input)
1834
+ normalized_session_id = str(session_id or "").strip()
1835
+ if not normalized_session_id:
1836
+ raise HTTPException(status_code=400, detail="Checkpoint resume requires session_id")
1837
+
1838
+ session = await service.get_session(normalized_session_id)
1839
+ if not session or session.agent_id != agent_id:
1840
+ raise HTTPException(status_code=404, detail="Session not found")
1841
+
1842
+ run_id = str(resume_input.get("run_id") or "").strip()
1843
+ checkpoint_id = str(resume_input.get("checkpoint_id") or "").strip()
1844
+ if not run_id or not checkpoint_id:
1845
+ raise HTTPException(
1846
+ status_code=400, detail="Checkpoint resume requires run_id and checkpoint_id"
1847
+ )
1848
+
1849
+ checkpoint = await _find_session_checkpoint(
1850
+ service=service,
1851
+ session_id=normalized_session_id,
1852
+ run_id=run_id,
1853
+ checkpoint_id=checkpoint_id,
1854
+ )
1855
+ if checkpoint is None:
1856
+ raise HTTPException(status_code=404, detail="Checkpoint not found")
1857
+
1858
+ resume_attempt_id = str(resume_input.get("resume_attempt_id") or "").strip()
1859
+ return {
1860
+ "type": "agentengine.resume_checkpoint",
1861
+ "run_id": run_id,
1862
+ "checkpoint_id": checkpoint_id,
1863
+ "resume_attempt_id": resume_attempt_id or f"resume_{uuid.uuid4().hex}",
1864
+ "framework": checkpoint["Framework"],
1865
+ "framework_ref": checkpoint["FrameworkRef"],
1866
+ "metadata": dict(checkpoint.get("Metadata") or {}),
1867
+ "checkpoint_metadata": dict(checkpoint.get("Metadata") or {}),
1868
+ "resume_instruction_enabled": bool(
1869
+ resume_input.get("resume_instruction_enabled")
1870
+ or resume_input.get("ResumeInstructionEnabled")
1871
+ ),
1872
+ "resume_instruction": str(
1873
+ resume_input.get("resume_instruction") or resume_input.get("ResumeInstruction") or ""
1874
+ ).strip(),
1875
+ }
1876
+
1877
+
1878
+ def _feedback_state_key(response_id: str) -> str:
1879
+ return str(response_id or "").strip()
1880
+
1881
+
1882
+ def _feedback_payload_from_state(item: Mapping[str, Any] | None) -> dict[str, Any] | None:
1883
+ if not isinstance(item, Mapping):
1884
+ return None
1885
+ rating = str(item.get("Rating") or item.get("rating") or "").strip().lower()
1886
+ if rating not in {"up", "down"}:
1887
+ return None
1888
+ return {
1889
+ "AgentId": str(item.get("AgentId") or item.get("agent_id") or ""),
1890
+ "SessionId": str(item.get("SessionId") or item.get("session_id") or ""),
1891
+ "ResponseId": str(item.get("ResponseId") or item.get("response_id") or ""),
1892
+ "EventId": str(item.get("EventId") or item.get("event_id") or ""),
1893
+ "Rating": rating,
1894
+ "Comment": str(item.get("Comment") or item.get("comment") or ""),
1895
+ "TraceId": str(item.get("TraceId") or item.get("trace_id") or ""),
1896
+ "RootSpanId": str(item.get("RootSpanId") or item.get("root_span_id") or ""),
1897
+ "CreatedAt": str(item.get("CreatedAt") or item.get("created_at") or ""),
1898
+ "UpdatedAt": str(item.get("UpdatedAt") or item.get("updated_at") or ""),
1899
+ }
1900
+
1901
+
1902
+ async def _find_feedback_assistant_event(
1903
+ *,
1904
+ session_id: str,
1905
+ response_id: str,
1906
+ event_id: str | None = None,
1907
+ ) -> SessionEvent | None:
1908
+ events = await resolve_session_service().get_events(session_id)
1909
+ normalized_event_id = str(event_id or "").strip()
1910
+ normalized_response_id = str(response_id or "").strip()
1911
+ for event in reversed(events):
1912
+ if normalized_event_id and event.id != normalized_event_id:
1913
+ continue
1914
+ metadata = event.metadata or {}
1915
+ if (
1916
+ normalized_response_id
1917
+ and str(metadata.get("response_id") or "") != normalized_response_id
1918
+ ):
1919
+ continue
1920
+ event_type = conversation.canonical_event_type(
1921
+ event.event_type,
1922
+ author=event.author,
1923
+ role=str((event.content or {}).get("role") or ""),
1924
+ )
1925
+ if event_type == "assistant_message":
1926
+ return event
1927
+ return None
1928
+
1929
+
1930
+ @app.post("/agentengine/api/v1/GetResponseFeedback")
1931
+ async def get_response_feedback_action(request: ResponseFeedbackRefActionRequest):
1932
+ session = await resolve_session_service().get_session(request.SessionId)
1933
+ if not session or session.agent_id != request.AgentId:
1934
+ return _action_response("GetResponseFeedback", {"Feedback": None})
1935
+ feedbacks = session.state.get("__ksadk_response_feedback__")
1936
+ feedback = None
1937
+ if isinstance(feedbacks, Mapping):
1938
+ feedback = _feedback_payload_from_state(
1939
+ feedbacks.get(_feedback_state_key(request.ResponseId))
1940
+ )
1941
+ return _action_response("GetResponseFeedback", {"Feedback": feedback})
1942
+
1943
+
1944
+ @app.post("/agentengine/api/v1/UpsertResponseFeedback")
1945
+ async def upsert_response_feedback_action(request: UpsertResponseFeedbackActionRequest):
1946
+ rating = str(request.Rating or "").strip().lower()
1947
+ if rating not in {"up", "down"}:
1948
+ raise HTTPException(status_code=400, detail="Feedback rating must be up or down")
1949
+
1950
+ service = resolve_session_service()
1951
+ session = await service.get_session(request.SessionId)
1952
+ if not session or session.agent_id != request.AgentId:
1953
+ raise HTTPException(status_code=404, detail="Session not found")
1954
+
1955
+ assistant_event = await _find_feedback_assistant_event(
1956
+ session_id=request.SessionId,
1957
+ response_id=request.ResponseId,
1958
+ event_id=request.EventId,
1959
+ )
1960
+ if assistant_event is None:
1961
+ raise HTTPException(status_code=404, detail="Assistant response not found")
1962
+
1963
+ now = str(time.time())
1964
+ existing_feedbacks = session.state.get("__ksadk_response_feedback__")
1965
+ feedbacks = dict(existing_feedbacks) if isinstance(existing_feedbacks, Mapping) else {}
1966
+ existing = (
1967
+ _feedback_payload_from_state(feedbacks.get(_feedback_state_key(request.ResponseId))) or {}
1968
+ )
1969
+ metadata = assistant_event.metadata or {}
1970
+ feedback = {
1971
+ "AgentId": request.AgentId,
1972
+ "SessionId": request.SessionId,
1973
+ "ResponseId": request.ResponseId,
1974
+ "EventId": request.EventId or assistant_event.id,
1975
+ "Rating": rating,
1976
+ "Comment": request.Comment or "",
1977
+ "TraceId": request.TraceId or str(metadata.get("trace_id") or ""),
1978
+ "RootSpanId": request.RootSpanId or str(metadata.get("root_span_id") or ""),
1979
+ "CreatedAt": existing.get("CreatedAt") or now,
1980
+ "UpdatedAt": now,
1981
+ }
1982
+ feedbacks[_feedback_state_key(request.ResponseId)] = feedback
1983
+ await service.update_state(
1984
+ agent_id=session.agent_id,
1985
+ user_id=session.user_id,
1986
+ session_id=session.id,
1987
+ scope="session",
1988
+ state_delta={"__ksadk_response_feedback__": feedbacks},
1989
+ )
1990
+ return _action_response("UpsertResponseFeedback", {"Feedback": feedback})
1991
+
1992
+
1993
+ @app.post("/agentengine/api/v1/DeleteResponseFeedback")
1994
+ async def delete_response_feedback_action(request: ResponseFeedbackRefActionRequest):
1995
+ service = resolve_session_service()
1996
+ session = await service.get_session(request.SessionId)
1997
+ if not session or session.agent_id != request.AgentId:
1998
+ return _action_response("DeleteResponseFeedback", {"Deleted": False})
1999
+ existing_feedbacks = session.state.get("__ksadk_response_feedback__")
2000
+ feedbacks = dict(existing_feedbacks) if isinstance(existing_feedbacks, Mapping) else {}
2001
+ deleted = feedbacks.pop(_feedback_state_key(request.ResponseId), None) is not None
2002
+ if deleted:
2003
+ await service.update_state(
2004
+ agent_id=session.agent_id,
2005
+ user_id=session.user_id,
2006
+ session_id=session.id,
2007
+ scope="session",
2008
+ state_delta={"__ksadk_response_feedback__": feedbacks},
2009
+ )
2010
+ return _action_response("DeleteResponseFeedback", {"Deleted": deleted})
2011
+
2012
+
2013
+ @app.post("/agentengine/api/v1/GetAgentUiBootstrap")
2014
+ async def get_agent_ui_bootstrap(request: UiBootstrapRequest):
2015
+ agent_id = request.AgentId or (_runtime_agent_id(runner) if runner else "default-agent")
2016
+ description = getattr(runner.detection_result, "description", "") if runner else ""
2017
+ framework = ""
2018
+ if runner:
2019
+ detection_type = getattr(getattr(runner, "detection_result", None), "type", None)
2020
+ framework = str(getattr(detection_type, "value", detection_type) or "").strip().lower()
2021
+ workspace_enabled = workspace_files_enabled(default=True)
2022
+ ui_spec = _resolve_agent_ui_spec()
2023
+ runtime_capabilities = (
2024
+ runner.get_runtime_capabilities()
2025
+ if runner and callable(getattr(runner, "get_runtime_capabilities", None))
2026
+ else {}
2027
+ )
2028
+ checkpoint_resume_capability = {
2029
+ "Supported": bool(
2030
+ (runtime_capabilities.get("ResumeRun") or {}).get("Supported")
2031
+ if isinstance(runtime_capabilities, Mapping)
2032
+ else False
2033
+ ),
2034
+ "Checkpoint": (runtime_capabilities.get("Checkpoint") or {})
2035
+ if isinstance(runtime_capabilities, Mapping)
2036
+ else {},
2037
+ "ResumeRun": (runtime_capabilities.get("ResumeRun") or {})
2038
+ if isinstance(runtime_capabilities, Mapping)
2039
+ else {},
2040
+ }
2041
+ checkpoint_resume_supported = bool(checkpoint_resume_capability["Supported"])
2042
+ cancel_run_supported = bool(
2043
+ (runtime_capabilities.get("CancelRun") or {}).get("Supported")
2044
+ if isinstance(runtime_capabilities, Mapping)
2045
+ else False
2046
+ )
2047
+ return _action_response(
2048
+ "GetAgentUiBootstrap",
2049
+ {
2050
+ "Agent": {
2051
+ "AgentId": agent_id,
2052
+ "Name": runner.detection_result.name if runner else agent_id,
2053
+ "Description": description or "",
2054
+ "Framework": framework,
2055
+ },
2056
+ "Modules": ["Chat", "Build", "Deploy"],
2057
+ "Capabilities": {
2058
+ "Attachments": True,
2059
+ "WorkspaceFiles": workspace_enabled,
2060
+ "Approval": True,
2061
+ "Thinking": True,
2062
+ "StopRun": cancel_run_supported,
2063
+ "ResumeRun": checkpoint_resume_supported,
2064
+ "RuntimeCapabilities": runtime_capabilities,
2065
+ "CheckpointResumeCapability": checkpoint_resume_capability,
2066
+ "RunLifecycle": {
2067
+ "Enabled": True,
2068
+ "Resume": True,
2069
+ "Abort": True,
2070
+ "Checkpoints": checkpoint_resume_supported,
2071
+ "CheckpointResume": checkpoint_resume_supported,
2072
+ "CheckpointResumePreview": checkpoint_resume_supported,
2073
+ },
2074
+ "MCP": False,
2075
+ "HostedRuntime": False,
2076
+ "NativeTerminal": _build_native_terminal_capability(framework),
2077
+ "BuiltinTools": describe_agentengine_tools(),
2078
+ },
2079
+ "WorkspaceFiles": build_workspace_files_bootstrap(enabled=workspace_enabled),
2080
+ "AccessMode": "Owner",
2081
+ "SharePermissions": {
2082
+ "Interactive": True,
2083
+ "DefaultPath": ui_spec.get("ui_path") or ui_spec.get("path") or "/chat",
2084
+ "SharePath": ui_spec.get("ui_path") or ui_spec.get("path") or "/chat",
2085
+ },
2086
+ "CustomUI": {
2087
+ "Enabled": bool(ui_spec.get("enabled")),
2088
+ "Profile": ui_spec.get("ui_profile") or ui_spec.get("profile"),
2089
+ "Path": ui_spec.get("ui_path") or ui_spec.get("path"),
2090
+ "Url": ui_spec.get("ui_url") or ui_spec.get("url"),
2091
+ "BundlePath": ui_spec.get("ui_bundle_path") or ui_spec.get("bundle_path"),
2092
+ },
2093
+ "ApiFormats": ["responses", "chat_completions"],
2094
+ "Stream": True,
2095
+ "SessionId": request.SessionId,
2096
+ "SessionBackend": describe_session_backend(),
2097
+ "HostedRuntime": None,
2098
+ "Model": _build_bootstrap_model_payload(),
2099
+ },
2100
+ )
2101
+
2102
+
2103
+ @app.post("/agentengine/api/v1/CreateSession")
2104
+ async def create_session_action(request: CreateSessionActionRequest):
2105
+ session = await _ensure_session(request.AgentId, request.UserId or "user", request.SessionId)
2106
+ return _action_response("CreateSession", {"Session": await _session_to_action_payload(session)})
2107
+
2108
+
2109
+ @app.post("/agentengine/api/v1/ListSessions")
2110
+ async def list_sessions_action(request: ListSessionsActionRequest):
2111
+ service = resolve_session_service()
2112
+ offset = (request.Page - 1) * request.PageSize
2113
+ sessions = await service.list_sessions(
2114
+ request.AgentId,
2115
+ request.UserId or "user",
2116
+ offset=offset,
2117
+ limit=request.PageSize,
2118
+ )
2119
+ total = await service.count_sessions(request.AgentId, request.UserId or "user")
2120
+ session_payloads = [await _session_to_action_payload(session) for session in sessions]
2121
+ return _action_response(
2122
+ "ListSessions",
2123
+ {
2124
+ "Sessions": session_payloads,
2125
+ "Total": total,
2126
+ "Page": request.Page,
2127
+ "PageSize": request.PageSize,
2128
+ },
2129
+ )
2130
+
2131
+
2132
+ @app.post("/agentengine/api/v1/GetSession")
2133
+ async def get_session_action(request: SessionIdRequest):
2134
+ service = resolve_session_service()
2135
+ session = await _hydrate_session(await service.get_session(request.SessionId))
2136
+ if not session:
2137
+ raise HTTPException(status_code=404, detail="Session not found")
2138
+ return _action_response("GetSession", {"Session": await _session_to_action_payload(session)})
2139
+
2140
+
2141
+ @app.post("/agentengine/api/v1/DeleteSession")
2142
+ async def delete_session_action(request: SessionIdRequest):
2143
+ service = resolve_session_service()
2144
+ await _cancel_detached_streams_for_session(request.SessionId)
2145
+ deleted = await service.delete_session(request.SessionId)
2146
+ if not deleted:
2147
+ raise HTTPException(status_code=404, detail="Session not found")
2148
+ return _action_response("DeleteSession", {"Deleted": True})
2149
+
2150
+
2151
+ @app.post("/agentengine/api/v1/ListSessionEvents")
2152
+ async def list_session_events_action(request: ListSessionEventsActionRequest):
2153
+ service = resolve_session_service()
2154
+ events = await service.get_events(
2155
+ request.SessionId,
2156
+ offset=request.Offset,
2157
+ limit=request.Limit,
2158
+ after_seq_id=request.AfterSeqId,
2159
+ before_seq_id=request.BeforeSeqId,
2160
+ )
2161
+ total = await service.count_events(
2162
+ request.SessionId,
2163
+ after_seq_id=request.AfterSeqId,
2164
+ before_seq_id=request.BeforeSeqId,
2165
+ )
2166
+ return _action_response(
2167
+ "ListSessionEvents",
2168
+ {
2169
+ "Events": [_event_to_action_payload(event) for event in events],
2170
+ "Total": total,
2171
+ "Offset": request.Offset or 0,
2172
+ "Limit": request.Limit if request.Limit is not None else len(events),
2173
+ "AfterSeqId": request.AfterSeqId,
2174
+ "BeforeSeqId": request.BeforeSeqId,
2175
+ },
2176
+ )
2177
+
2178
+
2179
+ @app.post("/agentengine/api/v1/ListSessionMessages")
2180
+ async def list_session_messages_action(request: ListSessionMessagesActionRequest):
2181
+ from ksadk.conversations.message_projection import project_session_messages
2182
+
2183
+ service = resolve_session_service()
2184
+ events = await service.get_events(
2185
+ request.SessionId,
2186
+ offset=0,
2187
+ limit=2000,
2188
+ after_seq_id=request.AfterSeqId,
2189
+ before_seq_id=request.BeforeSeqId,
2190
+ )
2191
+ serialized_events = [_event_to_action_payload(event) for event in events]
2192
+ messages = project_session_messages(
2193
+ serialized_events,
2194
+ include_reasoning=request.IncludeReasoning,
2195
+ include_tool_events=request.IncludeToolEvents,
2196
+ include_attachments=request.IncludeAttachments,
2197
+ )
2198
+ if request.AfterSeqId is not None:
2199
+ page = messages
2200
+ has_more = False
2201
+ next_cursor = None
2202
+ else:
2203
+ page = messages[-request.Limit :]
2204
+ minimum_seq_id = int(page[0].get("SeqId") or 0) if page else 0
2205
+ has_more = minimum_seq_id > 1 or len(serialized_events) >= 2000
2206
+ next_cursor = minimum_seq_id - 1 if has_more else None
2207
+ latest_seq_id = (
2208
+ int(page[-1].get("SeqId") or 0)
2209
+ if page
2210
+ else max((int(event.get("SeqId") or 0) for event in serialized_events), default=0)
2211
+ )
2212
+ return _action_response(
2213
+ "ListSessionMessages",
2214
+ {
2215
+ "SessionId": request.SessionId,
2216
+ "Messages": page,
2217
+ "LatestSeqId": latest_seq_id,
2218
+ "HasMore": has_more,
2219
+ "NextCursor": next_cursor,
2220
+ },
2221
+ )
2222
+
2223
+
2224
+ def _count_resumable_checkpoints(checkpoints: list[dict[str, Any]]) -> int:
2225
+ """统计可恢复 checkpoint 数量。
2226
+
2227
+ 规则:IsResumable=True AND ReplayAllowed!=False AND IsTerminal!=True
2228
+ AND CheckpointStatus not in {expired, disabled}。
2229
+ 不排除 resumed(已恢复过的仍计入,符合存档点可反复读的回档语义)。
2230
+ """
2231
+ resumable = 0
2232
+ for cp in checkpoints:
2233
+ if cp.get("IsResumable") is not True:
2234
+ continue
2235
+ if cp.get("ReplayAllowed") is False:
2236
+ continue
2237
+ if cp.get("IsTerminal") is True:
2238
+ continue
2239
+ status = str(cp.get("CheckpointStatus") or "").strip().lower()
2240
+ if status in {"expired", "disabled"}:
2241
+ continue
2242
+ resumable += 1
2243
+ return resumable
2244
+
2245
+
2246
+ async def _list_checkpoints_payload(request: ListSessionCheckpointsActionRequest) -> dict[str, Any]:
2247
+ service = resolve_session_service()
2248
+ session = await service.get_session(request.SessionId)
2249
+ if not session or session.agent_id != request.AgentId:
2250
+ raise HTTPException(status_code=404, detail="Session not found")
2251
+
2252
+ run_id_filter = str(request.RunId or "").strip()
2253
+ framework_filter = str(request.Framework or "").strip().lower()
2254
+ events = await service.get_events(request.SessionId)
2255
+ resume_audit = _resume_audit_by_checkpoint(events)
2256
+ checkpoints: list[dict[str, Any]] = []
2257
+ for event in events:
2258
+ checkpoint = _checkpoint_event_to_action_payload(event)
2259
+ if checkpoint is None:
2260
+ continue
2261
+ checkpoint = _apply_checkpoint_resume_audit(checkpoint, resume_audit)
2262
+ if run_id_filter and checkpoint["RunId"] != run_id_filter:
2263
+ continue
2264
+ if framework_filter and str(checkpoint["Framework"]).lower() != framework_filter:
2265
+ continue
2266
+ # ResumableTotal 在 OnlyResumable 过滤前统计全量可恢复数(RunId/Framework 范围内)
2267
+ checkpoints.append(checkpoint)
2268
+ checkpoints = _apply_adk_only_latest_resumable(checkpoints)
2269
+ resumable_total = _count_resumable_checkpoints(checkpoints)
2270
+ if request.OnlyResumable:
2271
+ checkpoints = [cp for cp in checkpoints if cp.get("IsResumable") is True]
2272
+ total = len(checkpoints)
2273
+ offset = int(request.Offset or 0)
2274
+ if request.Limit is not None:
2275
+ checkpoints = checkpoints[offset : offset + int(request.Limit)]
2276
+ elif offset:
2277
+ checkpoints = checkpoints[offset:]
2278
+
2279
+ return {
2280
+ "Checkpoints": checkpoints,
2281
+ "Total": total,
2282
+ "ResumableTotal": resumable_total,
2283
+ "HasResumableCheckpoint": resumable_total > 0,
2284
+ "Offset": offset,
2285
+ "Limit": request.Limit if request.Limit is not None else len(checkpoints),
2286
+ }
2287
+
2288
+
2289
+ @app.post("/agentengine/api/v1/ListSessionCheckpoints")
2290
+ async def list_session_checkpoints_action(request: ListSessionCheckpointsActionRequest):
2291
+ return _action_response("ListSessionCheckpoints", await _list_checkpoints_payload(request))
2292
+
2293
+
2294
+ @app.post("/agentengine/api/v1/ListToolReceipts")
2295
+ async def list_tool_receipts_action(request: ListToolReceiptsActionRequest):
2296
+ service = resolve_session_service()
2297
+ session = await service.get_session(request.SessionId)
2298
+ if not session or session.agent_id != request.AgentId:
2299
+ raise HTTPException(status_code=404, detail="Session not found")
2300
+
2301
+ run_id_filter = str(request.RunId or "").strip()
2302
+ checkpoint_id_filter = str(request.CheckpointId or "").strip()
2303
+ receipts: list[dict[str, Any]] = []
2304
+ for event in await service.get_events(request.SessionId):
2305
+ receipt = _tool_receipt_event_to_action_payload(event)
2306
+ if receipt is None:
2307
+ continue
2308
+ if run_id_filter and receipt["RunId"] != run_id_filter:
2309
+ continue
2310
+ if checkpoint_id_filter and receipt["CheckpointId"] != checkpoint_id_filter:
2311
+ continue
2312
+ receipts.append(receipt)
2313
+
2314
+ return _action_response(
2315
+ "ListToolReceipts",
2316
+ {"ToolReceipts": receipts},
2317
+ )
2318
+
2319
+
2320
+ @app.post("/agentengine/api/v1/GetCheckpointResumePreview")
2321
+ async def get_checkpoint_resume_preview_action(request: GetCheckpointResumePreviewActionRequest):
2322
+ service = resolve_session_service()
2323
+ session = await service.get_session(request.SessionId)
2324
+ if not session or session.agent_id != request.AgentId:
2325
+ raise HTTPException(status_code=404, detail="Session not found")
2326
+
2327
+ events = await service.get_events(request.SessionId)
2328
+ resume_audit = _resume_audit_by_checkpoint(events)
2329
+ checkpoint = None
2330
+ for event in reversed(events):
2331
+ candidate = _checkpoint_event_to_action_payload(event)
2332
+ if candidate is None:
2333
+ continue
2334
+ candidate = _apply_checkpoint_resume_audit(candidate, resume_audit)
2335
+ if candidate["RunId"] != str(request.RunId):
2336
+ continue
2337
+ if candidate["CheckpointId"] != str(request.CheckpointId):
2338
+ continue
2339
+ checkpoint = candidate
2340
+ break
2341
+ if checkpoint is None:
2342
+ raise HTTPException(status_code=404, detail="Checkpoint not found")
2343
+
2344
+ checkpoint = _check_adk_latest_resumable(checkpoint, events)
2345
+
2346
+ return _action_response(
2347
+ "GetCheckpointResumePreview",
2348
+ {"Preview": _build_checkpoint_resume_preview(checkpoint=checkpoint, events=events)},
2349
+ )
2350
+
2351
+
2352
+ @app.post("/agentengine/api/v1/ResumeRun")
2353
+ async def resume_run_action(request: ResumeRunActionRequest):
2354
+ service = resolve_session_service()
2355
+ session = await service.get_session(request.SessionId)
2356
+ if not session or session.agent_id != request.AgentId:
2357
+ raise HTTPException(status_code=404, detail="Session not found")
2358
+
2359
+ checkpoint = await _find_session_checkpoint(
2360
+ service=service,
2361
+ session_id=request.SessionId,
2362
+ run_id=str(request.RunId),
2363
+ checkpoint_id=str(request.CheckpointId),
2364
+ )
2365
+ if checkpoint is None:
2366
+ raise HTTPException(status_code=404, detail="Checkpoint not found")
2367
+ disabled_detail = _checkpoint_resume_disabled_detail(checkpoint)
2368
+ if disabled_detail is not None:
2369
+ if disabled_detail.get("is_terminal"):
2370
+ resume_attempt_id = str(request.ResumeAttemptId or f"resume_{uuid.uuid4().hex}")
2371
+ invocation_id = str(request.InvocationId or resume_attempt_id)
2372
+ await conversation.append_run_resume_event(
2373
+ session_id=request.SessionId,
2374
+ author=request.AgentId,
2375
+ run_id=str(request.RunId),
2376
+ checkpoint_id=str(request.CheckpointId),
2377
+ resume_attempt_id=resume_attempt_id,
2378
+ framework=checkpoint["Framework"],
2379
+ framework_ref=checkpoint["FrameworkRef"],
2380
+ invocation_id=invocation_id,
2381
+ session_service_provider=resolve_session_service,
2382
+ )
2383
+ await conversation.append_run_status_event(
2384
+ session_id=request.SessionId,
2385
+ author=request.AgentId,
2386
+ status="completed",
2387
+ invocation_id=invocation_id,
2388
+ detail="resume_noop_terminal_checkpoint",
2389
+ session_service_provider=resolve_session_service,
2390
+ run_mode=RUN_MODE_BACKGROUND,
2391
+ run_trigger=RUN_TRIGGER_CHECKPOINT_RESUME,
2392
+ )
2393
+ return _action_response(
2394
+ "ResumeRun",
2395
+ {
2396
+ "status": "noop",
2397
+ "Reason": disabled_detail["reason"],
2398
+ "CheckpointId": disabled_detail["checkpoint_id"],
2399
+ "RunId": disabled_detail["run_id"],
2400
+ "ResumeAttemptId": resume_attempt_id,
2401
+ },
2402
+ )
2403
+ raise HTTPException(status_code=409, detail=disabled_detail)
2404
+
2405
+ resume_input = {
2406
+ "type": "agentengine.resume_checkpoint",
2407
+ "run_id": str(request.RunId),
2408
+ "checkpoint_id": str(request.CheckpointId),
2409
+ "resume_attempt_id": str(request.ResumeAttemptId or f"resume_{uuid.uuid4().hex}"),
2410
+ "framework": checkpoint["Framework"],
2411
+ "framework_ref": checkpoint["FrameworkRef"],
2412
+ "metadata": dict(checkpoint.get("Metadata") or {}),
2413
+ "checkpoint_metadata": dict(checkpoint.get("Metadata") or {}),
2414
+ "resume_instruction_enabled": bool(getattr(request, "ResumeInstructionEnabled", False)),
2415
+ "resume_instruction": str(getattr(request, "ResumeInstruction", "") or "").strip(),
2416
+ }
2417
+ active_runner = _resolve_active_runner()
2418
+ user_id = session.user_id or "user"
2419
+
2420
+ if request.Stream:
2421
+ resume_invocation_id = str(request.InvocationId or resume_input["resume_attempt_id"])
2422
+ resume_key = _detached_resume_key_from_input(request.SessionId, resume_input)
2423
+ _reject_if_detached_resume_active(resume_key)
2424
+ return _detached_streaming_response(
2425
+ conversation.stream_responses_conversation_turn(
2426
+ runner=active_runner,
2427
+ agent_id=request.AgentId,
2428
+ user_id=user_id,
2429
+ messages=[],
2430
+ session_id=request.SessionId,
2431
+ model=request.Model,
2432
+ model_metadata=request.ModelMetadata,
2433
+ model_options=request.ModelOptions,
2434
+ request_metadata={"responses_conversation": True},
2435
+ resume_input=resume_input,
2436
+ invocation_id=resume_invocation_id,
2437
+ prepare_runner=_prepare_runner_for_model,
2438
+ session_service_provider=resolve_session_service,
2439
+ run_mode=RUN_MODE_BACKGROUND,
2440
+ ),
2441
+ invocation_id=resume_invocation_id,
2442
+ resume_key=resume_key,
2443
+ run_mode=RUN_MODE_BACKGROUND,
2444
+ run_trigger=RUN_TRIGGER_CHECKPOINT_RESUME,
2445
+ )
2446
+
2447
+ response_id = f"resp_{uuid.uuid4().hex}"
2448
+ resolved_session_id, result = await conversation.invoke_conversation_once(
2449
+ runner=active_runner,
2450
+ agent_id=request.AgentId,
2451
+ user_id=user_id,
2452
+ messages=[],
2453
+ session_id=request.SessionId,
2454
+ model=request.Model,
2455
+ model_metadata=request.ModelMetadata,
2456
+ model_options=request.ModelOptions,
2457
+ request_metadata={"responses_conversation": True},
2458
+ resume_input=resume_input,
2459
+ response_id=response_id,
2460
+ invocation_id=str(resume_input["resume_attempt_id"]),
2461
+ prepare_runner=_prepare_runner_for_model,
2462
+ session_service_provider=resolve_session_service,
2463
+ run_mode=RUN_MODE_FOREGROUND,
2464
+ )
2465
+ payload = conversation.build_responses_payload(
2466
+ output_text=result["output_text"],
2467
+ model=request.Model,
2468
+ session_id=resolved_session_id,
2469
+ response_id=response_id,
2470
+ metadata=result.get("metadata") if isinstance(result.get("metadata"), dict) else None,
2471
+ )
2472
+ return _action_response("ResumeRun", payload)
2473
+
2474
+
2475
+ @app.get("/agentengine/api/v1/SubscribeRunEvents", include_in_schema=False)
2476
+ async def subscribe_run_events_action(
2477
+ SessionId: str = Query(...),
2478
+ InvocationId: str = Query(...),
2479
+ AfterSeqId: int = Query(0),
2480
+ ):
2481
+ session_id = str(SessionId or "").strip()
2482
+ invocation_id = str(InvocationId or "").strip()
2483
+ if not session_id or not invocation_id:
2484
+ raise HTTPException(status_code=400, detail="SessionId and InvocationId are required")
2485
+
2486
+ async def event_generator() -> AsyncIterator[str]:
2487
+ service = resolve_session_service()
2488
+ last_seq_id = int(AfterSeqId or 0)
2489
+ deadline = time.monotonic() + 5 * 60
2490
+ while True:
2491
+ # 增量查询:把 after_seq_id 下推到后端,只取 seq_id > last_seq_id 的事件,
2492
+ # 避免每轮全量拉取。invocation_id 过滤仍在 Python 侧。
2493
+ events = await service.get_events(session_id, after_seq_id=last_seq_id)
2494
+ matched_events = [
2495
+ event
2496
+ for event in events
2497
+ if event.invocation_id == invocation_id
2498
+ ]
2499
+ for event in matched_events:
2500
+ last_seq_id = max(last_seq_id, event.seq_id)
2501
+ payload = _event_to_action_payload(event)
2502
+ yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
2503
+ if (
2504
+ event.event_type == "run_status"
2505
+ and str((event.content or {}).get("status") or "").strip().lower()
2506
+ in _RUN_TERMINAL_STATUSES
2507
+ ):
2508
+ yield "data: [DONE]\n\n"
2509
+ return
2510
+
2511
+ # 重连兜底:本轮无新事件时,查全量确认 run 是否已有 terminal
2512
+ # (客户端断连期间 run 已结束)。
2513
+ # 正常流式期间不触发此查询,保持增量收益。
2514
+ if not matched_events:
2515
+ all_events = await service.get_events(session_id)
2516
+ latest_status = None
2517
+ for event in all_events:
2518
+ if event.invocation_id != invocation_id or event.event_type != "run_status":
2519
+ continue
2520
+ latest_status = str((event.content or {}).get("status") or "").strip().lower()
2521
+ if latest_status in _RUN_TERMINAL_STATUSES:
2522
+ yield "data: [DONE]\n\n"
2523
+ return
2524
+ if time.monotonic() > deadline:
2525
+ return
2526
+ await asyncio.sleep(0.25)
2527
+
2528
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
2529
+
2530
+
2531
+ @app.post("/agentengine/api/v1/UploadFile")
2532
+ async def upload_file_action(file: UploadFile = File(...)):
2533
+ file_id = uuid.uuid4().hex
2534
+ data = await file.read()
2535
+ file_uri, _local_path = await AttachmentStorageService().store(
2536
+ data=data,
2537
+ file_id=file_id,
2538
+ display_name=file.filename,
2539
+ mime_type=file.content_type,
2540
+ )
2541
+
2542
+ return _action_response(
2543
+ "UploadFile",
2544
+ {
2545
+ "FileData": {
2546
+ "fileUri": file_uri,
2547
+ "displayName": file.filename or "uploaded_file",
2548
+ "mimeType": file.content_type or "application/octet-stream",
2549
+ "sizeBytes": len(data),
2550
+ }
2551
+ },
2552
+ )
2553
+
2554
+
2555
+ @app.get("/agentengine/api/v1/AttachmentContent", include_in_schema=False)
2556
+ async def attachment_content_action(FileUri: str = Query(...)):
2557
+ loaded = AttachmentStorageService().read(FileUri)
2558
+ if loaded is None:
2559
+ raise HTTPException(status_code=404, detail="Attachment not found")
2560
+
2561
+ return Response(
2562
+ content=loaded.data,
2563
+ media_type=loaded.mime_type or "application/octet-stream",
2564
+ headers={"Content-Disposition": f'inline; filename="{loaded.display_name}"'},
2565
+ )
2566
+
2567
+
2568
+ @app.post("/agentengine/api/v1/ListWorkspaceFiles")
2569
+ async def list_workspace_files_action(request: WorkspaceListActionRequest):
2570
+ response = await _workspace_runtime_request(
2571
+ "GET",
2572
+ "/_ksadk/workspace/v1/entries",
2573
+ params={
2574
+ "path": request.Path,
2575
+ "recursive": "true" if request.Recursive else "false",
2576
+ },
2577
+ )
2578
+ return _action_response("ListWorkspaceFiles", response.json())
2579
+
2580
+
2581
+ @app.post("/agentengine/api/v1/AddWorkspaceFile")
2582
+ async def upload_workspace_file_action(
2583
+ file: UploadFile = File(...),
2584
+ AgentId: Optional[str] = Form(None),
2585
+ Path: str = Form(...),
2586
+ ):
2587
+ del AgentId
2588
+ try:
2589
+ payload = await file.read()
2590
+ finally:
2591
+ await file.close()
2592
+
2593
+ file_name = file.filename or Path.rsplit("/", 1)[-1]
2594
+ response = await _workspace_runtime_request(
2595
+ "POST",
2596
+ f"/_ksadk/workspace/v1/files/{quote(Path, safe='/')}",
2597
+ files={
2598
+ "file": (
2599
+ file_name,
2600
+ payload,
2601
+ file.content_type or "application/octet-stream",
2602
+ )
2603
+ },
2604
+ )
2605
+ return _action_response("AddWorkspaceFile", response.json())
2606
+
2607
+
2608
+ @app.post("/agentengine/api/v1/DeleteWorkspaceFile")
2609
+ async def delete_workspace_file_action(request: WorkspaceDeleteActionRequest):
2610
+ response = await _workspace_runtime_request(
2611
+ "DELETE",
2612
+ f"/_ksadk/workspace/v1/files/{quote(request.Path, safe='/')}",
2613
+ )
2614
+ return _action_response("DeleteWorkspaceFile", response.json())
2615
+
2616
+
2617
+ @app.post("/agentengine/api/v1/CancelRun")
2618
+ async def cancel_run_action(request: CancelRunActionRequest):
2619
+ detached = _DETACHED_STREAMS_BY_INVOCATION.get(request.InvocationId)
2620
+ found = detached is not None
2621
+ cancel_requested = False
2622
+ if detached is not None:
2623
+ cancel_requested = detached.cancel()
2624
+ runner_cancel_status = "not_found" if found else "unsupported"
2625
+ active_runner = _resolve_active_runner()
2626
+ if active_runner is not None:
2627
+ try:
2628
+ runner_result = active_runner.request_cancel(request.InvocationId)
2629
+ if isinstance(runner_result, str) and runner_result:
2630
+ runner_cancel_status = runner_result
2631
+ elif runner_result is True:
2632
+ runner_cancel_status = "accepted"
2633
+ elif runner_result is False and not found:
2634
+ runner_cancel_status = "not_found"
2635
+ except Exception as exc:
2636
+ runner_cancel_status = "error"
2637
+ logger.warning("CancelRun failed: %s", exc)
2638
+ runner_accepted = runner_cancel_status in {"accepted", "cancelling", "cancelled"}
2639
+ status = "cancelling" if found or runner_accepted else runner_cancel_status
2640
+ return _action_response(
2641
+ "CancelRun",
2642
+ {
2643
+ "Cancelled": bool(cancel_requested or runner_accepted),
2644
+ "Found": found,
2645
+ "Status": status,
2646
+ "RunnerCancelStatus": runner_cancel_status,
2647
+ },
2648
+ )
2649
+
2650
+
2651
+ @app.get("/agentengine/api/v1/GetWorkspaceFileContent", include_in_schema=False)
2652
+ async def get_workspace_file_content_action(
2653
+ FilePath: str = Query(...),
2654
+ AgentId: Optional[str] = Query(None),
2655
+ ):
2656
+ del AgentId
2657
+ response = await _workspace_runtime_request(
2658
+ "GET",
2659
+ f"/_ksadk/workspace/v1/files/{quote(FilePath, safe='/')}",
2660
+ )
2661
+ headers = {}
2662
+ for key in ("content-disposition", "last-modified"):
2663
+ value = response.headers.get(key)
2664
+ if value:
2665
+ headers[key] = value
2666
+ return Response(
2667
+ content=response.content,
2668
+ status_code=response.status_code,
2669
+ headers=headers,
2670
+ media_type=response.headers.get("content-type"),
2671
+ )
2672
+
2673
+
2674
+ @app.get("/agentengine/api/v1/ws/{agent_id}/{file_path:path}", include_in_schema=False)
2675
+ async def workspace_file_path_route(request: Request, agent_id: str, file_path: str):
2676
+ response = await _workspace_runtime_request(
2677
+ "GET",
2678
+ f"/_ksadk/workspace/v1/files/{quote(file_path, safe='/')}",
2679
+ )
2680
+ headers = {}
2681
+ for key in ("content-disposition", "last-modified"):
2682
+ value = response.headers.get(key)
2683
+ if value:
2684
+ headers[key] = value
2685
+
2686
+ content_type = response.headers.get("content-type", "")
2687
+ is_html = "text/html" in content_type or file_path.lower().endswith((".html", ".htm"))
2688
+
2689
+ if is_html and response.status_code == 200:
2690
+ del agent_id
2691
+ base_href = build_workspace_file_base_href(file_path)
2692
+ asset_source = f"{request.url.scheme}://{request.url.netloc}{base_href}"
2693
+ html_doc = response.content.decode("utf-8", errors="replace")
2694
+ html_doc = inject_workspace_html_preview(html_doc, file_path)
2695
+ headers.pop("content-disposition", None)
2696
+ headers["Content-Security-Policy"] = build_workspace_preview_csp(asset_source)
2697
+ return Response(
2698
+ content=html_doc.encode("utf-8"),
2699
+ status_code=response.status_code,
2700
+ headers=headers,
2701
+ media_type="text/html; charset=utf-8",
2702
+ )
2703
+
2704
+ return Response(
2705
+ content=response.content,
2706
+ status_code=response.status_code,
2707
+ headers=headers,
2708
+ media_type=content_type,
2709
+ )
2710
+
2711
+
2712
+ @app.get("/agentengine/api/v1/ExportWorkspaceZip", include_in_schema=False)
2713
+ async def export_workspace_zip(
2714
+ AgentId: Optional[str] = Query(None),
2715
+ Path: str = Query("."),
2716
+ ):
2717
+ del AgentId
2718
+ dir_path = Path.strip() or "."
2719
+ response = await _workspace_runtime_request(
2720
+ "GET",
2721
+ "/_ksadk/workspace/v1/entries",
2722
+ params={"path": dir_path, "recursive": "true"},
2723
+ )
2724
+ data = response.json() if response.status_code == 200 else {}
2725
+ entries = data.get("Entries", []) if isinstance(data, dict) else []
2726
+ root = _workspace_root_dir()
2727
+ root_resolved = root.resolve()
2728
+ buf = io.BytesIO()
2729
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
2730
+ for entry in entries:
2731
+ if entry.get("Type") != "file":
2732
+ continue
2733
+ rel = entry.get("Path", "")
2734
+ if not rel:
2735
+ continue
2736
+ rel_path = PurePosixPath(rel)
2737
+ if rel_path.is_absolute() or ".." in rel_path.parts:
2738
+ continue
2739
+ target = root.joinpath(*rel_path.parts)
2740
+ if target.is_symlink():
2741
+ continue
2742
+ try:
2743
+ resolved_target = target.resolve(strict=True)
2744
+ except OSError:
2745
+ continue
2746
+ if not resolved_target.is_relative_to(root_resolved):
2747
+ continue
2748
+ if resolved_target.is_file():
2749
+ zf.writestr(rel_path.as_posix(), resolved_target.read_bytes())
2750
+ buf.seek(0)
2751
+ zip_name = f"workspace-{dir_path.replace('/', '-')}.zip" if dir_path != "." else "workspace.zip"
2752
+ return StreamingResponse(
2753
+ buf,
2754
+ media_type="application/zip",
2755
+ headers={"Content-Disposition": f'attachment; filename="{zip_name}"'},
2756
+ )
2757
+
2758
+
2759
+ def _normalize_model_catalog_items(raw_models: list[Any]) -> list[dict[str, Any]]:
2760
+ """统一模型目录 shape,并按 id 去重。
2761
+
2762
+ 这里刻意保留上游原始 dict 字段,再补 canonical metadata。
2763
+ 这样两周后模型服务扩展字段时,这一层不会再次把信息裁掉。
2764
+ """
2765
+
2766
+ normalized_by_id: dict[str, dict[str, Any]] = {}
2767
+ for raw_model in raw_models:
2768
+ item = normalize_model_metadata(raw_model)
2769
+ normalized_by_id[item["id"]] = item
2770
+ return sorted(normalized_by_id.values(), key=lambda item: item["id"])
2771
+
2772
+
2773
+ async def _build_models_payload() -> dict[str, Any]:
2774
+ import os
2775
+
2776
+ import httpx
2777
+
2778
+ api_base = os.getenv("OPENAI_BASE_URL") or os.getenv("OPENAI_API_BASE")
2779
+ api_key = os.getenv("OPENAI_API_KEY")
2780
+ current_model, source = _resolve_current_model()
2781
+
2782
+ def _fallback_catalog() -> dict[str, Any]:
2783
+ models = _normalize_model_catalog_items([current_model]) if current_model else []
2784
+ return {
2785
+ "data": models,
2786
+ "current": current_model,
2787
+ "source": source,
2788
+ }
2789
+
2790
+ if not api_base:
2791
+ return _fallback_catalog()
2792
+
2793
+ try:
2794
+ base_url = api_base.rstrip("/")
2795
+ if base_url.endswith("/v1"):
2796
+ url = f"{base_url}/models"
2797
+ else:
2798
+ url = f"{base_url}/v1/models"
2799
+
2800
+ headers = {}
2801
+ if api_key:
2802
+ headers["Authorization"] = f"Bearer {api_key}"
2803
+
2804
+ async with httpx.AsyncClient(verify=False, timeout=10) as client:
2805
+ resp = await client.get(url, headers=headers)
2806
+ resp.raise_for_status()
2807
+ data = resp.json()
2808
+
2809
+ if isinstance(data, list):
2810
+ models = _normalize_model_catalog_items(list(data))
2811
+ else:
2812
+ models = _normalize_model_catalog_items(list(data.get("data", [])))
2813
+ if current_model and all(
2814
+ str(item.get("id") or "").strip() != current_model for item in models
2815
+ ):
2816
+ models = _normalize_model_catalog_items([*models, current_model])
2817
+ return {"data": models, "current": current_model, "source": source}
2818
+ except Exception as e:
2819
+ logger.error(f"Failed to fetch models: {e}")
2820
+ fallback = _fallback_catalog()
2821
+ fallback["error"] = str(e)
2822
+ return fallback
2823
+
2824
+
2825
+ class ListAgentModelsRequest(BaseModel):
2826
+ AgentId: Optional[str] = None
2827
+ Name: Optional[str] = None
2828
+
2829
+
2830
+ @app.post("/agentengine/api/v1/ListAgentModels")
2831
+ async def list_agent_models_action(_request: ListAgentModelsRequest):
2832
+ payload = await _build_models_payload()
2833
+ return _action_response(
2834
+ "ListAgentModels",
2835
+ {
2836
+ "Models": payload.get("data", []),
2837
+ "Current": payload.get("current"),
2838
+ "Source": payload.get("source", ""),
2839
+ },
2840
+ )
2841
+
2842
+
2843
+ @app.get("/v1/models")
2844
+ async def list_openai_models():
2845
+ """Expose the current model catalog through the OpenAI-compatible path."""
2846
+
2847
+ payload = await _build_models_payload()
2848
+ return {
2849
+ "object": "list",
2850
+ "data": payload.get("data", []),
2851
+ "current": payload.get("current"),
2852
+ "source": payload.get("source", ""),
2853
+ }
2854
+
2855
+
2856
+ @app.post("/agentengine/api/v1/RunAgent")
2857
+ async def run_agent_action(request: RunAgentActionRequest):
2858
+ api_format = (request.ApiFormat or "responses").strip().lower()
2859
+ run_user_id = _clean_optional_string(request.UserId) or "user"
2860
+ account_id = _clean_optional_string(request.AccountId)
2861
+ service = resolve_session_service()
2862
+ resume_input = (
2863
+ conversation.extract_responses_resume_input(request.ResponsesInput)
2864
+ if request.ResponsesInput is not None
2865
+ else None
2866
+ )
2867
+ resume_input = await _resolve_checkpoint_resume_input_from_session(
2868
+ service=service,
2869
+ agent_id=request.AgentId,
2870
+ session_id=request.SessionId,
2871
+ resume_input=resume_input,
2872
+ )
2873
+ if resume_input is not None:
2874
+ messages = []
2875
+ elif request.ResponsesInput is not None and api_format == "responses":
2876
+ messages = conversation.normalize_responses_input(request.ResponsesInput)
2877
+ else:
2878
+ messages = conversation.normalize_kop_messages(request.Messages)
2879
+ request_metadata = (
2880
+ {"previous_response_id": request.PreviousResponseId} if request.PreviousResponseId else {}
2881
+ )
2882
+ if api_format == "responses":
2883
+ request_metadata["responses_conversation"] = True
2884
+
2885
+ if request.Background:
2886
+ invocation_id = request.InvocationId or f"inv_{uuid.uuid4().hex}"
2887
+ # 后台 stream 在 detached task 里才被消费(lazy),此时 session 尚未创建。
2888
+ # 先 ensure 出 session,才能立刻写 run_status=in_progress(供 SubscribeRunEvents
2889
+ # 拉到起始态),并把 resolved session_id 回填给 detached stream 的终态写入与 SubscribeUrl。
2890
+ background_session = await conversation.ensure_conversation_session(
2891
+ agent_id=request.AgentId,
2892
+ user_id=run_user_id,
2893
+ session_id=request.SessionId,
2894
+ session_service_provider=resolve_session_service,
2895
+ )
2896
+ resolved_background_session_id = background_session.id
2897
+ if resume_input is None:
2898
+ await conversation.prime_session_metadata_for_user_turn(
2899
+ service=service,
2900
+ session=background_session,
2901
+ messages=messages,
2902
+ )
2903
+ await conversation.append_run_status_event(
2904
+ session_id=resolved_background_session_id,
2905
+ author=_resolve_active_runner().detection_result.name,
2906
+ status="in_progress",
2907
+ invocation_id=invocation_id,
2908
+ session_service_provider=resolve_session_service,
2909
+ run_mode=RUN_MODE_BACKGROUND,
2910
+ run_trigger=trigger_from_resume_input(resume_input),
2911
+ )
2912
+ resume_key = _detached_resume_key_from_input(resolved_background_session_id, resume_input)
2913
+ _reject_if_detached_resume_active(resume_key)
2914
+ detached = _DetachedSSEStream(
2915
+ conversation.stream_responses_conversation_turn(
2916
+ runner=_resolve_active_runner(),
2917
+ agent_id=request.AgentId,
2918
+ user_id=run_user_id,
2919
+ messages=messages,
2920
+ session_id=resolved_background_session_id,
2921
+ model=request.Model,
2922
+ model_metadata=request.ModelMetadata,
2923
+ model_options=request.ModelOptions,
2924
+ request_metadata=request_metadata or None,
2925
+ resume_input=resume_input,
2926
+ account_id=account_id,
2927
+ invocation_id=invocation_id,
2928
+ prepare_runner=_prepare_runner_for_model,
2929
+ session_service_provider=resolve_session_service,
2930
+ run_mode=RUN_MODE_BACKGROUND,
2931
+ ),
2932
+ invocation_id=invocation_id,
2933
+ session_id=resolved_background_session_id,
2934
+ run_mode=RUN_MODE_BACKGROUND,
2935
+ run_trigger=trigger_from_resume_input(resume_input),
2936
+ )
2937
+ if invocation_id and resume_key:
2938
+ _DETACHED_RESUME_KEYS_BY_INVOCATION[invocation_id] = resume_key
2939
+ _ACTIVE_DETACHED_RESUME_INVOCATION_BY_KEY[resume_key] = invocation_id
2940
+ detached._task.add_done_callback(
2941
+ lambda _t, inv=invocation_id, rk=resume_key: _clear_detached_resume_key(inv, rk)
2942
+ )
2943
+ return _action_response(
2944
+ "RunAgent",
2945
+ {
2946
+ "SessionId": resolved_background_session_id,
2947
+ "InvocationId": invocation_id,
2948
+ "Status": "running",
2949
+ "Background": True,
2950
+ "SubscribeUrl": (
2951
+ "/agentengine/api/v1/SubscribeRunEvents"
2952
+ f"?SessionId={resolved_background_session_id}"
2953
+ f"&InvocationId={invocation_id}"
2954
+ ),
2955
+ },
2956
+ )
2957
+
2958
+ if request.Stream:
2959
+ if api_format == "chat_completions":
2960
+ completion_request = ChatCompletionRequest(
2961
+ messages=messages,
2962
+ model=request.Model,
2963
+ model_metadata=request.ModelMetadata,
2964
+ model_options=request.ModelOptions,
2965
+ stream=True,
2966
+ session_id=request.SessionId,
2967
+ user=run_user_id,
2968
+ account_id=account_id,
2969
+ )
2970
+ return await chat_completions(completion_request)
2971
+ resume_key = _detached_resume_key_from_input(request.SessionId, resume_input)
2972
+ _reject_if_detached_resume_active(resume_key)
2973
+ return _detached_streaming_response(
2974
+ conversation.stream_responses_conversation_turn(
2975
+ runner=_resolve_active_runner(),
2976
+ agent_id=request.AgentId,
2977
+ user_id=run_user_id,
2978
+ messages=messages,
2979
+ session_id=request.SessionId,
2980
+ model=request.Model,
2981
+ model_metadata=request.ModelMetadata,
2982
+ model_options=request.ModelOptions,
2983
+ request_metadata=request_metadata or None,
2984
+ resume_input=resume_input,
2985
+ account_id=account_id,
2986
+ invocation_id=request.InvocationId,
2987
+ prepare_runner=_prepare_runner_for_model,
2988
+ session_service_provider=resolve_session_service,
2989
+ run_mode=RUN_MODE_FOREGROUND,
2990
+ ),
2991
+ invocation_id=request.InvocationId,
2992
+ resume_key=resume_key,
2993
+ run_mode=RUN_MODE_FOREGROUND,
2994
+ run_trigger=trigger_from_resume_input(resume_input),
2995
+ )
2996
+
2997
+ responses_response_id = f"resp_{uuid.uuid4().hex}" if api_format != "chat_completions" else None
2998
+ resolved_session_id, result = await conversation.invoke_conversation_once(
2999
+ runner=_resolve_active_runner(),
3000
+ agent_id=request.AgentId,
3001
+ user_id=run_user_id,
3002
+ messages=messages,
3003
+ session_id=request.SessionId,
3004
+ model=request.Model,
3005
+ model_metadata=request.ModelMetadata,
3006
+ model_options=request.ModelOptions,
3007
+ request_metadata=request_metadata or None,
3008
+ resume_input=resume_input,
3009
+ response_id=responses_response_id,
3010
+ account_id=account_id,
3011
+ invocation_id=request.InvocationId,
3012
+ prepare_runner=_prepare_runner_for_model,
3013
+ session_service_provider=resolve_session_service,
3014
+ run_mode=RUN_MODE_FOREGROUND,
3015
+ )
3016
+ output_text = result["output_text"]
3017
+ if api_format == "chat_completions":
3018
+ payload = conversation.build_chat_completions_payload(
3019
+ output_text=output_text,
3020
+ model=request.Model,
3021
+ session_id=resolved_session_id,
3022
+ metadata=result.get("metadata"),
3023
+ )
3024
+ else:
3025
+ payload = conversation.build_responses_payload(
3026
+ output_text=output_text,
3027
+ model=request.Model,
3028
+ session_id=resolved_session_id,
3029
+ response_id=responses_response_id,
3030
+ metadata=result.get("metadata")
3031
+ if isinstance(result.get("metadata"), Mapping)
3032
+ else None,
3033
+ )
3034
+ return _action_response("RunAgent", payload)
3035
+
3036
+
3037
+ # ============================================================
3038
+ # Session Management API (ADK Web Compatible)
3039
+ # ============================================================
3040
+
3041
+
3042
+ @app.post("/apps/{app_name}/users/{user_id}/sessions")
3043
+ async def create_session(app_name: str, user_id: str, request: Request):
3044
+ """Create a new session"""
3045
+ # Check if importing existing events
3046
+ body = {}
3047
+ try:
3048
+ body = await request.json()
3049
+ except Exception:
3050
+ pass
3051
+
3052
+ service = resolve_session_service()
3053
+ session = await _ensure_session(app_name, user_id, body.get("sessionId") or body.get("id"))
3054
+
3055
+ for raw_event in body.get("events", []):
3056
+ session_event = SessionEvent.from_dict(raw_event, session_id=session.id)
3057
+ await service.append_event(session.id, session_event)
3058
+
3059
+ hydrated = await _hydrate_session(await service.get_session(session.id))
3060
+ return hydrated.to_legacy_dict() if hydrated else session.to_legacy_dict()
3061
+
3062
+
3063
+ @app.get("/apps/{app_name}/users/{user_id}/sessions")
3064
+ async def list_sessions(app_name: str, user_id: str):
3065
+ """List all sessions for a user"""
3066
+ service = resolve_session_service()
3067
+ sessions = await service.list_sessions(app_name, user_id)
3068
+ hydrated: List[Dict[str, Any]] = []
3069
+ for session in sessions:
3070
+ session.events = await service.get_events(session.id)
3071
+ hydrated.append(session.to_legacy_dict())
3072
+ return hydrated
3073
+
3074
+
3075
+ @app.get("/apps/{app_name}/users/{user_id}/sessions/{session_id}")
3076
+ async def get_session(app_name: str, user_id: str, session_id: str):
3077
+ """Get a specific session with its events"""
3078
+ service = resolve_session_service()
3079
+ session = await _hydrate_session(await service.get_session(session_id))
3080
+ if not session:
3081
+ raise HTTPException(status_code=404, detail="Session not found")
3082
+ return session.to_legacy_dict()
3083
+
3084
+
3085
+ @app.delete("/apps/{app_name}/users/{user_id}/sessions/{session_id}")
3086
+ async def delete_session(app_name: str, user_id: str, session_id: str):
3087
+ """Delete a session"""
3088
+ service = resolve_session_service()
3089
+ if await service.delete_session(session_id):
3090
+ return {"status": "deleted"}
3091
+ raise HTTPException(status_code=404, detail="Session not found")
3092
+
3093
+
3094
+ # ============================================================
3095
+ # Memory API - Save session to long-term memory
3096
+ # ============================================================
3097
+
3098
+
3099
+ @app.post("/apps/{app_name}/users/{user_id}/sessions/{session_id}/save_memory")
3100
+ async def save_session_to_memory(app_name: str, user_id: str, session_id: str):
3101
+ """将指定 session 保存到长期记忆
3102
+
3103
+ 当配置了 KSADK_LTM_BACKEND 时,将 session 中的用户消息
3104
+ 持久化到长期记忆后端,供后续 session 通过 load_memory 工具检索。
3105
+ """
3106
+ active_runner = _ensure_runner_loaded()
3107
+
3108
+ # 检查 runner 是否支持长期记忆
3109
+ from ksadk.runners.adk_runner import ADKRunner as _ADKRunner
3110
+
3111
+ if not isinstance(active_runner, _ADKRunner):
3112
+ raise HTTPException(
3113
+ status_code=400, detail="Long-term memory is only supported with ADK runner"
3114
+ )
3115
+
3116
+ if not active_runner._long_term_memory:
3117
+ raise HTTPException(
3118
+ status_code=400,
3119
+ detail="Long-term memory not configured. Set KSADK_LTM_BACKEND environment variable.",
3120
+ )
3121
+
3122
+ # 查找 ADK 内部 session ID
3123
+ internal_session_id = active_runner._session_map.get(session_id, session_id)
3124
+
3125
+ success = await active_runner.save_session_to_long_term_memory(
3126
+ session_id=internal_session_id,
3127
+ user_id=user_id,
3128
+ )
3129
+
3130
+ if success:
3131
+ return {"status": "saved", "session_id": session_id}
3132
+ else:
3133
+ raise HTTPException(status_code=500, detail="Failed to save session to long-term memory")
3134
+
3135
+
3136
+ # ============================================================
3137
+ # Run SSE - Core Agent Execution Endpoint
3138
+ # ============================================================
3139
+
3140
+
3141
+ @app.post("/run_sse")
3142
+ async def run_sse(request: AgentRunRequest):
3143
+ """Unified Streaming Endpoint compatible with ADK Web
3144
+
3145
+ Respects the `streaming` parameter:
3146
+ - streaming=False: Accumulate full response, send as single event
3147
+ - streaming=True: Stream tokens as they arrive (real-time)
3148
+ """
3149
+ active_runner = _ensure_runner_loaded()
3150
+ _prepare_runner_for_model(active_runner, request.model)
3151
+ use_streaming = request.streaming
3152
+ normalized_message = conversation.normalize_parts_content(
3153
+ request.newMessage.parts if request.newMessage else []
3154
+ )
3155
+ user_message = {
3156
+ "role": "user",
3157
+ "content": str(normalized_message.get("content") or ""),
3158
+ "display_content": str(normalized_message.get("display_content") or ""),
3159
+ "parts": list(normalized_message.get("parts") or []),
3160
+ "attachments": list(normalized_message.get("attachments") or []),
3161
+ "attachment_results": list(normalized_message.get("attachment_results") or []),
3162
+ }
3163
+
3164
+ model_version = "models/gemini-pro" if "gemini" in request.appName.lower() else "models/unknown"
3165
+ prepared_non_stream: conversation.PreparedConversationTurn | None = None
3166
+ if request.sessionId:
3167
+ await conversation.ensure_conversation_session(
3168
+ agent_id=request.appName,
3169
+ user_id=request.userId,
3170
+ session_id=request.sessionId,
3171
+ session_service_provider=resolve_session_service,
3172
+ )
3173
+ if not use_streaming:
3174
+ prepared_non_stream = await conversation.build_run_input(
3175
+ agent_id=request.appName,
3176
+ user_id=request.userId,
3177
+ session_id=request.sessionId,
3178
+ messages=[user_message],
3179
+ state_delta=request.stateDelta or {},
3180
+ invocation_id=request.invocationId,
3181
+ session_service_provider=resolve_session_service,
3182
+ )
3183
+ await conversation.append_run_status_event(
3184
+ session_id=prepared_non_stream.session_id,
3185
+ author=active_runner.detection_result.name,
3186
+ status="in_progress",
3187
+ invocation_id=prepared_non_stream.invocation_id,
3188
+ session_service_provider=resolve_session_service,
3189
+ run_mode=RUN_MODE_FOREGROUND,
3190
+ run_trigger=RUN_TRIGGER_NEW_RUN,
3191
+ )
3192
+
3193
+ async def event_generator():
3194
+ if not use_streaming:
3195
+ try:
3196
+ assert prepared_non_stream is not None
3197
+ session_id = prepared_non_stream.session_id
3198
+ user_input = prepared_non_stream.user_input
3199
+ attachments = prepared_non_stream.attachments
3200
+ attachment_results = prepared_non_stream.attachment_results
3201
+ current_attachments = prepared_non_stream.current_attachments
3202
+ current_attachment_results = prepared_non_stream.current_attachment_results
3203
+ input_content = prepared_non_stream.input_content
3204
+ input_messages = prepared_non_stream.input_messages
3205
+ user_parts = prepared_non_stream.user_parts
3206
+ history = prepared_non_stream.history
3207
+ invocation_id = prepared_non_stream.invocation_id
3208
+ common_metadata = {
3209
+ "modelVersion": model_version,
3210
+ "usageMetadata": {
3211
+ "promptTokenCount": len(user_input),
3212
+ "candidatesTokenCount": 0,
3213
+ "totalTokenCount": len(user_input),
3214
+ },
3215
+ }
3216
+ input_data = {
3217
+ "session_id": session_id,
3218
+ "input": user_input,
3219
+ "history": history,
3220
+ "input_content": list(input_content),
3221
+ "input_messages": list(input_messages),
3222
+ "input_parts": list(user_parts),
3223
+ "attachments": attachments,
3224
+ "attachment_results": attachment_results,
3225
+ "current_attachments": current_attachments,
3226
+ "current_attachment_results": current_attachment_results,
3227
+ "has_current_files": prepared_non_stream.has_current_files,
3228
+ "model": request.model,
3229
+ }
3230
+ result = await active_runner.invoke(input_data)
3231
+ final_text = result.get("output", "")
3232
+ response_event = {
3233
+ "id": str(uuid.uuid4()),
3234
+ "author": active_runner.detection_result.name,
3235
+ "sessionId": session_id,
3236
+ "invocationId": invocation_id,
3237
+ "content": {"role": "model", "parts": [{"text": final_text}]},
3238
+ "actions": {"finishReason": "STOP"},
3239
+ "modelVersion": common_metadata["modelVersion"],
3240
+ "usageMetadata": {
3241
+ "promptTokenCount": len(user_input),
3242
+ "candidatesTokenCount": len(final_text),
3243
+ "totalTokenCount": len(user_input) + len(final_text),
3244
+ },
3245
+ "timestamp": int(time.time() * 1000),
3246
+ }
3247
+ yield f"data: {json.dumps(response_event, ensure_ascii=False)}\n\n"
3248
+ if final_text:
3249
+ await conversation.append_conversation_event(
3250
+ session_id=session_id,
3251
+ author=active_runner.detection_result.name,
3252
+ role="model",
3253
+ text=final_text,
3254
+ invocation_id=invocation_id,
3255
+ event_type="assistant_message",
3256
+ session_service_provider=resolve_session_service,
3257
+ )
3258
+ await conversation.append_run_status_event(
3259
+ session_id=session_id,
3260
+ author=active_runner.detection_result.name,
3261
+ status="completed",
3262
+ invocation_id=invocation_id,
3263
+ session_service_provider=resolve_session_service,
3264
+ run_mode=RUN_MODE_FOREGROUND,
3265
+ run_trigger=RUN_TRIGGER_NEW_RUN,
3266
+ )
3267
+
3268
+ except Exception as e:
3269
+ logger.error(f"Error in invoke: {e}")
3270
+ await conversation.append_run_status_event(
3271
+ session_id=session_id,
3272
+ author=active_runner.detection_result.name,
3273
+ status="failed",
3274
+ invocation_id=invocation_id,
3275
+ detail=str(e),
3276
+ session_service_provider=resolve_session_service,
3277
+ run_mode=RUN_MODE_FOREGROUND,
3278
+ run_trigger=RUN_TRIGGER_NEW_RUN,
3279
+ )
3280
+ error_event = {
3281
+ "id": str(uuid.uuid4()),
3282
+ "sessionId": session_id,
3283
+ "invocationId": invocation_id,
3284
+ "error": str(e),
3285
+ "errorMessage": str(e),
3286
+ "timestamp": int(time.time() * 1000),
3287
+ }
3288
+ yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n"
3289
+ else:
3290
+ try:
3291
+ compaction_preview = await conversation.preview_auto_compaction(
3292
+ agent_id=request.appName,
3293
+ user_id=request.userId,
3294
+ session_id=request.sessionId,
3295
+ messages=[user_message],
3296
+ session_service_provider=resolve_session_service,
3297
+ )
3298
+ if compaction_preview.should_compact:
3299
+ yield conversation.build_compaction_sse_event(
3300
+ phase="start",
3301
+ trigger="auto",
3302
+ total_chars=compaction_preview.total_chars,
3303
+ group_count=compaction_preview.group_count,
3304
+ )
3305
+
3306
+ prepared = await conversation.build_run_input(
3307
+ agent_id=request.appName,
3308
+ user_id=request.userId,
3309
+ session_id=request.sessionId,
3310
+ messages=[user_message],
3311
+ state_delta=request.stateDelta or {},
3312
+ invocation_id=request.invocationId,
3313
+ session_service_provider=resolve_session_service,
3314
+ )
3315
+ if prepared.compaction_triggered:
3316
+ yield conversation.build_compaction_sse_event(
3317
+ phase="done",
3318
+ trigger=str(prepared.compaction_trigger or "auto"),
3319
+ compacted_until_seq_id=prepared.compacted_until_seq_id,
3320
+ total_chars=compaction_preview.total_chars
3321
+ if compaction_preview.should_compact
3322
+ else None,
3323
+ group_count=compaction_preview.group_count
3324
+ if compaction_preview.should_compact
3325
+ else None,
3326
+ )
3327
+
3328
+ session_id = prepared.session_id
3329
+ user_input = prepared.user_input
3330
+ attachments = prepared.attachments
3331
+ attachment_results = prepared.attachment_results
3332
+ current_attachments = prepared.current_attachments
3333
+ current_attachment_results = prepared.current_attachment_results
3334
+ input_content = prepared.input_content
3335
+ input_messages = prepared.input_messages
3336
+ user_parts = prepared.user_parts
3337
+ history = prepared.history
3338
+ invocation_id = prepared.invocation_id
3339
+ common_metadata = {
3340
+ "modelVersion": model_version,
3341
+ "usageMetadata": {
3342
+ "promptTokenCount": len(user_input),
3343
+ "candidatesTokenCount": 0,
3344
+ "totalTokenCount": len(user_input),
3345
+ },
3346
+ }
3347
+ await conversation.append_run_status_event(
3348
+ session_id=session_id,
3349
+ author=active_runner.detection_result.name,
3350
+ status="in_progress",
3351
+ invocation_id=invocation_id,
3352
+ session_service_provider=resolve_session_service,
3353
+ run_mode=RUN_MODE_FOREGROUND,
3354
+ run_trigger=RUN_TRIGGER_NEW_RUN,
3355
+ )
3356
+
3357
+ client_visible_text = ""
3358
+ authoritative_text = ""
3359
+ responses_output: list[Any] = []
3360
+ responses_response_id: str | None = None
3361
+ stream_iter = active_runner.stream(
3362
+ {
3363
+ "session_id": session_id,
3364
+ "input": user_input,
3365
+ "history": history,
3366
+ "input_content": list(input_content),
3367
+ "input_messages": list(input_messages),
3368
+ "input_parts": list(user_parts),
3369
+ "attachments": attachments,
3370
+ "attachment_results": attachment_results,
3371
+ "current_attachments": current_attachments,
3372
+ "current_attachment_results": current_attachment_results,
3373
+ "has_current_files": prepared.has_current_files,
3374
+ "model": request.model,
3375
+ }
3376
+ )
3377
+ while True:
3378
+ try:
3379
+ chunk = await asyncio.wait_for(stream_iter.__anext__(), timeout=15)
3380
+ except StopAsyncIteration:
3381
+ break
3382
+ except asyncio.TimeoutError:
3383
+ yield ": ping\n\n"
3384
+ continue
3385
+ event_id = str(uuid.uuid4())
3386
+ if chunk.get("type") == "responses_output":
3387
+ raw_output = chunk.get("output")
3388
+ responses_output = raw_output if isinstance(raw_output, list) else []
3389
+ raw_response_id = chunk.get("response_id")
3390
+ responses_response_id = (
3391
+ str(raw_response_id) if raw_response_id else responses_response_id
3392
+ )
3393
+ continue
3394
+ if chunk.get("type") == "thinking":
3395
+ delta = str(chunk.get("delta", ""))
3396
+ if delta:
3397
+ await conversation.append_reasoning_event(
3398
+ session_id=session_id,
3399
+ author=active_runner.detection_result.name,
3400
+ text=delta,
3401
+ invocation_id=invocation_id,
3402
+ session_service_provider=resolve_session_service,
3403
+ )
3404
+ yield (
3405
+ "event: response.reasoning.delta\n"
3406
+ f"data: {json.dumps({'delta': delta}, ensure_ascii=False)}\n\n"
3407
+ )
3408
+ continue
3409
+ if chunk.get("type") == "text":
3410
+ delta_text = chunk.get("delta", "")
3411
+ client_visible_text += delta_text
3412
+ authoritative_text = client_visible_text
3413
+ response_event = {
3414
+ "id": event_id,
3415
+ "author": chunk.get("node", active_runner.detection_result.name),
3416
+ "sessionId": session_id,
3417
+ "invocationId": invocation_id,
3418
+ "content": {"role": "model", "parts": [{"text": delta_text}]},
3419
+ "partial": True,
3420
+ "timestamp": int(time.time() * 1000),
3421
+ }
3422
+ yield f"data: {json.dumps(response_event, ensure_ascii=False)}\n\n"
3423
+ continue
3424
+ if chunk.get("type") == "tool_call":
3425
+ yield (
3426
+ "event: response.tool_call\n"
3427
+ "data: "
3428
+ + json.dumps(
3429
+ {
3430
+ "name": chunk.get("tool_name"),
3431
+ "args": chunk.get("tool_args", {}),
3432
+ },
3433
+ ensure_ascii=False,
3434
+ )
3435
+ + "\n\n"
3436
+ )
3437
+ tool_event = {
3438
+ "id": event_id,
3439
+ "author": chunk.get("node", "tool"),
3440
+ "sessionId": session_id,
3441
+ "invocationId": invocation_id,
3442
+ "content": {
3443
+ "role": "model",
3444
+ "parts": [
3445
+ {
3446
+ "functionCall": {
3447
+ "name": chunk.get("tool_name", "unknown"),
3448
+ "args": chunk.get("tool_args", {}),
3449
+ }
3450
+ }
3451
+ ],
3452
+ },
3453
+ "actions": {
3454
+ "finishReason": "STOP",
3455
+ "stateDelta": {},
3456
+ },
3457
+ "modelVersion": common_metadata["modelVersion"],
3458
+ "timestamp": int(time.time() * 1000),
3459
+ }
3460
+ yield f"data: {json.dumps(tool_event, ensure_ascii=False)}\n\n"
3461
+ await conversation.append_conversation_event(
3462
+ session_id=session_id,
3463
+ author=chunk.get("node", "tool"),
3464
+ role="model",
3465
+ text="",
3466
+ invocation_id=invocation_id,
3467
+ event_type="tool_call",
3468
+ session_service_provider=resolve_session_service,
3469
+ metadata={
3470
+ "tool_name": chunk.get("tool_name", "unknown"),
3471
+ "tool_args": chunk.get("tool_args", {}),
3472
+ },
3473
+ )
3474
+ continue
3475
+ if chunk.get("type") == "tool_result":
3476
+ await conversation.append_conversation_event(
3477
+ session_id=session_id,
3478
+ author=active_runner.detection_result.name,
3479
+ role="user",
3480
+ text=str(chunk.get("tool_output", "")),
3481
+ invocation_id=invocation_id,
3482
+ event_type="tool_result",
3483
+ session_service_provider=resolve_session_service,
3484
+ metadata={
3485
+ "tool_name": chunk.get("tool_name"),
3486
+ "tool_output": chunk.get("tool_output", {}),
3487
+ },
3488
+ )
3489
+ yield (
3490
+ "event: response.tool_result\n"
3491
+ "data: "
3492
+ + json.dumps(
3493
+ {
3494
+ "name": chunk.get("tool_name"),
3495
+ "output": chunk.get("tool_output", {}),
3496
+ },
3497
+ ensure_ascii=False,
3498
+ )
3499
+ + "\n\n"
3500
+ )
3501
+ continue
3502
+ if chunk.get("type") == "interrupt":
3503
+ await conversation.append_conversation_event(
3504
+ session_id=session_id,
3505
+ author=active_runner.detection_result.name,
3506
+ role="model",
3507
+ text="approval requested",
3508
+ invocation_id=invocation_id,
3509
+ event_type="approval_request",
3510
+ session_service_provider=resolve_session_service,
3511
+ metadata={"interrupt_info": chunk.get("interrupt_info")},
3512
+ )
3513
+ yield (
3514
+ "event: response.approval_request\n"
3515
+ "data: "
3516
+ + json.dumps(
3517
+ {"interrupt_info": chunk.get("interrupt_info")},
3518
+ ensure_ascii=False,
3519
+ )
3520
+ + "\n\n"
3521
+ )
3522
+ continue
3523
+ if chunk.get("type") == "final":
3524
+ final_text = chunk.get("output", "")
3525
+ if not final_text:
3526
+ continue
3527
+ authoritative_text = final_text
3528
+ if final_text != client_visible_text:
3529
+ final_event = {
3530
+ "id": event_id,
3531
+ "author": active_runner.detection_result.name,
3532
+ "sessionId": session_id,
3533
+ "invocationId": invocation_id,
3534
+ "content": {"role": "model", "parts": [{"text": final_text}]},
3535
+ "actions": {"finishReason": "STOP"},
3536
+ "modelVersion": common_metadata["modelVersion"],
3537
+ "usageMetadata": {
3538
+ "promptTokenCount": len(user_input),
3539
+ "candidatesTokenCount": len(final_text),
3540
+ "totalTokenCount": len(user_input) + len(final_text),
3541
+ },
3542
+ "timestamp": int(time.time() * 1000),
3543
+ }
3544
+ yield f"data: {json.dumps(final_event, ensure_ascii=False)}\n\n"
3545
+ client_visible_text = final_text
3546
+
3547
+ if authoritative_text:
3548
+ await conversation.append_conversation_event(
3549
+ session_id=session_id,
3550
+ author=active_runner.detection_result.name,
3551
+ role="model",
3552
+ text=authoritative_text,
3553
+ invocation_id=invocation_id,
3554
+ event_type="assistant_message",
3555
+ metadata={
3556
+ **({"responses_output": responses_output} if responses_output else {}),
3557
+ **(
3558
+ {"response_id": responses_response_id}
3559
+ if responses_response_id
3560
+ else {}
3561
+ ),
3562
+ },
3563
+ session_service_provider=resolve_session_service,
3564
+ )
3565
+ await conversation.append_run_status_event(
3566
+ session_id=session_id,
3567
+ author=active_runner.detection_result.name,
3568
+ status="completed",
3569
+ invocation_id=invocation_id,
3570
+ session_service_provider=resolve_session_service,
3571
+ run_mode=RUN_MODE_FOREGROUND,
3572
+ run_trigger=RUN_TRIGGER_NEW_RUN,
3573
+ )
3574
+
3575
+ except Exception as e:
3576
+ logger.error(f"Error in stream: {e}")
3577
+ await conversation.append_run_status_event(
3578
+ session_id=session_id,
3579
+ author=active_runner.detection_result.name,
3580
+ status="failed",
3581
+ invocation_id=invocation_id,
3582
+ detail=str(e),
3583
+ session_service_provider=resolve_session_service,
3584
+ run_mode=RUN_MODE_FOREGROUND,
3585
+ run_trigger=RUN_TRIGGER_NEW_RUN,
3586
+ )
3587
+ error_event = {
3588
+ "id": str(uuid.uuid4()),
3589
+ "sessionId": session_id,
3590
+ "invocationId": invocation_id,
3591
+ "error": str(e),
3592
+ "errorMessage": str(e),
3593
+ "timestamp": int(time.time() * 1000),
3594
+ }
3595
+ yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n"
3596
+
3597
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
3598
+
3599
+
3600
+ # ============================================================
3601
+ # Trace / Debug API (ADK Web Compatible)
3602
+ # ============================================================
3603
+
3604
+
3605
+ @app.get("/debug/trace/session/{session_id}")
3606
+ async def get_session_trace(session_id: str):
3607
+ """Get traces for a session - returns array of Span objects"""
3608
+ exporter = get_memory_exporter()
3609
+ if not exporter:
3610
+ return [] # Return empty array, not object
3611
+
3612
+ # Get all spans and transform to ADK-Web expected format
3613
+ raw_spans = exporter.get_finished_spans()
3614
+
3615
+ # Get session events for invocation mapping
3616
+ service = resolve_session_service()
3617
+ events = await service.get_events(session_id)
3618
+
3619
+ # Build invocation ID mapping from session events
3620
+ invocation_ids = {}
3621
+ for event in events:
3622
+ if event.id and event.invocation_id:
3623
+ invocation_ids[event.id] = event.invocation_id
3624
+
3625
+ # Transform spans to ADK-Web format
3626
+ spans = []
3627
+ for span in raw_spans:
3628
+ # Use session_id as trace_id for grouping
3629
+ trace_id = span.get("trace_id", session_id)
3630
+
3631
+ # Get or create invocation_id
3632
+ invocation_id = span.get("attributes", {}).get("gcp.vertex.agent.invocation_id")
3633
+ if not invocation_id:
3634
+ # Try to derive from event association
3635
+ invocation_id = trace_id[:36] if len(trace_id) >= 36 else trace_id
3636
+
3637
+ # Build attributes with required ADK fields
3638
+ attrs = span.get("attributes", {}).copy()
3639
+ attrs["gcp.vertex.agent.invocation_id"] = invocation_id
3640
+
3641
+ # If this is a LLM span, add request/response
3642
+ if "llm" in span.get("name", "").lower() or "invoke" in span.get("name", "").lower():
3643
+ if "user.input" in attrs:
3644
+ attrs["gcp.vertex.agent.llm_request"] = json.dumps(
3645
+ {
3646
+ "contents": [
3647
+ {"role": "user", "parts": [{"text": attrs.get("user.input", "")}]}
3648
+ ]
3649
+ }
3650
+ )
3651
+ if "agent.output" in attrs:
3652
+ attrs["gcp.vertex.agent.llm_response"] = json.dumps(
3653
+ {
3654
+ "candidates": [
3655
+ {
3656
+ "content": {
3657
+ "role": "model",
3658
+ "parts": [{"text": attrs.get("agent.output", "")}],
3659
+ }
3660
+ }
3661
+ ]
3662
+ }
3663
+ )
3664
+
3665
+ formatted_span = {
3666
+ "trace_id": trace_id,
3667
+ "span_id": span.get("span_id", str(uuid.uuid4())[:16]),
3668
+ "parent_span_id": span.get("parent_span_id"),
3669
+ "name": span.get("name", "unknown"),
3670
+ "start_time": span.get("start_time", 0),
3671
+ "end_time": span.get("end_time", 0),
3672
+ "attributes": attrs,
3673
+ "status": span.get("status", {}),
3674
+ }
3675
+ spans.append(formatted_span)
3676
+
3677
+ return spans # Return array directly
3678
+
3679
+
3680
+ @app.get("/debug/trace/{event_id}")
3681
+ async def get_event_trace(event_id: str):
3682
+ """Get trace for a specific event - returns array of Span objects"""
3683
+ exporter = get_memory_exporter()
3684
+ if not exporter:
3685
+ return []
3686
+
3687
+ spans = exporter.get_finished_spans()
3688
+ # Filter by event_id or return recent spans
3689
+ filtered = [s for s in spans if s.get("attributes", {}).get("event_id") == event_id]
3690
+ return filtered if filtered else spans[-10:]
3691
+
3692
+
3693
+ @app.get("/apps/{app_name}/users/{user_id}/sessions/{session_id}/events/{event_id}/graph")
3694
+ async def get_event_graph(app_name: str, user_id: str, session_id: str, event_id: str):
3695
+ """Get event graph (DOT format) - placeholder"""
3696
+ return {"dotSrc": None}
3697
+
3698
+
3699
+ # ============================================================
3700
+ # OpenAI Compatible API
3701
+ # ============================================================
3702
+
3703
+
3704
+ class ChatCompletionRequest(BaseModel):
3705
+ messages: List[Dict[str, Any]]
3706
+ model: Optional[str] = None
3707
+ model_metadata: Optional[Dict[str, Any]] = None
3708
+ model_options: Optional[Dict[str, Any]] = None
3709
+ stream: bool = False
3710
+ session_id: Optional[str] = None
3711
+ user: Optional[str] = None
3712
+ account_id: Optional[str] = None
3713
+ temperature: Optional[float] = 0.7
3714
+ max_tokens: Optional[int] = None
3715
+
3716
+
3717
+ @app.post("/v1/responses")
3718
+ async def responses(request: ResponsesRequest):
3719
+ """OpenAI Responses 兼容接口。"""
3720
+ active_runner = _resolve_active_runner()
3721
+ resolved_session_id, resolved_user_id = _resolve_responses_session_and_user(request)
3722
+ agent_id = _runtime_agent_id(active_runner)
3723
+
3724
+ resume_input = conversation.extract_responses_resume_input(request.input)
3725
+ resume_input = await _resolve_checkpoint_resume_input_from_session(
3726
+ service=resolve_session_service(),
3727
+ agent_id=agent_id,
3728
+ session_id=resolved_session_id,
3729
+ resume_input=resume_input,
3730
+ )
3731
+ messages = (
3732
+ [] if resume_input is not None else conversation.normalize_responses_input(request.input)
3733
+ )
3734
+ request_metadata = dict(request.metadata or {})
3735
+ if request.previous_response_id:
3736
+ request_metadata.setdefault("previous_response_id", request.previous_response_id)
3737
+ if request.prompt_cache_key:
3738
+ request_metadata.setdefault("prompt_cache_key", request.prompt_cache_key)
3739
+ if request.safety_identifier:
3740
+ request_metadata.setdefault("safety_identifier", request.safety_identifier)
3741
+ if request.user:
3742
+ request_metadata.setdefault("user", request.user)
3743
+ if request.conversation is not None:
3744
+ request_metadata.setdefault("conversation", request.conversation)
3745
+ if request.store is not None:
3746
+ request_metadata.setdefault("store", request.store)
3747
+ account_id = _clean_optional_string(request.account_id)
3748
+ invocation_id = _metadata_invocation_id(request_metadata)
3749
+
3750
+ if request.stream:
3751
+ resume_key = _detached_resume_key_from_input(resolved_session_id, resume_input)
3752
+ _reject_if_detached_resume_active(resume_key)
3753
+ return _detached_streaming_response(
3754
+ conversation.stream_responses_conversation_turn(
3755
+ runner=active_runner,
3756
+ agent_id=agent_id,
3757
+ user_id=resolved_user_id,
3758
+ messages=messages,
3759
+ session_id=resolved_session_id,
3760
+ model=request.model,
3761
+ model_metadata=request.model_metadata,
3762
+ model_options=request.model_options,
3763
+ instructions=request.instructions,
3764
+ request_metadata=request_metadata,
3765
+ resume_input=resume_input,
3766
+ account_id=account_id,
3767
+ invocation_id=invocation_id,
3768
+ prepare_runner=_prepare_runner_for_model,
3769
+ session_service_provider=resolve_session_service,
3770
+ run_mode=RUN_MODE_FOREGROUND,
3771
+ ),
3772
+ invocation_id=invocation_id,
3773
+ resume_key=resume_key,
3774
+ run_mode=RUN_MODE_FOREGROUND,
3775
+ run_trigger=trigger_from_resume_input(resume_input),
3776
+ )
3777
+
3778
+ response_id = f"resp_{uuid.uuid4().hex}"
3779
+ resolved_session_id, result = await conversation.invoke_conversation_once(
3780
+ runner=active_runner,
3781
+ agent_id=agent_id,
3782
+ user_id=resolved_user_id,
3783
+ messages=messages,
3784
+ session_id=resolved_session_id,
3785
+ model=request.model,
3786
+ model_metadata=request.model_metadata,
3787
+ model_options=request.model_options,
3788
+ instructions=request.instructions,
3789
+ request_metadata=request_metadata,
3790
+ resume_input=resume_input,
3791
+ response_id=response_id,
3792
+ account_id=account_id,
3793
+ invocation_id=invocation_id,
3794
+ prepare_runner=_prepare_runner_for_model,
3795
+ session_service_provider=resolve_session_service,
3796
+ run_mode=RUN_MODE_FOREGROUND,
3797
+ )
3798
+ return conversation.build_responses_payload(
3799
+ output_text=result["output_text"],
3800
+ model=request.model,
3801
+ session_id=resolved_session_id,
3802
+ response_id=response_id,
3803
+ metadata=result.get("metadata")
3804
+ if isinstance(result.get("metadata"), dict)
3805
+ else request_metadata,
3806
+ )
3807
+
3808
+
3809
+ @app.post("/v1/chat/completions")
3810
+ async def chat_completions(request: ChatCompletionRequest):
3811
+ """OpenAI 兼容的聊天补全接口 (支持流式和非流式)"""
3812
+ active_runner = _resolve_active_runner()
3813
+ messages = conversation.normalize_kop_messages(request.messages)
3814
+ agent_id = _runtime_agent_id(active_runner)
3815
+ resolved_user_id = _clean_optional_string(request.user) or "user"
3816
+ account_id = _clean_optional_string(request.account_id)
3817
+
3818
+ if request.stream:
3819
+ return StreamingResponse(
3820
+ conversation.stream_conversation_turn(
3821
+ runner=active_runner,
3822
+ agent_id=agent_id,
3823
+ user_id=resolved_user_id,
3824
+ messages=messages,
3825
+ session_id=request.session_id,
3826
+ model=request.model,
3827
+ model_metadata=request.model_metadata,
3828
+ model_options=request.model_options,
3829
+ account_id=account_id,
3830
+ prepare_runner=_prepare_runner_for_model,
3831
+ session_service_provider=resolve_session_service,
3832
+ run_mode=RUN_MODE_FOREGROUND,
3833
+ ),
3834
+ media_type="text/event-stream",
3835
+ )
3836
+
3837
+ resolved_session_id, result = await conversation.invoke_conversation_once(
3838
+ runner=active_runner,
3839
+ agent_id=agent_id,
3840
+ user_id=resolved_user_id,
3841
+ messages=messages,
3842
+ session_id=request.session_id,
3843
+ model=request.model,
3844
+ model_metadata=request.model_metadata,
3845
+ model_options=request.model_options,
3846
+ account_id=account_id,
3847
+ prepare_runner=_prepare_runner_for_model,
3848
+ session_service_provider=resolve_session_service,
3849
+ run_mode=RUN_MODE_FOREGROUND,
3850
+ )
3851
+ return conversation.build_chat_completions_payload(
3852
+ output_text=result["output_text"],
3853
+ model=request.model,
3854
+ session_id=resolved_session_id,
3855
+ metadata=result.get("metadata"),
3856
+ )
3857
+
3858
+
3859
+ # ============================================================
3860
+ # Stub Endpoints for ADK-Web Compatibility
3861
+ # ============================================================
3862
+
3863
+
3864
+ @app.get("/apps/{app_name}/eval_sets")
3865
+ async def list_eval_sets(app_name: str):
3866
+ """List evaluation sets - stub for ADK-Web"""
3867
+ return []
3868
+
3869
+
3870
+ @app.get("/apps/{app_name}/eval_results")
3871
+ async def list_eval_results(app_name: str):
3872
+ """List evaluation results - stub for ADK-Web"""
3873
+ return []
3874
+
3875
+
3876
+ @app.get("/builder/app/{app_name}")
3877
+ async def get_agent_builder(app_name: str, ts: int = 0, tmp: bool = False, file_path: str = None):
3878
+ """Get agent builder config - stub for ADK-Web"""
3879
+ # Return minimal YAML config for non-ADK projects
3880
+ return f"""name: {app_name}
3881
+ model: glm-5.1
3882
+ description: {app_name} agent
3883
+ instruction: You are a helpful assistant.
3884
+ """
3885
+
3886
+
3887
+ @app.post("/builder/save")
3888
+ async def save_agent_builder(request: Request, tmp: bool = False):
3889
+ """Save agent builder config - stub for ADK-Web"""
3890
+ return True
3891
+
3892
+
3893
+ @app.post("/builder/app/{app_name}/cancel")
3894
+ async def cancel_agent_changes(app_name: str):
3895
+ """Cancel agent builder changes - stub for ADK-Web"""
3896
+ return True
3897
+
3898
+
3899
+ # Legacy /traces endpoint
3900
+ @app.get("/traces")
3901
+ async def get_traces(limit: int = 50):
3902
+ """Get recent traces (OpenTelemetry)"""
3903
+ exporter = get_memory_exporter()
3904
+ if not exporter:
3905
+ return {"traces": []}
3906
+
3907
+ spans = exporter.get_finished_spans()
3908
+ traces = []
3909
+ for span in spans[-limit:]:
3910
+ traces.append(
3911
+ {
3912
+ "name": span.get("name", "unknown"),
3913
+ "status": span.get("status", {}).get("code", "UNSET"),
3914
+ "start_time": span.get("start_time"),
3915
+ "end_time": span.get("end_time"),
3916
+ "attributes": span.get("attributes", {}),
3917
+ }
3918
+ )
3919
+ return {"traces": traces}
3920
+
3921
+
3922
+ # ============================================================
3923
+ STATIC_DIR = Path(__file__).parent / "static"
3924
+
3925
+
3926
+ @app.get("/{requested_path:path}", include_in_schema=False)
3927
+ async def serve_agent_ui_static(requested_path: str):
3928
+ response = _resolve_ui_static_response(requested_path)
3929
+ if response is not None:
3930
+ return response
3931
+ raise HTTPException(status_code=404, detail="Not Found")