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,794 @@
1
+ # Copyright 2026 graph-agents-cli contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Human approval of gated API calls under the langgraph-server runtime, end to end.
16
+
17
+ A real LangGraph dev server (`langgraph dev`, a dev dependency of every
18
+ project) serves this app as its custom app, the app's auth handler, and the
19
+ graph of `approval_graph.py`: the agent with one tool whose call the test
20
+ policy gates. The upstream API is a local HTTP server that records every
21
+ request. Principals are JWTs signed with a key made here (`AUTH_POLICY=jwt`).
22
+ The run pauses and resumes through the server's own interrupt and resume.
23
+ Skipped when the LangGraph CLI and its in-memory server are not installed.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import importlib.util
29
+ import json
30
+ import os
31
+ import signal
32
+ import socket
33
+ import subprocess
34
+ import sys
35
+ import threading
36
+ import time
37
+ from collections.abc import Iterator
38
+ from contextlib import contextmanager
39
+ from dataclasses import dataclass, field
40
+ from datetime import datetime
41
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
42
+ from pathlib import Path
43
+ from typing import Any
44
+
45
+ import httpx
46
+ import jwt
47
+ import pytest
48
+ from cryptography.hazmat.primitives import serialization
49
+ from cryptography.hazmat.primitives.asymmetric import rsa
50
+
51
+ PROJECT = Path(__file__).resolve().parents[2]
52
+ AGENT_DIR = PROJECT / "{{cookiecutter.agent_directory}}"
53
+ LANGGRAPH = Path(sys.executable).with_name("langgraph")
54
+ pytestmark = pytest.mark.skipif(
55
+ not LANGGRAPH.exists() or importlib.util.find_spec("langgraph_api") is None,
56
+ reason="needs the LangGraph CLI with its in-memory server (langgraph-cli[inmem])",
57
+ )
58
+
59
+ ISSUER = "https://issuer.test/"
60
+ AUDIENCE = "agent-api"
61
+ KEY = rsa.generate_private_key(public_exponent=65537, key_size=2048)
62
+ A2A_PATH = "/a2a/{{cookiecutter.agent_directory}}"
63
+ TIMEOUT_S = 30 # the policy's approval.timeout_s (its minimum)
64
+ BODY = {"reason": "customer asked"}
65
+
66
+ POLICY = """
67
+ apis:
68
+ shop:
69
+ base_url_env: SHOP_API_BASE_URL
70
+ auth: none
71
+ allowed_methods: [GET, POST]
72
+ approval:
73
+ required_for:
74
+ operations:
75
+ - operationId: cancelOrder
76
+ path: /orders/{order_id}/cancel
77
+ methods: [POST]
78
+ approvers: [requester, "role:ops"]
79
+ timeout_s: TIMEOUT
80
+ """.replace("TIMEOUT", str(TIMEOUT_S))
81
+
82
+
83
+ def token(user: str, *roles: str) -> dict[str, str]:
84
+ now = int(time.time())
85
+ claims = {
86
+ "sub": user,
87
+ "iss": ISSUER,
88
+ "aud": AUDIENCE,
89
+ "iat": now,
90
+ "nbf": now,
91
+ "exp": now + 600,
92
+ "roles": list(roles or ("user",)),
93
+ }
94
+ return {"Authorization": f"Bearer {jwt.encode(claims, KEY, algorithm='RS256')}"}
95
+
96
+
97
+ class Upstream(ThreadingHTTPServer):
98
+ """The API the tool calls: records every request, answers 200."""
99
+
100
+ def __init__(self) -> None:
101
+ self.received: list[tuple[str, str, Any]] = []
102
+ self.lock = threading.Lock()
103
+ upstream = self
104
+
105
+ class Handler(BaseHTTPRequestHandler):
106
+ def do_PATCH(self) -> None:
107
+ self.do_POST()
108
+
109
+ def do_POST(self) -> None:
110
+ length = int(self.headers.get("content-length") or 0)
111
+ body = json.loads(self.rfile.read(length) or b"null")
112
+ with upstream.lock:
113
+ upstream.received.append((self.command, self.path, body))
114
+ payload = json.dumps({"cancelled": self.path}).encode()
115
+ self.send_response(200)
116
+ self.send_header("content-type", "application/json")
117
+ self.send_header("content-length", str(len(payload)))
118
+ self.end_headers()
119
+ self.wfile.write(payload)
120
+
121
+ def log_message(self, *args: Any) -> None:
122
+ pass
123
+
124
+ super().__init__(("127.0.0.1", 0), Handler)
125
+
126
+ def sent_to(self, path: str) -> list[Any]:
127
+ with self.lock:
128
+ return [body for _, p, body in self.received if p == path]
129
+
130
+
131
+ @dataclass
132
+ class Server:
133
+ url: str
134
+ upstream: Upstream
135
+ body_file: Path
136
+ log: Path
137
+ policy: Path
138
+ expiring: dict[str, Any] = field(default_factory=dict)
139
+ expiring_native: dict[str, Any] = field(default_factory=dict)
140
+
141
+
142
+ def _free_port() -> int:
143
+ with socket.socket() as sock:
144
+ sock.bind(("127.0.0.1", 0))
145
+ return int(sock.getsockname()[1])
146
+
147
+
148
+ def parse_sse(text: str) -> list[tuple[str, dict[str, Any]]]:
149
+ events: list[tuple[str, dict[str, Any]]] = []
150
+ event = None
151
+ for line in text.splitlines():
152
+ if line.startswith("event:"):
153
+ event = line[6:].strip()
154
+ elif line.startswith("data:") and event:
155
+ events.append((event, json.loads(line[5:].strip())))
156
+ event = None
157
+ return events
158
+
159
+
160
+ def post(
161
+ server: Server, path: str, body: dict[str, Any], headers: dict[str, str]
162
+ ) -> httpx.Response:
163
+ return httpx.post(f"{server.url}{path}", json=body, headers=headers, timeout=60)
164
+
165
+
166
+ def pause(server: Server, order: str, user: str = "alice") -> dict[str, Any]:
167
+ """A run on a new thread that pauses before the gated call; its `message.end`."""
168
+ return pause_on(server, f"Cancel the order for {order}", user)
169
+
170
+
171
+ def pause_on(server: Server, message: str, user: str = "alice") -> dict[str, Any]:
172
+ """A run on a new thread for `message` that pauses before a gated call; its `message.end`."""
173
+ r = post(server, "/chat", {"message": message}, token(user))
174
+ assert r.status_code == 200, r.text
175
+ events = parse_sse(r.text)
176
+ assert events[-1][0] == "message.end", events
177
+ end = events[-1][1]
178
+ assert end["status"] == "awaiting_approval", end
179
+ return end
180
+
181
+
182
+ def decide(
183
+ server: Server, end: dict[str, Any], decision: str, headers: dict[str, str]
184
+ ) -> httpx.Response:
185
+ approval = end["approval"]
186
+ return post(
187
+ server,
188
+ f"/threads/{end['thread_id']}/approvals/{approval['approval_id']}",
189
+ {"decision": decision},
190
+ headers,
191
+ )
192
+
193
+
194
+ def _write_config(work: Path) -> Path:
195
+ """The `langgraph.json` a test server runs with: this project, the test graph."""
196
+ project_config = json.loads((PROJECT / "langgraph.json").read_text(encoding="utf-8"))
197
+ config = work / "langgraph.json"
198
+ config.write_text(
199
+ json.dumps(
200
+ {
201
+ "dependencies": [str(PROJECT)],
202
+ "graphs": {"agent": f"{PROJECT / 'tests/integration/approval_graph.py'}:graph"},
203
+ "http": {"app": f"{AGENT_DIR / 'fast_api_app.py'}:app"},
204
+ "auth": {"path": f"{AGENT_DIR / 'app_utils/auth.py'}:auth"},
205
+ "python_version": project_config.get("python_version", "3.12"),
206
+ }
207
+ ),
208
+ encoding="utf-8",
209
+ )
210
+ return config
211
+
212
+
213
+ def _server_env(policy: Path, body_file: Path, upstream: Upstream) -> dict[str, str]:
214
+ pem = (
215
+ KEY.public_key()
216
+ .public_bytes(serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo)
217
+ .decode()
218
+ )
219
+ return {
220
+ **os.environ,
221
+ "MODEL_PROVIDER": "fake",
222
+ "MODEL_NAME": "fake",
223
+ "AUTH_POLICY": "jwt",
224
+ "AUTH_JWT_PUBLIC_KEY": pem,
225
+ "AUTH_JWT_ISSUER": ISSUER,
226
+ "AUTH_JWT_AUDIENCE": AUDIENCE,
227
+ "APP_ENV": "dev",
228
+ "RUNTIME": "langgraph-server",
229
+ "API_POLICY_PATH": str(policy),
230
+ "SHOP_API_BASE_URL": f"http://127.0.0.1:{upstream.server_address[1]}",
231
+ "TEST_APPROVAL_BODY_FILE": str(body_file),
232
+ "TRACING_ENABLED": "false",
233
+ "LANGSMITH_TRACING": "false",
234
+ "LANGGRAPH_CLI_NO_ANALYTICS": "1",
235
+ "PYTHON_DOTENV_DISABLED": "1",
236
+ }
237
+
238
+
239
+ def _start_dev(
240
+ work: Path, env: dict[str, str], port: int, log: Path, *, reload: bool = False
241
+ ) -> subprocess.Popen[bytes]:
242
+ """`langgraph dev` in `work` (where it keeps its threads, in `.langgraph_api/`), once up.
243
+
244
+ With `reload`, a change to a `.py` file under `work` restarts it (a hot reload).
245
+ """
246
+ args = [str(LANGGRAPH), "dev", "--config", str(work / "langgraph.json")]
247
+ args += ["--port", str(port), "--no-browser"] + ([] if reload else ["--no-reload"])
248
+ with log.open("ab") as out:
249
+ proc = subprocess.Popen(
250
+ args, cwd=work, env=env, stdout=out, stderr=subprocess.STDOUT, start_new_session=True
251
+ )
252
+ try:
253
+ _wait_up(proc, f"http://127.0.0.1:{port}", log)
254
+ except BaseException:
255
+ _stop(proc)
256
+ raise
257
+ return proc
258
+
259
+
260
+ def _wait_up(proc: subprocess.Popen[bytes], url: str, log: Path, timeout_s: float = 120) -> None:
261
+ deadline = time.monotonic() + timeout_s
262
+ while True:
263
+ if proc.poll() is not None:
264
+ pytest.fail(f"langgraph dev exited:\n{log.read_text()[-4000:]}")
265
+ try:
266
+ if httpx.get(f"{url}/health", timeout=2).status_code == 200:
267
+ return
268
+ except httpx.HTTPError:
269
+ pass
270
+ if time.monotonic() > deadline:
271
+ pytest.fail(f"langgraph dev did not start:\n{log.read_text()[-4000:]}")
272
+ time.sleep(0.5)
273
+
274
+
275
+ @pytest.fixture(scope="module")
276
+ def server(tmp_path_factory: pytest.TempPathFactory) -> Iterator[Server]:
277
+ work = tmp_path_factory.mktemp("approvals-server")
278
+ upstream = Upstream()
279
+ threading.Thread(target=upstream.serve_forever, daemon=True).start()
280
+ body_file = work / "body.json"
281
+ body_file.write_text(json.dumps(BODY), encoding="utf-8")
282
+ policy = work / "api-policy.yaml"
283
+ policy.write_text(POLICY, encoding="utf-8")
284
+ _write_config(work)
285
+ port = _free_port()
286
+ log = work / "server.log"
287
+ proc = None
288
+ try:
289
+ proc = _start_dev(work, _server_env(policy, body_file, upstream), port, log)
290
+ url = f"http://127.0.0.1:{port}"
291
+ running = Server(url=url, upstream=upstream, body_file=body_file, log=log, policy=policy)
292
+ # Paused first, so they have expired by the time the last tests use them.
293
+ running.expiring = pause(running, "90")
294
+ running.expiring_native = pause(running, "91")
295
+ yield running
296
+ finally:
297
+ if proc is not None:
298
+ _stop(proc)
299
+ upstream.shutdown()
300
+ upstream.server_close()
301
+
302
+
303
+ def native_run(server: Server, thread: str, body: dict[str, Any]) -> httpx.Response:
304
+ """A run through the server's own API (not the app's routes), as the thread's owner."""
305
+ return httpx.post(
306
+ f"{server.url}/threads/{thread}/runs/wait",
307
+ json={"assistant_id": "agent", **body},
308
+ headers=token("alice"),
309
+ timeout=60,
310
+ )
311
+
312
+
313
+ def paused_checkpoint(server: Server, thread: str) -> str:
314
+ """The checkpoint where the thread's run paused for the gated call."""
315
+ r = post(server, f"/threads/{thread}/history", {"limit": 20}, token("alice"))
316
+ assert r.status_code == 200, r.text
317
+ return next(
318
+ h["checkpoint"]["checkpoint_id"]
319
+ for h in r.json()
320
+ if any(task.get("interrupts") for task in h.get("tasks") or [])
321
+ )
322
+
323
+
324
+ def rerun_paused_step(server: Server, thread: str, checkpoint: str) -> list[httpx.Response]:
325
+ """The native runs that run a paused step again: without input, and from its checkpoint."""
326
+ return [
327
+ native_run(server, thread, {"input": None}),
328
+ native_run(server, thread, {"checkpoint_id": checkpoint}),
329
+ ]
330
+
331
+
332
+ def assert_refused_at_the_server(server: Server, thread: str, checkpoint: str) -> None:
333
+ """Native runs that would run the paused step again, and a copy (which would carry its
334
+ tool call without the approval), are refused on a thread with approvals."""
335
+ for r in rerun_paused_step(server, thread, checkpoint):
336
+ assert r.status_code == 403, r.text
337
+ assert "the thread has approvals of gated API calls" in r.json()["detail"]
338
+ r = httpx.post(f"{server.url}/threads/{thread}/copy", headers=token("alice"), timeout=30)
339
+ assert r.status_code == 403, r.text
340
+ assert "cannot be copied" in r.json()["detail"]
341
+
342
+
343
+ @contextmanager
344
+ def gate_removed(server: Server) -> Iterator[None]:
345
+ """A new policy that no longer gates the call (a new image, a typo)."""
346
+ server.policy.write_text(POLICY.split(" approval:")[0], encoding="utf-8")
347
+ try:
348
+ yield
349
+ finally:
350
+ server.policy.write_text(POLICY, encoding="utf-8")
351
+
352
+
353
+ def _stop(proc: subprocess.Popen[bytes]) -> None:
354
+ """Stop the server and everything it started (its own process group)."""
355
+ if proc.poll() is not None:
356
+ return
357
+ try:
358
+ os.killpg(proc.pid, signal.SIGTERM)
359
+ proc.wait(timeout=20)
360
+ except subprocess.TimeoutExpired:
361
+ os.killpg(proc.pid, signal.SIGKILL)
362
+ proc.wait(timeout=10)
363
+ except ProcessLookupError:
364
+ pass
365
+
366
+
367
+ def test_the_server_pauses_a_gated_call_and_sends_it_once_approved(server: Server) -> None:
368
+ end = pause(server, "11")
369
+ approval = end["approval"]
370
+ assert (approval["method"], approval["path"]) == ("POST", "/orders/11/cancel")
371
+ assert approval["body"] == BODY and approval["approvers"] == ["requester", "role:ops"]
372
+ assert server.upstream.sent_to("/orders/11/cancel") == []
373
+ thread = end["thread_id"]
374
+ # Pending: no new message on the thread.
375
+ r = post(server, "/chat", {"message": "hello", "thread_id": thread}, token("alice"))
376
+ assert r.status_code == 409 and r.json()["code"] == "approval_pending"
377
+ # Not an approver.
378
+ r = decide(server, end, "approve", token("bob"))
379
+ assert r.status_code == 403 and r.json()["code"] == "not_an_approver"
380
+ # The server's native API cannot resume the run around the approval route.
381
+ r = httpx.post(
382
+ f"{server.url}/threads/{thread}/runs/wait",
383
+ json={
384
+ "assistant_id": "agent",
385
+ "command": {"resume": {"x": {"type": "api_approval_decision", "decision": "approve"}}},
386
+ },
387
+ headers=token("alice"),
388
+ timeout=30,
389
+ )
390
+ assert r.status_code == 403, r.text
391
+ # Nor start a new run on it (which would abandon the pending approval): 409, as /chat.
392
+ r = httpx.post(
393
+ f"{server.url}/threads/{thread}/runs/wait",
394
+ json={
395
+ "assistant_id": "agent",
396
+ "input": {"messages": [{"role": "user", "content": "hello"}]},
397
+ },
398
+ headers=token("alice"),
399
+ timeout=30,
400
+ )
401
+ assert r.status_code == 409, r.text
402
+ assert "approval_pending" in r.json()["detail"]
403
+ assert server.upstream.sent_to("/orders/11/cancel") == []
404
+ # The requester approves: the run resumes and the call is sent, once.
405
+ r = decide(server, end, "approve", token("alice"))
406
+ assert r.status_code == 200, r.text
407
+ events = parse_sse(r.text)
408
+ assert events[0][1]["approval_id"] == approval["approval_id"]
409
+ result = next(d for e, d in events if e == "tool.result")
410
+ assert result["is_error"] is False
411
+ assert json.loads(result["result"])["acting_as"] == "alice"
412
+ assert events[-1][1]["status"] == "ok"
413
+ assert server.upstream.sent_to("/orders/11/cancel") == [BODY]
414
+ again = decide(server, end, "approve", token("alice"))
415
+ assert again.status_code == 409 and again.json()["code"] == "approval_not_pending"
416
+ assert server.upstream.sent_to("/orders/11/cancel") == [BODY]
417
+ listed = httpx.get(f"{server.url}/threads/{thread}/approvals", headers=token("alice"))
418
+ assert listed.json()[0]["status"] == "approved"
419
+
420
+
421
+ def test_the_server_sends_nothing_on_reject(server: Server) -> None:
422
+ end = pause(server, "12")
423
+ r = decide(server, end, "reject", token("carol", "ops"))
424
+ assert r.status_code == 200, r.text
425
+ result = next(d for e, d in parse_sse(r.text) if e == "tool.result")
426
+ assert result["is_error"] is True and "was not approved" in result["result"]
427
+ assert server.upstream.sent_to("/orders/12/cancel") == []
428
+
429
+
430
+ def test_a_role_approver_decides_and_the_run_acts_as_the_requester(server: Server) -> None:
431
+ end = pause(server, "13")
432
+ r = decide(server, end, "approve", token("carol", "ops"))
433
+ assert r.status_code == 200, r.text
434
+ result = next(d for e, d in parse_sse(r.text) if e == "tool.result")
435
+ assert json.loads(result["result"])["acting_as"] == "alice"
436
+ assert server.upstream.sent_to("/orders/13/cancel") == [BODY]
437
+
438
+
439
+ # One API, other approvers for other calls: the requester confirms updates and
440
+ # cancellations, and a second person holding role:admin approves new orders.
441
+ RULES_POLICY = """
442
+ apis:
443
+ shop:
444
+ base_url_env: SHOP_API_BASE_URL
445
+ auth: none
446
+ allowed_methods: [GET, POST, PATCH]
447
+ approval:
448
+ - required_for:
449
+ operations:
450
+ - {operationId: updateOrder, path: "/orders/{order_id}", methods: [PATCH]}
451
+ - {operationId: cancelOrder, path: "/orders/{order_id}/cancel", methods: [POST]}
452
+ approvers: [requester]
453
+ - required_for:
454
+ operations:
455
+ - {operationId: createOrder, path: /orders, methods: [POST]}
456
+ approvers: ["role:admin"]
457
+ timeout_s: 3600
458
+ """
459
+
460
+
461
+ @contextmanager
462
+ def approval_rules(server: Server) -> Iterator[None]:
463
+ """The test policy with a list of approval rules, for the time of one test."""
464
+ server.policy.write_text(RULES_POLICY, encoding="utf-8")
465
+ try:
466
+ yield
467
+ finally:
468
+ server.policy.write_text(POLICY, encoding="utf-8")
469
+
470
+
471
+ def test_each_call_waits_for_the_approvers_of_the_rule_that_gates_it(server: Server) -> None:
472
+ admin = token("root", "admin")
473
+ with approval_rules(server):
474
+ # An update: the requester's rule. Neither another user nor an admin decides it.
475
+ end = pause_on(server, "Amend the gift note for 61")
476
+ approval = end["approval"]
477
+ assert (approval["operation_id"], approval["approvers"]) == ("updateOrder", ["requester"])
478
+ for headers in (token("bob"), admin):
479
+ r = decide(server, end, "approve", headers)
480
+ assert r.status_code == 403 and r.json()["code"] == "not_an_approver", r.text
481
+ r = decide(server, end, "approve", token("alice"))
482
+ assert r.status_code == 200, r.text
483
+ assert server.upstream.sent_to("/orders/61") == [{"note": "gift"}]
484
+
485
+ # A cancellation: the requester's rule too; an admin may not decide it either.
486
+ end = pause_on(server, "Cancel the order for 62")
487
+ assert end["approval"]["approvers"] == ["requester"]
488
+ assert decide(server, end, "approve", admin).status_code == 403
489
+ assert decide(server, end, "approve", token("alice")).status_code == 200
490
+ assert server.upstream.sent_to("/orders/62/cancel") == [BODY]
491
+
492
+ # A new order: role:admin's rule, with its own expiry. The requester may not
493
+ # approve it, even holding the role (four eyes); another admin may.
494
+ end = pause_on(server, "Place a new one for GADGET-63")
495
+ approval = end["approval"]
496
+ assert (approval["operation_id"], approval["approvers"]) == ("createOrder", ["role:admin"])
497
+ assert approval["body"] == {"item": "GADGET-63"}
498
+ lifetime = datetime.fromisoformat(approval["expires_at"]) - datetime.fromisoformat(
499
+ approval["created_at"]
500
+ )
501
+ assert lifetime.total_seconds() == 3600
502
+ for headers in (token("alice"), token("alice", "admin"), token("bob", "ops")):
503
+ r = decide(server, end, "approve", headers)
504
+ assert r.status_code == 403, r.text
505
+ assert server.upstream.sent_to("/orders") == []
506
+ r = decide(server, end, "approve", admin)
507
+ assert r.status_code == 200, r.text
508
+ result = next(d for e, d in parse_sse(r.text) if e == "tool.result")
509
+ assert json.loads(result["result"])["acting_as"] == "alice"
510
+ assert server.upstream.sent_to("/orders") == [{"item": "GADGET-63"}]
511
+
512
+
513
+ def test_the_server_refuses_a_request_changed_after_approval(server: Server) -> None:
514
+ end = pause(server, "14")
515
+ server.body_file.write_text(json.dumps({"reason": "changed"}), encoding="utf-8")
516
+ try:
517
+ r = decide(server, end, "approve", token("alice"))
518
+ finally:
519
+ server.body_file.write_text(json.dumps(BODY), encoding="utf-8")
520
+ assert r.status_code == 200, r.text
521
+ result = next(d for e, d in parse_sse(r.text) if e == "tool.result")
522
+ assert result["is_error"] is True
523
+ assert "differs from the request that was approved" in result["result"]
524
+ assert server.upstream.sent_to("/orders/14/cancel") == []
525
+
526
+
527
+ def test_the_server_binds_a_decision_to_its_call_when_the_gate_is_removed(
528
+ server: Server,
529
+ ) -> None:
530
+ """A new policy without the gate while two calls wait: neither decision sends."""
531
+ rejected, approved = pause(server, "17"), pause(server, "18")
532
+ server.policy.write_text(POLICY.split(" approval:")[0], encoding="utf-8")
533
+ try:
534
+ r = decide(server, rejected, "reject", token("alice"))
535
+ assert r.status_code == 200, r.text
536
+ result = next(d for e, d in parse_sse(r.text) if e == "tool.result")
537
+ assert result["is_error"] is True and "an approver rejected it" in result["result"]
538
+ r = decide(server, approved, "approve", token("carol", "ops"))
539
+ assert r.status_code == 200, r.text
540
+ result = next(d for e, d in parse_sse(r.text) if e == "tool.result")
541
+ assert result["is_error"] is True
542
+ assert "approval gate the policy no longer has" in result["result"]
543
+ finally:
544
+ server.policy.write_text(POLICY, encoding="utf-8")
545
+ assert server.upstream.sent_to("/orders/17/cancel") == []
546
+ assert server.upstream.sent_to("/orders/18/cancel") == []
547
+
548
+
549
+ def test_a_native_replay_does_not_send_a_rejected_call_once_its_gate_is_removed(
550
+ server: Server,
551
+ ) -> None:
552
+ """Rejected, then un-gated: continuing or replaying the paused step sends nothing."""
553
+ end = pause(server, "20")
554
+ thread = end["thread_id"]
555
+ checkpoint = paused_checkpoint(server, thread)
556
+ with gate_removed(server):
557
+ r = decide(server, end, "reject", token("alice"))
558
+ assert r.status_code == 200, r.text
559
+ assert_refused_at_the_server(server, thread, checkpoint)
560
+ assert server.upstream.sent_to("/orders/20/cancel") == []
561
+
562
+
563
+ def test_a_native_replay_does_not_send_an_approved_call_a_second_time(server: Server) -> None:
564
+ """Approved and sent, then un-gated: a replay of the paused step does not send it again."""
565
+ end = pause(server, "21")
566
+ thread = end["thread_id"]
567
+ checkpoint = paused_checkpoint(server, thread)
568
+ r = decide(server, end, "approve", token("alice"))
569
+ assert r.status_code == 200, r.text
570
+ assert server.upstream.sent_to("/orders/21/cancel") == [BODY]
571
+ with gate_removed(server):
572
+ assert_refused_at_the_server(server, thread, checkpoint)
573
+ assert server.upstream.sent_to("/orders/21/cancel") == [BODY]
574
+
575
+
576
+ def test_a_thread_without_gated_calls_is_still_copied_and_continued(server: Server) -> None:
577
+ r = post(server, "/chat", {"message": "hello"}, token("alice"))
578
+ assert r.status_code == 200, r.text
579
+ thread = parse_sse(r.text)[-1][1]["thread_id"]
580
+ r = httpx.post(f"{server.url}/threads/{thread}/copy", headers=token("alice"), timeout=30)
581
+ assert r.status_code == 200, r.text
582
+ assert native_run(server, thread, {"input": None}).status_code == 200
583
+ assert native_run(server, r.json()["thread_id"], {"input": None}).status_code == 200
584
+
585
+
586
+ def test_deleting_the_thread_on_the_server_deletes_its_approvals(server: Server) -> None:
587
+ end = pause(server, "15")
588
+ thread = end["thread_id"]
589
+ r = httpx.delete(f"{server.url}/threads/{thread}", headers=token("alice"), timeout=30)
590
+ assert r.status_code in (200, 204), r.text
591
+ r = httpx.get(f"{server.url}/threads/{thread}/approvals", headers=token("alice"))
592
+ assert r.status_code == 404
593
+ listed = httpx.get(f"{server.url}/approvals", headers=token("alice")).json()
594
+ assert end["approval"]["approval_id"] not in [a["approval_id"] for a in listed]
595
+ assert decide(server, end, "approve", token("alice")).status_code == 404
596
+ assert server.upstream.sent_to("/orders/15/cancel") == []
597
+
598
+
599
+ def _rpc(server: Server, method: str, params: dict[str, Any]) -> dict[str, Any]:
600
+ r = httpx.post(
601
+ f"{server.url}{A2A_PATH}",
602
+ json={"jsonrpc": "2.0", "id": "1", "method": method, "params": params},
603
+ headers={**token("alice"), "A2A-Version": "1.0"},
604
+ timeout=60,
605
+ )
606
+ return r.json()
607
+
608
+
609
+ def test_the_server_a2a_task_waits_for_input_and_resumes(server: Server) -> None:
610
+ sent = _rpc(
611
+ server,
612
+ "SendMessage",
613
+ {
614
+ "message": {
615
+ "messageId": "m-1",
616
+ "role": "ROLE_USER",
617
+ "parts": [{"text": "Cancel the order for 16"}],
618
+ }
619
+ },
620
+ )
621
+ task = sent["result"]["task"]
622
+ assert task["status"]["state"] == "TASK_STATE_INPUT_REQUIRED", task
623
+ data = next(p["data"] for p in task["status"]["message"]["parts"] if "data" in p)
624
+ approval = data["approval"]
625
+ assert approval["path"] == "/orders/16/cancel"
626
+ done = _rpc(
627
+ server,
628
+ "SendMessage",
629
+ {
630
+ "message": {
631
+ "messageId": "m-2",
632
+ "role": "ROLE_USER",
633
+ "taskId": task["id"],
634
+ "contextId": task["contextId"],
635
+ "parts": [
636
+ {"data": {"approval_id": approval["approval_id"], "decision": "approve"}}
637
+ ],
638
+ }
639
+ },
640
+ )
641
+ assert done["result"]["task"]["status"]["state"] == "TASK_STATE_COMPLETED", done
642
+ assert server.upstream.sent_to("/orders/16/cancel") == [BODY]
643
+
644
+
645
+ def test_a_tool_output_holding_a_lone_surrogate_completes_the_run(server: Server) -> None:
646
+ """UTF-8 cannot encode a lone surrogate: the reply holds U+FFFD in its place.
647
+
648
+ The server could not send the tool's update on its own stream: the run
649
+ failed (`run_failed`) after the tool had acted, over /chat and over A2A.
650
+ """
651
+ r = post(server, "/chat", {"message": "Check the gauge for Bergen"}, token("alice"))
652
+ assert r.status_code == 200, r.text
653
+ events = parse_sse(r.text)
654
+ assert events[-1][0] == "message.end" and events[-1][1]["status"] == "ok", events
655
+ assert [d["result"] for e, d in events if e == "tool.result"] == ["gauge at Bergen:\ufffd42"]
656
+ sent = _rpc(
657
+ server,
658
+ "SendMessage",
659
+ {
660
+ "message": {
661
+ "messageId": "m-gauge",
662
+ "role": "ROLE_USER",
663
+ "parts": [{"text": "Check the gauge for Oslo"}],
664
+ }
665
+ },
666
+ )
667
+ task = sent["result"]["task"]
668
+ assert task["status"]["state"] == "TASK_STATE_COMPLETED", task
669
+ reply = "".join(p.get("text", "") for a in task["artifacts"] for p in a["parts"])
670
+ assert "gauge at Oslo:\ufffd42" in reply
671
+
672
+
673
+ def _wait_until_expired(end: dict[str, Any]) -> None:
674
+ expires = datetime.fromisoformat(end["approval"]["expires_at"])
675
+ wait = expires.timestamp() - time.time() + 1
676
+ if wait > 0:
677
+ time.sleep(wait)
678
+
679
+
680
+ def test_a_native_run_does_not_send_an_expired_call_once_its_gate_is_removed(
681
+ server: Server,
682
+ ) -> None:
683
+ """Near last: an approval paused when the server started has expired by now."""
684
+ end = server.expiring_native
685
+ _wait_until_expired(end)
686
+ thread = end["thread_id"]
687
+ checkpoint = paused_checkpoint(server, thread)
688
+ with gate_removed(server):
689
+ assert decide(server, end, "approve", token("alice")).status_code == 410
690
+ assert_refused_at_the_server(server, thread, checkpoint)
691
+ assert server.upstream.sent_to("/orders/91/cancel") == []
692
+
693
+
694
+ def test_an_expired_approval_on_the_server_sends_nothing(server: Server) -> None:
695
+ """Last: the approval paused when the server started has expired by now."""
696
+ end = server.expiring
697
+ _wait_until_expired(end)
698
+ r = decide(server, end, "approve", token("alice"))
699
+ assert r.status_code == 410 and r.json()["code"] == "approval_expired"
700
+ thread = end["thread_id"]
701
+ # Expired = rejected: the thread takes a new message, and the paused call's
702
+ # result says it was not approved.
703
+ r = post(server, "/chat", {"message": "hello", "thread_id": thread}, token("alice"))
704
+ assert r.status_code == 200, r.text
705
+ assert parse_sse(r.text)[-1][1]["status"] == "ok"
706
+ messages = httpx.get(f"{server.url}/threads/{thread}/messages", headers=token("alice")).json()
707
+ tool = next(m for m in messages if m["role"] == "tool")
708
+ assert "approval request expired" in tool["content"]
709
+ assert server.upstream.sent_to("/orders/90/cancel") == []
710
+
711
+
712
+ # The app's log line each time the server starts it (at startup and on a reload).
713
+ APP_STARTED = "chat runtime started"
714
+
715
+
716
+ def _reloaded(proc: subprocess.Popen[bytes], url: str, log: Path, starts: int) -> None:
717
+ """Wait until the server has started its app once more than `starts` times, and is up."""
718
+ deadline = time.monotonic() + 120
719
+ while log.read_text(errors="replace").count(APP_STARTED) <= starts:
720
+ if proc.poll() is not None or time.monotonic() > deadline:
721
+ pytest.fail(f"langgraph dev did not reload:\n{log.read_text()[-4000:]}")
722
+ time.sleep(0.5)
723
+ _wait_up(proc, url, log)
724
+
725
+
726
+ def test_the_dev_server_keeps_the_approvals_with_its_threads(tmp_path: Path) -> None:
727
+ """`langgraph dev` keeps its threads (in `.langgraph_api/`) across a hot reload and a
728
+ restart, and the approvals that bind their tool calls with them: once the gate is
729
+ removed, a run without input or a replay of the paused step sends none of a pending,
730
+ a rejected, a sent, or an approved but refused call."""
731
+ upstream = Upstream()
732
+ threading.Thread(target=upstream.serve_forever, daemon=True).start()
733
+ body_file = tmp_path / "body.json"
734
+ body_file.write_text(json.dumps(BODY), encoding="utf-8")
735
+ policy = tmp_path / "api-policy.yaml"
736
+ gated = POLICY.replace(f"timeout_s: {TIMEOUT_S}", "timeout_s: 3600") # nothing expires
737
+ policy.write_text(gated, encoding="utf-8")
738
+ _write_config(tmp_path)
739
+ env = _server_env(policy, body_file, upstream)
740
+ port = _free_port()
741
+ url = f"http://127.0.0.1:{port}"
742
+ log = tmp_path / "server.log"
743
+ proc = _start_dev(tmp_path, env, port, log, reload=True)
744
+ try:
745
+ server = Server(url=url, upstream=upstream, body_file=body_file, log=log, policy=policy)
746
+ waiting = pause(server, "81")
747
+ rejected = pause(server, "82")
748
+ assert decide(server, rejected, "reject", token("alice")).status_code == 200
749
+ sent = pause(server, "83")
750
+ assert decide(server, sent, "approve", token("alice")).status_code == 200
751
+ refused = pause(server, "84")
752
+ denied = " denied_operations:\n - path: /orders/84/cancel\n approval:"
753
+ policy.write_text(gated.replace(" approval:", denied), encoding="utf-8")
754
+ r = decide(server, refused, "approve", token("alice"))
755
+ policy.write_text(gated, encoding="utf-8")
756
+ result = next(d for e, d in parse_sse(r.text) if e == "tool.result")
757
+ assert result["is_error"] is True and "denied_operations" in result["result"]
758
+ paused = {
759
+ end["thread_id"]: (end, paused_checkpoint(server, end["thread_id"]))
760
+ for end in (waiting, rejected, sent, refused)
761
+ }
762
+ expected = {"81": [], "82": [], "83": [BODY], "84": []}
763
+
764
+ def assert_still_bound() -> None:
765
+ for thread, (end, _) in paused.items():
766
+ listed = httpx.get(f"{url}/threads/{thread}/approvals", headers=token("alice"))
767
+ assert listed.status_code == 200, listed.text
768
+ ids = [a["approval_id"] for a in listed.json()]
769
+ assert end["approval"]["approval_id"] in ids, listed.text
770
+ with gate_removed(server):
771
+ for thread, (_, checkpoint) in paused.items():
772
+ for r in rerun_paused_step(server, thread, checkpoint):
773
+ assert r.status_code in (403, 409), r.text
774
+ message = {"message": "hi", "thread_id": waiting["thread_id"]}
775
+ r = post(server, "/chat", message, token("alice"))
776
+ assert r.status_code == 409 and r.json()["code"] == "approval_pending"
777
+ for order, bodies in expected.items():
778
+ assert upstream.sent_to(f"/orders/{order}/cancel") == bodies, order
779
+
780
+ # A hot reload: a code change in the directory the server watches.
781
+ starts = log.read_text(errors="replace").count(APP_STARTED)
782
+ (tmp_path / "changed.py").write_text("CHANGED = True\n", encoding="utf-8")
783
+ _reloaded(proc, url, log, starts)
784
+ assert_still_bound()
785
+ # A restart.
786
+ _stop(proc)
787
+ proc = _start_dev(tmp_path, env, port, log)
788
+ assert_still_bound()
789
+ ledger = tmp_path / ".langgraph_api" / "agent_approvals.json"
790
+ assert ledger.stat().st_mode & 0o777 == 0o600
791
+ finally:
792
+ _stop(proc)
793
+ upstream.shutdown()
794
+ upstream.server_close()