agentengine-sdk-python 0.8.0__py3-none-any.whl → 0.8.2__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 (494) hide show
  1. {agentengine_sdk_python-0.8.0.dist-info → agentengine_sdk_python-0.8.2.dist-info}/METADATA +43 -7
  2. agentengine_sdk_python-0.8.2.dist-info/RECORD +778 -0
  3. {agentengine_sdk_python-0.8.0.dist-info → agentengine_sdk_python-0.8.2.dist-info}/WHEEL +1 -1
  4. ksadk/a2a/__init__.py +2 -0
  5. ksadk/a2a/_space_client_events.py +381 -0
  6. ksadk/a2a/bootstrap.py +0 -2
  7. ksadk/a2a/card.py +6 -111
  8. ksadk/a2a/control_plane.py +239 -1
  9. ksadk/a2a/event_adapter.py +156 -64
  10. ksadk/a2a/executor.py +195 -172
  11. ksadk/a2a/routes.py +12 -5
  12. ksadk/a2a/server.py +1 -8
  13. ksadk/a2a/service_env.py +33 -0
  14. ksadk/a2a/space_client.py +43 -349
  15. ksadk/a2a_card.py +101 -0
  16. ksadk/a2ui/core.py +222 -53
  17. ksadk/agui/_agent_helpers.py +243 -0
  18. ksadk/agui/a2ui_projection.py +12 -5
  19. ksadk/agui/agent.py +474 -275
  20. ksadk/agui/config.py +11 -5
  21. ksadk/agui/routes.py +7 -15
  22. ksadk/api/client.py +530 -40
  23. ksadk/api/environment.py +27 -0
  24. ksadk/builders/code_builder.py +352 -53
  25. ksadk/builders/container_builder.py +71 -44
  26. ksadk/builders/framework_requirements.py +2 -1
  27. ksadk/builders/ks3_uploader.py +2 -2
  28. ksadk/builders/managed_runtime_builder.py +60 -28
  29. ksadk/cli/__init__.py +25 -0
  30. ksadk/cli/cmd_a2a.py +18 -31
  31. ksadk/cli/cmd_create.py +13 -26
  32. ksadk/cli/cmd_dashboard.py +15 -1
  33. ksadk/cli/cmd_deploy.py +28 -3
  34. ksadk/cli/cmd_eval.py +443 -0
  35. ksadk/cli/cmd_evalset.py +284 -0
  36. ksadk/cli/cmd_files.py +5 -1
  37. ksadk/cli/cmd_hermes.py +137 -163
  38. ksadk/cli/cmd_invoke.py +28 -38
  39. ksadk/cli/cmd_launch.py +12 -0
  40. ksadk/cli/cmd_managed_runtime.py +95 -0
  41. ksadk/cli/cmd_observe.py +85 -0
  42. ksadk/cli/cmd_openclaw.py +85 -479
  43. ksadk/cli/cmd_replay.py +28 -4
  44. ksadk/cli/cmd_run.py +29 -67
  45. ksadk/cli/cmd_studio.py +201 -0
  46. ksadk/cli/cmd_web.py +15 -6
  47. ksadk/cli/dry_run.py +2 -1
  48. ksadk/cli/env_options.py +83 -0
  49. ksadk/cli/global_options.py +3 -1
  50. ksadk/cli/hermes_env.py +151 -0
  51. ksadk/cli/invoke_payload.py +49 -0
  52. ksadk/cli/network_options.py +7 -5
  53. ksadk/cli/openclaw_env.py +458 -0
  54. ksadk/cli/runtime_bootstrap.py +51 -0
  55. ksadk/cli/ui.py +6 -0
  56. ksadk/cli/workflow_common.py +5 -5
  57. ksadk/codex/__init__.py +3 -3
  58. ksadk/codex/client.py +833 -49
  59. ksadk/codex/phase.py +1 -1
  60. ksadk/codex/runtime.py +720 -127
  61. ksadk/common/kop_client.py +232 -0
  62. ksadk/configs/__init__.py +1 -3
  63. ksadk/configs/env_registry.py +244 -45
  64. ksadk/configs/env_registry_pcm.py +181 -0
  65. ksadk/configs/env_var_spec.py +18 -0
  66. ksadk/configs/global_config.py +6 -1
  67. ksadk/configs/settings.py +5 -108
  68. ksadk/context_engine/__init__.py +98 -0
  69. ksadk/context_engine/assembler.py +176 -0
  70. ksadk/context_engine/baseline.py +419 -0
  71. ksadk/context_engine/cache_observability.py +228 -0
  72. ksadk/context_engine/capabilities.py +443 -0
  73. ksadk/context_engine/contributors.py +344 -0
  74. ksadk/context_engine/hosted_pipeline.py +417 -0
  75. ksadk/context_engine/models.py +117 -0
  76. ksadk/context_engine/planner.py +698 -0
  77. ksadk/context_engine/policies.py +319 -0
  78. ksadk/context_engine/projection.py +31 -0
  79. ksadk/context_engine/shadow_plan.py +266 -0
  80. ksadk/context_engine/tokenizer.py +164 -0
  81. ksadk/conversations/__init__.py +85 -94
  82. ksadk/conversations/context.py +126 -6
  83. ksadk/conversations/message_projection.py +292 -28
  84. ksadk/conversations/model_context.py +49 -3
  85. ksadk/conversations/runtime_compaction.py +207 -5
  86. ksadk/conversations/runtime_input.py +193 -3
  87. ksadk/conversations/runtime_invocation.py +81 -3
  88. ksadk/conversations/runtime_observability.py +157 -4
  89. ksadk/conversations/runtime_payloads.py +42 -0
  90. ksadk/conversations/runtime_preparation.py +280 -2
  91. ksadk/conversations/runtime_resume.py +71 -32
  92. ksadk/conversations/runtime_stream_events.py +94 -9
  93. ksadk/conversations/runtime_streaming.py +175 -22
  94. ksadk/conversations/semantic_summary.py +311 -1
  95. ksadk/conversations/session_lock.py +51 -0
  96. ksadk/deployment/env_forward.py +72 -0
  97. ksadk/deployment/managed_runtime.py +38 -0
  98. ksadk/deployment/manager.py +24 -8
  99. ksadk/deployment/providers/serverless.py +228 -92
  100. ksadk/evaluation/__init__.py +116 -0
  101. ksadk/evaluation/a2a_adapter.py +452 -0
  102. ksadk/evaluation/adapters.py +67 -0
  103. ksadk/evaluation/agent_eval_client.py +395 -0
  104. ksadk/evaluation/cloud_binding.py +80 -0
  105. ksadk/evaluation/cloud_converter.py +194 -0
  106. ksadk/evaluation/cloud_service.py +199 -0
  107. ksadk/evaluation/contracts.py +478 -0
  108. ksadk/evaluation/evalset.py +351 -0
  109. ksadk/evaluation/evaluators.py +627 -0
  110. ksadk/evaluation/evidence.py +258 -0
  111. ksadk/evaluation/executor.py +143 -0
  112. ksadk/evaluation/local_adapter.py +550 -0
  113. ksadk/evaluation/service_env.py +27 -0
  114. ksadk/evaluation/storage.py +68 -0
  115. ksadk/evaluation/studio_build_adapter.py +253 -0
  116. ksadk/evaluation/target.py +82 -0
  117. ksadk/events/__init__.py +13 -13
  118. ksadk/events/_v1_compat/__init__.py +1 -0
  119. ksadk/events/_v1_compat/models.py +299 -0
  120. ksadk/events/_v1_compat/parser.py +247 -0
  121. ksadk/events/_v1_compat/projection.py +753 -0
  122. ksadk/events/adapters/__init__.py +34 -0
  123. ksadk/events/adapters/_a2a_snapshot.py +622 -0
  124. ksadk/events/adapters/_a2a_support.py +275 -0
  125. ksadk/events/adapters/_codex_interactions.py +375 -0
  126. ksadk/events/adapters/_codex_items.py +801 -0
  127. ksadk/events/adapters/_codex_validators.py +311 -0
  128. ksadk/events/adapters/_langgraph_support.py +785 -0
  129. ksadk/events/adapters/a2a.py +841 -0
  130. ksadk/events/adapters/adk.py +503 -0
  131. ksadk/events/adapters/codex.py +717 -0
  132. ksadk/events/adapters/langgraph.py +745 -0
  133. ksadk/events/canonical.py +440 -0
  134. ksadk/events/canonical_replay.py +497 -0
  135. ksadk/events/canonical_store.py +468 -0
  136. ksadk/events/cold_recovery.py +257 -0
  137. ksadk/events/content.py +89 -0
  138. ksadk/events/identity.py +86 -0
  139. ksadk/events/pipeline.py +455 -0
  140. ksadk/events/projections.py +66 -0
  141. ksadk/events/reducer.py +661 -0
  142. ksadk/events/replay.py +3 -36
  143. ksadk/events/runtime_event.py +20 -256
  144. ksadk/events/session_event.py +324 -0
  145. ksadk/events/store.py +3 -316
  146. ksadk/events/v1_compat.py +41 -0
  147. ksadk/harness/__init__.py +3 -2
  148. ksadk/harness/app.py +114 -123
  149. ksadk/harness/config.py +1 -1
  150. ksadk/harness/runtime.py +467 -0
  151. ksadk/hermes_terminal.py +5 -0
  152. ksadk/identity/resolver.py +4 -3
  153. ksadk/interaction/__init__.py +28 -0
  154. ksadk/interaction/contracts.py +136 -0
  155. ksadk/interaction/ledger.py +189 -0
  156. ksadk/interaction/provider.py +122 -0
  157. ksadk/interaction/providers/__init__.py +52 -0
  158. ksadk/interaction/providers/adk.py +40 -0
  159. ksadk/interaction/providers/codex.py +94 -0
  160. ksadk/interaction/providers/langgraph.py +58 -0
  161. ksadk/kernel/__init__.py +117 -0
  162. ksadk/kernel/authorization.py +258 -0
  163. ksadk/kernel/bootstrap.py +1074 -0
  164. ksadk/kernel/contract_fingerprints.py +61 -0
  165. ksadk/kernel/contracts.py +362 -0
  166. ksadk/kernel/control.py +219 -0
  167. ksadk/kernel/errors.py +79 -0
  168. ksadk/kernel/ingress.py +1102 -0
  169. ksadk/kernel/mapping.py +111 -0
  170. ksadk/kernel/memory_store.py +1133 -0
  171. ksadk/kernel/postgres_store.py +1756 -0
  172. ksadk/kernel/recovery.py +452 -0
  173. ksadk/kernel/runtime_identity.py +103 -0
  174. ksadk/kernel/sql/001_agent_kernel.sql +120 -0
  175. ksadk/kernel/sqlite_store.py +1410 -0
  176. ksadk/kernel/state.py +145 -0
  177. ksadk/kernel/store.py +232 -0
  178. ksadk/kernel/worker.py +953 -0
  179. ksadk/knowledge_base/client.py +22 -4
  180. ksadk/knowledge_base/service.py +32 -5
  181. ksadk/managed_a2a_card.py +160 -0
  182. ksadk/managed_runtime.py +16 -9
  183. ksadk/memory/__init__.py +61 -0
  184. ksadk/memory/adk/backends/base_ltm_backend.py +96 -1
  185. ksadk/memory/adk/backends/http_ltm_backend.py +14 -5
  186. ksadk/memory/adk/backends/inmemory_ltm_backend.py +136 -3
  187. ksadk/memory/adk/backends/sdk_ltm_backend.py +394 -5
  188. ksadk/memory/adk/backends/sqlite_ltm_backend.py +95 -0
  189. ksadk/memory/adk/long_term_memory.py +3 -2
  190. ksadk/memory/coordinator.py +461 -0
  191. ksadk/memory/events.py +202 -0
  192. ksadk/memory/extraction.py +228 -0
  193. ksadk/memory/ltm_backend_factory.py +16 -3
  194. ksadk/memory/models.py +316 -0
  195. ksadk/memory/policy.py +215 -0
  196. ksadk/memory/provider.py +47 -0
  197. ksadk/memory/provider_adapter.py +159 -0
  198. ksadk/memory/provider_resolver.py +71 -0
  199. ksadk/memory/providers/__init__.py +5 -0
  200. ksadk/memory/providers/local_sqlite.py +490 -0
  201. ksadk/memory/resolved_policy.py +145 -0
  202. ksadk/memory/service.py +198 -8
  203. ksadk/memory/tool.py +4 -0
  204. ksadk/model_proxy/bootstrap.py +16 -9
  205. ksadk/model_proxy/config.py +22 -1
  206. ksadk/model_proxy/detect.py +202 -9
  207. ksadk/model_proxy/namespace.py +67 -15
  208. ksadk/model_proxy/server.py +168 -6
  209. ksadk/model_proxy/transform.py +105 -3
  210. ksadk/observability/__init__.py +25 -0
  211. ksadk/observability/session_log.py +420 -0
  212. ksadk/observability/trajectory.py +105 -0
  213. ksadk/prompts/__init__.py +71 -0
  214. ksadk/prompts/compiler.py +215 -0
  215. ksadk/prompts/models.py +82 -0
  216. ksadk/prompts/projection.py +89 -0
  217. ksadk/prompts/resolved.py +137 -0
  218. ksadk/prompts/sources.py +233 -0
  219. ksadk/runners/_langgraph_runner_streams.py +822 -0
  220. ksadk/runners/adk_runner.py +14 -31
  221. ksadk/runners/base_runner.py +44 -3
  222. ksadk/runners/factory.py +3 -3
  223. ksadk/runners/langgraph_runner.py +252 -433
  224. ksadk/runners/utils/__init__.py +0 -10
  225. ksadk/runners/utils/loader.py +16 -0
  226. ksadk/runtime/__init__.py +67 -41
  227. ksadk/runtime/_runner_adapter/__init__.py +1 -0
  228. ksadk/runtime/_runner_adapter/stream_mapping.py +788 -0
  229. ksadk/runtime/adapter.py +199 -16
  230. ksadk/runtime/conversation_execution.py +687 -0
  231. ksadk/runtime/executor.py +392 -0
  232. ksadk/runtime/factory.py +342 -0
  233. ksadk/runtime/framework_adapters.py +6 -12
  234. ksadk/runtime/hosted_finalizer.py +268 -0
  235. ksadk/runtime/launch.py +61 -0
  236. ksadk/runtime/preprocessing.py +49 -14
  237. ksadk/runtime/runner_adapter.py +330 -391
  238. ksadk/runtime/runner_loading.py +36 -0
  239. ksadk/runtime/usage.py +33 -0
  240. ksadk/sandbox/backends/e2b.py +36 -3
  241. ksadk/server/__init__.py +39 -4
  242. ksadk/server/app.py +18 -492
  243. ksadk/server/composition.py +36 -2
  244. ksadk/server/factory.py +210 -149
  245. ksadk/server/routes/common.py +15 -46
  246. ksadk/server/routes/control.py +13 -9
  247. ksadk/server/routes/kernel_ingress.py +258 -0
  248. ksadk/server/routes/misc.py +10 -33
  249. ksadk/server/routes/models.py +14 -6
  250. ksadk/server/routes/openai_compat.py +129 -27
  251. ksadk/server/routes/projection.py +84 -16
  252. ksadk/server/routes/run.py +347 -460
  253. ksadk/server/routes/sessions.py +37 -40
  254. ksadk/server/routes/streaming.py +52 -7
  255. ksadk/server/routes/workspace.py +54 -21
  256. ksadk/server/static/assets/{ArtifactsPanel-Cjnqt-8W.js → ArtifactsPanel-DSU1sWD_.js} +1 -1
  257. ksadk/server/static/assets/{CodeBlock-BqyqZfuB.js → CodeBlock-DOH6MVcn.js} +4 -4
  258. ksadk/server/static/assets/{MathMessageMarkdown-BpH7jiFM.js → MathMessageMarkdown-BL8my1R2.js} +4 -4
  259. ksadk/server/static/assets/MathMessageMarkdown-BorAY7qD.css +1 -0
  260. ksadk/server/static/assets/MermaidBlock-Dz4IP-Tx.js +312 -0
  261. ksadk/server/static/assets/NativeTerminalPanel-DUrK0JpZ.js +1 -0
  262. ksadk/server/static/assets/abnfDiagram-VCTEODGH-g20pFzNV.js +1 -0
  263. ksadk/server/static/assets/arc-C4FzinUA.js +1 -0
  264. ksadk/server/static/assets/architecture-7GRP2DOG-DjKratdf.js +1 -0
  265. ksadk/server/static/assets/architectureDiagram-5GKGNRK7-DpIqL5h4.js +36 -0
  266. ksadk/server/static/assets/blockDiagram-NRAW4CY4-BjkljTNz.js +129 -0
  267. ksadk/server/static/assets/c4Diagram-UCG6FXSJ-Dk_ieq2X.js +38 -0
  268. ksadk/server/static/assets/channel-4cQHKtx1.js +1 -0
  269. ksadk/server/static/assets/chunk-2Q5K7J3B-DmgWkESh.js +1 -0
  270. ksadk/server/static/assets/{chunk-FMBD7UC4-BGhvzDYt.js → chunk-5VM5RSS4-BVHAchFb.js} +1 -1
  271. ksadk/server/static/assets/{chunk-ND2GUHAM-D98vPd-E.js → chunk-F27PBJKO-C-ipQzuS.js} +1 -1
  272. ksadk/server/static/assets/chunk-G27WJ6UU-C2EyJbIK.js +231 -0
  273. ksadk/server/static/assets/{chunk-4BX2VUAB-CsWIQRhc.js → chunk-JWPE2WC7-vYvVJb_M.js} +1 -1
  274. ksadk/server/static/assets/chunk-LCL6LL3I-BcI6ysPT.js +206 -0
  275. ksadk/server/static/assets/chunk-POPQ4Y6H-C030x_Z1.js +1 -0
  276. ksadk/server/static/assets/chunk-RHFEMEQ7-yyGpsz4n.js +168 -0
  277. ksadk/server/static/assets/chunk-SVP7TREG-BLTlmMU7.js +88 -0
  278. ksadk/server/static/assets/chunk-XXDRQBXY-C_32ArgP.js +1 -0
  279. ksadk/server/static/assets/classDiagram-DTDB5LWJ-DLFd9Xi0.js +1 -0
  280. ksadk/server/static/assets/classDiagram-v2-JRS7N3AN-DLFd9Xi0.js +1 -0
  281. ksadk/server/static/assets/{cose-bilkent-S5V4N54A-CX3yotHq.js → cose-bilkent-JH36ORCC-9YQYwdl0.js} +1 -1
  282. ksadk/server/static/assets/cynefin-OW5HDTMX-DjEM48Qp.js +1 -0
  283. ksadk/server/static/assets/cynefinDiagram-5FMLGOSQ-ErLF5N13.js +62 -0
  284. ksadk/server/static/assets/cytoscape.esm-CyCl8rPi.js +321 -0
  285. ksadk/server/static/assets/dagre-3AP2YEHR-DFiQF-6f.js +4 -0
  286. ksadk/server/static/assets/dagre-BuhGnRI1.js +1 -0
  287. ksadk/server/static/assets/diagram-S7CK7UJ4-Bt1v8-GC.js +30 -0
  288. ksadk/server/static/assets/diagram-UQ7AKVKN-DSCxJdBK.js +41 -0
  289. ksadk/server/static/assets/diagram-VSXAHHWV-DHfYwp_9.js +3 -0
  290. ksadk/server/static/assets/diagram-VX7I27RA-DdzD-4Le.js +24 -0
  291. ksadk/server/static/assets/diagram-Z3DM3KII-Du-nAJ9F.js +24 -0
  292. ksadk/server/static/assets/{dist-D82mtD2s.js → dist-8hqcx0sU.js} +1 -1
  293. ksadk/server/static/assets/dist-B7seoj3d.js +1 -0
  294. ksadk/server/static/assets/{dist-DHn6nBzr.js → dist-BE7bu-DL.js} +1 -1
  295. ksadk/server/static/assets/{dist-eoW6tZXA.js → dist-BVDffZ0U.js} +1 -1
  296. ksadk/server/static/assets/{dist-C2xjE7cO.js → dist-BX8z72IC.js} +1 -1
  297. ksadk/server/static/assets/{dist-CgF3fj15.js → dist-Bf2M8m3N.js} +1 -1
  298. ksadk/server/static/assets/{dist-Bs-jQcRX.js → dist-BjU6y-A2.js} +1 -1
  299. ksadk/server/static/assets/{dist-Djgl2wVZ.js → dist-BytlxIDT.js} +1 -1
  300. ksadk/server/static/assets/{dist-CVQRZjPu.js → dist-CSh_DE14.js} +1 -1
  301. ksadk/server/static/assets/{dist-6CVKuTAe.js → dist-CXYYBw3_.js} +1 -1
  302. ksadk/server/static/assets/{dist-DlzCRy0S.js → dist-C_wsv-Qd.js} +1 -1
  303. ksadk/server/static/assets/{dist-DF06ORQo.js → dist-ClxGviKw.js} +1 -1
  304. ksadk/server/static/assets/{dist-Cf23NY4L.js → dist-CttYUqzS.js} +1 -1
  305. ksadk/server/static/assets/{dist-ivOya1LD.js → dist-DNp6rLRA.js} +1 -1
  306. ksadk/server/static/assets/{dist-BibcG1V9.js → dist-DUX-id_F.js} +1 -1
  307. ksadk/server/static/assets/{dist-BUHRxvDG.js → dist-DmldrHd_.js} +1 -1
  308. ksadk/server/static/assets/{dist-BZ9n9AjO.js → dist-DnfXs8Vn.js} +2 -2
  309. ksadk/server/static/assets/{dist-DqNnNFrw.js → dist-Dq9gLD7L.js} +1 -1
  310. ksadk/server/static/assets/{dist-CGcp2ex8.js → dist-W-TIVntx.js} +1 -1
  311. ksadk/server/static/assets/{dist-Cwt7koer.js → dist-dDdADKFA.js} +1 -1
  312. ksadk/server/static/assets/{dist-C7-3jInC.js → dist-dydu68Fl.js} +1 -1
  313. ksadk/server/static/assets/{dist-C99THtIA.js → dist-ngtN0DJB.js} +1 -1
  314. ksadk/server/static/assets/ebnfDiagram-PWID7BFC-Da9C9NO1.js +1 -0
  315. ksadk/server/static/assets/erDiagram-SSCWMZ5O-EAGqqR79.js +99 -0
  316. ksadk/server/static/assets/eventmodeling-NTZA5JFV-CMgMaJrC.js +1 -0
  317. ksadk/server/static/assets/flowDiagram-A5DVABFB-BBKL0x3P.js +1 -0
  318. ksadk/server/static/assets/ganttDiagram-EL5Y4UJY-DqJsKb59.js +292 -0
  319. ksadk/server/static/assets/gitGraph-4MIJSDKK-Dw63mrhw.js +1 -0
  320. ksadk/server/static/assets/gitGraphDiagram-WWUBYQGX-B6g4dtDi.js +106 -0
  321. ksadk/server/static/assets/index-8ipRcQ-M.js +240 -0
  322. ksadk/server/static/assets/index-ByFCqjlh.css +2 -0
  323. ksadk/server/static/assets/info-A6RAGUB7-B6iAblII.js +1 -0
  324. ksadk/server/static/assets/infoDiagram-RXCK75RN-Djm_nbd5.js +2 -0
  325. ksadk/server/static/assets/{ishikawaDiagram-YF4QCWOH-DLOHGvWw.js → ishikawaDiagram-5VMMS53U-DWya9SG2.js} +6 -6
  326. ksadk/server/static/assets/{journeyDiagram-JHISSGLW-BIzKeejT.js → journeyDiagram-EYS64GPL-DutxhSNI.js} +6 -6
  327. ksadk/server/static/assets/{kanban-definition-UN3LZRKU-BWHoFSw2.js → kanban-definition-3QL26DDD-DJdOF8Fm.js} +10 -10
  328. ksadk/server/static/assets/katex-vFWytM5c.js +257 -0
  329. ksadk/server/static/assets/{linear-CvMBw9cA.js → linear-BHjG8b-J.js} +1 -1
  330. ksadk/server/static/assets/mermaid-parser.core-KGSy4jWT.js +166 -0
  331. ksadk/server/static/assets/{mindmap-definition-RKZ34NQL-CSAQxEAe.js → mindmap-definition-FBJOCRG2-9-HC88D4.js} +29 -29
  332. ksadk/server/static/assets/packet-AYTQ26CC-BOYAipuq.js +1 -0
  333. ksadk/server/static/assets/pegDiagram-XKGWAZYB-DrEZd4fD.js +1 -0
  334. ksadk/server/static/assets/pie-WAS4IAKB-Ctq0Kbku.js +1 -0
  335. ksadk/server/static/assets/pieDiagram-E7YTZNPT-DpjqOFFp.js +39 -0
  336. ksadk/server/static/assets/{quadrantDiagram-W4KKPZXB-CxIR4JXI.js → quadrantDiagram-AXDQQJYC-BiNhgOXX.js} +6 -6
  337. ksadk/server/static/assets/radar-RG4KPBEZ-DN0-ul_0.js +1 -0
  338. ksadk/server/static/assets/railroad-74A4TZTK-C6cC_to0.js +1 -0
  339. ksadk/server/static/assets/railroad-abnf-HS5TGJTU-BimjIuRc.js +1 -0
  340. ksadk/server/static/assets/railroad-ebnf-LZEXJU2U-CvY65ABq.js +1 -0
  341. ksadk/server/static/assets/railroad-peg-WCYAUIDC-D72Iq_Tv.js +1 -0
  342. ksadk/server/static/assets/railroadDiagram-O6MQD6OU-_Y3Ojg6G.js +1 -0
  343. ksadk/server/static/assets/requirementDiagram-EFPCY7ZU-DeCFdg9K.js +84 -0
  344. ksadk/server/static/assets/{sankeyDiagram-5OEKKPKP-DpGa6EAV.js → sankeyDiagram-P5KCCOFB-CPH75rhw.js} +7 -7
  345. ksadk/server/static/assets/{sequenceDiagram-3UESZ5HK-NTKNGGe5.js → sequenceDiagram-WJ2MYXX4-OEpQQdr4.js} +10 -10
  346. ksadk/server/static/assets/sizeCapture-X5ZJPWSS-heUErzhI.js +1 -0
  347. ksadk/server/static/assets/stateDiagram-HBIQ2CUA-DvA3jSMB.js +1 -0
  348. ksadk/server/static/assets/stateDiagram-v2-4QOOHH4V-BgQY03nz.js +1 -0
  349. ksadk/server/static/assets/swimlanes-XN3QIQJK-CR-dfN_t.js +1 -0
  350. ksadk/server/static/assets/swimlanesDiagram-VK2B7HYN-DLVw3q5Q.js +8 -0
  351. ksadk/server/static/assets/{timeline-definition-PNZ67QCA-jqoYs3jQ.js → timeline-definition-24CTP7MA-DAVJf1TX.js} +7 -7
  352. ksadk/server/static/assets/treeView-Q6P3EWNA-BMpO0wsB.js +1 -0
  353. ksadk/server/static/assets/treemap-WGGIJYW6-Et0aqzU3.js +1 -0
  354. ksadk/server/static/assets/vennDiagram-4TSXK5OY-DwM8eDkh.js +34 -0
  355. ksadk/server/static/assets/wardley-WFR3VGLG-CbY0mhpl.js +1 -0
  356. ksadk/server/static/assets/wardleyDiagram-VM6X3IG4-DZL-Zz2t.js +78 -0
  357. ksadk/server/static/assets/xychartDiagram-S5SC5T6Z-Dy6Yh_hu.js +7 -0
  358. ksadk/server/static/index.html +2 -2
  359. ksadk/sessions/__init__.py +26 -9
  360. ksadk/sessions/_local_service_sync.py +688 -0
  361. ksadk/sessions/_local_tables.py +21 -0
  362. ksadk/sessions/_postgres_schema.py +305 -0
  363. ksadk/sessions/_postgres_tables.py +15 -0
  364. ksadk/sessions/base.py +80 -1
  365. ksadk/sessions/in_memory.py +53 -2
  366. ksadk/sessions/local_service.py +63 -604
  367. ksadk/sessions/postgres_service.py +58 -148
  368. ksadk/sessions/resilient.py +35 -1
  369. ksadk/skills/runtime/backends/e2b.py +1 -1
  370. ksadk/skills/service_client.py +13 -3
  371. ksadk/studio/__init__.py +5 -0
  372. ksadk/studio/agent_avatar_assets.py +120 -0
  373. ksadk/studio/agent_lifecycle.py +51 -0
  374. ksadk/studio/api.py +1598 -0
  375. ksadk/studio/api_catalog_routes.py +324 -0
  376. ksadk/studio/api_contracts.py +286 -0
  377. ksadk/studio/api_helpers.py +132 -0
  378. ksadk/studio/api_memory_routes.py +82 -0
  379. ksadk/studio/authoring.py +749 -0
  380. ksadk/studio/authoring_coordinator.py +1017 -0
  381. ksadk/studio/builder.py +397 -0
  382. ksadk/studio/capabilities.py +368 -0
  383. ksadk/studio/cloud.py +2099 -0
  384. ksadk/studio/codex_agent_service.py +694 -0
  385. ksadk/studio/codex_authoring.py +469 -0
  386. ksadk/studio/codex_builder.py +437 -0
  387. ksadk/studio/codex_manifest.py +307 -0
  388. ksadk/studio/codex_run.py +395 -0
  389. ksadk/studio/compiler.py +190 -0
  390. ksadk/studio/contracts.py +755 -0
  391. ksadk/studio/errors.py +47 -0
  392. ksadk/studio/event_store.py +214 -0
  393. ksadk/studio/framework_run.py +342 -0
  394. ksadk/studio/hosted_kernel.py +317 -0
  395. ksadk/studio/identifiers.py +33 -0
  396. ksadk/studio/manifest_resolver.py +131 -0
  397. ksadk/studio/mcp_runtime.py +229 -0
  398. ksadk/studio/model_client.py +629 -0
  399. ksadk/studio/model_profile_service.py +214 -0
  400. ksadk/studio/operations.py +275 -0
  401. ksadk/studio/otel_trace.py +1060 -0
  402. ksadk/studio/pagination.py +182 -0
  403. ksadk/studio/pcm_memory.py +85 -0
  404. ksadk/studio/python_tool_inspection.py +183 -0
  405. ksadk/studio/repository.py +327 -0
  406. ksadk/studio/resource_catalog.py +1260 -0
  407. ksadk/studio/run_service.py +1133 -0
  408. ksadk/studio/runtime_catalog.py +74 -0
  409. ksadk/studio/runtime_source.py +365 -0
  410. ksadk/studio/service.py +2249 -0
  411. ksadk/studio/shared_web.py +1004 -0
  412. ksadk/studio/skill_discovery.py +628 -0
  413. ksadk/studio/static/assets/PrismRenderer-IYN-ffww.js +3 -0
  414. ksadk/studio/static/assets/index-BLpcdrgW.css +1 -0
  415. ksadk/studio/static/assets/index-CYekR2Xv.js +260 -0
  416. ksadk/studio/static/assets/react-vendor-BNmTiJ-I.js +9 -0
  417. ksadk/studio/static/assets/rolldown-runtime-hePW80VL.js +1 -0
  418. ksadk/studio/static/index.html +15 -0
  419. ksadk/studio/templates.py +722 -0
  420. ksadk/studio/validator.py +265 -0
  421. ksadk/studio/workspace.py +135 -0
  422. ksadk/tools/gateway.py +9 -0
  423. ksadk/toolsets/__init__.py +27 -1
  424. ksadk/toolsets/a2a.py +426 -0
  425. ksadk/tracing/__init__.py +19 -21
  426. ksadk/tracing/setup.py +317 -403
  427. ksadk/tracing/span_utils.py +0 -44
  428. ksadk/version.py +1 -1
  429. ksadk_runtime_common/memory_backend/registry.py +56 -56
  430. ksadk_runtime_common/schemas/runtime_event_v1.json +37 -0
  431. ksadk_runtime_common/schemas/runtime_event_v2.json +271 -0
  432. ksadk_runtime_common/workspace_files/router.py +8 -2
  433. agentengine_sdk_python-0.8.0.dist-info/RECORD +0 -578
  434. ksadk/a2a/card_builder.py +0 -64
  435. ksadk/a2a/client.py +0 -309
  436. ksadk/events/parser.py +0 -191
  437. ksadk/harness/runner.py +0 -211
  438. ksadk/runners/codex_runner.py +0 -132
  439. ksadk/runners/utils/langfuse.py +0 -230
  440. ksadk/server/static/assets/MathMessageMarkdown-DeYh8QPr.css +0 -1
  441. ksadk/server/static/assets/MermaidBlock-DWuxETaJ.js +0 -303
  442. ksadk/server/static/assets/NativeTerminalPanel-MDTopWMO.js +0 -1
  443. ksadk/server/static/assets/arc-CFKwSk_B.js +0 -1
  444. ksadk/server/static/assets/architecture-7EHR7CIX-CynqOWjZ.js +0 -1
  445. ksadk/server/static/assets/architectureDiagram-3BPJPVTR-Dej8jX8c.js +0 -36
  446. ksadk/server/static/assets/blockDiagram-GPEHLZMM-1F64jqvy.js +0 -132
  447. ksadk/server/static/assets/c4Diagram-AAUBKEIU-9q7AUAKb.js +0 -10
  448. ksadk/server/static/assets/channel-iUGv2N24.js +0 -1
  449. ksadk/server/static/assets/chunk-2J33WTMH-6qgwJm7Q.js +0 -1
  450. ksadk/server/static/assets/chunk-55IACEB6-BK8paA20.js +0 -1
  451. ksadk/server/static/assets/chunk-727SXJPM-B-JEhy7h.js +0 -206
  452. ksadk/server/static/assets/chunk-AQP2D5EJ-DYBvMHAa.js +0 -231
  453. ksadk/server/static/assets/chunk-QZHKN3VN-YQIEXeF6.js +0 -1
  454. ksadk/server/static/assets/classDiagram-4FO5ZUOK-CDAUuoXK.js +0 -1
  455. ksadk/server/static/assets/classDiagram-v2-Q7XG4LA2-CDAUuoXK.js +0 -1
  456. ksadk/server/static/assets/cytoscape.esm-DelgaX4f.js +0 -321
  457. ksadk/server/static/assets/dagre-BM42HDAG-C7TguAWo.js +0 -4
  458. ksadk/server/static/assets/dagre-Bx709z4p.js +0 -1
  459. ksadk/server/static/assets/diagram-2AECGRRQ-Dd17_kws.js +0 -43
  460. ksadk/server/static/assets/diagram-5GNKFQAL-DVIClIil.js +0 -10
  461. ksadk/server/static/assets/diagram-KO2AKTUF-Df_3gYQa.js +0 -3
  462. ksadk/server/static/assets/diagram-LMA3HP47-BhLk6ZtA.js +0 -24
  463. ksadk/server/static/assets/diagram-OG6HWLK6-D4GNGvuu.js +0 -24
  464. ksadk/server/static/assets/dist-DyvYLMW9.js +0 -1
  465. ksadk/server/static/assets/erDiagram-TEJ5UH35-CnfRcY2M.js +0 -85
  466. ksadk/server/static/assets/eventmodeling-FCH6USID-ChYO-ijM.js +0 -1
  467. ksadk/server/static/assets/flowDiagram-I6XJVG4X-DGaJjGNJ.js +0 -162
  468. ksadk/server/static/assets/ganttDiagram-6RSMTGT7-DpCoB2g9.js +0 -292
  469. ksadk/server/static/assets/gitGraph-WXDBUCRP-BN_0Fk-j.js +0 -1
  470. ksadk/server/static/assets/gitGraphDiagram-PVQCEYII-9IKVFBVT.js +0 -106
  471. ksadk/server/static/assets/graphlib-B8gBHxth.js +0 -1
  472. ksadk/server/static/assets/index-QSwCHuB8.js +0 -177
  473. ksadk/server/static/assets/index-jOPmTOZH.css +0 -2
  474. ksadk/server/static/assets/info-J43DQDTF-Cp6o3vId.js +0 -1
  475. ksadk/server/static/assets/infoDiagram-5YYISTIA-CYIIsb8y.js +0 -2
  476. ksadk/server/static/assets/katex-DATssjp1.js +0 -265
  477. ksadk/server/static/assets/mermaid-parser.core-mGAcEwcq.js +0 -161
  478. ksadk/server/static/assets/packet-YPE3B663-wQzct3cT.js +0 -1
  479. ksadk/server/static/assets/pie-LRSECV5Y-BgTQiv-9.js +0 -1
  480. ksadk/server/static/assets/pieDiagram-4H26LBE5-By-ra07M.js +0 -30
  481. ksadk/server/static/assets/radar-GUYGQ44K-BIq5fXsn.js +0 -1
  482. ksadk/server/static/assets/requirementDiagram-4Y6WPE33-DpW90iu3.js +0 -84
  483. ksadk/server/static/assets/stateDiagram-AJRCARHV-DGpd9yFG.js +0 -1
  484. ksadk/server/static/assets/stateDiagram-v2-BHNVJYJU-B3o579IQ.js +0 -1
  485. ksadk/server/static/assets/treeView-BLDUP644-Dqi-E0EM.js +0 -1
  486. ksadk/server/static/assets/treemap-LRROVOQU-ClQP9CJ9.js +0 -1
  487. ksadk/server/static/assets/vennDiagram-CIIHVFJN-l3Z7-wbP.js +0 -34
  488. ksadk/server/static/assets/wardley-L42UT6IY-8XLvnNIs.js +0 -1
  489. ksadk/server/static/assets/wardleyDiagram-YWT4CUSO-BR54GPmr.js +0 -78
  490. ksadk/server/static/assets/xychartDiagram-2RQKCTM6-SkiH8GsK.js +0 -7
  491. ksadk/tracing/exporters/langfuse_exporter.py +0 -460
  492. {agentengine_sdk_python-0.8.0.dist-info → agentengine_sdk_python-0.8.2.dist-info}/entry_points.txt +0 -0
  493. {agentengine_sdk_python-0.8.0.dist-info → agentengine_sdk_python-0.8.2.dist-info}/licenses/LICENSE +0 -0
  494. {agentengine_sdk_python-0.8.0.dist-info → agentengine_sdk_python-0.8.2.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,1756 @@
1
+ # -*- coding: utf-8 -*-
2
+ """PostgreSQL ``AgentKernelStore``(Phase 1 Task 4)。
3
+
4
+ 预发多写者 durable Inbox / Run / ActivationLease 存储:
5
+ - schema 见 ``ksadk/kernel/sql/001_agent_kernel.sql``(BIGINT fencing_token、
6
+ TIMESTAMPTZ lease、JSONB payload、``(tenant_id, session_id, idempotency_key)`` 唯一);
7
+ - claim 用 ``FOR UPDATE SKIP LOCKED`` 且仍按 ``accepted_seq`` 排序;
8
+ - activation takeover 用单条 ``INSERT .. ON CONFLICT .. DO UPDATE .. WHERE
9
+ lease_expires_at <= now()``(或 released / 同 activation)原子 ``fencing_token + 1``;
10
+ - 每个 writer 事务的第一步是对 activation 行做 ``FOR SHARE`` compare-fence
11
+ (:meth:`_assert_fence`),token/expiry 不匹配抛 :class:`StaleFenceError`
12
+ 并回滚整个事务;
13
+ - 与 SQLite 版不同(Task 3 把 ControlEvent 放事务外),这里的 lease CAS、
14
+ Inbox claim/complete、Run transition 与 SessionEvent append 都发生在
15
+ **同一个** PostgreSQL 事务里,commit 前被 kill 不会留下半状态。
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ from contextlib import asynccontextmanager
22
+ from datetime import datetime, timezone
23
+ from pathlib import Path
24
+ from typing import Any
25
+
26
+ from ksadk.events.session_event import (
27
+ _timestamp_to_float,
28
+ envelope_to_session_event,
29
+ session_event_storage_id,
30
+ session_event_to_envelope,
31
+ validate_write_guard,
32
+ )
33
+ from ksadk.interaction.contracts import (
34
+ InteractionRecord,
35
+ InteractionReceipt,
36
+ InteractionSubmission,
37
+ is_terminal,
38
+ )
39
+ from ksadk.interaction.ledger import (
40
+ ALREADY_RESOLVED,
41
+ REVISION_MISMATCH,
42
+ REQUEST_CONFLICT,
43
+ interaction_event,
44
+ request_digest,
45
+ requested_event_payload,
46
+ resolve_outcome,
47
+ submission_digest,
48
+ )
49
+ from ksadk.kernel.contracts import (
50
+ ActivationLease,
51
+ ActivationWriteGuard,
52
+ AdmissionWriteGuard,
53
+ AgentControlCommand,
54
+ AgentControlReceipt,
55
+ ControlError,
56
+ SessionEventEnvelope,
57
+ )
58
+ from ksadk.kernel.errors import InvalidCommandError, StaleFenceError
59
+ from ksadk.kernel.state import (
60
+ InboxState,
61
+ RunState,
62
+ assert_inbox_transition,
63
+ assert_run_transition,
64
+ is_active_run,
65
+ )
66
+ from ksadk.kernel.store import (
67
+ ActivationLeaseRequest,
68
+ InboxMessage,
69
+ RunRecord,
70
+ command_digest,
71
+ control_event,
72
+ new_message_id,
73
+ now_iso,
74
+ )
75
+ from ksadk.sessions._postgres_tables import (
76
+ KSADK_PG_EVENTS_TABLE,
77
+ KSADK_PG_SESSIONS_TABLE,
78
+ )
79
+
80
+ SCHEMA_PATH = Path(__file__).parent / "sql" / "001_agent_kernel.sql"
81
+
82
+ NONCE_RETENTION_SECONDS = 24 * 3600.0
83
+
84
+ ACTIVATION_FOR_SHARE_SQL = (
85
+ "SELECT activation_id, fencing_token, lease_expires_at, released, runtime_type,"
86
+ " bundle_digest, capability_digest, agent_instance_id, session_id"
87
+ " FROM kernel_activations"
88
+ " WHERE agent_instance_id = $1 AND session_id = $2"
89
+ " FOR SHARE"
90
+ )
91
+
92
+
93
+ def _now() -> datetime:
94
+ return datetime.now(timezone.utc)
95
+
96
+
97
+ def _parse_ts(value: str | None):
98
+ if value is None:
99
+ return None
100
+ parsed = datetime.fromisoformat(value)
101
+ if parsed.tzinfo is None:
102
+ parsed = parsed.replace(tzinfo=timezone.utc)
103
+ return parsed
104
+
105
+
106
+ class PostgresKernelEventLog:
107
+ """把 ``SessionEventEnvelope`` 写进共享 session log(ksadk_events)。
108
+
109
+ ``append_on(connection, ...)`` 允许调用方把 event insert 并入一个已经
110
+ 打开的 kernel writer 事务;``append`` 则自开事务。
111
+ """
112
+
113
+ def __init__(
114
+ self,
115
+ pool: Any,
116
+ *,
117
+ namespace: str = "default",
118
+ tenant_id: str = "default",
119
+ workspace_id: str = "default",
120
+ ) -> None:
121
+ self._pool = pool
122
+ self._namespace = namespace.strip() or "default"
123
+ self._tenant_id = tenant_id.strip() or "default"
124
+ self._workspace_id = workspace_id.strip() or "default"
125
+
126
+ @asynccontextmanager
127
+ async def _connection(self):
128
+ if hasattr(self._pool, "acquire"):
129
+ async with self._pool.acquire() as conn:
130
+ yield conn
131
+ else:
132
+ yield self._pool
133
+
134
+ async def append_on(
135
+ self,
136
+ connection: Any,
137
+ envelope: SessionEventEnvelope,
138
+ guard: Any | None = None,
139
+ ) -> SessionEventEnvelope:
140
+ if guard is not None:
141
+ validate_write_guard(envelope, guard)
142
+ if not envelope.session_id.strip():
143
+ raise ValueError("session_id must be nonempty")
144
+ packed = envelope_to_session_event(envelope)
145
+ storage_id = session_event_storage_id(envelope.session_id, str(envelope.event_id))
146
+ # 锁 session 行串行化 seq 分配,与 PostgresSessionService.append_event 相同。
147
+ session_row = await connection.fetchrow(
148
+ f"SELECT id FROM {KSADK_PG_SESSIONS_TABLE} WHERE namespace=$1 AND id=$2 FOR UPDATE",
149
+ self._namespace,
150
+ envelope.session_id,
151
+ )
152
+ if session_row is None:
153
+ raise InvalidCommandError(
154
+ f"session {envelope.session_id!r} does not exist in the shared event log"
155
+ )
156
+ next_seq = await connection.fetchval(
157
+ f"SELECT COALESCE(MAX(seq_id), 0) + 1 FROM {KSADK_PG_EVENTS_TABLE}"
158
+ " WHERE namespace=$1 AND session_id=$2",
159
+ self._namespace,
160
+ envelope.session_id,
161
+ )
162
+ seq = int(next_seq or 1)
163
+ packed.bind_seq_id(seq) # 把物理 seq 绑定回 runtime/session envelope 内容
164
+ await connection.execute(
165
+ f"""
166
+ INSERT INTO {KSADK_PG_EVENTS_TABLE} (
167
+ namespace, tenant_id, workspace_id, id, session_id, author,
168
+ event_type, content_json, timestamp, state_delta_json,
169
+ seq_id, invocation_id, metadata_json
170
+ ) VALUES (
171
+ $1, $2, $3, $4, $5, $6, $7, $8::jsonb, $9,
172
+ '{{}}'::jsonb, $10, $11, $12::jsonb
173
+ )
174
+ ON CONFLICT (namespace, id) DO NOTHING
175
+ """,
176
+ self._namespace,
177
+ self._tenant_id,
178
+ self._workspace_id,
179
+ storage_id,
180
+ envelope.session_id,
181
+ packed.author,
182
+ packed.event_type,
183
+ json.dumps(packed.content, ensure_ascii=False),
184
+ _timestamp_to_float(envelope.timestamp),
185
+ int(next_seq or 1),
186
+ packed.invocation_id,
187
+ json.dumps(packed.metadata, ensure_ascii=False),
188
+ )
189
+ stored = await self._fetch_event(connection, envelope.session_id, storage_id)
190
+ if stored is not None:
191
+ return stored
192
+ raise RuntimeError("kernel event insert did not persist") # pragma: no cover
193
+
194
+ async def _fetch_event(
195
+ self, connection: Any, session_id: str, storage_id: str
196
+ ) -> SessionEventEnvelope | None:
197
+ row = await connection.fetchrow(
198
+ f"SELECT content_json, metadata_json, seq_id, timestamp FROM {KSADK_PG_EVENTS_TABLE}"
199
+ " WHERE namespace=$1 AND session_id=$2 AND id=$3",
200
+ self._namespace,
201
+ session_id,
202
+ storage_id,
203
+ )
204
+ if row is None:
205
+ return None
206
+ from ksadk.sessions.base import SessionEvent
207
+
208
+ event = SessionEvent(
209
+ id=storage_id,
210
+ session_id=session_id,
211
+ author="",
212
+ event_type="",
213
+ content=json.loads(row["content_json"]),
214
+ timestamp=row["timestamp"],
215
+ seq_id=row["seq_id"],
216
+ metadata=json.loads(row["metadata_json"]),
217
+ )
218
+ return session_event_to_envelope(event)
219
+
220
+ async def append(
221
+ self, envelope: SessionEventEnvelope, *, guard: Any | None = None
222
+ ) -> SessionEventEnvelope:
223
+ async with self._connection() as connection:
224
+ async with connection.transaction():
225
+ return await self.append_on(connection, envelope, guard)
226
+
227
+ async def read(self, session_id: str, after_seq: int, limit: int) -> list[SessionEventEnvelope]:
228
+ if limit < 1:
229
+ raise ValueError("limit must be positive")
230
+ async with self._connection() as connection:
231
+ rows = await connection.fetch(
232
+ f"SELECT id, content_json, metadata_json, seq_id, timestamp, author,"
233
+ f" event_type, invocation_id FROM {KSADK_PG_EVENTS_TABLE}"
234
+ " WHERE namespace=$1 AND session_id=$2 AND seq_id > $3"
235
+ " ORDER BY seq_id LIMIT $4",
236
+ self._namespace,
237
+ session_id,
238
+ int(after_seq),
239
+ int(limit),
240
+ )
241
+ from ksadk.sessions.base import SessionEvent
242
+
243
+ envelopes: list[SessionEventEnvelope] = []
244
+ for row in rows:
245
+ event = SessionEvent(
246
+ id=row["id"],
247
+ session_id=session_id,
248
+ author=row["author"],
249
+ event_type=row["event_type"],
250
+ content=json.loads(row["content_json"]),
251
+ timestamp=row["timestamp"],
252
+ seq_id=row["seq_id"],
253
+ invocation_id=row["invocation_id"],
254
+ metadata=json.loads(row["metadata_json"]),
255
+ )
256
+ envelope = session_event_to_envelope(event)
257
+ if envelope is not None:
258
+ envelopes.append(envelope)
259
+ return envelopes
260
+
261
+
262
+ class PostgresNonceStore:
263
+ """跨 Pod / 重启 durable 的 mutation nonce 单次使用存储。
264
+
265
+ 单条 ``INSERT .. ON CONFLICT (nonce) DO NOTHING`` 原子占位;冲突时读回
266
+ 既有 identity 比较:同 ``(command_id, idempotency_key)`` 是网络重试,
267
+ 否则判为重放(返回 False)。注册成功时顺带清理超过 retention 的旧行。
268
+ """
269
+
270
+ def __init__(self, pool: Any, *, retention_seconds: float = NONCE_RETENTION_SECONDS) -> None:
271
+ self._pool = pool
272
+ self._retention = float(retention_seconds)
273
+
274
+ @asynccontextmanager
275
+ async def _connection(self):
276
+ if hasattr(self._pool, "acquire"):
277
+ async with self._pool.acquire() as conn:
278
+ yield conn
279
+ else:
280
+ yield self._pool
281
+
282
+ async def register(
283
+ self, nonce: str, command_id: str, idempotency_key: str
284
+ ) -> bool:
285
+ async with self._connection() as connection:
286
+ async with connection.transaction():
287
+ inserted = await connection.fetchval(
288
+ "INSERT INTO kernel_permit_nonces (nonce, command_id,"
289
+ " idempotency_key) VALUES ($1, $2, $3)"
290
+ " ON CONFLICT (nonce) DO NOTHING RETURNING nonce",
291
+ nonce,
292
+ command_id,
293
+ idempotency_key,
294
+ )
295
+ if inserted is not None:
296
+ await connection.execute(
297
+ "DELETE FROM kernel_permit_nonces"
298
+ " WHERE created_at < now() - make_interval(secs => $1)",
299
+ self._retention,
300
+ )
301
+ return True
302
+ existing = await connection.fetchrow(
303
+ "SELECT command_id, idempotency_key FROM kernel_permit_nonces"
304
+ " WHERE nonce=$1",
305
+ nonce,
306
+ )
307
+ return existing is not None and (
308
+ existing["command_id"] == command_id
309
+ and existing["idempotency_key"] == idempotency_key
310
+ )
311
+
312
+
313
+ class PostgresAgentKernelStore:
314
+ def __init__(
315
+ self,
316
+ pool: Any,
317
+ session_event_log: PostgresKernelEventLog | None,
318
+ *,
319
+ tenant_id: str = "default",
320
+ owns_pool: bool = False,
321
+ ) -> None:
322
+ self._pool = pool
323
+ self._events = session_event_log or PostgresKernelEventLog(pool)
324
+ self.tenant_id = tenant_id
325
+ self._owns_pool = owns_pool
326
+
327
+ # ------------------------------------------------------------- lifecycle
328
+
329
+ @asynccontextmanager
330
+ async def _connection(self):
331
+ if hasattr(self._pool, "acquire"):
332
+ async with self._pool.acquire() as conn:
333
+ yield conn
334
+ else:
335
+ yield self._pool
336
+
337
+ async def ensure_schema(self) -> None:
338
+ schema = SCHEMA_PATH.read_text(encoding="utf-8")
339
+ async with self._connection() as connection:
340
+ await connection.execute(schema)
341
+
342
+ async def reset_for_tests(self) -> None:
343
+ async with self._connection() as connection:
344
+ await connection.execute(
345
+ "DELETE FROM kernel_inbox; DELETE FROM kernel_runs;"
346
+ " DELETE FROM kernel_activations; DELETE FROM kernel_accepted_seq;"
347
+ " DELETE FROM kernel_interactions; DELETE FROM kernel_interaction_submissions;"
348
+ " DELETE FROM ksadk_events WHERE namespace = 'default';"
349
+ )
350
+
351
+ async def close(self) -> None:
352
+ if self._owns_pool and hasattr(self._pool, "close"):
353
+ await self._pool.close()
354
+
355
+ # ---------------------------------------------------------------- helpers
356
+
357
+ async def _assert_fence(
358
+ self, connection: Any, agent_instance_id: str, session_id: str, expected_fence: int
359
+ ) -> dict[str, Any]:
360
+ """compare-fence:事务内 ``FOR SHARE`` 读 activation 并比较 token/expiry。"""
361
+
362
+ row = await connection.fetchrow(
363
+ ACTIVATION_FOR_SHARE_SQL, agent_instance_id, session_id
364
+ )
365
+ if (
366
+ row is None
367
+ or row["released"]
368
+ or row["lease_expires_at"] <= _now()
369
+ or int(row["fencing_token"]) != int(expected_fence)
370
+ ):
371
+ raise StaleFenceError(
372
+ "activation lease does not match expected fence",
373
+ details={
374
+ "agent_instance_id": agent_instance_id,
375
+ "session_id": session_id,
376
+ "expected_fence": int(expected_fence),
377
+ },
378
+ )
379
+ return dict(row)
380
+
381
+ @staticmethod
382
+ def _receipt(
383
+ command: AgentControlCommand,
384
+ status: str,
385
+ *,
386
+ message_id: str | None = None,
387
+ accepted_seq: int | None = None,
388
+ error: ControlError | None = None,
389
+ ) -> AgentControlReceipt:
390
+ return AgentControlReceipt(
391
+ command_id=command.command_id,
392
+ status=status, # type: ignore[arg-type]
393
+ message_id=message_id,
394
+ accepted_seq=accepted_seq,
395
+ error=error,
396
+ )
397
+
398
+ async def _append_admission(
399
+ self, connection: Any, command: AgentControlCommand, envelope: SessionEventEnvelope
400
+ ) -> None:
401
+ # accepted/rejected 事实的 write guard 绑定提交方的 permit 引用
402
+ # (server permit_id 或本地 authority),不落内核自造 ref。
403
+ await self._events.append_on(
404
+ connection,
405
+ envelope,
406
+ AdmissionWriteGuard(
407
+ authorization_ref=command.authorization_ref,
408
+ command_id=command.command_id,
409
+ ),
410
+ )
411
+
412
+ async def _append_activation_fact(
413
+ self,
414
+ connection: Any,
415
+ envelope: SessionEventEnvelope,
416
+ activation: dict[str, Any],
417
+ fence: int,
418
+ ) -> SessionEventEnvelope:
419
+ return await self._events.append_on(
420
+ connection,
421
+ envelope,
422
+ ActivationWriteGuard(
423
+ activation_id=activation["activation_id"], fencing_token=int(fence)
424
+ ),
425
+ )
426
+
427
+ # --------------------------------------------------------------- commands
428
+
429
+ async def accept_command(
430
+ self, command: AgentControlCommand, *, queue_limit: int
431
+ ) -> AgentControlReceipt:
432
+ if queue_limit < 1:
433
+ raise InvalidCommandError("queue_limit must be positive")
434
+ async with self._connection() as connection:
435
+ async with connection.transaction():
436
+ existing = await connection.fetchrow(
437
+ "SELECT message_id, accepted_seq, request_digest FROM kernel_inbox"
438
+ " WHERE tenant_id=$1 AND session_id=$2 AND idempotency_key=$3",
439
+ self.tenant_id,
440
+ command.session_id,
441
+ command.idempotency_key,
442
+ )
443
+ if existing is not None:
444
+ if existing["request_digest"] != command_digest(command):
445
+ await self._append_admission(
446
+ connection,
447
+ command,
448
+ control_event(
449
+ session_id=command.session_id,
450
+ event_type="control.command_rejected",
451
+ payload={
452
+ "command_id": str(command.command_id),
453
+ "status": "rejected",
454
+ "reason": "idempotency_conflict",
455
+ },
456
+ causation_id=str(command.command_id),
457
+ ),
458
+ )
459
+ return self._receipt(
460
+ command,
461
+ "rejected",
462
+ error=ControlError(
463
+ code="idempotency_conflict",
464
+ message=(
465
+ "idempotency key reused with a different request digest"
466
+ ),
467
+ retryable=False,
468
+ ),
469
+ )
470
+ return self._receipt(
471
+ command,
472
+ "duplicate",
473
+ message_id=str(existing["message_id"]),
474
+ accepted_seq=int(existing["accepted_seq"]),
475
+ )
476
+
477
+ depth = await connection.fetchval(
478
+ "SELECT COUNT(*) FROM kernel_inbox WHERE tenant_id=$1"
479
+ " AND agent_instance_id=$2 AND session_id=$3 AND status='accepted'",
480
+ self.tenant_id,
481
+ command.agent_instance_id,
482
+ command.session_id,
483
+ )
484
+ if int(depth) >= queue_limit:
485
+ await self._append_admission(
486
+ connection,
487
+ command,
488
+ control_event(
489
+ session_id=command.session_id,
490
+ event_type="control.command_rejected",
491
+ payload={
492
+ "command_id": str(command.command_id),
493
+ "status": "queue_full",
494
+ "queue_limit": queue_limit,
495
+ },
496
+ causation_id=str(command.command_id),
497
+ ),
498
+ )
499
+ return self._receipt(
500
+ command,
501
+ "queue_full",
502
+ error=ControlError(
503
+ code="queue_full",
504
+ message=f"inbox reached queue_limit={queue_limit}",
505
+ retryable=True,
506
+ ),
507
+ )
508
+
509
+ accepted_seq = await connection.fetchval(
510
+ "UPDATE kernel_accepted_seq SET last_seq = last_seq + 1"
511
+ " WHERE tenant_id=$1 AND session_id=$2 RETURNING last_seq",
512
+ self.tenant_id,
513
+ command.session_id,
514
+ )
515
+ if accepted_seq is None:
516
+ await connection.execute(
517
+ "INSERT INTO kernel_accepted_seq (tenant_id, session_id, last_seq)"
518
+ " VALUES ($1, $2, 1) ON CONFLICT (tenant_id, session_id)"
519
+ " DO UPDATE SET last_seq = kernel_accepted_seq.last_seq + 1"
520
+ " RETURNING last_seq",
521
+ self.tenant_id,
522
+ command.session_id,
523
+ )
524
+ accepted_seq = await connection.fetchval(
525
+ "SELECT last_seq FROM kernel_accepted_seq"
526
+ " WHERE tenant_id=$1 AND session_id=$2",
527
+ self.tenant_id,
528
+ command.session_id,
529
+ )
530
+ accepted_seq = int(accepted_seq or 1)
531
+ message_id = new_message_id()
532
+ await connection.execute(
533
+ "INSERT INTO kernel_inbox (message_id, tenant_id, agent_instance_id,"
534
+ " session_id, idempotency_key, request_digest, accepted_seq, status,"
535
+ " claimed_fence, payload) VALUES ($1::uuid, $2, $3, $4, $5, $6, $7,"
536
+ " 'accepted', NULL, $8::jsonb)",
537
+ message_id,
538
+ self.tenant_id,
539
+ command.agent_instance_id,
540
+ command.session_id,
541
+ command.idempotency_key,
542
+ command_digest(command),
543
+ accepted_seq,
544
+ command.model_dump_json(),
545
+ )
546
+ # ControlEvent 与 inbox insert 同一事务(SQLite 版在事务外,此处按计划收进)。
547
+ await self._append_admission(
548
+ connection,
549
+ command,
550
+ control_event(
551
+ session_id=command.session_id,
552
+ event_type="control.command_accepted",
553
+ payload={
554
+ "command_id": str(command.command_id),
555
+ "status": "accepted",
556
+ "message_id": message_id,
557
+ "accepted_seq": accepted_seq,
558
+ "command_type": command.command_type,
559
+ },
560
+ causation_id=str(command.command_id),
561
+ ),
562
+ )
563
+ return self._receipt(
564
+ command, "accepted", message_id=message_id, accepted_seq=accepted_seq
565
+ )
566
+
567
+ async def load_message(self, message_id: str) -> InboxMessage | None:
568
+ async with self._connection() as connection:
569
+ row = await connection.fetchrow(
570
+ "SELECT * FROM kernel_inbox WHERE message_id=$1::uuid", str(message_id)
571
+ )
572
+ return self._row_to_message(row)
573
+
574
+ @staticmethod
575
+ def _row_to_message(row: Any) -> InboxMessage | None:
576
+ if row is None:
577
+ return None
578
+ return InboxMessage(
579
+ message_id=str(row["message_id"]),
580
+ agent_instance_id=row["agent_instance_id"],
581
+ session_id=row["session_id"],
582
+ idempotency_key=row["idempotency_key"],
583
+ request_digest=row["request_digest"],
584
+ accepted_seq=int(row["accepted_seq"]),
585
+ status=InboxState(row["status"]),
586
+ claimed_fence=(
587
+ int(row["claimed_fence"]) if row["claimed_fence"] is not None else None
588
+ ),
589
+ command=AgentControlCommand.model_validate_json(row["payload"]),
590
+ )
591
+
592
+ async def load_by_idempotency(
593
+ self, session_id: str, idempotency_key: str
594
+ ) -> InboxMessage | None:
595
+ async with self._connection() as connection:
596
+ row = await connection.fetchrow(
597
+ "SELECT * FROM kernel_inbox WHERE tenant_id=$1 AND session_id=$2"
598
+ " AND idempotency_key=$3",
599
+ self.tenant_id,
600
+ session_id,
601
+ idempotency_key,
602
+ )
603
+ return self._row_to_message(row)
604
+
605
+ async def reject_command(
606
+ self,
607
+ command: AgentControlCommand,
608
+ *,
609
+ status: str,
610
+ code: str,
611
+ message: str,
612
+ retryable: bool = False,
613
+ ) -> AgentControlReceipt:
614
+ """admission 拒绝(invalid_permit / unsupported / ...)的脱敏审计 + receipt。
615
+
616
+ 与 InMemory 版语义一致:只追加 ``control.command_rejected`` 事实,
617
+ 不写 Inbox 行。
618
+ """
619
+ async with self._connection() as connection:
620
+ async with connection.transaction():
621
+ await self._append_admission(
622
+ connection,
623
+ command,
624
+ control_event(
625
+ session_id=command.session_id,
626
+ event_type="control.command_rejected",
627
+ payload={
628
+ "command_id": str(command.command_id),
629
+ "status": status,
630
+ "reason": code,
631
+ },
632
+ causation_id=str(command.command_id),
633
+ ),
634
+ )
635
+ return self._receipt(
636
+ command,
637
+ status,
638
+ error=ControlError(code=code, message=message, retryable=retryable),
639
+ )
640
+
641
+ async def list_messages(
642
+ self, agent_instance_id: str, session_id: str | None = None
643
+ ) -> list[InboxMessage]:
644
+ async with self._connection() as connection:
645
+ rows = await connection.fetch(
646
+ "SELECT * FROM kernel_inbox WHERE agent_instance_id=$1"
647
+ + (" AND session_id=$2" if session_id else "")
648
+ + " ORDER BY accepted_seq",
649
+ agent_instance_id,
650
+ *([session_id] if session_id else []),
651
+ )
652
+ return [m for m in (self._row_to_message(r) for r in rows) if m is not None]
653
+
654
+ async def list_pending(
655
+ self,
656
+ agent_instance_id: str,
657
+ session_id: str | None = None,
658
+ *,
659
+ fencing_token: int | None = None,
660
+ ) -> list[InboxMessage]:
661
+ """按 accepted_seq 返回可恢复消息。
662
+
663
+ 传入当前 fence 时,旧 fence 留下的 ``claimed`` 也必须可见;真正的
664
+ owner 校验与 token 改写由 ``claim_message`` 在事务内完成。否则 Pod
665
+ 恰好在 claim 后退出,会让 stale claimed 永久挡住 FIFO 头。
666
+ """
667
+ sql = (
668
+ "SELECT * FROM kernel_inbox WHERE agent_instance_id=$1"
669
+ + (" AND session_id=$2" if session_id else "")
670
+ + (
671
+ " AND status IN ('accepted','claimed')"
672
+ if fencing_token is not None
673
+ else " AND status='accepted'"
674
+ )
675
+ + " ORDER BY accepted_seq"
676
+ )
677
+ args: list[Any] = [agent_instance_id]
678
+ if session_id:
679
+ args.append(session_id)
680
+ async with self._connection() as connection:
681
+ rows = await connection.fetch(sql, *args)
682
+ return [m for m in (self._row_to_message(r) for r in rows) if m is not None]
683
+
684
+ async def claim_message(self, message_id: str, fencing_token: int) -> InboxMessage:
685
+ """按 message_id 认领(worker 选择性 FIFO);同 fence 重复认领幂等。"""
686
+ message_id = str(message_id)
687
+ async with self._connection() as connection:
688
+ async with connection.transaction():
689
+ row = await connection.fetchrow(
690
+ "SELECT * FROM kernel_inbox WHERE message_id=$1::uuid FOR UPDATE",
691
+ message_id,
692
+ )
693
+ if row is None:
694
+ raise InvalidCommandError(f"unknown message_id {message_id!r}")
695
+ if (
696
+ row["status"] == InboxState.CLAIMED.value
697
+ and int(row["claimed_fence"]) == int(fencing_token)
698
+ ):
699
+ return self._row_to_message(row) # type: ignore[return-value]
700
+ activation = await self._assert_fence(
701
+ connection,
702
+ row["agent_instance_id"],
703
+ row["session_id"],
704
+ fencing_token,
705
+ )
706
+ if row["status"] not in (
707
+ InboxState.ACCEPTED.value,
708
+ InboxState.CLAIMED.value,
709
+ ):
710
+ raise InvalidCommandError(
711
+ f"message {message_id!r} is not claimable"
712
+ f" at status {row['status']}"
713
+ )
714
+ command = AgentControlCommand.model_validate_json(row["payload"])
715
+ if command.command_type == "enqueue":
716
+ # Strict per-session FIFO is a database invariant, not a
717
+ # scheduler convention. A second worker may have listed
718
+ # pending rows before the first worker committed its
719
+ # claim. Never let it skip an earlier enqueue that is
720
+ # still accepted/claimed; the query also observes an
721
+ # uncommitted earlier update as ``accepted`` under READ
722
+ # COMMITTED, so the later claim fails closed.
723
+ earlier = await connection.fetchval(
724
+ "SELECT EXISTS (SELECT 1 FROM kernel_inbox"
725
+ " WHERE tenant_id=$1 AND agent_instance_id=$2"
726
+ " AND session_id=$3 AND accepted_seq < $4"
727
+ " AND status IN ('accepted','claimed')"
728
+ " AND payload->>'command_type'='enqueue')",
729
+ self.tenant_id,
730
+ row["agent_instance_id"],
731
+ row["session_id"],
732
+ int(row["accepted_seq"]),
733
+ )
734
+ if earlier:
735
+ raise InvalidCommandError(
736
+ f"message {message_id!r} is not the FIFO enqueue head"
737
+ )
738
+ if row["status"] == InboxState.ACCEPTED.value:
739
+ assert_inbox_transition(
740
+ InboxState(row["status"]), InboxState.CLAIMED
741
+ )
742
+ await connection.execute(
743
+ "UPDATE kernel_inbox SET status='claimed', claimed_fence=$1"
744
+ " WHERE message_id=$2::uuid",
745
+ int(fencing_token),
746
+ message_id,
747
+ )
748
+ await self._append_activation_fact(
749
+ connection,
750
+ control_event(
751
+ session_id=row["session_id"],
752
+ event_type="control.message_claimed",
753
+ payload={
754
+ "message_id": message_id,
755
+ "accepted_seq": int(row["accepted_seq"]),
756
+ "fencing_token": int(fencing_token),
757
+ },
758
+ ),
759
+ activation,
760
+ fencing_token,
761
+ )
762
+ return await self.load_message(message_id) # type: ignore[return-value]
763
+
764
+ async def discard_claim(self, message_id: str, *, expected_fence: int) -> None:
765
+ """typed rejection 的确定性收口:CLAIMED -> DISCARDED。"""
766
+ message_id = str(message_id)
767
+ async with self._connection() as connection:
768
+ async with connection.transaction():
769
+ row = await connection.fetchrow(
770
+ "SELECT * FROM kernel_inbox WHERE message_id=$1::uuid FOR UPDATE",
771
+ message_id,
772
+ )
773
+ if row is None:
774
+ raise InvalidCommandError(f"unknown message_id {message_id!r}")
775
+ activation = await self._assert_fence(
776
+ connection,
777
+ row["agent_instance_id"],
778
+ row["session_id"],
779
+ expected_fence,
780
+ )
781
+ if (
782
+ row["status"] != InboxState.CLAIMED.value
783
+ or int(row["claimed_fence"]) != int(expected_fence)
784
+ ):
785
+ raise StaleFenceError(
786
+ f"message {message_id!r} is not claimed at fence {expected_fence}"
787
+ )
788
+ assert_inbox_transition(InboxState(row["status"]), InboxState.DISCARDED)
789
+ await connection.execute(
790
+ "UPDATE kernel_inbox SET status='discarded' WHERE message_id=$1::uuid",
791
+ message_id,
792
+ )
793
+ await self._append_activation_fact(
794
+ connection,
795
+ control_event(
796
+ session_id=row["session_id"],
797
+ event_type="control.message_discarded",
798
+ payload={
799
+ "message_id": message_id,
800
+ "fencing_token": int(expected_fence),
801
+ },
802
+ ),
803
+ activation,
804
+ expected_fence,
805
+ )
806
+
807
+ async def inbox_depth(
808
+ self, agent_instance_id: str, session_id: str | None = None
809
+ ) -> int:
810
+ async with self._connection() as connection:
811
+ return int(
812
+ await connection.fetchval(
813
+ "SELECT COUNT(*) FROM kernel_inbox WHERE agent_instance_id=$1"
814
+ + (" AND session_id=$2" if session_id else "")
815
+ + " AND status='accepted'",
816
+ agent_instance_id,
817
+ *([session_id] if session_id else []),
818
+ )
819
+ )
820
+
821
+ async def find_active_run(
822
+ self, agent_instance_id: str, session_id: str | None = None
823
+ ) -> RunRecord | None:
824
+ async with self._connection() as connection:
825
+ rows = await connection.fetch(
826
+ "SELECT * FROM kernel_runs WHERE agent_instance_id=$1"
827
+ + (" AND session_id=$2" if session_id else "")
828
+ + " ORDER BY created_at",
829
+ agent_instance_id,
830
+ *([session_id] if session_id else []),
831
+ )
832
+ for row in rows:
833
+ if is_active_run(RunState(row["state"])):
834
+ return RunRecord(
835
+ run_id=row["run_id"],
836
+ agent_instance_id=row["agent_instance_id"],
837
+ session_id=row["session_id"],
838
+ state=row["state"],
839
+ activation_fence=int(row["activation_fence"]),
840
+ created_at=row["created_at"].isoformat(),
841
+ updated_at=row["updated_at"].isoformat(),
842
+ metadata=json.loads(row["metadata"]),
843
+ )
844
+ return None
845
+
846
+ async def current_lease(
847
+ self, agent_instance_id: str, session_id: str | None = None
848
+ ) -> ActivationLease | None:
849
+ async with self._connection() as connection:
850
+ row = await connection.fetchrow(
851
+ "SELECT * FROM kernel_activations WHERE agent_instance_id=$1"
852
+ + (" AND session_id=$2" if session_id else "")
853
+ + " AND released=FALSE AND lease_expires_at > now()"
854
+ + (" ORDER BY lease_expires_at DESC LIMIT 1"),
855
+ agent_instance_id,
856
+ *([session_id] if session_id else []),
857
+ )
858
+ if row is None:
859
+ return None
860
+ return ActivationLease(
861
+ agent_instance_id=row["agent_instance_id"],
862
+ activation_id=row["activation_id"],
863
+ fencing_token=int(row["fencing_token"]),
864
+ lease_expires_at=row["lease_expires_at"].isoformat(),
865
+ bundle_digest=row["bundle_digest"],
866
+ runtime_type=row["runtime_type"],
867
+ capability_digest=row["capability_digest"],
868
+ )
869
+
870
+ async def claim_next(
871
+ self, agent_instance_id: str, session_id: str, fencing_token: int
872
+ ) -> InboxMessage | None:
873
+ async with self._connection() as connection:
874
+ async with connection.transaction():
875
+ activation = await self._assert_fence(
876
+ connection, agent_instance_id, session_id, fencing_token
877
+ )
878
+ row = await connection.fetchrow(
879
+ "SELECT message_id, status FROM kernel_inbox"
880
+ " WHERE agent_instance_id=$1 AND session_id=$2"
881
+ " AND (status='accepted' OR (status='claimed' AND claimed_fence <> $3))"
882
+ " ORDER BY accepted_seq"
883
+ " FOR UPDATE SKIP LOCKED"
884
+ " LIMIT 1",
885
+ agent_instance_id,
886
+ session_id,
887
+ int(fencing_token),
888
+ )
889
+ if row is None:
890
+ return None
891
+ if row["status"] == InboxState.ACCEPTED.value:
892
+ assert_inbox_transition(InboxState(row["status"]), InboxState.CLAIMED)
893
+ await connection.execute(
894
+ "UPDATE kernel_inbox SET status='claimed', claimed_fence=$1"
895
+ " WHERE message_id=$2::uuid",
896
+ int(fencing_token),
897
+ str(row["message_id"]),
898
+ )
899
+ await self._append_activation_fact(
900
+ connection,
901
+ control_event(
902
+ session_id=session_id,
903
+ event_type="control.message_claimed",
904
+ payload={
905
+ "message_id": str(row["message_id"]),
906
+ "fencing_token": int(fencing_token),
907
+ },
908
+ ),
909
+ activation,
910
+ fencing_token,
911
+ )
912
+ return await self.load_message(row["message_id"])
913
+
914
+ async def complete_claim(self, message_id: str, *, expected_fence: int) -> None:
915
+ message_id = str(message_id)
916
+ async with self._connection() as connection:
917
+ async with connection.transaction():
918
+ row = await connection.fetchrow(
919
+ "SELECT * FROM kernel_inbox WHERE message_id=$1::uuid", message_id
920
+ )
921
+ if row is None:
922
+ raise InvalidCommandError(f"unknown message_id {message_id!r}")
923
+ activation = await self._assert_fence(
924
+ connection, row["agent_instance_id"], row["session_id"], expected_fence
925
+ )
926
+ if (
927
+ row["status"] != InboxState.CLAIMED.value
928
+ or row["claimed_fence"] != int(expected_fence)
929
+ ):
930
+ raise StaleFenceError(
931
+ f"message {message_id!r} is not claimed at fence {expected_fence}"
932
+ )
933
+ assert_inbox_transition(InboxState(row["status"]), InboxState.COMPLETED)
934
+ await connection.execute(
935
+ "UPDATE kernel_inbox SET status='completed' WHERE message_id=$1::uuid",
936
+ message_id,
937
+ )
938
+ await self._append_activation_fact(
939
+ connection,
940
+ control_event(
941
+ session_id=row["session_id"],
942
+ event_type="control.message_completed",
943
+ payload={"message_id": message_id, "fencing_token": int(expected_fence)},
944
+ ),
945
+ activation,
946
+ expected_fence,
947
+ )
948
+
949
+ # -------------------------------------------------------------- interactions
950
+
951
+ async def _assert_interaction_guard(
952
+ self, connection: Any, agent_instance_id: str, session_id: str, guard: Any
953
+ ) -> dict[str, Any]:
954
+ row = await connection.fetchrow(
955
+ "SELECT activation_id, agent_instance_id, session_id, fencing_token,"
956
+ " lease_expires_at, released FROM kernel_activations"
957
+ " WHERE activation_id = $1 AND agent_instance_id = $2"
958
+ " AND session_id = $3 FOR SHARE",
959
+ guard.activation_id,
960
+ agent_instance_id,
961
+ session_id,
962
+ )
963
+ if (
964
+ row is None
965
+ or row["released"]
966
+ or row["lease_expires_at"] <= _now()
967
+ or int(row["fencing_token"]) != int(guard.fencing_token)
968
+ or row["agent_instance_id"] != agent_instance_id
969
+ or row["session_id"] != session_id
970
+ ):
971
+ raise StaleFenceError(
972
+ "interaction write guard does not match the current lease",
973
+ details={
974
+ "activation_id": guard.activation_id,
975
+ "fencing_token": int(guard.fencing_token),
976
+ "session_id": session_id,
977
+ },
978
+ )
979
+ return dict(row)
980
+
981
+ async def _interaction_row_for_guard(
982
+ self, connection: Any, interaction_id: str, guard: Any
983
+ ) -> Any | None:
984
+ """Resolve a public interaction id within its fenced activation scope.
985
+
986
+ Interaction ids are opaque browser-visible handles, not tenant grants.
987
+ A Runtime mutation already has the Server-admitted activation guard, so
988
+ select the row by that trusted AgentInstance/session before taking the
989
+ row lock. This prevents a same-id record in another tenant from being
990
+ selected and then rejected only after information has been consulted.
991
+ """
992
+
993
+ activation = await connection.fetchrow(
994
+ "SELECT activation_id, agent_instance_id, session_id, fencing_token,"
995
+ " lease_expires_at, released FROM kernel_activations"
996
+ " WHERE activation_id=$1 FOR SHARE",
997
+ guard.activation_id,
998
+ )
999
+ if (
1000
+ activation is None
1001
+ or activation["released"]
1002
+ or activation["lease_expires_at"] <= _now()
1003
+ or int(activation["fencing_token"]) != int(guard.fencing_token)
1004
+ ):
1005
+ raise StaleFenceError(
1006
+ "interaction write guard does not match the current lease",
1007
+ details={
1008
+ "activation_id": guard.activation_id,
1009
+ "fencing_token": int(guard.fencing_token),
1010
+ },
1011
+ )
1012
+ return await connection.fetchrow(
1013
+ "SELECT * FROM kernel_interactions WHERE interaction_id=$1"
1014
+ " AND agent_instance_id=$2 AND session_id=$3 FOR UPDATE",
1015
+ interaction_id,
1016
+ activation["agent_instance_id"],
1017
+ activation["session_id"],
1018
+ )
1019
+
1020
+ @staticmethod
1021
+ def _interaction_row_to_record(row: Any) -> InteractionRecord:
1022
+ from ksadk.interaction.contracts import InteractionPresentation
1023
+
1024
+ presentation = None
1025
+ if row["presentation"] is not None:
1026
+ presentation = InteractionPresentation.model_validate(
1027
+ json.loads(row["presentation"])
1028
+ )
1029
+ return InteractionRecord(
1030
+ interaction_id=row["interaction_id"],
1031
+ tenant_id=row["tenant_id"],
1032
+ agent_instance_id=row["agent_instance_id"],
1033
+ session_id=row["session_id"],
1034
+ run_id=row["run_id"],
1035
+ kind=row["kind"],
1036
+ request_schema=json.loads(row["request_schema"]),
1037
+ revision=int(row["revision"]),
1038
+ status=row["status"],
1039
+ created_at=row["created_at"].isoformat(),
1040
+ expires_at=(
1041
+ row["expires_at"].isoformat() if row["expires_at"] is not None else None
1042
+ ),
1043
+ presentation=presentation,
1044
+ provider_id=row["provider_id"] or "",
1045
+ native_target=(
1046
+ json.loads(row["native_target"])
1047
+ if row["native_target"] is not None
1048
+ else None
1049
+ ),
1050
+ continuation_metadata=(
1051
+ json.loads(row["continuation_metadata"])
1052
+ if row["continuation_metadata"] is not None
1053
+ else None
1054
+ ),
1055
+ )
1056
+
1057
+ async def request(
1058
+ self, record: InteractionRecord, *, guard: Any
1059
+ ) -> InteractionRecord:
1060
+ digest = request_digest(record)
1061
+ async with self._connection() as connection:
1062
+ async with connection.transaction():
1063
+ await self._assert_interaction_guard(
1064
+ connection, record.agent_instance_id, record.session_id, guard
1065
+ )
1066
+ existing = await connection.fetchrow(
1067
+ "SELECT * FROM kernel_interactions"
1068
+ " WHERE tenant_id=$1 AND interaction_id=$2",
1069
+ record.tenant_id,
1070
+ record.interaction_id,
1071
+ )
1072
+ if existing is not None:
1073
+ if existing["request_digest"] != digest:
1074
+ raise InvalidCommandError(
1075
+ "interaction_id reused with a different request digest",
1076
+ details={
1077
+ "reason": REQUEST_CONFLICT,
1078
+ "interaction_id": record.interaction_id,
1079
+ },
1080
+ )
1081
+ return self._interaction_row_to_record(existing)
1082
+ await connection.execute(
1083
+ """
1084
+ INSERT INTO kernel_interactions (
1085
+ interaction_id, tenant_id, agent_instance_id, session_id,
1086
+ run_id, kind, request_schema, presentation, revision, status,
1087
+ created_at, expires_at, provider_id, native_target,
1088
+ continuation_metadata, request_digest, updated_at
1089
+ ) VALUES (
1090
+ $1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9, 'pending',
1091
+ $10::timestamptz, $11::timestamptz, $12, $13::jsonb,
1092
+ $14::jsonb, $15, now()
1093
+ )
1094
+ """,
1095
+ record.interaction_id,
1096
+ record.tenant_id,
1097
+ record.agent_instance_id,
1098
+ record.session_id,
1099
+ record.run_id,
1100
+ record.kind,
1101
+ json.dumps(record.request_schema, ensure_ascii=False),
1102
+ (
1103
+ record.presentation.model_dump_json()
1104
+ if record.presentation is not None
1105
+ else None
1106
+ ),
1107
+ record.revision,
1108
+ _parse_ts(record.created_at),
1109
+ _parse_ts(record.expires_at),
1110
+ record.provider_id,
1111
+ (
1112
+ json.dumps(record.native_target, ensure_ascii=False)
1113
+ if record.native_target is not None
1114
+ else None
1115
+ ),
1116
+ (
1117
+ json.dumps(record.continuation_metadata, ensure_ascii=False)
1118
+ if record.continuation_metadata is not None
1119
+ else None
1120
+ ),
1121
+ digest,
1122
+ )
1123
+ # requested 事件与 pending 行同一事务:commit 前被 kill 无半状态。
1124
+ await self._events.append_on(
1125
+ connection, requested_event_payload(record, now_iso()), guard
1126
+ )
1127
+ return record
1128
+
1129
+ async def resolve(
1130
+ self, submission: InteractionSubmission, *, guard: Any
1131
+ ) -> InteractionReceipt:
1132
+ sub_digest = submission_digest(submission)
1133
+ async with self._connection() as connection:
1134
+ async with connection.transaction():
1135
+ row = await self._interaction_row_for_guard(
1136
+ connection, submission.interaction_id, guard
1137
+ )
1138
+ if row is None:
1139
+ raise InvalidCommandError(
1140
+ f"unknown interaction_id {submission.interaction_id!r}"
1141
+ )
1142
+ await self._assert_interaction_guard(
1143
+ connection, row["agent_instance_id"], row["session_id"], guard
1144
+ )
1145
+ current = self._interaction_row_to_record(row)
1146
+ if is_terminal(current.status):
1147
+ existing_sub = await connection.fetchrow(
1148
+ "SELECT submission_digest, receipt FROM"
1149
+ " kernel_interaction_submissions WHERE tenant_id=$1"
1150
+ " AND interaction_id=$2 AND idempotency_key=$3",
1151
+ current.tenant_id,
1152
+ current.interaction_id,
1153
+ submission.idempotency_key,
1154
+ )
1155
+ if (
1156
+ existing_sub is not None
1157
+ and existing_sub["submission_digest"] == sub_digest
1158
+ ):
1159
+ return InteractionReceipt.model_validate(
1160
+ json.loads(existing_sub["receipt"])
1161
+ )
1162
+ raise InvalidCommandError(
1163
+ f"interaction already reached terminal status"
1164
+ f" {current.status!r}",
1165
+ details={
1166
+ "reason": ALREADY_RESOLVED,
1167
+ "interaction_id": current.interaction_id,
1168
+ },
1169
+ )
1170
+ if current.revision != submission.expected_revision:
1171
+ raise InvalidCommandError(
1172
+ "interaction revision does not match expected_revision",
1173
+ details={
1174
+ "reason": REVISION_MISMATCH,
1175
+ "interaction_id": current.interaction_id,
1176
+ "expected_revision": submission.expected_revision,
1177
+ "current_revision": current.revision,
1178
+ },
1179
+ )
1180
+ outcome = resolve_outcome(submission.action)
1181
+ updated = current.model_copy(
1182
+ update={"status": "resolved", "revision": current.revision + 1}
1183
+ )
1184
+ stored = await self._events.append_on(
1185
+ connection,
1186
+ interaction_event(
1187
+ updated,
1188
+ event_type="interaction.resolved",
1189
+ timestamp=now_iso(),
1190
+ outcome=outcome,
1191
+ response=submission.response,
1192
+ actor_ref="user",
1193
+ ),
1194
+ guard,
1195
+ )
1196
+ receipt = InteractionReceipt(
1197
+ interaction_id=updated.interaction_id,
1198
+ revision=updated.revision,
1199
+ status="resolved",
1200
+ outcome=outcome, # type: ignore[arg-type]
1201
+ event_id=str(stored.event_id),
1202
+ accepted_seq=stored.seq,
1203
+ )
1204
+ await connection.execute(
1205
+ "UPDATE kernel_interactions SET revision=$1, status='resolved',"
1206
+ " response=$2::jsonb, outcome=$3, actor=$4, event_id=$5::uuid,"
1207
+ " accepted_seq=$6, fencing_token=$7, updated_at=now()"
1208
+ " WHERE tenant_id=$8 AND interaction_id=$9",
1209
+ updated.revision,
1210
+ json.dumps(submission.response, ensure_ascii=False),
1211
+ outcome,
1212
+ "user",
1213
+ str(stored.event_id),
1214
+ stored.seq,
1215
+ int(guard.fencing_token),
1216
+ updated.tenant_id,
1217
+ updated.interaction_id,
1218
+ )
1219
+ await connection.execute(
1220
+ "INSERT INTO kernel_interaction_submissions (tenant_id,"
1221
+ " interaction_id, idempotency_key, submission_digest, receipt)"
1222
+ " VALUES ($1, $2, $3, $4, $5::jsonb) ON CONFLICT DO NOTHING",
1223
+ updated.tenant_id,
1224
+ updated.interaction_id,
1225
+ submission.idempotency_key,
1226
+ sub_digest,
1227
+ receipt.model_dump_json(),
1228
+ )
1229
+ return receipt
1230
+
1231
+ async def _terminal_command(
1232
+ self,
1233
+ interaction_id: str,
1234
+ expected_revision: int,
1235
+ *,
1236
+ guard: Any,
1237
+ status: str,
1238
+ reason: str,
1239
+ ) -> InteractionReceipt:
1240
+ async with self._connection() as connection:
1241
+ async with connection.transaction():
1242
+ row = await self._interaction_row_for_guard(
1243
+ connection, interaction_id, guard
1244
+ )
1245
+ if row is None:
1246
+ raise InvalidCommandError(
1247
+ f"unknown interaction_id {interaction_id!r}"
1248
+ )
1249
+ await self._assert_interaction_guard(
1250
+ connection, row["agent_instance_id"], row["session_id"], guard
1251
+ )
1252
+ current = self._interaction_row_to_record(row)
1253
+ if is_terminal(current.status):
1254
+ raise InvalidCommandError(
1255
+ f"interaction already reached terminal status"
1256
+ f" {current.status!r}",
1257
+ details={
1258
+ "reason": ALREADY_RESOLVED,
1259
+ "interaction_id": current.interaction_id,
1260
+ },
1261
+ )
1262
+ if current.revision != expected_revision:
1263
+ raise InvalidCommandError(
1264
+ "interaction revision does not match expected_revision",
1265
+ details={
1266
+ "reason": REVISION_MISMATCH,
1267
+ "interaction_id": current.interaction_id,
1268
+ "expected_revision": expected_revision,
1269
+ "current_revision": current.revision,
1270
+ },
1271
+ )
1272
+ updated = current.model_copy(
1273
+ update={"status": status, "revision": current.revision + 1}
1274
+ )
1275
+ event_type = (
1276
+ "interaction.cancelled"
1277
+ if status == "cancelled"
1278
+ else "interaction.expired"
1279
+ )
1280
+ stored = await self._events.append_on(
1281
+ connection,
1282
+ interaction_event(
1283
+ updated,
1284
+ event_type=event_type,
1285
+ timestamp=now_iso(),
1286
+ reason=reason,
1287
+ ),
1288
+ guard,
1289
+ )
1290
+ receipt = InteractionReceipt(
1291
+ interaction_id=updated.interaction_id,
1292
+ revision=updated.revision,
1293
+ status=updated.status, # type: ignore[arg-type]
1294
+ outcome=updated.status, # type: ignore[arg-type]
1295
+ event_id=str(stored.event_id),
1296
+ accepted_seq=stored.seq,
1297
+ )
1298
+ await connection.execute(
1299
+ "UPDATE kernel_interactions SET revision=$1, status=$2,"
1300
+ " outcome=$3, event_id=$4::uuid, accepted_seq=$5,"
1301
+ " fencing_token=$6, updated_at=now()"
1302
+ " WHERE tenant_id=$7 AND interaction_id=$8",
1303
+ updated.revision,
1304
+ status,
1305
+ status,
1306
+ str(stored.event_id),
1307
+ stored.seq,
1308
+ int(guard.fencing_token),
1309
+ updated.tenant_id,
1310
+ updated.interaction_id,
1311
+ )
1312
+ return receipt
1313
+
1314
+ async def cancel(
1315
+ self, interaction_id: str, expected_revision: int, *, guard: Any
1316
+ ) -> InteractionReceipt:
1317
+ return await self._terminal_command(
1318
+ interaction_id,
1319
+ expected_revision,
1320
+ guard=guard,
1321
+ status="cancelled",
1322
+ reason="cancelled by owner",
1323
+ )
1324
+
1325
+ async def expire(
1326
+ self, interaction_id: str, expected_revision: int, *, guard: Any
1327
+ ) -> InteractionReceipt:
1328
+ return await self._terminal_command(
1329
+ interaction_id,
1330
+ expected_revision,
1331
+ guard=guard,
1332
+ status="expired",
1333
+ reason="interaction expired",
1334
+ )
1335
+
1336
+ async def get(
1337
+ self,
1338
+ interaction_id: str,
1339
+ *,
1340
+ tenant_id: str | None = None,
1341
+ agent_instance_id: str | None = None,
1342
+ session_id: str | None = None,
1343
+ run_id: str | None = None,
1344
+ ) -> InteractionRecord | None:
1345
+ """Read public ids only through a complete trusted execution scope."""
1346
+
1347
+ scope = (tenant_id, agent_instance_id, session_id, run_id)
1348
+ async with self._connection() as connection:
1349
+ if any(value is not None for value in scope):
1350
+ if not all(value is not None for value in scope):
1351
+ raise InvalidCommandError(
1352
+ "interaction lookup requires a complete trusted scope",
1353
+ details={"interaction_id": interaction_id},
1354
+ )
1355
+ row = await connection.fetchrow(
1356
+ "SELECT * FROM kernel_interactions WHERE interaction_id=$1"
1357
+ " AND tenant_id=$2 AND agent_instance_id=$3 AND session_id=$4"
1358
+ " AND run_id=$5",
1359
+ interaction_id,
1360
+ tenant_id,
1361
+ agent_instance_id,
1362
+ session_id,
1363
+ run_id,
1364
+ )
1365
+ return self._interaction_row_to_record(row) if row is not None else None
1366
+ rows = await connection.fetch(
1367
+ "SELECT * FROM kernel_interactions WHERE interaction_id=$1 LIMIT 2",
1368
+ interaction_id,
1369
+ )
1370
+ if len(rows) > 1:
1371
+ raise InvalidCommandError(
1372
+ f"interaction_id {interaction_id!r} is ambiguous without trusted scope",
1373
+ details={"reason": REQUEST_CONFLICT, "interaction_id": interaction_id},
1374
+ )
1375
+ return self._interaction_row_to_record(rows[0]) if rows else None
1376
+
1377
+ async def list_pending_interactions(
1378
+ self, tenant_id: str, session_id: str
1379
+ ) -> list[InteractionRecord]:
1380
+ async with self._connection() as connection:
1381
+ rows = await connection.fetch(
1382
+ "SELECT * FROM kernel_interactions WHERE tenant_id=$1 AND session_id=$2"
1383
+ " AND status='pending' ORDER BY created_at",
1384
+ tenant_id,
1385
+ session_id,
1386
+ )
1387
+ return [self._interaction_row_to_record(row) for row in rows]
1388
+
1389
+ # ------------------------------------------------------------- activations
1390
+
1391
+ async def acquire_activation(self, request: ActivationLeaseRequest) -> ActivationLease:
1392
+ async with self._connection() as connection:
1393
+ async with connection.transaction():
1394
+ row = await connection.fetchrow(
1395
+ """
1396
+ INSERT INTO kernel_activations (
1397
+ agent_instance_id, session_id, activation_id, fencing_token,
1398
+ lease_expires_at, runtime_type, bundle_digest, capability_digest
1399
+ ) VALUES (
1400
+ $1, $2, $3, 1, now() + make_interval(secs => $4), $5, $6, $7
1401
+ )
1402
+ ON CONFLICT (agent_instance_id, session_id) DO UPDATE SET
1403
+ activation_id = excluded.activation_id,
1404
+ fencing_token = CASE
1405
+ WHEN kernel_activations.activation_id = excluded.activation_id
1406
+ THEN kernel_activations.fencing_token
1407
+ ELSE kernel_activations.fencing_token + 1
1408
+ END,
1409
+ lease_expires_at = excluded.lease_expires_at,
1410
+ released = FALSE,
1411
+ runtime_type = excluded.runtime_type,
1412
+ bundle_digest = excluded.bundle_digest,
1413
+ capability_digest = excluded.capability_digest
1414
+ WHERE kernel_activations.released
1415
+ OR kernel_activations.lease_expires_at <= now()
1416
+ OR kernel_activations.activation_id = excluded.activation_id
1417
+ RETURNING fencing_token, lease_expires_at
1418
+ """,
1419
+ request.agent_instance_id,
1420
+ request.session_id,
1421
+ request.activation_id,
1422
+ request.lease_ttl_seconds,
1423
+ request.runtime_type,
1424
+ request.bundle_digest,
1425
+ request.capability_digest,
1426
+ )
1427
+ if row is None:
1428
+ holder = await connection.fetchrow(
1429
+ "SELECT activation_id, lease_expires_at FROM kernel_activations"
1430
+ " WHERE agent_instance_id=$1 AND session_id=$2",
1431
+ request.agent_instance_id,
1432
+ request.session_id,
1433
+ )
1434
+ raise InvalidCommandError(
1435
+ "activation lease is still held by another owner",
1436
+ details={
1437
+ "holder": holder["activation_id"] if holder else None,
1438
+ "lease_expires_at": (
1439
+ holder["lease_expires_at"].isoformat() if holder else None
1440
+ ),
1441
+ },
1442
+ )
1443
+ return ActivationLease(
1444
+ agent_instance_id=request.agent_instance_id,
1445
+ activation_id=request.activation_id,
1446
+ fencing_token=int(row["fencing_token"]),
1447
+ lease_expires_at=row["lease_expires_at"].isoformat(),
1448
+ bundle_digest=request.bundle_digest,
1449
+ runtime_type=request.runtime_type,
1450
+ capability_digest=request.capability_digest,
1451
+ )
1452
+
1453
+ async def renew_activation(
1454
+ self, activation_id: str, *, expected_fence: int, lease_ttl_seconds: float
1455
+ ) -> ActivationLease:
1456
+ async with self._connection() as connection:
1457
+ async with connection.transaction():
1458
+ row = await connection.fetchrow(
1459
+ "SELECT * FROM kernel_activations WHERE activation_id=$1 FOR UPDATE",
1460
+ activation_id,
1461
+ )
1462
+ if row is None:
1463
+ raise InvalidCommandError(f"unknown activation_id {activation_id!r}")
1464
+ if (
1465
+ row["released"]
1466
+ or row["lease_expires_at"] <= _now()
1467
+ or int(row["fencing_token"]) != int(expected_fence)
1468
+ ):
1469
+ raise StaleFenceError(
1470
+ f"cannot renew activation {activation_id!r} at fence {expected_fence}"
1471
+ )
1472
+ expires_at = await connection.fetchval(
1473
+ "UPDATE kernel_activations SET lease_expires_at = now()"
1474
+ " + make_interval(secs => $1) WHERE activation_id=$2"
1475
+ " RETURNING lease_expires_at",
1476
+ lease_ttl_seconds,
1477
+ activation_id,
1478
+ )
1479
+ return ActivationLease(
1480
+ agent_instance_id=row["agent_instance_id"],
1481
+ activation_id=activation_id,
1482
+ fencing_token=int(row["fencing_token"]),
1483
+ lease_expires_at=expires_at.isoformat(),
1484
+ bundle_digest=row["bundle_digest"],
1485
+ runtime_type=row["runtime_type"],
1486
+ capability_digest=row["capability_digest"],
1487
+ )
1488
+
1489
+ async def release_activation(self, activation_id: str, *, expected_fence: int) -> None:
1490
+ async with self._connection() as connection:
1491
+ async with connection.transaction():
1492
+ row = await connection.fetchrow(
1493
+ "SELECT fencing_token, released FROM kernel_activations"
1494
+ " WHERE activation_id=$1 FOR UPDATE",
1495
+ activation_id,
1496
+ )
1497
+ if row is None:
1498
+ raise InvalidCommandError(f"unknown activation_id {activation_id!r}")
1499
+ if row["released"] or int(row["fencing_token"]) != int(expected_fence):
1500
+ raise StaleFenceError(
1501
+ f"cannot release activation {activation_id!r} at fence {expected_fence}"
1502
+ )
1503
+ await connection.execute(
1504
+ "UPDATE kernel_activations SET released=TRUE, lease_expires_at=now()"
1505
+ " WHERE activation_id=$1",
1506
+ activation_id,
1507
+ )
1508
+
1509
+ # ------------------------------------------------------------------ events
1510
+
1511
+ async def append_event(
1512
+ self,
1513
+ envelope: SessionEventEnvelope,
1514
+ *,
1515
+ expected_fence: int,
1516
+ agent_instance_id: str | None = None,
1517
+ ) -> SessionEventEnvelope:
1518
+ async with self._connection() as connection:
1519
+ async with connection.transaction():
1520
+ activation = await self._resolve_activation(
1521
+ connection, envelope.session_id, agent_instance_id
1522
+ )
1523
+ # fence 比较必须发生在 event insert 之前且同一事务。
1524
+ await self._assert_fence(
1525
+ connection,
1526
+ activation["agent_instance_id"],
1527
+ envelope.session_id,
1528
+ expected_fence,
1529
+ )
1530
+ return await self._append_activation_fact(
1531
+ connection, envelope, activation, expected_fence
1532
+ )
1533
+
1534
+ async def _resolve_activation(
1535
+ self, connection: Any, session_id: str, agent_instance_id: str | None
1536
+ ) -> dict[str, Any]:
1537
+ if agent_instance_id is not None:
1538
+ row = await connection.fetchrow(
1539
+ "SELECT activation_id, agent_instance_id, session_id, released,"
1540
+ " fencing_token, lease_expires_at FROM kernel_activations"
1541
+ " WHERE agent_instance_id=$1 AND session_id=$2",
1542
+ agent_instance_id,
1543
+ session_id,
1544
+ )
1545
+ if row is None or row["released"]:
1546
+ raise StaleFenceError(
1547
+ "no active activation lease",
1548
+ details={"agent_instance_id": agent_instance_id, "session_id": session_id},
1549
+ )
1550
+ return dict(row)
1551
+ rows = await connection.fetch(
1552
+ "SELECT activation_id, agent_instance_id, session_id, released,"
1553
+ " fencing_token, lease_expires_at FROM kernel_activations WHERE session_id=$1",
1554
+ session_id,
1555
+ )
1556
+ active = [dict(row) for row in rows if not row["released"]]
1557
+ if len(active) != 1:
1558
+ raise StaleFenceError(
1559
+ "cannot resolve a single activation lease for session",
1560
+ details={"session_id": session_id, "matches": len(active)},
1561
+ )
1562
+ return active[0]
1563
+
1564
+ # -------------------------------------------------------------------- runs
1565
+
1566
+ async def load_run(self, run_id: str) -> RunRecord | None:
1567
+ async with self._connection() as connection:
1568
+ row = await connection.fetchrow(
1569
+ "SELECT * FROM kernel_runs WHERE run_id=$1", run_id
1570
+ )
1571
+ if row is None:
1572
+ return None
1573
+ return RunRecord(
1574
+ run_id=row["run_id"],
1575
+ agent_instance_id=row["agent_instance_id"],
1576
+ session_id=row["session_id"],
1577
+ state=row["state"],
1578
+ activation_fence=int(row["activation_fence"]),
1579
+ created_at=row["created_at"].isoformat(),
1580
+ updated_at=row["updated_at"].isoformat(),
1581
+ metadata=json.loads(row["metadata"]),
1582
+ )
1583
+
1584
+ async def save_run_transition(
1585
+ self, run: RunRecord, *, expected_fence: int
1586
+ ) -> RunRecord:
1587
+ async with self._connection() as connection:
1588
+ async with connection.transaction():
1589
+ activation = await self._assert_fence(
1590
+ connection, run.agent_instance_id, run.session_id, expected_fence
1591
+ )
1592
+ existing = await connection.fetchrow(
1593
+ "SELECT * FROM kernel_runs WHERE run_id=$1", run.run_id
1594
+ )
1595
+ assert_run_transition(
1596
+ RunState(existing["state"]) if existing is not None else None, run.state
1597
+ )
1598
+ if is_active_run(run.state):
1599
+ clash = await connection.fetchval(
1600
+ "SELECT run_id FROM kernel_runs WHERE session_id=$1 AND run_id <> $2"
1601
+ " AND state IN ('running','paused','waiting')",
1602
+ run.session_id,
1603
+ run.run_id,
1604
+ )
1605
+ if clash is not None:
1606
+ raise InvalidCommandError(
1607
+ "session already has an active run",
1608
+ details={"session_id": run.session_id, "active_run_id": clash},
1609
+ )
1610
+ timestamp = now_iso()
1611
+ stored = run.model_copy(
1612
+ update={
1613
+ "activation_fence": int(expected_fence),
1614
+ "created_at": (
1615
+ existing["created_at"].isoformat() if existing else timestamp
1616
+ ),
1617
+ "updated_at": timestamp,
1618
+ }
1619
+ )
1620
+ await connection.execute(
1621
+ """
1622
+ INSERT INTO kernel_runs (run_id, tenant_id, agent_instance_id, session_id,
1623
+ state, activation_fence, created_at, updated_at, metadata)
1624
+ VALUES ($1, $2, $3, $4, $5, $6, now(), now(), $7::jsonb)
1625
+ ON CONFLICT (run_id) DO UPDATE SET
1626
+ state = excluded.state,
1627
+ activation_fence = excluded.activation_fence,
1628
+ updated_at = now(),
1629
+ metadata = excluded.metadata
1630
+ """,
1631
+ stored.run_id,
1632
+ self.tenant_id,
1633
+ stored.agent_instance_id,
1634
+ stored.session_id,
1635
+ stored.state.value,
1636
+ stored.activation_fence,
1637
+ json.dumps(stored.metadata, ensure_ascii=False),
1638
+ )
1639
+ await self._append_activation_fact(
1640
+ connection,
1641
+ control_event(
1642
+ session_id=run.session_id,
1643
+ event_type="control.run_transition",
1644
+ payload={
1645
+ "run_id": run.run_id,
1646
+ "state": run.state.value,
1647
+ "fencing_token": int(expected_fence),
1648
+ },
1649
+ run_id=run.run_id,
1650
+ ),
1651
+ activation,
1652
+ expected_fence,
1653
+ )
1654
+ return stored
1655
+
1656
+
1657
+ class PostgresFencedSessionEventStore:
1658
+ """事务级 fenced ``SessionEventStore``(Task 4 Step 5)。
1659
+
1660
+ typed RuntimeEvent 写路径(``RuntimeEventStore.append -> append(envelope,
1661
+ guard=ActivationWriteGuard)``)的缺口修复:每个 ActivationWriteGuard
1662
+ append 都在**同一个 PostgreSQL 事务**里先对 activation 行做
1663
+ ``FOR SHARE`` compare-fence(activation_id / fencing_token / 未过期 /
1664
+ 未 released),再执行 event insert——被 takeover 的旧 owner 在写出任何
1665
+ runtime/progress/terminal 事实之前就被 :class:`StaleFenceError` 回滚。
1666
+
1667
+ AdmissionWriteGuard(accepted/rejected admission 事实)继续由
1668
+ :class:`PostgresAgentKernelStore` 的 writer 事务内联处理;独立调用时
1669
+ 走 event log 自开事务。
1670
+ """
1671
+
1672
+ def __init__(self, store: "PostgresAgentKernelStore") -> None:
1673
+ self._store = store
1674
+ self._log = store._events
1675
+
1676
+ @asynccontextmanager
1677
+ async def _connection(self):
1678
+ async with self._store._connection() as connection:
1679
+ yield connection
1680
+
1681
+ async def append(
1682
+ self, envelope: SessionEventEnvelope, *, guard: Any
1683
+ ) -> SessionEventEnvelope:
1684
+ from ksadk.events.session_event import validate_write_guard
1685
+
1686
+ validate_write_guard(envelope, guard)
1687
+ if isinstance(guard, ActivationWriteGuard):
1688
+ async with self._connection() as connection:
1689
+ async with connection.transaction():
1690
+ await self._assert_activation_fence(
1691
+ connection, guard, envelope.session_id
1692
+ )
1693
+ return await self._log.append_on(connection, envelope, guard)
1694
+ return await self._log.append(envelope, guard=guard)
1695
+
1696
+ async def _assert_activation_fence(
1697
+ self, connection: Any, guard: ActivationWriteGuard, session_id: str
1698
+ ) -> None:
1699
+ row = await connection.fetchrow(
1700
+ "SELECT activation_id, fencing_token, lease_expires_at, released"
1701
+ " FROM kernel_activations WHERE activation_id = $1 AND session_id = $2"
1702
+ " FOR SHARE",
1703
+ guard.activation_id,
1704
+ session_id,
1705
+ )
1706
+ if (
1707
+ row is None
1708
+ or row["released"]
1709
+ or row["lease_expires_at"] <= _now()
1710
+ or int(row["fencing_token"]) != int(guard.fencing_token)
1711
+ ):
1712
+ raise StaleFenceError(
1713
+ "activation lease does not match runtime event write guard",
1714
+ details={
1715
+ "activation_id": guard.activation_id,
1716
+ "expected_fence": int(guard.fencing_token),
1717
+ "observed_activation_id": (
1718
+ str(row["activation_id"]) if row is not None else None
1719
+ ),
1720
+ "observed_fence": (
1721
+ int(row["fencing_token"]) if row is not None else None
1722
+ ),
1723
+ "released": bool(row["released"]) if row is not None else None,
1724
+ "lease_expires_at": (
1725
+ row["lease_expires_at"].isoformat() if row is not None else None
1726
+ ),
1727
+ },
1728
+ )
1729
+
1730
+ async def read(
1731
+ self, session_id: str, after_seq: int, limit: int
1732
+ ) -> list[SessionEventEnvelope]:
1733
+ return await self._log.read(session_id, after_seq, limit)
1734
+
1735
+ async def subscribe(
1736
+ self, session_id: str, after_seq: int, *, poll_interval: float = 0.25
1737
+ ):
1738
+ import asyncio
1739
+
1740
+ cursor = int(after_seq or 0)
1741
+ while True:
1742
+ envelopes = await self._log.read(session_id, cursor, 1000)
1743
+ for envelope in envelopes:
1744
+ cursor = max(cursor, int(envelope.seq))
1745
+ yield envelope
1746
+ await asyncio.sleep(poll_interval)
1747
+
1748
+
1749
+ __all__ = [
1750
+ "PostgresAgentKernelStore",
1751
+ "PostgresFencedSessionEventStore",
1752
+ "PostgresKernelEventLog",
1753
+ "PostgresNonceStore",
1754
+ "SCHEMA_PATH",
1755
+ "NONCE_RETENTION_SECONDS",
1756
+ ]