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,767 @@
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
+ """Calls to JSON-RPC APIs (`protocol: jsonrpc|a2a`): what the policy client reads from the body.
16
+
17
+ The client derives what a request is from the JSON it sends, never from the
18
+ tool's labels: its JSON-RPC method and, for a message to another agent, whether
19
+ it approves or rejects a pending approval there. Every refusal happens before
20
+ anything is sent. Requests go to an httpx MockTransport; nothing leaves the
21
+ process. (The rules themselves are tested through both copies of the shared
22
+ block in graph-agents-cli's own suite.)
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ import logging
29
+ import os
30
+ import uuid
31
+ from collections.abc import AsyncIterator, Iterator
32
+ from pathlib import Path
33
+ from typing import Any
34
+ from urllib.parse import urlsplit
35
+
36
+ os.environ.setdefault("MODEL_PROVIDER", "fake")
37
+
38
+ import httpx
39
+ import pytest
40
+ from langchain.agents import create_agent
41
+ from langchain.tools import ToolRuntime
42
+ from langchain_core.tools import tool
43
+ from langgraph.checkpoint.memory import InMemorySaver
44
+ from langgraph.types import Command
45
+
46
+ from {{cookiecutter.agent_directory}}.app_utils.api_client import (
47
+ A2A_ORIGIN_EXTENSION,
48
+ APPROVAL_INTERRUPT,
49
+ ApiCallError,
50
+ ApiClient,
51
+ ApiPolicy,
52
+ ApiPolicyError,
53
+ BoundApproval,
54
+ call_identity,
55
+ get_client,
56
+ propagates,
57
+ reset_limits,
58
+ reset_policy_cache,
59
+ set_approval_ledger,
60
+ set_outbound_headers,
61
+ )
62
+ from {{cookiecutter.agent_directory}}.app_utils.approvals import (
63
+ APPROVED,
64
+ REJECTED,
65
+ ApprovalRecord,
66
+ ApprovalStore,
67
+ approval_digest,
68
+ approval_view,
69
+ decision_value,
70
+ record_from_interrupt,
71
+ )
72
+ from {{cookiecutter.agent_directory}}.app_utils.auth import Principal
73
+ from {{cookiecutter.agent_directory}}.app_utils.db import Database
74
+
75
+ POLICY = """
76
+ apis:
77
+ orders_agent:
78
+ description: "Orders agent: reads and cancels the caller's orders."
79
+ protocol: a2a
80
+ a2a: {path: /a2a/orders}
81
+ base_url_env: ORDERS_AGENT_URL
82
+ auth: bearer
83
+ token_env: ORDERS_AGENT_KEY
84
+ allowed_methods: [GET, POST]
85
+ allowed_operations:
86
+ - {operationId: getAgentCard, methods: [GET], path: /a2a/orders/.well-known/agent-card.json}
87
+ - {rpc_method: SendMessage, methods: [POST], path: /a2a/orders}
88
+ - {rpc_method: GetTask, methods: [POST], path: /a2a/orders}
89
+ - {operationId: getTask, rpc_method: GetTask}
90
+ approval:
91
+ required_for: {operations: [{a2a_operation: approve}]}
92
+ approvers: [requester]
93
+ billing_agent:
94
+ protocol: a2a
95
+ a2a: {path: /a2a/billing}
96
+ base_url_env: BILLING_AGENT_URL
97
+ auth: bearer
98
+ token_env: BILLING_AGENT_KEY
99
+ allowed_methods: [POST]
100
+ denied_operations:
101
+ - {a2a_operation: approve}
102
+ ledger:
103
+ protocol: jsonrpc
104
+ base_url_env: LEDGER_URL
105
+ auth: none
106
+ allowed_methods: [POST]
107
+ allowed_operations:
108
+ - {rpc_method: balance, path: /rpc}
109
+ plain:
110
+ base_url_env: PLAIN_URL
111
+ auth: none
112
+ allowed_methods: [POST]
113
+ """
114
+
115
+
116
+ @pytest.fixture
117
+ def policy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]:
118
+ # Agents are reached at cluster-internal names (plain http is refused elsewhere outside dev).
119
+ monkeypatch.delenv("APP_ENV", raising=False)
120
+ path = tmp_path / "api-policy.yaml"
121
+ path.write_text(POLICY, encoding="utf-8")
122
+ monkeypatch.setenv("API_POLICY_PATH", str(path))
123
+ monkeypatch.setenv("ORDERS_AGENT_URL", "http://orders-agent:8080")
124
+ monkeypatch.setenv("ORDERS_AGENT_KEY", "orders-key")
125
+ monkeypatch.setenv("BILLING_AGENT_URL", "http://billing-agent.billing.svc")
126
+ monkeypatch.setenv("BILLING_AGENT_KEY", "billing-key")
127
+ monkeypatch.setenv("LEDGER_URL", "http://ledger.test")
128
+ monkeypatch.setenv("PLAIN_URL", "http://plain.test")
129
+ reset_policy_cache()
130
+ reset_limits()
131
+ yield path
132
+ reset_policy_cache()
133
+ reset_limits()
134
+
135
+
136
+ SENT: list[httpx.Request] = []
137
+
138
+
139
+ def _transport() -> httpx.MockTransport:
140
+ def handler(request: httpx.Request) -> httpx.Response:
141
+ SENT.append(request)
142
+ return httpx.Response(200, json={"jsonrpc": "2.0", "id": "1", "result": {}})
143
+
144
+ SENT.clear()
145
+ return httpx.MockTransport(handler)
146
+
147
+
148
+ def _client(api: str) -> ApiClient:
149
+ return get_client(api, transport=_transport())
150
+
151
+
152
+ def _request(method: str, params: Any = None, **extra: Any) -> dict[str, Any]:
153
+ body: dict[str, Any] = {"jsonrpc": "2.0", "id": "1", "method": method}
154
+ if params is not None:
155
+ body["params"] = params
156
+ body.update(extra)
157
+ return body
158
+
159
+
160
+ def _message(*parts: Any) -> dict[str, Any]:
161
+ return {"message": {"role": "ROLE_USER", "messageId": "m1", "parts": list(parts)}}
162
+
163
+
164
+ def _decision(decision: str) -> dict[str, Any]:
165
+ return {"data": {"approval_id": "a1", "decision": decision}}
166
+
167
+
168
+ ASK = _request("SendMessage", _message({"text": "Which orders are open?"}))
169
+ APPROVE = _request("SendMessage", _message(_decision("approve")))
170
+ REJECT = _request("SendMessage", _message(_decision("reject")))
171
+ GET_TASK = _request("GetTask", {"id": "t1"})
172
+
173
+
174
+ async def test_the_calls_the_policy_allows_are_sent(policy: Path) -> None:
175
+ client = _client("orders_agent")
176
+ await client.post("/a2a/orders", json_body=ASK)
177
+ await client.post("/a2a/orders", json_body=GET_TASK)
178
+ await client.post("/a2a/orders", json_body=_request("tasks/get", {"id": "t1"}))
179
+ await client.post("/a2a/orders", operation_id="getTask", json_body=GET_TASK)
180
+ await client.get("/a2a/orders/.well-known/agent-card.json", operation_id="getAgentCard")
181
+ # A rejection is not gated: the agent that asked is told at once.
182
+ await client.post("/a2a/orders", json_body=REJECT)
183
+ assert [(r.method, r.url.path) for r in SENT] == [("POST", "/a2a/orders")] * 4 + [
184
+ ("GET", "/a2a/orders/.well-known/agent-card.json"),
185
+ ("POST", "/a2a/orders"),
186
+ ]
187
+
188
+
189
+ @pytest.mark.parametrize(
190
+ ("body", "message"),
191
+ [
192
+ (
193
+ _request("CancelTask", {"id": "t1"}),
194
+ "orders_agent: POST /a2a/orders (rpc_method CancelTask) refused by the API policy: "
195
+ "not in allowed_operations.",
196
+ ),
197
+ (
198
+ _request("tasks/cancel", {"id": "t1"}),
199
+ "orders_agent: POST /a2a/orders (rpc_method CancelTask) refused by the API policy: "
200
+ "not in allowed_operations.",
201
+ ),
202
+ (
203
+ [GET_TASK],
204
+ "orders_agent: protocol a2a sends one JSON-RPC request per call (a batch or "
205
+ "non-request body refused: a batch).",
206
+ ),
207
+ (
208
+ {"jsonrpc": "2.0", "method": "GetTask", "params": {"id": "t1"}},
209
+ "orders_agent: protocol a2a sends one JSON-RPC request per call (a batch or "
210
+ "non-request body refused: a notification (no id)).",
211
+ ),
212
+ (
213
+ {"decision": "approve", "approval_id": "a1"},
214
+ "orders_agent: protocol a2a sends one JSON-RPC request per call (a batch or "
215
+ "non-request body refused: members other than jsonrpc, method, params and id).",
216
+ ),
217
+ ],
218
+ )
219
+ async def test_what_the_policy_refuses_is_never_sent(
220
+ policy: Path, body: Any, message: str, caplog: pytest.LogCaptureFixture
221
+ ) -> None:
222
+ client = _client("orders_agent")
223
+ with caplog.at_level(logging.WARNING), pytest.raises(ApiPolicyError) as refused:
224
+ await client.post("/a2a/orders", json_body=body)
225
+ assert str(refused.value) == message
226
+ assert SENT == []
227
+ # The log names the rule, never the body.
228
+ assert "api call refused" in caplog.text and "t1" not in caplog.text
229
+
230
+
231
+ async def test_a_read_carries_no_body(policy: Path) -> None:
232
+ client = _client("orders_agent")
233
+ with pytest.raises(ApiPolicyError, match="a GET sends no body"):
234
+ await client.get("/a2a/orders/.well-known/agent-card.json", json_body={"x": 1})
235
+ assert SENT == []
236
+
237
+
238
+ async def test_a_label_never_carries_a_request_past_its_rule(policy: Path) -> None:
239
+ """T11: `getTask` names an entry pinning GetTask, so a message labelled so is refused."""
240
+ client = _client("orders_agent")
241
+ with pytest.raises(ApiPolicyError) as refused:
242
+ await client.post("/a2a/orders", operation_id="getTask", json_body=APPROVE)
243
+ assert str(refused.value) == (
244
+ "orders_agent: operation_id 'getTask' does not match the request (rpc_method "
245
+ "SendMessage); refused."
246
+ )
247
+ assert SENT == []
248
+
249
+
250
+ @pytest.mark.parametrize(
251
+ "body",
252
+ [
253
+ APPROVE,
254
+ _request("message/send", _message(_decision("approve"))),
255
+ # Approve wins over a rejection in the same message.
256
+ _request("SendMessage", _message(_decision("reject"), _decision("approve"))),
257
+ ],
258
+ )
259
+ async def test_a_message_that_approves_waits_for_a_human(policy: Path, body: Any) -> None:
260
+ """Gated for the requester, whatever its label or spelling: outside an agent run,
261
+ nothing can pause and ask, so it is refused and nothing is sent."""
262
+ client = _client("orders_agent")
263
+ with pytest.raises(ApiPolicyError, match=r"needs human approval \(requester\)"):
264
+ await client.post("/a2a/orders", operation_id="justAsking", json_body=body)
265
+ assert SENT == []
266
+
267
+
268
+ async def test_a_denied_approve_is_refused_and_the_rest_goes_through(policy: Path) -> None:
269
+ client = _client("billing_agent")
270
+ with pytest.raises(ApiPolicyError) as refused:
271
+ await client.post("/a2a/billing", json_body=APPROVE)
272
+ assert str(refused.value) == (
273
+ "billing_agent: POST /a2a/billing (rpc_method SendMessage, a2a_operation approve) refused "
274
+ "by the API policy: denied by denied_operations (a2a_operation=approve)."
275
+ )
276
+ assert SENT == []
277
+ await client.post("/a2a/billing", json_body=ASK)
278
+ assert len(SENT) == 1
279
+
280
+
281
+ async def test_an_approve_no_rule_holds_is_refused_even_by_an_unchecked_policy(
282
+ policy: Path,
283
+ ) -> None:
284
+ """The validator refuses such a policy; the client holds even one it never validated."""
285
+ settings = {
286
+ "protocol": "a2a",
287
+ "a2a": {"path": "/a2a/orders"},
288
+ "base_url_env": "ORDERS_AGENT_URL",
289
+ "auth": "bearer",
290
+ "token_env": "ORDERS_AGENT_KEY",
291
+ "allowed_methods": ["POST"],
292
+ }
293
+ client = ApiClient(ApiPolicy(apis={"orders_agent": settings}), "orders_agent")
294
+ client._transport = _transport()
295
+ with pytest.raises(ApiPolicyError) as refused:
296
+ await client.post("/a2a/orders", json_body=APPROVE)
297
+ assert str(refused.value) == (
298
+ "orders_agent: POST /a2a/orders (rpc_method SendMessage, a2a_operation approve) refused "
299
+ "by the API policy: a message that approves must wait for an approval or be denied "
300
+ "(gate or deny a2a_operation: approve)."
301
+ )
302
+ assert SENT == []
303
+ await client.post("/a2a/orders", json_body=ASK)
304
+ assert len(SENT) == 1
305
+
306
+
307
+ async def test_a_json_rpc_api_is_judged_by_its_method(policy: Path) -> None:
308
+ client = _client("ledger")
309
+ await client.post("/rpc", json_body=_request("balance", ["acct-1"]))
310
+ with pytest.raises(ApiPolicyError, match=r"\(rpc_method transfer\) refused"):
311
+ await client.post("/rpc", json_body=_request("transfer", ["acct-1", 5]))
312
+ # No A2A reading on a plain JSON-RPC API: a 0.3 name is just a name.
313
+ with pytest.raises(ApiPolicyError, match=r"\(rpc_method message/send\) refused"):
314
+ await client.post("/rpc", json_body=_request("message/send", _message(_decision("x"))))
315
+ assert len(SENT) == 1
316
+
317
+
318
+ async def test_an_http_api_sends_what_it_did_before(policy: Path) -> None:
319
+ """No JSON-RPC reading on an `http` API: any body goes, as in 0.2."""
320
+ client = _client("plain")
321
+ await client.post("/anything", json_body=[APPROVE, {"not": "json-rpc"}])
322
+ await client.post("/anything", operation_id="getTask", json_body=APPROVE)
323
+ assert len(SENT) == 2
324
+
325
+
326
+ # --- which call a decision is bound to (call_identity) ---------------------------------------
327
+ #
328
+ # Every call to an A2A peer is a POST to one endpoint, so API, method and path cannot tell a
329
+ # relay's calls apart: the read it sends first on resume would take the decision its approve
330
+ # message waits for, and a rejection would stop the message that tells the peer. The request
331
+ # each body is (its JSON-RPC method and A2A decision) is part of which call it is.
332
+
333
+ ALICE = Principal(id="alice", roles=["user"], attributes={"tenant": "t1"})
334
+ # The relay tool reads the peer's task before it decides (as the A2A client will).
335
+ READ_FIRST: list[bool] = []
336
+ PEER_SENT: list[dict[str, Any]] = []
337
+ # The peer's approval the decision is about, as the A2A client copies it into the message.
338
+ APPROVING: list[dict[str, Any]] = []
339
+
340
+
341
+ def _decide(decision: str) -> dict[str, Any]:
342
+ """The decision message, built the same on every run (the approval binds its body)."""
343
+ message: dict[str, Any] = {
344
+ "messageId": f"m-{decision}-a1",
345
+ "role": "ROLE_USER",
346
+ "contextId": "ctx-1",
347
+ "parts": [{"data": {"approval_id": "a1", "decision": decision}}],
348
+ }
349
+ if APPROVING:
350
+ message["metadata"] = {A2A_ORIGIN_EXTENSION: {"approving": APPROVING[0]}}
351
+ return {
352
+ "jsonrpc": "2.0",
353
+ "id": f"{decision}-a1",
354
+ "method": "SendMessage",
355
+ "params": {"message": message},
356
+ }
357
+
358
+
359
+ def _peer_upstream(request: httpx.Request) -> httpx.Response:
360
+ PEER_SENT.append(json.loads(request.content))
361
+ return httpx.Response(200, json={"jsonrpc": "2.0", "id": "1", "result": {}})
362
+
363
+
364
+ @tool
365
+ async def relay_approval(task_id: str, runtime: ToolRuntime[Any]) -> str:
366
+ """Relay the person's approval to the orders agent, or tell it they rejected it."""
367
+ client = get_client("orders_agent", transport=httpx.MockTransport(_peer_upstream))
368
+ if READ_FIRST:
369
+ await client.post("/a2a/orders", json_body=_request("GetTask", {"id": task_id}))
370
+ try:
371
+ await client.post("/a2a/orders", json_body=_decide("approve"))
372
+ except ApiPolicyError as exc:
373
+ if "rejected" not in str(exc):
374
+ raise
375
+ await client.post("/a2a/orders", json_body=_decide("reject"))
376
+ return "The person did not approve it; the orders agent was told."
377
+ return "Approved and sent to the orders agent."
378
+
379
+
380
+ ADMIN_DSN = os.environ.get("TEST_POSTGRES_DSN", "")
381
+
382
+
383
+ @pytest.fixture(params=["memory", "file", "postgres"])
384
+ async def store(request: pytest.FixtureRequest, tmp_path: Path) -> AsyncIterator[ApprovalStore]:
385
+ """The ledger in memory, kept in a file (`langgraph dev`), and on Postgres when
386
+ `TEST_POSTGRES_DSN` is set (a fresh database)."""
387
+ if request.param == "memory":
388
+ yield ApprovalStore(Database("memory"))
389
+ return
390
+ if request.param == "file":
391
+ kept = ApprovalStore(Database("memory"), path=tmp_path / ".langgraph_api" / "a.json")
392
+ assert await kept.load() == 0
393
+ yield kept
394
+ return
395
+ if not ADMIN_DSN:
396
+ pytest.skip("TEST_POSTGRES_DSN is not set")
397
+ import psycopg
398
+
399
+ name = f"gac_test_{uuid.uuid4().hex[:12]}"
400
+ async with await psycopg.AsyncConnection.connect(ADMIN_DSN, autocommit=True) as admin:
401
+ await admin.execute(f'CREATE DATABASE "{name}"')
402
+ db = Database("postgres", urlsplit(ADMIN_DSN)._replace(path=f"/{name}").geturl())
403
+ await db.open()
404
+ try:
405
+ yield ApprovalStore(db)
406
+ finally:
407
+ await db.close()
408
+ async with await psycopg.AsyncConnection.connect(ADMIN_DSN, autocommit=True) as admin:
409
+ await admin.execute(f'DROP DATABASE IF EXISTS "{name}" WITH (FORCE)')
410
+
411
+
412
+ @pytest.fixture
413
+ def relay(policy: Path, store: ApprovalStore) -> Iterator[Any]:
414
+ """An agent with the relay tool, the fake model and the ledger `store`."""
415
+ from {{cookiecutter.agent_directory}} import agent
416
+ from {{cookiecutter.agent_directory}}.app_utils.model import get_model
417
+
418
+ READ_FIRST.clear()
419
+ PEER_SENT.clear()
420
+ APPROVING.clear()
421
+ set_approval_ledger(store)
422
+ yield create_agent(
423
+ model=get_model(),
424
+ tools=[relay_approval],
425
+ system_prompt=agent.SYSTEM_PROMPT,
426
+ middleware=agent.middleware(),
427
+ context_schema=agent.AgentContext,
428
+ checkpointer=InMemorySaver(),
429
+ )
430
+ set_approval_ledger(None)
431
+
432
+
433
+ async def _run(graph: Any, thread: str, graph_input: Any) -> list[Any]:
434
+ config = {"configurable": {"thread_id": thread}}
435
+ async for _ in graph.astream(graph_input, config, stream_mode="updates"):
436
+ pass
437
+ return list((await graph.aget_state(config)).interrupts)
438
+
439
+
440
+ async def _pause(graph: Any, store: ApprovalStore) -> tuple[Any, ApprovalRecord]:
441
+ interrupts = await _run(graph, "t1", {"messages": [("user", "Relay the approval for t1")]})
442
+ assert len(interrupts) == 1
443
+ record, _, _ = await store.add(
444
+ record_from_interrupt(
445
+ interrupts[0].value,
446
+ interrupt_id=interrupts[0].id,
447
+ thread_id="t1",
448
+ run_id="r",
449
+ requester=ALICE,
450
+ )
451
+ )
452
+ return interrupts[0], record
453
+
454
+
455
+ async def _tool_result(graph: Any) -> str:
456
+ state = await graph.aget_state({"configurable": {"thread_id": "t1"}})
457
+ return next(m.content for m in reversed(state.values["messages"]) if m.type == "tool")
458
+
459
+
460
+ def _sent() -> list[str]:
461
+ """What reached the peer: each request's method, and its decision if any."""
462
+ return [
463
+ " ".join(
464
+ [body["method"]]
465
+ + [
466
+ p["data"]["decision"]
467
+ for p in (body.get("params") or {}).get("message", {}).get("parts", [])
468
+ if "data" in p
469
+ ]
470
+ )
471
+ for body in PEER_SENT
472
+ ]
473
+
474
+
475
+ async def test_the_approval_names_the_request_it_holds(relay: Any, store: ApprovalStore) -> None:
476
+ interrupt, record = await _pause(relay, store)
477
+ assert interrupt.value["type"] == APPROVAL_INTERRUPT
478
+ assert (interrupt.value["rpc_method"], interrupt.value["a2a_operation"]) == (
479
+ "SendMessage",
480
+ "approve",
481
+ )
482
+ public = record.public()
483
+ assert (public["rpc_method"], public["a2a_operation"]) == ("SendMessage", "approve")
484
+ view = approval_view(record)
485
+ assert (view["rpc_method"], view["a2a_operation"]) == ("SendMessage", "approve")
486
+ assert record.display_digest == approval_digest(view)
487
+ value = decision_value(record, "approve")
488
+ assert (value["rpc_method"], value["a2a_operation"]) == ("SendMessage", "approve")
489
+ assert _sent() == []
490
+
491
+
492
+ def _orders_approval(**overrides: Any) -> dict[str, Any]:
493
+ """The orders agent's pending approval as its A2A task reports it (exact values)."""
494
+ approving = {
495
+ "agent": "orders",
496
+ "approval_id": "de2e",
497
+ "call": {
498
+ "api": "orders_api",
499
+ "method": "POST",
500
+ "path": "/orders/ORD-1002/cancel",
501
+ "operation_id": "cancelOrder",
502
+ "query": {},
503
+ "body": {"reason": "customer asked", "amount": 1, "big": 12345678901234567890},
504
+ },
505
+ "reason": "cancel_order: the customer asked",
506
+ "expires_at": "2099-01-01T00:00:00+00:00",
507
+ "digest": "sha256:" + "a" * 64,
508
+ "reported_by": "orders",
509
+ "decide_with": "relayed",
510
+ "nested": None,
511
+ "unknown": "dropped",
512
+ }
513
+ approving.update(overrides)
514
+ return approving
515
+
516
+
517
+ async def test_a_relayed_approval_shows_what_it_decides_and_what_will_happen(
518
+ relay: Any, store: ApprovalStore
519
+ ) -> None:
520
+ """The approve message names the peer's approval (`approving`, copied from the peer):
521
+ the person's approval shows it as `nested`, exact values kept, and the call that then
522
+ happens as `effect` (orders' cancel, via orders)."""
523
+ APPROVING.append(_orders_approval())
524
+ interrupt, record = await _pause(relay, store)
525
+ nested = interrupt.value["nested"]
526
+ assert nested["call"]["body"] == {
527
+ "reason": "customer asked",
528
+ "amount": 1,
529
+ "big": 12345678901234567890,
530
+ }
531
+ assert "unknown" not in nested and nested["nested"] is None
532
+ effect = interrupt.value["effect"]
533
+ assert (effect["agent"], effect["via"], effect["method"], effect["path"]) == (
534
+ "orders",
535
+ ["orders"],
536
+ "POST",
537
+ "/orders/ORD-1002/cancel",
538
+ )
539
+ public = record.public()
540
+ assert public["nested"] == nested and public["effect"] == effect
541
+ # Bound like the rest of the body: the digest the person approves covers it.
542
+ assert record.display_digest == approval_digest(approval_view(record))
543
+ assert _sent() == []
544
+
545
+
546
+ async def test_a_two_hop_relay_shows_the_innermost_call_and_the_agents_between(
547
+ relay: Any, store: ApprovalStore
548
+ ) -> None:
549
+ inner = _orders_approval()
550
+ APPROVING.append(
551
+ _orders_approval(
552
+ agent="billing",
553
+ approval_id="b1",
554
+ call={"api": "orders_agent", "method": "POST", "path": "/a2a/orders"},
555
+ reported_by="billing",
556
+ nested=inner,
557
+ )
558
+ )
559
+ interrupt, _record = await _pause(relay, store)
560
+ effect = interrupt.value["effect"]
561
+ assert (effect["agent"], effect["via"], effect["path"]) == (
562
+ "orders",
563
+ ["billing", "orders"],
564
+ "/orders/ORD-1002/cancel",
565
+ )
566
+ assert interrupt.value["nested"]["nested"]["approval_id"] == "de2e"
567
+
568
+
569
+ async def test_a_rejection_naming_the_approval_is_sent_without_waiting(policy: Path) -> None:
570
+ """Only an approve message waits for the person: a rejection that carries the approval
571
+ it is about goes out at once (rejecting is always safe)."""
572
+ APPROVING[:] = [_orders_approval()]
573
+ try:
574
+ await _client("orders_agent").post("/a2a/orders", json_body=_decide("reject"))
575
+ finally:
576
+ APPROVING.clear()
577
+ [sent] = SENT
578
+ assert json.loads(sent.content)["params"]["message"]["parts"][0]["data"]["decision"] == "reject"
579
+
580
+
581
+ async def test_get_task_on_resume_does_not_consume_the_approve_decision(
582
+ relay: Any, store: ApprovalStore
583
+ ) -> None:
584
+ """The resumed tool reads the task first: that read is its own call, and the approve
585
+ message it then rebuilds takes the decision and is sent once."""
586
+ READ_FIRST.append(True)
587
+ interrupt, record = await _pause(relay, store)
588
+ assert _sent() == ["GetTask"]
589
+ decided = await store.decide(record.approval_id, APPROVED, "alice", None)
590
+ await _run(relay, "t1", Command(resume={interrupt.id: decision_value(decided, "approve")}))
591
+ assert _sent() == ["GetTask", "GetTask", "SendMessage approve"]
592
+ assert "Approved and sent" in await _tool_result(relay)
593
+ assert (await store.get(record.approval_id)).used_at is not None
594
+
595
+
596
+ async def test_reject_is_sent_after_a_rejection(relay: Any, store: ApprovalStore) -> None:
597
+ """The rejection stops the approve message, not the message that tells the peer."""
598
+ interrupt, record = await _pause(relay, store)
599
+ decided = await store.decide(record.approval_id, REJECTED, "alice", "not this one")
600
+ await _run(relay, "t1", Command(resume={interrupt.id: decision_value(decided, "reject")}))
601
+ assert _sent() == ["SendMessage reject"]
602
+ assert "the orders agent was told" in await _tool_result(relay)
603
+
604
+
605
+ async def test_bound_approvals_filter_by_operation(relay: Any, store: ApprovalStore) -> None:
606
+ """A tool call run again without a decision (a thread continued without input): the
607
+ ledger's approval holds the approve message only; the read goes out again."""
608
+ READ_FIRST.append(True)
609
+ _, record = await _pause(relay, store)
610
+ [bound] = await store.bound_approvals(tool_call=(record.message_id, record.tool_call_id))
611
+ assert (bound.rpc_method, bound.a2a_operation) == ("SendMessage", "approve")
612
+ await _run(relay, "t1", None)
613
+ assert _sent() == ["GetTask", "GetTask"]
614
+ assert "was resumed without an approval decision" in await _tool_result(relay)
615
+
616
+
617
+ async def test_a_kept_approval_still_names_its_request(tmp_path: Path) -> None:
618
+ """The ledger kept in a file (`langgraph dev`) reads the request back after a restart."""
619
+ path = tmp_path / ".langgraph_api" / "a.json"
620
+ value = {
621
+ "type": APPROVAL_INTERRUPT,
622
+ "api": "orders_agent",
623
+ "method": "POST",
624
+ "path": "/a2a/orders",
625
+ "body": _decide("approve"),
626
+ "tool_call_id": "c1",
627
+ "message_id": "m1",
628
+ "approvers": ["requester"],
629
+ "call_hash": "h",
630
+ "rpc_method": "SendMessage",
631
+ "a2a_operation": "approve",
632
+ }
633
+ first = ApprovalStore(Database("memory"), path=path)
634
+ await first.load()
635
+ record, _, _ = await first.add(
636
+ record_from_interrupt(value, interrupt_id="i1", thread_id="t1", run_id="r", requester=ALICE)
637
+ )
638
+ again = ApprovalStore(Database("memory"), path=path)
639
+ assert await again.load() == 1
640
+ [bound] = await again.bound_approvals(tool_call=("m1", "c1"))
641
+ assert (bound.rpc_method, bound.a2a_operation) == ("SendMessage", "approve")
642
+ assert (await again.get(record.approval_id)).display_digest == record.display_digest
643
+
644
+
645
+ def test_identity_unchanged_for_http_apis() -> None:
646
+ """Three fields for a call to an `http` API, as in 0.2: nothing new in its record."""
647
+ assert call_identity("shop", "post", "/Orders/7/") == ("shop", "POST", "/orders/7")
648
+ assert call_identity("shop", "POST", "/orders/7", None, None) == ("shop", "POST", "/orders/7")
649
+ assert call_identity(*BoundApproval("shop", "POST", "/orders/7", "pending", False)[:3]) == (
650
+ "shop",
651
+ "POST",
652
+ "/orders/7",
653
+ )
654
+ read = call_identity("peer", "POST", "/a2a/x", "GetTask")
655
+ approve = call_identity("peer", "POST", "/a2a/x", "SendMessage", "approve")
656
+ reject = call_identity("peer", "POST", "/a2a/x", "SendMessage", "reject")
657
+ assert len({read, approve, reject, call_identity("peer", "POST", "/a2a/x")}) == 4
658
+ value = {
659
+ "type": APPROVAL_INTERRUPT,
660
+ "api": "shop",
661
+ "method": "POST",
662
+ "path": "/orders/7/cancel",
663
+ "body": {"reason": "asked"},
664
+ "approvers": ["requester"],
665
+ "call_hash": "h",
666
+ }
667
+ record = record_from_interrupt(
668
+ value, interrupt_id="i1", thread_id="t1", run_id="r", requester=ALICE
669
+ )
670
+ for shown in (record.payload, record.public(), decision_value(record, "approve")):
671
+ assert "rpc_method" not in shown and "a2a_operation" not in shown
672
+ view = approval_view(record)
673
+ assert (view["rpc_method"], view["a2a_operation"]) == (None, None)
674
+ # The digest of an http approval is what 0.3's first builds computed.
675
+ assert record.display_digest == approval_digest(
676
+ {
677
+ "api": "shop",
678
+ "method": "POST",
679
+ "path": "/orders/7/cancel",
680
+ "operation_id": None,
681
+ "rpc_method": None,
682
+ "a2a_operation": None,
683
+ "query": {},
684
+ "body": {"reason": "asked"},
685
+ }
686
+ )
687
+
688
+
689
+ # --- another agent is part of the same request (trace headers) and gets credentials over TLS ---
690
+
691
+ TRACEPARENT = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
692
+
693
+
694
+ def test_an_a2a_peer_carries_the_correlation_headers_whatever_its_auth() -> None:
695
+ """The owner's decision: request ids and trace context go to A2A peers and to the APIs
696
+ that act for the user, never to other third parties."""
697
+ assert propagates({"protocol": "a2a", "auth": "bearer"})
698
+ assert propagates({"protocol": "a2a", "auth": "exchange"})
699
+ assert not propagates({"protocol": "jsonrpc", "auth": "bearer"})
700
+ assert not propagates({"protocol": "http", "auth": "none"})
701
+ assert propagates({"auth": "forward"}) and not propagates({"auth": "bearer"})
702
+
703
+
704
+ async def test_the_correlation_headers_reach_the_peer_only(policy: Path) -> None:
705
+ set_outbound_headers(lambda: {"X-Request-ID": "req-1", "traceparent": TRACEPARENT})
706
+ try:
707
+ await _client("orders_agent").post("/a2a/orders", json_body=ASK)
708
+ peer = SENT[-1]
709
+ await _client("ledger").post("/rpc", json_body=_request("balance", ["a"]))
710
+ ledger = SENT[-1]
711
+ await _client("plain").post("/anything", json_body={"x": 1})
712
+ plain = SENT[-1]
713
+ finally:
714
+ set_outbound_headers(None)
715
+ assert (peer.headers["x-request-id"], peer.headers["traceparent"]) == ("req-1", TRACEPARENT)
716
+ for third_party in (ledger, plain):
717
+ assert "x-request-id" not in third_party.headers
718
+ assert "traceparent" not in third_party.headers
719
+
720
+
721
+ @pytest.mark.parametrize(
722
+ "url",
723
+ [
724
+ "https://orders.example.com",
725
+ "http://orders-agent:8080",
726
+ "http://orders-agent.orders.svc",
727
+ "http://orders-agent.orders.svc.cluster.local",
728
+ "http://127.0.0.1:8001",
729
+ "http://localhost:8001",
730
+ "http://[::1]:8001",
731
+ ],
732
+ )
733
+ async def test_a_peer_gets_credentials_over_https_or_inside_the_cluster(
734
+ policy: Path, monkeypatch: pytest.MonkeyPatch, url: str
735
+ ) -> None:
736
+ monkeypatch.setenv("ORDERS_AGENT_URL", url)
737
+ await _client("orders_agent").post("/a2a/orders", json_body=ASK)
738
+ assert len(SENT) == 1
739
+
740
+
741
+ async def test_plain_http_to_a_peer_outside_dev_is_refused(
742
+ policy: Path, monkeypatch: pytest.MonkeyPatch
743
+ ) -> None:
744
+ """The transport rule (3.6), live now that `protocol: a2a` exists: the peer's credential
745
+ never crosses a network in the clear outside APP_ENV=dev."""
746
+ monkeypatch.setenv("ORDERS_AGENT_URL", "http://orders.example.com")
747
+ with pytest.raises(ApiCallError) as refused:
748
+ await _client("orders_agent").post("/a2a/orders", json_body=ASK)
749
+ assert str(refused.value) == (
750
+ "ORDERS_AGENT_URL must use https outside APP_ENV=dev to carry credentials; nothing was "
751
+ "sent to API 'orders_agent'."
752
+ )
753
+ assert SENT == []
754
+ monkeypatch.setenv("APP_ENV", "dev")
755
+ await _client("orders_agent").post("/a2a/orders", json_body=ASK)
756
+ assert len(SENT) == 1
757
+
758
+
759
+ async def test_other_apis_keep_their_transport(
760
+ policy: Path, monkeypatch: pytest.MonkeyPatch
761
+ ) -> None:
762
+ """Non-peer APIs are unchanged in 0.3 (a plain JSON-RPC API included)."""
763
+ monkeypatch.setenv("LEDGER_URL", "http://ledger.example.com")
764
+ monkeypatch.setenv("PLAIN_URL", "http://plain.example.com")
765
+ await _client("ledger").post("/rpc", json_body=_request("balance", ["a"]))
766
+ await _client("plain").post("/anything", json_body={"x": 1})
767
+ assert len(SENT) == 1 # each _client() starts a new transport