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,1536 @@
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
+ """Approvals of gated API calls: the records, who decides, the ledger, and the client's side.
16
+
17
+ The client's side runs a real graph (the fake model, one gated tool, an
18
+ in-memory checkpointer) that pauses in LangGraph's `interrupt()` and resumes
19
+ with decisions made here, the ledger being an `ApprovalStore`. Every store
20
+ test runs in memory, in memory kept in a file (as under `langgraph dev`), and
21
+ again on Postgres when `TEST_POSTGRES_DSN` is set (see
22
+ `tests/integration/test_postgres.py`).
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import json
29
+ import os
30
+ import uuid
31
+ from collections.abc import AsyncIterator, Iterator
32
+ from datetime import datetime, timedelta
33
+ from pathlib import Path
34
+ from typing import Any
35
+ from urllib.parse import urlsplit
36
+
37
+ os.environ.setdefault("MODEL_PROVIDER", "fake")
38
+
39
+ import httpx
40
+ import pytest
41
+ from langchain.agents import create_agent
42
+ from langchain.tools import ToolRuntime
43
+ from langchain_core.tools import tool
44
+ from langgraph.checkpoint.memory import InMemorySaver
45
+ from langgraph.types import Command
46
+
47
+ from {{cookiecutter.agent_directory}}.app_utils import api_client
48
+ from {{cookiecutter.agent_directory}}.app_utils.api_client import (
49
+ APPROVAL_DECISION,
50
+ APPROVAL_INTERRUPT,
51
+ ApiPolicyError,
52
+ BoundApproval,
53
+ call_hash,
54
+ canonical_call,
55
+ get_client,
56
+ redact_fields,
57
+ reset_policy_cache,
58
+ set_approval_ledger,
59
+ stated_purpose,
60
+ )
61
+ from {{cookiecutter.agent_directory}}.app_utils.approvals import (
62
+ APPROVED,
63
+ EXPIRED,
64
+ PENDING,
65
+ REJECTED,
66
+ ApprovalRecord,
67
+ ApprovalStore,
68
+ LedgerUnavailable,
69
+ approval_digest,
70
+ approval_view,
71
+ decide_refusal,
72
+ decision_value,
73
+ dev_ledger_path,
74
+ digest_refusal,
75
+ may_decide,
76
+ may_view,
77
+ record_from_interrupt,
78
+ resume_principal,
79
+ sees_call,
80
+ utcnow,
81
+ )
82
+ from {{cookiecutter.agent_directory}}.app_utils.approvals import _as_datetime as _as_datetime
83
+ from {{cookiecutter.agent_directory}}.app_utils.approvals import _row_of as _row_of
84
+ from {{cookiecutter.agent_directory}}.app_utils.auth import Actor, Principal
85
+ from {{cookiecutter.agent_directory}}.app_utils.db import Database
86
+ from {{cookiecutter.agent_directory}}.app_utils.threads import ThreadRecord
87
+
88
+ ALICE = Principal(
89
+ id="alice", roles=["user"], attributes={"tenant": "t1", "credentials": {"x": "s"}}
90
+ )
91
+
92
+ POLICY = """
93
+ apis:
94
+ shop:
95
+ base_url_env: SHOP_API_BASE_URL
96
+ auth: none
97
+ allowed_methods: [GET, POST]
98
+ approval:
99
+ required_for:
100
+ methods: [POST]
101
+ approvers: [requester, "role:ops"]
102
+ timeout_s: 60
103
+ """
104
+
105
+
106
+ def _interrupt_value(**overrides: Any) -> dict[str, Any]:
107
+ value = {
108
+ "type": APPROVAL_INTERRUPT,
109
+ "api": "shop",
110
+ "method": "POST",
111
+ "path": "/orders/7/cancel",
112
+ "query": {"notify": "yes"},
113
+ "body": {"reason": "asked"},
114
+ "operation_id": "cancelOrder",
115
+ "tool": "cancel_order",
116
+ "tool_call_id": "call-1",
117
+ "reason": "cancel_order: the customer asked",
118
+ "approvers": ["requester", "role:ops"],
119
+ "timeout_s": 60,
120
+ "rule": "approval.required_for.methods ['POST']",
121
+ "call_hash": "h1",
122
+ }
123
+ value.update(overrides)
124
+ return value
125
+
126
+
127
+ def _record(**overrides: Any) -> ApprovalRecord:
128
+ fields = {"interrupt_id": "i1", "thread_id": "t1", "run_id": "r1"}
129
+ value_overrides = {k: v for k, v in overrides.items() if k not in fields}
130
+ fields.update({k: v for k, v in overrides.items() if k in fields})
131
+ return record_from_interrupt(_interrupt_value(**value_overrides), requester=ALICE, **fields)
132
+
133
+
134
+ ADMIN_DSN = os.environ.get("TEST_POSTGRES_DSN", "")
135
+ # The time zone of the test databases' sessions: not UTC.
136
+ DB_TIME_ZONE = "America/New_York"
137
+
138
+
139
+ @pytest.fixture(params=["memory", "file", "postgres"])
140
+ async def store(request: pytest.FixtureRequest, tmp_path: Path) -> AsyncIterator[ApprovalStore]:
141
+ """The store in memory, in memory kept in a file (`langgraph dev`), and on Postgres
142
+ when `TEST_POSTGRES_DSN` is set (a fresh database)."""
143
+ if request.param == "memory":
144
+ yield ApprovalStore(Database("memory"))
145
+ return
146
+ if request.param == "file":
147
+ kept = ApprovalStore(Database("memory"), path=tmp_path / ".langgraph_api" / "a.json")
148
+ assert await kept.load() == 0
149
+ yield kept
150
+ return
151
+ if not ADMIN_DSN:
152
+ pytest.skip("TEST_POSTGRES_DSN is not set")
153
+ import psycopg
154
+
155
+ name = f"gac_test_{uuid.uuid4().hex[:12]}"
156
+ async with await psycopg.AsyncConnection.connect(ADMIN_DSN, autocommit=True) as admin:
157
+ await admin.execute(f'CREATE DATABASE "{name}"')
158
+ # Sessions answer in a zone other than UTC (as a server set up on a laptop does).
159
+ await admin.execute(f"ALTER DATABASE \"{name}\" SET timezone TO '{DB_TIME_ZONE}'")
160
+ db = Database("postgres", urlsplit(ADMIN_DSN)._replace(path=f"/{name}").geturl())
161
+ await db.open()
162
+ try:
163
+ yield ApprovalStore(db)
164
+ finally:
165
+ await db.close()
166
+ async with await psycopg.AsyncConnection.connect(ADMIN_DSN, autocommit=True) as admin:
167
+ await admin.execute(f'DROP DATABASE IF EXISTS "{name}" WITH (FORCE)')
168
+
169
+
170
+ # --- records -------------------------------------------------------------------------
171
+
172
+
173
+ def test_a_record_holds_the_call_and_never_shows_its_internals() -> None:
174
+ record = _record()
175
+ assert record.status == PENDING
176
+ assert record.expires_at - record.created_at == timedelta(seconds=60)
177
+ assert record.requester_hash == ALICE.hashed_id()
178
+ # The run context it resumes with: roles and public attributes, never credentials.
179
+ assert record.requester_context == {"roles": ["user"], "attributes": {"tenant": "t1"}}
180
+ public = record.public()
181
+ assert public["body"] == {"reason": "asked"} and public["query"] == {"notify": "yes"}
182
+ assert public["reason"] == "cancel_order: the customer asked"
183
+ for internal in ("call_hash", "interrupt_id", "rule", "tool_call_id", "requester_context"):
184
+ assert internal not in public
185
+ hidden = record.public(include_call=False)
186
+ assert "body" not in hidden and "query" not in hidden
187
+
188
+
189
+ @pytest.mark.parametrize(("given", "kept"), [(5, 30), (100_000, 86_400), ("x", 900), (True, 900)])
190
+ def test_the_timeout_is_kept_within_the_policy_bounds(given: Any, kept: int) -> None:
191
+ record = _record(timeout_s=given)
192
+ assert record.expires_at - record.created_at == timedelta(seconds=kept)
193
+
194
+
195
+ def test_a_pending_record_past_its_expiry_reads_as_expired() -> None:
196
+ record = _record()
197
+ assert record.effective_status(record.expires_at - timedelta(seconds=1)) == PENDING
198
+ assert record.effective_status(record.expires_at) == EXPIRED
199
+
200
+
201
+ # --- who decides ---------------------------------------------------------------------
202
+
203
+
204
+ @pytest.mark.parametrize(
205
+ ("principal", "approvers", "allowed"),
206
+ [
207
+ (Principal(id="alice"), ["requester"], True),
208
+ (Principal(id="alice", roles=["ops"]), ["role:ops"], False), # no self-approval
209
+ (Principal(id="alice", roles=["ops"]), ["requester", "role:ops"], True),
210
+ (Principal(id="carol", roles=["ops"]), ["role:ops"], True),
211
+ (Principal(id="carol", roles=["ops"]), ["requester"], False),
212
+ (Principal(id="bob", roles=["user"]), ["requester", "role:ops"], False),
213
+ (Principal(id="bob", roles=["Ops", "ops-team"]), ["role:ops"], False),
214
+ (Principal(id=""), ["requester"], False),
215
+ ],
216
+ )
217
+ def test_who_may_decide(principal: Principal, approvers: list[str], allowed: bool) -> None:
218
+ assert may_decide(principal, "alice", approvers) is allowed
219
+
220
+
221
+ def test_who_may_see(monkeypatch: pytest.MonkeyPatch) -> None:
222
+ monkeypatch.setenv("AUTH_READ_ACROSS_ROLES", "auditor")
223
+ assert may_view(Principal(id="alice"), "alice", ["role:ops"]) # the owner
224
+ assert may_view(Principal(id="carol", roles=["ops"]), "alice", ["role:ops"])
225
+ assert may_view(Principal(id="ann", roles=["auditor"]), "alice", ["role:ops"])
226
+ assert not may_view(Principal(id="bob"), "alice", ["role:ops"])
227
+
228
+
229
+ def test_the_resumed_run_acts_as_the_requester_never_the_decider() -> None:
230
+ record = _record()
231
+ carol = Principal(id="carol", roles=["ops", "admin"], attributes={"credentials": {"x": "c"}})
232
+ acting = resume_principal(record, "alice", carol)
233
+ assert acting.id == "alice" and acting.roles == ["user"]
234
+ assert acting.attributes == {"tenant": "t1"} # no credentials: not stored, not carol's
235
+ fresh = Principal(id="alice", roles=["user"], attributes={"credentials": {"x": "new"}})
236
+ assert resume_principal(record, "alice", fresh) is fresh
237
+
238
+
239
+ # --- the store and the ledger -----------------------------------------------------------
240
+
241
+
242
+ async def test_an_interrupt_asked_again_keeps_its_pending_approval(store: ApprovalStore) -> None:
243
+ first, created, superseded = await store.add(_record())
244
+ assert created and superseded == 0
245
+ again, created, superseded = await store.add(_record())
246
+ assert not created and again.approval_id == first.approval_id and superseded == 0
247
+ # The same interrupt asking for a different request supersedes the old one.
248
+ changed, created, superseded = await store.add(_record(call_hash="h2"))
249
+ assert created and superseded == 1 and changed.approval_id != first.approval_id
250
+ assert (await store.get(first.approval_id)).status == EXPIRED
251
+
252
+
253
+ async def test_a_decision_is_taken_once(store: ApprovalStore) -> None:
254
+ record, _, _ = await store.add(_record())
255
+ results = await asyncio.gather(
256
+ store.decide(record.approval_id, APPROVED, "a", None),
257
+ store.decide(record.approval_id, REJECTED, "b", "no"),
258
+ )
259
+ winners = [r for r in results if r is not None]
260
+ assert len(winners) == 1
261
+ assert await store.decide(record.approval_id, APPROVED, "c", None) is None
262
+ with pytest.raises(ValueError):
263
+ await store.decide(record.approval_id, PENDING, "c", None)
264
+
265
+
266
+ async def test_decided_records_drop_the_call_unless_full_capture(
267
+ store: ApprovalStore, monkeypatch: pytest.MonkeyPatch
268
+ ) -> None:
269
+ record, _, _ = await store.add(_record())
270
+ decided = await store.decide(record.approval_id, REJECTED, "x", " wrong customer ")
271
+ assert decided is not None and decided.comment == "wrong customer"
272
+ assert "body" not in decided.payload and "query" not in decided.payload
273
+ assert decided.payload["reason"] == "cancel_order: the customer asked"
274
+ monkeypatch.setenv("TRACE_CAPTURE", "full")
275
+ kept, _, _ = await store.add(_record(interrupt_id="i2"))
276
+ decided = await store.decide(kept.approval_id, APPROVED, "x", None)
277
+ assert decided is not None and decided.payload["body"] == {"reason": "asked"}
278
+
279
+
280
+ def test_times_read_from_a_database_are_kept_in_utc() -> None:
281
+ """A row's times in the session's zone (or naive) read as the same instant in UTC."""
282
+ for raw in (
283
+ "2026-09-28T16:17:10.165695-04:00",
284
+ datetime.fromisoformat("2026-09-28T16:17:10.165695-04:00"),
285
+ "2026-09-28T20:17:10.165695",
286
+ ):
287
+ parsed = _as_datetime(raw)
288
+ assert parsed is not None and parsed.isoformat() == "2026-09-28T20:17:10.165695+00:00"
289
+
290
+
291
+ async def test_times_read_back_are_the_times_written(store: ApprovalStore) -> None:
292
+ """Whatever the database session's zone (the Postgres store's is not UTC), an approval
293
+ reads back with the very times it was written with: a relay that reads the approvals
294
+ ledger binds them into the decision it sends."""
295
+ record, _, _ = await store.add(_record())
296
+ got = await store.get(record.approval_id)
297
+ assert got is not None and got.expires_at.utcoffset() == timedelta(0)
298
+ for key in ("created_at", "expires_at"):
299
+ assert got.public()[key] == record.public()[key]
300
+ decided = await store.decide(record.approval_id, APPROVED, "x", None)
301
+ assert decided is not None and decided.public()["decided_at"].endswith("+00:00")
302
+
303
+
304
+ async def test_an_expired_approval_cannot_be_decided(store: ApprovalStore) -> None:
305
+ record, _, _ = await store.add(_record())
306
+ store._clock = lambda: record.expires_at + timedelta(seconds=1)
307
+ assert await store.decide(record.approval_id, APPROVED, "x", None) is None
308
+ assert [r.approval_id for r in await store.expire_due()] == [record.approval_id]
309
+ assert (await store.get(record.approval_id)).status == EXPIRED
310
+ assert await store.expire_due() == []
311
+
312
+
313
+ async def test_the_ledger_lets_an_approval_through_once(store: ApprovalStore) -> None:
314
+ record, _, _ = await store.add(_record())
315
+ assert (
316
+ await store.consume(record.approval_id, "h1", "t1")
317
+ == "the approval is pending, not approved"
318
+ )
319
+ await store.decide(record.approval_id, APPROVED, "x", None)
320
+ assert await store.consume(record.approval_id, "h2", "t1") == (
321
+ "the approval is for a different request"
322
+ )
323
+ assert await store.consume(record.approval_id, "h1", "t2") == (
324
+ "the approval belongs to another thread"
325
+ )
326
+ assert await store.consume(record.approval_id, "h1", "t1") is None
327
+ assert await store.consume(record.approval_id, "h1", "t1") == "the approval was already used"
328
+ assert await store.consume("nope", "h1", "t1") == "no such approval"
329
+ rejected, _, _ = await store.add(_record(interrupt_id="i3"))
330
+ await store.decide(rejected.approval_id, REJECTED, "x", None)
331
+ assert await store.consume(rejected.approval_id, "h1", "t1") == (
332
+ "the approval is rejected, not approved"
333
+ )
334
+
335
+
336
+ async def test_listing_across_threads(
337
+ store: ApprovalStore, monkeypatch: pytest.MonkeyPatch
338
+ ) -> None:
339
+ mine, _, _ = await store.add(_record())
340
+ other = record_from_interrupt(
341
+ _interrupt_value(approvers=["role:finance"]),
342
+ interrupt_id="i9",
343
+ thread_id="t9",
344
+ run_id="r9",
345
+ requester=Principal(id="dora"),
346
+ )
347
+ await store.add(other)
348
+ assert [r.approval_id for r in await store.visible(ALICE)] == [mine.approval_id]
349
+ carol = Principal(id="carol", roles=["ops"])
350
+ assert [r.approval_id for r in await store.visible(carol)] == [mine.approval_id]
351
+ assert await store.visible(Principal(id="bob")) == []
352
+ monkeypatch.setenv("AUTH_READ_ACROSS_ROLES", "auditor")
353
+ ann = Principal(id="ann", roles=["auditor"])
354
+ assert {r.approval_id for r in await store.visible(ann)} == {
355
+ mine.approval_id,
356
+ other.approval_id,
357
+ }
358
+ assert await store.visible(ALICE, status=APPROVED) == []
359
+ assert len(await store.visible(ALICE, status=PENDING)) == 1
360
+ assert await store.delete_for_thread("t1") == 1
361
+ assert await store.visible(ALICE) == []
362
+
363
+
364
+ async def test_the_ledger_names_the_approvals_a_tool_call_asked_for(store: ApprovalStore) -> None:
365
+ """By the tool call (its model message and call id) or by the interrupt, on any thread."""
366
+ asked, _, _ = await store.add(_record(message_id="m1", tool_call_id="c1"))
367
+ copied = record_from_interrupt(
368
+ _interrupt_value(message_id="m1", tool_call_id="c1", path="/orders/8/cancel"),
369
+ interrupt_id="i2",
370
+ thread_id="t-copy",
371
+ run_id="r2",
372
+ requester=ALICE,
373
+ )
374
+ await store.add(copied)
375
+ # The same call id in another model message (a model that reuses ids) is another call.
376
+ await store.add(_record(interrupt_id="i3", message_id="m2", tool_call_id="c1"))
377
+ await store.decide(asked.approval_id, REJECTED, "x", None)
378
+ found = await store.bound_approvals(tool_call=("m1", "c1"))
379
+ assert sorted(found) == [
380
+ BoundApproval("shop", "POST", "/orders/7/cancel", REJECTED, False),
381
+ BoundApproval("shop", "POST", "/orders/8/cancel", PENDING, False),
382
+ ]
383
+ assert [b.path for b in await store.bound_approvals(interrupt_id="i2")] == ["/orders/8/cancel"]
384
+ assert await store.bound_approvals(tool_call=("m9", "c1"), interrupt_id="i9") == []
385
+ assert await store.bound_approvals() == []
386
+ # Read as a decision would be: used once sent, expired once past its expiry.
387
+ await store.decide(copied.approval_id, APPROVED, "x", None)
388
+ assert await store.consume(copied.approval_id, "h1", "t-copy") is None
389
+ [used] = await store.bound_approvals(interrupt_id="i2")
390
+ assert used.status == APPROVED and used.used
391
+ later, _, _ = await store.add(_record(interrupt_id="i4", message_id="m4", tool_call_id="c4"))
392
+ store._clock = lambda: later.expires_at + timedelta(seconds=1)
393
+ [expired] = await store.bound_approvals(tool_call=("m4", "c4"))
394
+ assert expired.status == EXPIRED and not expired.used
395
+
396
+
397
+ # --- kept in a file (langgraph dev) -----------------------------------------------------
398
+
399
+
400
+ def test_only_langgraph_dev_keeps_the_approvals_in_a_file(monkeypatch: pytest.MonkeyPatch) -> None:
401
+ monkeypatch.delenv("LANGGRAPH_RUNTIME_EDITION", raising=False)
402
+ monkeypatch.delenv("LANGGRAPH_DISABLE_FILE_PERSISTENCE", raising=False)
403
+ assert dev_ledger_path() is None
404
+ monkeypatch.setenv("LANGGRAPH_RUNTIME_EDITION", "postgres")
405
+ assert dev_ledger_path() is None
406
+ monkeypatch.setenv("LANGGRAPH_RUNTIME_EDITION", "inmem")
407
+ assert dev_ledger_path() == Path(".langgraph_api") / "agent_approvals.json"
408
+ # No file persistence: the dev server keeps no threads either.
409
+ monkeypatch.setenv("LANGGRAPH_DISABLE_FILE_PERSISTENCE", "true")
410
+ assert dev_ledger_path() is None
411
+ # A database always wins (the path is ignored).
412
+ assert ApprovalStore(Database("postgres", "postgresql://x/y"), path="a.json").path is None
413
+
414
+
415
+ async def test_the_approvals_kept_in_a_file_outlive_the_process(tmp_path: Path) -> None:
416
+ """What a restart or a hot reload of `langgraph dev` reads back: every state, bound."""
417
+ path = tmp_path / ".langgraph_api" / "agent_approvals.json"
418
+ first = ApprovalStore(Database("memory"), path=path)
419
+ await first.load()
420
+ pending, _, _ = await first.add(_record(interrupt_id="i1", message_id="m1", tool_call_id="c1"))
421
+ rejected, _, _ = await first.add(_record(interrupt_id="i2", message_id="m2", tool_call_id="c2"))
422
+ await first.decide(rejected.approval_id, REJECTED, "x", "no")
423
+ sent, _, _ = await first.add(_record(interrupt_id="i3", message_id="m3", tool_call_id="c3"))
424
+ await first.decide(sent.approval_id, APPROVED, "x", None)
425
+ assert await first.consume(sent.approval_id, "h1", "t1") is None
426
+ unused, _, _ = await first.add(_record(interrupt_id="i4", message_id="m4", tool_call_id="c4"))
427
+ await first.decide(unused.approval_id, APPROVED, "x", None)
428
+ assert path.stat().st_mode & 0o777 == 0o600
429
+ assert [p.name for p in path.parent.iterdir()] == [path.name] # no partial file left
430
+
431
+ again = ApprovalStore(Database("memory"), path=path)
432
+ assert await again.load() == 4
433
+ assert [r.approval_id for r in await again.for_thread("t1")] == [
434
+ unused.approval_id,
435
+ sent.approval_id,
436
+ rejected.approval_id,
437
+ pending.approval_id,
438
+ ]
439
+ for (message_id, call_id), status, used in (
440
+ (("m1", "c1"), PENDING, False),
441
+ (("m2", "c2"), REJECTED, False),
442
+ (("m3", "c3"), APPROVED, True),
443
+ (("m4", "c4"), APPROVED, False),
444
+ ):
445
+ [bound] = await again.bound_approvals(tool_call=(message_id, call_id))
446
+ assert (bound.status, bound.used) == (status, used)
447
+ reloaded = await again.get(pending.approval_id)
448
+ assert reloaded is not None
449
+ assert reloaded.public() == pending.public() and reloaded.call_hash == pending.call_hash
450
+ assert reloaded.requester_context == pending.requester_context
451
+ assert (await again.get(rejected.approval_id)).comment == "no"
452
+ assert await again.consume(sent.approval_id, "h1", "t1") == "the approval was already used"
453
+ # A change made after the restart is kept too; a deleted thread takes its approvals.
454
+ assert await again.decide(pending.approval_id, REJECTED, "y", None) is not None
455
+ assert await again.delete_for_thread("t1") == 4
456
+ third = ApprovalStore(Database("memory"), path=path)
457
+ assert await third.load() == 0
458
+
459
+
460
+ @pytest.mark.parametrize("content", [b"{not json", b'{"version": 99, "approvals": []}', b"[]"])
461
+ async def test_an_approvals_file_that_cannot_be_read_stops_the_startup(
462
+ tmp_path: Path, content: bytes
463
+ ) -> None:
464
+ path = tmp_path / "agent_approvals.json"
465
+ path.write_bytes(content)
466
+ with pytest.raises(LedgerUnavailable, match="cannot be read"):
467
+ await ApprovalStore(Database("memory"), path=path).load()
468
+
469
+
470
+ async def test_while_the_file_cannot_be_written_the_store_answers_nothing(
471
+ tmp_path: Path, monkeypatch: pytest.MonkeyPatch
472
+ ) -> None:
473
+ """A change not in the file could be lost on a restart: nothing is answered until it is."""
474
+ path = tmp_path / "agent_approvals.json"
475
+ store = ApprovalStore(Database("memory"), path=path)
476
+ await store.load()
477
+ write = store._write
478
+
479
+ def broken(change: int, data: bytes) -> None:
480
+ raise OSError("disk full")
481
+
482
+ monkeypatch.setattr(store, "_write", broken)
483
+ with pytest.raises(LedgerUnavailable, match="could not be written"):
484
+ await store.add(_record(message_id="m1", tool_call_id="c1"))
485
+ with pytest.raises(LedgerUnavailable):
486
+ await store.bound_approvals(tool_call=("m1", "c1"))
487
+ with pytest.raises(LedgerUnavailable):
488
+ await store.for_thread("t1")
489
+ # Written again (the sweep retries it too): the store answers, and the file has it.
490
+ monkeypatch.setattr(store, "_write", write)
491
+ [bound] = await store.bound_approvals(tool_call=("m1", "c1"))
492
+ assert bound.status == PENDING
493
+ again = ApprovalStore(Database("memory"), path=path)
494
+ assert await again.load() == 1
495
+
496
+
497
+ # --- the call, as bound and as shown ------------------------------------------------------
498
+
499
+
500
+ def test_the_call_hash_binds_everything_that_decides_the_request() -> None:
501
+ base = dict(
502
+ api="shop",
503
+ method="post",
504
+ url="http://shop.test/orders/7/cancel",
505
+ query=httpx.QueryParams([("a", "1"), ("b", "2")]),
506
+ json_body={"reason": "asked", "amount": 5},
507
+ operation_id="cancelOrder",
508
+ headers=[("If-Match", "v1")],
509
+ )
510
+ digest = call_hash(canonical_call(**base))
511
+ reordered = {**base, "json_body": {"amount": 5, "reason": "asked"}}
512
+ assert call_hash(canonical_call(**reordered)) == digest
513
+ for change in (
514
+ {"method": "PUT"},
515
+ {"url": "http://shop.test/orders/8/cancel"},
516
+ {"url": "http://other.test/orders/7/cancel"},
517
+ {"query": httpx.QueryParams([("b", "2"), ("a", "1")])},
518
+ {"json_body": {"reason": "asked", "amount": 6}},
519
+ {"operation_id": "archiveOrder"},
520
+ {"headers": [("If-Match", "v2")]},
521
+ {"api": "other"},
522
+ ):
523
+ assert call_hash(canonical_call(**{**base, **change})) != digest, change
524
+ with pytest.raises(ValueError):
525
+ call_hash(canonical_call(**{**base, "json_body": {"x": float("nan")}}))
526
+
527
+
528
+ def test_redacted_fields_are_masked_at_any_depth() -> None:
529
+ body = {"Card": "4111", "items": [{"card": "4222", "sku": "a"}], "note": {"CARD": 1}}
530
+ assert redact_fields(body, frozenset({"card"})) == {
531
+ "Card": "<redacted>",
532
+ "items": [{"card": "<redacted>", "sku": "a"}],
533
+ "note": {"CARD": "<redacted>"},
534
+ }
535
+ assert redact_fields(body, frozenset()) is body
536
+
537
+
538
+ def test_the_stated_purpose_is_the_text_the_model_wrote_with_the_call() -> None:
539
+ messages = [
540
+ {"type": "human", "content": "cancel 7"},
541
+ {
542
+ "type": "ai",
543
+ "content": [{"type": "text", "text": "Cancelling order 7\x00 as asked."}],
544
+ "tool_calls": [{"id": "c1", "name": "cancel_order", "args": {}}],
545
+ },
546
+ ]
547
+ assert stated_purpose(messages, "c1") == "Cancelling order 7 as asked."
548
+ assert stated_purpose(messages, "c2") is None
549
+ long = [{"type": "ai", "content": "x" * 900, "tool_calls": [{"id": "c1"}]}]
550
+ assert len(stated_purpose(long, "c1")) == 500
551
+
552
+
553
+ # --- the client's side, in a real graph -------------------------------------------------
554
+
555
+ SENT: list[httpx.Request] = []
556
+ BODY: dict[str, Any] = {}
557
+ SECOND_CALL: list[bool] = []
558
+ # The tool catches a refusal and tries the same call once more.
559
+ RETRY: list[bool] = []
560
+
561
+
562
+ def _upstream(request: httpx.Request) -> httpx.Response:
563
+ SENT.append(request)
564
+ return httpx.Response(200, json={"ok": True})
565
+
566
+
567
+ @tool
568
+ async def cancel_order(order_id: str, runtime: ToolRuntime[Any]) -> str:
569
+ """Cancel an order by its id."""
570
+ client = get_client("shop", transport=httpx.MockTransport(_upstream))
571
+
572
+ async def cancel() -> Any:
573
+ return await client.post(
574
+ "/orders/{order_id}/cancel",
575
+ operation_id="cancelOrder",
576
+ path_params={"order_id": order_id},
577
+ json_body=dict(BODY),
578
+ )
579
+
580
+ try:
581
+ data = await cancel()
582
+ except ApiPolicyError:
583
+ if not RETRY:
584
+ raise
585
+ data = await cancel()
586
+ if SECOND_CALL:
587
+ await client.post("/orders", operation_id="createOrder", json_body={"sku": "a"})
588
+ return json.dumps(data)
589
+
590
+
591
+ @pytest.fixture
592
+ def graph(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, store: ApprovalStore) -> Iterator[Any]:
593
+ from {{cookiecutter.agent_directory}} import agent
594
+ from {{cookiecutter.agent_directory}}.app_utils.model import get_model
595
+
596
+ policy = tmp_path / "api-policy.yaml"
597
+ policy.write_text(POLICY, encoding="utf-8")
598
+ monkeypatch.setenv("API_POLICY_PATH", str(policy))
599
+ monkeypatch.setenv("SHOP_API_BASE_URL", "http://shop.test")
600
+ reset_policy_cache()
601
+ SENT.clear()
602
+ SECOND_CALL.clear()
603
+ RETRY.clear()
604
+ BODY.clear()
605
+ BODY.update({"reason": "asked"})
606
+ set_approval_ledger(store)
607
+ yield create_agent(
608
+ model=get_model(),
609
+ tools=[cancel_order],
610
+ system_prompt=agent.SYSTEM_PROMPT,
611
+ middleware=agent.middleware(),
612
+ context_schema=agent.AgentContext,
613
+ checkpointer=InMemorySaver(),
614
+ )
615
+ set_approval_ledger(None)
616
+ reset_policy_cache()
617
+
618
+
619
+ async def _run(graph: Any, thread: str, graph_input: Any) -> list[Any]:
620
+ """Run to the end; the interrupts it paused on (id and value)."""
621
+ config = {"configurable": {"thread_id": thread}}
622
+ async for _ in graph.astream(graph_input, config, stream_mode="updates"):
623
+ pass
624
+ state = await graph.aget_state(config)
625
+ return list(state.interrupts)
626
+
627
+
628
+ async def _pause(graph: Any, thread: str, store: ApprovalStore) -> tuple[Any, ApprovalRecord]:
629
+ interrupts = await _run(graph, thread, {"messages": [("user", "Cancel the order for 7")]})
630
+ assert len(interrupts) == 1
631
+ record, _, _ = await store.add(
632
+ record_from_interrupt(
633
+ interrupts[0].value,
634
+ interrupt_id=interrupts[0].id,
635
+ thread_id=thread,
636
+ run_id="r",
637
+ requester=ALICE,
638
+ )
639
+ )
640
+ return interrupts[0], record
641
+
642
+
643
+ async def _last_tool_result(graph: Any, thread: str) -> str:
644
+ state = await graph.aget_state({"configurable": {"thread_id": thread}})
645
+ return next(m.content for m in reversed(state.values["messages"]) if m.type == "tool")
646
+
647
+
648
+ async def test_a_gated_call_interrupts_with_the_call_before_anything_is_sent(graph, store) -> None:
649
+ interrupt, record = await _pause(graph, "t1", store)
650
+ value = interrupt.value
651
+ assert value["type"] == APPROVAL_INTERRUPT
652
+ assert (value["method"], value["path"], value["body"]) == ("POST", "/orders/7/cancel", BODY)
653
+ assert value["tool"] == "cancel_order" and value["tool_call_id"] == "call_cancel_order"
654
+ assert value["message_id"] # the model message that made the call
655
+ assert record.message_id == value["message_id"]
656
+ assert value["approvers"] == ["requester", "role:ops"] and value["timeout_s"] == 60
657
+ assert len(value["call_hash"]) == 64
658
+ assert SENT == [] and record.status == PENDING
659
+
660
+
661
+ async def test_approved_the_call_is_sent_once(graph, store) -> None:
662
+ interrupt, record = await _pause(graph, "t1", store)
663
+ decided = await store.decide(record.approval_id, APPROVED, "x", None)
664
+ await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "approve")}))
665
+ assert [(r.method, r.url.path) for r in SENT] == [("POST", "/orders/7/cancel")]
666
+ assert json.loads(SENT[0].content) == BODY
667
+ assert '"ok": true' in await _last_tool_result(graph, "t1")
668
+ assert (await store.get(record.approval_id)).used_at is not None
669
+
670
+
671
+ async def test_rejected_or_expired_nothing_is_sent(graph, store) -> None:
672
+ interrupt, record = await _pause(graph, "t1", store)
673
+ decided = await store.decide(record.approval_id, REJECTED, "x", "not this one")
674
+ await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "reject")}))
675
+ assert "an approver rejected it (their comment: not this one)" in await _last_tool_result(
676
+ graph, "t1"
677
+ )
678
+ interrupt, record = await _pause(graph, "t2", store)
679
+ await _run(graph, "t2", Command(resume={interrupt.id: decision_value(record, "expired")}))
680
+ assert "the approval request expired" in await _last_tool_result(graph, "t2")
681
+ assert SENT == []
682
+
683
+
684
+ async def test_a_resume_the_store_did_not_approve_sends_nothing(graph, store) -> None:
685
+ """A forged resume value (the right hash, an approval still pending) is refused."""
686
+ interrupt, record = await _pause(graph, "t1", store)
687
+ forged = decision_value(record, "approve")
688
+ await _run(graph, "t1", Command(resume={interrupt.id: forged}))
689
+ assert "the approval is pending, not approved" in await _last_tool_result(graph, "t1")
690
+ assert SENT == []
691
+
692
+
693
+ async def test_a_used_approval_cannot_be_replayed_on_another_run(graph, store) -> None:
694
+ interrupt, record = await _pause(graph, "t1", store)
695
+ decided = await store.decide(record.approval_id, APPROVED, "x", None)
696
+ value = decision_value(decided, "approve")
697
+ await _run(graph, "t1", Command(resume={interrupt.id: value}))
698
+ assert len(SENT) == 1
699
+ # The same request, paused on another thread, resumed with the used approval.
700
+ other, _ = await _pause(graph, "t2", store)
701
+ await _run(graph, "t2", Command(resume={other.id: value}))
702
+ assert "the approval belongs to another thread" in await _last_tool_result(graph, "t2")
703
+ assert len(SENT) == 1
704
+
705
+
706
+ async def test_a_request_that_changed_is_not_covered(graph, store) -> None:
707
+ interrupt, record = await _pause(graph, "t1", store)
708
+ decided = await store.decide(record.approval_id, APPROVED, "x", None)
709
+ BODY["reason"] = "changed"
710
+ await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "approve")}))
711
+ assert "differs from the request that was approved" in await _last_tool_result(graph, "t1")
712
+ assert SENT == []
713
+
714
+
715
+ async def test_without_a_ledger_an_approval_is_not_used(graph, store) -> None:
716
+ interrupt, record = await _pause(graph, "t1", store)
717
+ decided = await store.decide(record.approval_id, APPROVED, "x", None)
718
+ set_approval_ledger(None)
719
+ await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "approve")}))
720
+ assert "no approvals ledger" in await _last_tool_result(graph, "t1")
721
+ assert SENT == []
722
+
723
+
724
+ async def test_a_tool_call_sends_at_most_one_approved_call(graph, store) -> None:
725
+ SECOND_CALL.append(True)
726
+ interrupt, record = await _pause(graph, "t1", store)
727
+ decided = await store.decide(record.approval_id, APPROVED, "x", None)
728
+ await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "approve")}))
729
+ result = await _last_tool_result(graph, "t1")
730
+ assert "already sent an approved call" in result
731
+ assert [r.url.path for r in SENT] == ["/orders/7/cancel"]
732
+ state = await graph.aget_state({"configurable": {"thread_id": "t1"}})
733
+ assert not state.interrupts
734
+
735
+
736
+ async def test_a_resume_without_a_decision_sends_nothing(graph, store) -> None:
737
+ interrupt, _ = await _pause(graph, "t1", store)
738
+ await _run(graph, "t1", Command(resume={interrupt.id: {"decision": "approve"}}))
739
+ assert "resumed without an approval decision" in await _last_tool_result(graph, "t1")
740
+ await _pause(graph, "t2", store)
741
+ assert SENT == []
742
+
743
+
744
+ # --- a decision binds its call, whatever the policy says when the run resumes -----------
745
+
746
+ # The policy as a new image may bring it while a call waits (POLICY gates POST).
747
+ POLICY_CHANGES = {
748
+ "gate removed": POLICY.split(" approval:")[0],
749
+ "gate narrowed": POLICY.replace("methods: [POST]", "methods: [DELETE]"),
750
+ "call denied": POLICY.replace(
751
+ " approval:",
752
+ " denied_operations:\n - path: /orders/{order_id}/cancel\n approval:",
753
+ ),
754
+ "allowed_methods narrowed": POLICY.replace(
755
+ "allowed_methods: [GET, POST]", "allowed_methods: [GET]"
756
+ ),
757
+ }
758
+ # The policy refuses before any decision is read; else the decision (or the gate) does.
759
+ REFUSED_BY = {
760
+ "call denied": "denied by denied_operations",
761
+ "allowed_methods narrowed": "is not in allowed_methods",
762
+ }
763
+ DECIDED_BY = {
764
+ "reject": "was not approved: an approver rejected it",
765
+ "expired": "was not approved: the approval request expired",
766
+ "approve": "approved under an approval gate the policy no longer has",
767
+ }
768
+
769
+
770
+ def _change_policy(tmp_path: Path, text: str) -> None:
771
+ (tmp_path / "api-policy.yaml").write_text(text, encoding="utf-8")
772
+ reset_policy_cache()
773
+
774
+
775
+ async def _decided(store: ApprovalStore, record: ApprovalRecord, decision: str) -> ApprovalRecord:
776
+ if decision == "expired":
777
+ closed = await store.expire(record.approval_id)
778
+ else:
779
+ status = APPROVED if decision == "approve" else REJECTED
780
+ closed = await store.decide(record.approval_id, status, "x", None)
781
+ assert closed is not None
782
+ return closed
783
+
784
+
785
+ @pytest.mark.parametrize("change", list(POLICY_CHANGES))
786
+ @pytest.mark.parametrize("decision", ["reject", "expired", "approve"])
787
+ async def test_a_decision_binds_its_call_whatever_the_policy_says_by_then(
788
+ graph, store, tmp_path, change: str, decision: str
789
+ ) -> None:
790
+ interrupt, record = await _pause(graph, "t1", store)
791
+ decided = await _decided(store, record, decision)
792
+ _change_policy(tmp_path, POLICY_CHANGES[change])
793
+ await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, decision)}))
794
+ result = await _last_tool_result(graph, "t1")
795
+ assert REFUSED_BY.get(change, DECIDED_BY[decision]) in result, result
796
+ assert SENT == []
797
+ assert (await store.get(record.approval_id)).used_at is None
798
+ state = await graph.aget_state({"configurable": {"thread_id": "t1"}})
799
+ assert not state.interrupts
800
+
801
+
802
+ async def test_an_approval_still_covers_its_call_under_a_gate_with_the_same_approvers(
803
+ graph, store, tmp_path
804
+ ) -> None:
805
+ interrupt, record = await _pause(graph, "t1", store)
806
+ decided = await _decided(store, record, "approve")
807
+ # Rewritten, still gating the call and asking the same approvers.
808
+ still_gated = POLICY.replace(
809
+ " methods: [POST]", " operations:\n - path: /orders/{x}/cancel"
810
+ )
811
+ _change_policy(tmp_path, still_gated)
812
+ await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "approve")}))
813
+ assert [(r.method, r.url.path) for r in SENT] == [("POST", "/orders/7/cancel")]
814
+ assert (await store.get(record.approval_id)).used_at is not None
815
+
816
+
817
+ # --- a list of approval rules: the rule that gates the call names who decides ----------
818
+
819
+ RULES_POLICY = """
820
+ apis:
821
+ shop:
822
+ base_url_env: SHOP_API_BASE_URL
823
+ auth: none
824
+ allowed_methods: [GET, POST]
825
+ approval:
826
+ - required_for:
827
+ operations:
828
+ - {operationId: createOrder, path: /orders, methods: [POST]}
829
+ approvers: ["role:admin"]
830
+ timeout_s: 3600
831
+ - required_for:
832
+ operations:
833
+ - {operationId: cancelOrder, path: "/orders/{order_id}/cancel", methods: [POST]}
834
+ approvers: [requester]
835
+ timeout_s: 120
836
+ """
837
+
838
+
839
+ async def test_the_approval_is_asked_of_the_rule_that_gates_the_call(
840
+ graph, store, tmp_path
841
+ ) -> None:
842
+ _change_policy(tmp_path, RULES_POLICY)
843
+ interrupt, record = await _pause(graph, "t1", store)
844
+ value = interrupt.value
845
+ assert (value["approvers"], value["timeout_s"]) == (["requester"], 120)
846
+ assert value["rule"].startswith("approval[1].required_for.operations (operationId=cancelOrder")
847
+ # The record keeps those approvers, and they decide: the requester, not an admin.
848
+ assert record.approvers == ["requester"]
849
+ assert record.expires_at - record.created_at == timedelta(seconds=120)
850
+ assert may_decide(ALICE, ALICE.id, record.approvers)
851
+ assert not may_decide(Principal(id="root", roles=["admin"]), ALICE.id, record.approvers)
852
+ decided = await _decided(store, record, "approve")
853
+ await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "approve")}))
854
+ assert [(r.method, r.url.path) for r in SENT] == [("POST", "/orders/7/cancel")]
855
+
856
+
857
+ async def test_a_rule_added_after_the_one_that_gated_the_call_keeps_its_approval(
858
+ graph, store, tmp_path
859
+ ) -> None:
860
+ _change_policy(tmp_path, RULES_POLICY)
861
+ interrupt, record = await _pause(graph, "t1", store)
862
+ decided = await _decided(store, record, "approve")
863
+ # A later rule covering the call too never applies to it: the approval still holds.
864
+ _change_policy(
865
+ tmp_path,
866
+ RULES_POLICY + ' - {required_for: {methods: [POST]}, approvers: ["role:ops"]}\n',
867
+ )
868
+ await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "approve")}))
869
+ assert [(r.method, r.url.path) for r in SENT] == [("POST", "/orders/7/cancel")]
870
+
871
+
872
+ async def test_rules_that_now_hand_the_call_to_other_approvers_void_its_approval(
873
+ graph, store, tmp_path
874
+ ) -> None:
875
+ """Bound at pause time: the approvers of the rule that gated the call then."""
876
+ _change_policy(tmp_path, RULES_POLICY)
877
+ interrupt, record = await _pause(graph, "t1", store)
878
+ decided = await _decided(store, record, "approve")
879
+ # A new image: a POST rule for role:admin now comes first and covers the call.
880
+ _change_policy(
881
+ tmp_path,
882
+ RULES_POLICY.replace(
883
+ " approval:\n",
884
+ ' approval:\n - {required_for: {methods: [POST]}, approvers: ["role:admin"]}\n',
885
+ ),
886
+ )
887
+ await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "approve")}))
888
+ assert "approval gate that has changed since" in await _last_tool_result(graph, "t1")
889
+ assert SENT == []
890
+ assert (await store.get(record.approval_id)).used_at is None
891
+
892
+
893
+ async def test_a_call_a_decision_stopped_stays_stopped_in_its_tool_call(
894
+ graph, store, tmp_path
895
+ ) -> None:
896
+ RETRY.append(True) # the tool tries the call again once it is refused
897
+ interrupt, record = await _pause(graph, "t1", store)
898
+ decided = await _decided(store, record, "reject")
899
+ _change_policy(tmp_path, POLICY_CHANGES["gate removed"])
900
+ await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "reject")}))
901
+ result = await _last_tool_result(graph, "t1")
902
+ assert "stopped by its approval decision earlier in this tool call" in result
903
+ assert SENT == []
904
+
905
+
906
+ async def test_a_call_still_waiting_pauses_again_for_its_own_approval(
907
+ graph, store, tmp_path
908
+ ) -> None:
909
+ """Another call's decision resumed the run: this one waits on, even un-gated meanwhile."""
910
+ interrupt, record = await _pause(graph, "t1", store)
911
+ _change_policy(tmp_path, POLICY_CHANGES["gate removed"])
912
+ await _run(graph, "t1", Command(resume={interrupt.id: decision_value(record, "pending")}))
913
+ assert SENT == []
914
+ state = await graph.aget_state({"configurable": {"thread_id": "t1"}})
915
+ [again] = state.interrupts
916
+ assert again.id == interrupt.id
917
+ assert again.value["call_hash"] == record.call_hash
918
+ assert again.value["approvers"] == ["requester", "role:ops"] # asked of the same approvers
919
+ kept, created, _ = await store.add(
920
+ record_from_interrupt(
921
+ again.value, interrupt_id=again.id, thread_id="t1", run_id="r2", requester=ALICE
922
+ )
923
+ )
924
+ assert not created and kept.approval_id == record.approval_id
925
+ # Its own decision then applies to it: approved, it is still not sent (no gate now).
926
+ decided = await _decided(store, record, "approve")
927
+ await _run(graph, "t1", Command(resume={again.id: decision_value(decided, "approve")}))
928
+ assert "approval gate the policy no longer has" in await _last_tool_result(graph, "t1")
929
+ assert SENT == []
930
+
931
+
932
+ # --- a tool call run again without a decision (LangGraph Server's own API can) ---------
933
+
934
+ # What the model reads when the ledger refuses a call its tool call asked an approval for.
935
+ BOUND_BY = {
936
+ "reject": "was not approved: an approver rejected it",
937
+ "expired": "was not approved: the approval request expired",
938
+ "approve": "was sent already with its approval, which is used once",
939
+ "pending": "was resumed without an approval decision",
940
+ }
941
+
942
+
943
+ async def _run_from(graph: Any, config: Any) -> None:
944
+ """Run the graph from the checkpoint `config` names, without input (a replay)."""
945
+ async for _ in graph.astream(None, config, stream_mode="updates"):
946
+ pass
947
+
948
+
949
+ @pytest.mark.parametrize("decision", ["reject", "expired", "approve", "pending"])
950
+ async def test_a_tool_call_run_again_without_its_decision_sends_nothing_more(
951
+ graph, store, tmp_path, decision: str
952
+ ) -> None:
953
+ """Continued without input, or replayed from its checkpoint, whatever the policy says."""
954
+ interrupt, record = await _pause(graph, "t1", store)
955
+ paused = (await graph.aget_state({"configurable": {"thread_id": "t1"}})).config
956
+ if decision != "pending":
957
+ decided = await _decided(store, record, decision)
958
+ if decision == "approve":
959
+ await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, decision)}))
960
+ assert len(SENT) == 1
961
+ _change_policy(tmp_path, POLICY_CHANGES["gate removed"])
962
+ await _run(graph, "t1", None) # continue the thread without input
963
+ await _run_from(graph, paused) # replay the paused step from its checkpoint (a fork)
964
+ await _run_from(graph, paused)
965
+ assert BOUND_BY[decision] in await _last_tool_result(graph, "t1")
966
+ assert len(SENT) == (1 if decision == "approve" else 0)
967
+
968
+
969
+ async def test_a_new_tool_call_is_not_bound_by_an_earlier_one(graph, store, tmp_path) -> None:
970
+ """The fake model reuses its call ids: a new message makes a new tool call."""
971
+ interrupt, record = await _pause(graph, "t1", store)
972
+ decided = await _decided(store, record, "reject")
973
+ await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "reject")}))
974
+ _change_policy(tmp_path, POLICY_CHANGES["gate removed"])
975
+ await _run(graph, "t1", {"messages": [("user", "Cancel the order for 7")]})
976
+ assert [r.url.path for r in SENT] == ["/orders/7/cancel"]
977
+
978
+
979
+ async def test_without_the_tool_call_scope_the_task_interrupt_binds_the_call(
980
+ graph, store, tmp_path
981
+ ) -> None:
982
+ """A graph without the agent's middleware: a continued task is known by its interrupt."""
983
+ from {{cookiecutter.agent_directory}} import agent
984
+ from {{cookiecutter.agent_directory}}.app_utils.model import get_model
985
+
986
+ bare = create_agent(
987
+ model=get_model(),
988
+ tools=[cancel_order],
989
+ context_schema=agent.AgentContext,
990
+ checkpointer=InMemorySaver(),
991
+ )
992
+ _, record = await _pause(bare, "t1", store)
993
+ assert record.message_id is None and record.tool_call_id is None
994
+ await _decided(store, record, "reject")
995
+ _change_policy(tmp_path, POLICY_CHANGES["gate removed"])
996
+ with pytest.raises(ApiPolicyError, match="an approver rejected it"):
997
+ await _run(bare, "t1", None)
998
+ assert SENT == []
999
+
1000
+
1001
+ class _BrokenLedger:
1002
+ async def consume(self, *args: Any, **kwargs: Any) -> str | None:
1003
+ return "unused"
1004
+
1005
+ async def bound_approvals(self, **kwargs: Any) -> list[BoundApproval]:
1006
+ raise ConnectionError("database down")
1007
+
1008
+
1009
+ async def test_a_ledger_that_cannot_answer_refuses_the_call(graph, store, tmp_path) -> None:
1010
+ _change_policy(tmp_path, POLICY_CHANGES["gate removed"])
1011
+ set_approval_ledger(_BrokenLedger())
1012
+ await _run(graph, "t1", {"messages": [("user", "Cancel the order for 7")]})
1013
+ result = await _last_tool_result(graph, "t1")
1014
+ assert "the approvals of this tool call could not be read (ConnectionError)" in result
1015
+ assert SENT == []
1016
+
1017
+
1018
+ async def test_outside_an_agent_run_a_gated_call_is_refused(graph) -> None:
1019
+ client = get_client("shop", transport=httpx.MockTransport(_upstream))
1020
+ with pytest.raises(ApiPolicyError) as exc:
1021
+ await client.post("/orders", operation_id="createOrder", json_body={"sku": "a"})
1022
+ assert "possible only inside an agent run" in str(exc.value)
1023
+ assert exc.value.reason.startswith("approval required by approval.required_for.methods")
1024
+ assert SENT == []
1025
+ assert api_client.approval_ledger() is not None # the fixture's, untouched
1026
+
1027
+
1028
+ def test_the_decision_type_is_what_the_client_expects() -> None:
1029
+ assert decision_value(_record(), "approve")["type"] == APPROVAL_DECISION
1030
+ assert utcnow().tzinfo is not None
1031
+
1032
+
1033
+ # --- agents acting for a user (0.3): who sees and decides ------------------------------------
1034
+
1035
+ CONCIERGE_ACTOR = Actor(id="concierge", chain=("concierge",))
1036
+ ALICE_VIA_CONCIERGE = Principal(
1037
+ id="alice",
1038
+ attributes={"tenant": "t1", "@actor": CONCIERGE_ACTOR.public()},
1039
+ actor=CONCIERGE_ACTOR,
1040
+ )
1041
+ ALICE_VIA_BILLING = Principal(id="alice", actor=Actor(id="billing", chain=("billing",)))
1042
+ CONCIERGE_THREAD = ThreadRecord(thread_id="t1", principal_id="alice", actor="concierge")
1043
+ DIRECT_THREAD = ThreadRecord(thread_id="t1", principal_id="alice")
1044
+
1045
+
1046
+ def test_delegated_cannot_decide_direct_gate() -> None:
1047
+ # The person decides, whichever agent started the thread; the agent never does.
1048
+ for approvers in (["requester"], ["requester", "role:ops"]):
1049
+ assert may_decide(Principal(id="alice"), CONCIERGE_THREAD, approvers)
1050
+ assert decide_refusal(ALICE_VIA_CONCIERGE, CONCIERGE_THREAD, approvers) == (
1051
+ "approval_direct_only",
1052
+ "This approval must be decided by the person at this agent (decide_with: direct), "
1053
+ "not relayed by agent concierge.",
1054
+ )
1055
+ # Another agent of the same user, or of another user, learns only that it is no approver.
1056
+ for stranger, thread in (
1057
+ (ALICE_VIA_BILLING, CONCIERGE_THREAD),
1058
+ (ALICE_VIA_CONCIERGE, DIRECT_THREAD),
1059
+ (Principal(id="bob", actor=CONCIERGE_ACTOR), CONCIERGE_THREAD),
1060
+ ):
1061
+ refusal = decide_refusal(stranger, thread, ["requester"])
1062
+ assert refusal is not None and refusal[0] == "not_an_approver"
1063
+ # A delegated principal's roles never make it a role approver.
1064
+ ops_agent = Principal(id="carol", roles=["ops"], actor=CONCIERGE_ACTOR)
1065
+ assert not may_decide(ops_agent, CONCIERGE_THREAD, ["role:ops"])
1066
+ assert may_decide(Principal(id="carol", roles=["ops"]), CONCIERGE_THREAD, ["role:ops"])
1067
+
1068
+
1069
+ def test_who_may_see_an_agents_thread(monkeypatch: pytest.MonkeyPatch) -> None:
1070
+ monkeypatch.setenv("AUTH_READ_ACROSS_ROLES", "auditor")
1071
+ approvers = ["requester"]
1072
+ assert may_view(Principal(id="alice"), CONCIERGE_THREAD, approvers) # the person
1073
+ assert may_view(ALICE_VIA_CONCIERGE, CONCIERGE_THREAD, approvers) # the agent that started it
1074
+ assert sees_call(ALICE_VIA_CONCIERGE, CONCIERGE_THREAD, approvers)
1075
+ assert not may_view(ALICE_VIA_BILLING, CONCIERGE_THREAD, approvers)
1076
+ assert not sees_call(ALICE_VIA_BILLING, CONCIERGE_THREAD, approvers)
1077
+ auditor_agent = Principal(id="ann", roles=["auditor"], actor=CONCIERGE_ACTOR)
1078
+ assert not may_view(auditor_agent, CONCIERGE_THREAD, approvers)
1079
+ assert may_view(Principal(id="ann", roles=["auditor"]), CONCIERGE_THREAD, approvers)
1080
+
1081
+
1082
+ def test_the_requester_actor_is_recorded() -> None:
1083
+ record = record_from_interrupt(
1084
+ _interrupt_value(),
1085
+ interrupt_id="i1",
1086
+ thread_id="t1",
1087
+ run_id="r1",
1088
+ requester=ALICE_VIA_CONCIERGE,
1089
+ )
1090
+ assert record.requester_actor == "concierge"
1091
+ assert record.requester_hash == Principal(id="alice").hashed_id()
1092
+ assert record.requester_context["attributes"]["@actor"]["id"] == "concierge"
1093
+ assert record.public()["requester_actor"] == "concierge"
1094
+ assert _record().public()["requester_actor"] is None
1095
+
1096
+
1097
+ def test_the_resumed_run_keeps_the_requesters_actor() -> None:
1098
+ record = record_from_interrupt(
1099
+ _interrupt_value(),
1100
+ interrupt_id="i1",
1101
+ thread_id="t1",
1102
+ run_id="r1",
1103
+ requester=ALICE_VIA_CONCIERGE,
1104
+ )
1105
+ # A role approver's decision: the requester rebuilt, actor included, no credentials.
1106
+ carol = Principal(id="carol", roles=["ops"], attributes={"credentials": {"x": "c"}})
1107
+ acting = resume_principal(record, CONCIERGE_THREAD, carol)
1108
+ assert acting.id == "alice" and acting.actor == CONCIERGE_ACTOR
1109
+ assert "credentials" not in acting.attributes
1110
+ # The person deciding at this agent: their own principal of this request.
1111
+ alice = Principal(id="alice", attributes={"credentials": {"x": "alice's"}})
1112
+ assert resume_principal(record, CONCIERGE_THREAD, alice) is alice
1113
+ # The same owner key: the principal of this request.
1114
+ assert resume_principal(record, CONCIERGE_THREAD, ALICE_VIA_CONCIERGE) == ALICE_VIA_CONCIERGE
1115
+
1116
+
1117
+ # --- the user's words of the request that paused (the origin extension) ----------------------
1118
+
1119
+ ASKED = {"text": "please cancel order 7", "truncated": False, "hops": 1}
1120
+
1121
+
1122
+ def _asked_record(**overrides: Any) -> ApprovalRecord:
1123
+ """An approval of a call the concierge's request paused, with the user's words it forwarded."""
1124
+ fields = {"interrupt_id": "i1", "thread_id": "t1", "run_id": "r1"}
1125
+ value_overrides = {k: v for k, v in overrides.items() if k not in fields}
1126
+ fields.update({k: v for k, v in overrides.items() if k in fields})
1127
+ return record_from_interrupt(
1128
+ _interrupt_value(**value_overrides), requester=ALICE_VIA_CONCIERGE, origin=ASKED, **fields
1129
+ )
1130
+
1131
+
1132
+ def test_the_users_words_are_kept_with_the_approval_and_never_shown() -> None:
1133
+ record = _asked_record()
1134
+ assert record.payload["origin"] == ASKED
1135
+ shown = json.dumps(
1136
+ [record.public(), approval_view(record), decision_value(record, "approve")], default=str
1137
+ )
1138
+ assert "origin" not in record.public() and ASKED["text"] not in shown
1139
+ # What the approver sees, and so the digest a relayed decision names, is the same.
1140
+ plain = record_from_interrupt(
1141
+ _interrupt_value(),
1142
+ interrupt_id="i1",
1143
+ thread_id="t1",
1144
+ run_id="r1",
1145
+ requester=ALICE_VIA_CONCIERGE,
1146
+ )
1147
+ assert "origin" not in plain.payload and record.display_digest == plain.display_digest
1148
+
1149
+
1150
+ def test_the_resumed_run_acts_on_the_words_of_the_request_that_paused() -> None:
1151
+ record = _asked_record()
1152
+ relayed = {"text": "yes, go ahead", "truncated": False, "hops": 1}
1153
+ relayer = Principal(
1154
+ id="alice",
1155
+ attributes={
1156
+ "@actor": CONCIERGE_ACTOR.public(),
1157
+ "credentials": {"@subject_token": "fresh", "@origin": relayed},
1158
+ },
1159
+ actor=CONCIERGE_ACTOR,
1160
+ )
1161
+ # The agent delivering the person's decision: its fresh token, the request's words (not
1162
+ # the words the person said when approving at the agent that asked).
1163
+ acting = resume_principal(record, CONCIERGE_THREAD, relayer, origin=ASKED)
1164
+ assert acting.id == "alice" and acting.actor == CONCIERGE_ACTOR
1165
+ assert acting.attributes["credentials"] == {"@subject_token": "fresh", "@origin": ASKED}
1166
+ assert relayer.attributes["credentials"]["@origin"] == relayed # the decider's own: untouched
1167
+ # No words kept with the approval (the request forwarded none): the decision's are not
1168
+ # taken instead.
1169
+ bare = resume_principal(record, CONCIERGE_THREAD, relayer, origin=None)
1170
+ assert bare.attributes["credentials"] == {"@subject_token": "fresh"}
1171
+ # A role approver: the requester rebuilt, with the request's words and no credential.
1172
+ carol = Principal(id="carol", roles=["ops"], attributes={"credentials": {"x": "c"}})
1173
+ rebuilt = resume_principal(record, CONCIERGE_THREAD, carol, origin=ASKED)
1174
+ assert rebuilt.id == "alice" and rebuilt.actor == CONCIERGE_ACTOR
1175
+ assert rebuilt.attributes["credentials"] == {"@origin": ASKED}
1176
+ assert "credentials" not in resume_principal(record, CONCIERGE_THREAD, carol).attributes
1177
+ # The person deciding at this agent acts with their own words.
1178
+ alice = Principal(id="alice", attributes={"credentials": {"x": "alice's"}})
1179
+ assert resume_principal(record, CONCIERGE_THREAD, alice, origin=ASKED) is alice
1180
+
1181
+
1182
+ async def test_the_users_words_go_once_the_approval_is_decided_or_expired(
1183
+ store: ApprovalStore, monkeypatch: pytest.MonkeyPatch
1184
+ ) -> None:
1185
+ monkeypatch.setenv("TRACE_CAPTURE", "full") # the call is kept; the words never are
1186
+ pending, _, _ = await store.add(_asked_record())
1187
+ assert (await store.get(pending.approval_id)).payload["origin"] == ASKED
1188
+ decided = await store.decide(pending.approval_id, APPROVED, "x", None)
1189
+ assert decided is not None and "origin" not in decided.payload
1190
+ assert decided.payload["body"] == {"reason": "asked"}
1191
+ assert "origin" not in (await store.get(pending.approval_id)).payload
1192
+ if store.path is not None:
1193
+ assert ASKED["text"] not in store.path.read_text(encoding="utf-8")
1194
+ # Expired: one the run no longer waits for, one past its time, one superseded.
1195
+ gone, _, _ = await store.add(_asked_record(interrupt_id="i2"))
1196
+ await store.expire(gone.approval_id)
1197
+ first, _, _ = await store.add(_asked_record(interrupt_id="i3"))
1198
+ later, _, _ = await store.add(_asked_record(interrupt_id="i3", call_hash="h2"))
1199
+ store._clock = lambda: later.expires_at + timedelta(seconds=1)
1200
+ assert [r.approval_id for r in await store.expire_due()] == [later.approval_id]
1201
+ for record in (gone, first, later):
1202
+ kept = await store.get(record.approval_id)
1203
+ assert kept.status == EXPIRED and "origin" not in kept.payload, kept
1204
+ if store.path is not None:
1205
+ assert ASKED["text"] not in store.path.read_text(encoding="utf-8")
1206
+
1207
+
1208
+ @pytest.mark.parametrize(("runtime", "kept"), [("fastapi", True), ("langgraph-server", False)])
1209
+ async def test_the_words_are_kept_only_where_the_resumed_run_can_use_them(
1210
+ runtime: str, kept: bool
1211
+ ) -> None:
1212
+ """LangGraph Server never passes credentials (the words among them) to tools."""
1213
+ from {{cookiecutter.agent_directory}}.app_utils import chat
1214
+
1215
+ rt = chat.ChatRuntime()
1216
+ rt.runtime = runtime
1217
+ rt.approvals = ApprovalStore(Database("memory"))
1218
+ asking = Principal(
1219
+ id="alice",
1220
+ attributes={"@actor": CONCIERGE_ACTOR.public(), "credentials": {"@origin": ASKED}},
1221
+ actor=CONCIERGE_ACTOR,
1222
+ )
1223
+ [record] = await rt._record_approvals(
1224
+ asking, "t1", "r1", [{"id": "i1", "value": _interrupt_value()}]
1225
+ )
1226
+ assert (record.payload.get("origin") == ASKED) is kept
1227
+ assert ("origin" in record.payload) is kept
1228
+
1229
+
1230
+ async def test_listing_follows_the_owner_key(store: ApprovalStore) -> None:
1231
+ by_agent = record_from_interrupt(
1232
+ _interrupt_value(),
1233
+ interrupt_id="i1",
1234
+ thread_id="t1",
1235
+ run_id="r1",
1236
+ requester=ALICE_VIA_CONCIERGE,
1237
+ )
1238
+ by_person, _, _ = await store.add(_record(interrupt_id="i2", thread_id="t2"))
1239
+ await store.add(by_agent)
1240
+ everything = {by_agent.approval_id, by_person.approval_id}
1241
+ assert {r.approval_id for r in await store.visible(ALICE)} == everything
1242
+ assert [r.approval_id for r in await store.visible(ALICE_VIA_CONCIERGE)] == [
1243
+ by_agent.approval_id
1244
+ ]
1245
+ assert await store.visible(ALICE_VIA_BILLING) == []
1246
+ # A delegated principal's roles list nothing a role of theirs could decide.
1247
+ ops_agent = Principal(id="carol", roles=["ops"], actor=CONCIERGE_ACTOR)
1248
+ assert await store.visible(ops_agent) == []
1249
+ reloaded = await store.get(by_agent.approval_id)
1250
+ assert reloaded is not None and reloaded.requester_actor == "concierge"
1251
+
1252
+
1253
+ # --- relayed decisions (decide_with: relayed, relayers) ----------------------------------------
1254
+
1255
+ RELAYED = {"decide_with": "relayed", "relayers": ["concierge"]}
1256
+
1257
+
1258
+ def _relayed_record(**overrides: Any) -> ApprovalRecord:
1259
+ value = _interrupt_value(approvers=["requester"], **{**RELAYED, **overrides})
1260
+ return record_from_interrupt(
1261
+ value, interrupt_id="i1", thread_id="t1", run_id="r1", requester=ALICE_VIA_CONCIERGE
1262
+ )
1263
+
1264
+
1265
+ def test_relayed_requires_listed_actor_owner_actor_and_digest() -> None:
1266
+ """The 2.2 table, cell by cell."""
1267
+ record = _relayed_record()
1268
+ rule = {"decide_with": record.decide_with, "relayers": record.relayers}
1269
+ approvers = record.approvers
1270
+ # Direct, the subject (whichever agent started the thread): yes, as under direct.
1271
+ assert decide_refusal(Principal(id="alice"), CONCIERGE_THREAD, approvers, **rule) is None
1272
+ # Direct, another subject holding a listed role: yes (a role gate is always direct).
1273
+ carol = Principal(id="carol", roles=["ops"])
1274
+ assert may_decide(carol, CONCIERGE_THREAD, ["requester", "role:ops"], **rule)
1275
+ # Delegated: all of requester listed, its own thread (subject and actor), a listed relayer.
1276
+ assert decide_refusal(ALICE_VIA_CONCIERGE, CONCIERGE_THREAD, approvers, **rule) is None
1277
+ for principal, thread, why in (
1278
+ (ALICE_VIA_BILLING, CONCIERGE_THREAD, "You may not decide this approval"),
1279
+ (ALICE_VIA_CONCIERGE, DIRECT_THREAD, "You may not decide this approval"),
1280
+ (
1281
+ Principal(id="bob", actor=CONCIERGE_ACTOR),
1282
+ ThreadRecord(thread_id="t1", principal_id="bob", actor="concierge"),
1283
+ None,
1284
+ ),
1285
+ ):
1286
+ refusal = decide_refusal(principal, thread, approvers, **rule)
1287
+ if why is None: # bob's own thread, but alice's approval rule: listed, so it may
1288
+ assert refusal is None
1289
+ else:
1290
+ assert refusal is not None and refusal[0] == "not_an_approver" and why in refusal[1]
1291
+ unlisted = {"decide_with": "relayed", "relayers": ["billing"]}
1292
+ assert decide_refusal(ALICE_VIA_CONCIERGE, CONCIERGE_THREAD, approvers, **unlisted) == (
1293
+ "not_an_approver",
1294
+ "concierge may not relay decisions for this approval.",
1295
+ )
1296
+ # A role-only rule is never relayed (the schema refuses it; fail closed anyway).
1297
+ refusal = decide_refusal(ALICE_VIA_CONCIERGE, CONCIERGE_THREAD, ["role:ops"], **rule)
1298
+ assert refusal is not None and refusal[0] == "approval_direct_only"
1299
+ # The digest: required of a relayer, checked when a direct decider sends one.
1300
+ assert digest_refusal(ALICE_VIA_CONCIERGE, record, record.display_digest) is False
1301
+ assert digest_refusal(ALICE_VIA_CONCIERGE, record, None) is True
1302
+ assert digest_refusal(ALICE_VIA_CONCIERGE, record, "sha256:" + "0" * 64) is True
1303
+ assert digest_refusal(Principal(id="alice"), record, None) is False
1304
+ assert digest_refusal(Principal(id="alice"), record, "sha256:x") is True
1305
+ assert digest_refusal(Principal(id="alice"), record, record.display_digest) is False
1306
+
1307
+
1308
+ def test_the_digest_covers_what_the_approver_sees() -> None:
1309
+ record = _relayed_record()
1310
+ assert record.display_digest == approval_digest(approval_view(record))
1311
+ assert record.display_digest.startswith("sha256:") and len(record.display_digest) == 71
1312
+ public = record.public()
1313
+ assert public["digest"] == record.display_digest
1314
+ assert (public["decide_with"], public["decided_via"]) == ("relayed", None)
1315
+ assert "relayers" not in public
1316
+ other = _relayed_record(body={"reason": "something else"})
1317
+ assert other.display_digest != record.display_digest
1318
+ assert _record().public()["decide_with"] == "direct" # a gate without decide_with
1319
+
1320
+
1321
+ def test_how_approvers_decide_is_bound_with_the_approvers() -> None:
1322
+ record = _relayed_record()
1323
+ assert (record.decide_with, record.relayers) == ("relayed", ["concierge"])
1324
+ value = decision_value(record, "approve")
1325
+ assert (value["decide_with"], value["relayers"]) == ("relayed", ["concierge"])
1326
+ # An interrupt from before 0.3 (no decide_with) asks for a direct decision.
1327
+ legacy = record_from_interrupt(
1328
+ {k: v for k, v in _interrupt_value().items() if k not in RELAYED},
1329
+ interrupt_id="i1",
1330
+ thread_id="t1",
1331
+ run_id="r1",
1332
+ requester=ALICE,
1333
+ )
1334
+ assert (legacy.decide_with, legacy.relayers) == ("direct", [])
1335
+ forged = _relayed_record(decide_with="any")
1336
+ assert (forged.decide_with, forged.relayers) == ("direct", [])
1337
+
1338
+
1339
+ async def test_decided_via_is_recorded(store: ApprovalStore) -> None:
1340
+ record, _, _ = await store.add(_relayed_record())
1341
+ decided = await store.decide(
1342
+ record.approval_id, APPROVED, Principal(id="alice").hashed_id(), None, "concierge"
1343
+ )
1344
+ assert decided is not None and decided.decided_via == "concierge"
1345
+ reloaded = await store.get(record.approval_id)
1346
+ assert reloaded is not None and reloaded.decided_via == "concierge"
1347
+ assert reloaded.public()["decided_via"] == "concierge"
1348
+ assert (reloaded.decide_with, reloaded.relayers) == ("relayed", ["concierge"])
1349
+ assert reloaded.display_digest == record.display_digest
1350
+
1351
+
1352
+ async def test_a_version_1_approvals_file_is_read(tmp_path: Path) -> None:
1353
+ path = tmp_path / ".langgraph_api" / "agent_approvals.json"
1354
+ path.parent.mkdir()
1355
+ row = {
1356
+ k: v
1357
+ for k, v in _row_of(_record()).items()
1358
+ if k not in ("requester_actor", "decide_with", "relayers", "decided_via", "display_digest")
1359
+ }
1360
+ path.write_text(json.dumps({"version": 1, "approvals": [row]}), encoding="utf-8")
1361
+ store = ApprovalStore(Database("memory"), path=path)
1362
+ assert await store.load() == 1
1363
+ [record] = await store.for_thread("t1")
1364
+ assert (record.decide_with, record.relayers, record.requester_actor) == ("direct", [], "")
1365
+ assert record.decided_via is None and record.display_digest is None
1366
+ # The next write is a version-2 file.
1367
+ await store.decide(record.approval_id, REJECTED, "x", None)
1368
+ assert json.loads(path.read_text())["version"] == 2
1369
+
1370
+
1371
+ async def test_a_gate_that_starts_relaying_does_not_keep_a_direct_approval(
1372
+ graph, store, tmp_path
1373
+ ) -> None:
1374
+ interrupt, record = await _pause(graph, "t1", store)
1375
+ assert interrupt.value["decide_with"] == "direct" and interrupt.value["relayers"] == []
1376
+ decided = await _decided(store, record, "approve")
1377
+ relayed = POLICY.replace(
1378
+ " timeout_s: 60",
1379
+ " timeout_s: 60\n decide_with: relayed\n relayers: [concierge]",
1380
+ )
1381
+ _change_policy(tmp_path, relayed)
1382
+ await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "approve")}))
1383
+ result = await _last_tool_result(graph, "t1")
1384
+ assert "decide_with or relayers, differs from the policy's now" in result
1385
+ assert SENT == []
1386
+
1387
+
1388
+ async def test_a_relayed_gate_binds_its_relayers(graph, store, tmp_path) -> None:
1389
+ relayed = POLICY.replace(
1390
+ ' approvers: [requester, "role:ops"]',
1391
+ " approvers: [requester]\n decide_with: relayed\n relayers: [concierge]",
1392
+ )
1393
+ _change_policy(tmp_path, relayed)
1394
+ interrupt, record = await _pause(graph, "t1", store)
1395
+ assert (interrupt.value["decide_with"], interrupt.value["relayers"]) == (
1396
+ "relayed",
1397
+ ["concierge"],
1398
+ )
1399
+ assert (record.decide_with, record.relayers) == ("relayed", ["concierge"])
1400
+ decided = await _decided(store, record, "approve")
1401
+ # Another relayer by the time the call is sent: the approval does not cover it.
1402
+ _change_policy(tmp_path, relayed.replace("[concierge]", "[billing]"))
1403
+ await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "approve")}))
1404
+ assert "differs from the policy's now" in await _last_tool_result(graph, "t1")
1405
+ assert SENT == []
1406
+ # The same relayers: sent once.
1407
+ _change_policy(tmp_path, relayed)
1408
+ interrupt, record = await _pause(graph, "t2", store)
1409
+ decided = await _decided(store, record, "approve")
1410
+ await _run(graph, "t2", Command(resume={interrupt.id: decision_value(decided, "approve")}))
1411
+ assert [r.url.path for r in SENT] == ["/orders/7/cancel"]
1412
+
1413
+
1414
+ # --- a decision relayed to another agent: `nested` and `effect` (0.3) -------------------------
1415
+
1416
+
1417
+ def _nested(depth: int = 2, expires_at: str = "2099-01-01T00:00:00+00:00") -> dict[str, Any]:
1418
+ """A relayed approval chain `depth` levels deep (billing relays orders' cancel)."""
1419
+ inner: dict[str, Any] = {
1420
+ "agent": "orders",
1421
+ "approval_id": "o1",
1422
+ "call": {
1423
+ "api": "orders_api",
1424
+ "method": "POST",
1425
+ "path": "/orders/7/cancel",
1426
+ "operation_id": "cancelOrder",
1427
+ "query": {"notify": "yes"},
1428
+ "body": {"card": "4111"},
1429
+ },
1430
+ "reason": "cancel_order: asked",
1431
+ "expires_at": expires_at,
1432
+ "nested": None,
1433
+ }
1434
+ for level in range(depth - 1):
1435
+ inner = {
1436
+ "agent": f"hop{level}",
1437
+ "approval_id": f"h{level}",
1438
+ "call": {"api": "orders_agent", "method": "POST", "path": "/a2a/orders", "body": {}},
1439
+ "reason": "relay",
1440
+ "expires_at": expires_at,
1441
+ "nested": inner,
1442
+ }
1443
+ return inner
1444
+
1445
+
1446
+ def _relayed_value(nested: dict[str, Any]) -> dict[str, Any]:
1447
+ from {{cookiecutter.agent_directory}}.app_utils.api_client import approval_effect
1448
+
1449
+ return _interrupt_value(
1450
+ api="billing_agent",
1451
+ path="/a2a/billing",
1452
+ nested=nested,
1453
+ effect=approval_effect(nested),
1454
+ rpc_method="SendMessage",
1455
+ a2a_operation="approve",
1456
+ )
1457
+
1458
+
1459
+ def test_a_relayed_approval_shows_the_chain_and_the_effect() -> None:
1460
+ record = record_from_interrupt(
1461
+ _relayed_value(_nested()), interrupt_id="i1", thread_id="t1", run_id="r1", requester=ALICE
1462
+ )
1463
+ public = record.public()
1464
+ assert public["effect"]["path"] == "/orders/7/cancel"
1465
+ assert public["effect"]["via"] == ["hop0", "orders"]
1466
+ assert public["nested"]["nested"]["call"]["body"] == {"card": "4111"}
1467
+ # Viewers who do not see the call (read-across roles) do not see the nested calls either.
1468
+ hidden = record.public(include_call=False)
1469
+ assert "body" not in hidden["nested"]["call"]
1470
+ assert "body" not in hidden["nested"]["nested"]["call"]
1471
+ assert "query" not in hidden["nested"]["nested"]["call"]
1472
+ assert "body" not in hidden["effect"] and "query" not in hidden["effect"]
1473
+ assert hidden["effect"]["path"] == "/orders/7/cancel"
1474
+
1475
+
1476
+ def test_a_relayed_approval_expires_before_the_one_it_decides() -> None:
1477
+ """Never ask the person to approve what has expired where it happens: at most the
1478
+ downstream approval's expiry less 5 s (the rule's own timeout otherwise)."""
1479
+ now = utcnow()
1480
+ soon = (now + timedelta(seconds=40)).isoformat()
1481
+ record = record_from_interrupt(
1482
+ _relayed_value(_nested(expires_at=soon)),
1483
+ interrupt_id="i1",
1484
+ thread_id="t1",
1485
+ run_id="r1",
1486
+ requester=ALICE,
1487
+ now=now,
1488
+ )
1489
+ assert record.expires_at == now + timedelta(seconds=35)
1490
+ later = record_from_interrupt(
1491
+ _relayed_value(_nested(expires_at=(now + timedelta(hours=1)).isoformat())),
1492
+ interrupt_id="i1",
1493
+ thread_id="t1",
1494
+ run_id="r1",
1495
+ requester=ALICE,
1496
+ now=now,
1497
+ )
1498
+ assert later.expires_at == now + timedelta(seconds=60) # the rule's timeout_s
1499
+
1500
+
1501
+ async def test_decided_records_drop_the_nested_calls_unless_full_capture(
1502
+ store: ApprovalStore, monkeypatch: pytest.MonkeyPatch
1503
+ ) -> None:
1504
+ record, _, _ = await store.add(
1505
+ record_from_interrupt(
1506
+ _relayed_value(_nested(depth=3)),
1507
+ interrupt_id="i1",
1508
+ thread_id="t1",
1509
+ run_id="r1",
1510
+ requester=ALICE,
1511
+ )
1512
+ )
1513
+ decided = await store.decide(record.approval_id, APPROVED, "x", None)
1514
+ assert decided is not None
1515
+ level = decided.payload["nested"]
1516
+ for _ in range(3):
1517
+ assert "body" not in level["call"] and "query" not in level["call"]
1518
+ assert level["call"]["path"] # the rest of the call stays
1519
+ level = level["nested"]
1520
+ assert level is None
1521
+ effect = decided.payload["effect"]
1522
+ assert "body" not in effect and "query" not in effect and effect["path"] == "/orders/7/cancel"
1523
+ assert (await store.get(record.approval_id)).payload == decided.payload
1524
+ monkeypatch.setenv("TRACE_CAPTURE", "full")
1525
+ kept, _, _ = await store.add(
1526
+ record_from_interrupt(
1527
+ _relayed_value(_nested()),
1528
+ interrupt_id="i2",
1529
+ thread_id="t1",
1530
+ run_id="r1",
1531
+ requester=ALICE,
1532
+ )
1533
+ )
1534
+ decided = await store.decide(kept.approval_id, APPROVED, "x", None)
1535
+ assert decided.payload["nested"]["nested"]["call"]["body"] == {"card": "4111"}
1536
+ assert decided.payload["effect"]["body"] == {"card": "4111"}