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.
- graph_agents_cli/__init__.py +26 -0
- graph_agents_cli/_api_policy.py +2145 -0
- graph_agents_cli/_approvals.py +400 -0
- graph_agents_cli/_build.py +186 -0
- graph_agents_cli/_build_info.json +7 -0
- graph_agents_cli/_chat_client.py +462 -0
- graph_agents_cli/_click.py +157 -0
- graph_agents_cli/_defaults.py +139 -0
- graph_agents_cli/_experiments.py +64 -0
- graph_agents_cli/_http.py +192 -0
- graph_agents_cli/_output.py +83 -0
- graph_agents_cli/_project.py +462 -0
- graph_agents_cli/_remote.py +220 -0
- graph_agents_cli/_response_schema.py +264 -0
- graph_agents_cli/_runner.py +319 -0
- graph_agents_cli/_skills_check.py +274 -0
- graph_agents_cli/_tools.py +189 -0
- graph_agents_cli/_trust.py +66 -0
- graph_agents_cli/api/__init__.py +15 -0
- graph_agents_cli/api/_changes.py +506 -0
- graph_agents_cli/api/_files.py +658 -0
- graph_agents_cli/api/cmd_api.py +2480 -0
- graph_agents_cli/deploy/__init__.py +15 -0
- graph_agents_cli/deploy/_config.py +171 -0
- graph_agents_cli/deploy/_image.py +128 -0
- graph_agents_cli/deploy/_kube.py +286 -0
- graph_agents_cli/deploy/_modes.py +234 -0
- graph_agents_cli/deploy/_preflight.py +370 -0
- graph_agents_cli/deploy/_values.py +168 -0
- graph_agents_cli/deploy/cmd_deploy.py +1866 -0
- graph_agents_cli/deploy/gitops.py +562 -0
- graph_agents_cli/deploy/local_load.py +273 -0
- graph_agents_cli/dev/__init__.py +13 -0
- graph_agents_cli/dev/cmd_build.py +131 -0
- graph_agents_cli/dev/cmd_install.py +78 -0
- graph_agents_cli/dev/cmd_lint.py +119 -0
- graph_agents_cli/dev/cmd_playground.py +297 -0
- graph_agents_cli/dev/policy_check.py +1287 -0
- graph_agents_cli/eval/__init__.py +22 -0
- graph_agents_cli/eval/_client.py +670 -0
- graph_agents_cli/eval/_common.py +177 -0
- graph_agents_cli/eval/_judge.py +168 -0
- graph_agents_cli/eval/_judge_runner.py +238 -0
- graph_agents_cli/eval/_paths.py +212 -0
- graph_agents_cli/eval/checks.py +581 -0
- graph_agents_cli/eval/cmd_analyze.py +278 -0
- graph_agents_cli/eval/cmd_compare.py +284 -0
- graph_agents_cli/eval/cmd_eval_group.py +80 -0
- graph_agents_cli/eval/cmd_generate.py +558 -0
- graph_agents_cli/eval/cmd_grade.py +466 -0
- graph_agents_cli/eval/cmd_metric.py +156 -0
- graph_agents_cli/eval/cmd_run.py +370 -0
- graph_agents_cli/eval/cmd_submit.py +400 -0
- graph_agents_cli/eval/config.py +435 -0
- graph_agents_cli/eval/dataset.py +350 -0
- graph_agents_cli/eval/gate.py +420 -0
- graph_agents_cli/eval/transcript.py +192 -0
- graph_agents_cli/extension/__init__.py +13 -0
- graph_agents_cli/extension/_compat.py +86 -0
- graph_agents_cli/extension/_loader.py +293 -0
- graph_agents_cli/extension/_manifest.py +135 -0
- graph_agents_cli/extension/_overrides.py +195 -0
- graph_agents_cli/extension/_paths.py +91 -0
- graph_agents_cli/extension/_refs.py +193 -0
- graph_agents_cli/extension/_resolver.py +453 -0
- graph_agents_cli/extension/_schema.py +106 -0
- graph_agents_cli/extension/_spec.py +253 -0
- graph_agents_cli/extension/_sync.py +102 -0
- graph_agents_cli/extension/_trust.py +58 -0
- graph_agents_cli/extension/cmd_extension_add.py +259 -0
- graph_agents_cli/extension/cmd_extension_group.py +57 -0
- graph_agents_cli/extension/cmd_extension_list.py +56 -0
- graph_agents_cli/extension/cmd_extension_remove.py +61 -0
- graph_agents_cli/extension/cmd_extension_update.py +195 -0
- graph_agents_cli/info/__init__.py +13 -0
- graph_agents_cli/info/cmd_info.py +222 -0
- graph_agents_cli/infra/__init__.py +15 -0
- graph_agents_cli/infra/checks.py +1169 -0
- graph_agents_cli/infra/cmd_infra.py +103 -0
- graph_agents_cli/main.py +591 -0
- graph_agents_cli/peer/__init__.py +15 -0
- graph_agents_cli/peer/_generate.py +254 -0
- graph_agents_cli/peer/cmd_peer.py +1151 -0
- graph_agents_cli/run/__init__.py +13 -0
- graph_agents_cli/run/_local_server.py +1157 -0
- graph_agents_cli/run/_signals.py +141 -0
- graph_agents_cli/run/cmd_approvals.py +530 -0
- graph_agents_cli/run/cmd_run.py +1421 -0
- graph_agents_cli/scaffold/__init__.py +19 -0
- graph_agents_cli/scaffold/agents/README.md +24 -0
- graph_agents_cli/scaffold/agents/empty_py/.template/templateconfig.yaml +22 -0
- graph_agents_cli/scaffold/agents/langgraph/.env.example +292 -0
- graph_agents_cli/scaffold/agents/langgraph/.template/templateconfig.yaml +28 -0
- graph_agents_cli/scaffold/agents/langgraph/Dockerfile +59 -0
- graph_agents_cli/scaffold/agents/langgraph/Dockerfile.langgraph-server +59 -0
- graph_agents_cli/scaffold/agents/langgraph/README.md +571 -0
- graph_agents_cli/scaffold/agents/langgraph/api-policy.yaml +60 -0
- graph_agents_cli/scaffold/agents/langgraph/app/__init__.py +20 -0
- graph_agents_cli/scaffold/agents/langgraph/app/agent.py +174 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/__init__.py +15 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a.py +2162 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a_client.py +1167 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/api_client.py +4220 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/approvals.py +1349 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/auth.py +1986 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/chat.py +2962 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/checkpointer.py +432 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/content.py +569 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/db.py +580 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/limits.py +203 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/metrics.py +231 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/middleware.py +361 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/model.py +611 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/playground.py +230 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/run_locks.py +459 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/structured.py +755 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/telemetry.py +681 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/threads.py +493 -0
- graph_agents_cli/scaffold/agents/langgraph/app/app_utils/token_exchange.py +959 -0
- graph_agents_cli/scaffold/agents/langgraph/app/fast_api_app.py +770 -0
- graph_agents_cli/scaffold/agents/langgraph/app/policies/__init__.py +55 -0
- graph_agents_cli/scaffold/agents/langgraph/app/policies/custom.py +97 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/__init__.py +46 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/example_api.py +92 -0
- graph_agents_cli/scaffold/agents/langgraph/app/tools/weather.py +33 -0
- graph_agents_cli/scaffold/agents/langgraph/langgraph.json +14 -0
- graph_agents_cli/scaffold/agents/langgraph/pyproject.toml +78 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/conftest.py +376 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/eval/datasets/basic-dataset.json +53 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/eval/eval_config.yaml +32 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/approval_graph.py +137 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_issuer.py +216 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_openai.py +357 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_outcomes.py +569 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_relay.py +479 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_api_surface.py +812 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals.py +1367 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals_server.py +794 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor.py +497 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor_server.py +247 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_history_repair.py +278 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_model_apis.py +242 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_postgres.py +637 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_resilience_postgres.py +770 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_runtime_guardrails.py +854 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_e2e.py +340 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_runtime.py +989 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_answers.py +584 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_server.py +222 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_token_exchange_issuer.py +650 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/.results/.placeholder +0 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/README.md +22 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/conftest.py +21 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/load_test/load_test.py +81 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_client.py +824 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_scoping.py +724 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client.py +1214 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client_hardening.py +716 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_policy_rpc.py +767 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_approval_ledger.py +1536 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_fake_model.py +115 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_jwt_policy.py +991 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_limits.py +310 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging.py +148 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging_hardening.py +271 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_policy.py +378 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_resilience.py +610 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_server_auth.py +702 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_structured.py +673 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_telemetry.py +404 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_thread_listing.py +255 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_threads.py +268 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_token_exchange.py +1320 -0
- graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_untrusted_content.py +393 -0
- graph_agents_cli/scaffold/agents/langgraph/uv-fastapi.lock +2084 -0
- graph_agents_cli/scaffold/agents/langgraph/uv-langgraph-server.lock +2106 -0
- graph_agents_cli/scaffold/agents/langgraph/{{cookiecutter.agent_guidance_filename}} +129 -0
- graph_agents_cli/scaffold/base_templates/_shared/graph-agents-cli-manifest.yaml +36 -0
- graph_agents_cli/scaffold/base_templates/python/.dockerignore +32 -0
- graph_agents_cli/scaffold/base_templates/python/.github/CODEOWNERS +30 -0
- graph_agents_cli/scaffold/base_templates/python/.github/agent.env +7 -0
- graph_agents_cli/scaffold/base_templates/python/.github/workflows/pr_checks.yaml +214 -0
- graph_agents_cli/scaffold/base_templates/python/.gitignore +209 -0
- graph_agents_cli/scaffold/base_templates/python/tests/unit/test_dummy.py +23 -0
- graph_agents_cli/scaffold/base_templates/python/{{cookiecutter.agent_guidance_filename}} +35 -0
- graph_agents_cli/scaffold/cmd_scaffold_group.py +49 -0
- graph_agents_cli/scaffold/commands/__init__.py +13 -0
- graph_agents_cli/scaffold/commands/create.py +1424 -0
- graph_agents_cli/scaffold/commands/enhance.py +1652 -0
- graph_agents_cli/scaffold/commands/upgrade.py +570 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/agent.env +12 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/promote-to-prod.yaml +371 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/staging.yaml +450 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-dev.yaml +43 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-prod.yaml +41 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-staging.yaml +43 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/.helmignore +14 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/Chart.yaml +21 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/examples/networkpolicy.yaml +103 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/NOTES.txt +48 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/_helpers.tpl +189 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/certificate.yaml +15 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/configmap.yaml +10 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/deployment.yaml +199 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/hpa.yaml +22 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/httproute.yaml +30 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/ingress.yaml +39 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/networkpolicy.yaml +48 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/pdb.yaml +13 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/postgresql-secret.yaml +37 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/service.yaml +15 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/serviceaccount.yaml +13 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/servicemonitor.yaml +42 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-dev.yaml +22 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-prod.yaml +45 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-staging.yaml +29 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values.yaml +396 -0
- graph_agents_cli/scaffold/deployment_targets/kubernetes/python/tests/integration/test_chart.py +269 -0
- graph_agents_cli/scaffold/deployment_targets/none/README.md +5 -0
- graph_agents_cli/scaffold/deployment_targets/none/python/README.md +6 -0
- graph_agents_cli/scaffold/utils/__init__.py +13 -0
- graph_agents_cli/scaffold/utils/backup.py +212 -0
- graph_agents_cli/scaffold/utils/build_record.py +257 -0
- graph_agents_cli/scaffold/utils/cli_options.py +184 -0
- graph_agents_cli/scaffold/utils/fs.py +83 -0
- graph_agents_cli/scaffold/utils/generate_locks.py +214 -0
- graph_agents_cli/scaffold/utils/generation_metadata.py +88 -0
- graph_agents_cli/scaffold/utils/keyedit.py +768 -0
- graph_agents_cli/scaffold/utils/keymerge.py +537 -0
- graph_agents_cli/scaffold/utils/language.py +138 -0
- graph_agents_cli/scaffold/utils/lock_utils.py +94 -0
- graph_agents_cli/scaffold/utils/logging.py +77 -0
- graph_agents_cli/scaffold/utils/manifest.py +292 -0
- graph_agents_cli/scaffold/utils/merge.py +970 -0
- graph_agents_cli/scaffold/utils/merge3.py +216 -0
- graph_agents_cli/scaffold/utils/openapi_seed.py +199 -0
- graph_agents_cli/scaffold/utils/remote_template.py +376 -0
- graph_agents_cli/scaffold/utils/template.py +1352 -0
- graph_agents_cli/scaffold/utils/upgrade.py +894 -0
- graph_agents_cli/scaffold/utils/version.py +438 -0
- graph_agents_cli/secrets/__init__.py +15 -0
- graph_agents_cli/secrets/_apply.py +954 -0
- graph_agents_cli/secrets/_required.py +188 -0
- graph_agents_cli/secrets/cmd_secrets.py +211 -0
- graph_agents_cli/setup/__init__.py +13 -0
- graph_agents_cli/setup/_antigravity.py +221 -0
- graph_agents_cli/setup/cmd_auth.py +1030 -0
- graph_agents_cli/setup/cmd_dev_token.py +513 -0
- graph_agents_cli/setup/cmd_setup.py +428 -0
- graph_agents_cli/setup/cmd_update.py +140 -0
- graph_agents_cli/skills/__init__.py +13 -0
- graph_agents_cli/skills/_bundle.py +65 -0
- graph_agents_cli/skills/data/README.md +19 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/SKILL.md +357 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/github-settings.md +113 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/gitops.md +137 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/kubernetes.md +315 -0
- graph_agents_cli/skills/data/graph-agents-cli-deploy/references/secrets.md +160 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/SKILL.md +303 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/references/dataset_schema.md +282 -0
- graph_agents_cli/skills/data/graph-agents-cli-eval/references/metrics-guide.md +143 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/SKILL.md +659 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langchain-models.md +124 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langgraph.md +235 -0
- graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/template-contract.md +477 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/SKILL.md +231 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/references/langsmith.md +46 -0
- graph_agents_cli/skills/data/graph-agents-cli-observability/references/otel.md +59 -0
- graph_agents_cli/skills/data/graph-agents-cli-scaffold/SKILL.md +414 -0
- graph_agents_cli/skills/data/graph-agents-cli-scaffold/references/flags.md +134 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/SKILL.md +478 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/brainstorming.md +118 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/commands.md +419 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/extension.md +156 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/internals.md +67 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/spec-template.md +56 -0
- graph_agents_cli/skills/data/graph-agents-cli-workflow/references/terminology.md +119 -0
- graph_agents_cli/system/__init__.py +15 -0
- graph_agents_cli/system/_apply.py +519 -0
- graph_agents_cli/system/_checks.py +1023 -0
- graph_agents_cli/system/_deploy.py +215 -0
- graph_agents_cli/system/_model.py +363 -0
- graph_agents_cli/system/_system.py +664 -0
- graph_agents_cli/system/_views.py +208 -0
- graph_agents_cli/system/cmd_system.py +423 -0
- graph_agents_cli-0.3.1.dist-info/METADATA +162 -0
- graph_agents_cli-0.3.1.dist-info/RECORD +291 -0
- graph_agents_cli-0.3.1.dist-info/WHEEL +4 -0
- graph_agents_cli-0.3.1.dist-info/entry_points.txt +2 -0
- graph_agents_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
- 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
|