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,569 @@
|
|
|
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
|
+
"""What an agent calling this one reads and sends over A2A (0.3), and what is never kept.
|
|
16
|
+
|
|
17
|
+
This agent plays `orders`, with a gated `cancelOrder` call to a backend that
|
|
18
|
+
records every request (`httpx.MockTransport`) and a tool that reports the user's
|
|
19
|
+
words its run was given. A test auth policy reads the caller from headers:
|
|
20
|
+
`X-User` is the person, `X-Actor` the agent presenting the request for them
|
|
21
|
+
(`concierge`), so the tests send what that agent would, over A2A JSON-RPC and
|
|
22
|
+
HTTP.
|
|
23
|
+
|
|
24
|
+
* The origin extension: the card declares it; the user's own words an agent
|
|
25
|
+
forwards reach the run (never a direct caller's metadata), and are never
|
|
26
|
+
stored with the task; too many hops fail the task. The run a relayed decision
|
|
27
|
+
resumes acts on the words of the request that paused it, whatever words (if
|
|
28
|
+
any) the decision carries.
|
|
29
|
+
* The approval request: `approval_json` keeps exact values, the text shows the body.
|
|
30
|
+
* A failed or refused task names why in an error part (`thread_busy`).
|
|
31
|
+
* Tasks waiting on an approval follow its outcome (KI-025): a decision sent on the
|
|
32
|
+
context alone, one the person takes over HTTP, a rejection, an expiry; another
|
|
33
|
+
owner's task on the same thread stays as it was.
|
|
34
|
+
|
|
35
|
+
Every test runs with the in-memory checkpointer, and on Postgres when
|
|
36
|
+
`TEST_POSTGRES_DSN` is set.
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import asyncio
|
|
42
|
+
import json
|
|
43
|
+
import os
|
|
44
|
+
import uuid
|
|
45
|
+
from collections.abc import AsyncIterator
|
|
46
|
+
from datetime import timedelta
|
|
47
|
+
from pathlib import Path
|
|
48
|
+
from typing import Any
|
|
49
|
+
from urllib.parse import urlsplit
|
|
50
|
+
|
|
51
|
+
# The environment must be in place before the app (and the graph) is imported.
|
|
52
|
+
os.environ.update(
|
|
53
|
+
{
|
|
54
|
+
"MODEL_PROVIDER": "fake",
|
|
55
|
+
"MODEL_NAME": "fake",
|
|
56
|
+
"CHECKPOINTER": "memory",
|
|
57
|
+
"AUTH_POLICY": "shared-bearer",
|
|
58
|
+
"API_KEY": "test-key",
|
|
59
|
+
"APP_ENV": "dev",
|
|
60
|
+
"TRACING_ENABLED": "false",
|
|
61
|
+
"RUNTIME": "fastapi",
|
|
62
|
+
"APP_URL": "http://testserver",
|
|
63
|
+
}
|
|
64
|
+
)
|
|
65
|
+
|
|
66
|
+
import httpx
|
|
67
|
+
import pytest
|
|
68
|
+
from fastapi import HTTPException
|
|
69
|
+
from langchain.tools import ToolRuntime
|
|
70
|
+
from langchain_core.tools import tool
|
|
71
|
+
from starlette.requests import Request
|
|
72
|
+
|
|
73
|
+
from {{cookiecutter.agent_directory}}.app_utils import auth as auth_module
|
|
74
|
+
from {{cookiecutter.agent_directory}}.app_utils import chat as chat_module
|
|
75
|
+
from {{cookiecutter.agent_directory}}.app_utils.api_client import (
|
|
76
|
+
A2A_ORIGIN_EXTENSION,
|
|
77
|
+
get_client,
|
|
78
|
+
require_user_mentioned,
|
|
79
|
+
)
|
|
80
|
+
from {{cookiecutter.agent_directory}}.app_utils.api_client import (
|
|
81
|
+
reset_policy_cache as reset_api_policy,
|
|
82
|
+
)
|
|
83
|
+
from {{cookiecutter.agent_directory}}.app_utils.approvals import utcnow
|
|
84
|
+
from {{cookiecutter.agent_directory}}.app_utils.auth import ACTIONS, Actor, Principal
|
|
85
|
+
from {{cookiecutter.agent_directory}}.app_utils.chat import RUNTIME
|
|
86
|
+
from {{cookiecutter.agent_directory}}.fast_api_app import app
|
|
87
|
+
|
|
88
|
+
A2A_PATH = "/a2a/{{cookiecutter.agent_directory}}"
|
|
89
|
+
PROMPT = "Cancel the order for 7"
|
|
90
|
+
BODY = {"reason": "customer asked", "amount": 1, "big": 12345678901234567890}
|
|
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
|
+
operations:
|
|
101
|
+
- operationId: cancelOrder
|
|
102
|
+
path: /orders/{order_id}/cancel
|
|
103
|
+
methods: [POST]
|
|
104
|
+
approvers: [requester]
|
|
105
|
+
timeout_s: 60
|
|
106
|
+
decide_with: relayed
|
|
107
|
+
relayers: [concierge]
|
|
108
|
+
"""
|
|
109
|
+
|
|
110
|
+
# What the backend received.
|
|
111
|
+
SENT: list[httpx.Request] = []
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _backend(request: httpx.Request) -> httpx.Response:
|
|
115
|
+
SENT.append(request)
|
|
116
|
+
return httpx.Response(200, json={"cancelled": request.url.path})
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
@tool
|
|
120
|
+
async def cancel_order(order_id: str, runtime: ToolRuntime[Any]) -> str:
|
|
121
|
+
"""Cancel an order by its id."""
|
|
122
|
+
context = getattr(runtime, "context", None)
|
|
123
|
+
client = get_client("shop", context=context, transport=httpx.MockTransport(_backend))
|
|
124
|
+
data = await client.post(
|
|
125
|
+
"/orders/{order_id}/cancel",
|
|
126
|
+
operation_id="cancelOrder",
|
|
127
|
+
path_params={"order_id": order_id},
|
|
128
|
+
json_body=BODY,
|
|
129
|
+
)
|
|
130
|
+
return json.dumps(data)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
# The user's words each run of `cancel_checked` was given (its `@origin`), in order.
|
|
134
|
+
SEEN_ORIGINS: list[Any] = []
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
@tool
|
|
138
|
+
async def cancel_checked(order_id: str, runtime: ToolRuntime[Any]) -> str:
|
|
139
|
+
"""Cancel an order, once the user's own words name it (`require_user_mentioned`)."""
|
|
140
|
+
context = getattr(runtime, "context", None)
|
|
141
|
+
attributes = getattr(context, "attributes", None) or {}
|
|
142
|
+
SEEN_ORIGINS.append((attributes.get("credentials") or {}).get("@origin"))
|
|
143
|
+
require_user_mentioned(order_id, runtime)
|
|
144
|
+
client = get_client("shop", context=context, transport=httpx.MockTransport(_backend))
|
|
145
|
+
data = await client.post(
|
|
146
|
+
"/orders/{order_id}/cancel",
|
|
147
|
+
operation_id="cancelOrder",
|
|
148
|
+
path_params={"order_id": order_id},
|
|
149
|
+
json_body=BODY,
|
|
150
|
+
)
|
|
151
|
+
return json.dumps(data)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
@tool
|
|
155
|
+
async def echo_origin(note: str, runtime: ToolRuntime[Any]) -> str:
|
|
156
|
+
"""Echo the words of the user this run was given (the origin), if any."""
|
|
157
|
+
context = getattr(runtime, "context", None)
|
|
158
|
+
attributes = getattr(context, "attributes", None) or {}
|
|
159
|
+
origin = (attributes.get("credentials") or {}).get("@origin")
|
|
160
|
+
return "ORIGIN=" + json.dumps(origin)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class ActorHeaderPolicy:
|
|
164
|
+
"""Test policy: `X-User` is the subject, `X-Actor` (when sent) the agent presenting it."""
|
|
165
|
+
|
|
166
|
+
async def authenticate(self, request: Request) -> Principal:
|
|
167
|
+
user = request.headers.get("x-user")
|
|
168
|
+
if not user:
|
|
169
|
+
raise HTTPException(401, "no user", headers={"WWW-Authenticate": "Bearer"})
|
|
170
|
+
actor = request.headers.get("x-actor")
|
|
171
|
+
return Principal(
|
|
172
|
+
id=user,
|
|
173
|
+
roles=["user"],
|
|
174
|
+
permissions=set(ACTIONS),
|
|
175
|
+
actor=Actor(id=actor) if actor else None,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
async def authorize(self, principal: Principal, action: str, resource: str | None) -> None:
|
|
179
|
+
return None
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
ADMIN_DSN = os.environ.get("TEST_POSTGRES_DSN", "")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@pytest.fixture(params=["memory", "postgres"])
|
|
186
|
+
async def database(
|
|
187
|
+
request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch
|
|
188
|
+
) -> AsyncIterator[str]:
|
|
189
|
+
if request.param == "memory":
|
|
190
|
+
yield "memory"
|
|
191
|
+
return
|
|
192
|
+
if not ADMIN_DSN:
|
|
193
|
+
pytest.skip("TEST_POSTGRES_DSN is not set")
|
|
194
|
+
import psycopg
|
|
195
|
+
|
|
196
|
+
name = f"gac_test_{uuid.uuid4().hex[:12]}"
|
|
197
|
+
async with await psycopg.AsyncConnection.connect(ADMIN_DSN, autocommit=True) as admin:
|
|
198
|
+
await admin.execute(f'CREATE DATABASE "{name}"')
|
|
199
|
+
monkeypatch.setenv("CHECKPOINTER", "postgres")
|
|
200
|
+
monkeypatch.setenv("POSTGRES_DSN", urlsplit(ADMIN_DSN)._replace(path=f"/{name}").geturl())
|
|
201
|
+
yield "postgres"
|
|
202
|
+
async with await psycopg.AsyncConnection.connect(ADMIN_DSN, autocommit=True) as admin:
|
|
203
|
+
await admin.execute(f'DROP DATABASE IF EXISTS "{name}" WITH (FORCE)')
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@pytest.fixture
|
|
207
|
+
async def orders(
|
|
208
|
+
database: str,
|
|
209
|
+
tmp_path: Path,
|
|
210
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
211
|
+
use_test_tools: Any,
|
|
212
|
+
) -> AsyncIterator[dict[str, Any]]:
|
|
213
|
+
"""The app as `orders`: clients for one person, directly and through the concierge."""
|
|
214
|
+
policy = tmp_path / "api-policy.yaml"
|
|
215
|
+
policy.write_text(POLICY, encoding="utf-8")
|
|
216
|
+
monkeypatch.setenv("API_POLICY_PATH", str(policy))
|
|
217
|
+
monkeypatch.setenv("SHOP_API_BASE_URL", "http://shop.test")
|
|
218
|
+
monkeypatch.setenv("AUTH_ALLOWED_ACTORS", "concierge")
|
|
219
|
+
monkeypatch.delenv("TRACE_CAPTURE", raising=False)
|
|
220
|
+
monkeypatch.setattr(auth_module, "get_policy", lambda: ActorHeaderPolicy())
|
|
221
|
+
reset_api_policy()
|
|
222
|
+
SENT.clear()
|
|
223
|
+
user = f"alice-{uuid.uuid4().hex[:8]}" # the A2A task store lives as long as the app
|
|
224
|
+
headers = {
|
|
225
|
+
"person": {"X-User": user},
|
|
226
|
+
"concierge": {"X-User": user, "X-Actor": "concierge"},
|
|
227
|
+
}
|
|
228
|
+
async with app.router.lifespan_context(app):
|
|
229
|
+
use_test_tools(cancel_order, echo_origin)
|
|
230
|
+
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
|
231
|
+
clients = {
|
|
232
|
+
name: httpx.AsyncClient(
|
|
233
|
+
transport=transport, base_url="http://testserver", headers=value, timeout=30
|
|
234
|
+
)
|
|
235
|
+
for name, value in headers.items()
|
|
236
|
+
}
|
|
237
|
+
try:
|
|
238
|
+
started = asyncio.get_running_loop().time()
|
|
239
|
+
while (await clients["person"].get("/ready")).status_code != 200: # the schema
|
|
240
|
+
assert asyncio.get_running_loop().time() - started < 30, "not ready"
|
|
241
|
+
await asyncio.sleep(0.1)
|
|
242
|
+
yield {**clients, "database": database, "user": user}
|
|
243
|
+
finally:
|
|
244
|
+
for client in clients.values():
|
|
245
|
+
await client.aclose()
|
|
246
|
+
reset_api_policy()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
async def _rpc(client: httpx.AsyncClient, method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
250
|
+
r = await client.post(
|
|
251
|
+
A2A_PATH,
|
|
252
|
+
json={"jsonrpc": "2.0", "id": uuid.uuid4().hex, "method": method, "params": params},
|
|
253
|
+
headers={"A2A-Version": "1.0"},
|
|
254
|
+
)
|
|
255
|
+
assert r.status_code == 200, r.text
|
|
256
|
+
return r.json()
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _message(
|
|
260
|
+
*parts: dict[str, Any], task: dict[str, Any] | None = None, **fields: Any
|
|
261
|
+
) -> dict[str, Any]:
|
|
262
|
+
message: dict[str, Any] = {"messageId": uuid.uuid4().hex, "role": "ROLE_USER", "parts": parts}
|
|
263
|
+
if task is not None:
|
|
264
|
+
message.update(taskId=task["id"], contextId=task["contextId"])
|
|
265
|
+
message.update(fields)
|
|
266
|
+
return {"message": message}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _data_parts(task: dict[str, Any], kind: str) -> list[dict[str, Any]]:
|
|
270
|
+
return [
|
|
271
|
+
p["data"]
|
|
272
|
+
for p in task["status"]["message"]["parts"]
|
|
273
|
+
if isinstance(p.get("data"), dict) and p["data"].get("type") == kind
|
|
274
|
+
]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _note(task: dict[str, Any]) -> str:
|
|
278
|
+
return "".join(p.get("text", "") for p in task["status"]["message"]["parts"])
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
async def _paused(orders: dict[str, Any]) -> tuple[dict[str, Any], dict[str, Any]]:
|
|
282
|
+
"""The concierge's request to cancel, paused for the person's approval."""
|
|
283
|
+
sent = await _rpc(orders["concierge"], "SendMessage", _message({"text": PROMPT}))
|
|
284
|
+
task = sent["result"]["task"]
|
|
285
|
+
assert task["status"]["state"] == "TASK_STATE_INPUT_REQUIRED", task
|
|
286
|
+
[request] = _data_parts(task, "approval_request")
|
|
287
|
+
[approval] = json.loads(request["approval_json"])
|
|
288
|
+
assert approval["status"] == "pending" and SENT == []
|
|
289
|
+
return task, approval
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _decision(approval: dict[str, Any], decision: str = "approve") -> dict[str, Any]:
|
|
293
|
+
return {
|
|
294
|
+
"data": {
|
|
295
|
+
"approval_id": approval["approval_id"],
|
|
296
|
+
"decision": decision,
|
|
297
|
+
"digest": approval["digest"],
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
async def _stored_tasks(orders: dict[str, Any]) -> list[str]:
|
|
303
|
+
"""Every stored task's JSON text (Postgres); [] under memory."""
|
|
304
|
+
if orders["database"] != "postgres":
|
|
305
|
+
return []
|
|
306
|
+
import psycopg
|
|
307
|
+
|
|
308
|
+
async with await psycopg.AsyncConnection.connect(os.environ["POSTGRES_DSN"]) as conn:
|
|
309
|
+
cursor = await conn.execute("SELECT task::text FROM a2a_tasks")
|
|
310
|
+
return [str(row[0]) for row in await cursor.fetchall()]
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
async def _stored_approvals(orders: dict[str, Any]) -> list[str]:
|
|
314
|
+
"""Every stored approval's payload as JSON text (Postgres); [] under memory."""
|
|
315
|
+
if orders["database"] != "postgres":
|
|
316
|
+
return []
|
|
317
|
+
import psycopg
|
|
318
|
+
|
|
319
|
+
async with await psycopg.AsyncConnection.connect(os.environ["POSTGRES_DSN"]) as conn:
|
|
320
|
+
cursor = await conn.execute("SELECT payload::text FROM approvals")
|
|
321
|
+
return [str(row[0]) for row in await cursor.fetchall()]
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
# --- the origin extension --------------------------------------------------------------------
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
async def test_the_card_declares_the_origin_extension(orders: dict[str, Any]) -> None:
|
|
328
|
+
card = (await orders["person"].get(f"{A2A_PATH}/.well-known/agent-card.json")).json()
|
|
329
|
+
[extension] = card["capabilities"]["extensions"]
|
|
330
|
+
assert extension["uri"] == A2A_ORIGIN_EXTENSION
|
|
331
|
+
assert not extension.get("required") # an optional extension: clients may ignore it
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
async def test_the_users_words_reach_the_run_and_are_never_stored(orders: dict[str, Any]) -> None:
|
|
335
|
+
origin = {"text": "please echo the origin, it is ORD-7", "truncated": False, "hops": 1}
|
|
336
|
+
sent = await _rpc(
|
|
337
|
+
orders["concierge"],
|
|
338
|
+
"SendMessage",
|
|
339
|
+
_message(
|
|
340
|
+
{"text": "Echo the origin"},
|
|
341
|
+
metadata={A2A_ORIGIN_EXTENSION: {"origin": origin}, "other": "kept"},
|
|
342
|
+
),
|
|
343
|
+
)
|
|
344
|
+
task = sent["result"]["task"]
|
|
345
|
+
assert task["status"]["state"] == "TASK_STATE_COMPLETED", task
|
|
346
|
+
reply = "".join(p["text"] for a in task["artifacts"] for p in a["parts"])
|
|
347
|
+
assert json.loads(reply.split("ORIGIN=", 1)[1].rstrip(".").split("\n")[0]) == origin
|
|
348
|
+
# Not stored: neither in the task the caller reads back nor in the table.
|
|
349
|
+
got = await _rpc(orders["concierge"], "GetTask", {"id": task["id"]})
|
|
350
|
+
held = json.dumps(got["result"])
|
|
351
|
+
assert A2A_ORIGIN_EXTENSION not in held and "ORD-7" not in held.split("ORIGIN=")[0]
|
|
352
|
+
assert got["result"]["history"][0]["metadata"] == {"other": "kept"}
|
|
353
|
+
for stored in await _stored_tasks(orders):
|
|
354
|
+
assert A2A_ORIGIN_EXTENSION not in stored
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
async def test_the_users_words_are_capped_at_a2a_origin_max_chars(
|
|
358
|
+
orders: dict[str, Any], monkeypatch: pytest.MonkeyPatch
|
|
359
|
+
) -> None:
|
|
360
|
+
"""Whatever the calling agent sends, the run gets at most A2A_ORIGIN_MAX_CHARS of it."""
|
|
361
|
+
monkeypatch.setenv("A2A_ORIGIN_MAX_CHARS", "12")
|
|
362
|
+
origin = {"text": "please echo the origin, it is ORD-7", "truncated": False, "hops": 1}
|
|
363
|
+
sent = await _rpc(
|
|
364
|
+
orders["concierge"],
|
|
365
|
+
"SendMessage",
|
|
366
|
+
_message({"text": "Echo the origin"}, metadata={A2A_ORIGIN_EXTENSION: {"origin": origin}}),
|
|
367
|
+
)
|
|
368
|
+
task = sent["result"]["task"]
|
|
369
|
+
reply = "".join(p["text"] for a in task["artifacts"] for p in a["parts"])
|
|
370
|
+
seen = json.loads(reply.split("ORIGIN=", 1)[1].rstrip(".").split("\n")[0])
|
|
371
|
+
assert seen == {"text": "please echo ", "truncated": True, "hops": 1}
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
async def test_a_persons_own_metadata_is_not_read_as_forwarded_words(
|
|
375
|
+
orders: dict[str, Any],
|
|
376
|
+
) -> None:
|
|
377
|
+
"""A direct caller's message is the person's own words: no origin is taken from it."""
|
|
378
|
+
origin = {"text": "someone else's words", "truncated": False, "hops": 1}
|
|
379
|
+
sent = await _rpc(
|
|
380
|
+
orders["person"],
|
|
381
|
+
"SendMessage",
|
|
382
|
+
_message({"text": "Echo the origin"}, metadata={A2A_ORIGIN_EXTENSION: {"origin": origin}}),
|
|
383
|
+
)
|
|
384
|
+
task = sent["result"]["task"]
|
|
385
|
+
reply = "".join(p["text"] for a in task["artifacts"] for p in a["parts"])
|
|
386
|
+
assert "ORIGIN=null" in reply
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
async def test_words_forwarded_through_too_many_agents_fail_the_task(
|
|
390
|
+
orders: dict[str, Any],
|
|
391
|
+
) -> None:
|
|
392
|
+
origin = {"text": "cancel ORD-7", "truncated": False, "hops": 4} # the default depth is 3
|
|
393
|
+
sent = await _rpc(
|
|
394
|
+
orders["concierge"],
|
|
395
|
+
"SendMessage",
|
|
396
|
+
_message({"text": PROMPT}, metadata={A2A_ORIGIN_EXTENSION: {"origin": origin}}),
|
|
397
|
+
)
|
|
398
|
+
task = sent["result"]["task"]
|
|
399
|
+
assert task["status"]["state"] == "TASK_STATE_FAILED"
|
|
400
|
+
assert "delegation chain too deep (AUTH_MAX_DELEGATION_DEPTH=3)" in _note(task)
|
|
401
|
+
assert _data_parts(task, "error") == [{"type": "error", "code": "delegation_too_deep"}]
|
|
402
|
+
assert SENT == []
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
@pytest.mark.parametrize("approving_words", ["yes, go ahead", None])
|
|
406
|
+
async def test_the_run_a_decision_resumes_acts_on_the_words_of_the_request_that_paused(
|
|
407
|
+
orders: dict[str, Any], use_test_tools: Any, approving_words: str | None
|
|
408
|
+
) -> None:
|
|
409
|
+
"""A decision an agent relays carries the words the person said when approving there
|
|
410
|
+
("yes, go ahead"), or none at all; the run it resumes still acts on the words of the
|
|
411
|
+
request that paused it. So a check against them holds on the resumed run too, and an
|
|
412
|
+
agent that relays an approval one level further rebuilds the very call the person saw.
|
|
413
|
+
The words are kept with the approval while it waits, and dropped once it is decided."""
|
|
414
|
+
use_test_tools(cancel_checked)
|
|
415
|
+
SEEN_ORIGINS.clear()
|
|
416
|
+
asked = {"text": "please cancel order 7", "truncated": False, "hops": 1}
|
|
417
|
+
sent = await _rpc(
|
|
418
|
+
orders["concierge"],
|
|
419
|
+
"SendMessage",
|
|
420
|
+
_message({"text": PROMPT}, metadata={A2A_ORIGIN_EXTENSION: {"origin": asked}}),
|
|
421
|
+
)
|
|
422
|
+
task = sent["result"]["task"]
|
|
423
|
+
assert task["status"]["state"] == "TASK_STATE_INPUT_REQUIRED", task
|
|
424
|
+
[request] = _data_parts(task, "approval_request")
|
|
425
|
+
[approval] = json.loads(request["approval_json"])
|
|
426
|
+
assert "origin" not in approval and asked["text"] not in json.dumps(request)
|
|
427
|
+
listed = await orders["concierge"].get(f"/threads/{task['contextId']}/approvals")
|
|
428
|
+
assert listed.status_code == 200 and asked["text"] not in listed.text
|
|
429
|
+
fields: dict[str, Any] = {"contextId": task["contextId"], "referenceTaskIds": [task["id"]]}
|
|
430
|
+
if approving_words is not None:
|
|
431
|
+
said = {"text": approving_words, "truncated": False, "hops": 1}
|
|
432
|
+
fields["metadata"] = {A2A_ORIGIN_EXTENSION: {"origin": said}}
|
|
433
|
+
decided = await _rpc(
|
|
434
|
+
orders["concierge"], "SendMessage", _message(_decision(approval), **fields)
|
|
435
|
+
)
|
|
436
|
+
carrier = decided["result"]["task"]
|
|
437
|
+
assert carrier["status"]["state"] == "TASK_STATE_COMPLETED", carrier
|
|
438
|
+
assert [(r.method, r.url.path) for r in SENT] == [("POST", "/orders/7/cancel")]
|
|
439
|
+
assert SEEN_ORIGINS == [asked, asked]
|
|
440
|
+
stored = await _stored_approvals(orders)
|
|
441
|
+
assert all(asked["text"] not in payload for payload in stored)
|
|
442
|
+
if orders["database"] == "postgres":
|
|
443
|
+
assert len(stored) == 1
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# --- what an agent reads: exact values, and why a task failed ---------------------------------
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
async def test_the_approval_request_holds_exact_values_and_shows_the_body(
|
|
450
|
+
orders: dict[str, Any],
|
|
451
|
+
) -> None:
|
|
452
|
+
task, approval = await _paused(orders)
|
|
453
|
+
assert approval["body"] == BODY # approval_json: exact, the big integer included
|
|
454
|
+
[request] = _data_parts(task, "approval_request")
|
|
455
|
+
assert request["approvals"][0]["body"]["amount"] == 1.0 # the Struct: a double
|
|
456
|
+
assert request["approvals"][0]["decide_with"] == "relayed"
|
|
457
|
+
assert '"big": 12345678901234567890' in _note(task)
|
|
458
|
+
assert "until" in _note(task) and approval["digest"] in json.dumps(request)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
async def test_a_busy_thread_fails_the_task_with_an_error_part(
|
|
462
|
+
orders: dict[str, Any], monkeypatch: pytest.MonkeyPatch
|
|
463
|
+
) -> None:
|
|
464
|
+
async def busy(self: Any, *args: Any, **kwargs: Any) -> Any:
|
|
465
|
+
yield chat_module.EVENT_ERROR, chat_module.thread_busy_error()
|
|
466
|
+
|
|
467
|
+
monkeypatch.setattr(chat_module.ChatRuntime, "stream", busy)
|
|
468
|
+
sent = await _rpc(orders["concierge"], "SendMessage", _message({"text": "hello"}))
|
|
469
|
+
task = sent["result"]["task"]
|
|
470
|
+
assert task["status"]["state"] == "TASK_STATE_FAILED"
|
|
471
|
+
assert _data_parts(task, "error") == [{"type": "error", "code": "thread_busy"}]
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
# --- tasks follow the approval's outcome (KI-025) ---------------------------------------------
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
async def test_a_task_follows_a_decision_sent_on_its_context(orders: dict[str, Any]) -> None:
|
|
478
|
+
"""The relay's decision names the context and the task it answers, not the task: it runs
|
|
479
|
+
as a new task, and the task that waited ends as that run did, pointing at it."""
|
|
480
|
+
task, approval = await _paused(orders)
|
|
481
|
+
decided = await _rpc(
|
|
482
|
+
orders["concierge"],
|
|
483
|
+
"SendMessage",
|
|
484
|
+
_message(_decision(approval), contextId=task["contextId"], referenceTaskIds=[task["id"]]),
|
|
485
|
+
)
|
|
486
|
+
carrier = decided["result"]["task"]
|
|
487
|
+
assert carrier["id"] != task["id"]
|
|
488
|
+
assert carrier["status"]["state"] == "TASK_STATE_COMPLETED", carrier
|
|
489
|
+
assert [(r.method, r.url.path) for r in SENT] == [("POST", "/orders/7/cancel")]
|
|
490
|
+
waited = (await _rpc(orders["concierge"], "GetTask", {"id": task["id"]}))["result"]
|
|
491
|
+
assert waited["status"]["state"] == "TASK_STATE_COMPLETED"
|
|
492
|
+
assert _note(waited).startswith(f"Continued in task {carrier['id']}.")
|
|
493
|
+
# The old status (what it waited for) is kept in the history.
|
|
494
|
+
assert any("Waiting for approval" in json.dumps(m) for m in waited["history"])
|
|
495
|
+
|
|
496
|
+
|
|
497
|
+
async def test_a_task_follows_a_rejection(orders: dict[str, Any]) -> None:
|
|
498
|
+
task, approval = await _paused(orders)
|
|
499
|
+
decided = await _rpc(
|
|
500
|
+
orders["concierge"],
|
|
501
|
+
"SendMessage",
|
|
502
|
+
_message(_decision(approval, "reject"), contextId=task["contextId"]),
|
|
503
|
+
)
|
|
504
|
+
carrier = decided["result"]["task"]
|
|
505
|
+
waited = (await _rpc(orders["concierge"], "GetTask", {"id": task["id"]}))["result"]
|
|
506
|
+
assert waited["status"]["state"] == "TASK_STATE_COMPLETED"
|
|
507
|
+
assert _note(waited).startswith(
|
|
508
|
+
f"Approval {approval['approval_id']} was rejected. Continued in task {carrier['id']}."
|
|
509
|
+
)
|
|
510
|
+
assert SENT == []
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
async def test_a_task_follows_a_decision_the_person_takes_over_http(
|
|
514
|
+
orders: dict[str, Any],
|
|
515
|
+
) -> None:
|
|
516
|
+
"""The person approves at this agent directly (the KI-025 case): the agent's task, under
|
|
517
|
+
another owner key than the person's, ends too, with the run's reply."""
|
|
518
|
+
task, approval = await _paused(orders)
|
|
519
|
+
r = await orders["person"].post(
|
|
520
|
+
f"/threads/{task['contextId']}/approvals/{approval['approval_id']}",
|
|
521
|
+
json={"decision": "approve"},
|
|
522
|
+
)
|
|
523
|
+
assert r.status_code == 200 and "message.end" in r.text
|
|
524
|
+
waited = (await _rpc(orders["concierge"], "GetTask", {"id": task["id"]}))["result"]
|
|
525
|
+
assert waited["status"]["state"] == "TASK_STATE_COMPLETED"
|
|
526
|
+
note = _note(waited)
|
|
527
|
+
assert note.startswith(
|
|
528
|
+
f"Approval {approval['approval_id']} was approved outside this task; the run "
|
|
529
|
+
"continued there."
|
|
530
|
+
)
|
|
531
|
+
assert "Here is what I found" in note # the resumed run's reply
|
|
532
|
+
|
|
533
|
+
|
|
534
|
+
async def test_a_task_follows_an_expiry(orders: dict[str, Any]) -> None:
|
|
535
|
+
task, approval = await _paused(orders)
|
|
536
|
+
store = RUNTIME.approvals
|
|
537
|
+
assert store is not None
|
|
538
|
+
later = utcnow() + timedelta(hours=1)
|
|
539
|
+
original = store._clock
|
|
540
|
+
store._clock = lambda: later
|
|
541
|
+
try:
|
|
542
|
+
await RUNTIME.sweep_approvals()
|
|
543
|
+
finally:
|
|
544
|
+
store._clock = original
|
|
545
|
+
waited = (await _rpc(orders["concierge"], "GetTask", {"id": task["id"]}))["result"]
|
|
546
|
+
assert waited["status"]["state"] == "TASK_STATE_FAILED"
|
|
547
|
+
assert _note(waited) == f"Approval {approval['approval_id']} expired before anyone decided."
|
|
548
|
+
assert SENT == []
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
async def test_another_owners_task_on_the_thread_is_left_alone(orders: dict[str, Any]) -> None:
|
|
552
|
+
"""The person asks on their agent's thread while it waits: their task waits too, under
|
|
553
|
+
their own key. The agent's decision ends the agent's task only."""
|
|
554
|
+
task, approval = await _paused(orders)
|
|
555
|
+
asked = await _rpc(
|
|
556
|
+
orders["person"], "SendMessage", _message({"text": "hello"}, contextId=task["contextId"])
|
|
557
|
+
)
|
|
558
|
+
persons = asked["result"]["task"]
|
|
559
|
+
assert persons["status"]["state"] == "TASK_STATE_INPUT_REQUIRED"
|
|
560
|
+
assert _data_parts(persons, "error") == [{"type": "error", "code": "approval_pending"}]
|
|
561
|
+
await _rpc(
|
|
562
|
+
orders["concierge"],
|
|
563
|
+
"SendMessage",
|
|
564
|
+
_message(_decision(approval), contextId=task["contextId"]),
|
|
565
|
+
)
|
|
566
|
+
waited = (await _rpc(orders["concierge"], "GetTask", {"id": task["id"]}))["result"]
|
|
567
|
+
assert waited["status"]["state"] == "TASK_STATE_COMPLETED"
|
|
568
|
+
left = (await _rpc(orders["person"], "GetTask", {"id": persons["id"]}))["result"]
|
|
569
|
+
assert left["status"]["state"] == "TASK_STATE_INPUT_REQUIRED"
|