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,724 @@
|
|
|
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
|
+
"""A2A tasks are scoped per principal, expire after A2A_TASK_TTL_S, and fail closed.
|
|
16
|
+
|
|
17
|
+
Two principals come from a header test policy swapped in for the selected
|
|
18
|
+
one; the app runs in-process with `MODEL_PROVIDER=fake` and `CHECKPOINTER=memory`.
|
|
19
|
+
Which store serves the tasks (Postgres under a Postgres runtime database) and how
|
|
20
|
+
the Postgres store writes and fails are checked here without a database; the
|
|
21
|
+
store against a real Postgres is in `tests/integration/test_postgres.py`, and
|
|
22
|
+
across real server processes in `tests/integration/test_resilience_postgres.py`.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import asyncio
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import uuid
|
|
31
|
+
from collections.abc import AsyncIterator
|
|
32
|
+
from typing import Any
|
|
33
|
+
|
|
34
|
+
# The environment must be in place before the app (and the graph) is imported.
|
|
35
|
+
os.environ.update(
|
|
36
|
+
{
|
|
37
|
+
"MODEL_PROVIDER": "fake",
|
|
38
|
+
"MODEL_NAME": "fake",
|
|
39
|
+
"CHECKPOINTER": "memory",
|
|
40
|
+
"AUTH_POLICY": "shared-bearer",
|
|
41
|
+
"API_KEY": "test-key",
|
|
42
|
+
"APP_ENV": "dev",
|
|
43
|
+
"TRACING_ENABLED": "false",
|
|
44
|
+
"RUNTIME": "fastapi",
|
|
45
|
+
"APP_URL": "http://testserver",
|
|
46
|
+
}
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
import httpx
|
|
50
|
+
import pytest
|
|
51
|
+
from a2a.client import ClientConfig, create_client
|
|
52
|
+
from a2a.server.context import ServerCallContext
|
|
53
|
+
from a2a.types import (
|
|
54
|
+
CancelTaskRequest,
|
|
55
|
+
GetTaskRequest,
|
|
56
|
+
ListTasksRequest,
|
|
57
|
+
Message,
|
|
58
|
+
Part,
|
|
59
|
+
Role,
|
|
60
|
+
SendMessageConfiguration,
|
|
61
|
+
SendMessageRequest,
|
|
62
|
+
Task,
|
|
63
|
+
TaskState,
|
|
64
|
+
)
|
|
65
|
+
from fastapi import HTTPException
|
|
66
|
+
from google.protobuf import json_format
|
|
67
|
+
from starlette.requests import Request
|
|
68
|
+
|
|
69
|
+
from {{cookiecutter.agent_directory}}.app_utils import a2a as a2a_module
|
|
70
|
+
from {{cookiecutter.agent_directory}}.app_utils import auth as auth_module
|
|
71
|
+
from {{cookiecutter.agent_directory}}.app_utils import chat as chat_module
|
|
72
|
+
from {{cookiecutter.agent_directory}}.app_utils import telemetry
|
|
73
|
+
from {{cookiecutter.agent_directory}}.app_utils.a2a import (
|
|
74
|
+
DEFAULT_TASK_TTL_S,
|
|
75
|
+
ExpiringTaskStore,
|
|
76
|
+
PolicyContextBuilder,
|
|
77
|
+
PostgresTaskStore,
|
|
78
|
+
PrincipalUser,
|
|
79
|
+
RuntimeTaskStore,
|
|
80
|
+
task_owner,
|
|
81
|
+
task_ttl_s,
|
|
82
|
+
)
|
|
83
|
+
from {{cookiecutter.agent_directory}}.app_utils.auth import ACTIONS, Actor, Principal
|
|
84
|
+
from {{cookiecutter.agent_directory}}.app_utils.checkpointer import MEMORY, POSTGRES
|
|
85
|
+
from {{cookiecutter.agent_directory}}.app_utils.db import Database
|
|
86
|
+
from {{cookiecutter.agent_directory}}.app_utils.limits import SettingsError
|
|
87
|
+
from {{cookiecutter.agent_directory}}.fast_api_app import app
|
|
88
|
+
|
|
89
|
+
A2A_PATH = "/a2a/{{cookiecutter.agent_directory}}"
|
|
90
|
+
A2A_URL = f"http://testserver{A2A_PATH}"
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class HeaderPolicy:
|
|
94
|
+
"""Test policy: `X-User` is the principal id."""
|
|
95
|
+
|
|
96
|
+
async def authenticate(self, request: Request) -> Principal:
|
|
97
|
+
user = request.headers.get("x-user")
|
|
98
|
+
if not user:
|
|
99
|
+
raise HTTPException(401, "no user", headers={"WWW-Authenticate": "Bearer"})
|
|
100
|
+
return Principal(id=user, roles=["user"], permissions=set(ACTIONS))
|
|
101
|
+
|
|
102
|
+
async def authorize(self, principal: Principal, action: str, resource: str | None) -> None:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@pytest.fixture
|
|
107
|
+
async def users(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[dict[str, Any]]:
|
|
108
|
+
"""A2A clients for alice and bob; messages containing `slow` keep working for 30 s."""
|
|
109
|
+
monkeypatch.setattr(auth_module, "get_policy", lambda: HeaderPolicy())
|
|
110
|
+
original = chat_module.ChatRuntime.stream
|
|
111
|
+
|
|
112
|
+
async def stream(self: Any, principal: Any, req: Any, thread_id: str) -> Any:
|
|
113
|
+
if "slow" in req.message:
|
|
114
|
+
yield chat_module.EVENT_DELTA, {"text": "working..."}
|
|
115
|
+
await asyncio.sleep(30)
|
|
116
|
+
async for item in original(self, principal, req, thread_id):
|
|
117
|
+
yield item
|
|
118
|
+
|
|
119
|
+
monkeypatch.setattr(chat_module.ChatRuntime, "stream", stream)
|
|
120
|
+
# Principals of their own: the A2A task store lives as long as the app, and
|
|
121
|
+
# other test modules (run first by a plain `pytest`) create tasks for "alice".
|
|
122
|
+
suffix = uuid.uuid4().hex[:8]
|
|
123
|
+
async with app.router.lifespan_context(app):
|
|
124
|
+
https = [
|
|
125
|
+
httpx.AsyncClient(
|
|
126
|
+
transport=httpx.ASGITransport(app=app),
|
|
127
|
+
base_url="http://testserver",
|
|
128
|
+
headers={"X-User": f"{name}-{suffix}"},
|
|
129
|
+
timeout=30,
|
|
130
|
+
)
|
|
131
|
+
for name in ("alice", "bob")
|
|
132
|
+
]
|
|
133
|
+
try:
|
|
134
|
+
clients = {
|
|
135
|
+
name: await create_client(A2A_URL, ClientConfig(streaming=False, httpx_client=h))
|
|
136
|
+
for name, h in zip(("alice", "bob"), https, strict=True)
|
|
137
|
+
}
|
|
138
|
+
clients["http"] = dict(zip(("alice", "bob"), https, strict=True))
|
|
139
|
+
yield clients
|
|
140
|
+
finally:
|
|
141
|
+
for h in https:
|
|
142
|
+
await h.aclose()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _message(text: str, **fields: Any) -> SendMessageRequest:
|
|
146
|
+
return SendMessageRequest(
|
|
147
|
+
message=Message(
|
|
148
|
+
message_id=f"m-{uuid.uuid4()}", role=Role.ROLE_USER, parts=[Part(text=text)], **fields
|
|
149
|
+
)
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
async def _send(client: Any, request: SendMessageRequest) -> Task:
|
|
154
|
+
task = None
|
|
155
|
+
async for chunk in client.send_message(request):
|
|
156
|
+
if chunk.HasField("task"):
|
|
157
|
+
task = chunk.task
|
|
158
|
+
assert task is not None
|
|
159
|
+
return task
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def test_other_principals_cannot_list_get_or_cancel_a_task(users: dict[str, Any]) -> None:
|
|
163
|
+
alice, bob = users["alice"], users["bob"]
|
|
164
|
+
task = await _send(alice, _message("my pin is 9876"))
|
|
165
|
+
assert task.status.state == TaskState.TASK_STATE_COMPLETED
|
|
166
|
+
|
|
167
|
+
own = await alice.list_tasks(ListTasksRequest(include_artifacts=True))
|
|
168
|
+
assert [t.id for t in own.tasks] == [task.id]
|
|
169
|
+
theirs = await bob.list_tasks(ListTasksRequest(include_artifacts=True))
|
|
170
|
+
assert theirs.total_size == 0 and not theirs.tasks
|
|
171
|
+
|
|
172
|
+
assert (await alice.get_task(GetTaskRequest(id=task.id))).id == task.id
|
|
173
|
+
with pytest.raises(Exception, match="not found"):
|
|
174
|
+
await bob.get_task(GetTaskRequest(id=task.id))
|
|
175
|
+
|
|
176
|
+
# The pre-1.0 JSON-RPC surface goes through the same store.
|
|
177
|
+
r = await users["http"]["bob"].post(
|
|
178
|
+
A2A_PATH,
|
|
179
|
+
json={"jsonrpc": "2.0", "id": "1", "method": "tasks/get", "params": {"id": task.id}},
|
|
180
|
+
headers={"A2A-Version": "0.3"},
|
|
181
|
+
)
|
|
182
|
+
body = r.json()
|
|
183
|
+
assert "result" not in body and "9876" not in r.text
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
async def test_cancel_is_owner_only_and_works_for_the_owner(users: dict[str, Any]) -> None:
|
|
187
|
+
alice, bob = users["alice"], users["bob"]
|
|
188
|
+
request = _message("slow job")
|
|
189
|
+
request.configuration.CopyFrom(SendMessageConfiguration(return_immediately=True))
|
|
190
|
+
task = await _send(alice, request)
|
|
191
|
+
await asyncio.sleep(0.2)
|
|
192
|
+
|
|
193
|
+
with pytest.raises(Exception, match="not found"):
|
|
194
|
+
await bob.cancel_task(CancelTaskRequest(id=task.id))
|
|
195
|
+
# Nor can bob attach to its live event stream.
|
|
196
|
+
r = await users["http"]["bob"].post(
|
|
197
|
+
A2A_PATH,
|
|
198
|
+
json={"jsonrpc": "2.0", "id": "2", "method": "SubscribeToTask", "params": {"id": task.id}},
|
|
199
|
+
headers={"A2A-Version": "1.0"},
|
|
200
|
+
)
|
|
201
|
+
assert "not found" in r.text.lower() and "working..." not in r.text
|
|
202
|
+
still = await alice.get_task(GetTaskRequest(id=task.id))
|
|
203
|
+
assert still.status.state == TaskState.TASK_STATE_WORKING
|
|
204
|
+
|
|
205
|
+
canceled = await alice.cancel_task(CancelTaskRequest(id=task.id))
|
|
206
|
+
assert canceled.status.state == TaskState.TASK_STATE_CANCELED
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
async def test_another_principals_task_or_context_cannot_be_continued(
|
|
210
|
+
users: dict[str, Any],
|
|
211
|
+
) -> None:
|
|
212
|
+
alice, bob = users["alice"], users["bob"]
|
|
213
|
+
task = await _send(alice, _message("hello"))
|
|
214
|
+
# Naming alice's task: not found for bob.
|
|
215
|
+
with pytest.raises(Exception, match="not found"):
|
|
216
|
+
await _send(bob, _message("hi", task_id=task.id))
|
|
217
|
+
# Naming alice's context (her thread): refused by thread ownership.
|
|
218
|
+
refused = await _send(bob, _message("hi", context_id=task.context_id))
|
|
219
|
+
assert refused.status.state == TaskState.TASK_STATE_FAILED
|
|
220
|
+
assert "another principal" in refused.status.message.parts[0].text
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
async def test_an_invalid_context_id_fails_the_task(users: dict[str, Any]) -> None:
|
|
224
|
+
task = await _send(users["alice"], _message("hi", context_id="../../etc/passwd"))
|
|
225
|
+
assert task.status.state == TaskState.TASK_STATE_FAILED
|
|
226
|
+
assert "Invalid contextId" in task.status.message.parts[0].text
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# --- the task store ------------------------------------------------------------
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
class Clock:
|
|
233
|
+
def __init__(self) -> None:
|
|
234
|
+
self.now = 100.0
|
|
235
|
+
|
|
236
|
+
def __call__(self) -> float:
|
|
237
|
+
return self.now
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _ctx(owner: str) -> ServerCallContext:
|
|
241
|
+
return ServerCallContext(user=PrincipalUser(owner))
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
async def test_tasks_expire_after_the_ttl() -> None:
|
|
245
|
+
clock = Clock()
|
|
246
|
+
store = ExpiringTaskStore(60, clock=clock)
|
|
247
|
+
await store.save(Task(id="t1", context_id="c1"), _ctx("alice"))
|
|
248
|
+
await store.save(Task(id="t2", context_id="c2"), _ctx("bob"))
|
|
249
|
+
clock.now += 30
|
|
250
|
+
assert await store.get("t1", _ctx("alice")) is not None
|
|
251
|
+
assert await store.get("t1", _ctx("bob")) is None
|
|
252
|
+
await store.save(Task(id="t1", context_id="c1"), _ctx("alice")) # an update restarts the TTL
|
|
253
|
+
clock.now += 45
|
|
254
|
+
assert await store.get("t1", _ctx("alice")) is not None
|
|
255
|
+
assert (await store.list(ListTasksRequest(), _ctx("bob"))).total_size == 0 # t2 expired
|
|
256
|
+
clock.now += 60
|
|
257
|
+
assert await store.get("t1", _ctx("alice")) is None
|
|
258
|
+
assert store._saved_at == {} # nothing left behind in memory
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
async def test_the_sweep_evicts_tasks_nobody_reads_again() -> None:
|
|
262
|
+
clock = Clock()
|
|
263
|
+
store = ExpiringTaskStore(10, clock=clock)
|
|
264
|
+
for i in range(20):
|
|
265
|
+
await store.save(Task(id=f"t{i}", context_id="c"), _ctx(f"user{i}"))
|
|
266
|
+
clock.now += 11
|
|
267
|
+
await store.save(Task(id="new", context_id="c"), _ctx("carol"))
|
|
268
|
+
assert list(store._saved_at) == ["carol"]
|
|
269
|
+
|
|
270
|
+
|
|
271
|
+
async def test_ttl_zero_keeps_tasks(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
272
|
+
clock = Clock()
|
|
273
|
+
store = ExpiringTaskStore(0, clock=clock)
|
|
274
|
+
await store.save(Task(id="t1", context_id="c1"), _ctx("alice"))
|
|
275
|
+
clock.now += 10**9
|
|
276
|
+
assert await store.get("t1", _ctx("alice")) is not None
|
|
277
|
+
monkeypatch.setenv("A2A_TASK_TTL_S", "0")
|
|
278
|
+
assert task_ttl_s() == 0
|
|
279
|
+
for bad in ("-1", "soon", "1.5"):
|
|
280
|
+
# Refused, not replaced by the default: the startup check names it.
|
|
281
|
+
monkeypatch.setenv("A2A_TASK_TTL_S", bad)
|
|
282
|
+
with pytest.raises(SettingsError, match="A2A_TASK_TTL_S"):
|
|
283
|
+
task_ttl_s()
|
|
284
|
+
monkeypatch.delenv("A2A_TASK_TTL_S")
|
|
285
|
+
assert task_ttl_s() == DEFAULT_TASK_TTL_S == 3600
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def test_no_principal_means_no_task_access() -> None:
|
|
289
|
+
with pytest.raises(PermissionError):
|
|
290
|
+
task_owner(ServerCallContext()) # unauthenticated user
|
|
291
|
+
with pytest.raises(PermissionError):
|
|
292
|
+
task_owner(_ctx(""))
|
|
293
|
+
request = Request({"type": "http", "method": "POST", "path": A2A_PATH, "headers": []})
|
|
294
|
+
with pytest.raises(PermissionError):
|
|
295
|
+
PolicyContextBuilder().build_user(request)
|
|
296
|
+
request.state.principal = Principal(id="alice")
|
|
297
|
+
assert PolicyContextBuilder().build_user(request).user_name == "alice"
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def test_the_call_context_does_not_carry_the_credential() -> None:
|
|
301
|
+
headers = [
|
|
302
|
+
(b"authorization", b"Bearer secret-token"),
|
|
303
|
+
(b"cookie", b"sid=secret-session"),
|
|
304
|
+
(b"a2a-version", b"1.0"),
|
|
305
|
+
]
|
|
306
|
+
request = Request({"type": "http", "method": "POST", "path": A2A_PATH, "headers": headers})
|
|
307
|
+
request.state.principal = Principal(id="alice")
|
|
308
|
+
context = PolicyContextBuilder().build(request)
|
|
309
|
+
assert context.user.user_name == "alice"
|
|
310
|
+
assert context.state["principal"].id == "alice"
|
|
311
|
+
assert context.state["headers"] == {"a2a-version": "1.0"}
|
|
312
|
+
assert "secret" not in repr(context)
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
async def test_without_the_policy_override_the_rpc_still_requires_the_key() -> None:
|
|
316
|
+
async with httpx.AsyncClient(
|
|
317
|
+
transport=httpx.ASGITransport(app=app), base_url="http://testserver"
|
|
318
|
+
) as client:
|
|
319
|
+
r = await client.post(A2A_PATH, json={"jsonrpc": "2.0", "id": "1", "method": "ListTasks"})
|
|
320
|
+
assert r.status_code == 401
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
# --- which store, and how the Postgres store writes and fails (no database) --------------
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
class RecordingDb:
|
|
327
|
+
"""Stands in for `Database`: records statements, or fails them with `error`."""
|
|
328
|
+
|
|
329
|
+
a2a_tasks_table = "a2a_tasks"
|
|
330
|
+
locks_table = "thread_locks"
|
|
331
|
+
is_postgres = True
|
|
332
|
+
|
|
333
|
+
def __init__(self, error: Exception | None = None) -> None:
|
|
334
|
+
self.error = error
|
|
335
|
+
self.statements: list[tuple[str, Any]] = []
|
|
336
|
+
|
|
337
|
+
async def _run(self, sql: str, params: Any) -> Any:
|
|
338
|
+
self.statements.append((" ".join(sql.split()), params))
|
|
339
|
+
if self.error is not None:
|
|
340
|
+
raise self.error
|
|
341
|
+
return None
|
|
342
|
+
|
|
343
|
+
async def execute(self, sql: str, params: Any = ()) -> None:
|
|
344
|
+
await self._run(sql, params)
|
|
345
|
+
|
|
346
|
+
async def fetchone(self, sql: str, params: Any = ()) -> Any:
|
|
347
|
+
return await self._run(sql, params)
|
|
348
|
+
|
|
349
|
+
async def fetchall(self, sql: str, params: Any = ()) -> list[Any]:
|
|
350
|
+
return await self._run(sql, params) or []
|
|
351
|
+
|
|
352
|
+
def writes(self) -> list[str]:
|
|
353
|
+
return [sql for sql, _ in self.statements if sql.startswith("INSERT")]
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def _task(state: int, text: str = "", second: int = 0) -> Task:
|
|
357
|
+
task = Task(id="t1", context_id="c1")
|
|
358
|
+
task.status.state = state
|
|
359
|
+
task.status.timestamp.FromSeconds(1_800_000_000 + second)
|
|
360
|
+
if text:
|
|
361
|
+
artifact = task.artifacts.add()
|
|
362
|
+
artifact.artifact_id = "a1"
|
|
363
|
+
artifact.parts.add().text = text
|
|
364
|
+
return task
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
async def test_the_task_store_follows_the_runtime_database(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
368
|
+
"""Postgres (shared by replicas) when the runtime's database is, memory otherwise."""
|
|
369
|
+
runtime = chat_module.RUNTIME
|
|
370
|
+
store = RuntimeTaskStore(60)
|
|
371
|
+
monkeypatch.setattr(runtime, "db", Database(MEMORY))
|
|
372
|
+
assert store.postgres() is None
|
|
373
|
+
db = Database(POSTGRES, "postgresql://agent@db.invalid/agent")
|
|
374
|
+
monkeypatch.setattr(runtime, "db", db)
|
|
375
|
+
monkeypatch.setattr(runtime, "started", True)
|
|
376
|
+
monkeypatch.setattr(runtime, "initialising", True)
|
|
377
|
+
with pytest.raises(HTTPException) as refused: # the schema is not set up yet
|
|
378
|
+
store.postgres()
|
|
379
|
+
assert refused.value.status_code == 503
|
|
380
|
+
monkeypatch.setattr(runtime, "initialising", False)
|
|
381
|
+
postgres = store.postgres()
|
|
382
|
+
assert isinstance(postgres, PostgresTaskStore)
|
|
383
|
+
assert postgres.db is db and postgres.table == "a2a_tasks" and store.postgres() is postgres
|
|
384
|
+
server = Database.for_server()
|
|
385
|
+
assert (server.a2a_tasks_table, db.a2a_tasks_table) == ("agent_a2a_tasks", "a2a_tasks")
|
|
386
|
+
assert "CREATE TABLE IF NOT EXISTS a2a_tasks" in db.ddl()
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
async def test_reply_chunks_are_written_at_most_once_a_second() -> None:
|
|
390
|
+
"""A streamed reply is not one database write per chunk; a status change always is."""
|
|
391
|
+
clock = Clock()
|
|
392
|
+
db = RecordingDb()
|
|
393
|
+
store = PostgresTaskStore(60, db, clock=clock) # type: ignore[arg-type]
|
|
394
|
+
await store.save(_task(TaskState.TASK_STATE_WORKING), _ctx("alice"))
|
|
395
|
+
for text in ("Hel", "Hello", "Hello, wor"):
|
|
396
|
+
clock.now += 0.2
|
|
397
|
+
await store.save(_task(TaskState.TASK_STATE_WORKING, text), _ctx("alice"))
|
|
398
|
+
assert len(db.writes()) == 1
|
|
399
|
+
clock.now += 1
|
|
400
|
+
await store.save(_task(TaskState.TASK_STATE_WORKING, "Hello, world"), _ctx("alice"))
|
|
401
|
+
assert len(db.writes()) == 2
|
|
402
|
+
await store.save(_task(TaskState.TASK_STATE_COMPLETED, "Hello, world!", 1), _ctx("alice"))
|
|
403
|
+
assert len(db.writes()) == 3
|
|
404
|
+
# Another principal's task of the same id is its own.
|
|
405
|
+
await store.save(_task(TaskState.TASK_STATE_WORKING), _ctx("bob"))
|
|
406
|
+
assert len(db.writes()) == 4
|
|
407
|
+
owners = [params[0] for sql, params in db.statements if sql.startswith("INSERT")]
|
|
408
|
+
assert owners == ["alice", "alice", "alice", "bob"]
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
async def test_database_errors_never_reach_the_caller_as_text() -> None:
|
|
412
|
+
import psycopg
|
|
413
|
+
|
|
414
|
+
unreachable = RecordingDb(psycopg.OperationalError("host db.internal:5432"))
|
|
415
|
+
down = PostgresTaskStore(60, unreachable) # type: ignore[arg-type]
|
|
416
|
+
with pytest.raises(HTTPException) as unavailable:
|
|
417
|
+
await down.get("t1", _ctx("alice"))
|
|
418
|
+
assert unavailable.value.status_code == 503
|
|
419
|
+
assert "db.internal" not in str(unavailable.value.detail)
|
|
420
|
+
failing = RecordingDb(RuntimeError("syntax error at SELECT secret"))
|
|
421
|
+
broken = PostgresTaskStore(60, failing) # type: ignore[arg-type]
|
|
422
|
+
with pytest.raises(a2a_module.InternalError) as internal:
|
|
423
|
+
await broken.get("t1", _ctx("alice"))
|
|
424
|
+
assert "Reference" in str(internal.value) and "secret" not in str(internal.value)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def _strings_in(value: Any) -> list[str]:
|
|
428
|
+
if isinstance(value, str):
|
|
429
|
+
return [value]
|
|
430
|
+
if isinstance(value, dict):
|
|
431
|
+
return [s for key, item in value.items() for s in (key, *_strings_in(item))]
|
|
432
|
+
if isinstance(value, list):
|
|
433
|
+
return [s for item in value for s in _strings_in(item)]
|
|
434
|
+
return []
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
async def test_a_nul_character_never_reaches_the_database() -> None:
|
|
438
|
+
"""Postgres stores no U+0000 (jsonb and TEXT refuse it), so the store writes U+FFFD.
|
|
439
|
+
|
|
440
|
+
Everywhere in the task (text, data, metadata, the contextId) and in the ids
|
|
441
|
+
a caller names. A save that failed on one lost the task while its run, and
|
|
442
|
+
whatever its tools did, went on. The owner is never changed.
|
|
443
|
+
"""
|
|
444
|
+
db = RecordingDb()
|
|
445
|
+
store = PostgresTaskStore(60, db) # type: ignore[arg-type]
|
|
446
|
+
task = _task(TaskState.TASK_STATE_COMPLETED, "reading\x00 42, and \\u0000 as text")
|
|
447
|
+
task.context_id = "ab\x00cd"
|
|
448
|
+
message = task.history.add()
|
|
449
|
+
message.message_id = "m1"
|
|
450
|
+
message.parts.add().text = "tell me\x00 more"
|
|
451
|
+
json_format.ParseDict({"key\x00": ["value\x00"]}, message.parts.add().data)
|
|
452
|
+
json_format.ParseDict({"note": "x\x00"}, message.metadata)
|
|
453
|
+
await store.save(task, _ctx("alice"))
|
|
454
|
+
await store.get("t\x00", _ctx("alice"))
|
|
455
|
+
await store.list(ListTasksRequest(context_id="ab\x00cd"), _ctx("alice"))
|
|
456
|
+
await store.delete("t\x00", _ctx("alice"))
|
|
457
|
+
sent = [param for _, params in db.statements for param in params if isinstance(param, str)]
|
|
458
|
+
assert not [param for param in sent if "\x00" in param]
|
|
459
|
+
(insert,) = [params for sql, params in db.statements if sql.startswith("INSERT")]
|
|
460
|
+
stored = json.loads(insert[6])
|
|
461
|
+
assert not [text for text in _strings_in(stored) if "\x00" in text]
|
|
462
|
+
assert stored["contextId"] == "ab\ufffdcd" and insert[2:4] == ("ab\ufffdcd", "ab\ufffdcd")
|
|
463
|
+
assert stored["artifacts"][0]["parts"][0]["text"] == "reading\ufffd 42, and \\u0000 as text"
|
|
464
|
+
assert stored["history"][0]["parts"] == [
|
|
465
|
+
{"text": "tell me\ufffd more"},
|
|
466
|
+
{"data": {"key\ufffd": ["value\ufffd"]}},
|
|
467
|
+
]
|
|
468
|
+
assert stored["history"][0]["metadata"] == {"note": "x\ufffd"}
|
|
469
|
+
lookups = [params for sql, params in db.statements if not sql.startswith("INSERT")]
|
|
470
|
+
assert ("alice", "t\ufffd") in [tuple(params[:2]) for params in lookups]
|
|
471
|
+
assert any("ab\ufffdcd" in params for params in lookups)
|
|
472
|
+
|
|
473
|
+
|
|
474
|
+
def test_the_a2a_defaults_on_record() -> None:
|
|
475
|
+
"""Defaults decided for 0.3 (P4 and the trace scope read them): the user's own words go
|
|
476
|
+
only to peers that declare the origin extension, and under the default trace scope an
|
|
477
|
+
incoming `traceparent` is continued on the A2A routes only."""
|
|
478
|
+
assert a2a_module.DEFAULT_A2A_FORWARD_ORIGIN == "auto"
|
|
479
|
+
assert telemetry.PEERS_INBOUND_TRACE_PREFIX == "/a2a/"
|
|
480
|
+
for path in (a2a_module.A2A_RPC_PATH, a2a_module.A2A_CARD_PATH):
|
|
481
|
+
assert path.startswith(telemetry.PEERS_INBOUND_TRACE_PREFIX)
|
|
482
|
+
assert not "/chat".startswith(telemetry.PEERS_INBOUND_TRACE_PREFIX)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
# --- agents acting for the same user (0.3): tasks are owned by the owner key ---------------
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
class ActorHeaderPolicy:
|
|
489
|
+
"""Test policy: `X-User` is the subject, `X-Actor` (when sent) the agent presenting it."""
|
|
490
|
+
|
|
491
|
+
async def authenticate(self, request: Request) -> Principal:
|
|
492
|
+
user = request.headers.get("x-user")
|
|
493
|
+
if not user:
|
|
494
|
+
raise HTTPException(401, "no user", headers={"WWW-Authenticate": "Bearer"})
|
|
495
|
+
actor = request.headers.get("x-actor")
|
|
496
|
+
return Principal(
|
|
497
|
+
id=user,
|
|
498
|
+
roles=["user"],
|
|
499
|
+
permissions=set(ACTIONS),
|
|
500
|
+
actor=Actor(id=actor) if actor else None,
|
|
501
|
+
)
|
|
502
|
+
|
|
503
|
+
async def authorize(self, principal: Principal, action: str, resource: str | None) -> None:
|
|
504
|
+
return None
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
@pytest.fixture
|
|
508
|
+
async def agents(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[dict[str, Any]]:
|
|
509
|
+
"""A2A clients of one user: directly, and through the agents concierge and billing."""
|
|
510
|
+
monkeypatch.setattr(auth_module, "get_policy", lambda: ActorHeaderPolicy())
|
|
511
|
+
monkeypatch.setenv("AUTH_ALLOWED_ACTORS", "concierge,billing")
|
|
512
|
+
user = f"alice-{uuid.uuid4().hex[:8]}"
|
|
513
|
+
headers = {
|
|
514
|
+
"direct": {"X-User": user},
|
|
515
|
+
"concierge": {"X-User": user, "X-Actor": "concierge"},
|
|
516
|
+
"billing": {"X-User": user, "X-Actor": "billing"},
|
|
517
|
+
}
|
|
518
|
+
async with app.router.lifespan_context(app):
|
|
519
|
+
https = {
|
|
520
|
+
name: httpx.AsyncClient(
|
|
521
|
+
transport=httpx.ASGITransport(app=app),
|
|
522
|
+
base_url="http://testserver",
|
|
523
|
+
headers=value,
|
|
524
|
+
timeout=30,
|
|
525
|
+
)
|
|
526
|
+
for name, value in headers.items()
|
|
527
|
+
}
|
|
528
|
+
try:
|
|
529
|
+
clients: dict[str, Any] = {
|
|
530
|
+
name: await create_client(A2A_URL, ClientConfig(streaming=False, httpx_client=h))
|
|
531
|
+
for name, h in https.items()
|
|
532
|
+
}
|
|
533
|
+
clients["http"] = https
|
|
534
|
+
yield clients
|
|
535
|
+
finally:
|
|
536
|
+
for h in https.values():
|
|
537
|
+
await h.aclose()
|
|
538
|
+
|
|
539
|
+
|
|
540
|
+
def test_the_task_owner_is_the_owner_key() -> None:
|
|
541
|
+
request = Request({"type": "http", "method": "POST", "path": A2A_PATH, "headers": []})
|
|
542
|
+
request.state.principal = Principal(id="alice", actor=Actor(id="concierge"))
|
|
543
|
+
assert PolicyContextBuilder().build_user(request).user_name == "alice\x1fconcierge"
|
|
544
|
+
request.state.principal = Principal(id="alice")
|
|
545
|
+
assert PolicyContextBuilder().build_user(request).user_name == "alice" # as in 0.2
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _store_keys_of(task_id: str) -> list[str]:
|
|
549
|
+
"""The owner keys the (in-memory) task stores of the mounted A2A routes hold `task_id` under."""
|
|
550
|
+
return [
|
|
551
|
+
key
|
|
552
|
+
for store in a2a_module._STORES
|
|
553
|
+
for key, tasks in store.memory._saved_at.items()
|
|
554
|
+
if task_id in tasks
|
|
555
|
+
]
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
async def test_other_actor_cannot_get_list_continue_cancel(agents: dict[str, Any]) -> None:
|
|
559
|
+
concierge, billing, person = agents["concierge"], agents["billing"], agents["direct"]
|
|
560
|
+
task = await _send(concierge, _message("my pin is 9876"))
|
|
561
|
+
assert task.status.state == TaskState.TASK_STATE_COMPLETED
|
|
562
|
+
assert [t.id for t in (await concierge.list_tasks(ListTasksRequest())).tasks] == [task.id]
|
|
563
|
+
# Another agent for the same user: another owner key, nothing to see.
|
|
564
|
+
assert (await billing.list_tasks(ListTasksRequest())).total_size == 0
|
|
565
|
+
with pytest.raises(Exception, match="not found"):
|
|
566
|
+
await billing.get_task(GetTaskRequest(id=task.id))
|
|
567
|
+
with pytest.raises(Exception, match="not found"):
|
|
568
|
+
await billing.cancel_task(CancelTaskRequest(id=task.id))
|
|
569
|
+
with pytest.raises(Exception, match="not found"):
|
|
570
|
+
await _send(billing, _message("hi", task_id=task.id))
|
|
571
|
+
# The person reads and lists the task their agent started for them (the owner's decision
|
|
572
|
+
# of 2026-09-28) ...
|
|
573
|
+
assert [t.id for t in (await person.list_tasks(ListTasksRequest())).tasks] == [task.id]
|
|
574
|
+
got = await person.get_task(GetTaskRequest(id=task.id))
|
|
575
|
+
assert got.id == task.id and got.status.state == TaskState.TASK_STATE_COMPLETED
|
|
576
|
+
# Found (billing's cancel is "not found"); it has ended, so it stays as it is, as for its owner.
|
|
577
|
+
ended = await person.cancel_task(CancelTaskRequest(id=task.id))
|
|
578
|
+
assert (ended.id, ended.status.state) == (task.id, TaskState.TASK_STATE_COMPLETED)
|
|
579
|
+
# ... but continuing it (a message naming it) stays with the concierge.
|
|
580
|
+
with pytest.raises(Exception, match="not found"):
|
|
581
|
+
await _send(person, _message("hi", task_id=task.id))
|
|
582
|
+
# The billing agent sees none of the person's own tasks either.
|
|
583
|
+
own = await _send(person, _message("hello"))
|
|
584
|
+
with pytest.raises(Exception, match="not found"):
|
|
585
|
+
await billing.get_task(GetTaskRequest(id=own.id))
|
|
586
|
+
assert (await billing.list_tasks(ListTasksRequest())).total_size == 0
|
|
587
|
+
assert (await concierge.list_tasks(ListTasksRequest())).total_size == 1
|
|
588
|
+
# The conversation: another agent is refused (no oracle beyond the thread rule) ...
|
|
589
|
+
refused = await _send(billing, _message("hi", context_id=task.context_id))
|
|
590
|
+
assert refused.status.state == TaskState.TASK_STATE_FAILED
|
|
591
|
+
assert "another principal" in refused.status.message.parts[0].text
|
|
592
|
+
# ... while the person, who owns every thread of theirs, may continue it.
|
|
593
|
+
continued = await _send(agents["direct"], _message("hi", context_id=task.context_id))
|
|
594
|
+
assert continued.status.state == TaskState.TASK_STATE_COMPLETED
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
async def test_the_person_cancels_a_task_their_agent_started(
|
|
598
|
+
agents: dict[str, Any], monkeypatch: pytest.MonkeyPatch
|
|
599
|
+
) -> None:
|
|
600
|
+
"""The cancel runs under the task's own owner key: the concierge's task is canceled where it
|
|
601
|
+
is, and no copy appears under the person's key. The agent's live stream stays its own."""
|
|
602
|
+
original = chat_module.ChatRuntime.stream
|
|
603
|
+
|
|
604
|
+
async def stream(self: Any, principal: Any, req: Any, thread_id: str) -> Any:
|
|
605
|
+
if "slow" in req.message: # keeps working for 30 s, as in the `users` fixture
|
|
606
|
+
yield chat_module.EVENT_DELTA, {"text": "working..."}
|
|
607
|
+
await asyncio.sleep(30)
|
|
608
|
+
async for item in original(self, principal, req, thread_id):
|
|
609
|
+
yield item
|
|
610
|
+
|
|
611
|
+
monkeypatch.setattr(chat_module.ChatRuntime, "stream", stream)
|
|
612
|
+
user = agents["http"]["direct"].headers["x-user"]
|
|
613
|
+
request = _message("slow job")
|
|
614
|
+
request.configuration.CopyFrom(SendMessageConfiguration(return_immediately=True))
|
|
615
|
+
task = await _send(agents["concierge"], request)
|
|
616
|
+
await asyncio.sleep(0.2)
|
|
617
|
+
with pytest.raises(Exception, match="not found"):
|
|
618
|
+
await agents["billing"].cancel_task(CancelTaskRequest(id=task.id))
|
|
619
|
+
r = await agents["http"]["direct"].post(
|
|
620
|
+
A2A_PATH,
|
|
621
|
+
json={"jsonrpc": "2.0", "id": "2", "method": "SubscribeToTask", "params": {"id": task.id}},
|
|
622
|
+
headers={"A2A-Version": "1.0"},
|
|
623
|
+
)
|
|
624
|
+
assert "not found" in r.text.lower() and "working..." not in r.text
|
|
625
|
+
working = await agents["direct"].get_task(GetTaskRequest(id=task.id))
|
|
626
|
+
assert working.status.state == TaskState.TASK_STATE_WORKING
|
|
627
|
+
|
|
628
|
+
canceled = await agents["direct"].cancel_task(CancelTaskRequest(id=task.id))
|
|
629
|
+
assert canceled.status.state == TaskState.TASK_STATE_CANCELED
|
|
630
|
+
seen = await agents["concierge"].get_task(GetTaskRequest(id=task.id))
|
|
631
|
+
assert seen.status.state == TaskState.TASK_STATE_CANCELED
|
|
632
|
+
assert _store_keys_of(task.id) == [f"{user}\x1fconcierge"]
|
|
633
|
+
listed = await agents["direct"].list_tasks(ListTasksRequest())
|
|
634
|
+
assert [(t.id, t.status.state) for t in listed.tasks] == [
|
|
635
|
+
(task.id, TaskState.TASK_STATE_CANCELED)
|
|
636
|
+
]
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
async def test_the_persons_list_pages_over_their_own_and_their_agents_tasks(
|
|
640
|
+
agents: dict[str, Any],
|
|
641
|
+
) -> None:
|
|
642
|
+
sent = [
|
|
643
|
+
await _send(agents[name], _message(f"hello from {name}"))
|
|
644
|
+
for name in ("direct", "concierge", "billing", "direct")
|
|
645
|
+
]
|
|
646
|
+
person = agents["direct"]
|
|
647
|
+
whole = await person.list_tasks(ListTasksRequest())
|
|
648
|
+
assert whole.total_size == 4 and {t.id for t in whole.tasks} == {t.id for t in sent}
|
|
649
|
+
# Newest status first, as the SDK's store orders one owner's tasks.
|
|
650
|
+
stamps = [t.status.timestamp.ToJsonString() for t in whole.tasks]
|
|
651
|
+
assert stamps == sorted(stamps, reverse=True)
|
|
652
|
+
pages, token = [], ""
|
|
653
|
+
while True:
|
|
654
|
+
page = await person.list_tasks(ListTasksRequest(page_size=1, page_token=token))
|
|
655
|
+
assert page.total_size == 4 and len(page.tasks) == 1
|
|
656
|
+
pages.append(page.tasks[0].id)
|
|
657
|
+
token = page.next_page_token
|
|
658
|
+
if not token:
|
|
659
|
+
break
|
|
660
|
+
assert pages == [t.id for t in whole.tasks]
|
|
661
|
+
# Filters apply across the keys: one conversation.
|
|
662
|
+
concierges = sent[1]
|
|
663
|
+
by_context = await person.list_tasks(ListTasksRequest(context_id=concierges.context_id))
|
|
664
|
+
assert [t.id for t in by_context.tasks] == [concierges.id]
|
|
665
|
+
with pytest.raises(Exception, match="Invalid page token"):
|
|
666
|
+
await person.list_tasks(ListTasksRequest(page_token="bm90LWEtdGFzaw"))
|
|
667
|
+
# Each agent still lists only its own.
|
|
668
|
+
for name, task in (("concierge", sent[1]), ("billing", sent[2])):
|
|
669
|
+
assert [t.id for t in (await agents[name].list_tasks(ListTasksRequest())).tasks] == [
|
|
670
|
+
task.id
|
|
671
|
+
]
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
async def test_another_person_sees_none_of_them(agents: dict[str, Any]) -> None:
|
|
675
|
+
task = await _send(agents["concierge"], _message("hello"))
|
|
676
|
+
user = agents["http"]["direct"].headers["x-user"]
|
|
677
|
+
for other in (f"{user}x", user[:-1], f"carol-{uuid.uuid4().hex[:8]}"):
|
|
678
|
+
# Neither a longer nor a shorter id, nor another subject, is this person.
|
|
679
|
+
async with httpx.AsyncClient(
|
|
680
|
+
transport=httpx.ASGITransport(app=app),
|
|
681
|
+
base_url="http://testserver",
|
|
682
|
+
headers={"X-User": other},
|
|
683
|
+
timeout=30,
|
|
684
|
+
) as http:
|
|
685
|
+
client = await create_client(A2A_URL, ClientConfig(streaming=False, httpx_client=http))
|
|
686
|
+
assert (await client.list_tasks(ListTasksRequest())).total_size == 0
|
|
687
|
+
with pytest.raises(Exception, match="not found"):
|
|
688
|
+
await client.get_task(GetTaskRequest(id=task.id))
|
|
689
|
+
with pytest.raises(Exception, match="not found"):
|
|
690
|
+
await client.cancel_task(CancelTaskRequest(id=task.id))
|
|
691
|
+
|
|
692
|
+
|
|
693
|
+
def test_a_subject_owns_its_own_and_its_agents_keys_only() -> None:
|
|
694
|
+
assert a2a_module.subject_owns("alice", "alice")
|
|
695
|
+
assert a2a_module.subject_owns("alice", "alice\x1fconcierge")
|
|
696
|
+
assert a2a_module.subject_owns("alice", "alice\x1fclient:web")
|
|
697
|
+
assert not a2a_module.subject_owns("alice", "alicex")
|
|
698
|
+
assert not a2a_module.subject_owns("alice", "alicex\x1fconcierge")
|
|
699
|
+
assert not a2a_module.subject_owns("alice", "alic")
|
|
700
|
+
assert not a2a_module.subject_owns("alice", "bob\x1falice")
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
def test_only_a_direct_caller_reaches_its_agents_tasks() -> None:
|
|
704
|
+
request = Request({"type": "http", "method": "POST", "path": A2A_PATH, "headers": []})
|
|
705
|
+
request.state.principal = Principal(id="alice")
|
|
706
|
+
user = PolicyContextBuilder().build_user(request)
|
|
707
|
+
assert (user.user_name, user.subject) == ("alice", "alice")
|
|
708
|
+
request.state.principal = Principal(id="alice", actor=Actor(id="concierge"))
|
|
709
|
+
user = PolicyContextBuilder().build_user(request)
|
|
710
|
+
assert (user.user_name, user.subject) == ("alice\x1fconcierge", None)
|
|
711
|
+
assert a2a_module.task_subject(ServerCallContext(user=user)) is None
|
|
712
|
+
owner = a2a_module.owner_context(ServerCallContext(user=PrincipalUser("alice")), "a\x1fb")
|
|
713
|
+
assert (task_owner(owner), a2a_module.task_subject(owner)) == ("a\x1fb", None)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
async def test_a_delegated_caller_is_refused_until_listed(
|
|
717
|
+
agents: dict[str, Any], monkeypatch: pytest.MonkeyPatch
|
|
718
|
+
) -> None:
|
|
719
|
+
monkeypatch.setenv("AUTH_ALLOWED_ACTORS", "billing")
|
|
720
|
+
r = await agents["http"]["concierge"].get(f"{A2A_PATH}/.well-known/agent-card.json")
|
|
721
|
+
assert r.status_code == 403
|
|
722
|
+
assert r.json() == {
|
|
723
|
+
"detail": "Delegated caller concierge is not allowed here (AUTH_ALLOWED_ACTORS)."
|
|
724
|
+
}
|