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,854 @@
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
+ """Production guardrails of the chat API, in-process (fastapi runtime, fake model, memory).
16
+
17
+ One run per thread (409), run timeout, recursion limit, heartbeats, client
18
+ disconnect, request and metadata caps, what reaches checkpoints and traces,
19
+ generic errors, thread list/delete, retention, readiness, metrics, request ids
20
+ and CORS.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import asyncio
26
+ import json
27
+ import os
28
+ import uuid
29
+ from collections.abc import AsyncIterator
30
+ from contextlib import aclosing
31
+ from datetime import UTC, datetime, timedelta
32
+ from typing import Any
33
+
34
+ # The environment must be in place before the app (and the graph) is imported.
35
+ os.environ.update(
36
+ {
37
+ "MODEL_PROVIDER": "fake",
38
+ "MODEL_NAME": "fake",
39
+ "CHECKPOINTER": "memory",
40
+ "AUTH_POLICY": "shared-bearer",
41
+ "API_KEY": "test-key",
42
+ "APP_ENV": "dev",
43
+ "TRACING_ENABLED": "false",
44
+ "RUNTIME": "fastapi",
45
+ "APP_URL": "http://testserver",
46
+ }
47
+ )
48
+
49
+ import httpx
50
+ import pytest
51
+ from langchain_core.messages import AIMessageChunk
52
+
53
+ from {{cookiecutter.agent_directory}} import agent as agent_module
54
+ from {{cookiecutter.agent_directory}}.app_utils.auth import Principal
55
+ from {{cookiecutter.agent_directory}}.app_utils.chat import RUNTIME, ChatRequest, sse_encode
56
+ from {{cookiecutter.agent_directory}}.app_utils.limits import SettingsError
57
+ from {{cookiecutter.agent_directory}}.app_utils.middleware import RunStreamingResponse
58
+ from {{cookiecutter.agent_directory}}.fast_api_app import app
59
+
60
+ AUTH = {"Authorization": "Bearer test-key"}
61
+ SHARED = Principal(id="shared", roles=["shared"])
62
+ LIMIT_VARS = (
63
+ "RUN_TIMEOUT_S",
64
+ "RECURSION_LIMIT",
65
+ "MAX_REQUEST_BYTES",
66
+ "MAX_METADATA_KEYS",
67
+ "MAX_METADATA_VALUE_CHARS",
68
+ "SSE_HEARTBEAT_S",
69
+ "RETENTION_DAYS",
70
+ "TRACE_CAPTURE",
71
+ "METRICS_ENABLED",
72
+ "CORS_ALLOW_ORIGINS",
73
+ "AUTH_READ_ACROSS_ROLES",
74
+ "METRICS_TOKEN",
75
+ "PRINCIPAL_HASH_SALT",
76
+ )
77
+
78
+
79
+ @pytest.fixture
80
+ async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[httpx.AsyncClient]:
81
+ for name in LIMIT_VARS:
82
+ monkeypatch.delenv(name, raising=False)
83
+ async with app.router.lifespan_context(app):
84
+ transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
85
+ async with httpx.AsyncClient(
86
+ transport=transport, base_url="http://testserver", timeout=30
87
+ ) as c:
88
+ yield c
89
+
90
+
91
+ def parse_sse(text: str) -> list[tuple[str, dict[str, Any]]]:
92
+ events: list[tuple[str, dict[str, Any]]] = []
93
+ event = None
94
+ for line in text.splitlines():
95
+ if line.startswith("event:"):
96
+ event = line[6:].strip()
97
+ elif line.startswith("data:") and event:
98
+ events.append((event, json.loads(line[5:].strip())))
99
+ event = None
100
+ return events
101
+
102
+
103
+ async def chat(
104
+ client: httpx.AsyncClient, message: str, thread_id: str | None = None, **body: Any
105
+ ) -> httpx.Response:
106
+ payload: dict[str, Any] = {"message": message, **body}
107
+ if thread_id:
108
+ payload["thread_id"] = thread_id
109
+ return await client.post("/chat", json=payload, headers=AUTH)
110
+
111
+
112
+ def slow_graph(
113
+ monkeypatch: pytest.MonkeyPatch, *, pause: float, started: asyncio.Event | None = None
114
+ ):
115
+ """Make the graph emit one text chunk, then stall for `pause` seconds, then finish."""
116
+
117
+ async def astream(*args: Any, **kwargs: Any):
118
+ if started is not None:
119
+ started.set()
120
+ yield "messages", (AIMessageChunk(content="partial ", id="ai-slow"), {})
121
+ await asyncio.sleep(pause)
122
+ yield "messages", (AIMessageChunk(content="done", id="ai-slow"), {})
123
+
124
+ monkeypatch.setattr(agent_module.graph, "astream", astream)
125
+
126
+
127
+ # --- one run per thread ---------------------------------------------------------
128
+
129
+
130
+ async def test_a_second_run_on_a_busy_thread_gets_409_and_nothing_is_lost(
131
+ client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
132
+ ) -> None:
133
+ thread_id = str(uuid.uuid4())
134
+ started = asyncio.Event()
135
+ original = agent_module.graph.astream
136
+ slow_graph(monkeypatch, pause=0.5, started=started)
137
+ first = asyncio.create_task(chat(client, "first", thread_id))
138
+ await started.wait()
139
+ second = await chat(client, "second", thread_id)
140
+ assert second.status_code == 409
141
+ assert second.json() == {
142
+ "code": "thread_busy",
143
+ "detail": "This thread already has a run in progress.",
144
+ }
145
+ assert parse_sse((await first).text)[-1][0] == "message.end"
146
+ monkeypatch.setattr(agent_module.graph, "astream", original)
147
+ # The lock is released with the run: the thread takes the next turn, and
148
+ # every accepted turn is in the history.
149
+ assert parse_sse((await chat(client, "hello again", thread_id)).text)[-1][0] == "message.end"
150
+ messages = (await client.get(f"/threads/{thread_id}/messages", headers=AUTH)).json()
151
+ assert [m["content"] for m in messages if m["role"] == "user"] == ["hello again"]
152
+
153
+
154
+ async def test_concurrent_turns_are_serialised_not_interleaved(client: httpx.AsyncClient) -> None:
155
+ thread_id = str(uuid.uuid4())
156
+ await chat(client, "hello", thread_id)
157
+ responses = await asyncio.gather(*(chat(client, f"turn {i}", thread_id) for i in range(8)))
158
+ accepted = [r for r in responses if r.status_code == 200]
159
+ assert accepted and all(r.status_code in (200, 409) for r in responses)
160
+ messages = (await client.get(f"/threads/{thread_id}/messages", headers=AUTH)).json()
161
+ users = [m["content"] for m in messages if m["role"] == "user"]
162
+ # Every turn that got 200 is persisted, each followed by its reply.
163
+ assert len(users) == 1 + len(accepted)
164
+ roles = [m["role"] for m in messages]
165
+ assert roles == ["user", "assistant"] * len(users)
166
+
167
+
168
+ # --- guardrails --------------------------------------------------------------------
169
+
170
+
171
+ async def test_run_timeout_cancels_the_run_and_frees_the_thread(
172
+ client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
173
+ ) -> None:
174
+ monkeypatch.setenv("RUN_TIMEOUT_S", "0.3")
175
+ slow_graph(monkeypatch, pause=30)
176
+ thread_id = str(uuid.uuid4())
177
+ events = parse_sse((await chat(client, "hello", thread_id)).text)
178
+ assert [e for e, _ in events] == ["message.start", "message.delta", "error"]
179
+ assert events[-1][1]["code"] == "timeout" and "0.3 s" in events[-1][1]["message"]
180
+ timeout = events[-1][1]
181
+ assert timeout["error_id"] in timeout["message"] and timeout["run_id"] == events[0][1]["run_id"]
182
+ assert RUNTIME.runs is not None
183
+ record = await RUNTIME.runs.get(events[0][1]["run_id"])
184
+ assert record is not None and record.status == "timeout"
185
+ assert thread_id not in RUNTIME.locks.held
186
+
187
+
188
+ async def test_a_run_stopped_mid_tool_call_leaves_a_usable_thread(
189
+ client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch, use_test_tools
190
+ ) -> None:
191
+ """The open tool call gets an error result, so the next turn's history is valid."""
192
+ import time
193
+
194
+ from langchain_core.tools import tool
195
+
196
+ @tool
197
+ def slow_probe(query: str) -> str:
198
+ """Test-only tool that outlives the run timeout."""
199
+ time.sleep(1.0)
200
+ return "late"
201
+
202
+ use_test_tools(slow_probe)
203
+ monkeypatch.setenv("RUN_TIMEOUT_S", "0.4")
204
+ thread_id = str(uuid.uuid4())
205
+ events = parse_sse((await chat(client, "Run the slow probe for Paris", thread_id)).text)
206
+ assert [e for e, _ in events] == ["message.start", "tool.call", "error"]
207
+ messages = (await client.get(f"/threads/{thread_id}/messages", headers=AUTH)).json()
208
+ assert [m["role"] for m in messages] == ["user", "assistant", "tool"]
209
+ assert messages[2]["is_error"] is True and "did not finish" in messages[2]["content"]
210
+ assert messages[2]["tool_call_id"] == messages[1]["tool_calls"][0]["id"]
211
+ monkeypatch.delenv("RUN_TIMEOUT_S")
212
+ events = parse_sse((await chat(client, "hello", thread_id)).text)
213
+ assert events[-1][0] == "message.end"
214
+
215
+
216
+ async def test_idle_streams_get_heartbeat_comments(
217
+ client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
218
+ ) -> None:
219
+ monkeypatch.setenv("SSE_HEARTBEAT_S", "0.05")
220
+ slow_graph(monkeypatch, pause=0.3)
221
+ text = (await chat(client, "hello")).text
222
+ assert ": keep-alive\n\n" in text
223
+ assert parse_sse(text)[-1][0] == "message.end"
224
+
225
+
226
+ def _probe_tool() -> Any:
227
+ from langchain_core.tools import tool
228
+
229
+ @tool
230
+ def probe(query: str) -> str:
231
+ """Test-only tool."""
232
+ return "ok"
233
+
234
+ return probe
235
+
236
+
237
+ @pytest.mark.parametrize("limit", ["1", "2"])
238
+ async def test_recursion_limit_ends_the_run_with_a_reply_and_keeps_its_work(
239
+ client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch, use_test_tools, limit: str
240
+ ) -> None:
241
+ """1: stopped between the tool call and the tool; 2: after the tool, before the answer."""
242
+ monkeypatch.setenv("RECURSION_LIMIT", limit)
243
+ use_test_tools(_probe_tool())
244
+ thread_id = str(uuid.uuid4())
245
+ events = parse_sse((await chat(client, "Run the probe for San Francisco", thread_id)).text)
246
+ names = [e for e, _ in events]
247
+ assert names[:2] == ["message.start", "tool.call"] and names[-2:] == [
248
+ "message.delta",
249
+ "message.end",
250
+ ]
251
+ assert "error" not in names
252
+ reply = events[-2][1]["text"]
253
+ assert reply.startswith("I had to stop before finishing") and f"({limit})" in reply
254
+ assert events[-1][1]["status"] == "step_limit"
255
+ assert RUNTIME.runs is not None
256
+ record = await RUNTIME.runs.get(events[0][1]["run_id"])
257
+ assert record is not None and record.status == "step_limit"
258
+ # The thread keeps the call, its result (an error when the tool never ran) and the reply.
259
+ messages = (await client.get(f"/threads/{thread_id}/messages", headers=AUTH)).json()
260
+ assert [m["role"] for m in messages] == ["user", "assistant", "tool", "assistant"]
261
+ assert messages[2]["tool_call_id"] == messages[1]["tool_calls"][0]["id"]
262
+ assert messages[2]["is_error"] is (limit == "1")
263
+ assert messages[3]["content"] == reply
264
+ # "continue" works with a fresh step budget.
265
+ monkeypatch.delenv("RECURSION_LIMIT")
266
+ events = parse_sse((await chat(client, "hello", thread_id)).text)
267
+ assert events[-1][0] == "message.end" and events[-1][1]["status"] == "ok"
268
+
269
+
270
+ async def test_recursion_limit_is_an_error_when_the_reply_cannot_be_written(
271
+ client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch, use_test_tools
272
+ ) -> None:
273
+ monkeypatch.setenv("RECURSION_LIMIT", "1")
274
+ use_test_tools(_probe_tool())
275
+
276
+ async def broken(*args: Any, **kwargs: Any) -> Any:
277
+ raise OSError("database unreachable")
278
+
279
+ monkeypatch.setattr(RUNTIME, "_repair_history", broken)
280
+ monkeypatch.setattr(RUNTIME, "_repair_before_run", lambda *a, **k: asyncio.sleep(0))
281
+ events = parse_sse((await chat(client, "Run the probe for San Francisco")).text)
282
+ assert events[-1][0] == "error"
283
+ assert events[-1][1]["code"] == "recursion_limit" and "(1 steps)" in events[-1][1]["message"]
284
+
285
+
286
+ async def test_the_run_uses_the_configured_recursion_limit(
287
+ client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
288
+ ) -> None:
289
+ seen: dict[str, Any] = {}
290
+ original = agent_module.graph.astream
291
+
292
+ def capture(*args: Any, **kwargs: Any):
293
+ seen.update(kwargs["config"])
294
+ return original(*args, **kwargs)
295
+
296
+ monkeypatch.setattr(agent_module.graph, "astream", capture)
297
+ await chat(client, "hello")
298
+ assert seen["recursion_limit"] == 50
299
+ assert agent_module.graph.config["recursion_limit"] == 50 # native default too
300
+
301
+
302
+ async def test_a_disconnected_client_cancels_the_run(monkeypatch: pytest.MonkeyPatch) -> None:
303
+ """The run is cancelled when the client goes away, recorded, and the thread freed."""
304
+ started = asyncio.Event()
305
+ slow_graph(monkeypatch, pause=30, started=started)
306
+ async with app.router.lifespan_context(app):
307
+ req = ChatRequest(message="hello")
308
+ thread_id = await RUNTIME.resolve_thread(SHARED, req)
309
+ lease = await RUNTIME.acquire_thread(thread_id)
310
+ run_ids: list[str] = []
311
+
312
+ async def events() -> AsyncIterator[str]:
313
+ async with aclosing(RUNTIME.stream(SHARED, req, thread_id, lease=lease)) as stream:
314
+ async for event, data in stream:
315
+ if event == "message.start":
316
+ run_ids.append(data["run_id"])
317
+ yield sse_encode(event, data)
318
+
319
+ response = RunStreamingResponse(
320
+ events(), on_close=lease.release, media_type="text/event-stream"
321
+ )
322
+ sent: list[dict[str, Any]] = []
323
+
324
+ async def send(message: dict[str, Any]) -> None:
325
+ sent.append(message)
326
+
327
+ async def receive() -> dict[str, Any]:
328
+ await started.wait()
329
+ await asyncio.sleep(0.05)
330
+ return {"type": "http.disconnect"}
331
+
332
+ scope = {"type": "http", "asgi": {"spec_version": "2.3"}}
333
+ await asyncio.wait_for(response(scope, receive, send), timeout=5)
334
+ assert RUNTIME.runs is not None
335
+ record = await RUNTIME.runs.get(run_ids[0])
336
+ assert record is not None and record.status == "cancelled"
337
+ assert lease.released and thread_id not in RUNTIME.locks.held
338
+
339
+
340
+ # --- request limits ------------------------------------------------------------------
341
+
342
+
343
+ async def test_request_bodies_over_the_cap_get_413(
344
+ client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
345
+ ) -> None:
346
+ monkeypatch.setenv("MAX_REQUEST_BYTES", "2000")
347
+ big = {"message": "x" * 3000}
348
+ r = await client.post("/chat", json=big, headers=AUTH)
349
+ assert r.status_code == 413 and "MAX_REQUEST_BYTES" in r.json()["detail"]
350
+
351
+ async def chunks() -> AsyncIterator[bytes]: # no Content-Length: counted while read
352
+ body = json.dumps(big).encode()
353
+ for i in range(0, len(body), 500):
354
+ yield body[i : i + 500]
355
+
356
+ r = await client.post(
357
+ "/chat", content=chunks(), headers={**AUTH, "content-type": "application/json"}
358
+ )
359
+ assert r.status_code == 413
360
+ # Unauthenticated callers are refused just the same, before any parsing.
361
+ assert (await client.post("/chat", json=big)).status_code == 413
362
+
363
+
364
+ async def test_deeply_nested_json_is_refused_not_a_crash(client: httpx.AsyncClient) -> None:
365
+ body = '{"message": "hi", "metadata": {"a": ' + "[" * 100_000 + "]" * 100_000 + "}}"
366
+ r = await client.post(
367
+ "/chat", content=body.encode(), headers={**AUTH, "content-type": "application/json"}
368
+ )
369
+ assert 400 <= r.status_code < 500
370
+
371
+
372
+ async def test_a2a_style_callers_get_thread_busy_as_an_event(client: httpx.AsyncClient) -> None:
373
+ """`stream()` without a lease (the A2A path) takes the lock itself."""
374
+ thread_id = await RUNTIME.resolve_thread(SHARED, ChatRequest(message="x"))
375
+ lease = await RUNTIME.acquire_thread(thread_id)
376
+ events = [e async for e in RUNTIME.stream(SHARED, ChatRequest(message="x"), thread_id)]
377
+ assert events == [
378
+ ("error", {"code": "thread_busy", "message": "This thread already has a run in progress."})
379
+ ]
380
+ await lease.release()
381
+ events = [e async for e in RUNTIME.stream(SHARED, ChatRequest(message="x"), thread_id)]
382
+ assert events[-1][0] == "message.end"
383
+
384
+
385
+ async def test_a_database_failure_is_a_generic_503(
386
+ client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
387
+ ) -> None:
388
+ assert RUNTIME.threads is not None
389
+
390
+ async def down(*args: Any, **kwargs: Any) -> Any:
391
+ raise OSError("connection to server at 10.0.0.7, port 5432 failed")
392
+
393
+ monkeypatch.setattr(RUNTIME.threads, "ensure", down)
394
+ r = await chat(client, "hello")
395
+ assert r.status_code == 503 and "10.0.0.7" not in r.text
396
+ assert r.json()["detail"].startswith("Database unavailable. Reference: ")
397
+
398
+
399
+ @pytest.mark.parametrize("value", ["NaN", "Infinity", "-Infinity"])
400
+ async def test_non_finite_numbers_get_422_not_500(client: httpx.AsyncClient, value: str) -> None:
401
+ """Python's JSON parser accepts NaN/Infinity; the 422 that echoes them must stay valid JSON."""
402
+ for body in (
403
+ '{"message": "hi", "metadata": {"k": VALUE}}',
404
+ '{"message": VALUE}',
405
+ '{"message": "hi", "metadata": {"k": [VALUE]}}',
406
+ ):
407
+ r = await client.post(
408
+ "/chat",
409
+ content=body.replace("VALUE", value).encode(),
410
+ headers={**AUTH, "content-type": "application/json"},
411
+ )
412
+ assert r.status_code == 422, r.text
413
+
414
+ def refuse(constant: str) -> None:
415
+ raise AssertionError(f"non-standard JSON constant {constant} in the response")
416
+
417
+ detail = json.loads(r.text, parse_constant=refuse)["detail"]
418
+ assert detail and all({"type", "loc", "msg"} <= set(error) for error in detail)
419
+
420
+
421
+ @pytest.mark.parametrize(
422
+ "metadata",
423
+ [
424
+ {f"k{i}": i for i in range(17)},
425
+ {"k": "v" * 257},
426
+ {"k" * 257: "v"},
427
+ {"nested": {"a": 1}},
428
+ {"list": [1, 2]},
429
+ ],
430
+ )
431
+ async def test_metadata_outside_the_caps_gets_422(
432
+ client: httpx.AsyncClient, metadata: dict[str, Any]
433
+ ) -> None:
434
+ r = await chat(client, "hello", metadata=metadata)
435
+ assert r.status_code == 422, r.text
436
+
437
+
438
+ async def test_metadata_at_the_caps_is_accepted(client: httpx.AsyncClient) -> None:
439
+ metadata = {f"k{i}": "v" * 256 for i in range(15)} | {"n": None}
440
+ assert (await chat(client, "hello", metadata=metadata)).status_code == 200
441
+
442
+
443
+ @pytest.mark.parametrize("thread_id", ["bad id", "a/b", "x" * 129, "ümlaut"])
444
+ async def test_invalid_thread_ids_get_422_everywhere(
445
+ client: httpx.AsyncClient, thread_id: str
446
+ ) -> None:
447
+ assert (await chat(client, "hello", thread_id)).status_code == 422
448
+ quoted = httpx.URL(f"/threads/{thread_id.replace('/', '%2F')}/messages")
449
+ r = await client.get(quoted, headers=AUTH)
450
+ assert r.status_code in (404, 422) # an unroutable path never reaches a store
451
+ with pytest.raises(Exception) as exc:
452
+ await RUNTIME.resolve_thread(SHARED, ChatRequest(message="x", thread_id=thread_id))
453
+ assert getattr(exc.value, "status_code", None) == 422
454
+
455
+
456
+ # --- what is persisted and traced --------------------------------------------------
457
+
458
+
459
+ @pytest.mark.parametrize("capture", ["metadata", "full"])
460
+ async def test_client_metadata_stays_out_of_checkpoints_and_cannot_spoof_ids(
461
+ client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch, capture: str
462
+ ) -> None:
463
+ monkeypatch.setenv("TRACE_CAPTURE", capture)
464
+ seen: dict[str, Any] = {}
465
+ original = agent_module.graph.astream
466
+
467
+ def capture_config(*args: Any, **kwargs: Any):
468
+ seen.update(kwargs["config"])
469
+ return original(*args, **kwargs)
470
+
471
+ monkeypatch.setattr(agent_module.graph, "astream", capture_config)
472
+ thread_id = str(uuid.uuid4())
473
+ client_meta = {
474
+ "thread_id": "SPOOFED",
475
+ "run_id": "SPOOFED",
476
+ "principal_hash": "SPOOFED",
477
+ "email": "pii@example.com",
478
+ }
479
+ events = parse_sse((await chat(client, "hello", thread_id, metadata=client_meta)).text)
480
+ run_id = events[0][1]["run_id"]
481
+ trace_meta = seen["metadata"]
482
+ assert trace_meta["thread_id"] == thread_id and trace_meta["run_id"] == run_id
483
+ assert trace_meta["principal_hash"] == SHARED.hashed_id()
484
+ if capture == "full":
485
+ assert trace_meta["client_metadata"] == client_meta
486
+ else:
487
+ assert "client_metadata" not in trace_meta and "pii@example.com" not in str(trace_meta)
488
+ # No checkpoint of the thread carries the client's metadata.
489
+ checkpoints = [
490
+ c
491
+ async for c in agent_module.graph.checkpointer.alist(
492
+ {"configurable": {"thread_id": thread_id}}
493
+ )
494
+ ]
495
+ assert checkpoints
496
+ assert all("pii@example.com" not in str(c.metadata) for c in checkpoints)
497
+ assert all(c.metadata.get("run_id") == run_id for c in checkpoints)
498
+ # The run record keeps it.
499
+ assert RUNTIME.runs is not None
500
+ record = await RUNTIME.runs.get(run_id)
501
+ assert record is not None and record.metadata == client_meta
502
+
503
+
504
+ # --- threads API and retention -----------------------------------------------------
505
+
506
+
507
+ async def test_threads_can_be_listed_and_deleted_by_their_owner(client: httpx.AsyncClient) -> None:
508
+ first, second = str(uuid.uuid4()), str(uuid.uuid4())
509
+ await chat(client, "hello", first)
510
+ await chat(client, "hello", second)
511
+ listed = (await client.get("/threads", headers=AUTH)).json()
512
+ ids = [t["thread_id"] for t in listed]
513
+ assert ids.index(second) < ids.index(first) # most recently active first
514
+ assert set(listed[0]) == {"thread_id", "owner", "created_at", "updated_at"}
515
+ assert (await client.get("/threads?limit=1", headers=AUTH)).json()[0]["thread_id"] == second
516
+ assert (await client.get("/threads?limit=0", headers=AUTH)).status_code == 422
517
+
518
+ r = await client.delete(f"/threads/{first}", headers=AUTH)
519
+ assert r.status_code == 204
520
+ assert (await client.get(f"/threads/{first}/messages", headers=AUTH)).status_code == 404
521
+ assert first not in [
522
+ t["thread_id"] for t in (await client.get("/threads", headers=AUTH)).json()
523
+ ]
524
+ state = await agent_module.graph.aget_state({"configurable": {"thread_id": first}})
525
+ assert not state.values # checkpoints gone
526
+ assert RUNTIME.runs is not None and await RUNTIME.runs.list_for_thread(first) == []
527
+ assert (await client.delete(f"/threads/{first}", headers=AUTH)).status_code == 404
528
+ assert (await client.delete("/threads/bad%20id", headers=AUTH)).status_code == 422
529
+ assert (await client.delete(f"/threads/{second}")).status_code == 401
530
+
531
+
532
+ async def test_only_the_owner_deletes_and_never_during_a_run(
533
+ client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
534
+ ) -> None:
535
+ thread_id = str(uuid.uuid4())
536
+ await chat(client, "hello", thread_id)
537
+ monkeypatch.setenv("AUTH_READ_ACROSS_ROLES", "auditor")
538
+ for other in (Principal(id="stranger"), Principal(id="auditor", roles=["auditor"])):
539
+ with pytest.raises(Exception) as exc:
540
+ await RUNTIME.delete_thread(other, thread_id)
541
+ assert getattr(exc.value, "status_code", None) == 403
542
+ lease = await RUNTIME.acquire_thread(thread_id)
543
+ r = await client.delete(f"/threads/{thread_id}", headers=AUTH)
544
+ assert r.status_code == 409 and r.json()["code"] == "thread_busy"
545
+ await lease.release()
546
+ assert (await client.delete(f"/threads/{thread_id}", headers=AUTH)).status_code == 204
547
+
548
+
549
+ async def test_a_delete_racing_a_chat_leaves_no_ownerless_state(
550
+ client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
551
+ ) -> None:
552
+ """A DELETE that lands after /chat checked ownership, before it took the run lock.
553
+
554
+ The turn must not be written to a thread without an owner row, where another
555
+ principal could claim the id and read it.
556
+ """
557
+ thread_id = str(uuid.uuid4())
558
+ await chat(client, "first", thread_id)
559
+ resolve = RUNTIME.resolve_thread
560
+
561
+ async def resolve_then_delete(principal: Principal, req: ChatRequest) -> str:
562
+ resolved = await resolve(principal, req)
563
+ if req.message == "secret second turn":
564
+ await RUNTIME.delete_thread(principal, resolved) # the racing DELETE
565
+ return resolved
566
+
567
+ monkeypatch.setattr(RUNTIME, "resolve_thread", resolve_then_delete)
568
+ events = parse_sse((await chat(client, "secret second turn", thread_id)).text)
569
+ assert events[-1][0] == "message.end"
570
+ assert RUNTIME.threads is not None
571
+ record = await RUNTIME.threads.get(thread_id)
572
+ # The turn started the thread afresh under its sender, never ownerless.
573
+ assert record is not None and record.principal_id == "shared"
574
+ bob = Principal(id="bob")
575
+ with pytest.raises(Exception) as exc:
576
+ await RUNTIME.resolve_thread(bob, ChatRequest(message="mine now", thread_id=thread_id))
577
+ assert getattr(exc.value, "status_code", None) == 403
578
+
579
+
580
+ async def test_a_delete_rechecks_the_owner_under_the_lock(
581
+ client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
582
+ ) -> None:
583
+ thread_id = str(uuid.uuid4())
584
+ await chat(client, "hello", thread_id)
585
+ acquire = RUNTIME.acquire_thread
586
+ assert RUNTIME.threads is not None
587
+
588
+ async def acquire_after_a_reclaim(tid: str, principal: Principal | None = None) -> Any:
589
+ # Between the owner check and the lock, the thread goes and bob claims the id.
590
+ await RUNTIME._delete_thread_data(tid)
591
+ await RUNTIME.threads.claim(tid, Principal(id="bob"))
592
+ return await acquire(tid, principal)
593
+
594
+ monkeypatch.setattr(RUNTIME, "acquire_thread", acquire_after_a_reclaim)
595
+ assert (await client.delete(f"/threads/{thread_id}", headers=AUTH)).status_code == 403
596
+ record = await RUNTIME.threads.get(thread_id)
597
+ assert record is not None and record.principal_id == "bob"
598
+ assert thread_id not in RUNTIME.locks.held
599
+
600
+
601
+ async def test_a2a_style_runs_recheck_ownership_under_the_lock(client: httpx.AsyncClient) -> None:
602
+ """`stream()` taking the lock itself re-checks the owner too (the A2A path)."""
603
+ thread_id = str(uuid.uuid4())
604
+ await chat(client, "first", thread_id)
605
+ req = ChatRequest(message="second", thread_id=thread_id)
606
+ resolved = await RUNTIME.resolve_thread(SHARED, req)
607
+ assert RUNTIME.threads is not None
608
+ await RUNTIME.delete_thread(SHARED, resolved)
609
+ # Someone else claims the id in between: the run is refused, nothing is written.
610
+ await RUNTIME.threads.claim(thread_id, Principal(id="bob"))
611
+ events = [e async for e in RUNTIME.stream(SHARED, req, resolved)]
612
+ assert events == [
613
+ ("error", {"code": "forbidden", "message": "This thread belongs to another principal."})
614
+ ]
615
+ state = await agent_module.graph.aget_state({"configurable": {"thread_id": thread_id}})
616
+ assert not state.values
617
+ assert thread_id not in RUNTIME.locks.held
618
+
619
+
620
+ async def test_a_thread_id_with_state_but_no_owner_cannot_be_claimed(
621
+ client: httpx.AsyncClient,
622
+ ) -> None:
623
+ """Checkpoints without an owner row (e.g. left by an older version) never pass to a new owner."""
624
+ thread_id = str(uuid.uuid4())
625
+ await agent_module.graph.ainvoke(
626
+ {"messages": [{"role": "user", "content": "orphaned secret"}]},
627
+ {"configurable": {"thread_id": thread_id}},
628
+ )
629
+ r = await chat(client, "let me read that", thread_id)
630
+ assert r.status_code == 403
631
+ assert RUNTIME.threads is not None and await RUNTIME.threads.get(thread_id) is None
632
+ assert (await client.get(f"/threads/{thread_id}/messages", headers=AUTH)).status_code == 404
633
+
634
+
635
+ async def test_retention_rechecks_idleness_under_the_lock(
636
+ client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
637
+ ) -> None:
638
+ """A thread resumed after the purge listed it (and before it was locked) is kept."""
639
+ thread_id = str(uuid.uuid4())
640
+ await chat(client, "hello", thread_id)
641
+ assert RUNTIME.threads is not None
642
+ RUNTIME.threads._memory[thread_id].updated_at = (
643
+ datetime.now(tz=UTC) - timedelta(days=40)
644
+ ).isoformat()
645
+ idle_before = RUNTIME.threads.idle_before
646
+
647
+ async def list_then_resume(cutoff_iso: str, *, limit: int = 500) -> list[str]:
648
+ candidates = await idle_before(cutoff_iso, limit=limit)
649
+ assert thread_id in candidates
650
+ await chat(client, "resumed", thread_id) # the owner comes back mid-round
651
+ return candidates
652
+
653
+ monkeypatch.setattr(RUNTIME.threads, "idle_before", list_then_resume)
654
+ assert await RUNTIME.purge_expired(30) == 0
655
+ assert await RUNTIME.threads.get(thread_id) is not None
656
+ messages = (await client.get(f"/threads/{thread_id}/messages", headers=AUTH)).json()
657
+ assert [m["content"] for m in messages if m["role"] == "user"] == ["hello", "resumed"]
658
+
659
+
660
+ async def test_retention_purges_idle_threads_only(
661
+ client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
662
+ ) -> None:
663
+ idle, busy, fresh = (str(uuid.uuid4()) for _ in range(3))
664
+ for thread_id in (idle, busy, fresh):
665
+ await chat(client, "hello", thread_id)
666
+ assert RUNTIME.threads is not None
667
+ old = (datetime.now(tz=UTC) - timedelta(days=40)).isoformat()
668
+ RUNTIME.threads._memory[idle].updated_at = old
669
+ RUNTIME.threads._memory[busy].updated_at = old
670
+ assert await RUNTIME.purge_expired(0) == 0 # 0 keeps everything
671
+ lease = await RUNTIME.acquire_thread(busy)
672
+ assert await RUNTIME.purge_expired(30) == 1
673
+ await lease.release()
674
+ assert await RUNTIME.threads.get(idle) is None
675
+ assert await RUNTIME.threads.get(busy) is not None and await RUNTIME.threads.get(fresh)
676
+ state = await agent_module.graph.aget_state({"configurable": {"thread_id": idle}})
677
+ assert not state.values
678
+ # A continued thread is active again: its idle clock restarts.
679
+ RUNTIME.threads._memory[fresh].updated_at = old
680
+ await chat(client, "again", fresh)
681
+ assert await RUNTIME.purge_expired(30) == 1 # only `busy`, now idle and free
682
+
683
+
684
+ # --- errors, readiness, metrics, request ids, CORS ----------------------------------
685
+
686
+
687
+ async def test_unhandled_errors_answer_a_generic_500_with_an_error_id(
688
+ client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
689
+ ) -> None:
690
+ async def boom(*args: Any, **kwargs: Any) -> Any:
691
+ raise RuntimeError("connection to 10.0.0.7:5432 refused")
692
+
693
+ monkeypatch.setattr(RUNTIME, "list_threads", boom)
694
+ r = await client.get("/threads", headers={**AUTH, "X-Request-ID": "req-500"})
695
+ assert r.status_code == 500
696
+ body = r.json()
697
+ assert "10.0.0.7" not in r.text and body["error_id"] in body["detail"]
698
+ # The 500 comes from outside the request-id middleware; it still names the request.
699
+ assert r.headers["x-request-id"] == "req-500"
700
+ r = await client.get("/threads", headers=AUTH)
701
+ assert r.status_code == 500 and len(r.headers["x-request-id"]) == 32
702
+
703
+
704
+ async def test_ready_reflects_the_database(
705
+ client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
706
+ ) -> None:
707
+ r = await client.get("/ready")
708
+ assert r.status_code == 200 and r.json() == {"status": "ready"}
709
+ assert RUNTIME.db is not None
710
+
711
+ async def down() -> None:
712
+ raise OSError("database unreachable")
713
+
714
+ monkeypatch.setattr(RUNTIME.db, "ping", down)
715
+ r = await client.get("/ready")
716
+ assert r.status_code == 503 and r.json() == {"status": "not_ready"}
717
+ assert (await client.get("/health")).status_code == 200 # liveness is process-only
718
+
719
+
720
+ async def test_metrics_count_requests_and_runs(
721
+ client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
722
+ ) -> None:
723
+ await chat(client, "hello")
724
+ await client.get(f"/threads/{uuid.uuid4()}/messages", headers=AUTH)
725
+ r = await client.get("/metrics")
726
+ assert r.status_code == 200 and r.headers["content-type"].startswith("text/plain")
727
+ text = r.text
728
+ assert 'agent_runs_total{status="ok"}' in text
729
+ assert 'http_requests_total{method="POST",route="/chat",status="200"}' in text
730
+ # Route templates, never raw paths (no series per thread id).
731
+ assert 'route="/threads/{thread_id}/messages"' in text
732
+ assert "agent_active_runs" in text and "agent_tokens_total" in text
733
+ monkeypatch.setenv("METRICS_ENABLED", "false")
734
+ assert (await client.get("/metrics")).status_code == 404
735
+
736
+
737
+ async def test_metrics_token_protects_metrics_only(
738
+ client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
739
+ ) -> None:
740
+ monkeypatch.setenv("METRICS_TOKEN", "scrape-secret-0123456789")
741
+ for headers in (
742
+ {},
743
+ {"Authorization": "Bearer wrong"},
744
+ {"Authorization": "Basic scrape-secret-0123456789"},
745
+ AUTH, # the API key is not the metrics token
746
+ ):
747
+ r = await client.get("/metrics", headers=headers)
748
+ assert r.status_code == 401 and r.headers["www-authenticate"] == "Bearer"
749
+ assert "agent_runs_total" not in r.text
750
+ r = await client.get("/metrics", headers={"Authorization": "Bearer scrape-secret-0123456789"})
751
+ assert r.status_code == 200 and "http_requests_total" in r.text
752
+ # Probes stay open.
753
+ assert (await client.get("/health")).status_code == 200
754
+ assert (await client.get("/ready")).status_code == 200
755
+ monkeypatch.setenv("METRICS_TOKEN", " ") # blank = not set
756
+ assert (await client.get("/metrics")).status_code == 200
757
+
758
+
759
+ async def test_request_ids_are_echoed_or_generated(client: httpx.AsyncClient) -> None:
760
+ r = await client.get("/health", headers={"X-Request-ID": "req-123.abc"})
761
+ assert r.headers["x-request-id"] == "req-123.abc"
762
+ r = await client.get("/health", headers={"X-Request-ID": "bad id\r\nx"[:6]})
763
+ assert r.headers["x-request-id"] != "bad id" and len(r.headers["x-request-id"]) == 32
764
+ r = await client.get("/health")
765
+ assert len(r.headers["x-request-id"]) == 32
766
+
767
+
768
+ async def test_bad_settings_stop_startup(monkeypatch: pytest.MonkeyPatch) -> None:
769
+ bad = {
770
+ "RUN_TIMEOUT_S": "five minutes",
771
+ "MAX_METADATA_KEYS": "-1",
772
+ "TRACE_CAPTURE": "everything",
773
+ "A2A_TASK_TTL_S": "an hour",
774
+ }
775
+ for name, value in bad.items():
776
+ monkeypatch.setenv(name, value)
777
+ with pytest.raises(SettingsError) as exc:
778
+ async with app.router.lifespan_context(app):
779
+ pass
780
+ for name in bad:
781
+ assert name in str(exc.value)
782
+
783
+
784
+ async def test_cors_is_off_by_default_and_follows_cors_allow_origins(
785
+ monkeypatch: pytest.MonkeyPatch,
786
+ ) -> None:
787
+ import importlib
788
+
789
+ from {{cookiecutter.agent_directory}} import fast_api_app as module
790
+
791
+ preflight = {
792
+ "Origin": "https://app.example.com",
793
+ "Access-Control-Request-Method": "POST",
794
+ "Access-Control-Request-Headers": "authorization,content-type",
795
+ }
796
+ async with httpx.AsyncClient(
797
+ transport=httpx.ASGITransport(app=module.app), base_url="http://testserver"
798
+ ) as c:
799
+ r = await c.options("/chat", headers=preflight)
800
+ assert "access-control-allow-origin" not in r.headers
801
+ monkeypatch.setenv("CORS_ALLOW_ORIGINS", "https://app.example.com")
802
+ try:
803
+ cors_app = importlib.reload(module).app
804
+ async with httpx.AsyncClient(
805
+ transport=httpx.ASGITransport(app=cors_app), base_url="http://testserver"
806
+ ) as c:
807
+ r = await c.options("/chat", headers=preflight)
808
+ assert r.status_code == 200
809
+ assert r.headers["access-control-allow-origin"] == "https://app.example.com"
810
+ assert r.headers["access-control-allow-credentials"] == "true"
811
+ r = await c.options("/chat", headers={**preflight, "Origin": "https://evil.example"})
812
+ assert "access-control-allow-origin" not in r.headers
813
+ finally:
814
+ monkeypatch.delenv("CORS_ALLOW_ORIGINS")
815
+ importlib.reload(module)
816
+
817
+
818
+ @pytest.mark.parametrize("kind", ["pool_timeout", "operational", "not_ready"])
819
+ async def test_an_unreachable_database_on_any_route_is_a_503_with_one_warning(
820
+ client: httpx.AsyncClient, caplog: pytest.LogCaptureFixture, kind: str
821
+ ) -> None:
822
+ """Routes outside the chat runtime too: 503 with an error id, one WARNING, no traceback."""
823
+ import logging
824
+
825
+ from psycopg import OperationalError
826
+ from psycopg_pool import PoolTimeout
827
+
828
+ from {{cookiecutter.agent_directory}}.app_utils.db import StorageNotReady
829
+
830
+ error = {
831
+ "pool_timeout": PoolTimeout("couldn't get a connection after 5.00 sec"),
832
+ "operational": OperationalError("connection failed: Connection refused"),
833
+ "not_ready": StorageNotReady("the database is not set up yet"),
834
+ }[kind]
835
+
836
+ async def database_down() -> None:
837
+ raise error
838
+
839
+ path = "/_test_database_down"
840
+ app.router.add_api_route(path, database_down, methods=["GET"])
841
+ try:
842
+ with caplog.at_level(logging.INFO):
843
+ r = await client.get(path, headers=AUTH)
844
+ finally:
845
+ app.router.routes[:] = [
846
+ route for route in app.router.routes if getattr(route, "path", None) != path
847
+ ]
848
+ assert r.status_code == 503
849
+ body = r.json()
850
+ assert body["detail"] == f"Database unavailable. Reference: {body['error_id']}."
851
+ assert r.headers.get("x-request-id")
852
+ lines = [rec for rec in caplog.records if body["error_id"] in rec.getMessage()]
853
+ assert len(lines) == 1 and lines[0].levelno == logging.WARNING and lines[0].exc_info is None
854
+ assert not [rec for rec in caplog.records if rec.levelno >= logging.ERROR]