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,959 @@
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
+ """RFC 8693 token exchange for `auth: exchange` APIs (another agent, a user-scoped service).
16
+
17
+ An `auth: exchange` API is called with a token the issuer mints for it in
18
+ exchange for the caller's own (the verified bearer the `jwt` policy keeps in
19
+ `credentials["@subject_token"]`, or a custom policy's `keep_subject_token`):
20
+ the API's `exchange.audience`, and its optional `scope` and `resource`, pin
21
+ what the new token is good for, and the issuer names this agent in its `act`
22
+ claim. `api_client` asks for it just before sending, after the policy check,
23
+ the approval gate and the limits, so a refused call, or one paused for a
24
+ person's approval, never exchanges; nothing here runs while a request is
25
+ authenticated.
26
+
27
+ Settings (environment; `TOKEN_EXCHANGE_CLIENT_SECRET` is a secret):
28
+
29
+ * `TOKEN_EXCHANGE_URL`: the issuer's token endpoint. https outside
30
+ `APP_ENV=dev`, unless the host is loopback or `TOKEN_EXCHANGE_ALLOW_HTTP=true`
31
+ (a trusted in-cluster issuer), as for `AUTH_JWT_JWKS_URL`.
32
+ * `TOKEN_EXCHANGE_CLIENT_ID`, `TOKEN_EXCHANGE_CLIENT_SECRET`: this agent's
33
+ client, sent as `TOKEN_EXCHANGE_CLIENT_AUTH` says (`client_secret_basic`,
34
+ the default, or `client_secret_post`).
35
+ * `TOKEN_EXCHANGE_SUBJECT_TOKEN_TYPE`: the `subject_token_type` sent
36
+ (`urn:ietf:params:oauth:token-type:access_token`, the default, or `...:jwt`).
37
+ * `TOKEN_EXCHANGE_TIMEOUT_MS` (2000): the whole exchange's deadline (connecting
38
+ takes at most 1 s of it).
39
+ * `TOKEN_EXCHANGE_MAX_TTL_S` (300, at most 300): how long an exchanged token is
40
+ reused.
41
+ * `TOKEN_EXCHANGE_FAILURE_TTL_S` (10): how long a refusal is remembered, and
42
+ how long the issuer is left alone after it fails.
43
+ * `TOKEN_EXCHANGE_CACHE_MAX` (10000): exchanged tokens kept per process.
44
+
45
+ Behaviour:
46
+
47
+ * Cached in process memory only (never persisted, traced or logged), per
48
+ subject token (its SHA-256), token URL, client, audience, scope and resource,
49
+ for min(`expires_in`, `TOKEN_EXCHANGE_MAX_TTL_S`, the subject token's
50
+ remaining lifetime) less 30 s; a token that would be kept under 5 s is used
51
+ once and not kept. Concurrent calls for one key share one exchange.
52
+ * A subject token with 10 s or less left is not exchanged.
53
+ * The issuer's refusal (a 4xx: `invalid_grant`, `invalid_target`, ...) is
54
+ remembered for that key for `TOKEN_EXCHANGE_FAILURE_TTL_S`.
55
+ * A timeout, a connection error, a 5xx (or 408/429) or an unusable answer
56
+ counts against the token URL's circuit breaker: 3 in a row open it for
57
+ `TOKEN_EXCHANGE_FAILURE_TTL_S`, while calls fail at once; then one call
58
+ probes the issuer, and its outcome closes or reopens it. During an issuer
59
+ outage a process waits at most one deadline per window, on calls to
60
+ exchange APIs only.
61
+ * An issued token that names no actor is refused, and nothing is sent: a JWT
62
+ without the `act` claim (or the claim `AUTH_JWT_ACTOR_CLAIM` names), and
63
+ any token that is not a readable signed JWT (opaque, encrypted). The agent
64
+ behind the API would read it as the user's own unless it sets
65
+ `AUTH_JWT_DIRECT_CLIENTS`, so it could let this agent decide the user's
66
+ approvals there. The refusal is remembered for that key like the issuer's
67
+ own. An API opts in with `exchange.allow_actorless: true` (the agent behind
68
+ it must then set `AUTH_JWT_DIRECT_CLIENTS` and list this agent as
69
+ `client:<its client id>` in `AUTH_ALLOWED_ACTORS`); the first such token
70
+ then logs one warning saying so. The claims are read unverified, for this
71
+ check only (the called agent verifies the token).
72
+ * Metrics: `agent_token_exchanges_total{api, outcome}` (`issued`, `cached`,
73
+ `refused`, `no_actor`, `unavailable`, `circuit_open`) and
74
+ `agent_token_exchange_duration_seconds{api}`; one log line per exchange
75
+ sent, one warning when the breaker opens. Neither carries token material or
76
+ the subject's hash; only the issuer's RFC 6749 `error` code and HTTP status.
77
+ """
78
+
79
+ from __future__ import annotations
80
+
81
+ import asyncio
82
+ import base64
83
+ import hashlib
84
+ import ipaddress
85
+ import json
86
+ import logging
87
+ import os
88
+ import re
89
+ import threading
90
+ import time
91
+ from collections import OrderedDict
92
+ from collections.abc import Callable, Mapping
93
+ from dataclasses import dataclass, field
94
+ from typing import Any
95
+ from urllib.parse import quote, urlsplit
96
+
97
+ import httpx
98
+
99
+ from {{cookiecutter.agent_directory}}.app_utils.limits import SettingsError
100
+
101
+ logger = logging.getLogger(__name__)
102
+
103
+ GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange"
104
+ ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token"
105
+ JWT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt"
106
+ SUBJECT_TOKEN_TYPES = (ACCESS_TOKEN_TYPE, JWT_TOKEN_TYPE)
107
+ CLIENT_SECRET_BASIC = "client_secret_basic"
108
+ CLIENT_SECRET_POST = "client_secret_post"
109
+ CLIENT_AUTH_METHODS = (CLIENT_SECRET_BASIC, CLIENT_SECRET_POST)
110
+
111
+ DEFAULT_TIMEOUT_MS = 2000
112
+ MIN_TIMEOUT_MS = 100
113
+ MAX_TIMEOUT_MS = 60_000
114
+ MAX_CONNECT_S = 1.0
115
+ DEFAULT_MAX_TTL_S = 300
116
+ MAX_TTL_CAP_S = 300
117
+ DEFAULT_FAILURE_TTL_S = 10
118
+ MAX_FAILURE_TTL_S = 300
119
+ DEFAULT_CACHE_MAX = 10_000
120
+ MAX_CACHE_MAX = 1_000_000
121
+ # `expires_in` when the issuer leaves it out (RFC 6749 section 5.1 makes it optional).
122
+ DEFAULT_EXPIRES_IN_S = 60
123
+ # A kept token is dropped this long before it would expire; one that would be kept for
124
+ # less than MIN_CACHE_TTL_S is used for the call that asked and not kept.
125
+ EXPIRY_MARGIN_S = 30
126
+ MIN_CACHE_TTL_S = 5
127
+ # A subject token with this long or less to live is not exchanged.
128
+ MIN_SUBJECT_LIFETIME_S = 10
129
+ RESPONSE_MAX_BYTES = 65_536
130
+ ACCESS_TOKEN_MAX_CHARS = 16_384
131
+ # Consecutive issuer failures that open a token URL's circuit breaker.
132
+ BREAKER_THRESHOLD = 3
133
+ # The RFC 8693 claim naming the agent that presents a token, unless
134
+ # `AUTH_JWT_ACTOR_CLAIM` names another (`auth.DEFAULT_JWT_ACTOR_CLAIM`).
135
+ ACTOR_CLAIM = "act"
136
+
137
+ ISSUED = "issued"
138
+ CACHED = "cached"
139
+ REFUSED = "refused"
140
+ NO_ACTOR = "no_actor"
141
+ UNAVAILABLE = "unavailable"
142
+ CIRCUIT_OPEN = "circuit_open"
143
+ OUTCOMES = (ISSUED, CACHED, REFUSED, NO_ACTOR, UNAVAILABLE, CIRCUIT_OPEN)
144
+ # Kept as a key's refusal when the issued token names no actor: never an RFC 6749 error code
145
+ # (those match `_ERROR_CODE_RE`, which allows no space).
146
+ _NO_ACTOR_CODE = "names no actor"
147
+
148
+ _TRUE = ("1", "true", "yes", "on")
149
+ _LOOPBACK_HOSTS = ("localhost",)
150
+ # The issuer's RFC 6749 error code, when it is one (never other response text).
151
+ _ERROR_CODE_RE = re.compile(r"[A-Za-z0-9_.-]{1,64}")
152
+
153
+
154
+ class TokenExchangeError(Exception):
155
+ """No token for the call: the message is what the tool (and the model) reads.
156
+
157
+ `outcome` is the metric's (`refused`, `no_actor`, `unavailable`,
158
+ `circuit_open`), or None
159
+ when no exchange was attempted (not configured, no or an expiring subject
160
+ token); `reason` is a short fixed phrase for the call's log line.
161
+ """
162
+
163
+ def __init__(self, message: str, *, outcome: str | None, reason: str) -> None:
164
+ super().__init__(message)
165
+ self.outcome = outcome
166
+ self.reason = reason
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Settings
171
+ # ---------------------------------------------------------------------------
172
+
173
+
174
+ def _dev(env: Mapping[str, str]) -> bool:
175
+ return env.get("APP_ENV") == "dev" # exactly, as auth.dev_mode()
176
+
177
+
178
+ def _int(env: Mapping[str, str], name: str, default: int, low: int, high: int) -> int:
179
+ raw = (env.get(name) or "").strip()
180
+ if not raw:
181
+ return default
182
+ try:
183
+ value = int(raw)
184
+ except ValueError:
185
+ raise SettingsError(f"{name}={raw!r} is not a whole number.") from None
186
+ if not low <= value <= high:
187
+ raise SettingsError(f"{name}={value} must be from {low} to {high}.")
188
+ return value
189
+
190
+
191
+ def _loopback(host: str) -> bool:
192
+ host = host.strip("[]").lower()
193
+ if host in _LOOPBACK_HOSTS:
194
+ return True
195
+ try:
196
+ return ipaddress.ip_address(host).is_loopback
197
+ except ValueError:
198
+ return False
199
+
200
+
201
+ @dataclass(frozen=True)
202
+ class ExchangeSettings:
203
+ """The `TOKEN_EXCHANGE_*` settings, validated (`exchange_settings`)."""
204
+
205
+ url: str | None = None
206
+ client_id: str | None = None
207
+ # Out of repr: a repr ends up in logs and tracebacks.
208
+ client_secret: str | None = field(default=None, repr=False)
209
+ client_auth: str = CLIENT_SECRET_BASIC
210
+ subject_token_type: str = ACCESS_TOKEN_TYPE
211
+ timeout_s: float = DEFAULT_TIMEOUT_MS / 1000
212
+ max_ttl_s: int = DEFAULT_MAX_TTL_S
213
+ failure_ttl_s: int = DEFAULT_FAILURE_TTL_S
214
+ cache_max: int = DEFAULT_CACHE_MAX
215
+
216
+ def missing(self) -> list[str]:
217
+ """The settings an exchange needs that are not set."""
218
+ return [
219
+ name
220
+ for name, value in (
221
+ ("TOKEN_EXCHANGE_URL", self.url),
222
+ ("TOKEN_EXCHANGE_CLIENT_ID", self.client_id),
223
+ ("TOKEN_EXCHANGE_CLIENT_SECRET", self.client_secret),
224
+ )
225
+ if not value
226
+ ]
227
+
228
+
229
+ def exchange_settings(env: Mapping[str, str] | None = None) -> ExchangeSettings:
230
+ """The token exchange settings; `SettingsError` names a bad one (a startup error).
231
+
232
+ Unset settings are not errors here (`ExchangeSettings.missing`, reported at
233
+ startup when an `auth: exchange` API exists); a malformed value, or an http
234
+ `TOKEN_EXCHANGE_URL` outside `APP_ENV=dev` without a loopback host or
235
+ `TOKEN_EXCHANGE_ALLOW_HTTP=true`, is.
236
+ """
237
+ env = os.environ if env is None else env
238
+ url = (env.get("TOKEN_EXCHANGE_URL") or "").strip() or None
239
+ if url is not None:
240
+ parts = urlsplit(url)
241
+ host = parts.hostname or ""
242
+ if parts.scheme not in ("https", "http") or not host:
243
+ raise SettingsError(
244
+ "TOKEN_EXCHANGE_URL must be an https:// URL (the issuer's token endpoint)."
245
+ )
246
+ if parts.username or parts.password or parts.fragment:
247
+ raise SettingsError("TOKEN_EXCHANGE_URL must not carry credentials or a fragment.")
248
+ allow_http = (env.get("TOKEN_EXCHANGE_ALLOW_HTTP") or "").strip().lower() in _TRUE
249
+ if parts.scheme == "http" and not (_dev(env) or _loopback(host) or allow_http):
250
+ raise SettingsError(
251
+ "TOKEN_EXCHANGE_URL must use https outside APP_ENV=dev (it carries the users' "
252
+ "tokens and this agent's client secret); set TOKEN_EXCHANGE_ALLOW_HTTP=true only "
253
+ "for a trusted in-cluster issuer."
254
+ )
255
+ client_auth = (env.get("TOKEN_EXCHANGE_CLIENT_AUTH") or CLIENT_SECRET_BASIC).strip().lower()
256
+ if client_auth not in CLIENT_AUTH_METHODS:
257
+ raise SettingsError(
258
+ f"TOKEN_EXCHANGE_CLIENT_AUTH={client_auth!r} must be one of "
259
+ f"{', '.join(CLIENT_AUTH_METHODS)}."
260
+ )
261
+ token_type = (env.get("TOKEN_EXCHANGE_SUBJECT_TOKEN_TYPE") or ACCESS_TOKEN_TYPE).strip()
262
+ if token_type not in SUBJECT_TOKEN_TYPES:
263
+ raise SettingsError(
264
+ f"TOKEN_EXCHANGE_SUBJECT_TOKEN_TYPE={token_type!r} must be one of "
265
+ f"{', '.join(SUBJECT_TOKEN_TYPES)}."
266
+ )
267
+ return ExchangeSettings(
268
+ url=url,
269
+ client_id=(env.get("TOKEN_EXCHANGE_CLIENT_ID") or "").strip() or None,
270
+ client_secret=env.get("TOKEN_EXCHANGE_CLIENT_SECRET") or None,
271
+ client_auth=client_auth,
272
+ subject_token_type=token_type,
273
+ timeout_s=_int(
274
+ env, "TOKEN_EXCHANGE_TIMEOUT_MS", DEFAULT_TIMEOUT_MS, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS
275
+ )
276
+ / 1000,
277
+ max_ttl_s=_int(env, "TOKEN_EXCHANGE_MAX_TTL_S", DEFAULT_MAX_TTL_S, 1, MAX_TTL_CAP_S),
278
+ failure_ttl_s=_int(
279
+ env, "TOKEN_EXCHANGE_FAILURE_TTL_S", DEFAULT_FAILURE_TTL_S, 1, MAX_FAILURE_TTL_S
280
+ ),
281
+ cache_max=_int(env, "TOKEN_EXCHANGE_CACHE_MAX", DEFAULT_CACHE_MAX, 1, MAX_CACHE_MAX),
282
+ )
283
+
284
+
285
+ # ---------------------------------------------------------------------------
286
+ # Which APIs act for the caller, and where (the compatibility matrix)
287
+ # ---------------------------------------------------------------------------
288
+
289
+ SHARED_BEARER = "shared-bearer"
290
+ JWT = "jwt"
291
+ FASTAPI = "fastapi"
292
+ LANGGRAPH_SERVER = "langgraph-server"
293
+
294
+
295
+ def server_runtime(env: Mapping[str, str] | None = None) -> str:
296
+ """`langgraph-server` or `fastapi`, read as `chat.detect_runtime` reads it."""
297
+ env = os.environ if env is None else env
298
+ explicit = (env.get("RUNTIME") or "").strip().lower()
299
+ if explicit in (FASTAPI, LANGGRAPH_SERVER):
300
+ return explicit
301
+ if (env.get("LANGGRAPH_SERVER") or "").lower() in ("1", "true", "yes"):
302
+ return LANGGRAPH_SERVER
303
+ if env.get("LANGSERVE_GRAPHS"):
304
+ return LANGGRAPH_SERVER
305
+ return FASTAPI
306
+
307
+
308
+ def _policy_apis() -> dict[str, dict[str, Any]]:
309
+ """The loaded api-policy's APIs; empty without a readable, valid policy."""
310
+ try:
311
+ from {{cookiecutter.agent_directory}}.app_utils.api_client import load_policy
312
+
313
+ return load_policy().apis
314
+ except Exception:
315
+ return {}
316
+
317
+
318
+ def _names(apis: Mapping[str, Mapping[str, Any]], test: Callable[[Mapping[str, Any]], bool]) -> str:
319
+ return ", ".join(name for name, api in apis.items() if test(api))
320
+
321
+
322
+ def compatibility(
323
+ auth_policy: str,
324
+ runtime: str,
325
+ apis: Mapping[str, Mapping[str, Any]] | None = None,
326
+ ) -> list[str]:
327
+ """The api-policy's identity-carrying APIs against this auth policy and runtime.
328
+
329
+ The problems (the startup refuses them outside `APP_ENV=dev` and logs them
330
+ under dev; `lint` and `api add` report the same cells as errors): an
331
+ `auth: exchange` API under `shared-bearer` (no user token to exchange) or
332
+ under langgraph-server (the server persists the run context, so the user's
333
+ token would be stored), and an `auth: forward` API under `shared-bearer`,
334
+ under `jwt` without `forward_audience`, or under langgraph-server, whose
335
+ every call fails for want of a credential.
336
+ """
337
+ apis = _policy_apis() if apis is None else apis
338
+ problems: list[str] = []
339
+ exchange = _names(apis, lambda a: a.get("auth") == "exchange")
340
+ forward = _names(apis, lambda a: a.get("auth") == "forward")
341
+ if exchange and auth_policy == SHARED_BEARER:
342
+ problems.append(
343
+ f"auth: exchange (apis: {exchange}) under AUTH_POLICY=shared-bearer: shared-bearer "
344
+ "has no user token to exchange; use auth: bearer with the peer's agent key"
345
+ )
346
+ if exchange and runtime == LANGGRAPH_SERVER:
347
+ problems.append(
348
+ f"auth: exchange (apis: {exchange}) is not supported with runtime langgraph-server: "
349
+ "LangGraph Server persists the run context, so the user's token would be stored"
350
+ )
351
+ if forward and auth_policy == SHARED_BEARER:
352
+ problems.append(
353
+ f"auth: forward (apis: {forward}) under AUTH_POLICY=shared-bearer: there is no user "
354
+ "credential to forward, so every call to it fails"
355
+ )
356
+ unaimed = _names(apis, lambda a: a.get("auth") == "forward" and "forward_audience" not in a)
357
+ if unaimed and auth_policy == JWT:
358
+ problems.append(
359
+ f"auth: forward (apis: {unaimed}) under AUTH_POLICY=jwt without forward_audience: jwt "
360
+ "sets no per-API credential, so every call to it fails; set forward_audience (the "
361
+ "caller's token must be minted for that audience too), or prefer auth: exchange"
362
+ )
363
+ if forward and runtime == LANGGRAPH_SERVER:
364
+ problems.append(
365
+ f"auth: forward (apis: {forward}) is not supported with runtime langgraph-server: "
366
+ "the server's run context carries no credentials, so every call to it fails"
367
+ )
368
+ return problems
369
+
370
+
371
+ def startup_problems(auth_policy: str, runtime: str | None = None) -> list[str]:
372
+ """What stops startup outside `APP_ENV=dev` (`auth.check_startup`, which logs it
373
+ under dev): the refused cells of the compatibility matrix, and an
374
+ `auth: exchange` API without the issuer's token endpoint or this agent's
375
+ client."""
376
+ apis = _policy_apis()
377
+ problems = compatibility(auth_policy, runtime or server_runtime(), apis)
378
+ exchange = _names(apis, lambda a: a.get("auth") == "exchange")
379
+ if exchange:
380
+ try:
381
+ missing = exchange_settings().missing()
382
+ except SettingsError:
383
+ missing = [] # the lifespan's settings check names the bad value
384
+ if missing:
385
+ problems.append(
386
+ f"auth: exchange (apis: {exchange}) needs {', '.join(missing)} (the issuer's "
387
+ "token endpoint and this agent's client there)"
388
+ )
389
+ return problems
390
+
391
+
392
+ # ---------------------------------------------------------------------------
393
+ # Loops
394
+ # ---------------------------------------------------------------------------
395
+
396
+ # How `jwt` names an agent known only by its token's client (`auth.CLIENT_ACTOR_PREFIX`).
397
+ CLIENT_ACTOR_PREFIX = "client:"
398
+
399
+
400
+ def own_names(env: Mapping[str, str] | None = None) -> set[str]:
401
+ """What names this agent: its A2A name, and the audiences its own tokens carry."""
402
+ env = os.environ if env is None else env
403
+ names = {env.get("A2A_NAME") or "{{cookiecutter.agent_directory}}"}
404
+ names.update(p.strip() for p in (env.get("AUTH_JWT_AUDIENCE") or "").split(",") if p.strip())
405
+ return names
406
+
407
+
408
+ def loop_problem(target: str, actor_chain: tuple[str, ...]) -> str | None:
409
+ """Why a call to the agent `target` (an audience) would loop, or None.
410
+
411
+ It would when `target` is this agent (its A2A name or one of its own
412
+ audiences), or an agent already in the delegation chain of the request
413
+ (`actor_chain`, current first): A -> B -> A. An actor the policy knows only
414
+ by its client (`client:<azp>`, an issuer that names no actor) counts by
415
+ that client's name.
416
+ """
417
+ me = os.environ.get("A2A_NAME") or "{{cookiecutter.agent_directory}}"
418
+ if target in own_names():
419
+ return f"calling {target} would call this agent itself ({me})"
420
+ if target in actor_chain or f"{CLIENT_ACTOR_PREFIX}{target}" in actor_chain:
421
+ path = " -> ".join([*reversed(actor_chain), me, target])
422
+ return f"calling {target} would loop back through the delegation chain ({path})"
423
+ return None
424
+
425
+
426
+ # ---------------------------------------------------------------------------
427
+ # The exchange
428
+ # ---------------------------------------------------------------------------
429
+
430
+
431
+ def _observe(api: str, outcome: str, seconds: float | None = None) -> None:
432
+ try:
433
+ from {{cookiecutter.agent_directory}}.app_utils.metrics import observe_token_exchange
434
+ except ImportError: # loaded outside its package, or without prometheus_client
435
+ return
436
+ observe_token_exchange(api, outcome, seconds)
437
+
438
+
439
+ def _client_auth(settings: ExchangeSettings) -> tuple[dict[str, str], dict[str, str]]:
440
+ """(headers, form fields) that authenticate this agent's client (RFC 6749 section 2.3.1)."""
441
+ client_id, secret = settings.client_id or "", settings.client_secret or ""
442
+ if settings.client_auth == CLIENT_SECRET_POST:
443
+ return {}, {"client_id": client_id, "client_secret": secret}
444
+ # Each part form-encoded first, then base64 (RFC 6749 section 2.3.1).
445
+ pair = f"{quote(client_id, safe='')}:{quote(secret, safe='')}"
446
+ return {"Authorization": "Basic " + base64.b64encode(pair.encode("utf-8")).decode("ascii")}, {}
447
+
448
+
449
+ @dataclass
450
+ class _Breaker:
451
+ """A token URL's circuit breaker: consecutive failures, open until, one probe."""
452
+
453
+ failures: int = 0
454
+ open_until: float | None = None
455
+ # When the call probing the issuer (half open) started; None when none is.
456
+ probing: float | None = None
457
+
458
+
459
+ class _Unavailable(Exception):
460
+ def __init__(self, detail: str) -> None:
461
+ super().__init__(detail)
462
+ self.detail = detail
463
+
464
+
465
+ class _Refused(Exception):
466
+ def __init__(self, code: str) -> None:
467
+ super().__init__(code)
468
+ self.code = code
469
+
470
+
471
+ class TokenExchanger:
472
+ """The process's exchanged tokens, refusals, in-flight exchanges and breakers."""
473
+
474
+ def __init__(
475
+ self,
476
+ *,
477
+ clock: Callable[[], float] = time.monotonic,
478
+ wall: Callable[[], float] = time.time,
479
+ transport: httpx.AsyncBaseTransport | None = None,
480
+ ) -> None:
481
+ self._clock = clock
482
+ self._wall = wall
483
+ self._transport = transport
484
+ self._lock = threading.Lock()
485
+ # key -> (token, drop at, on self._clock)
486
+ self._tokens: OrderedDict[tuple[str, ...], tuple[str, float]] = OrderedDict()
487
+ # key -> (the issuer's error code, forget at)
488
+ self._refusals: OrderedDict[tuple[str, ...], tuple[str, float]] = OrderedDict()
489
+ self._inflight: dict[tuple[str, ...], asyncio.Task[str]] = {}
490
+ self._breakers: dict[str, _Breaker] = {}
491
+ # Whether the warning for an allowed token that names no actor was logged.
492
+ self._warned_no_actor = False
493
+
494
+ def clear(self) -> None:
495
+ """Forget every token, refusal and breaker (tests)."""
496
+ with self._lock:
497
+ self._tokens.clear()
498
+ self._refusals.clear()
499
+ self._breakers.clear()
500
+ self._warned_no_actor = False
501
+
502
+ def cached(self) -> int:
503
+ """How many exchanged tokens are kept."""
504
+ with self._lock:
505
+ return len(self._tokens)
506
+
507
+ async def token(
508
+ self,
509
+ api: str,
510
+ subject_token: str,
511
+ *,
512
+ audience: str,
513
+ scope: str | None = None,
514
+ resource: str | None = None,
515
+ subject_expires_at: float | None = None,
516
+ settings: ExchangeSettings | None = None,
517
+ allow_actorless: bool = False,
518
+ ) -> str:
519
+ """A token for `api` exchanged for `subject_token`; `TokenExchangeError` otherwise.
520
+
521
+ `subject_expires_at` is the subject token's `exp` (epoch seconds), when known.
522
+ `allow_actorless` (the API's `exchange.allow_actorless`) accepts a token
523
+ that names no actor; without it such a token is refused (`no_actor`).
524
+ """
525
+ if settings is None:
526
+ try:
527
+ settings = exchange_settings()
528
+ except SettingsError as exc:
529
+ raise TokenExchangeError(
530
+ f"API {api!r} uses auth: exchange, but the token exchange settings are "
531
+ f"invalid ({exc}); nothing was sent.",
532
+ outcome=None,
533
+ reason="token exchange misconfigured",
534
+ ) from None
535
+ missing = settings.missing()
536
+ if missing:
537
+ raise TokenExchangeError(
538
+ f"API {api!r} uses auth: exchange, but {', '.join(missing)} "
539
+ f"{'is' if len(missing) == 1 else 'are'} not set; nothing was sent.",
540
+ outcome=None,
541
+ reason="token exchange not configured",
542
+ )
543
+ if (
544
+ subject_expires_at is not None
545
+ and subject_expires_at - self._wall() <= MIN_SUBJECT_LIFETIME_S
546
+ ):
547
+ raise TokenExchangeError(
548
+ f"API {api!r} uses auth: exchange, but the caller's token has expired; nothing "
549
+ "was sent.",
550
+ outcome=None,
551
+ reason="caller's token expired",
552
+ )
553
+ assert settings.url is not None and settings.client_id is not None
554
+ key = (
555
+ hashlib.sha256(subject_token.encode("utf-8")).hexdigest(),
556
+ settings.url,
557
+ settings.client_id,
558
+ audience,
559
+ scope or "",
560
+ resource or "",
561
+ # A token kept for an API that accepts one naming no actor is never handed to
562
+ # an API that does not.
563
+ "allow_actorless" if allow_actorless else "",
564
+ )
565
+ now = self._clock()
566
+ with self._lock:
567
+ kept = self._tokens.get(key)
568
+ if kept is not None and kept[1] > now:
569
+ self._tokens.move_to_end(key)
570
+ _observe(api, CACHED)
571
+ return kept[0]
572
+ if kept is not None:
573
+ del self._tokens[key]
574
+ refusal = self._refusals.get(key)
575
+ if refusal is not None and refusal[1] <= now:
576
+ del self._refusals[key]
577
+ refusal = None
578
+ if refusal is not None:
579
+ if refusal[0] == _NO_ACTOR_CODE:
580
+ _observe(api, NO_ACTOR)
581
+ raise self._no_actor_error(api)
582
+ _observe(api, REFUSED)
583
+ raise self._refused_error(api, refusal[0])
584
+
585
+ task = self._inflight.get(key)
586
+ joined = (
587
+ task is not None and not task.done() and task.get_loop() is asyncio.get_running_loop()
588
+ )
589
+ if not joined:
590
+ self._admit(api, settings) # the breaker: raises while it is open
591
+ task = asyncio.get_running_loop().create_task(
592
+ self._exchange(
593
+ api,
594
+ key,
595
+ subject_token,
596
+ audience,
597
+ scope,
598
+ resource,
599
+ subject_expires_at,
600
+ settings,
601
+ allow_actorless,
602
+ )
603
+ )
604
+ self._inflight[key] = task
605
+ task.add_done_callback(lambda done, k=key: self._forget_inflight(k, done))
606
+ assert task is not None
607
+ try:
608
+ # Shielded: a caller that goes away does not cancel the exchange others share.
609
+ token = await asyncio.shield(task)
610
+ except TokenExchangeError as exc:
611
+ if joined and exc.outcome:
612
+ _observe(api, exc.outcome)
613
+ raise
614
+ if joined:
615
+ _observe(api, CACHED)
616
+ return token
617
+
618
+ def _forget_inflight(self, key: tuple[str, ...], task: asyncio.Task[Any]) -> None:
619
+ if self._inflight.get(key) is task:
620
+ del self._inflight[key]
621
+ if task.cancelled():
622
+ # Cancelled, perhaps before it ran (its loop closed): it probes nothing any more.
623
+ with self._lock:
624
+ breaker = self._breakers.get(key[1])
625
+ if breaker is not None:
626
+ breaker.probing = None
627
+ else:
628
+ task.exception() # retrieved: a failed exchange nobody awaits is not "never retrieved"
629
+
630
+ def _admit(self, api: str, settings: ExchangeSettings) -> None:
631
+ """Let an exchange go to the issuer, or fail at once while its breaker is open."""
632
+ assert settings.url is not None
633
+ now = self._clock()
634
+ with self._lock:
635
+ breaker = self._breakers.setdefault(settings.url, _Breaker())
636
+ if breaker.open_until is None:
637
+ return
638
+ # A probe that has run past its deadline is gone (its loop closed): probe again.
639
+ probing = (
640
+ breaker.probing is not None and now - breaker.probing <= settings.timeout_s + 1
641
+ )
642
+ if now < breaker.open_until or probing:
643
+ wait = max(1, round(max(breaker.open_until, now + 1) - now))
644
+ else:
645
+ breaker.probing = now # half open: this call probes the issuer
646
+ return
647
+ _observe(api, CIRCUIT_OPEN)
648
+ raise TokenExchangeError(
649
+ f"token issuer unavailable (retry in {wait} s); nothing was sent to API {api!r}.",
650
+ outcome=CIRCUIT_OPEN,
651
+ reason="token issuer unavailable (circuit open)",
652
+ )
653
+
654
+ def _settle(self, settings: ExchangeSettings, *, failed: bool) -> None:
655
+ """Record the issuer's answer (or its failure) on the token URL's breaker."""
656
+ assert settings.url is not None
657
+ opened = False
658
+ with self._lock:
659
+ breaker = self._breakers.setdefault(settings.url, _Breaker())
660
+ if not failed:
661
+ breaker.failures, breaker.open_until, breaker.probing = 0, None, None
662
+ return
663
+ breaker.failures += 1
664
+ if breaker.probing is not None or breaker.failures >= BREAKER_THRESHOLD:
665
+ opened = breaker.open_until is None
666
+ breaker.open_until = self._clock() + settings.failure_ttl_s
667
+ breaker.probing = None
668
+ if opened:
669
+ logger.warning(
670
+ "token exchange: the issuer failed %d times in a row; calls to auth: exchange "
671
+ "APIs fail at once for %d s, then one call tries it again",
672
+ BREAKER_THRESHOLD,
673
+ settings.failure_ttl_s,
674
+ )
675
+
676
+ @staticmethod
677
+ def _no_actor_error(api: str) -> TokenExchangeError:
678
+ return TokenExchangeError(
679
+ f"token exchange for API {api!r}: the issuer's token names no actor (no "
680
+ f"{actor_claim()} claim, or not a readable JWT), so the agent behind the API would "
681
+ "take this agent's call for the user's own; nothing was sent. Have the issuer name "
682
+ "this agent in the token, or set exchange.allow_actorless: true for the API once "
683
+ "that agent sets AUTH_JWT_DIRECT_CLIENTS.",
684
+ outcome=NO_ACTOR,
685
+ reason="exchanged token names no actor",
686
+ )
687
+
688
+ @staticmethod
689
+ def _refused_error(api: str, code: str) -> TokenExchangeError:
690
+ return TokenExchangeError(
691
+ f"token exchange for API {api!r} was refused ({code}); nothing was sent.",
692
+ outcome=REFUSED,
693
+ reason=f"token exchange refused ({code})",
694
+ )
695
+
696
+ async def _exchange(
697
+ self,
698
+ api: str,
699
+ key: tuple[str, ...],
700
+ subject_token: str,
701
+ audience: str,
702
+ scope: str | None,
703
+ resource: str | None,
704
+ subject_expires_at: float | None,
705
+ settings: ExchangeSettings,
706
+ allow_actorless: bool = False,
707
+ ) -> str:
708
+ started = time.perf_counter()
709
+ try:
710
+ token, expires_in = await asyncio.wait_for(
711
+ self._post(subject_token, audience, scope, resource, settings),
712
+ timeout=settings.timeout_s,
713
+ )
714
+ except _Refused as exc:
715
+ self._settle(settings, failed=False) # the issuer answered
716
+ with self._lock:
717
+ self._refusals[key] = (exc.code, self._clock() + settings.failure_ttl_s)
718
+ self._refusals.move_to_end(key)
719
+ while len(self._refusals) > settings.cache_max:
720
+ self._refusals.popitem(last=False)
721
+ self._log(api, audience, f"refused ({exc.code})", started)
722
+ _observe(api, REFUSED, time.perf_counter() - started)
723
+ raise self._refused_error(api, exc.code) from None
724
+ except (_Unavailable, TimeoutError, httpx.HTTPError) as exc:
725
+ detail = (
726
+ exc.detail
727
+ if isinstance(exc, _Unavailable)
728
+ else "timed out"
729
+ if isinstance(exc, TimeoutError | httpx.TimeoutException)
730
+ else type(exc).__name__
731
+ )
732
+ self._settle(settings, failed=True)
733
+ self._log(api, audience, f"unavailable ({detail})", started, failed=True)
734
+ _observe(api, UNAVAILABLE, time.perf_counter() - started)
735
+ raise TokenExchangeError(
736
+ f"token issuer unavailable ({detail}); nothing was sent to API {api!r}.",
737
+ outcome=UNAVAILABLE,
738
+ reason=f"token issuer unavailable ({detail})",
739
+ ) from None
740
+ except BaseException:
741
+ # Cancelled (the loop is closing), or a bug: never leave a probe marked running.
742
+ with self._lock:
743
+ breaker = self._breakers.get(settings.url or "")
744
+ if breaker is not None:
745
+ breaker.probing = None
746
+ raise
747
+ self._settle(settings, failed=False)
748
+ claim = actor_claim()
749
+ try:
750
+ unnamed = names_no_actor(token, claim)
751
+ except Exception: # a bug while reading the claims: judged as naming no actor
752
+ unnamed = True
753
+ if unnamed and not allow_actorless:
754
+ with self._lock:
755
+ self._refusals[key] = (_NO_ACTOR_CODE, self._clock() + settings.failure_ttl_s)
756
+ self._refusals.move_to_end(key)
757
+ while len(self._refusals) > settings.cache_max:
758
+ self._refusals.popitem(last=False)
759
+ self._log(api, audience, "refused (the token names no actor)", started)
760
+ _observe(api, NO_ACTOR, time.perf_counter() - started)
761
+ raise self._no_actor_error(api)
762
+ self._keep(key, token, expires_in, subject_expires_at, settings)
763
+ self._log(api, audience, "issued", started)
764
+ _observe(api, ISSUED, time.perf_counter() - started)
765
+ if unnamed:
766
+ self._warn_actorless(api, audience, claim, settings)
767
+ return token
768
+
769
+ def _warn_actorless(
770
+ self, api: str, audience: str, claim: str, settings: ExchangeSettings
771
+ ) -> None:
772
+ """Warn once per process when an API that allows it gets a token naming no actor.
773
+
774
+ The agent behind the API reads this agent's calls as the user's own
775
+ (it could let this agent decide the user's approvals) unless it sets
776
+ `AUTH_JWT_DIRECT_CLIENTS`; this agent cannot check that it does.
777
+ """
778
+ with self._lock:
779
+ if self._warned_no_actor:
780
+ return
781
+ self._warned_no_actor = True
782
+ logger.warning(
783
+ "token exchange: the token the issuer minted for %s (audience %s) names no actor "
784
+ "(no %s claim), and exchange.allow_actorless lets it through: the agent behind it "
785
+ "reads this agent's calls as the user's own, and may let this agent decide the "
786
+ "user's approvals there, unless it sets AUTH_JWT_DIRECT_CLIENTS to the clients "
787
+ "people sign in with and lists client:%s in AUTH_ALLOWED_ACTORS",
788
+ api,
789
+ audience,
790
+ claim,
791
+ settings.client_id,
792
+ )
793
+
794
+ def _keep(
795
+ self,
796
+ key: tuple[str, ...],
797
+ token: str,
798
+ expires_in: int,
799
+ subject_expires_at: float | None,
800
+ settings: ExchangeSettings,
801
+ ) -> None:
802
+ """Keep the token for min(expires_in, the cap, the subject's lifetime) less the margin."""
803
+ lifetime = float(min(expires_in, settings.max_ttl_s))
804
+ if subject_expires_at is not None:
805
+ lifetime = min(lifetime, subject_expires_at - self._wall())
806
+ ttl = lifetime - EXPIRY_MARGIN_S
807
+ if ttl < MIN_CACHE_TTL_S:
808
+ return
809
+ with self._lock:
810
+ self._tokens[key] = (token, self._clock() + ttl)
811
+ self._tokens.move_to_end(key)
812
+ while len(self._tokens) > settings.cache_max:
813
+ self._tokens.popitem(last=False)
814
+
815
+ def _log(
816
+ self, api: str, audience: str, outcome: str, started: float, *, failed: bool = False
817
+ ) -> None:
818
+ latency_ms = int((time.perf_counter() - started) * 1000)
819
+ logger.log(
820
+ logging.WARNING if failed else logging.INFO,
821
+ "token exchange for %s (audience %s): %s (%d ms)",
822
+ api,
823
+ audience,
824
+ outcome,
825
+ latency_ms,
826
+ extra={"api": api, "outcome": outcome, "latency_ms": latency_ms},
827
+ )
828
+
829
+ async def _post(
830
+ self,
831
+ subject_token: str,
832
+ audience: str,
833
+ scope: str | None,
834
+ resource: str | None,
835
+ settings: ExchangeSettings,
836
+ ) -> tuple[str, int]:
837
+ """POST the RFC 8693 request; `(access_token, expires_in)`, or `_Refused`/`_Unavailable`."""
838
+ headers, auth_fields = _client_auth(settings)
839
+ form = {
840
+ "grant_type": GRANT_TYPE,
841
+ "subject_token": subject_token,
842
+ "subject_token_type": settings.subject_token_type,
843
+ "requested_token_type": ACCESS_TOKEN_TYPE,
844
+ "audience": audience,
845
+ **({"scope": scope} if scope else {}),
846
+ **({"resource": resource} if resource else {}),
847
+ **auth_fields,
848
+ }
849
+ timeout = httpx.Timeout(settings.timeout_s, connect=min(MAX_CONNECT_S, settings.timeout_s))
850
+ body = bytearray()
851
+ assert settings.url is not None
852
+ async with httpx.AsyncClient(
853
+ transport=self._transport, timeout=timeout, follow_redirects=False
854
+ ) as client:
855
+ async with client.stream(
856
+ "POST",
857
+ settings.url,
858
+ data=form,
859
+ headers={**headers, "Accept": "application/json"},
860
+ ) as response:
861
+ status = response.status_code
862
+ async for chunk in response.aiter_bytes():
863
+ body += chunk
864
+ if len(body) > RESPONSE_MAX_BYTES:
865
+ raise _Unavailable("unusable issuer response: too large")
866
+ if 400 <= status < 500 and status not in (408, 429):
867
+ raise _Refused(_error_code(bytes(body)) or f"HTTP {status}")
868
+ if status != 200:
869
+ raise _Unavailable(f"HTTP {status}")
870
+ return _issued(bytes(body))
871
+
872
+
873
+ def _error_code(body: bytes) -> str | None:
874
+ """The RFC 6749 `error` code of an error response, when it is one."""
875
+ try:
876
+ data = json.loads(body)
877
+ except ValueError:
878
+ return None
879
+ code = data.get("error") if isinstance(data, dict) else None
880
+ return code if isinstance(code, str) and _ERROR_CODE_RE.fullmatch(code) else None
881
+
882
+
883
+ def _issued(body: bytes) -> tuple[str, int]:
884
+ """`(access_token, expires_in)` of a 200 answer, or `_Unavailable` (an unusable one)."""
885
+ try:
886
+ data = json.loads(body)
887
+ except ValueError:
888
+ raise _Unavailable("unusable issuer response: not JSON") from None
889
+ if not isinstance(data, dict):
890
+ raise _Unavailable("unusable issuer response: not a JSON object")
891
+ token = data.get("access_token")
892
+ if not isinstance(token, str) or not token or len(token) > ACCESS_TOKEN_MAX_CHARS:
893
+ raise _Unavailable("unusable issuer response: access_token")
894
+ token_type = data.get("token_type")
895
+ if not isinstance(token_type, str) or token_type.lower() != "bearer":
896
+ raise _Unavailable("unusable issuer response: token_type is not Bearer")
897
+ issued_type = data.get("issued_token_type")
898
+ if issued_type is not None and issued_type != ACCESS_TOKEN_TYPE:
899
+ raise _Unavailable("unusable issuer response: issued_token_type is not an access token")
900
+ expires_in = data.get("expires_in", DEFAULT_EXPIRES_IN_S)
901
+ if not isinstance(expires_in, int) or isinstance(expires_in, bool) or expires_in <= 0:
902
+ raise _Unavailable("unusable issuer response: expires_in")
903
+ return token, expires_in
904
+
905
+
906
+ def actor_claim(env: Mapping[str, str] | None = None) -> str:
907
+ """The claim naming the agent that presents a token: `AUTH_JWT_ACTOR_CLAIM`, else `act`."""
908
+ env = os.environ if env is None else env
909
+ return (env.get("AUTH_JWT_ACTOR_CLAIM") or "").strip() or ACTOR_CLAIM
910
+
911
+
912
+ def names_no_actor(token: str, claim: str = ACTOR_CLAIM) -> bool:
913
+ """Whether `token` names no actor: it is not a readable signed JWT, or its claims lack `claim`.
914
+
915
+ `claim` is a dotted path, read as `jwt` reads it (a top-level claim of that
916
+ exact name first; a null value counts as present: the called agent refuses
917
+ it). The claims are read without verifying the token (the agent it is sent
918
+ to does): only to tell whether the issuer names the agent presenting it. A
919
+ token that cannot be read (opaque, encrypted, not base64url or JSON) names
920
+ no actor as far as this agent can tell: True.
921
+ """
922
+ parts = token.split(".")
923
+ if len(parts) != 3 or not parts[1]:
924
+ return True
925
+ try:
926
+ payload = base64.urlsafe_b64decode(parts[1] + "=" * (-len(parts[1]) % 4))
927
+ claims = json.loads(payload)
928
+ except (ValueError, RecursionError): # not base64url, UTF-8 or JSON; nested too deep
929
+ return True
930
+ if not isinstance(claims, dict):
931
+ return True
932
+ if claim in claims:
933
+ return False
934
+ node: Any = claims
935
+ for part in claim.split("."):
936
+ if not isinstance(node, dict) or part not in node:
937
+ return True
938
+ node = node[part]
939
+ return False
940
+
941
+
942
+ _exchanger = TokenExchanger()
943
+
944
+
945
+ def exchanger() -> TokenExchanger:
946
+ """The process's token exchanger."""
947
+ return _exchanger
948
+
949
+
950
+ def reset_token_exchange(
951
+ *,
952
+ clock: Callable[[], float] = time.monotonic,
953
+ wall: Callable[[], float] = time.time,
954
+ transport: httpx.AsyncBaseTransport | None = None,
955
+ ) -> TokenExchanger:
956
+ """Start from an empty exchanger (tests); returns it."""
957
+ global _exchanger
958
+ _exchanger = TokenExchanger(clock=clock, wall=wall, transport=transport)
959
+ return _exchanger