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,1536 @@
|
|
|
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
|
+
"""Approvals of gated API calls: the records, who decides, the ledger, and the client's side.
|
|
16
|
+
|
|
17
|
+
The client's side runs a real graph (the fake model, one gated tool, an
|
|
18
|
+
in-memory checkpointer) that pauses in LangGraph's `interrupt()` and resumes
|
|
19
|
+
with decisions made here, the ledger being an `ApprovalStore`. Every store
|
|
20
|
+
test runs in memory, in memory kept in a file (as under `langgraph dev`), and
|
|
21
|
+
again on Postgres when `TEST_POSTGRES_DSN` is set (see
|
|
22
|
+
`tests/integration/test_postgres.py`).
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import uuid
|
|
31
|
+
from collections.abc import AsyncIterator, Iterator
|
|
32
|
+
from datetime import datetime, timedelta
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
from typing import Any
|
|
35
|
+
from urllib.parse import urlsplit
|
|
36
|
+
|
|
37
|
+
os.environ.setdefault("MODEL_PROVIDER", "fake")
|
|
38
|
+
|
|
39
|
+
import httpx
|
|
40
|
+
import pytest
|
|
41
|
+
from langchain.agents import create_agent
|
|
42
|
+
from langchain.tools import ToolRuntime
|
|
43
|
+
from langchain_core.tools import tool
|
|
44
|
+
from langgraph.checkpoint.memory import InMemorySaver
|
|
45
|
+
from langgraph.types import Command
|
|
46
|
+
|
|
47
|
+
from {{cookiecutter.agent_directory}}.app_utils import api_client
|
|
48
|
+
from {{cookiecutter.agent_directory}}.app_utils.api_client import (
|
|
49
|
+
APPROVAL_DECISION,
|
|
50
|
+
APPROVAL_INTERRUPT,
|
|
51
|
+
ApiPolicyError,
|
|
52
|
+
BoundApproval,
|
|
53
|
+
call_hash,
|
|
54
|
+
canonical_call,
|
|
55
|
+
get_client,
|
|
56
|
+
redact_fields,
|
|
57
|
+
reset_policy_cache,
|
|
58
|
+
set_approval_ledger,
|
|
59
|
+
stated_purpose,
|
|
60
|
+
)
|
|
61
|
+
from {{cookiecutter.agent_directory}}.app_utils.approvals import (
|
|
62
|
+
APPROVED,
|
|
63
|
+
EXPIRED,
|
|
64
|
+
PENDING,
|
|
65
|
+
REJECTED,
|
|
66
|
+
ApprovalRecord,
|
|
67
|
+
ApprovalStore,
|
|
68
|
+
LedgerUnavailable,
|
|
69
|
+
approval_digest,
|
|
70
|
+
approval_view,
|
|
71
|
+
decide_refusal,
|
|
72
|
+
decision_value,
|
|
73
|
+
dev_ledger_path,
|
|
74
|
+
digest_refusal,
|
|
75
|
+
may_decide,
|
|
76
|
+
may_view,
|
|
77
|
+
record_from_interrupt,
|
|
78
|
+
resume_principal,
|
|
79
|
+
sees_call,
|
|
80
|
+
utcnow,
|
|
81
|
+
)
|
|
82
|
+
from {{cookiecutter.agent_directory}}.app_utils.approvals import _as_datetime as _as_datetime
|
|
83
|
+
from {{cookiecutter.agent_directory}}.app_utils.approvals import _row_of as _row_of
|
|
84
|
+
from {{cookiecutter.agent_directory}}.app_utils.auth import Actor, Principal
|
|
85
|
+
from {{cookiecutter.agent_directory}}.app_utils.db import Database
|
|
86
|
+
from {{cookiecutter.agent_directory}}.app_utils.threads import ThreadRecord
|
|
87
|
+
|
|
88
|
+
ALICE = Principal(
|
|
89
|
+
id="alice", roles=["user"], attributes={"tenant": "t1", "credentials": {"x": "s"}}
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
POLICY = """
|
|
93
|
+
apis:
|
|
94
|
+
shop:
|
|
95
|
+
base_url_env: SHOP_API_BASE_URL
|
|
96
|
+
auth: none
|
|
97
|
+
allowed_methods: [GET, POST]
|
|
98
|
+
approval:
|
|
99
|
+
required_for:
|
|
100
|
+
methods: [POST]
|
|
101
|
+
approvers: [requester, "role:ops"]
|
|
102
|
+
timeout_s: 60
|
|
103
|
+
"""
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _interrupt_value(**overrides: Any) -> dict[str, Any]:
|
|
107
|
+
value = {
|
|
108
|
+
"type": APPROVAL_INTERRUPT,
|
|
109
|
+
"api": "shop",
|
|
110
|
+
"method": "POST",
|
|
111
|
+
"path": "/orders/7/cancel",
|
|
112
|
+
"query": {"notify": "yes"},
|
|
113
|
+
"body": {"reason": "asked"},
|
|
114
|
+
"operation_id": "cancelOrder",
|
|
115
|
+
"tool": "cancel_order",
|
|
116
|
+
"tool_call_id": "call-1",
|
|
117
|
+
"reason": "cancel_order: the customer asked",
|
|
118
|
+
"approvers": ["requester", "role:ops"],
|
|
119
|
+
"timeout_s": 60,
|
|
120
|
+
"rule": "approval.required_for.methods ['POST']",
|
|
121
|
+
"call_hash": "h1",
|
|
122
|
+
}
|
|
123
|
+
value.update(overrides)
|
|
124
|
+
return value
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _record(**overrides: Any) -> ApprovalRecord:
|
|
128
|
+
fields = {"interrupt_id": "i1", "thread_id": "t1", "run_id": "r1"}
|
|
129
|
+
value_overrides = {k: v for k, v in overrides.items() if k not in fields}
|
|
130
|
+
fields.update({k: v for k, v in overrides.items() if k in fields})
|
|
131
|
+
return record_from_interrupt(_interrupt_value(**value_overrides), requester=ALICE, **fields)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
ADMIN_DSN = os.environ.get("TEST_POSTGRES_DSN", "")
|
|
135
|
+
# The time zone of the test databases' sessions: not UTC.
|
|
136
|
+
DB_TIME_ZONE = "America/New_York"
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@pytest.fixture(params=["memory", "file", "postgres"])
|
|
140
|
+
async def store(request: pytest.FixtureRequest, tmp_path: Path) -> AsyncIterator[ApprovalStore]:
|
|
141
|
+
"""The store in memory, in memory kept in a file (`langgraph dev`), and on Postgres
|
|
142
|
+
when `TEST_POSTGRES_DSN` is set (a fresh database)."""
|
|
143
|
+
if request.param == "memory":
|
|
144
|
+
yield ApprovalStore(Database("memory"))
|
|
145
|
+
return
|
|
146
|
+
if request.param == "file":
|
|
147
|
+
kept = ApprovalStore(Database("memory"), path=tmp_path / ".langgraph_api" / "a.json")
|
|
148
|
+
assert await kept.load() == 0
|
|
149
|
+
yield kept
|
|
150
|
+
return
|
|
151
|
+
if not ADMIN_DSN:
|
|
152
|
+
pytest.skip("TEST_POSTGRES_DSN is not set")
|
|
153
|
+
import psycopg
|
|
154
|
+
|
|
155
|
+
name = f"gac_test_{uuid.uuid4().hex[:12]}"
|
|
156
|
+
async with await psycopg.AsyncConnection.connect(ADMIN_DSN, autocommit=True) as admin:
|
|
157
|
+
await admin.execute(f'CREATE DATABASE "{name}"')
|
|
158
|
+
# Sessions answer in a zone other than UTC (as a server set up on a laptop does).
|
|
159
|
+
await admin.execute(f"ALTER DATABASE \"{name}\" SET timezone TO '{DB_TIME_ZONE}'")
|
|
160
|
+
db = Database("postgres", urlsplit(ADMIN_DSN)._replace(path=f"/{name}").geturl())
|
|
161
|
+
await db.open()
|
|
162
|
+
try:
|
|
163
|
+
yield ApprovalStore(db)
|
|
164
|
+
finally:
|
|
165
|
+
await db.close()
|
|
166
|
+
async with await psycopg.AsyncConnection.connect(ADMIN_DSN, autocommit=True) as admin:
|
|
167
|
+
await admin.execute(f'DROP DATABASE IF EXISTS "{name}" WITH (FORCE)')
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# --- records -------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def test_a_record_holds_the_call_and_never_shows_its_internals() -> None:
|
|
174
|
+
record = _record()
|
|
175
|
+
assert record.status == PENDING
|
|
176
|
+
assert record.expires_at - record.created_at == timedelta(seconds=60)
|
|
177
|
+
assert record.requester_hash == ALICE.hashed_id()
|
|
178
|
+
# The run context it resumes with: roles and public attributes, never credentials.
|
|
179
|
+
assert record.requester_context == {"roles": ["user"], "attributes": {"tenant": "t1"}}
|
|
180
|
+
public = record.public()
|
|
181
|
+
assert public["body"] == {"reason": "asked"} and public["query"] == {"notify": "yes"}
|
|
182
|
+
assert public["reason"] == "cancel_order: the customer asked"
|
|
183
|
+
for internal in ("call_hash", "interrupt_id", "rule", "tool_call_id", "requester_context"):
|
|
184
|
+
assert internal not in public
|
|
185
|
+
hidden = record.public(include_call=False)
|
|
186
|
+
assert "body" not in hidden and "query" not in hidden
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@pytest.mark.parametrize(("given", "kept"), [(5, 30), (100_000, 86_400), ("x", 900), (True, 900)])
|
|
190
|
+
def test_the_timeout_is_kept_within_the_policy_bounds(given: Any, kept: int) -> None:
|
|
191
|
+
record = _record(timeout_s=given)
|
|
192
|
+
assert record.expires_at - record.created_at == timedelta(seconds=kept)
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def test_a_pending_record_past_its_expiry_reads_as_expired() -> None:
|
|
196
|
+
record = _record()
|
|
197
|
+
assert record.effective_status(record.expires_at - timedelta(seconds=1)) == PENDING
|
|
198
|
+
assert record.effective_status(record.expires_at) == EXPIRED
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
# --- who decides ---------------------------------------------------------------------
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
@pytest.mark.parametrize(
|
|
205
|
+
("principal", "approvers", "allowed"),
|
|
206
|
+
[
|
|
207
|
+
(Principal(id="alice"), ["requester"], True),
|
|
208
|
+
(Principal(id="alice", roles=["ops"]), ["role:ops"], False), # no self-approval
|
|
209
|
+
(Principal(id="alice", roles=["ops"]), ["requester", "role:ops"], True),
|
|
210
|
+
(Principal(id="carol", roles=["ops"]), ["role:ops"], True),
|
|
211
|
+
(Principal(id="carol", roles=["ops"]), ["requester"], False),
|
|
212
|
+
(Principal(id="bob", roles=["user"]), ["requester", "role:ops"], False),
|
|
213
|
+
(Principal(id="bob", roles=["Ops", "ops-team"]), ["role:ops"], False),
|
|
214
|
+
(Principal(id=""), ["requester"], False),
|
|
215
|
+
],
|
|
216
|
+
)
|
|
217
|
+
def test_who_may_decide(principal: Principal, approvers: list[str], allowed: bool) -> None:
|
|
218
|
+
assert may_decide(principal, "alice", approvers) is allowed
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_who_may_see(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
222
|
+
monkeypatch.setenv("AUTH_READ_ACROSS_ROLES", "auditor")
|
|
223
|
+
assert may_view(Principal(id="alice"), "alice", ["role:ops"]) # the owner
|
|
224
|
+
assert may_view(Principal(id="carol", roles=["ops"]), "alice", ["role:ops"])
|
|
225
|
+
assert may_view(Principal(id="ann", roles=["auditor"]), "alice", ["role:ops"])
|
|
226
|
+
assert not may_view(Principal(id="bob"), "alice", ["role:ops"])
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_the_resumed_run_acts_as_the_requester_never_the_decider() -> None:
|
|
230
|
+
record = _record()
|
|
231
|
+
carol = Principal(id="carol", roles=["ops", "admin"], attributes={"credentials": {"x": "c"}})
|
|
232
|
+
acting = resume_principal(record, "alice", carol)
|
|
233
|
+
assert acting.id == "alice" and acting.roles == ["user"]
|
|
234
|
+
assert acting.attributes == {"tenant": "t1"} # no credentials: not stored, not carol's
|
|
235
|
+
fresh = Principal(id="alice", roles=["user"], attributes={"credentials": {"x": "new"}})
|
|
236
|
+
assert resume_principal(record, "alice", fresh) is fresh
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# --- the store and the ledger -----------------------------------------------------------
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
async def test_an_interrupt_asked_again_keeps_its_pending_approval(store: ApprovalStore) -> None:
|
|
243
|
+
first, created, superseded = await store.add(_record())
|
|
244
|
+
assert created and superseded == 0
|
|
245
|
+
again, created, superseded = await store.add(_record())
|
|
246
|
+
assert not created and again.approval_id == first.approval_id and superseded == 0
|
|
247
|
+
# The same interrupt asking for a different request supersedes the old one.
|
|
248
|
+
changed, created, superseded = await store.add(_record(call_hash="h2"))
|
|
249
|
+
assert created and superseded == 1 and changed.approval_id != first.approval_id
|
|
250
|
+
assert (await store.get(first.approval_id)).status == EXPIRED
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
async def test_a_decision_is_taken_once(store: ApprovalStore) -> None:
|
|
254
|
+
record, _, _ = await store.add(_record())
|
|
255
|
+
results = await asyncio.gather(
|
|
256
|
+
store.decide(record.approval_id, APPROVED, "a", None),
|
|
257
|
+
store.decide(record.approval_id, REJECTED, "b", "no"),
|
|
258
|
+
)
|
|
259
|
+
winners = [r for r in results if r is not None]
|
|
260
|
+
assert len(winners) == 1
|
|
261
|
+
assert await store.decide(record.approval_id, APPROVED, "c", None) is None
|
|
262
|
+
with pytest.raises(ValueError):
|
|
263
|
+
await store.decide(record.approval_id, PENDING, "c", None)
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
async def test_decided_records_drop_the_call_unless_full_capture(
|
|
267
|
+
store: ApprovalStore, monkeypatch: pytest.MonkeyPatch
|
|
268
|
+
) -> None:
|
|
269
|
+
record, _, _ = await store.add(_record())
|
|
270
|
+
decided = await store.decide(record.approval_id, REJECTED, "x", " wrong customer ")
|
|
271
|
+
assert decided is not None and decided.comment == "wrong customer"
|
|
272
|
+
assert "body" not in decided.payload and "query" not in decided.payload
|
|
273
|
+
assert decided.payload["reason"] == "cancel_order: the customer asked"
|
|
274
|
+
monkeypatch.setenv("TRACE_CAPTURE", "full")
|
|
275
|
+
kept, _, _ = await store.add(_record(interrupt_id="i2"))
|
|
276
|
+
decided = await store.decide(kept.approval_id, APPROVED, "x", None)
|
|
277
|
+
assert decided is not None and decided.payload["body"] == {"reason": "asked"}
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def test_times_read_from_a_database_are_kept_in_utc() -> None:
|
|
281
|
+
"""A row's times in the session's zone (or naive) read as the same instant in UTC."""
|
|
282
|
+
for raw in (
|
|
283
|
+
"2026-09-28T16:17:10.165695-04:00",
|
|
284
|
+
datetime.fromisoformat("2026-09-28T16:17:10.165695-04:00"),
|
|
285
|
+
"2026-09-28T20:17:10.165695",
|
|
286
|
+
):
|
|
287
|
+
parsed = _as_datetime(raw)
|
|
288
|
+
assert parsed is not None and parsed.isoformat() == "2026-09-28T20:17:10.165695+00:00"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
async def test_times_read_back_are_the_times_written(store: ApprovalStore) -> None:
|
|
292
|
+
"""Whatever the database session's zone (the Postgres store's is not UTC), an approval
|
|
293
|
+
reads back with the very times it was written with: a relay that reads the approvals
|
|
294
|
+
ledger binds them into the decision it sends."""
|
|
295
|
+
record, _, _ = await store.add(_record())
|
|
296
|
+
got = await store.get(record.approval_id)
|
|
297
|
+
assert got is not None and got.expires_at.utcoffset() == timedelta(0)
|
|
298
|
+
for key in ("created_at", "expires_at"):
|
|
299
|
+
assert got.public()[key] == record.public()[key]
|
|
300
|
+
decided = await store.decide(record.approval_id, APPROVED, "x", None)
|
|
301
|
+
assert decided is not None and decided.public()["decided_at"].endswith("+00:00")
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
async def test_an_expired_approval_cannot_be_decided(store: ApprovalStore) -> None:
|
|
305
|
+
record, _, _ = await store.add(_record())
|
|
306
|
+
store._clock = lambda: record.expires_at + timedelta(seconds=1)
|
|
307
|
+
assert await store.decide(record.approval_id, APPROVED, "x", None) is None
|
|
308
|
+
assert [r.approval_id for r in await store.expire_due()] == [record.approval_id]
|
|
309
|
+
assert (await store.get(record.approval_id)).status == EXPIRED
|
|
310
|
+
assert await store.expire_due() == []
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
async def test_the_ledger_lets_an_approval_through_once(store: ApprovalStore) -> None:
|
|
314
|
+
record, _, _ = await store.add(_record())
|
|
315
|
+
assert (
|
|
316
|
+
await store.consume(record.approval_id, "h1", "t1")
|
|
317
|
+
== "the approval is pending, not approved"
|
|
318
|
+
)
|
|
319
|
+
await store.decide(record.approval_id, APPROVED, "x", None)
|
|
320
|
+
assert await store.consume(record.approval_id, "h2", "t1") == (
|
|
321
|
+
"the approval is for a different request"
|
|
322
|
+
)
|
|
323
|
+
assert await store.consume(record.approval_id, "h1", "t2") == (
|
|
324
|
+
"the approval belongs to another thread"
|
|
325
|
+
)
|
|
326
|
+
assert await store.consume(record.approval_id, "h1", "t1") is None
|
|
327
|
+
assert await store.consume(record.approval_id, "h1", "t1") == "the approval was already used"
|
|
328
|
+
assert await store.consume("nope", "h1", "t1") == "no such approval"
|
|
329
|
+
rejected, _, _ = await store.add(_record(interrupt_id="i3"))
|
|
330
|
+
await store.decide(rejected.approval_id, REJECTED, "x", None)
|
|
331
|
+
assert await store.consume(rejected.approval_id, "h1", "t1") == (
|
|
332
|
+
"the approval is rejected, not approved"
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
async def test_listing_across_threads(
|
|
337
|
+
store: ApprovalStore, monkeypatch: pytest.MonkeyPatch
|
|
338
|
+
) -> None:
|
|
339
|
+
mine, _, _ = await store.add(_record())
|
|
340
|
+
other = record_from_interrupt(
|
|
341
|
+
_interrupt_value(approvers=["role:finance"]),
|
|
342
|
+
interrupt_id="i9",
|
|
343
|
+
thread_id="t9",
|
|
344
|
+
run_id="r9",
|
|
345
|
+
requester=Principal(id="dora"),
|
|
346
|
+
)
|
|
347
|
+
await store.add(other)
|
|
348
|
+
assert [r.approval_id for r in await store.visible(ALICE)] == [mine.approval_id]
|
|
349
|
+
carol = Principal(id="carol", roles=["ops"])
|
|
350
|
+
assert [r.approval_id for r in await store.visible(carol)] == [mine.approval_id]
|
|
351
|
+
assert await store.visible(Principal(id="bob")) == []
|
|
352
|
+
monkeypatch.setenv("AUTH_READ_ACROSS_ROLES", "auditor")
|
|
353
|
+
ann = Principal(id="ann", roles=["auditor"])
|
|
354
|
+
assert {r.approval_id for r in await store.visible(ann)} == {
|
|
355
|
+
mine.approval_id,
|
|
356
|
+
other.approval_id,
|
|
357
|
+
}
|
|
358
|
+
assert await store.visible(ALICE, status=APPROVED) == []
|
|
359
|
+
assert len(await store.visible(ALICE, status=PENDING)) == 1
|
|
360
|
+
assert await store.delete_for_thread("t1") == 1
|
|
361
|
+
assert await store.visible(ALICE) == []
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
async def test_the_ledger_names_the_approvals_a_tool_call_asked_for(store: ApprovalStore) -> None:
|
|
365
|
+
"""By the tool call (its model message and call id) or by the interrupt, on any thread."""
|
|
366
|
+
asked, _, _ = await store.add(_record(message_id="m1", tool_call_id="c1"))
|
|
367
|
+
copied = record_from_interrupt(
|
|
368
|
+
_interrupt_value(message_id="m1", tool_call_id="c1", path="/orders/8/cancel"),
|
|
369
|
+
interrupt_id="i2",
|
|
370
|
+
thread_id="t-copy",
|
|
371
|
+
run_id="r2",
|
|
372
|
+
requester=ALICE,
|
|
373
|
+
)
|
|
374
|
+
await store.add(copied)
|
|
375
|
+
# The same call id in another model message (a model that reuses ids) is another call.
|
|
376
|
+
await store.add(_record(interrupt_id="i3", message_id="m2", tool_call_id="c1"))
|
|
377
|
+
await store.decide(asked.approval_id, REJECTED, "x", None)
|
|
378
|
+
found = await store.bound_approvals(tool_call=("m1", "c1"))
|
|
379
|
+
assert sorted(found) == [
|
|
380
|
+
BoundApproval("shop", "POST", "/orders/7/cancel", REJECTED, False),
|
|
381
|
+
BoundApproval("shop", "POST", "/orders/8/cancel", PENDING, False),
|
|
382
|
+
]
|
|
383
|
+
assert [b.path for b in await store.bound_approvals(interrupt_id="i2")] == ["/orders/8/cancel"]
|
|
384
|
+
assert await store.bound_approvals(tool_call=("m9", "c1"), interrupt_id="i9") == []
|
|
385
|
+
assert await store.bound_approvals() == []
|
|
386
|
+
# Read as a decision would be: used once sent, expired once past its expiry.
|
|
387
|
+
await store.decide(copied.approval_id, APPROVED, "x", None)
|
|
388
|
+
assert await store.consume(copied.approval_id, "h1", "t-copy") is None
|
|
389
|
+
[used] = await store.bound_approvals(interrupt_id="i2")
|
|
390
|
+
assert used.status == APPROVED and used.used
|
|
391
|
+
later, _, _ = await store.add(_record(interrupt_id="i4", message_id="m4", tool_call_id="c4"))
|
|
392
|
+
store._clock = lambda: later.expires_at + timedelta(seconds=1)
|
|
393
|
+
[expired] = await store.bound_approvals(tool_call=("m4", "c4"))
|
|
394
|
+
assert expired.status == EXPIRED and not expired.used
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
# --- kept in a file (langgraph dev) -----------------------------------------------------
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def test_only_langgraph_dev_keeps_the_approvals_in_a_file(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
401
|
+
monkeypatch.delenv("LANGGRAPH_RUNTIME_EDITION", raising=False)
|
|
402
|
+
monkeypatch.delenv("LANGGRAPH_DISABLE_FILE_PERSISTENCE", raising=False)
|
|
403
|
+
assert dev_ledger_path() is None
|
|
404
|
+
monkeypatch.setenv("LANGGRAPH_RUNTIME_EDITION", "postgres")
|
|
405
|
+
assert dev_ledger_path() is None
|
|
406
|
+
monkeypatch.setenv("LANGGRAPH_RUNTIME_EDITION", "inmem")
|
|
407
|
+
assert dev_ledger_path() == Path(".langgraph_api") / "agent_approvals.json"
|
|
408
|
+
# No file persistence: the dev server keeps no threads either.
|
|
409
|
+
monkeypatch.setenv("LANGGRAPH_DISABLE_FILE_PERSISTENCE", "true")
|
|
410
|
+
assert dev_ledger_path() is None
|
|
411
|
+
# A database always wins (the path is ignored).
|
|
412
|
+
assert ApprovalStore(Database("postgres", "postgresql://x/y"), path="a.json").path is None
|
|
413
|
+
|
|
414
|
+
|
|
415
|
+
async def test_the_approvals_kept_in_a_file_outlive_the_process(tmp_path: Path) -> None:
|
|
416
|
+
"""What a restart or a hot reload of `langgraph dev` reads back: every state, bound."""
|
|
417
|
+
path = tmp_path / ".langgraph_api" / "agent_approvals.json"
|
|
418
|
+
first = ApprovalStore(Database("memory"), path=path)
|
|
419
|
+
await first.load()
|
|
420
|
+
pending, _, _ = await first.add(_record(interrupt_id="i1", message_id="m1", tool_call_id="c1"))
|
|
421
|
+
rejected, _, _ = await first.add(_record(interrupt_id="i2", message_id="m2", tool_call_id="c2"))
|
|
422
|
+
await first.decide(rejected.approval_id, REJECTED, "x", "no")
|
|
423
|
+
sent, _, _ = await first.add(_record(interrupt_id="i3", message_id="m3", tool_call_id="c3"))
|
|
424
|
+
await first.decide(sent.approval_id, APPROVED, "x", None)
|
|
425
|
+
assert await first.consume(sent.approval_id, "h1", "t1") is None
|
|
426
|
+
unused, _, _ = await first.add(_record(interrupt_id="i4", message_id="m4", tool_call_id="c4"))
|
|
427
|
+
await first.decide(unused.approval_id, APPROVED, "x", None)
|
|
428
|
+
assert path.stat().st_mode & 0o777 == 0o600
|
|
429
|
+
assert [p.name for p in path.parent.iterdir()] == [path.name] # no partial file left
|
|
430
|
+
|
|
431
|
+
again = ApprovalStore(Database("memory"), path=path)
|
|
432
|
+
assert await again.load() == 4
|
|
433
|
+
assert [r.approval_id for r in await again.for_thread("t1")] == [
|
|
434
|
+
unused.approval_id,
|
|
435
|
+
sent.approval_id,
|
|
436
|
+
rejected.approval_id,
|
|
437
|
+
pending.approval_id,
|
|
438
|
+
]
|
|
439
|
+
for (message_id, call_id), status, used in (
|
|
440
|
+
(("m1", "c1"), PENDING, False),
|
|
441
|
+
(("m2", "c2"), REJECTED, False),
|
|
442
|
+
(("m3", "c3"), APPROVED, True),
|
|
443
|
+
(("m4", "c4"), APPROVED, False),
|
|
444
|
+
):
|
|
445
|
+
[bound] = await again.bound_approvals(tool_call=(message_id, call_id))
|
|
446
|
+
assert (bound.status, bound.used) == (status, used)
|
|
447
|
+
reloaded = await again.get(pending.approval_id)
|
|
448
|
+
assert reloaded is not None
|
|
449
|
+
assert reloaded.public() == pending.public() and reloaded.call_hash == pending.call_hash
|
|
450
|
+
assert reloaded.requester_context == pending.requester_context
|
|
451
|
+
assert (await again.get(rejected.approval_id)).comment == "no"
|
|
452
|
+
assert await again.consume(sent.approval_id, "h1", "t1") == "the approval was already used"
|
|
453
|
+
# A change made after the restart is kept too; a deleted thread takes its approvals.
|
|
454
|
+
assert await again.decide(pending.approval_id, REJECTED, "y", None) is not None
|
|
455
|
+
assert await again.delete_for_thread("t1") == 4
|
|
456
|
+
third = ApprovalStore(Database("memory"), path=path)
|
|
457
|
+
assert await third.load() == 0
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
@pytest.mark.parametrize("content", [b"{not json", b'{"version": 99, "approvals": []}', b"[]"])
|
|
461
|
+
async def test_an_approvals_file_that_cannot_be_read_stops_the_startup(
|
|
462
|
+
tmp_path: Path, content: bytes
|
|
463
|
+
) -> None:
|
|
464
|
+
path = tmp_path / "agent_approvals.json"
|
|
465
|
+
path.write_bytes(content)
|
|
466
|
+
with pytest.raises(LedgerUnavailable, match="cannot be read"):
|
|
467
|
+
await ApprovalStore(Database("memory"), path=path).load()
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
async def test_while_the_file_cannot_be_written_the_store_answers_nothing(
|
|
471
|
+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
|
472
|
+
) -> None:
|
|
473
|
+
"""A change not in the file could be lost on a restart: nothing is answered until it is."""
|
|
474
|
+
path = tmp_path / "agent_approvals.json"
|
|
475
|
+
store = ApprovalStore(Database("memory"), path=path)
|
|
476
|
+
await store.load()
|
|
477
|
+
write = store._write
|
|
478
|
+
|
|
479
|
+
def broken(change: int, data: bytes) -> None:
|
|
480
|
+
raise OSError("disk full")
|
|
481
|
+
|
|
482
|
+
monkeypatch.setattr(store, "_write", broken)
|
|
483
|
+
with pytest.raises(LedgerUnavailable, match="could not be written"):
|
|
484
|
+
await store.add(_record(message_id="m1", tool_call_id="c1"))
|
|
485
|
+
with pytest.raises(LedgerUnavailable):
|
|
486
|
+
await store.bound_approvals(tool_call=("m1", "c1"))
|
|
487
|
+
with pytest.raises(LedgerUnavailable):
|
|
488
|
+
await store.for_thread("t1")
|
|
489
|
+
# Written again (the sweep retries it too): the store answers, and the file has it.
|
|
490
|
+
monkeypatch.setattr(store, "_write", write)
|
|
491
|
+
[bound] = await store.bound_approvals(tool_call=("m1", "c1"))
|
|
492
|
+
assert bound.status == PENDING
|
|
493
|
+
again = ApprovalStore(Database("memory"), path=path)
|
|
494
|
+
assert await again.load() == 1
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
# --- the call, as bound and as shown ------------------------------------------------------
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def test_the_call_hash_binds_everything_that_decides_the_request() -> None:
|
|
501
|
+
base = dict(
|
|
502
|
+
api="shop",
|
|
503
|
+
method="post",
|
|
504
|
+
url="http://shop.test/orders/7/cancel",
|
|
505
|
+
query=httpx.QueryParams([("a", "1"), ("b", "2")]),
|
|
506
|
+
json_body={"reason": "asked", "amount": 5},
|
|
507
|
+
operation_id="cancelOrder",
|
|
508
|
+
headers=[("If-Match", "v1")],
|
|
509
|
+
)
|
|
510
|
+
digest = call_hash(canonical_call(**base))
|
|
511
|
+
reordered = {**base, "json_body": {"amount": 5, "reason": "asked"}}
|
|
512
|
+
assert call_hash(canonical_call(**reordered)) == digest
|
|
513
|
+
for change in (
|
|
514
|
+
{"method": "PUT"},
|
|
515
|
+
{"url": "http://shop.test/orders/8/cancel"},
|
|
516
|
+
{"url": "http://other.test/orders/7/cancel"},
|
|
517
|
+
{"query": httpx.QueryParams([("b", "2"), ("a", "1")])},
|
|
518
|
+
{"json_body": {"reason": "asked", "amount": 6}},
|
|
519
|
+
{"operation_id": "archiveOrder"},
|
|
520
|
+
{"headers": [("If-Match", "v2")]},
|
|
521
|
+
{"api": "other"},
|
|
522
|
+
):
|
|
523
|
+
assert call_hash(canonical_call(**{**base, **change})) != digest, change
|
|
524
|
+
with pytest.raises(ValueError):
|
|
525
|
+
call_hash(canonical_call(**{**base, "json_body": {"x": float("nan")}}))
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def test_redacted_fields_are_masked_at_any_depth() -> None:
|
|
529
|
+
body = {"Card": "4111", "items": [{"card": "4222", "sku": "a"}], "note": {"CARD": 1}}
|
|
530
|
+
assert redact_fields(body, frozenset({"card"})) == {
|
|
531
|
+
"Card": "<redacted>",
|
|
532
|
+
"items": [{"card": "<redacted>", "sku": "a"}],
|
|
533
|
+
"note": {"CARD": "<redacted>"},
|
|
534
|
+
}
|
|
535
|
+
assert redact_fields(body, frozenset()) is body
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def test_the_stated_purpose_is_the_text_the_model_wrote_with_the_call() -> None:
|
|
539
|
+
messages = [
|
|
540
|
+
{"type": "human", "content": "cancel 7"},
|
|
541
|
+
{
|
|
542
|
+
"type": "ai",
|
|
543
|
+
"content": [{"type": "text", "text": "Cancelling order 7\x00 as asked."}],
|
|
544
|
+
"tool_calls": [{"id": "c1", "name": "cancel_order", "args": {}}],
|
|
545
|
+
},
|
|
546
|
+
]
|
|
547
|
+
assert stated_purpose(messages, "c1") == "Cancelling order 7 as asked."
|
|
548
|
+
assert stated_purpose(messages, "c2") is None
|
|
549
|
+
long = [{"type": "ai", "content": "x" * 900, "tool_calls": [{"id": "c1"}]}]
|
|
550
|
+
assert len(stated_purpose(long, "c1")) == 500
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
# --- the client's side, in a real graph -------------------------------------------------
|
|
554
|
+
|
|
555
|
+
SENT: list[httpx.Request] = []
|
|
556
|
+
BODY: dict[str, Any] = {}
|
|
557
|
+
SECOND_CALL: list[bool] = []
|
|
558
|
+
# The tool catches a refusal and tries the same call once more.
|
|
559
|
+
RETRY: list[bool] = []
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _upstream(request: httpx.Request) -> httpx.Response:
|
|
563
|
+
SENT.append(request)
|
|
564
|
+
return httpx.Response(200, json={"ok": True})
|
|
565
|
+
|
|
566
|
+
|
|
567
|
+
@tool
|
|
568
|
+
async def cancel_order(order_id: str, runtime: ToolRuntime[Any]) -> str:
|
|
569
|
+
"""Cancel an order by its id."""
|
|
570
|
+
client = get_client("shop", transport=httpx.MockTransport(_upstream))
|
|
571
|
+
|
|
572
|
+
async def cancel() -> Any:
|
|
573
|
+
return await client.post(
|
|
574
|
+
"/orders/{order_id}/cancel",
|
|
575
|
+
operation_id="cancelOrder",
|
|
576
|
+
path_params={"order_id": order_id},
|
|
577
|
+
json_body=dict(BODY),
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
try:
|
|
581
|
+
data = await cancel()
|
|
582
|
+
except ApiPolicyError:
|
|
583
|
+
if not RETRY:
|
|
584
|
+
raise
|
|
585
|
+
data = await cancel()
|
|
586
|
+
if SECOND_CALL:
|
|
587
|
+
await client.post("/orders", operation_id="createOrder", json_body={"sku": "a"})
|
|
588
|
+
return json.dumps(data)
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
@pytest.fixture
|
|
592
|
+
def graph(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, store: ApprovalStore) -> Iterator[Any]:
|
|
593
|
+
from {{cookiecutter.agent_directory}} import agent
|
|
594
|
+
from {{cookiecutter.agent_directory}}.app_utils.model import get_model
|
|
595
|
+
|
|
596
|
+
policy = tmp_path / "api-policy.yaml"
|
|
597
|
+
policy.write_text(POLICY, encoding="utf-8")
|
|
598
|
+
monkeypatch.setenv("API_POLICY_PATH", str(policy))
|
|
599
|
+
monkeypatch.setenv("SHOP_API_BASE_URL", "http://shop.test")
|
|
600
|
+
reset_policy_cache()
|
|
601
|
+
SENT.clear()
|
|
602
|
+
SECOND_CALL.clear()
|
|
603
|
+
RETRY.clear()
|
|
604
|
+
BODY.clear()
|
|
605
|
+
BODY.update({"reason": "asked"})
|
|
606
|
+
set_approval_ledger(store)
|
|
607
|
+
yield create_agent(
|
|
608
|
+
model=get_model(),
|
|
609
|
+
tools=[cancel_order],
|
|
610
|
+
system_prompt=agent.SYSTEM_PROMPT,
|
|
611
|
+
middleware=agent.middleware(),
|
|
612
|
+
context_schema=agent.AgentContext,
|
|
613
|
+
checkpointer=InMemorySaver(),
|
|
614
|
+
)
|
|
615
|
+
set_approval_ledger(None)
|
|
616
|
+
reset_policy_cache()
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
async def _run(graph: Any, thread: str, graph_input: Any) -> list[Any]:
|
|
620
|
+
"""Run to the end; the interrupts it paused on (id and value)."""
|
|
621
|
+
config = {"configurable": {"thread_id": thread}}
|
|
622
|
+
async for _ in graph.astream(graph_input, config, stream_mode="updates"):
|
|
623
|
+
pass
|
|
624
|
+
state = await graph.aget_state(config)
|
|
625
|
+
return list(state.interrupts)
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
async def _pause(graph: Any, thread: str, store: ApprovalStore) -> tuple[Any, ApprovalRecord]:
|
|
629
|
+
interrupts = await _run(graph, thread, {"messages": [("user", "Cancel the order for 7")]})
|
|
630
|
+
assert len(interrupts) == 1
|
|
631
|
+
record, _, _ = await store.add(
|
|
632
|
+
record_from_interrupt(
|
|
633
|
+
interrupts[0].value,
|
|
634
|
+
interrupt_id=interrupts[0].id,
|
|
635
|
+
thread_id=thread,
|
|
636
|
+
run_id="r",
|
|
637
|
+
requester=ALICE,
|
|
638
|
+
)
|
|
639
|
+
)
|
|
640
|
+
return interrupts[0], record
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
async def _last_tool_result(graph: Any, thread: str) -> str:
|
|
644
|
+
state = await graph.aget_state({"configurable": {"thread_id": thread}})
|
|
645
|
+
return next(m.content for m in reversed(state.values["messages"]) if m.type == "tool")
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
async def test_a_gated_call_interrupts_with_the_call_before_anything_is_sent(graph, store) -> None:
|
|
649
|
+
interrupt, record = await _pause(graph, "t1", store)
|
|
650
|
+
value = interrupt.value
|
|
651
|
+
assert value["type"] == APPROVAL_INTERRUPT
|
|
652
|
+
assert (value["method"], value["path"], value["body"]) == ("POST", "/orders/7/cancel", BODY)
|
|
653
|
+
assert value["tool"] == "cancel_order" and value["tool_call_id"] == "call_cancel_order"
|
|
654
|
+
assert value["message_id"] # the model message that made the call
|
|
655
|
+
assert record.message_id == value["message_id"]
|
|
656
|
+
assert value["approvers"] == ["requester", "role:ops"] and value["timeout_s"] == 60
|
|
657
|
+
assert len(value["call_hash"]) == 64
|
|
658
|
+
assert SENT == [] and record.status == PENDING
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
async def test_approved_the_call_is_sent_once(graph, store) -> None:
|
|
662
|
+
interrupt, record = await _pause(graph, "t1", store)
|
|
663
|
+
decided = await store.decide(record.approval_id, APPROVED, "x", None)
|
|
664
|
+
await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "approve")}))
|
|
665
|
+
assert [(r.method, r.url.path) for r in SENT] == [("POST", "/orders/7/cancel")]
|
|
666
|
+
assert json.loads(SENT[0].content) == BODY
|
|
667
|
+
assert '"ok": true' in await _last_tool_result(graph, "t1")
|
|
668
|
+
assert (await store.get(record.approval_id)).used_at is not None
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
async def test_rejected_or_expired_nothing_is_sent(graph, store) -> None:
|
|
672
|
+
interrupt, record = await _pause(graph, "t1", store)
|
|
673
|
+
decided = await store.decide(record.approval_id, REJECTED, "x", "not this one")
|
|
674
|
+
await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "reject")}))
|
|
675
|
+
assert "an approver rejected it (their comment: not this one)" in await _last_tool_result(
|
|
676
|
+
graph, "t1"
|
|
677
|
+
)
|
|
678
|
+
interrupt, record = await _pause(graph, "t2", store)
|
|
679
|
+
await _run(graph, "t2", Command(resume={interrupt.id: decision_value(record, "expired")}))
|
|
680
|
+
assert "the approval request expired" in await _last_tool_result(graph, "t2")
|
|
681
|
+
assert SENT == []
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
async def test_a_resume_the_store_did_not_approve_sends_nothing(graph, store) -> None:
|
|
685
|
+
"""A forged resume value (the right hash, an approval still pending) is refused."""
|
|
686
|
+
interrupt, record = await _pause(graph, "t1", store)
|
|
687
|
+
forged = decision_value(record, "approve")
|
|
688
|
+
await _run(graph, "t1", Command(resume={interrupt.id: forged}))
|
|
689
|
+
assert "the approval is pending, not approved" in await _last_tool_result(graph, "t1")
|
|
690
|
+
assert SENT == []
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
async def test_a_used_approval_cannot_be_replayed_on_another_run(graph, store) -> None:
|
|
694
|
+
interrupt, record = await _pause(graph, "t1", store)
|
|
695
|
+
decided = await store.decide(record.approval_id, APPROVED, "x", None)
|
|
696
|
+
value = decision_value(decided, "approve")
|
|
697
|
+
await _run(graph, "t1", Command(resume={interrupt.id: value}))
|
|
698
|
+
assert len(SENT) == 1
|
|
699
|
+
# The same request, paused on another thread, resumed with the used approval.
|
|
700
|
+
other, _ = await _pause(graph, "t2", store)
|
|
701
|
+
await _run(graph, "t2", Command(resume={other.id: value}))
|
|
702
|
+
assert "the approval belongs to another thread" in await _last_tool_result(graph, "t2")
|
|
703
|
+
assert len(SENT) == 1
|
|
704
|
+
|
|
705
|
+
|
|
706
|
+
async def test_a_request_that_changed_is_not_covered(graph, store) -> None:
|
|
707
|
+
interrupt, record = await _pause(graph, "t1", store)
|
|
708
|
+
decided = await store.decide(record.approval_id, APPROVED, "x", None)
|
|
709
|
+
BODY["reason"] = "changed"
|
|
710
|
+
await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "approve")}))
|
|
711
|
+
assert "differs from the request that was approved" in await _last_tool_result(graph, "t1")
|
|
712
|
+
assert SENT == []
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
async def test_without_a_ledger_an_approval_is_not_used(graph, store) -> None:
|
|
716
|
+
interrupt, record = await _pause(graph, "t1", store)
|
|
717
|
+
decided = await store.decide(record.approval_id, APPROVED, "x", None)
|
|
718
|
+
set_approval_ledger(None)
|
|
719
|
+
await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "approve")}))
|
|
720
|
+
assert "no approvals ledger" in await _last_tool_result(graph, "t1")
|
|
721
|
+
assert SENT == []
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
async def test_a_tool_call_sends_at_most_one_approved_call(graph, store) -> None:
|
|
725
|
+
SECOND_CALL.append(True)
|
|
726
|
+
interrupt, record = await _pause(graph, "t1", store)
|
|
727
|
+
decided = await store.decide(record.approval_id, APPROVED, "x", None)
|
|
728
|
+
await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "approve")}))
|
|
729
|
+
result = await _last_tool_result(graph, "t1")
|
|
730
|
+
assert "already sent an approved call" in result
|
|
731
|
+
assert [r.url.path for r in SENT] == ["/orders/7/cancel"]
|
|
732
|
+
state = await graph.aget_state({"configurable": {"thread_id": "t1"}})
|
|
733
|
+
assert not state.interrupts
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
async def test_a_resume_without_a_decision_sends_nothing(graph, store) -> None:
|
|
737
|
+
interrupt, _ = await _pause(graph, "t1", store)
|
|
738
|
+
await _run(graph, "t1", Command(resume={interrupt.id: {"decision": "approve"}}))
|
|
739
|
+
assert "resumed without an approval decision" in await _last_tool_result(graph, "t1")
|
|
740
|
+
await _pause(graph, "t2", store)
|
|
741
|
+
assert SENT == []
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
# --- a decision binds its call, whatever the policy says when the run resumes -----------
|
|
745
|
+
|
|
746
|
+
# The policy as a new image may bring it while a call waits (POLICY gates POST).
|
|
747
|
+
POLICY_CHANGES = {
|
|
748
|
+
"gate removed": POLICY.split(" approval:")[0],
|
|
749
|
+
"gate narrowed": POLICY.replace("methods: [POST]", "methods: [DELETE]"),
|
|
750
|
+
"call denied": POLICY.replace(
|
|
751
|
+
" approval:",
|
|
752
|
+
" denied_operations:\n - path: /orders/{order_id}/cancel\n approval:",
|
|
753
|
+
),
|
|
754
|
+
"allowed_methods narrowed": POLICY.replace(
|
|
755
|
+
"allowed_methods: [GET, POST]", "allowed_methods: [GET]"
|
|
756
|
+
),
|
|
757
|
+
}
|
|
758
|
+
# The policy refuses before any decision is read; else the decision (or the gate) does.
|
|
759
|
+
REFUSED_BY = {
|
|
760
|
+
"call denied": "denied by denied_operations",
|
|
761
|
+
"allowed_methods narrowed": "is not in allowed_methods",
|
|
762
|
+
}
|
|
763
|
+
DECIDED_BY = {
|
|
764
|
+
"reject": "was not approved: an approver rejected it",
|
|
765
|
+
"expired": "was not approved: the approval request expired",
|
|
766
|
+
"approve": "approved under an approval gate the policy no longer has",
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def _change_policy(tmp_path: Path, text: str) -> None:
|
|
771
|
+
(tmp_path / "api-policy.yaml").write_text(text, encoding="utf-8")
|
|
772
|
+
reset_policy_cache()
|
|
773
|
+
|
|
774
|
+
|
|
775
|
+
async def _decided(store: ApprovalStore, record: ApprovalRecord, decision: str) -> ApprovalRecord:
|
|
776
|
+
if decision == "expired":
|
|
777
|
+
closed = await store.expire(record.approval_id)
|
|
778
|
+
else:
|
|
779
|
+
status = APPROVED if decision == "approve" else REJECTED
|
|
780
|
+
closed = await store.decide(record.approval_id, status, "x", None)
|
|
781
|
+
assert closed is not None
|
|
782
|
+
return closed
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
@pytest.mark.parametrize("change", list(POLICY_CHANGES))
|
|
786
|
+
@pytest.mark.parametrize("decision", ["reject", "expired", "approve"])
|
|
787
|
+
async def test_a_decision_binds_its_call_whatever_the_policy_says_by_then(
|
|
788
|
+
graph, store, tmp_path, change: str, decision: str
|
|
789
|
+
) -> None:
|
|
790
|
+
interrupt, record = await _pause(graph, "t1", store)
|
|
791
|
+
decided = await _decided(store, record, decision)
|
|
792
|
+
_change_policy(tmp_path, POLICY_CHANGES[change])
|
|
793
|
+
await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, decision)}))
|
|
794
|
+
result = await _last_tool_result(graph, "t1")
|
|
795
|
+
assert REFUSED_BY.get(change, DECIDED_BY[decision]) in result, result
|
|
796
|
+
assert SENT == []
|
|
797
|
+
assert (await store.get(record.approval_id)).used_at is None
|
|
798
|
+
state = await graph.aget_state({"configurable": {"thread_id": "t1"}})
|
|
799
|
+
assert not state.interrupts
|
|
800
|
+
|
|
801
|
+
|
|
802
|
+
async def test_an_approval_still_covers_its_call_under_a_gate_with_the_same_approvers(
|
|
803
|
+
graph, store, tmp_path
|
|
804
|
+
) -> None:
|
|
805
|
+
interrupt, record = await _pause(graph, "t1", store)
|
|
806
|
+
decided = await _decided(store, record, "approve")
|
|
807
|
+
# Rewritten, still gating the call and asking the same approvers.
|
|
808
|
+
still_gated = POLICY.replace(
|
|
809
|
+
" methods: [POST]", " operations:\n - path: /orders/{x}/cancel"
|
|
810
|
+
)
|
|
811
|
+
_change_policy(tmp_path, still_gated)
|
|
812
|
+
await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "approve")}))
|
|
813
|
+
assert [(r.method, r.url.path) for r in SENT] == [("POST", "/orders/7/cancel")]
|
|
814
|
+
assert (await store.get(record.approval_id)).used_at is not None
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
# --- a list of approval rules: the rule that gates the call names who decides ----------
|
|
818
|
+
|
|
819
|
+
RULES_POLICY = """
|
|
820
|
+
apis:
|
|
821
|
+
shop:
|
|
822
|
+
base_url_env: SHOP_API_BASE_URL
|
|
823
|
+
auth: none
|
|
824
|
+
allowed_methods: [GET, POST]
|
|
825
|
+
approval:
|
|
826
|
+
- required_for:
|
|
827
|
+
operations:
|
|
828
|
+
- {operationId: createOrder, path: /orders, methods: [POST]}
|
|
829
|
+
approvers: ["role:admin"]
|
|
830
|
+
timeout_s: 3600
|
|
831
|
+
- required_for:
|
|
832
|
+
operations:
|
|
833
|
+
- {operationId: cancelOrder, path: "/orders/{order_id}/cancel", methods: [POST]}
|
|
834
|
+
approvers: [requester]
|
|
835
|
+
timeout_s: 120
|
|
836
|
+
"""
|
|
837
|
+
|
|
838
|
+
|
|
839
|
+
async def test_the_approval_is_asked_of_the_rule_that_gates_the_call(
|
|
840
|
+
graph, store, tmp_path
|
|
841
|
+
) -> None:
|
|
842
|
+
_change_policy(tmp_path, RULES_POLICY)
|
|
843
|
+
interrupt, record = await _pause(graph, "t1", store)
|
|
844
|
+
value = interrupt.value
|
|
845
|
+
assert (value["approvers"], value["timeout_s"]) == (["requester"], 120)
|
|
846
|
+
assert value["rule"].startswith("approval[1].required_for.operations (operationId=cancelOrder")
|
|
847
|
+
# The record keeps those approvers, and they decide: the requester, not an admin.
|
|
848
|
+
assert record.approvers == ["requester"]
|
|
849
|
+
assert record.expires_at - record.created_at == timedelta(seconds=120)
|
|
850
|
+
assert may_decide(ALICE, ALICE.id, record.approvers)
|
|
851
|
+
assert not may_decide(Principal(id="root", roles=["admin"]), ALICE.id, record.approvers)
|
|
852
|
+
decided = await _decided(store, record, "approve")
|
|
853
|
+
await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "approve")}))
|
|
854
|
+
assert [(r.method, r.url.path) for r in SENT] == [("POST", "/orders/7/cancel")]
|
|
855
|
+
|
|
856
|
+
|
|
857
|
+
async def test_a_rule_added_after_the_one_that_gated_the_call_keeps_its_approval(
|
|
858
|
+
graph, store, tmp_path
|
|
859
|
+
) -> None:
|
|
860
|
+
_change_policy(tmp_path, RULES_POLICY)
|
|
861
|
+
interrupt, record = await _pause(graph, "t1", store)
|
|
862
|
+
decided = await _decided(store, record, "approve")
|
|
863
|
+
# A later rule covering the call too never applies to it: the approval still holds.
|
|
864
|
+
_change_policy(
|
|
865
|
+
tmp_path,
|
|
866
|
+
RULES_POLICY + ' - {required_for: {methods: [POST]}, approvers: ["role:ops"]}\n',
|
|
867
|
+
)
|
|
868
|
+
await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "approve")}))
|
|
869
|
+
assert [(r.method, r.url.path) for r in SENT] == [("POST", "/orders/7/cancel")]
|
|
870
|
+
|
|
871
|
+
|
|
872
|
+
async def test_rules_that_now_hand_the_call_to_other_approvers_void_its_approval(
|
|
873
|
+
graph, store, tmp_path
|
|
874
|
+
) -> None:
|
|
875
|
+
"""Bound at pause time: the approvers of the rule that gated the call then."""
|
|
876
|
+
_change_policy(tmp_path, RULES_POLICY)
|
|
877
|
+
interrupt, record = await _pause(graph, "t1", store)
|
|
878
|
+
decided = await _decided(store, record, "approve")
|
|
879
|
+
# A new image: a POST rule for role:admin now comes first and covers the call.
|
|
880
|
+
_change_policy(
|
|
881
|
+
tmp_path,
|
|
882
|
+
RULES_POLICY.replace(
|
|
883
|
+
" approval:\n",
|
|
884
|
+
' approval:\n - {required_for: {methods: [POST]}, approvers: ["role:admin"]}\n',
|
|
885
|
+
),
|
|
886
|
+
)
|
|
887
|
+
await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "approve")}))
|
|
888
|
+
assert "approval gate that has changed since" in await _last_tool_result(graph, "t1")
|
|
889
|
+
assert SENT == []
|
|
890
|
+
assert (await store.get(record.approval_id)).used_at is None
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
async def test_a_call_a_decision_stopped_stays_stopped_in_its_tool_call(
|
|
894
|
+
graph, store, tmp_path
|
|
895
|
+
) -> None:
|
|
896
|
+
RETRY.append(True) # the tool tries the call again once it is refused
|
|
897
|
+
interrupt, record = await _pause(graph, "t1", store)
|
|
898
|
+
decided = await _decided(store, record, "reject")
|
|
899
|
+
_change_policy(tmp_path, POLICY_CHANGES["gate removed"])
|
|
900
|
+
await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "reject")}))
|
|
901
|
+
result = await _last_tool_result(graph, "t1")
|
|
902
|
+
assert "stopped by its approval decision earlier in this tool call" in result
|
|
903
|
+
assert SENT == []
|
|
904
|
+
|
|
905
|
+
|
|
906
|
+
async def test_a_call_still_waiting_pauses_again_for_its_own_approval(
|
|
907
|
+
graph, store, tmp_path
|
|
908
|
+
) -> None:
|
|
909
|
+
"""Another call's decision resumed the run: this one waits on, even un-gated meanwhile."""
|
|
910
|
+
interrupt, record = await _pause(graph, "t1", store)
|
|
911
|
+
_change_policy(tmp_path, POLICY_CHANGES["gate removed"])
|
|
912
|
+
await _run(graph, "t1", Command(resume={interrupt.id: decision_value(record, "pending")}))
|
|
913
|
+
assert SENT == []
|
|
914
|
+
state = await graph.aget_state({"configurable": {"thread_id": "t1"}})
|
|
915
|
+
[again] = state.interrupts
|
|
916
|
+
assert again.id == interrupt.id
|
|
917
|
+
assert again.value["call_hash"] == record.call_hash
|
|
918
|
+
assert again.value["approvers"] == ["requester", "role:ops"] # asked of the same approvers
|
|
919
|
+
kept, created, _ = await store.add(
|
|
920
|
+
record_from_interrupt(
|
|
921
|
+
again.value, interrupt_id=again.id, thread_id="t1", run_id="r2", requester=ALICE
|
|
922
|
+
)
|
|
923
|
+
)
|
|
924
|
+
assert not created and kept.approval_id == record.approval_id
|
|
925
|
+
# Its own decision then applies to it: approved, it is still not sent (no gate now).
|
|
926
|
+
decided = await _decided(store, record, "approve")
|
|
927
|
+
await _run(graph, "t1", Command(resume={again.id: decision_value(decided, "approve")}))
|
|
928
|
+
assert "approval gate the policy no longer has" in await _last_tool_result(graph, "t1")
|
|
929
|
+
assert SENT == []
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
# --- a tool call run again without a decision (LangGraph Server's own API can) ---------
|
|
933
|
+
|
|
934
|
+
# What the model reads when the ledger refuses a call its tool call asked an approval for.
|
|
935
|
+
BOUND_BY = {
|
|
936
|
+
"reject": "was not approved: an approver rejected it",
|
|
937
|
+
"expired": "was not approved: the approval request expired",
|
|
938
|
+
"approve": "was sent already with its approval, which is used once",
|
|
939
|
+
"pending": "was resumed without an approval decision",
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
async def _run_from(graph: Any, config: Any) -> None:
|
|
944
|
+
"""Run the graph from the checkpoint `config` names, without input (a replay)."""
|
|
945
|
+
async for _ in graph.astream(None, config, stream_mode="updates"):
|
|
946
|
+
pass
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
@pytest.mark.parametrize("decision", ["reject", "expired", "approve", "pending"])
|
|
950
|
+
async def test_a_tool_call_run_again_without_its_decision_sends_nothing_more(
|
|
951
|
+
graph, store, tmp_path, decision: str
|
|
952
|
+
) -> None:
|
|
953
|
+
"""Continued without input, or replayed from its checkpoint, whatever the policy says."""
|
|
954
|
+
interrupt, record = await _pause(graph, "t1", store)
|
|
955
|
+
paused = (await graph.aget_state({"configurable": {"thread_id": "t1"}})).config
|
|
956
|
+
if decision != "pending":
|
|
957
|
+
decided = await _decided(store, record, decision)
|
|
958
|
+
if decision == "approve":
|
|
959
|
+
await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, decision)}))
|
|
960
|
+
assert len(SENT) == 1
|
|
961
|
+
_change_policy(tmp_path, POLICY_CHANGES["gate removed"])
|
|
962
|
+
await _run(graph, "t1", None) # continue the thread without input
|
|
963
|
+
await _run_from(graph, paused) # replay the paused step from its checkpoint (a fork)
|
|
964
|
+
await _run_from(graph, paused)
|
|
965
|
+
assert BOUND_BY[decision] in await _last_tool_result(graph, "t1")
|
|
966
|
+
assert len(SENT) == (1 if decision == "approve" else 0)
|
|
967
|
+
|
|
968
|
+
|
|
969
|
+
async def test_a_new_tool_call_is_not_bound_by_an_earlier_one(graph, store, tmp_path) -> None:
|
|
970
|
+
"""The fake model reuses its call ids: a new message makes a new tool call."""
|
|
971
|
+
interrupt, record = await _pause(graph, "t1", store)
|
|
972
|
+
decided = await _decided(store, record, "reject")
|
|
973
|
+
await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "reject")}))
|
|
974
|
+
_change_policy(tmp_path, POLICY_CHANGES["gate removed"])
|
|
975
|
+
await _run(graph, "t1", {"messages": [("user", "Cancel the order for 7")]})
|
|
976
|
+
assert [r.url.path for r in SENT] == ["/orders/7/cancel"]
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
async def test_without_the_tool_call_scope_the_task_interrupt_binds_the_call(
|
|
980
|
+
graph, store, tmp_path
|
|
981
|
+
) -> None:
|
|
982
|
+
"""A graph without the agent's middleware: a continued task is known by its interrupt."""
|
|
983
|
+
from {{cookiecutter.agent_directory}} import agent
|
|
984
|
+
from {{cookiecutter.agent_directory}}.app_utils.model import get_model
|
|
985
|
+
|
|
986
|
+
bare = create_agent(
|
|
987
|
+
model=get_model(),
|
|
988
|
+
tools=[cancel_order],
|
|
989
|
+
context_schema=agent.AgentContext,
|
|
990
|
+
checkpointer=InMemorySaver(),
|
|
991
|
+
)
|
|
992
|
+
_, record = await _pause(bare, "t1", store)
|
|
993
|
+
assert record.message_id is None and record.tool_call_id is None
|
|
994
|
+
await _decided(store, record, "reject")
|
|
995
|
+
_change_policy(tmp_path, POLICY_CHANGES["gate removed"])
|
|
996
|
+
with pytest.raises(ApiPolicyError, match="an approver rejected it"):
|
|
997
|
+
await _run(bare, "t1", None)
|
|
998
|
+
assert SENT == []
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
class _BrokenLedger:
|
|
1002
|
+
async def consume(self, *args: Any, **kwargs: Any) -> str | None:
|
|
1003
|
+
return "unused"
|
|
1004
|
+
|
|
1005
|
+
async def bound_approvals(self, **kwargs: Any) -> list[BoundApproval]:
|
|
1006
|
+
raise ConnectionError("database down")
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
async def test_a_ledger_that_cannot_answer_refuses_the_call(graph, store, tmp_path) -> None:
|
|
1010
|
+
_change_policy(tmp_path, POLICY_CHANGES["gate removed"])
|
|
1011
|
+
set_approval_ledger(_BrokenLedger())
|
|
1012
|
+
await _run(graph, "t1", {"messages": [("user", "Cancel the order for 7")]})
|
|
1013
|
+
result = await _last_tool_result(graph, "t1")
|
|
1014
|
+
assert "the approvals of this tool call could not be read (ConnectionError)" in result
|
|
1015
|
+
assert SENT == []
|
|
1016
|
+
|
|
1017
|
+
|
|
1018
|
+
async def test_outside_an_agent_run_a_gated_call_is_refused(graph) -> None:
|
|
1019
|
+
client = get_client("shop", transport=httpx.MockTransport(_upstream))
|
|
1020
|
+
with pytest.raises(ApiPolicyError) as exc:
|
|
1021
|
+
await client.post("/orders", operation_id="createOrder", json_body={"sku": "a"})
|
|
1022
|
+
assert "possible only inside an agent run" in str(exc.value)
|
|
1023
|
+
assert exc.value.reason.startswith("approval required by approval.required_for.methods")
|
|
1024
|
+
assert SENT == []
|
|
1025
|
+
assert api_client.approval_ledger() is not None # the fixture's, untouched
|
|
1026
|
+
|
|
1027
|
+
|
|
1028
|
+
def test_the_decision_type_is_what_the_client_expects() -> None:
|
|
1029
|
+
assert decision_value(_record(), "approve")["type"] == APPROVAL_DECISION
|
|
1030
|
+
assert utcnow().tzinfo is not None
|
|
1031
|
+
|
|
1032
|
+
|
|
1033
|
+
# --- agents acting for a user (0.3): who sees and decides ------------------------------------
|
|
1034
|
+
|
|
1035
|
+
CONCIERGE_ACTOR = Actor(id="concierge", chain=("concierge",))
|
|
1036
|
+
ALICE_VIA_CONCIERGE = Principal(
|
|
1037
|
+
id="alice",
|
|
1038
|
+
attributes={"tenant": "t1", "@actor": CONCIERGE_ACTOR.public()},
|
|
1039
|
+
actor=CONCIERGE_ACTOR,
|
|
1040
|
+
)
|
|
1041
|
+
ALICE_VIA_BILLING = Principal(id="alice", actor=Actor(id="billing", chain=("billing",)))
|
|
1042
|
+
CONCIERGE_THREAD = ThreadRecord(thread_id="t1", principal_id="alice", actor="concierge")
|
|
1043
|
+
DIRECT_THREAD = ThreadRecord(thread_id="t1", principal_id="alice")
|
|
1044
|
+
|
|
1045
|
+
|
|
1046
|
+
def test_delegated_cannot_decide_direct_gate() -> None:
|
|
1047
|
+
# The person decides, whichever agent started the thread; the agent never does.
|
|
1048
|
+
for approvers in (["requester"], ["requester", "role:ops"]):
|
|
1049
|
+
assert may_decide(Principal(id="alice"), CONCIERGE_THREAD, approvers)
|
|
1050
|
+
assert decide_refusal(ALICE_VIA_CONCIERGE, CONCIERGE_THREAD, approvers) == (
|
|
1051
|
+
"approval_direct_only",
|
|
1052
|
+
"This approval must be decided by the person at this agent (decide_with: direct), "
|
|
1053
|
+
"not relayed by agent concierge.",
|
|
1054
|
+
)
|
|
1055
|
+
# Another agent of the same user, or of another user, learns only that it is no approver.
|
|
1056
|
+
for stranger, thread in (
|
|
1057
|
+
(ALICE_VIA_BILLING, CONCIERGE_THREAD),
|
|
1058
|
+
(ALICE_VIA_CONCIERGE, DIRECT_THREAD),
|
|
1059
|
+
(Principal(id="bob", actor=CONCIERGE_ACTOR), CONCIERGE_THREAD),
|
|
1060
|
+
):
|
|
1061
|
+
refusal = decide_refusal(stranger, thread, ["requester"])
|
|
1062
|
+
assert refusal is not None and refusal[0] == "not_an_approver"
|
|
1063
|
+
# A delegated principal's roles never make it a role approver.
|
|
1064
|
+
ops_agent = Principal(id="carol", roles=["ops"], actor=CONCIERGE_ACTOR)
|
|
1065
|
+
assert not may_decide(ops_agent, CONCIERGE_THREAD, ["role:ops"])
|
|
1066
|
+
assert may_decide(Principal(id="carol", roles=["ops"]), CONCIERGE_THREAD, ["role:ops"])
|
|
1067
|
+
|
|
1068
|
+
|
|
1069
|
+
def test_who_may_see_an_agents_thread(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
1070
|
+
monkeypatch.setenv("AUTH_READ_ACROSS_ROLES", "auditor")
|
|
1071
|
+
approvers = ["requester"]
|
|
1072
|
+
assert may_view(Principal(id="alice"), CONCIERGE_THREAD, approvers) # the person
|
|
1073
|
+
assert may_view(ALICE_VIA_CONCIERGE, CONCIERGE_THREAD, approvers) # the agent that started it
|
|
1074
|
+
assert sees_call(ALICE_VIA_CONCIERGE, CONCIERGE_THREAD, approvers)
|
|
1075
|
+
assert not may_view(ALICE_VIA_BILLING, CONCIERGE_THREAD, approvers)
|
|
1076
|
+
assert not sees_call(ALICE_VIA_BILLING, CONCIERGE_THREAD, approvers)
|
|
1077
|
+
auditor_agent = Principal(id="ann", roles=["auditor"], actor=CONCIERGE_ACTOR)
|
|
1078
|
+
assert not may_view(auditor_agent, CONCIERGE_THREAD, approvers)
|
|
1079
|
+
assert may_view(Principal(id="ann", roles=["auditor"]), CONCIERGE_THREAD, approvers)
|
|
1080
|
+
|
|
1081
|
+
|
|
1082
|
+
def test_the_requester_actor_is_recorded() -> None:
|
|
1083
|
+
record = record_from_interrupt(
|
|
1084
|
+
_interrupt_value(),
|
|
1085
|
+
interrupt_id="i1",
|
|
1086
|
+
thread_id="t1",
|
|
1087
|
+
run_id="r1",
|
|
1088
|
+
requester=ALICE_VIA_CONCIERGE,
|
|
1089
|
+
)
|
|
1090
|
+
assert record.requester_actor == "concierge"
|
|
1091
|
+
assert record.requester_hash == Principal(id="alice").hashed_id()
|
|
1092
|
+
assert record.requester_context["attributes"]["@actor"]["id"] == "concierge"
|
|
1093
|
+
assert record.public()["requester_actor"] == "concierge"
|
|
1094
|
+
assert _record().public()["requester_actor"] is None
|
|
1095
|
+
|
|
1096
|
+
|
|
1097
|
+
def test_the_resumed_run_keeps_the_requesters_actor() -> None:
|
|
1098
|
+
record = record_from_interrupt(
|
|
1099
|
+
_interrupt_value(),
|
|
1100
|
+
interrupt_id="i1",
|
|
1101
|
+
thread_id="t1",
|
|
1102
|
+
run_id="r1",
|
|
1103
|
+
requester=ALICE_VIA_CONCIERGE,
|
|
1104
|
+
)
|
|
1105
|
+
# A role approver's decision: the requester rebuilt, actor included, no credentials.
|
|
1106
|
+
carol = Principal(id="carol", roles=["ops"], attributes={"credentials": {"x": "c"}})
|
|
1107
|
+
acting = resume_principal(record, CONCIERGE_THREAD, carol)
|
|
1108
|
+
assert acting.id == "alice" and acting.actor == CONCIERGE_ACTOR
|
|
1109
|
+
assert "credentials" not in acting.attributes
|
|
1110
|
+
# The person deciding at this agent: their own principal of this request.
|
|
1111
|
+
alice = Principal(id="alice", attributes={"credentials": {"x": "alice's"}})
|
|
1112
|
+
assert resume_principal(record, CONCIERGE_THREAD, alice) is alice
|
|
1113
|
+
# The same owner key: the principal of this request.
|
|
1114
|
+
assert resume_principal(record, CONCIERGE_THREAD, ALICE_VIA_CONCIERGE) == ALICE_VIA_CONCIERGE
|
|
1115
|
+
|
|
1116
|
+
|
|
1117
|
+
# --- the user's words of the request that paused (the origin extension) ----------------------
|
|
1118
|
+
|
|
1119
|
+
ASKED = {"text": "please cancel order 7", "truncated": False, "hops": 1}
|
|
1120
|
+
|
|
1121
|
+
|
|
1122
|
+
def _asked_record(**overrides: Any) -> ApprovalRecord:
|
|
1123
|
+
"""An approval of a call the concierge's request paused, with the user's words it forwarded."""
|
|
1124
|
+
fields = {"interrupt_id": "i1", "thread_id": "t1", "run_id": "r1"}
|
|
1125
|
+
value_overrides = {k: v for k, v in overrides.items() if k not in fields}
|
|
1126
|
+
fields.update({k: v for k, v in overrides.items() if k in fields})
|
|
1127
|
+
return record_from_interrupt(
|
|
1128
|
+
_interrupt_value(**value_overrides), requester=ALICE_VIA_CONCIERGE, origin=ASKED, **fields
|
|
1129
|
+
)
|
|
1130
|
+
|
|
1131
|
+
|
|
1132
|
+
def test_the_users_words_are_kept_with_the_approval_and_never_shown() -> None:
|
|
1133
|
+
record = _asked_record()
|
|
1134
|
+
assert record.payload["origin"] == ASKED
|
|
1135
|
+
shown = json.dumps(
|
|
1136
|
+
[record.public(), approval_view(record), decision_value(record, "approve")], default=str
|
|
1137
|
+
)
|
|
1138
|
+
assert "origin" not in record.public() and ASKED["text"] not in shown
|
|
1139
|
+
# What the approver sees, and so the digest a relayed decision names, is the same.
|
|
1140
|
+
plain = record_from_interrupt(
|
|
1141
|
+
_interrupt_value(),
|
|
1142
|
+
interrupt_id="i1",
|
|
1143
|
+
thread_id="t1",
|
|
1144
|
+
run_id="r1",
|
|
1145
|
+
requester=ALICE_VIA_CONCIERGE,
|
|
1146
|
+
)
|
|
1147
|
+
assert "origin" not in plain.payload and record.display_digest == plain.display_digest
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
def test_the_resumed_run_acts_on_the_words_of_the_request_that_paused() -> None:
|
|
1151
|
+
record = _asked_record()
|
|
1152
|
+
relayed = {"text": "yes, go ahead", "truncated": False, "hops": 1}
|
|
1153
|
+
relayer = Principal(
|
|
1154
|
+
id="alice",
|
|
1155
|
+
attributes={
|
|
1156
|
+
"@actor": CONCIERGE_ACTOR.public(),
|
|
1157
|
+
"credentials": {"@subject_token": "fresh", "@origin": relayed},
|
|
1158
|
+
},
|
|
1159
|
+
actor=CONCIERGE_ACTOR,
|
|
1160
|
+
)
|
|
1161
|
+
# The agent delivering the person's decision: its fresh token, the request's words (not
|
|
1162
|
+
# the words the person said when approving at the agent that asked).
|
|
1163
|
+
acting = resume_principal(record, CONCIERGE_THREAD, relayer, origin=ASKED)
|
|
1164
|
+
assert acting.id == "alice" and acting.actor == CONCIERGE_ACTOR
|
|
1165
|
+
assert acting.attributes["credentials"] == {"@subject_token": "fresh", "@origin": ASKED}
|
|
1166
|
+
assert relayer.attributes["credentials"]["@origin"] == relayed # the decider's own: untouched
|
|
1167
|
+
# No words kept with the approval (the request forwarded none): the decision's are not
|
|
1168
|
+
# taken instead.
|
|
1169
|
+
bare = resume_principal(record, CONCIERGE_THREAD, relayer, origin=None)
|
|
1170
|
+
assert bare.attributes["credentials"] == {"@subject_token": "fresh"}
|
|
1171
|
+
# A role approver: the requester rebuilt, with the request's words and no credential.
|
|
1172
|
+
carol = Principal(id="carol", roles=["ops"], attributes={"credentials": {"x": "c"}})
|
|
1173
|
+
rebuilt = resume_principal(record, CONCIERGE_THREAD, carol, origin=ASKED)
|
|
1174
|
+
assert rebuilt.id == "alice" and rebuilt.actor == CONCIERGE_ACTOR
|
|
1175
|
+
assert rebuilt.attributes["credentials"] == {"@origin": ASKED}
|
|
1176
|
+
assert "credentials" not in resume_principal(record, CONCIERGE_THREAD, carol).attributes
|
|
1177
|
+
# The person deciding at this agent acts with their own words.
|
|
1178
|
+
alice = Principal(id="alice", attributes={"credentials": {"x": "alice's"}})
|
|
1179
|
+
assert resume_principal(record, CONCIERGE_THREAD, alice, origin=ASKED) is alice
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
async def test_the_users_words_go_once_the_approval_is_decided_or_expired(
|
|
1183
|
+
store: ApprovalStore, monkeypatch: pytest.MonkeyPatch
|
|
1184
|
+
) -> None:
|
|
1185
|
+
monkeypatch.setenv("TRACE_CAPTURE", "full") # the call is kept; the words never are
|
|
1186
|
+
pending, _, _ = await store.add(_asked_record())
|
|
1187
|
+
assert (await store.get(pending.approval_id)).payload["origin"] == ASKED
|
|
1188
|
+
decided = await store.decide(pending.approval_id, APPROVED, "x", None)
|
|
1189
|
+
assert decided is not None and "origin" not in decided.payload
|
|
1190
|
+
assert decided.payload["body"] == {"reason": "asked"}
|
|
1191
|
+
assert "origin" not in (await store.get(pending.approval_id)).payload
|
|
1192
|
+
if store.path is not None:
|
|
1193
|
+
assert ASKED["text"] not in store.path.read_text(encoding="utf-8")
|
|
1194
|
+
# Expired: one the run no longer waits for, one past its time, one superseded.
|
|
1195
|
+
gone, _, _ = await store.add(_asked_record(interrupt_id="i2"))
|
|
1196
|
+
await store.expire(gone.approval_id)
|
|
1197
|
+
first, _, _ = await store.add(_asked_record(interrupt_id="i3"))
|
|
1198
|
+
later, _, _ = await store.add(_asked_record(interrupt_id="i3", call_hash="h2"))
|
|
1199
|
+
store._clock = lambda: later.expires_at + timedelta(seconds=1)
|
|
1200
|
+
assert [r.approval_id for r in await store.expire_due()] == [later.approval_id]
|
|
1201
|
+
for record in (gone, first, later):
|
|
1202
|
+
kept = await store.get(record.approval_id)
|
|
1203
|
+
assert kept.status == EXPIRED and "origin" not in kept.payload, kept
|
|
1204
|
+
if store.path is not None:
|
|
1205
|
+
assert ASKED["text"] not in store.path.read_text(encoding="utf-8")
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
@pytest.mark.parametrize(("runtime", "kept"), [("fastapi", True), ("langgraph-server", False)])
|
|
1209
|
+
async def test_the_words_are_kept_only_where_the_resumed_run_can_use_them(
|
|
1210
|
+
runtime: str, kept: bool
|
|
1211
|
+
) -> None:
|
|
1212
|
+
"""LangGraph Server never passes credentials (the words among them) to tools."""
|
|
1213
|
+
from {{cookiecutter.agent_directory}}.app_utils import chat
|
|
1214
|
+
|
|
1215
|
+
rt = chat.ChatRuntime()
|
|
1216
|
+
rt.runtime = runtime
|
|
1217
|
+
rt.approvals = ApprovalStore(Database("memory"))
|
|
1218
|
+
asking = Principal(
|
|
1219
|
+
id="alice",
|
|
1220
|
+
attributes={"@actor": CONCIERGE_ACTOR.public(), "credentials": {"@origin": ASKED}},
|
|
1221
|
+
actor=CONCIERGE_ACTOR,
|
|
1222
|
+
)
|
|
1223
|
+
[record] = await rt._record_approvals(
|
|
1224
|
+
asking, "t1", "r1", [{"id": "i1", "value": _interrupt_value()}]
|
|
1225
|
+
)
|
|
1226
|
+
assert (record.payload.get("origin") == ASKED) is kept
|
|
1227
|
+
assert ("origin" in record.payload) is kept
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
async def test_listing_follows_the_owner_key(store: ApprovalStore) -> None:
|
|
1231
|
+
by_agent = record_from_interrupt(
|
|
1232
|
+
_interrupt_value(),
|
|
1233
|
+
interrupt_id="i1",
|
|
1234
|
+
thread_id="t1",
|
|
1235
|
+
run_id="r1",
|
|
1236
|
+
requester=ALICE_VIA_CONCIERGE,
|
|
1237
|
+
)
|
|
1238
|
+
by_person, _, _ = await store.add(_record(interrupt_id="i2", thread_id="t2"))
|
|
1239
|
+
await store.add(by_agent)
|
|
1240
|
+
everything = {by_agent.approval_id, by_person.approval_id}
|
|
1241
|
+
assert {r.approval_id for r in await store.visible(ALICE)} == everything
|
|
1242
|
+
assert [r.approval_id for r in await store.visible(ALICE_VIA_CONCIERGE)] == [
|
|
1243
|
+
by_agent.approval_id
|
|
1244
|
+
]
|
|
1245
|
+
assert await store.visible(ALICE_VIA_BILLING) == []
|
|
1246
|
+
# A delegated principal's roles list nothing a role of theirs could decide.
|
|
1247
|
+
ops_agent = Principal(id="carol", roles=["ops"], actor=CONCIERGE_ACTOR)
|
|
1248
|
+
assert await store.visible(ops_agent) == []
|
|
1249
|
+
reloaded = await store.get(by_agent.approval_id)
|
|
1250
|
+
assert reloaded is not None and reloaded.requester_actor == "concierge"
|
|
1251
|
+
|
|
1252
|
+
|
|
1253
|
+
# --- relayed decisions (decide_with: relayed, relayers) ----------------------------------------
|
|
1254
|
+
|
|
1255
|
+
RELAYED = {"decide_with": "relayed", "relayers": ["concierge"]}
|
|
1256
|
+
|
|
1257
|
+
|
|
1258
|
+
def _relayed_record(**overrides: Any) -> ApprovalRecord:
|
|
1259
|
+
value = _interrupt_value(approvers=["requester"], **{**RELAYED, **overrides})
|
|
1260
|
+
return record_from_interrupt(
|
|
1261
|
+
value, interrupt_id="i1", thread_id="t1", run_id="r1", requester=ALICE_VIA_CONCIERGE
|
|
1262
|
+
)
|
|
1263
|
+
|
|
1264
|
+
|
|
1265
|
+
def test_relayed_requires_listed_actor_owner_actor_and_digest() -> None:
|
|
1266
|
+
"""The 2.2 table, cell by cell."""
|
|
1267
|
+
record = _relayed_record()
|
|
1268
|
+
rule = {"decide_with": record.decide_with, "relayers": record.relayers}
|
|
1269
|
+
approvers = record.approvers
|
|
1270
|
+
# Direct, the subject (whichever agent started the thread): yes, as under direct.
|
|
1271
|
+
assert decide_refusal(Principal(id="alice"), CONCIERGE_THREAD, approvers, **rule) is None
|
|
1272
|
+
# Direct, another subject holding a listed role: yes (a role gate is always direct).
|
|
1273
|
+
carol = Principal(id="carol", roles=["ops"])
|
|
1274
|
+
assert may_decide(carol, CONCIERGE_THREAD, ["requester", "role:ops"], **rule)
|
|
1275
|
+
# Delegated: all of requester listed, its own thread (subject and actor), a listed relayer.
|
|
1276
|
+
assert decide_refusal(ALICE_VIA_CONCIERGE, CONCIERGE_THREAD, approvers, **rule) is None
|
|
1277
|
+
for principal, thread, why in (
|
|
1278
|
+
(ALICE_VIA_BILLING, CONCIERGE_THREAD, "You may not decide this approval"),
|
|
1279
|
+
(ALICE_VIA_CONCIERGE, DIRECT_THREAD, "You may not decide this approval"),
|
|
1280
|
+
(
|
|
1281
|
+
Principal(id="bob", actor=CONCIERGE_ACTOR),
|
|
1282
|
+
ThreadRecord(thread_id="t1", principal_id="bob", actor="concierge"),
|
|
1283
|
+
None,
|
|
1284
|
+
),
|
|
1285
|
+
):
|
|
1286
|
+
refusal = decide_refusal(principal, thread, approvers, **rule)
|
|
1287
|
+
if why is None: # bob's own thread, but alice's approval rule: listed, so it may
|
|
1288
|
+
assert refusal is None
|
|
1289
|
+
else:
|
|
1290
|
+
assert refusal is not None and refusal[0] == "not_an_approver" and why in refusal[1]
|
|
1291
|
+
unlisted = {"decide_with": "relayed", "relayers": ["billing"]}
|
|
1292
|
+
assert decide_refusal(ALICE_VIA_CONCIERGE, CONCIERGE_THREAD, approvers, **unlisted) == (
|
|
1293
|
+
"not_an_approver",
|
|
1294
|
+
"concierge may not relay decisions for this approval.",
|
|
1295
|
+
)
|
|
1296
|
+
# A role-only rule is never relayed (the schema refuses it; fail closed anyway).
|
|
1297
|
+
refusal = decide_refusal(ALICE_VIA_CONCIERGE, CONCIERGE_THREAD, ["role:ops"], **rule)
|
|
1298
|
+
assert refusal is not None and refusal[0] == "approval_direct_only"
|
|
1299
|
+
# The digest: required of a relayer, checked when a direct decider sends one.
|
|
1300
|
+
assert digest_refusal(ALICE_VIA_CONCIERGE, record, record.display_digest) is False
|
|
1301
|
+
assert digest_refusal(ALICE_VIA_CONCIERGE, record, None) is True
|
|
1302
|
+
assert digest_refusal(ALICE_VIA_CONCIERGE, record, "sha256:" + "0" * 64) is True
|
|
1303
|
+
assert digest_refusal(Principal(id="alice"), record, None) is False
|
|
1304
|
+
assert digest_refusal(Principal(id="alice"), record, "sha256:x") is True
|
|
1305
|
+
assert digest_refusal(Principal(id="alice"), record, record.display_digest) is False
|
|
1306
|
+
|
|
1307
|
+
|
|
1308
|
+
def test_the_digest_covers_what_the_approver_sees() -> None:
|
|
1309
|
+
record = _relayed_record()
|
|
1310
|
+
assert record.display_digest == approval_digest(approval_view(record))
|
|
1311
|
+
assert record.display_digest.startswith("sha256:") and len(record.display_digest) == 71
|
|
1312
|
+
public = record.public()
|
|
1313
|
+
assert public["digest"] == record.display_digest
|
|
1314
|
+
assert (public["decide_with"], public["decided_via"]) == ("relayed", None)
|
|
1315
|
+
assert "relayers" not in public
|
|
1316
|
+
other = _relayed_record(body={"reason": "something else"})
|
|
1317
|
+
assert other.display_digest != record.display_digest
|
|
1318
|
+
assert _record().public()["decide_with"] == "direct" # a gate without decide_with
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
def test_how_approvers_decide_is_bound_with_the_approvers() -> None:
|
|
1322
|
+
record = _relayed_record()
|
|
1323
|
+
assert (record.decide_with, record.relayers) == ("relayed", ["concierge"])
|
|
1324
|
+
value = decision_value(record, "approve")
|
|
1325
|
+
assert (value["decide_with"], value["relayers"]) == ("relayed", ["concierge"])
|
|
1326
|
+
# An interrupt from before 0.3 (no decide_with) asks for a direct decision.
|
|
1327
|
+
legacy = record_from_interrupt(
|
|
1328
|
+
{k: v for k, v in _interrupt_value().items() if k not in RELAYED},
|
|
1329
|
+
interrupt_id="i1",
|
|
1330
|
+
thread_id="t1",
|
|
1331
|
+
run_id="r1",
|
|
1332
|
+
requester=ALICE,
|
|
1333
|
+
)
|
|
1334
|
+
assert (legacy.decide_with, legacy.relayers) == ("direct", [])
|
|
1335
|
+
forged = _relayed_record(decide_with="any")
|
|
1336
|
+
assert (forged.decide_with, forged.relayers) == ("direct", [])
|
|
1337
|
+
|
|
1338
|
+
|
|
1339
|
+
async def test_decided_via_is_recorded(store: ApprovalStore) -> None:
|
|
1340
|
+
record, _, _ = await store.add(_relayed_record())
|
|
1341
|
+
decided = await store.decide(
|
|
1342
|
+
record.approval_id, APPROVED, Principal(id="alice").hashed_id(), None, "concierge"
|
|
1343
|
+
)
|
|
1344
|
+
assert decided is not None and decided.decided_via == "concierge"
|
|
1345
|
+
reloaded = await store.get(record.approval_id)
|
|
1346
|
+
assert reloaded is not None and reloaded.decided_via == "concierge"
|
|
1347
|
+
assert reloaded.public()["decided_via"] == "concierge"
|
|
1348
|
+
assert (reloaded.decide_with, reloaded.relayers) == ("relayed", ["concierge"])
|
|
1349
|
+
assert reloaded.display_digest == record.display_digest
|
|
1350
|
+
|
|
1351
|
+
|
|
1352
|
+
async def test_a_version_1_approvals_file_is_read(tmp_path: Path) -> None:
|
|
1353
|
+
path = tmp_path / ".langgraph_api" / "agent_approvals.json"
|
|
1354
|
+
path.parent.mkdir()
|
|
1355
|
+
row = {
|
|
1356
|
+
k: v
|
|
1357
|
+
for k, v in _row_of(_record()).items()
|
|
1358
|
+
if k not in ("requester_actor", "decide_with", "relayers", "decided_via", "display_digest")
|
|
1359
|
+
}
|
|
1360
|
+
path.write_text(json.dumps({"version": 1, "approvals": [row]}), encoding="utf-8")
|
|
1361
|
+
store = ApprovalStore(Database("memory"), path=path)
|
|
1362
|
+
assert await store.load() == 1
|
|
1363
|
+
[record] = await store.for_thread("t1")
|
|
1364
|
+
assert (record.decide_with, record.relayers, record.requester_actor) == ("direct", [], "")
|
|
1365
|
+
assert record.decided_via is None and record.display_digest is None
|
|
1366
|
+
# The next write is a version-2 file.
|
|
1367
|
+
await store.decide(record.approval_id, REJECTED, "x", None)
|
|
1368
|
+
assert json.loads(path.read_text())["version"] == 2
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
async def test_a_gate_that_starts_relaying_does_not_keep_a_direct_approval(
|
|
1372
|
+
graph, store, tmp_path
|
|
1373
|
+
) -> None:
|
|
1374
|
+
interrupt, record = await _pause(graph, "t1", store)
|
|
1375
|
+
assert interrupt.value["decide_with"] == "direct" and interrupt.value["relayers"] == []
|
|
1376
|
+
decided = await _decided(store, record, "approve")
|
|
1377
|
+
relayed = POLICY.replace(
|
|
1378
|
+
" timeout_s: 60",
|
|
1379
|
+
" timeout_s: 60\n decide_with: relayed\n relayers: [concierge]",
|
|
1380
|
+
)
|
|
1381
|
+
_change_policy(tmp_path, relayed)
|
|
1382
|
+
await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "approve")}))
|
|
1383
|
+
result = await _last_tool_result(graph, "t1")
|
|
1384
|
+
assert "decide_with or relayers, differs from the policy's now" in result
|
|
1385
|
+
assert SENT == []
|
|
1386
|
+
|
|
1387
|
+
|
|
1388
|
+
async def test_a_relayed_gate_binds_its_relayers(graph, store, tmp_path) -> None:
|
|
1389
|
+
relayed = POLICY.replace(
|
|
1390
|
+
' approvers: [requester, "role:ops"]',
|
|
1391
|
+
" approvers: [requester]\n decide_with: relayed\n relayers: [concierge]",
|
|
1392
|
+
)
|
|
1393
|
+
_change_policy(tmp_path, relayed)
|
|
1394
|
+
interrupt, record = await _pause(graph, "t1", store)
|
|
1395
|
+
assert (interrupt.value["decide_with"], interrupt.value["relayers"]) == (
|
|
1396
|
+
"relayed",
|
|
1397
|
+
["concierge"],
|
|
1398
|
+
)
|
|
1399
|
+
assert (record.decide_with, record.relayers) == ("relayed", ["concierge"])
|
|
1400
|
+
decided = await _decided(store, record, "approve")
|
|
1401
|
+
# Another relayer by the time the call is sent: the approval does not cover it.
|
|
1402
|
+
_change_policy(tmp_path, relayed.replace("[concierge]", "[billing]"))
|
|
1403
|
+
await _run(graph, "t1", Command(resume={interrupt.id: decision_value(decided, "approve")}))
|
|
1404
|
+
assert "differs from the policy's now" in await _last_tool_result(graph, "t1")
|
|
1405
|
+
assert SENT == []
|
|
1406
|
+
# The same relayers: sent once.
|
|
1407
|
+
_change_policy(tmp_path, relayed)
|
|
1408
|
+
interrupt, record = await _pause(graph, "t2", store)
|
|
1409
|
+
decided = await _decided(store, record, "approve")
|
|
1410
|
+
await _run(graph, "t2", Command(resume={interrupt.id: decision_value(decided, "approve")}))
|
|
1411
|
+
assert [r.url.path for r in SENT] == ["/orders/7/cancel"]
|
|
1412
|
+
|
|
1413
|
+
|
|
1414
|
+
# --- a decision relayed to another agent: `nested` and `effect` (0.3) -------------------------
|
|
1415
|
+
|
|
1416
|
+
|
|
1417
|
+
def _nested(depth: int = 2, expires_at: str = "2099-01-01T00:00:00+00:00") -> dict[str, Any]:
|
|
1418
|
+
"""A relayed approval chain `depth` levels deep (billing relays orders' cancel)."""
|
|
1419
|
+
inner: dict[str, Any] = {
|
|
1420
|
+
"agent": "orders",
|
|
1421
|
+
"approval_id": "o1",
|
|
1422
|
+
"call": {
|
|
1423
|
+
"api": "orders_api",
|
|
1424
|
+
"method": "POST",
|
|
1425
|
+
"path": "/orders/7/cancel",
|
|
1426
|
+
"operation_id": "cancelOrder",
|
|
1427
|
+
"query": {"notify": "yes"},
|
|
1428
|
+
"body": {"card": "4111"},
|
|
1429
|
+
},
|
|
1430
|
+
"reason": "cancel_order: asked",
|
|
1431
|
+
"expires_at": expires_at,
|
|
1432
|
+
"nested": None,
|
|
1433
|
+
}
|
|
1434
|
+
for level in range(depth - 1):
|
|
1435
|
+
inner = {
|
|
1436
|
+
"agent": f"hop{level}",
|
|
1437
|
+
"approval_id": f"h{level}",
|
|
1438
|
+
"call": {"api": "orders_agent", "method": "POST", "path": "/a2a/orders", "body": {}},
|
|
1439
|
+
"reason": "relay",
|
|
1440
|
+
"expires_at": expires_at,
|
|
1441
|
+
"nested": inner,
|
|
1442
|
+
}
|
|
1443
|
+
return inner
|
|
1444
|
+
|
|
1445
|
+
|
|
1446
|
+
def _relayed_value(nested: dict[str, Any]) -> dict[str, Any]:
|
|
1447
|
+
from {{cookiecutter.agent_directory}}.app_utils.api_client import approval_effect
|
|
1448
|
+
|
|
1449
|
+
return _interrupt_value(
|
|
1450
|
+
api="billing_agent",
|
|
1451
|
+
path="/a2a/billing",
|
|
1452
|
+
nested=nested,
|
|
1453
|
+
effect=approval_effect(nested),
|
|
1454
|
+
rpc_method="SendMessage",
|
|
1455
|
+
a2a_operation="approve",
|
|
1456
|
+
)
|
|
1457
|
+
|
|
1458
|
+
|
|
1459
|
+
def test_a_relayed_approval_shows_the_chain_and_the_effect() -> None:
|
|
1460
|
+
record = record_from_interrupt(
|
|
1461
|
+
_relayed_value(_nested()), interrupt_id="i1", thread_id="t1", run_id="r1", requester=ALICE
|
|
1462
|
+
)
|
|
1463
|
+
public = record.public()
|
|
1464
|
+
assert public["effect"]["path"] == "/orders/7/cancel"
|
|
1465
|
+
assert public["effect"]["via"] == ["hop0", "orders"]
|
|
1466
|
+
assert public["nested"]["nested"]["call"]["body"] == {"card": "4111"}
|
|
1467
|
+
# Viewers who do not see the call (read-across roles) do not see the nested calls either.
|
|
1468
|
+
hidden = record.public(include_call=False)
|
|
1469
|
+
assert "body" not in hidden["nested"]["call"]
|
|
1470
|
+
assert "body" not in hidden["nested"]["nested"]["call"]
|
|
1471
|
+
assert "query" not in hidden["nested"]["nested"]["call"]
|
|
1472
|
+
assert "body" not in hidden["effect"] and "query" not in hidden["effect"]
|
|
1473
|
+
assert hidden["effect"]["path"] == "/orders/7/cancel"
|
|
1474
|
+
|
|
1475
|
+
|
|
1476
|
+
def test_a_relayed_approval_expires_before_the_one_it_decides() -> None:
|
|
1477
|
+
"""Never ask the person to approve what has expired where it happens: at most the
|
|
1478
|
+
downstream approval's expiry less 5 s (the rule's own timeout otherwise)."""
|
|
1479
|
+
now = utcnow()
|
|
1480
|
+
soon = (now + timedelta(seconds=40)).isoformat()
|
|
1481
|
+
record = record_from_interrupt(
|
|
1482
|
+
_relayed_value(_nested(expires_at=soon)),
|
|
1483
|
+
interrupt_id="i1",
|
|
1484
|
+
thread_id="t1",
|
|
1485
|
+
run_id="r1",
|
|
1486
|
+
requester=ALICE,
|
|
1487
|
+
now=now,
|
|
1488
|
+
)
|
|
1489
|
+
assert record.expires_at == now + timedelta(seconds=35)
|
|
1490
|
+
later = record_from_interrupt(
|
|
1491
|
+
_relayed_value(_nested(expires_at=(now + timedelta(hours=1)).isoformat())),
|
|
1492
|
+
interrupt_id="i1",
|
|
1493
|
+
thread_id="t1",
|
|
1494
|
+
run_id="r1",
|
|
1495
|
+
requester=ALICE,
|
|
1496
|
+
now=now,
|
|
1497
|
+
)
|
|
1498
|
+
assert later.expires_at == now + timedelta(seconds=60) # the rule's timeout_s
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
async def test_decided_records_drop_the_nested_calls_unless_full_capture(
|
|
1502
|
+
store: ApprovalStore, monkeypatch: pytest.MonkeyPatch
|
|
1503
|
+
) -> None:
|
|
1504
|
+
record, _, _ = await store.add(
|
|
1505
|
+
record_from_interrupt(
|
|
1506
|
+
_relayed_value(_nested(depth=3)),
|
|
1507
|
+
interrupt_id="i1",
|
|
1508
|
+
thread_id="t1",
|
|
1509
|
+
run_id="r1",
|
|
1510
|
+
requester=ALICE,
|
|
1511
|
+
)
|
|
1512
|
+
)
|
|
1513
|
+
decided = await store.decide(record.approval_id, APPROVED, "x", None)
|
|
1514
|
+
assert decided is not None
|
|
1515
|
+
level = decided.payload["nested"]
|
|
1516
|
+
for _ in range(3):
|
|
1517
|
+
assert "body" not in level["call"] and "query" not in level["call"]
|
|
1518
|
+
assert level["call"]["path"] # the rest of the call stays
|
|
1519
|
+
level = level["nested"]
|
|
1520
|
+
assert level is None
|
|
1521
|
+
effect = decided.payload["effect"]
|
|
1522
|
+
assert "body" not in effect and "query" not in effect and effect["path"] == "/orders/7/cancel"
|
|
1523
|
+
assert (await store.get(record.approval_id)).payload == decided.payload
|
|
1524
|
+
monkeypatch.setenv("TRACE_CAPTURE", "full")
|
|
1525
|
+
kept, _, _ = await store.add(
|
|
1526
|
+
record_from_interrupt(
|
|
1527
|
+
_relayed_value(_nested()),
|
|
1528
|
+
interrupt_id="i2",
|
|
1529
|
+
thread_id="t1",
|
|
1530
|
+
run_id="r1",
|
|
1531
|
+
requester=ALICE,
|
|
1532
|
+
)
|
|
1533
|
+
)
|
|
1534
|
+
decided = await store.decide(kept.approval_id, APPROVED, "x", None)
|
|
1535
|
+
assert decided.payload["nested"]["nested"]["call"]["body"] == {"card": "4111"}
|
|
1536
|
+
assert decided.payload["effect"]["body"] == {"card": "4111"}
|