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,650 @@
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
+ """`auth: exchange` against an RFC 8693 issuer over HTTP (`fake_issuer.py`, loopback).
16
+
17
+ * Conformance: what `token_exchange` sends is what the issuer accepts (Basic and
18
+ `client_secret_post` client authentication, audience, scope, resource); the
19
+ token it gets back verifies at the callee as the user presented by this agent
20
+ (`act`); the issuer's refusals (an audience the client may not act for, a
21
+ service's own token, a wrong secret) reach the call as refusals. An issuer
22
+ that names no actor: its tokens are refused unless the API sets
23
+ `exchange.allow_actorless` (then one warning), over `/chat` too.
24
+ * Depth: nested exchanges carry the chain the callee's `AUTH_MAX_DELEGATION_DEPTH`
25
+ reads, hop by hop.
26
+ * An issuer that hangs: each call waits at most the deadline, the breaker then
27
+ fails calls at once, and the first call after the window recovers.
28
+ * This app as the concierge, end to end over `/chat` with alice's token: the
29
+ exchange happens in the tool call, never while authenticating; once per user
30
+ and API; not at all for a run that calls no such API; for a gated call only
31
+ once the person approved; and neither alice's token nor the exchanged one is
32
+ in the logs or (with `TEST_POSTGRES_DSN`) in any table of the database.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import asyncio
38
+ import json
39
+ import logging
40
+ import os
41
+ import time
42
+ import uuid
43
+ from collections.abc import AsyncIterator, Iterator
44
+ from pathlib import Path
45
+ from typing import Any
46
+ from urllib.parse import urlsplit
47
+
48
+ # The environment must be in place before the app (and the graph) is imported.
49
+ os.environ.update(
50
+ {
51
+ "MODEL_PROVIDER": "fake",
52
+ "MODEL_NAME": "fake",
53
+ "CHECKPOINTER": "memory",
54
+ "AUTH_POLICY": "shared-bearer",
55
+ "API_KEY": "test-key",
56
+ "APP_ENV": "dev",
57
+ "TRACING_ENABLED": "false",
58
+ "RUNTIME": "fastapi",
59
+ "APP_URL": "http://testserver",
60
+ }
61
+ )
62
+
63
+ import httpx
64
+ import jwt
65
+ import pytest
66
+ from fake_issuer import FakeIssuer
67
+ from fastapi import HTTPException
68
+ from langchain.tools import ToolRuntime
69
+ from langchain_core.tools import tool
70
+ from starlette.requests import Request
71
+
72
+ from {{cookiecutter.agent_directory}}.app_utils.api_client import get_client
73
+ from {{cookiecutter.agent_directory}}.app_utils.api_client import (
74
+ reset_policy_cache as reset_api_policy,
75
+ )
76
+ from {{cookiecutter.agent_directory}}.app_utils.auth import require, reset_policy_cache
77
+ from {{cookiecutter.agent_directory}}.app_utils.token_exchange import (
78
+ TokenExchangeError,
79
+ exchanger,
80
+ reset_token_exchange,
81
+ )
82
+ from {{cookiecutter.agent_directory}}.fast_api_app import app
83
+
84
+ SECRET = "s3cret"
85
+ # In the refusal of an exchanged token that names no actor, and in the warning an API that
86
+ # allows one (exchange.allow_actorless) logs (token_exchange).
87
+ NO_ACTOR = "names no actor"
88
+ CLIENTS = {
89
+ "concierge": (SECRET, {"billing", "orders"}),
90
+ "billing": (SECRET, {"orders"}),
91
+ "orders": (SECRET, {"shipping"}),
92
+ }
93
+
94
+
95
+ @pytest.fixture(scope="module")
96
+ def issuer() -> Iterator[FakeIssuer]:
97
+ server = FakeIssuer(CLIENTS)
98
+ try:
99
+ yield server
100
+ finally:
101
+ server.close()
102
+
103
+
104
+ @pytest.fixture
105
+ def exchange_env(issuer: FakeIssuer, monkeypatch: pytest.MonkeyPatch) -> Iterator[FakeIssuer]:
106
+ """This agent is the concierge client at the issuer; the callee reads the issuer's keys."""
107
+ for name, value in {
108
+ "TOKEN_EXCHANGE_URL": issuer.token_url,
109
+ "TOKEN_EXCHANGE_CLIENT_ID": "concierge",
110
+ "TOKEN_EXCHANGE_CLIENT_SECRET": SECRET,
111
+ "AUTH_POLICY": "jwt",
112
+ "AUTH_JWT_JWKS_URL": issuer.jwks_url,
113
+ "AUTH_JWT_ISSUER": issuer.issuer,
114
+ }.items():
115
+ monkeypatch.setenv(name, value)
116
+ for name in (
117
+ "TOKEN_EXCHANGE_CLIENT_AUTH",
118
+ "TOKEN_EXCHANGE_TIMEOUT_MS",
119
+ "TOKEN_EXCHANGE_FAILURE_TTL_S",
120
+ "AUTH_JWT_PUBLIC_KEY",
121
+ "AUTH_JWT_DIRECT_CLIENTS",
122
+ "AUTH_MAX_DELEGATION_DEPTH",
123
+ ):
124
+ monkeypatch.delenv(name, raising=False)
125
+ issuer.answers.clear()
126
+ issuer.hold.set()
127
+ issuer.expires_in = 300
128
+ issuer.act = True
129
+ reset_token_exchange()
130
+ reset_policy_cache()
131
+ yield issuer
132
+ issuer.hold.set()
133
+ reset_token_exchange()
134
+ reset_policy_cache()
135
+
136
+
137
+ def _request(token: str) -> Request:
138
+ return Request(
139
+ {
140
+ "type": "http",
141
+ "method": "POST",
142
+ "path": "/a2a/x",
143
+ "headers": [(b"authorization", f"Bearer {token}".encode())],
144
+ "query_string": b"",
145
+ "path_params": {},
146
+ }
147
+ )
148
+
149
+
150
+ async def _at(
151
+ monkeypatch: pytest.MonkeyPatch, audience: str, token: str, allowed: str, depth: int = 3
152
+ ) -> Any:
153
+ """The principal the agent `audience` (jwt, the issuer's keys) sees for `token`."""
154
+ monkeypatch.setenv("AUTH_JWT_AUDIENCE", audience)
155
+ monkeypatch.setenv("AUTH_ALLOWED_ACTORS", allowed)
156
+ monkeypatch.setenv("AUTH_MAX_DELEGATION_DEPTH", str(depth))
157
+ reset_policy_cache()
158
+ return await require("a2a.invoke")(_request(token))
159
+
160
+
161
+ def _as(client: str, monkeypatch: pytest.MonkeyPatch) -> None:
162
+ monkeypatch.setenv("TOKEN_EXCHANGE_CLIENT_ID", client)
163
+
164
+
165
+ def _claims(token: str) -> dict[str, Any]:
166
+ return jwt.decode(token, options={"verify_signature": False})
167
+
168
+
169
+ # ---------------------------------------------------------------------------
170
+ # Conformance
171
+ # ---------------------------------------------------------------------------
172
+
173
+
174
+ async def test_the_exchanged_token_is_the_user_presented_by_this_agent(
175
+ exchange_env: FakeIssuer, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
176
+ ) -> None:
177
+ issuer = exchange_env
178
+ caplog.set_level(logging.WARNING)
179
+ alice = issuer.login("alice", audience="concierge")
180
+ token = await exchanger().token(
181
+ "orders_agent",
182
+ alice,
183
+ audience="orders",
184
+ scope="orders.read",
185
+ resource="https://orders.example.com",
186
+ subject_expires_at=_claims(alice)["exp"],
187
+ )
188
+ assert issuer.requests[-1]["authorization"].startswith("Basic ")
189
+ claims = _claims(token)
190
+ assert (claims["aud"], claims["azp"], claims["act"]) == (
191
+ "orders",
192
+ "concierge",
193
+ {"sub": "concierge"},
194
+ )
195
+ assert (claims["scope"], claims["resource"]) == ("orders.read", "https://orders.example.com")
196
+ assert NO_ACTOR not in caplog.text # the issuer names the agent: nothing to warn about
197
+ principal = await _at(monkeypatch, "orders", token, allowed="concierge")
198
+ assert principal.id == "alice"
199
+ assert principal.actor.id == "concierge" and principal.actor.chain == ("concierge",)
200
+ # A token minted for orders is refused at billing (the audience is pinned).
201
+ with pytest.raises(HTTPException) as refused:
202
+ await _at(monkeypatch, "billing", token, allowed="concierge")
203
+ assert refused.value.status_code == 401
204
+
205
+
206
+ async def test_client_secret_post_conforms(
207
+ exchange_env: FakeIssuer, monkeypatch: pytest.MonkeyPatch
208
+ ) -> None:
209
+ issuer = exchange_env
210
+ monkeypatch.setenv("TOKEN_EXCHANGE_CLIENT_AUTH", "client_secret_post")
211
+ token = await exchanger().token(
212
+ "billing_agent", issuer.login("bob", audience="concierge"), audience="billing"
213
+ )
214
+ sent = issuer.requests[-1]
215
+ assert sent["authorization"] == ""
216
+ assert (sent["form"]["client_id"], sent["form"]["client_secret"]) == ("concierge", SECRET)
217
+ assert _claims(token)["act"] == {"sub": "concierge"}
218
+
219
+
220
+ @pytest.mark.parametrize(
221
+ ("setup", "code"),
222
+ [
223
+ ("audience", "invalid_target"), # the client may not act for that audience
224
+ ("service", "invalid_grant"), # a service's own token is never exchanged
225
+ ("secret", "invalid_client"), # a wrong client secret
226
+ ("foreign", "invalid_grant"), # a token the issuer did not sign
227
+ ],
228
+ )
229
+ async def test_the_issuers_refusals_reach_the_call(
230
+ exchange_env: FakeIssuer, monkeypatch: pytest.MonkeyPatch, setup: str, code: str
231
+ ) -> None:
232
+ issuer = exchange_env
233
+ subject, audience = issuer.login("alice", audience="concierge"), "orders"
234
+ if setup == "audience":
235
+ audience = "shipping"
236
+ elif setup == "service":
237
+ subject = issuer.login("concierge", audience="concierge", client="concierge")
238
+ elif setup == "secret":
239
+ monkeypatch.setenv("TOKEN_EXCHANGE_CLIENT_SECRET", "wrong")
240
+ else:
241
+ subject = FakeIssuer(CLIENTS).login("alice", audience="concierge")
242
+ with pytest.raises(TokenExchangeError) as exc:
243
+ await exchanger().token("orders_agent", subject, audience=audience)
244
+ assert str(exc.value) == (
245
+ f"token exchange for API 'orders_agent' was refused ({code}); nothing was sent."
246
+ )
247
+
248
+
249
+ async def test_an_issuer_that_names_no_actor(
250
+ exchange_env: FakeIssuer, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
251
+ ) -> None:
252
+ """Some issuers (Keycloak's standard token exchange among them) put no `act` in an
253
+ exchanged token, only `azp`. The caller refuses such a token unless the API opts in
254
+ (`exchange.allow_actorless`); then it warns once that the callee must tell the agent by
255
+ its client."""
256
+ issuer = exchange_env
257
+ issuer.act = False
258
+ caplog.set_level(logging.WARNING)
259
+ alice = issuer.login("alice", audience="concierge")
260
+ with pytest.raises(TokenExchangeError) as exc:
261
+ await exchanger().token("orders_agent", alice, audience="orders")
262
+ assert exc.value.outcome == "no_actor"
263
+ assert "names no actor" in str(exc.value) and "nothing was sent" in str(exc.value)
264
+ assert [r for r in caplog.records if r.levelno >= logging.WARNING] == []
265
+ token = await exchanger().token("orders_agent", alice, audience="orders", allow_actorless=True)
266
+ assert "act" not in _claims(token) and _claims(token)["azp"] == "concierge"
267
+ [warning] = [
268
+ r.getMessage()
269
+ for r in caplog.records
270
+ if r.levelno >= logging.WARNING and NO_ACTOR in r.getMessage()
271
+ ]
272
+ assert "AUTH_JWT_DIRECT_CLIENTS" in warning and "client:concierge" in warning
273
+ assert token not in caplog.text
274
+ # Without AUTH_JWT_DIRECT_CLIENTS such a token reads as alice's own (why the opt-in needs
275
+ # it): list the sign-in clients, and the concierge presenting it is `client:concierge`.
276
+ assert (await _at(monkeypatch, "orders", token, allowed="")).actor is None
277
+ monkeypatch.setenv("AUTH_JWT_DIRECT_CLIENTS", "web")
278
+ principal = await _at(monkeypatch, "orders", token, allowed="client:concierge")
279
+ assert principal.id == "alice" and principal.actor.id == "client:concierge"
280
+ own = issuer.login("alice", audience="orders") # alice's own sign-in (client web)
281
+ assert (await _at(monkeypatch, "orders", own, allowed="client:concierge")).actor is None
282
+
283
+
284
+ async def test_the_issuers_lifetime_is_capped(
285
+ exchange_env: FakeIssuer, monkeypatch: pytest.MonkeyPatch
286
+ ) -> None:
287
+ """An answer without `expires_in` is kept 30 s (60 s less the margin); a long one 270 s."""
288
+ issuer = exchange_env
289
+ now = [0.0]
290
+ reset_token_exchange(clock=lambda: now[0])
291
+ alice = issuer.login("alice", audience="concierge", ttl_s=7200)
292
+ for expires_in, kept in ((None, 30), (3600, 270)):
293
+ exchanger().clear()
294
+ issuer.expires_in = expires_in
295
+ first = await exchanger().token("orders_agent", alice, audience="orders")
296
+ now[0] += kept - 1
297
+ assert await exchanger().token("orders_agent", alice, audience="orders") == first
298
+ now[0] += 2
299
+ assert await exchanger().token("orders_agent", alice, audience="orders") != first
300
+
301
+
302
+ # ---------------------------------------------------------------------------
303
+ # Depth across real exchanges
304
+ # ---------------------------------------------------------------------------
305
+
306
+
307
+ async def test_the_delegation_depth_holds_across_real_exchanges(
308
+ exchange_env: FakeIssuer, monkeypatch: pytest.MonkeyPatch
309
+ ) -> None:
310
+ """concierge -> billing -> orders passes at depth 2; one more hop is 401 at the callee."""
311
+ issuer = exchange_env
312
+ at_concierge = issuer.login("alice", audience="concierge")
313
+ at_billing = await exchanger().token("billing_agent", at_concierge, audience="billing")
314
+ _as("billing", monkeypatch)
315
+ at_orders = await exchanger().token("orders_agent", at_billing, audience="orders")
316
+ principal = await _at(monkeypatch, "orders", at_orders, allowed="billing", depth=2)
317
+ assert principal.id == "alice" and principal.actor.chain == ("billing", "concierge")
318
+ _as("orders", monkeypatch)
319
+ at_shipping = await exchanger().token("shipping_agent", at_orders, audience="shipping")
320
+ with pytest.raises(HTTPException) as exc:
321
+ await _at(monkeypatch, "shipping", at_shipping, allowed="orders", depth=2)
322
+ assert (exc.value.status_code, exc.value.detail) == (
323
+ 401,
324
+ "Invalid bearer token: delegation too deep.",
325
+ )
326
+ principal = await _at(monkeypatch, "shipping", at_shipping, allowed="orders", depth=3)
327
+ assert principal.actor.chain == ("orders", "billing", "concierge")
328
+
329
+
330
+ # ---------------------------------------------------------------------------
331
+ # An issuer that hangs
332
+ # ---------------------------------------------------------------------------
333
+
334
+
335
+ async def test_an_issuer_that_hangs(
336
+ exchange_env: FakeIssuer, monkeypatch: pytest.MonkeyPatch
337
+ ) -> None:
338
+ issuer = exchange_env
339
+ monkeypatch.setenv("TOKEN_EXCHANGE_TIMEOUT_MS", "300")
340
+ monkeypatch.setenv("TOKEN_EXCHANGE_FAILURE_TTL_S", "1")
341
+ issuer.hold.clear()
342
+ for user in ("u1", "u2", "u3"):
343
+ started = time.monotonic()
344
+ with pytest.raises(TokenExchangeError, match=r"unavailable \(timed out\)"):
345
+ await exchanger().token(
346
+ "orders_agent", issuer.login(user, audience="concierge"), audience="orders"
347
+ )
348
+ assert time.monotonic() - started < 1.0
349
+ started = time.monotonic()
350
+ with pytest.raises(TokenExchangeError, match=r"retry in 1 s"):
351
+ await exchanger().token(
352
+ "orders_agent", issuer.login("u4", audience="concierge"), audience="orders"
353
+ )
354
+ assert time.monotonic() - started < 0.05
355
+ issuer.hold.set()
356
+ await asyncio.sleep(1.1)
357
+ token = await exchanger().token(
358
+ "orders_agent", issuer.login("u5", audience="concierge"), audience="orders"
359
+ )
360
+ assert _claims(token)["sub"] == "u5"
361
+
362
+
363
+ # ---------------------------------------------------------------------------
364
+ # This app as the concierge, end to end
365
+ # ---------------------------------------------------------------------------
366
+
367
+ POLICY = """
368
+ apis:
369
+ orders_agent:
370
+ base_url_env: ORDERS_AGENT_URL
371
+ auth: exchange
372
+ exchange: {audience: orders}
373
+ allowed_methods: [GET, POST]
374
+ approval:
375
+ required_for:
376
+ operations:
377
+ - {operationId: cancelOrder, path: "/orders/{order_id}/cancel", methods: [POST]}
378
+ approvers: [requester]
379
+ timeout_s: 60
380
+ """
381
+
382
+ # What the orders agent received: the verified claims of each bearer, and the tokens.
383
+ RECEIVED: list[dict[str, Any]] = []
384
+ TOKENS: list[str] = []
385
+ ISSUER: list[FakeIssuer] = []
386
+
387
+
388
+ def _orders_backend(request: httpx.Request) -> httpx.Response:
389
+ token = request.headers["authorization"].removeprefix("Bearer ")
390
+ claims = jwt.decode(
391
+ token,
392
+ ISSUER[0].key.public_key(),
393
+ algorithms=["RS256"],
394
+ audience="orders",
395
+ issuer=ISSUER[0].issuer,
396
+ )
397
+ RECEIVED.append({"path": request.url.path, "method": request.method, **claims})
398
+ TOKENS.append(token)
399
+ return httpx.Response(200, json={"status": "shipped"})
400
+
401
+
402
+ @tool
403
+ async def check_orders(runtime: ToolRuntime[Any]) -> str:
404
+ """Check the caller's orders at the orders agent."""
405
+ client = get_client(
406
+ "orders_agent", context=runtime.context, transport=httpx.MockTransport(_orders_backend)
407
+ )
408
+ return json.dumps(await client.get("/orders/status"))
409
+
410
+
411
+ @tool
412
+ async def cancel_order(order_id: str, runtime: ToolRuntime[Any]) -> str:
413
+ """Cancel an order by its id."""
414
+ client = get_client(
415
+ "orders_agent", context=runtime.context, transport=httpx.MockTransport(_orders_backend)
416
+ )
417
+ data = await client.post(
418
+ "/orders/{order_id}/cancel", operation_id="cancelOrder", path_params={"order_id": order_id}
419
+ )
420
+ return json.dumps(data)
421
+
422
+
423
+ ADMIN_DSN = os.environ.get("TEST_POSTGRES_DSN", "")
424
+
425
+
426
+ @pytest.fixture(params=["memory", "postgres"])
427
+ async def database(
428
+ request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch
429
+ ) -> AsyncIterator[str | None]:
430
+ if request.param == "memory":
431
+ yield None
432
+ return
433
+ if not ADMIN_DSN:
434
+ pytest.skip("TEST_POSTGRES_DSN is not set")
435
+ import psycopg
436
+
437
+ name = f"gac_test_{uuid.uuid4().hex[:12]}"
438
+ async with await psycopg.AsyncConnection.connect(ADMIN_DSN, autocommit=True) as admin:
439
+ await admin.execute(f'CREATE DATABASE "{name}"')
440
+ dsn = urlsplit(ADMIN_DSN)._replace(path=f"/{name}").geturl()
441
+ monkeypatch.setenv("CHECKPOINTER", "postgres")
442
+ monkeypatch.setenv("POSTGRES_DSN", dsn)
443
+ yield dsn
444
+ async with await psycopg.AsyncConnection.connect(ADMIN_DSN, autocommit=True) as admin:
445
+ await admin.execute(f'DROP DATABASE IF EXISTS "{name}" WITH (FORCE)')
446
+
447
+
448
+ @pytest.fixture
449
+ async def concierge(
450
+ database: str | None,
451
+ exchange_env: FakeIssuer,
452
+ tmp_path: Path,
453
+ monkeypatch: pytest.MonkeyPatch,
454
+ use_test_tools: Any,
455
+ ) -> AsyncIterator[httpx.AsyncClient]:
456
+ policy = tmp_path / "api-policy.yaml"
457
+ policy.write_text(POLICY, encoding="utf-8")
458
+ monkeypatch.setenv("API_POLICY_PATH", str(policy))
459
+ monkeypatch.setenv("ORDERS_AGENT_URL", "https://orders.test")
460
+ monkeypatch.setenv("AUTH_JWT_AUDIENCE", "concierge")
461
+ monkeypatch.delenv("AUTH_ALLOWED_ACTORS", raising=False)
462
+ reset_policy_cache()
463
+ reset_api_policy()
464
+ RECEIVED.clear()
465
+ TOKENS.clear()
466
+ ISSUER[:] = [exchange_env]
467
+ async with app.router.lifespan_context(app):
468
+ transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
469
+ async with httpx.AsyncClient(
470
+ transport=transport, base_url="http://testserver", timeout=30
471
+ ) as client:
472
+ started = asyncio.get_running_loop().time()
473
+ while (await client.get("/ready")).status_code != 200: # postgres: the schema
474
+ assert asyncio.get_running_loop().time() - started < 30, "not ready"
475
+ await asyncio.sleep(0.1)
476
+ client.use_tools = use_test_tools # type: ignore[attr-defined]
477
+ client.dsn = database # type: ignore[attr-defined]
478
+ yield client
479
+ reset_policy_cache()
480
+ reset_api_policy()
481
+
482
+
483
+ def _events(text: str) -> list[tuple[str, dict[str, Any]]]:
484
+ events, event = [], None
485
+ for line in text.splitlines():
486
+ if line.startswith("event:"):
487
+ event = line[6:].strip()
488
+ elif line.startswith("data:") and event:
489
+ events.append((event, json.loads(line[5:].strip())))
490
+ event = None
491
+ return events
492
+
493
+
494
+ async def _chat(client: httpx.AsyncClient, token: str, message: str) -> list[tuple[str, dict]]:
495
+ r = await client.post(
496
+ "/chat", json={"message": message}, headers={"Authorization": f"Bearer {token}"}
497
+ )
498
+ assert r.status_code == 200, r.text
499
+ return _events(r.text)
500
+
501
+
502
+ async def test_one_exchange_per_user_and_api_in_the_tool_call_only(
503
+ concierge: httpx.AsyncClient,
504
+ ) -> None:
505
+ issuer = ISSUER[0]
506
+ concierge.use_tools(check_orders) # type: ignore[attr-defined]
507
+ alice = issuer.login(f"alice-{uuid.uuid4().hex[:6]}", audience="concierge")
508
+ bob = issuer.login(f"bob-{uuid.uuid4().hex[:6]}", audience="concierge")
509
+ before = len(issuer.exchanges)
510
+ # Authenticating alone never exchanges (the subject token is kept, not used).
511
+ listed = await concierge.get("/threads", headers={"Authorization": f"Bearer {alice}"})
512
+ assert listed.status_code == 200 and len(issuer.exchanges) == before
513
+ # A run that calls no exchange API exchanges nothing.
514
+ await _chat(concierge, alice, "Hello there")
515
+ assert len(issuer.exchanges) == before and RECEIVED == []
516
+ for token in (alice, alice, bob, alice):
517
+ events = await _chat(concierge, token, "Check my orders please")
518
+ assert any(name == "tool.result" for name, _ in events), events
519
+ assert len(RECEIVED) == 4
520
+ assert len(issuer.exchanges) - before == 2 # the distinct (user, API) pairs
521
+ first = RECEIVED[0]
522
+ assert first["sub"] == _claims(alice)["sub"]
523
+ assert (first["aud"], first["azp"], first["act"]) == (
524
+ "orders",
525
+ "concierge",
526
+ {"sub": "concierge"},
527
+ )
528
+ assert {r["sub"] for r in RECEIVED} == {_claims(alice)["sub"], _claims(bob)["sub"]}
529
+
530
+
531
+ async def test_the_concierge_refuses_an_exchanged_token_that_names_no_actor(
532
+ concierge: httpx.AsyncClient, caplog: pytest.LogCaptureFixture
533
+ ) -> None:
534
+ """Over /chat, with an issuer that puts no `act` in exchanged tokens: the call fails
535
+ closed, the orders agent receives nothing, the model reads why, and no token is logged."""
536
+ issuer = ISSUER[0]
537
+ issuer.act = False
538
+ caplog.set_level(logging.DEBUG)
539
+ concierge.use_tools(check_orders) # type: ignore[attr-defined]
540
+ alice = issuer.login(f"alice-{uuid.uuid4().hex[:6]}", audience="concierge")
541
+ before = len(issuer.exchanges)
542
+ for _ in range(2):
543
+ events = await _chat(concierge, alice, "Check my orders please")
544
+ [result] = [data for name, data in events if name == "tool.result"]
545
+ assert result["is_error"] is True and NO_ACTOR in result["result"], result
546
+ assert "exchange.allow_actorless" in result["result"]
547
+ assert RECEIVED == [] and TOKENS == []
548
+ assert len(issuer.exchanges) - before == 1 # the refusal is remembered
549
+ logged = caplog.text + "".join(str(record.__dict__) for record in caplog.records)
550
+ assert alice not in logged and SECRET not in logged
551
+
552
+
553
+ async def test_the_concierge_warns_once_when_an_api_allows_tokens_naming_no_actor(
554
+ concierge: httpx.AsyncClient, caplog: pytest.LogCaptureFixture
555
+ ) -> None:
556
+ """The same issuer, with `exchange.allow_actorless: true` for orders_agent: the calls go
557
+ through, the first logs one warning naming what the called agent needs, and no token is
558
+ logged."""
559
+ Path(os.environ["API_POLICY_PATH"]).write_text(
560
+ POLICY.replace(
561
+ "exchange: {audience: orders}", "exchange: {audience: orders, allow_actorless: true}"
562
+ ),
563
+ encoding="utf-8",
564
+ )
565
+ reset_api_policy()
566
+ issuer = ISSUER[0]
567
+ issuer.act = False
568
+ caplog.set_level(logging.DEBUG)
569
+ concierge.use_tools(check_orders) # type: ignore[attr-defined]
570
+ alice = issuer.login(f"alice-{uuid.uuid4().hex[:6]}", audience="concierge")
571
+ bob = issuer.login(f"bob-{uuid.uuid4().hex[:6]}", audience="concierge")
572
+ for token in (alice, bob):
573
+ events = await _chat(concierge, token, "Check my orders please")
574
+ assert any(name == "tool.result" for name, _ in events), events
575
+ assert len(TOKENS) == 2 and all("act" not in _claims(t) for t in TOKENS)
576
+ warnings = [
577
+ r for r in caplog.records if r.levelno >= logging.WARNING and NO_ACTOR in r.getMessage()
578
+ ]
579
+ assert len(warnings) == 1 and warnings[0].levelno == logging.WARNING
580
+ assert "AUTH_JWT_DIRECT_CLIENTS" in warnings[0].getMessage()
581
+ assert "exchange.allow_actorless lets it through" in warnings[0].getMessage()
582
+ logged = caplog.text + "".join(str(record.__dict__) for record in caplog.records)
583
+ assert [s for s in (alice, bob, *TOKENS, SECRET) if s in logged] == []
584
+
585
+
586
+ async def test_a_gated_call_exchanges_only_after_the_person_approves(
587
+ concierge: httpx.AsyncClient,
588
+ ) -> None:
589
+ issuer = ISSUER[0]
590
+ concierge.use_tools(cancel_order) # type: ignore[attr-defined]
591
+ alice = issuer.login(f"alice-{uuid.uuid4().hex[:6]}", audience="concierge")
592
+ before = len(issuer.exchanges)
593
+ events = await _chat(concierge, alice, "Cancel the order for 7")
594
+ end = events[-1][1]
595
+ assert end["status"] == "awaiting_approval", events
596
+ assert len(issuer.exchanges) == before and RECEIVED == [] # paused: nothing exchanged
597
+ approval = end["approval"]
598
+ decided = await concierge.post(
599
+ f"/threads/{end['thread_id']}/approvals/{approval['approval_id']}",
600
+ json={"decision": "approve"},
601
+ headers={"Authorization": f"Bearer {alice}"},
602
+ )
603
+ assert decided.status_code == 200, decided.text
604
+ assert [(r["method"], r["path"]) for r in RECEIVED] == [("POST", "/orders/7/cancel")]
605
+ assert len(issuer.exchanges) - before == 1
606
+
607
+
608
+ async def test_no_token_at_rest(
609
+ concierge: httpx.AsyncClient, caplog: pytest.LogCaptureFixture
610
+ ) -> None:
611
+ """Neither the user's token nor the exchanged one is logged or stored (T5)."""
612
+ issuer = ISSUER[0]
613
+ caplog.set_level(logging.DEBUG)
614
+ concierge.use_tools(check_orders) # type: ignore[attr-defined]
615
+ alice = issuer.login(f"alice-{uuid.uuid4().hex[:6]}", audience="concierge")
616
+ await _chat(concierge, alice, "Check my orders please")
617
+ concierge.use_tools(cancel_order) # type: ignore[attr-defined]
618
+ end = (await _chat(concierge, alice, "Cancel the order for 7"))[-1][1]
619
+ await concierge.post(
620
+ f"/threads/{end['thread_id']}/approvals/{end['approval']['approval_id']}",
621
+ json={"decision": "approve"},
622
+ headers={"Authorization": f"Bearer {alice}"},
623
+ )
624
+ assert len(TOKENS) == 2
625
+ secrets = {alice, *TOKENS, SECRET}
626
+ logged = caplog.text + "".join(str(record.__dict__) for record in caplog.records)
627
+ assert [s for s in secrets if s in logged] == []
628
+ dsn = concierge.dsn # type: ignore[attr-defined]
629
+ if dsn is None:
630
+ return
631
+ import psycopg
632
+
633
+ found: list[str] = []
634
+ async with await psycopg.AsyncConnection.connect(dsn) as conn:
635
+ tables = await (
636
+ await conn.execute(
637
+ "SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'"
638
+ )
639
+ ).fetchall()
640
+ assert tables, "the app created no tables"
641
+ for (table,) in tables:
642
+ for row in await (await conn.execute(f'SELECT * FROM "{table}"')).fetchall():
643
+ for value in row:
644
+ data = (
645
+ bytes(value)
646
+ if isinstance(value, bytes | memoryview)
647
+ else json.dumps(value, default=str).encode()
648
+ )
649
+ found.extend(table for s in secrets if s.encode() in data)
650
+ assert found == []
@@ -0,0 +1,22 @@
1
+ # Load testing
2
+
3
+ [Locust](http://locust.io) drives `POST /chat`, the SSE endpoint this project serves.
4
+
5
+ Local, against `graph-agents-cli playground` or `uv run uvicorn {{cookiecutter.agent_directory}}.fast_api_app:app --port 8000`:
6
+
7
+ ```bash
8
+ export GRAPH_AGENTS_CLI_API_KEY=<the API_KEY from .env> # jwt: a token, e.g. from graph-agents-cli auth dev-token
9
+ export LOAD_TEST_PROMPT="<a request that exercises your tools>" # optional
10
+ uv run --with locust locust -f tests/load_test/load_test.py -H http://127.0.0.1:8000 \
11
+ -u 10 -r 2 -t 30s --headless
12
+ ```
13
+
14
+ Against a deployed environment, point at its URL and send that environment's key:
15
+
16
+ ```bash
17
+ export GRAPH_AGENTS_CLI_API_KEY=<staging API_KEY>
18
+ uv run --with locust locust -f tests/load_test/load_test.py -H https://agent.staging.example.com \
19
+ -u 10 -r 2 -t 60s --headless --csv=tests/load_test/.results/report
20
+ ```
21
+
22
+ `--csv` writes the latency and failure tables Locust prints at the end.