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.
- agentengine_sdk_python-0.6.9.dist-info/METADATA +179 -0
- agentengine_sdk_python-0.6.9.dist-info/RECORD +487 -0
- {agentengine_sdk_python-0.6.2.dist-info → agentengine_sdk_python-0.6.9.dist-info}/WHEEL +1 -1
- agentengine_sdk_python-0.6.9.dist-info/entry_points.txt +3 -0
- agentengine_sdk_python-0.6.9.dist-info/licenses/LICENSE +175 -0
- agentengine_sdk_python-0.6.9.dist-info/top_level.txt +2 -0
- ksadk/__init__.py +13 -0
- ksadk/__main__.py +4 -0
- ksadk/a2a/__init__.py +15 -0
- ksadk/a2a/card_builder.py +64 -0
- ksadk/a2a/client.py +309 -0
- ksadk/a2a/executor.py +201 -0
- ksadk/a2a/server.py +79 -0
- ksadk/agents/__init__.py +20 -0
- ksadk/agents/base.py +370 -0
- ksadk/agents/context.py +38 -0
- ksadk/agents/event.py +35 -0
- ksadk/agents/loop_agent.py +71 -0
- ksadk/agents/parallel_agent.py +109 -0
- ksadk/agents/runner_adapter.py +24 -0
- ksadk/agents/sequential_agent.py +34 -0
- ksadk/api/__init__.py +3 -0
- ksadk/api/client.py +2227 -0
- ksadk/builders/__init__.py +20 -0
- ksadk/builders/base.py +66 -0
- ksadk/builders/code_builder.py +1990 -0
- ksadk/builders/container_builder.py +786 -0
- ksadk/builders/framework_requirements.py +59 -0
- ksadk/builders/ks3_uploader.py +389 -0
- ksadk/builders/mcp_builder.py +476 -0
- ksadk/builders/requirements_utils.py +75 -0
- ksadk/cli/__init__.py +531 -0
- ksadk/cli/__main__.py +4 -0
- ksadk/cli/agent_ref.py +204 -0
- ksadk/cli/cmd_a2a.py +167 -0
- ksadk/cli/cmd_agent.py +201 -0
- ksadk/cli/cmd_build.py +270 -0
- ksadk/cli/cmd_completion.py +304 -0
- ksadk/cli/cmd_config.py +758 -0
- ksadk/cli/cmd_create.py +2277 -0
- ksadk/cli/cmd_dashboard.py +900 -0
- ksadk/cli/cmd_deploy.py +704 -0
- ksadk/cli/cmd_destroy.py +345 -0
- ksadk/cli/cmd_files.py +1227 -0
- ksadk/cli/cmd_hermes.py +1259 -0
- ksadk/cli/cmd_invoke.py +1336 -0
- ksadk/cli/cmd_launch.py +564 -0
- ksadk/cli/cmd_mcp.py +1174 -0
- ksadk/cli/cmd_model.py +302 -0
- ksadk/cli/cmd_openclaw.py +3918 -0
- ksadk/cli/cmd_run.py +232 -0
- ksadk/cli/cmd_status.py +588 -0
- ksadk/cli/cmd_version.py +522 -0
- ksadk/cli/cmd_web.py +288 -0
- ksadk/cli/deploy_utils.py +87 -0
- ksadk/cli/dry_run.py +214 -0
- ksadk/cli/env_options.py +82 -0
- ksadk/cli/error_utils.py +525 -0
- ksadk/cli/global_options.py +31 -0
- ksadk/cli/local_runtime.py +97 -0
- ksadk/cli/model_catalog.py +136 -0
- ksadk/cli/network_options.py +410 -0
- ksadk/cli/resource_common.py +621 -0
- ksadk/cli/storage.py +84 -0
- ksadk/cli/ui.py +431 -0
- ksadk/cli/workflow_common.py +552 -0
- ksadk/common/__init__.py +14 -0
- ksadk/common/aicp_env.py +128 -0
- ksadk/common/auth.py +190 -0
- ksadk/common/constants.py +84 -0
- ksadk/common/llm_utils.py +61 -0
- ksadk/configs/__init__.py +61 -0
- ksadk/configs/env_registry.py +342 -0
- ksadk/configs/global_config.py +200 -0
- ksadk/configs/settings.py +706 -0
- ksadk/conversations/__init__.py +95 -0
- ksadk/conversations/attachment_storage.py +379 -0
- ksadk/conversations/attachments.py +774 -0
- ksadk/conversations/compaction_pipeline.py +387 -0
- ksadk/conversations/compaction_prompt.py +128 -0
- ksadk/conversations/context.py +303 -0
- ksadk/conversations/model_context.py +360 -0
- ksadk/conversations/model_options.py +136 -0
- ksadk/conversations/normalize.py +446 -0
- ksadk/conversations/reasoning_markup.py +91 -0
- ksadk/conversations/run_kinds.py +61 -0
- ksadk/conversations/run_status.py +53 -0
- ksadk/conversations/runtime.py +5208 -0
- ksadk/conversations/semantic_summary.py +359 -0
- ksadk/conversations/session_title.py +254 -0
- ksadk/deployment/__init__.py +83 -0
- ksadk/deployment/agent_access.py +226 -0
- ksadk/deployment/base.py +229 -0
- ksadk/deployment/manager.py +332 -0
- ksadk/deployment/providers/__init__.py +10 -0
- ksadk/deployment/providers/serverless.py +1105 -0
- ksadk/deployment/registry.py +120 -0
- ksadk/deployment/state.py +78 -0
- ksadk/deployment/ui_config.py +21 -0
- ksadk/detection/__init__.py +7 -0
- ksadk/detection/detector.py +431 -0
- ksadk/detection/mcp_detector.py +250 -0
- ksadk/hermes_terminal.py +467 -0
- ksadk/identity/__init__.py +17 -0
- ksadk/identity/resolver.py +385 -0
- ksadk/knowledge_base/__init__.py +62 -0
- ksadk/knowledge_base/adk_tool.py +47 -0
- ksadk/knowledge_base/client.py +297 -0
- ksadk/knowledge_base/langchain_tool.py +55 -0
- ksadk/knowledge_base/service.py +66 -0
- ksadk/knowledge_base/tool.py +50 -0
- ksadk/markdown.py +169 -0
- ksadk/mcp_runtime/__init__.py +231 -0
- ksadk/memory/__init__.py +57 -0
- ksadk/memory/adk/__init__.py +51 -0
- ksadk/memory/adk/backends/__init__.py +5 -0
- ksadk/memory/adk/backends/base_ltm_backend.py +52 -0
- ksadk/memory/adk/backends/http_ltm_backend.py +183 -0
- ksadk/memory/adk/backends/inmemory_ltm_backend.py +96 -0
- ksadk/memory/adk/backends/sdk_ltm_backend.py +456 -0
- ksadk/memory/adk/long_term_memory.py +273 -0
- ksadk/memory/adk/short_term_memory.py +289 -0
- ksadk/memory/adk_tool.py +31 -0
- ksadk/memory/backends/__init__.py +6 -0
- ksadk/memory/backends/base.py +103 -0
- ksadk/memory/backends/memory.py +112 -0
- ksadk/memory/backends/redis.py +146 -0
- ksadk/memory/langchain_tool.py +39 -0
- ksadk/memory/ltm_backend_factory.py +24 -0
- ksadk/memory/manager.py +215 -0
- ksadk/memory/service.py +172 -0
- ksadk/memory/tool.py +96 -0
- ksadk/model_policy.py +223 -0
- ksadk/openclaw_gateway.py +378 -0
- ksadk/runners/__init__.py +8 -0
- ksadk/runners/adk_runner.py +1135 -0
- ksadk/runners/base_runner.py +305 -0
- ksadk/runners/deepagents_runner.py +13 -0
- ksadk/runners/factory.py +73 -0
- ksadk/runners/langchain_runner.py +539 -0
- ksadk/runners/langgraph_runner.py +852 -0
- ksadk/runners/patch_langchain.py +187 -0
- ksadk/runners/remote_runner.py +667 -0
- ksadk/runners/usage_accumulator.py +38 -0
- ksadk/runners/utils/__init__.py +19 -0
- ksadk/runners/utils/langfuse.py +230 -0
- ksadk/runners/utils/loader.py +62 -0
- ksadk/runtime_context.py +166 -0
- ksadk/runtime_state.py +25 -0
- ksadk/sandbox/__init__.py +28 -0
- ksadk/sandbox/backends/__init__.py +9 -0
- ksadk/sandbox/backends/e2b.py +154 -0
- ksadk/sandbox/backends/local_process.py +131 -0
- ksadk/sandbox/base.py +101 -0
- ksadk/sandbox/factory.py +53 -0
- ksadk/sandbox/registry.py +187 -0
- ksadk/server/__init__.py +7 -0
- ksadk/server/api_models.py +73 -0
- ksadk/server/app.py +3789 -0
- ksadk/server/static/assets/ArtifactsPanel-C0meuOdi.js +1 -0
- ksadk/server/static/assets/CodeBlock-BbI4HPtJ.js +9 -0
- ksadk/server/static/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- ksadk/server/static/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- ksadk/server/static/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- ksadk/server/static/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- ksadk/server/static/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- ksadk/server/static/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- ksadk/server/static/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- ksadk/server/static/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- ksadk/server/static/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- ksadk/server/static/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- ksadk/server/static/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- ksadk/server/static/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- ksadk/server/static/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- ksadk/server/static/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- ksadk/server/static/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- ksadk/server/static/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- ksadk/server/static/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- ksadk/server/static/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- ksadk/server/static/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- ksadk/server/static/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- ksadk/server/static/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- ksadk/server/static/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- ksadk/server/static/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- ksadk/server/static/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- ksadk/server/static/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- ksadk/server/static/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- ksadk/server/static/assets/MathMessageMarkdown-DMLQYoLp.js +8 -0
- ksadk/server/static/assets/MathMessageMarkdown-DeYh8QPr.css +1 -0
- ksadk/server/static/assets/MermaidBlock-DCR8WZ17.js +303 -0
- ksadk/server/static/assets/NativeTerminalPanel-CHOd7Rch.css +1 -0
- ksadk/server/static/assets/NativeTerminalPanel-CKUvWhUz.js +1 -0
- ksadk/server/static/assets/addon-fit-DthTIhi3.js +1 -0
- ksadk/server/static/assets/apl-CnwPGSsG.js +1 -0
- ksadk/server/static/assets/arc-bj2rZnP5.js +1 -0
- ksadk/server/static/assets/architecture-7EHR7CIX-Cr6Xz1_c.js +1 -0
- ksadk/server/static/assets/architectureDiagram-3BPJPVTR-2u1ltDIj.js +36 -0
- ksadk/server/static/assets/asciiarmor-qTkVPQu6.js +1 -0
- ksadk/server/static/assets/asn1-Dr8qZg38.js +1 -0
- ksadk/server/static/assets/asterisk-Bja0s6e1.js +1 -0
- ksadk/server/static/assets/blockDiagram-GPEHLZMM-D6fvdK20.js +132 -0
- ksadk/server/static/assets/brainfuck-D5EjA2JK.js +1 -0
- ksadk/server/static/assets/c4Diagram-AAUBKEIU-BK82L5n5.js +10 -0
- ksadk/server/static/assets/channel-BSSOwFIb.js +1 -0
- ksadk/server/static/assets/chunk-2J33WTMH-DLVdkeyQ.js +1 -0
- ksadk/server/static/assets/chunk-4BX2VUAB-BbwsYcbW.js +1 -0
- ksadk/server/static/assets/chunk-55IACEB6-DOairxzw.js +1 -0
- ksadk/server/static/assets/chunk-727SXJPM-hCO487gs.js +206 -0
- ksadk/server/static/assets/chunk-AQP2D5EJ-XU8vDOuG.js +231 -0
- ksadk/server/static/assets/chunk-FMBD7UC4-PrapyfMu.js +15 -0
- ksadk/server/static/assets/chunk-ND2GUHAM-CboKhTZ3.js +1 -0
- ksadk/server/static/assets/chunk-QZHKN3VN-XXpdiwUL.js +1 -0
- ksadk/server/static/assets/classDiagram-4FO5ZUOK-C5HvGiyK.js +1 -0
- ksadk/server/static/assets/classDiagram-v2-Q7XG4LA2-C5HvGiyK.js +1 -0
- ksadk/server/static/assets/clike-DHH8Ad3s.js +1 -0
- ksadk/server/static/assets/clojure-BflJGmDX.js +1 -0
- ksadk/server/static/assets/cmake-CyVbuzPu.js +1 -0
- ksadk/server/static/assets/cobol-B_OZ4V-R.js +1 -0
- ksadk/server/static/assets/coffeescript-B2pKbFk8.js +1 -0
- ksadk/server/static/assets/commonlisp-CcllspGY.js +1 -0
- ksadk/server/static/assets/cose-bilkent-S5V4N54A-_N8bJ-EU.js +1 -0
- ksadk/server/static/assets/crystal-M5-qzICs.js +1 -0
- ksadk/server/static/assets/css-CYpP4FRV.js +1 -0
- ksadk/server/static/assets/cypher-p9eYesGn.js +1 -0
- ksadk/server/static/assets/cytoscape.esm-DelgaX4f.js +321 -0
- ksadk/server/static/assets/d-BhMjBjQP.js +1 -0
- ksadk/server/static/assets/dagre-BM42HDAG-FoDlo5J2.js +4 -0
- ksadk/server/static/assets/dagre-Bx709z4p.js +1 -0
- ksadk/server/static/assets/defaultLocale-C8Fc0cco.js +1 -0
- ksadk/server/static/assets/diagram-2AECGRRQ-BBp6ebBw.js +43 -0
- ksadk/server/static/assets/diagram-5GNKFQAL-CqOvG1PR.js +10 -0
- ksadk/server/static/assets/diagram-KO2AKTUF-O9T3t6ba.js +3 -0
- ksadk/server/static/assets/diagram-LMA3HP47-CGG8ekBO.js +24 -0
- ksadk/server/static/assets/diagram-OG6HWLK6-4BmH1EEg.js +24 -0
- ksadk/server/static/assets/diff-ChtP43wD.js +1 -0
- ksadk/server/static/assets/dist-6kSZvCZh.js +1 -0
- ksadk/server/static/assets/dist-B67uF2uK.js +1 -0
- ksadk/server/static/assets/dist-BMKM66B4.js +1 -0
- ksadk/server/static/assets/dist-BVzcWa6D.js +1 -0
- ksadk/server/static/assets/dist-Bk1l05Ni.js +1 -0
- ksadk/server/static/assets/dist-BkJjNih3.js +1 -0
- ksadk/server/static/assets/dist-BmuTWcm7.js +9 -0
- ksadk/server/static/assets/dist-C2DKRFVK.js +1 -0
- ksadk/server/static/assets/dist-CWiTDN_V.js +6 -0
- ksadk/server/static/assets/dist-CZ6C3x8B.js +23 -0
- ksadk/server/static/assets/dist-CiZHB6ZW.js +1 -0
- ksadk/server/static/assets/dist-Cp1_jKwf.js +1 -0
- ksadk/server/static/assets/dist-Cwhk4lwI.js +2 -0
- ksadk/server/static/assets/dist-D5JGcxBx.js +11 -0
- ksadk/server/static/assets/dist-D99wf6_X.js +1 -0
- ksadk/server/static/assets/dist-DCpZrjaK.js +1 -0
- ksadk/server/static/assets/dist-DI-oVAXw.js +1 -0
- ksadk/server/static/assets/dist-DLMjL01k.js +1 -0
- ksadk/server/static/assets/dist-DLeM1OCK.js +1 -0
- ksadk/server/static/assets/dist-DM0rEQPk.js +1 -0
- ksadk/server/static/assets/dist-DbaH3p5h.js +1 -0
- ksadk/server/static/assets/dist-S4klkK5W.js +1 -0
- ksadk/server/static/assets/dockerfile-BLkNEvjs.js +1 -0
- ksadk/server/static/assets/dtd-DTF72YNO.js +1 -0
- ksadk/server/static/assets/dylan-C6jEEEk-.js +1 -0
- ksadk/server/static/assets/ebnf-D4c0_ac3.js +1 -0
- ksadk/server/static/assets/ecl-BSZaIHnp.js +1 -0
- ksadk/server/static/assets/eiffel-BEjRio4Q.js +1 -0
- ksadk/server/static/assets/elm-Cshzl8qu.js +1 -0
- ksadk/server/static/assets/erDiagram-TEJ5UH35-oJhaci7S.js +85 -0
- ksadk/server/static/assets/erlang-DaB2Rkuy.js +1 -0
- ksadk/server/static/assets/eventmodeling-FCH6USID-CIgwHJLL.js +1 -0
- ksadk/server/static/assets/factor-D7LVTn2l.js +1 -0
- ksadk/server/static/assets/fcl-ClOhbFR7.js +1 -0
- ksadk/server/static/assets/flowDiagram-I6XJVG4X-DtTmUan-.js +162 -0
- ksadk/server/static/assets/forth-Cezjo90N.js +1 -0
- ksadk/server/static/assets/fortran-Bt6PBEDR.js +1 -0
- ksadk/server/static/assets/ganttDiagram-6RSMTGT7-BSsb_eij.js +292 -0
- ksadk/server/static/assets/gas-BHEdbvp9.js +1 -0
- ksadk/server/static/assets/gherkin-oBAE_ms0.js +1 -0
- ksadk/server/static/assets/gitGraph-WXDBUCRP-BVAJ7uo0.js +1 -0
- ksadk/server/static/assets/gitGraphDiagram-PVQCEYII-BR5s6Pws.js +106 -0
- ksadk/server/static/assets/graphlib-B8gBHxth.js +1 -0
- ksadk/server/static/assets/groovy-BC3IOsC8.js +1 -0
- ksadk/server/static/assets/haskell-BRsoo5mP.js +1 -0
- ksadk/server/static/assets/haxe-DIz0ZZqd.js +1 -0
- ksadk/server/static/assets/http-CLVgA2GD.js +1 -0
- ksadk/server/static/assets/idl-BUZw3wgd.js +1 -0
- ksadk/server/static/assets/index-BH7-pC0B.js +165 -0
- ksadk/server/static/assets/index-DAURbCsP.css +1 -0
- ksadk/server/static/assets/info-J43DQDTF-MT80V4ge.js +1 -0
- ksadk/server/static/assets/infoDiagram-5YYISTIA-DYPc5cvh.js +2 -0
- ksadk/server/static/assets/init-D6jRqBbL.js +1 -0
- ksadk/server/static/assets/ishikawaDiagram-YF4QCWOH-MoBdALDg.js +70 -0
- ksadk/server/static/assets/javascript-9Tg8ixDm.js +1 -0
- ksadk/server/static/assets/journeyDiagram-JHISSGLW-DgLiQT8l.js +139 -0
- ksadk/server/static/assets/julia-CQfgDRfP.js +1 -0
- ksadk/server/static/assets/kanban-definition-UN3LZRKU-CsFx112F.js +89 -0
- ksadk/server/static/assets/katex-DRsiwpM2.js +265 -0
- ksadk/server/static/assets/linear-TNRQ5Orv.js +1 -0
- ksadk/server/static/assets/livescript-BXxG0Yva.js +1 -0
- ksadk/server/static/assets/lua-CiA1ziua.js +1 -0
- ksadk/server/static/assets/mathematica-u8YMmbU0.js +1 -0
- ksadk/server/static/assets/mbox-BEMVcqjs.js +1 -0
- ksadk/server/static/assets/mermaid-parser.core-D2eMVOgo.js +161 -0
- ksadk/server/static/assets/mindmap-definition-RKZ34NQL-BtByYFhL.js +96 -0
- ksadk/server/static/assets/mirc-C9zpzAZU.js +1 -0
- ksadk/server/static/assets/mllike-B45xgp2S.js +1 -0
- ksadk/server/static/assets/modelica-CqHI_q-D.js +1 -0
- ksadk/server/static/assets/mscgen-qgSLujhx.js +1 -0
- ksadk/server/static/assets/mumps-CW_TBmxz.js +1 -0
- ksadk/server/static/assets/nginx-BMaDbqW3.js +1 -0
- ksadk/server/static/assets/nsis-AQ_alPln.js +1 -0
- ksadk/server/static/assets/ntriples-d28e9m0E.js +1 -0
- ksadk/server/static/assets/octave-D2Q8cUp1.js +1 -0
- ksadk/server/static/assets/ordinal-hYBb2elL.js +1 -0
- ksadk/server/static/assets/oz-BwTct_5T.js +1 -0
- ksadk/server/static/assets/packet-YPE3B663-BLKhbXZB.js +1 -0
- ksadk/server/static/assets/pascal-Dp_KdFgX.js +1 -0
- ksadk/server/static/assets/perl-CdEHsPfU.js +1 -0
- ksadk/server/static/assets/pie-LRSECV5Y-DFOikjSY.js +1 -0
- ksadk/server/static/assets/pieDiagram-4H26LBE5-DPnuCB8c.js +30 -0
- ksadk/server/static/assets/pig-AF4Hwhju.js +1 -0
- ksadk/server/static/assets/powershell-CzG71I4L.js +1 -0
- ksadk/server/static/assets/properties-C357ku5U.js +1 -0
- ksadk/server/static/assets/protobuf-D1ij_kNL.js +1 -0
- ksadk/server/static/assets/pug-Bugr-47h.js +1 -0
- ksadk/server/static/assets/puppet-B4hC2X4m.js +1 -0
- ksadk/server/static/assets/python-BqYw9LYJ.js +1 -0
- ksadk/server/static/assets/q-B9NhS7L_.js +1 -0
- ksadk/server/static/assets/quadrantDiagram-W4KKPZXB-miFpCPv4.js +7 -0
- ksadk/server/static/assets/r-DADKenSm.js +1 -0
- ksadk/server/static/assets/radar-GUYGQ44K-095b1Ikw.js +1 -0
- ksadk/server/static/assets/requirementDiagram-4Y6WPE33-mjB_yYkW.js +84 -0
- ksadk/server/static/assets/rpm-gpDK5sbt.js +1 -0
- ksadk/server/static/assets/ruby-DsYpTuWg.js +1 -0
- ksadk/server/static/assets/sankeyDiagram-5OEKKPKP-D7hSlZZZ.js +40 -0
- ksadk/server/static/assets/sas-C2OyD2Y6.js +1 -0
- ksadk/server/static/assets/scheme-CyDdOh7e.js +1 -0
- ksadk/server/static/assets/sequenceDiagram-3UESZ5HK-D9Cx6hC3.js +162 -0
- ksadk/server/static/assets/shell-Du5Qg9im.js +1 -0
- ksadk/server/static/assets/sieve-BAxa3IjF.js +1 -0
- ksadk/server/static/assets/simple-mode-DRpGK0lJ.js +1 -0
- ksadk/server/static/assets/smalltalk-BLp5-jwC.js +1 -0
- ksadk/server/static/assets/solr-BBopId9w.js +1 -0
- ksadk/server/static/assets/sparql-Dz-mlCAC.js +1 -0
- ksadk/server/static/assets/spreadsheet-CTIncYxj.js +1 -0
- ksadk/server/static/assets/sql-DcCJ9jop.js +1 -0
- ksadk/server/static/assets/stateDiagram-AJRCARHV-D_zrg47o.js +1 -0
- ksadk/server/static/assets/stateDiagram-v2-BHNVJYJU-J9kCb0nC.js +1 -0
- ksadk/server/static/assets/stex-sDrdk2BW.js +1 -0
- ksadk/server/static/assets/stylus-DLLZ8dgm.js +1 -0
- ksadk/server/static/assets/swift-DKB6_1j6.js +1 -0
- ksadk/server/static/assets/tcl-BGfruO1u.js +1 -0
- ksadk/server/static/assets/textile-DWBqthVk.js +1 -0
- ksadk/server/static/assets/tiddlywiki-VlUiK86J.js +1 -0
- ksadk/server/static/assets/tiki-CxVlf5KZ.js +1 -0
- ksadk/server/static/assets/timeline-definition-PNZ67QCA-CwSec0Wa.js +120 -0
- ksadk/server/static/assets/toml-LvoKBwCs.js +1 -0
- ksadk/server/static/assets/treeView-BLDUP644-yinfJEY6.js +1 -0
- ksadk/server/static/assets/treemap-LRROVOQU-DosUqN1Q.js +1 -0
- ksadk/server/static/assets/troff-C2dAgh7P.js +1 -0
- ksadk/server/static/assets/ttcn-BePYh-hG.js +1 -0
- ksadk/server/static/assets/ttcn-cfg-CY0aot7Q.js +1 -0
- ksadk/server/static/assets/turtle-BDLWE2cD.js +1 -0
- ksadk/server/static/assets/vb-BthdM_4N.js +1 -0
- ksadk/server/static/assets/vbscript-D9f6rSsU.js +1 -0
- ksadk/server/static/assets/velocity-CqftRsjg.js +1 -0
- ksadk/server/static/assets/vennDiagram-CIIHVFJN-agCVobiF.js +34 -0
- ksadk/server/static/assets/verilog-D-x6ne0Z.js +1 -0
- ksadk/server/static/assets/vhdl-CMrOq2q1.js +1 -0
- ksadk/server/static/assets/wardley-L42UT6IY-Bvp7-5uH.js +1 -0
- ksadk/server/static/assets/wardleyDiagram-YWT4CUSO-TmrlhWNt.js +78 -0
- ksadk/server/static/assets/webidl-D5jiJvOU.js +1 -0
- ksadk/server/static/assets/xquery-fj_R-kMw.js +1 -0
- ksadk/server/static/assets/xterm-DooSxjI5.js +36 -0
- ksadk/server/static/assets/xychartDiagram-2RQKCTM6-DV71V-ah.js +7 -0
- ksadk/server/static/assets/yacas-CXXgtw0p.js +1 -0
- ksadk/server/static/assets/z80-CL1naaMV.js +1 -0
- ksadk/server/static/favicon.svg +1 -0
- ksadk/server/static/icons.svg +24 -0
- ksadk/server/static/index.html +14 -0
- ksadk/server/terminal_sessions.py +551 -0
- ksadk/sessions/__init__.py +283 -0
- ksadk/sessions/base.py +354 -0
- ksadk/sessions/continuity.py +313 -0
- ksadk/sessions/errors.py +7 -0
- ksadk/sessions/in_memory.py +273 -0
- ksadk/sessions/local_service.py +949 -0
- ksadk/sessions/postgres_service.py +838 -0
- ksadk/sessions/sqlite_service.py +13 -0
- ksadk/skills/__init__.py +9 -0
- ksadk/skills/loader.py +46 -0
- ksadk/skills/models.py +120 -0
- ksadk/skills/package_store.py +104 -0
- ksadk/skills/runtime/__init__.py +17 -0
- ksadk/skills/runtime/agent.py +183 -0
- ksadk/skills/runtime/artifacts.py +39 -0
- ksadk/skills/runtime/backends/__init__.py +1 -0
- ksadk/skills/runtime/backends/disabled.py +8 -0
- ksadk/skills/runtime/backends/e2b.py +168 -0
- ksadk/skills/runtime/backends/local.py +95 -0
- ksadk/skills/runtime/base.py +103 -0
- ksadk/skills/runtime/executor.py +219 -0
- ksadk/skills/runtime/factory.py +37 -0
- ksadk/skills/runtime/loader.py +131 -0
- ksadk/skills/runtime/registry.py +150 -0
- ksadk/skills/runtime/request.py +67 -0
- ksadk/skills/service_client.py +239 -0
- ksadk/skills/service_env.py +58 -0
- ksadk/skills/tool_defs.py +221 -0
- ksadk/terminal_client.py +69 -0
- ksadk/terminal_exec_policy.py +229 -0
- ksadk/tools/__init__.py +17 -0
- ksadk/tools/gateway.py +230 -0
- ksadk/tools/result_budget.py +154 -0
- ksadk/toolsets/__init__.py +852 -0
- ksadk/toolsets/_langchain.py +11 -0
- ksadk/toolsets/platform.py +93 -0
- ksadk/toolsets/sandbox.py +310 -0
- ksadk/toolsets/skills.py +217 -0
- ksadk/toolsets/web.py +266 -0
- ksadk/toolsets/workspace.py +878 -0
- ksadk/toolsets/workspace_state.py +44 -0
- ksadk/tracing/__init__.py +37 -0
- ksadk/tracing/exporters/__init__.py +1 -0
- ksadk/tracing/exporters/inmemory_exporter.py +116 -0
- ksadk/tracing/exporters/langfuse_exporter.py +445 -0
- ksadk/tracing/setup.py +829 -0
- ksadk/tracing/span_utils.py +114 -0
- ksadk/tui/__init__.py +12 -0
- ksadk/tui/app.py +400 -0
- ksadk/tui/clipboard.py +100 -0
- ksadk/tui/widgets/__init__.py +22 -0
- ksadk/tui/widgets/assistant.py +89 -0
- ksadk/tui/widgets/base.py +54 -0
- ksadk/tui/widgets/chat_input.py +246 -0
- ksadk/tui/widgets/history.py +156 -0
- ksadk/tui/widgets/system.py +87 -0
- ksadk/tui/widgets/thinking.py +110 -0
- ksadk/tui/widgets/tool_call.py +161 -0
- ksadk/tui/widgets/user.py +37 -0
- ksadk/ui_config.py +168 -0
- ksadk/version.py +4 -0
- ksadk_runtime_common/__init__.py +20 -0
- ksadk_runtime_common/memory_backend/__init__.py +29 -0
- ksadk_runtime_common/memory_backend/manifest.py +79 -0
- ksadk_runtime_common/memory_backend/providers/__init__.py +6 -0
- ksadk_runtime_common/memory_backend/providers/lancedb.py +151 -0
- ksadk_runtime_common/memory_backend/providers/mem0.py +74 -0
- ksadk_runtime_common/memory_backend/registry.py +56 -0
- ksadk_runtime_common/memory_backend/render.py +71 -0
- ksadk_runtime_common/schemas/memory_backend_manifest.schema.json +148 -0
- ksadk_runtime_common/workspace_files/__init__.py +29 -0
- ksadk_runtime_common/workspace_files/bootstrap.py +56 -0
- ksadk_runtime_common/workspace_files/constants.py +9 -0
- ksadk_runtime_common/workspace_files/path_utils.py +71 -0
- ksadk_runtime_common/workspace_files/preview.py +96 -0
- ksadk_runtime_common/workspace_files/router.py +254 -0
- agentengine_sdk_python-0.6.2.dist-info/METADATA +0 -46
- agentengine_sdk_python-0.6.2.dist-info/RECORD +0 -4
- 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")
|