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,989 @@
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 langgraph-server branch of ChatRuntime against a fake SDK client (no server needed).
16
+
17
+ The loopback SDK client runs under the server's `/noauth` root path, so the
18
+ server's own auth filters never see the custom routes' calls: ownership has to
19
+ be enforced by the app from the thread metadata it wrote at creation.
20
+
21
+ The fake raises the SDK's real error types (`NotFoundError`, `ConflictError`),
22
+ whose messages do not contain the status code: the runtime must go by the
23
+ status, not the text.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import contextlib
30
+ import sys
31
+ from collections.abc import AsyncIterator
32
+ from types import SimpleNamespace
33
+ from typing import Any
34
+
35
+ import httpx
36
+ import pytest
37
+ from fastapi import HTTPException
38
+
39
+ errors = pytest.importorskip("langgraph_sdk.errors")
40
+
41
+ from {{cookiecutter.agent_directory}}.app_utils import chat as chat_module # noqa: E402
42
+ from {{cookiecutter.agent_directory}}.app_utils.auth import Actor, Principal # noqa: E402
43
+ from {{cookiecutter.agent_directory}}.app_utils.chat import ( # noqa: E402
44
+ LANGGRAPH_SERVER,
45
+ ChatRequest,
46
+ ChatRuntime,
47
+ )
48
+ from {{cookiecutter.agent_directory}}.app_utils.content import INVALID_TOOL_CALL_RESULT # noqa: E402
49
+ from {{cookiecutter.agent_directory}}.app_utils.db import Database, RunRecord, RunStore # noqa: E402
50
+ from {{cookiecutter.agent_directory}}.app_utils.threads import ThreadLocks, ThreadStore # noqa: E402
51
+
52
+ OWNER = Principal(
53
+ id="A",
54
+ roles=["viewer"],
55
+ attributes={"tenant": "t1", "credentials": {"example": "secret-token-A"}},
56
+ )
57
+ STRANGER = Principal(id="B", roles=["viewer"])
58
+ AUDITOR = Principal(id="C", roles=["auditor"])
59
+ THREAD = "11111111-1111-1111-1111-111111111111"
60
+ OTHER = "44444444-4444-4444-4444-444444444444"
61
+
62
+
63
+ def sdk_error(cls: type[Exception], status: int, message: str) -> Exception:
64
+ """An SDK error exactly as langgraph_sdk raises it (the text has no status code)."""
65
+ request = httpx.Request("GET", "http://loopback/threads/x")
66
+ response = httpx.Response(status, request=request, json={"detail": message})
67
+ return cls(message, response=response, body={"detail": message})
68
+
69
+
70
+ def stored_messages(dumps: list[dict[str, Any]]) -> list[Any]:
71
+ """Messages as the server stores them, from their JSON form (every field kept)."""
72
+ from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
73
+
74
+ classes = {"human": HumanMessage, "ai": AIMessage, "tool": ToolMessage, "system": SystemMessage}
75
+ return [classes[m["type"]].model_validate(m) for m in dumps]
76
+
77
+
78
+ class FakeSdk:
79
+ """`langgraph_sdk.get_client` stand-in: one thread owned by A, recording every call."""
80
+
81
+ def __init__(self) -> None:
82
+ self.headers: list[dict[str, str] | None] = []
83
+ self.created: list[dict[str, Any]] = []
84
+ self.streams: list[dict[str, Any]] = []
85
+ self.cancelled: list[tuple[str, str]] = []
86
+ self.deleted: list[str] = []
87
+ self.searches: list[dict[str, Any]] = []
88
+ self.state_updates: list[dict[str, Any]] = []
89
+ self.slow_stream = False
90
+ self.stream_error: dict[str, Any] | None = None
91
+ # Seconds the thread stays busy after the stream's error part (the real
92
+ # server sends the error before it marks the run done); None: idle at once.
93
+ self.busy_after_error_s: float | None = None
94
+ # The message types of the thread when each run stream started.
95
+ self.history_at_stream: list[list[Any]] = []
96
+ self.threads_by_id: dict[str, dict[str, Any]] = {
97
+ THREAD: {
98
+ "thread_id": THREAD,
99
+ "metadata": {"principal_id": "A", "tenant": None},
100
+ "created_at": "2026-01-01T00:00:00+00:00",
101
+ "updated_at": "2026-01-02T00:00:00+00:00",
102
+ "status": "idle",
103
+ }
104
+ }
105
+ self.messages = [
106
+ {"type": "human", "id": "m1", "content": "hello"},
107
+ {
108
+ "type": "ai",
109
+ "id": "m2",
110
+ "content": "",
111
+ "tool_calls": [{"id": "c1", "name": "probe", "args": {"query": "SF"}}],
112
+ },
113
+ {
114
+ "type": "tool",
115
+ "id": "m3",
116
+ "tool_call_id": "c1",
117
+ "name": "probe",
118
+ "content": "sunny",
119
+ },
120
+ {"type": "ai", "id": "m4", "content": "It is sunny."},
121
+ ]
122
+
123
+ def apply(self, values: Any) -> None:
124
+ """Apply a state update as LangGraph Server does.
125
+
126
+ The server turns each message dict of the update into a message with
127
+ langchain's coercion (a dict it cannot read is a 400; a key it does not
128
+ know, such as `invalid_tool_calls`, lands in `additional_kwargs`) and
129
+ merges them into the stored messages, which keep every field, with
130
+ `add_messages` (a remove-all marker replaces the history).
131
+ """
132
+ from langchain_core.messages import convert_to_messages
133
+ from langgraph.graph.message import add_messages
134
+
135
+ try:
136
+ update = convert_to_messages((values or {}).get("messages", []))
137
+ except ValueError as exc:
138
+ raise sdk_error(errors.BadRequestError, 400, str(exc)) from exc
139
+ merged = add_messages(stored_messages(self.messages), update)
140
+ self.messages = [m.model_dump(exclude_none=True) for m in merged]
141
+
142
+ def get_client(
143
+ self, *, url: str | None = None, headers: dict[str, str] | None = None, **_: Any
144
+ ):
145
+ self.headers.append(headers)
146
+ sdk = self
147
+
148
+ class _Threads:
149
+ async def get(self, thread_id: str, **_: Any) -> dict[str, Any]:
150
+ if thread_id not in sdk.threads_by_id:
151
+ raise sdk_error(
152
+ errors.NotFoundError, 404, f"Thread with ID {thread_id} not found"
153
+ )
154
+ return sdk.threads_by_id[thread_id]
155
+
156
+ async def create(
157
+ self,
158
+ *,
159
+ thread_id: str | None = None,
160
+ metadata: Any = None,
161
+ if_exists: str | None = None,
162
+ **_: Any,
163
+ ):
164
+ thread_id = thread_id or "22222222-2222-2222-2222-222222222222"
165
+ if thread_id in sdk.threads_by_id:
166
+ if if_exists != "do_nothing":
167
+ raise sdk_error(errors.ConflictError, 409, "Thread already exists")
168
+ return sdk.threads_by_id[thread_id]
169
+ record = {"thread_id": thread_id, "metadata": dict(metadata or {})}
170
+ sdk.threads_by_id[thread_id] = record
171
+ sdk.created.append(record)
172
+ return record
173
+
174
+ async def get_state(self, thread_id: str, **_: Any) -> dict[str, Any]:
175
+ if thread_id not in sdk.threads_by_id:
176
+ raise sdk_error(errors.NotFoundError, 404, "Thread not found")
177
+ return {"values": {"messages": sdk.messages}}
178
+
179
+ async def search(self, **kwargs: Any) -> list[dict[str, Any]]:
180
+ sdk.searches.append(kwargs)
181
+ wanted = (kwargs.get("metadata") or {}).get("principal_id")
182
+ found = [
183
+ t
184
+ for t in sdk.threads_by_id.values()
185
+ if wanted is None or (t.get("metadata") or {}).get("principal_id") == wanted
186
+ ]
187
+ found.sort(
188
+ key=lambda t: t.get("updated_at") or "",
189
+ reverse=kwargs.get("sort_order") == "desc",
190
+ )
191
+ return found
192
+
193
+ async def delete(self, thread_id: str, **_: Any) -> None:
194
+ sdk.deleted.append(thread_id)
195
+ sdk.threads_by_id.pop(thread_id, None)
196
+
197
+ async def update_state(self, thread_id: str, values: Any, **kwargs: Any) -> None:
198
+ sdk.state_updates.append({"thread_id": thread_id, "values": values, **kwargs})
199
+ sdk.apply(values)
200
+
201
+ class _Runs:
202
+ async def stream(
203
+ self, thread_id: str, assistant_id: str, **kwargs: Any
204
+ ) -> AsyncIterator[Any]:
205
+ sdk.streams.append({"thread_id": thread_id, "assistant_id": assistant_id, **kwargs})
206
+ sdk.history_at_stream.append([m.get("type") for m in sdk.messages])
207
+ yield SimpleNamespace(event="metadata", data={"run_id": "srv-run-1", "attempt": 1})
208
+ if sdk.stream_error is not None:
209
+ if sdk.busy_after_error_s is not None:
210
+ thread = sdk.threads_by_id[thread_id]
211
+ thread["status"] = "busy"
212
+ asyncio.get_running_loop().call_later(
213
+ sdk.busy_after_error_s, thread.__setitem__, "status", "idle"
214
+ )
215
+ yield SimpleNamespace(event="error", data=sdk.stream_error)
216
+ return
217
+ if sdk.slow_stream:
218
+ await asyncio.sleep(30)
219
+ yield SimpleNamespace(
220
+ event="updates",
221
+ data={
222
+ "agent": {
223
+ "messages": [
224
+ {
225
+ "type": "ai",
226
+ "id": "ai-1",
227
+ "content": "",
228
+ "tool_calls": [
229
+ {"id": "c1", "name": "probe", "args": {"query": "SF"}}
230
+ ],
231
+ "usage_metadata": {"input_tokens": 3, "output_tokens": 4},
232
+ }
233
+ ]
234
+ }
235
+ },
236
+ )
237
+ yield SimpleNamespace(
238
+ event="updates",
239
+ data={
240
+ "tools": {
241
+ "messages": [
242
+ {
243
+ "type": "tool",
244
+ "tool_call_id": "c1",
245
+ "name": "probe",
246
+ "content": "sunny",
247
+ }
248
+ ]
249
+ }
250
+ },
251
+ )
252
+ yield SimpleNamespace(
253
+ event="messages",
254
+ data=[
255
+ {"type": "AIMessageChunk", "id": "ai-2", "content": "It is "},
256
+ {"langgraph_node": "agent"},
257
+ ],
258
+ )
259
+ yield SimpleNamespace(
260
+ event="messages",
261
+ data=[
262
+ {"type": "AIMessageChunk", "id": "ai-2", "content": "sunny."},
263
+ {"langgraph_node": "agent"},
264
+ ],
265
+ )
266
+
267
+ async def cancel(self, thread_id: str, run_id: str, **_: Any) -> None:
268
+ sdk.cancelled.append((thread_id, run_id))
269
+
270
+ class _Assistants:
271
+ async def search(self, **_: Any) -> list[dict[str, Any]]:
272
+ return [{"assistant_id": "agent"}]
273
+
274
+ return SimpleNamespace(threads=_Threads(), runs=_Runs(), assistants=_Assistants())
275
+
276
+
277
+ @pytest.fixture
278
+ def server(monkeypatch: pytest.MonkeyPatch) -> tuple[ChatRuntime, FakeSdk]:
279
+ sdk = FakeSdk()
280
+ monkeypatch.setitem(sys.modules, "langgraph_sdk", SimpleNamespace(get_client=sdk.get_client))
281
+ monkeypatch.setenv("RUNTIME", "langgraph-server")
282
+ for name in ("AUTH_READ_ACROSS_ROLES", "TRACE_CAPTURE", "AUTH_FORWARD_HEADERS", "APP_ENV"):
283
+ monkeypatch.delenv(name, raising=False)
284
+ rt = ChatRuntime()
285
+ assert rt.runtime == LANGGRAPH_SERVER
286
+ # What start() sets up, without a server: in-memory run records and locks.
287
+ rt.db = Database("memory", runs_table="agent_runs", with_threads=False)
288
+ rt.runs = RunStore(rt.db)
289
+ rt.threads = ThreadStore(rt.db)
290
+ rt.locks = ThreadLocks()
291
+ rt.started = True
292
+ return rt, sdk
293
+
294
+
295
+ async def _events(rt: ChatRuntime, principal: Principal, req: ChatRequest, thread_id: str):
296
+ return [(e, d) async for e, d in rt.stream(principal, req, thread_id)]
297
+
298
+
299
+ async def test_owner_continues_its_thread_and_the_stream_maps_the_contract_events(server) -> None:
300
+ rt, sdk = server
301
+ req = ChatRequest(
302
+ message="probe?",
303
+ thread_id=THREAD,
304
+ metadata={"source": "web"},
305
+ forward_headers={
306
+ "authorization": "Bearer k",
307
+ "cookie": "sid=1",
308
+ "x-session-token": "not-forwarded-by-default",
309
+ "x-request-id": "ignored",
310
+ },
311
+ )
312
+ assert await rt.resolve_thread(OWNER, req) == THREAD
313
+ events = await _events(rt, OWNER, req, THREAD)
314
+ names = [e for e, _ in events]
315
+ assert names == [
316
+ "message.start",
317
+ "tool.call",
318
+ "tool.result",
319
+ "message.delta",
320
+ "message.delta",
321
+ "message.end",
322
+ ]
323
+ assert events[1][1] == {"id": "c1", "name": "probe", "args": {"query": "SF"}}
324
+ end = events[-1][1]
325
+ assert end["usage"] == {"input_tokens": 3, "output_tokens": 4} and end["status"] == "ok"
326
+ assert end["thread_id"] == THREAD and end["run_id"] == events[0][1]["run_id"]
327
+ (stream,) = sdk.streams
328
+ assert stream["assistant_id"] == "agent" and stream["stream_mode"] == [
329
+ "messages-tuple",
330
+ "updates",
331
+ ]
332
+ assert stream["multitask_strategy"] == "reject" and stream["on_disconnect"] == "cancel"
333
+ assert stream["config"] == {"recursion_limit": 50}
334
+ # The server persists run context and metadata: no credentials, no raw
335
+ # principal id in metadata, no client metadata under metadata capture.
336
+ assert stream["context"] == {
337
+ "principal_id": "A",
338
+ "roles": ["viewer"],
339
+ "attributes": {"tenant": "t1"},
340
+ }
341
+ assert "secret-token-A" not in repr(stream)
342
+ # `principal_id` overrides the raw owner id the server merges in from the
343
+ # thread metadata (and copies into traces and checkpoint metadata).
344
+ assert stream["metadata"] == {
345
+ "thread_id": THREAD,
346
+ "run_id": end["run_id"],
347
+ "principal_hash": OWNER.hashed_id(),
348
+ "principal_id": OWNER.hashed_id(),
349
+ }
350
+ # Only the configured credential headers reach the SDK client.
351
+ assert sdk.headers[0] == {"authorization": "Bearer k", "cookie": "sid=1"}
352
+ record = await rt.runs.get(end["run_id"])
353
+ assert record is not None and record.status == "ok" and record.metadata == {"source": "web"}
354
+
355
+
356
+ async def test_client_metadata_reaches_traces_only_under_full_capture(server, monkeypatch) -> None:
357
+ rt, sdk = server
358
+ monkeypatch.setenv("TRACE_CAPTURE", "full")
359
+ req = ChatRequest(message="x", thread_id=THREAD, metadata={"run_id": "SPOOFED"})
360
+ await _events(rt, OWNER, req, THREAD)
361
+ meta = sdk.streams[-1]["metadata"]
362
+ assert meta["run_id"] != "SPOOFED" and meta["client_metadata"] == {"run_id": "SPOOFED"}
363
+
364
+
365
+ async def test_forwarded_headers_follow_auth_forward_headers(server, monkeypatch) -> None:
366
+ rt, sdk = server
367
+ headers = {"authorization": "Bearer k", "x-api-key": "k2", "cookie": "c"}
368
+ monkeypatch.setenv("AUTH_FORWARD_HEADERS", "X-Api-Key")
369
+ await rt.messages(OWNER, THREAD, headers)
370
+ assert sdk.headers[-1] == {"x-api-key": "k2"}
371
+ monkeypatch.setenv("AUTH_FORWARD_HEADERS", "")
372
+ await rt.messages(OWNER, THREAD, headers)
373
+ assert sdk.headers[-1] is None
374
+
375
+
376
+ async def test_stranger_cannot_continue_or_read_another_principals_thread(server) -> None:
377
+ rt, sdk = server
378
+ with pytest.raises(HTTPException) as exc:
379
+ await rt.resolve_thread(STRANGER, ChatRequest(message="x", thread_id=THREAD))
380
+ assert exc.value.status_code == 403
381
+ assert sdk.streams == [] and sdk.created == []
382
+ with pytest.raises(HTTPException) as exc:
383
+ await rt.messages(STRANGER, THREAD)
384
+ assert exc.value.status_code == 403
385
+
386
+
387
+ async def test_read_across_role_reads_without_tool_args_and_cannot_write(
388
+ server, monkeypatch
389
+ ) -> None:
390
+ rt, _sdk = server
391
+ monkeypatch.setenv("AUTH_READ_ACROSS_ROLES", "auditor")
392
+ messages = await rt.messages(AUDITOR, THREAD)
393
+ assert [m["role"] for m in messages] == ["user", "assistant", "tool", "assistant"]
394
+ assert messages[1]["tool_calls"] == [{"id": "c1", "name": "probe"}] # args omitted
395
+ monkeypatch.setenv("TRACE_CAPTURE", "full")
396
+ messages = await rt.messages(AUDITOR, THREAD)
397
+ assert messages[1]["tool_calls"][0]["args"] == {"query": "SF"}
398
+ with pytest.raises(HTTPException) as exc:
399
+ await rt.resolve_thread(AUDITOR, ChatRequest(message="x", thread_id=THREAD))
400
+ assert exc.value.status_code == 403
401
+
402
+
403
+ async def test_owner_reads_tool_args_under_metadata_capture(server) -> None:
404
+ rt, sdk = server
405
+ messages = await rt.messages(OWNER, THREAD, {"authorization": "Bearer k"})
406
+ assert messages[1]["tool_calls"][0]["args"] == {"query": "SF"}
407
+ assert messages[2]["tool_call_id"] == "c1" and messages[2]["is_error"] is False
408
+ assert sdk.headers[-1] == {"authorization": "Bearer k"}
409
+
410
+
411
+ async def test_unknown_thread_is_created_for_the_caller_and_404_on_read(server) -> None:
412
+ """The SDK's NotFoundError (no '404' in its text) means 'absent', never 503."""
413
+ rt, sdk = server
414
+ new_id = "33333333-3333-3333-3333-333333333333"
415
+ with pytest.raises(HTTPException) as exc:
416
+ await rt.messages(STRANGER, new_id)
417
+ assert exc.value.status_code == 404
418
+ assert await rt.resolve_thread(STRANGER, ChatRequest(message="x", thread_id=new_id)) == new_id
419
+ assert sdk.created == [{"thread_id": new_id, "metadata": {"principal_id": "B", "tenant": None}}]
420
+ # A thread the server holds without ownership metadata fails closed.
421
+ bare = "55555555-5555-5555-5555-555555555555"
422
+ sdk.threads_by_id[bare] = {"thread_id": bare, "metadata": {}}
423
+ with pytest.raises(HTTPException) as exc:
424
+ await rt.resolve_thread(OWNER, ChatRequest(message="x", thread_id=bare))
425
+ assert exc.value.status_code == 403
426
+
427
+
428
+ async def test_a_thread_created_in_between_by_someone_else_is_refused(server) -> None:
429
+ """get() says absent, but another request creates the id first: its owner wins (403)."""
430
+ rt, sdk = server
431
+ racing = "66666666-6666-6666-6666-666666666666"
432
+ original_get_client = sdk.get_client
433
+
434
+ def get_client(**kwargs: Any):
435
+ client = original_get_client(**kwargs)
436
+ real_get = client.threads.get
437
+
438
+ async def get_then_race(thread_id: str, **kw: Any):
439
+ try:
440
+ return await real_get(thread_id, **kw)
441
+ finally:
442
+ sdk.threads_by_id.setdefault(
443
+ thread_id, {"thread_id": thread_id, "metadata": {"principal_id": "B"}}
444
+ )
445
+
446
+ client.threads.get = get_then_race
447
+ return client
448
+
449
+ sys.modules["langgraph_sdk"].get_client = get_client
450
+ with pytest.raises(HTTPException) as exc:
451
+ await rt.resolve_thread(OWNER, ChatRequest(message="x", thread_id=racing))
452
+ assert exc.value.status_code == 403
453
+
454
+
455
+ @pytest.mark.parametrize("thread_id", ["t-demo-1", "not a uuid", "x" * 200])
456
+ async def test_thread_ids_must_be_uuids_under_the_server_runtime(server, thread_id: str) -> None:
457
+ rt, sdk = server
458
+ for call in (
459
+ rt.resolve_thread(OWNER, ChatRequest(message="x", thread_id=thread_id)),
460
+ rt.messages(OWNER, thread_id),
461
+ rt.delete_thread(OWNER, thread_id),
462
+ ):
463
+ with pytest.raises(HTTPException) as exc:
464
+ await call
465
+ assert exc.value.status_code == 422
466
+ assert sdk.created == [] and sdk.deleted == []
467
+
468
+
469
+ async def test_uuid_spellings_name_one_thread(server) -> None:
470
+ """Canonical ids: the run lock and ownership cannot be split by upper/lower case."""
471
+ rt, sdk = server
472
+ upper = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"
473
+ resolved = await rt.resolve_thread(OWNER, ChatRequest(message="x", thread_id=upper))
474
+ assert resolved == upper.lower() and sdk.created[-1]["thread_id"] == upper.lower()
475
+ lease = await rt.acquire_thread(resolved)
476
+ again = await rt.resolve_thread(OWNER, ChatRequest(message="x", thread_id=upper))
477
+ events = await _events(rt, OWNER, ChatRequest(message="x"), again)
478
+ assert events[-1][1]["code"] == "thread_busy"
479
+ await lease.release()
480
+
481
+
482
+ async def test_server_failure_is_a_generic_503_not_403(server, monkeypatch) -> None:
483
+ rt, _sdk = server
484
+
485
+ def broken(**_: Any):
486
+ class _Threads:
487
+ async def get(self, thread_id: str, **_: Any):
488
+ raise httpx.ConnectError("loopback down at 10.0.0.7:8123")
489
+
490
+ return SimpleNamespace(threads=_Threads(), runs=None)
491
+
492
+ monkeypatch.setitem(sys.modules, "langgraph_sdk", SimpleNamespace(get_client=broken))
493
+ with pytest.raises(HTTPException) as exc:
494
+ await rt.resolve_thread(OWNER, ChatRequest(message="x", thread_id=THREAD))
495
+ assert exc.value.status_code == 503
496
+ assert "10.0.0.7" not in exc.value.detail and "Reference:" in exc.value.detail
497
+
498
+
499
+ async def test_stream_error_part_becomes_a_generic_error_event(server, monkeypatch) -> None:
500
+ rt, _sdk = server
501
+
502
+ def failing(**_: Any):
503
+ class _Runs:
504
+ async def stream(self, *a: Any, **k: Any) -> AsyncIterator[Any]:
505
+ yield SimpleNamespace(
506
+ event="error", data={"error": "ValueError", "message": "db at 10.0.0.7 down"}
507
+ )
508
+
509
+ return SimpleNamespace(threads=None, runs=_Runs())
510
+
511
+ monkeypatch.setitem(sys.modules, "langgraph_sdk", SimpleNamespace(get_client=failing))
512
+ events = await _events(rt, OWNER, ChatRequest(message="x"), THREAD)
513
+ assert [e for e, _ in events] == ["message.start", "error"]
514
+ error = events[1][1]
515
+ assert error["code"] == "run_failed" and error["error_id"] in error["message"]
516
+ assert "10.0.0.7" not in repr(error) and "detail" not in error
517
+ record = await rt.runs.get(events[0][1]["run_id"])
518
+ assert record is not None and record.status == "error"
519
+
520
+
521
+ async def test_recursion_limit_error_part_maps_to_its_code(server, monkeypatch) -> None:
522
+ rt, _sdk = server
523
+
524
+ def failing(**_: Any):
525
+ class _Runs:
526
+ async def stream(self, *a: Any, **k: Any) -> AsyncIterator[Any]:
527
+ yield SimpleNamespace(
528
+ event="error", data={"error": "GraphRecursionError", "message": "..."}
529
+ )
530
+
531
+ return SimpleNamespace(threads=None, runs=_Runs())
532
+
533
+ monkeypatch.setitem(sys.modules, "langgraph_sdk", SimpleNamespace(get_client=failing))
534
+ events = await _events(rt, OWNER, ChatRequest(message="x"), THREAD)
535
+ assert events[-1][1]["code"] == "recursion_limit"
536
+
537
+
538
+ async def test_server_conflict_is_reported_as_thread_busy(server, monkeypatch) -> None:
539
+ """A run started through the native API holds the thread: the server rejects ours."""
540
+ rt, _sdk = server
541
+
542
+ def busy(**_: Any):
543
+ class _Runs:
544
+ async def stream(self, *a: Any, **k: Any) -> AsyncIterator[Any]:
545
+ raise sdk_error(errors.ConflictError, 409, "Thread is busy")
546
+ yield # pragma: no cover
547
+
548
+ return SimpleNamespace(threads=None, runs=_Runs())
549
+
550
+ monkeypatch.setitem(sys.modules, "langgraph_sdk", SimpleNamespace(get_client=busy))
551
+ events = await _events(rt, OWNER, ChatRequest(message="x"), THREAD)
552
+ assert events[-1][0] == "error" and events[-1][1]["code"] == "thread_busy"
553
+
554
+
555
+ async def test_second_run_on_a_busy_thread_is_refused_before_the_server(server) -> None:
556
+ rt, sdk = server
557
+ lease = await rt.acquire_thread(THREAD)
558
+ events = await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
559
+ assert events == [
560
+ ("error", {"code": "thread_busy", "message": "This thread already has a run in progress."})
561
+ ]
562
+ assert sdk.streams == []
563
+ await lease.release()
564
+ events = await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
565
+ assert events[-1][0] == "message.end"
566
+
567
+
568
+ async def test_timeout_cancels_the_server_run(server, monkeypatch) -> None:
569
+ rt, sdk = server
570
+ sdk.slow_stream = True
571
+ monkeypatch.setenv("RUN_TIMEOUT_S", "0.2")
572
+ events = await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
573
+ assert events[-1][0] == "error" and events[-1][1]["code"] == "timeout"
574
+ assert sdk.cancelled == [(THREAD, "srv-run-1")]
575
+ record = await rt.runs.get(events[0][1]["run_id"])
576
+ assert record is not None and record.status == "timeout"
577
+ assert THREAD not in rt.locks.held
578
+ assert sdk.state_updates == [] # every tool call of the thread has its result
579
+
580
+
581
+ async def test_a_stopped_run_gets_its_open_tool_calls_answered(server, monkeypatch) -> None:
582
+ rt, sdk = server
583
+ sdk.slow_stream = True
584
+ sdk.messages = sdk.messages[:2] # the assistant's tool call, no result yet
585
+ monkeypatch.setenv("RUN_TIMEOUT_S", "0.2")
586
+ await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
587
+ # Answered before the run started, right after the call; nothing left for the end.
588
+ (update,) = sdk.state_updates
589
+ assert update["as_node"] == "tools"
590
+ (patch,) = update["values"]["messages"]
591
+ assert patch["tool_call_id"] == "c1" and patch["status"] == "error"
592
+ assert "did not finish" in patch["content"]
593
+ assert sdk.history_at_stream == [["human", "ai", "tool"]]
594
+
595
+
596
+ async def test_a_result_after_the_next_turn_is_moved_back_before_the_run(server) -> None:
597
+ """The shape older versions left on the server: every later turn got a provider 400."""
598
+ rt, sdk = server
599
+ human, call = sdk.messages[:2]
600
+ sdk.messages = [
601
+ human,
602
+ {
603
+ **call,
604
+ "usage_metadata": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
605
+ "invalid_tool_calls": [],
606
+ },
607
+ {"type": "human", "id": "m9", "content": "hello?"},
608
+ {"type": "tool", "id": "t1", "tool_call_id": "c1", "name": "get_weather", "content": "x"},
609
+ ]
610
+ events = await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
611
+ assert events[-1][0] == "message.end"
612
+ (update,) = sdk.state_updates
613
+ rewritten = update["values"]["messages"]
614
+ assert rewritten[0] == {"type": "remove", "id": "__remove_all__", "content": ""}
615
+ assert [m["id"] for m in rewritten[1:]] == ["m1", "m2", "t1", "m9"]
616
+ assert "usage_metadata" not in rewritten[2] # only keys the server reads back as-is
617
+ assert sdk.history_at_stream == [["human", "ai", "tool", "human"]]
618
+ assert [m["id"] for m in sdk.messages[:4]] == ["m1", "m2", "t1", "m9"]
619
+
620
+
621
+ async def test_a_healthy_thread_with_repeated_tool_call_ids_is_never_rewritten(server) -> None:
622
+ """Models reuse ids across turns (`call_0` in every message): each turn's result stays."""
623
+ rt, sdk = server
624
+ human, call, result, reply = sdk.messages
625
+ sdk.messages = [
626
+ human,
627
+ call,
628
+ result,
629
+ reply,
630
+ {"type": "human", "id": "m5", "content": "and in Paris?"},
631
+ {**call, "id": "m6"},
632
+ {**result, "id": "m7", "content": "rainy"},
633
+ {"type": "ai", "id": "m8", "content": "It is rainy."},
634
+ ]
635
+ events = await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
636
+ assert events[-1][0] == "message.end"
637
+ assert sdk.state_updates == []
638
+
639
+
640
+ def _invalid_call(message_id: str, call_id: str) -> dict[str, Any]:
641
+ """An assistant message whose tool call has arguments that are not valid JSON, as stored."""
642
+ return {
643
+ "type": "ai",
644
+ "id": message_id,
645
+ "content": "",
646
+ "tool_calls": [],
647
+ "invalid_tool_calls": [
648
+ {
649
+ "type": "invalid_tool_call",
650
+ "id": call_id,
651
+ "name": "probe",
652
+ "args": "{'query': 'SF'}",
653
+ "error": "not valid JSON",
654
+ }
655
+ ],
656
+ }
657
+
658
+
659
+ def _refusals(openai_compatible: Any, messages: list[dict[str, Any]]) -> list[str]:
660
+ """What an OpenAI-compatible provider refuses of the thread's history, as it is stored."""
661
+ with contextlib.suppress(Exception): # the refusal is recorded; the error is expected
662
+ openai_compatible.model().invoke([*stored_messages(messages), ("user", "next")])
663
+ return openai_compatible.refusals
664
+
665
+
666
+ async def test_an_open_call_with_invalid_arguments_is_answered_before_the_run(
667
+ server, openai_compatible
668
+ ) -> None:
669
+ """LangChain sends an invalid call back to the provider as a call: it needs a result."""
670
+ rt, sdk = server
671
+ human = sdk.messages[0]
672
+ sdk.messages = [human, _invalid_call("m2", "call_0")]
673
+ assert _refusals(openai_compatible, sdk.messages) != [] # the broken thread
674
+ openai_compatible.refusals.clear()
675
+ events = await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
676
+ assert events[-1][0] == "message.end"
677
+ (update,) = sdk.state_updates
678
+ (patch,) = update["values"]["messages"] # appended right after the call
679
+ assert patch["tool_call_id"] == "call_0" and patch["content"] == INVALID_TOOL_CALL_RESULT
680
+ assert sdk.history_at_stream == [["human", "ai", "tool"]]
681
+ assert _refusals(openai_compatible, sdk.messages) == []
682
+
683
+
684
+ async def test_a_rewritten_history_keeps_an_invalid_call_answered(
685
+ server, openai_compatible
686
+ ) -> None:
687
+ """A rewrite rebuilds every message from a dict: the invalid call must stay a call."""
688
+ rt, sdk = server
689
+ human, call = sdk.messages[:2]
690
+ sdk.messages = [
691
+ human,
692
+ _invalid_call("m2", "call_0"),
693
+ {"type": "human", "id": "m3", "content": "hello?"},
694
+ call, # c1, answered only after the next turn: the history is rewritten
695
+ {"type": "human", "id": "m5", "content": "still there?"},
696
+ {"type": "tool", "id": "t1", "tool_call_id": "c1", "name": "probe", "content": "x"},
697
+ ]
698
+ events = await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
699
+ assert events[-1][0] == "message.end"
700
+ (update,) = sdk.state_updates
701
+ rewritten = update["values"]["messages"]
702
+ assert rewritten[0]["type"] == "remove"
703
+ assert rewritten[2]["tool_calls"] == [
704
+ {"name": "probe", "args": {}, "id": "call_0", "type": "tool_call"}
705
+ ]
706
+ assert rewritten[3]["tool_call_id"] == "call_0"
707
+ assert rewritten[3]["content"] == INVALID_TOOL_CALL_RESULT
708
+ assert [m["id"] for m in rewritten[1:]][:3] == ["m1", "m2", rewritten[3]["id"]]
709
+ assert _refusals(openai_compatible, sdk.messages) == []
710
+
711
+
712
+ async def test_one_id_for_two_calls_of_a_message_is_never_rewritten(server) -> None:
713
+ rt, sdk = server
714
+ human, call, result, reply = sdk.messages
715
+ two = {**call, "tool_calls": [call["tool_calls"][0], {**call["tool_calls"][0]}]}
716
+ sdk.messages = [human, two, result, {**result, "id": "m3b", "content": "rainy"}, reply]
717
+ events = await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
718
+ assert events[-1][0] == "message.end"
719
+ assert sdk.state_updates == []
720
+
721
+
722
+ async def test_a_thread_busy_with_a_native_run_is_left_alone(server) -> None:
723
+ """Its open tool call belongs to the run in progress: never answer it for that run."""
724
+ rt, sdk = server
725
+ sdk.messages = sdk.messages[:2]
726
+ sdk.threads_by_id[THREAD]["status"] = "busy"
727
+ await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
728
+ assert sdk.state_updates == []
729
+
730
+
731
+ async def test_the_step_limit_ends_a_server_run_with_a_reply(server) -> None:
732
+ rt, sdk = server
733
+ sdk.stream_error = {"error": "GraphRecursionError", "message": "..."}
734
+ events = await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
735
+ assert [e for e, _ in events] == ["message.start", "message.delta", "message.end"]
736
+ assert events[-1][1]["status"] == "step_limit"
737
+ (update,) = sdk.state_updates
738
+ assert update["as_node"] == "model"
739
+ assert update["values"]["messages"][-1]["content"] == events[1][1]["text"]
740
+ record = await rt.runs.get(events[0][1]["run_id"])
741
+ assert record is not None and record.status == "step_limit"
742
+
743
+
744
+ async def test_the_step_limit_reply_waits_for_the_server_to_mark_the_run_done(server) -> None:
745
+ """The server streams the recursion error before the thread stops being busy."""
746
+ rt, sdk = server
747
+ sdk.stream_error = {"error": "GraphRecursionError", "message": "..."}
748
+ sdk.busy_after_error_s = 0.3
749
+ events = await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
750
+ assert events[-1][0] == "message.end" and events[-1][1]["status"] == "step_limit"
751
+ assert sdk.state_updates and sdk.state_updates[-1]["as_node"] == "model"
752
+
753
+
754
+ async def test_a_thread_that_stays_busy_ends_the_run_with_the_recursion_error(
755
+ server, monkeypatch
756
+ ) -> None:
757
+ rt, sdk = server
758
+ monkeypatch.setattr(chat_module, "SERVER_IDLE_WAIT_S", 0.2)
759
+ sdk.stream_error = {"error": "GraphRecursionError", "message": "..."}
760
+ sdk.busy_after_error_s = 30
761
+ events = await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
762
+ assert events[-1][0] == "error" and events[-1][1]["code"] == "recursion_limit"
763
+ assert sdk.state_updates == [] # nothing written to a thread another run holds
764
+ record = await rt.runs.get(events[0][1]["run_id"])
765
+ assert record is not None and record.status == "error"
766
+
767
+
768
+ async def test_list_and_delete_threads_through_the_server(server, monkeypatch) -> None:
769
+ rt, sdk = server
770
+ await rt.resolve_thread(STRANGER, ChatRequest(message="x", thread_id=OTHER))
771
+ listed = await rt.list_threads(OWNER, limit=10, offset=0)
772
+ assert [t["thread_id"] for t in listed] == [THREAD]
773
+ assert sdk.searches[-1]["metadata"] == {"principal_id": "A"}
774
+ assert sdk.searches[-1]["sort_by"] == "updated_at"
775
+ monkeypatch.setenv("AUTH_READ_ACROSS_ROLES", "auditor")
776
+ listed = await rt.list_threads(AUDITOR, limit=10, offset=0)
777
+ assert {t["thread_id"] for t in listed} == {THREAD, OTHER}
778
+ with pytest.raises(HTTPException) as exc:
779
+ await rt.delete_thread(AUDITOR, THREAD) # read-across is read-only
780
+ assert exc.value.status_code == 403
781
+ await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
782
+ assert await rt.runs.list_for_thread(THREAD)
783
+ await rt.delete_thread(OWNER, THREAD)
784
+ assert sdk.deleted == [THREAD] and await rt.runs.list_for_thread(THREAD) == []
785
+ with pytest.raises(HTTPException) as exc:
786
+ await rt.delete_thread(OWNER, THREAD)
787
+ assert exc.value.status_code == 404
788
+
789
+
790
+ async def test_retention_purges_idle_server_threads(server) -> None:
791
+ rt, sdk = server
792
+ fresh = "77777777-7777-7777-7777-777777777777"
793
+ gone = "88888888-8888-8888-8888-888888888888" # deleted through the native API
794
+ sdk.threads_by_id[fresh] = {
795
+ "thread_id": fresh,
796
+ "metadata": {"principal_id": "A"},
797
+ "updated_at": "2999-01-01T00:00:00+00:00",
798
+ }
799
+ old = "2000-01-01T00:00:00+00:00"
800
+ for run_id, thread_id in (("r-fresh", fresh), ("r-gone", gone)):
801
+ await rt.runs.record(
802
+ RunRecord(
803
+ run_id=run_id,
804
+ thread_id=thread_id,
805
+ principal_hash="h",
806
+ model="m",
807
+ status="ok",
808
+ created_at=old,
809
+ )
810
+ )
811
+ assert await rt.purge_expired(30) == 1
812
+ assert sdk.deleted == [THREAD] and fresh in sdk.threads_by_id
813
+ assert await rt.runs.get("r-gone") is None and await rt.runs.get("r-fresh") is not None
814
+
815
+
816
+ async def test_run_metadata_hides_the_raw_owner_id_the_server_merges_in(
817
+ server, monkeypatch
818
+ ) -> None:
819
+ """What the server would store: thread metadata merged under the run's own metadata."""
820
+ rt, sdk = server
821
+ monkeypatch.setenv("PRINCIPAL_HASH_SALT", "pepper")
822
+ email = Principal(id="alice@example.com")
823
+ thread = "99999999-9999-9999-9999-999999999999"
824
+ await rt.resolve_thread(email, ChatRequest(message="x", thread_id=thread))
825
+ await _events(rt, email, ChatRequest(message="x", thread_id=thread), thread)
826
+ thread_metadata = sdk.threads_by_id[thread]["metadata"]
827
+ assert thread_metadata["principal_id"] == "alice@example.com" # the ownership stamp
828
+ merged = {**thread_metadata, **sdk.streams[-1]["metadata"]}
829
+ assert "alice@example.com" not in repr(merged)
830
+ assert merged["principal_id"] == email.hashed_id() == merged["principal_hash"]
831
+
832
+
833
+ async def test_retention_rechecks_idleness_under_the_lock_on_the_server(server) -> None:
834
+ """A server thread continued after the purge listed it is kept."""
835
+ rt, sdk = server
836
+ idle_threads = rt._server_idle_threads
837
+
838
+ async def list_then_resume(cutoff: Any, batch: int) -> list[str]:
839
+ candidates = await idle_threads(cutoff, batch)
840
+ assert THREAD in candidates
841
+ sdk.threads_by_id[THREAD]["updated_at"] = "2999-01-01T00:00:00+00:00"
842
+ return candidates
843
+
844
+ rt._server_idle_threads = list_then_resume # type: ignore[method-assign]
845
+ assert await rt.purge_expired(30) == 0
846
+ assert sdk.deleted == [] and THREAD in sdk.threads_by_id
847
+
848
+
849
+ async def test_orphaned_run_records_are_swept_page_by_page(server) -> None:
850
+ """Old run records of live threads never hide those of deleted ones on later pages."""
851
+ rt, sdk = server
852
+ old = "2000-01-01T00:00:00+00:00"
853
+ live = [f"00000000-0000-0000-0000-00000000000{i}" for i in range(5)]
854
+ for thread_id in live:
855
+ sdk.threads_by_id[thread_id] = {"thread_id": thread_id, "metadata": {"principal_id": "A"}}
856
+ gone = "ffffffff-ffff-ffff-ffff-ffffffffffff" # sorts after every live id
857
+ for n, thread_id in enumerate([*live, gone]):
858
+ await rt.runs.record(
859
+ RunRecord(
860
+ run_id=f"r{n}",
861
+ thread_id=thread_id,
862
+ principal_hash="h",
863
+ model="m",
864
+ status="ok",
865
+ created_at=old,
866
+ )
867
+ )
868
+ assert await rt.sweep_orphaned_runs(30, batch=2) == 1
869
+ assert await rt.runs.list_for_thread(gone) == []
870
+ assert all([await rt.runs.list_for_thread(t) for t in live])
871
+ assert await rt.sweep_orphaned_runs(0) == 0 # RETENTION_DAYS=0 keeps everything
872
+
873
+
874
+ async def test_the_native_thread_delete_drops_the_run_records(server) -> None:
875
+ """The server's own DELETE /threads/{id} (not the app's) removes the app's run records."""
876
+ rt, _sdk = server
877
+ from {{cookiecutter.agent_directory}}.app_utils.middleware import ThreadDeleteHookMiddleware
878
+
879
+ for run_id, thread_id in (("r1", THREAD), ("r2", OTHER)):
880
+ await rt.runs.record(
881
+ RunRecord(
882
+ run_id=run_id, thread_id=thread_id, principal_hash="h", model="m", status="ok"
883
+ )
884
+ )
885
+ answers = {THREAD: 204, OTHER: 404}
886
+
887
+ async def native_api(scope: Any, receive: Any, send: Any) -> None:
888
+ status = answers.get(scope["path"].rsplit("/", 1)[-1].lower(), 404)
889
+ await send({"type": "http.response.start", "status": status, "headers": []})
890
+ await send({"type": "http.response.body", "body": b""})
891
+
892
+ app = ThreadDeleteHookMiddleware(native_api, on_deleted=rt.forget_thread_runs)
893
+ async with httpx.AsyncClient(
894
+ transport=httpx.ASGITransport(app=app), base_url="http://server"
895
+ ) as client:
896
+ assert (await client.get(f"/threads/{THREAD}")).status_code == 204 # not a delete
897
+ assert await rt.runs.get("r1") is not None
898
+ assert (await client.delete(f"/threads/{OTHER}")).status_code == 404 # refused
899
+ assert await rt.runs.get("r2") is not None
900
+ # Any spelling of the id the server accepts names the canonical records.
901
+ assert (await client.delete(f"/threads/{THREAD.upper()}")).status_code == 204
902
+ assert await rt.runs.get("r1") is None and await rt.runs.get("r2") is not None
903
+
904
+
905
+ def test_the_server_runtime_leaves_thread_deletion_to_the_native_api(monkeypatch) -> None:
906
+ """A DELETE /threads/{id} route of the app would shadow the server's own (and its loopback)."""
907
+ import importlib
908
+
909
+ from {{cookiecutter.agent_directory}} import fast_api_app as module
910
+
911
+ def routes(app: Any) -> set[tuple[str, str]]:
912
+ return {(r.path, m) for r in app.routes for m in (getattr(r, "methods", None) or ())}
913
+
914
+ assert ("/threads/{thread_id}", "DELETE") in routes(module.app)
915
+ assert not {"AuthErrorMiddleware", "ThreadDeleteHookMiddleware"} & {
916
+ m.cls.__name__ for m in module.app.user_middleware
917
+ }
918
+ monkeypatch.setenv("RUNTIME", "langgraph-server")
919
+ try:
920
+ server_app = importlib.reload(module).app
921
+ server_routes = routes(server_app)
922
+ assert ("/threads/{thread_id}", "DELETE") not in server_routes
923
+ assert {("/threads", "GET"), ("/ready", "GET"), ("/chat", "POST")} <= server_routes
924
+ # The native API's auth errors and thread deletes pass through the app.
925
+ assert {"AuthErrorMiddleware", "ThreadDeleteHookMiddleware"} <= {
926
+ m.cls.__name__ for m in server_app.user_middleware
927
+ }
928
+ finally:
929
+ monkeypatch.setenv("RUNTIME", "fastapi")
930
+ importlib.reload(module)
931
+
932
+
933
+ def test_persistence_kind_follows_database_uri(server, monkeypatch) -> None:
934
+ rt, _sdk = server
935
+ monkeypatch.setenv("DATABASE_URI", ":memory:") # what `langgraph dev` sets
936
+ assert rt.checkpointer_kind() == "memory" and not Database.for_server().is_postgres
937
+ monkeypatch.setenv("DATABASE_URI", "postgresql://u:p@db:5432/agent")
938
+ assert rt.checkpointer_kind() == "postgres"
939
+ db = Database.for_server()
940
+ assert db.is_postgres and db.runs_table == "agent_runs" and not db.with_threads
941
+
942
+
943
+ async def test_ready_checks_the_server(server, monkeypatch) -> None:
944
+ rt, _sdk = server
945
+ assert await rt.ready() is True
946
+
947
+ def broken(**_: Any):
948
+ class _Assistants:
949
+ async def search(self, **_: Any):
950
+ raise httpx.ConnectError("down")
951
+
952
+ return SimpleNamespace(assistants=_Assistants())
953
+
954
+ monkeypatch.setitem(sys.modules, "langgraph_sdk", SimpleNamespace(get_client=broken))
955
+ assert await rt.ready() is False
956
+
957
+
958
+ # --- threads an agent starts for the user (0.3) ------------------------------------------------
959
+
960
+ AGENT_A = Principal(id="A", actor=Actor(id="concierge", chain=("concierge",)))
961
+ OTHER_AGENT_A = Principal(id="A", actor=Actor(id="billing", chain=("billing",)))
962
+
963
+
964
+ async def test_an_agents_thread_is_stamped_and_kept_from_other_agents(server) -> None:
965
+ rt, sdk = server
966
+ new_thread = "55555555-5555-5555-5555-555555555555"
967
+ await rt.resolve_thread(AGENT_A, ChatRequest(message="x", thread_id=new_thread))
968
+ assert sdk.threads_by_id[new_thread]["metadata"]["actor"] == "concierge"
969
+ assert sdk.threads_by_id[new_thread]["metadata"]["principal_id"] == "A"
970
+ # The person continues and reads it; another agent of theirs does neither.
971
+ assert await rt.resolve_thread(OWNER, ChatRequest(message="x", thread_id=new_thread))
972
+ await rt.messages(OWNER, new_thread)
973
+ for action in (
974
+ rt.resolve_thread(OTHER_AGENT_A, ChatRequest(message="x", thread_id=new_thread)),
975
+ rt.messages(OTHER_AGENT_A, new_thread),
976
+ rt.delete_thread(OTHER_AGENT_A, new_thread),
977
+ ):
978
+ with pytest.raises(HTTPException) as exc:
979
+ await action
980
+ assert exc.value.status_code == 403
981
+ # The fixture's thread (no actor: the person's own, or one from before 0.3) is no agent's.
982
+ with pytest.raises(HTTPException) as exc:
983
+ await rt.resolve_thread(AGENT_A, ChatRequest(message="x", thread_id=THREAD))
984
+ assert exc.value.status_code == 403
985
+ # An agent's listing names its actor; the person's does not.
986
+ await rt.list_threads(AGENT_A, limit=10, offset=0)
987
+ assert sdk.searches[-1]["metadata"] == {"principal_id": "A", "actor": "concierge"}
988
+ await rt.list_threads(OWNER, limit=10, offset=0)
989
+ assert sdk.searches[-1]["metadata"] == {"principal_id": "A"}