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,724 @@
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
+ """A2A tasks are scoped per principal, expire after A2A_TASK_TTL_S, and fail closed.
16
+
17
+ Two principals come from a header test policy swapped in for the selected
18
+ one; the app runs in-process with `MODEL_PROVIDER=fake` and `CHECKPOINTER=memory`.
19
+ Which store serves the tasks (Postgres under a Postgres runtime database) and how
20
+ the Postgres store writes and fails are checked here without a database; the
21
+ store against a real Postgres is in `tests/integration/test_postgres.py`, and
22
+ across real server processes in `tests/integration/test_resilience_postgres.py`.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import asyncio
28
+ import json
29
+ import os
30
+ import uuid
31
+ from collections.abc import AsyncIterator
32
+ from typing import Any
33
+
34
+ # The environment must be in place before the app (and the graph) is imported.
35
+ os.environ.update(
36
+ {
37
+ "MODEL_PROVIDER": "fake",
38
+ "MODEL_NAME": "fake",
39
+ "CHECKPOINTER": "memory",
40
+ "AUTH_POLICY": "shared-bearer",
41
+ "API_KEY": "test-key",
42
+ "APP_ENV": "dev",
43
+ "TRACING_ENABLED": "false",
44
+ "RUNTIME": "fastapi",
45
+ "APP_URL": "http://testserver",
46
+ }
47
+ )
48
+
49
+ import httpx
50
+ import pytest
51
+ from a2a.client import ClientConfig, create_client
52
+ from a2a.server.context import ServerCallContext
53
+ from a2a.types import (
54
+ CancelTaskRequest,
55
+ GetTaskRequest,
56
+ ListTasksRequest,
57
+ Message,
58
+ Part,
59
+ Role,
60
+ SendMessageConfiguration,
61
+ SendMessageRequest,
62
+ Task,
63
+ TaskState,
64
+ )
65
+ from fastapi import HTTPException
66
+ from google.protobuf import json_format
67
+ from starlette.requests import Request
68
+
69
+ from {{cookiecutter.agent_directory}}.app_utils import a2a as a2a_module
70
+ from {{cookiecutter.agent_directory}}.app_utils import auth as auth_module
71
+ from {{cookiecutter.agent_directory}}.app_utils import chat as chat_module
72
+ from {{cookiecutter.agent_directory}}.app_utils import telemetry
73
+ from {{cookiecutter.agent_directory}}.app_utils.a2a import (
74
+ DEFAULT_TASK_TTL_S,
75
+ ExpiringTaskStore,
76
+ PolicyContextBuilder,
77
+ PostgresTaskStore,
78
+ PrincipalUser,
79
+ RuntimeTaskStore,
80
+ task_owner,
81
+ task_ttl_s,
82
+ )
83
+ from {{cookiecutter.agent_directory}}.app_utils.auth import ACTIONS, Actor, Principal
84
+ from {{cookiecutter.agent_directory}}.app_utils.checkpointer import MEMORY, POSTGRES
85
+ from {{cookiecutter.agent_directory}}.app_utils.db import Database
86
+ from {{cookiecutter.agent_directory}}.app_utils.limits import SettingsError
87
+ from {{cookiecutter.agent_directory}}.fast_api_app import app
88
+
89
+ A2A_PATH = "/a2a/{{cookiecutter.agent_directory}}"
90
+ A2A_URL = f"http://testserver{A2A_PATH}"
91
+
92
+
93
+ class HeaderPolicy:
94
+ """Test policy: `X-User` is the principal id."""
95
+
96
+ async def authenticate(self, request: Request) -> Principal:
97
+ user = request.headers.get("x-user")
98
+ if not user:
99
+ raise HTTPException(401, "no user", headers={"WWW-Authenticate": "Bearer"})
100
+ return Principal(id=user, roles=["user"], permissions=set(ACTIONS))
101
+
102
+ async def authorize(self, principal: Principal, action: str, resource: str | None) -> None:
103
+ return None
104
+
105
+
106
+ @pytest.fixture
107
+ async def users(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[dict[str, Any]]:
108
+ """A2A clients for alice and bob; messages containing `slow` keep working for 30 s."""
109
+ monkeypatch.setattr(auth_module, "get_policy", lambda: HeaderPolicy())
110
+ original = chat_module.ChatRuntime.stream
111
+
112
+ async def stream(self: Any, principal: Any, req: Any, thread_id: str) -> Any:
113
+ if "slow" in req.message:
114
+ yield chat_module.EVENT_DELTA, {"text": "working..."}
115
+ await asyncio.sleep(30)
116
+ async for item in original(self, principal, req, thread_id):
117
+ yield item
118
+
119
+ monkeypatch.setattr(chat_module.ChatRuntime, "stream", stream)
120
+ # Principals of their own: the A2A task store lives as long as the app, and
121
+ # other test modules (run first by a plain `pytest`) create tasks for "alice".
122
+ suffix = uuid.uuid4().hex[:8]
123
+ async with app.router.lifespan_context(app):
124
+ https = [
125
+ httpx.AsyncClient(
126
+ transport=httpx.ASGITransport(app=app),
127
+ base_url="http://testserver",
128
+ headers={"X-User": f"{name}-{suffix}"},
129
+ timeout=30,
130
+ )
131
+ for name in ("alice", "bob")
132
+ ]
133
+ try:
134
+ clients = {
135
+ name: await create_client(A2A_URL, ClientConfig(streaming=False, httpx_client=h))
136
+ for name, h in zip(("alice", "bob"), https, strict=True)
137
+ }
138
+ clients["http"] = dict(zip(("alice", "bob"), https, strict=True))
139
+ yield clients
140
+ finally:
141
+ for h in https:
142
+ await h.aclose()
143
+
144
+
145
+ def _message(text: str, **fields: Any) -> SendMessageRequest:
146
+ return SendMessageRequest(
147
+ message=Message(
148
+ message_id=f"m-{uuid.uuid4()}", role=Role.ROLE_USER, parts=[Part(text=text)], **fields
149
+ )
150
+ )
151
+
152
+
153
+ async def _send(client: Any, request: SendMessageRequest) -> Task:
154
+ task = None
155
+ async for chunk in client.send_message(request):
156
+ if chunk.HasField("task"):
157
+ task = chunk.task
158
+ assert task is not None
159
+ return task
160
+
161
+
162
+ async def test_other_principals_cannot_list_get_or_cancel_a_task(users: dict[str, Any]) -> None:
163
+ alice, bob = users["alice"], users["bob"]
164
+ task = await _send(alice, _message("my pin is 9876"))
165
+ assert task.status.state == TaskState.TASK_STATE_COMPLETED
166
+
167
+ own = await alice.list_tasks(ListTasksRequest(include_artifacts=True))
168
+ assert [t.id for t in own.tasks] == [task.id]
169
+ theirs = await bob.list_tasks(ListTasksRequest(include_artifacts=True))
170
+ assert theirs.total_size == 0 and not theirs.tasks
171
+
172
+ assert (await alice.get_task(GetTaskRequest(id=task.id))).id == task.id
173
+ with pytest.raises(Exception, match="not found"):
174
+ await bob.get_task(GetTaskRequest(id=task.id))
175
+
176
+ # The pre-1.0 JSON-RPC surface goes through the same store.
177
+ r = await users["http"]["bob"].post(
178
+ A2A_PATH,
179
+ json={"jsonrpc": "2.0", "id": "1", "method": "tasks/get", "params": {"id": task.id}},
180
+ headers={"A2A-Version": "0.3"},
181
+ )
182
+ body = r.json()
183
+ assert "result" not in body and "9876" not in r.text
184
+
185
+
186
+ async def test_cancel_is_owner_only_and_works_for_the_owner(users: dict[str, Any]) -> None:
187
+ alice, bob = users["alice"], users["bob"]
188
+ request = _message("slow job")
189
+ request.configuration.CopyFrom(SendMessageConfiguration(return_immediately=True))
190
+ task = await _send(alice, request)
191
+ await asyncio.sleep(0.2)
192
+
193
+ with pytest.raises(Exception, match="not found"):
194
+ await bob.cancel_task(CancelTaskRequest(id=task.id))
195
+ # Nor can bob attach to its live event stream.
196
+ r = await users["http"]["bob"].post(
197
+ A2A_PATH,
198
+ json={"jsonrpc": "2.0", "id": "2", "method": "SubscribeToTask", "params": {"id": task.id}},
199
+ headers={"A2A-Version": "1.0"},
200
+ )
201
+ assert "not found" in r.text.lower() and "working..." not in r.text
202
+ still = await alice.get_task(GetTaskRequest(id=task.id))
203
+ assert still.status.state == TaskState.TASK_STATE_WORKING
204
+
205
+ canceled = await alice.cancel_task(CancelTaskRequest(id=task.id))
206
+ assert canceled.status.state == TaskState.TASK_STATE_CANCELED
207
+
208
+
209
+ async def test_another_principals_task_or_context_cannot_be_continued(
210
+ users: dict[str, Any],
211
+ ) -> None:
212
+ alice, bob = users["alice"], users["bob"]
213
+ task = await _send(alice, _message("hello"))
214
+ # Naming alice's task: not found for bob.
215
+ with pytest.raises(Exception, match="not found"):
216
+ await _send(bob, _message("hi", task_id=task.id))
217
+ # Naming alice's context (her thread): refused by thread ownership.
218
+ refused = await _send(bob, _message("hi", context_id=task.context_id))
219
+ assert refused.status.state == TaskState.TASK_STATE_FAILED
220
+ assert "another principal" in refused.status.message.parts[0].text
221
+
222
+
223
+ async def test_an_invalid_context_id_fails_the_task(users: dict[str, Any]) -> None:
224
+ task = await _send(users["alice"], _message("hi", context_id="../../etc/passwd"))
225
+ assert task.status.state == TaskState.TASK_STATE_FAILED
226
+ assert "Invalid contextId" in task.status.message.parts[0].text
227
+
228
+
229
+ # --- the task store ------------------------------------------------------------
230
+
231
+
232
+ class Clock:
233
+ def __init__(self) -> None:
234
+ self.now = 100.0
235
+
236
+ def __call__(self) -> float:
237
+ return self.now
238
+
239
+
240
+ def _ctx(owner: str) -> ServerCallContext:
241
+ return ServerCallContext(user=PrincipalUser(owner))
242
+
243
+
244
+ async def test_tasks_expire_after_the_ttl() -> None:
245
+ clock = Clock()
246
+ store = ExpiringTaskStore(60, clock=clock)
247
+ await store.save(Task(id="t1", context_id="c1"), _ctx("alice"))
248
+ await store.save(Task(id="t2", context_id="c2"), _ctx("bob"))
249
+ clock.now += 30
250
+ assert await store.get("t1", _ctx("alice")) is not None
251
+ assert await store.get("t1", _ctx("bob")) is None
252
+ await store.save(Task(id="t1", context_id="c1"), _ctx("alice")) # an update restarts the TTL
253
+ clock.now += 45
254
+ assert await store.get("t1", _ctx("alice")) is not None
255
+ assert (await store.list(ListTasksRequest(), _ctx("bob"))).total_size == 0 # t2 expired
256
+ clock.now += 60
257
+ assert await store.get("t1", _ctx("alice")) is None
258
+ assert store._saved_at == {} # nothing left behind in memory
259
+
260
+
261
+ async def test_the_sweep_evicts_tasks_nobody_reads_again() -> None:
262
+ clock = Clock()
263
+ store = ExpiringTaskStore(10, clock=clock)
264
+ for i in range(20):
265
+ await store.save(Task(id=f"t{i}", context_id="c"), _ctx(f"user{i}"))
266
+ clock.now += 11
267
+ await store.save(Task(id="new", context_id="c"), _ctx("carol"))
268
+ assert list(store._saved_at) == ["carol"]
269
+
270
+
271
+ async def test_ttl_zero_keeps_tasks(monkeypatch: pytest.MonkeyPatch) -> None:
272
+ clock = Clock()
273
+ store = ExpiringTaskStore(0, clock=clock)
274
+ await store.save(Task(id="t1", context_id="c1"), _ctx("alice"))
275
+ clock.now += 10**9
276
+ assert await store.get("t1", _ctx("alice")) is not None
277
+ monkeypatch.setenv("A2A_TASK_TTL_S", "0")
278
+ assert task_ttl_s() == 0
279
+ for bad in ("-1", "soon", "1.5"):
280
+ # Refused, not replaced by the default: the startup check names it.
281
+ monkeypatch.setenv("A2A_TASK_TTL_S", bad)
282
+ with pytest.raises(SettingsError, match="A2A_TASK_TTL_S"):
283
+ task_ttl_s()
284
+ monkeypatch.delenv("A2A_TASK_TTL_S")
285
+ assert task_ttl_s() == DEFAULT_TASK_TTL_S == 3600
286
+
287
+
288
+ def test_no_principal_means_no_task_access() -> None:
289
+ with pytest.raises(PermissionError):
290
+ task_owner(ServerCallContext()) # unauthenticated user
291
+ with pytest.raises(PermissionError):
292
+ task_owner(_ctx(""))
293
+ request = Request({"type": "http", "method": "POST", "path": A2A_PATH, "headers": []})
294
+ with pytest.raises(PermissionError):
295
+ PolicyContextBuilder().build_user(request)
296
+ request.state.principal = Principal(id="alice")
297
+ assert PolicyContextBuilder().build_user(request).user_name == "alice"
298
+
299
+
300
+ def test_the_call_context_does_not_carry_the_credential() -> None:
301
+ headers = [
302
+ (b"authorization", b"Bearer secret-token"),
303
+ (b"cookie", b"sid=secret-session"),
304
+ (b"a2a-version", b"1.0"),
305
+ ]
306
+ request = Request({"type": "http", "method": "POST", "path": A2A_PATH, "headers": headers})
307
+ request.state.principal = Principal(id="alice")
308
+ context = PolicyContextBuilder().build(request)
309
+ assert context.user.user_name == "alice"
310
+ assert context.state["principal"].id == "alice"
311
+ assert context.state["headers"] == {"a2a-version": "1.0"}
312
+ assert "secret" not in repr(context)
313
+
314
+
315
+ async def test_without_the_policy_override_the_rpc_still_requires_the_key() -> None:
316
+ async with httpx.AsyncClient(
317
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
318
+ ) as client:
319
+ r = await client.post(A2A_PATH, json={"jsonrpc": "2.0", "id": "1", "method": "ListTasks"})
320
+ assert r.status_code == 401
321
+
322
+
323
+ # --- which store, and how the Postgres store writes and fails (no database) --------------
324
+
325
+
326
+ class RecordingDb:
327
+ """Stands in for `Database`: records statements, or fails them with `error`."""
328
+
329
+ a2a_tasks_table = "a2a_tasks"
330
+ locks_table = "thread_locks"
331
+ is_postgres = True
332
+
333
+ def __init__(self, error: Exception | None = None) -> None:
334
+ self.error = error
335
+ self.statements: list[tuple[str, Any]] = []
336
+
337
+ async def _run(self, sql: str, params: Any) -> Any:
338
+ self.statements.append((" ".join(sql.split()), params))
339
+ if self.error is not None:
340
+ raise self.error
341
+ return None
342
+
343
+ async def execute(self, sql: str, params: Any = ()) -> None:
344
+ await self._run(sql, params)
345
+
346
+ async def fetchone(self, sql: str, params: Any = ()) -> Any:
347
+ return await self._run(sql, params)
348
+
349
+ async def fetchall(self, sql: str, params: Any = ()) -> list[Any]:
350
+ return await self._run(sql, params) or []
351
+
352
+ def writes(self) -> list[str]:
353
+ return [sql for sql, _ in self.statements if sql.startswith("INSERT")]
354
+
355
+
356
+ def _task(state: int, text: str = "", second: int = 0) -> Task:
357
+ task = Task(id="t1", context_id="c1")
358
+ task.status.state = state
359
+ task.status.timestamp.FromSeconds(1_800_000_000 + second)
360
+ if text:
361
+ artifact = task.artifacts.add()
362
+ artifact.artifact_id = "a1"
363
+ artifact.parts.add().text = text
364
+ return task
365
+
366
+
367
+ async def test_the_task_store_follows_the_runtime_database(monkeypatch: pytest.MonkeyPatch) -> None:
368
+ """Postgres (shared by replicas) when the runtime's database is, memory otherwise."""
369
+ runtime = chat_module.RUNTIME
370
+ store = RuntimeTaskStore(60)
371
+ monkeypatch.setattr(runtime, "db", Database(MEMORY))
372
+ assert store.postgres() is None
373
+ db = Database(POSTGRES, "postgresql://agent@db.invalid/agent")
374
+ monkeypatch.setattr(runtime, "db", db)
375
+ monkeypatch.setattr(runtime, "started", True)
376
+ monkeypatch.setattr(runtime, "initialising", True)
377
+ with pytest.raises(HTTPException) as refused: # the schema is not set up yet
378
+ store.postgres()
379
+ assert refused.value.status_code == 503
380
+ monkeypatch.setattr(runtime, "initialising", False)
381
+ postgres = store.postgres()
382
+ assert isinstance(postgres, PostgresTaskStore)
383
+ assert postgres.db is db and postgres.table == "a2a_tasks" and store.postgres() is postgres
384
+ server = Database.for_server()
385
+ assert (server.a2a_tasks_table, db.a2a_tasks_table) == ("agent_a2a_tasks", "a2a_tasks")
386
+ assert "CREATE TABLE IF NOT EXISTS a2a_tasks" in db.ddl()
387
+
388
+
389
+ async def test_reply_chunks_are_written_at_most_once_a_second() -> None:
390
+ """A streamed reply is not one database write per chunk; a status change always is."""
391
+ clock = Clock()
392
+ db = RecordingDb()
393
+ store = PostgresTaskStore(60, db, clock=clock) # type: ignore[arg-type]
394
+ await store.save(_task(TaskState.TASK_STATE_WORKING), _ctx("alice"))
395
+ for text in ("Hel", "Hello", "Hello, wor"):
396
+ clock.now += 0.2
397
+ await store.save(_task(TaskState.TASK_STATE_WORKING, text), _ctx("alice"))
398
+ assert len(db.writes()) == 1
399
+ clock.now += 1
400
+ await store.save(_task(TaskState.TASK_STATE_WORKING, "Hello, world"), _ctx("alice"))
401
+ assert len(db.writes()) == 2
402
+ await store.save(_task(TaskState.TASK_STATE_COMPLETED, "Hello, world!", 1), _ctx("alice"))
403
+ assert len(db.writes()) == 3
404
+ # Another principal's task of the same id is its own.
405
+ await store.save(_task(TaskState.TASK_STATE_WORKING), _ctx("bob"))
406
+ assert len(db.writes()) == 4
407
+ owners = [params[0] for sql, params in db.statements if sql.startswith("INSERT")]
408
+ assert owners == ["alice", "alice", "alice", "bob"]
409
+
410
+
411
+ async def test_database_errors_never_reach_the_caller_as_text() -> None:
412
+ import psycopg
413
+
414
+ unreachable = RecordingDb(psycopg.OperationalError("host db.internal:5432"))
415
+ down = PostgresTaskStore(60, unreachable) # type: ignore[arg-type]
416
+ with pytest.raises(HTTPException) as unavailable:
417
+ await down.get("t1", _ctx("alice"))
418
+ assert unavailable.value.status_code == 503
419
+ assert "db.internal" not in str(unavailable.value.detail)
420
+ failing = RecordingDb(RuntimeError("syntax error at SELECT secret"))
421
+ broken = PostgresTaskStore(60, failing) # type: ignore[arg-type]
422
+ with pytest.raises(a2a_module.InternalError) as internal:
423
+ await broken.get("t1", _ctx("alice"))
424
+ assert "Reference" in str(internal.value) and "secret" not in str(internal.value)
425
+
426
+
427
+ def _strings_in(value: Any) -> list[str]:
428
+ if isinstance(value, str):
429
+ return [value]
430
+ if isinstance(value, dict):
431
+ return [s for key, item in value.items() for s in (key, *_strings_in(item))]
432
+ if isinstance(value, list):
433
+ return [s for item in value for s in _strings_in(item)]
434
+ return []
435
+
436
+
437
+ async def test_a_nul_character_never_reaches_the_database() -> None:
438
+ """Postgres stores no U+0000 (jsonb and TEXT refuse it), so the store writes U+FFFD.
439
+
440
+ Everywhere in the task (text, data, metadata, the contextId) and in the ids
441
+ a caller names. A save that failed on one lost the task while its run, and
442
+ whatever its tools did, went on. The owner is never changed.
443
+ """
444
+ db = RecordingDb()
445
+ store = PostgresTaskStore(60, db) # type: ignore[arg-type]
446
+ task = _task(TaskState.TASK_STATE_COMPLETED, "reading\x00 42, and \\u0000 as text")
447
+ task.context_id = "ab\x00cd"
448
+ message = task.history.add()
449
+ message.message_id = "m1"
450
+ message.parts.add().text = "tell me\x00 more"
451
+ json_format.ParseDict({"key\x00": ["value\x00"]}, message.parts.add().data)
452
+ json_format.ParseDict({"note": "x\x00"}, message.metadata)
453
+ await store.save(task, _ctx("alice"))
454
+ await store.get("t\x00", _ctx("alice"))
455
+ await store.list(ListTasksRequest(context_id="ab\x00cd"), _ctx("alice"))
456
+ await store.delete("t\x00", _ctx("alice"))
457
+ sent = [param for _, params in db.statements for param in params if isinstance(param, str)]
458
+ assert not [param for param in sent if "\x00" in param]
459
+ (insert,) = [params for sql, params in db.statements if sql.startswith("INSERT")]
460
+ stored = json.loads(insert[6])
461
+ assert not [text for text in _strings_in(stored) if "\x00" in text]
462
+ assert stored["contextId"] == "ab\ufffdcd" and insert[2:4] == ("ab\ufffdcd", "ab\ufffdcd")
463
+ assert stored["artifacts"][0]["parts"][0]["text"] == "reading\ufffd 42, and \\u0000 as text"
464
+ assert stored["history"][0]["parts"] == [
465
+ {"text": "tell me\ufffd more"},
466
+ {"data": {"key\ufffd": ["value\ufffd"]}},
467
+ ]
468
+ assert stored["history"][0]["metadata"] == {"note": "x\ufffd"}
469
+ lookups = [params for sql, params in db.statements if not sql.startswith("INSERT")]
470
+ assert ("alice", "t\ufffd") in [tuple(params[:2]) for params in lookups]
471
+ assert any("ab\ufffdcd" in params for params in lookups)
472
+
473
+
474
+ def test_the_a2a_defaults_on_record() -> None:
475
+ """Defaults decided for 0.3 (P4 and the trace scope read them): the user's own words go
476
+ only to peers that declare the origin extension, and under the default trace scope an
477
+ incoming `traceparent` is continued on the A2A routes only."""
478
+ assert a2a_module.DEFAULT_A2A_FORWARD_ORIGIN == "auto"
479
+ assert telemetry.PEERS_INBOUND_TRACE_PREFIX == "/a2a/"
480
+ for path in (a2a_module.A2A_RPC_PATH, a2a_module.A2A_CARD_PATH):
481
+ assert path.startswith(telemetry.PEERS_INBOUND_TRACE_PREFIX)
482
+ assert not "/chat".startswith(telemetry.PEERS_INBOUND_TRACE_PREFIX)
483
+
484
+
485
+ # --- agents acting for the same user (0.3): tasks are owned by the owner key ---------------
486
+
487
+
488
+ class ActorHeaderPolicy:
489
+ """Test policy: `X-User` is the subject, `X-Actor` (when sent) the agent presenting it."""
490
+
491
+ async def authenticate(self, request: Request) -> Principal:
492
+ user = request.headers.get("x-user")
493
+ if not user:
494
+ raise HTTPException(401, "no user", headers={"WWW-Authenticate": "Bearer"})
495
+ actor = request.headers.get("x-actor")
496
+ return Principal(
497
+ id=user,
498
+ roles=["user"],
499
+ permissions=set(ACTIONS),
500
+ actor=Actor(id=actor) if actor else None,
501
+ )
502
+
503
+ async def authorize(self, principal: Principal, action: str, resource: str | None) -> None:
504
+ return None
505
+
506
+
507
+ @pytest.fixture
508
+ async def agents(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[dict[str, Any]]:
509
+ """A2A clients of one user: directly, and through the agents concierge and billing."""
510
+ monkeypatch.setattr(auth_module, "get_policy", lambda: ActorHeaderPolicy())
511
+ monkeypatch.setenv("AUTH_ALLOWED_ACTORS", "concierge,billing")
512
+ user = f"alice-{uuid.uuid4().hex[:8]}"
513
+ headers = {
514
+ "direct": {"X-User": user},
515
+ "concierge": {"X-User": user, "X-Actor": "concierge"},
516
+ "billing": {"X-User": user, "X-Actor": "billing"},
517
+ }
518
+ async with app.router.lifespan_context(app):
519
+ https = {
520
+ name: httpx.AsyncClient(
521
+ transport=httpx.ASGITransport(app=app),
522
+ base_url="http://testserver",
523
+ headers=value,
524
+ timeout=30,
525
+ )
526
+ for name, value in headers.items()
527
+ }
528
+ try:
529
+ clients: dict[str, Any] = {
530
+ name: await create_client(A2A_URL, ClientConfig(streaming=False, httpx_client=h))
531
+ for name, h in https.items()
532
+ }
533
+ clients["http"] = https
534
+ yield clients
535
+ finally:
536
+ for h in https.values():
537
+ await h.aclose()
538
+
539
+
540
+ def test_the_task_owner_is_the_owner_key() -> None:
541
+ request = Request({"type": "http", "method": "POST", "path": A2A_PATH, "headers": []})
542
+ request.state.principal = Principal(id="alice", actor=Actor(id="concierge"))
543
+ assert PolicyContextBuilder().build_user(request).user_name == "alice\x1fconcierge"
544
+ request.state.principal = Principal(id="alice")
545
+ assert PolicyContextBuilder().build_user(request).user_name == "alice" # as in 0.2
546
+
547
+
548
+ def _store_keys_of(task_id: str) -> list[str]:
549
+ """The owner keys the (in-memory) task stores of the mounted A2A routes hold `task_id` under."""
550
+ return [
551
+ key
552
+ for store in a2a_module._STORES
553
+ for key, tasks in store.memory._saved_at.items()
554
+ if task_id in tasks
555
+ ]
556
+
557
+
558
+ async def test_other_actor_cannot_get_list_continue_cancel(agents: dict[str, Any]) -> None:
559
+ concierge, billing, person = agents["concierge"], agents["billing"], agents["direct"]
560
+ task = await _send(concierge, _message("my pin is 9876"))
561
+ assert task.status.state == TaskState.TASK_STATE_COMPLETED
562
+ assert [t.id for t in (await concierge.list_tasks(ListTasksRequest())).tasks] == [task.id]
563
+ # Another agent for the same user: another owner key, nothing to see.
564
+ assert (await billing.list_tasks(ListTasksRequest())).total_size == 0
565
+ with pytest.raises(Exception, match="not found"):
566
+ await billing.get_task(GetTaskRequest(id=task.id))
567
+ with pytest.raises(Exception, match="not found"):
568
+ await billing.cancel_task(CancelTaskRequest(id=task.id))
569
+ with pytest.raises(Exception, match="not found"):
570
+ await _send(billing, _message("hi", task_id=task.id))
571
+ # The person reads and lists the task their agent started for them (the owner's decision
572
+ # of 2026-09-28) ...
573
+ assert [t.id for t in (await person.list_tasks(ListTasksRequest())).tasks] == [task.id]
574
+ got = await person.get_task(GetTaskRequest(id=task.id))
575
+ assert got.id == task.id and got.status.state == TaskState.TASK_STATE_COMPLETED
576
+ # Found (billing's cancel is "not found"); it has ended, so it stays as it is, as for its owner.
577
+ ended = await person.cancel_task(CancelTaskRequest(id=task.id))
578
+ assert (ended.id, ended.status.state) == (task.id, TaskState.TASK_STATE_COMPLETED)
579
+ # ... but continuing it (a message naming it) stays with the concierge.
580
+ with pytest.raises(Exception, match="not found"):
581
+ await _send(person, _message("hi", task_id=task.id))
582
+ # The billing agent sees none of the person's own tasks either.
583
+ own = await _send(person, _message("hello"))
584
+ with pytest.raises(Exception, match="not found"):
585
+ await billing.get_task(GetTaskRequest(id=own.id))
586
+ assert (await billing.list_tasks(ListTasksRequest())).total_size == 0
587
+ assert (await concierge.list_tasks(ListTasksRequest())).total_size == 1
588
+ # The conversation: another agent is refused (no oracle beyond the thread rule) ...
589
+ refused = await _send(billing, _message("hi", context_id=task.context_id))
590
+ assert refused.status.state == TaskState.TASK_STATE_FAILED
591
+ assert "another principal" in refused.status.message.parts[0].text
592
+ # ... while the person, who owns every thread of theirs, may continue it.
593
+ continued = await _send(agents["direct"], _message("hi", context_id=task.context_id))
594
+ assert continued.status.state == TaskState.TASK_STATE_COMPLETED
595
+
596
+
597
+ async def test_the_person_cancels_a_task_their_agent_started(
598
+ agents: dict[str, Any], monkeypatch: pytest.MonkeyPatch
599
+ ) -> None:
600
+ """The cancel runs under the task's own owner key: the concierge's task is canceled where it
601
+ is, and no copy appears under the person's key. The agent's live stream stays its own."""
602
+ original = chat_module.ChatRuntime.stream
603
+
604
+ async def stream(self: Any, principal: Any, req: Any, thread_id: str) -> Any:
605
+ if "slow" in req.message: # keeps working for 30 s, as in the `users` fixture
606
+ yield chat_module.EVENT_DELTA, {"text": "working..."}
607
+ await asyncio.sleep(30)
608
+ async for item in original(self, principal, req, thread_id):
609
+ yield item
610
+
611
+ monkeypatch.setattr(chat_module.ChatRuntime, "stream", stream)
612
+ user = agents["http"]["direct"].headers["x-user"]
613
+ request = _message("slow job")
614
+ request.configuration.CopyFrom(SendMessageConfiguration(return_immediately=True))
615
+ task = await _send(agents["concierge"], request)
616
+ await asyncio.sleep(0.2)
617
+ with pytest.raises(Exception, match="not found"):
618
+ await agents["billing"].cancel_task(CancelTaskRequest(id=task.id))
619
+ r = await agents["http"]["direct"].post(
620
+ A2A_PATH,
621
+ json={"jsonrpc": "2.0", "id": "2", "method": "SubscribeToTask", "params": {"id": task.id}},
622
+ headers={"A2A-Version": "1.0"},
623
+ )
624
+ assert "not found" in r.text.lower() and "working..." not in r.text
625
+ working = await agents["direct"].get_task(GetTaskRequest(id=task.id))
626
+ assert working.status.state == TaskState.TASK_STATE_WORKING
627
+
628
+ canceled = await agents["direct"].cancel_task(CancelTaskRequest(id=task.id))
629
+ assert canceled.status.state == TaskState.TASK_STATE_CANCELED
630
+ seen = await agents["concierge"].get_task(GetTaskRequest(id=task.id))
631
+ assert seen.status.state == TaskState.TASK_STATE_CANCELED
632
+ assert _store_keys_of(task.id) == [f"{user}\x1fconcierge"]
633
+ listed = await agents["direct"].list_tasks(ListTasksRequest())
634
+ assert [(t.id, t.status.state) for t in listed.tasks] == [
635
+ (task.id, TaskState.TASK_STATE_CANCELED)
636
+ ]
637
+
638
+
639
+ async def test_the_persons_list_pages_over_their_own_and_their_agents_tasks(
640
+ agents: dict[str, Any],
641
+ ) -> None:
642
+ sent = [
643
+ await _send(agents[name], _message(f"hello from {name}"))
644
+ for name in ("direct", "concierge", "billing", "direct")
645
+ ]
646
+ person = agents["direct"]
647
+ whole = await person.list_tasks(ListTasksRequest())
648
+ assert whole.total_size == 4 and {t.id for t in whole.tasks} == {t.id for t in sent}
649
+ # Newest status first, as the SDK's store orders one owner's tasks.
650
+ stamps = [t.status.timestamp.ToJsonString() for t in whole.tasks]
651
+ assert stamps == sorted(stamps, reverse=True)
652
+ pages, token = [], ""
653
+ while True:
654
+ page = await person.list_tasks(ListTasksRequest(page_size=1, page_token=token))
655
+ assert page.total_size == 4 and len(page.tasks) == 1
656
+ pages.append(page.tasks[0].id)
657
+ token = page.next_page_token
658
+ if not token:
659
+ break
660
+ assert pages == [t.id for t in whole.tasks]
661
+ # Filters apply across the keys: one conversation.
662
+ concierges = sent[1]
663
+ by_context = await person.list_tasks(ListTasksRequest(context_id=concierges.context_id))
664
+ assert [t.id for t in by_context.tasks] == [concierges.id]
665
+ with pytest.raises(Exception, match="Invalid page token"):
666
+ await person.list_tasks(ListTasksRequest(page_token="bm90LWEtdGFzaw"))
667
+ # Each agent still lists only its own.
668
+ for name, task in (("concierge", sent[1]), ("billing", sent[2])):
669
+ assert [t.id for t in (await agents[name].list_tasks(ListTasksRequest())).tasks] == [
670
+ task.id
671
+ ]
672
+
673
+
674
+ async def test_another_person_sees_none_of_them(agents: dict[str, Any]) -> None:
675
+ task = await _send(agents["concierge"], _message("hello"))
676
+ user = agents["http"]["direct"].headers["x-user"]
677
+ for other in (f"{user}x", user[:-1], f"carol-{uuid.uuid4().hex[:8]}"):
678
+ # Neither a longer nor a shorter id, nor another subject, is this person.
679
+ async with httpx.AsyncClient(
680
+ transport=httpx.ASGITransport(app=app),
681
+ base_url="http://testserver",
682
+ headers={"X-User": other},
683
+ timeout=30,
684
+ ) as http:
685
+ client = await create_client(A2A_URL, ClientConfig(streaming=False, httpx_client=http))
686
+ assert (await client.list_tasks(ListTasksRequest())).total_size == 0
687
+ with pytest.raises(Exception, match="not found"):
688
+ await client.get_task(GetTaskRequest(id=task.id))
689
+ with pytest.raises(Exception, match="not found"):
690
+ await client.cancel_task(CancelTaskRequest(id=task.id))
691
+
692
+
693
+ def test_a_subject_owns_its_own_and_its_agents_keys_only() -> None:
694
+ assert a2a_module.subject_owns("alice", "alice")
695
+ assert a2a_module.subject_owns("alice", "alice\x1fconcierge")
696
+ assert a2a_module.subject_owns("alice", "alice\x1fclient:web")
697
+ assert not a2a_module.subject_owns("alice", "alicex")
698
+ assert not a2a_module.subject_owns("alice", "alicex\x1fconcierge")
699
+ assert not a2a_module.subject_owns("alice", "alic")
700
+ assert not a2a_module.subject_owns("alice", "bob\x1falice")
701
+
702
+
703
+ def test_only_a_direct_caller_reaches_its_agents_tasks() -> None:
704
+ request = Request({"type": "http", "method": "POST", "path": A2A_PATH, "headers": []})
705
+ request.state.principal = Principal(id="alice")
706
+ user = PolicyContextBuilder().build_user(request)
707
+ assert (user.user_name, user.subject) == ("alice", "alice")
708
+ request.state.principal = Principal(id="alice", actor=Actor(id="concierge"))
709
+ user = PolicyContextBuilder().build_user(request)
710
+ assert (user.user_name, user.subject) == ("alice\x1fconcierge", None)
711
+ assert a2a_module.task_subject(ServerCallContext(user=user)) is None
712
+ owner = a2a_module.owner_context(ServerCallContext(user=PrincipalUser("alice")), "a\x1fb")
713
+ assert (task_owner(owner), a2a_module.task_subject(owner)) == ("a\x1fb", None)
714
+
715
+
716
+ async def test_a_delegated_caller_is_refused_until_listed(
717
+ agents: dict[str, Any], monkeypatch: pytest.MonkeyPatch
718
+ ) -> None:
719
+ monkeypatch.setenv("AUTH_ALLOWED_ACTORS", "billing")
720
+ r = await agents["http"]["concierge"].get(f"{A2A_PATH}/.well-known/agent-card.json")
721
+ assert r.status_code == 403
722
+ assert r.json() == {
723
+ "detail": "Delegated caller concierge is not allowed here (AUTH_ALLOWED_ACTORS)."
724
+ }