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,637 @@
|
|
|
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
|
+
"""Postgres behaviour: schema setup across replicas, run locks, atomic claims, pool healing,
|
|
16
|
+
and the A2A task store (shared by replicas, kept across restarts).
|
|
17
|
+
|
|
18
|
+
Opt-in: set `TEST_POSTGRES_DSN` to the URL of a server where the user may
|
|
19
|
+
create databases (each test gets a fresh one, dropped afterwards), for example
|
|
20
|
+
`postgresql://agent:agent@localhost:5432/postgres`.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import os
|
|
27
|
+
import uuid
|
|
28
|
+
from collections.abc import AsyncIterator
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
import pytest
|
|
32
|
+
from a2a.server.context import ServerCallContext
|
|
33
|
+
from a2a.types import ListTasksRequest, Task, TaskState
|
|
34
|
+
from google.protobuf import json_format
|
|
35
|
+
|
|
36
|
+
from {{cookiecutter.agent_directory}}.app_utils import chat as chat_module
|
|
37
|
+
from {{cookiecutter.agent_directory}}.app_utils.a2a import (
|
|
38
|
+
INTERRUPTED_TASK_TEXT,
|
|
39
|
+
ExpiringTaskStore,
|
|
40
|
+
PostgresTaskStore,
|
|
41
|
+
PrincipalUser,
|
|
42
|
+
)
|
|
43
|
+
from {{cookiecutter.agent_directory}}.app_utils.approvals import ApprovalStore, may_decide
|
|
44
|
+
from {{cookiecutter.agent_directory}}.app_utils.auth import Actor, Principal
|
|
45
|
+
from {{cookiecutter.agent_directory}}.app_utils.checkpointer import POSTGRES, get_checkpointer
|
|
46
|
+
from {{cookiecutter.agent_directory}}.app_utils.db import Database, RunRecord, RunStore
|
|
47
|
+
from {{cookiecutter.agent_directory}}.app_utils.threads import ThreadBusy, ThreadLocks, ThreadStore
|
|
48
|
+
|
|
49
|
+
ADMIN_DSN = os.environ.get("TEST_POSTGRES_DSN", "")
|
|
50
|
+
pytestmark = pytest.mark.skipif(not ADMIN_DSN, reason="TEST_POSTGRES_DSN is not set")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _with_database(dsn: str, name: str) -> str:
|
|
54
|
+
"""The same server URL, pointing at database `name`."""
|
|
55
|
+
from urllib.parse import urlsplit
|
|
56
|
+
|
|
57
|
+
return urlsplit(dsn)._replace(path=f"/{name}").geturl()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@pytest.fixture
|
|
61
|
+
async def dsn(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[str]:
|
|
62
|
+
import psycopg
|
|
63
|
+
|
|
64
|
+
name = f"gac_test_{uuid.uuid4().hex[:12]}"
|
|
65
|
+
async with await psycopg.AsyncConnection.connect(ADMIN_DSN, autocommit=True) as admin:
|
|
66
|
+
await admin.execute(f'CREATE DATABASE "{name}"')
|
|
67
|
+
url = _with_database(ADMIN_DSN, name)
|
|
68
|
+
monkeypatch.setenv("CHECKPOINTER", "postgres")
|
|
69
|
+
monkeypatch.setenv("POSTGRES_DSN", url)
|
|
70
|
+
yield url
|
|
71
|
+
async with await psycopg.AsyncConnection.connect(ADMIN_DSN, autocommit=True) as admin:
|
|
72
|
+
await admin.execute(f'DROP DATABASE IF EXISTS "{name}" WITH (FORCE)')
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def _terminate_other_sessions(dsn: str) -> int:
|
|
76
|
+
import psycopg
|
|
77
|
+
|
|
78
|
+
async with await psycopg.AsyncConnection.connect(dsn, autocommit=True) as conn:
|
|
79
|
+
cur = await conn.execute(
|
|
80
|
+
"SELECT count(pg_terminate_backend(pid)) FROM pg_stat_activity "
|
|
81
|
+
"WHERE datname = current_database() AND pid <> pg_backend_pid()"
|
|
82
|
+
)
|
|
83
|
+
row = await cur.fetchone()
|
|
84
|
+
return int(row[0]) if row else 0
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
async def test_replicas_starting_together_set_up_the_schema_once(dsn: str) -> None:
|
|
88
|
+
async def replica() -> None:
|
|
89
|
+
db = Database(POSTGRES, dsn)
|
|
90
|
+
await db.open()
|
|
91
|
+
try:
|
|
92
|
+
async with get_checkpointer(db.pool) as saver:
|
|
93
|
+
await saver.setup()
|
|
94
|
+
finally:
|
|
95
|
+
await db.close()
|
|
96
|
+
|
|
97
|
+
await asyncio.gather(*(replica() for _ in range(4)))
|
|
98
|
+
db = Database(POSTGRES, dsn)
|
|
99
|
+
await db.open()
|
|
100
|
+
try:
|
|
101
|
+
row = await db.fetchone("SELECT count(*) AS n FROM checkpoint_migrations")
|
|
102
|
+
assert row is not None and row["n"] > 0
|
|
103
|
+
finally:
|
|
104
|
+
await db.close()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def test_an_older_schema_is_migrated_in_place(dsn: str) -> None:
|
|
108
|
+
import psycopg
|
|
109
|
+
|
|
110
|
+
async with await psycopg.AsyncConnection.connect(dsn, autocommit=True) as conn:
|
|
111
|
+
await conn.execute(
|
|
112
|
+
"CREATE TABLE runs (run_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, "
|
|
113
|
+
"principal_hash TEXT NOT NULL, model TEXT, status TEXT NOT NULL, "
|
|
114
|
+
"input_tokens INTEGER, output_tokens INTEGER, latency_ms INTEGER, "
|
|
115
|
+
"error_type TEXT, payload JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT now())"
|
|
116
|
+
)
|
|
117
|
+
await conn.execute(
|
|
118
|
+
"CREATE TABLE threads (thread_id TEXT PRIMARY KEY, principal_id TEXT NOT NULL, "
|
|
119
|
+
"tenant TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now())"
|
|
120
|
+
)
|
|
121
|
+
await conn.execute("INSERT INTO threads (thread_id, principal_id) VALUES ('old', 'a')")
|
|
122
|
+
db = Database(POSTGRES, dsn)
|
|
123
|
+
await db.open()
|
|
124
|
+
try:
|
|
125
|
+
store = ThreadStore(db)
|
|
126
|
+
record = await store.get("old")
|
|
127
|
+
assert record is not None and record.updated_at
|
|
128
|
+
runs = RunStore(db)
|
|
129
|
+
await runs.record(
|
|
130
|
+
RunRecord(
|
|
131
|
+
run_id="r1",
|
|
132
|
+
thread_id="old",
|
|
133
|
+
principal_hash="h",
|
|
134
|
+
model="m",
|
|
135
|
+
status="ok",
|
|
136
|
+
metadata={"source": "web"},
|
|
137
|
+
)
|
|
138
|
+
)
|
|
139
|
+
stored = await runs.get("r1")
|
|
140
|
+
assert stored is not None and stored.metadata == {"source": "web"}
|
|
141
|
+
finally:
|
|
142
|
+
await db.close()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
async def test_the_run_lock_spans_replicas(dsn: str) -> None:
|
|
146
|
+
db = Database(POSTGRES, dsn)
|
|
147
|
+
await db.open() # creates the lease table
|
|
148
|
+
await db.close()
|
|
149
|
+
replica_a, replica_b = ThreadLocks(dsn), ThreadLocks(dsn)
|
|
150
|
+
try:
|
|
151
|
+
lease = await replica_a.acquire("t1")
|
|
152
|
+
with pytest.raises(ThreadBusy):
|
|
153
|
+
await replica_b.acquire("t1")
|
|
154
|
+
other = await replica_b.acquire("t2")
|
|
155
|
+
await lease.release()
|
|
156
|
+
again = await replica_b.acquire("t1")
|
|
157
|
+
await again.release()
|
|
158
|
+
await other.release()
|
|
159
|
+
finally:
|
|
160
|
+
await replica_a.close()
|
|
161
|
+
await replica_b.close()
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def test_claims_are_atomic_across_connections(dsn: str) -> None:
|
|
165
|
+
db = Database(POSTGRES, dsn)
|
|
166
|
+
await db.open()
|
|
167
|
+
try:
|
|
168
|
+
store = ThreadStore(db)
|
|
169
|
+
alice, bob = Principal(id="alice"), Principal(id="bob")
|
|
170
|
+
for i in range(20):
|
|
171
|
+
thread_id = f"race-{i}"
|
|
172
|
+
results = await asyncio.gather(
|
|
173
|
+
store.claim(thread_id, alice), store.claim(thread_id, bob)
|
|
174
|
+
)
|
|
175
|
+
assert sorted(created for _, created in results) == [False, True]
|
|
176
|
+
owners = {record.principal_id for record, _ in results}
|
|
177
|
+
assert len(owners) == 1 # both calls see the same, single owner
|
|
178
|
+
finally:
|
|
179
|
+
await db.close()
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
async def test_pool_and_run_locks_heal_after_connections_are_killed(dsn: str) -> None:
|
|
183
|
+
"""What a Postgres restart or failover does to live connections, without the restart."""
|
|
184
|
+
db = Database(POSTGRES, dsn)
|
|
185
|
+
await db.open()
|
|
186
|
+
locks = ThreadLocks(dsn)
|
|
187
|
+
try:
|
|
188
|
+
await asyncio.gather(*(db.fetchone("SELECT pg_sleep(0.05)") for _ in range(5)))
|
|
189
|
+
lease = await locks.acquire("t1")
|
|
190
|
+
await lease.release()
|
|
191
|
+
assert await _terminate_other_sessions(dsn) >= 2
|
|
192
|
+
for _ in range(5):
|
|
193
|
+
assert await db.fetchone("SELECT 1 AS ok") == {"ok": 1}
|
|
194
|
+
lease = await locks.acquire("t1")
|
|
195
|
+
await lease.release()
|
|
196
|
+
finally:
|
|
197
|
+
await locks.close()
|
|
198
|
+
await db.close()
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
async def test_server_runtime_run_records_use_their_own_table(
|
|
202
|
+
dsn: str, monkeypatch: pytest.MonkeyPatch
|
|
203
|
+
) -> None:
|
|
204
|
+
monkeypatch.setenv("DATABASE_URI", dsn)
|
|
205
|
+
db = Database.for_server()
|
|
206
|
+
await db.open()
|
|
207
|
+
try:
|
|
208
|
+
tables = await db.fetchall(
|
|
209
|
+
"SELECT table_name FROM information_schema.tables WHERE table_schema = 'public'"
|
|
210
|
+
)
|
|
211
|
+
names = {t["table_name"] for t in tables}
|
|
212
|
+
assert "agent_runs" in names and "threads" not in names and "runs" not in names
|
|
213
|
+
runs = RunStore(db)
|
|
214
|
+
await runs.record(
|
|
215
|
+
RunRecord(run_id="r1", thread_id="t", principal_hash="h", model="m", status="ok")
|
|
216
|
+
)
|
|
217
|
+
await runs.delete_for_thread("t")
|
|
218
|
+
assert await runs.get("r1") is None
|
|
219
|
+
finally:
|
|
220
|
+
await db.close()
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
async def test_run_record_pages_reach_every_thread(dsn: str) -> None:
|
|
224
|
+
"""`thread_ids_before` pages in id order, so a sweep reaches ids past the first page."""
|
|
225
|
+
db = Database(POSTGRES, dsn)
|
|
226
|
+
await db.open()
|
|
227
|
+
try:
|
|
228
|
+
runs = RunStore(db)
|
|
229
|
+
old = "2000-01-01T00:00:00+00:00"
|
|
230
|
+
ids = [f"t-{i:02d}" for i in range(7)]
|
|
231
|
+
for n, thread_id in enumerate(ids + ids): # two records per thread
|
|
232
|
+
await runs.record(
|
|
233
|
+
RunRecord(
|
|
234
|
+
run_id=f"r{n}",
|
|
235
|
+
thread_id=thread_id,
|
|
236
|
+
principal_hash="h",
|
|
237
|
+
model="m",
|
|
238
|
+
status="ok",
|
|
239
|
+
created_at=old,
|
|
240
|
+
)
|
|
241
|
+
)
|
|
242
|
+
await runs.record(
|
|
243
|
+
RunRecord(run_id="new", thread_id="t-new", principal_hash="h", model="m", status="ok")
|
|
244
|
+
)
|
|
245
|
+
pages: list[list[str]] = []
|
|
246
|
+
after = None
|
|
247
|
+
while True:
|
|
248
|
+
page = await runs.thread_ids_before("2020-01-01T00:00:00+00:00", after=after, limit=3)
|
|
249
|
+
pages.append(page)
|
|
250
|
+
if len(page) < 3:
|
|
251
|
+
break
|
|
252
|
+
after = page[-1]
|
|
253
|
+
assert [t for page in pages for t in page] == ids # each once, in order, no new one
|
|
254
|
+
finally:
|
|
255
|
+
await db.close()
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
async def test_an_id_with_state_but_no_owner_row_is_never_claimed(dsn: str) -> None:
|
|
259
|
+
db = Database(POSTGRES, dsn)
|
|
260
|
+
await db.open()
|
|
261
|
+
try:
|
|
262
|
+
store = ThreadStore(db)
|
|
263
|
+
alice, bob = Principal(id="alice"), Principal(id="bob")
|
|
264
|
+
|
|
265
|
+
async def orphan_only(thread_id: str) -> bool:
|
|
266
|
+
return thread_id == "orphan"
|
|
267
|
+
|
|
268
|
+
async def always(thread_id: str) -> bool:
|
|
269
|
+
return True
|
|
270
|
+
|
|
271
|
+
with pytest.raises(Exception) as exc:
|
|
272
|
+
await store.ensure("orphan", bob, has_state=orphan_only)
|
|
273
|
+
assert getattr(exc.value, "status_code", None) == 403
|
|
274
|
+
assert await store.get("orphan") is None
|
|
275
|
+
assert (await store.ensure("fresh", alice, has_state=orphan_only)).principal_id == "alice"
|
|
276
|
+
# A thread with an owner row is its owner's to continue, state or not.
|
|
277
|
+
assert (await store.ensure("fresh", alice, has_state=always)).principal_id == "alice"
|
|
278
|
+
with pytest.raises(Exception) as exc:
|
|
279
|
+
await store.ensure("fresh", bob, has_state=always)
|
|
280
|
+
assert getattr(exc.value, "status_code", None) == 403
|
|
281
|
+
finally:
|
|
282
|
+
await db.close()
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# --- A2A tasks ----------------------------------------------------------------------------
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _ctx(owner: str) -> ServerCallContext:
|
|
289
|
+
return ServerCallContext(user=PrincipalUser(owner))
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _a2a_task(
|
|
293
|
+
task_id: str, context_id: str = "c1", state: int = TaskState.TASK_STATE_COMPLETED, **extra: Any
|
|
294
|
+
) -> Task:
|
|
295
|
+
task = Task(id=task_id, context_id=context_id)
|
|
296
|
+
task.status.state = state
|
|
297
|
+
if "second" in extra:
|
|
298
|
+
task.status.timestamp.FromSeconds(1_800_000_000 + extra["second"])
|
|
299
|
+
if "text" in extra:
|
|
300
|
+
artifact = task.artifacts.add()
|
|
301
|
+
artifact.artifact_id = "a1"
|
|
302
|
+
artifact.parts.add().text = extra["text"]
|
|
303
|
+
return task
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
async def _opened(dsn: str) -> Database:
|
|
307
|
+
db = Database(POSTGRES, dsn)
|
|
308
|
+
await db.open() # creates the tables, the A2A task table included
|
|
309
|
+
return db
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
async def test_a2a_tasks_are_shared_by_replicas_and_kept_across_restarts(dsn: str) -> None:
|
|
313
|
+
replica_a, replica_b = await _opened(dsn), await _opened(dsn)
|
|
314
|
+
try:
|
|
315
|
+
store_a = PostgresTaskStore(3600, replica_a)
|
|
316
|
+
store_b = PostgresTaskStore(3600, replica_b)
|
|
317
|
+
task = _a2a_task("t1", "orders-thread.billing" * 3, second=5, text="Order 1 is on its way.")
|
|
318
|
+
await store_a.save(task, _ctx("alice"))
|
|
319
|
+
seen = await store_b.get("t1", _ctx("alice"))
|
|
320
|
+
assert seen is not None
|
|
321
|
+
assert json_format.MessageToDict(seen) == json_format.MessageToDict(task)
|
|
322
|
+
assert await store_b.get("t1", _ctx("bob")) is None # another principal: not found
|
|
323
|
+
assert (await store_b.list(ListTasksRequest(), _ctx("alice"))).total_size == 1
|
|
324
|
+
assert (await store_b.list(ListTasksRequest(), _ctx("bob"))).total_size == 0
|
|
325
|
+
finally:
|
|
326
|
+
await replica_a.close()
|
|
327
|
+
restarted = await _opened(dsn)
|
|
328
|
+
try:
|
|
329
|
+
assert await PostgresTaskStore(3600, restarted).get("t1", _ctx("alice")) is not None
|
|
330
|
+
await replica_b.close()
|
|
331
|
+
finally:
|
|
332
|
+
await restarted.close()
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
async def test_a2a_task_listing_matches_the_in_memory_store(dsn: str) -> None:
|
|
336
|
+
"""Filters, order (newest status first, no timestamp last), pages and totals as the SDK's."""
|
|
337
|
+
db = await _opened(dsn)
|
|
338
|
+
try:
|
|
339
|
+
postgres = PostgresTaskStore(0, db)
|
|
340
|
+
memory = ExpiringTaskStore(0)
|
|
341
|
+
tasks = [
|
|
342
|
+
_a2a_task("t1", "c1", TaskState.TASK_STATE_COMPLETED, second=10),
|
|
343
|
+
_a2a_task("t2", "c1", TaskState.TASK_STATE_INPUT_REQUIRED, second=30),
|
|
344
|
+
_a2a_task("t3", "c2", TaskState.TASK_STATE_COMPLETED, second=20),
|
|
345
|
+
_a2a_task("t4", "c2", TaskState.TASK_STATE_FAILED),
|
|
346
|
+
_a2a_task("t5", "c1", TaskState.TASK_STATE_COMPLETED, second=30),
|
|
347
|
+
_a2a_task("t6", "c1", TaskState.TASK_STATE_COMPLETED),
|
|
348
|
+
]
|
|
349
|
+
for task in tasks:
|
|
350
|
+
for store in (postgres, memory):
|
|
351
|
+
await store.save(task, _ctx("alice"))
|
|
352
|
+
await postgres.save(_a2a_task("t7", "c1", second=40), _ctx("bob"))
|
|
353
|
+
after = _a2a_task("x", second=20).status.timestamp
|
|
354
|
+
requests = [
|
|
355
|
+
ListTasksRequest(),
|
|
356
|
+
ListTasksRequest(context_id="c1"),
|
|
357
|
+
ListTasksRequest(status=TaskState.TASK_STATE_COMPLETED),
|
|
358
|
+
ListTasksRequest(status_timestamp_after=after),
|
|
359
|
+
]
|
|
360
|
+
for request in requests:
|
|
361
|
+
for page_size in (2, 3, 50):
|
|
362
|
+
pages: dict[str, list[list[str]]] = {}
|
|
363
|
+
for name, store in (("postgres", postgres), ("memory", memory)):
|
|
364
|
+
got: list[list[str]] = []
|
|
365
|
+
token = ""
|
|
366
|
+
while True:
|
|
367
|
+
page_request = ListTasksRequest()
|
|
368
|
+
page_request.CopyFrom(request)
|
|
369
|
+
page_request.page_size = page_size
|
|
370
|
+
page_request.page_token = token
|
|
371
|
+
page = await store.list(page_request, _ctx("alice"))
|
|
372
|
+
got.append([t.id for t in page.tasks] + [f"total={page.total_size}"])
|
|
373
|
+
token = page.next_page_token
|
|
374
|
+
if not token:
|
|
375
|
+
break
|
|
376
|
+
pages[name] = got
|
|
377
|
+
assert pages["postgres"] == pages["memory"], (request, page_size)
|
|
378
|
+
finally:
|
|
379
|
+
await db.close()
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
async def test_a_persons_listing_spans_their_agents_keys_as_the_in_memory_store_does(
|
|
383
|
+
dsn: str,
|
|
384
|
+
) -> None:
|
|
385
|
+
"""A person (a direct caller) lists their own tasks and those their agents started for
|
|
386
|
+
them, filtered, ordered and paged as the in-memory store does; no other subject's keys
|
|
387
|
+
(a longer or shorter id, an agent named like the person) come in."""
|
|
388
|
+
db = await _opened(dsn)
|
|
389
|
+
try:
|
|
390
|
+
postgres = PostgresTaskStore(0, db)
|
|
391
|
+
memory = ExpiringTaskStore(0)
|
|
392
|
+
rows = [
|
|
393
|
+
("alice", _a2a_task("t1", "c1", TaskState.TASK_STATE_COMPLETED, second=10)),
|
|
394
|
+
("alice\x1fconcierge", _a2a_task("t2", "c1", TaskState.TASK_STATE_WORKING, second=30)),
|
|
395
|
+
("alice\x1fbilling", _a2a_task("t3", "c2", TaskState.TASK_STATE_COMPLETED, second=20)),
|
|
396
|
+
("alice\x1fclient:web", _a2a_task("t4", "c2", TaskState.TASK_STATE_FAILED)),
|
|
397
|
+
(
|
|
398
|
+
"alice\x1fconcierge",
|
|
399
|
+
_a2a_task("t5", "c1", TaskState.TASK_STATE_COMPLETED, second=30),
|
|
400
|
+
),
|
|
401
|
+
("alicex", _a2a_task("x1", "c1", second=40)),
|
|
402
|
+
("alicex\x1fconcierge", _a2a_task("x2", "c1", second=40)),
|
|
403
|
+
("alic", _a2a_task("x3", "c1", second=40)),
|
|
404
|
+
("bob\x1falice", _a2a_task("x4", "c1", second=40)),
|
|
405
|
+
]
|
|
406
|
+
for owner, task in rows:
|
|
407
|
+
for store in (postgres, memory):
|
|
408
|
+
await store.save(task, _ctx(owner))
|
|
409
|
+
person = ServerCallContext(user=PrincipalUser("alice", subject="alice"))
|
|
410
|
+
after = _a2a_task("x", second=20).status.timestamp
|
|
411
|
+
requests = [
|
|
412
|
+
ListTasksRequest(),
|
|
413
|
+
ListTasksRequest(context_id="c1"),
|
|
414
|
+
ListTasksRequest(status=TaskState.TASK_STATE_COMPLETED),
|
|
415
|
+
ListTasksRequest(status_timestamp_after=after),
|
|
416
|
+
]
|
|
417
|
+
for request in requests:
|
|
418
|
+
for page_size in (1, 2, 50):
|
|
419
|
+
pages: dict[str, list[list[str]]] = {}
|
|
420
|
+
for name, store in (("postgres", postgres), ("memory", memory)):
|
|
421
|
+
got: list[list[str]] = []
|
|
422
|
+
token = ""
|
|
423
|
+
while True:
|
|
424
|
+
page_request = ListTasksRequest()
|
|
425
|
+
page_request.CopyFrom(request)
|
|
426
|
+
page_request.page_size = page_size
|
|
427
|
+
page_request.page_token = token
|
|
428
|
+
page = await store.list(page_request, person)
|
|
429
|
+
got.append([t.id for t in page.tasks] + [f"total={page.total_size}"])
|
|
430
|
+
token = page.next_page_token
|
|
431
|
+
if not token:
|
|
432
|
+
break
|
|
433
|
+
pages[name] = got
|
|
434
|
+
assert pages["postgres"] == pages["memory"], (request, page_size)
|
|
435
|
+
whole = await postgres.list(ListTasksRequest(), person)
|
|
436
|
+
assert [t.id for t in whole.tasks] == ["t5", "t2", "t3", "t1", "t4"]
|
|
437
|
+
# The same key without the person's scope: its own tasks only, as before.
|
|
438
|
+
assert [t.id for t in (await postgres.list(ListTasksRequest(), _ctx("alice"))).tasks] == [
|
|
439
|
+
"t1"
|
|
440
|
+
]
|
|
441
|
+
# Whose task is it, among the person's keys (their own first)?
|
|
442
|
+
for store in (postgres, memory):
|
|
443
|
+
assert await store.owner_for_subject("t2", "alice") == "alice\x1fconcierge"
|
|
444
|
+
assert await store.owner_for_subject("t1", "alice") == "alice"
|
|
445
|
+
for other in ("x1", "x2", "x3", "x4", "missing"):
|
|
446
|
+
assert await store.owner_for_subject(other, "alice") is None
|
|
447
|
+
assert await store.owner_for_subject("t2", "alicex") is None
|
|
448
|
+
finally:
|
|
449
|
+
await db.close()
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
async def test_a2a_tasks_expire_and_go_with_their_thread(dsn: str) -> None:
|
|
453
|
+
db = await _opened(dsn)
|
|
454
|
+
try:
|
|
455
|
+
store = PostgresTaskStore(1, db)
|
|
456
|
+
await store.save(_a2a_task("old", "c1"), _ctx("alice"))
|
|
457
|
+
await asyncio.sleep(1.2)
|
|
458
|
+
assert await store.get("old", _ctx("alice")) is None # expired on the database clock
|
|
459
|
+
assert await store.sweep() == (1, 0) # and deleted by the sweep
|
|
460
|
+
store = PostgresTaskStore(3600, db)
|
|
461
|
+
owned = (("alice", "a", "c1"), ("bob", "b", "c1"), ("bob", "c", "c2"))
|
|
462
|
+
for owner, task_id, context in owned:
|
|
463
|
+
await store.save(_a2a_task(task_id, context), _ctx(owner))
|
|
464
|
+
assert await store.delete_context("c1") == 2 # every owner's tasks of that thread
|
|
465
|
+
assert await store.get("c", _ctx("bob")) is not None
|
|
466
|
+
finally:
|
|
467
|
+
await db.close()
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
async def test_a2a_tasks_of_a_dead_run_are_failed_not_left_working(
|
|
471
|
+
dsn: str, monkeypatch: pytest.MonkeyPatch
|
|
472
|
+
) -> None:
|
|
473
|
+
"""A task `working` in a process that died is failed once its thread's lease is gone."""
|
|
474
|
+
monkeypatch.setattr(chat_module, "RECONCILE_GRACE_S", 0.0)
|
|
475
|
+
db = await _opened(dsn)
|
|
476
|
+
locks = ThreadLocks(dsn)
|
|
477
|
+
try:
|
|
478
|
+
store = PostgresTaskStore(3600, db)
|
|
479
|
+
await store.save(_a2a_task("dead", "t-dead", TaskState.TASK_STATE_WORKING), _ctx("alice"))
|
|
480
|
+
await store.save(_a2a_task("live", "t-live", TaskState.TASK_STATE_WORKING), _ctx("alice"))
|
|
481
|
+
await store.save(
|
|
482
|
+
_a2a_task("paused", "t-paused", TaskState.TASK_STATE_INPUT_REQUIRED), _ctx("alice")
|
|
483
|
+
)
|
|
484
|
+
lease = await locks.acquire("t-live") # its run goes on in some replica
|
|
485
|
+
await asyncio.sleep(0.05)
|
|
486
|
+
assert await store.sweep() == (0, 1)
|
|
487
|
+
dead = await store.get("dead", _ctx("alice"))
|
|
488
|
+
assert dead is not None and dead.status.state == TaskState.TASK_STATE_FAILED
|
|
489
|
+
assert dead.status.message.parts[0].text == INTERRUPTED_TASK_TEXT
|
|
490
|
+
live = await store.get("live", _ctx("alice"))
|
|
491
|
+
assert live is not None and live.status.state == TaskState.TASK_STATE_WORKING
|
|
492
|
+
# The lease is not this app's: a CancelTask here is refused (see a2a.py).
|
|
493
|
+
assert await store.running_elsewhere(live) is True
|
|
494
|
+
await lease.release()
|
|
495
|
+
assert await store.running_elsewhere(live) is False
|
|
496
|
+
paused = await store.get("paused", _ctx("alice"))
|
|
497
|
+
assert paused is not None and paused.status.state == TaskState.TASK_STATE_INPUT_REQUIRED
|
|
498
|
+
finally:
|
|
499
|
+
await locks.close()
|
|
500
|
+
await db.close()
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
async def test_a2a_tasks_holding_a_nul_are_saved_with_a_replacement_character(
|
|
504
|
+
dsn: str, monkeypatch: pytest.MonkeyPatch
|
|
505
|
+
) -> None:
|
|
506
|
+
"""Postgres stores no U+0000 (jsonb and TEXT refuse it): the store saves U+FFFD instead.
|
|
507
|
+
|
|
508
|
+
A save that failed on one (a tool's output the reply repeats, say) lost the
|
|
509
|
+
task while its run went on; the sweep then failed it as if its process had died.
|
|
510
|
+
"""
|
|
511
|
+
monkeypatch.setattr(chat_module, "RECONCILE_GRACE_S", 0.0)
|
|
512
|
+
db = await _opened(dsn)
|
|
513
|
+
try:
|
|
514
|
+
store = PostgresTaskStore(3600, db)
|
|
515
|
+
task = _a2a_task("t1", "ab\x00cd", TaskState.TASK_STATE_WORKING, text="reading\x00 42")
|
|
516
|
+
message = task.history.add()
|
|
517
|
+
message.message_id = "m1"
|
|
518
|
+
message.parts.add().text = "tell me\x00 more"
|
|
519
|
+
json_format.ParseDict({"key\x00": ["value\x00"]}, message.parts.add().data)
|
|
520
|
+
json_format.ParseDict({"note": "x\x00"}, message.metadata)
|
|
521
|
+
await store.save(task, _ctx("alice"))
|
|
522
|
+
seen = await store.get("t1", _ctx("alice"))
|
|
523
|
+
assert seen is not None
|
|
524
|
+
assert seen.context_id == "ab\ufffdcd"
|
|
525
|
+
assert seen.artifacts[0].parts[0].text == "reading\ufffd 42"
|
|
526
|
+
assert seen.history[0].parts[0].text == "tell me\ufffd more"
|
|
527
|
+
assert json_format.MessageToDict(seen.history[0].parts[1].data) == {
|
|
528
|
+
"key\ufffd": ["value\ufffd"]
|
|
529
|
+
}
|
|
530
|
+
assert json_format.MessageToDict(seen.history[0].metadata) == {"note": "x\ufffd"}
|
|
531
|
+
# The contextId a caller names finds it; an id holding a U+0000 is not an error.
|
|
532
|
+
listed = await store.list(ListTasksRequest(context_id="ab\x00cd"), _ctx("alice"))
|
|
533
|
+
assert [t.id for t in listed.tasks] == ["t1"]
|
|
534
|
+
assert await store.get("t\x00", _ctx("alice")) is None
|
|
535
|
+
# Its thread has no run: the sweep fails it, rewriting the stored task.
|
|
536
|
+
assert await store.sweep() == (0, 1)
|
|
537
|
+
failed = await store.get("t1", _ctx("alice"))
|
|
538
|
+
assert failed is not None and failed.status.state == TaskState.TASK_STATE_FAILED
|
|
539
|
+
assert failed.artifacts[0].parts[0].text == "reading\ufffd 42"
|
|
540
|
+
finally:
|
|
541
|
+
await db.close()
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
# --- agents calling agents (0.3): the tables upgraded in place, owner-keyed tasks ----------
|
|
545
|
+
|
|
546
|
+
# The approvals table as 0.2 created it.
|
|
547
|
+
APPROVALS_02 = """
|
|
548
|
+
CREATE TABLE approvals (
|
|
549
|
+
approval_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, run_id TEXT NOT NULL,
|
|
550
|
+
interrupt_id TEXT NOT NULL, requester_hash TEXT NOT NULL, requester_context JSONB,
|
|
551
|
+
api TEXT NOT NULL, method TEXT NOT NULL, path TEXT NOT NULL, operation_id TEXT,
|
|
552
|
+
call_hash TEXT NOT NULL, tool_call_id TEXT, message_id TEXT, approvers JSONB NOT NULL,
|
|
553
|
+
payload JSONB NOT NULL, status TEXT NOT NULL, decided_by TEXT, decided_at TIMESTAMPTZ,
|
|
554
|
+
comment TEXT, used_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
555
|
+
expires_at TIMESTAMPTZ NOT NULL
|
|
556
|
+
)
|
|
557
|
+
"""
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
async def test_a_02_schema_gains_the_actor_columns_in_place(dsn: str) -> None:
|
|
561
|
+
"""0.2 tables with rows: after the upgrade the rows read as direct, and the pending
|
|
562
|
+
approval is decided exactly as before."""
|
|
563
|
+
import psycopg
|
|
564
|
+
|
|
565
|
+
alice = Principal(id="alice")
|
|
566
|
+
async with await psycopg.AsyncConnection.connect(dsn, autocommit=True) as conn:
|
|
567
|
+
await conn.execute(APPROVALS_02)
|
|
568
|
+
await conn.execute(
|
|
569
|
+
"INSERT INTO approvals (approval_id, thread_id, run_id, interrupt_id, "
|
|
570
|
+
"requester_hash, api, method, path, call_hash, approvers, payload, status, "
|
|
571
|
+
"expires_at) VALUES ('a1', 't1', 'r1', 'i1', %s, 'shop', 'POST', '/orders/7/cancel', "
|
|
572
|
+
"'h1', '[\"requester\"]', '{\"query\": {}, \"body\": {}}', 'pending', "
|
|
573
|
+
"now() + interval '1 hour')",
|
|
574
|
+
(alice.hashed_id(),),
|
|
575
|
+
)
|
|
576
|
+
await conn.execute(
|
|
577
|
+
"CREATE TABLE threads (thread_id TEXT PRIMARY KEY, principal_id TEXT NOT NULL, "
|
|
578
|
+
"tenant TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), "
|
|
579
|
+
"updated_at TIMESTAMPTZ NOT NULL DEFAULT now())"
|
|
580
|
+
)
|
|
581
|
+
await conn.execute("INSERT INTO threads (thread_id, principal_id) VALUES ('t1', 'alice')")
|
|
582
|
+
await conn.execute(
|
|
583
|
+
"CREATE TABLE runs (run_id TEXT PRIMARY KEY, thread_id TEXT NOT NULL, "
|
|
584
|
+
"principal_hash TEXT NOT NULL, model TEXT, status TEXT NOT NULL, "
|
|
585
|
+
"input_tokens INTEGER, output_tokens INTEGER, latency_ms INTEGER, "
|
|
586
|
+
"error_type TEXT, metadata JSONB, payload JSONB, "
|
|
587
|
+
"created_at TIMESTAMPTZ NOT NULL DEFAULT now())"
|
|
588
|
+
)
|
|
589
|
+
db = await _opened(dsn)
|
|
590
|
+
try:
|
|
591
|
+
thread = await ThreadStore(db).get("t1")
|
|
592
|
+
assert thread is not None and thread.actor == ""
|
|
593
|
+
store = ApprovalStore(db)
|
|
594
|
+
record = await store.get("a1")
|
|
595
|
+
assert record is not None
|
|
596
|
+
assert (record.decide_with, record.relayers, record.requester_actor) == ("direct", [], "")
|
|
597
|
+
assert record.decided_via is None and record.display_digest is None
|
|
598
|
+
assert may_decide(alice, thread, record.approvers)
|
|
599
|
+
agent = Principal(id="alice", actor=Actor(id="concierge"))
|
|
600
|
+
assert not may_decide(agent, thread, record.approvers)
|
|
601
|
+
decided = await store.decide("a1", "approved", alice.hashed_id(), None)
|
|
602
|
+
assert decided is not None and decided.status == "approved" and decided.decided_via is None
|
|
603
|
+
runs = RunStore(db)
|
|
604
|
+
await runs.start(
|
|
605
|
+
RunRecord(
|
|
606
|
+
run_id="r2",
|
|
607
|
+
thread_id="t1",
|
|
608
|
+
principal_hash="h",
|
|
609
|
+
model="m",
|
|
610
|
+
status="ok",
|
|
611
|
+
actor="concierge",
|
|
612
|
+
)
|
|
613
|
+
)
|
|
614
|
+
stored = await runs.get("r2")
|
|
615
|
+
assert stored is not None and stored.actor == "concierge"
|
|
616
|
+
finally:
|
|
617
|
+
await db.close()
|
|
618
|
+
|
|
619
|
+
|
|
620
|
+
async def test_a2a_tasks_are_keyed_by_the_owner_key(dsn: str) -> None:
|
|
621
|
+
"""A delegated owner key (subject, U+001F, actor) is its own owner; direct keys are as
|
|
622
|
+
in 0.2."""
|
|
623
|
+
db = await _opened(dsn)
|
|
624
|
+
try:
|
|
625
|
+
store = PostgresTaskStore(3600, db)
|
|
626
|
+
agent_key = Principal(id="alice", actor=Actor(id="concierge")).owner_key()
|
|
627
|
+
assert agent_key == "alice\x1fconcierge"
|
|
628
|
+
await store.save(_a2a_task("t-agent", "ctx-agent", second=1), _ctx(agent_key))
|
|
629
|
+
await store.save(_a2a_task("t-alice", "ctx-alice", second=2), _ctx("alice"))
|
|
630
|
+
assert await store.get("t-agent", _ctx(agent_key)) is not None
|
|
631
|
+
assert await store.get("t-agent", _ctx("alice")) is None
|
|
632
|
+
assert await store.get("t-agent", _ctx("alice\x1fbilling")) is None
|
|
633
|
+
assert await store.get("t-alice", _ctx(agent_key)) is None
|
|
634
|
+
listed = await store.list(ListTasksRequest(), _ctx(agent_key))
|
|
635
|
+
assert [t.id for t in listed.tasks] == ["t-agent"]
|
|
636
|
+
finally:
|
|
637
|
+
await db.close()
|