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,1167 @@
|
|
|
1
|
+
# Copyright 2026 graph-agents-cli contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Ask other agents over A2A 1.0 (JSON-RPC) for the user, and relay the user's approvals.
|
|
16
|
+
|
|
17
|
+
A peer is an API in `api-policy.yaml` with `protocol: a2a` (`graph-agents-cli
|
|
18
|
+
peer add` writes it, and generates `tools/a2a_peers.py`, which calls
|
|
19
|
+
`peer_tools`). Every request goes through the policy client (`get_client`), so
|
|
20
|
+
the policy governs every byte: the allowed JSON-RPC methods, the approve gate,
|
|
21
|
+
the credential (`auth: exchange` mints a token for the peer just before
|
|
22
|
+
sending), the limits and the response cap; the A2A SDK's own HTTP client is
|
|
23
|
+
never used.
|
|
24
|
+
|
|
25
|
+
`A2APeerClient(peer)` talks to one peer:
|
|
26
|
+
|
|
27
|
+
* Before the first call it reads the peer's agent card (through the policy,
|
|
28
|
+
cached `A2A_CARD_TTL_S`, failures for 10 s) and refuses a peer whose card
|
|
29
|
+
offers no A2A 1.x JSON-RPC interface at exactly the URL this agent calls
|
|
30
|
+
(`<base URL env> + a2a.path`), or names another agent: a card's URL is never
|
|
31
|
+
dialed.
|
|
32
|
+
* One conversation per thread, peer and user: the `contextId` is a UUID keyed
|
|
33
|
+
with `PRINCIPAL_HASH_SALT` (`context_id_for`), so it is stable across turns
|
|
34
|
+
and replicas, and nobody can guess it. Calls to one peer in one thread are
|
|
35
|
+
serialized; different peers run in parallel.
|
|
36
|
+
* A peer's answer must be A2A 1.0 JSON-RPC; the reply is the last `response`
|
|
37
|
+
artifact's text (at most `A2A_REPLY_MAX_CHARS`). A task that failed because
|
|
38
|
+
the peer's thread was busy (`thread_busy` error part) is sent again, 3 times
|
|
39
|
+
at most (0.5, 1 and 2 s apart).
|
|
40
|
+
* With `A2A_FORWARD_ORIGIN=auto` (the default), a message to a peer whose card
|
|
41
|
+
declares the origin extension carries the user's own words: the user's
|
|
42
|
+
latest message when the user asked this agent directly, else the words the
|
|
43
|
+
agent calling this one forwarded; never text a model wrote. `off` never
|
|
44
|
+
sends them.
|
|
45
|
+
* A call that would come back to this agent, or to an agent already in the
|
|
46
|
+
request's delegation chain, is refused.
|
|
47
|
+
|
|
48
|
+
The approval relay (`A2APeerClient.relay`, the `approve_agent_action` tool):
|
|
49
|
+
the peer's pending approval is read from the peer itself (`GetTask`, its exact
|
|
50
|
+
`approval_json`; when the peer lost the task, its approvals ledger at
|
|
51
|
+
`GET /threads/{context_id}/approvals`), never from the model. A gate the peer
|
|
52
|
+
decides directly (`decide_with: direct`) is reported as `needs_direct_approval`.
|
|
53
|
+
Otherwise the decision is one context-addressed message naming the approval's
|
|
54
|
+
digest and, in its metadata, the approval it decides (`approving`); the
|
|
55
|
+
policy gates that message, so the person approves it here first, seeing what
|
|
56
|
+
will happen at the peer (`nested`, `effect`). The message is built the same on
|
|
57
|
+
every run, so the run resumed by the person's decision sends exactly what they
|
|
58
|
+
approved, once; a rejection (or an expiry) sends the same message rejecting,
|
|
59
|
+
ungated, so the peer's task ends at once.
|
|
60
|
+
|
|
61
|
+
Tool results reach the model fenced as untrusted text (`UntrustedToolResults`).
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
from __future__ import annotations
|
|
65
|
+
|
|
66
|
+
import asyncio
|
|
67
|
+
import hashlib
|
|
68
|
+
import hmac
|
|
69
|
+
import json
|
|
70
|
+
import logging
|
|
71
|
+
import os
|
|
72
|
+
import re
|
|
73
|
+
import uuid
|
|
74
|
+
from collections import OrderedDict
|
|
75
|
+
from collections.abc import Mapping
|
|
76
|
+
from dataclasses import dataclass, field
|
|
77
|
+
from typing import Any, Literal
|
|
78
|
+
|
|
79
|
+
import httpx
|
|
80
|
+
|
|
81
|
+
from {{cookiecutter.agent_directory}}.app_utils.api_client import (
|
|
82
|
+
A2A_ERROR_PART_TYPE,
|
|
83
|
+
A2A_ORIGIN_EXTENSION,
|
|
84
|
+
APPROVING_KEY,
|
|
85
|
+
EXCHANGE_KEY,
|
|
86
|
+
NESTED_CALL_KEYS,
|
|
87
|
+
ORIGIN_KEY,
|
|
88
|
+
PROTOCOL_A2A,
|
|
89
|
+
ApiCallError,
|
|
90
|
+
ApiClient,
|
|
91
|
+
ApiPolicyError,
|
|
92
|
+
api_protocol,
|
|
93
|
+
approval_effect,
|
|
94
|
+
current_caller,
|
|
95
|
+
current_context,
|
|
96
|
+
get_client,
|
|
97
|
+
latest_user_message,
|
|
98
|
+
load_policy,
|
|
99
|
+
origin_max_chars,
|
|
100
|
+
)
|
|
101
|
+
from {{cookiecutter.agent_directory}}.app_utils.limits import SettingsError
|
|
102
|
+
|
|
103
|
+
logger = logging.getLogger(__name__)
|
|
104
|
+
|
|
105
|
+
A2A_VERSION = "1.0"
|
|
106
|
+
CARD_PATH_SUFFIX = "/.well-known/agent-card.json"
|
|
107
|
+
# The operations a peer's policy entry names (`peer add` writes them).
|
|
108
|
+
CARD_OPERATION = "getAgentCard"
|
|
109
|
+
LEDGER_OPERATION = "listContextApprovals"
|
|
110
|
+
LEDGER_PATH = "/threads/{context_id}/approvals"
|
|
111
|
+
# JSON-RPC error codes of A2A.
|
|
112
|
+
TASK_NOT_FOUND = -32001
|
|
113
|
+
TASK_NOT_CANCELABLE = -32002
|
|
114
|
+
THREAD_BUSY = "thread_busy"
|
|
115
|
+
APPROVAL_REQUEST_TYPE = "approval_request"
|
|
116
|
+
# `A2A_FORWARD_ORIGIN`: `auto` sends the user's own words to a peer whose card declares the
|
|
117
|
+
# origin extension, `off` never does (the owner's decision of 2026-09-28: `auto`).
|
|
118
|
+
DEFAULT_A2A_FORWARD_ORIGIN = "auto"
|
|
119
|
+
FORWARD_ORIGIN_VALUES = ("auto", "off")
|
|
120
|
+
DEFAULT_CARD_TTL_S = 300
|
|
121
|
+
DEFAULT_REPLY_MAX_CHARS = 6000
|
|
122
|
+
CARD_FAILURE_TTL_S = 10.0
|
|
123
|
+
CARD_CACHE_MAX = 256
|
|
124
|
+
LOCKS_MAX = 4096
|
|
125
|
+
# What a peer says about itself, as the model reads it.
|
|
126
|
+
DESCRIPTION_MAX_CHARS = 300
|
|
127
|
+
STATUS_TEXT_MAX_CHARS = 1000
|
|
128
|
+
ERROR_MESSAGE_MAX_CHARS = 300
|
|
129
|
+
AGENT_SAID_MAX_CHARS = 1500
|
|
130
|
+
# A `thread_busy` answer is sent again after these pauses; a cancel the peer's other
|
|
131
|
+
# replica runs (-32002) once, after `CANCEL_RETRY_S`.
|
|
132
|
+
BUSY_RETRY_DELAYS_S = (0.5, 1.0, 2.0)
|
|
133
|
+
CANCEL_RETRY_S = 1.0
|
|
134
|
+
# The namespace of a decision's message id (uuid5: the same on every run).
|
|
135
|
+
_DECISION_NAMESPACE = uuid.UUID("7b3f6c1e-4a52-4d7c-9e0a-1f2d3c4b5a69")
|
|
136
|
+
_CONTROL = re.compile(r"[\x00-\x1f\x7f]")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
# ---------------------------------------------------------------------------
|
|
140
|
+
# Settings
|
|
141
|
+
# ---------------------------------------------------------------------------
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _int_setting(name: str, default: int, minimum: int) -> int:
|
|
145
|
+
raw = (os.environ.get(name) or "").strip()
|
|
146
|
+
if not raw:
|
|
147
|
+
return default
|
|
148
|
+
try:
|
|
149
|
+
value = int(raw)
|
|
150
|
+
except ValueError:
|
|
151
|
+
raise SettingsError(f"{name}={raw!r} is not a whole number.") from None
|
|
152
|
+
if value < minimum:
|
|
153
|
+
raise SettingsError(f"{name}={value} must be at least {minimum}.")
|
|
154
|
+
return value
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def card_ttl_s() -> int:
|
|
158
|
+
"""`A2A_CARD_TTL_S`: seconds a peer's checked agent card is reused (default 300; 0: never)."""
|
|
159
|
+
return _int_setting("A2A_CARD_TTL_S", DEFAULT_CARD_TTL_S, 0)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def reply_max_chars() -> int:
|
|
163
|
+
"""`A2A_REPLY_MAX_CHARS`: the most of a peer's reply the model reads (default 6000)."""
|
|
164
|
+
return _int_setting("A2A_REPLY_MAX_CHARS", DEFAULT_REPLY_MAX_CHARS, 1)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def forward_origin() -> str:
|
|
168
|
+
"""`A2A_FORWARD_ORIGIN`: `auto` (default) or `off`; `SettingsError` otherwise."""
|
|
169
|
+
value = (os.environ.get("A2A_FORWARD_ORIGIN") or DEFAULT_A2A_FORWARD_ORIGIN).strip().lower()
|
|
170
|
+
if value not in FORWARD_ORIGIN_VALUES:
|
|
171
|
+
raise SettingsError(
|
|
172
|
+
f"A2A_FORWARD_ORIGIN={value!r} must be one of {', '.join(FORWARD_ORIGIN_VALUES)}."
|
|
173
|
+
)
|
|
174
|
+
return value
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def client_settings() -> None:
|
|
178
|
+
"""Check every A2A client setting (the app's startup check calls this)."""
|
|
179
|
+
card_ttl_s()
|
|
180
|
+
reply_max_chars()
|
|
181
|
+
origin_max_chars()
|
|
182
|
+
forward_origin()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def own_name() -> str:
|
|
186
|
+
"""This agent's A2A name (`A2A_NAME`, else its agent directory), as its peers know it."""
|
|
187
|
+
return os.environ.get("A2A_NAME") or "{{cookiecutter.agent_directory}}"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# Errors and replies
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
class PeerRpcError(ApiCallError):
|
|
196
|
+
"""A peer answered a JSON-RPC call with an error; `code` is its JSON-RPC code."""
|
|
197
|
+
|
|
198
|
+
def __init__(self, message: str, code: Any) -> None:
|
|
199
|
+
super().__init__(message)
|
|
200
|
+
self.code = code
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@dataclass(frozen=True)
|
|
204
|
+
class PeerReply:
|
|
205
|
+
"""What a peer answered, read from its task (or its direct message).
|
|
206
|
+
|
|
207
|
+
`text` is the last `response` artifact's text (`A2A_REPLY_MAX_CHARS` at
|
|
208
|
+
most), `status_text` the task's status message. `approvals` are the pending
|
|
209
|
+
approvals the peer reported (exact values, from `approval_json`); None when
|
|
210
|
+
the task waits for no approval, or reported none this way (an older agent:
|
|
211
|
+
read its approvals ledger instead). `error_code` names why a task failed or
|
|
212
|
+
a decision was refused (the peer's error part).
|
|
213
|
+
"""
|
|
214
|
+
|
|
215
|
+
state: str
|
|
216
|
+
text: str
|
|
217
|
+
task_id: str | None
|
|
218
|
+
context_id: str
|
|
219
|
+
approvals: list[dict[str, Any]] | None = None
|
|
220
|
+
status_text: str = ""
|
|
221
|
+
error_code: str | None = None
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _clean(text: Any, limit: int) -> str:
|
|
225
|
+
"""`text` without control characters (newlines and tabs as spaces), at most `limit` long."""
|
|
226
|
+
if not isinstance(text, str):
|
|
227
|
+
return ""
|
|
228
|
+
cleaned = _CONTROL.sub(" ", text).strip()
|
|
229
|
+
return cleaned if len(cleaned) <= limit else cleaned[: limit - 3].rstrip() + "..."
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _texts(parts: Any) -> str:
|
|
233
|
+
return "".join(
|
|
234
|
+
str(part.get("text"))
|
|
235
|
+
for part in parts or []
|
|
236
|
+
if isinstance(part, Mapping) and "text" in part
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _data_parts(message: Any, kind: str) -> list[dict[str, Any]]:
|
|
241
|
+
parts = message.get("parts") if isinstance(message, Mapping) else None
|
|
242
|
+
return [
|
|
243
|
+
part["data"]
|
|
244
|
+
for part in parts or []
|
|
245
|
+
if isinstance(part, Mapping)
|
|
246
|
+
and isinstance(part.get("data"), Mapping)
|
|
247
|
+
and part["data"].get("type") == kind
|
|
248
|
+
]
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def _exact_approvals(message: Any) -> list[dict[str, Any]] | None:
|
|
252
|
+
"""The approvals a status message's approval request reports, as exact JSON (or None).
|
|
253
|
+
|
|
254
|
+
Read from `approval_json` only: the request's `Struct` holds every number as
|
|
255
|
+
a double, so an approval read from it could differ from the call it binds.
|
|
256
|
+
"""
|
|
257
|
+
for data in _data_parts(message, APPROVAL_REQUEST_TYPE):
|
|
258
|
+
text = data.get("approval_json")
|
|
259
|
+
if not isinstance(text, str):
|
|
260
|
+
return None
|
|
261
|
+
try:
|
|
262
|
+
approvals = json.loads(text)
|
|
263
|
+
except ValueError:
|
|
264
|
+
return None
|
|
265
|
+
if isinstance(approvals, list):
|
|
266
|
+
return [a for a in approvals if isinstance(a, dict)]
|
|
267
|
+
return None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def reply_of(peer: str, task: Mapping[str, Any], context_id: str) -> PeerReply:
|
|
271
|
+
"""A `PeerReply` from a task as the peer's JSON-RPC answer holds it."""
|
|
272
|
+
status = task.get("status") if isinstance(task.get("status"), Mapping) else {}
|
|
273
|
+
message = status.get("message") if isinstance(status.get("message"), Mapping) else {}
|
|
274
|
+
artifacts = [
|
|
275
|
+
a for a in task.get("artifacts") or [] if isinstance(a, Mapping) and a.get("parts")
|
|
276
|
+
]
|
|
277
|
+
responses = [a for a in artifacts if a.get("name") == "response"] or artifacts
|
|
278
|
+
text = _texts(responses[-1].get("parts")) if responses else ""
|
|
279
|
+
limit = reply_max_chars()
|
|
280
|
+
errors = _data_parts(message, A2A_ERROR_PART_TYPE)
|
|
281
|
+
code = errors[0].get("code") if errors else None
|
|
282
|
+
return PeerReply(
|
|
283
|
+
state=str(status.get("state") or "TASK_STATE_UNSPECIFIED"),
|
|
284
|
+
text=text if len(text) <= limit else text[:limit] + " [truncated]",
|
|
285
|
+
task_id=str(task["id"]) if task.get("id") else None,
|
|
286
|
+
context_id=str(task.get("contextId") or context_id),
|
|
287
|
+
approvals=_exact_approvals(message),
|
|
288
|
+
status_text=_texts(message.get("parts")),
|
|
289
|
+
error_code=str(code) if isinstance(code, str) else None,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# ---------------------------------------------------------------------------
|
|
294
|
+
# Peers, context ids, cards, locks
|
|
295
|
+
# ---------------------------------------------------------------------------
|
|
296
|
+
|
|
297
|
+
# The peers `peer_tools` registered: name -> {"api", "approvals", "description"}.
|
|
298
|
+
_PEERS: dict[str, dict[str, str]] = {}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def context_id_for(thread_id: str, peer: str, subject: str) -> str:
|
|
302
|
+
"""The A2A `contextId` of this thread's conversation with `peer` (its API) for `subject`.
|
|
303
|
+
|
|
304
|
+
A UUID made from an HMAC-SHA256 keyed with `PRINCIPAL_HASH_SALT` (plain
|
|
305
|
+
SHA-256 without it) of this agent's A2A name, the thread, the peer's API and
|
|
306
|
+
the user: the same on every turn and replica, not guessable with the salt,
|
|
307
|
+
and not disclosing the thread id. Changing the salt starts new conversations.
|
|
308
|
+
"""
|
|
309
|
+
data = "\x1f".join(("a2a-context", own_name(), thread_id, peer, subject)).encode("utf-8")
|
|
310
|
+
salt = (os.environ.get("PRINCIPAL_HASH_SALT") or "").strip()
|
|
311
|
+
if salt:
|
|
312
|
+
digest = hmac.new(salt.encode("utf-8"), data, hashlib.sha256).digest()
|
|
313
|
+
else:
|
|
314
|
+
digest = hashlib.sha256(data).digest()
|
|
315
|
+
return str(uuid.UUID(bytes=digest[:16], version=4))
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
@dataclass
|
|
319
|
+
class PeerCard:
|
|
320
|
+
"""What the checked agent card says: its description and whether it reads the origin."""
|
|
321
|
+
|
|
322
|
+
name: str
|
|
323
|
+
description: str
|
|
324
|
+
origin: bool
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
@dataclass
|
|
328
|
+
class _Cards:
|
|
329
|
+
"""Checked cards by (API, URL): a bounded LRU, failures kept briefly, one fetch at a time."""
|
|
330
|
+
|
|
331
|
+
entries: OrderedDict[tuple[str, str], tuple[float, PeerCard | Exception]] = field(
|
|
332
|
+
default_factory=OrderedDict
|
|
333
|
+
)
|
|
334
|
+
fetching: dict[tuple[str, str], asyncio.Future[PeerCard]] = field(default_factory=dict)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
_CARDS = _Cards()
|
|
338
|
+
# Per-context locks (one conversation runs one turn at a time), a bounded LRU of the
|
|
339
|
+
# unlocked ones.
|
|
340
|
+
_LOCKS: OrderedDict[str, asyncio.Lock] = OrderedDict()
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _lock_for(context_id: str) -> asyncio.Lock:
|
|
344
|
+
lock = _LOCKS.get(context_id)
|
|
345
|
+
if lock is None:
|
|
346
|
+
lock = _LOCKS[context_id] = asyncio.Lock()
|
|
347
|
+
_LOCKS.move_to_end(context_id)
|
|
348
|
+
if len(_LOCKS) > LOCKS_MAX:
|
|
349
|
+
for key in [k for k, v in _LOCKS.items() if not v.locked()][: len(_LOCKS) - LOCKS_MAX]:
|
|
350
|
+
del _LOCKS[key]
|
|
351
|
+
return lock
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def reset_a2a_client() -> None:
|
|
355
|
+
"""Forget the cards and locks (tests)."""
|
|
356
|
+
_CARDS.entries.clear()
|
|
357
|
+
_CARDS.fetching.clear()
|
|
358
|
+
_LOCKS.clear()
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _url_key(url: str) -> tuple[str, str, int | None, str]:
|
|
362
|
+
"""A URL as the card check compares it: scheme, host, port, path (one trailing slash off)."""
|
|
363
|
+
parsed = httpx.URL(url)
|
|
364
|
+
port = parsed.port or {"http": 80, "https": 443}.get(parsed.scheme)
|
|
365
|
+
return parsed.scheme.lower(), (parsed.host or "").lower(), port, parsed.path.rstrip("/") or "/"
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _thread_id(runtime: Any) -> str | None:
|
|
369
|
+
config = getattr(runtime, "config", None)
|
|
370
|
+
if not isinstance(config, Mapping):
|
|
371
|
+
try:
|
|
372
|
+
from langgraph.config import get_config
|
|
373
|
+
|
|
374
|
+
config = get_config()
|
|
375
|
+
except Exception: # outside a graph run
|
|
376
|
+
return None
|
|
377
|
+
thread_id = (config.get("configurable") or {}).get("thread_id")
|
|
378
|
+
return str(thread_id) if thread_id else None
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _origin_of(context: Any) -> Mapping[str, Any] | None:
|
|
382
|
+
attributes = getattr(context, "attributes", None)
|
|
383
|
+
if attributes is None and isinstance(context, Mapping):
|
|
384
|
+
attributes = context.get("attributes")
|
|
385
|
+
credentials = attributes.get("credentials") if isinstance(attributes, Mapping) else None
|
|
386
|
+
origin = credentials.get("@origin") if isinstance(credentials, Mapping) else None
|
|
387
|
+
return origin if isinstance(origin, Mapping) else None
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
# ---------------------------------------------------------------------------
|
|
391
|
+
# The client
|
|
392
|
+
# ---------------------------------------------------------------------------
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
class A2APeerClient:
|
|
396
|
+
"""One peer (a `protocol: a2a` API), called for the current run's user. See the module doc.
|
|
397
|
+
|
|
398
|
+
`peer` is a name `peer_tools` registered (`orders`), or the API's own name.
|
|
399
|
+
`runtime` is the tool's `ToolRuntime` (its thread, its user's words, its
|
|
400
|
+
context); `context` overrides the run context. `transport` is for tests.
|
|
401
|
+
"""
|
|
402
|
+
|
|
403
|
+
def __init__(
|
|
404
|
+
self,
|
|
405
|
+
peer: str,
|
|
406
|
+
*,
|
|
407
|
+
runtime: Any = None,
|
|
408
|
+
context: Any = None,
|
|
409
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
410
|
+
) -> None:
|
|
411
|
+
self.runtime = runtime
|
|
412
|
+
self.context = (
|
|
413
|
+
context
|
|
414
|
+
if context is not None
|
|
415
|
+
else getattr(runtime, "context", None) or current_context()
|
|
416
|
+
)
|
|
417
|
+
self.transport = transport
|
|
418
|
+
self.peer, self.api = self._resolve(peer)
|
|
419
|
+
settings = load_policy().api(self.api)
|
|
420
|
+
if api_protocol(settings) != PROTOCOL_A2A:
|
|
421
|
+
raise ApiPolicyError(f"API {self.api!r} is not an A2A peer (protocol: a2a).")
|
|
422
|
+
self.settings = settings
|
|
423
|
+
self.path = str((settings.get("a2a") or {}).get("path") or f"/a2a/{self.peer}")
|
|
424
|
+
|
|
425
|
+
@staticmethod
|
|
426
|
+
def _resolve(peer: str) -> tuple[str, str]:
|
|
427
|
+
name = str(peer or "").strip()
|
|
428
|
+
registered = _PEERS.get(name)
|
|
429
|
+
if registered is not None:
|
|
430
|
+
return name, registered["api"]
|
|
431
|
+
apis = load_policy().apis
|
|
432
|
+
for api in (name, f"{name}_agent"):
|
|
433
|
+
if api in apis and api_protocol(apis[api]) == PROTOCOL_A2A:
|
|
434
|
+
return name, api
|
|
435
|
+
if name in apis:
|
|
436
|
+
raise ApiPolicyError(f"API {name!r} is not an A2A peer (protocol: a2a).")
|
|
437
|
+
known = ", ".join(_PEERS) or "(none)"
|
|
438
|
+
raise ApiPolicyError(f"unknown agent {name!r}; ask one of: {known}")
|
|
439
|
+
|
|
440
|
+
# -- plumbing -----------------------------------------------------------------
|
|
441
|
+
|
|
442
|
+
def _client(self) -> ApiClient:
|
|
443
|
+
return get_client(self.api, context=self.context, transport=self.transport)
|
|
444
|
+
|
|
445
|
+
def _base_url(self) -> str:
|
|
446
|
+
return str(self._client().base_url()).rstrip("/")
|
|
447
|
+
|
|
448
|
+
def _audience(self) -> str | None:
|
|
449
|
+
exchange = self.settings.get(EXCHANGE_KEY)
|
|
450
|
+
if self.settings.get("auth") == "exchange" and isinstance(exchange, Mapping):
|
|
451
|
+
return str(exchange.get("audience") or "") or None
|
|
452
|
+
return str(self.settings.get("forward_audience") or "") or None
|
|
453
|
+
|
|
454
|
+
def check_loop(self) -> None:
|
|
455
|
+
"""Refuse a call that would come back to this agent or to one before it in the chain.
|
|
456
|
+
|
|
457
|
+
The peer is known by its name, the last segment of its `a2a.path` (its
|
|
458
|
+
A2A name) and its audience; each is checked as `token_exchange.loop_problem`
|
|
459
|
+
checks a delegation target: this agent's own names (its A2A name, its
|
|
460
|
+
audiences) and the agents the request came through (T9).
|
|
461
|
+
"""
|
|
462
|
+
from {{cookiecutter.agent_directory}}.app_utils.token_exchange import loop_problem
|
|
463
|
+
|
|
464
|
+
chain = current_caller(self.context).actor_chain
|
|
465
|
+
for name in (self.peer, self.path.rstrip("/").rsplit("/", 1)[-1], self._audience()):
|
|
466
|
+
problem = loop_problem(name, chain) if name else None
|
|
467
|
+
if problem:
|
|
468
|
+
raise ApiPolicyError(f"{problem}; refused, nothing was sent.")
|
|
469
|
+
|
|
470
|
+
def context_id(self) -> str:
|
|
471
|
+
"""This thread's conversation with the peer, for this user (`context_id_for`)."""
|
|
472
|
+
thread_id = _thread_id(self.runtime)
|
|
473
|
+
if not thread_id:
|
|
474
|
+
raise ApiPolicyError(
|
|
475
|
+
f"calling {self.peer} needs an agent run (a thread): refused, nothing was sent."
|
|
476
|
+
)
|
|
477
|
+
return context_id_for(thread_id, self.api, current_caller(self.context).principal_id)
|
|
478
|
+
|
|
479
|
+
async def _rpc(
|
|
480
|
+
self,
|
|
481
|
+
method: str,
|
|
482
|
+
params: Mapping[str, Any],
|
|
483
|
+
*,
|
|
484
|
+
rpc_id: str | None = None,
|
|
485
|
+
headers: Mapping[str, str] | None = None,
|
|
486
|
+
) -> Any:
|
|
487
|
+
"""One JSON-RPC 2.0 request to the peer; its `result`. Errors as the model reads them."""
|
|
488
|
+
request_id = rpc_id or uuid.uuid4().hex
|
|
489
|
+
body = {"jsonrpc": "2.0", "id": request_id, "method": method, "params": dict(params)}
|
|
490
|
+
try:
|
|
491
|
+
answer = await self._client().post(
|
|
492
|
+
self.path, json_body=body, headers={"A2A-Version": A2A_VERSION, **(headers or {})}
|
|
493
|
+
)
|
|
494
|
+
except ApiCallError as exc:
|
|
495
|
+
if exc.status_code == 401:
|
|
496
|
+
raise ApiCallError(
|
|
497
|
+
f"{self.peer} refused the credential (401): check exchange.audience and "
|
|
498
|
+
f"{self.peer}'s AUTH_JWT_AUDIENCE",
|
|
499
|
+
status_code=401,
|
|
500
|
+
body=exc.body,
|
|
501
|
+
) from None
|
|
502
|
+
raise
|
|
503
|
+
if (
|
|
504
|
+
not isinstance(answer, Mapping)
|
|
505
|
+
or answer.get("jsonrpc") != "2.0"
|
|
506
|
+
or answer.get("id") != request_id
|
|
507
|
+
or ("result" not in answer and "error" not in answer)
|
|
508
|
+
):
|
|
509
|
+
raise ApiCallError(f"{self.peer} answered with something that is not A2A 1.0 JSON-RPC")
|
|
510
|
+
if "error" in answer:
|
|
511
|
+
error = answer["error"] if isinstance(answer["error"], Mapping) else {}
|
|
512
|
+
code = error.get("code")
|
|
513
|
+
text = _clean(error.get("message"), ERROR_MESSAGE_MAX_CHARS)
|
|
514
|
+
raise PeerRpcError(f"{self.peer} refused {method}: {code} {text}".rstrip(), code)
|
|
515
|
+
return answer["result"]
|
|
516
|
+
|
|
517
|
+
def _task_reply(self, result: Any, context_id: str) -> PeerReply:
|
|
518
|
+
"""A `PeerReply` from a `SendMessage` result (a task or a message) or a task."""
|
|
519
|
+
from a2a.types.a2a_pb2 import SendMessageResponse, Task
|
|
520
|
+
from google.protobuf import json_format
|
|
521
|
+
|
|
522
|
+
if not isinstance(result, Mapping):
|
|
523
|
+
raise ApiCallError(f"{self.peer} answered with something that is not A2A 1.0 JSON-RPC")
|
|
524
|
+
try:
|
|
525
|
+
if "task" in result or "message" in result:
|
|
526
|
+
json_format.ParseDict(result, SendMessageResponse(), ignore_unknown_fields=True)
|
|
527
|
+
else:
|
|
528
|
+
json_format.ParseDict(result, Task(), ignore_unknown_fields=True)
|
|
529
|
+
except json_format.ParseError:
|
|
530
|
+
raise ApiCallError(
|
|
531
|
+
f"{self.peer} answered with something that is not A2A 1.0 JSON-RPC"
|
|
532
|
+
) from None
|
|
533
|
+
if isinstance(result.get("message"), Mapping):
|
|
534
|
+
message = result["message"]
|
|
535
|
+
text = _texts(message.get("parts"))
|
|
536
|
+
return PeerReply(
|
|
537
|
+
state="TASK_STATE_COMPLETED",
|
|
538
|
+
text=text[: reply_max_chars()],
|
|
539
|
+
task_id=None,
|
|
540
|
+
context_id=str(message.get("contextId") or context_id),
|
|
541
|
+
)
|
|
542
|
+
task = result["task"] if isinstance(result.get("task"), Mapping) else result
|
|
543
|
+
return reply_of(self.peer, task, context_id)
|
|
544
|
+
|
|
545
|
+
# -- the card -----------------------------------------------------------------
|
|
546
|
+
|
|
547
|
+
async def card(self) -> PeerCard:
|
|
548
|
+
"""The peer's checked agent card (cached; one fetch at a time). See the module doc."""
|
|
549
|
+
url = self._base_url()
|
|
550
|
+
key = (self.api, url)
|
|
551
|
+
loop = asyncio.get_running_loop()
|
|
552
|
+
cached = _CARDS.entries.get(key)
|
|
553
|
+
if cached is not None and cached[0] > loop.time():
|
|
554
|
+
_CARDS.entries.move_to_end(key)
|
|
555
|
+
if isinstance(cached[1], Exception):
|
|
556
|
+
raise cached[1]
|
|
557
|
+
return cached[1]
|
|
558
|
+
pending = _CARDS.fetching.get(key)
|
|
559
|
+
if pending is not None:
|
|
560
|
+
return await asyncio.shield(pending)
|
|
561
|
+
future: asyncio.Future[PeerCard] = loop.create_future()
|
|
562
|
+
_CARDS.fetching[key] = future
|
|
563
|
+
try:
|
|
564
|
+
card = await self._fetch_card(url)
|
|
565
|
+
except (ApiCallError, ApiPolicyError) as exc:
|
|
566
|
+
if not isinstance(exc, ApiPolicyError):
|
|
567
|
+
self._keep_card(key, exc, CARD_FAILURE_TTL_S)
|
|
568
|
+
future.set_exception(exc)
|
|
569
|
+
future.exception() # retrieved: a waiter may never come
|
|
570
|
+
raise
|
|
571
|
+
finally:
|
|
572
|
+
_CARDS.fetching.pop(key, None)
|
|
573
|
+
self._keep_card(key, card, float(card_ttl_s()))
|
|
574
|
+
future.set_result(card)
|
|
575
|
+
return card
|
|
576
|
+
|
|
577
|
+
@staticmethod
|
|
578
|
+
def _keep_card(key: tuple[str, str], value: PeerCard | Exception, ttl: float) -> None:
|
|
579
|
+
if ttl <= 0:
|
|
580
|
+
return
|
|
581
|
+
_CARDS.entries[key] = (asyncio.get_running_loop().time() + ttl, value)
|
|
582
|
+
_CARDS.entries.move_to_end(key)
|
|
583
|
+
while len(_CARDS.entries) > CARD_CACHE_MAX:
|
|
584
|
+
_CARDS.entries.popitem(last=False)
|
|
585
|
+
|
|
586
|
+
async def _fetch_card(self, url: str) -> PeerCard:
|
|
587
|
+
from a2a.types import AgentCard
|
|
588
|
+
from google.protobuf import json_format
|
|
589
|
+
|
|
590
|
+
try:
|
|
591
|
+
data = await self._client().get(
|
|
592
|
+
f"{self.path}{CARD_PATH_SUFFIX}",
|
|
593
|
+
operation_id=CARD_OPERATION,
|
|
594
|
+
headers={"A2A-Version": A2A_VERSION},
|
|
595
|
+
)
|
|
596
|
+
except ApiCallError as exc:
|
|
597
|
+
if exc.status_code == 401:
|
|
598
|
+
raise ApiCallError(
|
|
599
|
+
f"{self.peer} refused the credential (401): check exchange.audience and "
|
|
600
|
+
f"{self.peer}'s AUTH_JWT_AUDIENCE",
|
|
601
|
+
status_code=401,
|
|
602
|
+
) from None
|
|
603
|
+
raise
|
|
604
|
+
card = AgentCard()
|
|
605
|
+
try:
|
|
606
|
+
json_format.ParseDict(data, card, ignore_unknown_fields=True)
|
|
607
|
+
except (json_format.ParseError, TypeError, AttributeError):
|
|
608
|
+
raise ApiCallError(
|
|
609
|
+
f"{self.peer}'s agent card is not an A2A agent card; nothing was sent."
|
|
610
|
+
) from None
|
|
611
|
+
expected = f"{url}{self.path}"
|
|
612
|
+
versions = [
|
|
613
|
+
i
|
|
614
|
+
for i in card.supported_interfaces
|
|
615
|
+
if i.protocol_binding.upper() == "JSONRPC" and i.protocol_version.startswith("1.")
|
|
616
|
+
]
|
|
617
|
+
if not versions:
|
|
618
|
+
raise ApiCallError(
|
|
619
|
+
f"{self.peer}'s agent card offers no A2A 1.x JSON-RPC interface; nothing was sent."
|
|
620
|
+
)
|
|
621
|
+
if not any(_url_key(i.url) == _url_key(expected) for i in versions):
|
|
622
|
+
env = self.settings.get("base_url_env")
|
|
623
|
+
raise ApiCallError(
|
|
624
|
+
f"{self.peer}'s agent card names {versions[0].url} as its A2A endpoint, not the "
|
|
625
|
+
f"URL this agent calls ({env} + {self.path}): set the peer's APP_URL (appUrl in "
|
|
626
|
+
"its chart values); nothing was sent."
|
|
627
|
+
)
|
|
628
|
+
segment = self.path.rstrip("/").rsplit("/", 1)[-1]
|
|
629
|
+
if card.name != segment:
|
|
630
|
+
raise ApiCallError(
|
|
631
|
+
f"{self.peer}'s agent card is agent {card.name!r}, not {segment!r} (the last "
|
|
632
|
+
f"segment of its a2a.path, {self.path}); nothing was sent."
|
|
633
|
+
)
|
|
634
|
+
return PeerCard(
|
|
635
|
+
name=card.name,
|
|
636
|
+
description=_clean(card.description, DESCRIPTION_MAX_CHARS),
|
|
637
|
+
origin=any(e.uri == A2A_ORIGIN_EXTENSION for e in card.capabilities.extensions),
|
|
638
|
+
)
|
|
639
|
+
|
|
640
|
+
# -- the user's words -----------------------------------------------------------
|
|
641
|
+
|
|
642
|
+
def _origin(self, card: PeerCard) -> dict[str, Any] | None:
|
|
643
|
+
"""The user's own words this call forwards (the origin extension), or None.
|
|
644
|
+
|
|
645
|
+
Sent only under `A2A_FORWARD_ORIGIN=auto` to a peer whose card declares the
|
|
646
|
+
extension. The user's latest message when the user asked this agent
|
|
647
|
+
directly; the words the calling agent forwarded when an agent asked
|
|
648
|
+
(none: nothing is sent, never a model's text). `hops` counts the agents.
|
|
649
|
+
"""
|
|
650
|
+
if not card.origin or forward_origin() != "auto":
|
|
651
|
+
return None
|
|
652
|
+
if current_caller(self.context).delegated:
|
|
653
|
+
forwarded = _origin_of(self.context)
|
|
654
|
+
if forwarded is None or not isinstance(forwarded.get("text"), str):
|
|
655
|
+
return None
|
|
656
|
+
text, truncated = forwarded["text"], forwarded.get("truncated") is True
|
|
657
|
+
hops = forwarded.get("hops")
|
|
658
|
+
hops = int(hops) + 1 if isinstance(hops, int) and not isinstance(hops, bool) else 2
|
|
659
|
+
else:
|
|
660
|
+
text = latest_user_message(self.runtime) if self.runtime is not None else ""
|
|
661
|
+
truncated, hops = False, 1
|
|
662
|
+
if not text:
|
|
663
|
+
return None
|
|
664
|
+
cap = origin_max_chars()
|
|
665
|
+
return {"text": text[:cap], "truncated": truncated or len(text) > cap, "hops": hops}
|
|
666
|
+
|
|
667
|
+
@staticmethod
|
|
668
|
+
def _extension(
|
|
669
|
+
origin: Mapping[str, Any] | None, approving: Mapping[str, Any] | None = None
|
|
670
|
+
) -> tuple[dict[str, Any] | None, dict[str, str]]:
|
|
671
|
+
"""A message's metadata and headers for the origin extension (both empty: none)."""
|
|
672
|
+
data: dict[str, Any] = {}
|
|
673
|
+
if origin is not None:
|
|
674
|
+
data[ORIGIN_KEY] = dict(origin)
|
|
675
|
+
if approving is not None:
|
|
676
|
+
data[APPROVING_KEY] = dict(approving)
|
|
677
|
+
headers = {"A2A-Extensions": A2A_ORIGIN_EXTENSION} if origin is not None else {}
|
|
678
|
+
return ({A2A_ORIGIN_EXTENSION: data} if data else None), headers
|
|
679
|
+
|
|
680
|
+
# -- calls ------------------------------------------------------------------------
|
|
681
|
+
|
|
682
|
+
async def _prepare(self) -> tuple[PeerCard, str]:
|
|
683
|
+
self.check_loop()
|
|
684
|
+
context_id = self.context_id()
|
|
685
|
+
return await self.card(), context_id
|
|
686
|
+
|
|
687
|
+
async def send(self, text: str) -> PeerReply:
|
|
688
|
+
"""Send `text` to the peer (a blocking `SendMessage`) and return its reply.
|
|
689
|
+
|
|
690
|
+
A task the peer failed because its thread was busy is sent again, 3 times
|
|
691
|
+
at most. Calls to one peer in one thread are serialized.
|
|
692
|
+
"""
|
|
693
|
+
from a2a.types.a2a_pb2 import (
|
|
694
|
+
Message,
|
|
695
|
+
Part,
|
|
696
|
+
Role,
|
|
697
|
+
SendMessageConfiguration,
|
|
698
|
+
SendMessageRequest,
|
|
699
|
+
)
|
|
700
|
+
from google.protobuf import json_format
|
|
701
|
+
|
|
702
|
+
card, context_id = await self._prepare()
|
|
703
|
+
async with _lock_for(context_id):
|
|
704
|
+
for delay in (*BUSY_RETRY_DELAYS_S, None):
|
|
705
|
+
request = SendMessageRequest(
|
|
706
|
+
message=Message(
|
|
707
|
+
message_id=uuid.uuid4().hex,
|
|
708
|
+
context_id=context_id,
|
|
709
|
+
role=Role.ROLE_USER,
|
|
710
|
+
parts=[Part(text=text)],
|
|
711
|
+
),
|
|
712
|
+
configuration=SendMessageConfiguration(history_length=0),
|
|
713
|
+
)
|
|
714
|
+
params = json_format.MessageToDict(request)
|
|
715
|
+
metadata, headers = self._extension(self._origin(card))
|
|
716
|
+
if metadata is not None:
|
|
717
|
+
params["message"]["metadata"] = metadata # plain JSON: exact values
|
|
718
|
+
result = await self._rpc("SendMessage", params, headers=headers)
|
|
719
|
+
reply = self._task_reply(result, context_id)
|
|
720
|
+
if reply.error_code != THREAD_BUSY or delay is None:
|
|
721
|
+
return reply
|
|
722
|
+
logger.info("A2A peer %s was busy; sending again in %g s", self.peer, delay)
|
|
723
|
+
await asyncio.sleep(delay)
|
|
724
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
725
|
+
|
|
726
|
+
async def get_task(self, task_id: str) -> PeerReply:
|
|
727
|
+
"""The peer's task `task_id` (`GetTask`, owner-scoped at the peer)."""
|
|
728
|
+
from a2a.types.a2a_pb2 import GetTaskRequest
|
|
729
|
+
from google.protobuf import json_format
|
|
730
|
+
|
|
731
|
+
_card, context_id = await self._prepare()
|
|
732
|
+
params = json_format.MessageToDict(GetTaskRequest(id=str(task_id), history_length=0))
|
|
733
|
+
return self._task_reply(await self._rpc("GetTask", params), context_id)
|
|
734
|
+
|
|
735
|
+
async def cancel(self, task_id: str) -> PeerReply:
|
|
736
|
+
"""Cancel the peer's task (`CancelTask`; the policy must allow it: `peer add --calls
|
|
737
|
+
ask,status,cancel`). A task still running on another replica of the peer (-32002) is
|
|
738
|
+
asked again once, after 1 s."""
|
|
739
|
+
from a2a.types.a2a_pb2 import CancelTaskRequest
|
|
740
|
+
from google.protobuf import json_format
|
|
741
|
+
|
|
742
|
+
_card, context_id = await self._prepare()
|
|
743
|
+
params = json_format.MessageToDict(CancelTaskRequest(id=str(task_id)))
|
|
744
|
+
for retry in (True, False):
|
|
745
|
+
try:
|
|
746
|
+
return self._task_reply(await self._rpc("CancelTask", params), context_id)
|
|
747
|
+
except PeerRpcError as exc:
|
|
748
|
+
if exc.code != TASK_NOT_CANCELABLE:
|
|
749
|
+
raise
|
|
750
|
+
if not retry:
|
|
751
|
+
raise ApiCallError(
|
|
752
|
+
f"the task is still running on another replica of {self.peer}; "
|
|
753
|
+
"try again later"
|
|
754
|
+
) from None
|
|
755
|
+
await asyncio.sleep(CANCEL_RETRY_S)
|
|
756
|
+
raise AssertionError("unreachable") # pragma: no cover
|
|
757
|
+
|
|
758
|
+
async def _ledger(self, context_id: str) -> list[dict[str, Any]]:
|
|
759
|
+
"""The peer's approvals on this conversation, from its approvals ledger (exact JSON)."""
|
|
760
|
+
rows = await self._client().get(
|
|
761
|
+
LEDGER_PATH, operation_id=LEDGER_OPERATION, path_params={"context_id": context_id}
|
|
762
|
+
)
|
|
763
|
+
if not isinstance(rows, list):
|
|
764
|
+
raise ApiCallError(
|
|
765
|
+
f"{self.peer}'s approvals answered with something that is not a list"
|
|
766
|
+
)
|
|
767
|
+
return [row for row in rows if isinstance(row, dict)]
|
|
768
|
+
|
|
769
|
+
async def _pending(self, task_id: str | None) -> tuple[list[dict[str, Any]], PeerReply | None]:
|
|
770
|
+
"""What the peer waits on for this conversation: its pending approvals and the task.
|
|
771
|
+
|
|
772
|
+
`GetTask` first (the task must be this conversation's: T6); when the peer
|
|
773
|
+
lost the task (-32001), or reported its approvals only as a `Struct`, its
|
|
774
|
+
approvals ledger, which is authoritative.
|
|
775
|
+
"""
|
|
776
|
+
_card, context_id = await self._prepare()
|
|
777
|
+
reply: PeerReply | None = None
|
|
778
|
+
if task_id:
|
|
779
|
+
try:
|
|
780
|
+
reply = await self.get_task(task_id)
|
|
781
|
+
except PeerRpcError as exc:
|
|
782
|
+
if exc.code != TASK_NOT_FOUND:
|
|
783
|
+
raise
|
|
784
|
+
if reply is not None and reply.context_id != context_id:
|
|
785
|
+
raise ApiPolicyError(
|
|
786
|
+
f"task {task_id} of {self.peer} belongs to another conversation; refused."
|
|
787
|
+
)
|
|
788
|
+
if reply is not None and reply.state != "TASK_STATE_INPUT_REQUIRED":
|
|
789
|
+
return [], reply
|
|
790
|
+
if reply is not None and reply.approvals is not None:
|
|
791
|
+
return [
|
|
792
|
+
a for a in reply.approvals if a.get("status", "pending") == "pending"
|
|
793
|
+
], reply
|
|
794
|
+
rows = await self._ledger(context_id)
|
|
795
|
+
pending = [a for a in rows if a.get("status") == "pending"]
|
|
796
|
+
return sorted(pending, key=lambda a: str(a.get("created_at") or "")), reply
|
|
797
|
+
|
|
798
|
+
async def pending_approvals(self, task_id: str | None) -> list[dict[str, Any]]:
|
|
799
|
+
"""The approvals the peer waits on in this conversation (exact values), oldest first."""
|
|
800
|
+
pending, _reply = await self._pending(task_id)
|
|
801
|
+
return pending
|
|
802
|
+
|
|
803
|
+
def approving(self, approval: Mapping[str, Any]) -> dict[str, Any]:
|
|
804
|
+
"""What the person is asked to approve here: the peer's approval as the peer reported it."""
|
|
805
|
+
call = {key: approval[key] for key in NESTED_CALL_KEYS if key in approval}
|
|
806
|
+
return {
|
|
807
|
+
"agent": self.peer,
|
|
808
|
+
"approval_id": approval.get("approval_id"),
|
|
809
|
+
"call": call,
|
|
810
|
+
"reason": approval.get("reason"),
|
|
811
|
+
"expires_at": approval.get("expires_at"),
|
|
812
|
+
"digest": approval.get("digest"),
|
|
813
|
+
"reported_by": self.peer,
|
|
814
|
+
"decide_with": approval.get("decide_with"),
|
|
815
|
+
"nested": approval.get("nested") if isinstance(approval.get("nested"), dict) else None,
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
def _decision_params(
|
|
819
|
+
self,
|
|
820
|
+
approval: Mapping[str, Any],
|
|
821
|
+
decision: str,
|
|
822
|
+
context_id: str,
|
|
823
|
+
task_id: str | None,
|
|
824
|
+
origin: Mapping[str, Any] | None,
|
|
825
|
+
comment: str | None = None,
|
|
826
|
+
) -> tuple[dict[str, Any], dict[str, str]]:
|
|
827
|
+
"""The decision message, built the same on every run: the approval binds its body."""
|
|
828
|
+
approval_id = str(approval.get("approval_id"))
|
|
829
|
+
data: dict[str, Any] = {"approval_id": approval_id, "decision": decision}
|
|
830
|
+
if comment:
|
|
831
|
+
data["comment"] = comment
|
|
832
|
+
if approval.get("digest"):
|
|
833
|
+
data["digest"] = approval["digest"]
|
|
834
|
+
message: dict[str, Any] = {
|
|
835
|
+
"messageId": str(
|
|
836
|
+
uuid.uuid5(_DECISION_NAMESPACE, f"{context_id}:{approval_id}:{decision}")
|
|
837
|
+
),
|
|
838
|
+
"contextId": context_id,
|
|
839
|
+
"role": "ROLE_USER",
|
|
840
|
+
"parts": [{"data": data}],
|
|
841
|
+
}
|
|
842
|
+
if task_id:
|
|
843
|
+
message["referenceTaskIds"] = [str(task_id)]
|
|
844
|
+
metadata, headers = self._extension(origin, self.approving(approval))
|
|
845
|
+
message["metadata"] = metadata
|
|
846
|
+
return {"message": message, "configuration": {"historyLength": 0}}, headers
|
|
847
|
+
|
|
848
|
+
async def decide(
|
|
849
|
+
self,
|
|
850
|
+
approval: Mapping[str, Any],
|
|
851
|
+
decision: str,
|
|
852
|
+
comment: str | None = None,
|
|
853
|
+
*,
|
|
854
|
+
task_id: str | None = None,
|
|
855
|
+
) -> PeerReply:
|
|
856
|
+
"""Send the person's decision on the peer's `approval`, on this conversation.
|
|
857
|
+
|
|
858
|
+
`approve` is gated by this agent's policy (the person approves it here
|
|
859
|
+
first); `reject` is sent at once.
|
|
860
|
+
"""
|
|
861
|
+
card, context_id = await self._prepare()
|
|
862
|
+
params, headers = self._decision_params(
|
|
863
|
+
approval, decision, context_id, task_id, self._origin(card), comment
|
|
864
|
+
)
|
|
865
|
+
async with _lock_for(context_id):
|
|
866
|
+
result = await self._rpc(
|
|
867
|
+
"SendMessage",
|
|
868
|
+
params,
|
|
869
|
+
rpc_id=f"{decision}-{approval.get('approval_id')}",
|
|
870
|
+
headers=headers,
|
|
871
|
+
)
|
|
872
|
+
return self._task_reply(result, context_id)
|
|
873
|
+
|
|
874
|
+
async def relay(self, task_id: str) -> str:
|
|
875
|
+
"""The `approve_agent_action` tool: relay the person's decision on what the peer waits on.
|
|
876
|
+
|
|
877
|
+
See the module doc. Returns what the model reads.
|
|
878
|
+
"""
|
|
879
|
+
pending, reply = await self._pending(task_id)
|
|
880
|
+
if not pending:
|
|
881
|
+
state = f" (its task is {_state_name(reply.state)})" if reply is not None else ""
|
|
882
|
+
return f"{self.peer} is not waiting for an approval{state}."
|
|
883
|
+
approval, others = pending[0], pending[1:]
|
|
884
|
+
also = (
|
|
885
|
+
f" {self.peer} also waits on {len(others)} more approval(s) "
|
|
886
|
+
f"({', '.join(str(o.get('approval_id')) for o in others)}): relay them one at a time."
|
|
887
|
+
if others
|
|
888
|
+
else ""
|
|
889
|
+
)
|
|
890
|
+
if approval.get("decide_with", "direct") != "relayed":
|
|
891
|
+
return json.dumps(self.needs_direct(approval))
|
|
892
|
+
try:
|
|
893
|
+
answered = await self.decide(approval, "approve", task_id=task_id)
|
|
894
|
+
except ApiPolicyError as exc:
|
|
895
|
+
if exc.reason not in ("approval rejected", "approval expired"):
|
|
896
|
+
raise
|
|
897
|
+
# The person said no (or nobody answered): tell the peer, so its task ends now
|
|
898
|
+
# instead of waiting for its own approval to expire. Rejecting is always safe.
|
|
899
|
+
await self.decide(
|
|
900
|
+
approval, "reject", "The user did not approve this action.", task_id=task_id
|
|
901
|
+
)
|
|
902
|
+
why = "rejected it" if exc.reason == "approval rejected" else "did not answer in time"
|
|
903
|
+
return (
|
|
904
|
+
f"The user {why}; {self.peer} was told and did nothing. Tell the user it was not "
|
|
905
|
+
"done."
|
|
906
|
+
)
|
|
907
|
+
return describe(self.peer, answered, relays=True) + also
|
|
908
|
+
|
|
909
|
+
def needs_direct(self, approval: Mapping[str, Any]) -> dict[str, Any]:
|
|
910
|
+
"""What the model reads for a gate the peer lets the person decide only directly."""
|
|
911
|
+
effect = approval.get("effect") if isinstance(approval.get("effect"), dict) else None
|
|
912
|
+
if effect is None:
|
|
913
|
+
effect = approval_effect(self.approving(approval))
|
|
914
|
+
url = f"{self._base_url()}"
|
|
915
|
+
return {
|
|
916
|
+
"status": "needs_direct_approval",
|
|
917
|
+
"agent": self.peer,
|
|
918
|
+
"approval_id": approval.get("approval_id"),
|
|
919
|
+
"effect": effect,
|
|
920
|
+
"how": (
|
|
921
|
+
f"graph-agents-cli approvals approve {approval.get('approval_id')} --url {url}, "
|
|
922
|
+
f"or POST /threads/<conversation>/approvals/{approval.get('approval_id')} at "
|
|
923
|
+
f"{self.peer} with the person's own token"
|
|
924
|
+
),
|
|
925
|
+
"next_step": (
|
|
926
|
+
f"{self.peer} lets only the person decide this at {self.peer} itself "
|
|
927
|
+
"(decide_with: direct): tell the user exactly what it wants to do and how to "
|
|
928
|
+
"approve it there. Never say it was done."
|
|
929
|
+
),
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def _state_name(state: str) -> str:
|
|
934
|
+
return state.removeprefix("TASK_STATE_").lower().replace("_", "-")
|
|
935
|
+
|
|
936
|
+
|
|
937
|
+
def _what(approval: Mapping[str, Any]) -> str:
|
|
938
|
+
what = f"{approval.get('method')} {approval.get('path')}"
|
|
939
|
+
if approval.get("operation_id"):
|
|
940
|
+
what += f" ({approval['operation_id']})"
|
|
941
|
+
return what
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
def describe(peer: str, reply: PeerReply, *, relays: bool) -> str:
|
|
945
|
+
"""What the model reads for a peer's reply (`relays`: this agent may relay approvals)."""
|
|
946
|
+
if reply.state == "TASK_STATE_COMPLETED":
|
|
947
|
+
return reply.text or f"({peer} completed the task with an empty reply)"
|
|
948
|
+
if reply.state == "TASK_STATE_INPUT_REQUIRED" and reply.approvals:
|
|
949
|
+
refused = (
|
|
950
|
+
f"{peer} refused the decision ({reply.error_code}): "
|
|
951
|
+
+ _clean(reply.status_text, STATUS_TEXT_MAX_CHARS).split("Waiting for approval")[0]
|
|
952
|
+
if reply.error_code
|
|
953
|
+
else None
|
|
954
|
+
)
|
|
955
|
+
direct = [a for a in reply.approvals if a.get("decide_with", "direct") != "relayed"]
|
|
956
|
+
waiting = [
|
|
957
|
+
{
|
|
958
|
+
"approval_id": a.get("approval_id"),
|
|
959
|
+
"call": _what(a),
|
|
960
|
+
"effect": _effect_line(a.get("effect")),
|
|
961
|
+
"decided_by": "the person at " + peer
|
|
962
|
+
if a in direct
|
|
963
|
+
else "the person here (relayed)",
|
|
964
|
+
"expires_at": a.get("expires_at"),
|
|
965
|
+
}
|
|
966
|
+
for a in reply.approvals
|
|
967
|
+
]
|
|
968
|
+
if relays and not direct:
|
|
969
|
+
status, step = (
|
|
970
|
+
"needs_user_approval",
|
|
971
|
+
"Tell the user exactly what the agent wants to do, then call approve_agent_action "
|
|
972
|
+
"with this agent and task_id: the user is asked to approve or reject it there. "
|
|
973
|
+
"Never assume approval.",
|
|
974
|
+
)
|
|
975
|
+
else:
|
|
976
|
+
status, step = (
|
|
977
|
+
"needs_direct_approval",
|
|
978
|
+
f"Only the person can approve this, at {peer} itself: tell the user exactly what "
|
|
979
|
+
f"{peer} wants to do and that they must approve it there. Never say it was done.",
|
|
980
|
+
)
|
|
981
|
+
result: dict[str, Any] = {
|
|
982
|
+
"status": status,
|
|
983
|
+
"agent": peer,
|
|
984
|
+
"task_id": reply.task_id,
|
|
985
|
+
"waiting": waiting,
|
|
986
|
+
"agent_said": _clean(reply.text or reply.status_text, AGENT_SAID_MAX_CHARS),
|
|
987
|
+
"next_step": step,
|
|
988
|
+
}
|
|
989
|
+
if refused:
|
|
990
|
+
result["refused"] = refused.strip()
|
|
991
|
+
return json.dumps(result)
|
|
992
|
+
status_text = _clean(reply.status_text, STATUS_TEXT_MAX_CHARS)
|
|
993
|
+
if reply.state == "TASK_STATE_INPUT_REQUIRED":
|
|
994
|
+
return (
|
|
995
|
+
f"{peer} needs more input (task {reply.task_id}): "
|
|
996
|
+
f"{status_text or reply.text or 'no detail'}. Ask it again with the answer."
|
|
997
|
+
)
|
|
998
|
+
return f"{peer} ended the task as {_state_name(reply.state)}: {status_text or 'no detail'}"
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def _effect_line(effect: Any) -> str | None:
|
|
1002
|
+
if not isinstance(effect, Mapping):
|
|
1003
|
+
return None
|
|
1004
|
+
via = [str(v) for v in effect.get("via") or [] if v]
|
|
1005
|
+
hops = via[:-1] if via and via[-1] == effect.get("agent") else via
|
|
1006
|
+
who = str(effect.get("agent")) + (f" (via {', '.join(hops)})" if hops else "")
|
|
1007
|
+
return f"{who} will {_what(effect)}"
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
# ---------------------------------------------------------------------------
|
|
1011
|
+
# The tools
|
|
1012
|
+
# ---------------------------------------------------------------------------
|
|
1013
|
+
|
|
1014
|
+
ASK_DOC = """Ask another agent to do something for the user, over A2A, and return its reply.
|
|
1015
|
+
|
|
1016
|
+
The agents you can ask:
|
|
1017
|
+
{roster}
|
|
1018
|
+
|
|
1019
|
+
Args:
|
|
1020
|
+
agent: which agent to ask.
|
|
1021
|
+
request: a complete, self-contained instruction for that agent. Say who it is for only
|
|
1022
|
+
as "the user", and name every record id the user gave (order, invoice, SKU).
|
|
1023
|
+
|
|
1024
|
+
Several ask_agent calls in one turn run in parallel. If the reply says needs_user_approval,
|
|
1025
|
+
call approve_agent_action with that agent and task_id; if it says needs_direct_approval,
|
|
1026
|
+
tell the user what that agent wants to do and that they must approve it there.
|
|
1027
|
+
"""
|
|
1028
|
+
|
|
1029
|
+
RELAY_DOC = """Ask the user to approve an action another agent is waiting on, then pass the user's
|
|
1030
|
+
decision to that agent and return what it did.
|
|
1031
|
+
|
|
1032
|
+
Args:
|
|
1033
|
+
agent: the agent whose task reported needs_user_approval.
|
|
1034
|
+
task_id: that task's id, exactly as ask_agent returned it.
|
|
1035
|
+
"""
|
|
1036
|
+
|
|
1037
|
+
|
|
1038
|
+
def _check_peers(peers: Mapping[str, Mapping[str, str]]) -> dict[str, dict[str, str]]:
|
|
1039
|
+
"""Every peer is a `protocol: a2a` API of the loaded policy; raise (fail closed) otherwise."""
|
|
1040
|
+
apis = load_policy().apis
|
|
1041
|
+
checked: dict[str, dict[str, str]] = {}
|
|
1042
|
+
for name, peer in peers.items():
|
|
1043
|
+
api = str(peer.get("api") or "")
|
|
1044
|
+
if api not in apis or api_protocol(apis[api]) != PROTOCOL_A2A:
|
|
1045
|
+
raise ApiPolicyError(
|
|
1046
|
+
f"peer {name!r}: API {api!r} is not a protocol: a2a API of api-policy.yaml; "
|
|
1047
|
+
"run `graph-agents-cli peer sync` (tools/a2a_peers.py is out of date)."
|
|
1048
|
+
)
|
|
1049
|
+
checked[str(name)] = {
|
|
1050
|
+
"api": api,
|
|
1051
|
+
"approvals": str(peer.get("approvals") or "deny"),
|
|
1052
|
+
"description": _clean(peer.get("description"), DESCRIPTION_MAX_CHARS),
|
|
1053
|
+
}
|
|
1054
|
+
return checked
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def _warn_without_salt() -> None:
|
|
1058
|
+
dev = os.environ.get("APP_ENV") == "dev"
|
|
1059
|
+
if not dev and not (os.environ.get("PRINCIPAL_HASH_SALT") or "").strip():
|
|
1060
|
+
logger.warning(
|
|
1061
|
+
"A2A peers: PRINCIPAL_HASH_SALT is not set, so the contextIds sent to other agents "
|
|
1062
|
+
"are plain hashes of the thread and user ids; set it (a secret) so they cannot be "
|
|
1063
|
+
"guessed."
|
|
1064
|
+
)
|
|
1065
|
+
|
|
1066
|
+
|
|
1067
|
+
def peer_tools(
|
|
1068
|
+
peers: Mapping[str, Mapping[str, str]],
|
|
1069
|
+
*,
|
|
1070
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
1071
|
+
) -> list[Any]:
|
|
1072
|
+
"""The tools that ask `peers` (`{name: {"api", "approvals", "description"}}`).
|
|
1073
|
+
|
|
1074
|
+
`ask_agent(agent, request)`, whose description lists the peers and what each
|
|
1075
|
+
does; and, when a peer has `approvals: relay`, `approve_agent_action(agent,
|
|
1076
|
+
task_id)` for those peers. Every peer must be a `protocol: a2a` API of the
|
|
1077
|
+
loaded policy, or this raises (the app then fails to start: fail closed).
|
|
1078
|
+
"""
|
|
1079
|
+
from langchain.tools import ToolRuntime
|
|
1080
|
+
from langchain_core.tools import tool
|
|
1081
|
+
|
|
1082
|
+
checked = _check_peers(peers)
|
|
1083
|
+
if not checked:
|
|
1084
|
+
return []
|
|
1085
|
+
_PEERS.update(checked)
|
|
1086
|
+
_warn_without_salt()
|
|
1087
|
+
names = tuple(checked)
|
|
1088
|
+
relay_names = tuple(n for n, p in checked.items() if p["approvals"] == "relay")
|
|
1089
|
+
roster = "\n".join(
|
|
1090
|
+
f"- {name}: {peer['description'] or '(no description: set it with `peer add --description`)'}"
|
|
1091
|
+
for name, peer in checked.items()
|
|
1092
|
+
)
|
|
1093
|
+
|
|
1094
|
+
async def ask_agent(agent: str, request: str, runtime: Any) -> str:
|
|
1095
|
+
client = A2APeerClient(agent, runtime=runtime, transport=transport)
|
|
1096
|
+
reply = await client.send(request)
|
|
1097
|
+
return describe(client.peer, reply, relays=checked[client.peer]["approvals"] == "relay")
|
|
1098
|
+
|
|
1099
|
+
ask_agent.__annotations__ = {
|
|
1100
|
+
"agent": Literal[names], # type: ignore[valid-type]
|
|
1101
|
+
"request": str,
|
|
1102
|
+
"runtime": ToolRuntime[Any],
|
|
1103
|
+
"return": str,
|
|
1104
|
+
}
|
|
1105
|
+
ask_agent.__doc__ = ASK_DOC.format(roster=roster)
|
|
1106
|
+
tools: list[Any] = [tool(ask_agent)]
|
|
1107
|
+
if relay_names:
|
|
1108
|
+
|
|
1109
|
+
async def approve_agent_action(agent: str, task_id: str, runtime: Any) -> str:
|
|
1110
|
+
if agent not in relay_names:
|
|
1111
|
+
raise ApiPolicyError(
|
|
1112
|
+
f"this agent does not relay approvals to {agent!r}; relays go to: "
|
|
1113
|
+
f"{', '.join(relay_names)}"
|
|
1114
|
+
)
|
|
1115
|
+
client = A2APeerClient(agent, runtime=runtime, transport=transport)
|
|
1116
|
+
return await client.relay(task_id)
|
|
1117
|
+
|
|
1118
|
+
approve_agent_action.__annotations__ = {
|
|
1119
|
+
"agent": Literal[relay_names], # type: ignore[valid-type]
|
|
1120
|
+
"task_id": str,
|
|
1121
|
+
"runtime": ToolRuntime[Any],
|
|
1122
|
+
"return": str,
|
|
1123
|
+
}
|
|
1124
|
+
approve_agent_action.__doc__ = RELAY_DOC
|
|
1125
|
+
tools.append(tool(approve_agent_action))
|
|
1126
|
+
return tools
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def list_agents_tool(
|
|
1130
|
+
peers: Mapping[str, Mapping[str, str]],
|
|
1131
|
+
*,
|
|
1132
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
1133
|
+
) -> Any:
|
|
1134
|
+
"""A `list_agents` tool that reads each peer's agent card (for discovery at run time)."""
|
|
1135
|
+
from langchain.tools import ToolRuntime
|
|
1136
|
+
from langchain_core.tools import tool
|
|
1137
|
+
|
|
1138
|
+
checked = _check_peers(peers)
|
|
1139
|
+
_PEERS.update(checked)
|
|
1140
|
+
|
|
1141
|
+
async def list_agents(runtime: Any) -> str:
|
|
1142
|
+
async def one(name: str) -> str:
|
|
1143
|
+
try:
|
|
1144
|
+
card = await A2APeerClient(name, runtime=runtime, transport=transport).card()
|
|
1145
|
+
except (ApiCallError, ApiPolicyError) as exc:
|
|
1146
|
+
return f"- {name}: unavailable ({type(exc).__name__})"
|
|
1147
|
+
return f"- {name}: {card.description or checked[name]['description']}"
|
|
1148
|
+
|
|
1149
|
+
return "\n".join(await asyncio.gather(*(one(name) for name in checked)))
|
|
1150
|
+
|
|
1151
|
+
list_agents.__annotations__ = {"runtime": ToolRuntime[Any], "return": str}
|
|
1152
|
+
list_agents.__doc__ = "List the agents you can ask, with what each says it does (its card)."
|
|
1153
|
+
return tool(list_agents)
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
__all__ = [
|
|
1157
|
+
"A2APeerClient",
|
|
1158
|
+
"PeerCard",
|
|
1159
|
+
"PeerReply",
|
|
1160
|
+
"PeerRpcError",
|
|
1161
|
+
"client_settings",
|
|
1162
|
+
"context_id_for",
|
|
1163
|
+
"describe",
|
|
1164
|
+
"list_agents_tool",
|
|
1165
|
+
"peer_tools",
|
|
1166
|
+
"reset_a2a_client",
|
|
1167
|
+
]
|