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,1367 @@
|
|
|
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
|
+
"""Human approval of gated API calls through the app, in-process (the fastapi runtime).
|
|
16
|
+
|
|
17
|
+
A test tool cancels an order through `api_client` against an API the test
|
|
18
|
+
policy gates; the upstream API is an `httpx.MockTransport` that records every
|
|
19
|
+
request it receives, so each test can say exactly what was sent. Several
|
|
20
|
+
principals come from a header test policy (`X-User`, `X-Roles`). Every test
|
|
21
|
+
runs with the in-memory checkpointer, and again on Postgres (checkpoints,
|
|
22
|
+
approvals table, run leases) when `TEST_POSTGRES_DSN` is set.
|
|
23
|
+
`tests/integration/test_approvals_server.py` runs the same round trip under
|
|
24
|
+
the langgraph-server runtime against a real LangGraph dev server.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import asyncio
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import uuid
|
|
33
|
+
from collections.abc import AsyncIterator
|
|
34
|
+
from datetime import datetime, timedelta
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
from typing import Any
|
|
37
|
+
from urllib.parse import urlsplit
|
|
38
|
+
|
|
39
|
+
# The environment must be in place before the app (and the graph) is imported.
|
|
40
|
+
os.environ.update(
|
|
41
|
+
{
|
|
42
|
+
"MODEL_PROVIDER": "fake",
|
|
43
|
+
"MODEL_NAME": "fake",
|
|
44
|
+
"CHECKPOINTER": "memory",
|
|
45
|
+
"AUTH_POLICY": "shared-bearer",
|
|
46
|
+
"API_KEY": "test-key",
|
|
47
|
+
"APP_ENV": "dev",
|
|
48
|
+
"TRACING_ENABLED": "false",
|
|
49
|
+
"RUNTIME": "fastapi",
|
|
50
|
+
"APP_URL": "http://testserver",
|
|
51
|
+
}
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
import httpx
|
|
55
|
+
import pytest
|
|
56
|
+
from fastapi import HTTPException
|
|
57
|
+
from langchain.tools import ToolRuntime
|
|
58
|
+
from langchain_core.language_models import BaseChatModel
|
|
59
|
+
from langchain_core.messages import AIMessage, ToolMessage
|
|
60
|
+
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
61
|
+
from langchain_core.tools import tool
|
|
62
|
+
from starlette.requests import Request
|
|
63
|
+
|
|
64
|
+
from {{cookiecutter.agent_directory}}.app_utils import approvals as approvals_module
|
|
65
|
+
from {{cookiecutter.agent_directory}}.app_utils import auth as auth_module
|
|
66
|
+
from {{cookiecutter.agent_directory}}.app_utils.api_client import get_client, reset_policy_cache
|
|
67
|
+
from {{cookiecutter.agent_directory}}.app_utils.auth import ACTIONS, Principal
|
|
68
|
+
from {{cookiecutter.agent_directory}}.app_utils.chat import RUNTIME
|
|
69
|
+
from {{cookiecutter.agent_directory}}.fast_api_app import app
|
|
70
|
+
|
|
71
|
+
A2A_PATH = "/a2a/{{cookiecutter.agent_directory}}"
|
|
72
|
+
PROMPT = "Cancel the order for 7"
|
|
73
|
+
|
|
74
|
+
POLICY = """
|
|
75
|
+
apis:
|
|
76
|
+
shop:
|
|
77
|
+
base_url_env: SHOP_API_BASE_URL
|
|
78
|
+
auth: none
|
|
79
|
+
allowed_methods: [GET, POST]
|
|
80
|
+
approval:
|
|
81
|
+
required_for:
|
|
82
|
+
operations:
|
|
83
|
+
- operationId: cancelOrder
|
|
84
|
+
path: /orders/{order_id}/cancel
|
|
85
|
+
methods: [POST]
|
|
86
|
+
approvers: APPROVERS
|
|
87
|
+
timeout_s: 60
|
|
88
|
+
"""
|
|
89
|
+
|
|
90
|
+
# What the upstream API received, and what the tool will put in the body.
|
|
91
|
+
SENT: list[httpx.Request] = []
|
|
92
|
+
BODY: dict[str, Any] = {"reason": "customer asked"}
|
|
93
|
+
SEEN_BY_TOOL: list[str] = []
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _upstream(request: httpx.Request) -> httpx.Response:
|
|
97
|
+
SENT.append(request)
|
|
98
|
+
return httpx.Response(200, json={"cancelled": request.url.path})
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@tool
|
|
102
|
+
async def cancel_order(order_id: str, runtime: ToolRuntime[Any]) -> str:
|
|
103
|
+
"""Cancel an order by its id."""
|
|
104
|
+
context = getattr(runtime, "context", None)
|
|
105
|
+
SEEN_BY_TOOL.append(str(getattr(context, "principal_id", "")))
|
|
106
|
+
client = get_client("shop", context=context, transport=httpx.MockTransport(_upstream))
|
|
107
|
+
data = await client.post(
|
|
108
|
+
"/orders/{order_id}/cancel",
|
|
109
|
+
operation_id="cancelOrder",
|
|
110
|
+
path_params={"order_id": order_id},
|
|
111
|
+
json_body=dict(BODY),
|
|
112
|
+
redact=["card"],
|
|
113
|
+
)
|
|
114
|
+
return json.dumps(data)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
class HeaderPolicy:
|
|
118
|
+
"""Test policy: `X-User` is the principal id, `X-Roles` its comma-separated roles."""
|
|
119
|
+
|
|
120
|
+
async def authenticate(self, request: Request) -> Principal:
|
|
121
|
+
user = request.headers.get("x-user")
|
|
122
|
+
if not user:
|
|
123
|
+
raise HTTPException(401, "no user", headers={"WWW-Authenticate": "Bearer"})
|
|
124
|
+
roles = [r for r in (request.headers.get("x-roles") or "user").split(",") if r]
|
|
125
|
+
return Principal(id=user, roles=roles, permissions=set(ACTIONS))
|
|
126
|
+
|
|
127
|
+
async def authorize(self, principal: Principal, action: str, resource: str | None) -> None:
|
|
128
|
+
return None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _as(user: str, roles: str = "user") -> dict[str, str]:
|
|
132
|
+
return {"X-User": user, "X-Roles": roles}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def parse_sse(text: str) -> list[tuple[str, dict[str, Any]]]:
|
|
136
|
+
events: list[tuple[str, dict[str, Any]]] = []
|
|
137
|
+
event = None
|
|
138
|
+
for line in text.splitlines():
|
|
139
|
+
if line.startswith("event:"):
|
|
140
|
+
event = line[6:].strip()
|
|
141
|
+
elif line.startswith("data:") and event:
|
|
142
|
+
events.append((event, json.loads(line[5:].strip())))
|
|
143
|
+
event = None
|
|
144
|
+
return events
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def _write_policy(path: Path, approvers: str) -> None:
|
|
148
|
+
path.write_text(POLICY.replace("APPROVERS", approvers), encoding="utf-8")
|
|
149
|
+
reset_policy_cache()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
# What a new image's policy may say by the time a paused call's decision comes
|
|
153
|
+
# (the fixture's policy gates cancelOrder for [requester, "role:ops"]).
|
|
154
|
+
_GATED = POLICY.replace("APPROVERS", '[requester, "role:ops"]')
|
|
155
|
+
POLICY_CHANGES = {
|
|
156
|
+
"gate removed": _GATED.split(" approval:")[0],
|
|
157
|
+
"gate narrowed": _GATED.replace("cancelOrder", "refundOrder").replace(
|
|
158
|
+
"/orders/{order_id}/cancel", "/orders/{order_id}/refund"
|
|
159
|
+
),
|
|
160
|
+
"call denied": _GATED.replace(
|
|
161
|
+
" approval:",
|
|
162
|
+
" denied_operations:\n - operationId: cancelOrder\n path: "
|
|
163
|
+
"/orders/{order_id}/cancel\n approval:",
|
|
164
|
+
),
|
|
165
|
+
"allowed_methods narrowed": _GATED.replace(
|
|
166
|
+
"allowed_methods: [GET, POST]", "allowed_methods: [GET]"
|
|
167
|
+
),
|
|
168
|
+
}
|
|
169
|
+
# The policy refuses before any decision is read; else the decision (or the gate) does.
|
|
170
|
+
REFUSED_BY = {
|
|
171
|
+
"call denied": "denied by denied_operations",
|
|
172
|
+
"allowed_methods narrowed": "is not in allowed_methods",
|
|
173
|
+
}
|
|
174
|
+
DECIDED_BY = {
|
|
175
|
+
"reject": "was not approved: an approver rejected it",
|
|
176
|
+
"expired": "was not approved: the approval request expired",
|
|
177
|
+
"approve": "approved under an approval gate the policy no longer has",
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _change_policy(tmp_path: Path, change: str) -> None:
|
|
182
|
+
(tmp_path / "api-policy.yaml").write_text(POLICY_CHANGES[change], encoding="utf-8")
|
|
183
|
+
reset_policy_cache()
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
ADMIN_DSN = os.environ.get("TEST_POSTGRES_DSN", "")
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
@pytest.fixture(params=["memory", "postgres"])
|
|
190
|
+
async def database(
|
|
191
|
+
request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch
|
|
192
|
+
) -> AsyncIterator[str]:
|
|
193
|
+
"""Every test in memory, and on Postgres (a fresh database) when `TEST_POSTGRES_DSN` is set."""
|
|
194
|
+
if request.param == "memory":
|
|
195
|
+
yield "memory"
|
|
196
|
+
return
|
|
197
|
+
if not ADMIN_DSN:
|
|
198
|
+
pytest.skip("TEST_POSTGRES_DSN is not set")
|
|
199
|
+
import psycopg
|
|
200
|
+
|
|
201
|
+
name = f"gac_test_{uuid.uuid4().hex[:12]}"
|
|
202
|
+
async with await psycopg.AsyncConnection.connect(ADMIN_DSN, autocommit=True) as admin:
|
|
203
|
+
await admin.execute(f'CREATE DATABASE "{name}"')
|
|
204
|
+
monkeypatch.setenv("CHECKPOINTER", "postgres")
|
|
205
|
+
monkeypatch.setenv("POSTGRES_DSN", urlsplit(ADMIN_DSN)._replace(path=f"/{name}").geturl())
|
|
206
|
+
yield "postgres"
|
|
207
|
+
async with await psycopg.AsyncConnection.connect(ADMIN_DSN, autocommit=True) as admin:
|
|
208
|
+
await admin.execute(f'DROP DATABASE IF EXISTS "{name}" WITH (FORCE)')
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
@pytest.fixture
|
|
212
|
+
async def client(
|
|
213
|
+
database: str, tmp_path: Path, monkeypatch: pytest.MonkeyPatch, use_test_tools
|
|
214
|
+
) -> AsyncIterator[httpx.AsyncClient]:
|
|
215
|
+
policy = tmp_path / "api-policy.yaml"
|
|
216
|
+
_write_policy(policy, '[requester, "role:ops"]')
|
|
217
|
+
monkeypatch.setenv("API_POLICY_PATH", str(policy))
|
|
218
|
+
monkeypatch.setenv("SHOP_API_BASE_URL", "http://shop.test")
|
|
219
|
+
monkeypatch.delenv("AUTH_READ_ACROSS_ROLES", raising=False)
|
|
220
|
+
monkeypatch.delenv("TRACE_CAPTURE", raising=False)
|
|
221
|
+
monkeypatch.setattr(auth_module, "get_policy", lambda: HeaderPolicy())
|
|
222
|
+
SENT.clear()
|
|
223
|
+
SEEN_BY_TOOL.clear()
|
|
224
|
+
BODY.clear()
|
|
225
|
+
BODY.update({"reason": "customer asked", "card": "4111-1111"})
|
|
226
|
+
async with app.router.lifespan_context(app):
|
|
227
|
+
use_test_tools(cancel_order)
|
|
228
|
+
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
|
229
|
+
async with httpx.AsyncClient(
|
|
230
|
+
transport=transport, base_url="http://testserver", timeout=30
|
|
231
|
+
) as c:
|
|
232
|
+
started = asyncio.get_running_loop().time()
|
|
233
|
+
while (await c.get("/ready")).status_code != 200: # postgres: the schema set up
|
|
234
|
+
assert asyncio.get_running_loop().time() - started < 30, "not ready"
|
|
235
|
+
await asyncio.sleep(0.1)
|
|
236
|
+
yield c
|
|
237
|
+
reset_policy_cache()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
async def _pause(
|
|
241
|
+
client: httpx.AsyncClient, user: str = "alice", message: str = PROMPT
|
|
242
|
+
) -> dict[str, Any]:
|
|
243
|
+
"""Start a run that pauses before the gated call; the `message.end` event."""
|
|
244
|
+
r = await client.post("/chat", json={"message": message}, headers=_as(user))
|
|
245
|
+
assert r.status_code == 200, r.text
|
|
246
|
+
events = parse_sse(r.text)
|
|
247
|
+
names = [e for e, _ in events]
|
|
248
|
+
assert names[0] == "message.start" and names[-1] == "message.end", names
|
|
249
|
+
assert "tool.call" in names and "tool.result" not in names
|
|
250
|
+
return events[-1][1]
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
async def _decide(
|
|
254
|
+
client: httpx.AsyncClient,
|
|
255
|
+
end: dict[str, Any],
|
|
256
|
+
decision: str,
|
|
257
|
+
user: str = "alice",
|
|
258
|
+
roles: str = "user",
|
|
259
|
+
comment: str | None = None,
|
|
260
|
+
) -> httpx.Response:
|
|
261
|
+
approval = end["approval"]
|
|
262
|
+
body: dict[str, Any] = {"decision": decision}
|
|
263
|
+
if comment is not None:
|
|
264
|
+
body["comment"] = comment
|
|
265
|
+
return await client.post(
|
|
266
|
+
f"/threads/{end['thread_id']}/approvals/{approval['approval_id']}",
|
|
267
|
+
json=body,
|
|
268
|
+
headers=_as(user, roles),
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# --- pause, approve, send exactly once -----------------------------------------------
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
async def test_a_gated_call_pauses_the_run_and_sends_nothing(client) -> None:
|
|
276
|
+
end = await _pause(client)
|
|
277
|
+
assert end["status"] == "awaiting_approval"
|
|
278
|
+
approval = end["approval"]
|
|
279
|
+
assert end["approvals"] == [approval]
|
|
280
|
+
assert approval["status"] == "pending"
|
|
281
|
+
assert (approval["api"], approval["method"], approval["path"]) == (
|
|
282
|
+
"shop",
|
|
283
|
+
"POST",
|
|
284
|
+
"/orders/7/cancel",
|
|
285
|
+
)
|
|
286
|
+
assert approval["operation_id"] == "cancelOrder"
|
|
287
|
+
# The approver sees the body, with the field the tool named in `redact=` masked.
|
|
288
|
+
assert approval["body"] == {"reason": "customer asked", "card": "<redacted>"}
|
|
289
|
+
assert approval["query"] == {}
|
|
290
|
+
assert approval["tool"] == "cancel_order" and approval["reason"].startswith("cancel_order")
|
|
291
|
+
assert approval["approvers"] == ["requester", "role:ops"]
|
|
292
|
+
assert approval["expires_at"] > approval["created_at"]
|
|
293
|
+
assert approval["requester"] == Principal(id="alice").hashed_id()
|
|
294
|
+
# Internals stay in the server.
|
|
295
|
+
assert "call_hash" not in approval and "interrupt_id" not in approval
|
|
296
|
+
assert SENT == []
|
|
297
|
+
listed = await client.get(f"/threads/{end['thread_id']}/approvals", headers=_as("alice"))
|
|
298
|
+
assert listed.status_code == 200
|
|
299
|
+
assert [a["approval_id"] for a in listed.json()] == [approval["approval_id"]]
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
async def test_approve_sends_exactly_the_approved_request_once(client) -> None:
|
|
303
|
+
end = await _pause(client)
|
|
304
|
+
r = await _decide(client, end, "approve", comment="looks right")
|
|
305
|
+
assert r.status_code == 200, r.text
|
|
306
|
+
events = parse_sse(r.text)
|
|
307
|
+
names = [e for e, _ in events]
|
|
308
|
+
assert names[0] == "message.start" and names[-1] == "message.end", names
|
|
309
|
+
start = events[0][1]
|
|
310
|
+
assert start["approval_id"] == end["approval"]["approval_id"]
|
|
311
|
+
assert start["decision"] == "approve" and start["run_id"] != end["run_id"]
|
|
312
|
+
result = next(d for e, d in events if e == "tool.result")
|
|
313
|
+
assert result["is_error"] is False and "/orders/7/cancel" in result["result"]
|
|
314
|
+
assert events[-1][1]["status"] == "ok"
|
|
315
|
+
assert len(SENT) == 1
|
|
316
|
+
sent = SENT[0]
|
|
317
|
+
assert (sent.method, sent.url.path) == ("POST", "/orders/7/cancel")
|
|
318
|
+
# The masked field was masked for the approver only: the request is the tool's.
|
|
319
|
+
assert json.loads(sent.content) == {"reason": "customer asked", "card": "4111-1111"}
|
|
320
|
+
# The resumed run acted as the requester.
|
|
321
|
+
assert SEEN_BY_TOOL == ["alice", "alice"]
|
|
322
|
+
approvals = (
|
|
323
|
+
await client.get(f"/threads/{end['thread_id']}/approvals", headers=_as("alice"))
|
|
324
|
+
).json()
|
|
325
|
+
assert approvals[0]["status"] == "approved"
|
|
326
|
+
assert approvals[0]["decided_by"] == Principal(id="alice").hashed_id()
|
|
327
|
+
assert approvals[0]["comment"] == "looks right"
|
|
328
|
+
# Decided: the call's content is no longer kept (TRACE_CAPTURE=metadata).
|
|
329
|
+
assert "body" not in approvals[0] and "query" not in approvals[0]
|
|
330
|
+
# Single use: deciding again is a conflict, and nothing more is sent.
|
|
331
|
+
again = await _decide(client, end, "approve")
|
|
332
|
+
assert again.status_code == 409 and again.json()["code"] == "approval_not_pending"
|
|
333
|
+
assert again.json()["status"] == "approved"
|
|
334
|
+
assert len(SENT) == 1
|
|
335
|
+
# The thread takes new messages again.
|
|
336
|
+
r = await client.post(
|
|
337
|
+
"/chat", json={"message": "hello", "thread_id": end["thread_id"]}, headers=_as("alice")
|
|
338
|
+
)
|
|
339
|
+
assert r.status_code == 200 and parse_sse(r.text)[-1][1]["status"] == "ok"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
async def test_reject_sends_nothing_and_the_model_hears_why(client) -> None:
|
|
343
|
+
end = await _pause(client)
|
|
344
|
+
r = await _decide(client, end, "reject", comment="wrong customer")
|
|
345
|
+
assert r.status_code == 200, r.text
|
|
346
|
+
events = parse_sse(r.text)
|
|
347
|
+
result = next(d for e, d in events if e == "tool.result")
|
|
348
|
+
assert result["is_error"] is True
|
|
349
|
+
assert events[-1][1]["status"] == "ok"
|
|
350
|
+
assert SENT == []
|
|
351
|
+
# The model read the refusal (the history keeps the tool's error text).
|
|
352
|
+
from {{cookiecutter.agent_directory}}.agent import graph
|
|
353
|
+
|
|
354
|
+
state = await graph.aget_state({"configurable": {"thread_id": end["thread_id"]}})
|
|
355
|
+
tool_message = next(m for m in state.values["messages"] if m.type == "tool")
|
|
356
|
+
assert "was not approved: an approver rejected it" in tool_message.content
|
|
357
|
+
assert "wrong customer" in tool_message.content and "nothing was sent" in tool_message.content
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
async def test_an_expired_approval_is_410_sends_nothing_and_frees_the_thread(
|
|
361
|
+
client, monkeypatch
|
|
362
|
+
) -> None:
|
|
363
|
+
end = await _pause(client)
|
|
364
|
+
later = approvals_module.utcnow() + timedelta(seconds=61)
|
|
365
|
+
assert RUNTIME.approvals is not None
|
|
366
|
+
monkeypatch.setattr(RUNTIME.approvals, "_clock", lambda: later)
|
|
367
|
+
r = await _decide(client, end, "approve")
|
|
368
|
+
assert r.status_code == 410 and r.json()["code"] == "approval_expired"
|
|
369
|
+
listed = await client.get(f"/threads/{end['thread_id']}/approvals", headers=_as("alice"))
|
|
370
|
+
assert listed.json()[0]["status"] == "expired"
|
|
371
|
+
# Expired = rejected: the thread takes a new message, and the paused call's
|
|
372
|
+
# result says it was not approved.
|
|
373
|
+
r = await client.post(
|
|
374
|
+
"/chat", json={"message": "hello", "thread_id": end["thread_id"]}, headers=_as("alice")
|
|
375
|
+
)
|
|
376
|
+
assert r.status_code == 200 and parse_sse(r.text)[-1][1]["status"] == "ok"
|
|
377
|
+
messages = (
|
|
378
|
+
await client.get(f"/threads/{end['thread_id']}/messages", headers=_as("alice"))
|
|
379
|
+
).json()
|
|
380
|
+
from {{cookiecutter.agent_directory}}.agent import graph
|
|
381
|
+
|
|
382
|
+
state = await graph.aget_state({"configurable": {"thread_id": end["thread_id"]}})
|
|
383
|
+
tool_message = next(m for m in state.values["messages"] if m.type == "tool")
|
|
384
|
+
assert "approval request expired" in tool_message.content
|
|
385
|
+
assert [m["role"] for m in messages] == ["user", "assistant", "tool", "user", "assistant"]
|
|
386
|
+
assert SENT == []
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
async def test_a_request_changed_after_approval_is_refused(client) -> None:
|
|
390
|
+
end = await _pause(client)
|
|
391
|
+
BODY["reason"] = "something else" # the tool now builds a different request
|
|
392
|
+
r = await _decide(client, end, "approve")
|
|
393
|
+
assert r.status_code == 200, r.text
|
|
394
|
+
result = next(d for e, d in parse_sse(r.text) if e == "tool.result")
|
|
395
|
+
assert result["is_error"] is True
|
|
396
|
+
assert SENT == []
|
|
397
|
+
from {{cookiecutter.agent_directory}}.agent import graph
|
|
398
|
+
|
|
399
|
+
state = await graph.aget_state({"configurable": {"thread_id": end["thread_id"]}})
|
|
400
|
+
tool_message = next(m for m in state.values["messages"] if m.type == "tool")
|
|
401
|
+
assert "differs from the request that was approved" in tool_message.content
|
|
402
|
+
# The approval stays unused: it can never send the changed request.
|
|
403
|
+
record = await RUNTIME.approvals.get(end["approval"]["approval_id"])
|
|
404
|
+
assert record is not None and record.status == "approved" and record.used_at is None
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
async def test_an_approval_asked_of_other_approvers_than_the_policy_names_now_sends_nothing(
|
|
408
|
+
client, tmp_path
|
|
409
|
+
) -> None:
|
|
410
|
+
end = await _pause(client)
|
|
411
|
+
assert end["approval"]["approvers"] == ["requester", "role:ops"]
|
|
412
|
+
# A new image while the call waits: its policy asks for four eyes.
|
|
413
|
+
_write_policy(tmp_path / "api-policy.yaml", '["role:ops"]')
|
|
414
|
+
r = await _decide(client, end, "approve") # allowed by the approvers it was asked of
|
|
415
|
+
assert r.status_code == 200, r.text
|
|
416
|
+
result = next(d for e, d in parse_sse(r.text) if e == "tool.result")
|
|
417
|
+
assert result["is_error"] is True
|
|
418
|
+
assert SENT == []
|
|
419
|
+
from {{cookiecutter.agent_directory}}.agent import graph
|
|
420
|
+
|
|
421
|
+
state = await graph.aget_state({"configurable": {"thread_id": end["thread_id"]}})
|
|
422
|
+
tool_message = next(m for m in state.values["messages"] if m.type == "tool")
|
|
423
|
+
assert "approval gate that has changed" in tool_message.content
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
@pytest.mark.parametrize("change", list(POLICY_CHANGES))
|
|
427
|
+
@pytest.mark.parametrize("decision", ["reject", "approve"])
|
|
428
|
+
async def test_a_decision_binds_its_call_whatever_the_policy_says_by_then(
|
|
429
|
+
client, tmp_path, change: str, decision: str
|
|
430
|
+
) -> None:
|
|
431
|
+
"""Reject never sends, approve never outruns a later denial or narrowing."""
|
|
432
|
+
end = await _pause(client)
|
|
433
|
+
_change_policy(tmp_path, change) # a new image while the call waits
|
|
434
|
+
r = await _decide(client, end, decision)
|
|
435
|
+
assert r.status_code == 200, r.text
|
|
436
|
+
events = parse_sse(r.text)
|
|
437
|
+
result = next(d for e, d in events if e == "tool.result")
|
|
438
|
+
assert result["is_error"] is True
|
|
439
|
+
assert REFUSED_BY.get(change, DECIDED_BY[decision]) in result["result"], result
|
|
440
|
+
assert events[-1][1]["status"] == "ok"
|
|
441
|
+
assert SENT == []
|
|
442
|
+
record = await RUNTIME.approvals.get(end["approval"]["approval_id"])
|
|
443
|
+
assert record is not None and record.used_at is None
|
|
444
|
+
# The thread goes on, and nothing is sent later either.
|
|
445
|
+
r = await client.post(
|
|
446
|
+
"/chat", json={"message": "hello", "thread_id": end["thread_id"]}, headers=_as("alice")
|
|
447
|
+
)
|
|
448
|
+
assert r.status_code == 200 and parse_sse(r.text)[-1][1]["status"] == "ok"
|
|
449
|
+
assert SENT == []
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
async def _paused_checkpoint(thread: str) -> dict[str, Any]:
|
|
453
|
+
"""The config of the checkpoint the thread's run paused at (its latest, just after)."""
|
|
454
|
+
from {{cookiecutter.agent_directory}} import agent
|
|
455
|
+
|
|
456
|
+
state = await agent.graph.aget_state({"configurable": {"thread_id": thread}})
|
|
457
|
+
assert state.interrupts
|
|
458
|
+
return dict(state.config)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
async def _run_paused_step_again(thread: str, paused: dict[str, Any]) -> str:
|
|
462
|
+
"""What LangGraph Server's own API offers, on the local graph: continue the thread
|
|
463
|
+
without input, then replay the paused step from its checkpoint; the last tool result."""
|
|
464
|
+
from {{cookiecutter.agent_directory}} import agent
|
|
465
|
+
|
|
466
|
+
context = agent.AgentContext(principal_id="alice", roles=["user"])
|
|
467
|
+
latest = {"configurable": {"thread_id": thread}}
|
|
468
|
+
lease = await RUNTIME.acquire_thread(thread) # the checkpoint writes need the thread's lock
|
|
469
|
+
try:
|
|
470
|
+
await agent.graph.ainvoke(None, latest, context=context)
|
|
471
|
+
await agent.graph.ainvoke(None, paused, context=context)
|
|
472
|
+
finally:
|
|
473
|
+
await lease.release()
|
|
474
|
+
state = await agent.graph.aget_state(latest)
|
|
475
|
+
return next(m.content for m in reversed(state.values["messages"]) if m.type == "tool")
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
BOUND_BY = {
|
|
479
|
+
"reject": "was not approved: an approver rejected it",
|
|
480
|
+
"expired": "was not approved: the approval request expired",
|
|
481
|
+
"approve": "was sent already with its approval, which is used once",
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
@pytest.mark.parametrize("decision", ["reject", "expired", "approve"])
|
|
486
|
+
async def test_a_tool_call_run_again_without_its_decision_sends_nothing_more(
|
|
487
|
+
client, tmp_path, decision: str
|
|
488
|
+
) -> None:
|
|
489
|
+
"""The ledger binds a decision to its tool call, whatever the policy says by then."""
|
|
490
|
+
end = await _pause(client)
|
|
491
|
+
thread = end["thread_id"]
|
|
492
|
+
paused = await _paused_checkpoint(thread)
|
|
493
|
+
if decision == "expired":
|
|
494
|
+
await _expire_now(end["approval"]["approval_id"])
|
|
495
|
+
else:
|
|
496
|
+
r = await _decide(client, end, decision)
|
|
497
|
+
assert r.status_code == 200, r.text
|
|
498
|
+
sent = [req.url.path for req in SENT]
|
|
499
|
+
assert sent == (["/orders/7/cancel"] if decision == "approve" else [])
|
|
500
|
+
_change_policy(tmp_path, "gate removed")
|
|
501
|
+
assert BOUND_BY[decision] in await _run_paused_step_again(thread, paused)
|
|
502
|
+
assert [req.url.path for req in SENT] == sent
|
|
503
|
+
|
|
504
|
+
|
|
505
|
+
async def test_a_thread_with_approvals_refuses_a_replay_and_a_copy(client) -> None:
|
|
506
|
+
"""What the langgraph-server auth hook asks before a run without input or from a
|
|
507
|
+
checkpoint, and before a copy."""
|
|
508
|
+
end = await _pause(client)
|
|
509
|
+
for refusal in (RUNTIME.replay_refusal, RUNTIME.copy_refusal):
|
|
510
|
+
assert await refusal(end["thread_id"]) == "the thread has approvals of gated API calls"
|
|
511
|
+
r = await client.post("/chat", json={"message": "hello"}, headers=_as("alice"))
|
|
512
|
+
assert r.status_code == 200, r.text
|
|
513
|
+
thread = parse_sse(r.text)[-1][1]["thread_id"]
|
|
514
|
+
assert await RUNTIME.replay_refusal(thread) is None
|
|
515
|
+
assert await RUNTIME.copy_refusal(thread) is None
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
async def test_approvals_a_failed_run_recorded_do_not_block_the_thread(client, monkeypatch) -> None:
|
|
519
|
+
record = RUNTIME._record_approvals
|
|
520
|
+
|
|
521
|
+
async def record_then_fail(*args: Any, **kwargs: Any) -> Any:
|
|
522
|
+
await record(*args, **kwargs)
|
|
523
|
+
raise RuntimeError("the database went away")
|
|
524
|
+
|
|
525
|
+
monkeypatch.setattr(RUNTIME, "_record_approvals", record_then_fail)
|
|
526
|
+
r = await client.post("/chat", json={"message": PROMPT}, headers=_as("alice"))
|
|
527
|
+
events = parse_sse(r.text)
|
|
528
|
+
assert events[-1][0] == "error", events
|
|
529
|
+
thread = events[0][1]["thread_id"]
|
|
530
|
+
monkeypatch.setattr(RUNTIME, "_record_approvals", record)
|
|
531
|
+
# Nobody was told about the approval: it is expired, not left pending.
|
|
532
|
+
listed = (await client.get(f"/threads/{thread}/approvals", headers=_as("alice"))).json()
|
|
533
|
+
assert [a["status"] for a in listed] == ["expired"]
|
|
534
|
+
r = await client.post(
|
|
535
|
+
"/chat", json={"message": "hello", "thread_id": thread}, headers=_as("alice")
|
|
536
|
+
)
|
|
537
|
+
assert r.status_code == 200 and parse_sse(r.text)[-1][1]["status"] == "ok"
|
|
538
|
+
assert SENT == []
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
# --- who decides ---------------------------------------------------------------------
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
async def test_only_an_approver_decides(client) -> None:
|
|
545
|
+
end = await _pause(client)
|
|
546
|
+
for user, roles in (("bob", "user"), ("eve", "admin,support")):
|
|
547
|
+
r = await _decide(client, end, "approve", user=user, roles=roles)
|
|
548
|
+
assert r.status_code == 403 and r.json()["code"] == "not_an_approver", (user, r.text)
|
|
549
|
+
# A principal with a listed role decides (four eyes), and the run still acts
|
|
550
|
+
# as the requester.
|
|
551
|
+
r = await _decide(client, end, "approve", user="carol", roles="ops")
|
|
552
|
+
assert r.status_code == 200, r.text
|
|
553
|
+
assert len(SENT) == 1 and SEEN_BY_TOOL == ["alice", "alice"]
|
|
554
|
+
approvals = (
|
|
555
|
+
await client.get(f"/threads/{end['thread_id']}/approvals", headers=_as("alice"))
|
|
556
|
+
).json()
|
|
557
|
+
assert approvals[0]["decided_by"] == Principal(id="carol").hashed_id()
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
async def test_four_eyes_the_requester_cannot_approve_their_own_call(client, tmp_path) -> None:
|
|
561
|
+
_write_policy(tmp_path / "api-policy.yaml", '["role:ops"]')
|
|
562
|
+
end = await _pause(client, user="alice")
|
|
563
|
+
assert end["approval"]["approvers"] == ["role:ops"]
|
|
564
|
+
# Holding the role does not make the requester a second pair of eyes.
|
|
565
|
+
r = await _decide(client, end, "approve", user="alice", roles="ops")
|
|
566
|
+
assert r.status_code == 403
|
|
567
|
+
r = await _decide(client, end, "approve", user="carol", roles="ops")
|
|
568
|
+
assert r.status_code == 200 and len(SENT) == 1
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
# One API, other approvers for other calls: the requester confirms updates and
|
|
572
|
+
# cancellations, and a second person holding role:admin approves new orders.
|
|
573
|
+
RULES_POLICY = """
|
|
574
|
+
apis:
|
|
575
|
+
shop:
|
|
576
|
+
base_url_env: SHOP_API_BASE_URL
|
|
577
|
+
auth: none
|
|
578
|
+
allowed_methods: [GET, POST, PATCH]
|
|
579
|
+
approval:
|
|
580
|
+
- required_for:
|
|
581
|
+
operations:
|
|
582
|
+
- {operationId: updateOrder, path: "/orders/{order_id}", methods: [PATCH]}
|
|
583
|
+
- {operationId: cancelOrder, path: "/orders/{order_id}/cancel", methods: [POST]}
|
|
584
|
+
approvers: [requester]
|
|
585
|
+
- required_for:
|
|
586
|
+
operations:
|
|
587
|
+
- {operationId: createOrder, path: /orders, methods: [POST]}
|
|
588
|
+
approvers: ["role:admin"]
|
|
589
|
+
timeout_s: 3600
|
|
590
|
+
"""
|
|
591
|
+
|
|
592
|
+
|
|
593
|
+
@tool
|
|
594
|
+
async def place_order(item: str, runtime: ToolRuntime[Any]) -> str:
|
|
595
|
+
"""Place a new purchase of an item."""
|
|
596
|
+
context = getattr(runtime, "context", None)
|
|
597
|
+
SEEN_BY_TOOL.append(str(getattr(context, "principal_id", "")))
|
|
598
|
+
client = get_client("shop", context=context, transport=httpx.MockTransport(_upstream))
|
|
599
|
+
return json.dumps(
|
|
600
|
+
await client.post("/orders", operation_id="createOrder", json_body={"item": item})
|
|
601
|
+
)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
@tool
|
|
605
|
+
async def amend_order(order_id: str, runtime: ToolRuntime[Any]) -> str:
|
|
606
|
+
"""Amend an existing purchase with a gift note."""
|
|
607
|
+
context = getattr(runtime, "context", None)
|
|
608
|
+
client = get_client("shop", context=context, transport=httpx.MockTransport(_upstream))
|
|
609
|
+
data = await client.patch(
|
|
610
|
+
"/orders/{order_id}",
|
|
611
|
+
operation_id="updateOrder",
|
|
612
|
+
path_params={"order_id": order_id},
|
|
613
|
+
json_body={"note": "gift"},
|
|
614
|
+
)
|
|
615
|
+
return json.dumps(data)
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
async def test_each_call_waits_for_the_approvers_of_the_rule_that_gates_it(
|
|
619
|
+
client, tmp_path, use_test_tools
|
|
620
|
+
) -> None:
|
|
621
|
+
(tmp_path / "api-policy.yaml").write_text(RULES_POLICY, encoding="utf-8")
|
|
622
|
+
reset_policy_cache()
|
|
623
|
+
use_test_tools(cancel_order, place_order, amend_order)
|
|
624
|
+
admin = ("root", "admin")
|
|
625
|
+
|
|
626
|
+
# An update: the requester's rule. Neither another user nor an admin decides it.
|
|
627
|
+
end = await _pause(client, message="Amend the gift note for 7")
|
|
628
|
+
approval = end["approval"]
|
|
629
|
+
assert (approval["operation_id"], approval["approvers"]) == ("updateOrder", ["requester"])
|
|
630
|
+
for user, roles in (("bob", "user"), admin):
|
|
631
|
+
r = await _decide(client, end, "approve", user=user, roles=roles)
|
|
632
|
+
assert r.status_code == 403 and r.json()["code"] == "not_an_approver", (user, r.text)
|
|
633
|
+
assert SENT == []
|
|
634
|
+
r = await _decide(client, end, "approve")
|
|
635
|
+
assert r.status_code == 200, r.text
|
|
636
|
+
assert [(s.method, s.url.path) for s in SENT] == [("PATCH", "/orders/7")]
|
|
637
|
+
|
|
638
|
+
# A cancellation: the requester's rule too; an admin may not decide it either.
|
|
639
|
+
end = await _pause(client)
|
|
640
|
+
assert end["approval"]["approvers"] == ["requester"]
|
|
641
|
+
r = await _decide(client, end, "reject", user=admin[0], roles=admin[1])
|
|
642
|
+
assert r.status_code == 403
|
|
643
|
+
r = await _decide(client, end, "reject", comment="changed my mind")
|
|
644
|
+
assert r.status_code == 200 and len(SENT) == 1
|
|
645
|
+
|
|
646
|
+
# A new order: role:admin's rule, with its own expiry. The requester may not approve
|
|
647
|
+
# it, even holding the role (four eyes); another admin may.
|
|
648
|
+
end = await _pause(client, message="Place a new one for WIDGET-1")
|
|
649
|
+
approval = end["approval"]
|
|
650
|
+
assert (approval["operation_id"], approval["approvers"]) == ("createOrder", ["role:admin"])
|
|
651
|
+
assert approval["body"] == {"item": "WIDGET-1"}
|
|
652
|
+
lifetime = datetime.fromisoformat(approval["expires_at"]) - datetime.fromisoformat(
|
|
653
|
+
approval["created_at"]
|
|
654
|
+
)
|
|
655
|
+
assert lifetime == timedelta(seconds=3600)
|
|
656
|
+
for user, roles in (("alice", "user"), ("alice", "admin"), ("bob", "ops")):
|
|
657
|
+
r = await _decide(client, end, "approve", user=user, roles=roles)
|
|
658
|
+
assert r.status_code == 403, (user, roles, r.text)
|
|
659
|
+
listed = await client.get("/approvals?status=pending", headers=_as(*admin))
|
|
660
|
+
assert approval["approval_id"] in [a["approval_id"] for a in listed.json()]
|
|
661
|
+
r = await _decide(client, end, "approve", user=admin[0], roles=admin[1])
|
|
662
|
+
assert r.status_code == 200, r.text
|
|
663
|
+
assert [(s.method, s.url.path) for s in SENT] == [("PATCH", "/orders/7"), ("POST", "/orders")]
|
|
664
|
+
assert json.loads(SENT[-1].content) == {"item": "WIDGET-1"}
|
|
665
|
+
assert SEEN_BY_TOOL[-1] == "alice" # the resumed run still acts as the requester
|
|
666
|
+
record = await RUNTIME.approvals.get(approval["approval_id"])
|
|
667
|
+
assert record is not None and record.approvers == ["role:admin"]
|
|
668
|
+
assert record.decided_by == Principal(id="root").hashed_id()
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
async def test_listing_follows_who_may_see(client, monkeypatch) -> None:
|
|
672
|
+
end = await _pause(client)
|
|
673
|
+
thread = end["thread_id"]
|
|
674
|
+
approval_id = end["approval"]["approval_id"]
|
|
675
|
+
# A decider sees it, with the call; a stranger does not.
|
|
676
|
+
ops = await client.get(f"/threads/{thread}/approvals", headers=_as("carol", "ops"))
|
|
677
|
+
assert ops.status_code == 200 and ops.json()[0]["body"]["reason"] == "customer asked"
|
|
678
|
+
stranger = await client.get(f"/threads/{thread}/approvals", headers=_as("bob"))
|
|
679
|
+
assert stranger.status_code == 403
|
|
680
|
+
# Read-across roles list it, without the call's content, and cannot decide.
|
|
681
|
+
monkeypatch.setenv("AUTH_READ_ACROSS_ROLES", "auditor")
|
|
682
|
+
auditor = await client.get(f"/threads/{thread}/approvals", headers=_as("ann", "auditor"))
|
|
683
|
+
assert auditor.status_code == 200
|
|
684
|
+
assert auditor.json()[0]["approval_id"] == approval_id and "body" not in auditor.json()[0]
|
|
685
|
+
r = await _decide(client, end, "approve", user="ann", roles="auditor")
|
|
686
|
+
assert r.status_code == 403
|
|
687
|
+
# Across threads: the requester and the ops role see it, bob does not.
|
|
688
|
+
for user, roles, seen in (
|
|
689
|
+
("alice", "user", True),
|
|
690
|
+
("carol", "ops", True),
|
|
691
|
+
("bob", "user", False),
|
|
692
|
+
):
|
|
693
|
+
listed = await client.get("/approvals?status=pending", headers=_as(user, roles))
|
|
694
|
+
assert listed.status_code == 200
|
|
695
|
+
assert (approval_id in [a["approval_id"] for a in listed.json()]) is seen, user
|
|
696
|
+
assert (await client.get("/approvals?status=bogus", headers=_as("alice"))).status_code == 422
|
|
697
|
+
unknown = await client.get(f"/threads/{uuid.uuid4()}/approvals", headers=_as("alice"))
|
|
698
|
+
assert unknown.status_code == 404
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
async def test_an_unknown_or_foreign_approval_is_404(client) -> None:
|
|
702
|
+
end = await _pause(client)
|
|
703
|
+
other = await _pause(client, user="dora")
|
|
704
|
+
# Another thread's approval id on this thread's route.
|
|
705
|
+
r = await client.post(
|
|
706
|
+
f"/threads/{end['thread_id']}/approvals/{other['approval']['approval_id']}",
|
|
707
|
+
json={"decision": "approve"},
|
|
708
|
+
headers=_as("alice"),
|
|
709
|
+
)
|
|
710
|
+
assert r.status_code == 404 and r.json()["code"] == "approval_not_found"
|
|
711
|
+
r = await client.post(
|
|
712
|
+
f"/threads/{end['thread_id']}/approvals/{uuid.uuid4().hex}",
|
|
713
|
+
json={"decision": "approve"},
|
|
714
|
+
headers=_as("alice"),
|
|
715
|
+
)
|
|
716
|
+
assert r.status_code == 404
|
|
717
|
+
bad = await client.post(
|
|
718
|
+
f"/threads/{end['thread_id']}/approvals/{end['approval']['approval_id']}",
|
|
719
|
+
json={"decision": "maybe"},
|
|
720
|
+
headers=_as("alice"),
|
|
721
|
+
)
|
|
722
|
+
assert bad.status_code == 422
|
|
723
|
+
assert SENT == []
|
|
724
|
+
|
|
725
|
+
|
|
726
|
+
# --- the thread while pending ------------------------------------------------------------
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
async def test_a_pending_approval_blocks_new_messages(client) -> None:
|
|
730
|
+
end = await _pause(client)
|
|
731
|
+
r = await client.post(
|
|
732
|
+
"/chat", json={"message": "hello", "thread_id": end["thread_id"]}, headers=_as("alice")
|
|
733
|
+
)
|
|
734
|
+
assert r.status_code == 409
|
|
735
|
+
body = r.json()
|
|
736
|
+
assert body["code"] == "approval_pending"
|
|
737
|
+
assert [a["approval_id"] for a in body["approvals"]] == [end["approval"]["approval_id"]]
|
|
738
|
+
# Other threads are not affected.
|
|
739
|
+
other = await client.post("/chat", json={"message": "hello"}, headers=_as("alice"))
|
|
740
|
+
assert other.status_code == 200
|
|
741
|
+
|
|
742
|
+
|
|
743
|
+
async def test_two_decisions_race_and_one_wins(client) -> None:
|
|
744
|
+
end = await _pause(client)
|
|
745
|
+
first, second = await asyncio.gather(
|
|
746
|
+
_decide(client, end, "approve", user="alice"),
|
|
747
|
+
_decide(client, end, "reject", user="carol", roles="ops"),
|
|
748
|
+
)
|
|
749
|
+
statuses = sorted([first.status_code, second.status_code])
|
|
750
|
+
assert statuses == [200, 409], (first.text, second.text)
|
|
751
|
+
loser = first if first.status_code == 409 else second
|
|
752
|
+
assert loser.json()["code"] in ("approval_not_pending", "thread_busy")
|
|
753
|
+
assert len(SENT) <= 1
|
|
754
|
+
record = await RUNTIME.approvals.get(end["approval"]["approval_id"])
|
|
755
|
+
assert record is not None
|
|
756
|
+
assert len(SENT) == (1 if record.status == "approved" else 0)
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
async def test_an_approval_the_run_no_longer_waits_for_cannot_be_decided(client) -> None:
|
|
760
|
+
end = await _pause(client)
|
|
761
|
+
# The paused call gets a result some other way (as a history repair would).
|
|
762
|
+
from {{cookiecutter.agent_directory}}.app_utils.chat import ChatRequest
|
|
763
|
+
|
|
764
|
+
lease = await RUNTIME.acquire_thread(end["thread_id"]) # writes need the run lock
|
|
765
|
+
try:
|
|
766
|
+
await RUNTIME._repair_history(ChatRequest(message=""), end["thread_id"], "closed")
|
|
767
|
+
finally:
|
|
768
|
+
await lease.release()
|
|
769
|
+
r = await _decide(client, end, "approve")
|
|
770
|
+
assert r.status_code == 409 and r.json() == {
|
|
771
|
+
"code": "approval_not_pending",
|
|
772
|
+
"detail": "The run no longer waits for this approval.",
|
|
773
|
+
"status": "expired",
|
|
774
|
+
}
|
|
775
|
+
listed = await client.get(f"/threads/{end['thread_id']}/approvals", headers=_as("alice"))
|
|
776
|
+
assert listed.json()[0]["status"] == "expired"
|
|
777
|
+
# Not pending any more: the thread takes messages again.
|
|
778
|
+
r = await client.post(
|
|
779
|
+
"/chat", json={"message": "hello", "thread_id": end["thread_id"]}, headers=_as("alice")
|
|
780
|
+
)
|
|
781
|
+
assert r.status_code == 200
|
|
782
|
+
assert SENT == []
|
|
783
|
+
|
|
784
|
+
|
|
785
|
+
@tool
|
|
786
|
+
async def ask_a_person(question: str) -> str:
|
|
787
|
+
"""Ask a person something (pauses the graph with a plain interrupt)."""
|
|
788
|
+
from langgraph.types import interrupt
|
|
789
|
+
|
|
790
|
+
return str(interrupt({"question": question}))
|
|
791
|
+
|
|
792
|
+
|
|
793
|
+
async def test_a_pause_that_is_not_an_approval_ends_the_run_cleanly(client, use_test_tools) -> None:
|
|
794
|
+
use_test_tools(ask_a_person)
|
|
795
|
+
r = await client.post(
|
|
796
|
+
"/chat", json={"message": "Ask a person about the weather"}, headers=_as("alice")
|
|
797
|
+
)
|
|
798
|
+
events = parse_sse(r.text)
|
|
799
|
+
assert events[-1][0] == "error" and events[-1][1]["code"] == "unsupported_interrupt"
|
|
800
|
+
thread = events[0][1]["thread_id"]
|
|
801
|
+
assert (await client.get(f"/threads/{thread}/approvals", headers=_as("alice"))).json() == []
|
|
802
|
+
# The paused call was answered: the thread goes on.
|
|
803
|
+
r = await client.post(
|
|
804
|
+
"/chat", json={"message": "hello", "thread_id": thread}, headers=_as("alice")
|
|
805
|
+
)
|
|
806
|
+
assert parse_sse(r.text)[-1][1]["status"] == "ok"
|
|
807
|
+
|
|
808
|
+
|
|
809
|
+
async def test_deleting_the_thread_deletes_its_approvals(client) -> None:
|
|
810
|
+
end = await _pause(client)
|
|
811
|
+
approval_id = end["approval"]["approval_id"]
|
|
812
|
+
r = await client.delete(f"/threads/{end['thread_id']}", headers=_as("alice"))
|
|
813
|
+
assert r.status_code == 204
|
|
814
|
+
assert await RUNTIME.approvals.get(approval_id) is None
|
|
815
|
+
assert await RUNTIME.approvals.for_thread(end["thread_id"]) == []
|
|
816
|
+
r = await _decide(client, end, "approve")
|
|
817
|
+
assert r.status_code == 404
|
|
818
|
+
listed = await client.get("/approvals", headers=_as("alice"))
|
|
819
|
+
assert approval_id not in [a["approval_id"] for a in listed.json()]
|
|
820
|
+
assert SENT == []
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
async def test_the_approval_metrics_count(client, monkeypatch) -> None:
|
|
824
|
+
def count(event: str) -> float:
|
|
825
|
+
from {{cookiecutter.agent_directory}}.app_utils.metrics import APPROVALS
|
|
826
|
+
|
|
827
|
+
return APPROVALS.labels(event)._value.get()
|
|
828
|
+
|
|
829
|
+
before = {e: count(e) for e in ("requested", "approved", "rejected", "expired")}
|
|
830
|
+
await _decide(client, await _pause(client), "approve")
|
|
831
|
+
await _decide(client, await _pause(client), "reject")
|
|
832
|
+
await _pause(client)
|
|
833
|
+
later = approvals_module.utcnow() + timedelta(seconds=61)
|
|
834
|
+
monkeypatch.setattr(RUNTIME.approvals, "_clock", lambda: later)
|
|
835
|
+
await RUNTIME.sweep_approvals()
|
|
836
|
+
after = {e: count(e) for e in before}
|
|
837
|
+
assert {e: after[e] - before[e] for e in before} == {
|
|
838
|
+
"requested": 3,
|
|
839
|
+
"approved": 1,
|
|
840
|
+
"rejected": 1,
|
|
841
|
+
"expired": 1,
|
|
842
|
+
}
|
|
843
|
+
text = (await client.get("/metrics")).text
|
|
844
|
+
assert 'agent_approvals_total{event="requested"}' in text
|
|
845
|
+
assert 'agent_runs_total{status="awaiting_approval"}' in text
|
|
846
|
+
|
|
847
|
+
|
|
848
|
+
async def test_the_playground_and_the_prompt_know_about_approvals(client) -> None:
|
|
849
|
+
page = (await client.get("/playground")).text
|
|
850
|
+
assert "awaiting_approval" in page and "/approvals/" in page
|
|
851
|
+
assert "Approve" in page and "Reject" in page
|
|
852
|
+
from {{cookiecutter.agent_directory}}.agent import SYSTEM_PROMPT
|
|
853
|
+
|
|
854
|
+
assert "need a person's approval" in SYSTEM_PROMPT
|
|
855
|
+
assert "was not approved" in SYSTEM_PROMPT
|
|
856
|
+
|
|
857
|
+
|
|
858
|
+
# --- two gated calls in one step ---------------------------------------------------------
|
|
859
|
+
|
|
860
|
+
|
|
861
|
+
class TwoCancels(BaseChatModel):
|
|
862
|
+
"""Asks for two cancellations at once (parallel tool calls), then answers."""
|
|
863
|
+
|
|
864
|
+
@property
|
|
865
|
+
def _llm_type(self) -> str:
|
|
866
|
+
return "two-cancels"
|
|
867
|
+
|
|
868
|
+
def bind_tools(self, tools: Any, **kwargs: Any) -> Any:
|
|
869
|
+
return self
|
|
870
|
+
|
|
871
|
+
def _generate(self, messages: list[Any], stop: Any = None, run_manager: Any = None, **kw: Any):
|
|
872
|
+
if isinstance(messages[-1], ToolMessage):
|
|
873
|
+
reply = AIMessage(content="Done.")
|
|
874
|
+
else:
|
|
875
|
+
reply = AIMessage(
|
|
876
|
+
content="Cancelling orders 7 and 8, as you asked.",
|
|
877
|
+
tool_calls=[
|
|
878
|
+
{"name": "cancel_order", "args": {"order_id": "7"}, "id": "c7"},
|
|
879
|
+
{"name": "cancel_order", "args": {"order_id": "8"}, "id": "c8"},
|
|
880
|
+
],
|
|
881
|
+
)
|
|
882
|
+
return ChatResult(generations=[ChatGeneration(message=reply)])
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
@pytest.fixture
|
|
886
|
+
def two_calls(client, monkeypatch) -> None:
|
|
887
|
+
from langchain.agents import create_agent
|
|
888
|
+
|
|
889
|
+
from {{cookiecutter.agent_directory}} import agent
|
|
890
|
+
|
|
891
|
+
graph = create_agent(
|
|
892
|
+
model=TwoCancels(),
|
|
893
|
+
tools=[cancel_order],
|
|
894
|
+
system_prompt=agent.SYSTEM_PROMPT,
|
|
895
|
+
middleware=agent.middleware(),
|
|
896
|
+
context_schema=agent.AgentContext,
|
|
897
|
+
)
|
|
898
|
+
graph.checkpointer = agent.graph.checkpointer
|
|
899
|
+
monkeypatch.setattr(agent, "graph", graph)
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
async def _expire_now(approval_id: str) -> None:
|
|
903
|
+
store = RUNTIME.approvals
|
|
904
|
+
assert store is not None
|
|
905
|
+
past = approvals_module.utcnow() - timedelta(seconds=1)
|
|
906
|
+
if store.db.is_postgres:
|
|
907
|
+
await store.db.execute(
|
|
908
|
+
f"UPDATE {store.table} SET expires_at = %s WHERE approval_id = %s", (past, approval_id)
|
|
909
|
+
)
|
|
910
|
+
else:
|
|
911
|
+
store._memory[approval_id].expires_at = past
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
async def test_two_gated_calls_wait_for_their_own_decisions(client, two_calls) -> None:
|
|
915
|
+
end = await _pause(client)
|
|
916
|
+
by_path = {a["path"]: a for a in end["approvals"]}
|
|
917
|
+
assert set(by_path) == {"/orders/7/cancel", "/orders/8/cancel"}
|
|
918
|
+
assert by_path["/orders/7/cancel"]["reason"] == (
|
|
919
|
+
"cancel_order: Cancelling orders 7 and 8, as you asked."
|
|
920
|
+
)
|
|
921
|
+
first = {**end, "approval": by_path["/orders/7/cancel"]}
|
|
922
|
+
r = await _decide(client, first, "approve")
|
|
923
|
+
assert r.status_code == 200, r.text
|
|
924
|
+
events = parse_sse(r.text)
|
|
925
|
+
# The other call still waits, for the same approval (not a second one).
|
|
926
|
+
again = events[-1][1]
|
|
927
|
+
assert again["status"] == "awaiting_approval"
|
|
928
|
+
assert [a["approval_id"] for a in again["approvals"]] == [
|
|
929
|
+
by_path["/orders/8/cancel"]["approval_id"]
|
|
930
|
+
]
|
|
931
|
+
assert [req.url.path for req in SENT] == ["/orders/7/cancel"]
|
|
932
|
+
r = await _decide(client, {**end, "approval": by_path["/orders/8/cancel"]}, "approve")
|
|
933
|
+
assert r.status_code == 200, r.text
|
|
934
|
+
assert parse_sse(r.text)[-1][1]["status"] == "ok"
|
|
935
|
+
# Each approved call was sent once: the first was not sent again on the second resume.
|
|
936
|
+
assert [req.url.path for req in SENT] == ["/orders/7/cancel", "/orders/8/cancel"]
|
|
937
|
+
|
|
938
|
+
|
|
939
|
+
async def test_a_decision_also_closes_the_calls_whose_approval_expired(client, two_calls) -> None:
|
|
940
|
+
end = await _pause(client)
|
|
941
|
+
by_path = {a["path"]: a for a in end["approvals"]}
|
|
942
|
+
await _expire_now(by_path["/orders/8/cancel"]["approval_id"])
|
|
943
|
+
r = await _decide(client, {**end, "approval": by_path["/orders/7/cancel"]}, "approve")
|
|
944
|
+
assert r.status_code == 200, r.text
|
|
945
|
+
events = parse_sse(r.text)
|
|
946
|
+
results = {d["id"]: d for e, d in events if e == "tool.result"}
|
|
947
|
+
assert results["c7"]["is_error"] is False and results["c8"]["is_error"] is True
|
|
948
|
+
assert events[-1][1]["status"] == "ok"
|
|
949
|
+
assert [req.url.path for req in SENT] == ["/orders/7/cancel"]
|
|
950
|
+
listed = (
|
|
951
|
+
await client.get(f"/threads/{end['thread_id']}/approvals", headers=_as("alice"))
|
|
952
|
+
).json()
|
|
953
|
+
assert {a["path"]: a["status"] for a in listed} == {
|
|
954
|
+
"/orders/7/cancel": "approved",
|
|
955
|
+
"/orders/8/cancel": "expired",
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
@pytest.mark.parametrize("change", list(POLICY_CHANGES))
|
|
960
|
+
async def test_an_expired_approval_stops_its_call_whatever_the_policy_says_by_then(
|
|
961
|
+
client, two_calls, tmp_path, change: str
|
|
962
|
+
) -> None:
|
|
963
|
+
end = await _pause(client)
|
|
964
|
+
by_path = {a["path"]: a for a in end["approvals"]}
|
|
965
|
+
await _expire_now(by_path["/orders/8/cancel"]["approval_id"])
|
|
966
|
+
_change_policy(tmp_path, change)
|
|
967
|
+
# Deciding the other call resumes the run: the expired one gets "expired".
|
|
968
|
+
r = await _decide(client, {**end, "approval": by_path["/orders/7/cancel"]}, "reject")
|
|
969
|
+
assert r.status_code == 200, r.text
|
|
970
|
+
events = parse_sse(r.text)
|
|
971
|
+
results = {d["id"]: d for e, d in events if e == "tool.result"}
|
|
972
|
+
assert results["c8"]["is_error"] is True
|
|
973
|
+
assert REFUSED_BY.get(change, DECIDED_BY["expired"]) in results["c8"]["result"]
|
|
974
|
+
assert results["c7"]["is_error"] is True
|
|
975
|
+
assert events[-1][1]["status"] == "ok"
|
|
976
|
+
assert SENT == []
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
@pytest.mark.parametrize("change", list(REFUSED_BY))
|
|
980
|
+
@pytest.mark.parametrize("decision", ["approve", "reject"])
|
|
981
|
+
async def test_a_waiting_call_the_policy_now_refuses_does_not_block_the_thread(
|
|
982
|
+
client, two_calls, tmp_path, change: str, decision: str
|
|
983
|
+
) -> None:
|
|
984
|
+
"""The other call runs again on the resume and is refused before it could pause again:
|
|
985
|
+
its approval waits for nothing, so it is expired and the thread takes new messages."""
|
|
986
|
+
end = await _pause(client)
|
|
987
|
+
by_path = {a["path"]: a for a in end["approvals"]}
|
|
988
|
+
_change_policy(tmp_path, change)
|
|
989
|
+
r = await _decide(client, {**end, "approval": by_path["/orders/7/cancel"]}, decision)
|
|
990
|
+
assert r.status_code == 200, r.text
|
|
991
|
+
events = parse_sse(r.text)
|
|
992
|
+
results = {d["id"]: d for e, d in events if e == "tool.result"}
|
|
993
|
+
assert REFUSED_BY[change] in results["c8"]["result"]
|
|
994
|
+
assert events[-1][1]["status"] == "ok"
|
|
995
|
+
listed = (
|
|
996
|
+
await client.get(f"/threads/{end['thread_id']}/approvals", headers=_as("alice"))
|
|
997
|
+
).json()
|
|
998
|
+
assert {a["path"]: a["status"] for a in listed} == {
|
|
999
|
+
"/orders/7/cancel": "approved" if decision == "approve" else "rejected",
|
|
1000
|
+
"/orders/8/cancel": "expired",
|
|
1001
|
+
}
|
|
1002
|
+
r = await client.post(
|
|
1003
|
+
"/chat", json={"message": "hello", "thread_id": end["thread_id"]}, headers=_as("alice")
|
|
1004
|
+
)
|
|
1005
|
+
assert r.status_code == 200, r.text
|
|
1006
|
+
assert parse_sse(r.text)[-1][1]["status"] == "ok"
|
|
1007
|
+
assert SENT == []
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
async def test_a_call_still_waiting_waits_on_when_its_gate_is_removed(
|
|
1011
|
+
client, two_calls, tmp_path
|
|
1012
|
+
) -> None:
|
|
1013
|
+
end = await _pause(client)
|
|
1014
|
+
by_path = {a["path"]: a for a in end["approvals"]}
|
|
1015
|
+
_change_policy(tmp_path, "gate removed")
|
|
1016
|
+
r = await _decide(client, {**end, "approval": by_path["/orders/7/cancel"]}, "approve")
|
|
1017
|
+
assert r.status_code == 200, r.text
|
|
1018
|
+
events = parse_sse(r.text)
|
|
1019
|
+
results = {d["id"]: d for e, d in events if e == "tool.result"}
|
|
1020
|
+
assert DECIDED_BY["approve"] in results["c7"]["result"]
|
|
1021
|
+
# The other call was not sent on its own: it still waits, for the same approval.
|
|
1022
|
+
again = events[-1][1]
|
|
1023
|
+
assert again["status"] == "awaiting_approval"
|
|
1024
|
+
waiting = by_path["/orders/8/cancel"]
|
|
1025
|
+
assert [a["approval_id"] for a in again["approvals"]] == [waiting["approval_id"]]
|
|
1026
|
+
assert again["approvals"][0]["approvers"] == ["requester", "role:ops"]
|
|
1027
|
+
assert SENT == []
|
|
1028
|
+
r = await _decide(client, {**end, "approval": waiting}, "reject")
|
|
1029
|
+
assert r.status_code == 200, r.text
|
|
1030
|
+
events = parse_sse(r.text)
|
|
1031
|
+
results = {d["id"]: d for e, d in events if e == "tool.result"}
|
|
1032
|
+
assert DECIDED_BY["reject"] in results["c8"]["result"]
|
|
1033
|
+
assert events[-1][1]["status"] == "ok"
|
|
1034
|
+
assert SENT == []
|
|
1035
|
+
|
|
1036
|
+
|
|
1037
|
+
# --- A2A: input-required and back ----------------------------------------------------
|
|
1038
|
+
|
|
1039
|
+
|
|
1040
|
+
async def _rpc(client: httpx.AsyncClient, user: str, method: str, params: dict) -> dict:
|
|
1041
|
+
r = await client.post(
|
|
1042
|
+
A2A_PATH,
|
|
1043
|
+
json={"jsonrpc": "2.0", "id": "1", "method": method, "params": params},
|
|
1044
|
+
headers={**_as(user), "A2A-Version": "1.0"},
|
|
1045
|
+
)
|
|
1046
|
+
return r.json()
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
def _parts_data(message: dict[str, Any]) -> dict[str, Any]:
|
|
1050
|
+
return next(p["data"] for p in message["parts"] if "data" in p)
|
|
1051
|
+
|
|
1052
|
+
|
|
1053
|
+
async def test_a2a_input_required_round_trip(client) -> None:
|
|
1054
|
+
# A user of its own: the A2A task store lives as long as the app.
|
|
1055
|
+
user = f"ann-{uuid.uuid4().hex[:8]}"
|
|
1056
|
+
sent = await _rpc(
|
|
1057
|
+
client,
|
|
1058
|
+
user,
|
|
1059
|
+
"SendMessage",
|
|
1060
|
+
{"message": {"messageId": "m-1", "role": "ROLE_USER", "parts": [{"text": PROMPT}]}},
|
|
1061
|
+
)
|
|
1062
|
+
task = sent["result"]["task"]
|
|
1063
|
+
assert task["status"]["state"] == "TASK_STATE_INPUT_REQUIRED", task
|
|
1064
|
+
data = _parts_data(task["status"]["message"])
|
|
1065
|
+
assert data["type"] == "approval_request"
|
|
1066
|
+
approval = data["approval"]
|
|
1067
|
+
assert approval["path"] == "/orders/7/cancel" and approval["status"] == "pending"
|
|
1068
|
+
assert SENT == []
|
|
1069
|
+
|
|
1070
|
+
# A text message meanwhile: still waiting for the decision.
|
|
1071
|
+
waiting = await _rpc(
|
|
1072
|
+
client,
|
|
1073
|
+
user,
|
|
1074
|
+
"SendMessage",
|
|
1075
|
+
{
|
|
1076
|
+
"message": {
|
|
1077
|
+
"messageId": "m-2",
|
|
1078
|
+
"role": "ROLE_USER",
|
|
1079
|
+
"taskId": task["id"],
|
|
1080
|
+
"contextId": task["contextId"],
|
|
1081
|
+
"parts": [{"text": "hello?"}],
|
|
1082
|
+
}
|
|
1083
|
+
},
|
|
1084
|
+
)
|
|
1085
|
+
assert waiting["result"]["task"]["status"]["state"] == "TASK_STATE_INPUT_REQUIRED"
|
|
1086
|
+
assert SENT == []
|
|
1087
|
+
|
|
1088
|
+
# A malformed decision is invalid params.
|
|
1089
|
+
bad = await _rpc(
|
|
1090
|
+
client,
|
|
1091
|
+
user,
|
|
1092
|
+
"SendMessage",
|
|
1093
|
+
{
|
|
1094
|
+
"message": {
|
|
1095
|
+
"messageId": "m-3",
|
|
1096
|
+
"role": "ROLE_USER",
|
|
1097
|
+
"taskId": task["id"],
|
|
1098
|
+
"contextId": task["contextId"],
|
|
1099
|
+
"parts": [{"data": {"approval_id": approval["approval_id"], "decision": "yes"}}],
|
|
1100
|
+
}
|
|
1101
|
+
},
|
|
1102
|
+
)
|
|
1103
|
+
assert bad["error"]["code"] == -32602
|
|
1104
|
+
|
|
1105
|
+
done = await _rpc(
|
|
1106
|
+
client,
|
|
1107
|
+
user,
|
|
1108
|
+
"SendMessage",
|
|
1109
|
+
{
|
|
1110
|
+
"message": {
|
|
1111
|
+
"messageId": "m-4",
|
|
1112
|
+
"role": "ROLE_USER",
|
|
1113
|
+
"taskId": task["id"],
|
|
1114
|
+
"contextId": task["contextId"],
|
|
1115
|
+
"parts": [
|
|
1116
|
+
{"data": {"approval_id": approval["approval_id"], "decision": "approve"}}
|
|
1117
|
+
],
|
|
1118
|
+
}
|
|
1119
|
+
},
|
|
1120
|
+
)
|
|
1121
|
+
finished = done["result"]["task"]
|
|
1122
|
+
assert finished["status"]["state"] == "TASK_STATE_COMPLETED", finished
|
|
1123
|
+
assert len(SENT) == 1 and SENT[0].url.path == "/orders/7/cancel"
|
|
1124
|
+
text = "".join(p.get("text", "") for a in finished["artifacts"] for p in a["parts"])
|
|
1125
|
+
assert "/orders/7/cancel" in text
|
|
1126
|
+
|
|
1127
|
+
|
|
1128
|
+
class NoDecidePolicy(HeaderPolicy):
|
|
1129
|
+
"""The header policy, refusing the `approval.decide` action."""
|
|
1130
|
+
|
|
1131
|
+
async def authorize(self, principal: Principal, action: str, resource: str | None) -> None:
|
|
1132
|
+
if action == "approval.decide":
|
|
1133
|
+
raise HTTPException(403, "approval.decide is not allowed for you")
|
|
1134
|
+
|
|
1135
|
+
|
|
1136
|
+
async def test_a2a_decision_needs_the_approval_decide_action(client, monkeypatch) -> None:
|
|
1137
|
+
user = f"ann-{uuid.uuid4().hex[:8]}"
|
|
1138
|
+
sent = await _rpc(
|
|
1139
|
+
client,
|
|
1140
|
+
user,
|
|
1141
|
+
"SendMessage",
|
|
1142
|
+
{"message": {"messageId": "m-1", "role": "ROLE_USER", "parts": [{"text": PROMPT}]}},
|
|
1143
|
+
)
|
|
1144
|
+
task = sent["result"]["task"]
|
|
1145
|
+
approval = _parts_data(task["status"]["message"])["approval"]
|
|
1146
|
+
monkeypatch.setattr(auth_module, "get_policy", lambda: NoDecidePolicy())
|
|
1147
|
+
refused = await _rpc(
|
|
1148
|
+
client,
|
|
1149
|
+
user,
|
|
1150
|
+
"SendMessage",
|
|
1151
|
+
{
|
|
1152
|
+
"message": {
|
|
1153
|
+
"messageId": "m-2",
|
|
1154
|
+
"role": "ROLE_USER",
|
|
1155
|
+
"taskId": task["id"],
|
|
1156
|
+
"contextId": task["contextId"],
|
|
1157
|
+
"parts": [
|
|
1158
|
+
{"data": {"approval_id": approval["approval_id"], "decision": "approve"}}
|
|
1159
|
+
],
|
|
1160
|
+
}
|
|
1161
|
+
},
|
|
1162
|
+
)
|
|
1163
|
+
after = refused["result"]["task"]
|
|
1164
|
+
assert after["status"]["state"] == "TASK_STATE_INPUT_REQUIRED", after
|
|
1165
|
+
note = "".join(p.get("text", "") for p in after["status"]["message"]["parts"])
|
|
1166
|
+
assert "approval.decide is not allowed" in note
|
|
1167
|
+
# The HTTP route refuses it the same way; the approval stays pending.
|
|
1168
|
+
paused_on = {"thread_id": task["contextId"], "approval": approval}
|
|
1169
|
+
r = await _decide(client, paused_on, "approve", user)
|
|
1170
|
+
assert r.status_code == 403
|
|
1171
|
+
assert SENT == []
|
|
1172
|
+
record = await RUNTIME.approvals.get(approval["approval_id"])
|
|
1173
|
+
assert record is not None and record.status == "pending"
|
|
1174
|
+
|
|
1175
|
+
|
|
1176
|
+
async def test_a2a_decision_by_a_requester_the_policy_does_not_list_is_refused(
|
|
1177
|
+
client, tmp_path
|
|
1178
|
+
) -> None:
|
|
1179
|
+
user = f"ann-{uuid.uuid4().hex[:8]}"
|
|
1180
|
+
_write_policy(tmp_path / "api-policy.yaml", '["role:ops"]')
|
|
1181
|
+
sent = await _rpc(
|
|
1182
|
+
client,
|
|
1183
|
+
user,
|
|
1184
|
+
"SendMessage",
|
|
1185
|
+
{"message": {"messageId": "m-1", "role": "ROLE_USER", "parts": [{"text": PROMPT}]}},
|
|
1186
|
+
)
|
|
1187
|
+
task = sent["result"]["task"]
|
|
1188
|
+
approval = _parts_data(task["status"]["message"])["approval"]
|
|
1189
|
+
refused = await _rpc(
|
|
1190
|
+
client,
|
|
1191
|
+
user,
|
|
1192
|
+
"SendMessage",
|
|
1193
|
+
{
|
|
1194
|
+
"message": {
|
|
1195
|
+
"messageId": "m-2",
|
|
1196
|
+
"role": "ROLE_USER",
|
|
1197
|
+
"taskId": task["id"],
|
|
1198
|
+
"contextId": task["contextId"],
|
|
1199
|
+
"parts": [
|
|
1200
|
+
{"data": {"approval_id": approval["approval_id"], "decision": "approve"}}
|
|
1201
|
+
],
|
|
1202
|
+
}
|
|
1203
|
+
},
|
|
1204
|
+
)
|
|
1205
|
+
after = refused["result"]["task"]
|
|
1206
|
+
assert after["status"]["state"] == "TASK_STATE_INPUT_REQUIRED"
|
|
1207
|
+
note = "".join(p.get("text", "") for p in after["status"]["message"]["parts"])
|
|
1208
|
+
assert "not_an_approver" in note
|
|
1209
|
+
assert SENT == []
|
|
1210
|
+
|
|
1211
|
+
|
|
1212
|
+
# --- A2A: text Postgres cannot store ------------------------------------------------------
|
|
1213
|
+
|
|
1214
|
+
NUL = "\x00"
|
|
1215
|
+
# A stored A2A task holds U+FFFD in place of a U+0000 on Postgres, which cannot store one.
|
|
1216
|
+
STORED_NUL = {"memory": NUL, "postgres": "\ufffd"}
|
|
1217
|
+
READINGS: list[str] = []
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
@tool
|
|
1221
|
+
async def meter_reading(query: str) -> str:
|
|
1222
|
+
"""Read the meter at a place (test-only: the reading holds a U+0000)."""
|
|
1223
|
+
READINGS.append(query)
|
|
1224
|
+
return f"meter at {query}:{NUL}42"
|
|
1225
|
+
|
|
1226
|
+
|
|
1227
|
+
def _a2a_message(text: str, **fields: Any) -> dict[str, Any]:
|
|
1228
|
+
return {
|
|
1229
|
+
"message": {
|
|
1230
|
+
"messageId": uuid.uuid4().hex,
|
|
1231
|
+
"role": "ROLE_USER",
|
|
1232
|
+
"parts": [{"text": text}],
|
|
1233
|
+
**fields,
|
|
1234
|
+
}
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
async def _stored(client: httpx.AsyncClient, user: str, task_id: str) -> dict[str, Any]:
|
|
1239
|
+
got = await _rpc(client, user, "GetTask", {"id": task_id})
|
|
1240
|
+
assert "result" in got, got
|
|
1241
|
+
return got["result"]
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
async def test_an_a2a_task_holding_a_nul_completes_and_is_kept(
|
|
1245
|
+
client, database, use_test_tools
|
|
1246
|
+
) -> None:
|
|
1247
|
+
"""A U+0000 in a message, streamed or not, or in a tool's output: the task completes.
|
|
1248
|
+
|
|
1249
|
+
On Postgres the save of such a task failed (-32603) while its run, and the
|
|
1250
|
+
tool, went on; the task was then failed as if its process had died.
|
|
1251
|
+
"""
|
|
1252
|
+
user = f"nul-{uuid.uuid4().hex[:8]}"
|
|
1253
|
+
stored = STORED_NUL[database]
|
|
1254
|
+
|
|
1255
|
+
sent = await _rpc(client, user, "SendMessage", _a2a_message(f"tell me{NUL} more"))
|
|
1256
|
+
assert sent["result"]["task"]["status"]["state"] == "TASK_STATE_COMPLETED", sent
|
|
1257
|
+
task = await _stored(client, user, sent["result"]["task"]["id"])
|
|
1258
|
+
assert task["status"]["state"] == "TASK_STATE_COMPLETED"
|
|
1259
|
+
assert task["history"][0]["parts"] == [{"text": f"tell me{stored} more"}]
|
|
1260
|
+
|
|
1261
|
+
r = await client.post(
|
|
1262
|
+
A2A_PATH,
|
|
1263
|
+
json={
|
|
1264
|
+
"jsonrpc": "2.0",
|
|
1265
|
+
"id": "1",
|
|
1266
|
+
"method": "SendStreamingMessage",
|
|
1267
|
+
"params": _a2a_message(f"stream me{NUL} please"),
|
|
1268
|
+
},
|
|
1269
|
+
headers={**_as(user), "A2A-Version": "1.0"},
|
|
1270
|
+
)
|
|
1271
|
+
events = [json.loads(line[5:]) for line in r.text.splitlines() if line.startswith("data:")]
|
|
1272
|
+
assert events and all("result" in event for event in events), r.text[-500:]
|
|
1273
|
+
last = events[-1]["result"]["statusUpdate"]
|
|
1274
|
+
assert last["status"]["state"] == "TASK_STATE_COMPLETED"
|
|
1275
|
+
streamed = await _stored(client, user, last["taskId"])
|
|
1276
|
+
assert streamed["status"]["state"] == "TASK_STATE_COMPLETED"
|
|
1277
|
+
|
|
1278
|
+
use_test_tools(meter_reading)
|
|
1279
|
+
READINGS.clear()
|
|
1280
|
+
sent = await _rpc(client, user, "SendMessage", _a2a_message("Take the meter reading for Paris"))
|
|
1281
|
+
assert sent["result"]["task"]["status"]["state"] == "TASK_STATE_COMPLETED", sent
|
|
1282
|
+
assert READINGS == ["Paris"]
|
|
1283
|
+
task = await _stored(client, user, sent["result"]["task"]["id"])
|
|
1284
|
+
assert task["status"]["state"] == "TASK_STATE_COMPLETED"
|
|
1285
|
+
reply = "".join(p.get("text", "") for a in task["artifacts"] for p in a["parts"])
|
|
1286
|
+
assert f"meter at Paris:{stored}42" in reply
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
async def test_an_a2a_id_holding_a_nul_is_answered_like_any_other(client, database) -> None:
|
|
1290
|
+
"""A contextId holding a U+0000 fails its task (it is not a valid one); a task id is unknown."""
|
|
1291
|
+
user = f"nul-{uuid.uuid4().hex[:8]}"
|
|
1292
|
+
context = f"ab{NUL}cd"
|
|
1293
|
+
sent = await _rpc(client, user, "SendMessage", _a2a_message("hello", contextId=context))
|
|
1294
|
+
failed = sent["result"]["task"]
|
|
1295
|
+
assert failed["status"]["state"] == "TASK_STATE_FAILED", sent
|
|
1296
|
+
assert "Invalid contextId" in failed["status"]["message"]["parts"][0]["text"]
|
|
1297
|
+
assert (await _stored(client, user, failed["id"]))["status"]["state"] == "TASK_STATE_FAILED"
|
|
1298
|
+
listed = await _rpc(client, user, "ListTasks", {"contextId": context})
|
|
1299
|
+
assert [t["id"] for t in listed["result"]["tasks"]] == [failed["id"]]
|
|
1300
|
+
|
|
1301
|
+
unknown = await _rpc(client, user, "GetTask", {"id": f"no{NUL}such"})
|
|
1302
|
+
assert unknown["error"]["code"] == -32001, unknown
|
|
1303
|
+
named = await _rpc(client, user, "SendMessage", _a2a_message("hi", taskId=f"no{NUL}such"))
|
|
1304
|
+
assert named["error"]["code"] == -32001, named
|
|
1305
|
+
|
|
1306
|
+
|
|
1307
|
+
# --- A tool's output that is not valid Unicode ---------------------------------------------
|
|
1308
|
+
|
|
1309
|
+
LONE_SURROGATE = "\ud800" # what an upstream JSON "\ud800" escape decodes to
|
|
1310
|
+
GAUGES: list[str] = []
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
@tool
|
|
1314
|
+
async def gauge_reading(query: str) -> str:
|
|
1315
|
+
"""Read the gauge at a place (test-only: the reading holds a lone surrogate)."""
|
|
1316
|
+
GAUGES.append(query)
|
|
1317
|
+
return f"gauge at {query}:{LONE_SURROGATE}42"
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
async def test_a_tool_output_holding_a_lone_surrogate_completes_the_run(
|
|
1321
|
+
client, database, use_test_tools
|
|
1322
|
+
) -> None:
|
|
1323
|
+
"""UTF-8 cannot encode a lone surrogate: the reply holds U+FFFD in its place.
|
|
1324
|
+
|
|
1325
|
+
The /chat stream ended at the `tool.result` event, and the A2A task failed
|
|
1326
|
+
with -32603 and the Python exception text, both after the tool had acted.
|
|
1327
|
+
"""
|
|
1328
|
+
user = f"surrogate-{uuid.uuid4().hex[:8]}"
|
|
1329
|
+
use_test_tools(gauge_reading)
|
|
1330
|
+
GAUGES.clear()
|
|
1331
|
+
replaced = "gauge at Bergen:\ufffd42"
|
|
1332
|
+
|
|
1333
|
+
r = await client.post(
|
|
1334
|
+
"/chat", json={"message": "Check the gauge for Bergen"}, headers=_as(user)
|
|
1335
|
+
)
|
|
1336
|
+
assert r.status_code == 200, r.text
|
|
1337
|
+
events = parse_sse(r.text)
|
|
1338
|
+
names = [e for e, _ in events]
|
|
1339
|
+
assert names[0] == "message.start" and names[-1] == "message.end", events
|
|
1340
|
+
assert events[-1][1]["status"] == "ok"
|
|
1341
|
+
assert [d["result"] for e, d in events if e == "tool.result"] == [replaced]
|
|
1342
|
+
assert replaced in "".join(d["text"] for e, d in events if e == "message.delta")
|
|
1343
|
+
thread = events[0][1]["thread_id"]
|
|
1344
|
+
history = await client.get(f"/threads/{thread}/messages", headers=_as(user))
|
|
1345
|
+
assert [m["content"] for m in history.json() if m["role"] == "tool"] == [replaced]
|
|
1346
|
+
assert GAUGES == ["Bergen"]
|
|
1347
|
+
|
|
1348
|
+
sent = await _rpc(client, user, "SendMessage", _a2a_message("Check the gauge for Oslo"))
|
|
1349
|
+
assert sent["result"]["task"]["status"]["state"] == "TASK_STATE_COMPLETED", sent
|
|
1350
|
+
task = await _stored(client, user, sent["result"]["task"]["id"])
|
|
1351
|
+
reply = "".join(p.get("text", "") for a in task["artifacts"] for p in a["parts"])
|
|
1352
|
+
assert "gauge at Oslo:\ufffd42" in reply
|
|
1353
|
+
|
|
1354
|
+
r = await client.post(
|
|
1355
|
+
A2A_PATH,
|
|
1356
|
+
json={
|
|
1357
|
+
"jsonrpc": "2.0",
|
|
1358
|
+
"id": "1",
|
|
1359
|
+
"method": "SendStreamingMessage",
|
|
1360
|
+
"params": _a2a_message("Check the gauge for Rome"),
|
|
1361
|
+
},
|
|
1362
|
+
headers={**_as(user), "A2A-Version": "1.0"},
|
|
1363
|
+
)
|
|
1364
|
+
events = [json.loads(line[5:]) for line in r.text.splitlines() if line.startswith("data:")]
|
|
1365
|
+
assert events and all("result" in event for event in events), r.text[-500:]
|
|
1366
|
+
assert events[-1]["result"]["statusUpdate"]["status"]["state"] == "TASK_STATE_COMPLETED"
|
|
1367
|
+
assert GAUGES == ["Bergen", "Oslo", "Rome"]
|