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,2162 @@
1
+ # Copyright 2026 Google LLC
2
+ # Modifications Copyright 2026 graph-agents-cli contributors
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # https://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ """A2A protocol layer: the executor, the agent card, and the routes.
17
+
18
+ The executor drives the same invocation path as `/chat` (`ChatRuntime`), so
19
+ the A2A `contextId` is the chat thread id and the same policy, run records
20
+ and thread ownership apply. The card is served at
21
+ `/a2a/<agent_directory>/.well-known/agent-card.json` and JSON-RPC at
22
+ `/a2a/<agent_directory>`; both are guarded by the policy middleware in
23
+ `fast_api_app.py` (`card.read` / `a2a.invoke`), and the card advertises the
24
+ resulting security scheme.
25
+
26
+ A2A tasks belong to the principal that created them: the call context's user
27
+ is the authenticated principal, and the task store keys every task by that
28
+ principal's owner key (its id; for an agent calling for a user, the user's id
29
+ and the agent's, `Principal.owner_key`), so ListTasks, GetTask, CancelTask
30
+ and SubscribeToTask only ever see the caller's own tasks (another principal's
31
+ task id, or another agent's for the same user, reads as "not found"). The one
32
+ exception is the person themselves: a direct (non-delegated) caller also
33
+ reads (`GetTask`), lists (`ListTasks`) and cancels (`CancelTask`) the tasks
34
+ their agents started for them, the owner keys that begin with their id
35
+ (`SUBJECT_TASKS`). Continuing such a task (a message naming its `taskId`)
36
+ and subscribing to it stay with the agent that started it, and an agent never
37
+ sees another agent's tasks. Where the chat runtime has a Postgres database (`CHECKPOINTER=postgres`,
38
+ or a Postgres `DATABASE_URI` under langgraph-server) the tasks are kept there
39
+ (`PostgresTaskStore`): every replica sees them and they survive restarts and
40
+ rollouts, so `GetTask`, `ListTasks`, `CancelTask` and a message naming a
41
+ `taskId` (an approval decision) work on any replica. Otherwise
42
+ (`CHECKPOINTER=memory`) they are in process memory, per process. A task is
43
+ dropped `A2A_TASK_TTL_S` seconds (default 3600) after its last update; 0 keeps
44
+ it until its thread is deleted (in memory: until restart). A task whose run
45
+ ended with its process (a crash, an OOM kill) is failed rather than left
46
+ `working`. Live streams stay with the replica running the task: while its run
47
+ goes on, `SubscribeToTask` and `CancelTask` on another replica are refused
48
+ (-32004 and -32002: the caller may retry, or follow the task with `GetTask`). The
49
+ conversation itself is the thread, which is durable under
50
+ `CHECKPOINTER=postgres`. Deleting the thread (the owner's
51
+ `DELETE /threads/{id}`, or the retention purge under fastapi) drops the tasks
52
+ of that conversation too.
53
+
54
+ Requests: a message needs the user role, text, no empty text part, and at
55
+ most `MAX_MESSAGE_CHARS` characters in all (the `/chat` limit); anything else
56
+ is a JSON-RPC invalid-params error (-32602) before a task is created. A2A 0.3
57
+ requests get the same error codes as 1.0 (an unknown task is -32001, logged
58
+ at INFO; see `LegacyJsonRpcAdapter`). The reply is one `response` artifact:
59
+ `SendMessage` returns it as one text part; `SendStreamingMessage` streams it
60
+ in chunks (the last with `lastChunk`), and the stored task keeps the chunks
61
+ joined into one part. A project with a response schema (`structured.py`)
62
+ answers in JSON: the text part is the answer's exact JSON text, and the
63
+ `response` artifact adds a data part holding the answer (`mediaType`
64
+ `application/json`; a protobuf `Value` holds every number as a double, so
65
+ read the text part where exact integers matter); the card lists
66
+ `application/json` among its output modes.
67
+
68
+ Approvals: a run that pauses before a gated API call moves the task to
69
+ `input-required`; its status message holds a text part saying what waits
70
+ for approval (each call, its body as JSON up to 2,000 characters, and for a
71
+ relayed decision the call that will happen) and a data part
72
+ `{"type": "approval_request", "approval": {...}, "approvals": [...],
73
+ "approval_json": "..."}` (the approvals as `/chat`'s `message.end` has them;
74
+ a `Struct` holds numbers as doubles, so `approval_json` repeats them as exact
75
+ JSON text). The client answers with a message whose data part is
76
+ `{"approval_id": "...", "decision": "approve" | "reject", "comment": "..."}`
77
+ (no text needed; an agent relaying the person's decision adds the approval's
78
+ `digest`), on the same task or on its context alone (a new task, naming the
79
+ waiting one in `referenceTaskIds`): the decision goes through the same checks
80
+ as `POST /threads/{thread_id}/approvals/{approval_id}`, the auth policy's
81
+ `approval.decide` action included (the task's principal is the requester,
82
+ so this works when `requester` is an approver), and the resumed
83
+ run completes the task or pauses it again. A text message while an approval
84
+ is pending, or a decision refused (not an approver, expired, decided
85
+ already), leaves the task `input-required` with the pending approvals, or
86
+ fails it when none is pending any more. However an approval ends (a decision
87
+ over A2A or HTTP and the run it resumed, or its expiry), the requester's
88
+ `input-required` tasks on that thread that wait on it follow
89
+ (`follow_approval`, an `A2A_TASK_LISTENERS` entry): they take the run's
90
+ outcome and say where it continued (a structured run's answer as a new
91
+ `response` artifact, whole). A failed task, and a refused decision,
92
+ carry a data part `{"type": "error", "code": ...}` (`thread_busy`: send the
93
+ message again).
94
+
95
+ The origin extension (`A2A_ORIGIN_EXTENSION`, declared in the card): an agent
96
+ calling for a user may send the user's own words in the message metadata under
97
+ its URI (`{"origin": {"text", "truncated", "hops"}}`; in a decision it relays,
98
+ `approving` too). For a delegated principal only, they go to the run's private
99
+ credentials (`@origin`), capped at `A2A_ORIGIN_MAX_CHARS`; more `hops` than
100
+ `AUTH_MAX_DELEGATION_DEPTH` fails the task. A run a decision resumes acts on
101
+ the words of the request that paused it, which its approval keeps until it is
102
+ decided (`approvals.resume_principal`), not on the words the decision carries.
103
+ `RuntimeTaskStore.save` takes them out of every message before a task is stored.
104
+
105
+ The card's description (and its one skill's) is `A2A_DESCRIPTION`, its
106
+ version `AGENT_VERSION`, its name the mount name `A2A_NAME`.
107
+ """
108
+
109
+ from __future__ import annotations
110
+
111
+ import json
112
+ import logging
113
+ import os
114
+ import re
115
+ import time
116
+ import uuid
117
+ from collections import OrderedDict
118
+ from collections.abc import Awaitable, Callable
119
+ from contextlib import aclosing
120
+ from datetime import UTC, datetime
121
+ from typing import Any
122
+
123
+ from a2a.auth.user import User
124
+ from a2a.helpers import new_task_from_user_message
125
+ from a2a.server.agent_execution import AgentExecutor, RequestContext
126
+ from a2a.server.context import ServerCallContext
127
+ from a2a.server.events import EventQueue
128
+ from a2a.server.request_handlers import DefaultRequestHandler
129
+ from a2a.server.routes import (
130
+ add_a2a_routes_to_fastapi,
131
+ create_agent_card_routes,
132
+ create_jsonrpc_routes,
133
+ )
134
+ from a2a.server.routes.common import DefaultServerCallContextBuilder
135
+ from a2a.server.tasks import InMemoryTaskStore, TaskStore, TaskUpdater
136
+ from a2a.types import (
137
+ AgentCapabilities,
138
+ AgentCard,
139
+ AgentExtension,
140
+ AgentInterface,
141
+ AgentSkill,
142
+ APIKeySecurityScheme,
143
+ Artifact,
144
+ HTTPAuthSecurityScheme,
145
+ Message,
146
+ Part,
147
+ Role,
148
+ SecurityScheme,
149
+ Task,
150
+ TaskState,
151
+ )
152
+ from a2a.types.a2a_pb2 import (
153
+ CancelTaskRequest,
154
+ GetTaskRequest,
155
+ ListTasksRequest,
156
+ ListTasksResponse,
157
+ SendMessageRequest,
158
+ SubscribeToTaskRequest,
159
+ )
160
+ from a2a.utils.constants import (
161
+ AGENT_CARD_WELL_KNOWN_PATH,
162
+ DEFAULT_LIST_TASKS_PAGE_SIZE,
163
+ PROTOCOL_VERSION_1_0,
164
+ )
165
+ from a2a.utils.errors import (
166
+ JSON_RPC_ERROR_CODE_MAP,
167
+ A2AError,
168
+ InternalError,
169
+ InvalidParamsError,
170
+ TaskNotCancelableError,
171
+ TaskNotFoundError,
172
+ UnsupportedOperationError,
173
+ )
174
+ from a2a.utils.task import decode_page_token, encode_page_token
175
+ from fastapi import FastAPI, HTTPException
176
+ from google.protobuf import json_format, struct_pb2
177
+ from starlette.requests import Request
178
+ from starlette.responses import JSONResponse
179
+
180
+ from {{cookiecutter.agent_directory}}.app_utils import a2a_client
181
+ from {{cookiecutter.agent_directory}}.app_utils import chat as chat_runtime
182
+ from {{cookiecutter.agent_directory}}.app_utils.api_client import (
183
+ A2A_ERROR_PART_TYPE,
184
+ A2A_ORIGIN_EXTENSION,
185
+ DEFAULT_A2A_ORIGIN_MAX_CHARS,
186
+ ORIGIN_KEY,
187
+ origin_max_chars,
188
+ )
189
+ from {{cookiecutter.agent_directory}}.app_utils.approvals import (
190
+ CODE_APPROVAL_PENDING,
191
+ COMMENT_MAX_CHARS,
192
+ DECISIONS,
193
+ DIGEST_MAX_CHARS,
194
+ )
195
+ from {{cookiecutter.agent_directory}}.app_utils.auth import (
196
+ CUSTOM,
197
+ JWT,
198
+ OWNER_KEY_SEPARATOR,
199
+ Principal,
200
+ authorize_action,
201
+ check_startup,
202
+ delegation_settings,
203
+ policy_name,
204
+ with_origin,
205
+ )
206
+ from {{cookiecutter.agent_directory}}.app_utils.chat import (
207
+ EVENT_DELTA,
208
+ EVENT_END,
209
+ EVENT_ERROR,
210
+ LANGGRAPH_SERVER,
211
+ RUNTIME,
212
+ STATUS_AWAITING_APPROVAL,
213
+ STRUCTURED_KEY,
214
+ ApprovalError,
215
+ ChatRequest,
216
+ detect_runtime,
217
+ new_error_id,
218
+ unavailable,
219
+ )
220
+ from {{cookiecutter.agent_directory}}.app_utils.content import valid_text
221
+ from {{cookiecutter.agent_directory}}.app_utils.db import (
222
+ Database,
223
+ StorageNotReady,
224
+ is_database_unavailable,
225
+ )
226
+ from {{cookiecutter.agent_directory}}.app_utils.limits import SettingsError
227
+ from {{cookiecutter.agent_directory}}.app_utils.middleware import max_message_chars
228
+ from {{cookiecutter.agent_directory}}.app_utils.structured import answer_text
229
+ from {{cookiecutter.agent_directory}}.app_utils.structured import enabled as structured_enabled
230
+ from {{cookiecutter.agent_directory}}.app_utils.threads import (
231
+ A2A_TASK_LISTENERS,
232
+ DELETE_LISTENERS,
233
+ OUTCOME_COMPLETED,
234
+ OUTCOME_FAILED,
235
+ OUTCOME_INPUT_REQUIRED,
236
+ THREAD_BUSY,
237
+ ApprovalOutcome,
238
+ ThreadBusy,
239
+ )
240
+
241
+ logger = logging.getLogger(__name__)
242
+
243
+ # Mount name for the A2A endpoint: the agent directory, so it matches the
244
+ # `graph-agents-cli run --mode a2a` default.
245
+ A2A_NAME = os.environ.get("A2A_NAME") or "{{cookiecutter.agent_directory}}"
246
+ A2A_RPC_PATH = f"/a2a/{A2A_NAME}"
247
+ A2A_CARD_PATH = f"{A2A_RPC_PATH}{AGENT_CARD_WELL_KNOWN_PATH}"
248
+ DEFAULT_DESCRIPTION = "{{cookiecutter.project_name}}: a LangGraph agent served over the A2A protocol."
249
+ DEFAULT_SKILL_DESCRIPTION = "Hold a conversation with the agent."
250
+ # Set by the request handler for the executor: whether the caller streams the reply.
251
+ STREAMING_STATE_KEY = "a2a_streaming"
252
+ # The media type of a structured answer's data part (and of the card's output mode).
253
+ JSON_MEDIA_TYPE = "application/json"
254
+ # Whether this agent's A2A client passes the user's own words on to the agents it calls
255
+ # (`A2A_FORWARD_ORIGIN`, `a2a_client.py`): `auto` sends them only to a peer whose card
256
+ # declares the origin extension, and `off` never does.
257
+ DEFAULT_A2A_FORWARD_ORIGIN = a2a_client.DEFAULT_A2A_FORWARD_ORIGIN
258
+ # The card's description of the origin extension (`api_client.A2A_ORIGIN_EXTENSION`).
259
+ ORIGIN_EXTENSION_DESCRIPTION = (
260
+ "graph-agents-cli origin: an agent calling for a user forwards, in the message metadata "
261
+ "under this URI, the user's own words (origin: text, truncated, hops), and in a decision "
262
+ "it relays the approval it is about (approving). Never stored with the task."
263
+ )
264
+
265
+
266
+ def card_description() -> str | None:
267
+ """`A2A_DESCRIPTION`: what the agent card (and its chat skill) says the agent does."""
268
+ return (os.environ.get("A2A_DESCRIPTION") or "").strip() or None
269
+
270
+
271
+ def advertised_base_url() -> str:
272
+ """Base URL for the agent card: APP_URL, else the host and port we bind.
273
+
274
+ The chart sets APP_URL from `appUrl` or the gateway/ingress hostname; a
275
+ deployment without one (APP_ENV other than dev) is warned about, because
276
+ A2A clients dial the card's URL, not the URL they fetched the card from.
277
+ """
278
+ if app_url := os.environ.get("APP_URL"):
279
+ return app_url.rstrip("/")
280
+ host = os.environ.get("HOST", "127.0.0.1")
281
+ if host in ("0.0.0.0", "::", ""): # what we bind is not what clients dial
282
+ host = "127.0.0.1"
283
+ fallback = f"http://{host}:{os.environ.get('PORT', '8000')}"
284
+ if (os.environ.get("APP_ENV") or "dev") != "dev":
285
+ logger.warning(
286
+ "APP_URL is not set: the A2A agent card advertises %s (the bind address), which "
287
+ "remote clients cannot reach. Set appUrl (or a gateway/ingress hostname) in the "
288
+ "chart values, or APP_URL in the environment.",
289
+ fallback,
290
+ )
291
+ return fallback
292
+
293
+
294
+ DEFAULT_TASK_TTL_S = 3600
295
+ # The same rule as the chat API's `thread_id`: the A2A contextId becomes the thread id.
296
+ CONTEXT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_.:-]{1,128}$")
297
+
298
+
299
+ def task_ttl_s() -> int:
300
+ """`A2A_TASK_TTL_S`: seconds a task is kept after its last update; 0 = no expiry.
301
+
302
+ Without expiry a task is kept until its thread is deleted (in memory: until restart).
303
+
304
+ Anything but a whole number >= 0 is a `SettingsError`, which the app's
305
+ startup settings check reports with every other bad setting.
306
+ """
307
+ raw = (os.environ.get("A2A_TASK_TTL_S") or "").strip()
308
+ if not raw:
309
+ return DEFAULT_TASK_TTL_S
310
+ try:
311
+ value = int(raw)
312
+ except ValueError:
313
+ raise SettingsError(f"A2A_TASK_TTL_S={raw!r} is not a whole number of seconds.") from None
314
+ if value < 0:
315
+ raise SettingsError(f"A2A_TASK_TTL_S={value} must be >= 0.")
316
+ return value
317
+
318
+
319
+ # The A2A operations through which a person (a direct caller) reaches the tasks their
320
+ # agents started for them, besides their own (the owner decision of 2026-09-28).
321
+ SUBJECT_TASKS = ("GetTask", "ListTasks", "CancelTask")
322
+
323
+
324
+ class PrincipalUser(User):
325
+ """The authenticated principal as an A2A user: its owner key is the task owner.
326
+
327
+ The key is the principal's id for a direct caller, and its subject and
328
+ actor for an agent calling for a user (`Principal.owner_key`): an agent's
329
+ tasks are its own, and another agent acting for the same user never sees
330
+ them. Direct callers' keys are their ids, as before 0.3.
331
+
332
+ `subject` is set for a direct caller only (its id): the person, who also
333
+ reads, lists and cancels the tasks their agents started for them
334
+ (`SUBJECT_TASKS`, the owner keys `subject_owns`).
335
+ """
336
+
337
+ def __init__(self, principal_id: str, *, subject: str | None = None) -> None:
338
+ self._principal_id = principal_id
339
+ self.subject = subject
340
+
341
+ @property
342
+ def is_authenticated(self) -> bool:
343
+ return True
344
+
345
+ @property
346
+ def user_name(self) -> str:
347
+ return self._principal_id
348
+
349
+
350
+ def subject_owns(subject: str, owner: str) -> bool:
351
+ """Whether the task owner key `owner` is `subject`'s own, or one of its agents' for it.
352
+
353
+ An agent's key is the subject, the separator (which no id holds) and the
354
+ agent: so a subject never matches another subject's keys, whatever they are.
355
+ """
356
+ return owner == subject or owner.startswith(f"{subject}{OWNER_KEY_SEPARATOR}")
357
+
358
+
359
+ def task_subject(context: ServerCallContext) -> str | None:
360
+ """The person a call context reaches their agents' tasks for, or None (an agent's call)."""
361
+ user = context.user
362
+ if not isinstance(user, PrincipalUser) or not user.subject:
363
+ return None
364
+ return user.subject
365
+
366
+
367
+ def owner_context(context: ServerCallContext, owner: str) -> ServerCallContext:
368
+ """`context` acting on the tasks of the owner key `owner` (the call's state is shared).
369
+
370
+ What a person's GetTask and CancelTask run under for a task their agent
371
+ started: every read and write of the store is then that task's own, so a
372
+ canceled task is saved where it is, never copied under the person's key.
373
+ """
374
+ return ServerCallContext(
375
+ state=context.state,
376
+ user=PrincipalUser(owner),
377
+ tenant=context.tenant,
378
+ requested_extensions=context.requested_extensions,
379
+ )
380
+
381
+
382
+ def task_owner(context: ServerCallContext) -> str:
383
+ """The task store's owner key. Fails closed without an authenticated principal."""
384
+ user = context.user
385
+ if not isinstance(user, PrincipalUser) or not user.user_name:
386
+ raise PermissionError("A2A task access without an authenticated principal")
387
+ return user.user_name
388
+
389
+
390
+ class PolicyContextBuilder(DefaultServerCallContextBuilder):
391
+ """The default call context plus the principal set by the policy middleware.
392
+
393
+ The context's user is that principal, so the task store scopes tasks per principal.
394
+ """
395
+
396
+ def build(self, request: Request) -> ServerCallContext:
397
+ context = super().build(request)
398
+ context.state["principal"] = getattr(request.state, "principal", None)
399
+ # The credential was checked by the middleware; keep it out of the call
400
+ # context, which the executor and any code it calls can see.
401
+ headers = context.state.get("headers")
402
+ if isinstance(headers, dict):
403
+ for name in ("authorization", "proxy-authorization", "cookie"):
404
+ headers.pop(name, None)
405
+ return context
406
+
407
+ def build_user(self, request: Request) -> User:
408
+ principal = getattr(request.state, "principal", None)
409
+ if not isinstance(principal, Principal) or not principal.id:
410
+ # The middleware authenticates every A2A request before it gets here.
411
+ raise PermissionError("A2A request without an authenticated principal")
412
+ return PrincipalUser(
413
+ principal.owner_key(), subject=None if principal.delegated else principal.id
414
+ )
415
+
416
+
417
+ def context_key(context_id: str) -> str:
418
+ """A contextId as the thread id it names (UUIDs canonical under langgraph-server)."""
419
+ if detect_runtime() == LANGGRAPH_SERVER:
420
+ try:
421
+ return str(uuid.UUID(context_id))
422
+ except ValueError:
423
+ return context_id
424
+ return context_id
425
+
426
+
427
+ def _plain_text(part: Part) -> bool:
428
+ return (
429
+ part.HasField("text")
430
+ and not part.HasField("metadata")
431
+ and not part.media_type
432
+ and not part.filename
433
+ )
434
+
435
+
436
+ def coalesce_text_parts(task: Task) -> Task:
437
+ """`task` with each artifact's runs of plain text parts joined into one part.
438
+
439
+ A streamed reply arrives as one part per chunk; a client reading the
440
+ stored task (`GetTask`, `ListTasks`) gets the text in one piece. The text
441
+ is unchanged. Returns `task` itself when nothing needs joining.
442
+ """
443
+ if not any(len(artifact.parts) > 1 for artifact in task.artifacts):
444
+ return task
445
+ joined = Task()
446
+ joined.CopyFrom(task)
447
+ for artifact in joined.artifacts:
448
+ parts: list[Part] = []
449
+ for part in artifact.parts:
450
+ if parts and _plain_text(part) and _plain_text(parts[-1]):
451
+ parts[-1].text += part.text
452
+ else:
453
+ copy = Part()
454
+ copy.CopyFrom(part)
455
+ parts.append(copy)
456
+ del artifact.parts[:]
457
+ artifact.parts.extend(parts)
458
+ return joined
459
+
460
+
461
+ class ExpiringTaskStore(TaskStore):
462
+ """The SDK's in-memory task store, scoped by `task_owner`, with TTL eviction.
463
+
464
+ A task is evicted `ttl_s` seconds after its last save (`ttl_s <= 0`
465
+ disables eviction); expired tasks are dropped on access and by a sweep
466
+ that runs at most once a minute. Per process: replicas do not share it.
467
+ Tasks are also indexed by conversation (`context_id`), so deleting a
468
+ thread drops its tasks (`delete_context`), whoever created them.
469
+ """
470
+
471
+ def __init__(self, ttl_s: float, *, clock: Callable[[], float] = time.monotonic) -> None:
472
+ self.ttl_s = ttl_s
473
+ self._clock = clock
474
+ self._inner = InMemoryTaskStore(owner_resolver=task_owner)
475
+ self._saved_at: dict[str, dict[str, float]] = {}
476
+ # context key -> {(owner, task id)}, and (owner, task id) -> context key
477
+ self._by_context: dict[str, set[tuple[str, str]]] = {}
478
+ self._context_of: dict[tuple[str, str], str] = {}
479
+ self._sweep_every = max(1.0, min(60.0, ttl_s / 2)) if ttl_s > 0 else 0.0
480
+ self._next_sweep = 0.0
481
+
482
+ @staticmethod
483
+ def _context_for(owner: str) -> ServerCallContext:
484
+ return ServerCallContext(user=PrincipalUser(owner))
485
+
486
+ def _expired(self, owner: str, task_id: str, now: float) -> bool:
487
+ saved = self._saved_at.get(owner, {}).get(task_id)
488
+ return self.ttl_s > 0 and saved is not None and now - saved >= self.ttl_s
489
+
490
+ def _forget(self, owner: str, task_id: str) -> None:
491
+ tasks = self._saved_at.get(owner)
492
+ if tasks is not None:
493
+ tasks.pop(task_id, None)
494
+ if not tasks:
495
+ self._saved_at.pop(owner, None)
496
+ key = self._context_of.pop((owner, task_id), None)
497
+ if key is not None:
498
+ members = self._by_context.get(key)
499
+ if members is not None:
500
+ members.discard((owner, task_id))
501
+ if not members:
502
+ self._by_context.pop(key, None)
503
+
504
+ async def _evict_expired(self, owner: str | None = None) -> None:
505
+ """Drop the expired tasks of `owner` (of every owner when None)."""
506
+ if self.ttl_s <= 0:
507
+ return
508
+ now = self._clock()
509
+ for name in [owner] if owner is not None else list(self._saved_at):
510
+ expired = [t for t in list(self._saved_at.get(name, {})) if self._expired(name, t, now)]
511
+ for task_id in expired:
512
+ await self._inner.delete(task_id, self._context_for(name))
513
+ self._forget(name, task_id)
514
+
515
+ async def _maybe_sweep(self) -> None:
516
+ now = self._clock()
517
+ if self.ttl_s > 0 and now >= self._next_sweep:
518
+ self._next_sweep = now + self._sweep_every
519
+ await self._evict_expired()
520
+
521
+ async def save(self, task: Task, context: ServerCallContext) -> None:
522
+ owner = task_owner(context)
523
+ await self._maybe_sweep()
524
+ await self._inner.save(coalesce_text_parts(task), context)
525
+ self._saved_at.setdefault(owner, {})[task.id] = self._clock()
526
+ if task.context_id and (owner, task.id) not in self._context_of:
527
+ key = context_key(task.context_id)
528
+ self._context_of[(owner, task.id)] = key
529
+ self._by_context.setdefault(key, set()).add((owner, task.id))
530
+
531
+ async def get(self, task_id: str, context: ServerCallContext) -> Task | None:
532
+ owner = task_owner(context)
533
+ await self._maybe_sweep()
534
+ await self._evict_expired(owner)
535
+ return await self._inner.get(task_id, context)
536
+
537
+ async def list(self, params: ListTasksRequest, context: ServerCallContext) -> ListTasksResponse:
538
+ """The caller's tasks; for a person (a direct caller), their agents' for them too."""
539
+ owner = task_owner(context)
540
+ await self._maybe_sweep()
541
+ subject = task_subject(context)
542
+ if subject is None:
543
+ await self._evict_expired(owner)
544
+ return await self._inner.list(params, context)
545
+ # Every owner key of the subject's, filtered as the SDK's store filters one owner's
546
+ # tasks, then ordered and paged together as it orders and pages them.
547
+ every = ListTasksRequest()
548
+ every.CopyFrom(params)
549
+ every.ClearField("page_token")
550
+ every.page_size = _EVERY_TASK
551
+ tasks: list[Task] = []
552
+ for key in [key for key in list(self._saved_at) if subject_owns(subject, key)]:
553
+ await self._evict_expired(key)
554
+ tasks.extend((await self._inner.list(every, self._context_for(key))).tasks)
555
+ return _page(tasks, params)
556
+
557
+ async def owner_for_subject(self, task_id: str, subject: str) -> str | None:
558
+ """The owner key of `task_id` among `subject`'s own and its agents' keys, or None."""
559
+ await self._maybe_sweep()
560
+ keys = sorted(
561
+ (key for key in list(self._saved_at) if subject_owns(subject, key)),
562
+ key=lambda key: key != subject, # the person's own task first
563
+ )
564
+ for key in keys:
565
+ await self._evict_expired(key)
566
+ if task_id in self._saved_at.get(key, {}):
567
+ return key
568
+ return None
569
+
570
+ async def delete(self, task_id: str, context: ServerCallContext) -> None:
571
+ owner = task_owner(context)
572
+ await self._inner.delete(task_id, context)
573
+ self._forget(owner, task_id)
574
+
575
+ async def delete_context(self, thread_id: str) -> int:
576
+ """Drop every task of the conversation `thread_id` (any owner); how many were dropped."""
577
+ members = list(self._by_context.get(context_key(thread_id), ()))
578
+ for owner, task_id in members:
579
+ await self._inner.delete(task_id, self._context_for(owner))
580
+ self._forget(owner, task_id)
581
+ return len(members)
582
+
583
+ async def follow(self, outcome: ApprovalOutcome) -> int:
584
+ """The conversation's tasks waiting on an approval take its outcome (`follows`)."""
585
+ count = 0
586
+ for owner, task_id in list(self._by_context.get(context_key(outcome.thread_id), ())):
587
+ context = self._context_for(owner)
588
+ task = await self.get(task_id, context)
589
+ if task is not None and follows(task, owner, outcome):
590
+ await self.save(followed(task, outcome), context)
591
+ count += 1
592
+ return count
593
+
594
+
595
+ # A page size no store call reaches: every task of an owner, for `_page` to page them.
596
+ _EVERY_TASK = 2**31 - 1
597
+
598
+
599
+ def _task_order(task: Task) -> tuple[bool, str, str]:
600
+ """The SDK in-memory store's ListTasks order key (sorted in reverse: newest status first)."""
601
+ stamped = task.HasField("status") and task.status.HasField("timestamp")
602
+ return (stamped, task.status.timestamp.ToJsonString() if stamped else "", task.id)
603
+
604
+
605
+ def _page(tasks: list[Task], params: ListTasksRequest) -> ListTasksResponse:
606
+ """Filtered `tasks` ordered and paged as the SDK's in-memory store pages one owner's."""
607
+ tasks = sorted(tasks, key=_task_order, reverse=True)
608
+ start = 0
609
+ if params.page_token:
610
+ start_id = decode_page_token(params.page_token)
611
+ for index, task in enumerate(tasks):
612
+ if task.id == start_id:
613
+ start = index
614
+ break
615
+ else:
616
+ raise InvalidParamsError(f"Invalid page token: {params.page_token}")
617
+ page_size = params.page_size or DEFAULT_LIST_TASKS_PAGE_SIZE
618
+ end = start + page_size
619
+ return ListTasksResponse(
620
+ next_page_token=encode_page_token(tasks[end].id) if end < len(tasks) else None,
621
+ tasks=tasks[start:end],
622
+ total_size=len(tasks),
623
+ page_size=page_size,
624
+ )
625
+
626
+
627
+ # Task states whose run is still going (or about to start). A task left in one of
628
+ # them by a process that died is failed by the Postgres store's sweep once its
629
+ # thread's run lease has expired (`chat.RECONCILE_GRACE_S` after its last save).
630
+ RUNNING_STATES = frozenset({TaskState.TASK_STATE_SUBMITTED, TaskState.TASK_STATE_WORKING})
631
+ # The Postgres store's sweep (expired tasks, tasks of dead runs): at most this often
632
+ # per process, on access.
633
+ TASK_SWEEP_INTERVAL_S = 60.0
634
+ # A save that changes only the reply (a streamed chunk: the status is the same) is
635
+ # written at most this often per task; any status change is written at once, whole.
636
+ CHUNK_SAVE_INTERVAL_S = 1.0
637
+ _CHUNK_TRACKING_CAP = 10_000
638
+ _ORPHANS_PER_SWEEP = 200
639
+ INTERRUPTED_TASK_TEXT = (
640
+ "This task stopped: the agent process running it ended before it finished. "
641
+ "Send the message again."
642
+ )
643
+
644
+
645
+ def _status_at(task: Task) -> datetime | None:
646
+ if not task.status.HasField("timestamp"):
647
+ return None
648
+ return task.status.timestamp.ToDatetime(tzinfo=UTC)
649
+
650
+
651
+ # Postgres stores no U+0000, in a TEXT column or in jsonb (escaped or not). A
652
+ # task can hold one wherever text comes from outside: a user's message, a
653
+ # contextId, a tool's output the reply repeats. Such a string is stored with
654
+ # U+FFFD in its place; the save must not fail, since the run (and whatever
655
+ # its tools did) goes on regardless.
656
+ _NUL, _NUL_STORED = "\x00", "\ufffd"
657
+
658
+
659
+ def _text(value: str) -> str:
660
+ """`value` as a TEXT column holds it (a U+0000 becomes U+FFFD)."""
661
+ return value.replace(_NUL, _NUL_STORED) if _NUL in value else value
662
+
663
+
664
+ def _storable(value: Any) -> Any:
665
+ """A parsed JSON value with `_text` applied to every string in it, keys included."""
666
+ if isinstance(value, str):
667
+ return _text(value)
668
+ if isinstance(value, dict):
669
+ return {_text(key): _storable(item) for key, item in value.items()}
670
+ if isinstance(value, list):
671
+ return [_storable(item) for item in value]
672
+ return value
673
+
674
+
675
+ def _task_json(task: Task) -> str:
676
+ data = json_format.MessageToDict(task)
677
+ stored = json.dumps(data)
678
+ # json.dumps writes a U+0000 as the escape \u0000, so a task without one is
679
+ # written as is; an escaped backslash followed by "u0000" only costs a walk.
680
+ if "\\u0000" in stored:
681
+ stored = json.dumps(_storable(data))
682
+ return stored
683
+
684
+
685
+ def _task_from(value: Any) -> Task:
686
+ task = Task()
687
+ data = json.loads(value) if isinstance(value, str | bytes) else value
688
+ # A newer SDK on another replica (a rolling upgrade) may write fields this one lacks.
689
+ json_format.ParseDict(data, task, ignore_unknown_fields=True)
690
+ return task
691
+
692
+
693
+ class PostgresTaskStore(TaskStore):
694
+ """A2A tasks in the app's Postgres database, shared by every replica, kept across restarts.
695
+
696
+ Table `a2a_tasks` (`agent_a2a_tasks` under langgraph-server), created with
697
+ the app tables (`db.py`). The same contract as `ExpiringTaskStore`: tasks
698
+ are scoped by `task_owner` (the table's key is the owner and the task id,
699
+ so another principal's task reads as not found), a task is invisible
700
+ `ttl_s` seconds after its last save and deleted by a sweep (`ttl_s <= 0`:
701
+ kept until its thread is deleted), and deleting a thread deletes its tasks
702
+ (`delete_context`), whoever created them, for every replica. The TTL runs
703
+ on the database clock, so replicas agree on it.
704
+
705
+ The sweep also ends tasks whose run died with its process (a crash, an OOM
706
+ kill, a lost node): a task still `submitted` or `working` whose thread has
707
+ had no live run lease since `chat.RECONCILE_GRACE_S` after its last save is
708
+ failed with a message saying so, instead of looking busy until it expires.
709
+
710
+ A U+0000, which Postgres cannot store, is stored (and read back) as U+FFFD,
711
+ in the task and in the ids a request names (`_text`, `_storable`); the
712
+ owner is used as it is, so two principals never share a key.
713
+
714
+ Database failures surface as the app's 503 (`unavailable`, logged with an
715
+ error id), never as the database's own message, which the JSON-RPC layer
716
+ would otherwise pass to the caller.
717
+ """
718
+
719
+ def __init__(
720
+ self, ttl_s: float, db: Database, *, clock: Callable[[], float] = time.monotonic
721
+ ) -> None:
722
+ self.ttl_s = ttl_s
723
+ self.db = db
724
+ self.table = db.a2a_tasks_table
725
+ self.locks_table = db.locks_table
726
+ self._clock = clock
727
+ self._next_sweep = 0.0
728
+ # (owner, task id) -> (status as saved, when): the last write of a running task.
729
+ self._written: OrderedDict[tuple[str, str], tuple[bytes, float]] = OrderedDict()
730
+
731
+ def _visible(self) -> tuple[str, tuple[float, float]]:
732
+ """The SQL condition (and its parameters) of a task that has not expired."""
733
+ ttl = float(self.ttl_s)
734
+ return "(%s <= 0 OR updated_at > now() - make_interval(secs => %s))", (ttl, ttl)
735
+
736
+ async def _db(self, call: Awaitable[Any]) -> Any:
737
+ """Run a database call; an unreachable database is a 503, anything else an internal error."""
738
+ try:
739
+ return await call
740
+ except (A2AError, HTTPException):
741
+ raise
742
+ except Exception as exc:
743
+ if is_database_unavailable(exc):
744
+ raise unavailable("Database", exc) from exc
745
+ error_id = new_error_id()
746
+ logger.error("A2A task store failed (error_id=%s)", error_id, exc_info=exc)
747
+ raise InternalError(message=f"The task store failed. Reference: {error_id}.") from None
748
+
749
+ async def save(self, task: Task, context: ServerCallContext) -> None:
750
+ owner = task_owner(context)
751
+ await self._maybe_sweep()
752
+ task = coalesce_text_parts(task)
753
+ key = (owner, task.id)
754
+ status = task.status.SerializeToString(deterministic=True)
755
+ now = self._clock()
756
+ last = self._written.get(key)
757
+ if last is not None and last[0] == status and now - last[1] < CHUNK_SAVE_INTERVAL_S:
758
+ # A reply chunk: the next save of this task (its end, at the latest) writes it.
759
+ return
760
+ await self._db(
761
+ self.db.execute(
762
+ f"""
763
+ INSERT INTO {self.table} (owner, task_id, context_id, thread_id, state,
764
+ status_at, task, updated_at)
765
+ VALUES (%s, %s, %s, %s, %s, %s, %s::jsonb, now())
766
+ ON CONFLICT (owner, task_id) DO UPDATE SET
767
+ context_id = EXCLUDED.context_id, thread_id = EXCLUDED.thread_id,
768
+ state = EXCLUDED.state, status_at = EXCLUDED.status_at,
769
+ task = EXCLUDED.task, updated_at = now()
770
+ """,
771
+ (
772
+ owner,
773
+ _text(task.id),
774
+ _text(task.context_id),
775
+ _text(context_key(task.context_id)) if task.context_id else "",
776
+ TaskState.Name(task.status.state),
777
+ _status_at(task),
778
+ _task_json(task),
779
+ ),
780
+ )
781
+ )
782
+ if task.status.state in RUNNING_STATES:
783
+ self._written[key] = (status, now)
784
+ self._written.move_to_end(key)
785
+ while len(self._written) > _CHUNK_TRACKING_CAP:
786
+ self._written.popitem(last=False)
787
+ else:
788
+ self._written.pop(key, None)
789
+
790
+ async def get(self, task_id: str, context: ServerCallContext) -> Task | None:
791
+ owner = task_owner(context)
792
+ await self._maybe_sweep()
793
+ visible, ttl = self._visible()
794
+ row = await self._db(
795
+ self.db.fetchone(
796
+ f"SELECT task FROM {self.table} WHERE owner = %s AND task_id = %s AND {visible}",
797
+ (owner, _text(task_id), *ttl),
798
+ )
799
+ )
800
+ return _task_from(row["task"]) if row else None
801
+
802
+ @staticmethod
803
+ def _owned(owner: str, subject: str | None) -> tuple[str, list[Any]]:
804
+ """The SQL condition (and its parameters) of the tasks a caller lists: its owner key's,
805
+ and for a person (`subject`) also their agents' (the keys that begin with the subject
806
+ and the separator; compared exactly, never with LIKE or a collation's range)."""
807
+ if subject is None:
808
+ return "owner = %s", [owner]
809
+ prefix = f"{subject}{OWNER_KEY_SEPARATOR}"
810
+ return "(owner = %s OR left(owner, %s) = %s)", [subject, len(prefix), prefix]
811
+
812
+ async def list(self, params: ListTasksRequest, context: ServerCallContext) -> ListTasksResponse:
813
+ """The owner's tasks, newest status first, as the SDK's stores order and page them.
814
+
815
+ For a person (a direct caller), their agents' tasks for them too.
816
+ """
817
+ owner = task_owner(context)
818
+ await self._maybe_sweep()
819
+ visible, ttl = self._visible()
820
+ owned, owned_args = self._owned(owner, task_subject(context))
821
+ where = [owned, visible]
822
+ args: list[Any] = [*owned_args, *ttl]
823
+ if params.context_id:
824
+ where.append("context_id = %s")
825
+ args.append(_text(params.context_id))
826
+ if params.status:
827
+ where.append("state = %s")
828
+ args.append(TaskState.Name(params.status))
829
+ if params.HasField("status_timestamp_after"):
830
+ where.append("status_at >= %s")
831
+ args.append(params.status_timestamp_after.ToDatetime(tzinfo=UTC))
832
+ condition = " AND ".join(where)
833
+ counted = await self._db(
834
+ self.db.fetchone(f"SELECT count(*) AS n FROM {self.table} WHERE {condition}", args)
835
+ )
836
+ total = int(counted["n"]) if counted else 0
837
+ page_where, page_args = list(where), list(args)
838
+ if params.page_token:
839
+ start_id = _text(decode_page_token(params.page_token))
840
+ start = await self._db(
841
+ self.db.fetchone(
842
+ f"SELECT status_at FROM {self.table} "
843
+ f"WHERE {owned} AND task_id = %s AND {visible}",
844
+ (*owned_args, start_id, *ttl),
845
+ )
846
+ )
847
+ if start is None:
848
+ raise InvalidParamsError(f"Invalid page token: {params.page_token}")
849
+ if start["status_at"] is not None:
850
+ page_where.append(
851
+ "((status_at = %s AND task_id <= %s) OR status_at < %s OR status_at IS NULL)"
852
+ )
853
+ page_args += [start["status_at"], start_id, start["status_at"]]
854
+ else:
855
+ page_where.append("(status_at IS NULL AND task_id <= %s)")
856
+ page_args.append(start_id)
857
+ page_size = params.page_size or DEFAULT_LIST_TASKS_PAGE_SIZE
858
+ rows = await self._db(
859
+ self.db.fetchall(
860
+ f"SELECT task_id, task FROM {self.table} WHERE {' AND '.join(page_where)} "
861
+ "ORDER BY (status_at IS NULL), status_at DESC, task_id DESC LIMIT %s",
862
+ [*page_args, page_size + 1],
863
+ )
864
+ )
865
+ next_token = (
866
+ encode_page_token(rows[page_size]["task_id"]) if len(rows) > page_size else None
867
+ )
868
+ return ListTasksResponse(
869
+ tasks=[_task_from(row["task"]) for row in rows[:page_size]],
870
+ total_size=total,
871
+ next_page_token=next_token,
872
+ page_size=page_size,
873
+ )
874
+
875
+ async def owner_for_subject(self, task_id: str, subject: str) -> str | None:
876
+ """The owner key of `task_id` among `subject`'s own and its agents' keys, or None."""
877
+ await self._maybe_sweep()
878
+ visible, ttl = self._visible()
879
+ owned, owned_args = self._owned(subject, subject)
880
+ row = await self._db(
881
+ self.db.fetchone(
882
+ f"SELECT owner FROM {self.table} WHERE task_id = %s AND {owned} AND {visible} "
883
+ "ORDER BY (owner = %s) DESC LIMIT 1",
884
+ (_text(task_id), *owned_args, *ttl, subject),
885
+ )
886
+ )
887
+ return str(row["owner"]) if row else None
888
+
889
+ async def delete(self, task_id: str, context: ServerCallContext) -> None:
890
+ owner = task_owner(context)
891
+ self._written.pop((owner, task_id), None)
892
+ await self._db(
893
+ self.db.execute(
894
+ f"DELETE FROM {self.table} WHERE owner = %s AND task_id = %s",
895
+ (owner, _text(task_id)),
896
+ )
897
+ )
898
+
899
+ async def delete_context(self, thread_id: str) -> int:
900
+ """Drop every task of the conversation `thread_id` (any owner); how many were dropped."""
901
+ rows = await self.db.fetchall(
902
+ f"DELETE FROM {self.table} WHERE thread_id = %s RETURNING owner, task_id",
903
+ (_text(context_key(thread_id)),),
904
+ )
905
+ for row in rows:
906
+ self._written.pop((row["owner"], row["task_id"]), None)
907
+ return len(rows)
908
+
909
+ async def follow(self, outcome: ApprovalOutcome) -> int:
910
+ """The conversation's tasks waiting on an approval take its outcome (`follows`).
911
+
912
+ The candidates are the conversation's `input-required` tasks that the
913
+ decision named or whose approval request lists the approval (a jsonb
914
+ containment); each is rewritten only while it is still waiting, under
915
+ its own owner key.
916
+ """
917
+ visible, ttl = self._visible()
918
+ listed = json.dumps(
919
+ {
920
+ "status": {
921
+ "message": {
922
+ "parts": [{"data": {"approvals": [{"approval_id": outcome.approval_id}]}}]
923
+ }
924
+ }
925
+ }
926
+ )
927
+ rows = await self.db.fetchall(
928
+ f"""
929
+ SELECT owner, task_id, task FROM {self.table}
930
+ WHERE thread_id = %s AND state = 'TASK_STATE_INPUT_REQUIRED' AND {visible}
931
+ AND (task_id = ANY(%s) OR task @> %s::jsonb)
932
+ """,
933
+ (
934
+ _text(context_key(outcome.thread_id)),
935
+ *ttl,
936
+ [_text(t) for t in outcome.references],
937
+ listed,
938
+ ),
939
+ )
940
+ count = 0
941
+ for row in rows:
942
+ task = _task_from(row["task"])
943
+ if not follows(task, str(row["owner"]), outcome):
944
+ continue
945
+ new = followed(task, outcome)
946
+ updated = await self.db.fetchone(
947
+ f"""
948
+ UPDATE {self.table}
949
+ SET state = %s, status_at = %s, task = %s::jsonb, updated_at = now()
950
+ WHERE owner = %s AND task_id = %s AND state = 'TASK_STATE_INPUT_REQUIRED'
951
+ RETURNING task_id
952
+ """,
953
+ (
954
+ TaskState.Name(new.status.state),
955
+ _status_at(new),
956
+ _task_json(new),
957
+ row["owner"],
958
+ row["task_id"],
959
+ ),
960
+ )
961
+ count += updated is not None
962
+ return count
963
+
964
+ async def running_elsewhere(self, task: Task) -> bool:
965
+ """Whether `task`'s run is going on in another process (its thread's live lease is not ours)."""
966
+ if task.status.state not in RUNNING_STATES or not task.context_id:
967
+ return False
968
+ thread_id = context_key(task.context_id)
969
+ if RUNTIME.locks.lease(thread_id) is not None:
970
+ return False
971
+ row = await self._db(
972
+ self.db.fetchone(
973
+ f"SELECT 1 AS held FROM {self.locks_table} "
974
+ "WHERE thread_id = %s AND expires_at > now()",
975
+ (_text(thread_id),),
976
+ )
977
+ )
978
+ return row is not None
979
+
980
+ async def _maybe_sweep(self) -> None:
981
+ now = self._clock()
982
+ if now < self._next_sweep:
983
+ return
984
+ self._next_sweep = now + TASK_SWEEP_INTERVAL_S
985
+ try:
986
+ await self.sweep()
987
+ except Exception as exc: # the request itself answers for an unreachable database
988
+ logger.warning("A2A task sweep failed (%s); retrying later", type(exc).__name__)
989
+
990
+ async def sweep(self) -> tuple[int, int]:
991
+ """Delete expired tasks and fail the tasks of dead runs; (deleted, failed)."""
992
+ deleted = 0
993
+ if self.ttl_s > 0:
994
+ rows = await self.db.fetchall(
995
+ f"DELETE FROM {self.table} "
996
+ "WHERE updated_at <= now() - make_interval(secs => %s) RETURNING task_id",
997
+ (float(self.ttl_s),),
998
+ )
999
+ deleted = len(rows)
1000
+ orphans = await self.db.fetchall(
1001
+ f"""
1002
+ SELECT t.owner, t.task_id, t.task, t.updated_at FROM {self.table} AS t
1003
+ WHERE t.state IN ('TASK_STATE_SUBMITTED', 'TASK_STATE_WORKING')
1004
+ AND t.updated_at < now() - make_interval(secs => %s)
1005
+ AND NOT EXISTS (
1006
+ SELECT 1 FROM {self.locks_table} AS l
1007
+ WHERE l.thread_id = t.thread_id AND l.expires_at > now())
1008
+ ORDER BY t.updated_at LIMIT %s
1009
+ """,
1010
+ (float(chat_runtime.RECONCILE_GRACE_S), _ORPHANS_PER_SWEEP),
1011
+ )
1012
+ failed = 0
1013
+ for row in orphans:
1014
+ task = _task_from(row["task"])
1015
+ if task.status.HasField("message"):
1016
+ task.history.append(task.status.message)
1017
+ task.status.state = TaskState.TASK_STATE_FAILED
1018
+ task.status.message.CopyFrom(
1019
+ Message(
1020
+ message_id=uuid.uuid4().hex,
1021
+ role=Role.ROLE_AGENT,
1022
+ task_id=task.id,
1023
+ context_id=task.context_id,
1024
+ parts=[Part(text=INTERRUPTED_TASK_TEXT)],
1025
+ )
1026
+ )
1027
+ task.status.timestamp.GetCurrentTime()
1028
+ # Only if nothing saved the task since it was read (its run came back to it).
1029
+ updated = await self.db.fetchone(
1030
+ f"""
1031
+ UPDATE {self.table}
1032
+ SET state = %s, status_at = %s, task = %s::jsonb, updated_at = now()
1033
+ WHERE owner = %s AND task_id = %s AND updated_at = %s
1034
+ RETURNING task_id
1035
+ """,
1036
+ (
1037
+ TaskState.Name(task.status.state),
1038
+ _status_at(task),
1039
+ _task_json(task),
1040
+ row["owner"],
1041
+ row["task_id"],
1042
+ row["updated_at"],
1043
+ ),
1044
+ )
1045
+ failed += updated is not None
1046
+ if failed:
1047
+ logger.info("failed %d A2A task(s) whose run ended with its process", failed)
1048
+ return deleted, failed
1049
+
1050
+
1051
+ class RuntimeTaskStore(TaskStore):
1052
+ """The app's A2A task store: Postgres when the chat runtime's database is Postgres.
1053
+
1054
+ That is `CHECKPOINTER=postgres` (fastapi) or a Postgres `DATABASE_URI`
1055
+ (langgraph-server), where run records and approvals live too: the tasks are
1056
+ then shared by every replica and survive restarts (`PostgresTaskStore`).
1057
+ Otherwise they are in process memory (`ExpiringTaskStore`). The choice is
1058
+ made on each call, since the routes are mounted before the runtime starts;
1059
+ while the database is not set up yet, calls answer 503.
1060
+ """
1061
+
1062
+ def __init__(self, ttl_s: float) -> None:
1063
+ self.ttl_s = ttl_s
1064
+ self.memory = ExpiringTaskStore(ttl_s)
1065
+ self._postgres: PostgresTaskStore | None = None
1066
+
1067
+ def postgres(self) -> PostgresTaskStore | None:
1068
+ """The Postgres store of the running runtime's database, or None (memory)."""
1069
+ db = RUNTIME.db
1070
+ if db is None or not db.is_postgres:
1071
+ return None
1072
+ if not RUNTIME.started or RUNTIME.initialising:
1073
+ raise unavailable("Database", StorageNotReady("the database is not set up yet"))
1074
+ if self._postgres is None or self._postgres.db is not db:
1075
+ self._postgres = PostgresTaskStore(self.ttl_s, db)
1076
+ return self._postgres
1077
+
1078
+ def _store(self) -> TaskStore:
1079
+ return self.postgres() or self.memory
1080
+
1081
+ async def save(self, task: Task, context: ServerCallContext) -> None:
1082
+ # The one place every task write passes: the words a calling agent forwarded (the
1083
+ # origin extension's metadata) are never stored.
1084
+ await self._store().save(without_extension(task), context)
1085
+
1086
+ async def get(self, task_id: str, context: ServerCallContext) -> Task | None:
1087
+ return await self._store().get(task_id, context)
1088
+
1089
+ async def follow(self, outcome: ApprovalOutcome) -> int:
1090
+ """The tasks waiting on an approval follow its outcome, in both stores."""
1091
+ count = await self.memory.follow(outcome)
1092
+ store = self.postgres()
1093
+ if store is not None:
1094
+ count += await store.follow(outcome)
1095
+ return count
1096
+
1097
+ async def list(self, params: ListTasksRequest, context: ServerCallContext) -> ListTasksResponse:
1098
+ return await self._store().list(params, context)
1099
+
1100
+ async def owner_for_subject(self, task_id: str, subject: str) -> str | None:
1101
+ return await self._store().owner_for_subject(task_id, subject)
1102
+
1103
+ async def delete(self, task_id: str, context: ServerCallContext) -> None:
1104
+ await self._store().delete(task_id, context)
1105
+
1106
+ async def delete_context(self, thread_id: str) -> int:
1107
+ dropped = await self.memory.delete_context(thread_id)
1108
+ store = self.postgres()
1109
+ if store is not None:
1110
+ dropped += await store.delete_context(thread_id)
1111
+ return dropped
1112
+
1113
+ async def running_elsewhere(self, task: Task) -> bool:
1114
+ store = self.postgres()
1115
+ return store is not None and await store.running_elsewhere(task)
1116
+
1117
+
1118
+ # The stores of the mounted A2A routes (one per app), for `forget_context`.
1119
+ _STORES: list[RuntimeTaskStore] = []
1120
+
1121
+
1122
+ async def forget_context(thread_id: str) -> None:
1123
+ """Drop the A2A tasks of a deleted thread (a `threads.DELETE_LISTENERS` entry)."""
1124
+ dropped = 0
1125
+ for store in list(_STORES):
1126
+ dropped += await store.delete_context(thread_id)
1127
+ if dropped:
1128
+ logger.info("dropped %d A2A task(s) of a deleted thread", dropped)
1129
+
1130
+
1131
+ APPROVAL_REQUEST_TYPE = "approval_request"
1132
+ _APPROVAL_ID_MAX_CHARS = 64
1133
+ # How much of an approval's body the text of an approval request shows.
1134
+ APPROVAL_TEXT_BODY_MAX_CHARS = 2000
1135
+
1136
+
1137
+ # ---------------------------------------------------------------------------
1138
+ # The origin extension: the user's own words, forwarded by the agent calling for them
1139
+ # ---------------------------------------------------------------------------
1140
+
1141
+
1142
+ def _extension_data(message: Message | None) -> dict[str, Any] | None:
1143
+ """What a message's metadata holds under the origin extension's URI, or None."""
1144
+ if message is None or not message.HasField("metadata"):
1145
+ return None
1146
+ try:
1147
+ metadata = json_format.MessageToDict(message.metadata)
1148
+ except Exception:
1149
+ return None
1150
+ data = metadata.get(A2A_ORIGIN_EXTENSION)
1151
+ return data if isinstance(data, dict) else None
1152
+
1153
+
1154
+ def read_origin(message: Message | None, principal: Principal) -> tuple[dict[str, Any] | None, int]:
1155
+ """The origin a calling agent forwarded (`{text, truncated, hops}`), and its `hops`.
1156
+
1157
+ Read for a delegated principal only (a person's own message is their own
1158
+ words), from `message.metadata[A2A_ORIGIN_EXTENSION]["origin"]`. The text is
1159
+ kept at most `A2A_ORIGIN_MAX_CHARS` long (`truncated` then true); a value of
1160
+ another shape is ignored (no origin: `require_user_mentioned` refuses).
1161
+ `hops` counts the agents the words came through (0 without an origin): the
1162
+ caller fails the task past `AUTH_MAX_DELEGATION_DEPTH`.
1163
+ """
1164
+ if not principal.delegated:
1165
+ return None, 0
1166
+ data = _extension_data(message)
1167
+ origin = data.get(ORIGIN_KEY) if data is not None else None
1168
+ if not isinstance(origin, dict) or not isinstance(origin.get("text"), str):
1169
+ return None, 0
1170
+ hops = origin.get("hops")
1171
+ hops = int(hops) if isinstance(hops, int | float) and not isinstance(hops, bool) else 1
1172
+ try:
1173
+ cap = origin_max_chars()
1174
+ except SettingsError: # the startup check refuses it; the default bound holds meanwhile
1175
+ cap = DEFAULT_A2A_ORIGIN_MAX_CHARS
1176
+ text = origin["text"]
1177
+ truncated = origin.get("truncated") is True or len(text) > cap
1178
+ if _has_lone_surrogate(text):
1179
+ return None, max(hops, 1)
1180
+ return {"text": text[:cap], "truncated": truncated, "hops": max(hops, 1)}, max(hops, 1)
1181
+
1182
+
1183
+ def _max_depth() -> int:
1184
+ try:
1185
+ return delegation_settings().max_depth
1186
+ except SettingsError: # the startup check refuses it; fail closed
1187
+ return 1
1188
+
1189
+
1190
+ def without_extension(task: Task) -> Task:
1191
+ """`task` with the origin extension's metadata taken out of every message it holds.
1192
+
1193
+ The user's words a calling agent forwarded (and the `approving` copy of a
1194
+ relayed decision) are never stored: `RuntimeTaskStore.save` passes every
1195
+ task through this. Returns `task` itself when no message holds any.
1196
+ """
1197
+ messages = [task.status.message] if task.status.HasField("message") else []
1198
+ messages.extend(task.history)
1199
+ if not any(
1200
+ m.HasField("metadata") and A2A_ORIGIN_EXTENSION in m.metadata.fields for m in messages
1201
+ ):
1202
+ return task
1203
+ stripped = Task()
1204
+ stripped.CopyFrom(task)
1205
+ held = [stripped.status.message] if stripped.status.HasField("message") else []
1206
+ for message in [*held, *stripped.history]:
1207
+ if message.HasField("metadata") and A2A_ORIGIN_EXTENSION in message.metadata.fields:
1208
+ del message.metadata[A2A_ORIGIN_EXTENSION]
1209
+ if not message.metadata.fields:
1210
+ message.ClearField("metadata")
1211
+ return stripped
1212
+
1213
+
1214
+ # ---------------------------------------------------------------------------
1215
+ # Status messages: what waits for approval, and why a task failed
1216
+ # ---------------------------------------------------------------------------
1217
+
1218
+ ERROR_CODE_UNAUTHENTICATED = "unauthenticated"
1219
+ ERROR_CODE_INVALID_CONTEXT = "invalid_context_id"
1220
+ ERROR_CODE_INVALID_MESSAGE = "invalid_message"
1221
+ ERROR_CODE_TOO_DEEP = "delegation_too_deep"
1222
+ ERROR_CODE_FORBIDDEN = "forbidden"
1223
+ ERROR_CODE_FAILED = "run_failed"
1224
+
1225
+
1226
+ def answer_part(answer: Any) -> Part:
1227
+ """A structured answer as a data part (`mediaType` `application/json`)."""
1228
+ data = struct_pb2.Value()
1229
+ json_format.ParseDict(answer, data)
1230
+ return Part(data=data, media_type=JSON_MEDIA_TYPE)
1231
+
1232
+
1233
+ def error_part(code: str) -> Part:
1234
+ """The data part a failed (or refused) task carries: `{"type": "error", "code": ...}`.
1235
+
1236
+ A client branches on the code (`thread_busy`: send the message again), never
1237
+ on the text.
1238
+ """
1239
+ data = struct_pb2.Value()
1240
+ json_format.ParseDict({"type": A2A_ERROR_PART_TYPE, "code": code}, data)
1241
+ return Part(data=data)
1242
+
1243
+
1244
+ def _json_text(value: Any, limit: int) -> str:
1245
+ text = json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
1246
+ return text if len(text) <= limit else text[: limit - 3] + "..."
1247
+
1248
+
1249
+ def _effect_text(effect: Any) -> str | None:
1250
+ """`orders (via billing) will POST /orders/7/cancel (cancelOrder), as reported by orders`."""
1251
+ if not isinstance(effect, dict):
1252
+ return None
1253
+ via = [str(v) for v in effect.get("via") or [] if v]
1254
+ hops = via[:-1] if via and via[-1] == effect.get("agent") else via
1255
+ who = str(effect.get("agent") or "the agent") + (f" (via {', '.join(hops)})" if hops else "")
1256
+ what = f"{effect.get('method')} {effect.get('path')}"
1257
+ if effect.get("operation_id"):
1258
+ what += f" ({effect['operation_id']})"
1259
+ reported = f", as reported by {via[-1]}" if via else ""
1260
+ return f"{who} will {what}{reported}"
1261
+
1262
+
1263
+ def approval_parts(
1264
+ approvals: list[Any], note: str | None = None, code: str | None = None
1265
+ ) -> list[Part]:
1266
+ """The parts of an input-required status message: what waits for approval, as text and data.
1267
+
1268
+ The data part is `{"type": "approval_request", "approval", "approvals",
1269
+ "approval_json"}`: the approvals as `/chat`'s `message.end` has them, and
1270
+ `approval_json`, the same list as exact JSON text (a `Struct` holds every
1271
+ number as a double: `1` reads `1.0`, and large integers round), which an
1272
+ agent relaying the decision reads. The text names each call, its body
1273
+ (at most `APPROVAL_TEXT_BODY_MAX_CHARS` characters of JSON) and, for a
1274
+ relayed decision, the call that will happen (`effect`). `code`: why a
1275
+ decision sent was refused (an error part too).
1276
+ """
1277
+ approvals = [a for a in approvals if isinstance(a, dict)]
1278
+ lines = [note] if note else []
1279
+ for approval in approvals:
1280
+ what = f"{approval.get('method')} {approval.get('path')}"
1281
+ reason = approval.get("reason")
1282
+ lines.append(
1283
+ f"Waiting for approval {approval.get('approval_id')}: {what}"
1284
+ + (f" ({reason})" if reason else "")
1285
+ + f", until {approval.get('expires_at')}."
1286
+ )
1287
+ if approval.get("body") is not None:
1288
+ lines.append(f" body: {_json_text(approval['body'], APPROVAL_TEXT_BODY_MAX_CHARS)}")
1289
+ effect = _effect_text(approval.get("effect"))
1290
+ if effect:
1291
+ lines.append(f" effect: {effect}.")
1292
+ lines.append(
1293
+ 'Answer with a data part {"approval_id": "...", "decision": "approve"} '
1294
+ '(or "reject"; an optional "comment"; an agent relaying the person\'s decision adds '
1295
+ 'the approval\'s "digest").'
1296
+ )
1297
+ data = struct_pb2.Value()
1298
+ json_format.ParseDict(
1299
+ {
1300
+ "type": APPROVAL_REQUEST_TYPE,
1301
+ "approval": approvals[0] if approvals else None,
1302
+ "approvals": approvals,
1303
+ "approval_json": json.dumps(approvals, ensure_ascii=False, default=str),
1304
+ },
1305
+ data,
1306
+ )
1307
+ parts = [Part(text=valid_text("\n".join(lines))), Part(data=data)]
1308
+ if code:
1309
+ parts.append(error_part(code))
1310
+ return parts
1311
+
1312
+
1313
+ def waits_on(task: Task, approval_id: str) -> bool:
1314
+ """Whether a task's status message lists `approval_id` in its approval request."""
1315
+ if not task.status.HasField("message"):
1316
+ return False
1317
+ for part in task.status.message.parts:
1318
+ data = _part_data(part)
1319
+ if isinstance(data, dict) and data.get("type") == APPROVAL_REQUEST_TYPE:
1320
+ for approval in data.get("approvals") or []:
1321
+ if isinstance(approval, dict) and approval.get("approval_id") == approval_id:
1322
+ return True
1323
+ return False
1324
+
1325
+
1326
+ def owner_is_requester(owner: str, requester_hash: str, requester_actor: str) -> bool:
1327
+ """Whether the task owner key `owner` is an approval's requester (its hashed subject and
1328
+ its actor, "" for a direct one): the approval's record keeps the subject hashed only."""
1329
+ subject, _, actor = owner.partition(OWNER_KEY_SEPARATOR)
1330
+ return actor == (requester_actor or "") and Principal(id=subject).hashed_id() == (
1331
+ requester_hash
1332
+ )
1333
+
1334
+
1335
+ _OUTCOME_STATES = {
1336
+ OUTCOME_COMPLETED: TaskState.TASK_STATE_COMPLETED,
1337
+ OUTCOME_FAILED: TaskState.TASK_STATE_FAILED,
1338
+ OUTCOME_INPUT_REQUIRED: TaskState.TASK_STATE_INPUT_REQUIRED,
1339
+ }
1340
+
1341
+
1342
+ def follows(task: Task, owner: str, outcome: ApprovalOutcome) -> bool:
1343
+ """Whether a stored task takes an approval's outcome (`ApprovalOutcome`).
1344
+
1345
+ It is `input-required`, it is not the task that carried the decision, it
1346
+ belongs to the approval's requester, and the decision named it
1347
+ (`referenceTaskIds`) or its approval request lists the approval.
1348
+ """
1349
+ return (
1350
+ task.status.state == TaskState.TASK_STATE_INPUT_REQUIRED
1351
+ and task.id != outcome.continued_in
1352
+ and owner_is_requester(owner, outcome.requester_hash, outcome.requester_actor)
1353
+ and (task.id in outcome.references or waits_on(task, outcome.approval_id))
1354
+ )
1355
+
1356
+
1357
+ def followed(task: Task, outcome: ApprovalOutcome) -> Task:
1358
+ """`task` in the state an approval's outcome gives it (its old status moves to history).
1359
+
1360
+ A structured run's answer (`outcome.answer`) is added as a new `response`
1361
+ artifact, its JSON text and a data part, as the reply of a decision sent
1362
+ on the task is: the task's last artifact is the answer.
1363
+ """
1364
+ new = Task()
1365
+ new.CopyFrom(task)
1366
+ if new.status.HasField("message"):
1367
+ new.history.append(new.status.message)
1368
+ state = _OUTCOME_STATES.get(outcome.state, TaskState.TASK_STATE_FAILED)
1369
+ if state == TaskState.TASK_STATE_INPUT_REQUIRED and outcome.approvals:
1370
+ parts = approval_parts(list(outcome.approvals), note=outcome.text)
1371
+ else:
1372
+ parts = [Part(text=valid_text(outcome.text))]
1373
+ new.status.state = state
1374
+ new.status.message.CopyFrom(
1375
+ Message(
1376
+ message_id=uuid.uuid4().hex,
1377
+ role=Role.ROLE_AGENT,
1378
+ task_id=new.id,
1379
+ context_id=new.context_id,
1380
+ parts=parts,
1381
+ )
1382
+ )
1383
+ new.status.timestamp.GetCurrentTime()
1384
+ if state == TaskState.TASK_STATE_COMPLETED and outcome.answer is not None:
1385
+ new.artifacts.append(
1386
+ Artifact(
1387
+ artifact_id=uuid.uuid4().hex,
1388
+ name="response",
1389
+ parts=[
1390
+ Part(text=valid_text(answer_text(outcome.answer))),
1391
+ answer_part(outcome.answer),
1392
+ ],
1393
+ )
1394
+ )
1395
+ return new
1396
+
1397
+
1398
+ async def follow_approval(outcome: ApprovalOutcome) -> None:
1399
+ """The tasks waiting on an approval follow its outcome (an `A2A_TASK_LISTENERS` entry)."""
1400
+ followed_count = 0
1401
+ for store in list(_STORES):
1402
+ followed_count += await store.follow(outcome)
1403
+ if followed_count:
1404
+ logger.info(
1405
+ "%d A2A task(s) followed an approval's outcome (%s)", followed_count, outcome.state
1406
+ )
1407
+
1408
+
1409
+ def decision_problem(data: Any) -> str | None:
1410
+ """Why a data part naming an approval is not a valid decision, or None.
1411
+
1412
+ A decision is `{"approval_id": str, "decision": "approve" | "reject",
1413
+ "comment": str (optional), "digest": str (optional; an agent relaying the
1414
+ person's decision sends the approval's digest)}`.
1415
+ """
1416
+ if not isinstance(data, dict):
1417
+ return "An approval decision must be an object."
1418
+ unknown = sorted(set(data) - {"approval_id", "decision", "comment", "digest"})
1419
+ if unknown:
1420
+ return (
1421
+ "An approval decision has only approval_id, decision, comment and digest "
1422
+ f"(not {unknown[0]})."
1423
+ )
1424
+ approval_id = data.get("approval_id")
1425
+ if not isinstance(approval_id, str) or not 0 < len(approval_id) <= _APPROVAL_ID_MAX_CHARS:
1426
+ return "approval_id must be the approval's id."
1427
+ if data.get("decision") not in DECISIONS:
1428
+ return "decision must be 'approve' or 'reject'."
1429
+ comment = data.get("comment")
1430
+ if comment is not None and (not isinstance(comment, str) or len(comment) > COMMENT_MAX_CHARS):
1431
+ return f"comment must be text of at most {COMMENT_MAX_CHARS} characters."
1432
+ digest = data.get("digest")
1433
+ if digest is not None and (not isinstance(digest, str) or len(digest) > DIGEST_MAX_CHARS):
1434
+ return f"digest must be the approval's digest (at most {DIGEST_MAX_CHARS} characters)."
1435
+ if any(_has_lone_surrogate(text) for text in _strings(data)):
1436
+ return "The approval decision is not valid Unicode text (an unpaired surrogate)."
1437
+ return None
1438
+
1439
+
1440
+ def _names_approval(data: Any) -> bool:
1441
+ """Whether a data part is meant as an approval decision (it names an approval)."""
1442
+ return isinstance(data, dict) and ("approval_id" in data or "decision" in data)
1443
+
1444
+
1445
+ def _part_data(part: Part) -> Any:
1446
+ if not part.HasField("data"):
1447
+ return None
1448
+ try:
1449
+ return json_format.MessageToDict(part.data)
1450
+ except Exception:
1451
+ return None
1452
+
1453
+
1454
+ def approval_decision(message: Message | None) -> dict[str, Any] | None:
1455
+ """The approval decision a message's data part carries (checked), or None."""
1456
+ for part in message.parts if message is not None else []:
1457
+ data = _part_data(part)
1458
+ if _names_approval(data) and decision_problem(data) is None:
1459
+ return data
1460
+ return None
1461
+
1462
+
1463
+ def _decision_parts_problem(datas: list[Any]) -> tuple[bool, str | None]:
1464
+ """Whether data parts carry an approval decision, and why they cannot when they try."""
1465
+ for data in datas:
1466
+ if _names_approval(data):
1467
+ return True, decision_problem(data)
1468
+ return False, None
1469
+
1470
+
1471
+ def message_problem(from_user: bool, texts: list[str], decides: bool = False) -> str | None:
1472
+ """Why a message cannot start a run, or None.
1473
+
1474
+ It needs the user role, at least one text part (unless it carries an
1475
+ approval decision, `decides`), no empty text part (the SDK cannot start a
1476
+ task from one), and at most `MAX_MESSAGE_CHARS` characters of text in all
1477
+ (joined as the executor joins them), the limit `/chat` applies.
1478
+ """
1479
+ if not from_user:
1480
+ return "The message must have the user role."
1481
+ if not texts and not decides:
1482
+ return "The message needs a text part."
1483
+ if any(not text for text in texts):
1484
+ return "The message has an empty text part."
1485
+ if any(_has_lone_surrogate(text) for text in texts):
1486
+ return "The message is not valid Unicode text (an unpaired surrogate)."
1487
+ cap = max_message_chars()
1488
+ if len("\n".join(texts)) > cap:
1489
+ return f"The message is longer than {cap} characters (MAX_MESSAGE_CHARS)."
1490
+ return None
1491
+
1492
+
1493
+ def check_user_message(message: Message) -> None:
1494
+ """An invalid-params error (-32602) for a message the agent cannot run."""
1495
+ texts = [part.text for part in message.parts if part.HasField("text")]
1496
+ decides, problem = _decision_parts_problem([_part_data(part) for part in message.parts])
1497
+ problem = problem or message_problem(message.role == Role.ROLE_USER, texts, decides)
1498
+ if problem:
1499
+ raise InvalidParamsError(problem)
1500
+
1501
+
1502
+ def _has_lone_surrogate(text: str) -> bool:
1503
+ try:
1504
+ text.encode("utf-8")
1505
+ except UnicodeEncodeError:
1506
+ return True
1507
+ return False
1508
+
1509
+
1510
+ def _strings(value: Any) -> Any:
1511
+ """Every string in a parsed JSON value (keys included)."""
1512
+ if isinstance(value, str):
1513
+ yield value
1514
+ elif isinstance(value, dict):
1515
+ for key, item in value.items():
1516
+ yield from _strings(key)
1517
+ yield from _strings(item)
1518
+ elif isinstance(value, list):
1519
+ for item in value:
1520
+ yield from _strings(item)
1521
+
1522
+
1523
+ # A2A 0.3 methods, served by the SDK's compatibility layer. That layer logs a
1524
+ # request that fails its validation at ERROR with the offending values (the
1525
+ # message text included), and answers any error raised while handling a
1526
+ # request as an internal error (-32603, logged with a traceback). So a 0.3
1527
+ # request is checked before it gets there (`fast_api_app.A2APolicyMiddleware`
1528
+ # answers the error itself, naming fields, never values), and the layer is
1529
+ # served by `LegacyJsonRpcAdapter`, which answers an A2A error raised while
1530
+ # handling a request (an unknown task, say) with its own code.
1531
+ LEGACY_SEND_METHODS = ("message/send", "message/stream")
1532
+ try:
1533
+ from a2a.compat.v0_3 import types as types_v03
1534
+ from a2a.compat.v0_3.jsonrpc_adapter import JSONRPC03Adapter
1535
+ from a2a.compat.v0_3.request_handler import RequestHandler03
1536
+
1537
+ LEGACY_METHOD_MODELS: dict[str, Any] = dict(JSONRPC03Adapter.METHOD_TO_MODEL)
1538
+ except ImportError: # an SDK without the 0.3 layer, or with it elsewhere
1539
+ JSONRPC03Adapter = RequestHandler03 = types_v03 = None # type: ignore[assignment,misc]
1540
+ LEGACY_METHOD_MODELS = {}
1541
+ JSONRPC_INVALID_REQUEST = -32600
1542
+ JSONRPC_INVALID_PARAMS = -32602
1543
+ JSONRPC_INTERNAL_ERROR = -32603
1544
+
1545
+
1546
+ def legacy_request_error(payload: Any) -> dict[str, Any] | None:
1547
+ """The JSON-RPC error (`code`, `message`) for an A2A 0.3 request that cannot run, or None.
1548
+
1549
+ None for anything that is not a 0.3 request (1.0 requests are checked by
1550
+ the handler, a body that is not JSON by the SDK). A 0.3 request is
1551
+ validated with the SDK's own model for its method; the answer names the
1552
+ fields that failed and the rule, never the values sent. A string that is
1553
+ not valid Unicode (an unpaired surrogate) anywhere in it, and a 0.3
1554
+ message that cannot run (`legacy_message_problem`), are invalid params too.
1555
+ """
1556
+ if not isinstance(payload, dict):
1557
+ return None
1558
+ method = payload.get("method")
1559
+ if not isinstance(method, str) or method not in LEGACY_METHOD_MODELS:
1560
+ return None
1561
+ if any(_has_lone_surrogate(text) for text in _strings(payload)):
1562
+ return {
1563
+ "code": JSONRPC_INVALID_PARAMS,
1564
+ "message": "The request is not valid Unicode text (an unpaired surrogate).",
1565
+ }
1566
+ try:
1567
+ LEGACY_METHOD_MODELS[method].model_validate(payload)
1568
+ except Exception as exc:
1569
+ errors = exc.errors() if hasattr(exc, "errors") else []
1570
+ places = [".".join(str(p) for p in error.get("loc", ())) for error in errors]
1571
+ rules = [
1572
+ f"{place}: {error.get('msg')}" for place, error in zip(places, errors, strict=True)
1573
+ ]
1574
+ in_params = bool(places) and all(p == "params" or p.startswith("params.") for p in places)
1575
+ return {
1576
+ "code": JSONRPC_INVALID_PARAMS if in_params else JSONRPC_INVALID_REQUEST,
1577
+ "message": "Invalid A2A 0.3 request: " + ("; ".join(rules[:3]) or "malformed"),
1578
+ }
1579
+ problem = legacy_message_problem(payload)
1580
+ if problem is not None:
1581
+ return {"code": JSONRPC_INVALID_PARAMS, "message": problem}
1582
+ return None
1583
+
1584
+
1585
+ def legacy_message_problem(payload: Any) -> str | None:
1586
+ """Why an A2A 0.3 `message/send` or `message/stream` request cannot run, or None.
1587
+
1588
+ None as well for anything else (other methods, a malformed request:
1589
+ `legacy_request_error` answers those).
1590
+ """
1591
+ if not isinstance(payload, dict) or payload.get("method") not in LEGACY_SEND_METHODS:
1592
+ return None
1593
+ params = payload.get("params")
1594
+ message = params.get("message") if isinstance(params, dict) else None
1595
+ if not isinstance(message, dict) or not isinstance(message.get("parts"), list):
1596
+ return None
1597
+ texts = [
1598
+ part["text"]
1599
+ for part in message["parts"]
1600
+ if isinstance(part, dict) and isinstance(part.get("text"), str)
1601
+ ]
1602
+ decides, problem = _decision_parts_problem(
1603
+ [part.get("data") for part in message["parts"] if isinstance(part, dict)]
1604
+ )
1605
+ return problem or message_problem(message.get("role") == "user", texts, decides)
1606
+
1607
+
1608
+ def legacy_error(exc: A2AError) -> dict[str, Any] | None:
1609
+ """The JSON-RPC error for an A2A error raised by a 0.3 request, or None for an internal one.
1610
+
1611
+ The code is the one A2A 1.0 answers with (`TaskNotFoundError` -32001,
1612
+ `TaskNotCancelableError` -32002, `PushNotificationNotSupportedError`
1613
+ -32003, ...); the message is the error's own, which names no value the
1614
+ server holds. It is logged at INFO: the caller asked for something that
1615
+ is not there, the server did nothing wrong.
1616
+ """
1617
+ code = JSON_RPC_ERROR_CODE_MAP.get(type(exc), JSONRPC_INTERNAL_ERROR)
1618
+ if code == JSONRPC_INTERNAL_ERROR:
1619
+ return None
1620
+ logger.info("A2A 0.3 request refused: %s (%d)", type(exc).__name__, code)
1621
+ return {"code": code, "message": str(exc)}
1622
+
1623
+
1624
+ if RequestHandler03 is not None:
1625
+
1626
+ class LegacyRequestHandler(RequestHandler03):
1627
+ """The SDK's 0.3 request handler; a streamed request's A2A error ends its stream."""
1628
+
1629
+ async def on_message_send_stream(self, request: Any, context: ServerCallContext) -> Any:
1630
+ try:
1631
+ async for event in super().on_message_send_stream(request, context):
1632
+ yield event
1633
+ except A2AError as exc:
1634
+ yield _legacy_stream_error(request, exc)
1635
+
1636
+ async def on_subscribe_to_task(self, request: Any, context: ServerCallContext) -> Any:
1637
+ try:
1638
+ async for event in super().on_subscribe_to_task(request, context):
1639
+ yield event
1640
+ except A2AError as exc:
1641
+ yield _legacy_stream_error(request, exc)
1642
+
1643
+ class LegacyJsonRpcAdapter(JSONRPC03Adapter):
1644
+ """The SDK's A2A 0.3 layer, answering each A2A error with its own code.
1645
+
1646
+ The SDK's layer answers any error raised while handling a 0.3 request
1647
+ as an internal error (-32603) and logs it at ERROR with a traceback, so
1648
+ any caller could write ERROR records with an unknown or deleted task id
1649
+ (`tasks/get`, `tasks/cancel`, `tasks/resubscribe`) or a push
1650
+ notification request (not supported). Here such a request gets the
1651
+ error A2A 1.0 answers with (`legacy_error`); an internal error is still
1652
+ answered and logged as the SDK does.
1653
+ """
1654
+
1655
+ def __init__(self, http_handler: Any, context_builder: Any = None) -> None:
1656
+ super().__init__(http_handler, context_builder)
1657
+ self.handler = LegacyRequestHandler(request_handler=http_handler)
1658
+
1659
+ async def _process_non_streaming_request(
1660
+ self, request_id: Any, request_obj: Any, context: ServerCallContext
1661
+ ) -> Any:
1662
+ try:
1663
+ return await super()._process_non_streaming_request(
1664
+ request_id, request_obj, context
1665
+ )
1666
+ except A2AError as exc:
1667
+ error = legacy_error(exc)
1668
+ if error is None:
1669
+ raise
1670
+ return JSONResponse({"jsonrpc": "2.0", "id": request_id, "error": error})
1671
+
1672
+
1673
+ def _legacy_stream_error(request: Any, exc: A2AError) -> Any:
1674
+ """The stream event that ends a streamed 0.3 request with the A2A error `exc`."""
1675
+ error = legacy_error(exc)
1676
+ if error is None:
1677
+ raise exc
1678
+ return types_v03.SendStreamingMessageResponse(
1679
+ root=types_v03.JSONRPCErrorResponse(
1680
+ id=getattr(request, "id", None), error=types_v03.JSONRPCError(**error)
1681
+ )
1682
+ )
1683
+
1684
+
1685
+ def _use_legacy_adapter(routes: list[Any], request_handler: Any, context_builder: Any) -> None:
1686
+ """Serve A2A 0.3 requests on `routes` with `LegacyJsonRpcAdapter` (see there).
1687
+
1688
+ The SDK builds its 0.3 layer inside the JSON-RPC route's dispatcher; it is
1689
+ replaced there. With an SDK that builds it elsewhere the routes keep the
1690
+ SDK's own layer (the A2A 0.3 tests in `tests/integration/test_api_surface.py`
1691
+ notice).
1692
+ """
1693
+ if RequestHandler03 is None:
1694
+ return
1695
+ for route in routes:
1696
+ dispatcher = getattr(getattr(route, "endpoint", None), "__self__", None)
1697
+ if isinstance(getattr(dispatcher, "_v03_adapter", None), JSONRPC03Adapter):
1698
+ dispatcher._v03_adapter = LegacyJsonRpcAdapter(request_handler, context_builder)
1699
+
1700
+
1701
+ class PolicyRequestHandler(DefaultRequestHandler):
1702
+ """The SDK's request handler, checking each message before a task exists.
1703
+
1704
+ It also records whether the reply is streamed, so the executor returns a
1705
+ non-streamed reply as one text part, and answers a cancel or a
1706
+ subscription naming a task the caller does not have (`CancelTask`,
1707
+ `SubscribeToTask`, 0.3 `tasks/cancel`, `tasks/resubscribe`) before the SDK
1708
+ sets the task up: the SDK starts two event-queue loops first and leaves
1709
+ them running when the task is not found, which logs two ERROR records
1710
+ ("Task was destroyed but it is pending!") per request. A cancel or a
1711
+ subscription that reaches a replica other than the one running the task
1712
+ is refused (-32002 not cancelable, -32004 unsupported): the SDK would mark
1713
+ the task canceled here while its run goes on there and overwrites that, or
1714
+ wait here for events that only happen there.
1715
+
1716
+ A person (a direct caller) reads (`GetTask`) and cancels (`CancelTask`) the
1717
+ tasks their agents started for them as well as their own: the request then
1718
+ runs under the task's own owner key (`owner_context`), so the canceled task
1719
+ is saved where it is. `ListTasks` lists them all (the stores widen a
1720
+ person's list). A message naming such a task, and `SubscribeToTask`, stay
1721
+ with its owner: "not found" for the person.
1722
+ """
1723
+
1724
+ async def _task_context(self, task_id: str, context: ServerCallContext) -> ServerCallContext:
1725
+ """The context a GetTask or CancelTask of `task_id` runs under.
1726
+
1727
+ The caller's own, unless the caller is a person and the task is one their
1728
+ agent started for them: then that task's owner key's (`owner_context`).
1729
+ """
1730
+ subject = task_subject(context)
1731
+ finder = getattr(self.task_store, "owner_for_subject", None)
1732
+ if subject is None or finder is None:
1733
+ return context
1734
+ owner = await finder(task_id, subject)
1735
+ if owner is None or owner == task_owner(context):
1736
+ return context
1737
+ return owner_context(context, owner)
1738
+
1739
+ async def on_get_task( # type: ignore[override]
1740
+ self, params: GetTaskRequest, context: ServerCallContext
1741
+ ) -> Any:
1742
+ return await super().on_get_task(params, await self._task_context(params.id, context))
1743
+
1744
+ async def on_message_send( # type: ignore[override]
1745
+ self, params: SendMessageRequest, context: ServerCallContext
1746
+ ) -> Any:
1747
+ check_user_message(params.message)
1748
+ context.state[STREAMING_STATE_KEY] = False
1749
+ return await super().on_message_send(params, context)
1750
+
1751
+ async def on_message_send_stream( # type: ignore[override]
1752
+ self, params: SendMessageRequest, context: ServerCallContext
1753
+ ) -> Any:
1754
+ check_user_message(params.message)
1755
+ context.state[STREAMING_STATE_KEY] = True
1756
+ async for event in super().on_message_send_stream(params, context):
1757
+ yield event
1758
+
1759
+ async def on_cancel_task( # type: ignore[override]
1760
+ self, params: CancelTaskRequest, context: ServerCallContext
1761
+ ) -> Any:
1762
+ context = await self._task_context(params.id, context)
1763
+ task = await self.task_store.get(params.id, context)
1764
+ if task is None:
1765
+ raise TaskNotFoundError
1766
+ store = self.task_store
1767
+ if isinstance(store, RuntimeTaskStore) and await store.running_elsewhere(task):
1768
+ raise TaskNotCancelableError(
1769
+ message="The task is running on another replica of this agent: send the cancel "
1770
+ "again (it may reach that replica), or wait for the task to end."
1771
+ )
1772
+ return await super().on_cancel_task(params, context)
1773
+
1774
+ async def on_subscribe_to_task( # type: ignore[override]
1775
+ self, params: SubscribeToTaskRequest, context: ServerCallContext
1776
+ ) -> Any:
1777
+ task = await self.task_store.get(params.id, context)
1778
+ if task is None:
1779
+ raise TaskNotFoundError
1780
+ store = self.task_store
1781
+ if isinstance(store, RuntimeTaskStore) and await store.running_elsewhere(task):
1782
+ raise UnsupportedOperationError(
1783
+ message="The task is running on another replica of this agent: follow it with "
1784
+ "GetTask, or subscribe again (it may reach that replica)."
1785
+ )
1786
+ async for event in super().on_subscribe_to_task(params, context):
1787
+ yield event
1788
+
1789
+
1790
+ def _client_message(exc: Exception) -> str:
1791
+ """What an A2A client is told about a failure: a 4xx detail as is, anything else generic."""
1792
+ if isinstance(exc, HTTPException) and exc.status_code < 500:
1793
+ return str(exc.detail)
1794
+ error_id = uuid.uuid4().hex[:12]
1795
+ logger.error("A2A task failed (error id %s)", error_id, exc_info=exc)
1796
+ return f"The agent could not process this request (error id {error_id})."
1797
+
1798
+
1799
+ def approval_request(
1800
+ updater: TaskUpdater, approvals: list[Any], note: str | None = None, code: str | None = None
1801
+ ) -> Message:
1802
+ """The input-required status message: what waits for approval, as text and as data."""
1803
+ return updater.new_agent_message(approval_parts(approvals, note=note, code=code))
1804
+
1805
+
1806
+ async def _fail(updater: TaskUpdater, text: str, code: str) -> None:
1807
+ """Fail the task with `text` and an error part naming `code` (`error_part`)."""
1808
+ await updater.failed(updater.new_agent_message([Part(text=valid_text(text)), error_part(code)]))
1809
+
1810
+
1811
+ class _Reply:
1812
+ """The `response` artifact of one task.
1813
+
1814
+ Streamed, the text goes out in chunks with one chunk held back, so the
1815
+ last one can carry `last_chunk`; not streamed, it is sent once, whole, as
1816
+ one text part.
1817
+ """
1818
+
1819
+ def __init__(self, updater: TaskUpdater, artifact_id: str, *, streaming: bool) -> None:
1820
+ self._updater = updater
1821
+ self._artifact_id = artifact_id
1822
+ self._streaming = streaming
1823
+ self._held: list[str] = []
1824
+ self._sent = False
1825
+ self._finished = False
1826
+
1827
+ async def add(self, text: str) -> None:
1828
+ if self._streaming and self._held:
1829
+ await self._send("".join(self._held), last=False)
1830
+ self._held.clear()
1831
+ self._held.append(text)
1832
+
1833
+ async def finish(self, answer: Any = None) -> None:
1834
+ """Send what is held as the last chunk (one empty part when the reply was empty).
1835
+
1836
+ `answer`: a structured run's answer, sent as a data part after the text.
1837
+ """
1838
+ if self._finished:
1839
+ return
1840
+ self._finished = True
1841
+ if self._held or not self._sent or answer is not None:
1842
+ await self._send("".join(self._held), last=True, answer=answer)
1843
+ self._held.clear()
1844
+
1845
+ async def _send(self, text: str, *, last: bool, answer: Any = None) -> None:
1846
+ # A lone surrogate (which protobuf cannot encode) is sent as U+FFFD.
1847
+ parts = [Part(text=valid_text(text))]
1848
+ if answer is not None:
1849
+ parts.append(answer_part(answer))
1850
+ await self._updater.add_artifact(
1851
+ parts,
1852
+ artifact_id=self._artifact_id,
1853
+ name="response",
1854
+ append=self._sent,
1855
+ last_chunk=last,
1856
+ )
1857
+ self._sent = True
1858
+
1859
+
1860
+ class LangGraphAgentExecutor(AgentExecutor):
1861
+ """Bridge the A2A request lifecycle to the chat runtime."""
1862
+
1863
+ async def execute(self, context: RequestContext, event_queue: EventQueue) -> None:
1864
+ user_input = context.get_user_input()
1865
+ task = context.current_task
1866
+ if task is None:
1867
+ # First message of a conversation. The Task itself has to reach the
1868
+ # queue before any status update, or the server rejects the stream.
1869
+ task = new_task_from_user_message(context.message)
1870
+ await event_queue.enqueue_event(task)
1871
+ updater = TaskUpdater(event_queue, task.id, task.context_id)
1872
+ await updater.start_work()
1873
+
1874
+ state = context.call_context.state if context.call_context is not None else {}
1875
+ principal = state.get("principal")
1876
+ if not isinstance(principal, Principal):
1877
+ # Never run as an anonymous principal: the middleware must have authenticated.
1878
+ await _fail(updater, "Not authenticated.", ERROR_CODE_UNAUTHENTICATED)
1879
+ return
1880
+ if task.context_id and not CONTEXT_ID_PATTERN.fullmatch(task.context_id):
1881
+ await _fail(
1882
+ updater,
1883
+ "Invalid contextId: use 1-128 characters from A-Z a-z 0-9 _ . : -",
1884
+ ERROR_CODE_INVALID_CONTEXT,
1885
+ )
1886
+ return
1887
+ # An agent calling for a user may forward the user's own words (the origin
1888
+ # extension): kept with this request's credentials only, never stored. The run
1889
+ # a decision resumes acts on the words of the request that paused it instead
1890
+ # (kept with the approval: `resume_principal`), not on the decision's.
1891
+ origin, hops = read_origin(context.message, principal)
1892
+ depth = _max_depth()
1893
+ if hops > depth:
1894
+ await _fail(
1895
+ updater,
1896
+ f"delegation chain too deep (AUTH_MAX_DELEGATION_DEPTH={depth})",
1897
+ ERROR_CODE_TOO_DEEP,
1898
+ )
1899
+ return
1900
+ if origin is not None:
1901
+ principal = with_origin(principal, origin)
1902
+ decision = approval_decision(context.message)
1903
+ if decision is None and (not user_input or len(user_input) > max_message_chars()):
1904
+ # The request handler refuses these first; this guards any other caller.
1905
+ await _fail(updater, "The message is empty or too long.", ERROR_CODE_INVALID_MESSAGE)
1906
+ return
1907
+ req = ChatRequest(message=user_input or "", thread_id=task.context_id or None)
1908
+ try:
1909
+ thread_id = await RUNTIME.resolve_thread(principal, req)
1910
+ except Exception as exc: # ownership or server errors end the task
1911
+ code = (
1912
+ ERROR_CODE_FORBIDDEN
1913
+ if isinstance(exc, HTTPException) and exc.status_code == 403
1914
+ else ERROR_CODE_FAILED
1915
+ )
1916
+ await _fail(updater, _client_message(exc), code)
1917
+ return
1918
+
1919
+ lease = None
1920
+ if decision is not None:
1921
+ try:
1922
+ # The endpoint authorized `a2a.invoke`; deciding needs `approval.decide`
1923
+ # too, as on the HTTP route.
1924
+ await authorize_action(principal, "approval.decide", thread_id)
1925
+ lease, resume, acting = await RUNTIME.decide(
1926
+ principal,
1927
+ thread_id,
1928
+ decision["approval_id"],
1929
+ decision["decision"],
1930
+ decision.get("comment"),
1931
+ digest=decision.get("digest"),
1932
+ )
1933
+ except (ApprovalError, ThreadBusy, HTTPException) as exc:
1934
+ await self._decision_refused(updater, thread_id, exc)
1935
+ return
1936
+ # The tasks that waited on this approval follow the resumed run and point here.
1937
+ resume.continued_in = task.id
1938
+ resume.references = tuple(context.message.reference_task_ids)
1939
+ run = RUNTIME.stream(
1940
+ acting,
1941
+ ChatRequest(message="", thread_id=thread_id),
1942
+ thread_id,
1943
+ lease=lease,
1944
+ resume=resume,
1945
+ )
1946
+ else:
1947
+ run = RUNTIME.stream(principal, req, thread_id)
1948
+ try:
1949
+ # Closed here, whatever happens: the run's own cleanup (which waits
1950
+ # for the graph to stop, then releases the thread) runs first.
1951
+ async with aclosing(run) as events:
1952
+ await self._relay(updater, events, streaming=bool(state.get(STREAMING_STATE_KEY)))
1953
+ finally:
1954
+ if lease is not None:
1955
+ # The run released it when it ended; this covers one that never started.
1956
+ await lease.release()
1957
+
1958
+ @staticmethod
1959
+ async def _relay(updater: TaskUpdater, run: Any, *, streaming: bool) -> None:
1960
+ """Turn a run's events into the task's artifact and final state."""
1961
+ # The reply is the `response` artifact (A2A clients read it from
1962
+ # artifacts, not from the final status message).
1963
+ reply = _Reply(updater, uuid.uuid4().hex, streaming=streaming)
1964
+ answer: Any = None
1965
+ async for event, data in run:
1966
+ if event == EVENT_DELTA and data.get("text"):
1967
+ await reply.add(data["text"])
1968
+ elif event == EVENT_ERROR:
1969
+ await reply.finish()
1970
+ if data.get("code") == CODE_APPROVAL_PENDING:
1971
+ # A new message while an approval is pending: ask for the decision.
1972
+ await updater.requires_input(
1973
+ approval_request(
1974
+ updater, data.get("approvals") or [], code=CODE_APPROVAL_PENDING
1975
+ )
1976
+ )
1977
+ return
1978
+ await _fail(
1979
+ updater,
1980
+ f"{data.get('code')}: {data.get('message')}",
1981
+ str(data.get("code") or ERROR_CODE_FAILED),
1982
+ )
1983
+ return
1984
+ elif event == EVENT_END and data.get("status") == STATUS_AWAITING_APPROVAL:
1985
+ await reply.finish()
1986
+ await updater.requires_input(
1987
+ approval_request(updater, data.get("approvals") or [data.get("approval")])
1988
+ )
1989
+ return
1990
+ elif event == EVENT_END:
1991
+ # A structured run's answer (the chat runtime made its strings valid text).
1992
+ answer = data.get(STRUCTURED_KEY)
1993
+ await reply.finish(answer)
1994
+ await updater.complete()
1995
+
1996
+ @staticmethod
1997
+ async def _decision_refused(updater: TaskUpdater, thread_id: str, exc: Exception) -> None:
1998
+ """A decision that could not be taken: still waiting (input-required) or failed.
1999
+
2000
+ Either way the status message carries an error part naming why (the
2001
+ approval's error code, `thread_busy`, ...).
2002
+ """
2003
+ if isinstance(exc, ApprovalError):
2004
+ note, code = f"{exc.code}: {exc.detail}", exc.code
2005
+ elif isinstance(exc, ThreadBusy):
2006
+ note, code = f"{THREAD_BUSY}: {exc}", THREAD_BUSY
2007
+ else:
2008
+ note = _client_message(exc)
2009
+ code = (
2010
+ ERROR_CODE_FORBIDDEN
2011
+ if isinstance(exc, HTTPException) and exc.status_code == 403
2012
+ else ERROR_CODE_FAILED
2013
+ )
2014
+ try:
2015
+ pending = await RUNTIME.pending_approvals(thread_id)
2016
+ except Exception:
2017
+ pending = []
2018
+ if pending:
2019
+ await updater.requires_input(approval_request(updater, pending, note=note, code=code))
2020
+ else:
2021
+ await _fail(updater, note, code)
2022
+
2023
+ async def cancel(self, context: RequestContext, event_queue: EventQueue) -> None:
2024
+ # The request handler has already checked that the caller owns the task
2025
+ # (the task store is scoped per principal); it then stops the running
2026
+ # `execute`, which ends the run, and records the task as canceled.
2027
+ logger.info("A2A task %s canceled by its owner", context.task_id)
2028
+
2029
+
2030
+ def _security() -> tuple[dict[str, SecurityScheme], str]:
2031
+ name = policy_name()
2032
+ if name == JWT:
2033
+ return (
2034
+ {
2035
+ "bearer": SecurityScheme(
2036
+ http_auth_security_scheme=HTTPAuthSecurityScheme(
2037
+ scheme="bearer",
2038
+ bearer_format="JWT",
2039
+ description="OIDC/JWT access token of the calling user.",
2040
+ )
2041
+ )
2042
+ },
2043
+ "bearer",
2044
+ )
2045
+ if name == CUSTOM:
2046
+ return (
2047
+ {
2048
+ "custom": SecurityScheme(
2049
+ api_key_security_scheme=APIKeySecurityScheme(
2050
+ name="Authorization",
2051
+ location="header",
2052
+ description="Credential defined by the project's custom auth policy.",
2053
+ )
2054
+ )
2055
+ },
2056
+ "custom",
2057
+ )
2058
+ return (
2059
+ {
2060
+ "bearer": SecurityScheme(
2061
+ http_auth_security_scheme=HTTPAuthSecurityScheme(
2062
+ scheme="bearer", description="Shared bearer key (API_KEY)."
2063
+ )
2064
+ )
2065
+ },
2066
+ "bearer",
2067
+ )
2068
+
2069
+
2070
+ def _answer_modes() -> list[str]:
2071
+ """`application/json` when the project answers in JSON (a response schema)."""
2072
+ try:
2073
+ return [JSON_MEDIA_TYPE] if structured_enabled() else []
2074
+ except SettingsError:
2075
+ # A bad schema: the lifespan's settings check refuses to start and names it.
2076
+ return []
2077
+
2078
+
2079
+ def agent_card() -> AgentCard:
2080
+ rpc_url = f"{advertised_base_url()}{A2A_RPC_PATH}"
2081
+ schemes, required = _security()
2082
+ description = card_description()
2083
+ card = AgentCard(
2084
+ name=A2A_NAME,
2085
+ description=description or DEFAULT_DESCRIPTION,
2086
+ # One 1.0 interface: advertising 0.3 as well steers 1.0 clients to the
2087
+ # wrong version. 0.3 clients are still served on the same URL
2088
+ # (enable_v0_3_compat below).
2089
+ supported_interfaces=[
2090
+ AgentInterface(
2091
+ url=rpc_url, protocol_binding="JSONRPC", protocol_version=PROTOCOL_VERSION_1_0
2092
+ ),
2093
+ ],
2094
+ version=os.environ.get("AGENT_VERSION", "0.1.0"),
2095
+ default_input_modes=["text/plain"],
2096
+ default_output_modes=["text/plain", *_answer_modes()],
2097
+ capabilities=AgentCapabilities(
2098
+ streaming=True,
2099
+ extensions=[
2100
+ AgentExtension(
2101
+ uri=A2A_ORIGIN_EXTENSION,
2102
+ description=ORIGIN_EXTENSION_DESCRIPTION,
2103
+ required=False,
2104
+ )
2105
+ ],
2106
+ ),
2107
+ security_schemes=schemes,
2108
+ skills=[
2109
+ AgentSkill(
2110
+ id="chat",
2111
+ name="chat",
2112
+ description=description or DEFAULT_SKILL_DESCRIPTION,
2113
+ tags=["chat", "langgraph"],
2114
+ )
2115
+ ],
2116
+ )
2117
+ requirement = card.security_requirements.add()
2118
+ requirement.schemes[required].SetInParent()
2119
+ return card
2120
+
2121
+
2122
+ def add_a2a_routes(app: FastAPI) -> Any:
2123
+ """Mount the JSON-RPC endpoint and the agent card on the app.
2124
+
2125
+ The card advertises the selected auth policy, so the policy is built and
2126
+ checked here, when the app is assembled: a misconfigured policy stops the
2127
+ process at startup (outside `APP_ENV=dev`) instead of failing requests.
2128
+ """
2129
+ check_startup()
2130
+ card = agent_card()
2131
+ try:
2132
+ ttl = task_ttl_s()
2133
+ except SettingsError:
2134
+ # The app is assembled at import; its lifespan's settings check then
2135
+ # refuses to start and names this variable with every other bad one.
2136
+ ttl = DEFAULT_TASK_TTL_S
2137
+ store = RuntimeTaskStore(ttl)
2138
+ _STORES.append(store)
2139
+ if forget_context not in DELETE_LISTENERS:
2140
+ DELETE_LISTENERS.append(forget_context)
2141
+ if follow_approval not in A2A_TASK_LISTENERS:
2142
+ A2A_TASK_LISTENERS.append(follow_approval)
2143
+ request_handler = PolicyRequestHandler(
2144
+ agent_executor=LangGraphAgentExecutor(),
2145
+ task_store=store,
2146
+ agent_card=card,
2147
+ )
2148
+ context_builder = PolicyContextBuilder()
2149
+ # v0.3 compat keeps older A2A clients working against the same endpoint.
2150
+ jsonrpc_routes = create_jsonrpc_routes(
2151
+ request_handler,
2152
+ rpc_url=A2A_RPC_PATH,
2153
+ context_builder=context_builder,
2154
+ enable_v0_3_compat=True,
2155
+ )
2156
+ _use_legacy_adapter(jsonrpc_routes, request_handler, context_builder)
2157
+ add_a2a_routes_to_fastapi(
2158
+ app,
2159
+ agent_card_routes=create_agent_card_routes(card, card_url=A2A_CARD_PATH),
2160
+ jsonrpc_routes=jsonrpc_routes,
2161
+ )
2162
+ return card