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,1167 @@
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
+ """Ask other agents over A2A 1.0 (JSON-RPC) for the user, and relay the user's approvals.
16
+
17
+ A peer is an API in `api-policy.yaml` with `protocol: a2a` (`graph-agents-cli
18
+ peer add` writes it, and generates `tools/a2a_peers.py`, which calls
19
+ `peer_tools`). Every request goes through the policy client (`get_client`), so
20
+ the policy governs every byte: the allowed JSON-RPC methods, the approve gate,
21
+ the credential (`auth: exchange` mints a token for the peer just before
22
+ sending), the limits and the response cap; the A2A SDK's own HTTP client is
23
+ never used.
24
+
25
+ `A2APeerClient(peer)` talks to one peer:
26
+
27
+ * Before the first call it reads the peer's agent card (through the policy,
28
+ cached `A2A_CARD_TTL_S`, failures for 10 s) and refuses a peer whose card
29
+ offers no A2A 1.x JSON-RPC interface at exactly the URL this agent calls
30
+ (`<base URL env> + a2a.path`), or names another agent: a card's URL is never
31
+ dialed.
32
+ * One conversation per thread, peer and user: the `contextId` is a UUID keyed
33
+ with `PRINCIPAL_HASH_SALT` (`context_id_for`), so it is stable across turns
34
+ and replicas, and nobody can guess it. Calls to one peer in one thread are
35
+ serialized; different peers run in parallel.
36
+ * A peer's answer must be A2A 1.0 JSON-RPC; the reply is the last `response`
37
+ artifact's text (at most `A2A_REPLY_MAX_CHARS`). A task that failed because
38
+ the peer's thread was busy (`thread_busy` error part) is sent again, 3 times
39
+ at most (0.5, 1 and 2 s apart).
40
+ * With `A2A_FORWARD_ORIGIN=auto` (the default), a message to a peer whose card
41
+ declares the origin extension carries the user's own words: the user's
42
+ latest message when the user asked this agent directly, else the words the
43
+ agent calling this one forwarded; never text a model wrote. `off` never
44
+ sends them.
45
+ * A call that would come back to this agent, or to an agent already in the
46
+ request's delegation chain, is refused.
47
+
48
+ The approval relay (`A2APeerClient.relay`, the `approve_agent_action` tool):
49
+ the peer's pending approval is read from the peer itself (`GetTask`, its exact
50
+ `approval_json`; when the peer lost the task, its approvals ledger at
51
+ `GET /threads/{context_id}/approvals`), never from the model. A gate the peer
52
+ decides directly (`decide_with: direct`) is reported as `needs_direct_approval`.
53
+ Otherwise the decision is one context-addressed message naming the approval's
54
+ digest and, in its metadata, the approval it decides (`approving`); the
55
+ policy gates that message, so the person approves it here first, seeing what
56
+ will happen at the peer (`nested`, `effect`). The message is built the same on
57
+ every run, so the run resumed by the person's decision sends exactly what they
58
+ approved, once; a rejection (or an expiry) sends the same message rejecting,
59
+ ungated, so the peer's task ends at once.
60
+
61
+ Tool results reach the model fenced as untrusted text (`UntrustedToolResults`).
62
+ """
63
+
64
+ from __future__ import annotations
65
+
66
+ import asyncio
67
+ import hashlib
68
+ import hmac
69
+ import json
70
+ import logging
71
+ import os
72
+ import re
73
+ import uuid
74
+ from collections import OrderedDict
75
+ from collections.abc import Mapping
76
+ from dataclasses import dataclass, field
77
+ from typing import Any, Literal
78
+
79
+ import httpx
80
+
81
+ from {{cookiecutter.agent_directory}}.app_utils.api_client import (
82
+ A2A_ERROR_PART_TYPE,
83
+ A2A_ORIGIN_EXTENSION,
84
+ APPROVING_KEY,
85
+ EXCHANGE_KEY,
86
+ NESTED_CALL_KEYS,
87
+ ORIGIN_KEY,
88
+ PROTOCOL_A2A,
89
+ ApiCallError,
90
+ ApiClient,
91
+ ApiPolicyError,
92
+ api_protocol,
93
+ approval_effect,
94
+ current_caller,
95
+ current_context,
96
+ get_client,
97
+ latest_user_message,
98
+ load_policy,
99
+ origin_max_chars,
100
+ )
101
+ from {{cookiecutter.agent_directory}}.app_utils.limits import SettingsError
102
+
103
+ logger = logging.getLogger(__name__)
104
+
105
+ A2A_VERSION = "1.0"
106
+ CARD_PATH_SUFFIX = "/.well-known/agent-card.json"
107
+ # The operations a peer's policy entry names (`peer add` writes them).
108
+ CARD_OPERATION = "getAgentCard"
109
+ LEDGER_OPERATION = "listContextApprovals"
110
+ LEDGER_PATH = "/threads/{context_id}/approvals"
111
+ # JSON-RPC error codes of A2A.
112
+ TASK_NOT_FOUND = -32001
113
+ TASK_NOT_CANCELABLE = -32002
114
+ THREAD_BUSY = "thread_busy"
115
+ APPROVAL_REQUEST_TYPE = "approval_request"
116
+ # `A2A_FORWARD_ORIGIN`: `auto` sends the user's own words to a peer whose card declares the
117
+ # origin extension, `off` never does (the owner's decision of 2026-09-28: `auto`).
118
+ DEFAULT_A2A_FORWARD_ORIGIN = "auto"
119
+ FORWARD_ORIGIN_VALUES = ("auto", "off")
120
+ DEFAULT_CARD_TTL_S = 300
121
+ DEFAULT_REPLY_MAX_CHARS = 6000
122
+ CARD_FAILURE_TTL_S = 10.0
123
+ CARD_CACHE_MAX = 256
124
+ LOCKS_MAX = 4096
125
+ # What a peer says about itself, as the model reads it.
126
+ DESCRIPTION_MAX_CHARS = 300
127
+ STATUS_TEXT_MAX_CHARS = 1000
128
+ ERROR_MESSAGE_MAX_CHARS = 300
129
+ AGENT_SAID_MAX_CHARS = 1500
130
+ # A `thread_busy` answer is sent again after these pauses; a cancel the peer's other
131
+ # replica runs (-32002) once, after `CANCEL_RETRY_S`.
132
+ BUSY_RETRY_DELAYS_S = (0.5, 1.0, 2.0)
133
+ CANCEL_RETRY_S = 1.0
134
+ # The namespace of a decision's message id (uuid5: the same on every run).
135
+ _DECISION_NAMESPACE = uuid.UUID("7b3f6c1e-4a52-4d7c-9e0a-1f2d3c4b5a69")
136
+ _CONTROL = re.compile(r"[\x00-\x1f\x7f]")
137
+
138
+
139
+ # ---------------------------------------------------------------------------
140
+ # Settings
141
+ # ---------------------------------------------------------------------------
142
+
143
+
144
+ def _int_setting(name: str, default: int, minimum: int) -> int:
145
+ raw = (os.environ.get(name) or "").strip()
146
+ if not raw:
147
+ return default
148
+ try:
149
+ value = int(raw)
150
+ except ValueError:
151
+ raise SettingsError(f"{name}={raw!r} is not a whole number.") from None
152
+ if value < minimum:
153
+ raise SettingsError(f"{name}={value} must be at least {minimum}.")
154
+ return value
155
+
156
+
157
+ def card_ttl_s() -> int:
158
+ """`A2A_CARD_TTL_S`: seconds a peer's checked agent card is reused (default 300; 0: never)."""
159
+ return _int_setting("A2A_CARD_TTL_S", DEFAULT_CARD_TTL_S, 0)
160
+
161
+
162
+ def reply_max_chars() -> int:
163
+ """`A2A_REPLY_MAX_CHARS`: the most of a peer's reply the model reads (default 6000)."""
164
+ return _int_setting("A2A_REPLY_MAX_CHARS", DEFAULT_REPLY_MAX_CHARS, 1)
165
+
166
+
167
+ def forward_origin() -> str:
168
+ """`A2A_FORWARD_ORIGIN`: `auto` (default) or `off`; `SettingsError` otherwise."""
169
+ value = (os.environ.get("A2A_FORWARD_ORIGIN") or DEFAULT_A2A_FORWARD_ORIGIN).strip().lower()
170
+ if value not in FORWARD_ORIGIN_VALUES:
171
+ raise SettingsError(
172
+ f"A2A_FORWARD_ORIGIN={value!r} must be one of {', '.join(FORWARD_ORIGIN_VALUES)}."
173
+ )
174
+ return value
175
+
176
+
177
+ def client_settings() -> None:
178
+ """Check every A2A client setting (the app's startup check calls this)."""
179
+ card_ttl_s()
180
+ reply_max_chars()
181
+ origin_max_chars()
182
+ forward_origin()
183
+
184
+
185
+ def own_name() -> str:
186
+ """This agent's A2A name (`A2A_NAME`, else its agent directory), as its peers know it."""
187
+ return os.environ.get("A2A_NAME") or "{{cookiecutter.agent_directory}}"
188
+
189
+
190
+ # ---------------------------------------------------------------------------
191
+ # Errors and replies
192
+ # ---------------------------------------------------------------------------
193
+
194
+
195
+ class PeerRpcError(ApiCallError):
196
+ """A peer answered a JSON-RPC call with an error; `code` is its JSON-RPC code."""
197
+
198
+ def __init__(self, message: str, code: Any) -> None:
199
+ super().__init__(message)
200
+ self.code = code
201
+
202
+
203
+ @dataclass(frozen=True)
204
+ class PeerReply:
205
+ """What a peer answered, read from its task (or its direct message).
206
+
207
+ `text` is the last `response` artifact's text (`A2A_REPLY_MAX_CHARS` at
208
+ most), `status_text` the task's status message. `approvals` are the pending
209
+ approvals the peer reported (exact values, from `approval_json`); None when
210
+ the task waits for no approval, or reported none this way (an older agent:
211
+ read its approvals ledger instead). `error_code` names why a task failed or
212
+ a decision was refused (the peer's error part).
213
+ """
214
+
215
+ state: str
216
+ text: str
217
+ task_id: str | None
218
+ context_id: str
219
+ approvals: list[dict[str, Any]] | None = None
220
+ status_text: str = ""
221
+ error_code: str | None = None
222
+
223
+
224
+ def _clean(text: Any, limit: int) -> str:
225
+ """`text` without control characters (newlines and tabs as spaces), at most `limit` long."""
226
+ if not isinstance(text, str):
227
+ return ""
228
+ cleaned = _CONTROL.sub(" ", text).strip()
229
+ return cleaned if len(cleaned) <= limit else cleaned[: limit - 3].rstrip() + "..."
230
+
231
+
232
+ def _texts(parts: Any) -> str:
233
+ return "".join(
234
+ str(part.get("text"))
235
+ for part in parts or []
236
+ if isinstance(part, Mapping) and "text" in part
237
+ )
238
+
239
+
240
+ def _data_parts(message: Any, kind: str) -> list[dict[str, Any]]:
241
+ parts = message.get("parts") if isinstance(message, Mapping) else None
242
+ return [
243
+ part["data"]
244
+ for part in parts or []
245
+ if isinstance(part, Mapping)
246
+ and isinstance(part.get("data"), Mapping)
247
+ and part["data"].get("type") == kind
248
+ ]
249
+
250
+
251
+ def _exact_approvals(message: Any) -> list[dict[str, Any]] | None:
252
+ """The approvals a status message's approval request reports, as exact JSON (or None).
253
+
254
+ Read from `approval_json` only: the request's `Struct` holds every number as
255
+ a double, so an approval read from it could differ from the call it binds.
256
+ """
257
+ for data in _data_parts(message, APPROVAL_REQUEST_TYPE):
258
+ text = data.get("approval_json")
259
+ if not isinstance(text, str):
260
+ return None
261
+ try:
262
+ approvals = json.loads(text)
263
+ except ValueError:
264
+ return None
265
+ if isinstance(approvals, list):
266
+ return [a for a in approvals if isinstance(a, dict)]
267
+ return None
268
+
269
+
270
+ def reply_of(peer: str, task: Mapping[str, Any], context_id: str) -> PeerReply:
271
+ """A `PeerReply` from a task as the peer's JSON-RPC answer holds it."""
272
+ status = task.get("status") if isinstance(task.get("status"), Mapping) else {}
273
+ message = status.get("message") if isinstance(status.get("message"), Mapping) else {}
274
+ artifacts = [
275
+ a for a in task.get("artifacts") or [] if isinstance(a, Mapping) and a.get("parts")
276
+ ]
277
+ responses = [a for a in artifacts if a.get("name") == "response"] or artifacts
278
+ text = _texts(responses[-1].get("parts")) if responses else ""
279
+ limit = reply_max_chars()
280
+ errors = _data_parts(message, A2A_ERROR_PART_TYPE)
281
+ code = errors[0].get("code") if errors else None
282
+ return PeerReply(
283
+ state=str(status.get("state") or "TASK_STATE_UNSPECIFIED"),
284
+ text=text if len(text) <= limit else text[:limit] + " [truncated]",
285
+ task_id=str(task["id"]) if task.get("id") else None,
286
+ context_id=str(task.get("contextId") or context_id),
287
+ approvals=_exact_approvals(message),
288
+ status_text=_texts(message.get("parts")),
289
+ error_code=str(code) if isinstance(code, str) else None,
290
+ )
291
+
292
+
293
+ # ---------------------------------------------------------------------------
294
+ # Peers, context ids, cards, locks
295
+ # ---------------------------------------------------------------------------
296
+
297
+ # The peers `peer_tools` registered: name -> {"api", "approvals", "description"}.
298
+ _PEERS: dict[str, dict[str, str]] = {}
299
+
300
+
301
+ def context_id_for(thread_id: str, peer: str, subject: str) -> str:
302
+ """The A2A `contextId` of this thread's conversation with `peer` (its API) for `subject`.
303
+
304
+ A UUID made from an HMAC-SHA256 keyed with `PRINCIPAL_HASH_SALT` (plain
305
+ SHA-256 without it) of this agent's A2A name, the thread, the peer's API and
306
+ the user: the same on every turn and replica, not guessable with the salt,
307
+ and not disclosing the thread id. Changing the salt starts new conversations.
308
+ """
309
+ data = "\x1f".join(("a2a-context", own_name(), thread_id, peer, subject)).encode("utf-8")
310
+ salt = (os.environ.get("PRINCIPAL_HASH_SALT") or "").strip()
311
+ if salt:
312
+ digest = hmac.new(salt.encode("utf-8"), data, hashlib.sha256).digest()
313
+ else:
314
+ digest = hashlib.sha256(data).digest()
315
+ return str(uuid.UUID(bytes=digest[:16], version=4))
316
+
317
+
318
+ @dataclass
319
+ class PeerCard:
320
+ """What the checked agent card says: its description and whether it reads the origin."""
321
+
322
+ name: str
323
+ description: str
324
+ origin: bool
325
+
326
+
327
+ @dataclass
328
+ class _Cards:
329
+ """Checked cards by (API, URL): a bounded LRU, failures kept briefly, one fetch at a time."""
330
+
331
+ entries: OrderedDict[tuple[str, str], tuple[float, PeerCard | Exception]] = field(
332
+ default_factory=OrderedDict
333
+ )
334
+ fetching: dict[tuple[str, str], asyncio.Future[PeerCard]] = field(default_factory=dict)
335
+
336
+
337
+ _CARDS = _Cards()
338
+ # Per-context locks (one conversation runs one turn at a time), a bounded LRU of the
339
+ # unlocked ones.
340
+ _LOCKS: OrderedDict[str, asyncio.Lock] = OrderedDict()
341
+
342
+
343
+ def _lock_for(context_id: str) -> asyncio.Lock:
344
+ lock = _LOCKS.get(context_id)
345
+ if lock is None:
346
+ lock = _LOCKS[context_id] = asyncio.Lock()
347
+ _LOCKS.move_to_end(context_id)
348
+ if len(_LOCKS) > LOCKS_MAX:
349
+ for key in [k for k, v in _LOCKS.items() if not v.locked()][: len(_LOCKS) - LOCKS_MAX]:
350
+ del _LOCKS[key]
351
+ return lock
352
+
353
+
354
+ def reset_a2a_client() -> None:
355
+ """Forget the cards and locks (tests)."""
356
+ _CARDS.entries.clear()
357
+ _CARDS.fetching.clear()
358
+ _LOCKS.clear()
359
+
360
+
361
+ def _url_key(url: str) -> tuple[str, str, int | None, str]:
362
+ """A URL as the card check compares it: scheme, host, port, path (one trailing slash off)."""
363
+ parsed = httpx.URL(url)
364
+ port = parsed.port or {"http": 80, "https": 443}.get(parsed.scheme)
365
+ return parsed.scheme.lower(), (parsed.host or "").lower(), port, parsed.path.rstrip("/") or "/"
366
+
367
+
368
+ def _thread_id(runtime: Any) -> str | None:
369
+ config = getattr(runtime, "config", None)
370
+ if not isinstance(config, Mapping):
371
+ try:
372
+ from langgraph.config import get_config
373
+
374
+ config = get_config()
375
+ except Exception: # outside a graph run
376
+ return None
377
+ thread_id = (config.get("configurable") or {}).get("thread_id")
378
+ return str(thread_id) if thread_id else None
379
+
380
+
381
+ def _origin_of(context: Any) -> Mapping[str, Any] | None:
382
+ attributes = getattr(context, "attributes", None)
383
+ if attributes is None and isinstance(context, Mapping):
384
+ attributes = context.get("attributes")
385
+ credentials = attributes.get("credentials") if isinstance(attributes, Mapping) else None
386
+ origin = credentials.get("@origin") if isinstance(credentials, Mapping) else None
387
+ return origin if isinstance(origin, Mapping) else None
388
+
389
+
390
+ # ---------------------------------------------------------------------------
391
+ # The client
392
+ # ---------------------------------------------------------------------------
393
+
394
+
395
+ class A2APeerClient:
396
+ """One peer (a `protocol: a2a` API), called for the current run's user. See the module doc.
397
+
398
+ `peer` is a name `peer_tools` registered (`orders`), or the API's own name.
399
+ `runtime` is the tool's `ToolRuntime` (its thread, its user's words, its
400
+ context); `context` overrides the run context. `transport` is for tests.
401
+ """
402
+
403
+ def __init__(
404
+ self,
405
+ peer: str,
406
+ *,
407
+ runtime: Any = None,
408
+ context: Any = None,
409
+ transport: httpx.AsyncBaseTransport | None = None,
410
+ ) -> None:
411
+ self.runtime = runtime
412
+ self.context = (
413
+ context
414
+ if context is not None
415
+ else getattr(runtime, "context", None) or current_context()
416
+ )
417
+ self.transport = transport
418
+ self.peer, self.api = self._resolve(peer)
419
+ settings = load_policy().api(self.api)
420
+ if api_protocol(settings) != PROTOCOL_A2A:
421
+ raise ApiPolicyError(f"API {self.api!r} is not an A2A peer (protocol: a2a).")
422
+ self.settings = settings
423
+ self.path = str((settings.get("a2a") or {}).get("path") or f"/a2a/{self.peer}")
424
+
425
+ @staticmethod
426
+ def _resolve(peer: str) -> tuple[str, str]:
427
+ name = str(peer or "").strip()
428
+ registered = _PEERS.get(name)
429
+ if registered is not None:
430
+ return name, registered["api"]
431
+ apis = load_policy().apis
432
+ for api in (name, f"{name}_agent"):
433
+ if api in apis and api_protocol(apis[api]) == PROTOCOL_A2A:
434
+ return name, api
435
+ if name in apis:
436
+ raise ApiPolicyError(f"API {name!r} is not an A2A peer (protocol: a2a).")
437
+ known = ", ".join(_PEERS) or "(none)"
438
+ raise ApiPolicyError(f"unknown agent {name!r}; ask one of: {known}")
439
+
440
+ # -- plumbing -----------------------------------------------------------------
441
+
442
+ def _client(self) -> ApiClient:
443
+ return get_client(self.api, context=self.context, transport=self.transport)
444
+
445
+ def _base_url(self) -> str:
446
+ return str(self._client().base_url()).rstrip("/")
447
+
448
+ def _audience(self) -> str | None:
449
+ exchange = self.settings.get(EXCHANGE_KEY)
450
+ if self.settings.get("auth") == "exchange" and isinstance(exchange, Mapping):
451
+ return str(exchange.get("audience") or "") or None
452
+ return str(self.settings.get("forward_audience") or "") or None
453
+
454
+ def check_loop(self) -> None:
455
+ """Refuse a call that would come back to this agent or to one before it in the chain.
456
+
457
+ The peer is known by its name, the last segment of its `a2a.path` (its
458
+ A2A name) and its audience; each is checked as `token_exchange.loop_problem`
459
+ checks a delegation target: this agent's own names (its A2A name, its
460
+ audiences) and the agents the request came through (T9).
461
+ """
462
+ from {{cookiecutter.agent_directory}}.app_utils.token_exchange import loop_problem
463
+
464
+ chain = current_caller(self.context).actor_chain
465
+ for name in (self.peer, self.path.rstrip("/").rsplit("/", 1)[-1], self._audience()):
466
+ problem = loop_problem(name, chain) if name else None
467
+ if problem:
468
+ raise ApiPolicyError(f"{problem}; refused, nothing was sent.")
469
+
470
+ def context_id(self) -> str:
471
+ """This thread's conversation with the peer, for this user (`context_id_for`)."""
472
+ thread_id = _thread_id(self.runtime)
473
+ if not thread_id:
474
+ raise ApiPolicyError(
475
+ f"calling {self.peer} needs an agent run (a thread): refused, nothing was sent."
476
+ )
477
+ return context_id_for(thread_id, self.api, current_caller(self.context).principal_id)
478
+
479
+ async def _rpc(
480
+ self,
481
+ method: str,
482
+ params: Mapping[str, Any],
483
+ *,
484
+ rpc_id: str | None = None,
485
+ headers: Mapping[str, str] | None = None,
486
+ ) -> Any:
487
+ """One JSON-RPC 2.0 request to the peer; its `result`. Errors as the model reads them."""
488
+ request_id = rpc_id or uuid.uuid4().hex
489
+ body = {"jsonrpc": "2.0", "id": request_id, "method": method, "params": dict(params)}
490
+ try:
491
+ answer = await self._client().post(
492
+ self.path, json_body=body, headers={"A2A-Version": A2A_VERSION, **(headers or {})}
493
+ )
494
+ except ApiCallError as exc:
495
+ if exc.status_code == 401:
496
+ raise ApiCallError(
497
+ f"{self.peer} refused the credential (401): check exchange.audience and "
498
+ f"{self.peer}'s AUTH_JWT_AUDIENCE",
499
+ status_code=401,
500
+ body=exc.body,
501
+ ) from None
502
+ raise
503
+ if (
504
+ not isinstance(answer, Mapping)
505
+ or answer.get("jsonrpc") != "2.0"
506
+ or answer.get("id") != request_id
507
+ or ("result" not in answer and "error" not in answer)
508
+ ):
509
+ raise ApiCallError(f"{self.peer} answered with something that is not A2A 1.0 JSON-RPC")
510
+ if "error" in answer:
511
+ error = answer["error"] if isinstance(answer["error"], Mapping) else {}
512
+ code = error.get("code")
513
+ text = _clean(error.get("message"), ERROR_MESSAGE_MAX_CHARS)
514
+ raise PeerRpcError(f"{self.peer} refused {method}: {code} {text}".rstrip(), code)
515
+ return answer["result"]
516
+
517
+ def _task_reply(self, result: Any, context_id: str) -> PeerReply:
518
+ """A `PeerReply` from a `SendMessage` result (a task or a message) or a task."""
519
+ from a2a.types.a2a_pb2 import SendMessageResponse, Task
520
+ from google.protobuf import json_format
521
+
522
+ if not isinstance(result, Mapping):
523
+ raise ApiCallError(f"{self.peer} answered with something that is not A2A 1.0 JSON-RPC")
524
+ try:
525
+ if "task" in result or "message" in result:
526
+ json_format.ParseDict(result, SendMessageResponse(), ignore_unknown_fields=True)
527
+ else:
528
+ json_format.ParseDict(result, Task(), ignore_unknown_fields=True)
529
+ except json_format.ParseError:
530
+ raise ApiCallError(
531
+ f"{self.peer} answered with something that is not A2A 1.0 JSON-RPC"
532
+ ) from None
533
+ if isinstance(result.get("message"), Mapping):
534
+ message = result["message"]
535
+ text = _texts(message.get("parts"))
536
+ return PeerReply(
537
+ state="TASK_STATE_COMPLETED",
538
+ text=text[: reply_max_chars()],
539
+ task_id=None,
540
+ context_id=str(message.get("contextId") or context_id),
541
+ )
542
+ task = result["task"] if isinstance(result.get("task"), Mapping) else result
543
+ return reply_of(self.peer, task, context_id)
544
+
545
+ # -- the card -----------------------------------------------------------------
546
+
547
+ async def card(self) -> PeerCard:
548
+ """The peer's checked agent card (cached; one fetch at a time). See the module doc."""
549
+ url = self._base_url()
550
+ key = (self.api, url)
551
+ loop = asyncio.get_running_loop()
552
+ cached = _CARDS.entries.get(key)
553
+ if cached is not None and cached[0] > loop.time():
554
+ _CARDS.entries.move_to_end(key)
555
+ if isinstance(cached[1], Exception):
556
+ raise cached[1]
557
+ return cached[1]
558
+ pending = _CARDS.fetching.get(key)
559
+ if pending is not None:
560
+ return await asyncio.shield(pending)
561
+ future: asyncio.Future[PeerCard] = loop.create_future()
562
+ _CARDS.fetching[key] = future
563
+ try:
564
+ card = await self._fetch_card(url)
565
+ except (ApiCallError, ApiPolicyError) as exc:
566
+ if not isinstance(exc, ApiPolicyError):
567
+ self._keep_card(key, exc, CARD_FAILURE_TTL_S)
568
+ future.set_exception(exc)
569
+ future.exception() # retrieved: a waiter may never come
570
+ raise
571
+ finally:
572
+ _CARDS.fetching.pop(key, None)
573
+ self._keep_card(key, card, float(card_ttl_s()))
574
+ future.set_result(card)
575
+ return card
576
+
577
+ @staticmethod
578
+ def _keep_card(key: tuple[str, str], value: PeerCard | Exception, ttl: float) -> None:
579
+ if ttl <= 0:
580
+ return
581
+ _CARDS.entries[key] = (asyncio.get_running_loop().time() + ttl, value)
582
+ _CARDS.entries.move_to_end(key)
583
+ while len(_CARDS.entries) > CARD_CACHE_MAX:
584
+ _CARDS.entries.popitem(last=False)
585
+
586
+ async def _fetch_card(self, url: str) -> PeerCard:
587
+ from a2a.types import AgentCard
588
+ from google.protobuf import json_format
589
+
590
+ try:
591
+ data = await self._client().get(
592
+ f"{self.path}{CARD_PATH_SUFFIX}",
593
+ operation_id=CARD_OPERATION,
594
+ headers={"A2A-Version": A2A_VERSION},
595
+ )
596
+ except ApiCallError as exc:
597
+ if exc.status_code == 401:
598
+ raise ApiCallError(
599
+ f"{self.peer} refused the credential (401): check exchange.audience and "
600
+ f"{self.peer}'s AUTH_JWT_AUDIENCE",
601
+ status_code=401,
602
+ ) from None
603
+ raise
604
+ card = AgentCard()
605
+ try:
606
+ json_format.ParseDict(data, card, ignore_unknown_fields=True)
607
+ except (json_format.ParseError, TypeError, AttributeError):
608
+ raise ApiCallError(
609
+ f"{self.peer}'s agent card is not an A2A agent card; nothing was sent."
610
+ ) from None
611
+ expected = f"{url}{self.path}"
612
+ versions = [
613
+ i
614
+ for i in card.supported_interfaces
615
+ if i.protocol_binding.upper() == "JSONRPC" and i.protocol_version.startswith("1.")
616
+ ]
617
+ if not versions:
618
+ raise ApiCallError(
619
+ f"{self.peer}'s agent card offers no A2A 1.x JSON-RPC interface; nothing was sent."
620
+ )
621
+ if not any(_url_key(i.url) == _url_key(expected) for i in versions):
622
+ env = self.settings.get("base_url_env")
623
+ raise ApiCallError(
624
+ f"{self.peer}'s agent card names {versions[0].url} as its A2A endpoint, not the "
625
+ f"URL this agent calls ({env} + {self.path}): set the peer's APP_URL (appUrl in "
626
+ "its chart values); nothing was sent."
627
+ )
628
+ segment = self.path.rstrip("/").rsplit("/", 1)[-1]
629
+ if card.name != segment:
630
+ raise ApiCallError(
631
+ f"{self.peer}'s agent card is agent {card.name!r}, not {segment!r} (the last "
632
+ f"segment of its a2a.path, {self.path}); nothing was sent."
633
+ )
634
+ return PeerCard(
635
+ name=card.name,
636
+ description=_clean(card.description, DESCRIPTION_MAX_CHARS),
637
+ origin=any(e.uri == A2A_ORIGIN_EXTENSION for e in card.capabilities.extensions),
638
+ )
639
+
640
+ # -- the user's words -----------------------------------------------------------
641
+
642
+ def _origin(self, card: PeerCard) -> dict[str, Any] | None:
643
+ """The user's own words this call forwards (the origin extension), or None.
644
+
645
+ Sent only under `A2A_FORWARD_ORIGIN=auto` to a peer whose card declares the
646
+ extension. The user's latest message when the user asked this agent
647
+ directly; the words the calling agent forwarded when an agent asked
648
+ (none: nothing is sent, never a model's text). `hops` counts the agents.
649
+ """
650
+ if not card.origin or forward_origin() != "auto":
651
+ return None
652
+ if current_caller(self.context).delegated:
653
+ forwarded = _origin_of(self.context)
654
+ if forwarded is None or not isinstance(forwarded.get("text"), str):
655
+ return None
656
+ text, truncated = forwarded["text"], forwarded.get("truncated") is True
657
+ hops = forwarded.get("hops")
658
+ hops = int(hops) + 1 if isinstance(hops, int) and not isinstance(hops, bool) else 2
659
+ else:
660
+ text = latest_user_message(self.runtime) if self.runtime is not None else ""
661
+ truncated, hops = False, 1
662
+ if not text:
663
+ return None
664
+ cap = origin_max_chars()
665
+ return {"text": text[:cap], "truncated": truncated or len(text) > cap, "hops": hops}
666
+
667
+ @staticmethod
668
+ def _extension(
669
+ origin: Mapping[str, Any] | None, approving: Mapping[str, Any] | None = None
670
+ ) -> tuple[dict[str, Any] | None, dict[str, str]]:
671
+ """A message's metadata and headers for the origin extension (both empty: none)."""
672
+ data: dict[str, Any] = {}
673
+ if origin is not None:
674
+ data[ORIGIN_KEY] = dict(origin)
675
+ if approving is not None:
676
+ data[APPROVING_KEY] = dict(approving)
677
+ headers = {"A2A-Extensions": A2A_ORIGIN_EXTENSION} if origin is not None else {}
678
+ return ({A2A_ORIGIN_EXTENSION: data} if data else None), headers
679
+
680
+ # -- calls ------------------------------------------------------------------------
681
+
682
+ async def _prepare(self) -> tuple[PeerCard, str]:
683
+ self.check_loop()
684
+ context_id = self.context_id()
685
+ return await self.card(), context_id
686
+
687
+ async def send(self, text: str) -> PeerReply:
688
+ """Send `text` to the peer (a blocking `SendMessage`) and return its reply.
689
+
690
+ A task the peer failed because its thread was busy is sent again, 3 times
691
+ at most. Calls to one peer in one thread are serialized.
692
+ """
693
+ from a2a.types.a2a_pb2 import (
694
+ Message,
695
+ Part,
696
+ Role,
697
+ SendMessageConfiguration,
698
+ SendMessageRequest,
699
+ )
700
+ from google.protobuf import json_format
701
+
702
+ card, context_id = await self._prepare()
703
+ async with _lock_for(context_id):
704
+ for delay in (*BUSY_RETRY_DELAYS_S, None):
705
+ request = SendMessageRequest(
706
+ message=Message(
707
+ message_id=uuid.uuid4().hex,
708
+ context_id=context_id,
709
+ role=Role.ROLE_USER,
710
+ parts=[Part(text=text)],
711
+ ),
712
+ configuration=SendMessageConfiguration(history_length=0),
713
+ )
714
+ params = json_format.MessageToDict(request)
715
+ metadata, headers = self._extension(self._origin(card))
716
+ if metadata is not None:
717
+ params["message"]["metadata"] = metadata # plain JSON: exact values
718
+ result = await self._rpc("SendMessage", params, headers=headers)
719
+ reply = self._task_reply(result, context_id)
720
+ if reply.error_code != THREAD_BUSY or delay is None:
721
+ return reply
722
+ logger.info("A2A peer %s was busy; sending again in %g s", self.peer, delay)
723
+ await asyncio.sleep(delay)
724
+ raise AssertionError("unreachable") # pragma: no cover
725
+
726
+ async def get_task(self, task_id: str) -> PeerReply:
727
+ """The peer's task `task_id` (`GetTask`, owner-scoped at the peer)."""
728
+ from a2a.types.a2a_pb2 import GetTaskRequest
729
+ from google.protobuf import json_format
730
+
731
+ _card, context_id = await self._prepare()
732
+ params = json_format.MessageToDict(GetTaskRequest(id=str(task_id), history_length=0))
733
+ return self._task_reply(await self._rpc("GetTask", params), context_id)
734
+
735
+ async def cancel(self, task_id: str) -> PeerReply:
736
+ """Cancel the peer's task (`CancelTask`; the policy must allow it: `peer add --calls
737
+ ask,status,cancel`). A task still running on another replica of the peer (-32002) is
738
+ asked again once, after 1 s."""
739
+ from a2a.types.a2a_pb2 import CancelTaskRequest
740
+ from google.protobuf import json_format
741
+
742
+ _card, context_id = await self._prepare()
743
+ params = json_format.MessageToDict(CancelTaskRequest(id=str(task_id)))
744
+ for retry in (True, False):
745
+ try:
746
+ return self._task_reply(await self._rpc("CancelTask", params), context_id)
747
+ except PeerRpcError as exc:
748
+ if exc.code != TASK_NOT_CANCELABLE:
749
+ raise
750
+ if not retry:
751
+ raise ApiCallError(
752
+ f"the task is still running on another replica of {self.peer}; "
753
+ "try again later"
754
+ ) from None
755
+ await asyncio.sleep(CANCEL_RETRY_S)
756
+ raise AssertionError("unreachable") # pragma: no cover
757
+
758
+ async def _ledger(self, context_id: str) -> list[dict[str, Any]]:
759
+ """The peer's approvals on this conversation, from its approvals ledger (exact JSON)."""
760
+ rows = await self._client().get(
761
+ LEDGER_PATH, operation_id=LEDGER_OPERATION, path_params={"context_id": context_id}
762
+ )
763
+ if not isinstance(rows, list):
764
+ raise ApiCallError(
765
+ f"{self.peer}'s approvals answered with something that is not a list"
766
+ )
767
+ return [row for row in rows if isinstance(row, dict)]
768
+
769
+ async def _pending(self, task_id: str | None) -> tuple[list[dict[str, Any]], PeerReply | None]:
770
+ """What the peer waits on for this conversation: its pending approvals and the task.
771
+
772
+ `GetTask` first (the task must be this conversation's: T6); when the peer
773
+ lost the task (-32001), or reported its approvals only as a `Struct`, its
774
+ approvals ledger, which is authoritative.
775
+ """
776
+ _card, context_id = await self._prepare()
777
+ reply: PeerReply | None = None
778
+ if task_id:
779
+ try:
780
+ reply = await self.get_task(task_id)
781
+ except PeerRpcError as exc:
782
+ if exc.code != TASK_NOT_FOUND:
783
+ raise
784
+ if reply is not None and reply.context_id != context_id:
785
+ raise ApiPolicyError(
786
+ f"task {task_id} of {self.peer} belongs to another conversation; refused."
787
+ )
788
+ if reply is not None and reply.state != "TASK_STATE_INPUT_REQUIRED":
789
+ return [], reply
790
+ if reply is not None and reply.approvals is not None:
791
+ return [
792
+ a for a in reply.approvals if a.get("status", "pending") == "pending"
793
+ ], reply
794
+ rows = await self._ledger(context_id)
795
+ pending = [a for a in rows if a.get("status") == "pending"]
796
+ return sorted(pending, key=lambda a: str(a.get("created_at") or "")), reply
797
+
798
+ async def pending_approvals(self, task_id: str | None) -> list[dict[str, Any]]:
799
+ """The approvals the peer waits on in this conversation (exact values), oldest first."""
800
+ pending, _reply = await self._pending(task_id)
801
+ return pending
802
+
803
+ def approving(self, approval: Mapping[str, Any]) -> dict[str, Any]:
804
+ """What the person is asked to approve here: the peer's approval as the peer reported it."""
805
+ call = {key: approval[key] for key in NESTED_CALL_KEYS if key in approval}
806
+ return {
807
+ "agent": self.peer,
808
+ "approval_id": approval.get("approval_id"),
809
+ "call": call,
810
+ "reason": approval.get("reason"),
811
+ "expires_at": approval.get("expires_at"),
812
+ "digest": approval.get("digest"),
813
+ "reported_by": self.peer,
814
+ "decide_with": approval.get("decide_with"),
815
+ "nested": approval.get("nested") if isinstance(approval.get("nested"), dict) else None,
816
+ }
817
+
818
+ def _decision_params(
819
+ self,
820
+ approval: Mapping[str, Any],
821
+ decision: str,
822
+ context_id: str,
823
+ task_id: str | None,
824
+ origin: Mapping[str, Any] | None,
825
+ comment: str | None = None,
826
+ ) -> tuple[dict[str, Any], dict[str, str]]:
827
+ """The decision message, built the same on every run: the approval binds its body."""
828
+ approval_id = str(approval.get("approval_id"))
829
+ data: dict[str, Any] = {"approval_id": approval_id, "decision": decision}
830
+ if comment:
831
+ data["comment"] = comment
832
+ if approval.get("digest"):
833
+ data["digest"] = approval["digest"]
834
+ message: dict[str, Any] = {
835
+ "messageId": str(
836
+ uuid.uuid5(_DECISION_NAMESPACE, f"{context_id}:{approval_id}:{decision}")
837
+ ),
838
+ "contextId": context_id,
839
+ "role": "ROLE_USER",
840
+ "parts": [{"data": data}],
841
+ }
842
+ if task_id:
843
+ message["referenceTaskIds"] = [str(task_id)]
844
+ metadata, headers = self._extension(origin, self.approving(approval))
845
+ message["metadata"] = metadata
846
+ return {"message": message, "configuration": {"historyLength": 0}}, headers
847
+
848
+ async def decide(
849
+ self,
850
+ approval: Mapping[str, Any],
851
+ decision: str,
852
+ comment: str | None = None,
853
+ *,
854
+ task_id: str | None = None,
855
+ ) -> PeerReply:
856
+ """Send the person's decision on the peer's `approval`, on this conversation.
857
+
858
+ `approve` is gated by this agent's policy (the person approves it here
859
+ first); `reject` is sent at once.
860
+ """
861
+ card, context_id = await self._prepare()
862
+ params, headers = self._decision_params(
863
+ approval, decision, context_id, task_id, self._origin(card), comment
864
+ )
865
+ async with _lock_for(context_id):
866
+ result = await self._rpc(
867
+ "SendMessage",
868
+ params,
869
+ rpc_id=f"{decision}-{approval.get('approval_id')}",
870
+ headers=headers,
871
+ )
872
+ return self._task_reply(result, context_id)
873
+
874
+ async def relay(self, task_id: str) -> str:
875
+ """The `approve_agent_action` tool: relay the person's decision on what the peer waits on.
876
+
877
+ See the module doc. Returns what the model reads.
878
+ """
879
+ pending, reply = await self._pending(task_id)
880
+ if not pending:
881
+ state = f" (its task is {_state_name(reply.state)})" if reply is not None else ""
882
+ return f"{self.peer} is not waiting for an approval{state}."
883
+ approval, others = pending[0], pending[1:]
884
+ also = (
885
+ f" {self.peer} also waits on {len(others)} more approval(s) "
886
+ f"({', '.join(str(o.get('approval_id')) for o in others)}): relay them one at a time."
887
+ if others
888
+ else ""
889
+ )
890
+ if approval.get("decide_with", "direct") != "relayed":
891
+ return json.dumps(self.needs_direct(approval))
892
+ try:
893
+ answered = await self.decide(approval, "approve", task_id=task_id)
894
+ except ApiPolicyError as exc:
895
+ if exc.reason not in ("approval rejected", "approval expired"):
896
+ raise
897
+ # The person said no (or nobody answered): tell the peer, so its task ends now
898
+ # instead of waiting for its own approval to expire. Rejecting is always safe.
899
+ await self.decide(
900
+ approval, "reject", "The user did not approve this action.", task_id=task_id
901
+ )
902
+ why = "rejected it" if exc.reason == "approval rejected" else "did not answer in time"
903
+ return (
904
+ f"The user {why}; {self.peer} was told and did nothing. Tell the user it was not "
905
+ "done."
906
+ )
907
+ return describe(self.peer, answered, relays=True) + also
908
+
909
+ def needs_direct(self, approval: Mapping[str, Any]) -> dict[str, Any]:
910
+ """What the model reads for a gate the peer lets the person decide only directly."""
911
+ effect = approval.get("effect") if isinstance(approval.get("effect"), dict) else None
912
+ if effect is None:
913
+ effect = approval_effect(self.approving(approval))
914
+ url = f"{self._base_url()}"
915
+ return {
916
+ "status": "needs_direct_approval",
917
+ "agent": self.peer,
918
+ "approval_id": approval.get("approval_id"),
919
+ "effect": effect,
920
+ "how": (
921
+ f"graph-agents-cli approvals approve {approval.get('approval_id')} --url {url}, "
922
+ f"or POST /threads/<conversation>/approvals/{approval.get('approval_id')} at "
923
+ f"{self.peer} with the person's own token"
924
+ ),
925
+ "next_step": (
926
+ f"{self.peer} lets only the person decide this at {self.peer} itself "
927
+ "(decide_with: direct): tell the user exactly what it wants to do and how to "
928
+ "approve it there. Never say it was done."
929
+ ),
930
+ }
931
+
932
+
933
+ def _state_name(state: str) -> str:
934
+ return state.removeprefix("TASK_STATE_").lower().replace("_", "-")
935
+
936
+
937
+ def _what(approval: Mapping[str, Any]) -> str:
938
+ what = f"{approval.get('method')} {approval.get('path')}"
939
+ if approval.get("operation_id"):
940
+ what += f" ({approval['operation_id']})"
941
+ return what
942
+
943
+
944
+ def describe(peer: str, reply: PeerReply, *, relays: bool) -> str:
945
+ """What the model reads for a peer's reply (`relays`: this agent may relay approvals)."""
946
+ if reply.state == "TASK_STATE_COMPLETED":
947
+ return reply.text or f"({peer} completed the task with an empty reply)"
948
+ if reply.state == "TASK_STATE_INPUT_REQUIRED" and reply.approvals:
949
+ refused = (
950
+ f"{peer} refused the decision ({reply.error_code}): "
951
+ + _clean(reply.status_text, STATUS_TEXT_MAX_CHARS).split("Waiting for approval")[0]
952
+ if reply.error_code
953
+ else None
954
+ )
955
+ direct = [a for a in reply.approvals if a.get("decide_with", "direct") != "relayed"]
956
+ waiting = [
957
+ {
958
+ "approval_id": a.get("approval_id"),
959
+ "call": _what(a),
960
+ "effect": _effect_line(a.get("effect")),
961
+ "decided_by": "the person at " + peer
962
+ if a in direct
963
+ else "the person here (relayed)",
964
+ "expires_at": a.get("expires_at"),
965
+ }
966
+ for a in reply.approvals
967
+ ]
968
+ if relays and not direct:
969
+ status, step = (
970
+ "needs_user_approval",
971
+ "Tell the user exactly what the agent wants to do, then call approve_agent_action "
972
+ "with this agent and task_id: the user is asked to approve or reject it there. "
973
+ "Never assume approval.",
974
+ )
975
+ else:
976
+ status, step = (
977
+ "needs_direct_approval",
978
+ f"Only the person can approve this, at {peer} itself: tell the user exactly what "
979
+ f"{peer} wants to do and that they must approve it there. Never say it was done.",
980
+ )
981
+ result: dict[str, Any] = {
982
+ "status": status,
983
+ "agent": peer,
984
+ "task_id": reply.task_id,
985
+ "waiting": waiting,
986
+ "agent_said": _clean(reply.text or reply.status_text, AGENT_SAID_MAX_CHARS),
987
+ "next_step": step,
988
+ }
989
+ if refused:
990
+ result["refused"] = refused.strip()
991
+ return json.dumps(result)
992
+ status_text = _clean(reply.status_text, STATUS_TEXT_MAX_CHARS)
993
+ if reply.state == "TASK_STATE_INPUT_REQUIRED":
994
+ return (
995
+ f"{peer} needs more input (task {reply.task_id}): "
996
+ f"{status_text or reply.text or 'no detail'}. Ask it again with the answer."
997
+ )
998
+ return f"{peer} ended the task as {_state_name(reply.state)}: {status_text or 'no detail'}"
999
+
1000
+
1001
+ def _effect_line(effect: Any) -> str | None:
1002
+ if not isinstance(effect, Mapping):
1003
+ return None
1004
+ via = [str(v) for v in effect.get("via") or [] if v]
1005
+ hops = via[:-1] if via and via[-1] == effect.get("agent") else via
1006
+ who = str(effect.get("agent")) + (f" (via {', '.join(hops)})" if hops else "")
1007
+ return f"{who} will {_what(effect)}"
1008
+
1009
+
1010
+ # ---------------------------------------------------------------------------
1011
+ # The tools
1012
+ # ---------------------------------------------------------------------------
1013
+
1014
+ ASK_DOC = """Ask another agent to do something for the user, over A2A, and return its reply.
1015
+
1016
+ The agents you can ask:
1017
+ {roster}
1018
+
1019
+ Args:
1020
+ agent: which agent to ask.
1021
+ request: a complete, self-contained instruction for that agent. Say who it is for only
1022
+ as "the user", and name every record id the user gave (order, invoice, SKU).
1023
+
1024
+ Several ask_agent calls in one turn run in parallel. If the reply says needs_user_approval,
1025
+ call approve_agent_action with that agent and task_id; if it says needs_direct_approval,
1026
+ tell the user what that agent wants to do and that they must approve it there.
1027
+ """
1028
+
1029
+ RELAY_DOC = """Ask the user to approve an action another agent is waiting on, then pass the user's
1030
+ decision to that agent and return what it did.
1031
+
1032
+ Args:
1033
+ agent: the agent whose task reported needs_user_approval.
1034
+ task_id: that task's id, exactly as ask_agent returned it.
1035
+ """
1036
+
1037
+
1038
+ def _check_peers(peers: Mapping[str, Mapping[str, str]]) -> dict[str, dict[str, str]]:
1039
+ """Every peer is a `protocol: a2a` API of the loaded policy; raise (fail closed) otherwise."""
1040
+ apis = load_policy().apis
1041
+ checked: dict[str, dict[str, str]] = {}
1042
+ for name, peer in peers.items():
1043
+ api = str(peer.get("api") or "")
1044
+ if api not in apis or api_protocol(apis[api]) != PROTOCOL_A2A:
1045
+ raise ApiPolicyError(
1046
+ f"peer {name!r}: API {api!r} is not a protocol: a2a API of api-policy.yaml; "
1047
+ "run `graph-agents-cli peer sync` (tools/a2a_peers.py is out of date)."
1048
+ )
1049
+ checked[str(name)] = {
1050
+ "api": api,
1051
+ "approvals": str(peer.get("approvals") or "deny"),
1052
+ "description": _clean(peer.get("description"), DESCRIPTION_MAX_CHARS),
1053
+ }
1054
+ return checked
1055
+
1056
+
1057
+ def _warn_without_salt() -> None:
1058
+ dev = os.environ.get("APP_ENV") == "dev"
1059
+ if not dev and not (os.environ.get("PRINCIPAL_HASH_SALT") or "").strip():
1060
+ logger.warning(
1061
+ "A2A peers: PRINCIPAL_HASH_SALT is not set, so the contextIds sent to other agents "
1062
+ "are plain hashes of the thread and user ids; set it (a secret) so they cannot be "
1063
+ "guessed."
1064
+ )
1065
+
1066
+
1067
+ def peer_tools(
1068
+ peers: Mapping[str, Mapping[str, str]],
1069
+ *,
1070
+ transport: httpx.AsyncBaseTransport | None = None,
1071
+ ) -> list[Any]:
1072
+ """The tools that ask `peers` (`{name: {"api", "approvals", "description"}}`).
1073
+
1074
+ `ask_agent(agent, request)`, whose description lists the peers and what each
1075
+ does; and, when a peer has `approvals: relay`, `approve_agent_action(agent,
1076
+ task_id)` for those peers. Every peer must be a `protocol: a2a` API of the
1077
+ loaded policy, or this raises (the app then fails to start: fail closed).
1078
+ """
1079
+ from langchain.tools import ToolRuntime
1080
+ from langchain_core.tools import tool
1081
+
1082
+ checked = _check_peers(peers)
1083
+ if not checked:
1084
+ return []
1085
+ _PEERS.update(checked)
1086
+ _warn_without_salt()
1087
+ names = tuple(checked)
1088
+ relay_names = tuple(n for n, p in checked.items() if p["approvals"] == "relay")
1089
+ roster = "\n".join(
1090
+ f"- {name}: {peer['description'] or '(no description: set it with `peer add --description`)'}"
1091
+ for name, peer in checked.items()
1092
+ )
1093
+
1094
+ async def ask_agent(agent: str, request: str, runtime: Any) -> str:
1095
+ client = A2APeerClient(agent, runtime=runtime, transport=transport)
1096
+ reply = await client.send(request)
1097
+ return describe(client.peer, reply, relays=checked[client.peer]["approvals"] == "relay")
1098
+
1099
+ ask_agent.__annotations__ = {
1100
+ "agent": Literal[names], # type: ignore[valid-type]
1101
+ "request": str,
1102
+ "runtime": ToolRuntime[Any],
1103
+ "return": str,
1104
+ }
1105
+ ask_agent.__doc__ = ASK_DOC.format(roster=roster)
1106
+ tools: list[Any] = [tool(ask_agent)]
1107
+ if relay_names:
1108
+
1109
+ async def approve_agent_action(agent: str, task_id: str, runtime: Any) -> str:
1110
+ if agent not in relay_names:
1111
+ raise ApiPolicyError(
1112
+ f"this agent does not relay approvals to {agent!r}; relays go to: "
1113
+ f"{', '.join(relay_names)}"
1114
+ )
1115
+ client = A2APeerClient(agent, runtime=runtime, transport=transport)
1116
+ return await client.relay(task_id)
1117
+
1118
+ approve_agent_action.__annotations__ = {
1119
+ "agent": Literal[relay_names], # type: ignore[valid-type]
1120
+ "task_id": str,
1121
+ "runtime": ToolRuntime[Any],
1122
+ "return": str,
1123
+ }
1124
+ approve_agent_action.__doc__ = RELAY_DOC
1125
+ tools.append(tool(approve_agent_action))
1126
+ return tools
1127
+
1128
+
1129
+ def list_agents_tool(
1130
+ peers: Mapping[str, Mapping[str, str]],
1131
+ *,
1132
+ transport: httpx.AsyncBaseTransport | None = None,
1133
+ ) -> Any:
1134
+ """A `list_agents` tool that reads each peer's agent card (for discovery at run time)."""
1135
+ from langchain.tools import ToolRuntime
1136
+ from langchain_core.tools import tool
1137
+
1138
+ checked = _check_peers(peers)
1139
+ _PEERS.update(checked)
1140
+
1141
+ async def list_agents(runtime: Any) -> str:
1142
+ async def one(name: str) -> str:
1143
+ try:
1144
+ card = await A2APeerClient(name, runtime=runtime, transport=transport).card()
1145
+ except (ApiCallError, ApiPolicyError) as exc:
1146
+ return f"- {name}: unavailable ({type(exc).__name__})"
1147
+ return f"- {name}: {card.description or checked[name]['description']}"
1148
+
1149
+ return "\n".join(await asyncio.gather(*(one(name) for name in checked)))
1150
+
1151
+ list_agents.__annotations__ = {"runtime": ToolRuntime[Any], "return": str}
1152
+ list_agents.__doc__ = "List the agents you can ask, with what each says it does (its card)."
1153
+ return tool(list_agents)
1154
+
1155
+
1156
+ __all__ = [
1157
+ "A2APeerClient",
1158
+ "PeerCard",
1159
+ "PeerReply",
1160
+ "PeerRpcError",
1161
+ "client_settings",
1162
+ "context_id_for",
1163
+ "describe",
1164
+ "list_agents_tool",
1165
+ "peer_tools",
1166
+ "reset_a2a_client",
1167
+ ]