graph-agents-cli 0.3.1__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 (291) hide show
  1. graph_agents_cli/__init__.py +26 -0
  2. graph_agents_cli/_api_policy.py +2145 -0
  3. graph_agents_cli/_approvals.py +400 -0
  4. graph_agents_cli/_build.py +186 -0
  5. graph_agents_cli/_build_info.json +7 -0
  6. graph_agents_cli/_chat_client.py +462 -0
  7. graph_agents_cli/_click.py +157 -0
  8. graph_agents_cli/_defaults.py +139 -0
  9. graph_agents_cli/_experiments.py +64 -0
  10. graph_agents_cli/_http.py +192 -0
  11. graph_agents_cli/_output.py +83 -0
  12. graph_agents_cli/_project.py +462 -0
  13. graph_agents_cli/_remote.py +220 -0
  14. graph_agents_cli/_response_schema.py +264 -0
  15. graph_agents_cli/_runner.py +319 -0
  16. graph_agents_cli/_skills_check.py +274 -0
  17. graph_agents_cli/_tools.py +189 -0
  18. graph_agents_cli/_trust.py +66 -0
  19. graph_agents_cli/api/__init__.py +15 -0
  20. graph_agents_cli/api/_changes.py +506 -0
  21. graph_agents_cli/api/_files.py +658 -0
  22. graph_agents_cli/api/cmd_api.py +2480 -0
  23. graph_agents_cli/deploy/__init__.py +15 -0
  24. graph_agents_cli/deploy/_config.py +171 -0
  25. graph_agents_cli/deploy/_image.py +128 -0
  26. graph_agents_cli/deploy/_kube.py +286 -0
  27. graph_agents_cli/deploy/_modes.py +234 -0
  28. graph_agents_cli/deploy/_preflight.py +370 -0
  29. graph_agents_cli/deploy/_values.py +168 -0
  30. graph_agents_cli/deploy/cmd_deploy.py +1866 -0
  31. graph_agents_cli/deploy/gitops.py +562 -0
  32. graph_agents_cli/deploy/local_load.py +273 -0
  33. graph_agents_cli/dev/__init__.py +13 -0
  34. graph_agents_cli/dev/cmd_build.py +131 -0
  35. graph_agents_cli/dev/cmd_install.py +78 -0
  36. graph_agents_cli/dev/cmd_lint.py +119 -0
  37. graph_agents_cli/dev/cmd_playground.py +297 -0
  38. graph_agents_cli/dev/policy_check.py +1287 -0
  39. graph_agents_cli/eval/__init__.py +22 -0
  40. graph_agents_cli/eval/_client.py +670 -0
  41. graph_agents_cli/eval/_common.py +177 -0
  42. graph_agents_cli/eval/_judge.py +168 -0
  43. graph_agents_cli/eval/_judge_runner.py +238 -0
  44. graph_agents_cli/eval/_paths.py +212 -0
  45. graph_agents_cli/eval/checks.py +581 -0
  46. graph_agents_cli/eval/cmd_analyze.py +278 -0
  47. graph_agents_cli/eval/cmd_compare.py +284 -0
  48. graph_agents_cli/eval/cmd_eval_group.py +80 -0
  49. graph_agents_cli/eval/cmd_generate.py +558 -0
  50. graph_agents_cli/eval/cmd_grade.py +466 -0
  51. graph_agents_cli/eval/cmd_metric.py +156 -0
  52. graph_agents_cli/eval/cmd_run.py +370 -0
  53. graph_agents_cli/eval/cmd_submit.py +400 -0
  54. graph_agents_cli/eval/config.py +435 -0
  55. graph_agents_cli/eval/dataset.py +350 -0
  56. graph_agents_cli/eval/gate.py +420 -0
  57. graph_agents_cli/eval/transcript.py +192 -0
  58. graph_agents_cli/extension/__init__.py +13 -0
  59. graph_agents_cli/extension/_compat.py +86 -0
  60. graph_agents_cli/extension/_loader.py +293 -0
  61. graph_agents_cli/extension/_manifest.py +135 -0
  62. graph_agents_cli/extension/_overrides.py +195 -0
  63. graph_agents_cli/extension/_paths.py +91 -0
  64. graph_agents_cli/extension/_refs.py +193 -0
  65. graph_agents_cli/extension/_resolver.py +453 -0
  66. graph_agents_cli/extension/_schema.py +106 -0
  67. graph_agents_cli/extension/_spec.py +253 -0
  68. graph_agents_cli/extension/_sync.py +102 -0
  69. graph_agents_cli/extension/_trust.py +58 -0
  70. graph_agents_cli/extension/cmd_extension_add.py +259 -0
  71. graph_agents_cli/extension/cmd_extension_group.py +57 -0
  72. graph_agents_cli/extension/cmd_extension_list.py +56 -0
  73. graph_agents_cli/extension/cmd_extension_remove.py +61 -0
  74. graph_agents_cli/extension/cmd_extension_update.py +195 -0
  75. graph_agents_cli/info/__init__.py +13 -0
  76. graph_agents_cli/info/cmd_info.py +222 -0
  77. graph_agents_cli/infra/__init__.py +15 -0
  78. graph_agents_cli/infra/checks.py +1169 -0
  79. graph_agents_cli/infra/cmd_infra.py +103 -0
  80. graph_agents_cli/main.py +591 -0
  81. graph_agents_cli/peer/__init__.py +15 -0
  82. graph_agents_cli/peer/_generate.py +254 -0
  83. graph_agents_cli/peer/cmd_peer.py +1151 -0
  84. graph_agents_cli/run/__init__.py +13 -0
  85. graph_agents_cli/run/_local_server.py +1157 -0
  86. graph_agents_cli/run/_signals.py +141 -0
  87. graph_agents_cli/run/cmd_approvals.py +530 -0
  88. graph_agents_cli/run/cmd_run.py +1421 -0
  89. graph_agents_cli/scaffold/__init__.py +19 -0
  90. graph_agents_cli/scaffold/agents/README.md +24 -0
  91. graph_agents_cli/scaffold/agents/empty_py/.template/templateconfig.yaml +22 -0
  92. graph_agents_cli/scaffold/agents/langgraph/.env.example +292 -0
  93. graph_agents_cli/scaffold/agents/langgraph/.template/templateconfig.yaml +28 -0
  94. graph_agents_cli/scaffold/agents/langgraph/Dockerfile +59 -0
  95. graph_agents_cli/scaffold/agents/langgraph/Dockerfile.langgraph-server +59 -0
  96. graph_agents_cli/scaffold/agents/langgraph/README.md +571 -0
  97. graph_agents_cli/scaffold/agents/langgraph/api-policy.yaml +60 -0
  98. graph_agents_cli/scaffold/agents/langgraph/app/__init__.py +20 -0
  99. graph_agents_cli/scaffold/agents/langgraph/app/agent.py +174 -0
  100. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/__init__.py +15 -0
  101. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a.py +2162 -0
  102. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a_client.py +1167 -0
  103. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/api_client.py +4220 -0
  104. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/approvals.py +1349 -0
  105. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/auth.py +1986 -0
  106. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/chat.py +2962 -0
  107. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/checkpointer.py +432 -0
  108. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/content.py +569 -0
  109. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/db.py +580 -0
  110. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/limits.py +203 -0
  111. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/metrics.py +231 -0
  112. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/middleware.py +361 -0
  113. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/model.py +611 -0
  114. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/playground.py +230 -0
  115. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/run_locks.py +459 -0
  116. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/structured.py +755 -0
  117. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/telemetry.py +681 -0
  118. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/threads.py +493 -0
  119. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/token_exchange.py +959 -0
  120. graph_agents_cli/scaffold/agents/langgraph/app/fast_api_app.py +770 -0
  121. graph_agents_cli/scaffold/agents/langgraph/app/policies/__init__.py +55 -0
  122. graph_agents_cli/scaffold/agents/langgraph/app/policies/custom.py +97 -0
  123. graph_agents_cli/scaffold/agents/langgraph/app/tools/__init__.py +46 -0
  124. graph_agents_cli/scaffold/agents/langgraph/app/tools/example_api.py +92 -0
  125. graph_agents_cli/scaffold/agents/langgraph/app/tools/weather.py +33 -0
  126. graph_agents_cli/scaffold/agents/langgraph/langgraph.json +14 -0
  127. graph_agents_cli/scaffold/agents/langgraph/pyproject.toml +78 -0
  128. graph_agents_cli/scaffold/agents/langgraph/tests/conftest.py +376 -0
  129. graph_agents_cli/scaffold/agents/langgraph/tests/eval/datasets/basic-dataset.json +53 -0
  130. graph_agents_cli/scaffold/agents/langgraph/tests/eval/eval_config.yaml +32 -0
  131. graph_agents_cli/scaffold/agents/langgraph/tests/integration/approval_graph.py +137 -0
  132. graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_issuer.py +216 -0
  133. graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_openai.py +357 -0
  134. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_outcomes.py +569 -0
  135. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_relay.py +479 -0
  136. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_api_surface.py +812 -0
  137. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals.py +1367 -0
  138. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals_server.py +794 -0
  139. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor.py +497 -0
  140. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor_server.py +247 -0
  141. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_history_repair.py +278 -0
  142. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_model_apis.py +242 -0
  143. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_postgres.py +637 -0
  144. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_resilience_postgres.py +770 -0
  145. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_runtime_guardrails.py +854 -0
  146. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_e2e.py +340 -0
  147. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_runtime.py +989 -0
  148. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_answers.py +584 -0
  149. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_server.py +222 -0
  150. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_token_exchange_issuer.py +650 -0
  151. graph_agents_cli/scaffold/agents/langgraph/tests/load_test/.results/.placeholder +0 -0
  152. graph_agents_cli/scaffold/agents/langgraph/tests/load_test/README.md +22 -0
  153. graph_agents_cli/scaffold/agents/langgraph/tests/load_test/conftest.py +21 -0
  154. graph_agents_cli/scaffold/agents/langgraph/tests/load_test/load_test.py +81 -0
  155. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_client.py +824 -0
  156. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_scoping.py +724 -0
  157. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client.py +1214 -0
  158. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client_hardening.py +716 -0
  159. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_policy_rpc.py +767 -0
  160. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_approval_ledger.py +1536 -0
  161. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_fake_model.py +115 -0
  162. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_jwt_policy.py +991 -0
  163. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_limits.py +310 -0
  164. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging.py +148 -0
  165. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging_hardening.py +271 -0
  166. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_policy.py +378 -0
  167. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_resilience.py +610 -0
  168. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_server_auth.py +702 -0
  169. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_structured.py +673 -0
  170. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_telemetry.py +404 -0
  171. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_thread_listing.py +255 -0
  172. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_threads.py +268 -0
  173. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_token_exchange.py +1320 -0
  174. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_untrusted_content.py +393 -0
  175. graph_agents_cli/scaffold/agents/langgraph/uv-fastapi.lock +2084 -0
  176. graph_agents_cli/scaffold/agents/langgraph/uv-langgraph-server.lock +2106 -0
  177. graph_agents_cli/scaffold/agents/langgraph/{{cookiecutter.agent_guidance_filename}} +129 -0
  178. graph_agents_cli/scaffold/base_templates/_shared/graph-agents-cli-manifest.yaml +36 -0
  179. graph_agents_cli/scaffold/base_templates/python/.dockerignore +32 -0
  180. graph_agents_cli/scaffold/base_templates/python/.github/CODEOWNERS +30 -0
  181. graph_agents_cli/scaffold/base_templates/python/.github/agent.env +7 -0
  182. graph_agents_cli/scaffold/base_templates/python/.github/workflows/pr_checks.yaml +214 -0
  183. graph_agents_cli/scaffold/base_templates/python/.gitignore +209 -0
  184. graph_agents_cli/scaffold/base_templates/python/tests/unit/test_dummy.py +23 -0
  185. graph_agents_cli/scaffold/base_templates/python/{{cookiecutter.agent_guidance_filename}} +35 -0
  186. graph_agents_cli/scaffold/cmd_scaffold_group.py +49 -0
  187. graph_agents_cli/scaffold/commands/__init__.py +13 -0
  188. graph_agents_cli/scaffold/commands/create.py +1424 -0
  189. graph_agents_cli/scaffold/commands/enhance.py +1652 -0
  190. graph_agents_cli/scaffold/commands/upgrade.py +570 -0
  191. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/agent.env +12 -0
  192. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/promote-to-prod.yaml +371 -0
  193. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/staging.yaml +450 -0
  194. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-dev.yaml +43 -0
  195. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-prod.yaml +41 -0
  196. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-staging.yaml +43 -0
  197. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/.helmignore +14 -0
  198. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/Chart.yaml +21 -0
  199. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/examples/networkpolicy.yaml +103 -0
  200. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/NOTES.txt +48 -0
  201. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/_helpers.tpl +189 -0
  202. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/certificate.yaml +15 -0
  203. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/configmap.yaml +10 -0
  204. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/deployment.yaml +199 -0
  205. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/hpa.yaml +22 -0
  206. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/httproute.yaml +30 -0
  207. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/ingress.yaml +39 -0
  208. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/networkpolicy.yaml +48 -0
  209. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/pdb.yaml +13 -0
  210. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/postgresql-secret.yaml +37 -0
  211. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/service.yaml +15 -0
  212. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/serviceaccount.yaml +13 -0
  213. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/servicemonitor.yaml +42 -0
  214. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-dev.yaml +22 -0
  215. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-prod.yaml +45 -0
  216. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-staging.yaml +29 -0
  217. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values.yaml +396 -0
  218. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/tests/integration/test_chart.py +269 -0
  219. graph_agents_cli/scaffold/deployment_targets/none/README.md +5 -0
  220. graph_agents_cli/scaffold/deployment_targets/none/python/README.md +6 -0
  221. graph_agents_cli/scaffold/utils/__init__.py +13 -0
  222. graph_agents_cli/scaffold/utils/backup.py +212 -0
  223. graph_agents_cli/scaffold/utils/build_record.py +257 -0
  224. graph_agents_cli/scaffold/utils/cli_options.py +184 -0
  225. graph_agents_cli/scaffold/utils/fs.py +83 -0
  226. graph_agents_cli/scaffold/utils/generate_locks.py +214 -0
  227. graph_agents_cli/scaffold/utils/generation_metadata.py +88 -0
  228. graph_agents_cli/scaffold/utils/keyedit.py +768 -0
  229. graph_agents_cli/scaffold/utils/keymerge.py +537 -0
  230. graph_agents_cli/scaffold/utils/language.py +138 -0
  231. graph_agents_cli/scaffold/utils/lock_utils.py +94 -0
  232. graph_agents_cli/scaffold/utils/logging.py +77 -0
  233. graph_agents_cli/scaffold/utils/manifest.py +292 -0
  234. graph_agents_cli/scaffold/utils/merge.py +970 -0
  235. graph_agents_cli/scaffold/utils/merge3.py +216 -0
  236. graph_agents_cli/scaffold/utils/openapi_seed.py +199 -0
  237. graph_agents_cli/scaffold/utils/remote_template.py +376 -0
  238. graph_agents_cli/scaffold/utils/template.py +1352 -0
  239. graph_agents_cli/scaffold/utils/upgrade.py +894 -0
  240. graph_agents_cli/scaffold/utils/version.py +438 -0
  241. graph_agents_cli/secrets/__init__.py +15 -0
  242. graph_agents_cli/secrets/_apply.py +954 -0
  243. graph_agents_cli/secrets/_required.py +188 -0
  244. graph_agents_cli/secrets/cmd_secrets.py +211 -0
  245. graph_agents_cli/setup/__init__.py +13 -0
  246. graph_agents_cli/setup/_antigravity.py +221 -0
  247. graph_agents_cli/setup/cmd_auth.py +1030 -0
  248. graph_agents_cli/setup/cmd_dev_token.py +513 -0
  249. graph_agents_cli/setup/cmd_setup.py +428 -0
  250. graph_agents_cli/setup/cmd_update.py +140 -0
  251. graph_agents_cli/skills/__init__.py +13 -0
  252. graph_agents_cli/skills/_bundle.py +65 -0
  253. graph_agents_cli/skills/data/README.md +19 -0
  254. graph_agents_cli/skills/data/graph-agents-cli-deploy/SKILL.md +357 -0
  255. graph_agents_cli/skills/data/graph-agents-cli-deploy/references/github-settings.md +113 -0
  256. graph_agents_cli/skills/data/graph-agents-cli-deploy/references/gitops.md +137 -0
  257. graph_agents_cli/skills/data/graph-agents-cli-deploy/references/kubernetes.md +315 -0
  258. graph_agents_cli/skills/data/graph-agents-cli-deploy/references/secrets.md +160 -0
  259. graph_agents_cli/skills/data/graph-agents-cli-eval/SKILL.md +303 -0
  260. graph_agents_cli/skills/data/graph-agents-cli-eval/references/dataset_schema.md +282 -0
  261. graph_agents_cli/skills/data/graph-agents-cli-eval/references/metrics-guide.md +143 -0
  262. graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/SKILL.md +659 -0
  263. graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langchain-models.md +124 -0
  264. graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langgraph.md +235 -0
  265. graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/template-contract.md +477 -0
  266. graph_agents_cli/skills/data/graph-agents-cli-observability/SKILL.md +231 -0
  267. graph_agents_cli/skills/data/graph-agents-cli-observability/references/langsmith.md +46 -0
  268. graph_agents_cli/skills/data/graph-agents-cli-observability/references/otel.md +59 -0
  269. graph_agents_cli/skills/data/graph-agents-cli-scaffold/SKILL.md +414 -0
  270. graph_agents_cli/skills/data/graph-agents-cli-scaffold/references/flags.md +134 -0
  271. graph_agents_cli/skills/data/graph-agents-cli-workflow/SKILL.md +478 -0
  272. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/brainstorming.md +118 -0
  273. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/commands.md +419 -0
  274. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/extension.md +156 -0
  275. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/internals.md +67 -0
  276. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/spec-template.md +56 -0
  277. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/terminology.md +119 -0
  278. graph_agents_cli/system/__init__.py +15 -0
  279. graph_agents_cli/system/_apply.py +519 -0
  280. graph_agents_cli/system/_checks.py +1023 -0
  281. graph_agents_cli/system/_deploy.py +215 -0
  282. graph_agents_cli/system/_model.py +363 -0
  283. graph_agents_cli/system/_system.py +664 -0
  284. graph_agents_cli/system/_views.py +208 -0
  285. graph_agents_cli/system/cmd_system.py +423 -0
  286. graph_agents_cli-0.3.1.dist-info/METADATA +162 -0
  287. graph_agents_cli-0.3.1.dist-info/RECORD +291 -0
  288. graph_agents_cli-0.3.1.dist-info/WHEEL +4 -0
  289. graph_agents_cli-0.3.1.dist-info/entry_points.txt +2 -0
  290. graph_agents_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
  291. graph_agents_cli-0.3.1.dist-info/licenses/NOTICE +19 -0
@@ -0,0 +1,2962 @@
1
+ # Copyright 2026 graph-agents-cli contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """The one graph invocation path behind `/chat`, A2A and the playground.
16
+
17
+ `ChatRuntime.stream()` yields the events of the chat API
18
+ (`message.start`, `message.delta`, `tool.call`, `tool.result`, `message.end`,
19
+ `error`, plus internal heartbeats sent as SSE `: keep-alive` comments) from either:
20
+
21
+ * the in-process graph with the checkpointer bound at startup (`fastapi`), or
22
+ * the LangGraph Server this app is mounted in (`langgraph-server`), through the
23
+ SDK's loopback client, so the server keeps owning persistence and threads.
24
+
25
+ Both runtimes apply the same rules:
26
+
27
+ * Thread ownership (one principal per thread): through the `threads` table
28
+ under fastapi, and through the thread metadata `{principal_id, tenant}` under
29
+ langgraph-server (plus `actor` for a thread an agent started for its user:
30
+ the user owns it too, that agent only its own, see `threads.is_owner`). The SDK loopback client runs under the server's `/noauth`
31
+ root path, so the server's own `@auth.on` filters never see these calls; the
32
+ check has to live here.
33
+ * One run per thread: a second run while one is in progress gets
34
+ `ThreadBusy` (HTTP 409 `{"code": "thread_busy"}` on `/chat`). Deleting a
35
+ thread (and the retention purge) takes the same lock, and a run checks the
36
+ thread's owner again once it holds it, so a turn is never written to a
37
+ thread that has no owner (fastapi). Across replicas the lock is a lease
38
+ (`run_locks.py`): a run whose lease cannot be renewed is stopped (status
39
+ `interrupted`) and its checkpoint writes are refused (fastapi).
40
+ * A valid history: model providers reject an assistant tool call that is not
41
+ followed by its result. A run cut short (a timeout, a disconnect, a crash, a
42
+ database outage, an OOM kill) can leave one, so every run first answers the
43
+ open tool calls of its thread with an error result placed right after the
44
+ call (and puts misplaced results back in place), before its own turn. A
45
+ call whose arguments are not valid JSON counts as a call too (providers
46
+ are sent it back as one); the agent answers those in the run that made
47
+ them (`content.AnswerInvalidToolCalls`).
48
+ * Guardrails: `RUN_TIMEOUT_S` cancels a run (status `timeout`), a client that
49
+ disconnects cancels its run (status `cancelled`), and `SSE_HEARTBEAT_S`
50
+ keeps idle streams alive. `RECURSION_LIMIT` caps graph steps: a run that
51
+ reaches it ends with a final message saying so (`message.end` status
52
+ `step_limit`) and keeps what it did in the thread.
53
+ * Errors reach clients as a generic message with an `error_id`; the detail
54
+ goes to the log under that id (and to the event under `APP_ENV=dev` only).
55
+ An unreachable database is a 503 (or an `unavailable` event) after a few
56
+ seconds, logged as one line without a traceback.
57
+ * Client metadata is kept in the run record and, under `TRACE_CAPTURE=full`
58
+ only, in traces under `client_metadata` (never over the server-set
59
+ `thread_id`, `run_id`, `principal_hash`); it is not written into checkpoints.
60
+ * Run records are durable in Postgres when the runtime has one (see `db.py`):
61
+ written as `running` when a run starts and updated when it ends; a run that
62
+ never ended (its process died) is marked `interrupted` by the next
63
+ reconciliation (every minute, on any replica). `RETENTION_DAYS` purges
64
+ threads idle longer than that. Under langgraph-server the server's own
65
+ `DELETE /threads/{id}` removes the thread's run records too
66
+ (`forget_thread_runs`, called by `middleware.ThreadDeleteHookMiddleware`).
67
+ * Startup does not wait for the database: the app starts, `/ready` answers
68
+ 503 and the schema setup is retried in the background until the database
69
+ answers; requests meanwhile get 503.
70
+ * Human approval of gated API calls (`approvals.py`): a run whose tool made a
71
+ gated call pauses (a LangGraph interrupt, kept in the checkpoint); the run
72
+ records one pending approval per interrupt and ends with `message.end`
73
+ status `awaiting_approval` carrying `approval` (and `approvals`, all of
74
+ them). While one is pending, a new message on the thread is refused
75
+ (`ApprovalPending`, HTTP 409 `{"code": "approval_pending"}`). `decide()`
76
+ checks the decider and the approval, decides it atomically under the
77
+ thread's run lock, and `stream(..., resume=...)` resumes the paused run with
78
+ the decision (under langgraph-server through the server's native resume),
79
+ acting as the requester; the run's other paused calls get their own
80
+ approval's state with it (a decision binds its call whatever the policy says
81
+ about gating it by then, see `api_client`). A pending approval that expires is closed by the
82
+ next resume (the tool gets "expired") or, when a new message comes instead,
83
+ by the history repair (the call's result says the approval expired).
84
+ Deleting a thread deletes its approvals.
85
+ """
86
+
87
+ from __future__ import annotations
88
+
89
+ import asyncio
90
+ import contextlib
91
+ import json
92
+ import logging
93
+ import os
94
+ import time
95
+ import uuid
96
+ from collections import Counter, deque
97
+ from collections.abc import AsyncIterator, Callable, Iterator, Mapping
98
+ from contextlib import AsyncExitStack
99
+ from dataclasses import dataclass, field
100
+ from datetime import UTC, datetime, timedelta
101
+ from typing import Any
102
+
103
+ from fastapi import HTTPException
104
+
105
+ from {{cookiecutter.agent_directory}}.app_utils import metrics
106
+ from {{cookiecutter.agent_directory}}.app_utils.api_client import (
107
+ DECISION_APPROVE,
108
+ DECISION_EXPIRED,
109
+ DECISION_PENDING,
110
+ DECISION_REJECT,
111
+ approval_ledger,
112
+ set_approval_ledger,
113
+ )
114
+ from {{cookiecutter.agent_directory}}.app_utils.api_client import end_run as end_api_run
115
+ from {{cookiecutter.agent_directory}}.app_utils.approvals import (
116
+ APPROVE,
117
+ APPROVED,
118
+ CODE_APPROVAL_PENDING,
119
+ CODE_DIGEST_MISMATCH,
120
+ CODE_EXPIRED,
121
+ CODE_NOT_PENDING,
122
+ DECISIONS,
123
+ DIGEST_MISMATCH_DETAIL,
124
+ EXPIRED,
125
+ ORIGIN_PAYLOAD_KEY,
126
+ PENDING,
127
+ REJECTED,
128
+ SWEEP_INTERVAL_S,
129
+ ApprovalRecord,
130
+ ApprovalStore,
131
+ decide_refusal,
132
+ decision_value,
133
+ dev_ledger_path,
134
+ digest_refusal,
135
+ is_approval_interrupt,
136
+ may_view,
137
+ record_from_interrupt,
138
+ resume_principal,
139
+ sees_call,
140
+ )
141
+ from {{cookiecutter.agent_directory}}.app_utils.auth import Principal, origin_of, run_context_of
142
+ from {{cookiecutter.agent_directory}}.app_utils.checkpointer import (
143
+ checkpointer_kind,
144
+ get_checkpointer,
145
+ postgres_saver,
146
+ )
147
+ from {{cookiecutter.agent_directory}}.app_utils.content import (
148
+ INVALID_TOOL_CALL_RESULT,
149
+ INVALID_TOOL_CALL_TYPE,
150
+ content_to_text,
151
+ valid_text,
152
+ valid_value,
153
+ )
154
+ from {{cookiecutter.agent_directory}}.app_utils.db import (
155
+ RUN_INTERRUPTED,
156
+ Database,
157
+ RunRecord,
158
+ RunStore,
159
+ StorageNotReady,
160
+ capture_full,
161
+ is_database_unavailable,
162
+ is_postgres_url,
163
+ )
164
+ from {{cookiecutter.agent_directory}}.app_utils.limits import (
165
+ recursion_limit,
166
+ retention_days,
167
+ run_timeout_s,
168
+ sequential_tool_calls,
169
+ sse_heartbeat_s,
170
+ steps_for_tool_calls,
171
+ valid_thread_id,
172
+ )
173
+ from {{cookiecutter.agent_directory}}.app_utils.model import model_label
174
+ from {{cookiecutter.agent_directory}}.app_utils.structured import (
175
+ ANSWER_TOOL,
176
+ MAX_ANSWER_ATTEMPTS,
177
+ MAX_REPORTED_PROBLEMS,
178
+ StructuredAnswerError,
179
+ answer_text,
180
+ response_schema,
181
+ validate,
182
+ )
183
+ from {{cookiecutter.agent_directory}}.app_utils.telemetry import bind_log_context
184
+ from {{cookiecutter.agent_directory}}.app_utils.threads import (
185
+ OUTCOME_COMPLETED,
186
+ OUTCOME_FAILED,
187
+ OUTCOME_INPUT_REQUIRED,
188
+ THREAD_BUSY,
189
+ ApprovalOutcome,
190
+ LeaseLost,
191
+ ThreadBusy,
192
+ ThreadLease,
193
+ ThreadLocks,
194
+ ThreadRecord,
195
+ ThreadStore,
196
+ approval_outcome,
197
+ assert_access,
198
+ assert_owner,
199
+ is_owner,
200
+ owner_metadata_filter,
201
+ reads_across,
202
+ thread_deleted,
203
+ )
204
+
205
+ logger = logging.getLogger(__name__)
206
+
207
+ FASTAPI = "fastapi"
208
+ LANGGRAPH_SERVER = "langgraph-server"
209
+ GRAPH_ID = "agent" # the key in langgraph.json "graphs"
210
+
211
+ EVENT_START = "message.start"
212
+ EVENT_DELTA = "message.delta"
213
+ EVENT_TOOL_CALL = "tool.call"
214
+ EVENT_TOOL_RESULT = "tool.result"
215
+ EVENT_END = "message.end"
216
+ EVENT_ERROR = "error"
217
+ # Internal: sent as an SSE comment line, ignored by A2A.
218
+ EVENT_HEARTBEAT = "heartbeat"
219
+
220
+ # Run statuses in run records and metrics (`running` while a run is in progress).
221
+ STATUS_OK = "ok"
222
+ # The run paused before a gated API call: it waits for a human decision.
223
+ STATUS_AWAITING_APPROVAL = "awaiting_approval"
224
+ STATUS_STEP_LIMIT = "step_limit"
225
+ STATUS_ERROR = "error"
226
+ STATUS_TIMEOUT = "timeout"
227
+ STATUS_CANCELLED = "cancelled"
228
+ STATUS_INTERRUPTED = RUN_INTERRUPTED
229
+
230
+ # Error codes clients see in `error` events.
231
+ CODE_RUN_FAILED = "run_failed"
232
+ CODE_TIMEOUT = "timeout"
233
+ CODE_RECURSION = "recursion_limit"
234
+ CODE_UNAVAILABLE = "unavailable"
235
+ CODE_FORBIDDEN = "forbidden"
236
+ # The graph paused for input this server cannot collect (an interrupt that is
237
+ # not a gated API call's).
238
+ CODE_UNSUPPORTED_INTERRUPT = "unsupported_interrupt"
239
+ # A project with a response schema: no try of the answer fitted it (or the graph gave none).
240
+ CODE_INVALID_STRUCTURED_RESPONSE = "invalid_structured_response"
241
+ # The state key (and `message.end` field) of a structured final answer.
242
+ STRUCTURED_KEY = "structured_response"
243
+
244
+ # Request headers passed on to the LangGraph Server under langgraph-server.
245
+ # They matter when LANGGRAPH_SERVER_URL points at a real HTTP endpoint (its
246
+ # auth handler runs there); the default in-process loopback ignores them.
247
+ DEFAULT_FORWARD_HEADERS = ("authorization", "cookie")
248
+
249
+ RETENTION_INTERVAL_S = 3600.0
250
+ RETENTION_FIRST_DELAY_S = 60.0
251
+ RETENTION_BATCH = 500
252
+ RETENTION_MAX_BATCHES = 20
253
+
254
+ # Runs of dead processes are closed (`interrupted`) this often, by every replica.
255
+ RECONCILE_INTERVAL_S = 60.0
256
+ # A `running` record younger than this is never reconciled (clock skew margin).
257
+ RECONCILE_GRACE_S = 60.0
258
+ # Final run records that could not be written are retried (at most this many kept).
259
+ MAX_UNRECORDED_RUNS = 1000
260
+ # Database setup at startup: retried with this back-off while the database is down.
261
+ INIT_RETRY_FIRST_S = 0.5
262
+ INIT_RETRY_MAX_S = 2.0
263
+ # Each bookkeeping step after a run (history repair, run record) is bounded.
264
+ FINISH_STEP_TIMEOUT_S = 5.0
265
+ # How long the end of a run waits for its cancelled graph to stop.
266
+ PUMP_STOP_WAIT_S = 1.0
267
+
268
+ # Error results for tool calls a run left open, by why they were left open.
269
+ OPEN_CALL_INTERRUPTED = (
270
+ "The tool call did not finish: the run was interrupted before its result was saved."
271
+ )
272
+ OPEN_CALL_STEP_LIMIT = "The tool call did not run: the run reached its step limit."
273
+ # Results for a tool call left open by a run that paused for approval, when a
274
+ # new message comes instead of a resume, by the approval's status.
275
+ OPEN_CALL_BY_APPROVAL = {
276
+ EXPIRED: "The call was not approved: the approval request expired before anyone decided, "
277
+ "so nothing was sent.",
278
+ REJECTED: "The call was not approved: an approver rejected it, so nothing was sent.",
279
+ APPROVED: "The call was approved, but the run did not continue, so nothing was sent.",
280
+ }
281
+ OPEN_CALL_SENT_UNSAVED = (
282
+ "The call was approved and sent, but the run stopped before its result was saved."
283
+ )
284
+ # The resume value a paused call gets for its approval's state (see `decide`).
285
+ # How much of a resumed run's reply the A2A tasks that waited on its approval say.
286
+ OUTCOME_REPLY_MAX_CHARS = 2000
287
+ RESUME_AS = {
288
+ PENDING: DECISION_PENDING,
289
+ APPROVED: DECISION_APPROVE,
290
+ REJECTED: DECISION_REJECT,
291
+ EXPIRED: DECISION_EXPIRED,
292
+ }
293
+ UNSUPPORTED_INTERRUPT_MESSAGE = (
294
+ "The agent paused for input this server cannot collect. Send a new message to continue."
295
+ )
296
+ STEP_LIMIT_MESSAGE = (
297
+ "I had to stop before finishing: this request needs more steps than one run may take "
298
+ "({limit}). What I did so far is kept in this conversation, so you can ask me to "
299
+ "continue, or narrow the request."
300
+ )
301
+
302
+
303
+ def detect_runtime() -> str:
304
+ """`langgraph-server` when mounted inside the server, else `fastapi`.
305
+
306
+ The server Dockerfile sets `LANGGRAPH_SERVER=1`; `langgraph dev` and the
307
+ server image also export `LANGSERVE_GRAPHS`. `RUNTIME` overrides both.
308
+ """
309
+ explicit = (os.environ.get("RUNTIME") or "").strip().lower()
310
+ if explicit in (FASTAPI, LANGGRAPH_SERVER):
311
+ return explicit
312
+ if os.environ.get("LANGGRAPH_SERVER", "").lower() in ("1", "true", "yes"):
313
+ return LANGGRAPH_SERVER
314
+ if os.environ.get("LANGSERVE_GRAPHS"):
315
+ return LANGGRAPH_SERVER
316
+ return FASTAPI
317
+
318
+
319
+ def forward_header_names() -> frozenset[str]:
320
+ """`AUTH_FORWARD_HEADERS` (comma list, case-insensitive); default `authorization,cookie`.
321
+
322
+ Set it to the headers your auth policy reads when the LangGraph Server is
323
+ reached over HTTP; an empty value forwards nothing.
324
+ """
325
+ raw = os.environ.get("AUTH_FORWARD_HEADERS")
326
+ names = DEFAULT_FORWARD_HEADERS if raw is None else raw.split(",")
327
+ return frozenset(n.strip().lower() for n in names if n.strip())
328
+
329
+
330
+ def select_forward_headers(headers: Mapping[str, str]) -> dict[str, str]:
331
+ allowed = forward_header_names()
332
+ return {k: v for k, v in headers.items() if k.lower() in allowed}
333
+
334
+
335
+ def dev_mode() -> bool:
336
+ """`APP_ENV` is exactly `dev` (as `auth.dev_mode`)."""
337
+ return os.environ.get("APP_ENV") == "dev"
338
+
339
+
340
+ def sse_encode(event: str, data: Mapping[str, Any]) -> str:
341
+ """One SSE event. A lone surrogate anywhere in `data` is sent as U+FFFD
342
+ (`valid_text`): UTF-8 cannot encode one, and it would end the stream."""
343
+ if event == EVENT_HEARTBEAT:
344
+ return ": keep-alive\n\n"
345
+ return f"event: {event}\ndata: {valid_text(json.dumps(data, ensure_ascii=False))}\n\n"
346
+
347
+
348
+ def new_error_id() -> str:
349
+ return uuid.uuid4().hex[:16]
350
+
351
+
352
+ def _first_line(exc: BaseException) -> str:
353
+ text = str(exc).strip()
354
+ return text.splitlines()[0][:300] if text else ""
355
+
356
+
357
+ def unavailable(what: str, exc: BaseException) -> HTTPException:
358
+ """A 503 whose detail names an error id, never the exception text (logged instead)."""
359
+ error_id = log_unavailable(what, exc)
360
+ return HTTPException(status_code=503, detail=unavailable_detail(what, error_id))
361
+
362
+
363
+ def unavailable_detail(what: str, error_id: str) -> str:
364
+ return f"{what} unavailable. Reference: {error_id}."
365
+
366
+
367
+ def log_unavailable(what: str, exc: BaseException) -> str:
368
+ """Log `exc` under a new error id, which it returns.
369
+
370
+ An unreachable database is expected during an outage: one WARNING line,
371
+ no traceback. Anything else is logged as an ERROR with its traceback.
372
+ """
373
+ error_id = new_error_id()
374
+ if is_database_unavailable(exc):
375
+ logger.warning(
376
+ "%s unavailable (error_id=%s): %s: %s",
377
+ what,
378
+ error_id,
379
+ type(exc).__name__,
380
+ _first_line(exc),
381
+ )
382
+ else:
383
+ logger.error(
384
+ "%s unavailable (error_id=%s): %s", what, error_id, type(exc).__name__, exc_info=exc
385
+ )
386
+ return error_id
387
+
388
+
389
+ @contextlib.contextmanager
390
+ def database_errors() -> Iterator[None]:
391
+ """Turn an unreachable database inside the block into a 503 with an error id."""
392
+ try:
393
+ yield
394
+ except HTTPException:
395
+ raise
396
+ except Exception as exc:
397
+ if is_database_unavailable(exc):
398
+ raise unavailable("Database", exc) from exc
399
+ raise
400
+
401
+
402
+ def validate_thread_id(thread_id: str, runtime: str) -> str:
403
+ """The thread id in canonical form, or 422 for an id outside the accepted form.
404
+
405
+ LangGraph Server needs a UUID; it is canonicalised (lower case, hyphens)
406
+ so one thread never has two spellings, e.g. for the run lock.
407
+ """
408
+ if not valid_thread_id(thread_id):
409
+ raise HTTPException(
410
+ status_code=422,
411
+ detail="thread_id must be 1-128 letters, digits or '_ . : -'.",
412
+ )
413
+ if runtime == LANGGRAPH_SERVER:
414
+ try:
415
+ return str(uuid.UUID(thread_id))
416
+ except ValueError:
417
+ raise HTTPException(
418
+ status_code=422,
419
+ detail="thread_id must be a UUID under the langgraph-server runtime.",
420
+ ) from None
421
+ return thread_id
422
+
423
+
424
+ def http_status(exc: BaseException) -> int | None:
425
+ """The HTTP status of an SDK/httpx error (`NotFoundError`, `ConflictError`, ...), if any."""
426
+ status = getattr(exc, "status_code", None)
427
+ if isinstance(status, int):
428
+ return status
429
+ response = getattr(exc, "response", None)
430
+ status = getattr(response, "status_code", None)
431
+ return status if isinstance(status, int) else None
432
+
433
+
434
+ @dataclass
435
+ class ChatRequest:
436
+ message: str
437
+ thread_id: str | None = None
438
+ metadata: dict[str, Any] = field(default_factory=dict)
439
+ forward_headers: dict[str, str] = field(default_factory=dict)
440
+
441
+
442
+ @dataclass
443
+ class _RunState:
444
+ text: list[str] = field(default_factory=list)
445
+ tool_calls: list[dict[str, Any]] = field(default_factory=list)
446
+ input_tokens: int = 0
447
+ output_tokens: int = 0
448
+ seen_ai_ids: set[str] = field(default_factory=set)
449
+ server_run_id: str | None = None
450
+ # The graph's interrupts (`{"id", "value"}`) when the run paused.
451
+ interrupts: list[dict[str, Any]] = field(default_factory=list)
452
+ # The project has a response schema: the model's text is not streamed, and the
453
+ # run's answer is the last `structured_response` the graph's updates carried.
454
+ structured_mode: bool = False
455
+ structured: Any = None
456
+
457
+
458
+ @dataclass
459
+ class Resume:
460
+ """A paused run to resume (`ChatRuntime.decide`): the resume value per interrupt id.
461
+
462
+ `continued_in` is the A2A task that carries the decision (the executor sets
463
+ it), and `references` the tasks its message named (`referenceTaskIds`): the
464
+ tasks still waiting on the approval follow the resumed run's outcome
465
+ (`ApprovalOutcome`), pointing at that task.
466
+ """
467
+
468
+ values: dict[str, Any]
469
+ approval: ApprovalRecord
470
+ decision: str
471
+ continued_in: str | None = None
472
+ references: tuple[str, ...] = ()
473
+
474
+
475
+ class ApprovalPending(Exception):
476
+ """The thread has a pending approval: no new message until it is decided or expires.
477
+
478
+ HTTP 409 `{"code": "approval_pending"}` on `/chat`.
479
+ """
480
+
481
+ def __init__(self, thread_id: str, approvals: list[dict[str, Any]]) -> None:
482
+ super().__init__(
483
+ "This thread is waiting for the approval of an action; decide it "
484
+ "(POST /threads/{thread_id}/approvals/{approval_id}) or wait until it expires."
485
+ )
486
+ self.thread_id = thread_id
487
+ self.approvals = approvals
488
+
489
+
490
+ class ApprovalError(Exception):
491
+ """A decision that cannot be taken: `status_code` 404, 403, 409 or 410 with a `code`.
492
+
493
+ The app answers `{"code", "detail", ...extra}` (`status`: the approval's
494
+ status for 409 and 410).
495
+ """
496
+
497
+ def __init__(self, status_code: int, code: str, detail: str, **extra: Any) -> None:
498
+ super().__init__(detail)
499
+ self.status_code = status_code
500
+ self.code = code
501
+ self.detail = detail
502
+ self.extra = extra
503
+
504
+ def body(self) -> dict[str, Any]:
505
+ return {"code": self.code, "detail": self.detail, **self.extra}
506
+
507
+
508
+ class RunTimeout(Exception):
509
+ """The run passed `RUN_TIMEOUT_S`."""
510
+
511
+
512
+ # ---------------------------------------------------------------------------
513
+ # Message accessors that work for LangChain objects and the server's JSON dicts
514
+ # ---------------------------------------------------------------------------
515
+
516
+
517
+ def _get(m: Any, key: str, default: Any = None) -> Any:
518
+ if isinstance(m, Mapping):
519
+ return m.get(key, default)
520
+ return getattr(m, key, default)
521
+
522
+
523
+ def _msg_type(m: Any) -> str:
524
+ return str(_get(m, "type", "") or "")
525
+
526
+
527
+ def _is_ai(m: Any) -> bool:
528
+ return _msg_type(m) in ("ai", "AIMessage", "AIMessageChunk")
529
+
530
+
531
+ def _is_tool(m: Any) -> bool:
532
+ return _msg_type(m) in ("tool", "ToolMessage", "ToolMessageChunk")
533
+
534
+
535
+ def _is_human(m: Any) -> bool:
536
+ return _msg_type(m) in ("human", "HumanMessage")
537
+
538
+
539
+ def _role(m: Any) -> str:
540
+ if _is_ai(m):
541
+ return "assistant"
542
+ if _is_tool(m):
543
+ return "tool"
544
+ if _is_human(m):
545
+ return "user"
546
+ t = _msg_type(m)
547
+ return "system" if t in ("system", "SystemMessage") else t
548
+
549
+
550
+ def tool_calls_of(m: Any) -> list[Any]:
551
+ """Every tool call of an assistant message, in the order providers are sent them.
552
+
553
+ The calls whose arguments did not parse (`invalid_tool_calls`, marked
554
+ `"type": "invalid_tool_call"`) count too: LangChain sends them back to
555
+ the provider as tool calls (langchain-openai does), so each needs a
556
+ result like any other call.
557
+ """
558
+ if not _is_ai(m):
559
+ return []
560
+ calls = list(_get(m, "tool_calls") or [])
561
+ for call in _get(m, "invalid_tool_calls") or []:
562
+ if isinstance(call, Mapping) and call.get("type") != INVALID_TOOL_CALL_TYPE:
563
+ call = {**call, "type": INVALID_TOOL_CALL_TYPE}
564
+ calls.append(call)
565
+ return calls
566
+
567
+
568
+ def is_invalid_tool_call(call: Any) -> bool:
569
+ """Whether `call` is one whose arguments did not parse (never run)."""
570
+ return _get(call, "type") == INVALID_TOOL_CALL_TYPE
571
+
572
+
573
+ def open_call_result_text(call: Any, reason: str) -> str:
574
+ """The error result for a call that has none: `reason`, or why an invalid call never ran."""
575
+ return INVALID_TOOL_CALL_RESULT if is_invalid_tool_call(call) else reason
576
+
577
+
578
+ def _call_id(call: Any) -> str:
579
+ return str(_get(call, "id") or "")
580
+
581
+
582
+ def _call_args(call: Any) -> dict[str, Any]:
583
+ """A call's arguments as a dict; `{}` for a call whose arguments did not parse."""
584
+ args = _get(call, "args")
585
+ return dict(args) if isinstance(args, Mapping) else {}
586
+
587
+
588
+ def _iter_messages(update: Any) -> Iterator[Any]:
589
+ if isinstance(update, Mapping):
590
+ messages = update.get("messages")
591
+ if isinstance(messages, list):
592
+ yield from messages
593
+ elif messages is not None:
594
+ yield messages
595
+
596
+
597
+ def _accumulate_usage(state: _RunState, m: Any) -> None:
598
+ usage = _get(m, "usage_metadata") or {}
599
+ if isinstance(usage, Mapping):
600
+ state.input_tokens += int(usage.get("input_tokens") or 0)
601
+ state.output_tokens += int(usage.get("output_tokens") or 0)
602
+
603
+
604
+ # The key of an `updates` stream item that carries the graph's interrupts.
605
+ INTERRUPT_KEY = "__interrupt__"
606
+
607
+
608
+ def interrupts_of(value: Any) -> list[dict[str, Any]]:
609
+ """Interrupts as `{"id", "value"}`, from LangGraph `Interrupt` objects or the server's JSON."""
610
+ items = value if isinstance(value, list | tuple) else [value]
611
+ out: list[dict[str, Any]] = []
612
+ for item in items:
613
+ interrupt_id = _get(item, "id")
614
+ if interrupt_id:
615
+ out.append({"id": str(interrupt_id), "value": _get(item, "value")})
616
+ return out
617
+
618
+
619
+ def map_stream_item(mode: str, data: Any, state: _RunState) -> Iterator[tuple[str, dict[str, Any]]]:
620
+ """Map one LangGraph stream item (`messages` or `updates` mode) to chat events.
621
+
622
+ The graph's interrupts (an `updates` item under `__interrupt__`) are
623
+ collected in `state.interrupts`, not sent: the run's end reports them.
624
+
625
+ Under a response schema (`state.structured_mode`) the model's text is not
626
+ sent (the run's reply is its answer's JSON text, sent when it ends), the
627
+ answer tool's call and result are not either (the tool strategy's answer
628
+ is no tool call to a client), and the last `structured_response` an update
629
+ carries is kept in `state.structured`.
630
+ """
631
+ if mode == "messages":
632
+ chunk = data[0] if isinstance(data, list | tuple) and data else data
633
+ if _is_ai(chunk) and not (
634
+ _get(chunk, "tool_call_chunks")
635
+ or _get(chunk, "tool_calls")
636
+ or _get(chunk, "invalid_tool_calls")
637
+ ):
638
+ text = content_to_text(_get(chunk, "content", ""))
639
+ if text and not state.structured_mode:
640
+ state.text.append(text)
641
+ yield EVENT_DELTA, {"text": text}
642
+ return
643
+ if mode != "updates" or not isinstance(data, Mapping):
644
+ return
645
+ for key, update in data.items():
646
+ if key == INTERRUPT_KEY:
647
+ state.interrupts.extend(interrupts_of(update))
648
+ continue
649
+ if state.structured_mode and isinstance(update, Mapping) and STRUCTURED_KEY in update:
650
+ # Each model step sets it (None when the step gave no answer): the last one counts.
651
+ state.structured = update[STRUCTURED_KEY]
652
+ for m in _iter_messages(update):
653
+ if _is_ai(m):
654
+ msg_id = str(_get(m, "id") or "")
655
+ if msg_id and msg_id in state.seen_ai_ids:
656
+ continue
657
+ if msg_id:
658
+ state.seen_ai_ids.add(msg_id)
659
+ _accumulate_usage(state, m)
660
+ for call in tool_calls_of(m):
661
+ if state.structured_mode and _get(call, "name") == ANSWER_TOOL:
662
+ continue
663
+ entry = {
664
+ "id": str(_get(call, "id") or uuid.uuid4()),
665
+ "name": str(_get(call, "name") or ""),
666
+ "args": _call_args(call),
667
+ }
668
+ state.tool_calls.append({**entry, "result": None, "is_error": False})
669
+ yield EVENT_TOOL_CALL, entry
670
+ elif _is_tool(m):
671
+ if state.structured_mode and _get(m, "name") == ANSWER_TOOL:
672
+ continue
673
+ call_id = str(_get(m, "tool_call_id") or "")
674
+ result = content_to_text(_get(m, "content", ""))
675
+ is_error = str(_get(m, "status") or "success") == "error"
676
+ for tc in state.tool_calls:
677
+ if tc["id"] == call_id:
678
+ tc["result"] = result
679
+ tc["is_error"] = is_error
680
+ yield (
681
+ EVENT_TOOL_RESULT,
682
+ {
683
+ "id": call_id,
684
+ "name": str(_get(m, "name") or ""),
685
+ "result": result,
686
+ "is_error": is_error,
687
+ },
688
+ )
689
+
690
+
691
+ def trace_metadata(
692
+ thread_id: str, run_id: str, principal: Principal, client_metadata: Mapping[str, Any]
693
+ ) -> dict[str, Any]:
694
+ """Run metadata for traces (and, for its scalar keys, checkpoints).
695
+
696
+ The server-set ids cannot be overwritten: client metadata only ever sits
697
+ under `client_metadata`, a nested object, which LangGraph does not copy
698
+ into checkpoint metadata. It is included only under `TRACE_CAPTURE=full`.
699
+ """
700
+ meta: dict[str, Any] = {
701
+ "thread_id": thread_id,
702
+ "run_id": run_id,
703
+ "principal_hash": principal.hashed_id(),
704
+ }
705
+ if principal.actor is not None:
706
+ # The agent presenting the request (a client name, not personal data).
707
+ meta["actor"] = principal.actor.id
708
+ meta["actor_chain"] = ",".join(principal.actor.chain)
709
+ if client_metadata and capture_full():
710
+ meta["client_metadata"] = dict(client_metadata)
711
+ return meta
712
+
713
+
714
+ def serialize_message(m: Any, *, include_tool_args: bool = True) -> dict[str, Any]:
715
+ out: dict[str, Any] = {
716
+ "id": _get(m, "id"),
717
+ "role": _role(m),
718
+ "content": content_to_text(_get(m, "content", "")),
719
+ }
720
+ if _is_ai(m):
721
+ calls = []
722
+ for call in tool_calls_of(m):
723
+ entry: dict[str, Any] = {"id": _get(call, "id"), "name": _get(call, "name")}
724
+ if include_tool_args:
725
+ entry["args"] = _call_args(call)
726
+ calls.append(entry)
727
+ if calls:
728
+ out["tool_calls"] = calls
729
+ if _is_tool(m):
730
+ out["tool_call_id"] = _get(m, "tool_call_id")
731
+ out["name"] = _get(m, "name")
732
+ out["is_error"] = str(_get(m, "status") or "success") == "error"
733
+ return out
734
+
735
+
736
+ def dangling_tool_calls(messages: list[Any]) -> list[Any]:
737
+ """Tool calls of the last assistant message with no result right after it.
738
+
739
+ Only results that directly follow the call count: a result placed after
740
+ a later message does not make the history valid for model providers.
741
+ """
742
+ for i in range(len(messages) - 1, -1, -1):
743
+ if _is_ai(messages[i]):
744
+ answered: Counter[str] = Counter()
745
+ for m in messages[i + 1 :]:
746
+ if not _is_tool(m):
747
+ break
748
+ answered[str(_get(m, "tool_call_id") or "")] += 1
749
+ open_calls = []
750
+ for c in tool_calls_of(messages[i]):
751
+ if answered[_call_id(c)] > 0:
752
+ answered[_call_id(c)] -= 1
753
+ else:
754
+ open_calls.append(c)
755
+ return open_calls
756
+ return []
757
+
758
+
759
+ @dataclass
760
+ class HistoryRepair:
761
+ """A tool-call history put right: see `repair_tool_history`."""
762
+
763
+ messages: list[Any] # the whole corrected history
764
+ added: list[Any] # error results created for calls that had none
765
+ append_only: bool # `messages` is the old history plus `added` at its end
766
+
767
+
768
+ def repair_tool_history(
769
+ messages: list[Any], make_result: Callable[[Any], Any]
770
+ ) -> HistoryRepair | None:
771
+ """The history with every tool call followed by its result, or None when it already is.
772
+
773
+ Model providers reject an assistant message whose tool calls are not
774
+ answered right after it, and a tool result that answers no call. Every
775
+ call counts, the ones whose arguments did not parse included
776
+ (`tool_calls_of`). Calls and results are paired by position, turn by
777
+ turn, since tool-call ids repeat across turns (a model may number its
778
+ calls `call_0`, `call_1`, ... in every message) and can repeat within one
779
+ message (some OpenAI-compatible servers send the same id, or an empty
780
+ one, for parallel calls):
781
+
782
+ * a call's result is a tool message with its id in the block of tool
783
+ messages right after the call's assistant message, one per call with
784
+ that id; these are kept where they are, in their order;
785
+ * a call without one takes the first unclaimed tool message with its id
786
+ that sits after the call and before the next assistant message that
787
+ makes a call with the same id (a result a crash or an old repair left
788
+ behind a later message), moved right after the call;
789
+ * a call with neither gets `make_result(call)` (an error result);
790
+ * a tool message no call claims is dropped.
791
+
792
+ A history in which every call is answered right after it, and every tool
793
+ message answers a call, is returned as None and never rewritten.
794
+ """
795
+ count = len(messages)
796
+
797
+ def call_ids(m: Any) -> list[str]:
798
+ return [_call_id(call) for call in tool_calls_of(m)]
799
+
800
+ def result_id(m: Any) -> str:
801
+ return str(_get(m, "tool_call_id") or "")
802
+
803
+ # Pass 1: the results that answer an assistant message right after it.
804
+ claimed: set[int] = set()
805
+ direct: dict[int, list[int]] = {} # assistant index -> indexes of its direct results
806
+ for i, m in enumerate(messages):
807
+ if not _is_ai(m):
808
+ continue
809
+ pending = Counter(call_ids(m))
810
+ block: list[int] = []
811
+ j = i + 1
812
+ while j < count and _is_tool(messages[j]):
813
+ if pending[result_id(messages[j])] > 0:
814
+ pending[result_id(messages[j])] -= 1
815
+ block.append(j)
816
+ claimed.add(j)
817
+ j += 1
818
+ direct[i] = block
819
+
820
+ def later_result(i: int, call_id: str) -> int | None:
821
+ """An unclaimed result for `call_id` after message i, before the id is called again."""
822
+ for k in range(i + 1, count):
823
+ m = messages[k]
824
+ if _is_ai(m) and call_id in call_ids(m):
825
+ return None
826
+ if _is_tool(m) and k not in claimed and result_id(m) == call_id:
827
+ return k
828
+ return None
829
+
830
+ # Pass 2: rebuild, each assistant message followed by its results.
831
+ out: list[Any] = []
832
+ added: list[Any] = []
833
+ for i, m in enumerate(messages):
834
+ if _is_tool(m):
835
+ continue # placed after its call below, or dropped
836
+ out.append(m)
837
+ if not _is_ai(m):
838
+ continue
839
+ answered = Counter(result_id(messages[k]) for k in direct[i])
840
+ out.extend(messages[k] for k in direct[i])
841
+ for call in tool_calls_of(m):
842
+ call_id = _call_id(call)
843
+ if answered[call_id] > 0:
844
+ answered[call_id] -= 1
845
+ continue
846
+ k = later_result(i, call_id)
847
+ if k is not None:
848
+ claimed.add(k)
849
+ out.append(messages[k])
850
+ else:
851
+ result = make_result(call)
852
+ out.append(result)
853
+ added.append(result)
854
+ if len(out) == count and all(a is b for a, b in zip(out, messages, strict=True)):
855
+ return None
856
+ append_only = len(out) >= count and all(
857
+ a is b for a, b in zip(out[:count], messages, strict=True)
858
+ )
859
+ return HistoryRepair(messages=out, added=added, append_only=append_only)
860
+
861
+
862
+ def _server_message(m: Any) -> dict[str, Any]:
863
+ """A server state message as a dict the server turns back into the same message.
864
+
865
+ The server rebuilds messages from dicts by their known keys and puts any
866
+ other key into `additional_kwargs`; only the keys that matter for the
867
+ history are kept. It has no key for `invalid_tool_calls`, so a call
868
+ whose arguments did not parse is kept as a call with no arguments: its
869
+ error result must still answer a call the provider is sent.
870
+ """
871
+ if not isinstance(m, Mapping):
872
+ return m
873
+ keep = ["type", "content", "id", "name", "additional_kwargs", "response_metadata"]
874
+ if _is_tool(m):
875
+ keep.extend(("tool_call_id", "status", "artifact"))
876
+ out = {k: m[k] for k in keep if m.get(k) is not None}
877
+ if _is_ai(m):
878
+ calls = [
879
+ {
880
+ "name": str(_get(c, "name") or ""),
881
+ "args": {},
882
+ "id": _get(c, "id"),
883
+ "type": "tool_call",
884
+ }
885
+ if is_invalid_tool_call(c)
886
+ else c
887
+ for c in tool_calls_of(m)
888
+ ]
889
+ if calls:
890
+ out["tool_calls"] = calls
891
+ return out
892
+
893
+
894
+ # The remove-everything marker of LangGraph's `add_messages` reducer
895
+ # (`langgraph.graph.message.REMOVE_ALL_MESSAGES`).
896
+ REMOVE_ALL_MESSAGES = "__remove_all__"
897
+ # How long a step-limit reply waits for the server to mark our run done.
898
+ SERVER_IDLE_WAIT_S = 3.0
899
+
900
+
901
+ def _server_busy(thread: Any) -> bool:
902
+ return isinstance(thread, Mapping) and thread.get("status") == "busy"
903
+
904
+
905
+ def thread_busy_error() -> dict[str, Any]:
906
+ return {"code": THREAD_BUSY, "message": "This thread already has a run in progress."}
907
+
908
+
909
+ def _lease_lost(exc: BaseException | None) -> LeaseLost | None:
910
+ """The `LeaseLost` behind `exc` (itself, or what it was raised from), if any."""
911
+ seen: set[int] = set()
912
+ while exc is not None and id(exc) not in seen:
913
+ if isinstance(exc, LeaseLost):
914
+ return exc
915
+ seen.add(id(exc))
916
+ exc = exc.__cause__ or exc.__context__
917
+ return None
918
+
919
+
920
+ def _is_structured_answer_error(exc: BaseException) -> bool:
921
+ if isinstance(exc, StructuredAnswerError):
922
+ return True
923
+ # Under langgraph-server the error arrives as the stream's `error` part.
924
+ return isinstance(exc, _ServerRunError) and exc.error_type == "StructuredAnswerError"
925
+
926
+
927
+ def _is_recursion_error(exc: BaseException) -> bool:
928
+ if type(exc).__name__ == "GraphRecursionError":
929
+ return True
930
+ # Under langgraph-server the error arrives as the stream's `error` part.
931
+ return isinstance(exc, _ServerRunError) and exc.error_type == "GraphRecursionError"
932
+
933
+
934
+ class _ServerRunError(RuntimeError):
935
+ """An `error` part of a LangGraph Server run stream."""
936
+
937
+ def __init__(self, data: Any) -> None:
938
+ super().__init__(str(data))
939
+ self.error_type = str(data.get("error") or "") if isinstance(data, Mapping) else ""
940
+
941
+
942
+ # ---------------------------------------------------------------------------
943
+ # Pacing: run timeout and heartbeats around a stream of graph events
944
+ # ---------------------------------------------------------------------------
945
+
946
+ _ITEM, _DONE, _FAILED, _IDLE = "item", "done", "failed", "idle"
947
+
948
+
949
+ class _Pump:
950
+ """Drive an async iterator in a task of its own and hand its items over a queue.
951
+
952
+ The consumer can then wait with a timeout (for heartbeats and the run
953
+ deadline) without cancelling the graph mid-step, and cancel it outright
954
+ when the run times out or the client leaves.
955
+ """
956
+
957
+ def __init__(self, source: AsyncIterator[Any]) -> None:
958
+ self._queue: asyncio.Queue[tuple[str, Any]] = asyncio.Queue(maxsize=64)
959
+ self._interrupted: BaseException | None = None
960
+ self._task = asyncio.ensure_future(self._run(source))
961
+
962
+ async def _run(self, source: AsyncIterator[Any]) -> None:
963
+ try:
964
+ async for item in source:
965
+ await self._queue.put((_ITEM, item))
966
+ except asyncio.CancelledError:
967
+ raise
968
+ except BaseException as exc:
969
+ await self._queue.put((_FAILED, exc))
970
+ return
971
+ await self._queue.put((_DONE, None))
972
+
973
+ def interrupt(self, exc: BaseException) -> None:
974
+ """Stop the source now; the consumer's next `next()` fails with `exc`."""
975
+ if self._interrupted is not None:
976
+ return
977
+ self._interrupted = exc
978
+ self.cancel()
979
+ with contextlib.suppress(asyncio.QueueFull): # a full queue wakes nobody anyway
980
+ self._queue.put_nowait((_FAILED, exc))
981
+
982
+ async def next(self, timeout: float) -> tuple[str, Any]:
983
+ if self._interrupted is not None:
984
+ return _FAILED, self._interrupted
985
+ try:
986
+ item = await asyncio.wait_for(self._queue.get(), timeout)
987
+ except TimeoutError:
988
+ return _IDLE, None
989
+ if self._interrupted is not None:
990
+ return _FAILED, self._interrupted
991
+ return item
992
+
993
+ @property
994
+ def task(self) -> asyncio.Future[None]:
995
+ return self._task
996
+
997
+ def cancel(self) -> None:
998
+ if not self._task.done():
999
+ self._task.cancel()
1000
+
1001
+ async def close(self, timeout: float = 10) -> bool:
1002
+ """Cancel the source and wait up to `timeout` s for it to stop; True when it has."""
1003
+ self.cancel()
1004
+ if not self._task.done():
1005
+ await asyncio.wait({self._task}, timeout=timeout)
1006
+ return self._task.done()
1007
+
1008
+
1009
+ class ChatRuntime:
1010
+ """Process-wide chat runtime, started and stopped by the app lifespan."""
1011
+
1012
+ def __init__(self) -> None:
1013
+ self.runtime = detect_runtime()
1014
+ self.db: Database | None = None
1015
+ self.runs: RunStore | None = None
1016
+ self.threads: ThreadStore | None = None
1017
+ self.approvals: ApprovalStore | None = None
1018
+ self.locks: ThreadLocks = ThreadLocks()
1019
+ self._exit: AsyncExitStack | None = None
1020
+ self._retention_task: asyncio.Task[None] | None = None
1021
+ self._init_task: asyncio.Task[None] | None = None
1022
+ self._maintenance_task: asyncio.Task[None] | None = None
1023
+ self._approvals_task: asyncio.Task[None] | None = None
1024
+ self._saver: Any = None
1025
+ # Final run records whose write failed, retried by the maintenance loop.
1026
+ self._unrecorded: deque[RunRecord] = deque(maxlen=MAX_UNRECORDED_RUNS)
1027
+ self._was_ready: bool | None = None
1028
+ self.started = False
1029
+ # True from startup until the database schema is set up (postgres).
1030
+ self.initialising = False
1031
+
1032
+ # -- lifecycle ---------------------------------------------------------
1033
+
1034
+ async def start(self) -> None:
1035
+ """Bind storage and start the background work; never waits for the database.
1036
+
1037
+ Configuration errors (an unknown `CHECKPOINTER`, a missing or
1038
+ unparsable DSN) stop startup. An unreachable database does not: the
1039
+ schema setup is retried in the background (`initialising`), `/ready`
1040
+ answers 503 and requests get 503 until it succeeds.
1041
+ """
1042
+ if self.started:
1043
+ return
1044
+ self.runtime = detect_runtime()
1045
+ self._exit = AsyncExitStack()
1046
+ try:
1047
+ if self.runtime == FASTAPI:
1048
+ self.db = Database.from_env()
1049
+ else:
1050
+ # The server binds persistence; run records go to its Postgres
1051
+ # (DATABASE_URI) when it has one.
1052
+ self.db = Database.for_server()
1053
+ await self.db.open_pool()
1054
+ self._exit.push_async_callback(self.db.close)
1055
+ self.locks = ThreadLocks(
1056
+ self.db.dsn if self.db.is_postgres else None,
1057
+ table=self.db.locks_table,
1058
+ health=self.db.health,
1059
+ )
1060
+ self._exit.push_async_callback(self.locks.close)
1061
+ if self.runtime == FASTAPI:
1062
+ from {{cookiecutter.agent_directory}}.agent import graph
1063
+
1064
+ if self.db.is_postgres:
1065
+ # Set up with the app tables once the database answers.
1066
+ self._saver = postgres_saver(self.db.pool, fence=self.locks.fence)
1067
+ else:
1068
+ self._saver = await self._exit.enter_async_context(get_checkpointer())
1069
+ graph.checkpointer = self._saver
1070
+ self.runs = RunStore(self.db)
1071
+ self.threads = ThreadStore(self.db)
1072
+ # Under `langgraph dev` the approvals are kept in a file beside the
1073
+ # server's own threads, which outlive this process (a restart, a
1074
+ # hot reload): the records binding their tool calls must too.
1075
+ self.approvals = ApprovalStore(
1076
+ self.db, path=dev_ledger_path() if self.runtime == LANGGRAPH_SERVER else None
1077
+ )
1078
+ await self.approvals.load()
1079
+ # The ledger the API client marks approvals used in, once, before a
1080
+ # gated call is sent (the graph runs in this process under both runtimes).
1081
+ set_approval_ledger(self.approvals)
1082
+ except BaseException:
1083
+ await self._exit.aclose()
1084
+ self._exit = None
1085
+ raise
1086
+ self.started = True
1087
+ self._check_step_budget()
1088
+ if self.db.is_postgres:
1089
+ self.initialising = True
1090
+ self._init_task = asyncio.create_task(self._initialise(), name="database-setup")
1091
+ else:
1092
+ self._storage_ready()
1093
+ logger.info(
1094
+ "chat runtime started: runtime=%s checkpointer=%s retention_days=%s",
1095
+ self.runtime,
1096
+ self.checkpointer_kind(),
1097
+ retention_days(),
1098
+ )
1099
+
1100
+ async def _initialise(self) -> None:
1101
+ """Set up the schema, retrying with back-off until the database answers."""
1102
+ assert self.db is not None
1103
+ extra = self._saver.setup if self.runtime == FASTAPI and self.db.is_postgres else None
1104
+ delay = INIT_RETRY_FIRST_S
1105
+ attempt = 0
1106
+ while True:
1107
+ attempt += 1
1108
+ try:
1109
+ await self.db.setup(extra)
1110
+ break
1111
+ except asyncio.CancelledError:
1112
+ raise
1113
+ except Exception as exc:
1114
+ if attempt == 1 or attempt % 30 == 0: # about once a minute
1115
+ if is_database_unavailable(exc):
1116
+ logger.warning(
1117
+ "database not reachable yet (%s: %s); not ready, retrying every %g s",
1118
+ type(exc).__name__,
1119
+ _first_line(exc),
1120
+ INIT_RETRY_MAX_S,
1121
+ )
1122
+ else:
1123
+ logger.error("database setup failed; retrying", exc_info=exc)
1124
+ await asyncio.sleep(delay)
1125
+ delay = min(delay * 2, INIT_RETRY_MAX_S)
1126
+ self.initialising = False
1127
+ if attempt > 1:
1128
+ logger.info("database set up after %d attempts", attempt)
1129
+ self._storage_ready()
1130
+
1131
+ def _storage_ready(self) -> None:
1132
+ """Start the background work that needs the database."""
1133
+ days = retention_days()
1134
+ if days > 0:
1135
+ self._retention_task = asyncio.create_task(self._retention_loop(days))
1136
+ if self.db is not None and self.db.is_postgres:
1137
+ self._maintenance_task = asyncio.create_task(self._maintenance_loop())
1138
+ self._approvals_task = asyncio.create_task(self._approvals_loop())
1139
+
1140
+ async def stop(self) -> None:
1141
+ tasks = [
1142
+ self._init_task,
1143
+ self._maintenance_task,
1144
+ self._retention_task,
1145
+ self._approvals_task,
1146
+ ]
1147
+ self._init_task = self._maintenance_task = self._retention_task = None
1148
+ self._approvals_task = None
1149
+ for task in tasks:
1150
+ if task is not None:
1151
+ task.cancel()
1152
+ await asyncio.gather(*(t for t in tasks if t is not None), return_exceptions=True)
1153
+ if self.approvals is not None and approval_ledger() is self.approvals:
1154
+ set_approval_ledger(None)
1155
+ if self._exit is not None:
1156
+ await self._exit.aclose()
1157
+ self._exit = None
1158
+ self.started = False
1159
+ self.initialising = False
1160
+
1161
+ def checkpointer_kind(self) -> str:
1162
+ if self.runtime == LANGGRAPH_SERVER:
1163
+ return "postgres" if is_postgres_url(os.environ.get("DATABASE_URI")) else "memory"
1164
+ return checkpointer_kind()
1165
+
1166
+ def _check_step_budget(self) -> None:
1167
+ """Warn when an API's `max_calls_per_run` cannot be reached within `RECURSION_LIMIT`."""
1168
+ try:
1169
+ from {{cookiecutter.agent_directory}}.app_utils.api_client import load_policy
1170
+
1171
+ policy = load_policy()
1172
+ except Exception: # no or invalid policy: reported where it is used
1173
+ return
1174
+ limit = recursion_limit()
1175
+ fits = sequential_tool_calls(limit)
1176
+ for name, settings in sorted(policy.apis.items()):
1177
+ max_calls = (settings.get("limits") or {}).get("max_calls_per_run")
1178
+ if isinstance(max_calls, int) and max_calls > fits:
1179
+ logger.warning(
1180
+ "RECURSION_LIMIT=%d allows about %d sequential tool calls per run, fewer "
1181
+ "than limits.max_calls_per_run=%d of API %r: runs stop at the step limit "
1182
+ "first. Set RECURSION_LIMIT to at least %d, or lower the API's limit.",
1183
+ limit,
1184
+ fits,
1185
+ max_calls,
1186
+ name,
1187
+ steps_for_tool_calls(max_calls),
1188
+ )
1189
+
1190
+ def _require_storage(self) -> None:
1191
+ """503 while the database is not set up yet (startup during an outage)."""
1192
+ if not self.started or self.initialising:
1193
+ raise unavailable("Database", StorageNotReady("the database is not set up yet"))
1194
+
1195
+ async def ready(self, timeout: float = 2.0) -> bool:
1196
+ """True when the runtime's storage answers a trivial query within `timeout` seconds."""
1197
+ ok = False
1198
+ reason = "starting"
1199
+ if self.started and self.db is not None and not self.initialising:
1200
+ try:
1201
+ async with asyncio.timeout(timeout):
1202
+ await self.db.ping()
1203
+ if self.runtime == LANGGRAPH_SERVER:
1204
+ await self._sdk_client({}).assistants.search(limit=1)
1205
+ ok = True
1206
+ except Exception as exc:
1207
+ reason = type(exc).__name__
1208
+ elif self.initialising:
1209
+ reason = "the database is not set up yet"
1210
+ if ok != self._was_ready: # log changes only, not every probe
1211
+ if ok:
1212
+ logger.info("ready")
1213
+ else:
1214
+ logger.warning("not ready: %s", reason)
1215
+ self._was_ready = ok
1216
+ return ok
1217
+
1218
+ async def _maintenance_loop(self) -> None:
1219
+ """Every `RECONCILE_INTERVAL_S`: write failed run records, close runs of dead processes."""
1220
+ failing = False
1221
+ while True:
1222
+ try:
1223
+ await self.reconcile_runs()
1224
+ if failing:
1225
+ failing = False
1226
+ logger.info("run reconciliation works again")
1227
+ except asyncio.CancelledError:
1228
+ raise
1229
+ except Exception as exc:
1230
+ if not failing: # once per outage, not every round
1231
+ failing = True
1232
+ logger.warning(
1233
+ "run reconciliation failed (%s); retrying every %g s",
1234
+ type(exc).__name__,
1235
+ RECONCILE_INTERVAL_S,
1236
+ )
1237
+ await asyncio.sleep(RECONCILE_INTERVAL_S)
1238
+
1239
+ async def _approvals_loop(self) -> None:
1240
+ """Every `SWEEP_INTERVAL_S`: mark pending approvals past their expiry `expired`."""
1241
+ failing = False
1242
+ while True:
1243
+ try:
1244
+ await self.sweep_approvals()
1245
+ failing = False
1246
+ except asyncio.CancelledError:
1247
+ raise
1248
+ except Exception as exc:
1249
+ if not failing:
1250
+ failing = True
1251
+ logger.warning(
1252
+ "approval expiry sweep failed (%s); retrying every %g s",
1253
+ type(exc).__name__,
1254
+ SWEEP_INTERVAL_S,
1255
+ )
1256
+ await asyncio.sleep(SWEEP_INTERVAL_S)
1257
+
1258
+ async def sweep_approvals(self) -> list[ApprovalRecord]:
1259
+ """Mark pending approvals past their expiry `expired` (= rejected); return them."""
1260
+ if self.approvals is None:
1261
+ return []
1262
+ expired = await self.approvals.expire_due()
1263
+ if expired:
1264
+ metrics.observe_approvals("expired", len(expired))
1265
+ logger.info("%d pending approvals expired", len(expired))
1266
+ await self._approvals_expired(expired)
1267
+ return expired
1268
+
1269
+ async def _approvals_expired(self, records: list[ApprovalRecord]) -> None:
1270
+ """The A2A tasks waiting on these (now expired) approvals fail (`ApprovalOutcome`)."""
1271
+ for record in records:
1272
+ await approval_outcome(
1273
+ ApprovalOutcome(
1274
+ thread_id=record.thread_id,
1275
+ approval_id=record.approval_id,
1276
+ requester_hash=record.requester_hash,
1277
+ requester_actor=record.requester_actor,
1278
+ state=OUTCOME_FAILED,
1279
+ text=f"Approval {record.approval_id} expired before anyone decided.",
1280
+ )
1281
+ )
1282
+
1283
+ async def _resumed_outcome(
1284
+ self,
1285
+ thread_id: str,
1286
+ resume: Resume,
1287
+ status: str,
1288
+ paused: list[ApprovalRecord],
1289
+ reply: str,
1290
+ answer: Any = None,
1291
+ ) -> None:
1292
+ """The A2A tasks waiting on the decided approval follow the run it resumed (KI-025).
1293
+
1294
+ They end as the run did (`completed`, `failed`, or `input-required` with
1295
+ the approvals it paused for again), saying where it continued: in the
1296
+ A2A task that carried the decision, or outside any task (the HTTP
1297
+ route), with the run's reply. A structured run's `answer` goes whole in
1298
+ the outcome (the tasks add it as a `response` artifact), not in the text.
1299
+ """
1300
+ record = resume.approval
1301
+ if status == STATUS_AWAITING_APPROVAL:
1302
+ state = OUTCOME_INPUT_REQUIRED
1303
+ elif status in (STATUS_OK, STATUS_STEP_LIMIT):
1304
+ state = OUTCOME_COMPLETED
1305
+ else:
1306
+ state = OUTCOME_FAILED
1307
+ verdict = "approved" if resume.decision == APPROVE else "rejected"
1308
+ if resume.continued_in:
1309
+ text = f"Continued in task {resume.continued_in}."
1310
+ if verdict == "rejected":
1311
+ text = f"Approval {record.approval_id} was rejected. {text}"
1312
+ else:
1313
+ text = (
1314
+ f"Approval {record.approval_id} was {verdict} outside this task; the run "
1315
+ "continued there."
1316
+ )
1317
+ if state == OUTCOME_FAILED:
1318
+ text += f" The run ended with status {status}."
1319
+ if reply.strip():
1320
+ text += "\n\n" + reply.strip()[:OUTCOME_REPLY_MAX_CHARS]
1321
+ await approval_outcome(
1322
+ ApprovalOutcome(
1323
+ thread_id=thread_id,
1324
+ approval_id=record.approval_id,
1325
+ requester_hash=record.requester_hash,
1326
+ requester_actor=record.requester_actor,
1327
+ state=state,
1328
+ text=text,
1329
+ approvals=tuple(r.public() for r in paused),
1330
+ references=resume.references,
1331
+ continued_in=resume.continued_in,
1332
+ answer=answer if state == OUTCOME_COMPLETED else None,
1333
+ )
1334
+ )
1335
+
1336
+ async def reconcile_runs(self, grace_s: float | None = None) -> list[str]:
1337
+ """Write run records that failed earlier, then mark runs of dead processes `interrupted`."""
1338
+ if self.runs is None:
1339
+ return []
1340
+ grace_s = RECONCILE_GRACE_S if grace_s is None else grace_s
1341
+ while self._unrecorded:
1342
+ record = self._unrecorded[0]
1343
+ await self.runs.record(record)
1344
+ self._unrecorded.popleft()
1345
+ run_ids = await self.runs.reconcile(grace_s)
1346
+ if run_ids:
1347
+ metrics.observe_interrupted_runs(len(run_ids))
1348
+ logger.warning(
1349
+ "%d runs never finished (their process stopped); recorded as interrupted: %s",
1350
+ len(run_ids),
1351
+ ", ".join(run_ids[:20]),
1352
+ )
1353
+ return run_ids
1354
+
1355
+ # -- threads -----------------------------------------------------------
1356
+
1357
+ async def resolve_thread(self, principal: Principal, req: ChatRequest) -> str:
1358
+ """The thread id for this request, after the ownership check (403 before streaming)."""
1359
+ if req.thread_id is not None:
1360
+ req.thread_id = validate_thread_id(req.thread_id, self.runtime)
1361
+ self._require_storage()
1362
+ if self.runtime == LANGGRAPH_SERVER:
1363
+ return await self._server_resolve_thread(principal, req)
1364
+ thread_id = req.thread_id or str(uuid.uuid4())
1365
+ await self._ensure_owner(principal, thread_id)
1366
+ return thread_id
1367
+
1368
+ async def _ensure_owner(self, principal: Principal, thread_id: str) -> None:
1369
+ """fastapi: claim the thread for `principal` when it has no owner, else check the owner.
1370
+
1371
+ 403 for someone else's thread, and for a thread id whose checkpoints
1372
+ have no owner row (none should exist; this keeps any that do from
1373
+ passing to whoever claims the id next).
1374
+ """
1375
+ assert self.threads is not None
1376
+ try:
1377
+ await self.threads.ensure(thread_id, principal, has_state=self._has_checkpoints)
1378
+ except HTTPException:
1379
+ raise
1380
+ except Exception as exc: # the database: a 503 naming an error id, not its text
1381
+ raise unavailable("Database", exc) from exc
1382
+
1383
+ async def _has_checkpoints(self, thread_id: str) -> bool:
1384
+ from {{cookiecutter.agent_directory}}.agent import graph
1385
+
1386
+ saver = graph.checkpointer
1387
+ if saver is None or not hasattr(saver, "aget_tuple"):
1388
+ return False
1389
+ return await saver.aget_tuple({"configurable": {"thread_id": thread_id}}) is not None
1390
+
1391
+ async def acquire_thread(
1392
+ self, thread_id: str, principal: Principal | None = None
1393
+ ) -> ThreadLease:
1394
+ """The thread's run lock; `ThreadBusy` when a run is in progress on it.
1395
+
1396
+ With `principal` (the sender of the run about to start), the owner is
1397
+ checked again once the lock is held (fastapi): a DELETE can take the
1398
+ lock, and remove the thread, between `resolve_thread` and this call.
1399
+ The run then starts the thread afresh for its sender, or gets 403 when
1400
+ another principal claimed the id meanwhile, instead of writing a turn
1401
+ that no owner row covers. Deletion and retention hold the same lock,
1402
+ so the owner cannot change while the run holds it. (Under
1403
+ langgraph-server the server keeps thread and state together: a run on
1404
+ a deleted thread fails there with 404.)
1405
+ """
1406
+ self._require_storage()
1407
+ try:
1408
+ lease = await self.locks.acquire(thread_id)
1409
+ except ThreadBusy:
1410
+ raise
1411
+ except Exception as exc:
1412
+ raise unavailable("Database", exc) from exc
1413
+ if principal is not None and self.runtime == FASTAPI:
1414
+ try:
1415
+ await self._ensure_owner(principal, thread_id)
1416
+ except BaseException:
1417
+ await lease.release()
1418
+ raise
1419
+ return lease
1420
+
1421
+ async def list_threads(
1422
+ self,
1423
+ principal: Principal,
1424
+ *,
1425
+ limit: int,
1426
+ offset: int,
1427
+ forward_headers: Mapping[str, str] | None = None,
1428
+ ) -> list[dict[str, Any]]:
1429
+ """The caller's threads (every thread for a read-across role), most recent first."""
1430
+ self._require_storage()
1431
+ if self.runtime == LANGGRAPH_SERVER:
1432
+ return await self._server_list_threads(principal, limit, offset, forward_headers or {})
1433
+ assert self.threads is not None
1434
+ with database_errors():
1435
+ records = await self.threads.list_for(principal, limit=limit, offset=offset)
1436
+ return [r.public() for r in records]
1437
+
1438
+ async def delete_thread(
1439
+ self,
1440
+ principal: Principal,
1441
+ thread_id: str,
1442
+ forward_headers: Mapping[str, str] | None = None,
1443
+ ) -> None:
1444
+ """Delete a thread with its checkpoints and run records: the owner only.
1445
+
1446
+ 404 for an unknown thread, 403 for someone else's, `ThreadBusy` while
1447
+ a run is in progress on it. The owner is checked before the run lock
1448
+ (a stranger never takes it) and again once it is held: the thread may
1449
+ have been deleted, and its id claimed by someone else, in between.
1450
+ """
1451
+ thread_id = validate_thread_id(thread_id, self.runtime)
1452
+ self._require_storage()
1453
+ headers = forward_headers or {}
1454
+ with database_errors():
1455
+ self._assert_deletable(principal, await self._thread_record(thread_id, headers))
1456
+ lease = await self.acquire_thread(thread_id)
1457
+ try:
1458
+ with database_errors():
1459
+ self._assert_deletable(principal, await self._thread_record(thread_id, headers))
1460
+ await self._delete_thread_data(thread_id, headers)
1461
+ finally:
1462
+ await lease.release()
1463
+ logger.info("thread deleted", extra={"thread_id": thread_id})
1464
+
1465
+ async def _thread_record(
1466
+ self, thread_id: str, forward_headers: Mapping[str, str]
1467
+ ) -> ThreadRecord | None:
1468
+ if self.runtime == LANGGRAPH_SERVER:
1469
+ return await self._server_thread_record(self._sdk_client(forward_headers), thread_id)
1470
+ assert self.threads is not None
1471
+ return await self.threads.get(thread_id)
1472
+
1473
+ @staticmethod
1474
+ def _assert_deletable(principal: Principal, record: ThreadRecord | None) -> None:
1475
+ if record is None:
1476
+ raise HTTPException(status_code=404, detail="Unknown thread.")
1477
+ assert_owner(principal, record)
1478
+
1479
+ async def _delete_thread_data(
1480
+ self, thread_id: str, forward_headers: Mapping[str, str] | None = None
1481
+ ) -> None:
1482
+ if self.runtime == LANGGRAPH_SERVER:
1483
+ client = self._sdk_client(forward_headers or {})
1484
+ try:
1485
+ await client.threads.delete(thread_id)
1486
+ except Exception as exc:
1487
+ if http_status(exc) != 404:
1488
+ raise unavailable("LangGraph Server", exc) from exc
1489
+ # The app keeps no thread rows here; tell the listeners itself (the
1490
+ # retention purge lands here; the A2A task store drops the thread's
1491
+ # tasks). Under fastapi `ThreadStore.delete` below does it.
1492
+ await thread_deleted(thread_id)
1493
+ else:
1494
+ from {{cookiecutter.agent_directory}}.agent import graph
1495
+
1496
+ if graph.checkpointer is not None:
1497
+ await graph.checkpointer.adelete_thread(thread_id)
1498
+ if self.runs is not None:
1499
+ await self.runs.delete_for_thread(thread_id)
1500
+ if self.approvals is not None:
1501
+ await self.approvals.delete_for_thread(thread_id)
1502
+ if self.threads is not None and self.runtime == FASTAPI:
1503
+ await self.threads.delete(thread_id)
1504
+
1505
+ async def forget_thread_runs(self, thread_id: str) -> None:
1506
+ """Drop a deleted thread's run records and approvals (after the server's own
1507
+ DELETE succeeded)."""
1508
+ if self.runs is None and self.approvals is None:
1509
+ return
1510
+ try:
1511
+ thread_id = str(uuid.UUID(thread_id)) # run records use the canonical form
1512
+ except ValueError:
1513
+ return
1514
+ if self.runs is not None:
1515
+ await self.runs.delete_for_thread(thread_id)
1516
+ if self.approvals is not None:
1517
+ await self.approvals.delete_for_thread(thread_id)
1518
+ logger.info(
1519
+ "run records and approvals of a deleted thread removed", extra={"thread_id": thread_id}
1520
+ )
1521
+
1522
+ # -- approvals -------------------------------------------------------------
1523
+
1524
+ async def pending_approvals(self, thread_id: str) -> list[dict[str, Any]]:
1525
+ """The thread's pending approvals as its owner sees them (oldest first)."""
1526
+ if self.approvals is None:
1527
+ return []
1528
+ with database_errors():
1529
+ return [r.public() for r in await self.approvals.pending_for_thread(thread_id)]
1530
+
1531
+ async def replay_refusal(self, thread_id: str) -> str | None:
1532
+ """Why a run without input, or from a checkpoint, is refused on this thread; None if not.
1533
+
1534
+ Such a run (LangGraph Server's own API) runs a paused step's tool calls
1535
+ again without a decision: refused on a thread that recorded approvals
1536
+ or waits on a gated call's interrupt, so a decided call is sent only
1537
+ through its decision (the API client refuses it too). Fails closed:
1538
+ when this cannot be checked, the run is refused.
1539
+ """
1540
+ refusal = await self.copy_refusal(thread_id)
1541
+ if refusal:
1542
+ return refusal
1543
+ try:
1544
+ paused = await self._paused_interrupts(thread_id, {})
1545
+ except Exception as exc:
1546
+ logger.warning(
1547
+ "could not check a thread's gated calls (%s): run refused", type(exc).__name__
1548
+ )
1549
+ return "its gated API calls could not be checked"
1550
+ if any(is_approval_interrupt(value) for value in paused.values()):
1551
+ return "the thread waits on a gated API call"
1552
+ return None
1553
+
1554
+ async def copy_refusal(self, thread_id: str) -> str | None:
1555
+ """Why this thread is not copied (LangGraph Server's native copy); None if it may be.
1556
+
1557
+ A copy keeps the thread's tool calls but not their approvals, which
1558
+ belong to the source (and go when it is deleted): refused on a thread
1559
+ that recorded approvals. Only the records are read: the server holds
1560
+ the source while it copies it. (A copy of a thread that waits on a
1561
+ gated call it never recorded waits on it too, and `replay_refusal`
1562
+ refuses the runs that would run it again there.) Fails closed.
1563
+ """
1564
+ if self.approvals is None:
1565
+ return "the approvals store is not ready"
1566
+ try:
1567
+ if await self.approvals.for_thread(thread_id):
1568
+ return "the thread has approvals of gated API calls"
1569
+ except Exception as exc:
1570
+ logger.warning("could not check a thread's approvals (%s): refused", type(exc).__name__)
1571
+ return "its approvals could not be checked"
1572
+ return None
1573
+
1574
+ async def assert_no_pending_approval(self, thread_id: str) -> None:
1575
+ """`ApprovalPending` (409) while the thread waits for an approval decision."""
1576
+ pending = await self.pending_approvals(thread_id)
1577
+ if pending:
1578
+ raise ApprovalPending(thread_id, pending)
1579
+
1580
+ async def thread_approvals(
1581
+ self,
1582
+ principal: Principal,
1583
+ thread_id: str,
1584
+ forward_headers: Mapping[str, str] | None = None,
1585
+ ) -> list[dict[str, Any]]:
1586
+ """A thread's approvals the caller may see, newest first.
1587
+
1588
+ The owner and read-across roles see every one; a decider the ones it
1589
+ may decide. Anyone else gets 403, an unknown thread 404.
1590
+ """
1591
+ thread_id = validate_thread_id(thread_id, self.runtime)
1592
+ self._require_storage()
1593
+ assert self.approvals is not None
1594
+ with database_errors():
1595
+ await self.sweep_approvals()
1596
+ thread = await self._thread_record(thread_id, forward_headers or {})
1597
+ if thread is None:
1598
+ raise HTTPException(status_code=404, detail="Unknown thread.")
1599
+ with database_errors():
1600
+ records = await self.approvals.for_thread(thread_id)
1601
+ visible = [r for r in records if may_view(principal, thread, r.approvers)]
1602
+ if not visible and not (is_owner(principal, thread) or reads_across(principal)):
1603
+ raise HTTPException(status_code=403, detail="This thread belongs to another principal.")
1604
+ return [r.public(include_call=sees_call(principal, thread, r.approvers)) for r in visible]
1605
+
1606
+ async def visible_approvals(
1607
+ self, principal: Principal, *, status: str | None, limit: int, offset: int
1608
+ ) -> list[dict[str, Any]]:
1609
+ """Approvals across threads the caller requested, may decide, or reads across."""
1610
+ self._require_storage()
1611
+ assert self.approvals is not None
1612
+ with database_errors():
1613
+ await self.sweep_approvals()
1614
+ records = await self.approvals.visible(
1615
+ principal, status=status, limit=limit, offset=offset
1616
+ )
1617
+ # The requester and the deciders see the call; read-across roles only
1618
+ # under TRACE_CAPTURE=full (as `sees_call`). The store lists a delegated
1619
+ # principal's own approvals only, and its roles never decide.
1620
+ own = principal.hashed_id()
1621
+ roles = set() if principal.delegated else {f"role:{r}" for r in principal.roles}
1622
+ return [
1623
+ r.public(
1624
+ include_call=r.requester_hash == own
1625
+ or bool(roles & set(r.approvers))
1626
+ or capture_full()
1627
+ )
1628
+ for r in records
1629
+ ]
1630
+
1631
+ async def decide(
1632
+ self,
1633
+ principal: Principal,
1634
+ thread_id: str,
1635
+ approval_id: str,
1636
+ decision: str,
1637
+ comment: str | None = None,
1638
+ forward_headers: Mapping[str, str] | None = None,
1639
+ digest: str | None = None,
1640
+ ) -> tuple[ThreadLease, Resume, Principal]:
1641
+ """Approve or reject a pending approval; the thread's run lock and what to resume.
1642
+
1643
+ `ApprovalError` 404 (no such approval on this thread), 403 (the caller
1644
+ may not decide it: `not_an_approver`, or `approval_direct_only` for an
1645
+ agent at a gate the person decides directly), 410 (expired), 409
1646
+ (decided already, the run no longer waits for it, or
1647
+ `approval_digest_mismatch`: a relayed decision must name the digest of
1648
+ the call the person saw, and a direct one is checked when it sends
1649
+ one); `ThreadBusy` while a run is in progress on the thread. The decision is one atomic change under the thread's run lock,
1650
+ so of two concurrent decisions one wins. Returns the held lease (the
1651
+ caller streams the resumed run with it), the resume values and the
1652
+ principal the resumed run acts as (the requester, never the decider).
1653
+ """
1654
+ thread_id = validate_thread_id(thread_id, self.runtime)
1655
+ self._require_storage()
1656
+ assert self.approvals is not None
1657
+ verdict = DECISIONS.get(decision)
1658
+ if verdict is None:
1659
+ raise HTTPException(status_code=422, detail="decision must be 'approve' or 'reject'.")
1660
+ headers = forward_headers or {}
1661
+ with database_errors():
1662
+ await self.sweep_approvals()
1663
+ record = await self.approvals.get(approval_id)
1664
+ if record is None or record.thread_id != thread_id:
1665
+ raise ApprovalError(404, "approval_not_found", "Unknown approval.")
1666
+ with database_errors():
1667
+ thread = await self._thread_record(thread_id, headers)
1668
+ if thread is None:
1669
+ raise ApprovalError(404, "approval_not_found", "Unknown approval.")
1670
+ refusal = decide_refusal(
1671
+ principal,
1672
+ thread,
1673
+ record.approvers,
1674
+ decide_with=record.decide_with,
1675
+ relayers=record.relayers,
1676
+ )
1677
+ if refusal is not None:
1678
+ raise ApprovalError(403, *refusal)
1679
+ self._check_decidable(record)
1680
+ if digest_refusal(principal, record, digest):
1681
+ raise ApprovalError(409, CODE_DIGEST_MISMATCH, DIGEST_MISMATCH_DETAIL)
1682
+ # The user's words of the request that paused (the resumed run acts on them), read
1683
+ # before the decision drops them from the record.
1684
+ paused_origin = record.payload.get(ORIGIN_PAYLOAD_KEY)
1685
+ relayed_by = principal.actor.id if principal.actor is not None else None
1686
+ lease = await self.acquire_thread(thread_id)
1687
+ try:
1688
+ with database_errors():
1689
+ paused = await self._paused_interrupts(thread_id, headers)
1690
+ if record.interrupt_id not in paused:
1691
+ # The thread went on without this approval (nothing waits for it).
1692
+ gone = await self.approvals.expire(approval_id)
1693
+ if gone is not None:
1694
+ metrics.observe_approvals("expired")
1695
+ await self._approvals_expired([gone])
1696
+ raise ApprovalError(
1697
+ 409,
1698
+ CODE_NOT_PENDING,
1699
+ "The run no longer waits for this approval.",
1700
+ status=EXPIRED,
1701
+ )
1702
+ decided = await self.approvals.decide(
1703
+ approval_id, verdict, principal.hashed_id(), comment, decided_via=relayed_by
1704
+ )
1705
+ if decided is None:
1706
+ current = await self.approvals.get(approval_id)
1707
+ if current is None:
1708
+ raise ApprovalError(404, "approval_not_found", "Unknown approval.")
1709
+ self._check_decidable(current)
1710
+ raise ApprovalError(
1711
+ 409, CODE_NOT_PENDING, "The approval is decided already.", status=PENDING
1712
+ )
1713
+ metrics.observe_approvals(verdict)
1714
+ resume_as = DECISION_APPROVE if decision == APPROVE else DECISION_REJECT
1715
+ values = {decided.interrupt_id: decision_value(decided, resume_as)}
1716
+ # The paused run's other interrupts get their own approval's state
1717
+ # too: their tools run again on this resume, and each decision is
1718
+ # bound to its call whatever the policy now says about gating it.
1719
+ # An expired (or rejected) one ends its tool, a pending one pauses
1720
+ # again for the same approval, and an approved one whose run never
1721
+ # continued is sent only as an approval allows.
1722
+ latest: dict[str, ApprovalRecord] = {}
1723
+ for other in await self.approvals.for_thread(thread_id): # newest first
1724
+ latest.setdefault(other.interrupt_id, other)
1725
+ for interrupt_id in paused:
1726
+ other = latest.get(interrupt_id)
1727
+ if interrupt_id not in values and other is not None:
1728
+ state = other.effective_status(self.approvals.now())
1729
+ values[interrupt_id] = decision_value(other, RESUME_AS[state])
1730
+ except BaseException:
1731
+ await lease.release()
1732
+ raise
1733
+ logger.info(
1734
+ "approval decided: %s%s",
1735
+ verdict,
1736
+ f" (relayed by {relayed_by})" if relayed_by else "",
1737
+ extra={"thread_id": thread_id, "approval_id": approval_id},
1738
+ )
1739
+ acting = resume_principal(
1740
+ decided,
1741
+ thread,
1742
+ principal,
1743
+ origin=paused_origin if isinstance(paused_origin, dict) else None,
1744
+ )
1745
+ return lease, Resume(values=values, approval=decided, decision=decision), acting
1746
+
1747
+ @staticmethod
1748
+ def _check_decidable(record: ApprovalRecord) -> None:
1749
+ status = record.effective_status()
1750
+ if status == EXPIRED:
1751
+ raise ApprovalError(410, CODE_EXPIRED, "The approval expired.", status=status)
1752
+ if status != PENDING:
1753
+ raise ApprovalError(
1754
+ 409, CODE_NOT_PENDING, f"The approval is {status} already.", status=status
1755
+ )
1756
+
1757
+ async def _paused_interrupts(
1758
+ self, thread_id: str, forward_headers: Mapping[str, str]
1759
+ ) -> dict[str, Any]:
1760
+ """The interrupts the thread's paused run waits on: id -> value."""
1761
+ if self.runtime == LANGGRAPH_SERVER:
1762
+ client = self._sdk_client(forward_headers)
1763
+ try:
1764
+ state = await client.threads.get_state(thread_id)
1765
+ except Exception as exc:
1766
+ if http_status(exc) == 404:
1767
+ return {}
1768
+ raise unavailable("LangGraph Server", exc) from exc
1769
+ items = state.get("interrupts") if isinstance(state, Mapping) else None
1770
+ return {i["id"]: i["value"] for i in interrupts_of(items or [])}
1771
+ from {{cookiecutter.agent_directory}}.agent import graph
1772
+
1773
+ snapshot = await graph.aget_state({"configurable": {"thread_id": thread_id}})
1774
+ found = list(getattr(snapshot, "interrupts", None) or ())
1775
+ return {i["id"]: i["value"] for i in interrupts_of(found)}
1776
+
1777
+ async def _record_approvals(
1778
+ self, principal: Principal, thread_id: str, run_id: str, interrupts: list[dict[str, Any]]
1779
+ ) -> list[ApprovalRecord]:
1780
+ """A pending approval per interrupt of a paused run (one already pending is kept).
1781
+
1782
+ Under the fastapi runtime each keeps the user's words the request carried
1783
+ (the resumed run acts on them); LangGraph Server never passes them to tools.
1784
+ """
1785
+ assert self.approvals is not None
1786
+ records: list[ApprovalRecord] = []
1787
+ origin = origin_of(principal) if self.runtime != LANGGRAPH_SERVER else None
1788
+ for item in interrupts:
1789
+ record, created, superseded = await self.approvals.add(
1790
+ record_from_interrupt(
1791
+ item["value"],
1792
+ interrupt_id=item["id"],
1793
+ thread_id=thread_id,
1794
+ run_id=run_id,
1795
+ requester=principal,
1796
+ now=self.approvals.now(),
1797
+ origin=origin,
1798
+ )
1799
+ )
1800
+ if created:
1801
+ metrics.observe_approvals("requested")
1802
+ metrics.observe_approvals("expired", superseded)
1803
+ if all(r.approval_id != record.approval_id for r in records):
1804
+ records.append(record)
1805
+ logger.info("run paused for %d approval(s)", len(records), extra={"thread_id": thread_id})
1806
+ return records
1807
+
1808
+ async def _approval_results(self, thread_id: str) -> dict[str, str]:
1809
+ """Error results, by tool call id, for calls a paused run left open (see the repair)."""
1810
+ if self.approvals is None:
1811
+ return {}
1812
+ texts: dict[str, str] = {}
1813
+ for record in await self.approvals.for_thread(thread_id): # newest first
1814
+ text = (
1815
+ OPEN_CALL_SENT_UNSAVED
1816
+ if record.used_at is not None
1817
+ else OPEN_CALL_BY_APPROVAL.get(record.effective_status())
1818
+ )
1819
+ if record.tool_call_id and text and record.tool_call_id not in texts:
1820
+ texts[record.tool_call_id] = text
1821
+ return texts
1822
+
1823
+ # -- retention -----------------------------------------------------------
1824
+
1825
+ async def _retention_loop(self, days: int) -> None:
1826
+ await asyncio.sleep(RETENTION_FIRST_DELAY_S)
1827
+ while True:
1828
+ try:
1829
+ purged = 0
1830
+ for _ in range(RETENTION_MAX_BATCHES): # a backlog drains over a few rounds
1831
+ removed = await self.purge_expired(
1832
+ days, batch=RETENTION_BATCH, sweep_run_records=False
1833
+ )
1834
+ purged += removed
1835
+ if removed < RETENTION_BATCH:
1836
+ break
1837
+ if purged:
1838
+ logger.info("retention purge removed %d idle threads", purged)
1839
+ swept = await self.sweep_orphaned_runs(days, batch=RETENTION_BATCH)
1840
+ if swept:
1841
+ logger.info("retention removed the run records of %d deleted threads", swept)
1842
+ except asyncio.CancelledError:
1843
+ raise
1844
+ except Exception:
1845
+ logger.exception("retention purge failed; retrying in an hour")
1846
+ await asyncio.sleep(RETENTION_INTERVAL_S)
1847
+
1848
+ async def purge_expired(
1849
+ self, days: int | None = None, *, batch: int = 500, sweep_run_records: bool = True
1850
+ ) -> int:
1851
+ """Delete threads (checkpoints, run records) idle for more than `days`; best effort.
1852
+
1853
+ A thread with a run in progress is skipped this round, and so is one
1854
+ continued since it was listed: idleness is checked again under the
1855
+ thread's run lock, which a new turn needs too. Returns how many
1856
+ threads were removed. `sweep_run_records` also runs
1857
+ `sweep_orphaned_runs` (the hourly loop runs it once per round instead).
1858
+ """
1859
+ days = retention_days() if days is None else days
1860
+ if days <= 0:
1861
+ return 0
1862
+ cutoff = datetime.now(tz=UTC) - timedelta(days=days)
1863
+ if self.runtime == LANGGRAPH_SERVER:
1864
+ candidates = await self._server_idle_threads(cutoff, batch)
1865
+ else:
1866
+ assert self.threads is not None
1867
+ candidates = await self.threads.idle_before(cutoff.isoformat(), limit=batch)
1868
+ purged = 0
1869
+ for thread_id in candidates:
1870
+ try:
1871
+ lease = await self.locks.acquire(thread_id)
1872
+ except ThreadBusy:
1873
+ continue
1874
+ try:
1875
+ if not await self._still_idle(thread_id, cutoff):
1876
+ continue
1877
+ await self._delete_thread_data(thread_id)
1878
+ purged += 1
1879
+ finally:
1880
+ await lease.release()
1881
+ if sweep_run_records:
1882
+ await self.sweep_orphaned_runs(days, batch=batch)
1883
+ return purged
1884
+
1885
+ async def _still_idle(self, thread_id: str, cutoff: datetime) -> bool:
1886
+ """True when the thread still exists and has not been continued since `cutoff`."""
1887
+ if self.runtime == LANGGRAPH_SERVER:
1888
+ try:
1889
+ thread = await self._sdk_client({}).threads.get(thread_id)
1890
+ except Exception as exc:
1891
+ if http_status(exc) == 404:
1892
+ return False
1893
+ raise
1894
+ if not isinstance(thread, Mapping) or thread.get("status") == "busy":
1895
+ return False
1896
+ updated = _parse_time(thread.get("updated_at"))
1897
+ else:
1898
+ assert self.threads is not None
1899
+ record = await self.threads.get(thread_id)
1900
+ if record is None:
1901
+ return False
1902
+ updated = _parse_time(record.updated_at or record.created_at)
1903
+ return updated is not None and updated < cutoff
1904
+
1905
+ async def sweep_orphaned_runs(self, days: int | None = None, *, batch: int = 500) -> int:
1906
+ """langgraph-server: drop run records, older than `days`, of threads the server no longer has.
1907
+
1908
+ The server's own `DELETE /threads/{id}` removes a thread's run records
1909
+ (`forget_thread_runs`); this catches the rest (for example threads
1910
+ deleted while this app could not reach its database). Paged by
1911
+ thread id, so later pages are reached however many live threads have
1912
+ old run records. Returns how many threads' records were removed.
1913
+ """
1914
+ days = retention_days() if days is None else days
1915
+ if days <= 0 or self.runtime != LANGGRAPH_SERVER or self.runs is None:
1916
+ return 0
1917
+ cutoff_iso = (datetime.now(tz=UTC) - timedelta(days=days)).isoformat()
1918
+ client = self._sdk_client({})
1919
+ removed = 0
1920
+ after: str | None = None
1921
+ for _ in range(RETENTION_MAX_BATCHES):
1922
+ thread_ids = await self.runs.thread_ids_before(cutoff_iso, after=after, limit=batch)
1923
+ for thread_id in thread_ids:
1924
+ if await self._server_thread_record(client, thread_id) is None:
1925
+ await self.runs.delete_for_thread(thread_id)
1926
+ if self.approvals is not None:
1927
+ await self.approvals.delete_for_thread(thread_id)
1928
+ removed += 1
1929
+ if len(thread_ids) < batch:
1930
+ break
1931
+ after = thread_ids[-1]
1932
+ return removed
1933
+
1934
+ # -- streaming ---------------------------------------------------------
1935
+
1936
+ async def stream(
1937
+ self,
1938
+ principal: Principal,
1939
+ req: ChatRequest,
1940
+ thread_id: str,
1941
+ lease: ThreadLease | None = None,
1942
+ resume: Resume | None = None,
1943
+ ) -> AsyncIterator[tuple[str, dict[str, Any]]]:
1944
+ """Run the graph once on `thread_id` and yield the chat events.
1945
+
1946
+ `lease` is the thread's run lock when the caller took it already
1947
+ (`/chat` does, to answer 409 before streaming); otherwise it is taken
1948
+ here and a busy thread yields a single `thread_busy` error event. The
1949
+ lock is released when the run ends, however it ends.
1950
+
1951
+ The run is recorded (`running`) before it starts: a run that cannot be
1952
+ recorded does not start (an `unavailable` error event). A new message
1953
+ on a thread with a pending approval does not start either (an
1954
+ `approval_pending` error event). With `resume` (from `decide()`) the
1955
+ paused run continues with the decision instead of a new message, and
1956
+ `principal` is the requester it acts as. A run that pauses for approval
1957
+ ends with `message.end` status `awaiting_approval` and the approvals.
1958
+ """
1959
+ if lease is None:
1960
+ try:
1961
+ lease = await self.acquire_thread(thread_id, principal)
1962
+ except ThreadBusy:
1963
+ yield EVENT_ERROR, thread_busy_error()
1964
+ return
1965
+ except HTTPException as exc: # not the owner any more, or the database is down
1966
+ code = CODE_FORBIDDEN if exc.status_code == 403 else CODE_UNAVAILABLE
1967
+ yield EVENT_ERROR, {"code": code, "message": str(exc.detail)}
1968
+ return
1969
+ if resume is None:
1970
+ try:
1971
+ await self.assert_no_pending_approval(thread_id)
1972
+ except (ApprovalPending, HTTPException) as exc:
1973
+ await lease.release()
1974
+ if isinstance(exc, ApprovalPending):
1975
+ yield (
1976
+ EVENT_ERROR,
1977
+ {
1978
+ "code": CODE_APPROVAL_PENDING,
1979
+ "message": str(exc),
1980
+ "approvals": exc.approvals,
1981
+ },
1982
+ )
1983
+ else:
1984
+ yield EVENT_ERROR, {"code": CODE_UNAVAILABLE, "message": str(exc.detail)}
1985
+ return
1986
+ except BaseException:
1987
+ await lease.release()
1988
+ raise
1989
+ run_id = str(uuid.uuid4())
1990
+ actor = principal.actor.id if principal.actor is not None else None
1991
+ bind_log_context(
1992
+ run_id=run_id, thread_id=thread_id, principal_hash=principal.hashed_id(), actor=actor
1993
+ )
1994
+ record = RunRecord(
1995
+ run_id=run_id,
1996
+ thread_id=thread_id,
1997
+ principal_hash=principal.hashed_id(),
1998
+ model=model_label(),
1999
+ status=STATUS_OK,
2000
+ metadata=dict(req.metadata) or None,
2001
+ actor=actor,
2002
+ )
2003
+ try:
2004
+ if self.runs is not None:
2005
+ async with asyncio.timeout(FINISH_STEP_TIMEOUT_S):
2006
+ await self.runs.start(record)
2007
+ except Exception as exc:
2008
+ # Not recorded, not run: every run that starts leaves a record.
2009
+ await lease.release()
2010
+ error_id = new_error_id()
2011
+ logger.log(
2012
+ logging.WARNING if is_database_unavailable(exc) else logging.ERROR,
2013
+ "run not started: its run record could not be written (error_id=%s): %s: %s",
2014
+ error_id,
2015
+ type(exc).__name__,
2016
+ _first_line(exc),
2017
+ exc_info=None if is_database_unavailable(exc) else exc,
2018
+ )
2019
+ yield (
2020
+ EVENT_ERROR,
2021
+ {
2022
+ "code": CODE_UNAVAILABLE,
2023
+ "message": f"The run could not start: the database is unavailable. "
2024
+ f"Reference: {error_id}.",
2025
+ "error_id": error_id,
2026
+ "run_id": run_id,
2027
+ },
2028
+ )
2029
+ return
2030
+ loop = asyncio.get_running_loop()
2031
+ started = time.perf_counter()
2032
+ deadline = loop.time() + run_timeout_s()
2033
+ heartbeat = sse_heartbeat_s()
2034
+ state = _RunState()
2035
+ status = STATUS_OK
2036
+ error: BaseException | None = None
2037
+ error_event: dict[str, Any] | None = None
2038
+ final_text: str | None = None
2039
+ # A structured run's answer (the object) and its JSON text, the run's reply.
2040
+ answer: Any = None
2041
+ answer_json: str | None = None
2042
+ pump: _Pump | None = None
2043
+ paused: list[ApprovalRecord] = []
2044
+ # A resumed run's reply, for the A2A tasks that waited on its approval.
2045
+ reply: list[str] = []
2046
+ metrics.ACTIVE_RUNS.inc()
2047
+ try:
2048
+ start: dict[str, Any] = {"thread_id": thread_id, "run_id": run_id}
2049
+ if resume is not None:
2050
+ start.update(approval_id=resume.approval.approval_id, decision=resume.decision)
2051
+ yield EVENT_START, start
2052
+ lease.check()
2053
+ schema = response_schema()
2054
+ state.structured_mode = schema is not None
2055
+ if self.runtime == LANGGRAPH_SERVER:
2056
+ source = self._server_events(principal, req, thread_id, run_id, state, resume)
2057
+ else:
2058
+ source = self._local_events(principal, req, thread_id, run_id, resume)
2059
+ pump = _Pump(source)
2060
+ # A lease lost mid-run (this replica cannot confirm it still owns
2061
+ # the thread) stops the run now, not at its next write.
2062
+ lease.on_lost(lambda: pump.interrupt(LeaseLost(thread_id, lease.lost_reason or "lost")))
2063
+ while True:
2064
+ remaining = deadline - loop.time()
2065
+ if remaining <= 0:
2066
+ raise RunTimeout()
2067
+ kind, payload = await pump.next(min(heartbeat, remaining))
2068
+ if kind == _IDLE:
2069
+ if loop.time() >= deadline:
2070
+ raise RunTimeout()
2071
+ yield EVENT_HEARTBEAT, {}
2072
+ continue
2073
+ if kind == _DONE:
2074
+ break
2075
+ if kind == _FAILED:
2076
+ raise payload
2077
+ mode, data = payload
2078
+ for event in map_stream_item(mode, data, state):
2079
+ if resume is not None and event[0] == EVENT_DELTA:
2080
+ reply.append(str(event[1].get("text") or ""))
2081
+ yield event
2082
+ if state.interrupts:
2083
+ # The graph paused. A gated API call's interrupt waits for a
2084
+ # decision (recorded before the lock is released, so no new
2085
+ # message can slip in first); any other kind cannot be answered here.
2086
+ lease.check()
2087
+ if all(is_approval_interrupt(i["value"]) for i in state.interrupts):
2088
+ paused = await self._record_approvals(
2089
+ principal, thread_id, run_id, state.interrupts
2090
+ )
2091
+ status = STATUS_AWAITING_APPROVAL
2092
+ else:
2093
+ status = STATUS_ERROR
2094
+ error_event = {
2095
+ "code": CODE_UNSUPPORTED_INTERRUPT,
2096
+ "message": UNSUPPORTED_INTERRUPT_MESSAGE,
2097
+ "run_id": run_id,
2098
+ }
2099
+ logger.warning("run paused for input this server cannot collect")
2100
+ elif state.structured_mode:
2101
+ if state.structured is None:
2102
+ status = STATUS_ERROR
2103
+ error_event = self._no_answer_event(run_id)
2104
+ else:
2105
+ # A lone surrogate cannot go out (UTF-8, protobuf): sent as U+FFFD.
2106
+ answer = valid_value(state.structured)
2107
+ # `StructuredAnswer` checks every answer before the graph takes it: one
2108
+ # that reaches delivery and does not fit is refused, never sent.
2109
+ unfit = validate(schema, answer)
2110
+ if unfit:
2111
+ status = STATUS_ERROR
2112
+ error_event = self._unfit_answer_event(run_id, unfit)
2113
+ answer = None
2114
+ else:
2115
+ answer_json = answer_text(answer)
2116
+ state.text = [answer_json]
2117
+ except RunTimeout:
2118
+ status = STATUS_TIMEOUT
2119
+ error_id = new_error_id()
2120
+ error_event = {
2121
+ "code": CODE_TIMEOUT,
2122
+ "message": f"The run took longer than {run_timeout_s():g} s and was cancelled. "
2123
+ f"Reference: {error_id}.",
2124
+ "error_id": error_id,
2125
+ "run_id": run_id,
2126
+ }
2127
+ logger.warning("run timed out (error_id=%s)", error_id)
2128
+ except (asyncio.CancelledError, GeneratorExit):
2129
+ status = STATUS_CANCELLED
2130
+ logger.info("run cancelled: the client went away")
2131
+ raise
2132
+ except Exception as exc:
2133
+ error = exc
2134
+ if _lease_lost(exc) is not None:
2135
+ status = STATUS_INTERRUPTED
2136
+ elif _is_recursion_error(exc):
2137
+ if pump is not None:
2138
+ await pump.close() # the graph has stopped; make sure before writing
2139
+ final_text = await self._end_at_step_limit(req, thread_id, lease)
2140
+ status = STATUS_STEP_LIMIT if final_text is not None else STATUS_ERROR
2141
+ else:
2142
+ status = STATUS_ERROR
2143
+ if final_text is None:
2144
+ error_event = self._error_event(exc, run_id)
2145
+ finally:
2146
+ if pump is not None:
2147
+ pump.cancel()
2148
+ latency_ms = int((time.perf_counter() - started) * 1000)
2149
+ metrics.ACTIVE_RUNS.dec()
2150
+ metrics.observe_run(status, latency_ms / 1000, state.input_tokens, state.output_tokens)
2151
+ # The run's outbound-API call counts (limits.max_calls_per_run) go with it.
2152
+ end_api_run(run_id)
2153
+ finish = asyncio.ensure_future(
2154
+ self._finish_run(
2155
+ principal,
2156
+ req,
2157
+ record,
2158
+ state,
2159
+ status,
2160
+ error,
2161
+ latency_ms,
2162
+ pump,
2163
+ lease,
2164
+ resumed=resume is not None,
2165
+ )
2166
+ )
2167
+ # Shielded: the record and the lock release complete even when the
2168
+ # consumer is cancelled again while waiting.
2169
+ await asyncio.shield(finish)
2170
+ if resume is not None:
2171
+ await self._resumed_outcome(
2172
+ thread_id,
2173
+ resume,
2174
+ status,
2175
+ paused,
2176
+ "".join(reply) + (final_text or ""),
2177
+ answer if answer_json is not None else None,
2178
+ )
2179
+ if error_event is not None:
2180
+ yield EVENT_ERROR, error_event
2181
+ return
2182
+ if final_text is not None:
2183
+ yield EVENT_DELTA, {"text": final_text}
2184
+ if answer_json is not None:
2185
+ yield EVENT_DELTA, {"text": answer_json}
2186
+ end: dict[str, Any] = {
2187
+ "thread_id": thread_id,
2188
+ "run_id": run_id,
2189
+ "usage": {
2190
+ "input_tokens": state.input_tokens,
2191
+ "output_tokens": state.output_tokens,
2192
+ },
2193
+ "latency_ms": latency_ms,
2194
+ "status": status,
2195
+ }
2196
+ if paused:
2197
+ # The requester sees what it is asked to approve (it asked for it).
2198
+ end["approval"] = paused[0].public()
2199
+ end["approvals"] = [record.public() for record in paused]
2200
+ if answer_json is not None:
2201
+ end[STRUCTURED_KEY] = answer
2202
+ yield EVENT_END, end
2203
+
2204
+ @staticmethod
2205
+ def _no_answer_event(run_id: str) -> dict[str, Any]:
2206
+ """A structured run that ended with no answer (the graph gave no `structured_response`)."""
2207
+ error_id = new_error_id()
2208
+ logger.warning(
2209
+ "run ended without a structured answer: the project has a response schema, but the "
2210
+ "graph gave no structured_response (is agent.py built with response_format()?) "
2211
+ "(error_id=%s)",
2212
+ error_id,
2213
+ )
2214
+ return {
2215
+ "code": CODE_INVALID_STRUCTURED_RESPONSE,
2216
+ "message": "The run ended without an answer in the shape of the response schema. "
2217
+ f"Reference: {error_id}.",
2218
+ "error_id": error_id,
2219
+ "run_id": run_id,
2220
+ }
2221
+
2222
+ @staticmethod
2223
+ def _unfit_answer_event(run_id: str, problems: list[str]) -> dict[str, Any]:
2224
+ """A structured run whose answer does not fit the schema when it is delivered.
2225
+
2226
+ `StructuredAnswer` sends such an answer back to the model; one that gets
2227
+ here was never checked (agent.py's `middleware()` has no
2228
+ `StructuredAnswer()`), so the run fails rather than deliver it.
2229
+ """
2230
+ error_id = new_error_id()
2231
+ logger.warning(
2232
+ "run failed: its answer does not fit the response schema and was never checked "
2233
+ "(is StructuredAnswer() last in agent.py's middleware()?) (error_id=%s): %s",
2234
+ error_id,
2235
+ "; ".join(problems[:MAX_REPORTED_PROBLEMS]),
2236
+ )
2237
+ return {
2238
+ "code": CODE_INVALID_STRUCTURED_RESPONSE,
2239
+ "message": "The run failed: the agent's answer did not fit the response schema. "
2240
+ f"Reference: {error_id}.",
2241
+ "error_id": error_id,
2242
+ "run_id": run_id,
2243
+ }
2244
+
2245
+ def _error_event(self, exc: BaseException, run_id: str) -> dict[str, Any]:
2246
+ """The client-facing error: a code and a generic message; the detail is logged."""
2247
+ if isinstance(exc, ThreadBusy) or (
2248
+ http_status(exc) == 409 and self.runtime == LANGGRAPH_SERVER
2249
+ ):
2250
+ logger.info("run refused: the thread is busy")
2251
+ return {**thread_busy_error(), "run_id": run_id}
2252
+ error_id = new_error_id()
2253
+ lost = _lease_lost(exc)
2254
+ if _is_recursion_error(exc):
2255
+ logger.warning("run reached the recursion limit (error_id=%s)", error_id)
2256
+ event: dict[str, Any] = {
2257
+ "code": CODE_RECURSION,
2258
+ "message": f"The run reached the step limit ({recursion_limit()} steps) and "
2259
+ f"was stopped. Reference: {error_id}.",
2260
+ }
2261
+ elif _is_structured_answer_error(exc):
2262
+ logger.warning(
2263
+ "run failed: no answer fitted the response schema (error_id=%s): %s",
2264
+ error_id,
2265
+ _first_line(exc),
2266
+ )
2267
+ event = {
2268
+ "code": CODE_INVALID_STRUCTURED_RESPONSE,
2269
+ "message": "The run failed: the agent's answer did not fit the response schema "
2270
+ f"in {MAX_ANSWER_ATTEMPTS} tries. Reference: {error_id}.",
2271
+ }
2272
+ elif lost is not None:
2273
+ logger.warning("run stopped (error_id=%s): %s", error_id, lost)
2274
+ event = {
2275
+ "code": CODE_UNAVAILABLE,
2276
+ "message": "The run was stopped: it could no longer confirm it was the only "
2277
+ f"run on this thread. Reference: {error_id}.",
2278
+ }
2279
+ elif is_database_unavailable(exc):
2280
+ logger.warning(
2281
+ "run failed: the database is unavailable (error_id=%s): %s: %s",
2282
+ error_id,
2283
+ type(exc).__name__,
2284
+ _first_line(exc),
2285
+ )
2286
+ event = {
2287
+ "code": CODE_UNAVAILABLE,
2288
+ "message": f"The run failed: the database is unavailable. Reference: {error_id}.",
2289
+ }
2290
+ else:
2291
+ logger.error("run failed (error_id=%s)", error_id, exc_info=exc)
2292
+ event = {
2293
+ "code": CODE_RUN_FAILED,
2294
+ "message": f"The run failed. Reference: {error_id}.",
2295
+ }
2296
+ event.update({"error_id": error_id, "run_id": run_id})
2297
+ if dev_mode():
2298
+ event["detail"] = f"{type(exc).__name__}: {exc}"
2299
+ return event
2300
+
2301
+ async def _finish_run(
2302
+ self,
2303
+ principal: Principal,
2304
+ req: ChatRequest,
2305
+ record: RunRecord,
2306
+ state: _RunState,
2307
+ status: str,
2308
+ error: BaseException | None,
2309
+ latency_ms: int,
2310
+ pump: _Pump | None,
2311
+ lease: ThreadLease,
2312
+ *,
2313
+ resumed: bool = False,
2314
+ ) -> None:
2315
+ """Bookkeeping after a run, each step bounded: the client's last event is not held up.
2316
+
2317
+ `resumed`: the run continued a paused one with a decision (`decide()`).
2318
+ """
2319
+ thread_id = record.thread_id
2320
+ try:
2321
+ if pump is not None and not await pump.close(PUMP_STOP_WAIT_S):
2322
+ # Still stopping (it waits for writes in flight, which the lease
2323
+ # fence refuses): the thread stays busy until it has stopped,
2324
+ # but the client need not wait for it.
2325
+ lease.hold_until(pump.task)
2326
+ if (
2327
+ status in (STATUS_TIMEOUT, STATUS_CANCELLED, STATUS_INTERRUPTED)
2328
+ and self.runtime == LANGGRAPH_SERVER
2329
+ ):
2330
+ await self._cancel_server_run(req, thread_id, state)
2331
+ jobs = [self._record_run(principal, req, record, state, status, error, latency_ms)]
2332
+ stopped = pump is None or pump.task.done()
2333
+ # A graph still stopping could write after the repair: the next run repairs then.
2334
+ if stopped and self._repairs_after(status, error, lease):
2335
+ reason = f"The tool call did not finish: the run stopped ({status})."
2336
+ jobs.append(self._close_dangling_tool_calls(req, thread_id, reason))
2337
+ await asyncio.gather(*jobs)
2338
+ if stopped and not lease.lost:
2339
+ if status not in (STATUS_OK, STATUS_AWAITING_APPROVAL):
2340
+ await self._expire_orphaned_approvals(req, thread_id, record.run_id)
2341
+ elif resumed:
2342
+ # A resumed call a later denial (or narrower policy) refused
2343
+ # did not pause again: its approval waits for nothing now.
2344
+ await self._expire_orphaned_approvals(req, thread_id)
2345
+ finally:
2346
+ await lease.release()
2347
+ logger.info(
2348
+ "run finished",
2349
+ extra={"status": status, "latency_ms": latency_ms, "run_id": record.run_id},
2350
+ )
2351
+
2352
+ async def _expire_orphaned_approvals(
2353
+ self, req: ChatRequest, thread_id: str, run_id: str | None = None
2354
+ ) -> None:
2355
+ """Expire the thread's pending approvals that nothing waits for (best effort).
2356
+
2357
+ A run that ends without pausing (cancelled, failed, timed out) can
2358
+ leave pending approvals behind: ones it recorded before it was cut
2359
+ short (its client never saw them: `run_id`, the stopped run), or ones
2360
+ of the paused run it resumed whose calls the repair has since
2361
+ answered. A resumed run that ends (paused again or not) can too: a
2362
+ sibling call whose approval was still pending runs again on the
2363
+ resume and, when the policy now refuses it (a denial added, or
2364
+ narrower `allowed_methods`), ends with that error instead of pausing
2365
+ again for its approval. Left pending, they would refuse the thread's
2366
+ next message (409 `approval_pending`) until they expire; expired, the
2367
+ next message goes through and the history repair tells the model the
2368
+ call was not approved.
2369
+ """
2370
+ if self.approvals is None or (self.db is not None and not self.db.health.up):
2371
+ return
2372
+ expired: list[ApprovalRecord] = []
2373
+ try:
2374
+ async with asyncio.timeout(FINISH_STEP_TIMEOUT_S):
2375
+ pending = await self.approvals.pending_for_thread(thread_id)
2376
+ if not pending:
2377
+ return
2378
+ waiting = await self._paused_interrupts(thread_id, req.forward_headers)
2379
+ for item in pending:
2380
+ if (run_id is not None and item.run_id == run_id) or (
2381
+ item.interrupt_id not in waiting
2382
+ ):
2383
+ gone = await self.approvals.expire(item.approval_id)
2384
+ if gone is not None:
2385
+ expired.append(gone)
2386
+ except Exception as exc:
2387
+ logger.warning(
2388
+ "could not expire the approvals of a stopped run (%s); they expire on time",
2389
+ type(exc).__name__,
2390
+ )
2391
+ if expired:
2392
+ metrics.observe_approvals("expired", len(expired))
2393
+ logger.info("expired %d approval(s) the thread no longer waits for", len(expired))
2394
+ await self._approvals_expired(expired)
2395
+
2396
+ def _repairs_after(self, status: str, error: BaseException | None, lease: ThreadLease) -> bool:
2397
+ """Whether a stopped run should answer the tool calls it left open.
2398
+
2399
+ Not after a clean end, not after a pause for approval (the open call
2400
+ waits for its decision), not when the run never started (the thread
2401
+ was busy: the open calls belong to the run in progress) and not when
2402
+ this process no longer owns the thread.
2403
+ """
2404
+ if (
2405
+ status in (STATUS_OK, STATUS_AWAITING_APPROVAL, STATUS_STEP_LIMIT, STATUS_INTERRUPTED)
2406
+ or lease.lost
2407
+ ):
2408
+ return False
2409
+ if error is None:
2410
+ return True
2411
+ return not (isinstance(error, ThreadBusy) or http_status(error) == 409)
2412
+
2413
+ async def _close_dangling_tool_calls(
2414
+ self, req: ChatRequest, thread_id: str, reason: str
2415
+ ) -> None:
2416
+ """Answer the tool calls a stopped run left open, so the thread stays usable (best effort).
2417
+
2418
+ A run cut short between the model's tool call and the tool's result
2419
+ leaves an assistant message whose tool calls have no results; model
2420
+ providers reject such a history on the next turn. Each open call gets
2421
+ an error result right after it. The next run repairs anything this
2422
+ misses (for example when the database is down right now).
2423
+ """
2424
+ if self.db is not None and not self.db.health.up:
2425
+ logger.info("the database is down: the next run answers the open tool calls")
2426
+ return
2427
+ try:
2428
+ async with asyncio.timeout(FINISH_STEP_TIMEOUT_S):
2429
+ closed = await self._repair_history(req, thread_id, reason)
2430
+ except Exception as exc:
2431
+ logger.warning(
2432
+ "could not close the tool calls of a stopped run (%s); the next run will",
2433
+ type(exc).__name__,
2434
+ )
2435
+ return
2436
+ if closed:
2437
+ logger.info("closed %d tool calls left open by a stopped run", closed)
2438
+
2439
+ async def _repair_history(
2440
+ self,
2441
+ req: ChatRequest,
2442
+ thread_id: str,
2443
+ reason: str,
2444
+ final_text: str | None = None,
2445
+ texts: Mapping[str, str] | None = None,
2446
+ ) -> int:
2447
+ """Put the thread's tool-call history right, then add `final_text` as the last reply.
2448
+
2449
+ Every tool call gets its result right after it (an error result saying
2450
+ `reason` when it has none, `texts[call id]` for a call named there,
2451
+ or that the arguments were not valid JSON for a call whose arguments
2452
+ did not parse) and results that answer no call go; see
2453
+ `repair_tool_history`. Only the owner of the thread's run lease writes
2454
+ (fastapi: the checkpointer's fence). Returns how many open calls got a
2455
+ result. Under langgraph-server a thread with a run in progress (started
2456
+ through the server's own API) is left alone.
2457
+ """
2458
+ if self.runtime == LANGGRAPH_SERVER:
2459
+ return await self._server_repair_history(req, thread_id, reason, final_text, texts)
2460
+ from langchain_core.messages import AIMessage, RemoveMessage, ToolMessage
2461
+ from langgraph.constants import END
2462
+
2463
+ from {{cookiecutter.agent_directory}}.agent import graph
2464
+
2465
+ def error_result(call: Any) -> Any:
2466
+ return ToolMessage(
2467
+ content=open_call_result_text(call, (texts or {}).get(_call_id(call), reason)),
2468
+ tool_call_id=str(_get(call, "id") or ""),
2469
+ name=str(_get(call, "name") or ""),
2470
+ status="error",
2471
+ id=str(uuid.uuid4()),
2472
+ )
2473
+
2474
+ config = {"configurable": {"thread_id": thread_id}}
2475
+ snapshot = await graph.aget_state(config)
2476
+ if snapshot.tasks:
2477
+ # A step that never finished (the process stopped mid-step): the
2478
+ # state shown includes what its tasks already wrote, but an update
2479
+ # is applied to the last checkpoint without them. Make them part
2480
+ # of the thread first, as the next run's input would.
2481
+ await graph.aupdate_state(config, None, as_node=END)
2482
+ snapshot = await graph.aget_state(config)
2483
+ messages = list((snapshot.values or {}).get("messages") or [])
2484
+ repair = repair_tool_history(messages, error_result)
2485
+ update: list[Any] = []
2486
+ if repair is not None:
2487
+ update = (
2488
+ list(repair.added)
2489
+ if repair.append_only
2490
+ else [RemoveMessage(id=REMOVE_ALL_MESSAGES), *repair.messages]
2491
+ )
2492
+ if final_text is not None:
2493
+ update.append(AIMessage(content=final_text, id=str(uuid.uuid4())))
2494
+ if not update:
2495
+ return 0
2496
+ if final_text is not None and "model" in graph.nodes:
2497
+ as_node: str | None = "model"
2498
+ else:
2499
+ as_node = "tools" if "tools" in graph.nodes else None
2500
+ await graph.aupdate_state(config, {"messages": update}, as_node=as_node)
2501
+ if repair is not None and not repair.append_only:
2502
+ logger.warning("moved misplaced tool results back after their calls")
2503
+ return len(repair.added) if repair is not None else 0
2504
+
2505
+ async def _server_repair_history(
2506
+ self,
2507
+ req: ChatRequest,
2508
+ thread_id: str,
2509
+ reason: str,
2510
+ final_text: str | None,
2511
+ texts: Mapping[str, str] | None = None,
2512
+ ) -> int:
2513
+ client = self._sdk_client(req.forward_headers)
2514
+ thread = await client.threads.get(thread_id)
2515
+ if final_text is not None and _server_busy(thread):
2516
+ # Ending our own run at the step limit: the server sends the run's
2517
+ # error before it marks the run done and the thread idle.
2518
+ thread = await self._server_wait_idle(client, thread_id, SERVER_IDLE_WAIT_S)
2519
+ if _server_busy(thread):
2520
+ # A run owns the thread (one started through the server's own API,
2521
+ # or ours still winding down): its open tool calls are its own.
2522
+ if final_text is not None:
2523
+ raise ThreadBusy(thread_id)
2524
+ return 0
2525
+ snapshot = await client.threads.get_state(thread_id)
2526
+ if isinstance(snapshot, Mapping) and snapshot.get("tasks"):
2527
+ # Writes of a step that never finished: part of the thread first
2528
+ # (see `_repair_history`).
2529
+ await client.threads.update_state(thread_id, None, as_node="__end__")
2530
+ snapshot = await client.threads.get_state(thread_id)
2531
+ values = snapshot.get("values") if isinstance(snapshot, Mapping) else None
2532
+ messages = list((values or {}).get("messages") or [])
2533
+
2534
+ def error_result(call: Any) -> dict[str, Any]:
2535
+ return {
2536
+ "type": "tool",
2537
+ "content": open_call_result_text(call, (texts or {}).get(_call_id(call), reason)),
2538
+ "tool_call_id": str(_get(call, "id") or ""),
2539
+ "name": str(_get(call, "name") or ""),
2540
+ "status": "error",
2541
+ "id": str(uuid.uuid4()),
2542
+ }
2543
+
2544
+ repair = repair_tool_history(messages, error_result)
2545
+ update: list[Any] = []
2546
+ if repair is not None:
2547
+ update = (
2548
+ list(repair.added)
2549
+ if repair.append_only
2550
+ else [
2551
+ # The server turns each dict back into a message and needs a
2552
+ # `content` on every one, a RemoveMessage's included.
2553
+ {"type": "remove", "id": REMOVE_ALL_MESSAGES, "content": ""},
2554
+ *(_server_message(m) for m in repair.messages),
2555
+ ]
2556
+ )
2557
+ if final_text is not None:
2558
+ update.append({"type": "ai", "content": final_text, "id": str(uuid.uuid4())})
2559
+ if not update:
2560
+ return 0
2561
+ as_node = "model" if final_text is not None else "tools"
2562
+ await client.threads.update_state(thread_id, {"messages": update}, as_node=as_node)
2563
+ return len(repair.added) if repair is not None else 0
2564
+
2565
+ async def _server_wait_idle(self, client: Any, thread_id: str, timeout_s: float) -> Any:
2566
+ """The server thread once it is no longer busy, or as it is after `timeout_s`."""
2567
+ deadline = time.monotonic() + timeout_s
2568
+ thread = await client.threads.get(thread_id)
2569
+ while _server_busy(thread) and time.monotonic() < deadline:
2570
+ await asyncio.sleep(0.1)
2571
+ thread = await client.threads.get(thread_id)
2572
+ return thread
2573
+
2574
+ async def _end_at_step_limit(
2575
+ self, req: ChatRequest, thread_id: str, lease: ThreadLease
2576
+ ) -> str | None:
2577
+ """End a run that reached `RECURSION_LIMIT` with a final reply saying so.
2578
+
2579
+ The thread keeps everything the run did (its open tool calls get an
2580
+ error result), then the reply; the client gets the reply and
2581
+ `message.end` with status `step_limit`. None when that cannot be
2582
+ written: the run then ends with the `recursion_limit` error.
2583
+ """
2584
+ text = STEP_LIMIT_MESSAGE.format(limit=recursion_limit())
2585
+ try:
2586
+ lease.check()
2587
+ async with asyncio.timeout(FINISH_STEP_TIMEOUT_S):
2588
+ await self._repair_history(req, thread_id, OPEN_CALL_STEP_LIMIT, final_text=text)
2589
+ except Exception as exc:
2590
+ logger.warning("could not end the run with a step-limit reply (%s)", type(exc).__name__)
2591
+ return None
2592
+ logger.warning(
2593
+ "run reached the step limit (%d steps); ended it with a reply", recursion_limit()
2594
+ )
2595
+ return text
2596
+
2597
+ async def _local_events(
2598
+ self,
2599
+ principal: Principal,
2600
+ req: ChatRequest,
2601
+ thread_id: str,
2602
+ run_id: str,
2603
+ resume: Resume | None = None,
2604
+ ) -> AsyncIterator[tuple[str, Any]]:
2605
+ from langgraph.types import Command
2606
+
2607
+ from {{cookiecutter.agent_directory}}.agent import AgentContext, graph
2608
+
2609
+ if resume is None:
2610
+ # A run cut short earlier (a crash, an outage) may have left tool calls
2611
+ # without results: answer them before this run appends its turn.
2612
+ # (A resume continues the paused step: its open call is the one decided.)
2613
+ await self._repair_before_run(req, thread_id)
2614
+ config = {
2615
+ "configurable": {"thread_id": thread_id},
2616
+ "run_id": uuid.UUID(run_id),
2617
+ "recursion_limit": recursion_limit(),
2618
+ "metadata": trace_metadata(thread_id, run_id, principal, req.metadata),
2619
+ }
2620
+ # In-process only (never persisted): tools may need the caller's
2621
+ # credentials for `auth: forward` APIs.
2622
+ context = AgentContext(
2623
+ principal_id=principal.id,
2624
+ roles=list(principal.roles),
2625
+ attributes=dict(principal.attributes),
2626
+ )
2627
+ graph_input: Any = (
2628
+ Command(resume=resume.values)
2629
+ if resume is not None
2630
+ else {"messages": [{"role": "user", "content": req.message}]}
2631
+ )
2632
+ async for mode, data in graph.astream(
2633
+ graph_input,
2634
+ config=config,
2635
+ context=context,
2636
+ stream_mode=["messages", "updates"],
2637
+ ):
2638
+ yield mode, data
2639
+
2640
+ async def _repair_before_run(self, req: ChatRequest, thread_id: str) -> None:
2641
+ """Repair the thread's history before a run appends to it.
2642
+
2643
+ Under fastapi a failure fails the run (its database is this app's).
2644
+ Under langgraph-server the repair is best effort: the server may be
2645
+ reached through a client that cannot read the state.
2646
+ """
2647
+ try:
2648
+ # A call a paused run left waiting for an approval that expired (or
2649
+ # was decided but never resumed) says so in its result.
2650
+ texts = await self._approval_results(thread_id)
2651
+ closed = await self._repair_history(req, thread_id, OPEN_CALL_INTERRUPTED, texts=texts)
2652
+ except Exception as exc:
2653
+ if self.runtime == FASTAPI:
2654
+ raise
2655
+ logger.warning(
2656
+ "could not check the thread's tool calls before the run: %s", type(exc).__name__
2657
+ )
2658
+ return
2659
+ if closed:
2660
+ logger.warning("answered %d tool calls an interrupted run left open", closed)
2661
+
2662
+ async def _record_run(
2663
+ self,
2664
+ principal: Principal,
2665
+ req: ChatRequest,
2666
+ record: RunRecord,
2667
+ state: _RunState,
2668
+ status: str,
2669
+ error: BaseException | None,
2670
+ latency_ms: int,
2671
+ ) -> None:
2672
+ if self.runs is None:
2673
+ return
2674
+ payload: dict[str, Any] | None = None
2675
+ if capture_full():
2676
+ payload = {
2677
+ "message": req.message,
2678
+ "response": "".join(state.text),
2679
+ "tool_calls": state.tool_calls,
2680
+ "error": str(error) if error else None,
2681
+ }
2682
+ final = RunRecord(
2683
+ run_id=record.run_id,
2684
+ thread_id=record.thread_id,
2685
+ principal_hash=principal.hashed_id(),
2686
+ model=record.model,
2687
+ status=status,
2688
+ input_tokens=state.input_tokens,
2689
+ output_tokens=state.output_tokens,
2690
+ latency_ms=latency_ms,
2691
+ error_type=type(error).__name__ if error else None,
2692
+ metadata=dict(req.metadata) or None,
2693
+ payload=payload,
2694
+ created_at=record.created_at,
2695
+ actor=record.actor,
2696
+ )
2697
+ if self.db is not None and not self.db.health.up:
2698
+ # Known down: do not hold the reply up; the maintenance loop writes it.
2699
+ self._unrecorded.append(final)
2700
+ logger.warning("the run record is written once the database is back")
2701
+ return
2702
+ try:
2703
+ async with asyncio.timeout(FINISH_STEP_TIMEOUT_S):
2704
+ await self.runs.record(final)
2705
+ except Exception as exc: # a failed run record must not break the reply
2706
+ self._unrecorded.append(final)
2707
+ logger.warning(
2708
+ "could not write the run record (%s); retrying in the background",
2709
+ type(exc).__name__,
2710
+ )
2711
+
2712
+ # -- reading a thread ----------------------------------------------------
2713
+
2714
+ async def messages(
2715
+ self,
2716
+ principal: Principal,
2717
+ thread_id: str,
2718
+ forward_headers: Mapping[str, str] | None = None,
2719
+ ) -> list[dict[str, Any]]:
2720
+ thread_id = validate_thread_id(thread_id, self.runtime)
2721
+ self._require_storage()
2722
+ if self.runtime == LANGGRAPH_SERVER:
2723
+ return await self._server_messages(principal, thread_id, forward_headers or {})
2724
+ assert self.threads is not None
2725
+ with database_errors():
2726
+ record = await self.threads.get(thread_id)
2727
+ if record is None:
2728
+ raise HTTPException(status_code=404, detail="Unknown thread.")
2729
+ assert_access(principal, record)
2730
+ # Tool arguments reach a non-owner only under TRACE_CAPTURE=full.
2731
+ include_args = is_owner(principal, record) or capture_full()
2732
+ from {{cookiecutter.agent_directory}}.agent import graph
2733
+
2734
+ with database_errors():
2735
+ snapshot = await graph.aget_state({"configurable": {"thread_id": thread_id}})
2736
+ values = snapshot.values if snapshot is not None else {}
2737
+ return [
2738
+ serialize_message(m, include_tool_args=include_args) for m in values.get("messages", [])
2739
+ ]
2740
+
2741
+ # -- langgraph-server: proxy through the loopback SDK client --------------
2742
+
2743
+ def _sdk_client(self, req_headers: Mapping[str, str]) -> Any:
2744
+ from langgraph_sdk import get_client
2745
+
2746
+ headers = select_forward_headers(req_headers)
2747
+ return get_client(
2748
+ url=os.environ.get("LANGGRAPH_SERVER_URL") or None, headers=headers or None
2749
+ )
2750
+
2751
+ @staticmethod
2752
+ def _record_of(thread: Any, thread_id: str) -> ThreadRecord:
2753
+ meta = (thread.get("metadata") or {}) if isinstance(thread, Mapping) else {}
2754
+ return ThreadRecord(
2755
+ thread_id=str(thread.get("thread_id") or thread_id)
2756
+ if isinstance(thread, Mapping)
2757
+ else thread_id,
2758
+ principal_id=str(meta.get("principal_id") or ""),
2759
+ tenant=meta.get("tenant"),
2760
+ # No `actor` (a direct thread, or one created before 0.3): no agent's thread.
2761
+ actor=str(meta.get("actor") or ""),
2762
+ )
2763
+
2764
+ async def _server_thread_record(self, client: Any, thread_id: str) -> ThreadRecord | None:
2765
+ """The thread's ownership metadata as the app wrote it at creation, or None when absent.
2766
+
2767
+ The loopback client is unauthenticated on the server, so any error
2768
+ other than 404 is a transport/server failure (503), never an ownership
2769
+ signal. A thread without `principal_id` metadata (created through the
2770
+ native API without this app) fails closed: nobody but a read-across
2771
+ role reads it.
2772
+ """
2773
+ try:
2774
+ thread = await client.threads.get(thread_id)
2775
+ except Exception as exc:
2776
+ if http_status(exc) == 404:
2777
+ return None
2778
+ raise unavailable("LangGraph Server", exc) from exc
2779
+ return self._record_of(thread, thread_id)
2780
+
2781
+ async def _server_resolve_thread(self, principal: Principal, req: ChatRequest) -> str:
2782
+ client = self._sdk_client(req.forward_headers)
2783
+ metadata = {
2784
+ "principal_id": principal.id,
2785
+ "tenant": principal.public_attributes().get("tenant"),
2786
+ }
2787
+ if principal.actor is not None:
2788
+ # The agent that starts the thread for its user (see `threads.is_owner`).
2789
+ metadata["actor"] = principal.actor.id
2790
+ if req.thread_id:
2791
+ record = await self._server_thread_record(client, req.thread_id)
2792
+ if record is not None:
2793
+ # chat.send is a write: the owner only (403 before message.start).
2794
+ assert_owner(principal, record)
2795
+ return record.thread_id
2796
+ try:
2797
+ # `do_nothing` returns the existing thread when another request
2798
+ # created it in between: its metadata then decides, not ours.
2799
+ thread = await client.threads.create(
2800
+ thread_id=req.thread_id, metadata=metadata, if_exists="do_nothing"
2801
+ )
2802
+ except Exception as exc: # loopback not configured, server down, ...
2803
+ raise unavailable("LangGraph Server", exc) from exc
2804
+ record = self._record_of(thread, req.thread_id or "")
2805
+ assert_owner(principal, record)
2806
+ return record.thread_id
2807
+
2808
+ async def _server_events(
2809
+ self,
2810
+ principal: Principal,
2811
+ req: ChatRequest,
2812
+ thread_id: str,
2813
+ run_id: str,
2814
+ state: _RunState,
2815
+ resume: Resume | None = None,
2816
+ ) -> AsyncIterator[tuple[str, Any]]:
2817
+ if resume is None:
2818
+ # As under fastapi: answer tool calls an interrupted run left open first.
2819
+ await self._repair_before_run(req, thread_id)
2820
+ client = self._sdk_client(req.forward_headers)
2821
+ # A resume continues the paused run through the server's native resume
2822
+ # (the server's auth handler refuses a `command` from outside the app).
2823
+ run_input: dict[str, Any] = (
2824
+ {"command": {"resume": resume.values}}
2825
+ if resume is not None
2826
+ else {"input": {"messages": [{"role": "user", "content": req.message}]}}
2827
+ )
2828
+ metadata = {
2829
+ **trace_metadata(thread_id, run_id, principal, req.metadata),
2830
+ # The server merges the thread's metadata, whose `principal_id` is
2831
+ # the raw owner id (the ownership check needs it there), into the
2832
+ # run's metadata, and from there into the traced config metadata
2833
+ # and each checkpoint's metadata. This key overrides it there with
2834
+ # the hashed id; the thread itself keeps the raw one.
2835
+ "principal_id": principal.hashed_id(),
2836
+ }
2837
+ async for part in client.runs.stream(
2838
+ thread_id,
2839
+ GRAPH_ID,
2840
+ **run_input,
2841
+ stream_mode=["messages-tuple", "updates"],
2842
+ metadata=metadata,
2843
+ config={"recursion_limit": recursion_limit()},
2844
+ # The server persists run context: never the principal's credentials.
2845
+ # The raw id stays here (tools act on the caller's behalf); run
2846
+ # context is not traced, and the metadata key above keeps it out of
2847
+ # checkpoint metadata too. The server's auth handler puts the same
2848
+ # context on a run it authorizes (LANGGRAPH_SERVER_URL, native runs).
2849
+ context=run_context_of(principal),
2850
+ multitask_strategy="reject",
2851
+ on_disconnect="cancel",
2852
+ ):
2853
+ event = str(getattr(part, "event", ""))
2854
+ data = getattr(part, "data", None)
2855
+ if event == "metadata" and isinstance(data, Mapping):
2856
+ state.server_run_id = str(data.get("run_id") or "") or None
2857
+ elif event.startswith("messages"):
2858
+ yield "messages", data
2859
+ elif event.startswith("updates"):
2860
+ yield "updates", data
2861
+ elif event == "error":
2862
+ raise _ServerRunError(data)
2863
+
2864
+ async def _cancel_server_run(self, req: ChatRequest, thread_id: str, state: _RunState) -> None:
2865
+ """Stop the server-side run behind a timed-out or abandoned stream (best effort)."""
2866
+ if not state.server_run_id:
2867
+ return
2868
+ try:
2869
+ client = self._sdk_client(req.forward_headers)
2870
+ async with asyncio.timeout(10):
2871
+ await client.runs.cancel(thread_id, state.server_run_id, wait=True)
2872
+ except Exception as exc:
2873
+ if http_status(exc) not in (404, 409):
2874
+ logger.warning("could not cancel the server run: %s", type(exc).__name__)
2875
+
2876
+ async def _server_messages(
2877
+ self, principal: Principal, thread_id: str, forward_headers: Mapping[str, str]
2878
+ ) -> list[dict[str, Any]]:
2879
+ client = self._sdk_client(forward_headers)
2880
+ record = await self._server_thread_record(client, thread_id)
2881
+ if record is None:
2882
+ raise HTTPException(status_code=404, detail="Unknown thread.")
2883
+ assert_access(principal, record)
2884
+ # Tool arguments reach a non-owner only under TRACE_CAPTURE=full.
2885
+ include_args = is_owner(principal, record) or capture_full()
2886
+ try:
2887
+ state = await client.threads.get_state(thread_id)
2888
+ except Exception as exc:
2889
+ if http_status(exc) == 404:
2890
+ raise HTTPException(status_code=404, detail="Unknown thread.") from exc
2891
+ raise unavailable("LangGraph Server", exc) from exc
2892
+ values = state.get("values") or {}
2893
+ return [
2894
+ serialize_message(m, include_tool_args=include_args) for m in values.get("messages", [])
2895
+ ]
2896
+
2897
+ async def _server_list_threads(
2898
+ self,
2899
+ principal: Principal,
2900
+ limit: int,
2901
+ offset: int,
2902
+ forward_headers: Mapping[str, str],
2903
+ ) -> list[dict[str, Any]]:
2904
+ client = self._sdk_client(forward_headers)
2905
+ filters: dict[str, Any] = {}
2906
+ if not reads_across(principal):
2907
+ filters["metadata"] = owner_metadata_filter(principal)
2908
+ try:
2909
+ threads = await client.threads.search(
2910
+ limit=limit, offset=offset, sort_by="updated_at", sort_order="desc", **filters
2911
+ )
2912
+ except Exception as exc:
2913
+ raise unavailable("LangGraph Server", exc) from exc
2914
+ out = []
2915
+ for thread in threads:
2916
+ if not isinstance(thread, Mapping):
2917
+ continue
2918
+ out.append(
2919
+ {
2920
+ "thread_id": str(thread.get("thread_id")),
2921
+ "created_at": _iso(thread.get("created_at")),
2922
+ "updated_at": _iso(thread.get("updated_at")),
2923
+ }
2924
+ )
2925
+ return out
2926
+
2927
+ async def _server_idle_threads(self, cutoff: datetime, batch: int) -> list[str]:
2928
+ client = self._sdk_client({})
2929
+ threads = await client.threads.search(
2930
+ limit=batch, offset=0, sort_by="updated_at", sort_order="asc"
2931
+ )
2932
+ idle: list[str] = []
2933
+ for thread in threads:
2934
+ updated = _parse_time(thread.get("updated_at")) if isinstance(thread, Mapping) else None
2935
+ if updated is None or updated >= cutoff:
2936
+ break
2937
+ if thread.get("status") == "busy":
2938
+ continue
2939
+ idle.append(str(thread.get("thread_id")))
2940
+ return idle
2941
+
2942
+
2943
+ def _iso(value: Any) -> str | None:
2944
+ if value is None:
2945
+ return None
2946
+ return value.isoformat() if isinstance(value, datetime) else str(value)
2947
+
2948
+
2949
+ def _parse_time(value: Any) -> datetime | None:
2950
+ if isinstance(value, datetime):
2951
+ parsed = value
2952
+ elif isinstance(value, str):
2953
+ with contextlib.suppress(ValueError):
2954
+ parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
2955
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
2956
+ return None
2957
+ else:
2958
+ return None
2959
+ return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
2960
+
2961
+
2962
+ RUNTIME = ChatRuntime()