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,824 @@
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
+ """The A2A client (`app_utils/a2a_client.py`): asking other agents, and relaying approvals.
16
+
17
+ The peer is a fake A2A 1.0 agent `orders` behind `httpx.MockTransport` (its
18
+ card, its JSON-RPC endpoint and its approvals ledger), so every request the
19
+ client sends, and what the policy lets through, is checked without a network.
20
+ The relay runs in a real graph (the fake model, the ledger an `ApprovalStore`)
21
+ that pauses at the policy's approve gate and resumes with a decision made here.
22
+ The same client against a real agent (this template, with token exchange) is in
23
+ `tests/integration/test_a2a_relay.py`.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import asyncio
29
+ import json
30
+ import os
31
+ import uuid
32
+ from collections.abc import Iterator
33
+ from pathlib import Path
34
+ from types import SimpleNamespace
35
+ from typing import Any
36
+
37
+ os.environ.setdefault("MODEL_PROVIDER", "fake")
38
+
39
+ import httpx
40
+ import pytest
41
+ from a2a.types.a2a_pb2 import SendMessageRequest
42
+ from google.protobuf import json_format
43
+ from langchain.agents import create_agent
44
+ from langgraph.checkpoint.memory import InMemorySaver
45
+ from langgraph.types import Command
46
+
47
+ from {{cookiecutter.agent_directory}}.app_utils import a2a_client
48
+ from {{cookiecutter.agent_directory}}.app_utils.a2a_client import (
49
+ A2APeerClient,
50
+ PeerRpcError,
51
+ client_settings,
52
+ context_id_for,
53
+ peer_tools,
54
+ reset_a2a_client,
55
+ )
56
+ from {{cookiecutter.agent_directory}}.app_utils.api_client import (
57
+ A2A_ORIGIN_EXTENSION,
58
+ ApiCallError,
59
+ ApiPolicyError,
60
+ reset_limits,
61
+ reset_policy_cache,
62
+ set_approval_ledger,
63
+ )
64
+ from {{cookiecutter.agent_directory}}.app_utils.approvals import (
65
+ APPROVED,
66
+ REJECTED,
67
+ ApprovalStore,
68
+ decision_value,
69
+ record_from_interrupt,
70
+ )
71
+ from {{cookiecutter.agent_directory}}.app_utils.auth import Principal
72
+ from {{cookiecutter.agent_directory}}.app_utils.db import Database
73
+ from {{cookiecutter.agent_directory}}.app_utils.limits import SettingsError
74
+
75
+ BASE = "http://orders-agent:8080"
76
+ PATH = "/a2a/orders"
77
+
78
+ POLICY = """
79
+ apis:
80
+ orders_agent:
81
+ description: "Orders agent: reads the caller's orders; cancels one after approval."
82
+ protocol: a2a
83
+ a2a: {path: /a2a/orders}
84
+ base_url_env: ORDERS_AGENT_URL
85
+ auth: bearer
86
+ token_env: ORDERS_AGENT_KEY
87
+ allowed_methods: [GET, POST]
88
+ allowed_operations:
89
+ - {operationId: getAgentCard, methods: [GET], path: /a2a/orders/.well-known/agent-card.json}
90
+ - {rpc_method: SendMessage, methods: [POST], path: /a2a/orders}
91
+ - {rpc_method: GetTask, methods: [POST], path: /a2a/orders}
92
+ - {rpc_method: CancelTask, methods: [POST], path: /a2a/orders}
93
+ - {operationId: listContextApprovals, methods: [GET], path: "/threads/{context_id}/approvals"}
94
+ approval:
95
+ - required_for: {operations: [{a2a_operation: approve}]}
96
+ approvers: [requester]
97
+ timeout_s: 900
98
+ limits: {max_calls_per_run: 12, max_response_bytes: 1048576}
99
+ timeouts_ms: {connect: 2000, read: 120000}
100
+ billing_agent:
101
+ description: "Billing agent: invoices and refunds."
102
+ protocol: a2a
103
+ a2a: {path: /a2a/billing}
104
+ base_url_env: BILLING_AGENT_URL
105
+ auth: bearer
106
+ token_env: BILLING_AGENT_KEY
107
+ allowed_methods: [GET, POST]
108
+ allowed_operations:
109
+ - {operationId: getAgentCard, methods: [GET], path: /a2a/billing/.well-known/agent-card.json}
110
+ - {rpc_method: SendMessage, methods: [POST], path: /a2a/billing}
111
+ - {rpc_method: GetTask, methods: [POST], path: /a2a/billing}
112
+ denied_operations:
113
+ - {a2a_operation: approve}
114
+ shop:
115
+ base_url_env: SHOP_API_BASE_URL
116
+ auth: none
117
+ allowed_methods: [GET]
118
+ """
119
+ PEERS = {
120
+ "orders": {
121
+ "api": "orders_agent",
122
+ "approvals": "relay",
123
+ "description": "Orders agent: reads the caller's orders; cancels one after approval.",
124
+ },
125
+ "billing": {"api": "billing_agent", "approvals": "deny", "description": "Billing agent."},
126
+ }
127
+ ALICE = {"principal_id": "alice", "roles": ["user"], "attributes": {}}
128
+
129
+
130
+ def _approval(**overrides: Any) -> dict[str, Any]:
131
+ """orders' pending approval, as its public approval object (exact values)."""
132
+ approval = {
133
+ "approval_id": "de2e",
134
+ "status": "pending",
135
+ "api": "orders_api",
136
+ "method": "POST",
137
+ "path": "/orders/ORD-1002/cancel",
138
+ "operation_id": "cancelOrder",
139
+ "query": {},
140
+ "body": {"reason": "customer asked", "amount": 1, "big": 12345678901234567890},
141
+ "reason": "cancel_order: the customer asked",
142
+ "approvers": ["requester"],
143
+ "decide_with": "relayed",
144
+ "digest": "sha256:" + "d" * 64,
145
+ "created_at": "2026-09-28T10:00:00+00:00",
146
+ "expires_at": "2099-01-01T00:00:00+00:00",
147
+ }
148
+ approval.update(overrides)
149
+ return approval
150
+
151
+
152
+ class FakePeer:
153
+ """An A2A 1.0 agent `orders` behind httpx.MockTransport.
154
+
155
+ A message containing "cancel" pauses for `approvals` (input-required, with
156
+ `approval_json`); a decision message completes; anything else is echoed.
157
+ `busy` answers that many messages with a failed `thread_busy` task, `lost`
158
+ answers GetTask -32001, `cancel_busy` answers that many cancels -32002, and
159
+ `card` / `answer` replace the card or every JSON-RPC answer.
160
+ """
161
+
162
+ def __init__(self, name: str = "orders", base: str = BASE) -> None:
163
+ self.name, self.base = name, base
164
+ self.requests: list[httpx.Request] = []
165
+ self.tasks: dict[str, dict[str, Any]] = {}
166
+ self.approvals: list[dict[str, Any]] = [_approval()]
167
+ self.busy = self.cancel_busy = 0
168
+ self.lost = False
169
+ self.approval_json = True
170
+ self.context_override: str | None = None
171
+ self.card: dict[str, Any] | None = None
172
+ self.card_status = 200
173
+ self.answer: Any = None
174
+
175
+ def transport(self) -> httpx.MockTransport:
176
+ return httpx.MockTransport(self.handle)
177
+
178
+ @property
179
+ def calls(self) -> list[str]:
180
+ """Each JSON-RPC request's method (and decision), in order; card and ledger reads too."""
181
+ out = []
182
+ for r in self.requests:
183
+ if r.method == "GET":
184
+ out.append("card" if r.url.path.endswith("agent-card.json") else "ledger")
185
+ continue
186
+ body = json.loads(r.content)
187
+ decisions = [
188
+ p["data"]["decision"]
189
+ for p in body["params"].get("message", {}).get("parts", [])
190
+ if "data" in p
191
+ ]
192
+ out.append(" ".join([body["method"], *decisions]))
193
+ return out
194
+
195
+ def default_card(self) -> dict[str, Any]:
196
+ return {
197
+ "name": self.name,
198
+ "description": "Orders agent\x07: reads and cancels orders." + " x" * 400,
199
+ "supportedInterfaces": [
200
+ {
201
+ "url": f"{self.base}/a2a/{self.name}",
202
+ "protocolBinding": "JSONRPC",
203
+ "protocolVersion": "1.0",
204
+ }
205
+ ],
206
+ "version": "0.1.0",
207
+ "capabilities": {"streaming": True, "extensions": [{"uri": A2A_ORIGIN_EXTENSION}]},
208
+ }
209
+
210
+ def handle(self, request: httpx.Request) -> httpx.Response:
211
+ self.requests.append(request)
212
+ if request.method == "GET" and request.url.path.endswith("agent-card.json"):
213
+ if self.card_status != 200:
214
+ return httpx.Response(self.card_status, json={"detail": "no"})
215
+ return httpx.Response(200, json=self.card or self.default_card())
216
+ if request.method == "GET": # the approvals ledger
217
+ return httpx.Response(200, json=self.approvals)
218
+ body = json.loads(request.content)
219
+ if self.answer is not None:
220
+ return httpx.Response(200, json=self.answer)
221
+ rpc_id, method, params = body["id"], body["method"], body["params"]
222
+ if method == "SendMessage":
223
+ return self._send(rpc_id, params["message"])
224
+ if method == "GetTask":
225
+ if self.lost or params["id"] not in self.tasks:
226
+ return self._error(rpc_id, -32001, "Task not found")
227
+ return httpx.Response(
228
+ 200, json={"jsonrpc": "2.0", "id": rpc_id, "result": self.tasks[params["id"]]}
229
+ )
230
+ if method == "CancelTask":
231
+ if self.cancel_busy:
232
+ self.cancel_busy -= 1
233
+ return self._error(rpc_id, -32002, "running on another replica")
234
+ task = self._task("TASK_STATE_CANCELED", "canceled", body["params"].get("id"))
235
+ return httpx.Response(200, json={"jsonrpc": "2.0", "id": rpc_id, "result": task})
236
+ return self._error(rpc_id, -32601, "Method not found")
237
+
238
+ @staticmethod
239
+ def _error(rpc_id: Any, code: int, message: str) -> httpx.Response:
240
+ return httpx.Response(
241
+ 200, json={"jsonrpc": "2.0", "id": rpc_id, "error": {"code": code, "message": message}}
242
+ )
243
+
244
+ def _task(
245
+ self,
246
+ state: str,
247
+ text: str,
248
+ task_id: str | None = None,
249
+ context_id: str = "",
250
+ parts: list[dict[str, Any]] | None = None,
251
+ reply: str | None = None,
252
+ ) -> dict[str, Any]:
253
+ task: dict[str, Any] = {
254
+ "id": task_id or uuid.uuid4().hex,
255
+ "contextId": self.context_override or context_id,
256
+ "status": {
257
+ "state": state,
258
+ "message": {
259
+ "messageId": uuid.uuid4().hex,
260
+ "role": "ROLE_AGENT",
261
+ "parts": parts or [{"text": text}],
262
+ },
263
+ },
264
+ }
265
+ if reply is not None:
266
+ task["artifacts"] = [
267
+ {"artifactId": "a1", "name": "response", "parts": [{"text": "first draft"}]},
268
+ {"artifactId": "a2", "name": "response", "parts": [{"text": reply}]},
269
+ ]
270
+ self.tasks[task["id"]] = task
271
+ return task
272
+
273
+ def _send(self, rpc_id: Any, message: dict[str, Any]) -> httpx.Response:
274
+ context_id = message.get("contextId", "")
275
+ text = "".join(p.get("text", "") for p in message["parts"])
276
+ decisions = [p["data"] for p in message["parts"] if "data" in p]
277
+ if self.busy:
278
+ self.busy -= 1
279
+ task = self._task(
280
+ "TASK_STATE_FAILED",
281
+ "thread_busy: busy",
282
+ context_id=context_id,
283
+ parts=[
284
+ {"text": "thread_busy: busy"},
285
+ {"data": {"type": "error", "code": "thread_busy"}},
286
+ ],
287
+ )
288
+ elif decisions:
289
+ done = "Cancelled ORD-1002." if decisions[0]["decision"] == "approve" else "Kept it."
290
+ task = self._task("TASK_STATE_COMPLETED", "", context_id=context_id, reply=done)
291
+ elif "cancel" in text:
292
+ data: dict[str, Any] = {
293
+ "type": "approval_request",
294
+ "approval": self.approvals[0] if self.approvals else None,
295
+ "approvals": self.approvals,
296
+ }
297
+ if self.approval_json:
298
+ data["approval_json"] = json.dumps(self.approvals)
299
+ task = self._task(
300
+ "TASK_STATE_INPUT_REQUIRED",
301
+ "",
302
+ context_id=context_id,
303
+ parts=[
304
+ {"text": "Waiting for approval de2e: POST /orders/ORD-1002/cancel"},
305
+ {"data": data},
306
+ ],
307
+ )
308
+ else:
309
+ task = self._task(
310
+ "TASK_STATE_COMPLETED", "", context_id=context_id, reply=f"echo: {text}"
311
+ )
312
+ return httpx.Response(200, json={"jsonrpc": "2.0", "id": rpc_id, "result": {"task": task}})
313
+
314
+
315
+ @pytest.fixture
316
+ def policy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]:
317
+ monkeypatch.delenv("APP_ENV", raising=False) # cluster-internal peers over plain http
318
+ for name in (
319
+ "A2A_FORWARD_ORIGIN",
320
+ "A2A_CARD_TTL_S",
321
+ "A2A_REPLY_MAX_CHARS",
322
+ "A2A_ORIGIN_MAX_CHARS",
323
+ "PRINCIPAL_HASH_SALT",
324
+ "A2A_NAME",
325
+ ):
326
+ monkeypatch.delenv(name, raising=False)
327
+ path = tmp_path / "api-policy.yaml"
328
+ path.write_text(POLICY, encoding="utf-8")
329
+ monkeypatch.setenv("API_POLICY_PATH", str(path))
330
+ monkeypatch.setenv("ORDERS_AGENT_URL", BASE)
331
+ monkeypatch.setenv("ORDERS_AGENT_KEY", "orders-key")
332
+ monkeypatch.setenv("BILLING_AGENT_URL", "http://billing-agent:8080")
333
+ monkeypatch.setenv("BILLING_AGENT_KEY", "billing-key")
334
+ reset_policy_cache()
335
+ reset_limits()
336
+ reset_a2a_client()
337
+ yield path
338
+ reset_policy_cache()
339
+ reset_limits()
340
+ reset_a2a_client()
341
+ a2a_client._PEERS.clear()
342
+
343
+
344
+ def _runtime(text: str = "Cancel ORD-1002 please", context: Any = None, thread: str = "t1") -> Any:
345
+ return SimpleNamespace(
346
+ context=context if context is not None else dict(ALICE),
347
+ config={"configurable": {"thread_id": thread}},
348
+ state={"messages": [{"type": "human", "content": text}]},
349
+ )
350
+
351
+
352
+ def _client(peer: FakePeer, runtime: Any = None, name: str = "orders") -> A2APeerClient:
353
+ return A2APeerClient(name, runtime=runtime or _runtime(), transport=peer.transport())
354
+
355
+
356
+ # --- requests, cards, contexts ---------------------------------------------------------------
357
+
358
+
359
+ async def test_a_message_is_an_a2a_1_0_request_with_its_version(policy: Path) -> None:
360
+ peer = FakePeer()
361
+ reply = await _client(peer).send("Where is ORD-1002?")
362
+ assert reply.state == "TASK_STATE_COMPLETED"
363
+ assert reply.text == "echo: Where is ORD-1002?" # the LAST response artifact
364
+ assert peer.calls == ["card", "SendMessage"]
365
+ sent = peer.requests[1]
366
+ assert sent.headers["A2A-Version"] == "1.0"
367
+ body = json.loads(sent.content)
368
+ assert body["jsonrpc"] == "2.0" and body["method"] == "SendMessage"
369
+ request = SendMessageRequest()
370
+ json_format.ParseDict(body["params"], request) # a valid SDK request
371
+ assert request.message.context_id == context_id_for("t1", "orders_agent", "alice")
372
+ assert request.configuration.history_length == 0
373
+ assert "authorization" in {k.lower() for k in sent.headers} # the policy's credential
374
+
375
+
376
+ def test_the_context_id_is_keyed_stable_and_uuid_shaped(monkeypatch: pytest.MonkeyPatch) -> None:
377
+ monkeypatch.delenv("PRINCIPAL_HASH_SALT", raising=False)
378
+ plain = context_id_for("t1", "orders_agent", "alice")
379
+ assert plain == context_id_for("t1", "orders_agent", "alice") # every turn, every replica
380
+ assert uuid.UUID(plain).version == 4 and "t1" not in plain
381
+ others = {
382
+ context_id_for("t2", "orders_agent", "alice"),
383
+ context_id_for("t1", "billing_agent", "alice"),
384
+ context_id_for("t1", "orders_agent", "bob"),
385
+ }
386
+ assert plain not in others and len(others) == 3
387
+ monkeypatch.setenv("PRINCIPAL_HASH_SALT", "s3cret")
388
+ salted = context_id_for("t1", "orders_agent", "alice")
389
+ assert salted != plain and uuid.UUID(salted).version == 4
390
+
391
+
392
+ async def test_the_card_is_read_once_and_its_url_never_dialed(policy: Path) -> None:
393
+ peer = FakePeer()
394
+ client = _client(peer)
395
+ await client.send("one")
396
+ await client.send("two")
397
+ assert peer.calls == ["card", "SendMessage", "SendMessage"]
398
+ card = await client.card()
399
+ assert card.origin and "\x07" not in card.description and len(card.description) <= 300
400
+
401
+
402
+ @pytest.mark.parametrize(
403
+ ("card", "refusal"),
404
+ [
405
+ (
406
+ {
407
+ "supportedInterfaces": [
408
+ {
409
+ "url": "http://evil.test/a2a/orders",
410
+ "protocolBinding": "JSONRPC",
411
+ "protocolVersion": "1.0",
412
+ }
413
+ ]
414
+ },
415
+ "names http://evil.test/a2a/orders as its A2A endpoint, not the URL this agent calls",
416
+ ),
417
+ (
418
+ {
419
+ "supportedInterfaces": [
420
+ {
421
+ "url": f"{BASE}/a2a/orders",
422
+ "protocolBinding": "JSONRPC",
423
+ "protocolVersion": "0.3.0",
424
+ }
425
+ ]
426
+ },
427
+ "offers no A2A 1.x JSON-RPC interface",
428
+ ),
429
+ ({"name": "billing"}, "is agent 'billing', not 'orders'"),
430
+ ],
431
+ )
432
+ async def test_a_card_that_is_not_this_peer_refuses_every_call(
433
+ policy: Path, card: dict[str, Any], refusal: str
434
+ ) -> None:
435
+ peer = FakePeer()
436
+ peer.card = {**peer.default_card(), **card}
437
+ with pytest.raises(ApiCallError, match=refusal):
438
+ await _client(peer).send("hello")
439
+ assert peer.calls == ["card"] # nothing was sent, and the card's URL was never dialed
440
+ with pytest.raises(ApiCallError, match=refusal): # the failure is kept for a while
441
+ await _client(peer).send("hello")
442
+ assert peer.calls == ["card"]
443
+
444
+
445
+ async def test_a_card_behind_a_credential_the_peer_refuses(policy: Path) -> None:
446
+ peer = FakePeer()
447
+ peer.card_status = 401
448
+ with pytest.raises(ApiCallError, match=r"orders refused the credential \(401\)"):
449
+ await _client(peer).send("hello")
450
+
451
+
452
+ def test_locks_and_cards_are_bounded(monkeypatch: pytest.MonkeyPatch) -> None:
453
+ monkeypatch.setattr(a2a_client, "LOCKS_MAX", 3)
454
+ reset_a2a_client()
455
+ held = a2a_client._lock_for("c0")
456
+ asyncio.run(held.acquire())
457
+ for index in range(1, 6):
458
+ a2a_client._lock_for(f"c{index}")
459
+ assert len(a2a_client._LOCKS) == 3 and "c0" in a2a_client._LOCKS # a held lock stays
460
+ reset_a2a_client()
461
+
462
+
463
+ async def test_a_busy_peer_is_asked_again_three_times_at_most(
464
+ policy: Path, monkeypatch: pytest.MonkeyPatch
465
+ ) -> None:
466
+ waits: list[float] = []
467
+
468
+ async def no_wait(seconds: float) -> None:
469
+ waits.append(seconds)
470
+
471
+ monkeypatch.setattr(a2a_client.asyncio, "sleep", no_wait)
472
+ peer = FakePeer()
473
+ peer.busy = 2
474
+ reply = await _client(peer).send("hello")
475
+ assert reply.text == "echo: hello" and waits == [0.5, 1.0]
476
+ peer.busy = 9
477
+ reply = await _client(peer).send("hello")
478
+ assert reply.state == "TASK_STATE_FAILED" and reply.error_code == "thread_busy"
479
+ assert waits == [0.5, 1.0, 0.5, 1.0, 2.0]
480
+
481
+
482
+ async def test_a_cancel_the_peers_other_replica_runs_is_asked_again_once(
483
+ policy: Path, monkeypatch: pytest.MonkeyPatch
484
+ ) -> None:
485
+ async def no_wait(seconds: float) -> None:
486
+ return None
487
+
488
+ monkeypatch.setattr(a2a_client.asyncio, "sleep", no_wait)
489
+ peer = FakePeer()
490
+ peer.cancel_busy = 1
491
+ assert (await _client(peer).cancel("t9")).state == "TASK_STATE_CANCELED"
492
+ peer.cancel_busy = 2
493
+ with pytest.raises(ApiCallError, match="still running on another replica of orders"):
494
+ await _client(peer).cancel("t9")
495
+
496
+
497
+ async def test_a_call_the_policy_does_not_allow_is_never_sent(policy: Path) -> None:
498
+ """billing's entry allows no CancelTask: the allow-list refuses it before sending."""
499
+ peer = FakePeer(name="billing", base="http://billing-agent:8080")
500
+ with pytest.raises(ApiPolicyError, match="rpc_method CancelTask"):
501
+ await _client(peer, name="billing").cancel("t9")
502
+ assert "CancelTask" not in peer.calls
503
+
504
+
505
+ # --- loops and errors --------------------------------------------------------------------
506
+
507
+
508
+ async def test_a_call_back_to_this_agent_or_up_the_chain_is_refused(
509
+ policy: Path, monkeypatch: pytest.MonkeyPatch
510
+ ) -> None:
511
+ peer = FakePeer()
512
+ monkeypatch.setenv("A2A_NAME", "orders")
513
+ with pytest.raises(ApiPolicyError, match="would call this agent itself"):
514
+ await _client(peer).send("hello")
515
+ monkeypatch.setenv("A2A_NAME", "billing")
516
+ chained = {
517
+ **ALICE,
518
+ "attributes": {"@actor": {"id": "concierge", "chain": ["concierge"]}},
519
+ }
520
+ monkeypatch.setattr(
521
+ A2APeerClient, "_resolve", staticmethod(lambda peer: ("concierge", "orders_agent"))
522
+ )
523
+ with pytest.raises(
524
+ ApiPolicyError, match=r"loop back through the delegation chain \(concierge -> billing"
525
+ ):
526
+ await _client(peer, _runtime(context=chained)).send("hello")
527
+ assert peer.requests == []
528
+
529
+
530
+ async def test_errors_the_model_reads(policy: Path) -> None:
531
+ with pytest.raises(ApiPolicyError, match="unknown agent 'shipping'; ask one of"):
532
+ A2APeerClient("shipping", runtime=_runtime())
533
+ with pytest.raises(ApiPolicyError, match="is not an A2A peer"):
534
+ A2APeerClient("shop", runtime=_runtime())
535
+ peer = FakePeer()
536
+ await _client(peer).card()
537
+ peer.answer = {"jsonrpc": "2.0", "id": "x", "error": {"code": -32602, "message": "bad " * 200}}
538
+ with pytest.raises(ApiCallError, match="orders answered with something that is not A2A"):
539
+ await _client(peer).send("hello") # another request's id
540
+ peer.answer = None
541
+ peer.card = None
542
+
543
+ def refuse(request: httpx.Request) -> httpx.Response:
544
+ body = json.loads(request.content)
545
+ return httpx.Response(
546
+ 200,
547
+ json={
548
+ "jsonrpc": "2.0",
549
+ "id": body["id"],
550
+ "error": {"code": -32602, "message": "bad " * 200},
551
+ },
552
+ )
553
+
554
+ client = A2APeerClient("orders", runtime=_runtime(), transport=httpx.MockTransport(refuse))
555
+ reset_a2a_client()
556
+ a2a_client._CARDS.entries[("orders_agent", BASE)] = (
557
+ asyncio.get_running_loop().time() + 60,
558
+ a2a_client.PeerCard(name="orders", description="", origin=False),
559
+ )
560
+ with pytest.raises(PeerRpcError) as refused:
561
+ await client.send("hello")
562
+ assert refused.value.code == -32602
563
+ assert str(refused.value).startswith("orders refused SendMessage: -32602 bad bad")
564
+ assert len(str(refused.value)) < 360
565
+
566
+
567
+ def test_bad_settings_stop_startup(monkeypatch: pytest.MonkeyPatch) -> None:
568
+ client_settings()
569
+ for name, value in (
570
+ ("A2A_FORWARD_ORIGIN", "always"),
571
+ ("A2A_CARD_TTL_S", "-1"),
572
+ ("A2A_REPLY_MAX_CHARS", "0"),
573
+ ("A2A_ORIGIN_MAX_CHARS", "lots"),
574
+ ):
575
+ monkeypatch.setenv(name, value)
576
+ with pytest.raises(SettingsError, match=name):
577
+ client_settings()
578
+ monkeypatch.delenv(name)
579
+
580
+
581
+ # --- the user's own words (the origin extension) ----------------------------------------------
582
+
583
+
584
+ def _sent_origin(peer: FakePeer) -> tuple[Any, str | None]:
585
+ sent = next(r for r in peer.requests if r.method == "POST")
586
+ metadata = json.loads(sent.content)["params"]["message"].get("metadata") or {}
587
+ return metadata.get(A2A_ORIGIN_EXTENSION), sent.headers.get("A2A-Extensions")
588
+
589
+
590
+ async def test_the_users_words_go_to_a_peer_that_reads_them(policy: Path) -> None:
591
+ peer = FakePeer()
592
+ await _client(peer, _runtime("Cancel ORD-1002, it came broken")).send("cancel ORD-1002")
593
+ extension, header = _sent_origin(peer)
594
+ assert header == A2A_ORIGIN_EXTENSION
595
+ assert extension == {
596
+ "origin": {"text": "Cancel ORD-1002, it came broken", "truncated": False, "hops": 1}
597
+ }
598
+
599
+
600
+ async def test_the_words_are_capped_and_forwarded_on_with_one_more_hop(
601
+ policy: Path, monkeypatch: pytest.MonkeyPatch
602
+ ) -> None:
603
+ monkeypatch.setenv("A2A_ORIGIN_MAX_CHARS", "10")
604
+ peer = FakePeer()
605
+ await _client(peer, _runtime("Cancel ORD-1002 now")).send("x")
606
+ assert _sent_origin(peer)[0]["origin"] == {"text": "Cancel ORD", "truncated": True, "hops": 1}
607
+ # An agent calling for the user passes on the words it was given, never its own request.
608
+ delegated = {
609
+ **ALICE,
610
+ "attributes": {
611
+ "@actor": {"id": "concierge", "chain": ["concierge"]},
612
+ "credentials": {"@origin": {"text": "ORD-7", "truncated": False, "hops": 1}},
613
+ },
614
+ }
615
+ peer = FakePeer()
616
+ await _client(peer, _runtime("the concierge's words", context=delegated)).send("x")
617
+ assert _sent_origin(peer)[0]["origin"] == {"text": "ORD-7", "truncated": False, "hops": 2}
618
+ without = {**delegated, "attributes": {"@actor": {"id": "concierge"}}}
619
+ peer = FakePeer()
620
+ await _client(peer, _runtime("the concierge's words", context=without)).send("x")
621
+ assert _sent_origin(peer) == (None, None)
622
+
623
+
624
+ async def test_the_words_stay_here_when_off_or_when_the_peer_does_not_read_them(
625
+ policy: Path, monkeypatch: pytest.MonkeyPatch
626
+ ) -> None:
627
+ monkeypatch.setenv("A2A_FORWARD_ORIGIN", "off")
628
+ peer = FakePeer()
629
+ await _client(peer).send("x")
630
+ assert _sent_origin(peer) == (None, None)
631
+ monkeypatch.delenv("A2A_FORWARD_ORIGIN")
632
+ reset_a2a_client()
633
+ peer = FakePeer()
634
+ peer.card = {**peer.default_card(), "capabilities": {"streaming": True}}
635
+ await _client(peer).send("x")
636
+ assert _sent_origin(peer) == (None, None)
637
+
638
+
639
+ # --- the tools --------------------------------------------------------------------------------
640
+
641
+
642
+ def test_the_tools_name_the_peers_and_fail_closed(policy: Path) -> None:
643
+ from langchain_core.utils.function_calling import convert_to_openai_tool
644
+
645
+ ask, relay = peer_tools(PEERS)
646
+ ask_schema = convert_to_openai_tool(ask)["function"]
647
+ assert ask_schema["name"] == "ask_agent"
648
+ assert ask_schema["parameters"]["properties"]["agent"]["enum"] == ["orders", "billing"]
649
+ assert "- orders: Orders agent: reads the caller's orders" in ask_schema["description"]
650
+ relay_schema = convert_to_openai_tool(relay)["function"]
651
+ assert relay_schema["name"] == "approve_agent_action"
652
+ # Only the peers this agent relays to (one: a JSON-schema constant).
653
+ assert relay_schema["parameters"]["properties"]["agent"]["const"] == "orders"
654
+ with pytest.raises(ApiPolicyError, match="'shop' is not a protocol: a2a API"):
655
+ peer_tools({"shop": {"api": "shop", "approvals": "deny"}})
656
+ assert len(peer_tools({"billing": PEERS["billing"]})) == 1 # no relay: no approve tool
657
+
658
+
659
+ # --- the relay, in a graph that pauses at the approve gate -------------------------------------
660
+
661
+
662
+ @pytest.fixture
663
+ def store() -> ApprovalStore:
664
+ return ApprovalStore(Database("memory"))
665
+
666
+
667
+ @pytest.fixture
668
+ def relay_graph(policy: Path, store: ApprovalStore) -> Iterator[tuple[Any, FakePeer]]:
669
+ """An agent with the peer tools (approve_agent_action first: the fake model calls the first
670
+ tool a message mentions), the fake peer and the ledger `store`."""
671
+ from {{cookiecutter.agent_directory}} import agent
672
+ from {{cookiecutter.agent_directory}}.app_utils.model import get_model
673
+
674
+ peer = FakePeer()
675
+ set_approval_ledger(store)
676
+ tools = list(reversed(peer_tools(PEERS, transport=peer.transport())))
677
+ graph = create_agent(
678
+ model=get_model(),
679
+ tools=tools,
680
+ system_prompt=agent.SYSTEM_PROMPT,
681
+ middleware=agent.middleware(),
682
+ context_schema=agent.AgentContext,
683
+ checkpointer=InMemorySaver(),
684
+ )
685
+ yield graph, peer
686
+ set_approval_ledger(None)
687
+
688
+
689
+ async def _run(graph: Any, graph_input: Any) -> list[Any]:
690
+ from {{cookiecutter.agent_directory}}.agent import AgentContext
691
+
692
+ config = {"configurable": {"thread_id": "t1"}}
693
+ context = AgentContext(principal_id="alice", roles=["user"])
694
+ async for _ in graph.astream(graph_input, config, context=context, stream_mode="updates"):
695
+ pass
696
+ return list((await graph.aget_state(config)).interrupts)
697
+
698
+
699
+ async def _tool_result(graph: Any) -> str:
700
+ state = await graph.aget_state({"configurable": {"thread_id": "t1"}})
701
+ return next(m.content for m in reversed(state.values["messages"]) if m.type == "tool")
702
+
703
+
704
+ async def _ask(graph: Any) -> str:
705
+ """ask_agent asks orders to cancel: it pauses there, and reports the task to relay."""
706
+ assert await _run(graph, {"messages": [("user", "Ask orders about cancel ORD-1002")]}) == []
707
+ text = await _tool_result(graph)
708
+ start = text.index("{")
709
+ result = json.loads(text[start : text.rindex("}") + 1])
710
+ assert result["status"] == "needs_user_approval" and result["agent"] == "orders"
711
+ return str(result["task_id"])
712
+
713
+
714
+ async def _pause(graph: Any, store: ApprovalStore, task_id: str) -> tuple[Any, Any]:
715
+ interrupts = await _run(graph, {"messages": [("user", f"approve_agent_action for {task_id}")]})
716
+ assert len(interrupts) == 1
717
+ record, _, _ = await store.add(
718
+ record_from_interrupt(
719
+ interrupts[0].value,
720
+ interrupt_id=interrupts[0].id,
721
+ thread_id="t1",
722
+ run_id="r",
723
+ requester=Principal(id="alice", roles=["user"]),
724
+ )
725
+ )
726
+ return interrupts[0], record
727
+
728
+
729
+ async def test_the_relay_shows_the_person_what_happens_and_sends_it_once(
730
+ relay_graph: tuple[Any, FakePeer], store: ApprovalStore
731
+ ) -> None:
732
+ graph, peer = relay_graph
733
+ task_id = await _ask(graph)
734
+ interrupt, record = await _pause(graph, store, task_id)
735
+ public = record.public()
736
+ assert public["a2a_operation"] == "approve"
737
+ assert public["effect"]["path"] == "/orders/ORD-1002/cancel"
738
+ assert public["effect"]["via"] == ["orders"]
739
+ assert public["nested"]["call"]["body"]["big"] == 12345678901234567890 # exact
740
+ assert public["nested"]["digest"] == _approval()["digest"]
741
+ assert peer.calls[-1] == "GetTask" # read, nothing decided yet
742
+ decided = await store.decide(record.approval_id, APPROVED, "alice", None)
743
+ await _run(graph, Command(resume={interrupt.id: decision_value(decided, "approve")}))
744
+ assert "Cancelled ORD-1002." in await _tool_result(graph)
745
+ assert [c for c in peer.calls if c.startswith("SendMessage")] == [
746
+ "SendMessage", # the ask
747
+ "SendMessage approve", # once
748
+ ]
749
+ decision = json.loads(peer.requests[-1].content)
750
+ message = decision["params"]["message"]
751
+ assert decision["id"] == "approve-de2e"
752
+ assert message["parts"] == [
753
+ {"data": {"approval_id": "de2e", "decision": "approve", "digest": _approval()["digest"]}}
754
+ ]
755
+ assert "taskId" not in message and message["referenceTaskIds"] == [task_id]
756
+ assert message["contextId"] == context_id_for("t1", "orders_agent", "alice")
757
+ assert message["metadata"][A2A_ORIGIN_EXTENSION]["approving"]["approval_id"] == "de2e"
758
+ assert (await store.get(record.approval_id)).used_at is not None
759
+
760
+
761
+ async def test_after_the_peer_lost_the_task_its_ledger_is_read_and_one_decision_sent(
762
+ relay_graph: tuple[Any, FakePeer], store: ApprovalStore
763
+ ) -> None:
764
+ graph, peer = relay_graph
765
+ task_id = await _ask(graph)
766
+ peer.lost = True
767
+ interrupt, record = await _pause(graph, store, task_id)
768
+ assert peer.calls[-2:] == ["GetTask", "ledger"]
769
+ decided = await store.decide(record.approval_id, APPROVED, "alice", None)
770
+ await _run(graph, Command(resume={interrupt.id: decision_value(decided, "approve")}))
771
+ assert [c for c in peer.calls if c.startswith("SendMessage ")] == ["SendMessage approve"]
772
+
773
+
774
+ async def test_a_rejection_tells_the_peer_and_nothing_is_done(
775
+ relay_graph: tuple[Any, FakePeer], store: ApprovalStore
776
+ ) -> None:
777
+ graph, peer = relay_graph
778
+ task_id = await _ask(graph)
779
+ interrupt, record = await _pause(graph, store, task_id)
780
+ decided = await store.decide(record.approval_id, REJECTED, "alice", "no")
781
+ await _run(graph, Command(resume={interrupt.id: decision_value(decided, "reject")}))
782
+ result = await _tool_result(graph)
783
+ assert "The user rejected it; orders was told and did nothing" in result
784
+ assert [c for c in peer.calls if c.startswith("SendMessage ")] == ["SendMessage reject"]
785
+
786
+
787
+ async def test_a_task_of_another_conversation_is_refused(
788
+ relay_graph: tuple[Any, FakePeer], store: ApprovalStore
789
+ ) -> None:
790
+ graph, peer = relay_graph
791
+ task_id = await _ask(graph)
792
+ peer.tasks[task_id]["contextId"] = "someone-elses-context"
793
+ assert await _run(graph, {"messages": [("user", f"approve_agent_action for {task_id}")]}) == []
794
+ assert "belongs to another conversation" in await _tool_result(graph)
795
+ assert not any(c.startswith("SendMessage ") for c in peer.calls)
796
+
797
+
798
+ async def test_a_gate_the_person_decides_at_the_peer_is_reported_not_relayed(
799
+ relay_graph: tuple[Any, FakePeer], store: ApprovalStore
800
+ ) -> None:
801
+ graph, peer = relay_graph
802
+ peer.approvals = [_approval(decide_with="direct")]
803
+ assert await _run(graph, {"messages": [("user", "Ask orders about cancel ORD-1002")]}) == []
804
+ asked = await _tool_result(graph)
805
+ assert '"status": "needs_direct_approval"' in asked
806
+ task_id = next(iter(peer.tasks))
807
+ assert await _run(graph, {"messages": [("user", f"approve_agent_action for {task_id}")]}) == []
808
+ result = await _tool_result(graph)
809
+ assert '"status": "needs_direct_approval"' in result
810
+ assert "graph-agents-cli approvals approve de2e --url http://orders-agent:8080" in result
811
+ assert '"path": "/orders/ORD-1002/cancel"' in result # the effect
812
+ assert not any(c.startswith("SendMessage ") for c in peer.calls) # no gated call
813
+
814
+
815
+ async def test_a_peer_waiting_for_nothing_is_said_so(
816
+ relay_graph: tuple[Any, FakePeer], store: ApprovalStore
817
+ ) -> None:
818
+ graph, peer = relay_graph
819
+ await _client(peer).send("hello")
820
+ done = next(iter(peer.tasks))
821
+ assert await _run(graph, {"messages": [("user", f"approve_agent_action for {done}")]}) == []
822
+ assert "orders is not waiting for an approval (its task is completed)" in (
823
+ await _tool_result(graph)
824
+ )