agentengine-sdk-python 0.6.2__py3-none-any.whl → 0.6.9__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 (490) hide show
  1. agentengine_sdk_python-0.6.9.dist-info/METADATA +179 -0
  2. agentengine_sdk_python-0.6.9.dist-info/RECORD +487 -0
  3. {agentengine_sdk_python-0.6.2.dist-info → agentengine_sdk_python-0.6.9.dist-info}/WHEEL +1 -1
  4. agentengine_sdk_python-0.6.9.dist-info/entry_points.txt +3 -0
  5. agentengine_sdk_python-0.6.9.dist-info/licenses/LICENSE +175 -0
  6. agentengine_sdk_python-0.6.9.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 +2227 -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 +2277 -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 +1259 -0
  46. ksadk/cli/cmd_invoke.py +1336 -0
  47. ksadk/cli/cmd_launch.py +564 -0
  48. ksadk/cli/cmd_mcp.py +1174 -0
  49. ksadk/cli/cmd_model.py +302 -0
  50. ksadk/cli/cmd_openclaw.py +3918 -0
  51. ksadk/cli/cmd_run.py +232 -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 +410 -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 +342 -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/model_context.py +360 -0
  83. ksadk/conversations/model_options.py +136 -0
  84. ksadk/conversations/normalize.py +446 -0
  85. ksadk/conversations/reasoning_markup.py +91 -0
  86. ksadk/conversations/run_kinds.py +61 -0
  87. ksadk/conversations/run_status.py +53 -0
  88. ksadk/conversations/runtime.py +5208 -0
  89. ksadk/conversations/semantic_summary.py +359 -0
  90. ksadk/conversations/session_title.py +254 -0
  91. ksadk/deployment/__init__.py +83 -0
  92. ksadk/deployment/agent_access.py +226 -0
  93. ksadk/deployment/base.py +229 -0
  94. ksadk/deployment/manager.py +332 -0
  95. ksadk/deployment/providers/__init__.py +10 -0
  96. ksadk/deployment/providers/serverless.py +1105 -0
  97. ksadk/deployment/registry.py +120 -0
  98. ksadk/deployment/state.py +78 -0
  99. ksadk/deployment/ui_config.py +21 -0
  100. ksadk/detection/__init__.py +7 -0
  101. ksadk/detection/detector.py +431 -0
  102. ksadk/detection/mcp_detector.py +250 -0
  103. ksadk/hermes_terminal.py +467 -0
  104. ksadk/identity/__init__.py +17 -0
  105. ksadk/identity/resolver.py +385 -0
  106. ksadk/knowledge_base/__init__.py +62 -0
  107. ksadk/knowledge_base/adk_tool.py +47 -0
  108. ksadk/knowledge_base/client.py +297 -0
  109. ksadk/knowledge_base/langchain_tool.py +55 -0
  110. ksadk/knowledge_base/service.py +66 -0
  111. ksadk/knowledge_base/tool.py +50 -0
  112. ksadk/markdown.py +169 -0
  113. ksadk/mcp_runtime/__init__.py +231 -0
  114. ksadk/memory/__init__.py +57 -0
  115. ksadk/memory/adk/__init__.py +51 -0
  116. ksadk/memory/adk/backends/__init__.py +5 -0
  117. ksadk/memory/adk/backends/base_ltm_backend.py +52 -0
  118. ksadk/memory/adk/backends/http_ltm_backend.py +183 -0
  119. ksadk/memory/adk/backends/inmemory_ltm_backend.py +96 -0
  120. ksadk/memory/adk/backends/sdk_ltm_backend.py +456 -0
  121. ksadk/memory/adk/long_term_memory.py +273 -0
  122. ksadk/memory/adk/short_term_memory.py +289 -0
  123. ksadk/memory/adk_tool.py +31 -0
  124. ksadk/memory/backends/__init__.py +6 -0
  125. ksadk/memory/backends/base.py +103 -0
  126. ksadk/memory/backends/memory.py +112 -0
  127. ksadk/memory/backends/redis.py +146 -0
  128. ksadk/memory/langchain_tool.py +39 -0
  129. ksadk/memory/ltm_backend_factory.py +24 -0
  130. ksadk/memory/manager.py +215 -0
  131. ksadk/memory/service.py +172 -0
  132. ksadk/memory/tool.py +96 -0
  133. ksadk/model_policy.py +223 -0
  134. ksadk/openclaw_gateway.py +378 -0
  135. ksadk/runners/__init__.py +8 -0
  136. ksadk/runners/adk_runner.py +1135 -0
  137. ksadk/runners/base_runner.py +305 -0
  138. ksadk/runners/deepagents_runner.py +13 -0
  139. ksadk/runners/factory.py +73 -0
  140. ksadk/runners/langchain_runner.py +539 -0
  141. ksadk/runners/langgraph_runner.py +852 -0
  142. ksadk/runners/patch_langchain.py +187 -0
  143. ksadk/runners/remote_runner.py +667 -0
  144. ksadk/runners/usage_accumulator.py +38 -0
  145. ksadk/runners/utils/__init__.py +19 -0
  146. ksadk/runners/utils/langfuse.py +230 -0
  147. ksadk/runners/utils/loader.py +62 -0
  148. ksadk/runtime_context.py +166 -0
  149. ksadk/runtime_state.py +25 -0
  150. ksadk/sandbox/__init__.py +28 -0
  151. ksadk/sandbox/backends/__init__.py +9 -0
  152. ksadk/sandbox/backends/e2b.py +154 -0
  153. ksadk/sandbox/backends/local_process.py +131 -0
  154. ksadk/sandbox/base.py +101 -0
  155. ksadk/sandbox/factory.py +53 -0
  156. ksadk/sandbox/registry.py +187 -0
  157. ksadk/server/__init__.py +7 -0
  158. ksadk/server/api_models.py +73 -0
  159. ksadk/server/app.py +3789 -0
  160. ksadk/server/static/assets/ArtifactsPanel-C0meuOdi.js +1 -0
  161. ksadk/server/static/assets/CodeBlock-BbI4HPtJ.js +9 -0
  162. ksadk/server/static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  163. ksadk/server/static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  164. ksadk/server/static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  165. ksadk/server/static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  166. ksadk/server/static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  167. ksadk/server/static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  168. ksadk/server/static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  169. ksadk/server/static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  170. ksadk/server/static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  171. ksadk/server/static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  172. ksadk/server/static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  173. ksadk/server/static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  174. ksadk/server/static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  175. ksadk/server/static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  176. ksadk/server/static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  177. ksadk/server/static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  178. ksadk/server/static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  179. ksadk/server/static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  180. ksadk/server/static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  181. ksadk/server/static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  182. ksadk/server/static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  183. ksadk/server/static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  184. ksadk/server/static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  185. ksadk/server/static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  186. ksadk/server/static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  187. ksadk/server/static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  188. ksadk/server/static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  189. ksadk/server/static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  190. ksadk/server/static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  191. ksadk/server/static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  192. ksadk/server/static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  193. ksadk/server/static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  194. ksadk/server/static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  195. ksadk/server/static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  196. ksadk/server/static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  197. ksadk/server/static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  198. ksadk/server/static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  199. ksadk/server/static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  200. ksadk/server/static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  201. ksadk/server/static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  202. ksadk/server/static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  203. ksadk/server/static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  204. ksadk/server/static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  205. ksadk/server/static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  206. ksadk/server/static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  207. ksadk/server/static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  208. ksadk/server/static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  209. ksadk/server/static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  210. ksadk/server/static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  211. ksadk/server/static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  212. ksadk/server/static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  213. ksadk/server/static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  214. ksadk/server/static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  215. ksadk/server/static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  216. ksadk/server/static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  217. ksadk/server/static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  218. ksadk/server/static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  219. ksadk/server/static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  220. ksadk/server/static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  221. ksadk/server/static/assets/MathMessageMarkdown-DMLQYoLp.js +8 -0
  222. ksadk/server/static/assets/MathMessageMarkdown-DeYh8QPr.css +1 -0
  223. ksadk/server/static/assets/MermaidBlock-DCR8WZ17.js +303 -0
  224. ksadk/server/static/assets/NativeTerminalPanel-CHOd7Rch.css +1 -0
  225. ksadk/server/static/assets/NativeTerminalPanel-CKUvWhUz.js +1 -0
  226. ksadk/server/static/assets/addon-fit-DthTIhi3.js +1 -0
  227. ksadk/server/static/assets/apl-CnwPGSsG.js +1 -0
  228. ksadk/server/static/assets/arc-bj2rZnP5.js +1 -0
  229. ksadk/server/static/assets/architecture-7EHR7CIX-Cr6Xz1_c.js +1 -0
  230. ksadk/server/static/assets/architectureDiagram-3BPJPVTR-2u1ltDIj.js +36 -0
  231. ksadk/server/static/assets/asciiarmor-qTkVPQu6.js +1 -0
  232. ksadk/server/static/assets/asn1-Dr8qZg38.js +1 -0
  233. ksadk/server/static/assets/asterisk-Bja0s6e1.js +1 -0
  234. ksadk/server/static/assets/blockDiagram-GPEHLZMM-D6fvdK20.js +132 -0
  235. ksadk/server/static/assets/brainfuck-D5EjA2JK.js +1 -0
  236. ksadk/server/static/assets/c4Diagram-AAUBKEIU-BK82L5n5.js +10 -0
  237. ksadk/server/static/assets/channel-BSSOwFIb.js +1 -0
  238. ksadk/server/static/assets/chunk-2J33WTMH-DLVdkeyQ.js +1 -0
  239. ksadk/server/static/assets/chunk-4BX2VUAB-BbwsYcbW.js +1 -0
  240. ksadk/server/static/assets/chunk-55IACEB6-DOairxzw.js +1 -0
  241. ksadk/server/static/assets/chunk-727SXJPM-hCO487gs.js +206 -0
  242. ksadk/server/static/assets/chunk-AQP2D5EJ-XU8vDOuG.js +231 -0
  243. ksadk/server/static/assets/chunk-FMBD7UC4-PrapyfMu.js +15 -0
  244. ksadk/server/static/assets/chunk-ND2GUHAM-CboKhTZ3.js +1 -0
  245. ksadk/server/static/assets/chunk-QZHKN3VN-XXpdiwUL.js +1 -0
  246. ksadk/server/static/assets/classDiagram-4FO5ZUOK-C5HvGiyK.js +1 -0
  247. ksadk/server/static/assets/classDiagram-v2-Q7XG4LA2-C5HvGiyK.js +1 -0
  248. ksadk/server/static/assets/clike-DHH8Ad3s.js +1 -0
  249. ksadk/server/static/assets/clojure-BflJGmDX.js +1 -0
  250. ksadk/server/static/assets/cmake-CyVbuzPu.js +1 -0
  251. ksadk/server/static/assets/cobol-B_OZ4V-R.js +1 -0
  252. ksadk/server/static/assets/coffeescript-B2pKbFk8.js +1 -0
  253. ksadk/server/static/assets/commonlisp-CcllspGY.js +1 -0
  254. ksadk/server/static/assets/cose-bilkent-S5V4N54A-_N8bJ-EU.js +1 -0
  255. ksadk/server/static/assets/crystal-M5-qzICs.js +1 -0
  256. ksadk/server/static/assets/css-CYpP4FRV.js +1 -0
  257. ksadk/server/static/assets/cypher-p9eYesGn.js +1 -0
  258. ksadk/server/static/assets/cytoscape.esm-DelgaX4f.js +321 -0
  259. ksadk/server/static/assets/d-BhMjBjQP.js +1 -0
  260. ksadk/server/static/assets/dagre-BM42HDAG-FoDlo5J2.js +4 -0
  261. ksadk/server/static/assets/dagre-Bx709z4p.js +1 -0
  262. ksadk/server/static/assets/defaultLocale-C8Fc0cco.js +1 -0
  263. ksadk/server/static/assets/diagram-2AECGRRQ-BBp6ebBw.js +43 -0
  264. ksadk/server/static/assets/diagram-5GNKFQAL-CqOvG1PR.js +10 -0
  265. ksadk/server/static/assets/diagram-KO2AKTUF-O9T3t6ba.js +3 -0
  266. ksadk/server/static/assets/diagram-LMA3HP47-CGG8ekBO.js +24 -0
  267. ksadk/server/static/assets/diagram-OG6HWLK6-4BmH1EEg.js +24 -0
  268. ksadk/server/static/assets/diff-ChtP43wD.js +1 -0
  269. ksadk/server/static/assets/dist-6kSZvCZh.js +1 -0
  270. ksadk/server/static/assets/dist-B67uF2uK.js +1 -0
  271. ksadk/server/static/assets/dist-BMKM66B4.js +1 -0
  272. ksadk/server/static/assets/dist-BVzcWa6D.js +1 -0
  273. ksadk/server/static/assets/dist-Bk1l05Ni.js +1 -0
  274. ksadk/server/static/assets/dist-BkJjNih3.js +1 -0
  275. ksadk/server/static/assets/dist-BmuTWcm7.js +9 -0
  276. ksadk/server/static/assets/dist-C2DKRFVK.js +1 -0
  277. ksadk/server/static/assets/dist-CWiTDN_V.js +6 -0
  278. ksadk/server/static/assets/dist-CZ6C3x8B.js +23 -0
  279. ksadk/server/static/assets/dist-CiZHB6ZW.js +1 -0
  280. ksadk/server/static/assets/dist-Cp1_jKwf.js +1 -0
  281. ksadk/server/static/assets/dist-Cwhk4lwI.js +2 -0
  282. ksadk/server/static/assets/dist-D5JGcxBx.js +11 -0
  283. ksadk/server/static/assets/dist-D99wf6_X.js +1 -0
  284. ksadk/server/static/assets/dist-DCpZrjaK.js +1 -0
  285. ksadk/server/static/assets/dist-DI-oVAXw.js +1 -0
  286. ksadk/server/static/assets/dist-DLMjL01k.js +1 -0
  287. ksadk/server/static/assets/dist-DLeM1OCK.js +1 -0
  288. ksadk/server/static/assets/dist-DM0rEQPk.js +1 -0
  289. ksadk/server/static/assets/dist-DbaH3p5h.js +1 -0
  290. ksadk/server/static/assets/dist-S4klkK5W.js +1 -0
  291. ksadk/server/static/assets/dockerfile-BLkNEvjs.js +1 -0
  292. ksadk/server/static/assets/dtd-DTF72YNO.js +1 -0
  293. ksadk/server/static/assets/dylan-C6jEEEk-.js +1 -0
  294. ksadk/server/static/assets/ebnf-D4c0_ac3.js +1 -0
  295. ksadk/server/static/assets/ecl-BSZaIHnp.js +1 -0
  296. ksadk/server/static/assets/eiffel-BEjRio4Q.js +1 -0
  297. ksadk/server/static/assets/elm-Cshzl8qu.js +1 -0
  298. ksadk/server/static/assets/erDiagram-TEJ5UH35-oJhaci7S.js +85 -0
  299. ksadk/server/static/assets/erlang-DaB2Rkuy.js +1 -0
  300. ksadk/server/static/assets/eventmodeling-FCH6USID-CIgwHJLL.js +1 -0
  301. ksadk/server/static/assets/factor-D7LVTn2l.js +1 -0
  302. ksadk/server/static/assets/fcl-ClOhbFR7.js +1 -0
  303. ksadk/server/static/assets/flowDiagram-I6XJVG4X-DtTmUan-.js +162 -0
  304. ksadk/server/static/assets/forth-Cezjo90N.js +1 -0
  305. ksadk/server/static/assets/fortran-Bt6PBEDR.js +1 -0
  306. ksadk/server/static/assets/ganttDiagram-6RSMTGT7-BSsb_eij.js +292 -0
  307. ksadk/server/static/assets/gas-BHEdbvp9.js +1 -0
  308. ksadk/server/static/assets/gherkin-oBAE_ms0.js +1 -0
  309. ksadk/server/static/assets/gitGraph-WXDBUCRP-BVAJ7uo0.js +1 -0
  310. ksadk/server/static/assets/gitGraphDiagram-PVQCEYII-BR5s6Pws.js +106 -0
  311. ksadk/server/static/assets/graphlib-B8gBHxth.js +1 -0
  312. ksadk/server/static/assets/groovy-BC3IOsC8.js +1 -0
  313. ksadk/server/static/assets/haskell-BRsoo5mP.js +1 -0
  314. ksadk/server/static/assets/haxe-DIz0ZZqd.js +1 -0
  315. ksadk/server/static/assets/http-CLVgA2GD.js +1 -0
  316. ksadk/server/static/assets/idl-BUZw3wgd.js +1 -0
  317. ksadk/server/static/assets/index-BH7-pC0B.js +165 -0
  318. ksadk/server/static/assets/index-DAURbCsP.css +1 -0
  319. ksadk/server/static/assets/info-J43DQDTF-MT80V4ge.js +1 -0
  320. ksadk/server/static/assets/infoDiagram-5YYISTIA-DYPc5cvh.js +2 -0
  321. ksadk/server/static/assets/init-D6jRqBbL.js +1 -0
  322. ksadk/server/static/assets/ishikawaDiagram-YF4QCWOH-MoBdALDg.js +70 -0
  323. ksadk/server/static/assets/javascript-9Tg8ixDm.js +1 -0
  324. ksadk/server/static/assets/journeyDiagram-JHISSGLW-DgLiQT8l.js +139 -0
  325. ksadk/server/static/assets/julia-CQfgDRfP.js +1 -0
  326. ksadk/server/static/assets/kanban-definition-UN3LZRKU-CsFx112F.js +89 -0
  327. ksadk/server/static/assets/katex-DRsiwpM2.js +265 -0
  328. ksadk/server/static/assets/linear-TNRQ5Orv.js +1 -0
  329. ksadk/server/static/assets/livescript-BXxG0Yva.js +1 -0
  330. ksadk/server/static/assets/lua-CiA1ziua.js +1 -0
  331. ksadk/server/static/assets/mathematica-u8YMmbU0.js +1 -0
  332. ksadk/server/static/assets/mbox-BEMVcqjs.js +1 -0
  333. ksadk/server/static/assets/mermaid-parser.core-D2eMVOgo.js +161 -0
  334. ksadk/server/static/assets/mindmap-definition-RKZ34NQL-BtByYFhL.js +96 -0
  335. ksadk/server/static/assets/mirc-C9zpzAZU.js +1 -0
  336. ksadk/server/static/assets/mllike-B45xgp2S.js +1 -0
  337. ksadk/server/static/assets/modelica-CqHI_q-D.js +1 -0
  338. ksadk/server/static/assets/mscgen-qgSLujhx.js +1 -0
  339. ksadk/server/static/assets/mumps-CW_TBmxz.js +1 -0
  340. ksadk/server/static/assets/nginx-BMaDbqW3.js +1 -0
  341. ksadk/server/static/assets/nsis-AQ_alPln.js +1 -0
  342. ksadk/server/static/assets/ntriples-d28e9m0E.js +1 -0
  343. ksadk/server/static/assets/octave-D2Q8cUp1.js +1 -0
  344. ksadk/server/static/assets/ordinal-hYBb2elL.js +1 -0
  345. ksadk/server/static/assets/oz-BwTct_5T.js +1 -0
  346. ksadk/server/static/assets/packet-YPE3B663-BLKhbXZB.js +1 -0
  347. ksadk/server/static/assets/pascal-Dp_KdFgX.js +1 -0
  348. ksadk/server/static/assets/perl-CdEHsPfU.js +1 -0
  349. ksadk/server/static/assets/pie-LRSECV5Y-DFOikjSY.js +1 -0
  350. ksadk/server/static/assets/pieDiagram-4H26LBE5-DPnuCB8c.js +30 -0
  351. ksadk/server/static/assets/pig-AF4Hwhju.js +1 -0
  352. ksadk/server/static/assets/powershell-CzG71I4L.js +1 -0
  353. ksadk/server/static/assets/properties-C357ku5U.js +1 -0
  354. ksadk/server/static/assets/protobuf-D1ij_kNL.js +1 -0
  355. ksadk/server/static/assets/pug-Bugr-47h.js +1 -0
  356. ksadk/server/static/assets/puppet-B4hC2X4m.js +1 -0
  357. ksadk/server/static/assets/python-BqYw9LYJ.js +1 -0
  358. ksadk/server/static/assets/q-B9NhS7L_.js +1 -0
  359. ksadk/server/static/assets/quadrantDiagram-W4KKPZXB-miFpCPv4.js +7 -0
  360. ksadk/server/static/assets/r-DADKenSm.js +1 -0
  361. ksadk/server/static/assets/radar-GUYGQ44K-095b1Ikw.js +1 -0
  362. ksadk/server/static/assets/requirementDiagram-4Y6WPE33-mjB_yYkW.js +84 -0
  363. ksadk/server/static/assets/rpm-gpDK5sbt.js +1 -0
  364. ksadk/server/static/assets/ruby-DsYpTuWg.js +1 -0
  365. ksadk/server/static/assets/sankeyDiagram-5OEKKPKP-D7hSlZZZ.js +40 -0
  366. ksadk/server/static/assets/sas-C2OyD2Y6.js +1 -0
  367. ksadk/server/static/assets/scheme-CyDdOh7e.js +1 -0
  368. ksadk/server/static/assets/sequenceDiagram-3UESZ5HK-D9Cx6hC3.js +162 -0
  369. ksadk/server/static/assets/shell-Du5Qg9im.js +1 -0
  370. ksadk/server/static/assets/sieve-BAxa3IjF.js +1 -0
  371. ksadk/server/static/assets/simple-mode-DRpGK0lJ.js +1 -0
  372. ksadk/server/static/assets/smalltalk-BLp5-jwC.js +1 -0
  373. ksadk/server/static/assets/solr-BBopId9w.js +1 -0
  374. ksadk/server/static/assets/sparql-Dz-mlCAC.js +1 -0
  375. ksadk/server/static/assets/spreadsheet-CTIncYxj.js +1 -0
  376. ksadk/server/static/assets/sql-DcCJ9jop.js +1 -0
  377. ksadk/server/static/assets/stateDiagram-AJRCARHV-D_zrg47o.js +1 -0
  378. ksadk/server/static/assets/stateDiagram-v2-BHNVJYJU-J9kCb0nC.js +1 -0
  379. ksadk/server/static/assets/stex-sDrdk2BW.js +1 -0
  380. ksadk/server/static/assets/stylus-DLLZ8dgm.js +1 -0
  381. ksadk/server/static/assets/swift-DKB6_1j6.js +1 -0
  382. ksadk/server/static/assets/tcl-BGfruO1u.js +1 -0
  383. ksadk/server/static/assets/textile-DWBqthVk.js +1 -0
  384. ksadk/server/static/assets/tiddlywiki-VlUiK86J.js +1 -0
  385. ksadk/server/static/assets/tiki-CxVlf5KZ.js +1 -0
  386. ksadk/server/static/assets/timeline-definition-PNZ67QCA-CwSec0Wa.js +120 -0
  387. ksadk/server/static/assets/toml-LvoKBwCs.js +1 -0
  388. ksadk/server/static/assets/treeView-BLDUP644-yinfJEY6.js +1 -0
  389. ksadk/server/static/assets/treemap-LRROVOQU-DosUqN1Q.js +1 -0
  390. ksadk/server/static/assets/troff-C2dAgh7P.js +1 -0
  391. ksadk/server/static/assets/ttcn-BePYh-hG.js +1 -0
  392. ksadk/server/static/assets/ttcn-cfg-CY0aot7Q.js +1 -0
  393. ksadk/server/static/assets/turtle-BDLWE2cD.js +1 -0
  394. ksadk/server/static/assets/vb-BthdM_4N.js +1 -0
  395. ksadk/server/static/assets/vbscript-D9f6rSsU.js +1 -0
  396. ksadk/server/static/assets/velocity-CqftRsjg.js +1 -0
  397. ksadk/server/static/assets/vennDiagram-CIIHVFJN-agCVobiF.js +34 -0
  398. ksadk/server/static/assets/verilog-D-x6ne0Z.js +1 -0
  399. ksadk/server/static/assets/vhdl-CMrOq2q1.js +1 -0
  400. ksadk/server/static/assets/wardley-L42UT6IY-Bvp7-5uH.js +1 -0
  401. ksadk/server/static/assets/wardleyDiagram-YWT4CUSO-TmrlhWNt.js +78 -0
  402. ksadk/server/static/assets/webidl-D5jiJvOU.js +1 -0
  403. ksadk/server/static/assets/xquery-fj_R-kMw.js +1 -0
  404. ksadk/server/static/assets/xterm-DooSxjI5.js +36 -0
  405. ksadk/server/static/assets/xychartDiagram-2RQKCTM6-DV71V-ah.js +7 -0
  406. ksadk/server/static/assets/yacas-CXXgtw0p.js +1 -0
  407. ksadk/server/static/assets/z80-CL1naaMV.js +1 -0
  408. ksadk/server/static/favicon.svg +1 -0
  409. ksadk/server/static/icons.svg +24 -0
  410. ksadk/server/static/index.html +14 -0
  411. ksadk/server/terminal_sessions.py +551 -0
  412. ksadk/sessions/__init__.py +283 -0
  413. ksadk/sessions/base.py +354 -0
  414. ksadk/sessions/continuity.py +313 -0
  415. ksadk/sessions/errors.py +7 -0
  416. ksadk/sessions/in_memory.py +273 -0
  417. ksadk/sessions/local_service.py +949 -0
  418. ksadk/sessions/postgres_service.py +838 -0
  419. ksadk/sessions/sqlite_service.py +13 -0
  420. ksadk/skills/__init__.py +9 -0
  421. ksadk/skills/loader.py +46 -0
  422. ksadk/skills/models.py +120 -0
  423. ksadk/skills/package_store.py +104 -0
  424. ksadk/skills/runtime/__init__.py +17 -0
  425. ksadk/skills/runtime/agent.py +183 -0
  426. ksadk/skills/runtime/artifacts.py +39 -0
  427. ksadk/skills/runtime/backends/__init__.py +1 -0
  428. ksadk/skills/runtime/backends/disabled.py +8 -0
  429. ksadk/skills/runtime/backends/e2b.py +168 -0
  430. ksadk/skills/runtime/backends/local.py +95 -0
  431. ksadk/skills/runtime/base.py +103 -0
  432. ksadk/skills/runtime/executor.py +219 -0
  433. ksadk/skills/runtime/factory.py +37 -0
  434. ksadk/skills/runtime/loader.py +131 -0
  435. ksadk/skills/runtime/registry.py +150 -0
  436. ksadk/skills/runtime/request.py +67 -0
  437. ksadk/skills/service_client.py +239 -0
  438. ksadk/skills/service_env.py +58 -0
  439. ksadk/skills/tool_defs.py +221 -0
  440. ksadk/terminal_client.py +69 -0
  441. ksadk/terminal_exec_policy.py +229 -0
  442. ksadk/tools/__init__.py +17 -0
  443. ksadk/tools/gateway.py +230 -0
  444. ksadk/tools/result_budget.py +154 -0
  445. ksadk/toolsets/__init__.py +852 -0
  446. ksadk/toolsets/_langchain.py +11 -0
  447. ksadk/toolsets/platform.py +93 -0
  448. ksadk/toolsets/sandbox.py +310 -0
  449. ksadk/toolsets/skills.py +217 -0
  450. ksadk/toolsets/web.py +266 -0
  451. ksadk/toolsets/workspace.py +878 -0
  452. ksadk/toolsets/workspace_state.py +44 -0
  453. ksadk/tracing/__init__.py +37 -0
  454. ksadk/tracing/exporters/__init__.py +1 -0
  455. ksadk/tracing/exporters/inmemory_exporter.py +116 -0
  456. ksadk/tracing/exporters/langfuse_exporter.py +445 -0
  457. ksadk/tracing/setup.py +829 -0
  458. ksadk/tracing/span_utils.py +114 -0
  459. ksadk/tui/__init__.py +12 -0
  460. ksadk/tui/app.py +400 -0
  461. ksadk/tui/clipboard.py +100 -0
  462. ksadk/tui/widgets/__init__.py +22 -0
  463. ksadk/tui/widgets/assistant.py +89 -0
  464. ksadk/tui/widgets/base.py +54 -0
  465. ksadk/tui/widgets/chat_input.py +246 -0
  466. ksadk/tui/widgets/history.py +156 -0
  467. ksadk/tui/widgets/system.py +87 -0
  468. ksadk/tui/widgets/thinking.py +110 -0
  469. ksadk/tui/widgets/tool_call.py +161 -0
  470. ksadk/tui/widgets/user.py +37 -0
  471. ksadk/ui_config.py +168 -0
  472. ksadk/version.py +4 -0
  473. ksadk_runtime_common/__init__.py +20 -0
  474. ksadk_runtime_common/memory_backend/__init__.py +29 -0
  475. ksadk_runtime_common/memory_backend/manifest.py +79 -0
  476. ksadk_runtime_common/memory_backend/providers/__init__.py +6 -0
  477. ksadk_runtime_common/memory_backend/providers/lancedb.py +151 -0
  478. ksadk_runtime_common/memory_backend/providers/mem0.py +74 -0
  479. ksadk_runtime_common/memory_backend/registry.py +56 -0
  480. ksadk_runtime_common/memory_backend/render.py +71 -0
  481. ksadk_runtime_common/schemas/memory_backend_manifest.schema.json +148 -0
  482. ksadk_runtime_common/workspace_files/__init__.py +29 -0
  483. ksadk_runtime_common/workspace_files/bootstrap.py +56 -0
  484. ksadk_runtime_common/workspace_files/constants.py +9 -0
  485. ksadk_runtime_common/workspace_files/path_utils.py +71 -0
  486. ksadk_runtime_common/workspace_files/preview.py +96 -0
  487. ksadk_runtime_common/workspace_files/router.py +254 -0
  488. agentengine_sdk_python-0.6.2.dist-info/METADATA +0 -46
  489. agentengine_sdk_python-0.6.2.dist-info/RECORD +0 -4
  490. agentengine_sdk_python-0.6.2.dist-info/top_level.txt +0 -1
ksadk/server/app.py ADDED
@@ -0,0 +1,3789 @@
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 ListSessionCheckpointsActionRequest(BaseModel):
1068
+ AgentId: str
1069
+ SessionId: str
1070
+ RunId: Optional[str] = None
1071
+ OnlyResumable: bool = False
1072
+ Framework: Optional[str] = None
1073
+ Offset: Optional[int] = Field(None, ge=0)
1074
+ Limit: Optional[int] = Field(None, ge=1, le=500)
1075
+
1076
+
1077
+ class ListToolReceiptsActionRequest(BaseModel):
1078
+ AgentId: str
1079
+ SessionId: str
1080
+ RunId: Optional[str] = None
1081
+ CheckpointId: Optional[str] = None
1082
+
1083
+
1084
+ class ResumeRunActionRequest(BaseModel):
1085
+ AgentId: str
1086
+ SessionId: str
1087
+ RunId: str
1088
+ CheckpointId: str
1089
+ ResumeAttemptId: Optional[str] = None
1090
+ InvocationId: Optional[str] = None
1091
+ Stream: bool = False
1092
+ Model: Optional[str] = None
1093
+ ModelMetadata: Optional[Dict[str, Any]] = None
1094
+ ModelOptions: Optional[Dict[str, Any]] = None
1095
+ ResumeInstructionEnabled: bool = False
1096
+ ResumeInstruction: Optional[str] = None
1097
+
1098
+
1099
+ class GetCheckpointResumePreviewActionRequest(BaseModel):
1100
+ AgentId: str
1101
+ SessionId: str
1102
+ RunId: str
1103
+ CheckpointId: str
1104
+
1105
+
1106
+ class RunAgentActionRequest(BaseModel):
1107
+ AgentId: str
1108
+ Messages: List[Dict[str, Any]] = Field(default_factory=list)
1109
+ UserId: Optional[str] = "user"
1110
+ AccountId: Optional[str] = None
1111
+ SessionId: Optional[str] = None
1112
+ InvocationId: Optional[str] = None
1113
+ ApiFormat: str = "responses"
1114
+ Stream: bool = False
1115
+ Background: bool = False # 立即返回 job 句柄,后台执行,进度走 SubscribeRunEvents
1116
+ Model: Optional[str] = None
1117
+ ModelMetadata: Optional[Dict[str, Any]] = None
1118
+ ModelOptions: Optional[Dict[str, Any]] = None
1119
+ ResponsesInput: Optional[Any] = None
1120
+ PreviousResponseId: Optional[str] = None
1121
+
1122
+
1123
+ class ResponseFeedbackRefActionRequest(BaseModel):
1124
+ AgentId: str
1125
+ SessionId: str
1126
+ ResponseId: str
1127
+
1128
+
1129
+ class UpsertResponseFeedbackActionRequest(ResponseFeedbackRefActionRequest):
1130
+ Rating: str
1131
+ Comment: Optional[str] = ""
1132
+ EventId: Optional[str] = None
1133
+ TraceId: Optional[str] = None
1134
+ RootSpanId: Optional[str] = None
1135
+
1136
+
1137
+ class ResponsesRequest(BaseModel):
1138
+ input: Any
1139
+ model: Optional[str] = None
1140
+ model_metadata: Optional[Dict[str, Any]] = None
1141
+ model_options: Optional[Dict[str, Any]] = None
1142
+ instructions: Optional[str] = None
1143
+ metadata: Optional[Dict[str, Any]] = None
1144
+ conversation: Optional[Any] = None
1145
+ safety_identifier: Optional[str] = None
1146
+ prompt_cache_key: Optional[str] = None
1147
+ user: Optional[str] = None
1148
+ account_id: Optional[str] = None
1149
+ store: Optional[bool] = None
1150
+ previous_response_id: Optional[str] = None
1151
+ stream: bool = False
1152
+ session_id: Optional[str] = None
1153
+
1154
+
1155
+ class WorkspaceListActionRequest(BaseModel):
1156
+ AgentId: Optional[str] = None
1157
+ Path: str = "."
1158
+ Recursive: bool = False
1159
+
1160
+
1161
+ def _clean_optional_string(value: Any) -> str | None:
1162
+ if value is None:
1163
+ return None
1164
+ text = str(value).strip()
1165
+ return text or None
1166
+
1167
+
1168
+ def _resolve_responses_conversation_id(conversation_value: Any) -> str | None:
1169
+ if conversation_value is None:
1170
+ return None
1171
+ if isinstance(conversation_value, str):
1172
+ return _clean_optional_string(conversation_value)
1173
+ if isinstance(conversation_value, Mapping):
1174
+ return _clean_optional_string(conversation_value.get("id"))
1175
+ raise HTTPException(
1176
+ status_code=400,
1177
+ detail="Responses field 'conversation' must be a string or an object with an 'id'.",
1178
+ )
1179
+
1180
+
1181
+ def _resolve_responses_session_and_user(request: ResponsesRequest) -> tuple[str | None, str]:
1182
+ conversation_id = _resolve_responses_conversation_id(request.conversation)
1183
+ legacy_session_id = _clean_optional_string(request.session_id)
1184
+
1185
+ if conversation_id and legacy_session_id and conversation_id != legacy_session_id:
1186
+ raise HTTPException(
1187
+ status_code=400,
1188
+ detail=(
1189
+ "Responses field 'conversation' conflicts with ksadk legacy field "
1190
+ "'session_id'. Use 'conversation' for OpenAI-compatible calls."
1191
+ ),
1192
+ )
1193
+ if conversation_id and request.previous_response_id:
1194
+ raise HTTPException(
1195
+ status_code=400,
1196
+ detail=(
1197
+ "Responses fields 'conversation' and 'previous_response_id' cannot be "
1198
+ "used together."
1199
+ ),
1200
+ )
1201
+
1202
+ resolved_session_id = conversation_id or legacy_session_id
1203
+ resolved_user_id = (
1204
+ _clean_optional_string(request.safety_identifier)
1205
+ or _clean_optional_string(request.user)
1206
+ or "user"
1207
+ )
1208
+ return resolved_session_id, resolved_user_id
1209
+
1210
+
1211
+ def _runtime_agent_id(active_runner: BaseRunner) -> str:
1212
+ runtime_id = _clean_optional_string(os.getenv("AGENT_RUNTIME_ID"))
1213
+ if runtime_id:
1214
+ return runtime_id
1215
+ return str(getattr(active_runner.detection_result, "name", "") or "agent")
1216
+
1217
+
1218
+ def _metadata_invocation_id(metadata: Mapping[str, Any] | None) -> str | None:
1219
+ if not isinstance(metadata, Mapping):
1220
+ return None
1221
+ agentengine_metadata = metadata.get("agentengine")
1222
+ if not isinstance(agentengine_metadata, Mapping):
1223
+ return None
1224
+ return _clean_optional_string(agentengine_metadata.get("invocation_id"))
1225
+
1226
+
1227
+ def _event_text(event: SessionEvent) -> str:
1228
+ parts = (event.content or {}).get("parts")
1229
+ if isinstance(parts, list):
1230
+ text_parts: list[str] = []
1231
+ for part in parts:
1232
+ if isinstance(part, Mapping):
1233
+ text_parts.append(str(part.get("text") or ""))
1234
+ else:
1235
+ text_parts.append(str(part or ""))
1236
+ text = "".join(text_parts).strip()
1237
+ if text:
1238
+ return text
1239
+ return str((event.content or {}).get("text") or "").strip()
1240
+
1241
+
1242
+ def _truncate_session_text(text: str, limit: int = 512) -> str:
1243
+ normalized = " ".join(str(text or "").strip().split())
1244
+ if len(normalized) <= limit:
1245
+ return normalized
1246
+ return f"{normalized[: max(limit - 1, 0)].rstrip()}…"
1247
+
1248
+
1249
+ def _session_user_prompt_from_event(event: SessionEvent) -> str:
1250
+ metadata = event.metadata or {}
1251
+ content = event.content or {}
1252
+ return str(
1253
+ metadata.get("agent_input")
1254
+ or metadata.get("user_input")
1255
+ or content.get("agent_input")
1256
+ or _event_text(event)
1257
+ or ""
1258
+ ).strip()
1259
+
1260
+
1261
+ def _run_status_payload_status(event: SessionEvent) -> str:
1262
+ return str(
1263
+ (event.metadata or {}).get("status")
1264
+ or (event.metadata or {}).get("run_status")
1265
+ or (event.content or {}).get("status")
1266
+ or ""
1267
+ ).strip()
1268
+
1269
+
1270
+ def _event_run_id(event: SessionEvent) -> str:
1271
+ return str(
1272
+ (event.metadata or {}).get("run_id")
1273
+ or (event.metadata or {}).get("invocation_id")
1274
+ or event.invocation_id
1275
+ or ""
1276
+ ).strip()
1277
+
1278
+
1279
+ def _session_topic_from_events(events: list[SessionEvent]) -> str:
1280
+ for event in reversed(events):
1281
+ metadata = event.metadata or {}
1282
+ tool_output = metadata.get("tool_output")
1283
+ if isinstance(tool_output, Mapping):
1284
+ topic = str(tool_output.get("topic") or tool_output.get("research_title") or "").strip()
1285
+ if topic:
1286
+ return topic
1287
+ topic = str(metadata.get("research_title") or metadata.get("task_title") or "").strip()
1288
+ if topic:
1289
+ return topic
1290
+ return ""
1291
+
1292
+
1293
+ def _latest_session_run_status(events: list[SessionEvent]) -> tuple[str, str]:
1294
+ latest_by_invocation: dict[str, tuple[str, SessionEvent]] = {}
1295
+ for event in reversed(events):
1296
+ if event.event_type != "run_status":
1297
+ continue
1298
+ status = _run_status_payload_status(event)
1299
+ invocation_id = _event_run_id(event)
1300
+ if status or invocation_id:
1301
+ latest_by_invocation.setdefault(invocation_id, (status, event))
1302
+ for invocation_id, (status, _) in latest_by_invocation.items():
1303
+ if status in _RUN_ACTIVE_STATUSES:
1304
+ return invocation_id, status
1305
+ for invocation_id, (status, _) in latest_by_invocation.items():
1306
+ if status not in _RUN_TERMINAL_STATUSES:
1307
+ return invocation_id, status
1308
+ if latest_by_invocation:
1309
+ invocation_id, (status, _) = next(iter(latest_by_invocation.items()))
1310
+ return invocation_id, status
1311
+ for event in reversed(events):
1312
+ invocation_id = _event_run_id(event)
1313
+ if invocation_id:
1314
+ return invocation_id, ""
1315
+ return "", ""
1316
+
1317
+
1318
+ def _latest_session_run_metadata(
1319
+ events: list[SessionEvent],
1320
+ ) -> tuple[str, str, str, str]:
1321
+ """返回 (invocation_id, status, run_mode, run_trigger)。
1322
+
1323
+ 与 _latest_session_run_status 同语义,但额外从最新 run_status 事件的 metadata
1324
+ 读取 run_mode/run_trigger。旧事件缺字段降级 unknown。原 _latest_session_run_status
1325
+ 不动,保护现有 ActiveInvocationId/ActiveRunStatus 契约。
1326
+ """
1327
+ invocation_id, status = _latest_session_run_status(events)
1328
+ run_mode = RUN_MODE_UNKNOWN
1329
+ run_trigger = RUN_TRIGGER_UNKNOWN
1330
+ if invocation_id:
1331
+ for event in reversed(events):
1332
+ if event.event_type != "run_status" or _event_run_id(event) != invocation_id:
1333
+ continue
1334
+ metadata = event.metadata or {}
1335
+ run_mode = str(metadata.get("run_mode") or RUN_MODE_UNKNOWN)
1336
+ run_trigger = str(metadata.get("run_trigger") or RUN_TRIGGER_UNKNOWN)
1337
+ break
1338
+ return invocation_id, status, run_mode, run_trigger
1339
+
1340
+
1341
+ class WorkspaceDeleteActionRequest(BaseModel):
1342
+ AgentId: Optional[str] = None
1343
+ Path: str
1344
+
1345
+
1346
+ class CancelRunActionRequest(BaseModel):
1347
+ AgentId: Optional[str] = None
1348
+ InvocationId: str
1349
+
1350
+
1351
+ async def _session_to_action_payload(session: Session) -> dict[str, Any]:
1352
+ events = list(session.events or [])
1353
+ if not events:
1354
+ try:
1355
+ events = await resolve_session_service().get_events(session.id)
1356
+ except Exception as exc:
1357
+ logger.debug("Failed to hydrate events for session %s: %s", session.id, exc)
1358
+ events = []
1359
+ event_prompts = [
1360
+ _session_user_prompt_from_event(event)
1361
+ for event in events
1362
+ if event.event_type == "user_message"
1363
+ ]
1364
+ event_prompts = [prompt for prompt in event_prompts if prompt]
1365
+ first_prompt = session.first_prompt or (event_prompts[0] if event_prompts else "")
1366
+ last_prompt = session.last_prompt or (event_prompts[-1] if event_prompts else "")
1367
+ (
1368
+ active_invocation_id,
1369
+ active_run_status,
1370
+ active_run_mode,
1371
+ active_run_trigger,
1372
+ ) = _latest_session_run_metadata(events)
1373
+ title = session.title
1374
+ title_source = session.title_source
1375
+ if not title:
1376
+ title_seed = _session_topic_from_events(events) or first_prompt
1377
+ if title_seed:
1378
+ title = build_fallback_title(title_seed)
1379
+ title_source = "fallback_first_prompt"
1380
+ if title_source == "fallback_first_prompt":
1381
+ heuristic = build_heuristic_title(
1382
+ first_prompt=first_prompt or title,
1383
+ assistant_text=session.summary or "",
1384
+ )
1385
+ if heuristic and heuristic != title:
1386
+ title = heuristic
1387
+ title_source = HEURISTIC_SESSION_TITLE_SOURCE
1388
+ payload = {
1389
+ "SessionId": session.id,
1390
+ "AgentId": session.agent_id,
1391
+ "UserId": session.user_id,
1392
+ "Title": title,
1393
+ "TitleSource": title_source,
1394
+ "Summary": session.summary,
1395
+ "FirstPrompt": _truncate_session_text(first_prompt),
1396
+ "LastPrompt": _truncate_session_text(last_prompt),
1397
+ "ActiveInvocationId": active_invocation_id,
1398
+ "ActiveRunStatus": active_run_status,
1399
+ "ActiveRunMode": active_run_mode,
1400
+ "ActiveRunTrigger": active_run_trigger,
1401
+ "State": _sanitize_session_state_for_action(session.state),
1402
+ "CreatedAt": session.created_at,
1403
+ "UpdatedAt": session.updated_at,
1404
+ "Version": session.version,
1405
+ }
1406
+ if runner is not None:
1407
+ try:
1408
+ continuity = await runner.get_session_adapter().describe_continuity(
1409
+ runner=runner,
1410
+ session=session,
1411
+ core=ConversationSessionCore(resolve_session_service()),
1412
+ )
1413
+ payload["Continuity"] = continuity.to_payload()
1414
+ except Exception as exc:
1415
+ logger.debug("Failed to describe continuity for session %s: %s", session.id, exc)
1416
+ return payload
1417
+
1418
+
1419
+ def _event_to_action_payload(event: SessionEvent) -> dict[str, Any]:
1420
+ payload = {
1421
+ "EventId": event.id,
1422
+ "SessionId": event.session_id,
1423
+ "Author": event.author,
1424
+ "EventType": event.event_type,
1425
+ "Content": event.content,
1426
+ "Timestamp": event.timestamp,
1427
+ "SeqId": event.seq_id,
1428
+ "Metadata": event.metadata,
1429
+ }
1430
+ if event.invocation_id:
1431
+ payload["InvocationId"] = event.invocation_id
1432
+ return payload
1433
+
1434
+
1435
+ def _checkpoint_event_to_action_payload(event: SessionEvent) -> dict[str, Any] | None:
1436
+ if event.event_type != "run_checkpoint":
1437
+ return None
1438
+ metadata = event.metadata or {}
1439
+ run_id = str(metadata.get("run_id") or "").strip()
1440
+ checkpoint_id = str(metadata.get("checkpoint_id") or "").strip()
1441
+ framework = str(metadata.get("framework") or "").strip()
1442
+ framework_ref = metadata.get("framework_ref")
1443
+ if not run_id or not checkpoint_id or not framework or not isinstance(framework_ref, Mapping):
1444
+ return None
1445
+ next_node = str(metadata.get("next_node") or "").strip()
1446
+ if not next_node:
1447
+ langgraph_ref = framework_ref.get("langgraph")
1448
+ if isinstance(langgraph_ref, Mapping):
1449
+ next_node = str(langgraph_ref.get("next_node") or "").strip()
1450
+ is_terminal = bool(metadata.get("is_terminal", False))
1451
+ is_resumable_raw = metadata.get("is_resumable")
1452
+ is_resumable = is_resumable_raw if isinstance(is_resumable_raw, bool) else None
1453
+ backend = str(metadata.get("backend") or "unknown").strip() or "unknown"
1454
+ scope = str(metadata.get("scope") or "unknown").strip() or "unknown"
1455
+ durable = bool(metadata.get("durable", False))
1456
+ disabled_reason = str(metadata.get("resume_disabled_reason") or "").strip()
1457
+ if is_terminal:
1458
+ is_resumable = False
1459
+ disabled_reason = disabled_reason or "该 checkpoint 已是终态;可选择更早恢复点重跑"
1460
+ if backend == "memory" or scope == "process_local":
1461
+ is_resumable = False
1462
+ disabled_reason = disabled_reason or "进程内 checkpoint 不能跨实例恢复"
1463
+ resume_status = str(metadata.get("resume_status") or "").strip()
1464
+ if not resume_status:
1465
+ if is_resumable is True:
1466
+ resume_status = "resumable"
1467
+ elif is_resumable is False:
1468
+ resume_status = "disabled"
1469
+ else:
1470
+ resume_status = "unknown"
1471
+ if resume_status == "disabled" and not disabled_reason:
1472
+ disabled_reason = "该 checkpoint 不可恢复"
1473
+ artifact_preview = metadata.get("artifact_preview")
1474
+ if not isinstance(artifact_preview, Mapping):
1475
+ artifact_preview = {}
1476
+ resume_count_raw = metadata.get("resume_count")
1477
+ try:
1478
+ resume_count = int(resume_count_raw) if resume_count_raw is not None else 0
1479
+ except (TypeError, ValueError):
1480
+ resume_count = 0
1481
+ last_resumed_at = metadata.get("last_resumed_at")
1482
+ replay_allowed_raw = metadata.get("replay_allowed")
1483
+ replay_allowed = replay_allowed_raw if isinstance(replay_allowed_raw, bool) else True
1484
+ expires_at = metadata.get("expires_at")
1485
+ checkpoint_status = str(metadata.get("checkpoint_status") or "").strip()
1486
+ if not checkpoint_status:
1487
+ if is_terminal:
1488
+ checkpoint_status = "resumed" if resume_count else "terminal"
1489
+ elif is_resumable is False:
1490
+ checkpoint_status = "disabled"
1491
+ else:
1492
+ checkpoint_status = "active"
1493
+ payload = {
1494
+ "EventId": event.id,
1495
+ "SessionId": event.session_id,
1496
+ "InvocationId": event.invocation_id,
1497
+ "SeqId": event.seq_id,
1498
+ "Timestamp": event.timestamp,
1499
+ "RunId": run_id,
1500
+ "CheckpointId": checkpoint_id,
1501
+ "Framework": framework,
1502
+ "FrameworkRef": dict(framework_ref),
1503
+ "Phase": str(metadata.get("phase") or ""),
1504
+ "Metadata": metadata,
1505
+ "IsResumable": is_resumable,
1506
+ "ResumeStatus": resume_status,
1507
+ "IsTerminal": is_terminal,
1508
+ "ResumeDisabledReason": disabled_reason,
1509
+ "NextNode": next_node,
1510
+ "StageKey": str(metadata.get("stage_key") or ""),
1511
+ "StageName": str(
1512
+ metadata.get("stage_name") or metadata.get("stage") or metadata.get("title") or ""
1513
+ ),
1514
+ "StageIndex": metadata.get("stage_index"),
1515
+ "TotalStages": metadata.get("total_stages"),
1516
+ "Backend": backend,
1517
+ "Scope": scope,
1518
+ "Durable": durable,
1519
+ "CreatedAt": event.timestamp,
1520
+ "ArtifactPreview": dict(artifact_preview),
1521
+ "LastResumedAt": last_resumed_at,
1522
+ "ResumeCount": resume_count,
1523
+ "ReplayAllowed": replay_allowed,
1524
+ "ExpiresAt": expires_at,
1525
+ "CheckpointStatus": checkpoint_status,
1526
+ }
1527
+ stage = str(metadata.get("stage") or metadata.get("title") or "").strip()
1528
+ summary = str(metadata.get("summary") or metadata.get("description") or "").strip()
1529
+ next_action = str(metadata.get("next_action") or metadata.get("nextAction") or "").strip()
1530
+ status = str(metadata.get("status") or "").strip()
1531
+ if stage:
1532
+ payload["Stage"] = stage
1533
+ if summary:
1534
+ payload["Summary"] = summary
1535
+ if next_action:
1536
+ payload["NextAction"] = next_action
1537
+ if status:
1538
+ payload["Status"] = status
1539
+ return payload
1540
+
1541
+
1542
+ def _resume_audit_by_checkpoint(
1543
+ events: list[SessionEvent],
1544
+ ) -> dict[tuple[str, str], dict[str, Any]]:
1545
+ audit: dict[tuple[str, str], dict[str, Any]] = {}
1546
+ for event in events:
1547
+ if event.event_type != "run_resume":
1548
+ continue
1549
+ metadata = event.metadata or {}
1550
+ run_id = str(metadata.get("run_id") or "").strip()
1551
+ checkpoint_id = str(metadata.get("checkpoint_id") or "").strip()
1552
+ if not run_id or not checkpoint_id:
1553
+ continue
1554
+ key = (run_id, checkpoint_id)
1555
+ item = audit.setdefault(key, {"resume_count": 0, "last_resumed_at": None})
1556
+ item["resume_count"] = int(item["resume_count"]) + 1
1557
+ item["last_resumed_at"] = event.timestamp
1558
+ return audit
1559
+
1560
+
1561
+ def _apply_checkpoint_resume_audit(
1562
+ checkpoint: dict[str, Any],
1563
+ audit_by_checkpoint: Mapping[tuple[str, str], Mapping[str, Any]],
1564
+ ) -> dict[str, Any]:
1565
+ audit = audit_by_checkpoint.get(
1566
+ (
1567
+ str(checkpoint.get("RunId") or ""),
1568
+ str(checkpoint.get("CheckpointId") or ""),
1569
+ ),
1570
+ {},
1571
+ )
1572
+ metadata = dict(checkpoint.get("Metadata") or {})
1573
+ resume_count = int(audit.get("resume_count") or checkpoint.get("ResumeCount") or 0)
1574
+ last_resumed_at = audit.get("last_resumed_at") or checkpoint.get("LastResumedAt")
1575
+ checkpoint["ResumeCount"] = resume_count
1576
+ checkpoint["LastResumedAt"] = last_resumed_at
1577
+ metadata["resume_count"] = resume_count
1578
+ metadata["last_resumed_at"] = last_resumed_at
1579
+ if resume_count and checkpoint.get("CheckpointStatus") in {"", "active"}:
1580
+ checkpoint["CheckpointStatus"] = "resumed"
1581
+ expires_at = checkpoint.get("ExpiresAt")
1582
+ expires_at_dt = _parse_iso_datetime(expires_at)
1583
+ if expires_at_dt is not None and expires_at_dt <= datetime.now(timezone.utc):
1584
+ checkpoint["IsResumable"] = False
1585
+ checkpoint["ResumeStatus"] = "disabled"
1586
+ checkpoint["CheckpointStatus"] = "expired"
1587
+ checkpoint["ResumeDisabledReason"] = "该 checkpoint 已过期"
1588
+ elif resume_count and checkpoint.get("ReplayAllowed") is False:
1589
+ checkpoint["IsResumable"] = False
1590
+ checkpoint["ResumeStatus"] = "disabled"
1591
+ checkpoint["ResumeDisabledReason"] = "该 checkpoint 已恢复过,当前策略不允许重复恢复"
1592
+ metadata["checkpoint_status"] = checkpoint.get("CheckpointStatus")
1593
+ metadata["resume_status"] = checkpoint.get("ResumeStatus")
1594
+ metadata["resume_disabled_reason"] = checkpoint.get("ResumeDisabledReason")
1595
+ checkpoint["Metadata"] = metadata
1596
+ return checkpoint
1597
+
1598
+
1599
+ _SIDE_EFFECT_TOOL_NAMES = {
1600
+ "write_workspace_file",
1601
+ "write_workspace_files",
1602
+ "delete_workspace_file",
1603
+ "execute_skills",
1604
+ "run_command",
1605
+ "run_code",
1606
+ }
1607
+
1608
+
1609
+ def _tool_receipt_event_to_action_payload(event: SessionEvent) -> dict[str, Any] | None:
1610
+ if event.event_type != "tool_result":
1611
+ return None
1612
+ metadata = event.metadata or {}
1613
+ receipt = metadata.get("tool_receipt")
1614
+ if not isinstance(receipt, Mapping):
1615
+ return None
1616
+ tool_name = str(receipt.get("tool_name") or metadata.get("tool_name") or "").strip()
1617
+ if not tool_name:
1618
+ return None
1619
+ return {
1620
+ "EventId": event.id,
1621
+ "SessionId": event.session_id,
1622
+ "InvocationId": event.invocation_id,
1623
+ "SeqId": event.seq_id,
1624
+ "Timestamp": event.timestamp,
1625
+ "ReceiptId": str(receipt.get("receipt_id") or ""),
1626
+ "IdempotencyKey": str(receipt.get("idempotency_key") or ""),
1627
+ "ToolName": tool_name,
1628
+ "ToolCallId": str(receipt.get("tool_call_id") or ""),
1629
+ "RunId": str(receipt.get("run_id") or metadata.get("run_id") or ""),
1630
+ "CheckpointId": str(receipt.get("checkpoint_id") or ""),
1631
+ "Status": str(receipt.get("status") or ""),
1632
+ "Replayed": bool(receipt.get("replayed") or metadata.get("replayed")),
1633
+ "Metadata": dict(metadata),
1634
+ }
1635
+
1636
+
1637
+ def _build_checkpoint_resume_preview(
1638
+ *,
1639
+ checkpoint: Mapping[str, Any],
1640
+ events: list[SessionEvent],
1641
+ ) -> dict[str, Any]:
1642
+ checkpoint_seq_id = int(checkpoint.get("SeqId") or 0)
1643
+ run_id = str(checkpoint.get("RunId") or "")
1644
+ receipts: list[dict[str, Any]] = []
1645
+ for event in events:
1646
+ if checkpoint_seq_id and int(event.seq_id or 0) > checkpoint_seq_id:
1647
+ continue
1648
+ receipt = _tool_receipt_event_to_action_payload(event)
1649
+ if receipt is None:
1650
+ continue
1651
+ if run_id and receipt["RunId"] and receipt["RunId"] != run_id:
1652
+ continue
1653
+ receipts.append(receipt)
1654
+
1655
+ side_effect_receipts = [
1656
+ receipt for receipt in receipts if receipt["ToolName"] in _SIDE_EFFECT_TOOL_NAMES
1657
+ ]
1658
+ risk_level = "low"
1659
+ if side_effect_receipts:
1660
+ risk_level = "medium"
1661
+ if any(receipt["Status"] == "failed" for receipt in receipts):
1662
+ risk_level = "high"
1663
+
1664
+ return {
1665
+ "Checkpoint": dict(checkpoint),
1666
+ "Capabilities": {
1667
+ "Checkpoints": True,
1668
+ "CheckpointResume": checkpoint.get("IsResumable") is not False,
1669
+ "ToolReceipts": True,
1670
+ "IdempotentToolReplay": True,
1671
+ },
1672
+ "CanResume": checkpoint.get("IsResumable") is not False,
1673
+ "Reason": str(checkpoint.get("ResumeDisabledReason") or ""),
1674
+ "NextNode": str(checkpoint.get("NextNode") or ""),
1675
+ "ExpectedAction": (
1676
+ "resume_from_checkpoint"
1677
+ if checkpoint.get("IsResumable") is True
1678
+ else ("preview_required" if checkpoint.get("ResumeStatus") == "unknown" else "disabled")
1679
+ ),
1680
+ "ToolReceipts": receipts,
1681
+ "Risk": {
1682
+ "Level": risk_level,
1683
+ "DuplicateSideEffectRisk": bool(side_effect_receipts),
1684
+ "SideEffectReceiptCount": len(side_effect_receipts),
1685
+ "FailedReceiptCount": len(
1686
+ [receipt for receipt in receipts if receipt["Status"] == "failed"]
1687
+ ),
1688
+ },
1689
+ "Summary": {
1690
+ "RunId": run_id,
1691
+ "CheckpointId": str(checkpoint.get("CheckpointId") or ""),
1692
+ "Phase": str(checkpoint.get("Phase") or ""),
1693
+ "ToolReceiptCount": len(receipts),
1694
+ },
1695
+ }
1696
+
1697
+
1698
+ def _checkpoint_resume_disabled_detail(checkpoint: Mapping[str, Any]) -> dict[str, Any] | None:
1699
+ if checkpoint.get("IsResumable") is not False:
1700
+ return None
1701
+ reason = (
1702
+ str(checkpoint.get("ResumeDisabledReason") or "").strip() or "Checkpoint is not resumable"
1703
+ )
1704
+ return {
1705
+ "code": "checkpoint_not_resumable",
1706
+ "reason": reason,
1707
+ "checkpoint_id": str(checkpoint.get("CheckpointId") or ""),
1708
+ "run_id": str(checkpoint.get("RunId") or ""),
1709
+ "resume_status": str(checkpoint.get("ResumeStatus") or "disabled"),
1710
+ "is_terminal": bool(checkpoint.get("IsTerminal")),
1711
+ }
1712
+
1713
+
1714
+ async def _find_session_checkpoint(
1715
+ *,
1716
+ service: Any,
1717
+ session_id: str,
1718
+ run_id: str,
1719
+ checkpoint_id: str,
1720
+ ) -> dict[str, Any] | None:
1721
+ events = await service.get_events(session_id)
1722
+ resume_audit = _resume_audit_by_checkpoint(events)
1723
+ for event in reversed(events):
1724
+ checkpoint = _checkpoint_event_to_action_payload(event)
1725
+ if checkpoint is None:
1726
+ continue
1727
+ checkpoint = _apply_checkpoint_resume_audit(checkpoint, resume_audit)
1728
+ if checkpoint["RunId"] != run_id:
1729
+ continue
1730
+ if checkpoint["CheckpointId"] != checkpoint_id:
1731
+ continue
1732
+ return checkpoint
1733
+ return None
1734
+
1735
+
1736
+ async def _resolve_checkpoint_resume_input_from_session(
1737
+ *,
1738
+ service: Any,
1739
+ agent_id: str,
1740
+ session_id: str | None,
1741
+ resume_input: Mapping[str, Any] | None,
1742
+ ) -> dict[str, Any] | None:
1743
+ if not isinstance(resume_input, Mapping):
1744
+ return None
1745
+ if str(resume_input.get("type") or "").strip() != "agentengine.resume_checkpoint":
1746
+ return dict(resume_input)
1747
+ normalized_session_id = str(session_id or "").strip()
1748
+ if not normalized_session_id:
1749
+ raise HTTPException(status_code=400, detail="Checkpoint resume requires session_id")
1750
+
1751
+ session = await service.get_session(normalized_session_id)
1752
+ if not session or session.agent_id != agent_id:
1753
+ raise HTTPException(status_code=404, detail="Session not found")
1754
+
1755
+ run_id = str(resume_input.get("run_id") or "").strip()
1756
+ checkpoint_id = str(resume_input.get("checkpoint_id") or "").strip()
1757
+ if not run_id or not checkpoint_id:
1758
+ raise HTTPException(
1759
+ status_code=400, detail="Checkpoint resume requires run_id and checkpoint_id"
1760
+ )
1761
+
1762
+ checkpoint = await _find_session_checkpoint(
1763
+ service=service,
1764
+ session_id=normalized_session_id,
1765
+ run_id=run_id,
1766
+ checkpoint_id=checkpoint_id,
1767
+ )
1768
+ if checkpoint is None:
1769
+ raise HTTPException(status_code=404, detail="Checkpoint not found")
1770
+
1771
+ resume_attempt_id = str(resume_input.get("resume_attempt_id") or "").strip()
1772
+ return {
1773
+ "type": "agentengine.resume_checkpoint",
1774
+ "run_id": run_id,
1775
+ "checkpoint_id": checkpoint_id,
1776
+ "resume_attempt_id": resume_attempt_id or f"resume_{uuid.uuid4().hex}",
1777
+ "framework": checkpoint["Framework"],
1778
+ "framework_ref": checkpoint["FrameworkRef"],
1779
+ "metadata": dict(checkpoint.get("Metadata") or {}),
1780
+ "checkpoint_metadata": dict(checkpoint.get("Metadata") or {}),
1781
+ "resume_instruction_enabled": bool(
1782
+ resume_input.get("resume_instruction_enabled")
1783
+ or resume_input.get("ResumeInstructionEnabled")
1784
+ ),
1785
+ "resume_instruction": str(
1786
+ resume_input.get("resume_instruction") or resume_input.get("ResumeInstruction") or ""
1787
+ ).strip(),
1788
+ }
1789
+
1790
+
1791
+ def _feedback_state_key(response_id: str) -> str:
1792
+ return str(response_id or "").strip()
1793
+
1794
+
1795
+ def _feedback_payload_from_state(item: Mapping[str, Any] | None) -> dict[str, Any] | None:
1796
+ if not isinstance(item, Mapping):
1797
+ return None
1798
+ rating = str(item.get("Rating") or item.get("rating") or "").strip().lower()
1799
+ if rating not in {"up", "down"}:
1800
+ return None
1801
+ return {
1802
+ "AgentId": str(item.get("AgentId") or item.get("agent_id") or ""),
1803
+ "SessionId": str(item.get("SessionId") or item.get("session_id") or ""),
1804
+ "ResponseId": str(item.get("ResponseId") or item.get("response_id") or ""),
1805
+ "EventId": str(item.get("EventId") or item.get("event_id") or ""),
1806
+ "Rating": rating,
1807
+ "Comment": str(item.get("Comment") or item.get("comment") or ""),
1808
+ "TraceId": str(item.get("TraceId") or item.get("trace_id") or ""),
1809
+ "RootSpanId": str(item.get("RootSpanId") or item.get("root_span_id") or ""),
1810
+ "CreatedAt": str(item.get("CreatedAt") or item.get("created_at") or ""),
1811
+ "UpdatedAt": str(item.get("UpdatedAt") or item.get("updated_at") or ""),
1812
+ }
1813
+
1814
+
1815
+ async def _find_feedback_assistant_event(
1816
+ *,
1817
+ session_id: str,
1818
+ response_id: str,
1819
+ event_id: str | None = None,
1820
+ ) -> SessionEvent | None:
1821
+ events = await resolve_session_service().get_events(session_id)
1822
+ normalized_event_id = str(event_id or "").strip()
1823
+ normalized_response_id = str(response_id or "").strip()
1824
+ for event in reversed(events):
1825
+ if normalized_event_id and event.id != normalized_event_id:
1826
+ continue
1827
+ metadata = event.metadata or {}
1828
+ if (
1829
+ normalized_response_id
1830
+ and str(metadata.get("response_id") or "") != normalized_response_id
1831
+ ):
1832
+ continue
1833
+ event_type = conversation.canonical_event_type(
1834
+ event.event_type,
1835
+ author=event.author,
1836
+ role=str((event.content or {}).get("role") or ""),
1837
+ )
1838
+ if event_type == "assistant_message":
1839
+ return event
1840
+ return None
1841
+
1842
+
1843
+ @app.post("/agentengine/api/v1/GetResponseFeedback")
1844
+ async def get_response_feedback_action(request: ResponseFeedbackRefActionRequest):
1845
+ session = await resolve_session_service().get_session(request.SessionId)
1846
+ if not session or session.agent_id != request.AgentId:
1847
+ return _action_response("GetResponseFeedback", {"Feedback": None})
1848
+ feedbacks = session.state.get("__ksadk_response_feedback__")
1849
+ feedback = None
1850
+ if isinstance(feedbacks, Mapping):
1851
+ feedback = _feedback_payload_from_state(
1852
+ feedbacks.get(_feedback_state_key(request.ResponseId))
1853
+ )
1854
+ return _action_response("GetResponseFeedback", {"Feedback": feedback})
1855
+
1856
+
1857
+ @app.post("/agentengine/api/v1/UpsertResponseFeedback")
1858
+ async def upsert_response_feedback_action(request: UpsertResponseFeedbackActionRequest):
1859
+ rating = str(request.Rating or "").strip().lower()
1860
+ if rating not in {"up", "down"}:
1861
+ raise HTTPException(status_code=400, detail="Feedback rating must be up or down")
1862
+
1863
+ service = resolve_session_service()
1864
+ session = await service.get_session(request.SessionId)
1865
+ if not session or session.agent_id != request.AgentId:
1866
+ raise HTTPException(status_code=404, detail="Session not found")
1867
+
1868
+ assistant_event = await _find_feedback_assistant_event(
1869
+ session_id=request.SessionId,
1870
+ response_id=request.ResponseId,
1871
+ event_id=request.EventId,
1872
+ )
1873
+ if assistant_event is None:
1874
+ raise HTTPException(status_code=404, detail="Assistant response not found")
1875
+
1876
+ now = str(time.time())
1877
+ existing_feedbacks = session.state.get("__ksadk_response_feedback__")
1878
+ feedbacks = dict(existing_feedbacks) if isinstance(existing_feedbacks, Mapping) else {}
1879
+ existing = (
1880
+ _feedback_payload_from_state(feedbacks.get(_feedback_state_key(request.ResponseId))) or {}
1881
+ )
1882
+ metadata = assistant_event.metadata or {}
1883
+ feedback = {
1884
+ "AgentId": request.AgentId,
1885
+ "SessionId": request.SessionId,
1886
+ "ResponseId": request.ResponseId,
1887
+ "EventId": request.EventId or assistant_event.id,
1888
+ "Rating": rating,
1889
+ "Comment": request.Comment or "",
1890
+ "TraceId": request.TraceId or str(metadata.get("trace_id") or ""),
1891
+ "RootSpanId": request.RootSpanId or str(metadata.get("root_span_id") or ""),
1892
+ "CreatedAt": existing.get("CreatedAt") or now,
1893
+ "UpdatedAt": now,
1894
+ }
1895
+ feedbacks[_feedback_state_key(request.ResponseId)] = feedback
1896
+ await service.update_state(
1897
+ agent_id=session.agent_id,
1898
+ user_id=session.user_id,
1899
+ session_id=session.id,
1900
+ scope="session",
1901
+ state_delta={"__ksadk_response_feedback__": feedbacks},
1902
+ )
1903
+ return _action_response("UpsertResponseFeedback", {"Feedback": feedback})
1904
+
1905
+
1906
+ @app.post("/agentengine/api/v1/DeleteResponseFeedback")
1907
+ async def delete_response_feedback_action(request: ResponseFeedbackRefActionRequest):
1908
+ service = resolve_session_service()
1909
+ session = await service.get_session(request.SessionId)
1910
+ if not session or session.agent_id != request.AgentId:
1911
+ return _action_response("DeleteResponseFeedback", {"Deleted": False})
1912
+ existing_feedbacks = session.state.get("__ksadk_response_feedback__")
1913
+ feedbacks = dict(existing_feedbacks) if isinstance(existing_feedbacks, Mapping) else {}
1914
+ deleted = feedbacks.pop(_feedback_state_key(request.ResponseId), None) is not None
1915
+ if deleted:
1916
+ await service.update_state(
1917
+ agent_id=session.agent_id,
1918
+ user_id=session.user_id,
1919
+ session_id=session.id,
1920
+ scope="session",
1921
+ state_delta={"__ksadk_response_feedback__": feedbacks},
1922
+ )
1923
+ return _action_response("DeleteResponseFeedback", {"Deleted": deleted})
1924
+
1925
+
1926
+ @app.post("/agentengine/api/v1/GetAgentUiBootstrap")
1927
+ async def get_agent_ui_bootstrap(request: UiBootstrapRequest):
1928
+ agent_id = request.AgentId or (_runtime_agent_id(runner) if runner else "default-agent")
1929
+ description = getattr(runner.detection_result, "description", "") if runner else ""
1930
+ framework = ""
1931
+ if runner:
1932
+ detection_type = getattr(getattr(runner, "detection_result", None), "type", None)
1933
+ framework = str(getattr(detection_type, "value", detection_type) or "").strip().lower()
1934
+ workspace_enabled = workspace_files_enabled(default=True)
1935
+ ui_spec = _resolve_agent_ui_spec()
1936
+ runtime_capabilities = (
1937
+ runner.get_runtime_capabilities()
1938
+ if runner and callable(getattr(runner, "get_runtime_capabilities", None))
1939
+ else {}
1940
+ )
1941
+ checkpoint_resume_capability = {
1942
+ "Supported": bool(
1943
+ (runtime_capabilities.get("ResumeRun") or {}).get("Supported")
1944
+ if isinstance(runtime_capabilities, Mapping)
1945
+ else False
1946
+ ),
1947
+ "Checkpoint": (runtime_capabilities.get("Checkpoint") or {})
1948
+ if isinstance(runtime_capabilities, Mapping)
1949
+ else {},
1950
+ "ResumeRun": (runtime_capabilities.get("ResumeRun") or {})
1951
+ if isinstance(runtime_capabilities, Mapping)
1952
+ else {},
1953
+ }
1954
+ return _action_response(
1955
+ "GetAgentUiBootstrap",
1956
+ {
1957
+ "Agent": {
1958
+ "AgentId": agent_id,
1959
+ "Name": runner.detection_result.name if runner else agent_id,
1960
+ "Description": description or "",
1961
+ "Framework": framework,
1962
+ },
1963
+ "Modules": ["Chat", "Build", "Deploy"],
1964
+ "Capabilities": {
1965
+ "Attachments": True,
1966
+ "WorkspaceFiles": workspace_enabled,
1967
+ "Approval": True,
1968
+ "Thinking": True,
1969
+ "StopRun": True,
1970
+ "ResumeRun": True,
1971
+ "RuntimeCapabilities": runtime_capabilities,
1972
+ "CheckpointResumeCapability": checkpoint_resume_capability,
1973
+ "RunLifecycle": {
1974
+ "Enabled": True,
1975
+ "Resume": True,
1976
+ "Abort": True,
1977
+ "Checkpoints": True,
1978
+ "CheckpointResume": True,
1979
+ "CheckpointResumePreview": True,
1980
+ },
1981
+ "MCP": False,
1982
+ "HostedRuntime": False,
1983
+ "NativeTerminal": _build_native_terminal_capability(framework),
1984
+ "BuiltinTools": describe_agentengine_tools(),
1985
+ },
1986
+ "WorkspaceFiles": build_workspace_files_bootstrap(enabled=workspace_enabled),
1987
+ "AccessMode": "Owner",
1988
+ "SharePermissions": {
1989
+ "Interactive": True,
1990
+ "DefaultPath": ui_spec.get("ui_path") or ui_spec.get("path") or "/chat",
1991
+ "SharePath": ui_spec.get("ui_path") or ui_spec.get("path") or "/chat",
1992
+ },
1993
+ "CustomUI": {
1994
+ "Enabled": bool(ui_spec.get("enabled")),
1995
+ "Profile": ui_spec.get("ui_profile") or ui_spec.get("profile"),
1996
+ "Path": ui_spec.get("ui_path") or ui_spec.get("path"),
1997
+ "Url": ui_spec.get("ui_url") or ui_spec.get("url"),
1998
+ "BundlePath": ui_spec.get("ui_bundle_path") or ui_spec.get("bundle_path"),
1999
+ },
2000
+ "ApiFormats": ["responses", "chat_completions"],
2001
+ "Stream": True,
2002
+ "SessionId": request.SessionId,
2003
+ "SessionBackend": describe_session_backend(),
2004
+ "HostedRuntime": None,
2005
+ "Model": _build_bootstrap_model_payload(),
2006
+ },
2007
+ )
2008
+
2009
+
2010
+ @app.post("/agentengine/api/v1/CreateSession")
2011
+ async def create_session_action(request: CreateSessionActionRequest):
2012
+ session = await _ensure_session(request.AgentId, request.UserId or "user", request.SessionId)
2013
+ return _action_response("CreateSession", {"Session": await _session_to_action_payload(session)})
2014
+
2015
+
2016
+ @app.post("/agentengine/api/v1/ListSessions")
2017
+ async def list_sessions_action(request: ListSessionsActionRequest):
2018
+ service = resolve_session_service()
2019
+ offset = (request.Page - 1) * request.PageSize
2020
+ sessions = await service.list_sessions(
2021
+ request.AgentId,
2022
+ request.UserId or "user",
2023
+ offset=offset,
2024
+ limit=request.PageSize,
2025
+ )
2026
+ total = await service.count_sessions(request.AgentId, request.UserId or "user")
2027
+ session_payloads = [await _session_to_action_payload(session) for session in sessions]
2028
+ return _action_response(
2029
+ "ListSessions",
2030
+ {
2031
+ "Sessions": session_payloads,
2032
+ "Total": total,
2033
+ "Page": request.Page,
2034
+ "PageSize": request.PageSize,
2035
+ },
2036
+ )
2037
+
2038
+
2039
+ @app.post("/agentengine/api/v1/GetSession")
2040
+ async def get_session_action(request: SessionIdRequest):
2041
+ service = resolve_session_service()
2042
+ session = await _hydrate_session(await service.get_session(request.SessionId))
2043
+ if not session:
2044
+ raise HTTPException(status_code=404, detail="Session not found")
2045
+ return _action_response("GetSession", {"Session": await _session_to_action_payload(session)})
2046
+
2047
+
2048
+ @app.post("/agentengine/api/v1/DeleteSession")
2049
+ async def delete_session_action(request: SessionIdRequest):
2050
+ service = resolve_session_service()
2051
+ await _cancel_detached_streams_for_session(request.SessionId)
2052
+ deleted = await service.delete_session(request.SessionId)
2053
+ if not deleted:
2054
+ raise HTTPException(status_code=404, detail="Session not found")
2055
+ return _action_response("DeleteSession", {"Deleted": True})
2056
+
2057
+
2058
+ @app.post("/agentengine/api/v1/ListSessionEvents")
2059
+ async def list_session_events_action(request: ListSessionEventsActionRequest):
2060
+ service = resolve_session_service()
2061
+ events = await service.get_events(
2062
+ request.SessionId,
2063
+ offset=request.Offset,
2064
+ limit=request.Limit,
2065
+ after_seq_id=request.AfterSeqId,
2066
+ before_seq_id=request.BeforeSeqId,
2067
+ )
2068
+ total = await service.count_events(
2069
+ request.SessionId,
2070
+ after_seq_id=request.AfterSeqId,
2071
+ before_seq_id=request.BeforeSeqId,
2072
+ )
2073
+ return _action_response(
2074
+ "ListSessionEvents",
2075
+ {
2076
+ "Events": [_event_to_action_payload(event) for event in events],
2077
+ "Total": total,
2078
+ "Offset": request.Offset or 0,
2079
+ "Limit": request.Limit if request.Limit is not None else len(events),
2080
+ "AfterSeqId": request.AfterSeqId,
2081
+ "BeforeSeqId": request.BeforeSeqId,
2082
+ },
2083
+ )
2084
+
2085
+
2086
+ def _count_resumable_checkpoints(checkpoints: list[dict[str, Any]]) -> int:
2087
+ """统计可恢复 checkpoint 数量。
2088
+
2089
+ 规则:IsResumable=True AND ReplayAllowed!=False AND IsTerminal!=True
2090
+ AND CheckpointStatus not in {expired, disabled}。
2091
+ 不排除 resumed(已恢复过的仍计入,符合存档点可反复读的回档语义)。
2092
+ """
2093
+ resumable = 0
2094
+ for cp in checkpoints:
2095
+ if cp.get("IsResumable") is not True:
2096
+ continue
2097
+ if cp.get("ReplayAllowed") is False:
2098
+ continue
2099
+ if cp.get("IsTerminal") is True:
2100
+ continue
2101
+ status = str(cp.get("CheckpointStatus") or "").strip().lower()
2102
+ if status in {"expired", "disabled"}:
2103
+ continue
2104
+ resumable += 1
2105
+ return resumable
2106
+
2107
+
2108
+ async def _list_checkpoints_payload(request: ListSessionCheckpointsActionRequest) -> dict[str, Any]:
2109
+ service = resolve_session_service()
2110
+ session = await service.get_session(request.SessionId)
2111
+ if not session or session.agent_id != request.AgentId:
2112
+ raise HTTPException(status_code=404, detail="Session not found")
2113
+
2114
+ run_id_filter = str(request.RunId or "").strip()
2115
+ framework_filter = str(request.Framework or "").strip().lower()
2116
+ events = await service.get_events(request.SessionId)
2117
+ resume_audit = _resume_audit_by_checkpoint(events)
2118
+ checkpoints: list[dict[str, Any]] = []
2119
+ for event in events:
2120
+ checkpoint = _checkpoint_event_to_action_payload(event)
2121
+ if checkpoint is None:
2122
+ continue
2123
+ checkpoint = _apply_checkpoint_resume_audit(checkpoint, resume_audit)
2124
+ if run_id_filter and checkpoint["RunId"] != run_id_filter:
2125
+ continue
2126
+ if framework_filter and str(checkpoint["Framework"]).lower() != framework_filter:
2127
+ continue
2128
+ # ResumableTotal 在 OnlyResumable 过滤前统计全量可恢复数(RunId/Framework 范围内)
2129
+ checkpoints.append(checkpoint)
2130
+ resumable_total = _count_resumable_checkpoints(checkpoints)
2131
+ if request.OnlyResumable:
2132
+ checkpoints = [cp for cp in checkpoints if cp.get("IsResumable") is True]
2133
+ total = len(checkpoints)
2134
+ offset = int(request.Offset or 0)
2135
+ if request.Limit is not None:
2136
+ checkpoints = checkpoints[offset : offset + int(request.Limit)]
2137
+ elif offset:
2138
+ checkpoints = checkpoints[offset:]
2139
+
2140
+ return {
2141
+ "Checkpoints": checkpoints,
2142
+ "Total": total,
2143
+ "ResumableTotal": resumable_total,
2144
+ "HasResumableCheckpoint": resumable_total > 0,
2145
+ "Offset": offset,
2146
+ "Limit": request.Limit if request.Limit is not None else len(checkpoints),
2147
+ }
2148
+
2149
+
2150
+ @app.post("/agentengine/api/v1/ListSessionCheckpoints")
2151
+ async def list_session_checkpoints_action(request: ListSessionCheckpointsActionRequest):
2152
+ return _action_response("ListSessionCheckpoints", await _list_checkpoints_payload(request))
2153
+
2154
+
2155
+ @app.post("/agentengine/api/v1/ListToolReceipts")
2156
+ async def list_tool_receipts_action(request: ListToolReceiptsActionRequest):
2157
+ service = resolve_session_service()
2158
+ session = await service.get_session(request.SessionId)
2159
+ if not session or session.agent_id != request.AgentId:
2160
+ raise HTTPException(status_code=404, detail="Session not found")
2161
+
2162
+ run_id_filter = str(request.RunId or "").strip()
2163
+ checkpoint_id_filter = str(request.CheckpointId or "").strip()
2164
+ receipts: list[dict[str, Any]] = []
2165
+ for event in await service.get_events(request.SessionId):
2166
+ receipt = _tool_receipt_event_to_action_payload(event)
2167
+ if receipt is None:
2168
+ continue
2169
+ if run_id_filter and receipt["RunId"] != run_id_filter:
2170
+ continue
2171
+ if checkpoint_id_filter and receipt["CheckpointId"] != checkpoint_id_filter:
2172
+ continue
2173
+ receipts.append(receipt)
2174
+
2175
+ return _action_response(
2176
+ "ListToolReceipts",
2177
+ {"ToolReceipts": receipts},
2178
+ )
2179
+
2180
+
2181
+ @app.post("/agentengine/api/v1/GetCheckpointResumePreview")
2182
+ async def get_checkpoint_resume_preview_action(request: GetCheckpointResumePreviewActionRequest):
2183
+ service = resolve_session_service()
2184
+ session = await service.get_session(request.SessionId)
2185
+ if not session or session.agent_id != request.AgentId:
2186
+ raise HTTPException(status_code=404, detail="Session not found")
2187
+
2188
+ events = await service.get_events(request.SessionId)
2189
+ resume_audit = _resume_audit_by_checkpoint(events)
2190
+ checkpoint = None
2191
+ for event in reversed(events):
2192
+ candidate = _checkpoint_event_to_action_payload(event)
2193
+ if candidate is None:
2194
+ continue
2195
+ candidate = _apply_checkpoint_resume_audit(candidate, resume_audit)
2196
+ if candidate["RunId"] != str(request.RunId):
2197
+ continue
2198
+ if candidate["CheckpointId"] != str(request.CheckpointId):
2199
+ continue
2200
+ checkpoint = candidate
2201
+ break
2202
+ if checkpoint is None:
2203
+ raise HTTPException(status_code=404, detail="Checkpoint not found")
2204
+
2205
+ return _action_response(
2206
+ "GetCheckpointResumePreview",
2207
+ {"Preview": _build_checkpoint_resume_preview(checkpoint=checkpoint, events=events)},
2208
+ )
2209
+
2210
+
2211
+ @app.post("/agentengine/api/v1/ResumeRun")
2212
+ async def resume_run_action(request: ResumeRunActionRequest):
2213
+ service = resolve_session_service()
2214
+ session = await service.get_session(request.SessionId)
2215
+ if not session or session.agent_id != request.AgentId:
2216
+ raise HTTPException(status_code=404, detail="Session not found")
2217
+
2218
+ checkpoint = await _find_session_checkpoint(
2219
+ service=service,
2220
+ session_id=request.SessionId,
2221
+ run_id=str(request.RunId),
2222
+ checkpoint_id=str(request.CheckpointId),
2223
+ )
2224
+ if checkpoint is None:
2225
+ raise HTTPException(status_code=404, detail="Checkpoint not found")
2226
+ disabled_detail = _checkpoint_resume_disabled_detail(checkpoint)
2227
+ if disabled_detail is not None:
2228
+ if disabled_detail.get("is_terminal"):
2229
+ resume_attempt_id = str(request.ResumeAttemptId or f"resume_{uuid.uuid4().hex}")
2230
+ invocation_id = str(request.InvocationId or resume_attempt_id)
2231
+ await conversation.append_run_resume_event(
2232
+ session_id=request.SessionId,
2233
+ author=request.AgentId,
2234
+ run_id=str(request.RunId),
2235
+ checkpoint_id=str(request.CheckpointId),
2236
+ resume_attempt_id=resume_attempt_id,
2237
+ framework=checkpoint["Framework"],
2238
+ framework_ref=checkpoint["FrameworkRef"],
2239
+ invocation_id=invocation_id,
2240
+ session_service_provider=resolve_session_service,
2241
+ )
2242
+ await conversation.append_run_status_event(
2243
+ session_id=request.SessionId,
2244
+ author=request.AgentId,
2245
+ status="completed",
2246
+ invocation_id=invocation_id,
2247
+ detail="resume_noop_terminal_checkpoint",
2248
+ session_service_provider=resolve_session_service,
2249
+ run_mode=RUN_MODE_BACKGROUND,
2250
+ run_trigger=RUN_TRIGGER_CHECKPOINT_RESUME,
2251
+ )
2252
+ return _action_response(
2253
+ "ResumeRun",
2254
+ {
2255
+ "status": "noop",
2256
+ "Reason": disabled_detail["reason"],
2257
+ "CheckpointId": disabled_detail["checkpoint_id"],
2258
+ "RunId": disabled_detail["run_id"],
2259
+ "ResumeAttemptId": resume_attempt_id,
2260
+ },
2261
+ )
2262
+ raise HTTPException(status_code=409, detail=disabled_detail)
2263
+
2264
+ resume_input = {
2265
+ "type": "agentengine.resume_checkpoint",
2266
+ "run_id": str(request.RunId),
2267
+ "checkpoint_id": str(request.CheckpointId),
2268
+ "resume_attempt_id": str(request.ResumeAttemptId or f"resume_{uuid.uuid4().hex}"),
2269
+ "framework": checkpoint["Framework"],
2270
+ "framework_ref": checkpoint["FrameworkRef"],
2271
+ "metadata": dict(checkpoint.get("Metadata") or {}),
2272
+ "checkpoint_metadata": dict(checkpoint.get("Metadata") or {}),
2273
+ "resume_instruction_enabled": bool(getattr(request, "ResumeInstructionEnabled", False)),
2274
+ "resume_instruction": str(getattr(request, "ResumeInstruction", "") or "").strip(),
2275
+ }
2276
+ active_runner = _resolve_active_runner()
2277
+ user_id = session.user_id or "user"
2278
+
2279
+ if request.Stream:
2280
+ resume_invocation_id = str(request.InvocationId or resume_input["resume_attempt_id"])
2281
+ resume_key = _detached_resume_key_from_input(request.SessionId, resume_input)
2282
+ _reject_if_detached_resume_active(resume_key)
2283
+ return _detached_streaming_response(
2284
+ conversation.stream_responses_conversation_turn(
2285
+ runner=active_runner,
2286
+ agent_id=request.AgentId,
2287
+ user_id=user_id,
2288
+ messages=[],
2289
+ session_id=request.SessionId,
2290
+ model=request.Model,
2291
+ model_metadata=request.ModelMetadata,
2292
+ model_options=request.ModelOptions,
2293
+ request_metadata={"responses_conversation": True},
2294
+ resume_input=resume_input,
2295
+ invocation_id=resume_invocation_id,
2296
+ prepare_runner=_prepare_runner_for_model,
2297
+ session_service_provider=resolve_session_service,
2298
+ run_mode=RUN_MODE_BACKGROUND,
2299
+ ),
2300
+ invocation_id=resume_invocation_id,
2301
+ resume_key=resume_key,
2302
+ run_mode=RUN_MODE_BACKGROUND,
2303
+ run_trigger=RUN_TRIGGER_CHECKPOINT_RESUME,
2304
+ )
2305
+
2306
+ response_id = f"resp_{uuid.uuid4().hex}"
2307
+ resolved_session_id, result = await conversation.invoke_conversation_once(
2308
+ runner=active_runner,
2309
+ agent_id=request.AgentId,
2310
+ user_id=user_id,
2311
+ messages=[],
2312
+ session_id=request.SessionId,
2313
+ model=request.Model,
2314
+ model_metadata=request.ModelMetadata,
2315
+ model_options=request.ModelOptions,
2316
+ request_metadata={"responses_conversation": True},
2317
+ resume_input=resume_input,
2318
+ response_id=response_id,
2319
+ invocation_id=str(resume_input["resume_attempt_id"]),
2320
+ prepare_runner=_prepare_runner_for_model,
2321
+ session_service_provider=resolve_session_service,
2322
+ run_mode=RUN_MODE_FOREGROUND,
2323
+ )
2324
+ payload = conversation.build_responses_payload(
2325
+ output_text=result["output_text"],
2326
+ model=request.Model,
2327
+ session_id=resolved_session_id,
2328
+ response_id=response_id,
2329
+ metadata=result.get("metadata") if isinstance(result.get("metadata"), dict) else None,
2330
+ )
2331
+ return _action_response("ResumeRun", payload)
2332
+
2333
+
2334
+ @app.get("/agentengine/api/v1/SubscribeRunEvents", include_in_schema=False)
2335
+ async def subscribe_run_events_action(
2336
+ SessionId: str = Query(...),
2337
+ InvocationId: str = Query(...),
2338
+ AfterSeqId: int = Query(0),
2339
+ ):
2340
+ session_id = str(SessionId or "").strip()
2341
+ invocation_id = str(InvocationId or "").strip()
2342
+ if not session_id or not invocation_id:
2343
+ raise HTTPException(status_code=400, detail="SessionId and InvocationId are required")
2344
+
2345
+ async def event_generator() -> AsyncIterator[str]:
2346
+ service = resolve_session_service()
2347
+ last_seq_id = int(AfterSeqId or 0)
2348
+ deadline = time.monotonic() + 5 * 60
2349
+ while True:
2350
+ # 增量查询:把 after_seq_id 下推到后端,只取 seq_id > last_seq_id 的事件,
2351
+ # 避免每轮全量拉取。invocation_id 过滤仍在 Python 侧。
2352
+ events = await service.get_events(session_id, after_seq_id=last_seq_id)
2353
+ matched_events = [
2354
+ event
2355
+ for event in events
2356
+ if event.invocation_id == invocation_id
2357
+ ]
2358
+ for event in matched_events:
2359
+ last_seq_id = max(last_seq_id, event.seq_id)
2360
+ payload = _event_to_action_payload(event)
2361
+ yield f"data: {json.dumps(payload, ensure_ascii=False)}\n\n"
2362
+ if (
2363
+ event.event_type == "run_status"
2364
+ and str((event.content or {}).get("status") or "").strip().lower()
2365
+ in _RUN_TERMINAL_STATUSES
2366
+ ):
2367
+ yield "data: [DONE]\n\n"
2368
+ return
2369
+
2370
+ # 重连兜底:本轮无新事件时,查全量确认 run 是否已有 terminal(客户端断连期间 run 已结束)。
2371
+ # 正常流式期间不触发此查询,保持增量收益。
2372
+ if not matched_events:
2373
+ all_events = await service.get_events(session_id)
2374
+ latest_status = None
2375
+ for event in all_events:
2376
+ if event.invocation_id != invocation_id or event.event_type != "run_status":
2377
+ continue
2378
+ latest_status = str((event.content or {}).get("status") or "").strip().lower()
2379
+ if latest_status in _RUN_TERMINAL_STATUSES:
2380
+ yield "data: [DONE]\n\n"
2381
+ return
2382
+ if time.monotonic() > deadline:
2383
+ return
2384
+ await asyncio.sleep(0.25)
2385
+
2386
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
2387
+
2388
+
2389
+ @app.post("/agentengine/api/v1/UploadFile")
2390
+ async def upload_file_action(file: UploadFile = File(...)):
2391
+ file_id = uuid.uuid4().hex
2392
+ data = await file.read()
2393
+ file_uri, _local_path = await AttachmentStorageService().store(
2394
+ data=data,
2395
+ file_id=file_id,
2396
+ display_name=file.filename,
2397
+ mime_type=file.content_type,
2398
+ )
2399
+
2400
+ return _action_response(
2401
+ "UploadFile",
2402
+ {
2403
+ "FileData": {
2404
+ "fileUri": file_uri,
2405
+ "displayName": file.filename or "uploaded_file",
2406
+ "mimeType": file.content_type or "application/octet-stream",
2407
+ "sizeBytes": len(data),
2408
+ }
2409
+ },
2410
+ )
2411
+
2412
+
2413
+ @app.get("/agentengine/api/v1/AttachmentContent", include_in_schema=False)
2414
+ async def attachment_content_action(FileUri: str = Query(...)):
2415
+ loaded = AttachmentStorageService().read(FileUri)
2416
+ if loaded is None:
2417
+ raise HTTPException(status_code=404, detail="Attachment not found")
2418
+
2419
+ return Response(
2420
+ content=loaded.data,
2421
+ media_type=loaded.mime_type or "application/octet-stream",
2422
+ headers={"Content-Disposition": f'inline; filename="{loaded.display_name}"'},
2423
+ )
2424
+
2425
+
2426
+ @app.post("/agentengine/api/v1/ListWorkspaceFiles")
2427
+ async def list_workspace_files_action(request: WorkspaceListActionRequest):
2428
+ response = await _workspace_runtime_request(
2429
+ "GET",
2430
+ "/_ksadk/workspace/v1/entries",
2431
+ params={
2432
+ "path": request.Path,
2433
+ "recursive": "true" if request.Recursive else "false",
2434
+ },
2435
+ )
2436
+ return _action_response("ListWorkspaceFiles", response.json())
2437
+
2438
+
2439
+ @app.post("/agentengine/api/v1/AddWorkspaceFile")
2440
+ async def upload_workspace_file_action(
2441
+ file: UploadFile = File(...),
2442
+ AgentId: Optional[str] = Form(None),
2443
+ Path: str = Form(...),
2444
+ ):
2445
+ del AgentId
2446
+ try:
2447
+ payload = await file.read()
2448
+ finally:
2449
+ await file.close()
2450
+
2451
+ file_name = file.filename or Path.rsplit("/", 1)[-1]
2452
+ response = await _workspace_runtime_request(
2453
+ "POST",
2454
+ f"/_ksadk/workspace/v1/files/{quote(Path, safe='/')}",
2455
+ files={
2456
+ "file": (
2457
+ file_name,
2458
+ payload,
2459
+ file.content_type or "application/octet-stream",
2460
+ )
2461
+ },
2462
+ )
2463
+ return _action_response("AddWorkspaceFile", response.json())
2464
+
2465
+
2466
+ @app.post("/agentengine/api/v1/DeleteWorkspaceFile")
2467
+ async def delete_workspace_file_action(request: WorkspaceDeleteActionRequest):
2468
+ response = await _workspace_runtime_request(
2469
+ "DELETE",
2470
+ f"/_ksadk/workspace/v1/files/{quote(request.Path, safe='/')}",
2471
+ )
2472
+ return _action_response("DeleteWorkspaceFile", response.json())
2473
+
2474
+
2475
+ @app.post("/agentengine/api/v1/CancelRun")
2476
+ async def cancel_run_action(request: CancelRunActionRequest):
2477
+ detached = _DETACHED_STREAMS_BY_INVOCATION.get(request.InvocationId)
2478
+ found = detached is not None
2479
+ cancel_requested = False
2480
+ if detached is not None:
2481
+ cancel_requested = detached.cancel()
2482
+ runner_cancel_status = "not_found" if found else "unsupported"
2483
+ active_runner = _resolve_active_runner()
2484
+ if active_runner is not None:
2485
+ try:
2486
+ runner_result = active_runner.request_cancel(request.InvocationId)
2487
+ if isinstance(runner_result, str) and runner_result:
2488
+ runner_cancel_status = runner_result
2489
+ elif runner_result is True:
2490
+ runner_cancel_status = "accepted"
2491
+ elif runner_result is False and not found:
2492
+ runner_cancel_status = "not_found"
2493
+ except Exception as exc:
2494
+ runner_cancel_status = "error"
2495
+ logger.warning("CancelRun failed: %s", exc)
2496
+ runner_accepted = runner_cancel_status in {"accepted", "cancelling", "cancelled"}
2497
+ status = "cancelling" if found or runner_accepted else runner_cancel_status
2498
+ return _action_response(
2499
+ "CancelRun",
2500
+ {
2501
+ "Cancelled": bool(cancel_requested or runner_accepted),
2502
+ "Found": found,
2503
+ "Status": status,
2504
+ "RunnerCancelStatus": runner_cancel_status,
2505
+ },
2506
+ )
2507
+
2508
+
2509
+ @app.get("/agentengine/api/v1/GetWorkspaceFileContent", include_in_schema=False)
2510
+ async def get_workspace_file_content_action(
2511
+ FilePath: str = Query(...),
2512
+ AgentId: Optional[str] = Query(None),
2513
+ ):
2514
+ del AgentId
2515
+ response = await _workspace_runtime_request(
2516
+ "GET",
2517
+ f"/_ksadk/workspace/v1/files/{quote(FilePath, safe='/')}",
2518
+ )
2519
+ headers = {}
2520
+ for key in ("content-disposition", "last-modified"):
2521
+ value = response.headers.get(key)
2522
+ if value:
2523
+ headers[key] = value
2524
+ return Response(
2525
+ content=response.content,
2526
+ status_code=response.status_code,
2527
+ headers=headers,
2528
+ media_type=response.headers.get("content-type"),
2529
+ )
2530
+
2531
+
2532
+ @app.get("/agentengine/api/v1/ws/{agent_id}/{file_path:path}", include_in_schema=False)
2533
+ async def workspace_file_path_route(request: Request, agent_id: str, file_path: str):
2534
+ response = await _workspace_runtime_request(
2535
+ "GET",
2536
+ f"/_ksadk/workspace/v1/files/{quote(file_path, safe='/')}",
2537
+ )
2538
+ headers = {}
2539
+ for key in ("content-disposition", "last-modified"):
2540
+ value = response.headers.get(key)
2541
+ if value:
2542
+ headers[key] = value
2543
+
2544
+ content_type = response.headers.get("content-type", "")
2545
+ is_html = "text/html" in content_type or file_path.lower().endswith((".html", ".htm"))
2546
+
2547
+ if is_html and response.status_code == 200:
2548
+ del agent_id
2549
+ base_href = build_workspace_file_base_href(file_path)
2550
+ asset_source = f"{request.url.scheme}://{request.url.netloc}{base_href}"
2551
+ html_doc = response.content.decode("utf-8", errors="replace")
2552
+ html_doc = inject_workspace_html_preview(html_doc, file_path)
2553
+ headers.pop("content-disposition", None)
2554
+ headers["Content-Security-Policy"] = build_workspace_preview_csp(asset_source)
2555
+ return Response(
2556
+ content=html_doc.encode("utf-8"),
2557
+ status_code=response.status_code,
2558
+ headers=headers,
2559
+ media_type="text/html; charset=utf-8",
2560
+ )
2561
+
2562
+ return Response(
2563
+ content=response.content,
2564
+ status_code=response.status_code,
2565
+ headers=headers,
2566
+ media_type=content_type,
2567
+ )
2568
+
2569
+
2570
+ @app.get("/agentengine/api/v1/ExportWorkspaceZip", include_in_schema=False)
2571
+ async def export_workspace_zip(
2572
+ AgentId: Optional[str] = Query(None),
2573
+ Path: str = Query("."),
2574
+ ):
2575
+ del AgentId
2576
+ dir_path = Path.strip() or "."
2577
+ response = await _workspace_runtime_request(
2578
+ "GET",
2579
+ "/_ksadk/workspace/v1/entries",
2580
+ params={"path": dir_path, "recursive": "true"},
2581
+ )
2582
+ data = response.json() if response.status_code == 200 else {}
2583
+ entries = data.get("Entries", []) if isinstance(data, dict) else []
2584
+ root = _workspace_root_dir()
2585
+ root_resolved = root.resolve()
2586
+ buf = io.BytesIO()
2587
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
2588
+ for entry in entries:
2589
+ if entry.get("Type") != "file":
2590
+ continue
2591
+ rel = entry.get("Path", "")
2592
+ if not rel:
2593
+ continue
2594
+ rel_path = PurePosixPath(rel)
2595
+ if rel_path.is_absolute() or ".." in rel_path.parts:
2596
+ continue
2597
+ target = root.joinpath(*rel_path.parts)
2598
+ if target.is_symlink():
2599
+ continue
2600
+ try:
2601
+ resolved_target = target.resolve(strict=True)
2602
+ except OSError:
2603
+ continue
2604
+ if not resolved_target.is_relative_to(root_resolved):
2605
+ continue
2606
+ if resolved_target.is_file():
2607
+ zf.writestr(rel_path.as_posix(), resolved_target.read_bytes())
2608
+ buf.seek(0)
2609
+ zip_name = f"workspace-{dir_path.replace('/', '-')}.zip" if dir_path != "." else "workspace.zip"
2610
+ return StreamingResponse(
2611
+ buf,
2612
+ media_type="application/zip",
2613
+ headers={"Content-Disposition": f'attachment; filename="{zip_name}"'},
2614
+ )
2615
+
2616
+
2617
+ def _normalize_model_catalog_items(raw_models: list[Any]) -> list[dict[str, Any]]:
2618
+ """统一模型目录 shape,并按 id 去重。
2619
+
2620
+ 这里刻意保留上游原始 dict 字段,再补 canonical metadata。
2621
+ 这样两周后模型服务扩展字段时,这一层不会再次把信息裁掉。
2622
+ """
2623
+
2624
+ normalized_by_id: dict[str, dict[str, Any]] = {}
2625
+ for raw_model in raw_models:
2626
+ item = normalize_model_metadata(raw_model)
2627
+ normalized_by_id[item["id"]] = item
2628
+ return sorted(normalized_by_id.values(), key=lambda item: item["id"])
2629
+
2630
+
2631
+ async def _build_models_payload() -> dict[str, Any]:
2632
+ import os
2633
+
2634
+ import httpx
2635
+
2636
+ api_base = os.getenv("OPENAI_BASE_URL") or os.getenv("OPENAI_API_BASE")
2637
+ api_key = os.getenv("OPENAI_API_KEY")
2638
+ current_model, source = _resolve_current_model()
2639
+
2640
+ def _fallback_catalog() -> dict[str, Any]:
2641
+ models = _normalize_model_catalog_items([current_model]) if current_model else []
2642
+ return {
2643
+ "data": models,
2644
+ "current": current_model,
2645
+ "source": source,
2646
+ }
2647
+
2648
+ if not api_base:
2649
+ return _fallback_catalog()
2650
+
2651
+ try:
2652
+ base_url = api_base.rstrip("/")
2653
+ if base_url.endswith("/v1"):
2654
+ url = f"{base_url}/models"
2655
+ else:
2656
+ url = f"{base_url}/v1/models"
2657
+
2658
+ headers = {}
2659
+ if api_key:
2660
+ headers["Authorization"] = f"Bearer {api_key}"
2661
+
2662
+ async with httpx.AsyncClient(verify=False, timeout=10) as client:
2663
+ resp = await client.get(url, headers=headers)
2664
+ resp.raise_for_status()
2665
+ data = resp.json()
2666
+
2667
+ if isinstance(data, list):
2668
+ models = _normalize_model_catalog_items(list(data))
2669
+ else:
2670
+ models = _normalize_model_catalog_items(list(data.get("data", [])))
2671
+ if current_model and all(
2672
+ str(item.get("id") or "").strip() != current_model for item in models
2673
+ ):
2674
+ models = _normalize_model_catalog_items([*models, current_model])
2675
+ return {"data": models, "current": current_model, "source": source}
2676
+ except Exception as e:
2677
+ logger.error(f"Failed to fetch models: {e}")
2678
+ fallback = _fallback_catalog()
2679
+ fallback["error"] = str(e)
2680
+ return fallback
2681
+
2682
+
2683
+ class ListAgentModelsRequest(BaseModel):
2684
+ AgentId: Optional[str] = None
2685
+ Name: Optional[str] = None
2686
+
2687
+
2688
+ @app.post("/agentengine/api/v1/ListAgentModels")
2689
+ async def list_agent_models_action(_request: ListAgentModelsRequest):
2690
+ payload = await _build_models_payload()
2691
+ return _action_response(
2692
+ "ListAgentModels",
2693
+ {
2694
+ "Models": payload.get("data", []),
2695
+ "Current": payload.get("current"),
2696
+ "Source": payload.get("source", ""),
2697
+ },
2698
+ )
2699
+
2700
+
2701
+ @app.get("/v1/models")
2702
+ async def list_openai_models():
2703
+ """Expose the current model catalog through the OpenAI-compatible path."""
2704
+
2705
+ payload = await _build_models_payload()
2706
+ return {
2707
+ "object": "list",
2708
+ "data": payload.get("data", []),
2709
+ "current": payload.get("current"),
2710
+ "source": payload.get("source", ""),
2711
+ }
2712
+
2713
+
2714
+ @app.post("/agentengine/api/v1/RunAgent")
2715
+ async def run_agent_action(request: RunAgentActionRequest):
2716
+ api_format = (request.ApiFormat or "responses").strip().lower()
2717
+ run_user_id = _clean_optional_string(request.UserId) or "user"
2718
+ account_id = _clean_optional_string(request.AccountId)
2719
+ service = resolve_session_service()
2720
+ resume_input = (
2721
+ conversation.extract_responses_resume_input(request.ResponsesInput)
2722
+ if request.ResponsesInput is not None
2723
+ else None
2724
+ )
2725
+ resume_input = await _resolve_checkpoint_resume_input_from_session(
2726
+ service=service,
2727
+ agent_id=request.AgentId,
2728
+ session_id=request.SessionId,
2729
+ resume_input=resume_input,
2730
+ )
2731
+ if resume_input is not None:
2732
+ messages = []
2733
+ elif request.ResponsesInput is not None and api_format == "responses":
2734
+ messages = conversation.normalize_responses_input(request.ResponsesInput)
2735
+ else:
2736
+ messages = conversation.normalize_kop_messages(request.Messages)
2737
+ request_metadata = (
2738
+ {"previous_response_id": request.PreviousResponseId} if request.PreviousResponseId else {}
2739
+ )
2740
+ if api_format == "responses":
2741
+ request_metadata["responses_conversation"] = True
2742
+
2743
+ if request.Background:
2744
+ invocation_id = request.InvocationId or f"inv_{uuid.uuid4().hex}"
2745
+ # 后台 stream 在 detached task 里才被消费(lazy),此时 session 尚未创建。
2746
+ # 先 ensure 出 session,才能立刻写 run_status=in_progress(供 SubscribeRunEvents
2747
+ # 拉到起始态),并把 resolved session_id 回填给 detached stream 的终态写入与 SubscribeUrl。
2748
+ background_session = await conversation.ensure_conversation_session(
2749
+ agent_id=request.AgentId,
2750
+ user_id=run_user_id,
2751
+ session_id=request.SessionId,
2752
+ session_service_provider=resolve_session_service,
2753
+ )
2754
+ resolved_background_session_id = background_session.id
2755
+ if resume_input is None:
2756
+ await conversation.prime_session_metadata_for_user_turn(
2757
+ service=service,
2758
+ session=background_session,
2759
+ messages=messages,
2760
+ )
2761
+ await conversation.append_run_status_event(
2762
+ session_id=resolved_background_session_id,
2763
+ author=_resolve_active_runner().detection_result.name,
2764
+ status="in_progress",
2765
+ invocation_id=invocation_id,
2766
+ session_service_provider=resolve_session_service,
2767
+ run_mode=RUN_MODE_BACKGROUND,
2768
+ run_trigger=trigger_from_resume_input(resume_input),
2769
+ )
2770
+ resume_key = _detached_resume_key_from_input(resolved_background_session_id, resume_input)
2771
+ _reject_if_detached_resume_active(resume_key)
2772
+ detached = _DetachedSSEStream(
2773
+ conversation.stream_responses_conversation_turn(
2774
+ runner=_resolve_active_runner(),
2775
+ agent_id=request.AgentId,
2776
+ user_id=run_user_id,
2777
+ messages=messages,
2778
+ session_id=resolved_background_session_id,
2779
+ model=request.Model,
2780
+ model_metadata=request.ModelMetadata,
2781
+ model_options=request.ModelOptions,
2782
+ request_metadata=request_metadata or None,
2783
+ resume_input=resume_input,
2784
+ account_id=account_id,
2785
+ invocation_id=invocation_id,
2786
+ prepare_runner=_prepare_runner_for_model,
2787
+ session_service_provider=resolve_session_service,
2788
+ run_mode=RUN_MODE_BACKGROUND,
2789
+ ),
2790
+ invocation_id=invocation_id,
2791
+ session_id=resolved_background_session_id,
2792
+ run_mode=RUN_MODE_BACKGROUND,
2793
+ run_trigger=trigger_from_resume_input(resume_input),
2794
+ )
2795
+ if invocation_id and resume_key:
2796
+ _DETACHED_RESUME_KEYS_BY_INVOCATION[invocation_id] = resume_key
2797
+ _ACTIVE_DETACHED_RESUME_INVOCATION_BY_KEY[resume_key] = invocation_id
2798
+ detached._task.add_done_callback(
2799
+ lambda _t, inv=invocation_id, rk=resume_key: _clear_detached_resume_key(inv, rk)
2800
+ )
2801
+ return _action_response(
2802
+ "RunAgent",
2803
+ {
2804
+ "SessionId": resolved_background_session_id,
2805
+ "InvocationId": invocation_id,
2806
+ "Status": "running",
2807
+ "Background": True,
2808
+ "SubscribeUrl": (
2809
+ "/agentengine/api/v1/SubscribeRunEvents"
2810
+ f"?SessionId={resolved_background_session_id}"
2811
+ f"&InvocationId={invocation_id}"
2812
+ ),
2813
+ },
2814
+ )
2815
+
2816
+ if request.Stream:
2817
+ if api_format == "chat_completions":
2818
+ completion_request = ChatCompletionRequest(
2819
+ messages=messages,
2820
+ model=request.Model,
2821
+ model_metadata=request.ModelMetadata,
2822
+ model_options=request.ModelOptions,
2823
+ stream=True,
2824
+ session_id=request.SessionId,
2825
+ user=run_user_id,
2826
+ account_id=account_id,
2827
+ )
2828
+ return await chat_completions(completion_request)
2829
+ resume_key = _detached_resume_key_from_input(request.SessionId, resume_input)
2830
+ _reject_if_detached_resume_active(resume_key)
2831
+ return _detached_streaming_response(
2832
+ conversation.stream_responses_conversation_turn(
2833
+ runner=_resolve_active_runner(),
2834
+ agent_id=request.AgentId,
2835
+ user_id=run_user_id,
2836
+ messages=messages,
2837
+ session_id=request.SessionId,
2838
+ model=request.Model,
2839
+ model_metadata=request.ModelMetadata,
2840
+ model_options=request.ModelOptions,
2841
+ request_metadata=request_metadata or None,
2842
+ resume_input=resume_input,
2843
+ account_id=account_id,
2844
+ invocation_id=request.InvocationId,
2845
+ prepare_runner=_prepare_runner_for_model,
2846
+ session_service_provider=resolve_session_service,
2847
+ run_mode=RUN_MODE_FOREGROUND,
2848
+ ),
2849
+ invocation_id=request.InvocationId,
2850
+ resume_key=resume_key,
2851
+ run_mode=RUN_MODE_FOREGROUND,
2852
+ run_trigger=trigger_from_resume_input(resume_input),
2853
+ )
2854
+
2855
+ responses_response_id = f"resp_{uuid.uuid4().hex}" if api_format != "chat_completions" else None
2856
+ resolved_session_id, result = await conversation.invoke_conversation_once(
2857
+ runner=_resolve_active_runner(),
2858
+ agent_id=request.AgentId,
2859
+ user_id=run_user_id,
2860
+ messages=messages,
2861
+ session_id=request.SessionId,
2862
+ model=request.Model,
2863
+ model_metadata=request.ModelMetadata,
2864
+ model_options=request.ModelOptions,
2865
+ request_metadata=request_metadata or None,
2866
+ resume_input=resume_input,
2867
+ response_id=responses_response_id,
2868
+ account_id=account_id,
2869
+ invocation_id=request.InvocationId,
2870
+ prepare_runner=_prepare_runner_for_model,
2871
+ session_service_provider=resolve_session_service,
2872
+ run_mode=RUN_MODE_FOREGROUND,
2873
+ )
2874
+ output_text = result["output_text"]
2875
+ if api_format == "chat_completions":
2876
+ payload = conversation.build_chat_completions_payload(
2877
+ output_text=output_text,
2878
+ model=request.Model,
2879
+ session_id=resolved_session_id,
2880
+ metadata=result.get("metadata"),
2881
+ )
2882
+ else:
2883
+ payload = conversation.build_responses_payload(
2884
+ output_text=output_text,
2885
+ model=request.Model,
2886
+ session_id=resolved_session_id,
2887
+ response_id=responses_response_id,
2888
+ metadata=result.get("metadata")
2889
+ if isinstance(result.get("metadata"), Mapping)
2890
+ else None,
2891
+ )
2892
+ return _action_response("RunAgent", payload)
2893
+
2894
+
2895
+ # ============================================================
2896
+ # Session Management API (ADK Web Compatible)
2897
+ # ============================================================
2898
+
2899
+
2900
+ @app.post("/apps/{app_name}/users/{user_id}/sessions")
2901
+ async def create_session(app_name: str, user_id: str, request: Request):
2902
+ """Create a new session"""
2903
+ # Check if importing existing events
2904
+ body = {}
2905
+ try:
2906
+ body = await request.json()
2907
+ except Exception:
2908
+ pass
2909
+
2910
+ service = resolve_session_service()
2911
+ session = await _ensure_session(app_name, user_id, body.get("sessionId") or body.get("id"))
2912
+
2913
+ for raw_event in body.get("events", []):
2914
+ session_event = SessionEvent.from_dict(raw_event, session_id=session.id)
2915
+ await service.append_event(session.id, session_event)
2916
+
2917
+ hydrated = await _hydrate_session(await service.get_session(session.id))
2918
+ return hydrated.to_legacy_dict() if hydrated else session.to_legacy_dict()
2919
+
2920
+
2921
+ @app.get("/apps/{app_name}/users/{user_id}/sessions")
2922
+ async def list_sessions(app_name: str, user_id: str):
2923
+ """List all sessions for a user"""
2924
+ service = resolve_session_service()
2925
+ sessions = await service.list_sessions(app_name, user_id)
2926
+ hydrated: List[Dict[str, Any]] = []
2927
+ for session in sessions:
2928
+ session.events = await service.get_events(session.id)
2929
+ hydrated.append(session.to_legacy_dict())
2930
+ return hydrated
2931
+
2932
+
2933
+ @app.get("/apps/{app_name}/users/{user_id}/sessions/{session_id}")
2934
+ async def get_session(app_name: str, user_id: str, session_id: str):
2935
+ """Get a specific session with its events"""
2936
+ service = resolve_session_service()
2937
+ session = await _hydrate_session(await service.get_session(session_id))
2938
+ if not session:
2939
+ raise HTTPException(status_code=404, detail="Session not found")
2940
+ return session.to_legacy_dict()
2941
+
2942
+
2943
+ @app.delete("/apps/{app_name}/users/{user_id}/sessions/{session_id}")
2944
+ async def delete_session(app_name: str, user_id: str, session_id: str):
2945
+ """Delete a session"""
2946
+ service = resolve_session_service()
2947
+ if await service.delete_session(session_id):
2948
+ return {"status": "deleted"}
2949
+ raise HTTPException(status_code=404, detail="Session not found")
2950
+
2951
+
2952
+ # ============================================================
2953
+ # Memory API - Save session to long-term memory
2954
+ # ============================================================
2955
+
2956
+
2957
+ @app.post("/apps/{app_name}/users/{user_id}/sessions/{session_id}/save_memory")
2958
+ async def save_session_to_memory(app_name: str, user_id: str, session_id: str):
2959
+ """将指定 session 保存到长期记忆
2960
+
2961
+ 当配置了 KSADK_LTM_BACKEND 时,将 session 中的用户消息
2962
+ 持久化到长期记忆后端,供后续 session 通过 load_memory 工具检索。
2963
+ """
2964
+ active_runner = _ensure_runner_loaded()
2965
+
2966
+ # 检查 runner 是否支持长期记忆
2967
+ from ksadk.runners.adk_runner import ADKRunner as _ADKRunner
2968
+
2969
+ if not isinstance(active_runner, _ADKRunner):
2970
+ raise HTTPException(
2971
+ status_code=400, detail="Long-term memory is only supported with ADK runner"
2972
+ )
2973
+
2974
+ if not active_runner._long_term_memory:
2975
+ raise HTTPException(
2976
+ status_code=400,
2977
+ detail="Long-term memory not configured. Set KSADK_LTM_BACKEND environment variable.",
2978
+ )
2979
+
2980
+ # 查找 ADK 内部 session ID
2981
+ internal_session_id = active_runner._session_map.get(session_id, session_id)
2982
+
2983
+ success = await active_runner.save_session_to_long_term_memory(
2984
+ session_id=internal_session_id,
2985
+ user_id=user_id,
2986
+ )
2987
+
2988
+ if success:
2989
+ return {"status": "saved", "session_id": session_id}
2990
+ else:
2991
+ raise HTTPException(status_code=500, detail="Failed to save session to long-term memory")
2992
+
2993
+
2994
+ # ============================================================
2995
+ # Run SSE - Core Agent Execution Endpoint
2996
+ # ============================================================
2997
+
2998
+
2999
+ @app.post("/run_sse")
3000
+ async def run_sse(request: AgentRunRequest):
3001
+ """Unified Streaming Endpoint compatible with ADK Web
3002
+
3003
+ Respects the `streaming` parameter:
3004
+ - streaming=False: Accumulate full response, send as single event
3005
+ - streaming=True: Stream tokens as they arrive (real-time)
3006
+ """
3007
+ active_runner = _ensure_runner_loaded()
3008
+ _prepare_runner_for_model(active_runner, request.model)
3009
+ use_streaming = request.streaming
3010
+ normalized_message = conversation.normalize_parts_content(
3011
+ request.newMessage.parts if request.newMessage else []
3012
+ )
3013
+ user_message = {
3014
+ "role": "user",
3015
+ "content": str(normalized_message.get("content") or ""),
3016
+ "display_content": str(normalized_message.get("display_content") or ""),
3017
+ "parts": list(normalized_message.get("parts") or []),
3018
+ "attachments": list(normalized_message.get("attachments") or []),
3019
+ "attachment_results": list(normalized_message.get("attachment_results") or []),
3020
+ }
3021
+
3022
+ model_version = "models/gemini-pro" if "gemini" in request.appName.lower() else "models/unknown"
3023
+ prepared_non_stream: conversation.PreparedConversationTurn | None = None
3024
+ if request.sessionId:
3025
+ await conversation.ensure_conversation_session(
3026
+ agent_id=request.appName,
3027
+ user_id=request.userId,
3028
+ session_id=request.sessionId,
3029
+ session_service_provider=resolve_session_service,
3030
+ )
3031
+ if not use_streaming:
3032
+ prepared_non_stream = await conversation.build_run_input(
3033
+ agent_id=request.appName,
3034
+ user_id=request.userId,
3035
+ session_id=request.sessionId,
3036
+ messages=[user_message],
3037
+ state_delta=request.stateDelta or {},
3038
+ invocation_id=request.invocationId,
3039
+ session_service_provider=resolve_session_service,
3040
+ )
3041
+ await conversation.append_run_status_event(
3042
+ session_id=prepared_non_stream.session_id,
3043
+ author=active_runner.detection_result.name,
3044
+ status="in_progress",
3045
+ invocation_id=prepared_non_stream.invocation_id,
3046
+ session_service_provider=resolve_session_service,
3047
+ run_mode=RUN_MODE_FOREGROUND,
3048
+ run_trigger=RUN_TRIGGER_NEW_RUN,
3049
+ )
3050
+
3051
+ async def event_generator():
3052
+ if not use_streaming:
3053
+ try:
3054
+ assert prepared_non_stream is not None
3055
+ session_id = prepared_non_stream.session_id
3056
+ user_input = prepared_non_stream.user_input
3057
+ attachments = prepared_non_stream.attachments
3058
+ attachment_results = prepared_non_stream.attachment_results
3059
+ current_attachments = prepared_non_stream.current_attachments
3060
+ current_attachment_results = prepared_non_stream.current_attachment_results
3061
+ input_content = prepared_non_stream.input_content
3062
+ input_messages = prepared_non_stream.input_messages
3063
+ user_parts = prepared_non_stream.user_parts
3064
+ history = prepared_non_stream.history
3065
+ invocation_id = prepared_non_stream.invocation_id
3066
+ common_metadata = {
3067
+ "modelVersion": model_version,
3068
+ "usageMetadata": {
3069
+ "promptTokenCount": len(user_input),
3070
+ "candidatesTokenCount": 0,
3071
+ "totalTokenCount": len(user_input),
3072
+ },
3073
+ }
3074
+ input_data = {
3075
+ "session_id": session_id,
3076
+ "input": user_input,
3077
+ "history": history,
3078
+ "input_content": list(input_content),
3079
+ "input_messages": list(input_messages),
3080
+ "input_parts": list(user_parts),
3081
+ "attachments": attachments,
3082
+ "attachment_results": attachment_results,
3083
+ "current_attachments": current_attachments,
3084
+ "current_attachment_results": current_attachment_results,
3085
+ "has_current_files": prepared_non_stream.has_current_files,
3086
+ "model": request.model,
3087
+ }
3088
+ result = await active_runner.invoke(input_data)
3089
+ final_text = result.get("output", "")
3090
+ response_event = {
3091
+ "id": str(uuid.uuid4()),
3092
+ "author": active_runner.detection_result.name,
3093
+ "sessionId": session_id,
3094
+ "invocationId": invocation_id,
3095
+ "content": {"role": "model", "parts": [{"text": final_text}]},
3096
+ "actions": {"finishReason": "STOP"},
3097
+ "modelVersion": common_metadata["modelVersion"],
3098
+ "usageMetadata": {
3099
+ "promptTokenCount": len(user_input),
3100
+ "candidatesTokenCount": len(final_text),
3101
+ "totalTokenCount": len(user_input) + len(final_text),
3102
+ },
3103
+ "timestamp": int(time.time() * 1000),
3104
+ }
3105
+ yield f"data: {json.dumps(response_event, ensure_ascii=False)}\n\n"
3106
+ if final_text:
3107
+ await conversation.append_conversation_event(
3108
+ session_id=session_id,
3109
+ author=active_runner.detection_result.name,
3110
+ role="model",
3111
+ text=final_text,
3112
+ invocation_id=invocation_id,
3113
+ event_type="assistant_message",
3114
+ session_service_provider=resolve_session_service,
3115
+ )
3116
+ await conversation.append_run_status_event(
3117
+ session_id=session_id,
3118
+ author=active_runner.detection_result.name,
3119
+ status="completed",
3120
+ invocation_id=invocation_id,
3121
+ session_service_provider=resolve_session_service,
3122
+ run_mode=RUN_MODE_FOREGROUND,
3123
+ run_trigger=RUN_TRIGGER_NEW_RUN,
3124
+ )
3125
+
3126
+ except Exception as e:
3127
+ logger.error(f"Error in invoke: {e}")
3128
+ await conversation.append_run_status_event(
3129
+ session_id=session_id,
3130
+ author=active_runner.detection_result.name,
3131
+ status="failed",
3132
+ invocation_id=invocation_id,
3133
+ detail=str(e),
3134
+ session_service_provider=resolve_session_service,
3135
+ run_mode=RUN_MODE_FOREGROUND,
3136
+ run_trigger=RUN_TRIGGER_NEW_RUN,
3137
+ )
3138
+ error_event = {
3139
+ "id": str(uuid.uuid4()),
3140
+ "sessionId": session_id,
3141
+ "invocationId": invocation_id,
3142
+ "error": str(e),
3143
+ "errorMessage": str(e),
3144
+ "timestamp": int(time.time() * 1000),
3145
+ }
3146
+ yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n"
3147
+ else:
3148
+ try:
3149
+ compaction_preview = await conversation.preview_auto_compaction(
3150
+ agent_id=request.appName,
3151
+ user_id=request.userId,
3152
+ session_id=request.sessionId,
3153
+ messages=[user_message],
3154
+ session_service_provider=resolve_session_service,
3155
+ )
3156
+ if compaction_preview.should_compact:
3157
+ yield conversation.build_compaction_sse_event(
3158
+ phase="start",
3159
+ trigger="auto",
3160
+ total_chars=compaction_preview.total_chars,
3161
+ group_count=compaction_preview.group_count,
3162
+ )
3163
+
3164
+ prepared = await conversation.build_run_input(
3165
+ agent_id=request.appName,
3166
+ user_id=request.userId,
3167
+ session_id=request.sessionId,
3168
+ messages=[user_message],
3169
+ state_delta=request.stateDelta or {},
3170
+ invocation_id=request.invocationId,
3171
+ session_service_provider=resolve_session_service,
3172
+ )
3173
+ if prepared.compaction_triggered:
3174
+ yield conversation.build_compaction_sse_event(
3175
+ phase="done",
3176
+ trigger=str(prepared.compaction_trigger or "auto"),
3177
+ compacted_until_seq_id=prepared.compacted_until_seq_id,
3178
+ total_chars=compaction_preview.total_chars
3179
+ if compaction_preview.should_compact
3180
+ else None,
3181
+ group_count=compaction_preview.group_count
3182
+ if compaction_preview.should_compact
3183
+ else None,
3184
+ )
3185
+
3186
+ session_id = prepared.session_id
3187
+ user_input = prepared.user_input
3188
+ attachments = prepared.attachments
3189
+ attachment_results = prepared.attachment_results
3190
+ current_attachments = prepared.current_attachments
3191
+ current_attachment_results = prepared.current_attachment_results
3192
+ input_content = prepared.input_content
3193
+ input_messages = prepared.input_messages
3194
+ user_parts = prepared.user_parts
3195
+ history = prepared.history
3196
+ invocation_id = prepared.invocation_id
3197
+ common_metadata = {
3198
+ "modelVersion": model_version,
3199
+ "usageMetadata": {
3200
+ "promptTokenCount": len(user_input),
3201
+ "candidatesTokenCount": 0,
3202
+ "totalTokenCount": len(user_input),
3203
+ },
3204
+ }
3205
+ await conversation.append_run_status_event(
3206
+ session_id=session_id,
3207
+ author=active_runner.detection_result.name,
3208
+ status="in_progress",
3209
+ invocation_id=invocation_id,
3210
+ session_service_provider=resolve_session_service,
3211
+ run_mode=RUN_MODE_FOREGROUND,
3212
+ run_trigger=RUN_TRIGGER_NEW_RUN,
3213
+ )
3214
+
3215
+ client_visible_text = ""
3216
+ authoritative_text = ""
3217
+ responses_output: list[Any] = []
3218
+ responses_response_id: str | None = None
3219
+ stream_iter = active_runner.stream(
3220
+ {
3221
+ "session_id": session_id,
3222
+ "input": user_input,
3223
+ "history": history,
3224
+ "input_content": list(input_content),
3225
+ "input_messages": list(input_messages),
3226
+ "input_parts": list(user_parts),
3227
+ "attachments": attachments,
3228
+ "attachment_results": attachment_results,
3229
+ "current_attachments": current_attachments,
3230
+ "current_attachment_results": current_attachment_results,
3231
+ "has_current_files": prepared.has_current_files,
3232
+ "model": request.model,
3233
+ }
3234
+ )
3235
+ while True:
3236
+ try:
3237
+ chunk = await asyncio.wait_for(stream_iter.__anext__(), timeout=15)
3238
+ except StopAsyncIteration:
3239
+ break
3240
+ except asyncio.TimeoutError:
3241
+ yield ": ping\n\n"
3242
+ continue
3243
+ event_id = str(uuid.uuid4())
3244
+ if chunk.get("type") == "responses_output":
3245
+ raw_output = chunk.get("output")
3246
+ responses_output = raw_output if isinstance(raw_output, list) else []
3247
+ raw_response_id = chunk.get("response_id")
3248
+ responses_response_id = (
3249
+ str(raw_response_id) if raw_response_id else responses_response_id
3250
+ )
3251
+ continue
3252
+ if chunk.get("type") == "thinking":
3253
+ delta = str(chunk.get("delta", ""))
3254
+ if delta:
3255
+ await conversation.append_reasoning_event(
3256
+ session_id=session_id,
3257
+ author=active_runner.detection_result.name,
3258
+ text=delta,
3259
+ invocation_id=invocation_id,
3260
+ session_service_provider=resolve_session_service,
3261
+ )
3262
+ yield (
3263
+ "event: response.reasoning.delta\n"
3264
+ f"data: {json.dumps({'delta': delta}, ensure_ascii=False)}\n\n"
3265
+ )
3266
+ continue
3267
+ if chunk.get("type") == "text":
3268
+ delta_text = chunk.get("delta", "")
3269
+ client_visible_text += delta_text
3270
+ authoritative_text = client_visible_text
3271
+ response_event = {
3272
+ "id": event_id,
3273
+ "author": chunk.get("node", active_runner.detection_result.name),
3274
+ "sessionId": session_id,
3275
+ "invocationId": invocation_id,
3276
+ "content": {"role": "model", "parts": [{"text": delta_text}]},
3277
+ "partial": True,
3278
+ "timestamp": int(time.time() * 1000),
3279
+ }
3280
+ yield f"data: {json.dumps(response_event, ensure_ascii=False)}\n\n"
3281
+ continue
3282
+ if chunk.get("type") == "tool_call":
3283
+ yield (
3284
+ "event: response.tool_call\n"
3285
+ "data: "
3286
+ + json.dumps(
3287
+ {
3288
+ "name": chunk.get("tool_name"),
3289
+ "args": chunk.get("tool_args", {}),
3290
+ },
3291
+ ensure_ascii=False,
3292
+ )
3293
+ + "\n\n"
3294
+ )
3295
+ tool_event = {
3296
+ "id": event_id,
3297
+ "author": chunk.get("node", "tool"),
3298
+ "sessionId": session_id,
3299
+ "invocationId": invocation_id,
3300
+ "content": {
3301
+ "role": "model",
3302
+ "parts": [
3303
+ {
3304
+ "functionCall": {
3305
+ "name": chunk.get("tool_name", "unknown"),
3306
+ "args": chunk.get("tool_args", {}),
3307
+ }
3308
+ }
3309
+ ],
3310
+ },
3311
+ "actions": {
3312
+ "finishReason": "STOP",
3313
+ "stateDelta": {},
3314
+ },
3315
+ "modelVersion": common_metadata["modelVersion"],
3316
+ "timestamp": int(time.time() * 1000),
3317
+ }
3318
+ yield f"data: {json.dumps(tool_event, ensure_ascii=False)}\n\n"
3319
+ await conversation.append_conversation_event(
3320
+ session_id=session_id,
3321
+ author=chunk.get("node", "tool"),
3322
+ role="model",
3323
+ text="",
3324
+ invocation_id=invocation_id,
3325
+ event_type="tool_call",
3326
+ session_service_provider=resolve_session_service,
3327
+ metadata={
3328
+ "tool_name": chunk.get("tool_name", "unknown"),
3329
+ "tool_args": chunk.get("tool_args", {}),
3330
+ },
3331
+ )
3332
+ continue
3333
+ if chunk.get("type") == "tool_result":
3334
+ await conversation.append_conversation_event(
3335
+ session_id=session_id,
3336
+ author=active_runner.detection_result.name,
3337
+ role="user",
3338
+ text=str(chunk.get("tool_output", "")),
3339
+ invocation_id=invocation_id,
3340
+ event_type="tool_result",
3341
+ session_service_provider=resolve_session_service,
3342
+ metadata={
3343
+ "tool_name": chunk.get("tool_name"),
3344
+ "tool_output": chunk.get("tool_output", {}),
3345
+ },
3346
+ )
3347
+ yield (
3348
+ "event: response.tool_result\n"
3349
+ "data: "
3350
+ + json.dumps(
3351
+ {
3352
+ "name": chunk.get("tool_name"),
3353
+ "output": chunk.get("tool_output", {}),
3354
+ },
3355
+ ensure_ascii=False,
3356
+ )
3357
+ + "\n\n"
3358
+ )
3359
+ continue
3360
+ if chunk.get("type") == "interrupt":
3361
+ await conversation.append_conversation_event(
3362
+ session_id=session_id,
3363
+ author=active_runner.detection_result.name,
3364
+ role="model",
3365
+ text="approval requested",
3366
+ invocation_id=invocation_id,
3367
+ event_type="approval_request",
3368
+ session_service_provider=resolve_session_service,
3369
+ metadata={"interrupt_info": chunk.get("interrupt_info")},
3370
+ )
3371
+ yield (
3372
+ "event: response.approval_request\n"
3373
+ "data: "
3374
+ + json.dumps(
3375
+ {"interrupt_info": chunk.get("interrupt_info")},
3376
+ ensure_ascii=False,
3377
+ )
3378
+ + "\n\n"
3379
+ )
3380
+ continue
3381
+ if chunk.get("type") == "final":
3382
+ final_text = chunk.get("output", "")
3383
+ if not final_text:
3384
+ continue
3385
+ authoritative_text = final_text
3386
+ if final_text != client_visible_text:
3387
+ final_event = {
3388
+ "id": event_id,
3389
+ "author": active_runner.detection_result.name,
3390
+ "sessionId": session_id,
3391
+ "invocationId": invocation_id,
3392
+ "content": {"role": "model", "parts": [{"text": final_text}]},
3393
+ "actions": {"finishReason": "STOP"},
3394
+ "modelVersion": common_metadata["modelVersion"],
3395
+ "usageMetadata": {
3396
+ "promptTokenCount": len(user_input),
3397
+ "candidatesTokenCount": len(final_text),
3398
+ "totalTokenCount": len(user_input) + len(final_text),
3399
+ },
3400
+ "timestamp": int(time.time() * 1000),
3401
+ }
3402
+ yield f"data: {json.dumps(final_event, ensure_ascii=False)}\n\n"
3403
+ client_visible_text = final_text
3404
+
3405
+ if authoritative_text:
3406
+ await conversation.append_conversation_event(
3407
+ session_id=session_id,
3408
+ author=active_runner.detection_result.name,
3409
+ role="model",
3410
+ text=authoritative_text,
3411
+ invocation_id=invocation_id,
3412
+ event_type="assistant_message",
3413
+ metadata={
3414
+ **({"responses_output": responses_output} if responses_output else {}),
3415
+ **(
3416
+ {"response_id": responses_response_id}
3417
+ if responses_response_id
3418
+ else {}
3419
+ ),
3420
+ },
3421
+ session_service_provider=resolve_session_service,
3422
+ )
3423
+ await conversation.append_run_status_event(
3424
+ session_id=session_id,
3425
+ author=active_runner.detection_result.name,
3426
+ status="completed",
3427
+ invocation_id=invocation_id,
3428
+ session_service_provider=resolve_session_service,
3429
+ run_mode=RUN_MODE_FOREGROUND,
3430
+ run_trigger=RUN_TRIGGER_NEW_RUN,
3431
+ )
3432
+
3433
+ except Exception as e:
3434
+ logger.error(f"Error in stream: {e}")
3435
+ await conversation.append_run_status_event(
3436
+ session_id=session_id,
3437
+ author=active_runner.detection_result.name,
3438
+ status="failed",
3439
+ invocation_id=invocation_id,
3440
+ detail=str(e),
3441
+ session_service_provider=resolve_session_service,
3442
+ run_mode=RUN_MODE_FOREGROUND,
3443
+ run_trigger=RUN_TRIGGER_NEW_RUN,
3444
+ )
3445
+ error_event = {
3446
+ "id": str(uuid.uuid4()),
3447
+ "sessionId": session_id,
3448
+ "invocationId": invocation_id,
3449
+ "error": str(e),
3450
+ "errorMessage": str(e),
3451
+ "timestamp": int(time.time() * 1000),
3452
+ }
3453
+ yield f"data: {json.dumps(error_event, ensure_ascii=False)}\n\n"
3454
+
3455
+ return StreamingResponse(event_generator(), media_type="text/event-stream")
3456
+
3457
+
3458
+ # ============================================================
3459
+ # Trace / Debug API (ADK Web Compatible)
3460
+ # ============================================================
3461
+
3462
+
3463
+ @app.get("/debug/trace/session/{session_id}")
3464
+ async def get_session_trace(session_id: str):
3465
+ """Get traces for a session - returns array of Span objects"""
3466
+ exporter = get_memory_exporter()
3467
+ if not exporter:
3468
+ return [] # Return empty array, not object
3469
+
3470
+ # Get all spans and transform to ADK-Web expected format
3471
+ raw_spans = exporter.get_finished_spans()
3472
+
3473
+ # Get session events for invocation mapping
3474
+ service = resolve_session_service()
3475
+ events = await service.get_events(session_id)
3476
+
3477
+ # Build invocation ID mapping from session events
3478
+ invocation_ids = {}
3479
+ for event in events:
3480
+ if event.id and event.invocation_id:
3481
+ invocation_ids[event.id] = event.invocation_id
3482
+
3483
+ # Transform spans to ADK-Web format
3484
+ spans = []
3485
+ for span in raw_spans:
3486
+ # Use session_id as trace_id for grouping
3487
+ trace_id = span.get("trace_id", session_id)
3488
+
3489
+ # Get or create invocation_id
3490
+ invocation_id = span.get("attributes", {}).get("gcp.vertex.agent.invocation_id")
3491
+ if not invocation_id:
3492
+ # Try to derive from event association
3493
+ invocation_id = trace_id[:36] if len(trace_id) >= 36 else trace_id
3494
+
3495
+ # Build attributes with required ADK fields
3496
+ attrs = span.get("attributes", {}).copy()
3497
+ attrs["gcp.vertex.agent.invocation_id"] = invocation_id
3498
+
3499
+ # If this is a LLM span, add request/response
3500
+ if "llm" in span.get("name", "").lower() or "invoke" in span.get("name", "").lower():
3501
+ if "user.input" in attrs:
3502
+ attrs["gcp.vertex.agent.llm_request"] = json.dumps(
3503
+ {
3504
+ "contents": [
3505
+ {"role": "user", "parts": [{"text": attrs.get("user.input", "")}]}
3506
+ ]
3507
+ }
3508
+ )
3509
+ if "agent.output" in attrs:
3510
+ attrs["gcp.vertex.agent.llm_response"] = json.dumps(
3511
+ {
3512
+ "candidates": [
3513
+ {
3514
+ "content": {
3515
+ "role": "model",
3516
+ "parts": [{"text": attrs.get("agent.output", "")}],
3517
+ }
3518
+ }
3519
+ ]
3520
+ }
3521
+ )
3522
+
3523
+ formatted_span = {
3524
+ "trace_id": trace_id,
3525
+ "span_id": span.get("span_id", str(uuid.uuid4())[:16]),
3526
+ "parent_span_id": span.get("parent_span_id"),
3527
+ "name": span.get("name", "unknown"),
3528
+ "start_time": span.get("start_time", 0),
3529
+ "end_time": span.get("end_time", 0),
3530
+ "attributes": attrs,
3531
+ "status": span.get("status", {}),
3532
+ }
3533
+ spans.append(formatted_span)
3534
+
3535
+ return spans # Return array directly
3536
+
3537
+
3538
+ @app.get("/debug/trace/{event_id}")
3539
+ async def get_event_trace(event_id: str):
3540
+ """Get trace for a specific event - returns array of Span objects"""
3541
+ exporter = get_memory_exporter()
3542
+ if not exporter:
3543
+ return []
3544
+
3545
+ spans = exporter.get_finished_spans()
3546
+ # Filter by event_id or return recent spans
3547
+ filtered = [s for s in spans if s.get("attributes", {}).get("event_id") == event_id]
3548
+ return filtered if filtered else spans[-10:]
3549
+
3550
+
3551
+ @app.get("/apps/{app_name}/users/{user_id}/sessions/{session_id}/events/{event_id}/graph")
3552
+ async def get_event_graph(app_name: str, user_id: str, session_id: str, event_id: str):
3553
+ """Get event graph (DOT format) - placeholder"""
3554
+ return {"dotSrc": None}
3555
+
3556
+
3557
+ # ============================================================
3558
+ # OpenAI Compatible API
3559
+ # ============================================================
3560
+
3561
+
3562
+ class ChatCompletionRequest(BaseModel):
3563
+ messages: List[Dict[str, Any]]
3564
+ model: Optional[str] = None
3565
+ model_metadata: Optional[Dict[str, Any]] = None
3566
+ model_options: Optional[Dict[str, Any]] = None
3567
+ stream: bool = False
3568
+ session_id: Optional[str] = None
3569
+ user: Optional[str] = None
3570
+ account_id: Optional[str] = None
3571
+ temperature: Optional[float] = 0.7
3572
+ max_tokens: Optional[int] = None
3573
+
3574
+
3575
+ @app.post("/v1/responses")
3576
+ async def responses(request: ResponsesRequest):
3577
+ """OpenAI Responses 兼容接口。"""
3578
+ active_runner = _resolve_active_runner()
3579
+ resolved_session_id, resolved_user_id = _resolve_responses_session_and_user(request)
3580
+ agent_id = _runtime_agent_id(active_runner)
3581
+
3582
+ resume_input = conversation.extract_responses_resume_input(request.input)
3583
+ resume_input = await _resolve_checkpoint_resume_input_from_session(
3584
+ service=resolve_session_service(),
3585
+ agent_id=agent_id,
3586
+ session_id=resolved_session_id,
3587
+ resume_input=resume_input,
3588
+ )
3589
+ messages = (
3590
+ [] if resume_input is not None else conversation.normalize_responses_input(request.input)
3591
+ )
3592
+ request_metadata = dict(request.metadata or {})
3593
+ if request.previous_response_id:
3594
+ request_metadata.setdefault("previous_response_id", request.previous_response_id)
3595
+ if request.prompt_cache_key:
3596
+ request_metadata.setdefault("prompt_cache_key", request.prompt_cache_key)
3597
+ if request.safety_identifier:
3598
+ request_metadata.setdefault("safety_identifier", request.safety_identifier)
3599
+ if request.user:
3600
+ request_metadata.setdefault("user", request.user)
3601
+ if request.conversation is not None:
3602
+ request_metadata.setdefault("conversation", request.conversation)
3603
+ if request.store is not None:
3604
+ request_metadata.setdefault("store", request.store)
3605
+ account_id = _clean_optional_string(request.account_id)
3606
+ invocation_id = _metadata_invocation_id(request_metadata)
3607
+
3608
+ if request.stream:
3609
+ resume_key = _detached_resume_key_from_input(resolved_session_id, resume_input)
3610
+ _reject_if_detached_resume_active(resume_key)
3611
+ return _detached_streaming_response(
3612
+ conversation.stream_responses_conversation_turn(
3613
+ runner=active_runner,
3614
+ agent_id=agent_id,
3615
+ user_id=resolved_user_id,
3616
+ messages=messages,
3617
+ session_id=resolved_session_id,
3618
+ model=request.model,
3619
+ model_metadata=request.model_metadata,
3620
+ model_options=request.model_options,
3621
+ instructions=request.instructions,
3622
+ request_metadata=request_metadata,
3623
+ resume_input=resume_input,
3624
+ account_id=account_id,
3625
+ invocation_id=invocation_id,
3626
+ prepare_runner=_prepare_runner_for_model,
3627
+ session_service_provider=resolve_session_service,
3628
+ run_mode=RUN_MODE_FOREGROUND,
3629
+ ),
3630
+ invocation_id=invocation_id,
3631
+ resume_key=resume_key,
3632
+ run_mode=RUN_MODE_FOREGROUND,
3633
+ run_trigger=trigger_from_resume_input(resume_input),
3634
+ )
3635
+
3636
+ response_id = f"resp_{uuid.uuid4().hex}"
3637
+ resolved_session_id, result = await conversation.invoke_conversation_once(
3638
+ runner=active_runner,
3639
+ agent_id=agent_id,
3640
+ user_id=resolved_user_id,
3641
+ messages=messages,
3642
+ session_id=resolved_session_id,
3643
+ model=request.model,
3644
+ model_metadata=request.model_metadata,
3645
+ model_options=request.model_options,
3646
+ instructions=request.instructions,
3647
+ request_metadata=request_metadata,
3648
+ resume_input=resume_input,
3649
+ response_id=response_id,
3650
+ account_id=account_id,
3651
+ invocation_id=invocation_id,
3652
+ prepare_runner=_prepare_runner_for_model,
3653
+ session_service_provider=resolve_session_service,
3654
+ run_mode=RUN_MODE_FOREGROUND,
3655
+ )
3656
+ return conversation.build_responses_payload(
3657
+ output_text=result["output_text"],
3658
+ model=request.model,
3659
+ session_id=resolved_session_id,
3660
+ response_id=response_id,
3661
+ metadata=result.get("metadata")
3662
+ if isinstance(result.get("metadata"), dict)
3663
+ else request_metadata,
3664
+ )
3665
+
3666
+
3667
+ @app.post("/v1/chat/completions")
3668
+ async def chat_completions(request: ChatCompletionRequest):
3669
+ """OpenAI 兼容的聊天补全接口 (支持流式和非流式)"""
3670
+ active_runner = _resolve_active_runner()
3671
+ messages = conversation.normalize_kop_messages(request.messages)
3672
+ agent_id = _runtime_agent_id(active_runner)
3673
+ resolved_user_id = _clean_optional_string(request.user) or "user"
3674
+ account_id = _clean_optional_string(request.account_id)
3675
+
3676
+ if request.stream:
3677
+ return StreamingResponse(
3678
+ conversation.stream_conversation_turn(
3679
+ runner=active_runner,
3680
+ agent_id=agent_id,
3681
+ user_id=resolved_user_id,
3682
+ messages=messages,
3683
+ session_id=request.session_id,
3684
+ model=request.model,
3685
+ model_metadata=request.model_metadata,
3686
+ model_options=request.model_options,
3687
+ account_id=account_id,
3688
+ prepare_runner=_prepare_runner_for_model,
3689
+ session_service_provider=resolve_session_service,
3690
+ run_mode=RUN_MODE_FOREGROUND,
3691
+ ),
3692
+ media_type="text/event-stream",
3693
+ )
3694
+
3695
+ resolved_session_id, result = await conversation.invoke_conversation_once(
3696
+ runner=active_runner,
3697
+ agent_id=agent_id,
3698
+ user_id=resolved_user_id,
3699
+ messages=messages,
3700
+ session_id=request.session_id,
3701
+ model=request.model,
3702
+ model_metadata=request.model_metadata,
3703
+ model_options=request.model_options,
3704
+ account_id=account_id,
3705
+ prepare_runner=_prepare_runner_for_model,
3706
+ session_service_provider=resolve_session_service,
3707
+ run_mode=RUN_MODE_FOREGROUND,
3708
+ )
3709
+ return conversation.build_chat_completions_payload(
3710
+ output_text=result["output_text"],
3711
+ model=request.model,
3712
+ session_id=resolved_session_id,
3713
+ metadata=result.get("metadata"),
3714
+ )
3715
+
3716
+
3717
+ # ============================================================
3718
+ # Stub Endpoints for ADK-Web Compatibility
3719
+ # ============================================================
3720
+
3721
+
3722
+ @app.get("/apps/{app_name}/eval_sets")
3723
+ async def list_eval_sets(app_name: str):
3724
+ """List evaluation sets - stub for ADK-Web"""
3725
+ return []
3726
+
3727
+
3728
+ @app.get("/apps/{app_name}/eval_results")
3729
+ async def list_eval_results(app_name: str):
3730
+ """List evaluation results - stub for ADK-Web"""
3731
+ return []
3732
+
3733
+
3734
+ @app.get("/builder/app/{app_name}")
3735
+ async def get_agent_builder(app_name: str, ts: int = 0, tmp: bool = False, file_path: str = None):
3736
+ """Get agent builder config - stub for ADK-Web"""
3737
+ # Return minimal YAML config for non-ADK projects
3738
+ return f"""name: {app_name}
3739
+ model: glm-5.1
3740
+ description: {app_name} agent
3741
+ instruction: You are a helpful assistant.
3742
+ """
3743
+
3744
+
3745
+ @app.post("/builder/save")
3746
+ async def save_agent_builder(request: Request, tmp: bool = False):
3747
+ """Save agent builder config - stub for ADK-Web"""
3748
+ return True
3749
+
3750
+
3751
+ @app.post("/builder/app/{app_name}/cancel")
3752
+ async def cancel_agent_changes(app_name: str):
3753
+ """Cancel agent builder changes - stub for ADK-Web"""
3754
+ return True
3755
+
3756
+
3757
+ # Legacy /traces endpoint
3758
+ @app.get("/traces")
3759
+ async def get_traces(limit: int = 50):
3760
+ """Get recent traces (OpenTelemetry)"""
3761
+ exporter = get_memory_exporter()
3762
+ if not exporter:
3763
+ return {"traces": []}
3764
+
3765
+ spans = exporter.get_finished_spans()
3766
+ traces = []
3767
+ for span in spans[-limit:]:
3768
+ traces.append(
3769
+ {
3770
+ "name": span.get("name", "unknown"),
3771
+ "status": span.get("status", {}).get("code", "UNSET"),
3772
+ "start_time": span.get("start_time"),
3773
+ "end_time": span.get("end_time"),
3774
+ "attributes": span.get("attributes", {}),
3775
+ }
3776
+ )
3777
+ return {"traces": traces}
3778
+
3779
+
3780
+ # ============================================================
3781
+ STATIC_DIR = Path(__file__).parent / "static"
3782
+
3783
+
3784
+ @app.get("/{requested_path:path}", include_in_schema=False)
3785
+ async def serve_agent_ui_static(requested_path: str):
3786
+ response = _resolve_ui_static_response(requested_path)
3787
+ if response is not None:
3788
+ return response
3789
+ raise HTTPException(status_code=404, detail="Not Found")