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,767 @@
|
|
|
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
|
+
"""Calls to JSON-RPC APIs (`protocol: jsonrpc|a2a`): what the policy client reads from the body.
|
|
16
|
+
|
|
17
|
+
The client derives what a request is from the JSON it sends, never from the
|
|
18
|
+
tool's labels: its JSON-RPC method and, for a message to another agent, whether
|
|
19
|
+
it approves or rejects a pending approval there. Every refusal happens before
|
|
20
|
+
anything is sent. Requests go to an httpx MockTransport; nothing leaves the
|
|
21
|
+
process. (The rules themselves are tested through both copies of the shared
|
|
22
|
+
block in graph-agents-cli's own suite.)
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import json
|
|
28
|
+
import logging
|
|
29
|
+
import os
|
|
30
|
+
import uuid
|
|
31
|
+
from collections.abc import AsyncIterator, Iterator
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Any
|
|
34
|
+
from urllib.parse import urlsplit
|
|
35
|
+
|
|
36
|
+
os.environ.setdefault("MODEL_PROVIDER", "fake")
|
|
37
|
+
|
|
38
|
+
import httpx
|
|
39
|
+
import pytest
|
|
40
|
+
from langchain.agents import create_agent
|
|
41
|
+
from langchain.tools import ToolRuntime
|
|
42
|
+
from langchain_core.tools import tool
|
|
43
|
+
from langgraph.checkpoint.memory import InMemorySaver
|
|
44
|
+
from langgraph.types import Command
|
|
45
|
+
|
|
46
|
+
from {{cookiecutter.agent_directory}}.app_utils.api_client import (
|
|
47
|
+
A2A_ORIGIN_EXTENSION,
|
|
48
|
+
APPROVAL_INTERRUPT,
|
|
49
|
+
ApiCallError,
|
|
50
|
+
ApiClient,
|
|
51
|
+
ApiPolicy,
|
|
52
|
+
ApiPolicyError,
|
|
53
|
+
BoundApproval,
|
|
54
|
+
call_identity,
|
|
55
|
+
get_client,
|
|
56
|
+
propagates,
|
|
57
|
+
reset_limits,
|
|
58
|
+
reset_policy_cache,
|
|
59
|
+
set_approval_ledger,
|
|
60
|
+
set_outbound_headers,
|
|
61
|
+
)
|
|
62
|
+
from {{cookiecutter.agent_directory}}.app_utils.approvals import (
|
|
63
|
+
APPROVED,
|
|
64
|
+
REJECTED,
|
|
65
|
+
ApprovalRecord,
|
|
66
|
+
ApprovalStore,
|
|
67
|
+
approval_digest,
|
|
68
|
+
approval_view,
|
|
69
|
+
decision_value,
|
|
70
|
+
record_from_interrupt,
|
|
71
|
+
)
|
|
72
|
+
from {{cookiecutter.agent_directory}}.app_utils.auth import Principal
|
|
73
|
+
from {{cookiecutter.agent_directory}}.app_utils.db import Database
|
|
74
|
+
|
|
75
|
+
POLICY = """
|
|
76
|
+
apis:
|
|
77
|
+
orders_agent:
|
|
78
|
+
description: "Orders agent: reads and cancels the caller's orders."
|
|
79
|
+
protocol: a2a
|
|
80
|
+
a2a: {path: /a2a/orders}
|
|
81
|
+
base_url_env: ORDERS_AGENT_URL
|
|
82
|
+
auth: bearer
|
|
83
|
+
token_env: ORDERS_AGENT_KEY
|
|
84
|
+
allowed_methods: [GET, POST]
|
|
85
|
+
allowed_operations:
|
|
86
|
+
- {operationId: getAgentCard, methods: [GET], path: /a2a/orders/.well-known/agent-card.json}
|
|
87
|
+
- {rpc_method: SendMessage, methods: [POST], path: /a2a/orders}
|
|
88
|
+
- {rpc_method: GetTask, methods: [POST], path: /a2a/orders}
|
|
89
|
+
- {operationId: getTask, rpc_method: GetTask}
|
|
90
|
+
approval:
|
|
91
|
+
required_for: {operations: [{a2a_operation: approve}]}
|
|
92
|
+
approvers: [requester]
|
|
93
|
+
billing_agent:
|
|
94
|
+
protocol: a2a
|
|
95
|
+
a2a: {path: /a2a/billing}
|
|
96
|
+
base_url_env: BILLING_AGENT_URL
|
|
97
|
+
auth: bearer
|
|
98
|
+
token_env: BILLING_AGENT_KEY
|
|
99
|
+
allowed_methods: [POST]
|
|
100
|
+
denied_operations:
|
|
101
|
+
- {a2a_operation: approve}
|
|
102
|
+
ledger:
|
|
103
|
+
protocol: jsonrpc
|
|
104
|
+
base_url_env: LEDGER_URL
|
|
105
|
+
auth: none
|
|
106
|
+
allowed_methods: [POST]
|
|
107
|
+
allowed_operations:
|
|
108
|
+
- {rpc_method: balance, path: /rpc}
|
|
109
|
+
plain:
|
|
110
|
+
base_url_env: PLAIN_URL
|
|
111
|
+
auth: none
|
|
112
|
+
allowed_methods: [POST]
|
|
113
|
+
"""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
@pytest.fixture
|
|
117
|
+
def policy(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Iterator[Path]:
|
|
118
|
+
# Agents are reached at cluster-internal names (plain http is refused elsewhere outside dev).
|
|
119
|
+
monkeypatch.delenv("APP_ENV", raising=False)
|
|
120
|
+
path = tmp_path / "api-policy.yaml"
|
|
121
|
+
path.write_text(POLICY, encoding="utf-8")
|
|
122
|
+
monkeypatch.setenv("API_POLICY_PATH", str(path))
|
|
123
|
+
monkeypatch.setenv("ORDERS_AGENT_URL", "http://orders-agent:8080")
|
|
124
|
+
monkeypatch.setenv("ORDERS_AGENT_KEY", "orders-key")
|
|
125
|
+
monkeypatch.setenv("BILLING_AGENT_URL", "http://billing-agent.billing.svc")
|
|
126
|
+
monkeypatch.setenv("BILLING_AGENT_KEY", "billing-key")
|
|
127
|
+
monkeypatch.setenv("LEDGER_URL", "http://ledger.test")
|
|
128
|
+
monkeypatch.setenv("PLAIN_URL", "http://plain.test")
|
|
129
|
+
reset_policy_cache()
|
|
130
|
+
reset_limits()
|
|
131
|
+
yield path
|
|
132
|
+
reset_policy_cache()
|
|
133
|
+
reset_limits()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
SENT: list[httpx.Request] = []
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _transport() -> httpx.MockTransport:
|
|
140
|
+
def handler(request: httpx.Request) -> httpx.Response:
|
|
141
|
+
SENT.append(request)
|
|
142
|
+
return httpx.Response(200, json={"jsonrpc": "2.0", "id": "1", "result": {}})
|
|
143
|
+
|
|
144
|
+
SENT.clear()
|
|
145
|
+
return httpx.MockTransport(handler)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _client(api: str) -> ApiClient:
|
|
149
|
+
return get_client(api, transport=_transport())
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _request(method: str, params: Any = None, **extra: Any) -> dict[str, Any]:
|
|
153
|
+
body: dict[str, Any] = {"jsonrpc": "2.0", "id": "1", "method": method}
|
|
154
|
+
if params is not None:
|
|
155
|
+
body["params"] = params
|
|
156
|
+
body.update(extra)
|
|
157
|
+
return body
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _message(*parts: Any) -> dict[str, Any]:
|
|
161
|
+
return {"message": {"role": "ROLE_USER", "messageId": "m1", "parts": list(parts)}}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _decision(decision: str) -> dict[str, Any]:
|
|
165
|
+
return {"data": {"approval_id": "a1", "decision": decision}}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
ASK = _request("SendMessage", _message({"text": "Which orders are open?"}))
|
|
169
|
+
APPROVE = _request("SendMessage", _message(_decision("approve")))
|
|
170
|
+
REJECT = _request("SendMessage", _message(_decision("reject")))
|
|
171
|
+
GET_TASK = _request("GetTask", {"id": "t1"})
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def test_the_calls_the_policy_allows_are_sent(policy: Path) -> None:
|
|
175
|
+
client = _client("orders_agent")
|
|
176
|
+
await client.post("/a2a/orders", json_body=ASK)
|
|
177
|
+
await client.post("/a2a/orders", json_body=GET_TASK)
|
|
178
|
+
await client.post("/a2a/orders", json_body=_request("tasks/get", {"id": "t1"}))
|
|
179
|
+
await client.post("/a2a/orders", operation_id="getTask", json_body=GET_TASK)
|
|
180
|
+
await client.get("/a2a/orders/.well-known/agent-card.json", operation_id="getAgentCard")
|
|
181
|
+
# A rejection is not gated: the agent that asked is told at once.
|
|
182
|
+
await client.post("/a2a/orders", json_body=REJECT)
|
|
183
|
+
assert [(r.method, r.url.path) for r in SENT] == [("POST", "/a2a/orders")] * 4 + [
|
|
184
|
+
("GET", "/a2a/orders/.well-known/agent-card.json"),
|
|
185
|
+
("POST", "/a2a/orders"),
|
|
186
|
+
]
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@pytest.mark.parametrize(
|
|
190
|
+
("body", "message"),
|
|
191
|
+
[
|
|
192
|
+
(
|
|
193
|
+
_request("CancelTask", {"id": "t1"}),
|
|
194
|
+
"orders_agent: POST /a2a/orders (rpc_method CancelTask) refused by the API policy: "
|
|
195
|
+
"not in allowed_operations.",
|
|
196
|
+
),
|
|
197
|
+
(
|
|
198
|
+
_request("tasks/cancel", {"id": "t1"}),
|
|
199
|
+
"orders_agent: POST /a2a/orders (rpc_method CancelTask) refused by the API policy: "
|
|
200
|
+
"not in allowed_operations.",
|
|
201
|
+
),
|
|
202
|
+
(
|
|
203
|
+
[GET_TASK],
|
|
204
|
+
"orders_agent: protocol a2a sends one JSON-RPC request per call (a batch or "
|
|
205
|
+
"non-request body refused: a batch).",
|
|
206
|
+
),
|
|
207
|
+
(
|
|
208
|
+
{"jsonrpc": "2.0", "method": "GetTask", "params": {"id": "t1"}},
|
|
209
|
+
"orders_agent: protocol a2a sends one JSON-RPC request per call (a batch or "
|
|
210
|
+
"non-request body refused: a notification (no id)).",
|
|
211
|
+
),
|
|
212
|
+
(
|
|
213
|
+
{"decision": "approve", "approval_id": "a1"},
|
|
214
|
+
"orders_agent: protocol a2a sends one JSON-RPC request per call (a batch or "
|
|
215
|
+
"non-request body refused: members other than jsonrpc, method, params and id).",
|
|
216
|
+
),
|
|
217
|
+
],
|
|
218
|
+
)
|
|
219
|
+
async def test_what_the_policy_refuses_is_never_sent(
|
|
220
|
+
policy: Path, body: Any, message: str, caplog: pytest.LogCaptureFixture
|
|
221
|
+
) -> None:
|
|
222
|
+
client = _client("orders_agent")
|
|
223
|
+
with caplog.at_level(logging.WARNING), pytest.raises(ApiPolicyError) as refused:
|
|
224
|
+
await client.post("/a2a/orders", json_body=body)
|
|
225
|
+
assert str(refused.value) == message
|
|
226
|
+
assert SENT == []
|
|
227
|
+
# The log names the rule, never the body.
|
|
228
|
+
assert "api call refused" in caplog.text and "t1" not in caplog.text
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
async def test_a_read_carries_no_body(policy: Path) -> None:
|
|
232
|
+
client = _client("orders_agent")
|
|
233
|
+
with pytest.raises(ApiPolicyError, match="a GET sends no body"):
|
|
234
|
+
await client.get("/a2a/orders/.well-known/agent-card.json", json_body={"x": 1})
|
|
235
|
+
assert SENT == []
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
async def test_a_label_never_carries_a_request_past_its_rule(policy: Path) -> None:
|
|
239
|
+
"""T11: `getTask` names an entry pinning GetTask, so a message labelled so is refused."""
|
|
240
|
+
client = _client("orders_agent")
|
|
241
|
+
with pytest.raises(ApiPolicyError) as refused:
|
|
242
|
+
await client.post("/a2a/orders", operation_id="getTask", json_body=APPROVE)
|
|
243
|
+
assert str(refused.value) == (
|
|
244
|
+
"orders_agent: operation_id 'getTask' does not match the request (rpc_method "
|
|
245
|
+
"SendMessage); refused."
|
|
246
|
+
)
|
|
247
|
+
assert SENT == []
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@pytest.mark.parametrize(
|
|
251
|
+
"body",
|
|
252
|
+
[
|
|
253
|
+
APPROVE,
|
|
254
|
+
_request("message/send", _message(_decision("approve"))),
|
|
255
|
+
# Approve wins over a rejection in the same message.
|
|
256
|
+
_request("SendMessage", _message(_decision("reject"), _decision("approve"))),
|
|
257
|
+
],
|
|
258
|
+
)
|
|
259
|
+
async def test_a_message_that_approves_waits_for_a_human(policy: Path, body: Any) -> None:
|
|
260
|
+
"""Gated for the requester, whatever its label or spelling: outside an agent run,
|
|
261
|
+
nothing can pause and ask, so it is refused and nothing is sent."""
|
|
262
|
+
client = _client("orders_agent")
|
|
263
|
+
with pytest.raises(ApiPolicyError, match=r"needs human approval \(requester\)"):
|
|
264
|
+
await client.post("/a2a/orders", operation_id="justAsking", json_body=body)
|
|
265
|
+
assert SENT == []
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
async def test_a_denied_approve_is_refused_and_the_rest_goes_through(policy: Path) -> None:
|
|
269
|
+
client = _client("billing_agent")
|
|
270
|
+
with pytest.raises(ApiPolicyError) as refused:
|
|
271
|
+
await client.post("/a2a/billing", json_body=APPROVE)
|
|
272
|
+
assert str(refused.value) == (
|
|
273
|
+
"billing_agent: POST /a2a/billing (rpc_method SendMessage, a2a_operation approve) refused "
|
|
274
|
+
"by the API policy: denied by denied_operations (a2a_operation=approve)."
|
|
275
|
+
)
|
|
276
|
+
assert SENT == []
|
|
277
|
+
await client.post("/a2a/billing", json_body=ASK)
|
|
278
|
+
assert len(SENT) == 1
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
async def test_an_approve_no_rule_holds_is_refused_even_by_an_unchecked_policy(
|
|
282
|
+
policy: Path,
|
|
283
|
+
) -> None:
|
|
284
|
+
"""The validator refuses such a policy; the client holds even one it never validated."""
|
|
285
|
+
settings = {
|
|
286
|
+
"protocol": "a2a",
|
|
287
|
+
"a2a": {"path": "/a2a/orders"},
|
|
288
|
+
"base_url_env": "ORDERS_AGENT_URL",
|
|
289
|
+
"auth": "bearer",
|
|
290
|
+
"token_env": "ORDERS_AGENT_KEY",
|
|
291
|
+
"allowed_methods": ["POST"],
|
|
292
|
+
}
|
|
293
|
+
client = ApiClient(ApiPolicy(apis={"orders_agent": settings}), "orders_agent")
|
|
294
|
+
client._transport = _transport()
|
|
295
|
+
with pytest.raises(ApiPolicyError) as refused:
|
|
296
|
+
await client.post("/a2a/orders", json_body=APPROVE)
|
|
297
|
+
assert str(refused.value) == (
|
|
298
|
+
"orders_agent: POST /a2a/orders (rpc_method SendMessage, a2a_operation approve) refused "
|
|
299
|
+
"by the API policy: a message that approves must wait for an approval or be denied "
|
|
300
|
+
"(gate or deny a2a_operation: approve)."
|
|
301
|
+
)
|
|
302
|
+
assert SENT == []
|
|
303
|
+
await client.post("/a2a/orders", json_body=ASK)
|
|
304
|
+
assert len(SENT) == 1
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
async def test_a_json_rpc_api_is_judged_by_its_method(policy: Path) -> None:
|
|
308
|
+
client = _client("ledger")
|
|
309
|
+
await client.post("/rpc", json_body=_request("balance", ["acct-1"]))
|
|
310
|
+
with pytest.raises(ApiPolicyError, match=r"\(rpc_method transfer\) refused"):
|
|
311
|
+
await client.post("/rpc", json_body=_request("transfer", ["acct-1", 5]))
|
|
312
|
+
# No A2A reading on a plain JSON-RPC API: a 0.3 name is just a name.
|
|
313
|
+
with pytest.raises(ApiPolicyError, match=r"\(rpc_method message/send\) refused"):
|
|
314
|
+
await client.post("/rpc", json_body=_request("message/send", _message(_decision("x"))))
|
|
315
|
+
assert len(SENT) == 1
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
async def test_an_http_api_sends_what_it_did_before(policy: Path) -> None:
|
|
319
|
+
"""No JSON-RPC reading on an `http` API: any body goes, as in 0.2."""
|
|
320
|
+
client = _client("plain")
|
|
321
|
+
await client.post("/anything", json_body=[APPROVE, {"not": "json-rpc"}])
|
|
322
|
+
await client.post("/anything", operation_id="getTask", json_body=APPROVE)
|
|
323
|
+
assert len(SENT) == 2
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
# --- which call a decision is bound to (call_identity) ---------------------------------------
|
|
327
|
+
#
|
|
328
|
+
# Every call to an A2A peer is a POST to one endpoint, so API, method and path cannot tell a
|
|
329
|
+
# relay's calls apart: the read it sends first on resume would take the decision its approve
|
|
330
|
+
# message waits for, and a rejection would stop the message that tells the peer. The request
|
|
331
|
+
# each body is (its JSON-RPC method and A2A decision) is part of which call it is.
|
|
332
|
+
|
|
333
|
+
ALICE = Principal(id="alice", roles=["user"], attributes={"tenant": "t1"})
|
|
334
|
+
# The relay tool reads the peer's task before it decides (as the A2A client will).
|
|
335
|
+
READ_FIRST: list[bool] = []
|
|
336
|
+
PEER_SENT: list[dict[str, Any]] = []
|
|
337
|
+
# The peer's approval the decision is about, as the A2A client copies it into the message.
|
|
338
|
+
APPROVING: list[dict[str, Any]] = []
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
def _decide(decision: str) -> dict[str, Any]:
|
|
342
|
+
"""The decision message, built the same on every run (the approval binds its body)."""
|
|
343
|
+
message: dict[str, Any] = {
|
|
344
|
+
"messageId": f"m-{decision}-a1",
|
|
345
|
+
"role": "ROLE_USER",
|
|
346
|
+
"contextId": "ctx-1",
|
|
347
|
+
"parts": [{"data": {"approval_id": "a1", "decision": decision}}],
|
|
348
|
+
}
|
|
349
|
+
if APPROVING:
|
|
350
|
+
message["metadata"] = {A2A_ORIGIN_EXTENSION: {"approving": APPROVING[0]}}
|
|
351
|
+
return {
|
|
352
|
+
"jsonrpc": "2.0",
|
|
353
|
+
"id": f"{decision}-a1",
|
|
354
|
+
"method": "SendMessage",
|
|
355
|
+
"params": {"message": message},
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def _peer_upstream(request: httpx.Request) -> httpx.Response:
|
|
360
|
+
PEER_SENT.append(json.loads(request.content))
|
|
361
|
+
return httpx.Response(200, json={"jsonrpc": "2.0", "id": "1", "result": {}})
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
@tool
|
|
365
|
+
async def relay_approval(task_id: str, runtime: ToolRuntime[Any]) -> str:
|
|
366
|
+
"""Relay the person's approval to the orders agent, or tell it they rejected it."""
|
|
367
|
+
client = get_client("orders_agent", transport=httpx.MockTransport(_peer_upstream))
|
|
368
|
+
if READ_FIRST:
|
|
369
|
+
await client.post("/a2a/orders", json_body=_request("GetTask", {"id": task_id}))
|
|
370
|
+
try:
|
|
371
|
+
await client.post("/a2a/orders", json_body=_decide("approve"))
|
|
372
|
+
except ApiPolicyError as exc:
|
|
373
|
+
if "rejected" not in str(exc):
|
|
374
|
+
raise
|
|
375
|
+
await client.post("/a2a/orders", json_body=_decide("reject"))
|
|
376
|
+
return "The person did not approve it; the orders agent was told."
|
|
377
|
+
return "Approved and sent to the orders agent."
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
ADMIN_DSN = os.environ.get("TEST_POSTGRES_DSN", "")
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
@pytest.fixture(params=["memory", "file", "postgres"])
|
|
384
|
+
async def store(request: pytest.FixtureRequest, tmp_path: Path) -> AsyncIterator[ApprovalStore]:
|
|
385
|
+
"""The ledger in memory, kept in a file (`langgraph dev`), and on Postgres when
|
|
386
|
+
`TEST_POSTGRES_DSN` is set (a fresh database)."""
|
|
387
|
+
if request.param == "memory":
|
|
388
|
+
yield ApprovalStore(Database("memory"))
|
|
389
|
+
return
|
|
390
|
+
if request.param == "file":
|
|
391
|
+
kept = ApprovalStore(Database("memory"), path=tmp_path / ".langgraph_api" / "a.json")
|
|
392
|
+
assert await kept.load() == 0
|
|
393
|
+
yield kept
|
|
394
|
+
return
|
|
395
|
+
if not ADMIN_DSN:
|
|
396
|
+
pytest.skip("TEST_POSTGRES_DSN is not set")
|
|
397
|
+
import psycopg
|
|
398
|
+
|
|
399
|
+
name = f"gac_test_{uuid.uuid4().hex[:12]}"
|
|
400
|
+
async with await psycopg.AsyncConnection.connect(ADMIN_DSN, autocommit=True) as admin:
|
|
401
|
+
await admin.execute(f'CREATE DATABASE "{name}"')
|
|
402
|
+
db = Database("postgres", urlsplit(ADMIN_DSN)._replace(path=f"/{name}").geturl())
|
|
403
|
+
await db.open()
|
|
404
|
+
try:
|
|
405
|
+
yield ApprovalStore(db)
|
|
406
|
+
finally:
|
|
407
|
+
await db.close()
|
|
408
|
+
async with await psycopg.AsyncConnection.connect(ADMIN_DSN, autocommit=True) as admin:
|
|
409
|
+
await admin.execute(f'DROP DATABASE IF EXISTS "{name}" WITH (FORCE)')
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@pytest.fixture
|
|
413
|
+
def relay(policy: Path, store: ApprovalStore) -> Iterator[Any]:
|
|
414
|
+
"""An agent with the relay tool, the fake model and the ledger `store`."""
|
|
415
|
+
from {{cookiecutter.agent_directory}} import agent
|
|
416
|
+
from {{cookiecutter.agent_directory}}.app_utils.model import get_model
|
|
417
|
+
|
|
418
|
+
READ_FIRST.clear()
|
|
419
|
+
PEER_SENT.clear()
|
|
420
|
+
APPROVING.clear()
|
|
421
|
+
set_approval_ledger(store)
|
|
422
|
+
yield create_agent(
|
|
423
|
+
model=get_model(),
|
|
424
|
+
tools=[relay_approval],
|
|
425
|
+
system_prompt=agent.SYSTEM_PROMPT,
|
|
426
|
+
middleware=agent.middleware(),
|
|
427
|
+
context_schema=agent.AgentContext,
|
|
428
|
+
checkpointer=InMemorySaver(),
|
|
429
|
+
)
|
|
430
|
+
set_approval_ledger(None)
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
async def _run(graph: Any, thread: str, graph_input: Any) -> list[Any]:
|
|
434
|
+
config = {"configurable": {"thread_id": thread}}
|
|
435
|
+
async for _ in graph.astream(graph_input, config, stream_mode="updates"):
|
|
436
|
+
pass
|
|
437
|
+
return list((await graph.aget_state(config)).interrupts)
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
async def _pause(graph: Any, store: ApprovalStore) -> tuple[Any, ApprovalRecord]:
|
|
441
|
+
interrupts = await _run(graph, "t1", {"messages": [("user", "Relay the approval for t1")]})
|
|
442
|
+
assert len(interrupts) == 1
|
|
443
|
+
record, _, _ = await store.add(
|
|
444
|
+
record_from_interrupt(
|
|
445
|
+
interrupts[0].value,
|
|
446
|
+
interrupt_id=interrupts[0].id,
|
|
447
|
+
thread_id="t1",
|
|
448
|
+
run_id="r",
|
|
449
|
+
requester=ALICE,
|
|
450
|
+
)
|
|
451
|
+
)
|
|
452
|
+
return interrupts[0], record
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
async def _tool_result(graph: Any) -> str:
|
|
456
|
+
state = await graph.aget_state({"configurable": {"thread_id": "t1"}})
|
|
457
|
+
return next(m.content for m in reversed(state.values["messages"]) if m.type == "tool")
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _sent() -> list[str]:
|
|
461
|
+
"""What reached the peer: each request's method, and its decision if any."""
|
|
462
|
+
return [
|
|
463
|
+
" ".join(
|
|
464
|
+
[body["method"]]
|
|
465
|
+
+ [
|
|
466
|
+
p["data"]["decision"]
|
|
467
|
+
for p in (body.get("params") or {}).get("message", {}).get("parts", [])
|
|
468
|
+
if "data" in p
|
|
469
|
+
]
|
|
470
|
+
)
|
|
471
|
+
for body in PEER_SENT
|
|
472
|
+
]
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
async def test_the_approval_names_the_request_it_holds(relay: Any, store: ApprovalStore) -> None:
|
|
476
|
+
interrupt, record = await _pause(relay, store)
|
|
477
|
+
assert interrupt.value["type"] == APPROVAL_INTERRUPT
|
|
478
|
+
assert (interrupt.value["rpc_method"], interrupt.value["a2a_operation"]) == (
|
|
479
|
+
"SendMessage",
|
|
480
|
+
"approve",
|
|
481
|
+
)
|
|
482
|
+
public = record.public()
|
|
483
|
+
assert (public["rpc_method"], public["a2a_operation"]) == ("SendMessage", "approve")
|
|
484
|
+
view = approval_view(record)
|
|
485
|
+
assert (view["rpc_method"], view["a2a_operation"]) == ("SendMessage", "approve")
|
|
486
|
+
assert record.display_digest == approval_digest(view)
|
|
487
|
+
value = decision_value(record, "approve")
|
|
488
|
+
assert (value["rpc_method"], value["a2a_operation"]) == ("SendMessage", "approve")
|
|
489
|
+
assert _sent() == []
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
def _orders_approval(**overrides: Any) -> dict[str, Any]:
|
|
493
|
+
"""The orders agent's pending approval as its A2A task reports it (exact values)."""
|
|
494
|
+
approving = {
|
|
495
|
+
"agent": "orders",
|
|
496
|
+
"approval_id": "de2e",
|
|
497
|
+
"call": {
|
|
498
|
+
"api": "orders_api",
|
|
499
|
+
"method": "POST",
|
|
500
|
+
"path": "/orders/ORD-1002/cancel",
|
|
501
|
+
"operation_id": "cancelOrder",
|
|
502
|
+
"query": {},
|
|
503
|
+
"body": {"reason": "customer asked", "amount": 1, "big": 12345678901234567890},
|
|
504
|
+
},
|
|
505
|
+
"reason": "cancel_order: the customer asked",
|
|
506
|
+
"expires_at": "2099-01-01T00:00:00+00:00",
|
|
507
|
+
"digest": "sha256:" + "a" * 64,
|
|
508
|
+
"reported_by": "orders",
|
|
509
|
+
"decide_with": "relayed",
|
|
510
|
+
"nested": None,
|
|
511
|
+
"unknown": "dropped",
|
|
512
|
+
}
|
|
513
|
+
approving.update(overrides)
|
|
514
|
+
return approving
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
async def test_a_relayed_approval_shows_what_it_decides_and_what_will_happen(
|
|
518
|
+
relay: Any, store: ApprovalStore
|
|
519
|
+
) -> None:
|
|
520
|
+
"""The approve message names the peer's approval (`approving`, copied from the peer):
|
|
521
|
+
the person's approval shows it as `nested`, exact values kept, and the call that then
|
|
522
|
+
happens as `effect` (orders' cancel, via orders)."""
|
|
523
|
+
APPROVING.append(_orders_approval())
|
|
524
|
+
interrupt, record = await _pause(relay, store)
|
|
525
|
+
nested = interrupt.value["nested"]
|
|
526
|
+
assert nested["call"]["body"] == {
|
|
527
|
+
"reason": "customer asked",
|
|
528
|
+
"amount": 1,
|
|
529
|
+
"big": 12345678901234567890,
|
|
530
|
+
}
|
|
531
|
+
assert "unknown" not in nested and nested["nested"] is None
|
|
532
|
+
effect = interrupt.value["effect"]
|
|
533
|
+
assert (effect["agent"], effect["via"], effect["method"], effect["path"]) == (
|
|
534
|
+
"orders",
|
|
535
|
+
["orders"],
|
|
536
|
+
"POST",
|
|
537
|
+
"/orders/ORD-1002/cancel",
|
|
538
|
+
)
|
|
539
|
+
public = record.public()
|
|
540
|
+
assert public["nested"] == nested and public["effect"] == effect
|
|
541
|
+
# Bound like the rest of the body: the digest the person approves covers it.
|
|
542
|
+
assert record.display_digest == approval_digest(approval_view(record))
|
|
543
|
+
assert _sent() == []
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
async def test_a_two_hop_relay_shows_the_innermost_call_and_the_agents_between(
|
|
547
|
+
relay: Any, store: ApprovalStore
|
|
548
|
+
) -> None:
|
|
549
|
+
inner = _orders_approval()
|
|
550
|
+
APPROVING.append(
|
|
551
|
+
_orders_approval(
|
|
552
|
+
agent="billing",
|
|
553
|
+
approval_id="b1",
|
|
554
|
+
call={"api": "orders_agent", "method": "POST", "path": "/a2a/orders"},
|
|
555
|
+
reported_by="billing",
|
|
556
|
+
nested=inner,
|
|
557
|
+
)
|
|
558
|
+
)
|
|
559
|
+
interrupt, _record = await _pause(relay, store)
|
|
560
|
+
effect = interrupt.value["effect"]
|
|
561
|
+
assert (effect["agent"], effect["via"], effect["path"]) == (
|
|
562
|
+
"orders",
|
|
563
|
+
["billing", "orders"],
|
|
564
|
+
"/orders/ORD-1002/cancel",
|
|
565
|
+
)
|
|
566
|
+
assert interrupt.value["nested"]["nested"]["approval_id"] == "de2e"
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
async def test_a_rejection_naming_the_approval_is_sent_without_waiting(policy: Path) -> None:
|
|
570
|
+
"""Only an approve message waits for the person: a rejection that carries the approval
|
|
571
|
+
it is about goes out at once (rejecting is always safe)."""
|
|
572
|
+
APPROVING[:] = [_orders_approval()]
|
|
573
|
+
try:
|
|
574
|
+
await _client("orders_agent").post("/a2a/orders", json_body=_decide("reject"))
|
|
575
|
+
finally:
|
|
576
|
+
APPROVING.clear()
|
|
577
|
+
[sent] = SENT
|
|
578
|
+
assert json.loads(sent.content)["params"]["message"]["parts"][0]["data"]["decision"] == "reject"
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
async def test_get_task_on_resume_does_not_consume_the_approve_decision(
|
|
582
|
+
relay: Any, store: ApprovalStore
|
|
583
|
+
) -> None:
|
|
584
|
+
"""The resumed tool reads the task first: that read is its own call, and the approve
|
|
585
|
+
message it then rebuilds takes the decision and is sent once."""
|
|
586
|
+
READ_FIRST.append(True)
|
|
587
|
+
interrupt, record = await _pause(relay, store)
|
|
588
|
+
assert _sent() == ["GetTask"]
|
|
589
|
+
decided = await store.decide(record.approval_id, APPROVED, "alice", None)
|
|
590
|
+
await _run(relay, "t1", Command(resume={interrupt.id: decision_value(decided, "approve")}))
|
|
591
|
+
assert _sent() == ["GetTask", "GetTask", "SendMessage approve"]
|
|
592
|
+
assert "Approved and sent" in await _tool_result(relay)
|
|
593
|
+
assert (await store.get(record.approval_id)).used_at is not None
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
async def test_reject_is_sent_after_a_rejection(relay: Any, store: ApprovalStore) -> None:
|
|
597
|
+
"""The rejection stops the approve message, not the message that tells the peer."""
|
|
598
|
+
interrupt, record = await _pause(relay, store)
|
|
599
|
+
decided = await store.decide(record.approval_id, REJECTED, "alice", "not this one")
|
|
600
|
+
await _run(relay, "t1", Command(resume={interrupt.id: decision_value(decided, "reject")}))
|
|
601
|
+
assert _sent() == ["SendMessage reject"]
|
|
602
|
+
assert "the orders agent was told" in await _tool_result(relay)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
async def test_bound_approvals_filter_by_operation(relay: Any, store: ApprovalStore) -> None:
|
|
606
|
+
"""A tool call run again without a decision (a thread continued without input): the
|
|
607
|
+
ledger's approval holds the approve message only; the read goes out again."""
|
|
608
|
+
READ_FIRST.append(True)
|
|
609
|
+
_, record = await _pause(relay, store)
|
|
610
|
+
[bound] = await store.bound_approvals(tool_call=(record.message_id, record.tool_call_id))
|
|
611
|
+
assert (bound.rpc_method, bound.a2a_operation) == ("SendMessage", "approve")
|
|
612
|
+
await _run(relay, "t1", None)
|
|
613
|
+
assert _sent() == ["GetTask", "GetTask"]
|
|
614
|
+
assert "was resumed without an approval decision" in await _tool_result(relay)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
async def test_a_kept_approval_still_names_its_request(tmp_path: Path) -> None:
|
|
618
|
+
"""The ledger kept in a file (`langgraph dev`) reads the request back after a restart."""
|
|
619
|
+
path = tmp_path / ".langgraph_api" / "a.json"
|
|
620
|
+
value = {
|
|
621
|
+
"type": APPROVAL_INTERRUPT,
|
|
622
|
+
"api": "orders_agent",
|
|
623
|
+
"method": "POST",
|
|
624
|
+
"path": "/a2a/orders",
|
|
625
|
+
"body": _decide("approve"),
|
|
626
|
+
"tool_call_id": "c1",
|
|
627
|
+
"message_id": "m1",
|
|
628
|
+
"approvers": ["requester"],
|
|
629
|
+
"call_hash": "h",
|
|
630
|
+
"rpc_method": "SendMessage",
|
|
631
|
+
"a2a_operation": "approve",
|
|
632
|
+
}
|
|
633
|
+
first = ApprovalStore(Database("memory"), path=path)
|
|
634
|
+
await first.load()
|
|
635
|
+
record, _, _ = await first.add(
|
|
636
|
+
record_from_interrupt(value, interrupt_id="i1", thread_id="t1", run_id="r", requester=ALICE)
|
|
637
|
+
)
|
|
638
|
+
again = ApprovalStore(Database("memory"), path=path)
|
|
639
|
+
assert await again.load() == 1
|
|
640
|
+
[bound] = await again.bound_approvals(tool_call=("m1", "c1"))
|
|
641
|
+
assert (bound.rpc_method, bound.a2a_operation) == ("SendMessage", "approve")
|
|
642
|
+
assert (await again.get(record.approval_id)).display_digest == record.display_digest
|
|
643
|
+
|
|
644
|
+
|
|
645
|
+
def test_identity_unchanged_for_http_apis() -> None:
|
|
646
|
+
"""Three fields for a call to an `http` API, as in 0.2: nothing new in its record."""
|
|
647
|
+
assert call_identity("shop", "post", "/Orders/7/") == ("shop", "POST", "/orders/7")
|
|
648
|
+
assert call_identity("shop", "POST", "/orders/7", None, None) == ("shop", "POST", "/orders/7")
|
|
649
|
+
assert call_identity(*BoundApproval("shop", "POST", "/orders/7", "pending", False)[:3]) == (
|
|
650
|
+
"shop",
|
|
651
|
+
"POST",
|
|
652
|
+
"/orders/7",
|
|
653
|
+
)
|
|
654
|
+
read = call_identity("peer", "POST", "/a2a/x", "GetTask")
|
|
655
|
+
approve = call_identity("peer", "POST", "/a2a/x", "SendMessage", "approve")
|
|
656
|
+
reject = call_identity("peer", "POST", "/a2a/x", "SendMessage", "reject")
|
|
657
|
+
assert len({read, approve, reject, call_identity("peer", "POST", "/a2a/x")}) == 4
|
|
658
|
+
value = {
|
|
659
|
+
"type": APPROVAL_INTERRUPT,
|
|
660
|
+
"api": "shop",
|
|
661
|
+
"method": "POST",
|
|
662
|
+
"path": "/orders/7/cancel",
|
|
663
|
+
"body": {"reason": "asked"},
|
|
664
|
+
"approvers": ["requester"],
|
|
665
|
+
"call_hash": "h",
|
|
666
|
+
}
|
|
667
|
+
record = record_from_interrupt(
|
|
668
|
+
value, interrupt_id="i1", thread_id="t1", run_id="r", requester=ALICE
|
|
669
|
+
)
|
|
670
|
+
for shown in (record.payload, record.public(), decision_value(record, "approve")):
|
|
671
|
+
assert "rpc_method" not in shown and "a2a_operation" not in shown
|
|
672
|
+
view = approval_view(record)
|
|
673
|
+
assert (view["rpc_method"], view["a2a_operation"]) == (None, None)
|
|
674
|
+
# The digest of an http approval is what 0.3's first builds computed.
|
|
675
|
+
assert record.display_digest == approval_digest(
|
|
676
|
+
{
|
|
677
|
+
"api": "shop",
|
|
678
|
+
"method": "POST",
|
|
679
|
+
"path": "/orders/7/cancel",
|
|
680
|
+
"operation_id": None,
|
|
681
|
+
"rpc_method": None,
|
|
682
|
+
"a2a_operation": None,
|
|
683
|
+
"query": {},
|
|
684
|
+
"body": {"reason": "asked"},
|
|
685
|
+
}
|
|
686
|
+
)
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
# --- another agent is part of the same request (trace headers) and gets credentials over TLS ---
|
|
690
|
+
|
|
691
|
+
TRACEPARENT = "00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01"
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def test_an_a2a_peer_carries_the_correlation_headers_whatever_its_auth() -> None:
|
|
695
|
+
"""The owner's decision: request ids and trace context go to A2A peers and to the APIs
|
|
696
|
+
that act for the user, never to other third parties."""
|
|
697
|
+
assert propagates({"protocol": "a2a", "auth": "bearer"})
|
|
698
|
+
assert propagates({"protocol": "a2a", "auth": "exchange"})
|
|
699
|
+
assert not propagates({"protocol": "jsonrpc", "auth": "bearer"})
|
|
700
|
+
assert not propagates({"protocol": "http", "auth": "none"})
|
|
701
|
+
assert propagates({"auth": "forward"}) and not propagates({"auth": "bearer"})
|
|
702
|
+
|
|
703
|
+
|
|
704
|
+
async def test_the_correlation_headers_reach_the_peer_only(policy: Path) -> None:
|
|
705
|
+
set_outbound_headers(lambda: {"X-Request-ID": "req-1", "traceparent": TRACEPARENT})
|
|
706
|
+
try:
|
|
707
|
+
await _client("orders_agent").post("/a2a/orders", json_body=ASK)
|
|
708
|
+
peer = SENT[-1]
|
|
709
|
+
await _client("ledger").post("/rpc", json_body=_request("balance", ["a"]))
|
|
710
|
+
ledger = SENT[-1]
|
|
711
|
+
await _client("plain").post("/anything", json_body={"x": 1})
|
|
712
|
+
plain = SENT[-1]
|
|
713
|
+
finally:
|
|
714
|
+
set_outbound_headers(None)
|
|
715
|
+
assert (peer.headers["x-request-id"], peer.headers["traceparent"]) == ("req-1", TRACEPARENT)
|
|
716
|
+
for third_party in (ledger, plain):
|
|
717
|
+
assert "x-request-id" not in third_party.headers
|
|
718
|
+
assert "traceparent" not in third_party.headers
|
|
719
|
+
|
|
720
|
+
|
|
721
|
+
@pytest.mark.parametrize(
|
|
722
|
+
"url",
|
|
723
|
+
[
|
|
724
|
+
"https://orders.example.com",
|
|
725
|
+
"http://orders-agent:8080",
|
|
726
|
+
"http://orders-agent.orders.svc",
|
|
727
|
+
"http://orders-agent.orders.svc.cluster.local",
|
|
728
|
+
"http://127.0.0.1:8001",
|
|
729
|
+
"http://localhost:8001",
|
|
730
|
+
"http://[::1]:8001",
|
|
731
|
+
],
|
|
732
|
+
)
|
|
733
|
+
async def test_a_peer_gets_credentials_over_https_or_inside_the_cluster(
|
|
734
|
+
policy: Path, monkeypatch: pytest.MonkeyPatch, url: str
|
|
735
|
+
) -> None:
|
|
736
|
+
monkeypatch.setenv("ORDERS_AGENT_URL", url)
|
|
737
|
+
await _client("orders_agent").post("/a2a/orders", json_body=ASK)
|
|
738
|
+
assert len(SENT) == 1
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
async def test_plain_http_to_a_peer_outside_dev_is_refused(
|
|
742
|
+
policy: Path, monkeypatch: pytest.MonkeyPatch
|
|
743
|
+
) -> None:
|
|
744
|
+
"""The transport rule (3.6), live now that `protocol: a2a` exists: the peer's credential
|
|
745
|
+
never crosses a network in the clear outside APP_ENV=dev."""
|
|
746
|
+
monkeypatch.setenv("ORDERS_AGENT_URL", "http://orders.example.com")
|
|
747
|
+
with pytest.raises(ApiCallError) as refused:
|
|
748
|
+
await _client("orders_agent").post("/a2a/orders", json_body=ASK)
|
|
749
|
+
assert str(refused.value) == (
|
|
750
|
+
"ORDERS_AGENT_URL must use https outside APP_ENV=dev to carry credentials; nothing was "
|
|
751
|
+
"sent to API 'orders_agent'."
|
|
752
|
+
)
|
|
753
|
+
assert SENT == []
|
|
754
|
+
monkeypatch.setenv("APP_ENV", "dev")
|
|
755
|
+
await _client("orders_agent").post("/a2a/orders", json_body=ASK)
|
|
756
|
+
assert len(SENT) == 1
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
async def test_other_apis_keep_their_transport(
|
|
760
|
+
policy: Path, monkeypatch: pytest.MonkeyPatch
|
|
761
|
+
) -> None:
|
|
762
|
+
"""Non-peer APIs are unchanged in 0.3 (a plain JSON-RPC API included)."""
|
|
763
|
+
monkeypatch.setenv("LEDGER_URL", "http://ledger.example.com")
|
|
764
|
+
monkeypatch.setenv("PLAIN_URL", "http://plain.example.com")
|
|
765
|
+
await _client("ledger").post("/rpc", json_body=_request("balance", ["a"]))
|
|
766
|
+
await _client("plain").post("/anything", json_body={"x": 1})
|
|
767
|
+
assert len(SENT) == 1 # each _client() starts a new transport
|