agentengine-sdk-python 0.8.1__py3-none-any.whl → 0.8.3__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (476) hide show
  1. {agentengine_sdk_python-0.8.1.dist-info → agentengine_sdk_python-0.8.3.dist-info}/METADATA +25 -12
  2. {agentengine_sdk_python-0.8.1.dist-info → agentengine_sdk_python-0.8.3.dist-info}/RECORD +421 -232
  3. ksadk/_build_provenance.json +1 -0
  4. ksadk/a2a/_space_client_events.py +381 -0
  5. ksadk/a2a/event_adapter.py +156 -64
  6. ksadk/a2a/executor.py +179 -63
  7. ksadk/a2a/space_client.py +6 -333
  8. ksadk/a2ui/core.py +222 -53
  9. ksadk/agui/_agent_helpers.py +247 -0
  10. ksadk/agui/a2ui_projection.py +12 -5
  11. ksadk/agui/agent.py +443 -259
  12. ksadk/api/client.py +528 -27
  13. ksadk/builders/code_builder.py +284 -8
  14. ksadk/builders/container_builder.py +8 -1
  15. ksadk/builders/managed_runtime_builder.py +42 -35
  16. ksadk/cli/__init__.py +23 -0
  17. ksadk/cli/cmd_dashboard.py +15 -1
  18. ksadk/cli/cmd_deploy.py +10 -2
  19. ksadk/cli/cmd_eval.py +66 -9
  20. ksadk/cli/cmd_evalset.py +284 -0
  21. ksadk/cli/cmd_files.py +5 -1
  22. ksadk/cli/cmd_hermes.py +1 -126
  23. ksadk/cli/cmd_invoke.py +28 -38
  24. ksadk/cli/cmd_managed_runtime.py +95 -0
  25. ksadk/cli/cmd_observe.py +85 -0
  26. ksadk/cli/cmd_openclaw.py +1 -435
  27. ksadk/cli/cmd_plugin.py +657 -0
  28. ksadk/cli/cmd_replay.py +28 -4
  29. ksadk/cli/cmd_studio.py +99 -8
  30. ksadk/cli/cmd_web.py +10 -2
  31. ksadk/cli/hermes_env.py +151 -0
  32. ksadk/cli/invoke_payload.py +49 -0
  33. ksadk/cli/openclaw_env.py +458 -0
  34. ksadk/codex/client.py +180 -30
  35. ksadk/codex/runtime.py +475 -331
  36. ksadk/configs/env_registry.py +94 -12
  37. ksadk/configs/env_registry_pcm.py +181 -0
  38. ksadk/configs/env_var_spec.py +18 -0
  39. ksadk/configs/global_config.py +5 -0
  40. ksadk/configs/settings.py +9 -0
  41. ksadk/context_engine/__init__.py +98 -0
  42. ksadk/context_engine/assembler.py +176 -0
  43. ksadk/context_engine/baseline.py +419 -0
  44. ksadk/context_engine/cache_observability.py +228 -0
  45. ksadk/context_engine/capabilities.py +443 -0
  46. ksadk/context_engine/contributors.py +409 -0
  47. ksadk/context_engine/hosted_pipeline.py +417 -0
  48. ksadk/context_engine/models.py +117 -0
  49. ksadk/context_engine/planner.py +698 -0
  50. ksadk/context_engine/policies.py +319 -0
  51. ksadk/context_engine/projection.py +31 -0
  52. ksadk/context_engine/shadow_plan.py +266 -0
  53. ksadk/context_engine/tokenizer.py +164 -0
  54. ksadk/conversations/__init__.py +19 -0
  55. ksadk/conversations/context.py +230 -30
  56. ksadk/conversations/contracts.py +266 -0
  57. ksadk/conversations/message_projection.py +308 -26
  58. ksadk/conversations/model_context.py +49 -3
  59. ksadk/conversations/projector.py +616 -0
  60. ksadk/conversations/reducer.py +98 -0
  61. ksadk/conversations/runtime_compaction.py +207 -5
  62. ksadk/conversations/runtime_input.py +184 -2
  63. ksadk/conversations/runtime_invocation.py +81 -3
  64. ksadk/conversations/runtime_observability.py +141 -0
  65. ksadk/conversations/runtime_payloads.py +42 -0
  66. ksadk/conversations/runtime_preparation.py +272 -2
  67. ksadk/conversations/runtime_resume.py +20 -1
  68. ksadk/conversations/runtime_stream_events.py +94 -9
  69. ksadk/conversations/semantic_summary.py +311 -1
  70. ksadk/conversations/session_lock.py +51 -0
  71. ksadk/deployment/env_forward.py +72 -0
  72. ksadk/deployment/managed_runtime.py +5 -0
  73. ksadk/deployment/providers/serverless.py +143 -69
  74. ksadk/evaluation/__init__.py +32 -0
  75. ksadk/evaluation/a2a_adapter.py +9 -1
  76. ksadk/evaluation/adapters.py +18 -5
  77. ksadk/evaluation/agent_eval_client.py +395 -0
  78. ksadk/evaluation/cloud_binding.py +80 -0
  79. ksadk/evaluation/cloud_converter.py +194 -0
  80. ksadk/evaluation/cloud_service.py +199 -0
  81. ksadk/evaluation/contracts.py +73 -4
  82. ksadk/evaluation/evaluators.py +183 -17
  83. ksadk/evaluation/evidence.py +258 -0
  84. ksadk/evaluation/executor.py +31 -6
  85. ksadk/evaluation/local_adapter.py +550 -0
  86. ksadk/evaluation/service_env.py +27 -0
  87. ksadk/evaluation/studio_build_adapter.py +253 -0
  88. ksadk/evaluation/target.py +15 -6
  89. ksadk/events/__init__.py +13 -13
  90. ksadk/events/_v1_compat/__init__.py +1 -0
  91. ksadk/events/_v1_compat/models.py +299 -0
  92. ksadk/events/_v1_compat/parser.py +247 -0
  93. ksadk/events/_v1_compat/projection.py +755 -0
  94. ksadk/events/adapters/__init__.py +34 -0
  95. ksadk/events/adapters/_a2a_snapshot.py +622 -0
  96. ksadk/events/adapters/_a2a_support.py +275 -0
  97. ksadk/events/adapters/_codex_interactions.py +375 -0
  98. ksadk/events/adapters/_codex_items.py +801 -0
  99. ksadk/events/adapters/_codex_validators.py +311 -0
  100. ksadk/events/adapters/_langgraph_support.py +785 -0
  101. ksadk/events/adapters/a2a.py +841 -0
  102. ksadk/events/adapters/adk.py +503 -0
  103. ksadk/events/adapters/codex.py +907 -0
  104. ksadk/events/adapters/langgraph.py +745 -0
  105. ksadk/events/canonical.py +440 -0
  106. ksadk/events/canonical_replay.py +497 -0
  107. ksadk/events/canonical_store.py +468 -0
  108. ksadk/events/cold_recovery.py +257 -0
  109. ksadk/events/content.py +89 -0
  110. ksadk/events/identity.py +86 -0
  111. ksadk/events/pipeline.py +455 -0
  112. ksadk/events/projections.py +66 -0
  113. ksadk/events/reducer.py +661 -0
  114. ksadk/events/replay.py +3 -36
  115. ksadk/events/runtime_event.py +20 -265
  116. ksadk/events/session_event.py +324 -0
  117. ksadk/events/store.py +3 -316
  118. ksadk/events/v1_compat.py +41 -0
  119. ksadk/harness/__init__.py +45 -35
  120. ksadk/harness/runtime.py +220 -58
  121. ksadk/identity/resolver.py +4 -3
  122. ksadk/interaction/__init__.py +28 -0
  123. ksadk/interaction/contracts.py +136 -0
  124. ksadk/interaction/ledger.py +189 -0
  125. ksadk/interaction/provider.py +122 -0
  126. ksadk/interaction/providers/__init__.py +52 -0
  127. ksadk/interaction/providers/adk.py +40 -0
  128. ksadk/interaction/providers/codex.py +94 -0
  129. ksadk/interaction/providers/langgraph.py +58 -0
  130. ksadk/kernel/__init__.py +117 -0
  131. ksadk/kernel/authorization.py +258 -0
  132. ksadk/kernel/bootstrap.py +1074 -0
  133. ksadk/kernel/contract_fingerprints.py +62 -0
  134. ksadk/kernel/contracts.py +370 -0
  135. ksadk/kernel/control.py +220 -0
  136. ksadk/kernel/errors.py +79 -0
  137. ksadk/kernel/ingress.py +1123 -0
  138. ksadk/kernel/mapping.py +111 -0
  139. ksadk/kernel/memory_store.py +1134 -0
  140. ksadk/kernel/postgres_store.py +1757 -0
  141. ksadk/kernel/recovery.py +452 -0
  142. ksadk/kernel/runtime_identity.py +103 -0
  143. ksadk/kernel/sql/001_agent_kernel.sql +120 -0
  144. ksadk/kernel/sqlite_store.py +1411 -0
  145. ksadk/kernel/state.py +145 -0
  146. ksadk/kernel/store.py +232 -0
  147. ksadk/kernel/worker.py +964 -0
  148. ksadk/knowledge_base/client.py +7 -0
  149. ksadk/knowledge_base/service.py +32 -5
  150. ksadk/memory/__init__.py +61 -0
  151. ksadk/memory/adk/backends/base_ltm_backend.py +96 -1
  152. ksadk/memory/adk/backends/http_ltm_backend.py +14 -5
  153. ksadk/memory/adk/backends/inmemory_ltm_backend.py +136 -3
  154. ksadk/memory/adk/backends/sdk_ltm_backend.py +384 -2
  155. ksadk/memory/adk/backends/sqlite_ltm_backend.py +95 -0
  156. ksadk/memory/coordinator.py +461 -0
  157. ksadk/memory/events.py +202 -0
  158. ksadk/memory/extraction.py +228 -0
  159. ksadk/memory/ltm_backend_factory.py +16 -3
  160. ksadk/memory/models.py +316 -0
  161. ksadk/memory/policy.py +215 -0
  162. ksadk/memory/provider.py +47 -0
  163. ksadk/memory/provider_adapter.py +159 -0
  164. ksadk/memory/provider_resolver.py +71 -0
  165. ksadk/memory/providers/__init__.py +5 -0
  166. ksadk/memory/providers/local_sqlite.py +497 -0
  167. ksadk/memory/resolved_policy.py +145 -0
  168. ksadk/memory/service.py +188 -6
  169. ksadk/model_proxy/bootstrap.py +16 -9
  170. ksadk/model_proxy/detect.py +202 -9
  171. ksadk/model_proxy/namespace.py +39 -15
  172. ksadk/model_proxy/server.py +24 -5
  173. ksadk/model_proxy/transform.py +60 -2
  174. ksadk/observability/__init__.py +25 -0
  175. ksadk/observability/session_log.py +420 -0
  176. ksadk/observability/trajectory.py +105 -0
  177. ksadk/plugins/__init__.py +167 -0
  178. ksadk/plugins/bridges/__init__.py +45 -0
  179. ksadk/plugins/bridges/codex.py +552 -0
  180. ksadk/plugins/bridges/dsh.py +1116 -0
  181. ksadk/plugins/builtins.py +747 -0
  182. ksadk/plugins/bundle.py +246 -0
  183. ksadk/plugins/bundle_security.py +167 -0
  184. ksadk/plugins/composition.py +529 -0
  185. ksadk/plugins/context_contributor.py +437 -0
  186. ksadk/plugins/contracts.py +451 -0
  187. ksadk/plugins/dsh_toolchain.py +935 -0
  188. ksadk/plugins/ecosystem_bridge.py +1105 -0
  189. ksadk/plugins/ecosystem_probe.py +191 -0
  190. ksadk/plugins/host.py +724 -0
  191. ksadk/plugins/providers/__init__.py +85 -0
  192. ksadk/plugins/providers/bundles/ksadk-codex/cordis.patch.yml +3 -0
  193. ksadk/plugins/providers/bundles/ksadk-codex/index.mjs +18 -0
  194. ksadk/plugins/providers/bundles/ksadk-codex/package.json +19 -0
  195. ksadk/plugins/providers/bundles/ksadk-harness/cordis.patch.yml +3 -0
  196. ksadk/plugins/providers/bundles/ksadk-harness/index.mjs +19 -0
  197. ksadk/plugins/providers/bundles/ksadk-harness/package.json +19 -0
  198. ksadk/plugins/providers/codex.py +722 -0
  199. ksadk/plugins/providers/codex_dsh.py +83 -0
  200. ksadk/plugins/providers/dsh.py +951 -0
  201. ksadk/plugins/providers/dsh_descriptor_host.py +226 -0
  202. ksadk/plugins/providers/harness.py +627 -0
  203. ksadk/plugins/providers/harness_dsh.py +91 -0
  204. ksadk/plugins/providers/legacy.py +215 -0
  205. ksadk/plugins/providers/legacy_catalog.py +100 -0
  206. ksadk/plugins/providers/shipped_dsh.py +370 -0
  207. ksadk/plugins/resolver.py +331 -0
  208. ksadk/plugins/subagent_providers/__init__.py +12 -0
  209. ksadk/plugins/subagent_providers/codex.py +443 -0
  210. ksadk/plugins/subagents.py +333 -0
  211. ksadk/prompts/__init__.py +71 -0
  212. ksadk/prompts/compiler.py +215 -0
  213. ksadk/prompts/models.py +82 -0
  214. ksadk/prompts/projection.py +89 -0
  215. ksadk/prompts/resolved.py +137 -0
  216. ksadk/prompts/sources.py +233 -0
  217. ksadk/runners/_langgraph_runner_streams.py +822 -0
  218. ksadk/runners/adk_runner.py +5 -5
  219. ksadk/runners/base_runner.py +16 -0
  220. ksadk/runners/factory.py +1 -2
  221. ksadk/runners/langgraph_runner.py +198 -437
  222. ksadk/runtime/_runner_adapter/__init__.py +1 -0
  223. ksadk/runtime/_runner_adapter/stream_mapping.py +797 -0
  224. ksadk/runtime/adapter.py +119 -2
  225. ksadk/runtime/conversation_execution.py +316 -182
  226. ksadk/runtime/executor.py +144 -3
  227. ksadk/runtime/factory.py +268 -8
  228. ksadk/runtime/framework_adapters.py +18 -0
  229. ksadk/runtime/hosted_finalizer.py +268 -0
  230. ksadk/runtime/launch.py +14 -1
  231. ksadk/runtime/preprocessing.py +25 -3
  232. ksadk/runtime/runner_adapter.py +293 -508
  233. ksadk/runtime/usage.py +33 -0
  234. ksadk/sandbox/__init__.py +2 -1
  235. ksadk/sandbox/backends/e2b.py +35 -2
  236. ksadk/sandbox/factory.py +72 -0
  237. ksadk/scheduler/__init__.py +18 -0
  238. ksadk/scheduler/calendar.py +102 -0
  239. ksadk/scheduler/contracts.py +254 -0
  240. ksadk/scheduler/dispatcher.py +129 -0
  241. ksadk/scheduler/engine.py +416 -0
  242. ksadk/scheduler/sqlite_store.py +563 -0
  243. ksadk/server/composition.py +8 -0
  244. ksadk/server/factory.py +69 -6
  245. ksadk/server/routes/kernel_ingress.py +258 -0
  246. ksadk/server/routes/openai_compat.py +88 -8
  247. ksadk/server/routes/projection.py +35 -20
  248. ksadk/server/routes/run.py +94 -0
  249. ksadk/server/routes/sessions.py +2 -0
  250. ksadk/server/routes/streaming.py +5 -2
  251. ksadk/server/static/assets/{ArtifactsPanel-CakCqxEN.js → ArtifactsPanel-10GPXIJ6.js} +1 -1
  252. ksadk/server/static/assets/{CodeBlock-CynLknyO.js → CodeBlock-BXk1l-G1.js} +4 -4
  253. ksadk/server/static/assets/MathMessageMarkdown-BorAY7qD.css +1 -0
  254. ksadk/server/static/assets/{MathMessageMarkdown-3sQdOysS.js → MathMessageMarkdown-D9uB55Oh.js} +4 -4
  255. ksadk/server/static/assets/MermaidBlock--OEYoXIJ.js +312 -0
  256. ksadk/server/static/assets/NativeTerminalPanel-DrEe1syq.js +1 -0
  257. ksadk/server/static/assets/abnfDiagram-VCTEODGH-eeTFJLvo.js +1 -0
  258. ksadk/server/static/assets/arc-S3ZNbIAd.js +1 -0
  259. ksadk/server/static/assets/architecture-7GRP2DOG-Dk9nRQ6-.js +1 -0
  260. ksadk/server/static/assets/architectureDiagram-5GKGNRK7-CRjgEJDX.js +36 -0
  261. ksadk/server/static/assets/blockDiagram-NRAW4CY4-C7bsBoNH.js +129 -0
  262. ksadk/server/static/assets/c4Diagram-UCG6FXSJ-BX3c33Ob.js +38 -0
  263. ksadk/server/static/assets/channel-s354Yo6o.js +1 -0
  264. ksadk/server/static/assets/chunk-2Q5K7J3B-CDpPpKR5.js +1 -0
  265. ksadk/server/static/assets/{chunk-FMBD7UC4-B_fH2YuH.js → chunk-5VM5RSS4-bZ7ws-8m.js} +1 -1
  266. ksadk/server/static/assets/{chunk-ND2GUHAM-D6rFLaVV.js → chunk-F27PBJKO-D2r0pvhY.js} +1 -1
  267. ksadk/server/static/assets/chunk-G27WJ6UU-CZwF3oUP.js +231 -0
  268. ksadk/server/static/assets/{chunk-4BX2VUAB-B9drTgOW.js → chunk-JWPE2WC7-DigFYCML.js} +1 -1
  269. ksadk/server/static/assets/chunk-LCL6LL3I-CnorGV5q.js +206 -0
  270. ksadk/server/static/assets/chunk-POPQ4Y6H-CexntQA-.js +1 -0
  271. ksadk/server/static/assets/chunk-RHFEMEQ7-CHTC7UxA.js +168 -0
  272. ksadk/server/static/assets/chunk-SVP7TREG-BC1EWt_-.js +88 -0
  273. ksadk/server/static/assets/chunk-XXDRQBXY-BTG24xN0.js +1 -0
  274. ksadk/server/static/assets/classDiagram-DTDB5LWJ-Dd0nucl9.js +1 -0
  275. ksadk/server/static/assets/classDiagram-v2-JRS7N3AN-Dd0nucl9.js +1 -0
  276. ksadk/server/static/assets/{cose-bilkent-S5V4N54A-ChNs_Mdk.js → cose-bilkent-JH36ORCC-CkonRdAS.js} +1 -1
  277. ksadk/server/static/assets/cynefin-OW5HDTMX-BBlT4uMf.js +1 -0
  278. ksadk/server/static/assets/cynefinDiagram-5FMLGOSQ-DEOCBe15.js +62 -0
  279. ksadk/server/static/assets/cytoscape.esm-CyCl8rPi.js +321 -0
  280. ksadk/server/static/assets/dagre-3AP2YEHR-DBThPuLa.js +4 -0
  281. ksadk/server/static/assets/dagre-ClTPrTht.js +1 -0
  282. ksadk/server/static/assets/diagram-S7CK7UJ4-DsZHrnpJ.js +30 -0
  283. ksadk/server/static/assets/diagram-UQ7AKVKN-BJR4nhVr.js +41 -0
  284. ksadk/server/static/assets/diagram-VSXAHHWV-xzcERX0a.js +3 -0
  285. ksadk/server/static/assets/diagram-VX7I27RA-tn2xM1xu.js +24 -0
  286. ksadk/server/static/assets/diagram-Z3DM3KII-BZPGBkw5.js +24 -0
  287. ksadk/server/static/assets/{dist-gLnrGsoU.js → dist-4zN1_tq5.js} +1 -1
  288. ksadk/server/static/assets/{dist-C1sz5Eo3.js → dist-8VWI-2bZ.js} +1 -1
  289. ksadk/server/static/assets/{dist-BjOdlDjj.js → dist-B-46q3Hk.js} +1 -1
  290. ksadk/server/static/assets/{dist-o2OVmKvt.js → dist-B0gdk4rl.js} +1 -1
  291. ksadk/server/static/assets/{dist-4N2NMVYg.js → dist-B1oWRmrH.js} +1 -1
  292. ksadk/server/static/assets/{dist-CmZHzlwZ.js → dist-BI1qQH4n.js} +1 -1
  293. ksadk/server/static/assets/{dist-X4TnqwOK.js → dist-BJLUwH6W.js} +1 -1
  294. ksadk/server/static/assets/{dist-B5oS1zcj.js → dist-Bbwqa8TG.js} +1 -1
  295. ksadk/server/static/assets/{dist-j6Dxmf_j.js → dist-Btyo4vX4.js} +1 -1
  296. ksadk/server/static/assets/{dist-DQU9EWw-.js → dist-C71CsujO.js} +1 -1
  297. ksadk/server/static/assets/{dist-BIlKoW0k.js → dist-CXpH_Rdd.js} +1 -1
  298. ksadk/server/static/assets/{dist-DJN_6Y39.js → dist-Cq4OL6nO.js} +1 -1
  299. ksadk/server/static/assets/{dist-Dli7qW-B.js → dist-D1V3f1B4.js} +1 -1
  300. ksadk/server/static/assets/{dist-fhOuWds5.js → dist-DJmCPRVB.js} +1 -1
  301. ksadk/server/static/assets/{dist-CWV4lDr1.js → dist-DQAB0qn_.js} +2 -2
  302. ksadk/server/static/assets/{dist-D6MpCZtz.js → dist-DUwKa_l6.js} +1 -1
  303. ksadk/server/static/assets/dist-DdRBIceA.js +1 -0
  304. ksadk/server/static/assets/{dist-DWKByxWV.js → dist-DfGFrb0v.js} +1 -1
  305. ksadk/server/static/assets/{dist-DsHPdk3W.js → dist-DjhwGUgZ.js} +1 -1
  306. ksadk/server/static/assets/{dist-B-jNR6Vn.js → dist-DyqV4fL4.js} +1 -1
  307. ksadk/server/static/assets/{dist-BB3-OwTQ.js → dist-M67Hwu0n.js} +1 -1
  308. ksadk/server/static/assets/{dist-_pHPTyZ-.js → dist-u8tVH5Ak.js} +1 -1
  309. ksadk/server/static/assets/ebnfDiagram-PWID7BFC-DVfMsLge.js +1 -0
  310. ksadk/server/static/assets/erDiagram-SSCWMZ5O-CWEZSYaw.js +99 -0
  311. ksadk/server/static/assets/eventmodeling-NTZA5JFV-Dr5okd8V.js +1 -0
  312. ksadk/server/static/assets/flowDiagram-A5DVABFB-VEO-cVcO.js +1 -0
  313. ksadk/server/static/assets/ganttDiagram-EL5Y4UJY-TWIxoyYL.js +292 -0
  314. ksadk/server/static/assets/gitGraph-4MIJSDKK-CKiKY9Cf.js +1 -0
  315. ksadk/server/static/assets/gitGraphDiagram-WWUBYQGX-DDh2hd2S.js +106 -0
  316. ksadk/server/static/assets/index-B2k_urY8.js +241 -0
  317. ksadk/server/static/assets/index-ILPt6IWF.css +2 -0
  318. ksadk/server/static/assets/info-A6RAGUB7-ubA4Nm_p.js +1 -0
  319. ksadk/server/static/assets/infoDiagram-RXCK75RN-CgoyWiDR.js +2 -0
  320. ksadk/server/static/assets/{ishikawaDiagram-YF4QCWOH-K0OOfVna.js → ishikawaDiagram-5VMMS53U-CyRIu_om.js} +6 -6
  321. ksadk/server/static/assets/{journeyDiagram-JHISSGLW-8LeOJaZp.js → journeyDiagram-EYS64GPL-DN5JXHzN.js} +6 -6
  322. ksadk/server/static/assets/{kanban-definition-UN3LZRKU-C6uCgZrW.js → kanban-definition-3QL26DDD-CNP3ui66.js} +10 -10
  323. ksadk/server/static/assets/katex-DWEzQy1d.js +257 -0
  324. ksadk/server/static/assets/{linear-hdBcYzM8.js → linear-C-qHp7d2.js} +1 -1
  325. ksadk/server/static/assets/mermaid-parser.core-Cl-K943T.js +166 -0
  326. ksadk/server/static/assets/{mindmap-definition-RKZ34NQL-Bjy_2v4_.js → mindmap-definition-FBJOCRG2-D5DB5mYm.js} +29 -29
  327. ksadk/server/static/assets/packet-AYTQ26CC-C7R3NUlK.js +1 -0
  328. ksadk/server/static/assets/pegDiagram-XKGWAZYB-BQVc9ia_.js +1 -0
  329. ksadk/server/static/assets/pie-WAS4IAKB-BIT6qjr3.js +1 -0
  330. ksadk/server/static/assets/pieDiagram-E7YTZNPT-B6UCAvtY.js +39 -0
  331. ksadk/server/static/assets/{quadrantDiagram-W4KKPZXB-BmkRbwCg.js → quadrantDiagram-AXDQQJYC-mIr2d3eb.js} +6 -6
  332. ksadk/server/static/assets/radar-RG4KPBEZ-l5noef9X.js +1 -0
  333. ksadk/server/static/assets/railroad-74A4TZTK-Baea6ZvM.js +1 -0
  334. ksadk/server/static/assets/railroad-abnf-HS5TGJTU-OKn8zZnU.js +1 -0
  335. ksadk/server/static/assets/railroad-ebnf-LZEXJU2U-DdKAKC4l.js +1 -0
  336. ksadk/server/static/assets/railroad-peg-WCYAUIDC-Bds8udlG.js +1 -0
  337. ksadk/server/static/assets/railroadDiagram-O6MQD6OU-Dt3FFDmK.js +1 -0
  338. ksadk/server/static/assets/requirementDiagram-EFPCY7ZU-Cz_vQtgF.js +84 -0
  339. ksadk/server/static/assets/{sankeyDiagram-5OEKKPKP-l6iXmU65.js → sankeyDiagram-P5KCCOFB-wlO1TsH5.js} +7 -7
  340. ksadk/server/static/assets/{sequenceDiagram-3UESZ5HK-Cq6UBeQB.js → sequenceDiagram-WJ2MYXX4-CHRbElYq.js} +10 -10
  341. ksadk/server/static/assets/sizeCapture-X5ZJPWSS-DXX54iyO.js +1 -0
  342. ksadk/server/static/assets/stateDiagram-HBIQ2CUA-Dp_lw9II.js +1 -0
  343. ksadk/server/static/assets/stateDiagram-v2-4QOOHH4V-5b-8o2iS.js +1 -0
  344. ksadk/server/static/assets/swimlanes-XN3QIQJK-D8O5UlLG.js +1 -0
  345. ksadk/server/static/assets/swimlanesDiagram-VK2B7HYN-uf6WPRA4.js +8 -0
  346. ksadk/server/static/assets/{timeline-definition-PNZ67QCA-DEz24vV0.js → timeline-definition-24CTP7MA-8mfKcxvG.js} +7 -7
  347. ksadk/server/static/assets/treeView-Q6P3EWNA-CK5CSrSO.js +1 -0
  348. ksadk/server/static/assets/treemap-WGGIJYW6-Ch2BKgAM.js +1 -0
  349. ksadk/server/static/assets/vennDiagram-4TSXK5OY-EnNl0DE7.js +34 -0
  350. ksadk/server/static/assets/wardley-WFR3VGLG-YW3Q0ml_.js +1 -0
  351. ksadk/server/static/assets/wardleyDiagram-VM6X3IG4-DBu5fbYr.js +78 -0
  352. ksadk/server/static/assets/xychartDiagram-S5SC5T6Z-BSVXs4IY.js +7 -0
  353. ksadk/server/static/index.html +2 -2
  354. ksadk/sessions/__init__.py +26 -9
  355. ksadk/sessions/_local_service_sync.py +688 -0
  356. ksadk/sessions/_local_tables.py +21 -0
  357. ksadk/sessions/_postgres_schema.py +305 -0
  358. ksadk/sessions/_postgres_tables.py +15 -0
  359. ksadk/sessions/base.py +80 -1
  360. ksadk/sessions/in_memory.py +53 -2
  361. ksadk/sessions/local_service.py +63 -604
  362. ksadk/sessions/postgres_service.py +58 -148
  363. ksadk/sessions/resilient.py +35 -1
  364. ksadk/skills/service_client.py +48 -0
  365. ksadk/studio/agent_lifecycle.py +26 -20
  366. ksadk/studio/api.py +1063 -69
  367. ksadk/studio/api_catalog_routes.py +5 -0
  368. ksadk/studio/api_contracts.py +148 -6
  369. ksadk/studio/api_memory_routes.py +82 -0
  370. ksadk/studio/api_plugin_routes.py +480 -0
  371. ksadk/studio/attachment_store.py +187 -0
  372. ksadk/studio/authoring.py +259 -28
  373. ksadk/studio/authoring_coordinator.py +809 -67
  374. ksadk/studio/builder.py +197 -40
  375. ksadk/studio/capabilities.py +17 -0
  376. ksadk/studio/cloud.py +1920 -95
  377. ksadk/studio/codex_agent_service.py +137 -15
  378. ksadk/studio/codex_authoring.py +469 -0
  379. ksadk/studio/codex_builder.py +177 -25
  380. ksadk/studio/codex_manifest.py +71 -22
  381. ksadk/studio/codex_run.py +59 -4
  382. ksadk/studio/compatibility_report.py +445 -0
  383. ksadk/studio/compiler.py +7 -1
  384. ksadk/studio/contracts.py +234 -4
  385. ksadk/studio/dsh_provider_registration.py +768 -0
  386. ksadk/studio/event_store.py +79 -101
  387. ksadk/studio/framework_run.py +264 -3
  388. ksadk/studio/hosted_kernel.py +317 -0
  389. ksadk/studio/manifest_resolver.py +131 -0
  390. ksadk/studio/model_client.py +95 -9
  391. ksadk/studio/model_profile_service.py +1 -1
  392. ksadk/studio/operations.py +47 -5
  393. ksadk/studio/otel_trace.py +348 -42
  394. ksadk/studio/pcm_memory.py +85 -0
  395. ksadk/studio/plugin_composition.py +301 -0
  396. ksadk/studio/plugin_kernel_adapter.py +128 -0
  397. ksadk/studio/plugin_runtime.py +652 -0
  398. ksadk/studio/repository.py +23 -2
  399. ksadk/studio/resource_catalog.py +49 -1
  400. ksadk/studio/run_service.py +1266 -202
  401. ksadk/studio/runtime_source.py +51 -17
  402. ksadk/studio/scheduler_runtime.py +374 -0
  403. ksadk/studio/scheduler_service.py +211 -0
  404. ksadk/studio/service.py +1815 -117
  405. ksadk/studio/shared_web.py +199 -50
  406. ksadk/studio/soul.py +52 -0
  407. ksadk/studio/static/assets/index-DXs1Eq4M.css +1 -0
  408. ksadk/studio/static/assets/index-DeDqwTsD.js +283 -0
  409. ksadk/studio/static/index.html +2 -2
  410. ksadk/studio/templates.py +100 -8
  411. ksadk/studio/validator.py +20 -1
  412. ksadk/studio/workspace.py +31 -32
  413. ksadk/tracing/setup.py +304 -3
  414. ksadk/version.py +1 -1
  415. ksadk_runtime_common/schemas/runtime_event_v1.json +37 -0
  416. ksadk_runtime_common/schemas/runtime_event_v2.json +271 -0
  417. ksadk_runtime_common/workspace_files/path_utils.py +41 -0
  418. ksadk/events/parser.py +0 -191
  419. ksadk/server/static/assets/MathMessageMarkdown-DeYh8QPr.css +0 -1
  420. ksadk/server/static/assets/MermaidBlock-NERD_ef7.js +0 -303
  421. ksadk/server/static/assets/NativeTerminalPanel-CiHWf82q.js +0 -1
  422. ksadk/server/static/assets/arc-DrgjcKhl.js +0 -1
  423. ksadk/server/static/assets/architecture-7EHR7CIX-CpZLRCcY.js +0 -1
  424. ksadk/server/static/assets/architectureDiagram-3BPJPVTR-BXoVRbQ6.js +0 -36
  425. ksadk/server/static/assets/blockDiagram-GPEHLZMM-BnaB3Ywv.js +0 -132
  426. ksadk/server/static/assets/c4Diagram-AAUBKEIU-Dw4weZcp.js +0 -10
  427. ksadk/server/static/assets/channel-Dt3rQTFI.js +0 -1
  428. ksadk/server/static/assets/chunk-2J33WTMH-ChjpLK7j.js +0 -1
  429. ksadk/server/static/assets/chunk-55IACEB6-CK4ROATy.js +0 -1
  430. ksadk/server/static/assets/chunk-727SXJPM-B9KtTTgw.js +0 -206
  431. ksadk/server/static/assets/chunk-AQP2D5EJ-1O1mki3m.js +0 -231
  432. ksadk/server/static/assets/chunk-QZHKN3VN-iXEZtKQc.js +0 -1
  433. ksadk/server/static/assets/classDiagram-4FO5ZUOK-DIveV17Q.js +0 -1
  434. ksadk/server/static/assets/classDiagram-v2-Q7XG4LA2-DIveV17Q.js +0 -1
  435. ksadk/server/static/assets/cytoscape.esm-DelgaX4f.js +0 -321
  436. ksadk/server/static/assets/dagre-BM42HDAG-D0QXg_MQ.js +0 -4
  437. ksadk/server/static/assets/dagre-Bx709z4p.js +0 -1
  438. ksadk/server/static/assets/diagram-2AECGRRQ-DBDDKwBQ.js +0 -43
  439. ksadk/server/static/assets/diagram-5GNKFQAL-BkwDOxmO.js +0 -10
  440. ksadk/server/static/assets/diagram-KO2AKTUF-Bv8waYvY.js +0 -3
  441. ksadk/server/static/assets/diagram-LMA3HP47-e1VECDdu.js +0 -24
  442. ksadk/server/static/assets/diagram-OG6HWLK6-Ds9adfRl.js +0 -24
  443. ksadk/server/static/assets/dist-B5J2uhNk.js +0 -1
  444. ksadk/server/static/assets/erDiagram-TEJ5UH35-BPItFKH6.js +0 -85
  445. ksadk/server/static/assets/eventmodeling-FCH6USID-DYFSxbqr.js +0 -1
  446. ksadk/server/static/assets/flowDiagram-I6XJVG4X-BbYB9w5Y.js +0 -162
  447. ksadk/server/static/assets/ganttDiagram-6RSMTGT7-TQXa0KXv.js +0 -292
  448. ksadk/server/static/assets/gitGraph-WXDBUCRP-DyNYp6BN.js +0 -1
  449. ksadk/server/static/assets/gitGraphDiagram-PVQCEYII-bKblCnpa.js +0 -106
  450. ksadk/server/static/assets/graphlib-B8gBHxth.js +0 -1
  451. ksadk/server/static/assets/index-R52pJJvg.js +0 -177
  452. ksadk/server/static/assets/index-jJr3_955.css +0 -2
  453. ksadk/server/static/assets/info-J43DQDTF-WUx7qTde.js +0 -1
  454. ksadk/server/static/assets/infoDiagram-5YYISTIA-BGVT7J_i.js +0 -2
  455. ksadk/server/static/assets/katex-CYWseY5E.js +0 -265
  456. ksadk/server/static/assets/mermaid-parser.core-BWrDjSZM.js +0 -161
  457. ksadk/server/static/assets/packet-YPE3B663-BN13y_xl.js +0 -1
  458. ksadk/server/static/assets/pie-LRSECV5Y-ftSLXhhc.js +0 -1
  459. ksadk/server/static/assets/pieDiagram-4H26LBE5-GmmqNiVH.js +0 -30
  460. ksadk/server/static/assets/radar-GUYGQ44K-jaICkIeO.js +0 -1
  461. ksadk/server/static/assets/requirementDiagram-4Y6WPE33-DSWFqL7c.js +0 -84
  462. ksadk/server/static/assets/stateDiagram-AJRCARHV-qzTQpg3Y.js +0 -1
  463. ksadk/server/static/assets/stateDiagram-v2-BHNVJYJU-BYCoYGAN.js +0 -1
  464. ksadk/server/static/assets/treeView-BLDUP644-Cpib_vlz.js +0 -1
  465. ksadk/server/static/assets/treemap-LRROVOQU-DQxfmVHK.js +0 -1
  466. ksadk/server/static/assets/vennDiagram-CIIHVFJN-CMCixki2.js +0 -34
  467. ksadk/server/static/assets/wardley-L42UT6IY-BQYHnnkU.js +0 -1
  468. ksadk/server/static/assets/wardleyDiagram-YWT4CUSO-CVzB8F9K.js +0 -78
  469. ksadk/server/static/assets/xychartDiagram-2RQKCTM6-BFBqBLc1.js +0 -7
  470. ksadk/studio/evaluation.py +0 -192
  471. ksadk/studio/static/assets/index-CaEN0eTR.css +0 -1
  472. ksadk/studio/static/assets/index-DI2eY_NH.js +0 -252
  473. {agentengine_sdk_python-0.8.1.dist-info → agentengine_sdk_python-0.8.3.dist-info}/WHEEL +0 -0
  474. {agentengine_sdk_python-0.8.1.dist-info → agentengine_sdk_python-0.8.3.dist-info}/entry_points.txt +0 -0
  475. {agentengine_sdk_python-0.8.1.dist-info → agentengine_sdk_python-0.8.3.dist-info}/licenses/LICENSE +0 -0
  476. {agentengine_sdk_python-0.8.1.dist-info → agentengine_sdk_python-0.8.3.dist-info}/top_level.txt +0 -0
@@ -3,15 +3,54 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import hashlib
7
+ import json
8
+ import logging
6
9
  import time
7
10
  from collections.abc import Callable, Mapping
8
- from dataclasses import dataclass, field
11
+ from dataclasses import asdict, dataclass, field
9
12
  from datetime import datetime, timezone
13
+ from pathlib import Path
10
14
  from typing import Any
11
15
  from uuid import uuid4
12
16
 
13
17
  from ksadk.agui.a2ui_projection import project_a2ui_operations
14
- from ksadk.events.runtime_event import EventType, RuntimeEvent
18
+ from ksadk.conversations.projector import (
19
+ project_conversation_item,
20
+ project_interaction_conversation_item,
21
+ )
22
+ from ksadk.events.canonical import (
23
+ ContinuationCreated,
24
+ ContinuationResumed,
25
+ ErrorInfo,
26
+ InteractionRequested,
27
+ InteractionResolved,
28
+ ItemCompleted,
29
+ ItemFailed,
30
+ ItemStarted,
31
+ ItemUpdated,
32
+ OutputRef,
33
+ RunCanceled,
34
+ RunCompleted,
35
+ RunFailed,
36
+ RunInterrupted,
37
+ RunProgress,
38
+ RunStarted,
39
+ RuntimeEvent,
40
+ SourceRef,
41
+ UsageReported,
42
+ dump_runtime_event,
43
+ parse_runtime_event,
44
+ )
45
+ from ksadk.events.canonical_store import session_event_to_runtime_event
46
+ from ksadk.events.content import (
47
+ ContentSnapshot,
48
+ DataContent,
49
+ TextContent,
50
+ ToolCallContent,
51
+ ToolResultContent,
52
+ )
53
+ from ksadk.events.store import RuntimeEventStore
15
54
  from ksadk.runtime import (
16
55
  CONVERSATION_PREPROCESSING_METADATA_KEY,
17
56
  PauseResult,
@@ -21,6 +60,8 @@ from ksadk.runtime import (
21
60
  RuntimeLaunchContext,
22
61
  StartRequest,
23
62
  )
63
+ from ksadk.sessions.base import BaseSessionService
64
+ from ksadk.sessions.local_service import LocalSessionService
24
65
  from ksadk.studio.contracts import RunEvent, RunRecord, RunStatus, Usage
25
66
  from ksadk.studio.errors import StudioError
26
67
  from ksadk.studio.event_store import RunEventStore
@@ -29,6 +70,9 @@ from ksadk.studio.workspace import Workspace
29
70
  _CANCEL_TIMEOUT_SECONDS = 2.0
30
71
 
31
72
 
73
+ logger = logging.getLogger(__name__)
74
+
75
+
32
76
  @dataclass(frozen=True)
33
77
  class StudioRunSpec:
34
78
  """Product metadata plus the core launch context for one Studio run."""
@@ -39,6 +83,7 @@ class StudioRunSpec:
39
83
  model: str | None = None
40
84
  request_config: Mapping[str, Any] = field(default_factory=dict)
41
85
  manifest_sha256: str = ""
86
+ plugin_bundle_root: Path | None = None
42
87
 
43
88
 
44
89
  class StudioRunService:
@@ -50,14 +95,57 @@ class StudioRunService:
50
95
  executor: RuntimeExecutor,
51
96
  *,
52
97
  event_store: RunEventStore | None = None,
98
+ session_service: BaseSessionService | None = None,
99
+ runtime_events: RuntimeEventStore | None = None,
100
+ plugin_runtime: Any | None = None,
53
101
  ) -> None:
54
102
  self.workspace = workspace
55
103
  self.executor = executor
56
104
  self.event_store = event_store or RunEventStore(workspace)
105
+ self.session_service = session_service or LocalSessionService(
106
+ project_dir=str(workspace.root)
107
+ )
108
+ self.runtime_events = runtime_events or RuntimeEventStore(self.session_service)
109
+ self.plugin_runtime = plugin_runtime
57
110
  self._active_handles: dict[str, Any] = {}
58
111
  self._cancel_flags: dict[str, bool] = {}
59
- self._control_queues: dict[str, asyncio.Queue[tuple[str, ResumePayload | None]]] = {}
112
+ self._control_queues: dict[
113
+ str,
114
+ asyncio.Queue[tuple[str, ResumePayload | None, asyncio.Future[None] | None]],
115
+ ] = {}
60
116
  self._waiting_modes: dict[str, str] = {}
117
+ # Interaction resolution appends to one run-level event file. Serialise
118
+ # every decision for that run, not only identical interaction ids, so
119
+ # two concurrently visible cards cannot overwrite each other's receipt.
120
+ self._interaction_locks: dict[str, asyncio.Lock] = {}
121
+
122
+ async def recover_interrupted(self) -> None:
123
+ """Settle local runs left active across a Studio restart.
124
+
125
+ An in-process handle cannot be resumed safely. Keep its durable event
126
+ history and record an explicit interruption; lease-backed hosted
127
+ recovery remains owned by the Kernel composition root.
128
+ """
129
+ active = {RunStatus.RUNNING, RunStatus.WAITING_INPUT}
130
+ for record in self.event_store.list_runs():
131
+ if record.status not in active:
132
+ continue
133
+ record.status = RunStatus.INTERRUPTED
134
+ record.completed_at = datetime.now(timezone.utc)
135
+ record.error = {
136
+ "code": "LOCAL_STUDIO_RESTARTED",
137
+ "message": "本地 Studio 重启,运行未在本地恢复。",
138
+ }
139
+ self.event_store.save(record)
140
+ self.event_store.append(
141
+ record.id,
142
+ "run.interrupted",
143
+ {"reason": "local_studio_restarted", "recoverable": False},
144
+ )
145
+
146
+ async def events(self, run_id: str, *, after: int = 0) -> list[RunEvent]:
147
+ """Read the durable Studio event timeline for API/SSE replay."""
148
+ return self.event_store.events(run_id, after=after)
61
149
 
62
150
  async def run(
63
151
  self,
@@ -84,7 +172,9 @@ class StudioRunService:
84
172
  goal_objective=str(spec.request_config.get("goal_objective") or ""),
85
173
  input=user_input,
86
174
  )
175
+ await self.session_service.create_session(spec.agent_id, "local-user", session)
87
176
  self.event_store.create(record)
177
+ prepared_turn = await self._capture_pcm_evidence(record, spec, user_input)
88
178
  created = self.event_store.append(
89
179
  record.id,
90
180
  "run.created",
@@ -103,23 +193,56 @@ class StudioRunService:
103
193
  if on_event is not None:
104
194
  on_event(created)
105
195
 
196
+ kernel_runtime = self._kernel_runtime_for_spec(spec)
197
+ if kernel_runtime is not None:
198
+ return await self._kernel_run(
199
+ spec,
200
+ user_input,
201
+ record=record,
202
+ on_event=on_event,
203
+ kernel_runtime=kernel_runtime,
204
+ )
205
+ if spec.plugin_bundle_root is not None:
206
+ return await self._plugin_run(
207
+ spec,
208
+ user_input,
209
+ record=record,
210
+ on_event=on_event,
211
+ )
212
+
106
213
  started = time.monotonic()
107
214
  record.status = RunStatus.RUNNING
108
215
  record.started_at = datetime.now(timezone.utc)
109
216
  self.event_store.save(record)
110
217
 
111
- def persist(runtime_event: RuntimeEvent) -> RunEvent:
112
- event_type, data = project_runtime_event(runtime_event)
218
+ async def persist(runtime_event: RuntimeEvent) -> RunEvent:
219
+ persisted = await self.runtime_events.append_one(record.session_id, runtime_event)
220
+ event_type, data = project_runtime_event(
221
+ persisted,
222
+ session_id=record.session_id,
223
+ public_run_id=record.id,
224
+ )
225
+ if isinstance(persisted, InteractionRequested):
226
+ data["revision"] = 1
227
+ conversation_item = data.get("conversationItem")
228
+ if isinstance(conversation_item, dict):
229
+ item_payload = conversation_item.get("payload")
230
+ if isinstance(item_payload, dict):
231
+ item_payload["revision"] = 1
113
232
  stored = self.event_store.append(record.id, event_type, data)
114
233
  if on_event is not None:
115
234
  on_event(stored)
116
235
  return stored
117
236
 
118
237
  handle = None
119
- final_text = ""
120
- streamed_final = ""
238
+ completed_text_by_item: dict[tuple[str, str], str] = {}
239
+ streamed_text_by_item: dict[tuple[str, str], str] = {}
240
+ terminal_output_refs: tuple[OutputRef, ...] = ()
121
241
  runtime_duration_ms: int | None = None
122
- control_queue: asyncio.Queue[tuple[str, ResumePayload | None]] = asyncio.Queue()
242
+ item_phases: dict[tuple[str, str], str | None] = {}
243
+ control_queue: asyncio.Queue[
244
+ tuple[str, ResumePayload | None, asyncio.Future[None] | None]
245
+ ] = asyncio.Queue()
123
246
  self._control_queues[run_id] = control_queue
124
247
  try:
125
248
  tool_approval_mode = str(spec.request_config.get("tool_approval_mode") or "")
@@ -134,6 +257,8 @@ class StudioRunService:
134
257
  conversation_request["request_metadata"] = {
135
258
  "tool_approval_mode": tool_approval_mode,
136
259
  }
260
+ if prepared_turn is not None:
261
+ conversation_request["prepared_turn"] = asdict(prepared_turn)
137
262
  request = StartRequest(
138
263
  input=runtime_input if runtime_input is not None else user_input,
139
264
  user_id="local-user",
@@ -155,63 +280,76 @@ class StudioRunService:
155
280
  terminal_seen = False
156
281
  should_resume = False
157
282
  async for event in self.executor.stream(handle):
158
- persist(event)
283
+ await persist(event)
159
284
  if self._cancel_flags.get(run_id):
160
285
  raise asyncio.CancelledError()
161
- if event.event_type == EventType.TEXT_DELTA and event.phase == "final_answer":
162
- text = str(event.payload.get("text") or "")
163
- if event.payload.get("replace"):
164
- streamed_final = text
286
+ if isinstance(event, ItemStarted) and event.item_kind == "message":
287
+ item_phases[(event.scope_id, event.item_id)] = event.phase
288
+ elif (
289
+ isinstance(event, ItemUpdated)
290
+ and event.item_kind == "message"
291
+ and item_phases.get(
292
+ (event.scope_id, event.item_id), "final_answer"
293
+ )
294
+ == "final_answer"
295
+ ):
296
+ text = event.update.text if isinstance(event.update, TextContent) else ""
297
+ item_key = (event.scope_id, event.item_id)
298
+ if event.op == "replace":
299
+ streamed_text_by_item[item_key] = text
165
300
  else:
166
- streamed_final += text
301
+ streamed_text_by_item[item_key] = (
302
+ streamed_text_by_item.get(item_key, "") + text
303
+ )
167
304
  elif (
168
- event.event_type == EventType.TEXT_COMPLETED
169
- and event.phase == "final_answer"
305
+ isinstance(event, ItemCompleted)
306
+ and event.item_kind == "message"
307
+ and item_phases.get(
308
+ (event.scope_id, event.item_id), "final_answer"
309
+ )
310
+ == "final_answer"
170
311
  ):
171
- final_text = str(event.payload.get("text") or "")
172
- elif event.event_type == EventType.USAGE_REPORTED:
312
+ completed_text_by_item[(event.scope_id, event.item_id)] = "".join(
313
+ part.text
314
+ for part in event.snapshot.parts
315
+ if isinstance(part, TextContent)
316
+ )
317
+ elif isinstance(event, UsageReported):
173
318
  record.usage = Usage(
174
- input_tokens=int(event.payload.get("input_tokens") or 0),
175
- output_tokens=int(event.payload.get("output_tokens") or 0),
176
- total_tokens=int(event.payload.get("total_tokens") or 0),
177
- cached_input_tokens=int(event.payload.get("cached_tokens") or 0),
178
- reasoning_output_tokens=int(event.payload.get("reasoning_tokens") or 0),
319
+ input_tokens=event.input_tokens,
320
+ output_tokens=event.output_tokens,
321
+ total_tokens=event.total_tokens,
322
+ cached_input_tokens=event.cached_tokens,
323
+ reasoning_output_tokens=event.reasoning_tokens,
179
324
  reported=True,
180
- source=str(event.payload.get("source") or runtime_type),
325
+ source=str(event.source.framework or runtime_type),
181
326
  )
182
- elif event.event_type in {
183
- EventType.APPROVAL_REQUESTED,
184
- EventType.A2UI_INTERACTION,
185
- }:
327
+ elif isinstance(event, InteractionRequested):
186
328
  record.status = RunStatus.WAITING_INPUT
187
329
  self._waiting_modes[run_id] = "live"
188
330
  self.event_store.save(record)
189
- if event.event_type == EventType.APPROVAL_REQUESTED:
331
+ if event.interaction_kind == "approval":
190
332
  self._persist_approval_surface(record, event, on_event=on_event)
191
- elif event.event_type in {
192
- EventType.APPROVAL_RESOLVED,
193
- EventType.A2UI_ACTION,
194
- }:
333
+ elif isinstance(event, InteractionResolved):
195
334
  record.status = RunStatus.RUNNING
196
335
  self._waiting_modes.pop(run_id, None)
197
336
  self.event_store.save(record)
198
- elif event.event_type == EventType.RUN_FAILED:
337
+ elif isinstance(event, RunFailed):
199
338
  terminal_seen = True
200
339
  record.status = RunStatus.FAILED
201
340
  record.error = {
202
341
  "code": "RUNTIME_RUN_FAILED",
203
- "message": str(event.payload.get("error") or "Runtime 运行失败"),
342
+ "message": str(event.error.message or "Runtime 运行失败"),
204
343
  }
205
- elif event.event_type == EventType.RUN_CANCELED:
344
+ elif isinstance(event, RunCanceled):
206
345
  terminal_seen = True
207
346
  record.status = RunStatus.CANCELLED
208
347
  record.error = {
209
348
  "code": "RUN_CANCELLED",
210
- "message": str(event.payload.get("status") or "运行已取消"),
349
+ "message": str(event.reason or "运行已取消"),
211
350
  }
212
- elif event.event_type == EventType.RUN_INTERRUPTED:
213
- interrupted_status = str(event.payload.get("status") or "")
214
- if interrupted_status == "paused":
351
+ elif isinstance(event, RunInterrupted):
352
+ if event.reason == "user_pause":
215
353
  record.status = RunStatus.PAUSED
216
354
  should_resume = True
217
355
  elif record.status == RunStatus.WAITING_INPUT:
@@ -222,13 +360,18 @@ class StudioRunService:
222
360
  record.status = RunStatus.INTERRUPTED
223
361
  record.error = {
224
362
  "code": "RUN_INTERRUPTED",
225
- "message": interrupted_status or "运行已中断",
363
+ "message": event.reason or "运行已中断",
226
364
  }
227
365
  self.event_store.save(record)
228
- elif event.event_type == EventType.RUN_COMPLETED:
366
+ elif isinstance(event, RunCompleted):
229
367
  terminal_seen = True
230
368
  record.status = RunStatus.COMPLETED
231
- raw_duration = event.payload.get("duration_ms")
369
+ terminal_output_refs = event.output_refs
370
+ raw_duration = event.source.metadata.get("duration_ms")
371
+ if raw_duration is None:
372
+ metrics = event.source.metadata.get("metrics")
373
+ if isinstance(metrics, dict):
374
+ raw_duration = metrics.get("duration_ms")
232
375
  if raw_duration is not None:
233
376
  runtime_duration_ms = max(0, int(raw_duration))
234
377
  if terminal_seen:
@@ -237,7 +380,7 @@ class StudioRunService:
237
380
  record.status = RunStatus.COMPLETED
238
381
  break
239
382
 
240
- command, resume_payload = await control_queue.get()
383
+ command, resume_payload, submit_ack = await control_queue.get()
241
384
  if command == "cancel" or self._cancel_flags.get(run_id):
242
385
  raise asyncio.CancelledError()
243
386
  if command != "resume":
@@ -248,11 +391,18 @@ class StudioRunService:
248
391
  if native_thread_id
249
392
  else ResumeTarget(kind="invocation_id", id=handle.run_id)
250
393
  )
251
- handle = await self.executor.resume(
252
- handle,
253
- target,
254
- resume_payload,
255
- )
394
+ try:
395
+ handle = await self.executor.resume(
396
+ handle,
397
+ target,
398
+ resume_payload,
399
+ )
400
+ except Exception as exc:
401
+ if submit_ack is not None and not submit_ack.done():
402
+ submit_ack.set_exception(exc)
403
+ raise
404
+ if submit_ack is not None and not submit_ack.done():
405
+ submit_ack.set_result(None)
256
406
  self._active_handles[run_id] = handle
257
407
  record.runtime_handle = handle.model_dump(mode="json")
258
408
  record.status = RunStatus.RUNNING
@@ -266,7 +416,17 @@ class StudioRunService:
266
416
  if on_event is not None:
267
417
  on_event(resumed)
268
418
  self.event_store.save(record)
269
- record.output = final_text or streamed_final
419
+ text_by_item = {**streamed_text_by_item, **completed_text_by_item}
420
+ if terminal_output_refs:
421
+ terminal_parts = [
422
+ text_by_item.get((ref.scope_id, ref.item_id), "")
423
+ for ref in terminal_output_refs
424
+ ]
425
+ record.output = "\n\n".join(part for part in terminal_parts if part)
426
+ else:
427
+ completed_parts = [part for part in completed_text_by_item.values() if part]
428
+ fallback_parts = [part for part in streamed_text_by_item.values() if part]
429
+ record.output = "\n\n".join(completed_parts or fallback_parts)
270
430
  except asyncio.CancelledError:
271
431
  cancel_result = "task_cancelled"
272
432
  if handle is not None and self.executor.is_attached(handle):
@@ -282,30 +442,44 @@ class StudioRunService:
282
442
  cancel_result = "cancel_failed"
283
443
  record.status = RunStatus.CANCELLED
284
444
  record.error = {"code": "RUN_CANCELLED", "message": "运行已取消"}
285
- cancelled = RuntimeEvent.create(
286
- EventType.RUN_CANCELED,
287
- agent_id=spec.agent_id,
288
- user_id="local-user",
289
- session_id=session,
290
- invocation_id=handle.run_id if handle is not None else run_id,
291
- seq_id=len(self.event_store.events(run_id)) + 1,
292
- payload={"status": "cancelled", "cancel_result": cancel_result},
445
+ cancelled_run_id = handle.run_id if handle is not None else run_id
446
+ cancelled = RunCanceled(
447
+ schema_version=2,
448
+ event_id=f"evt_cancel_{uuid4().hex}",
449
+ seq=len(self.event_store.events(run_id)) + 1,
450
+ timestamp=time.time(),
451
+ run_id=cancelled_run_id,
452
+ scope_id=cancelled_run_id,
453
+ source=SourceRef(
454
+ framework="ksadk",
455
+ metadata={"cancel_result": cancel_result},
456
+ ),
457
+ status="canceled",
458
+ reason="cancelled",
293
459
  )
294
- persist(cancelled)
460
+ await persist(cancelled)
295
461
  raise
296
462
  except Exception as exc: # noqa: BLE001
297
463
  record.status = RunStatus.FAILED
298
464
  record.error = {"code": "RUNTIME_RUN_FAILED", "message": str(exc)}
299
- failure = RuntimeEvent.create(
300
- EventType.RUN_FAILED,
301
- agent_id=spec.agent_id,
302
- user_id="local-user",
303
- session_id=session,
304
- invocation_id=handle.run_id if handle is not None else run_id,
305
- seq_id=len(self.event_store.events(run_id)) + 1,
306
- payload={"status": "failed", "error": str(exc)},
465
+ failed_run_id = handle.run_id if handle is not None else run_id
466
+ failure = RunFailed(
467
+ schema_version=2,
468
+ event_id=f"evt_fail_{uuid4().hex}",
469
+ seq=len(self.event_store.events(run_id)) + 1,
470
+ timestamp=time.time(),
471
+ run_id=failed_run_id,
472
+ scope_id=failed_run_id,
473
+ source=SourceRef(framework="ksadk"),
474
+ status="failed",
475
+ error=ErrorInfo(
476
+ code="RUNTIME_RUN_FAILED",
477
+ message=str(exc),
478
+ source="ksadk",
479
+ scope_id=failed_run_id,
480
+ ),
307
481
  )
308
- persist(failure)
482
+ await persist(failure)
309
483
  finally:
310
484
  if handle is not None and self.executor.is_attached(handle):
311
485
  try:
@@ -326,14 +500,385 @@ class StudioRunService:
326
500
  record.duration_ms = int((time.monotonic() - started) * 1000)
327
501
  record.duration_source = "studio"
328
502
  self.event_store.save(record)
503
+ await self._sync_trace(record)
329
504
  return record
330
505
 
506
+ async def _sync_trace(self, record: RunRecord) -> None:
507
+ self.event_store.trace_store.sync(record, await self.events(record.id))
508
+
509
+ async def _plugin_run(
510
+ self,
511
+ spec: StudioRunSpec,
512
+ user_input: str,
513
+ *,
514
+ record: RunRecord,
515
+ on_event: Callable[[RunEvent], None] | None,
516
+ ) -> RunRecord:
517
+ """Execute a composed Build without translating it into ADK/LangGraph.
518
+
519
+ PluginHost retains the activation under the Studio session id. Harness
520
+ providers write canonical RuntimeEvents directly; providers that only
521
+ implement the minimum request/result protocol receive a canonical
522
+ envelope here so Studio still has one durable conversation/event model.
523
+ """
524
+
525
+ started = time.monotonic()
526
+ record.status = RunStatus.RUNNING
527
+ record.started_at = datetime.now(timezone.utc)
528
+ record.runtime_handle = {
529
+ "provider": "pluginhost",
530
+ "bundleDigest": str(spec.request_config.get("plugin_bundle_digest") or ""),
531
+ "activationKey": record.session_id,
532
+ }
533
+ self.event_store.save(record)
534
+ rows_before = await self.session_service.get_events(record.session_id)
535
+ after_seq = max((int(row.seq_id or 0) for row in rows_before), default=0)
536
+
537
+ async def publish(events: list[RuntimeEvent]) -> None:
538
+ for runtime_event in events:
539
+ event_type, data = project_runtime_event(
540
+ runtime_event,
541
+ session_id=record.session_id,
542
+ )
543
+ stored = self.event_store.append(record.id, event_type, data)
544
+ if on_event is not None:
545
+ on_event(stored)
546
+
547
+ try:
548
+ if self.plugin_runtime is None:
549
+ raise StudioError(
550
+ "PLUGIN_RUNTIME_UNAVAILABLE",
551
+ "Studio 尚未配置 PluginHost Runtime",
552
+ status_code=503,
553
+ )
554
+ request_metadata = {
555
+ key: value
556
+ for key, value in {
557
+ "tool_approval_mode": spec.request_config.get("tool_approval_mode"),
558
+ "collaboration_mode": spec.request_config.get("collaboration_mode"),
559
+ "goal_objective": spec.request_config.get("goal_objective"),
560
+ "reasoning_effort": spec.request_config.get("effort"),
561
+ }.items()
562
+ if value not in {None, ""}
563
+ }
564
+ result = await self.plugin_runtime.execute(
565
+ spec,
566
+ {
567
+ "user_id": "local-user",
568
+ "session_id": record.session_id,
569
+ "invocation_id": record.id,
570
+ "messages": [{"role": "user", "content": user_input}],
571
+ "model": spec.model,
572
+ "request_metadata": request_metadata,
573
+ },
574
+ session_id=record.session_id,
575
+ )
576
+ if result.session_id != record.session_id:
577
+ raise StudioError(
578
+ "PLUGIN_SESSION_MISMATCH",
579
+ "AgentProvider 返回了不同的 Session",
580
+ status_code=502,
581
+ details={
582
+ "expected": record.session_id,
583
+ "actual": result.session_id,
584
+ },
585
+ )
586
+ canonical = await self._plugin_events_after(
587
+ record.session_id,
588
+ after_seq=after_seq,
589
+ run_id=record.id,
590
+ )
591
+ if not canonical:
592
+ canonical = await self._persist_plugin_result_events(
593
+ record,
594
+ result.output_text,
595
+ usage=result.usage,
596
+ )
597
+ await publish(canonical)
598
+ record.output = result.output_text
599
+ record.status = RunStatus.COMPLETED
600
+ _apply_plugin_usage(record, result.usage)
601
+ except asyncio.CancelledError:
602
+ record.status = RunStatus.CANCELLED
603
+ record.error = {"code": "RUN_CANCELLED", "message": "运行已取消"}
604
+ cancelled = await self.runtime_events.append_one(
605
+ record.session_id,
606
+ RunCanceled(
607
+ **_plugin_event_envelope(record, "run.canceled"),
608
+ status="canceled",
609
+ reason="cancelled",
610
+ ),
611
+ )
612
+ await publish([cancelled])
613
+ raise
614
+ except Exception as exc: # noqa: BLE001 - plugin boundary is typed below
615
+ record.status = RunStatus.FAILED
616
+ code = str(getattr(exc, "code", "PLUGIN_RUNTIME_FAILED"))
617
+ record.error = {"code": code, "message": str(exc)}
618
+ canonical = await self._plugin_events_after(
619
+ record.session_id,
620
+ after_seq=after_seq,
621
+ run_id=record.id,
622
+ )
623
+ if canonical:
624
+ await publish(canonical)
625
+ else:
626
+ failed = await self.runtime_events.append_one(
627
+ record.session_id,
628
+ RunFailed(
629
+ **_plugin_event_envelope(record, "run.failed"),
630
+ status="failed",
631
+ error=ErrorInfo(
632
+ code=code,
633
+ message=str(exc),
634
+ source="pluginhost",
635
+ scope_id=record.id,
636
+ ),
637
+ ),
638
+ )
639
+ await publish([failed])
640
+ finally:
641
+ record.completed_at = datetime.now(timezone.utc)
642
+ record.duration_ms = int((time.monotonic() - started) * 1000)
643
+ record.duration_source = "studio"
644
+ self.event_store.save(record)
645
+ await self._sync_trace(record)
646
+ return record
647
+
648
+ async def _plugin_events_after(
649
+ self,
650
+ session_id: str,
651
+ *,
652
+ after_seq: int,
653
+ run_id: str,
654
+ ) -> list[RuntimeEvent]:
655
+ rows = await self.session_service.get_events(
656
+ session_id,
657
+ after_seq_id=after_seq,
658
+ )
659
+ events: list[RuntimeEvent] = []
660
+ for row in rows:
661
+ event = session_event_to_runtime_event(row)
662
+ if event is not None and event.run_id == run_id:
663
+ events.append(event)
664
+ return events
665
+
666
+ async def _persist_plugin_result_events(
667
+ self,
668
+ record: RunRecord,
669
+ output_text: str,
670
+ *,
671
+ usage: Mapping[str, Any],
672
+ ) -> list[RuntimeEvent]:
673
+ item_id = f"{record.id}:assistant"
674
+ part_id = f"{record.id}:text"
675
+ snapshot = ContentSnapshot(
676
+ parts=(TextContent(part_id=part_id, text=output_text),)
677
+ )
678
+ events: list[RuntimeEvent] = [
679
+ RunStarted(
680
+ **_plugin_event_envelope(record, "run.started"),
681
+ status="running",
682
+ ),
683
+ ItemStarted(
684
+ **_plugin_event_envelope(record, "item.started"),
685
+ item_id=item_id,
686
+ item_kind="message",
687
+ phase="final_answer",
688
+ ),
689
+ ItemUpdated(
690
+ **_plugin_event_envelope(record, "item.updated"),
691
+ item_id=item_id,
692
+ item_kind="message",
693
+ op="append",
694
+ update=TextContent(part_id=part_id, text=output_text),
695
+ ),
696
+ ItemCompleted(
697
+ **_plugin_event_envelope(record, "item.completed"),
698
+ item_id=item_id,
699
+ item_kind="message",
700
+ snapshot=snapshot,
701
+ ),
702
+ ]
703
+ normalized = _normalized_plugin_usage(usage)
704
+ if normalized["reported"]:
705
+ events.append(
706
+ UsageReported(
707
+ **_plugin_event_envelope(record, "usage.reported"),
708
+ input_tokens=normalized["input_tokens"],
709
+ output_tokens=normalized["output_tokens"],
710
+ total_tokens=normalized["total_tokens"],
711
+ cached_tokens=normalized["cached_tokens"],
712
+ reasoning_tokens=normalized["reasoning_tokens"],
713
+ )
714
+ )
715
+ events.append(
716
+ RunCompleted(
717
+ **_plugin_event_envelope(record, "run.completed"),
718
+ status="completed",
719
+ output_refs=(
720
+ OutputRef(
721
+ scope_id=record.id,
722
+ item_id=item_id,
723
+ part_id=part_id,
724
+ ),
725
+ ),
726
+ )
727
+ )
728
+ return [
729
+ await self.runtime_events.append_one(record.session_id, event)
730
+ for event in events
731
+ ]
732
+
733
+ async def _kernel_run(
734
+ self,
735
+ spec: StudioRunSpec,
736
+ user_input: str,
737
+ *,
738
+ record: RunRecord,
739
+ on_event: Callable[[RunEvent], None] | None,
740
+ kernel_runtime: Any,
741
+ ) -> RunRecord:
742
+ """kernel 路径(灰度 opt-in):Studio run -> AgentControlCommand -> receipt。
743
+
744
+ mutation 只走 kernel.submit;Studio RunEvent shape 保留,cursor 源自
745
+ 同一 Session seq(SessionEventSubscription.after_seq)。
746
+ """
747
+ from ksadk.kernel import ingress as _kernel_ingress
748
+
749
+ started = time.monotonic()
750
+ record.status = RunStatus.RUNNING
751
+ record.started_at = datetime.now(timezone.utc)
752
+ self.event_store.save(record)
753
+ try:
754
+ trusted = _kernel_ingress.trusted_context(
755
+ source_kind="studio",
756
+ source_ref=record.id,
757
+ # The Worker polls the concrete Kernel Runtime instance, not
758
+ # whichever synthetic ``local-agent`` happened to be inferred
759
+ # from a Studio request. A Studio Build may use the Kernel
760
+ # path only after ``_kernel_runtime_for_spec`` established that
761
+ # it is this Runtime's exact Agent/Runtime binding.
762
+ tenant_id=str(kernel_runtime.config.tenant_id),
763
+ agent_instance_id=str(kernel_runtime.config.agent_instance_id),
764
+ session_id=record.session_id,
765
+ operations=("enqueue",),
766
+ )
767
+ idempotency_key = str(
768
+ (spec.request_config or {}).get("idempotency_key") or record.id
769
+ )
770
+ command = _kernel_ingress.map_studio_request(
771
+ session_id=record.session_id,
772
+ idempotency_key=idempotency_key,
773
+ content=user_input,
774
+ run_id=record.id,
775
+ trusted=trusted,
776
+ )
777
+ receipt = await _kernel_ingress.submit_command(
778
+ command, permit=trusted.permit
779
+ )
780
+ if receipt.status not in ("accepted", "duplicate"):
781
+ record.status = RunStatus.FAILED
782
+ record.error = {
783
+ "code": "kernel_command_rejected",
784
+ "message": f"agent kernel rejected command: {receipt.status}",
785
+ }
786
+ self.event_store.save(record)
787
+ return record
788
+ output_text = ""
789
+ async for _seq, projected in _kernel_ingress.subscribe_projected(
790
+ record.session_id,
791
+ trusted=trusted,
792
+ after_seq=int(receipt.accepted_seq or 0),
793
+ projector=_studio_envelope_projection,
794
+ ):
795
+ if projected is None:
796
+ continue
797
+ event_type, data = projected
798
+ if event_type == "message.delta":
799
+ output_text += str(data.get("delta") or "")
800
+ elif event_type == "run.completed":
801
+ output_text = str(data.get("output_text") or output_text)
802
+ stored = self.event_store.append(record.id, event_type, data)
803
+ if on_event is not None:
804
+ on_event(stored)
805
+ record.output = output_text
806
+ record.status = RunStatus.COMPLETED
807
+ record.completed_at = datetime.now(timezone.utc)
808
+ record.duration_ms = int((time.monotonic() - started) * 1000)
809
+ self.event_store.save(record)
810
+ await self._sync_trace(record)
811
+ return record
812
+
813
+ except Exception as exc: # noqa: BLE001
814
+ logger.exception("Studio kernel ingress failed for run %s", record.id)
815
+ record.status = RunStatus.FAILED
816
+ record.error = {
817
+ "code": "kernel_ingress_failed",
818
+ "message": str(exc),
819
+ }
820
+ record.completed_at = datetime.now(timezone.utc)
821
+ record.duration_ms = int((time.monotonic() - started) * 1000)
822
+ self.event_store.save(record)
823
+ await self._sync_trace(record)
824
+ return record
825
+
826
+ @staticmethod
827
+ def _kernel_runtime_for_spec(spec: StudioRunSpec) -> Any | None:
828
+ """Return the active Kernel Runtime only for its bound Studio Build.
829
+
830
+ An in-process AgentKernel owns one concrete RuntimeAdapter factory and
831
+ its immutable startup defaults. Merely observing that a Kernel is
832
+ enabled is therefore insufficient: sending an arbitrary Studio Build
833
+ through it can silently execute the default Adapter instead. When the
834
+ binding is not exact, the existing direct Studio path remains the
835
+ compatible local behaviour; it is safer than manufacturing a false
836
+ AgentControl/Scheduler success.
837
+ """
838
+
839
+ from ksadk.kernel.bootstrap import get_agent_kernel_runtime
840
+ from ksadk.kernel.ingress import kernel_route_active
841
+
842
+ if not kernel_route_active():
843
+ return None
844
+ runtime = get_agent_kernel_runtime()
845
+ if runtime is None:
846
+ return None
847
+ active_context = getattr(runtime.config, "launch_context", None)
848
+ if active_context is None:
849
+ return None
850
+ if str(getattr(active_context, "runtime_type", "")).strip().lower() != (
851
+ spec.launch_context.runtime_type.strip().lower()
852
+ ):
853
+ return None
854
+ try:
855
+ active_project = Path(active_context.project_dir).resolve()
856
+ build_project = Path(spec.launch_context.project_dir).resolve()
857
+ except (OSError, TypeError, ValueError):
858
+ return None
859
+ if active_project != build_project:
860
+ return None
861
+ defaults = getattr(runtime.config, "start_request_defaults", {}) or {}
862
+ bound_agent_id = str(
863
+ defaults.get("agent_id") or runtime.config.agent_instance_id
864
+ ).strip()
865
+ if bound_agent_id != spec.agent_id:
866
+ return None
867
+ declared_instance = str(
868
+ (spec.launch_context.config or {}).get("agent_instance_id") or ""
869
+ ).strip()
870
+ if declared_instance and declared_instance != str(
871
+ runtime.config.agent_instance_id
872
+ ):
873
+ return None
874
+ return runtime
875
+
331
876
  async def cancel_run(self, run_id: str) -> dict[str, str]:
332
877
  """Request cancellation; the flag and executor perform the actual stop."""
333
878
  self._cancel_flags[run_id] = True
334
879
  queue = self._control_queues.get(run_id)
335
880
  if queue is not None:
336
- queue.put_nowait(("cancel", None))
881
+ queue.put_nowait(("cancel", None, None))
337
882
  handle = self._active_handles.get(run_id)
338
883
  if handle is not None and self.executor.is_attached(handle):
339
884
  try:
@@ -384,7 +929,7 @@ class StudioRunService:
384
929
  "Studio 进程已重启,当前暂停点无法恢复",
385
930
  status_code=409,
386
931
  )
387
- queue.put_nowait(("resume", ResumePayload(kind="free_text", data="继续运行")))
932
+ queue.put_nowait(("resume", ResumePayload(kind="free_text", data="继续运行"), None))
388
933
  return {"runId": run_id, "status": "resuming"}
389
934
 
390
935
  async def submit_interaction(
@@ -394,28 +939,76 @@ class StudioRunService:
394
939
  *,
395
940
  name: str,
396
941
  data: dict[str, Any] | None = None,
942
+ expected_revision: int,
943
+ idempotency_key: str,
397
944
  ) -> dict[str, Any]:
398
- record = self.event_store.get(run_id)
399
- if record.status != RunStatus.WAITING_INPUT:
400
- raise StudioError(
401
- "INTERACTION_NOT_PENDING",
402
- "该 Run 当前没有等待中的交互",
403
- status_code=409,
945
+ lock = self._interaction_locks.setdefault(run_id, asyncio.Lock())
946
+ payload_data = dict(data or {})
947
+ digest = hashlib.sha256(
948
+ json.dumps(
949
+ {"name": name, "data": payload_data},
950
+ ensure_ascii=False,
951
+ sort_keys=True,
952
+ separators=(",", ":"),
953
+ ).encode("utf-8")
954
+ ).hexdigest()
955
+ async with lock:
956
+ return await self._submit_interaction_locked(
957
+ run_id,
958
+ interaction_id,
959
+ name=name,
960
+ data=payload_data,
961
+ expected_revision=expected_revision,
962
+ idempotency_key=idempotency_key,
963
+ request_digest=digest,
404
964
  )
405
- prior = [
406
- event
407
- for event in self.event_store.events(run_id)
408
- if event.type == "a2ui.action"
409
- and str(event.data.get("interactionId") or event.data.get("interaction_id") or "")
410
- == interaction_id
411
- ]
412
- if prior:
413
- return {"runId": run_id, "interactionId": interaction_id, "status": "resolved"}
965
+
966
+ async def _submit_interaction_locked(
967
+ self,
968
+ run_id: str,
969
+ interaction_id: str,
970
+ *,
971
+ name: str,
972
+ data: dict[str, Any],
973
+ expected_revision: int,
974
+ idempotency_key: str,
975
+ request_digest: str,
976
+ ) -> dict[str, Any]:
977
+ events = self.event_store.events(run_id)
978
+ prior = next(
979
+ (
980
+ event
981
+ for event in reversed(events)
982
+ if event.type == "a2ui.action"
983
+ and str(event.data.get("interactionId") or "") == interaction_id
984
+ and _interaction_revision(event.data) > 0
985
+ ),
986
+ None,
987
+ )
988
+ if prior is not None:
989
+ if str(prior.data.get("idempotencyKey") or "") != idempotency_key:
990
+ raise StudioError(
991
+ "INTERACTION_ALREADY_RESOLVED",
992
+ "该交互已由其他请求处理",
993
+ status_code=409,
994
+ )
995
+ if str(prior.data.get("requestDigest") or "") != request_digest:
996
+ raise StudioError(
997
+ "INTERACTION_IDEMPOTENCY_CONFLICT",
998
+ "同一幂等键不能提交不同内容",
999
+ status_code=409,
1000
+ )
1001
+ record = self.event_store.get(run_id)
1002
+ if record.status == RunStatus.WAITING_INPUT:
1003
+ record.status = RunStatus.RUNNING
1004
+ self.event_store.save(record)
1005
+ self._waiting_modes.pop(run_id, None)
1006
+ return dict(prior.data.get("receipt") or {})
414
1007
 
415
1008
  interaction = next(
416
1009
  (
417
1010
  event
418
- for event in reversed(self.event_store.events(run_id))
1011
+ for event in reversed(events)
419
1012
  if event.type == "a2ui.interaction"
420
1013
  and str(event.data.get("interactionId") or event.data.get("interaction_id") or "")
421
1014
  == interaction_id
@@ -424,8 +1017,29 @@ class StudioRunService:
424
1017
  )
425
1018
  if interaction is None:
426
1019
  raise StudioError("INTERACTION_NOT_FOUND", "交互请求不存在", status_code=404)
1020
+ revision = _interaction_revision(interaction.data)
1021
+ if revision < 1:
1022
+ raise StudioError(
1023
+ "INTERACTION_READ_ONLY",
1024
+ "交互请求缺少权威 revision,只能查看不能操作",
1025
+ status_code=409,
1026
+ )
1027
+ if expected_revision != revision:
1028
+ raise StudioError(
1029
+ "INTERACTION_REVISION_MISMATCH",
1030
+ "交互版本已变化,请刷新后重试",
1031
+ status_code=409,
1032
+ details={"expectedRevision": expected_revision, "actualRevision": revision},
1033
+ )
1034
+ record = self.event_store.get(run_id)
1035
+ if record.status != RunStatus.WAITING_INPUT:
1036
+ raise StudioError(
1037
+ "INTERACTION_NOT_PENDING",
1038
+ "该 Run 当前没有等待中的交互",
1039
+ status_code=409,
1040
+ )
427
1041
  kind = str(interaction.data.get("kind") or "form")
428
- payload_data = {"decision": name, **dict(data or {})}
1042
+ payload_data = {"decision": name, **data}
429
1043
  payload = ResumePayload(
430
1044
  kind="approval_decision" if kind == "approval" else "hitl_answer",
431
1045
  call_id=interaction_id,
@@ -438,7 +1052,7 @@ class StudioRunService:
438
1052
  raise StudioError("INTERACTION_EXPIRED", "运行时交互已失效", status_code=409)
439
1053
  try:
440
1054
  await self.executor.submit(handle, payload)
441
- except (RuntimeError, ValueError) as exc:
1055
+ except Exception as exc: # noqa: BLE001 - provider errors are user-safe here
442
1056
  raise StudioError(
443
1057
  "INTERACTION_SUBMIT_FAILED",
444
1058
  str(exc),
@@ -448,61 +1062,152 @@ class StudioRunService:
448
1062
  queue = self._control_queues.get(run_id)
449
1063
  if queue is None:
450
1064
  raise StudioError("INTERACTION_EXPIRED", "运行时交互已失效", status_code=409)
451
- queue.put_nowait(("resume", payload))
1065
+ submit_ack = asyncio.get_running_loop().create_future()
1066
+ queue.put_nowait(("resume", payload, submit_ack))
1067
+ try:
1068
+ await submit_ack
1069
+ except Exception as exc: # noqa: BLE001 - provider errors are user-safe here
1070
+ raise StudioError(
1071
+ "INTERACTION_SUBMIT_FAILED",
1072
+ str(exc),
1073
+ status_code=409,
1074
+ ) from exc
452
1075
  else:
453
1076
  raise StudioError("INTERACTION_EXPIRED", "运行时交互已失效", status_code=409)
454
1077
 
455
- resolved = self.event_store.append(
456
- run_id,
457
- "approval.resolved" if kind == "approval" else "interaction.resolved",
458
- {
459
- "runId": run_id,
460
- "interactionId": interaction_id,
461
- "callId": interaction_id,
462
- "name": name,
463
- "data": dict(data or {}),
464
- },
465
- )
466
- action = self.event_store.append(
1078
+ next_revision = revision + 1
1079
+ resolved_data = {
1080
+ "runId": run_id,
1081
+ "interactionId": interaction_id,
1082
+ "callId": interaction_id,
1083
+ "name": name,
1084
+ "data": data,
1085
+ "revision": next_revision,
1086
+ "expectedRevision": expected_revision,
1087
+ "idempotencyKey": idempotency_key,
1088
+ }
1089
+ action_data = {
1090
+ "runId": run_id,
1091
+ "surfaceId": str(
1092
+ interaction.data.get("surfaceId") or interaction.data.get("surface_id") or ""
1093
+ ),
1094
+ "interactionId": interaction_id,
1095
+ "actionId": f"action-{interaction_id}",
1096
+ "name": name,
1097
+ "data": data,
1098
+ "revision": next_revision,
1099
+ "expectedRevision": expected_revision,
1100
+ "idempotencyKey": idempotency_key,
1101
+ "requestDigest": request_digest,
1102
+ }
1103
+ _, _, receipt = self.event_store.append_interaction_resolution(
467
1104
  run_id,
468
- "a2ui.action",
469
- {
470
- "runId": run_id,
471
- "surfaceId": str(
472
- interaction.data.get("surfaceId") or interaction.data.get("surface_id") or ""
473
- ),
474
- "interactionId": interaction_id,
475
- "actionId": f"action-{interaction_id}",
476
- "name": name,
477
- "data": dict(data or {}),
478
- },
1105
+ resolved_type=("approval.resolved" if kind == "approval" else "interaction.resolved"),
1106
+ resolved_data=resolved_data,
1107
+ action_data=action_data,
479
1108
  )
480
1109
  record.status = RunStatus.RUNNING
481
1110
  self.event_store.save(record)
482
1111
  self._waiting_modes.pop(run_id, None)
483
- return {
484
- "runId": run_id,
485
- "interactionId": interaction_id,
486
- "status": "resolved",
487
- "eventId": action.id,
488
- "resolutionEventId": resolved.id,
489
- }
1112
+ return receipt
1113
+
1114
+ async def _capture_pcm_evidence(
1115
+ self,
1116
+ record: RunRecord,
1117
+ spec: StudioRunSpec,
1118
+ user_input: str,
1119
+ ) -> Any | None:
1120
+ """Prepare once and persist the exact prompt/context input sent to Runtime."""
1121
+
1122
+ try:
1123
+ from ksadk.conversations.runtime_preparation import build_run_input
1124
+ from ksadk.sessions.in_memory import InMemorySessionService
1125
+
1126
+ temporary_sessions = InMemorySessionService()
1127
+ await temporary_sessions.create_session(
1128
+ agent_id=spec.agent_id,
1129
+ user_id="local-user",
1130
+ session_id=record.session_id,
1131
+ )
1132
+ config = dict(spec.request_config or {})
1133
+ prepared = await build_run_input(
1134
+ agent_id=spec.agent_id,
1135
+ user_id="local-user",
1136
+ session_id=record.session_id,
1137
+ messages=self._conversation_messages(
1138
+ spec.agent_id,
1139
+ record.session_id,
1140
+ user_input,
1141
+ ),
1142
+ model=spec.model,
1143
+ instructions=str(config.get("instructions") or ""),
1144
+ agent_system=str(config.get("agent_system") or ""),
1145
+ agent_task=str(config.get("agent_task") or ""),
1146
+ prompt_integration_mode=str(config.get("prompt_integration_mode") or ""),
1147
+ context_engine_rollout=str(config.get("context_engine_rollout") or "") or None,
1148
+ memory_recall_enabled=config.get("memory_recall_enabled"),
1149
+ memory_write_rollout=str(config.get("memory_write_rollout") or "") or None,
1150
+ memory_enabled=config.get("memory_enabled"),
1151
+ memory_write_mode=str(config.get("memory_write_mode") or "candidate"),
1152
+ flush_before_compaction=bool(config.get("flush_before_compaction", True)),
1153
+ provider_ref=str(config.get("provider_ref") or "local-default"),
1154
+ runtime_type=spec.launch_context.runtime_type,
1155
+ deployment_mode=spec.launch_context.deployment_mode,
1156
+ invocation_id=record.id,
1157
+ session_service_provider=lambda: temporary_sessions,
1158
+ agent_max_input_tokens=config.get("max_input_tokens"),
1159
+ agent_reserve_output_tokens=config.get("reserve_output_tokens"),
1160
+ )
1161
+ record.context_plan = prepared.context_plan
1162
+ compiled = prepared.compiled_prompt
1163
+ shadow = prepared.shadow_context_plan or {}
1164
+ record.prompt_evidence = {
1165
+ "contentHash": (compiled or {}).get("prompt_content_hash"),
1166
+ "stablePrefixHash": (compiled or {}).get("prompt_stable_prefix_hash"),
1167
+ "sectionHashes": (compiled or {}).get("prompt_section_hashes", {}),
1168
+ "tokensBySection": (compiled or {}).get("prompt_tokens_by_section", {}),
1169
+ "estimatedTokens": (compiled or {}).get("prompt_estimated_tokens"),
1170
+ "sectionCount": (compiled or {}).get("prompt_section_count"),
1171
+ "integrationMode": shadow.get("integration_mode")
1172
+ or config.get("prompt_integration_mode")
1173
+ or "native",
1174
+ "accountingAccuracy": shadow.get("accounting_accuracy") or "opaque",
1175
+ "promptOwner": shadow.get("prompt_owner") or "runtime",
1176
+ "runtimeType": shadow.get("runtime_type") or spec.launch_context.runtime_type,
1177
+ "deploymentMode": shadow.get("deployment_mode")
1178
+ or spec.launch_context.deployment_mode,
1179
+ "capabilityHash": shadow.get("capability_hash"),
1180
+ "tokensByKind": shadow.get("tokens_by_kind", {}),
1181
+ "plannedInputTokens": shadow.get("planned_input_tokens"),
1182
+ }
1183
+ record.working_state = prepared.working_state
1184
+ for event in getattr(prepared, "memory_recall_events", []):
1185
+ self.event_store.append(
1186
+ record.id,
1187
+ event.get("type", "memory.recall.event"),
1188
+ event,
1189
+ )
1190
+ self.event_store.save(record)
1191
+ return prepared
1192
+ except Exception: # noqa: BLE001 - evidence collection is best effort
1193
+ logger.exception("Studio PCM evidence capture failed for run %s", record.id)
1194
+ return None
490
1195
 
491
1196
  def _persist_approval_surface(
492
1197
  self,
493
1198
  record: RunRecord,
494
- event: RuntimeEvent,
1199
+ event: InteractionRequested,
495
1200
  *,
496
1201
  on_event: Callable[[RunEvent], None] | None,
497
1202
  ) -> None:
498
- approval_id = str(event.payload.get("approval_id") or event.payload.get("call_id") or "")
1203
+ approval_id = event.interaction_id
499
1204
  if not approval_id:
500
1205
  return
501
1206
  surface_id = f"approval-{approval_id}"
502
- detail = event.payload.get("detail")
503
- detail = detail if isinstance(detail, dict) else {}
1207
+ detail_value = event.request.detail
1208
+ detail = detail_value if isinstance(detail_value, dict) else {}
504
1209
  command = str(detail.get("command") or detail.get("reason") or "")
505
- kind = str(event.payload.get("kind") or "tool")
1210
+ kind = str(event.request.kind or "tool")
506
1211
  components = [
507
1212
  {
508
1213
  "id": "root",
@@ -551,6 +1256,7 @@ class StudioRunService:
551
1256
  "surfaceId": surface_id,
552
1257
  "interactionId": approval_id,
553
1258
  "kind": "approval",
1259
+ "revision": 1,
554
1260
  "inputSchema": {
555
1261
  "type": "object",
556
1262
  "properties": {
@@ -601,107 +1307,465 @@ class StudioRunService:
601
1307
  return {}
602
1308
 
603
1309
 
604
- def project_runtime_event(event: RuntimeEvent) -> tuple[str, dict[str, Any]]:
605
- """Project the canonical RuntimeEvent into Studio's persisted event view."""
1310
+ def _plugin_event_envelope(record: RunRecord, event_type: str) -> dict[str, Any]:
1311
+ return {
1312
+ "schema_version": 2,
1313
+ "event_id": f"{record.id}:{event_type}",
1314
+ "seq": 0,
1315
+ "timestamp": time.time(),
1316
+ "run_id": record.id,
1317
+ "scope_id": record.id,
1318
+ "source": SourceRef(
1319
+ framework="ksadk",
1320
+ metadata={
1321
+ "runtime": "pluginhost",
1322
+ "agent_id": record.agent_id,
1323
+ "session_id": record.session_id,
1324
+ "build_id": record.build_id,
1325
+ },
1326
+ ),
1327
+ }
606
1328
 
607
- payload = dict(event.payload)
608
- event_type = event.event_type
609
- if event_type == EventType.RUN_STARTED:
610
- projected = "run.started"
611
- elif event_type == EventType.RUN_PROGRESS:
612
- projected = str(payload.get("native_event") or "run.progress")
613
- native_data = payload.get("native_data")
614
- if isinstance(native_data, dict):
615
- payload = dict(native_data)
616
- elif event_type in {EventType.REASONING_DELTA, EventType.REASONING_COMPLETED}:
617
- projected = (
618
- "thinking.delta" if event_type == EventType.REASONING_DELTA else "thinking.completed"
1329
+
1330
+ def _normalized_plugin_usage(usage: Mapping[str, Any]) -> dict[str, Any]:
1331
+ def number(*keys: str) -> int:
1332
+ for key in keys:
1333
+ value = usage.get(key)
1334
+ if value is not None:
1335
+ try:
1336
+ return max(0, int(value))
1337
+ except (TypeError, ValueError):
1338
+ return 0
1339
+ return 0
1340
+
1341
+ normalized = {
1342
+ "input_tokens": number("input_tokens", "inputTokens", "prompt_tokens"),
1343
+ "output_tokens": number(
1344
+ "output_tokens", "outputTokens", "completion_tokens"
1345
+ ),
1346
+ "total_tokens": number("total_tokens", "totalTokens"),
1347
+ "cached_tokens": number("cached_tokens", "cachedTokens"),
1348
+ "reasoning_tokens": number("reasoning_tokens", "reasoningTokens"),
1349
+ }
1350
+ if not normalized["total_tokens"]:
1351
+ normalized["total_tokens"] = (
1352
+ normalized["input_tokens"] + normalized["output_tokens"]
1353
+ )
1354
+ normalized["reported"] = bool(usage) and any(
1355
+ key in usage
1356
+ for key in (
1357
+ "input_tokens",
1358
+ "inputTokens",
1359
+ "prompt_tokens",
1360
+ "output_tokens",
1361
+ "outputTokens",
1362
+ "completion_tokens",
1363
+ "total_tokens",
1364
+ "totalTokens",
619
1365
  )
620
- elif event_type == EventType.TEXT_DELTA:
621
- projected = "message.delta" if event.phase == "final_answer" else "thinking.delta"
622
- elif event_type == EventType.TEXT_COMPLETED:
623
- projected = "message.completed" if event.phase == "final_answer" else "thinking.completed"
624
- elif event_type == EventType.TOOL_CALL_BEGIN:
625
- projected = "command.started" if payload.get("name") == "codex.command" else "tool.started"
626
- if projected == "command.started":
627
- raw_args = payload.get("args")
628
- args: dict[str, Any] = raw_args if isinstance(raw_args, dict) else {}
1366
+ )
1367
+ return normalized
1368
+
1369
+
1370
+ def _interaction_revision(data: Mapping[str, Any]) -> int:
1371
+ """Treat malformed or historical interaction revisions as read-only."""
1372
+
1373
+ try:
1374
+ return max(0, int(data.get("revision") or 0))
1375
+ except (TypeError, ValueError):
1376
+ return 0
1377
+
1378
+
1379
+ def _apply_plugin_usage(record: RunRecord, usage: Mapping[str, Any]) -> None:
1380
+ normalized = _normalized_plugin_usage(usage)
1381
+ if not normalized["reported"]:
1382
+ return
1383
+ record.usage = Usage(
1384
+ input_tokens=normalized["input_tokens"],
1385
+ output_tokens=normalized["output_tokens"],
1386
+ total_tokens=normalized["total_tokens"],
1387
+ cached_input_tokens=normalized["cached_tokens"],
1388
+ reasoning_output_tokens=normalized["reasoning_tokens"],
1389
+ reported=True,
1390
+ source="pluginhost",
1391
+ )
1392
+
1393
+
1394
+ def project_runtime_event(
1395
+ event: RuntimeEvent,
1396
+ *,
1397
+ session_id: str | None = None,
1398
+ public_run_id: str | None = None,
1399
+ ) -> tuple[str, dict[str, Any]]:
1400
+ """Project the canonical RuntimeEvent into Studio's persisted event view.
1401
+
1402
+ 公开承诺字段(契约声明见 ``ksadk/events/projections.py``,执行形态为
1403
+ ``tests/protocol/test_cross_projection_golden.py``):
1404
+ - 所有事件 payload 必含 ``runId``/``scopeId``;
1405
+ - ``message.*``/``thinking.*`` 含 ``itemId``(delta 另含 ``partId``);
1406
+ - ``tool.*``/``command.*``/``approval.*``/``a2ui.*`` 含 ``itemId``;
1407
+ - ``a2ui.surface.*`` 含 ``surfaceId`` 与 ``a2uiOperations`` 列表。
1408
+
1409
+ 内部不保证字段:除上述外的 payload 附加键、事件类型枚举的完备性
1410
+ (新增 canonical 事件类型在未适配前可能整条丢弃)。
1411
+ """
1412
+
1413
+ if isinstance(event, RunStarted):
1414
+ projected = "run.started"
1415
+ payload: dict[str, Any] = {}
1416
+ elif isinstance(event, RunProgress):
1417
+ projected = "run.progress"
1418
+ payload = {"progress": event.progress, "message": event.message}
1419
+ elif isinstance(event, RunCompleted):
1420
+ projected = "run.completed"
1421
+ payload = {}
1422
+ elif isinstance(event, RunFailed):
1423
+ projected = "run.failed"
1424
+ payload = {"error": event.error.message or ""}
1425
+ elif isinstance(event, RunCanceled):
1426
+ projected = "run.cancelled"
1427
+ payload = {"reason": event.reason or ""}
1428
+ elif isinstance(event, RunInterrupted):
1429
+ projected = "run.paused" if event.reason == "user_pause" else "run.interrupted"
1430
+ payload = {"reason": event.reason or ""}
1431
+ elif isinstance(event, ItemUpdated) and event.item_kind in {"message", "reasoning"}:
1432
+ text = event.update.text if isinstance(event.update, TextContent) else ""
1433
+ is_thinking = event.item_kind == "reasoning"
1434
+ projected = "thinking.delta" if is_thinking else "message.delta"
1435
+ payload = {"text": text}
1436
+ elif isinstance(event, ItemCompleted) and event.item_kind in {"message", "reasoning"}:
1437
+ text = ""
1438
+ for part in event.snapshot.parts:
1439
+ if isinstance(part, TextContent):
1440
+ text = part.text
1441
+ break
1442
+ is_thinking = event.item_kind == "reasoning"
1443
+ projected = "thinking.completed" if is_thinking else "message.completed"
1444
+ payload = {"text": text}
1445
+ elif isinstance(event, ItemStarted) and event.item_kind == "tool_call":
1446
+ tool_part = _first_content(event.initial, ToolCallContent) if event.initial else None
1447
+ if tool_part is not None and tool_part.name == "codex.command":
1448
+ projected = "command.started"
1449
+ args = tool_part.arguments if isinstance(tool_part.arguments, dict) else {}
629
1450
  payload = {
630
- "callId": str(payload.get("call_id") or ""),
1451
+ "callId": tool_part.call_id,
631
1452
  "command": str(args.get("command") or ""),
632
1453
  "cwd": str(args.get("cwd") or ""),
633
1454
  "commandActions": args.get("command_actions") or [],
634
1455
  }
635
- else:
1456
+ elif tool_part is not None:
1457
+ projected = "tool.started"
636
1458
  payload = {
637
- "callId": str(payload.get("call_id") or ""),
638
- "tool": str(payload.get("name") or ""),
639
- "args": payload.get("args"),
1459
+ "callId": tool_part.call_id,
1460
+ "tool": tool_part.name,
1461
+ "args": tool_part.arguments,
640
1462
  }
641
- elif event_type == EventType.TOOL_CALL_END:
642
- projected = (
643
- "command.completed" if payload.get("name") == "codex.command" else "tool.completed"
644
- )
645
- if projected == "command.completed":
646
- raw_result = payload.get("result")
647
- result: dict[str, Any] = raw_result if isinstance(raw_result, dict) else {}
1463
+ else:
1464
+ projected = "tool.started"
1465
+ payload = {}
1466
+ elif isinstance(event, ItemCompleted) and event.item_kind == "tool_call":
1467
+ tool_call = _first_content(event.snapshot, ToolCallContent)
1468
+ tool_result = _first_content(event.snapshot, ToolResultContent)
1469
+ if tool_call is not None and tool_call.name == "codex.command":
1470
+ projected = "command.completed"
1471
+ result = (
1472
+ tool_result.result
1473
+ if tool_result is not None and isinstance(tool_result.result, dict)
1474
+ else {}
1475
+ )
648
1476
  payload = {
649
- "callId": str(payload.get("call_id") or ""),
1477
+ "callId": tool_call.call_id,
650
1478
  "status": str(result.get("status") or ""),
651
1479
  "exitCode": result.get("exit_code"),
652
1480
  "durationMs": result.get("duration_ms"),
653
1481
  "output": str(result.get("output") or ""),
654
1482
  }
655
1483
  else:
656
- raw_tool_result = payload.get("result")
657
- tool_result: dict[str, Any] = (
658
- raw_tool_result if isinstance(raw_tool_result, dict) else {}
1484
+ projected = "tool.completed"
1485
+ result = (
1486
+ tool_result.result
1487
+ if tool_result is not None and isinstance(tool_result.result, dict)
1488
+ else {}
659
1489
  )
660
- tool_status = str(tool_result.get("status") or "")
661
- if tool_result.get("error"):
1490
+ tool_status = str(result.get("status") or "")
1491
+ if tool_result is not None and tool_result.is_error:
662
1492
  tool_status = "failed"
1493
+ call_id = (tool_call or tool_result).call_id if (tool_call or tool_result) else ""
663
1494
  payload = {
664
- "callId": str(payload.get("call_id") or ""),
665
- "tool": str(payload.get("name") or ""),
1495
+ "callId": call_id,
1496
+ "tool": tool_call.name if tool_call is not None else "",
666
1497
  "status": tool_status,
667
- "durationMs": tool_result.get("duration_ms"),
668
- "output": str(tool_result.get("output") or ""),
669
- **({"error": str(tool_result["error"])} if tool_result.get("error") else {}),
1498
+ "durationMs": result.get("duration_ms"),
1499
+ "output": str(result.get("output") or ""),
670
1500
  }
671
- elif event_type == EventType.RUN_COMPLETED:
672
- projected = "run.completed"
673
- elif event_type == EventType.RUN_FAILED:
674
- projected = "run.failed"
675
- elif event_type == EventType.RUN_CANCELED:
676
- projected = "run.cancelled"
677
- elif event_type == EventType.RUN_INTERRUPTED:
678
- projected = (
679
- "run.paused" if str(payload.get("status") or "") == "paused" else "run.interrupted"
680
- )
681
- elif event_type in {
682
- EventType.A2UI_SURFACE_BEGIN,
683
- EventType.A2UI_SURFACE_UPDATE,
684
- EventType.A2UI_SURFACE_END,
685
- EventType.A2UI_INTERACTION,
686
- EventType.A2UI_ACTION,
687
- }:
688
- projected = str(event_type)
689
- surface_id = str(payload.get("surface_id") or payload.get("surfaceId") or "")
690
- if surface_id:
691
- payload["surfaceId"] = surface_id
692
- interaction_id = str(payload.get("interaction_id") or payload.get("interactionId") or "")
693
- if interaction_id:
694
- payload["interactionId"] = interaction_id
695
- schema = payload.get("input_schema", payload.get("inputSchema"))
696
- if isinstance(schema, dict):
697
- payload["inputSchema"] = schema
698
- operations = project_a2ui_operations(event_type, payload)
699
- if operations:
700
- payload["a2uiOperations"] = operations
1501
+ if tool_result is not None and tool_result.is_error:
1502
+ payload["error"] = str(result.get("error") or "")
1503
+ elif (
1504
+ isinstance(event, (ItemStarted, ItemUpdated, ItemCompleted))
1505
+ and event.item_kind == "data"
1506
+ and event.source.protocol == "a2ui"
1507
+ ):
1508
+ projected, payload = _project_a2ui_surface(event)
1509
+ elif isinstance(event, InteractionRequested):
1510
+ if event.interaction_kind == "approval":
1511
+ projected = "approval.requested"
1512
+ payload = {
1513
+ "approvalId": event.interaction_id,
1514
+ "callId": event.request.call_id or "",
1515
+ "kind": event.request.kind,
1516
+ "detail": event.request.detail,
1517
+ }
1518
+ else:
1519
+ projected = "a2ui.interaction"
1520
+ payload = {
1521
+ "interactionId": event.interaction_id,
1522
+ "kind": "form",
1523
+ "inputSchema": {},
1524
+ }
1525
+ elif isinstance(event, InteractionResolved):
1526
+ if event.interaction_kind == "approval":
1527
+ projected = "approval.resolved"
1528
+ payload = {
1529
+ "approvalId": event.interaction_id,
1530
+ "callId": "",
1531
+ "decision": "",
1532
+ }
1533
+ else:
1534
+ projected = "a2ui.action"
1535
+ payload = {"interactionId": event.interaction_id}
1536
+ elif isinstance(event, ContinuationCreated):
1537
+ projected = "checkpoint.created"
1538
+ payload = {
1539
+ "checkpointId": event.continuation_id,
1540
+ "granularity": event.ref.get("granularity", "snapshot"),
1541
+ "resumable": event.resumable,
1542
+ }
1543
+ elif isinstance(event, ContinuationResumed):
1544
+ projected = "checkpoint.resumed"
1545
+ payload = {
1546
+ "checkpointId": event.continuation_id,
1547
+ "resumeAttemptId": event.resume_attempt_id,
1548
+ }
1549
+ elif isinstance(event, UsageReported):
1550
+ projected = "usage.reported"
1551
+ payload = {
1552
+ "inputTokens": event.input_tokens,
1553
+ "outputTokens": event.output_tokens,
1554
+ "totalTokens": event.total_tokens,
1555
+ "cachedTokens": event.cached_tokens,
1556
+ "reasoningTokens": event.reasoning_tokens,
1557
+ }
1558
+ else:
1559
+ projected = event.event_type
1560
+ payload = {}
1561
+
1562
+ _attach_studio_identity(payload, event)
1563
+ if public_run_id and public_run_id != event.run_id:
1564
+ payload["runtimeRunId"] = event.run_id
1565
+ payload["runId"] = public_run_id
1566
+ payload["runtimeEvent"] = dump_runtime_event(event)
1567
+ # Additive only: legacy Studio/Web consumers keep reading the established
1568
+ # event type and payload keys. New surfaces may opt into this typed,
1569
+ # identity-aware representation without reconstructing one from text.
1570
+ payload["conversationItem"] = project_conversation_item(
1571
+ event,
1572
+ session_id=session_id,
1573
+ run_id=public_run_id,
1574
+ ).model_dump(by_alias=True, exclude_none=True, mode="json")
1575
+ return projected, payload
1576
+
1577
+
1578
+ def _first_content(
1579
+ snapshot: ContentSnapshot | None, content_type: type
1580
+ ) -> Any | None:
1581
+ if snapshot is None:
1582
+ return None
1583
+ for part in snapshot.parts:
1584
+ if isinstance(part, content_type):
1585
+ return part
1586
+ return None
1587
+
1588
+
1589
+ def _project_a2ui_surface(
1590
+ event: ItemStarted | ItemUpdated | ItemCompleted,
1591
+ ) -> tuple[str, dict[str, Any]]:
1592
+ if isinstance(event, ItemStarted):
1593
+ projected = "a2ui.surface.begin"
1594
+ data_parts = event.initial.parts if event.initial is not None else ()
1595
+ elif isinstance(event, ItemUpdated):
1596
+ projected = "a2ui.surface.update"
1597
+ data_parts = (event.update,) if isinstance(event.update, DataContent) else ()
701
1598
  else:
702
- projected = str(event_type)
703
- payload["runtimeEvent"] = event.to_dict()
1599
+ projected = "a2ui.surface.end"
1600
+ data_parts = event.snapshot.parts
1601
+
1602
+ if event.source.metadata.get("operation_batch") is True and isinstance(event, ItemCompleted):
1603
+ # Completion closes the immutable canonical batch; it is not a request
1604
+ # to remove the surface that the batch just created.
1605
+ projected = "a2ui.surface.begin"
1606
+
1607
+ surface_id = str(event.source.metadata.get("surface_id") or "")
1608
+ operations: list[dict[str, Any]] = []
1609
+ for part in data_parts:
1610
+ if isinstance(part, DataContent):
1611
+ data = part.data
1612
+ if isinstance(data, list):
1613
+ operations.extend(dict(op) for op in data if isinstance(op, Mapping))
1614
+ elif isinstance(data, Mapping):
1615
+ operations.extend(project_a2ui_operations(projected, data))
1616
+ if not operations:
1617
+ operations = project_a2ui_operations(projected, {"surface_id": surface_id})
1618
+ payload: dict[str, Any] = {
1619
+ "surfaceId": surface_id,
1620
+ "a2uiOperations": operations,
1621
+ }
704
1622
  return projected, payload
705
1623
 
706
1624
 
1625
+ def _attach_studio_identity(payload: dict[str, Any], event: RuntimeEvent) -> None:
1626
+ """Attach §8.4 identity fields (runId/scopeId/itemId/partId/operation)."""
1627
+ payload["runId"] = event.run_id
1628
+ payload["scopeId"] = event.scope_id
1629
+ if isinstance(event, (ItemStarted, ItemUpdated, ItemCompleted, ItemFailed)):
1630
+ payload["itemId"] = event.item_id
1631
+ if isinstance(event, ItemUpdated):
1632
+ payload["operation"] = event.op
1633
+ if isinstance(event, ItemUpdated) and hasattr(event.update, "part_id"):
1634
+ payload["partId"] = event.update.part_id
1635
+ if isinstance(event, ItemStarted) and event.initial is not None:
1636
+ for part in event.initial.parts:
1637
+ if hasattr(part, "part_id"):
1638
+ payload["partId"] = part.part_id
1639
+ break
1640
+ if isinstance(event, ItemCompleted):
1641
+ for part in event.snapshot.parts:
1642
+ if hasattr(part, "part_id"):
1643
+ payload["partId"] = part.part_id
1644
+ break
1645
+ if isinstance(event, (InteractionRequested, InteractionResolved)):
1646
+ payload["itemId"] = event.interaction_id
1647
+ if isinstance(event, (ContinuationCreated, ContinuationResumed)):
1648
+ payload["itemId"] = event.continuation_id
1649
+
1650
+
707
1651
  __all__ = ["StudioRunService", "StudioRunSpec", "project_runtime_event"]
1652
+
1653
+
1654
+ def _studio_envelope_projection(envelope) -> tuple[str, dict[str, Any]] | None:
1655
+ """Session envelope -> Studio RunEvent 投影;cursor 仍用 envelope.seq。"""
1656
+
1657
+ payload = envelope.payload or {}
1658
+ # Kernel RuntimeEvent is persisted as a typed runtime/v2 SessionEvent
1659
+ # envelope. Reuse the exact same projector as the direct Studio path so
1660
+ # a consumer receives its additive ConversationItem regardless of which
1661
+ # ingress delivered the Run. Legacy event families retain the historical
1662
+ # text-only fallback below.
1663
+ if envelope.family == "runtime" and envelope.family_version == 2:
1664
+ try:
1665
+ runtime_event = parse_runtime_event(payload)
1666
+ except (TypeError, ValueError):
1667
+ # A damaged/unknown runtime payload must not make old Studio
1668
+ # subscriptions fail. Preserve its event type for legacy
1669
+ # observability while refusing to invent a typed conversation item.
1670
+ return envelope.event_type, {"runId": envelope.run_id or ""}
1671
+ return project_runtime_event(runtime_event, session_id=envelope.session_id)
1672
+ if envelope.family == "interaction" and envelope.family_version == 1:
1673
+ conversation_item = project_interaction_conversation_item(envelope)
1674
+ if conversation_item is None:
1675
+ # Keep a malformed durable fact observable without reusing an
1676
+ # actionable legacy event name. Existing Studio reducers turn
1677
+ # ``approval.requested`` into a submit button, so emitting that
1678
+ # name without an authoritative revision would bypass the typed
1679
+ # ConversationItem fail-closed boundary.
1680
+ payload = envelope.payload or {}
1681
+ return envelope.event_type, {
1682
+ "runId": envelope.run_id or str(payload.get("run_id") or ""),
1683
+ "itemId": str(payload.get("interaction_id") or envelope.event_id),
1684
+ "interactionReadOnly": True,
1685
+ }
1686
+ projected = _project_interaction_envelope_legacy(envelope)
1687
+ if projected is None:
1688
+ return None
1689
+ event_type, data = projected
1690
+ data["conversationItem"] = conversation_item.model_dump(
1691
+ by_alias=True,
1692
+ exclude_none=True,
1693
+ mode="json",
1694
+ )
1695
+ return event_type, data
1696
+ if envelope.event_type == "run.completed":
1697
+ return "run.completed", {
1698
+ "runId": envelope.run_id or "",
1699
+ "output_text": str(payload.get("output_text") or ""),
1700
+ }
1701
+ text = str(payload.get("delta") or payload.get("text") or "")
1702
+ if text:
1703
+ return "message.delta", {"delta": text}
1704
+ return None
1705
+
1706
+
1707
+ def _project_interaction_envelope_legacy(
1708
+ envelope,
1709
+ ) -> tuple[str, dict[str, Any]] | None:
1710
+ """Keep established Studio event names while Interaction/v1 owns truth."""
1711
+
1712
+ payload = envelope.payload or {}
1713
+ interaction_id = str(payload.get("interaction_id") or "")
1714
+ interaction_kind = str(payload.get("kind") or "")
1715
+ revision = payload.get("revision")
1716
+ request = payload.get("request")
1717
+ request = request if isinstance(request, Mapping) else {}
1718
+ presentation = request.get("presentation")
1719
+ presentation = presentation if isinstance(presentation, Mapping) else {}
1720
+ common: dict[str, Any] = {
1721
+ "runId": envelope.run_id or str(payload.get("run_id") or ""),
1722
+ "itemId": interaction_id,
1723
+ "interactionId": interaction_id,
1724
+ }
1725
+ # Expose the value as received for legacy/read-only diagnostics. Only the
1726
+ # typed ConversationItem validates it as an authoritative writable token.
1727
+ if revision is not None:
1728
+ common["revision"] = revision
1729
+
1730
+ if envelope.event_type == "interaction.requested":
1731
+ if interaction_kind == "approval":
1732
+ return "approval.requested", {
1733
+ **common,
1734
+ "approvalId": interaction_id,
1735
+ "callId": "",
1736
+ "kind": str(presentation.get("title") or interaction_kind),
1737
+ "detail": presentation.get("description"),
1738
+ }
1739
+ return "a2ui.interaction", {
1740
+ **common,
1741
+ "kind": interaction_kind or "form",
1742
+ "inputSchema": (
1743
+ dict(request.get("request_schema"))
1744
+ if isinstance(request.get("request_schema"), Mapping)
1745
+ else {}
1746
+ ),
1747
+ }
1748
+ if envelope.event_type in {
1749
+ "interaction.resolved",
1750
+ "interaction.cancelled",
1751
+ "interaction.expired",
1752
+ }:
1753
+ outcome = str(
1754
+ payload.get("outcome")
1755
+ or (
1756
+ "cancelled"
1757
+ if envelope.event_type == "interaction.cancelled"
1758
+ else "expired"
1759
+ if envelope.event_type == "interaction.expired"
1760
+ else ""
1761
+ )
1762
+ )
1763
+ if interaction_kind == "approval":
1764
+ return "approval.resolved", {
1765
+ **common,
1766
+ "approvalId": interaction_id,
1767
+ "callId": "",
1768
+ "decision": outcome,
1769
+ }
1770
+ return "a2ui.action", {**common, "name": outcome}
1771
+ return None