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,824 @@
|
|
|
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
|
+
"""The A2A client (`app_utils/a2a_client.py`): asking other agents, and relaying approvals.
|
|
16
|
+
|
|
17
|
+
The peer is a fake A2A 1.0 agent `orders` behind `httpx.MockTransport` (its
|
|
18
|
+
card, its JSON-RPC endpoint and its approvals ledger), so every request the
|
|
19
|
+
client sends, and what the policy lets through, is checked without a network.
|
|
20
|
+
The relay runs in a real graph (the fake model, the ledger an `ApprovalStore`)
|
|
21
|
+
that pauses at the policy's approve gate and resumes with a decision made here.
|
|
22
|
+
The same client against a real agent (this template, with token exchange) is in
|
|
23
|
+
`tests/integration/test_a2a_relay.py`.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import uuid
|
|
32
|
+
from collections.abc import Iterator
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from types import SimpleNamespace
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
os.environ.setdefault("MODEL_PROVIDER", "fake")
|
|
38
|
+
|
|
39
|
+
import httpx
|
|
40
|
+
import pytest
|
|
41
|
+
from a2a.types.a2a_pb2 import SendMessageRequest
|
|
42
|
+
from google.protobuf import json_format
|
|
43
|
+
from langchain.agents import create_agent
|
|
44
|
+
from langgraph.checkpoint.memory import InMemorySaver
|
|
45
|
+
from langgraph.types import Command
|
|
46
|
+
|
|
47
|
+
from {{cookiecutter.agent_directory}}.app_utils import a2a_client
|
|
48
|
+
from {{cookiecutter.agent_directory}}.app_utils.a2a_client import (
|
|
49
|
+
A2APeerClient,
|
|
50
|
+
PeerRpcError,
|
|
51
|
+
client_settings,
|
|
52
|
+
context_id_for,
|
|
53
|
+
peer_tools,
|
|
54
|
+
reset_a2a_client,
|
|
55
|
+
)
|
|
56
|
+
from {{cookiecutter.agent_directory}}.app_utils.api_client import (
|
|
57
|
+
A2A_ORIGIN_EXTENSION,
|
|
58
|
+
ApiCallError,
|
|
59
|
+
ApiPolicyError,
|
|
60
|
+
reset_limits,
|
|
61
|
+
reset_policy_cache,
|
|
62
|
+
set_approval_ledger,
|
|
63
|
+
)
|
|
64
|
+
from {{cookiecutter.agent_directory}}.app_utils.approvals import (
|
|
65
|
+
APPROVED,
|
|
66
|
+
REJECTED,
|
|
67
|
+
ApprovalStore,
|
|
68
|
+
decision_value,
|
|
69
|
+
record_from_interrupt,
|
|
70
|
+
)
|
|
71
|
+
from {{cookiecutter.agent_directory}}.app_utils.auth import Principal
|
|
72
|
+
from {{cookiecutter.agent_directory}}.app_utils.db import Database
|
|
73
|
+
from {{cookiecutter.agent_directory}}.app_utils.limits import SettingsError
|
|
74
|
+
|
|
75
|
+
BASE = "http://orders-agent:8080"
|
|
76
|
+
PATH = "/a2a/orders"
|
|
77
|
+
|
|
78
|
+
POLICY = """
|
|
79
|
+
apis:
|
|
80
|
+
orders_agent:
|
|
81
|
+
description: "Orders agent: reads the caller's orders; cancels one after approval."
|
|
82
|
+
protocol: a2a
|
|
83
|
+
a2a: {path: /a2a/orders}
|
|
84
|
+
base_url_env: ORDERS_AGENT_URL
|
|
85
|
+
auth: bearer
|
|
86
|
+
token_env: ORDERS_AGENT_KEY
|
|
87
|
+
allowed_methods: [GET, POST]
|
|
88
|
+
allowed_operations:
|
|
89
|
+
- {operationId: getAgentCard, methods: [GET], path: /a2a/orders/.well-known/agent-card.json}
|
|
90
|
+
- {rpc_method: SendMessage, methods: [POST], path: /a2a/orders}
|
|
91
|
+
- {rpc_method: GetTask, methods: [POST], path: /a2a/orders}
|
|
92
|
+
- {rpc_method: CancelTask, methods: [POST], path: /a2a/orders}
|
|
93
|
+
- {operationId: listContextApprovals, methods: [GET], path: "/threads/{context_id}/approvals"}
|
|
94
|
+
approval:
|
|
95
|
+
- required_for: {operations: [{a2a_operation: approve}]}
|
|
96
|
+
approvers: [requester]
|
|
97
|
+
timeout_s: 900
|
|
98
|
+
limits: {max_calls_per_run: 12, max_response_bytes: 1048576}
|
|
99
|
+
timeouts_ms: {connect: 2000, read: 120000}
|
|
100
|
+
billing_agent:
|
|
101
|
+
description: "Billing agent: invoices and refunds."
|
|
102
|
+
protocol: a2a
|
|
103
|
+
a2a: {path: /a2a/billing}
|
|
104
|
+
base_url_env: BILLING_AGENT_URL
|
|
105
|
+
auth: bearer
|
|
106
|
+
token_env: BILLING_AGENT_KEY
|
|
107
|
+
allowed_methods: [GET, POST]
|
|
108
|
+
allowed_operations:
|
|
109
|
+
- {operationId: getAgentCard, methods: [GET], path: /a2a/billing/.well-known/agent-card.json}
|
|
110
|
+
- {rpc_method: SendMessage, methods: [POST], path: /a2a/billing}
|
|
111
|
+
- {rpc_method: GetTask, methods: [POST], path: /a2a/billing}
|
|
112
|
+
denied_operations:
|
|
113
|
+
- {a2a_operation: approve}
|
|
114
|
+
shop:
|
|
115
|
+
base_url_env: SHOP_API_BASE_URL
|
|
116
|
+
auth: none
|
|
117
|
+
allowed_methods: [GET]
|
|
118
|
+
"""
|
|
119
|
+
PEERS = {
|
|
120
|
+
"orders": {
|
|
121
|
+
"api": "orders_agent",
|
|
122
|
+
"approvals": "relay",
|
|
123
|
+
"description": "Orders agent: reads the caller's orders; cancels one after approval.",
|
|
124
|
+
},
|
|
125
|
+
"billing": {"api": "billing_agent", "approvals": "deny", "description": "Billing agent."},
|
|
126
|
+
}
|
|
127
|
+
ALICE = {"principal_id": "alice", "roles": ["user"], "attributes": {}}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _approval(**overrides: Any) -> dict[str, Any]:
|
|
131
|
+
"""orders' pending approval, as its public approval object (exact values)."""
|
|
132
|
+
approval = {
|
|
133
|
+
"approval_id": "de2e",
|
|
134
|
+
"status": "pending",
|
|
135
|
+
"api": "orders_api",
|
|
136
|
+
"method": "POST",
|
|
137
|
+
"path": "/orders/ORD-1002/cancel",
|
|
138
|
+
"operation_id": "cancelOrder",
|
|
139
|
+
"query": {},
|
|
140
|
+
"body": {"reason": "customer asked", "amount": 1, "big": 12345678901234567890},
|
|
141
|
+
"reason": "cancel_order: the customer asked",
|
|
142
|
+
"approvers": ["requester"],
|
|
143
|
+
"decide_with": "relayed",
|
|
144
|
+
"digest": "sha256:" + "d" * 64,
|
|
145
|
+
"created_at": "2026-09-28T10:00:00+00:00",
|
|
146
|
+
"expires_at": "2099-01-01T00:00:00+00:00",
|
|
147
|
+
}
|
|
148
|
+
approval.update(overrides)
|
|
149
|
+
return approval
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class FakePeer:
|
|
153
|
+
"""An A2A 1.0 agent `orders` behind httpx.MockTransport.
|
|
154
|
+
|
|
155
|
+
A message containing "cancel" pauses for `approvals` (input-required, with
|
|
156
|
+
`approval_json`); a decision message completes; anything else is echoed.
|
|
157
|
+
`busy` answers that many messages with a failed `thread_busy` task, `lost`
|
|
158
|
+
answers GetTask -32001, `cancel_busy` answers that many cancels -32002, and
|
|
159
|
+
`card` / `answer` replace the card or every JSON-RPC answer.
|
|
160
|
+
"""
|
|
161
|
+
|
|
162
|
+
def __init__(self, name: str = "orders", base: str = BASE) -> None:
|
|
163
|
+
self.name, self.base = name, base
|
|
164
|
+
self.requests: list[httpx.Request] = []
|
|
165
|
+
self.tasks: dict[str, dict[str, Any]] = {}
|
|
166
|
+
self.approvals: list[dict[str, Any]] = [_approval()]
|
|
167
|
+
self.busy = self.cancel_busy = 0
|
|
168
|
+
self.lost = False
|
|
169
|
+
self.approval_json = True
|
|
170
|
+
self.context_override: str | None = None
|
|
171
|
+
self.card: dict[str, Any] | None = None
|
|
172
|
+
self.card_status = 200
|
|
173
|
+
self.answer: Any = None
|
|
174
|
+
|
|
175
|
+
def transport(self) -> httpx.MockTransport:
|
|
176
|
+
return httpx.MockTransport(self.handle)
|
|
177
|
+
|
|
178
|
+
@property
|
|
179
|
+
def calls(self) -> list[str]:
|
|
180
|
+
"""Each JSON-RPC request's method (and decision), in order; card and ledger reads too."""
|
|
181
|
+
out = []
|
|
182
|
+
for r in self.requests:
|
|
183
|
+
if r.method == "GET":
|
|
184
|
+
out.append("card" if r.url.path.endswith("agent-card.json") else "ledger")
|
|
185
|
+
continue
|
|
186
|
+
body = json.loads(r.content)
|
|
187
|
+
decisions = [
|
|
188
|
+
p["data"]["decision"]
|
|
189
|
+
for p in body["params"].get("message", {}).get("parts", [])
|
|
190
|
+
if "data" in p
|
|
191
|
+
]
|
|
192
|
+
out.append(" ".join([body["method"], *decisions]))
|
|
193
|
+
return out
|
|
194
|
+
|
|
195
|
+
def default_card(self) -> dict[str, Any]:
|
|
196
|
+
return {
|
|
197
|
+
"name": self.name,
|
|
198
|
+
"description": "Orders agent\x07: reads and cancels orders." + " x" * 400,
|
|
199
|
+
"supportedInterfaces": [
|
|
200
|
+
{
|
|
201
|
+
"url": f"{self.base}/a2a/{self.name}",
|
|
202
|
+
"protocolBinding": "JSONRPC",
|
|
203
|
+
"protocolVersion": "1.0",
|
|
204
|
+
}
|
|
205
|
+
],
|
|
206
|
+
"version": "0.1.0",
|
|
207
|
+
"capabilities": {"streaming": True, "extensions": [{"uri": A2A_ORIGIN_EXTENSION}]},
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
def handle(self, request: httpx.Request) -> httpx.Response:
|
|
211
|
+
self.requests.append(request)
|
|
212
|
+
if request.method == "GET" and request.url.path.endswith("agent-card.json"):
|
|
213
|
+
if self.card_status != 200:
|
|
214
|
+
return httpx.Response(self.card_status, json={"detail": "no"})
|
|
215
|
+
return httpx.Response(200, json=self.card or self.default_card())
|
|
216
|
+
if request.method == "GET": # the approvals ledger
|
|
217
|
+
return httpx.Response(200, json=self.approvals)
|
|
218
|
+
body = json.loads(request.content)
|
|
219
|
+
if self.answer is not None:
|
|
220
|
+
return httpx.Response(200, json=self.answer)
|
|
221
|
+
rpc_id, method, params = body["id"], body["method"], body["params"]
|
|
222
|
+
if method == "SendMessage":
|
|
223
|
+
return self._send(rpc_id, params["message"])
|
|
224
|
+
if method == "GetTask":
|
|
225
|
+
if self.lost or params["id"] not in self.tasks:
|
|
226
|
+
return self._error(rpc_id, -32001, "Task not found")
|
|
227
|
+
return httpx.Response(
|
|
228
|
+
200, json={"jsonrpc": "2.0", "id": rpc_id, "result": self.tasks[params["id"]]}
|
|
229
|
+
)
|
|
230
|
+
if method == "CancelTask":
|
|
231
|
+
if self.cancel_busy:
|
|
232
|
+
self.cancel_busy -= 1
|
|
233
|
+
return self._error(rpc_id, -32002, "running on another replica")
|
|
234
|
+
task = self._task("TASK_STATE_CANCELED", "canceled", body["params"].get("id"))
|
|
235
|
+
return httpx.Response(200, json={"jsonrpc": "2.0", "id": rpc_id, "result": task})
|
|
236
|
+
return self._error(rpc_id, -32601, "Method not found")
|
|
237
|
+
|
|
238
|
+
@staticmethod
|
|
239
|
+
def _error(rpc_id: Any, code: int, message: str) -> httpx.Response:
|
|
240
|
+
return httpx.Response(
|
|
241
|
+
200, json={"jsonrpc": "2.0", "id": rpc_id, "error": {"code": code, "message": message}}
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
def _task(
|
|
245
|
+
self,
|
|
246
|
+
state: str,
|
|
247
|
+
text: str,
|
|
248
|
+
task_id: str | None = None,
|
|
249
|
+
context_id: str = "",
|
|
250
|
+
parts: list[dict[str, Any]] | None = None,
|
|
251
|
+
reply: str | None = None,
|
|
252
|
+
) -> dict[str, Any]:
|
|
253
|
+
task: dict[str, Any] = {
|
|
254
|
+
"id": task_id or uuid.uuid4().hex,
|
|
255
|
+
"contextId": self.context_override or context_id,
|
|
256
|
+
"status": {
|
|
257
|
+
"state": state,
|
|
258
|
+
"message": {
|
|
259
|
+
"messageId": uuid.uuid4().hex,
|
|
260
|
+
"role": "ROLE_AGENT",
|
|
261
|
+
"parts": parts or [{"text": text}],
|
|
262
|
+
},
|
|
263
|
+
},
|
|
264
|
+
}
|
|
265
|
+
if reply is not None:
|
|
266
|
+
task["artifacts"] = [
|
|
267
|
+
{"artifactId": "a1", "name": "response", "parts": [{"text": "first draft"}]},
|
|
268
|
+
{"artifactId": "a2", "name": "response", "parts": [{"text": reply}]},
|
|
269
|
+
]
|
|
270
|
+
self.tasks[task["id"]] = task
|
|
271
|
+
return task
|
|
272
|
+
|
|
273
|
+
def _send(self, rpc_id: Any, message: dict[str, Any]) -> httpx.Response:
|
|
274
|
+
context_id = message.get("contextId", "")
|
|
275
|
+
text = "".join(p.get("text", "") for p in message["parts"])
|
|
276
|
+
decisions = [p["data"] for p in message["parts"] if "data" in p]
|
|
277
|
+
if self.busy:
|
|
278
|
+
self.busy -= 1
|
|
279
|
+
task = self._task(
|
|
280
|
+
"TASK_STATE_FAILED",
|
|
281
|
+
"thread_busy: busy",
|
|
282
|
+
context_id=context_id,
|
|
283
|
+
parts=[
|
|
284
|
+
{"text": "thread_busy: busy"},
|
|
285
|
+
{"data": {"type": "error", "code": "thread_busy"}},
|
|
286
|
+
],
|
|
287
|
+
)
|
|
288
|
+
elif decisions:
|
|
289
|
+
done = "Cancelled ORD-1002." if decisions[0]["decision"] == "approve" else "Kept it."
|
|
290
|
+
task = self._task("TASK_STATE_COMPLETED", "", context_id=context_id, reply=done)
|
|
291
|
+
elif "cancel" in text:
|
|
292
|
+
data: dict[str, Any] = {
|
|
293
|
+
"type": "approval_request",
|
|
294
|
+
"approval": self.approvals[0] if self.approvals else None,
|
|
295
|
+
"approvals": self.approvals,
|
|
296
|
+
}
|
|
297
|
+
if self.approval_json:
|
|
298
|
+
data["approval_json"] = json.dumps(self.approvals)
|
|
299
|
+
task = self._task(
|
|
300
|
+
"TASK_STATE_INPUT_REQUIRED",
|
|
301
|
+
"",
|
|
302
|
+
context_id=context_id,
|
|
303
|
+
parts=[
|
|
304
|
+
{"text": "Waiting for approval de2e: POST /orders/ORD-1002/cancel"},
|
|
305
|
+
{"data": data},
|
|
306
|
+
],
|
|
307
|
+
)
|
|
308
|
+
else:
|
|
309
|
+
task = self._task(
|
|
310
|
+
"TASK_STATE_COMPLETED", "", context_id=context_id, reply=f"echo: {text}"
|
|
311
|
+
)
|
|
312
|
+
return httpx.Response(200, json={"jsonrpc": "2.0", "id": rpc_id, "result": {"task": task}})
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
@pytest.fixture
|
|
316
|
+
def policy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]:
|
|
317
|
+
monkeypatch.delenv("APP_ENV", raising=False) # cluster-internal peers over plain http
|
|
318
|
+
for name in (
|
|
319
|
+
"A2A_FORWARD_ORIGIN",
|
|
320
|
+
"A2A_CARD_TTL_S",
|
|
321
|
+
"A2A_REPLY_MAX_CHARS",
|
|
322
|
+
"A2A_ORIGIN_MAX_CHARS",
|
|
323
|
+
"PRINCIPAL_HASH_SALT",
|
|
324
|
+
"A2A_NAME",
|
|
325
|
+
):
|
|
326
|
+
monkeypatch.delenv(name, raising=False)
|
|
327
|
+
path = tmp_path / "api-policy.yaml"
|
|
328
|
+
path.write_text(POLICY, encoding="utf-8")
|
|
329
|
+
monkeypatch.setenv("API_POLICY_PATH", str(path))
|
|
330
|
+
monkeypatch.setenv("ORDERS_AGENT_URL", BASE)
|
|
331
|
+
monkeypatch.setenv("ORDERS_AGENT_KEY", "orders-key")
|
|
332
|
+
monkeypatch.setenv("BILLING_AGENT_URL", "http://billing-agent:8080")
|
|
333
|
+
monkeypatch.setenv("BILLING_AGENT_KEY", "billing-key")
|
|
334
|
+
reset_policy_cache()
|
|
335
|
+
reset_limits()
|
|
336
|
+
reset_a2a_client()
|
|
337
|
+
yield path
|
|
338
|
+
reset_policy_cache()
|
|
339
|
+
reset_limits()
|
|
340
|
+
reset_a2a_client()
|
|
341
|
+
a2a_client._PEERS.clear()
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _runtime(text: str = "Cancel ORD-1002 please", context: Any = None, thread: str = "t1") -> Any:
|
|
345
|
+
return SimpleNamespace(
|
|
346
|
+
context=context if context is not None else dict(ALICE),
|
|
347
|
+
config={"configurable": {"thread_id": thread}},
|
|
348
|
+
state={"messages": [{"type": "human", "content": text}]},
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _client(peer: FakePeer, runtime: Any = None, name: str = "orders") -> A2APeerClient:
|
|
353
|
+
return A2APeerClient(name, runtime=runtime or _runtime(), transport=peer.transport())
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
# --- requests, cards, contexts ---------------------------------------------------------------
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
async def test_a_message_is_an_a2a_1_0_request_with_its_version(policy: Path) -> None:
|
|
360
|
+
peer = FakePeer()
|
|
361
|
+
reply = await _client(peer).send("Where is ORD-1002?")
|
|
362
|
+
assert reply.state == "TASK_STATE_COMPLETED"
|
|
363
|
+
assert reply.text == "echo: Where is ORD-1002?" # the LAST response artifact
|
|
364
|
+
assert peer.calls == ["card", "SendMessage"]
|
|
365
|
+
sent = peer.requests[1]
|
|
366
|
+
assert sent.headers["A2A-Version"] == "1.0"
|
|
367
|
+
body = json.loads(sent.content)
|
|
368
|
+
assert body["jsonrpc"] == "2.0" and body["method"] == "SendMessage"
|
|
369
|
+
request = SendMessageRequest()
|
|
370
|
+
json_format.ParseDict(body["params"], request) # a valid SDK request
|
|
371
|
+
assert request.message.context_id == context_id_for("t1", "orders_agent", "alice")
|
|
372
|
+
assert request.configuration.history_length == 0
|
|
373
|
+
assert "authorization" in {k.lower() for k in sent.headers} # the policy's credential
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def test_the_context_id_is_keyed_stable_and_uuid_shaped(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
377
|
+
monkeypatch.delenv("PRINCIPAL_HASH_SALT", raising=False)
|
|
378
|
+
plain = context_id_for("t1", "orders_agent", "alice")
|
|
379
|
+
assert plain == context_id_for("t1", "orders_agent", "alice") # every turn, every replica
|
|
380
|
+
assert uuid.UUID(plain).version == 4 and "t1" not in plain
|
|
381
|
+
others = {
|
|
382
|
+
context_id_for("t2", "orders_agent", "alice"),
|
|
383
|
+
context_id_for("t1", "billing_agent", "alice"),
|
|
384
|
+
context_id_for("t1", "orders_agent", "bob"),
|
|
385
|
+
}
|
|
386
|
+
assert plain not in others and len(others) == 3
|
|
387
|
+
monkeypatch.setenv("PRINCIPAL_HASH_SALT", "s3cret")
|
|
388
|
+
salted = context_id_for("t1", "orders_agent", "alice")
|
|
389
|
+
assert salted != plain and uuid.UUID(salted).version == 4
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
async def test_the_card_is_read_once_and_its_url_never_dialed(policy: Path) -> None:
|
|
393
|
+
peer = FakePeer()
|
|
394
|
+
client = _client(peer)
|
|
395
|
+
await client.send("one")
|
|
396
|
+
await client.send("two")
|
|
397
|
+
assert peer.calls == ["card", "SendMessage", "SendMessage"]
|
|
398
|
+
card = await client.card()
|
|
399
|
+
assert card.origin and "\x07" not in card.description and len(card.description) <= 300
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
@pytest.mark.parametrize(
|
|
403
|
+
("card", "refusal"),
|
|
404
|
+
[
|
|
405
|
+
(
|
|
406
|
+
{
|
|
407
|
+
"supportedInterfaces": [
|
|
408
|
+
{
|
|
409
|
+
"url": "http://evil.test/a2a/orders",
|
|
410
|
+
"protocolBinding": "JSONRPC",
|
|
411
|
+
"protocolVersion": "1.0",
|
|
412
|
+
}
|
|
413
|
+
]
|
|
414
|
+
},
|
|
415
|
+
"names http://evil.test/a2a/orders as its A2A endpoint, not the URL this agent calls",
|
|
416
|
+
),
|
|
417
|
+
(
|
|
418
|
+
{
|
|
419
|
+
"supportedInterfaces": [
|
|
420
|
+
{
|
|
421
|
+
"url": f"{BASE}/a2a/orders",
|
|
422
|
+
"protocolBinding": "JSONRPC",
|
|
423
|
+
"protocolVersion": "0.3.0",
|
|
424
|
+
}
|
|
425
|
+
]
|
|
426
|
+
},
|
|
427
|
+
"offers no A2A 1.x JSON-RPC interface",
|
|
428
|
+
),
|
|
429
|
+
({"name": "billing"}, "is agent 'billing', not 'orders'"),
|
|
430
|
+
],
|
|
431
|
+
)
|
|
432
|
+
async def test_a_card_that_is_not_this_peer_refuses_every_call(
|
|
433
|
+
policy: Path, card: dict[str, Any], refusal: str
|
|
434
|
+
) -> None:
|
|
435
|
+
peer = FakePeer()
|
|
436
|
+
peer.card = {**peer.default_card(), **card}
|
|
437
|
+
with pytest.raises(ApiCallError, match=refusal):
|
|
438
|
+
await _client(peer).send("hello")
|
|
439
|
+
assert peer.calls == ["card"] # nothing was sent, and the card's URL was never dialed
|
|
440
|
+
with pytest.raises(ApiCallError, match=refusal): # the failure is kept for a while
|
|
441
|
+
await _client(peer).send("hello")
|
|
442
|
+
assert peer.calls == ["card"]
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
async def test_a_card_behind_a_credential_the_peer_refuses(policy: Path) -> None:
|
|
446
|
+
peer = FakePeer()
|
|
447
|
+
peer.card_status = 401
|
|
448
|
+
with pytest.raises(ApiCallError, match=r"orders refused the credential \(401\)"):
|
|
449
|
+
await _client(peer).send("hello")
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
def test_locks_and_cards_are_bounded(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
453
|
+
monkeypatch.setattr(a2a_client, "LOCKS_MAX", 3)
|
|
454
|
+
reset_a2a_client()
|
|
455
|
+
held = a2a_client._lock_for("c0")
|
|
456
|
+
asyncio.run(held.acquire())
|
|
457
|
+
for index in range(1, 6):
|
|
458
|
+
a2a_client._lock_for(f"c{index}")
|
|
459
|
+
assert len(a2a_client._LOCKS) == 3 and "c0" in a2a_client._LOCKS # a held lock stays
|
|
460
|
+
reset_a2a_client()
|
|
461
|
+
|
|
462
|
+
|
|
463
|
+
async def test_a_busy_peer_is_asked_again_three_times_at_most(
|
|
464
|
+
policy: Path, monkeypatch: pytest.MonkeyPatch
|
|
465
|
+
) -> None:
|
|
466
|
+
waits: list[float] = []
|
|
467
|
+
|
|
468
|
+
async def no_wait(seconds: float) -> None:
|
|
469
|
+
waits.append(seconds)
|
|
470
|
+
|
|
471
|
+
monkeypatch.setattr(a2a_client.asyncio, "sleep", no_wait)
|
|
472
|
+
peer = FakePeer()
|
|
473
|
+
peer.busy = 2
|
|
474
|
+
reply = await _client(peer).send("hello")
|
|
475
|
+
assert reply.text == "echo: hello" and waits == [0.5, 1.0]
|
|
476
|
+
peer.busy = 9
|
|
477
|
+
reply = await _client(peer).send("hello")
|
|
478
|
+
assert reply.state == "TASK_STATE_FAILED" and reply.error_code == "thread_busy"
|
|
479
|
+
assert waits == [0.5, 1.0, 0.5, 1.0, 2.0]
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
async def test_a_cancel_the_peers_other_replica_runs_is_asked_again_once(
|
|
483
|
+
policy: Path, monkeypatch: pytest.MonkeyPatch
|
|
484
|
+
) -> None:
|
|
485
|
+
async def no_wait(seconds: float) -> None:
|
|
486
|
+
return None
|
|
487
|
+
|
|
488
|
+
monkeypatch.setattr(a2a_client.asyncio, "sleep", no_wait)
|
|
489
|
+
peer = FakePeer()
|
|
490
|
+
peer.cancel_busy = 1
|
|
491
|
+
assert (await _client(peer).cancel("t9")).state == "TASK_STATE_CANCELED"
|
|
492
|
+
peer.cancel_busy = 2
|
|
493
|
+
with pytest.raises(ApiCallError, match="still running on another replica of orders"):
|
|
494
|
+
await _client(peer).cancel("t9")
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
async def test_a_call_the_policy_does_not_allow_is_never_sent(policy: Path) -> None:
|
|
498
|
+
"""billing's entry allows no CancelTask: the allow-list refuses it before sending."""
|
|
499
|
+
peer = FakePeer(name="billing", base="http://billing-agent:8080")
|
|
500
|
+
with pytest.raises(ApiPolicyError, match="rpc_method CancelTask"):
|
|
501
|
+
await _client(peer, name="billing").cancel("t9")
|
|
502
|
+
assert "CancelTask" not in peer.calls
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
# --- loops and errors --------------------------------------------------------------------
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
async def test_a_call_back_to_this_agent_or_up_the_chain_is_refused(
|
|
509
|
+
policy: Path, monkeypatch: pytest.MonkeyPatch
|
|
510
|
+
) -> None:
|
|
511
|
+
peer = FakePeer()
|
|
512
|
+
monkeypatch.setenv("A2A_NAME", "orders")
|
|
513
|
+
with pytest.raises(ApiPolicyError, match="would call this agent itself"):
|
|
514
|
+
await _client(peer).send("hello")
|
|
515
|
+
monkeypatch.setenv("A2A_NAME", "billing")
|
|
516
|
+
chained = {
|
|
517
|
+
**ALICE,
|
|
518
|
+
"attributes": {"@actor": {"id": "concierge", "chain": ["concierge"]}},
|
|
519
|
+
}
|
|
520
|
+
monkeypatch.setattr(
|
|
521
|
+
A2APeerClient, "_resolve", staticmethod(lambda peer: ("concierge", "orders_agent"))
|
|
522
|
+
)
|
|
523
|
+
with pytest.raises(
|
|
524
|
+
ApiPolicyError, match=r"loop back through the delegation chain \(concierge -> billing"
|
|
525
|
+
):
|
|
526
|
+
await _client(peer, _runtime(context=chained)).send("hello")
|
|
527
|
+
assert peer.requests == []
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
async def test_errors_the_model_reads(policy: Path) -> None:
|
|
531
|
+
with pytest.raises(ApiPolicyError, match="unknown agent 'shipping'; ask one of"):
|
|
532
|
+
A2APeerClient("shipping", runtime=_runtime())
|
|
533
|
+
with pytest.raises(ApiPolicyError, match="is not an A2A peer"):
|
|
534
|
+
A2APeerClient("shop", runtime=_runtime())
|
|
535
|
+
peer = FakePeer()
|
|
536
|
+
await _client(peer).card()
|
|
537
|
+
peer.answer = {"jsonrpc": "2.0", "id": "x", "error": {"code": -32602, "message": "bad " * 200}}
|
|
538
|
+
with pytest.raises(ApiCallError, match="orders answered with something that is not A2A"):
|
|
539
|
+
await _client(peer).send("hello") # another request's id
|
|
540
|
+
peer.answer = None
|
|
541
|
+
peer.card = None
|
|
542
|
+
|
|
543
|
+
def refuse(request: httpx.Request) -> httpx.Response:
|
|
544
|
+
body = json.loads(request.content)
|
|
545
|
+
return httpx.Response(
|
|
546
|
+
200,
|
|
547
|
+
json={
|
|
548
|
+
"jsonrpc": "2.0",
|
|
549
|
+
"id": body["id"],
|
|
550
|
+
"error": {"code": -32602, "message": "bad " * 200},
|
|
551
|
+
},
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
client = A2APeerClient("orders", runtime=_runtime(), transport=httpx.MockTransport(refuse))
|
|
555
|
+
reset_a2a_client()
|
|
556
|
+
a2a_client._CARDS.entries[("orders_agent", BASE)] = (
|
|
557
|
+
asyncio.get_running_loop().time() + 60,
|
|
558
|
+
a2a_client.PeerCard(name="orders", description="", origin=False),
|
|
559
|
+
)
|
|
560
|
+
with pytest.raises(PeerRpcError) as refused:
|
|
561
|
+
await client.send("hello")
|
|
562
|
+
assert refused.value.code == -32602
|
|
563
|
+
assert str(refused.value).startswith("orders refused SendMessage: -32602 bad bad")
|
|
564
|
+
assert len(str(refused.value)) < 360
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
def test_bad_settings_stop_startup(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
568
|
+
client_settings()
|
|
569
|
+
for name, value in (
|
|
570
|
+
("A2A_FORWARD_ORIGIN", "always"),
|
|
571
|
+
("A2A_CARD_TTL_S", "-1"),
|
|
572
|
+
("A2A_REPLY_MAX_CHARS", "0"),
|
|
573
|
+
("A2A_ORIGIN_MAX_CHARS", "lots"),
|
|
574
|
+
):
|
|
575
|
+
monkeypatch.setenv(name, value)
|
|
576
|
+
with pytest.raises(SettingsError, match=name):
|
|
577
|
+
client_settings()
|
|
578
|
+
monkeypatch.delenv(name)
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
# --- the user's own words (the origin extension) ----------------------------------------------
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def _sent_origin(peer: FakePeer) -> tuple[Any, str | None]:
|
|
585
|
+
sent = next(r for r in peer.requests if r.method == "POST")
|
|
586
|
+
metadata = json.loads(sent.content)["params"]["message"].get("metadata") or {}
|
|
587
|
+
return metadata.get(A2A_ORIGIN_EXTENSION), sent.headers.get("A2A-Extensions")
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
async def test_the_users_words_go_to_a_peer_that_reads_them(policy: Path) -> None:
|
|
591
|
+
peer = FakePeer()
|
|
592
|
+
await _client(peer, _runtime("Cancel ORD-1002, it came broken")).send("cancel ORD-1002")
|
|
593
|
+
extension, header = _sent_origin(peer)
|
|
594
|
+
assert header == A2A_ORIGIN_EXTENSION
|
|
595
|
+
assert extension == {
|
|
596
|
+
"origin": {"text": "Cancel ORD-1002, it came broken", "truncated": False, "hops": 1}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
|
|
600
|
+
async def test_the_words_are_capped_and_forwarded_on_with_one_more_hop(
|
|
601
|
+
policy: Path, monkeypatch: pytest.MonkeyPatch
|
|
602
|
+
) -> None:
|
|
603
|
+
monkeypatch.setenv("A2A_ORIGIN_MAX_CHARS", "10")
|
|
604
|
+
peer = FakePeer()
|
|
605
|
+
await _client(peer, _runtime("Cancel ORD-1002 now")).send("x")
|
|
606
|
+
assert _sent_origin(peer)[0]["origin"] == {"text": "Cancel ORD", "truncated": True, "hops": 1}
|
|
607
|
+
# An agent calling for the user passes on the words it was given, never its own request.
|
|
608
|
+
delegated = {
|
|
609
|
+
**ALICE,
|
|
610
|
+
"attributes": {
|
|
611
|
+
"@actor": {"id": "concierge", "chain": ["concierge"]},
|
|
612
|
+
"credentials": {"@origin": {"text": "ORD-7", "truncated": False, "hops": 1}},
|
|
613
|
+
},
|
|
614
|
+
}
|
|
615
|
+
peer = FakePeer()
|
|
616
|
+
await _client(peer, _runtime("the concierge's words", context=delegated)).send("x")
|
|
617
|
+
assert _sent_origin(peer)[0]["origin"] == {"text": "ORD-7", "truncated": False, "hops": 2}
|
|
618
|
+
without = {**delegated, "attributes": {"@actor": {"id": "concierge"}}}
|
|
619
|
+
peer = FakePeer()
|
|
620
|
+
await _client(peer, _runtime("the concierge's words", context=without)).send("x")
|
|
621
|
+
assert _sent_origin(peer) == (None, None)
|
|
622
|
+
|
|
623
|
+
|
|
624
|
+
async def test_the_words_stay_here_when_off_or_when_the_peer_does_not_read_them(
|
|
625
|
+
policy: Path, monkeypatch: pytest.MonkeyPatch
|
|
626
|
+
) -> None:
|
|
627
|
+
monkeypatch.setenv("A2A_FORWARD_ORIGIN", "off")
|
|
628
|
+
peer = FakePeer()
|
|
629
|
+
await _client(peer).send("x")
|
|
630
|
+
assert _sent_origin(peer) == (None, None)
|
|
631
|
+
monkeypatch.delenv("A2A_FORWARD_ORIGIN")
|
|
632
|
+
reset_a2a_client()
|
|
633
|
+
peer = FakePeer()
|
|
634
|
+
peer.card = {**peer.default_card(), "capabilities": {"streaming": True}}
|
|
635
|
+
await _client(peer).send("x")
|
|
636
|
+
assert _sent_origin(peer) == (None, None)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
# --- the tools --------------------------------------------------------------------------------
|
|
640
|
+
|
|
641
|
+
|
|
642
|
+
def test_the_tools_name_the_peers_and_fail_closed(policy: Path) -> None:
|
|
643
|
+
from langchain_core.utils.function_calling import convert_to_openai_tool
|
|
644
|
+
|
|
645
|
+
ask, relay = peer_tools(PEERS)
|
|
646
|
+
ask_schema = convert_to_openai_tool(ask)["function"]
|
|
647
|
+
assert ask_schema["name"] == "ask_agent"
|
|
648
|
+
assert ask_schema["parameters"]["properties"]["agent"]["enum"] == ["orders", "billing"]
|
|
649
|
+
assert "- orders: Orders agent: reads the caller's orders" in ask_schema["description"]
|
|
650
|
+
relay_schema = convert_to_openai_tool(relay)["function"]
|
|
651
|
+
assert relay_schema["name"] == "approve_agent_action"
|
|
652
|
+
# Only the peers this agent relays to (one: a JSON-schema constant).
|
|
653
|
+
assert relay_schema["parameters"]["properties"]["agent"]["const"] == "orders"
|
|
654
|
+
with pytest.raises(ApiPolicyError, match="'shop' is not a protocol: a2a API"):
|
|
655
|
+
peer_tools({"shop": {"api": "shop", "approvals": "deny"}})
|
|
656
|
+
assert len(peer_tools({"billing": PEERS["billing"]})) == 1 # no relay: no approve tool
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
# --- the relay, in a graph that pauses at the approve gate -------------------------------------
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
@pytest.fixture
|
|
663
|
+
def store() -> ApprovalStore:
|
|
664
|
+
return ApprovalStore(Database("memory"))
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
@pytest.fixture
|
|
668
|
+
def relay_graph(policy: Path, store: ApprovalStore) -> Iterator[tuple[Any, FakePeer]]:
|
|
669
|
+
"""An agent with the peer tools (approve_agent_action first: the fake model calls the first
|
|
670
|
+
tool a message mentions), the fake peer and the ledger `store`."""
|
|
671
|
+
from {{cookiecutter.agent_directory}} import agent
|
|
672
|
+
from {{cookiecutter.agent_directory}}.app_utils.model import get_model
|
|
673
|
+
|
|
674
|
+
peer = FakePeer()
|
|
675
|
+
set_approval_ledger(store)
|
|
676
|
+
tools = list(reversed(peer_tools(PEERS, transport=peer.transport())))
|
|
677
|
+
graph = create_agent(
|
|
678
|
+
model=get_model(),
|
|
679
|
+
tools=tools,
|
|
680
|
+
system_prompt=agent.SYSTEM_PROMPT,
|
|
681
|
+
middleware=agent.middleware(),
|
|
682
|
+
context_schema=agent.AgentContext,
|
|
683
|
+
checkpointer=InMemorySaver(),
|
|
684
|
+
)
|
|
685
|
+
yield graph, peer
|
|
686
|
+
set_approval_ledger(None)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
async def _run(graph: Any, graph_input: Any) -> list[Any]:
|
|
690
|
+
from {{cookiecutter.agent_directory}}.agent import AgentContext
|
|
691
|
+
|
|
692
|
+
config = {"configurable": {"thread_id": "t1"}}
|
|
693
|
+
context = AgentContext(principal_id="alice", roles=["user"])
|
|
694
|
+
async for _ in graph.astream(graph_input, config, context=context, stream_mode="updates"):
|
|
695
|
+
pass
|
|
696
|
+
return list((await graph.aget_state(config)).interrupts)
|
|
697
|
+
|
|
698
|
+
|
|
699
|
+
async def _tool_result(graph: Any) -> str:
|
|
700
|
+
state = await graph.aget_state({"configurable": {"thread_id": "t1"}})
|
|
701
|
+
return next(m.content for m in reversed(state.values["messages"]) if m.type == "tool")
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
async def _ask(graph: Any) -> str:
|
|
705
|
+
"""ask_agent asks orders to cancel: it pauses there, and reports the task to relay."""
|
|
706
|
+
assert await _run(graph, {"messages": [("user", "Ask orders about cancel ORD-1002")]}) == []
|
|
707
|
+
text = await _tool_result(graph)
|
|
708
|
+
start = text.index("{")
|
|
709
|
+
result = json.loads(text[start : text.rindex("}") + 1])
|
|
710
|
+
assert result["status"] == "needs_user_approval" and result["agent"] == "orders"
|
|
711
|
+
return str(result["task_id"])
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
async def _pause(graph: Any, store: ApprovalStore, task_id: str) -> tuple[Any, Any]:
|
|
715
|
+
interrupts = await _run(graph, {"messages": [("user", f"approve_agent_action for {task_id}")]})
|
|
716
|
+
assert len(interrupts) == 1
|
|
717
|
+
record, _, _ = await store.add(
|
|
718
|
+
record_from_interrupt(
|
|
719
|
+
interrupts[0].value,
|
|
720
|
+
interrupt_id=interrupts[0].id,
|
|
721
|
+
thread_id="t1",
|
|
722
|
+
run_id="r",
|
|
723
|
+
requester=Principal(id="alice", roles=["user"]),
|
|
724
|
+
)
|
|
725
|
+
)
|
|
726
|
+
return interrupts[0], record
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
async def test_the_relay_shows_the_person_what_happens_and_sends_it_once(
|
|
730
|
+
relay_graph: tuple[Any, FakePeer], store: ApprovalStore
|
|
731
|
+
) -> None:
|
|
732
|
+
graph, peer = relay_graph
|
|
733
|
+
task_id = await _ask(graph)
|
|
734
|
+
interrupt, record = await _pause(graph, store, task_id)
|
|
735
|
+
public = record.public()
|
|
736
|
+
assert public["a2a_operation"] == "approve"
|
|
737
|
+
assert public["effect"]["path"] == "/orders/ORD-1002/cancel"
|
|
738
|
+
assert public["effect"]["via"] == ["orders"]
|
|
739
|
+
assert public["nested"]["call"]["body"]["big"] == 12345678901234567890 # exact
|
|
740
|
+
assert public["nested"]["digest"] == _approval()["digest"]
|
|
741
|
+
assert peer.calls[-1] == "GetTask" # read, nothing decided yet
|
|
742
|
+
decided = await store.decide(record.approval_id, APPROVED, "alice", None)
|
|
743
|
+
await _run(graph, Command(resume={interrupt.id: decision_value(decided, "approve")}))
|
|
744
|
+
assert "Cancelled ORD-1002." in await _tool_result(graph)
|
|
745
|
+
assert [c for c in peer.calls if c.startswith("SendMessage")] == [
|
|
746
|
+
"SendMessage", # the ask
|
|
747
|
+
"SendMessage approve", # once
|
|
748
|
+
]
|
|
749
|
+
decision = json.loads(peer.requests[-1].content)
|
|
750
|
+
message = decision["params"]["message"]
|
|
751
|
+
assert decision["id"] == "approve-de2e"
|
|
752
|
+
assert message["parts"] == [
|
|
753
|
+
{"data": {"approval_id": "de2e", "decision": "approve", "digest": _approval()["digest"]}}
|
|
754
|
+
]
|
|
755
|
+
assert "taskId" not in message and message["referenceTaskIds"] == [task_id]
|
|
756
|
+
assert message["contextId"] == context_id_for("t1", "orders_agent", "alice")
|
|
757
|
+
assert message["metadata"][A2A_ORIGIN_EXTENSION]["approving"]["approval_id"] == "de2e"
|
|
758
|
+
assert (await store.get(record.approval_id)).used_at is not None
|
|
759
|
+
|
|
760
|
+
|
|
761
|
+
async def test_after_the_peer_lost_the_task_its_ledger_is_read_and_one_decision_sent(
|
|
762
|
+
relay_graph: tuple[Any, FakePeer], store: ApprovalStore
|
|
763
|
+
) -> None:
|
|
764
|
+
graph, peer = relay_graph
|
|
765
|
+
task_id = await _ask(graph)
|
|
766
|
+
peer.lost = True
|
|
767
|
+
interrupt, record = await _pause(graph, store, task_id)
|
|
768
|
+
assert peer.calls[-2:] == ["GetTask", "ledger"]
|
|
769
|
+
decided = await store.decide(record.approval_id, APPROVED, "alice", None)
|
|
770
|
+
await _run(graph, Command(resume={interrupt.id: decision_value(decided, "approve")}))
|
|
771
|
+
assert [c for c in peer.calls if c.startswith("SendMessage ")] == ["SendMessage approve"]
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
async def test_a_rejection_tells_the_peer_and_nothing_is_done(
|
|
775
|
+
relay_graph: tuple[Any, FakePeer], store: ApprovalStore
|
|
776
|
+
) -> None:
|
|
777
|
+
graph, peer = relay_graph
|
|
778
|
+
task_id = await _ask(graph)
|
|
779
|
+
interrupt, record = await _pause(graph, store, task_id)
|
|
780
|
+
decided = await store.decide(record.approval_id, REJECTED, "alice", "no")
|
|
781
|
+
await _run(graph, Command(resume={interrupt.id: decision_value(decided, "reject")}))
|
|
782
|
+
result = await _tool_result(graph)
|
|
783
|
+
assert "The user rejected it; orders was told and did nothing" in result
|
|
784
|
+
assert [c for c in peer.calls if c.startswith("SendMessage ")] == ["SendMessage reject"]
|
|
785
|
+
|
|
786
|
+
|
|
787
|
+
async def test_a_task_of_another_conversation_is_refused(
|
|
788
|
+
relay_graph: tuple[Any, FakePeer], store: ApprovalStore
|
|
789
|
+
) -> None:
|
|
790
|
+
graph, peer = relay_graph
|
|
791
|
+
task_id = await _ask(graph)
|
|
792
|
+
peer.tasks[task_id]["contextId"] = "someone-elses-context"
|
|
793
|
+
assert await _run(graph, {"messages": [("user", f"approve_agent_action for {task_id}")]}) == []
|
|
794
|
+
assert "belongs to another conversation" in await _tool_result(graph)
|
|
795
|
+
assert not any(c.startswith("SendMessage ") for c in peer.calls)
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
async def test_a_gate_the_person_decides_at_the_peer_is_reported_not_relayed(
|
|
799
|
+
relay_graph: tuple[Any, FakePeer], store: ApprovalStore
|
|
800
|
+
) -> None:
|
|
801
|
+
graph, peer = relay_graph
|
|
802
|
+
peer.approvals = [_approval(decide_with="direct")]
|
|
803
|
+
assert await _run(graph, {"messages": [("user", "Ask orders about cancel ORD-1002")]}) == []
|
|
804
|
+
asked = await _tool_result(graph)
|
|
805
|
+
assert '"status": "needs_direct_approval"' in asked
|
|
806
|
+
task_id = next(iter(peer.tasks))
|
|
807
|
+
assert await _run(graph, {"messages": [("user", f"approve_agent_action for {task_id}")]}) == []
|
|
808
|
+
result = await _tool_result(graph)
|
|
809
|
+
assert '"status": "needs_direct_approval"' in result
|
|
810
|
+
assert "graph-agents-cli approvals approve de2e --url http://orders-agent:8080" in result
|
|
811
|
+
assert '"path": "/orders/ORD-1002/cancel"' in result # the effect
|
|
812
|
+
assert not any(c.startswith("SendMessage ") for c in peer.calls) # no gated call
|
|
813
|
+
|
|
814
|
+
|
|
815
|
+
async def test_a_peer_waiting_for_nothing_is_said_so(
|
|
816
|
+
relay_graph: tuple[Any, FakePeer], store: ApprovalStore
|
|
817
|
+
) -> None:
|
|
818
|
+
graph, peer = relay_graph
|
|
819
|
+
await _client(peer).send("hello")
|
|
820
|
+
done = next(iter(peer.tasks))
|
|
821
|
+
assert await _run(graph, {"messages": [("user", f"approve_agent_action for {done}")]}) == []
|
|
822
|
+
assert "orders is not waiting for an approval (its task is completed)" in (
|
|
823
|
+
await _tool_result(graph)
|
|
824
|
+
)
|