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,1367 @@
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
+ """Human approval of gated API calls through the app, in-process (the fastapi runtime).
16
+
17
+ A test tool cancels an order through `api_client` against an API the test
18
+ policy gates; the upstream API is an `httpx.MockTransport` that records every
19
+ request it receives, so each test can say exactly what was sent. Several
20
+ principals come from a header test policy (`X-User`, `X-Roles`). Every test
21
+ runs with the in-memory checkpointer, and again on Postgres (checkpoints,
22
+ approvals table, run leases) when `TEST_POSTGRES_DSN` is set.
23
+ `tests/integration/test_approvals_server.py` runs the same round trip under
24
+ the langgraph-server runtime against a real LangGraph dev server.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import asyncio
30
+ import json
31
+ import os
32
+ import uuid
33
+ from collections.abc import AsyncIterator
34
+ from datetime import datetime, timedelta
35
+ from pathlib import Path
36
+ from typing import Any
37
+ from urllib.parse import urlsplit
38
+
39
+ # The environment must be in place before the app (and the graph) is imported.
40
+ os.environ.update(
41
+ {
42
+ "MODEL_PROVIDER": "fake",
43
+ "MODEL_NAME": "fake",
44
+ "CHECKPOINTER": "memory",
45
+ "AUTH_POLICY": "shared-bearer",
46
+ "API_KEY": "test-key",
47
+ "APP_ENV": "dev",
48
+ "TRACING_ENABLED": "false",
49
+ "RUNTIME": "fastapi",
50
+ "APP_URL": "http://testserver",
51
+ }
52
+ )
53
+
54
+ import httpx
55
+ import pytest
56
+ from fastapi import HTTPException
57
+ from langchain.tools import ToolRuntime
58
+ from langchain_core.language_models import BaseChatModel
59
+ from langchain_core.messages import AIMessage, ToolMessage
60
+ from langchain_core.outputs import ChatGeneration, ChatResult
61
+ from langchain_core.tools import tool
62
+ from starlette.requests import Request
63
+
64
+ from {{cookiecutter.agent_directory}}.app_utils import approvals as approvals_module
65
+ from {{cookiecutter.agent_directory}}.app_utils import auth as auth_module
66
+ from {{cookiecutter.agent_directory}}.app_utils.api_client import get_client, reset_policy_cache
67
+ from {{cookiecutter.agent_directory}}.app_utils.auth import ACTIONS, Principal
68
+ from {{cookiecutter.agent_directory}}.app_utils.chat import RUNTIME
69
+ from {{cookiecutter.agent_directory}}.fast_api_app import app
70
+
71
+ A2A_PATH = "/a2a/{{cookiecutter.agent_directory}}"
72
+ PROMPT = "Cancel the order for 7"
73
+
74
+ POLICY = """
75
+ apis:
76
+ shop:
77
+ base_url_env: SHOP_API_BASE_URL
78
+ auth: none
79
+ allowed_methods: [GET, POST]
80
+ approval:
81
+ required_for:
82
+ operations:
83
+ - operationId: cancelOrder
84
+ path: /orders/{order_id}/cancel
85
+ methods: [POST]
86
+ approvers: APPROVERS
87
+ timeout_s: 60
88
+ """
89
+
90
+ # What the upstream API received, and what the tool will put in the body.
91
+ SENT: list[httpx.Request] = []
92
+ BODY: dict[str, Any] = {"reason": "customer asked"}
93
+ SEEN_BY_TOOL: list[str] = []
94
+
95
+
96
+ def _upstream(request: httpx.Request) -> httpx.Response:
97
+ SENT.append(request)
98
+ return httpx.Response(200, json={"cancelled": request.url.path})
99
+
100
+
101
+ @tool
102
+ async def cancel_order(order_id: str, runtime: ToolRuntime[Any]) -> str:
103
+ """Cancel an order by its id."""
104
+ context = getattr(runtime, "context", None)
105
+ SEEN_BY_TOOL.append(str(getattr(context, "principal_id", "")))
106
+ client = get_client("shop", context=context, transport=httpx.MockTransport(_upstream))
107
+ data = await client.post(
108
+ "/orders/{order_id}/cancel",
109
+ operation_id="cancelOrder",
110
+ path_params={"order_id": order_id},
111
+ json_body=dict(BODY),
112
+ redact=["card"],
113
+ )
114
+ return json.dumps(data)
115
+
116
+
117
+ class HeaderPolicy:
118
+ """Test policy: `X-User` is the principal id, `X-Roles` its comma-separated roles."""
119
+
120
+ async def authenticate(self, request: Request) -> Principal:
121
+ user = request.headers.get("x-user")
122
+ if not user:
123
+ raise HTTPException(401, "no user", headers={"WWW-Authenticate": "Bearer"})
124
+ roles = [r for r in (request.headers.get("x-roles") or "user").split(",") if r]
125
+ return Principal(id=user, roles=roles, permissions=set(ACTIONS))
126
+
127
+ async def authorize(self, principal: Principal, action: str, resource: str | None) -> None:
128
+ return None
129
+
130
+
131
+ def _as(user: str, roles: str = "user") -> dict[str, str]:
132
+ return {"X-User": user, "X-Roles": roles}
133
+
134
+
135
+ def parse_sse(text: str) -> list[tuple[str, dict[str, Any]]]:
136
+ events: list[tuple[str, dict[str, Any]]] = []
137
+ event = None
138
+ for line in text.splitlines():
139
+ if line.startswith("event:"):
140
+ event = line[6:].strip()
141
+ elif line.startswith("data:") and event:
142
+ events.append((event, json.loads(line[5:].strip())))
143
+ event = None
144
+ return events
145
+
146
+
147
+ def _write_policy(path: Path, approvers: str) -> None:
148
+ path.write_text(POLICY.replace("APPROVERS", approvers), encoding="utf-8")
149
+ reset_policy_cache()
150
+
151
+
152
+ # What a new image's policy may say by the time a paused call's decision comes
153
+ # (the fixture's policy gates cancelOrder for [requester, "role:ops"]).
154
+ _GATED = POLICY.replace("APPROVERS", '[requester, "role:ops"]')
155
+ POLICY_CHANGES = {
156
+ "gate removed": _GATED.split(" approval:")[0],
157
+ "gate narrowed": _GATED.replace("cancelOrder", "refundOrder").replace(
158
+ "/orders/{order_id}/cancel", "/orders/{order_id}/refund"
159
+ ),
160
+ "call denied": _GATED.replace(
161
+ " approval:",
162
+ " denied_operations:\n - operationId: cancelOrder\n path: "
163
+ "/orders/{order_id}/cancel\n approval:",
164
+ ),
165
+ "allowed_methods narrowed": _GATED.replace(
166
+ "allowed_methods: [GET, POST]", "allowed_methods: [GET]"
167
+ ),
168
+ }
169
+ # The policy refuses before any decision is read; else the decision (or the gate) does.
170
+ REFUSED_BY = {
171
+ "call denied": "denied by denied_operations",
172
+ "allowed_methods narrowed": "is not in allowed_methods",
173
+ }
174
+ DECIDED_BY = {
175
+ "reject": "was not approved: an approver rejected it",
176
+ "expired": "was not approved: the approval request expired",
177
+ "approve": "approved under an approval gate the policy no longer has",
178
+ }
179
+
180
+
181
+ def _change_policy(tmp_path: Path, change: str) -> None:
182
+ (tmp_path / "api-policy.yaml").write_text(POLICY_CHANGES[change], encoding="utf-8")
183
+ reset_policy_cache()
184
+
185
+
186
+ ADMIN_DSN = os.environ.get("TEST_POSTGRES_DSN", "")
187
+
188
+
189
+ @pytest.fixture(params=["memory", "postgres"])
190
+ async def database(
191
+ request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch
192
+ ) -> AsyncIterator[str]:
193
+ """Every test in memory, and on Postgres (a fresh database) when `TEST_POSTGRES_DSN` is set."""
194
+ if request.param == "memory":
195
+ yield "memory"
196
+ return
197
+ if not ADMIN_DSN:
198
+ pytest.skip("TEST_POSTGRES_DSN is not set")
199
+ import psycopg
200
+
201
+ name = f"gac_test_{uuid.uuid4().hex[:12]}"
202
+ async with await psycopg.AsyncConnection.connect(ADMIN_DSN, autocommit=True) as admin:
203
+ await admin.execute(f'CREATE DATABASE "{name}"')
204
+ monkeypatch.setenv("CHECKPOINTER", "postgres")
205
+ monkeypatch.setenv("POSTGRES_DSN", urlsplit(ADMIN_DSN)._replace(path=f"/{name}").geturl())
206
+ yield "postgres"
207
+ async with await psycopg.AsyncConnection.connect(ADMIN_DSN, autocommit=True) as admin:
208
+ await admin.execute(f'DROP DATABASE IF EXISTS "{name}" WITH (FORCE)')
209
+
210
+
211
+ @pytest.fixture
212
+ async def client(
213
+ database: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, use_test_tools
214
+ ) -> AsyncIterator[httpx.AsyncClient]:
215
+ policy = tmp_path / "api-policy.yaml"
216
+ _write_policy(policy, '[requester, "role:ops"]')
217
+ monkeypatch.setenv("API_POLICY_PATH", str(policy))
218
+ monkeypatch.setenv("SHOP_API_BASE_URL", "http://shop.test")
219
+ monkeypatch.delenv("AUTH_READ_ACROSS_ROLES", raising=False)
220
+ monkeypatch.delenv("TRACE_CAPTURE", raising=False)
221
+ monkeypatch.setattr(auth_module, "get_policy", lambda: HeaderPolicy())
222
+ SENT.clear()
223
+ SEEN_BY_TOOL.clear()
224
+ BODY.clear()
225
+ BODY.update({"reason": "customer asked", "card": "4111-1111"})
226
+ async with app.router.lifespan_context(app):
227
+ use_test_tools(cancel_order)
228
+ transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
229
+ async with httpx.AsyncClient(
230
+ transport=transport, base_url="http://testserver", timeout=30
231
+ ) as c:
232
+ started = asyncio.get_running_loop().time()
233
+ while (await c.get("/ready")).status_code != 200: # postgres: the schema set up
234
+ assert asyncio.get_running_loop().time() - started < 30, "not ready"
235
+ await asyncio.sleep(0.1)
236
+ yield c
237
+ reset_policy_cache()
238
+
239
+
240
+ async def _pause(
241
+ client: httpx.AsyncClient, user: str = "alice", message: str = PROMPT
242
+ ) -> dict[str, Any]:
243
+ """Start a run that pauses before the gated call; the `message.end` event."""
244
+ r = await client.post("/chat", json={"message": message}, headers=_as(user))
245
+ assert r.status_code == 200, r.text
246
+ events = parse_sse(r.text)
247
+ names = [e for e, _ in events]
248
+ assert names[0] == "message.start" and names[-1] == "message.end", names
249
+ assert "tool.call" in names and "tool.result" not in names
250
+ return events[-1][1]
251
+
252
+
253
+ async def _decide(
254
+ client: httpx.AsyncClient,
255
+ end: dict[str, Any],
256
+ decision: str,
257
+ user: str = "alice",
258
+ roles: str = "user",
259
+ comment: str | None = None,
260
+ ) -> httpx.Response:
261
+ approval = end["approval"]
262
+ body: dict[str, Any] = {"decision": decision}
263
+ if comment is not None:
264
+ body["comment"] = comment
265
+ return await client.post(
266
+ f"/threads/{end['thread_id']}/approvals/{approval['approval_id']}",
267
+ json=body,
268
+ headers=_as(user, roles),
269
+ )
270
+
271
+
272
+ # --- pause, approve, send exactly once -----------------------------------------------
273
+
274
+
275
+ async def test_a_gated_call_pauses_the_run_and_sends_nothing(client) -> None:
276
+ end = await _pause(client)
277
+ assert end["status"] == "awaiting_approval"
278
+ approval = end["approval"]
279
+ assert end["approvals"] == [approval]
280
+ assert approval["status"] == "pending"
281
+ assert (approval["api"], approval["method"], approval["path"]) == (
282
+ "shop",
283
+ "POST",
284
+ "/orders/7/cancel",
285
+ )
286
+ assert approval["operation_id"] == "cancelOrder"
287
+ # The approver sees the body, with the field the tool named in `redact=` masked.
288
+ assert approval["body"] == {"reason": "customer asked", "card": "<redacted>"}
289
+ assert approval["query"] == {}
290
+ assert approval["tool"] == "cancel_order" and approval["reason"].startswith("cancel_order")
291
+ assert approval["approvers"] == ["requester", "role:ops"]
292
+ assert approval["expires_at"] > approval["created_at"]
293
+ assert approval["requester"] == Principal(id="alice").hashed_id()
294
+ # Internals stay in the server.
295
+ assert "call_hash" not in approval and "interrupt_id" not in approval
296
+ assert SENT == []
297
+ listed = await client.get(f"/threads/{end['thread_id']}/approvals", headers=_as("alice"))
298
+ assert listed.status_code == 200
299
+ assert [a["approval_id"] for a in listed.json()] == [approval["approval_id"]]
300
+
301
+
302
+ async def test_approve_sends_exactly_the_approved_request_once(client) -> None:
303
+ end = await _pause(client)
304
+ r = await _decide(client, end, "approve", comment="looks right")
305
+ assert r.status_code == 200, r.text
306
+ events = parse_sse(r.text)
307
+ names = [e for e, _ in events]
308
+ assert names[0] == "message.start" and names[-1] == "message.end", names
309
+ start = events[0][1]
310
+ assert start["approval_id"] == end["approval"]["approval_id"]
311
+ assert start["decision"] == "approve" and start["run_id"] != end["run_id"]
312
+ result = next(d for e, d in events if e == "tool.result")
313
+ assert result["is_error"] is False and "/orders/7/cancel" in result["result"]
314
+ assert events[-1][1]["status"] == "ok"
315
+ assert len(SENT) == 1
316
+ sent = SENT[0]
317
+ assert (sent.method, sent.url.path) == ("POST", "/orders/7/cancel")
318
+ # The masked field was masked for the approver only: the request is the tool's.
319
+ assert json.loads(sent.content) == {"reason": "customer asked", "card": "4111-1111"}
320
+ # The resumed run acted as the requester.
321
+ assert SEEN_BY_TOOL == ["alice", "alice"]
322
+ approvals = (
323
+ await client.get(f"/threads/{end['thread_id']}/approvals", headers=_as("alice"))
324
+ ).json()
325
+ assert approvals[0]["status"] == "approved"
326
+ assert approvals[0]["decided_by"] == Principal(id="alice").hashed_id()
327
+ assert approvals[0]["comment"] == "looks right"
328
+ # Decided: the call's content is no longer kept (TRACE_CAPTURE=metadata).
329
+ assert "body" not in approvals[0] and "query" not in approvals[0]
330
+ # Single use: deciding again is a conflict, and nothing more is sent.
331
+ again = await _decide(client, end, "approve")
332
+ assert again.status_code == 409 and again.json()["code"] == "approval_not_pending"
333
+ assert again.json()["status"] == "approved"
334
+ assert len(SENT) == 1
335
+ # The thread takes new messages again.
336
+ r = await client.post(
337
+ "/chat", json={"message": "hello", "thread_id": end["thread_id"]}, headers=_as("alice")
338
+ )
339
+ assert r.status_code == 200 and parse_sse(r.text)[-1][1]["status"] == "ok"
340
+
341
+
342
+ async def test_reject_sends_nothing_and_the_model_hears_why(client) -> None:
343
+ end = await _pause(client)
344
+ r = await _decide(client, end, "reject", comment="wrong customer")
345
+ assert r.status_code == 200, r.text
346
+ events = parse_sse(r.text)
347
+ result = next(d for e, d in events if e == "tool.result")
348
+ assert result["is_error"] is True
349
+ assert events[-1][1]["status"] == "ok"
350
+ assert SENT == []
351
+ # The model read the refusal (the history keeps the tool's error text).
352
+ from {{cookiecutter.agent_directory}}.agent import graph
353
+
354
+ state = await graph.aget_state({"configurable": {"thread_id": end["thread_id"]}})
355
+ tool_message = next(m for m in state.values["messages"] if m.type == "tool")
356
+ assert "was not approved: an approver rejected it" in tool_message.content
357
+ assert "wrong customer" in tool_message.content and "nothing was sent" in tool_message.content
358
+
359
+
360
+ async def test_an_expired_approval_is_410_sends_nothing_and_frees_the_thread(
361
+ client, monkeypatch
362
+ ) -> None:
363
+ end = await _pause(client)
364
+ later = approvals_module.utcnow() + timedelta(seconds=61)
365
+ assert RUNTIME.approvals is not None
366
+ monkeypatch.setattr(RUNTIME.approvals, "_clock", lambda: later)
367
+ r = await _decide(client, end, "approve")
368
+ assert r.status_code == 410 and r.json()["code"] == "approval_expired"
369
+ listed = await client.get(f"/threads/{end['thread_id']}/approvals", headers=_as("alice"))
370
+ assert listed.json()[0]["status"] == "expired"
371
+ # Expired = rejected: the thread takes a new message, and the paused call's
372
+ # result says it was not approved.
373
+ r = await client.post(
374
+ "/chat", json={"message": "hello", "thread_id": end["thread_id"]}, headers=_as("alice")
375
+ )
376
+ assert r.status_code == 200 and parse_sse(r.text)[-1][1]["status"] == "ok"
377
+ messages = (
378
+ await client.get(f"/threads/{end['thread_id']}/messages", headers=_as("alice"))
379
+ ).json()
380
+ from {{cookiecutter.agent_directory}}.agent import graph
381
+
382
+ state = await graph.aget_state({"configurable": {"thread_id": end["thread_id"]}})
383
+ tool_message = next(m for m in state.values["messages"] if m.type == "tool")
384
+ assert "approval request expired" in tool_message.content
385
+ assert [m["role"] for m in messages] == ["user", "assistant", "tool", "user", "assistant"]
386
+ assert SENT == []
387
+
388
+
389
+ async def test_a_request_changed_after_approval_is_refused(client) -> None:
390
+ end = await _pause(client)
391
+ BODY["reason"] = "something else" # the tool now builds a different request
392
+ r = await _decide(client, end, "approve")
393
+ assert r.status_code == 200, r.text
394
+ result = next(d for e, d in parse_sse(r.text) if e == "tool.result")
395
+ assert result["is_error"] is True
396
+ assert SENT == []
397
+ from {{cookiecutter.agent_directory}}.agent import graph
398
+
399
+ state = await graph.aget_state({"configurable": {"thread_id": end["thread_id"]}})
400
+ tool_message = next(m for m in state.values["messages"] if m.type == "tool")
401
+ assert "differs from the request that was approved" in tool_message.content
402
+ # The approval stays unused: it can never send the changed request.
403
+ record = await RUNTIME.approvals.get(end["approval"]["approval_id"])
404
+ assert record is not None and record.status == "approved" and record.used_at is None
405
+
406
+
407
+ async def test_an_approval_asked_of_other_approvers_than_the_policy_names_now_sends_nothing(
408
+ client, tmp_path
409
+ ) -> None:
410
+ end = await _pause(client)
411
+ assert end["approval"]["approvers"] == ["requester", "role:ops"]
412
+ # A new image while the call waits: its policy asks for four eyes.
413
+ _write_policy(tmp_path / "api-policy.yaml", '["role:ops"]')
414
+ r = await _decide(client, end, "approve") # allowed by the approvers it was asked of
415
+ assert r.status_code == 200, r.text
416
+ result = next(d for e, d in parse_sse(r.text) if e == "tool.result")
417
+ assert result["is_error"] is True
418
+ assert SENT == []
419
+ from {{cookiecutter.agent_directory}}.agent import graph
420
+
421
+ state = await graph.aget_state({"configurable": {"thread_id": end["thread_id"]}})
422
+ tool_message = next(m for m in state.values["messages"] if m.type == "tool")
423
+ assert "approval gate that has changed" in tool_message.content
424
+
425
+
426
+ @pytest.mark.parametrize("change", list(POLICY_CHANGES))
427
+ @pytest.mark.parametrize("decision", ["reject", "approve"])
428
+ async def test_a_decision_binds_its_call_whatever_the_policy_says_by_then(
429
+ client, tmp_path, change: str, decision: str
430
+ ) -> None:
431
+ """Reject never sends, approve never outruns a later denial or narrowing."""
432
+ end = await _pause(client)
433
+ _change_policy(tmp_path, change) # a new image while the call waits
434
+ r = await _decide(client, end, decision)
435
+ assert r.status_code == 200, r.text
436
+ events = parse_sse(r.text)
437
+ result = next(d for e, d in events if e == "tool.result")
438
+ assert result["is_error"] is True
439
+ assert REFUSED_BY.get(change, DECIDED_BY[decision]) in result["result"], result
440
+ assert events[-1][1]["status"] == "ok"
441
+ assert SENT == []
442
+ record = await RUNTIME.approvals.get(end["approval"]["approval_id"])
443
+ assert record is not None and record.used_at is None
444
+ # The thread goes on, and nothing is sent later either.
445
+ r = await client.post(
446
+ "/chat", json={"message": "hello", "thread_id": end["thread_id"]}, headers=_as("alice")
447
+ )
448
+ assert r.status_code == 200 and parse_sse(r.text)[-1][1]["status"] == "ok"
449
+ assert SENT == []
450
+
451
+
452
+ async def _paused_checkpoint(thread: str) -> dict[str, Any]:
453
+ """The config of the checkpoint the thread's run paused at (its latest, just after)."""
454
+ from {{cookiecutter.agent_directory}} import agent
455
+
456
+ state = await agent.graph.aget_state({"configurable": {"thread_id": thread}})
457
+ assert state.interrupts
458
+ return dict(state.config)
459
+
460
+
461
+ async def _run_paused_step_again(thread: str, paused: dict[str, Any]) -> str:
462
+ """What LangGraph Server's own API offers, on the local graph: continue the thread
463
+ without input, then replay the paused step from its checkpoint; the last tool result."""
464
+ from {{cookiecutter.agent_directory}} import agent
465
+
466
+ context = agent.AgentContext(principal_id="alice", roles=["user"])
467
+ latest = {"configurable": {"thread_id": thread}}
468
+ lease = await RUNTIME.acquire_thread(thread) # the checkpoint writes need the thread's lock
469
+ try:
470
+ await agent.graph.ainvoke(None, latest, context=context)
471
+ await agent.graph.ainvoke(None, paused, context=context)
472
+ finally:
473
+ await lease.release()
474
+ state = await agent.graph.aget_state(latest)
475
+ return next(m.content for m in reversed(state.values["messages"]) if m.type == "tool")
476
+
477
+
478
+ BOUND_BY = {
479
+ "reject": "was not approved: an approver rejected it",
480
+ "expired": "was not approved: the approval request expired",
481
+ "approve": "was sent already with its approval, which is used once",
482
+ }
483
+
484
+
485
+ @pytest.mark.parametrize("decision", ["reject", "expired", "approve"])
486
+ async def test_a_tool_call_run_again_without_its_decision_sends_nothing_more(
487
+ client, tmp_path, decision: str
488
+ ) -> None:
489
+ """The ledger binds a decision to its tool call, whatever the policy says by then."""
490
+ end = await _pause(client)
491
+ thread = end["thread_id"]
492
+ paused = await _paused_checkpoint(thread)
493
+ if decision == "expired":
494
+ await _expire_now(end["approval"]["approval_id"])
495
+ else:
496
+ r = await _decide(client, end, decision)
497
+ assert r.status_code == 200, r.text
498
+ sent = [req.url.path for req in SENT]
499
+ assert sent == (["/orders/7/cancel"] if decision == "approve" else [])
500
+ _change_policy(tmp_path, "gate removed")
501
+ assert BOUND_BY[decision] in await _run_paused_step_again(thread, paused)
502
+ assert [req.url.path for req in SENT] == sent
503
+
504
+
505
+ async def test_a_thread_with_approvals_refuses_a_replay_and_a_copy(client) -> None:
506
+ """What the langgraph-server auth hook asks before a run without input or from a
507
+ checkpoint, and before a copy."""
508
+ end = await _pause(client)
509
+ for refusal in (RUNTIME.replay_refusal, RUNTIME.copy_refusal):
510
+ assert await refusal(end["thread_id"]) == "the thread has approvals of gated API calls"
511
+ r = await client.post("/chat", json={"message": "hello"}, headers=_as("alice"))
512
+ assert r.status_code == 200, r.text
513
+ thread = parse_sse(r.text)[-1][1]["thread_id"]
514
+ assert await RUNTIME.replay_refusal(thread) is None
515
+ assert await RUNTIME.copy_refusal(thread) is None
516
+
517
+
518
+ async def test_approvals_a_failed_run_recorded_do_not_block_the_thread(client, monkeypatch) -> None:
519
+ record = RUNTIME._record_approvals
520
+
521
+ async def record_then_fail(*args: Any, **kwargs: Any) -> Any:
522
+ await record(*args, **kwargs)
523
+ raise RuntimeError("the database went away")
524
+
525
+ monkeypatch.setattr(RUNTIME, "_record_approvals", record_then_fail)
526
+ r = await client.post("/chat", json={"message": PROMPT}, headers=_as("alice"))
527
+ events = parse_sse(r.text)
528
+ assert events[-1][0] == "error", events
529
+ thread = events[0][1]["thread_id"]
530
+ monkeypatch.setattr(RUNTIME, "_record_approvals", record)
531
+ # Nobody was told about the approval: it is expired, not left pending.
532
+ listed = (await client.get(f"/threads/{thread}/approvals", headers=_as("alice"))).json()
533
+ assert [a["status"] for a in listed] == ["expired"]
534
+ r = await client.post(
535
+ "/chat", json={"message": "hello", "thread_id": thread}, headers=_as("alice")
536
+ )
537
+ assert r.status_code == 200 and parse_sse(r.text)[-1][1]["status"] == "ok"
538
+ assert SENT == []
539
+
540
+
541
+ # --- who decides ---------------------------------------------------------------------
542
+
543
+
544
+ async def test_only_an_approver_decides(client) -> None:
545
+ end = await _pause(client)
546
+ for user, roles in (("bob", "user"), ("eve", "admin,support")):
547
+ r = await _decide(client, end, "approve", user=user, roles=roles)
548
+ assert r.status_code == 403 and r.json()["code"] == "not_an_approver", (user, r.text)
549
+ # A principal with a listed role decides (four eyes), and the run still acts
550
+ # as the requester.
551
+ r = await _decide(client, end, "approve", user="carol", roles="ops")
552
+ assert r.status_code == 200, r.text
553
+ assert len(SENT) == 1 and SEEN_BY_TOOL == ["alice", "alice"]
554
+ approvals = (
555
+ await client.get(f"/threads/{end['thread_id']}/approvals", headers=_as("alice"))
556
+ ).json()
557
+ assert approvals[0]["decided_by"] == Principal(id="carol").hashed_id()
558
+
559
+
560
+ async def test_four_eyes_the_requester_cannot_approve_their_own_call(client, tmp_path) -> None:
561
+ _write_policy(tmp_path / "api-policy.yaml", '["role:ops"]')
562
+ end = await _pause(client, user="alice")
563
+ assert end["approval"]["approvers"] == ["role:ops"]
564
+ # Holding the role does not make the requester a second pair of eyes.
565
+ r = await _decide(client, end, "approve", user="alice", roles="ops")
566
+ assert r.status_code == 403
567
+ r = await _decide(client, end, "approve", user="carol", roles="ops")
568
+ assert r.status_code == 200 and len(SENT) == 1
569
+
570
+
571
+ # One API, other approvers for other calls: the requester confirms updates and
572
+ # cancellations, and a second person holding role:admin approves new orders.
573
+ RULES_POLICY = """
574
+ apis:
575
+ shop:
576
+ base_url_env: SHOP_API_BASE_URL
577
+ auth: none
578
+ allowed_methods: [GET, POST, PATCH]
579
+ approval:
580
+ - required_for:
581
+ operations:
582
+ - {operationId: updateOrder, path: "/orders/{order_id}", methods: [PATCH]}
583
+ - {operationId: cancelOrder, path: "/orders/{order_id}/cancel", methods: [POST]}
584
+ approvers: [requester]
585
+ - required_for:
586
+ operations:
587
+ - {operationId: createOrder, path: /orders, methods: [POST]}
588
+ approvers: ["role:admin"]
589
+ timeout_s: 3600
590
+ """
591
+
592
+
593
+ @tool
594
+ async def place_order(item: str, runtime: ToolRuntime[Any]) -> str:
595
+ """Place a new purchase of an item."""
596
+ context = getattr(runtime, "context", None)
597
+ SEEN_BY_TOOL.append(str(getattr(context, "principal_id", "")))
598
+ client = get_client("shop", context=context, transport=httpx.MockTransport(_upstream))
599
+ return json.dumps(
600
+ await client.post("/orders", operation_id="createOrder", json_body={"item": item})
601
+ )
602
+
603
+
604
+ @tool
605
+ async def amend_order(order_id: str, runtime: ToolRuntime[Any]) -> str:
606
+ """Amend an existing purchase with a gift note."""
607
+ context = getattr(runtime, "context", None)
608
+ client = get_client("shop", context=context, transport=httpx.MockTransport(_upstream))
609
+ data = await client.patch(
610
+ "/orders/{order_id}",
611
+ operation_id="updateOrder",
612
+ path_params={"order_id": order_id},
613
+ json_body={"note": "gift"},
614
+ )
615
+ return json.dumps(data)
616
+
617
+
618
+ async def test_each_call_waits_for_the_approvers_of_the_rule_that_gates_it(
619
+ client, tmp_path, use_test_tools
620
+ ) -> None:
621
+ (tmp_path / "api-policy.yaml").write_text(RULES_POLICY, encoding="utf-8")
622
+ reset_policy_cache()
623
+ use_test_tools(cancel_order, place_order, amend_order)
624
+ admin = ("root", "admin")
625
+
626
+ # An update: the requester's rule. Neither another user nor an admin decides it.
627
+ end = await _pause(client, message="Amend the gift note for 7")
628
+ approval = end["approval"]
629
+ assert (approval["operation_id"], approval["approvers"]) == ("updateOrder", ["requester"])
630
+ for user, roles in (("bob", "user"), admin):
631
+ r = await _decide(client, end, "approve", user=user, roles=roles)
632
+ assert r.status_code == 403 and r.json()["code"] == "not_an_approver", (user, r.text)
633
+ assert SENT == []
634
+ r = await _decide(client, end, "approve")
635
+ assert r.status_code == 200, r.text
636
+ assert [(s.method, s.url.path) for s in SENT] == [("PATCH", "/orders/7")]
637
+
638
+ # A cancellation: the requester's rule too; an admin may not decide it either.
639
+ end = await _pause(client)
640
+ assert end["approval"]["approvers"] == ["requester"]
641
+ r = await _decide(client, end, "reject", user=admin[0], roles=admin[1])
642
+ assert r.status_code == 403
643
+ r = await _decide(client, end, "reject", comment="changed my mind")
644
+ assert r.status_code == 200 and len(SENT) == 1
645
+
646
+ # A new order: role:admin's rule, with its own expiry. The requester may not approve
647
+ # it, even holding the role (four eyes); another admin may.
648
+ end = await _pause(client, message="Place a new one for WIDGET-1")
649
+ approval = end["approval"]
650
+ assert (approval["operation_id"], approval["approvers"]) == ("createOrder", ["role:admin"])
651
+ assert approval["body"] == {"item": "WIDGET-1"}
652
+ lifetime = datetime.fromisoformat(approval["expires_at"]) - datetime.fromisoformat(
653
+ approval["created_at"]
654
+ )
655
+ assert lifetime == timedelta(seconds=3600)
656
+ for user, roles in (("alice", "user"), ("alice", "admin"), ("bob", "ops")):
657
+ r = await _decide(client, end, "approve", user=user, roles=roles)
658
+ assert r.status_code == 403, (user, roles, r.text)
659
+ listed = await client.get("/approvals?status=pending", headers=_as(*admin))
660
+ assert approval["approval_id"] in [a["approval_id"] for a in listed.json()]
661
+ r = await _decide(client, end, "approve", user=admin[0], roles=admin[1])
662
+ assert r.status_code == 200, r.text
663
+ assert [(s.method, s.url.path) for s in SENT] == [("PATCH", "/orders/7"), ("POST", "/orders")]
664
+ assert json.loads(SENT[-1].content) == {"item": "WIDGET-1"}
665
+ assert SEEN_BY_TOOL[-1] == "alice" # the resumed run still acts as the requester
666
+ record = await RUNTIME.approvals.get(approval["approval_id"])
667
+ assert record is not None and record.approvers == ["role:admin"]
668
+ assert record.decided_by == Principal(id="root").hashed_id()
669
+
670
+
671
+ async def test_listing_follows_who_may_see(client, monkeypatch) -> None:
672
+ end = await _pause(client)
673
+ thread = end["thread_id"]
674
+ approval_id = end["approval"]["approval_id"]
675
+ # A decider sees it, with the call; a stranger does not.
676
+ ops = await client.get(f"/threads/{thread}/approvals", headers=_as("carol", "ops"))
677
+ assert ops.status_code == 200 and ops.json()[0]["body"]["reason"] == "customer asked"
678
+ stranger = await client.get(f"/threads/{thread}/approvals", headers=_as("bob"))
679
+ assert stranger.status_code == 403
680
+ # Read-across roles list it, without the call's content, and cannot decide.
681
+ monkeypatch.setenv("AUTH_READ_ACROSS_ROLES", "auditor")
682
+ auditor = await client.get(f"/threads/{thread}/approvals", headers=_as("ann", "auditor"))
683
+ assert auditor.status_code == 200
684
+ assert auditor.json()[0]["approval_id"] == approval_id and "body" not in auditor.json()[0]
685
+ r = await _decide(client, end, "approve", user="ann", roles="auditor")
686
+ assert r.status_code == 403
687
+ # Across threads: the requester and the ops role see it, bob does not.
688
+ for user, roles, seen in (
689
+ ("alice", "user", True),
690
+ ("carol", "ops", True),
691
+ ("bob", "user", False),
692
+ ):
693
+ listed = await client.get("/approvals?status=pending", headers=_as(user, roles))
694
+ assert listed.status_code == 200
695
+ assert (approval_id in [a["approval_id"] for a in listed.json()]) is seen, user
696
+ assert (await client.get("/approvals?status=bogus", headers=_as("alice"))).status_code == 422
697
+ unknown = await client.get(f"/threads/{uuid.uuid4()}/approvals", headers=_as("alice"))
698
+ assert unknown.status_code == 404
699
+
700
+
701
+ async def test_an_unknown_or_foreign_approval_is_404(client) -> None:
702
+ end = await _pause(client)
703
+ other = await _pause(client, user="dora")
704
+ # Another thread's approval id on this thread's route.
705
+ r = await client.post(
706
+ f"/threads/{end['thread_id']}/approvals/{other['approval']['approval_id']}",
707
+ json={"decision": "approve"},
708
+ headers=_as("alice"),
709
+ )
710
+ assert r.status_code == 404 and r.json()["code"] == "approval_not_found"
711
+ r = await client.post(
712
+ f"/threads/{end['thread_id']}/approvals/{uuid.uuid4().hex}",
713
+ json={"decision": "approve"},
714
+ headers=_as("alice"),
715
+ )
716
+ assert r.status_code == 404
717
+ bad = await client.post(
718
+ f"/threads/{end['thread_id']}/approvals/{end['approval']['approval_id']}",
719
+ json={"decision": "maybe"},
720
+ headers=_as("alice"),
721
+ )
722
+ assert bad.status_code == 422
723
+ assert SENT == []
724
+
725
+
726
+ # --- the thread while pending ------------------------------------------------------------
727
+
728
+
729
+ async def test_a_pending_approval_blocks_new_messages(client) -> None:
730
+ end = await _pause(client)
731
+ r = await client.post(
732
+ "/chat", json={"message": "hello", "thread_id": end["thread_id"]}, headers=_as("alice")
733
+ )
734
+ assert r.status_code == 409
735
+ body = r.json()
736
+ assert body["code"] == "approval_pending"
737
+ assert [a["approval_id"] for a in body["approvals"]] == [end["approval"]["approval_id"]]
738
+ # Other threads are not affected.
739
+ other = await client.post("/chat", json={"message": "hello"}, headers=_as("alice"))
740
+ assert other.status_code == 200
741
+
742
+
743
+ async def test_two_decisions_race_and_one_wins(client) -> None:
744
+ end = await _pause(client)
745
+ first, second = await asyncio.gather(
746
+ _decide(client, end, "approve", user="alice"),
747
+ _decide(client, end, "reject", user="carol", roles="ops"),
748
+ )
749
+ statuses = sorted([first.status_code, second.status_code])
750
+ assert statuses == [200, 409], (first.text, second.text)
751
+ loser = first if first.status_code == 409 else second
752
+ assert loser.json()["code"] in ("approval_not_pending", "thread_busy")
753
+ assert len(SENT) <= 1
754
+ record = await RUNTIME.approvals.get(end["approval"]["approval_id"])
755
+ assert record is not None
756
+ assert len(SENT) == (1 if record.status == "approved" else 0)
757
+
758
+
759
+ async def test_an_approval_the_run_no_longer_waits_for_cannot_be_decided(client) -> None:
760
+ end = await _pause(client)
761
+ # The paused call gets a result some other way (as a history repair would).
762
+ from {{cookiecutter.agent_directory}}.app_utils.chat import ChatRequest
763
+
764
+ lease = await RUNTIME.acquire_thread(end["thread_id"]) # writes need the run lock
765
+ try:
766
+ await RUNTIME._repair_history(ChatRequest(message=""), end["thread_id"], "closed")
767
+ finally:
768
+ await lease.release()
769
+ r = await _decide(client, end, "approve")
770
+ assert r.status_code == 409 and r.json() == {
771
+ "code": "approval_not_pending",
772
+ "detail": "The run no longer waits for this approval.",
773
+ "status": "expired",
774
+ }
775
+ listed = await client.get(f"/threads/{end['thread_id']}/approvals", headers=_as("alice"))
776
+ assert listed.json()[0]["status"] == "expired"
777
+ # Not pending any more: the thread takes messages again.
778
+ r = await client.post(
779
+ "/chat", json={"message": "hello", "thread_id": end["thread_id"]}, headers=_as("alice")
780
+ )
781
+ assert r.status_code == 200
782
+ assert SENT == []
783
+
784
+
785
+ @tool
786
+ async def ask_a_person(question: str) -> str:
787
+ """Ask a person something (pauses the graph with a plain interrupt)."""
788
+ from langgraph.types import interrupt
789
+
790
+ return str(interrupt({"question": question}))
791
+
792
+
793
+ async def test_a_pause_that_is_not_an_approval_ends_the_run_cleanly(client, use_test_tools) -> None:
794
+ use_test_tools(ask_a_person)
795
+ r = await client.post(
796
+ "/chat", json={"message": "Ask a person about the weather"}, headers=_as("alice")
797
+ )
798
+ events = parse_sse(r.text)
799
+ assert events[-1][0] == "error" and events[-1][1]["code"] == "unsupported_interrupt"
800
+ thread = events[0][1]["thread_id"]
801
+ assert (await client.get(f"/threads/{thread}/approvals", headers=_as("alice"))).json() == []
802
+ # The paused call was answered: the thread goes on.
803
+ r = await client.post(
804
+ "/chat", json={"message": "hello", "thread_id": thread}, headers=_as("alice")
805
+ )
806
+ assert parse_sse(r.text)[-1][1]["status"] == "ok"
807
+
808
+
809
+ async def test_deleting_the_thread_deletes_its_approvals(client) -> None:
810
+ end = await _pause(client)
811
+ approval_id = end["approval"]["approval_id"]
812
+ r = await client.delete(f"/threads/{end['thread_id']}", headers=_as("alice"))
813
+ assert r.status_code == 204
814
+ assert await RUNTIME.approvals.get(approval_id) is None
815
+ assert await RUNTIME.approvals.for_thread(end["thread_id"]) == []
816
+ r = await _decide(client, end, "approve")
817
+ assert r.status_code == 404
818
+ listed = await client.get("/approvals", headers=_as("alice"))
819
+ assert approval_id not in [a["approval_id"] for a in listed.json()]
820
+ assert SENT == []
821
+
822
+
823
+ async def test_the_approval_metrics_count(client, monkeypatch) -> None:
824
+ def count(event: str) -> float:
825
+ from {{cookiecutter.agent_directory}}.app_utils.metrics import APPROVALS
826
+
827
+ return APPROVALS.labels(event)._value.get()
828
+
829
+ before = {e: count(e) for e in ("requested", "approved", "rejected", "expired")}
830
+ await _decide(client, await _pause(client), "approve")
831
+ await _decide(client, await _pause(client), "reject")
832
+ await _pause(client)
833
+ later = approvals_module.utcnow() + timedelta(seconds=61)
834
+ monkeypatch.setattr(RUNTIME.approvals, "_clock", lambda: later)
835
+ await RUNTIME.sweep_approvals()
836
+ after = {e: count(e) for e in before}
837
+ assert {e: after[e] - before[e] for e in before} == {
838
+ "requested": 3,
839
+ "approved": 1,
840
+ "rejected": 1,
841
+ "expired": 1,
842
+ }
843
+ text = (await client.get("/metrics")).text
844
+ assert 'agent_approvals_total{event="requested"}' in text
845
+ assert 'agent_runs_total{status="awaiting_approval"}' in text
846
+
847
+
848
+ async def test_the_playground_and_the_prompt_know_about_approvals(client) -> None:
849
+ page = (await client.get("/playground")).text
850
+ assert "awaiting_approval" in page and "/approvals/" in page
851
+ assert "Approve" in page and "Reject" in page
852
+ from {{cookiecutter.agent_directory}}.agent import SYSTEM_PROMPT
853
+
854
+ assert "need a person's approval" in SYSTEM_PROMPT
855
+ assert "was not approved" in SYSTEM_PROMPT
856
+
857
+
858
+ # --- two gated calls in one step ---------------------------------------------------------
859
+
860
+
861
+ class TwoCancels(BaseChatModel):
862
+ """Asks for two cancellations at once (parallel tool calls), then answers."""
863
+
864
+ @property
865
+ def _llm_type(self) -> str:
866
+ return "two-cancels"
867
+
868
+ def bind_tools(self, tools: Any, **kwargs: Any) -> Any:
869
+ return self
870
+
871
+ def _generate(self, messages: list[Any], stop: Any = None, run_manager: Any = None, **kw: Any):
872
+ if isinstance(messages[-1], ToolMessage):
873
+ reply = AIMessage(content="Done.")
874
+ else:
875
+ reply = AIMessage(
876
+ content="Cancelling orders 7 and 8, as you asked.",
877
+ tool_calls=[
878
+ {"name": "cancel_order", "args": {"order_id": "7"}, "id": "c7"},
879
+ {"name": "cancel_order", "args": {"order_id": "8"}, "id": "c8"},
880
+ ],
881
+ )
882
+ return ChatResult(generations=[ChatGeneration(message=reply)])
883
+
884
+
885
+ @pytest.fixture
886
+ def two_calls(client, monkeypatch) -> None:
887
+ from langchain.agents import create_agent
888
+
889
+ from {{cookiecutter.agent_directory}} import agent
890
+
891
+ graph = create_agent(
892
+ model=TwoCancels(),
893
+ tools=[cancel_order],
894
+ system_prompt=agent.SYSTEM_PROMPT,
895
+ middleware=agent.middleware(),
896
+ context_schema=agent.AgentContext,
897
+ )
898
+ graph.checkpointer = agent.graph.checkpointer
899
+ monkeypatch.setattr(agent, "graph", graph)
900
+
901
+
902
+ async def _expire_now(approval_id: str) -> None:
903
+ store = RUNTIME.approvals
904
+ assert store is not None
905
+ past = approvals_module.utcnow() - timedelta(seconds=1)
906
+ if store.db.is_postgres:
907
+ await store.db.execute(
908
+ f"UPDATE {store.table} SET expires_at = %s WHERE approval_id = %s", (past, approval_id)
909
+ )
910
+ else:
911
+ store._memory[approval_id].expires_at = past
912
+
913
+
914
+ async def test_two_gated_calls_wait_for_their_own_decisions(client, two_calls) -> None:
915
+ end = await _pause(client)
916
+ by_path = {a["path"]: a for a in end["approvals"]}
917
+ assert set(by_path) == {"/orders/7/cancel", "/orders/8/cancel"}
918
+ assert by_path["/orders/7/cancel"]["reason"] == (
919
+ "cancel_order: Cancelling orders 7 and 8, as you asked."
920
+ )
921
+ first = {**end, "approval": by_path["/orders/7/cancel"]}
922
+ r = await _decide(client, first, "approve")
923
+ assert r.status_code == 200, r.text
924
+ events = parse_sse(r.text)
925
+ # The other call still waits, for the same approval (not a second one).
926
+ again = events[-1][1]
927
+ assert again["status"] == "awaiting_approval"
928
+ assert [a["approval_id"] for a in again["approvals"]] == [
929
+ by_path["/orders/8/cancel"]["approval_id"]
930
+ ]
931
+ assert [req.url.path for req in SENT] == ["/orders/7/cancel"]
932
+ r = await _decide(client, {**end, "approval": by_path["/orders/8/cancel"]}, "approve")
933
+ assert r.status_code == 200, r.text
934
+ assert parse_sse(r.text)[-1][1]["status"] == "ok"
935
+ # Each approved call was sent once: the first was not sent again on the second resume.
936
+ assert [req.url.path for req in SENT] == ["/orders/7/cancel", "/orders/8/cancel"]
937
+
938
+
939
+ async def test_a_decision_also_closes_the_calls_whose_approval_expired(client, two_calls) -> None:
940
+ end = await _pause(client)
941
+ by_path = {a["path"]: a for a in end["approvals"]}
942
+ await _expire_now(by_path["/orders/8/cancel"]["approval_id"])
943
+ r = await _decide(client, {**end, "approval": by_path["/orders/7/cancel"]}, "approve")
944
+ assert r.status_code == 200, r.text
945
+ events = parse_sse(r.text)
946
+ results = {d["id"]: d for e, d in events if e == "tool.result"}
947
+ assert results["c7"]["is_error"] is False and results["c8"]["is_error"] is True
948
+ assert events[-1][1]["status"] == "ok"
949
+ assert [req.url.path for req in SENT] == ["/orders/7/cancel"]
950
+ listed = (
951
+ await client.get(f"/threads/{end['thread_id']}/approvals", headers=_as("alice"))
952
+ ).json()
953
+ assert {a["path"]: a["status"] for a in listed} == {
954
+ "/orders/7/cancel": "approved",
955
+ "/orders/8/cancel": "expired",
956
+ }
957
+
958
+
959
+ @pytest.mark.parametrize("change", list(POLICY_CHANGES))
960
+ async def test_an_expired_approval_stops_its_call_whatever_the_policy_says_by_then(
961
+ client, two_calls, tmp_path, change: str
962
+ ) -> None:
963
+ end = await _pause(client)
964
+ by_path = {a["path"]: a for a in end["approvals"]}
965
+ await _expire_now(by_path["/orders/8/cancel"]["approval_id"])
966
+ _change_policy(tmp_path, change)
967
+ # Deciding the other call resumes the run: the expired one gets "expired".
968
+ r = await _decide(client, {**end, "approval": by_path["/orders/7/cancel"]}, "reject")
969
+ assert r.status_code == 200, r.text
970
+ events = parse_sse(r.text)
971
+ results = {d["id"]: d for e, d in events if e == "tool.result"}
972
+ assert results["c8"]["is_error"] is True
973
+ assert REFUSED_BY.get(change, DECIDED_BY["expired"]) in results["c8"]["result"]
974
+ assert results["c7"]["is_error"] is True
975
+ assert events[-1][1]["status"] == "ok"
976
+ assert SENT == []
977
+
978
+
979
+ @pytest.mark.parametrize("change", list(REFUSED_BY))
980
+ @pytest.mark.parametrize("decision", ["approve", "reject"])
981
+ async def test_a_waiting_call_the_policy_now_refuses_does_not_block_the_thread(
982
+ client, two_calls, tmp_path, change: str, decision: str
983
+ ) -> None:
984
+ """The other call runs again on the resume and is refused before it could pause again:
985
+ its approval waits for nothing, so it is expired and the thread takes new messages."""
986
+ end = await _pause(client)
987
+ by_path = {a["path"]: a for a in end["approvals"]}
988
+ _change_policy(tmp_path, change)
989
+ r = await _decide(client, {**end, "approval": by_path["/orders/7/cancel"]}, decision)
990
+ assert r.status_code == 200, r.text
991
+ events = parse_sse(r.text)
992
+ results = {d["id"]: d for e, d in events if e == "tool.result"}
993
+ assert REFUSED_BY[change] in results["c8"]["result"]
994
+ assert events[-1][1]["status"] == "ok"
995
+ listed = (
996
+ await client.get(f"/threads/{end['thread_id']}/approvals", headers=_as("alice"))
997
+ ).json()
998
+ assert {a["path"]: a["status"] for a in listed} == {
999
+ "/orders/7/cancel": "approved" if decision == "approve" else "rejected",
1000
+ "/orders/8/cancel": "expired",
1001
+ }
1002
+ r = await client.post(
1003
+ "/chat", json={"message": "hello", "thread_id": end["thread_id"]}, headers=_as("alice")
1004
+ )
1005
+ assert r.status_code == 200, r.text
1006
+ assert parse_sse(r.text)[-1][1]["status"] == "ok"
1007
+ assert SENT == []
1008
+
1009
+
1010
+ async def test_a_call_still_waiting_waits_on_when_its_gate_is_removed(
1011
+ client, two_calls, tmp_path
1012
+ ) -> None:
1013
+ end = await _pause(client)
1014
+ by_path = {a["path"]: a for a in end["approvals"]}
1015
+ _change_policy(tmp_path, "gate removed")
1016
+ r = await _decide(client, {**end, "approval": by_path["/orders/7/cancel"]}, "approve")
1017
+ assert r.status_code == 200, r.text
1018
+ events = parse_sse(r.text)
1019
+ results = {d["id"]: d for e, d in events if e == "tool.result"}
1020
+ assert DECIDED_BY["approve"] in results["c7"]["result"]
1021
+ # The other call was not sent on its own: it still waits, for the same approval.
1022
+ again = events[-1][1]
1023
+ assert again["status"] == "awaiting_approval"
1024
+ waiting = by_path["/orders/8/cancel"]
1025
+ assert [a["approval_id"] for a in again["approvals"]] == [waiting["approval_id"]]
1026
+ assert again["approvals"][0]["approvers"] == ["requester", "role:ops"]
1027
+ assert SENT == []
1028
+ r = await _decide(client, {**end, "approval": waiting}, "reject")
1029
+ assert r.status_code == 200, r.text
1030
+ events = parse_sse(r.text)
1031
+ results = {d["id"]: d for e, d in events if e == "tool.result"}
1032
+ assert DECIDED_BY["reject"] in results["c8"]["result"]
1033
+ assert events[-1][1]["status"] == "ok"
1034
+ assert SENT == []
1035
+
1036
+
1037
+ # --- A2A: input-required and back ----------------------------------------------------
1038
+
1039
+
1040
+ async def _rpc(client: httpx.AsyncClient, user: str, method: str, params: dict) -> dict:
1041
+ r = await client.post(
1042
+ A2A_PATH,
1043
+ json={"jsonrpc": "2.0", "id": "1", "method": method, "params": params},
1044
+ headers={**_as(user), "A2A-Version": "1.0"},
1045
+ )
1046
+ return r.json()
1047
+
1048
+
1049
+ def _parts_data(message: dict[str, Any]) -> dict[str, Any]:
1050
+ return next(p["data"] for p in message["parts"] if "data" in p)
1051
+
1052
+
1053
+ async def test_a2a_input_required_round_trip(client) -> None:
1054
+ # A user of its own: the A2A task store lives as long as the app.
1055
+ user = f"ann-{uuid.uuid4().hex[:8]}"
1056
+ sent = await _rpc(
1057
+ client,
1058
+ user,
1059
+ "SendMessage",
1060
+ {"message": {"messageId": "m-1", "role": "ROLE_USER", "parts": [{"text": PROMPT}]}},
1061
+ )
1062
+ task = sent["result"]["task"]
1063
+ assert task["status"]["state"] == "TASK_STATE_INPUT_REQUIRED", task
1064
+ data = _parts_data(task["status"]["message"])
1065
+ assert data["type"] == "approval_request"
1066
+ approval = data["approval"]
1067
+ assert approval["path"] == "/orders/7/cancel" and approval["status"] == "pending"
1068
+ assert SENT == []
1069
+
1070
+ # A text message meanwhile: still waiting for the decision.
1071
+ waiting = await _rpc(
1072
+ client,
1073
+ user,
1074
+ "SendMessage",
1075
+ {
1076
+ "message": {
1077
+ "messageId": "m-2",
1078
+ "role": "ROLE_USER",
1079
+ "taskId": task["id"],
1080
+ "contextId": task["contextId"],
1081
+ "parts": [{"text": "hello?"}],
1082
+ }
1083
+ },
1084
+ )
1085
+ assert waiting["result"]["task"]["status"]["state"] == "TASK_STATE_INPUT_REQUIRED"
1086
+ assert SENT == []
1087
+
1088
+ # A malformed decision is invalid params.
1089
+ bad = await _rpc(
1090
+ client,
1091
+ user,
1092
+ "SendMessage",
1093
+ {
1094
+ "message": {
1095
+ "messageId": "m-3",
1096
+ "role": "ROLE_USER",
1097
+ "taskId": task["id"],
1098
+ "contextId": task["contextId"],
1099
+ "parts": [{"data": {"approval_id": approval["approval_id"], "decision": "yes"}}],
1100
+ }
1101
+ },
1102
+ )
1103
+ assert bad["error"]["code"] == -32602
1104
+
1105
+ done = await _rpc(
1106
+ client,
1107
+ user,
1108
+ "SendMessage",
1109
+ {
1110
+ "message": {
1111
+ "messageId": "m-4",
1112
+ "role": "ROLE_USER",
1113
+ "taskId": task["id"],
1114
+ "contextId": task["contextId"],
1115
+ "parts": [
1116
+ {"data": {"approval_id": approval["approval_id"], "decision": "approve"}}
1117
+ ],
1118
+ }
1119
+ },
1120
+ )
1121
+ finished = done["result"]["task"]
1122
+ assert finished["status"]["state"] == "TASK_STATE_COMPLETED", finished
1123
+ assert len(SENT) == 1 and SENT[0].url.path == "/orders/7/cancel"
1124
+ text = "".join(p.get("text", "") for a in finished["artifacts"] for p in a["parts"])
1125
+ assert "/orders/7/cancel" in text
1126
+
1127
+
1128
+ class NoDecidePolicy(HeaderPolicy):
1129
+ """The header policy, refusing the `approval.decide` action."""
1130
+
1131
+ async def authorize(self, principal: Principal, action: str, resource: str | None) -> None:
1132
+ if action == "approval.decide":
1133
+ raise HTTPException(403, "approval.decide is not allowed for you")
1134
+
1135
+
1136
+ async def test_a2a_decision_needs_the_approval_decide_action(client, monkeypatch) -> None:
1137
+ user = f"ann-{uuid.uuid4().hex[:8]}"
1138
+ sent = await _rpc(
1139
+ client,
1140
+ user,
1141
+ "SendMessage",
1142
+ {"message": {"messageId": "m-1", "role": "ROLE_USER", "parts": [{"text": PROMPT}]}},
1143
+ )
1144
+ task = sent["result"]["task"]
1145
+ approval = _parts_data(task["status"]["message"])["approval"]
1146
+ monkeypatch.setattr(auth_module, "get_policy", lambda: NoDecidePolicy())
1147
+ refused = await _rpc(
1148
+ client,
1149
+ user,
1150
+ "SendMessage",
1151
+ {
1152
+ "message": {
1153
+ "messageId": "m-2",
1154
+ "role": "ROLE_USER",
1155
+ "taskId": task["id"],
1156
+ "contextId": task["contextId"],
1157
+ "parts": [
1158
+ {"data": {"approval_id": approval["approval_id"], "decision": "approve"}}
1159
+ ],
1160
+ }
1161
+ },
1162
+ )
1163
+ after = refused["result"]["task"]
1164
+ assert after["status"]["state"] == "TASK_STATE_INPUT_REQUIRED", after
1165
+ note = "".join(p.get("text", "") for p in after["status"]["message"]["parts"])
1166
+ assert "approval.decide is not allowed" in note
1167
+ # The HTTP route refuses it the same way; the approval stays pending.
1168
+ paused_on = {"thread_id": task["contextId"], "approval": approval}
1169
+ r = await _decide(client, paused_on, "approve", user)
1170
+ assert r.status_code == 403
1171
+ assert SENT == []
1172
+ record = await RUNTIME.approvals.get(approval["approval_id"])
1173
+ assert record is not None and record.status == "pending"
1174
+
1175
+
1176
+ async def test_a2a_decision_by_a_requester_the_policy_does_not_list_is_refused(
1177
+ client, tmp_path
1178
+ ) -> None:
1179
+ user = f"ann-{uuid.uuid4().hex[:8]}"
1180
+ _write_policy(tmp_path / "api-policy.yaml", '["role:ops"]')
1181
+ sent = await _rpc(
1182
+ client,
1183
+ user,
1184
+ "SendMessage",
1185
+ {"message": {"messageId": "m-1", "role": "ROLE_USER", "parts": [{"text": PROMPT}]}},
1186
+ )
1187
+ task = sent["result"]["task"]
1188
+ approval = _parts_data(task["status"]["message"])["approval"]
1189
+ refused = await _rpc(
1190
+ client,
1191
+ user,
1192
+ "SendMessage",
1193
+ {
1194
+ "message": {
1195
+ "messageId": "m-2",
1196
+ "role": "ROLE_USER",
1197
+ "taskId": task["id"],
1198
+ "contextId": task["contextId"],
1199
+ "parts": [
1200
+ {"data": {"approval_id": approval["approval_id"], "decision": "approve"}}
1201
+ ],
1202
+ }
1203
+ },
1204
+ )
1205
+ after = refused["result"]["task"]
1206
+ assert after["status"]["state"] == "TASK_STATE_INPUT_REQUIRED"
1207
+ note = "".join(p.get("text", "") for p in after["status"]["message"]["parts"])
1208
+ assert "not_an_approver" in note
1209
+ assert SENT == []
1210
+
1211
+
1212
+ # --- A2A: text Postgres cannot store ------------------------------------------------------
1213
+
1214
+ NUL = "\x00"
1215
+ # A stored A2A task holds U+FFFD in place of a U+0000 on Postgres, which cannot store one.
1216
+ STORED_NUL = {"memory": NUL, "postgres": "\ufffd"}
1217
+ READINGS: list[str] = []
1218
+
1219
+
1220
+ @tool
1221
+ async def meter_reading(query: str) -> str:
1222
+ """Read the meter at a place (test-only: the reading holds a U+0000)."""
1223
+ READINGS.append(query)
1224
+ return f"meter at {query}:{NUL}42"
1225
+
1226
+
1227
+ def _a2a_message(text: str, **fields: Any) -> dict[str, Any]:
1228
+ return {
1229
+ "message": {
1230
+ "messageId": uuid.uuid4().hex,
1231
+ "role": "ROLE_USER",
1232
+ "parts": [{"text": text}],
1233
+ **fields,
1234
+ }
1235
+ }
1236
+
1237
+
1238
+ async def _stored(client: httpx.AsyncClient, user: str, task_id: str) -> dict[str, Any]:
1239
+ got = await _rpc(client, user, "GetTask", {"id": task_id})
1240
+ assert "result" in got, got
1241
+ return got["result"]
1242
+
1243
+
1244
+ async def test_an_a2a_task_holding_a_nul_completes_and_is_kept(
1245
+ client, database, use_test_tools
1246
+ ) -> None:
1247
+ """A U+0000 in a message, streamed or not, or in a tool's output: the task completes.
1248
+
1249
+ On Postgres the save of such a task failed (-32603) while its run, and the
1250
+ tool, went on; the task was then failed as if its process had died.
1251
+ """
1252
+ user = f"nul-{uuid.uuid4().hex[:8]}"
1253
+ stored = STORED_NUL[database]
1254
+
1255
+ sent = await _rpc(client, user, "SendMessage", _a2a_message(f"tell me{NUL} more"))
1256
+ assert sent["result"]["task"]["status"]["state"] == "TASK_STATE_COMPLETED", sent
1257
+ task = await _stored(client, user, sent["result"]["task"]["id"])
1258
+ assert task["status"]["state"] == "TASK_STATE_COMPLETED"
1259
+ assert task["history"][0]["parts"] == [{"text": f"tell me{stored} more"}]
1260
+
1261
+ r = await client.post(
1262
+ A2A_PATH,
1263
+ json={
1264
+ "jsonrpc": "2.0",
1265
+ "id": "1",
1266
+ "method": "SendStreamingMessage",
1267
+ "params": _a2a_message(f"stream me{NUL} please"),
1268
+ },
1269
+ headers={**_as(user), "A2A-Version": "1.0"},
1270
+ )
1271
+ events = [json.loads(line[5:]) for line in r.text.splitlines() if line.startswith("data:")]
1272
+ assert events and all("result" in event for event in events), r.text[-500:]
1273
+ last = events[-1]["result"]["statusUpdate"]
1274
+ assert last["status"]["state"] == "TASK_STATE_COMPLETED"
1275
+ streamed = await _stored(client, user, last["taskId"])
1276
+ assert streamed["status"]["state"] == "TASK_STATE_COMPLETED"
1277
+
1278
+ use_test_tools(meter_reading)
1279
+ READINGS.clear()
1280
+ sent = await _rpc(client, user, "SendMessage", _a2a_message("Take the meter reading for Paris"))
1281
+ assert sent["result"]["task"]["status"]["state"] == "TASK_STATE_COMPLETED", sent
1282
+ assert READINGS == ["Paris"]
1283
+ task = await _stored(client, user, sent["result"]["task"]["id"])
1284
+ assert task["status"]["state"] == "TASK_STATE_COMPLETED"
1285
+ reply = "".join(p.get("text", "") for a in task["artifacts"] for p in a["parts"])
1286
+ assert f"meter at Paris:{stored}42" in reply
1287
+
1288
+
1289
+ async def test_an_a2a_id_holding_a_nul_is_answered_like_any_other(client, database) -> None:
1290
+ """A contextId holding a U+0000 fails its task (it is not a valid one); a task id is unknown."""
1291
+ user = f"nul-{uuid.uuid4().hex[:8]}"
1292
+ context = f"ab{NUL}cd"
1293
+ sent = await _rpc(client, user, "SendMessage", _a2a_message("hello", contextId=context))
1294
+ failed = sent["result"]["task"]
1295
+ assert failed["status"]["state"] == "TASK_STATE_FAILED", sent
1296
+ assert "Invalid contextId" in failed["status"]["message"]["parts"][0]["text"]
1297
+ assert (await _stored(client, user, failed["id"]))["status"]["state"] == "TASK_STATE_FAILED"
1298
+ listed = await _rpc(client, user, "ListTasks", {"contextId": context})
1299
+ assert [t["id"] for t in listed["result"]["tasks"]] == [failed["id"]]
1300
+
1301
+ unknown = await _rpc(client, user, "GetTask", {"id": f"no{NUL}such"})
1302
+ assert unknown["error"]["code"] == -32001, unknown
1303
+ named = await _rpc(client, user, "SendMessage", _a2a_message("hi", taskId=f"no{NUL}such"))
1304
+ assert named["error"]["code"] == -32001, named
1305
+
1306
+
1307
+ # --- A tool's output that is not valid Unicode ---------------------------------------------
1308
+
1309
+ LONE_SURROGATE = "\ud800" # what an upstream JSON "\ud800" escape decodes to
1310
+ GAUGES: list[str] = []
1311
+
1312
+
1313
+ @tool
1314
+ async def gauge_reading(query: str) -> str:
1315
+ """Read the gauge at a place (test-only: the reading holds a lone surrogate)."""
1316
+ GAUGES.append(query)
1317
+ return f"gauge at {query}:{LONE_SURROGATE}42"
1318
+
1319
+
1320
+ async def test_a_tool_output_holding_a_lone_surrogate_completes_the_run(
1321
+ client, database, use_test_tools
1322
+ ) -> None:
1323
+ """UTF-8 cannot encode a lone surrogate: the reply holds U+FFFD in its place.
1324
+
1325
+ The /chat stream ended at the `tool.result` event, and the A2A task failed
1326
+ with -32603 and the Python exception text, both after the tool had acted.
1327
+ """
1328
+ user = f"surrogate-{uuid.uuid4().hex[:8]}"
1329
+ use_test_tools(gauge_reading)
1330
+ GAUGES.clear()
1331
+ replaced = "gauge at Bergen:\ufffd42"
1332
+
1333
+ r = await client.post(
1334
+ "/chat", json={"message": "Check the gauge for Bergen"}, headers=_as(user)
1335
+ )
1336
+ assert r.status_code == 200, r.text
1337
+ events = parse_sse(r.text)
1338
+ names = [e for e, _ in events]
1339
+ assert names[0] == "message.start" and names[-1] == "message.end", events
1340
+ assert events[-1][1]["status"] == "ok"
1341
+ assert [d["result"] for e, d in events if e == "tool.result"] == [replaced]
1342
+ assert replaced in "".join(d["text"] for e, d in events if e == "message.delta")
1343
+ thread = events[0][1]["thread_id"]
1344
+ history = await client.get(f"/threads/{thread}/messages", headers=_as(user))
1345
+ assert [m["content"] for m in history.json() if m["role"] == "tool"] == [replaced]
1346
+ assert GAUGES == ["Bergen"]
1347
+
1348
+ sent = await _rpc(client, user, "SendMessage", _a2a_message("Check the gauge for Oslo"))
1349
+ assert sent["result"]["task"]["status"]["state"] == "TASK_STATE_COMPLETED", sent
1350
+ task = await _stored(client, user, sent["result"]["task"]["id"])
1351
+ reply = "".join(p.get("text", "") for a in task["artifacts"] for p in a["parts"])
1352
+ assert "gauge at Oslo:\ufffd42" in reply
1353
+
1354
+ r = await client.post(
1355
+ A2A_PATH,
1356
+ json={
1357
+ "jsonrpc": "2.0",
1358
+ "id": "1",
1359
+ "method": "SendStreamingMessage",
1360
+ "params": _a2a_message("Check the gauge for Rome"),
1361
+ },
1362
+ headers={**_as(user), "A2A-Version": "1.0"},
1363
+ )
1364
+ events = [json.loads(line[5:]) for line in r.text.splitlines() if line.startswith("data:")]
1365
+ assert events and all("result" in event for event in events), r.text[-500:]
1366
+ assert events[-1]["result"]["statusUpdate"]["status"]["state"] == "TASK_STATE_COMPLETED"
1367
+ assert GAUGES == ["Bergen", "Oslo", "Rome"]