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,610 @@
|
|
|
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
|
+
"""Building blocks of the runtime's failure handling, without a database.
|
|
16
|
+
|
|
17
|
+
Tool-call history repair, tool calls whose arguments are not valid JSON, the
|
|
18
|
+
step budget, connection defaults and error classification, database health,
|
|
19
|
+
and stopping a run when its lease is lost.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import logging
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import pytest
|
|
29
|
+
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
|
30
|
+
|
|
31
|
+
from {{cookiecutter.agent_directory}}.app_utils import chat, checkpointer, limits, metrics
|
|
32
|
+
from {{cookiecutter.agent_directory}}.app_utils.chat import (
|
|
33
|
+
_lease_lost,
|
|
34
|
+
_Pump,
|
|
35
|
+
dangling_tool_calls,
|
|
36
|
+
repair_tool_history,
|
|
37
|
+
)
|
|
38
|
+
from {{cookiecutter.agent_directory}}.app_utils.content import (
|
|
39
|
+
INVALID_TOOL_CALL_RESULT,
|
|
40
|
+
AnswerInvalidToolCalls,
|
|
41
|
+
UntrustedToolResults,
|
|
42
|
+
)
|
|
43
|
+
from {{cookiecutter.agent_directory}}.app_utils.threads import LeaseLost
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def ai(call_ids: list[str], id: str = "") -> AIMessage:
|
|
47
|
+
return AIMessage(
|
|
48
|
+
content="",
|
|
49
|
+
id=id or f"ai-{'-'.join(call_ids)}",
|
|
50
|
+
tool_calls=[{"id": c, "name": "get_weather", "args": {}} for c in call_ids],
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def tool(call_id: str) -> ToolMessage:
|
|
55
|
+
return ToolMessage(content="sunny", tool_call_id=call_id, id=f"tool-{call_id}")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def human(text: str) -> HumanMessage:
|
|
59
|
+
return HumanMessage(content=text, id=f"h-{text}")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def error_result(call: Any) -> ToolMessage:
|
|
63
|
+
return ToolMessage(content="did not finish", tool_call_id=call["id"], status="error")
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def shape(messages: list[Any]) -> list[str]:
|
|
67
|
+
out = []
|
|
68
|
+
for m in messages:
|
|
69
|
+
if isinstance(m, ToolMessage):
|
|
70
|
+
out.append(f"tool:{m.tool_call_id}:{m.status}")
|
|
71
|
+
elif isinstance(m, AIMessage):
|
|
72
|
+
out.append("ai(" + ",".join(c["id"] for c in m.tool_calls) + ")")
|
|
73
|
+
else:
|
|
74
|
+
out.append("user")
|
|
75
|
+
return out
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# --- tool-call history repair ---------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_a_valid_history_needs_no_repair() -> None:
|
|
82
|
+
history = [human("a"), ai(["c1", "c2"]), tool("c1"), tool("c2"), AIMessage(content="ok")]
|
|
83
|
+
assert repair_tool_history(history, error_result) is None
|
|
84
|
+
assert dangling_tool_calls(history) == []
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_open_calls_at_the_end_are_answered_in_place() -> None:
|
|
88
|
+
"""A run killed between the tool call and its result (the crash / outage case)."""
|
|
89
|
+
history = [human("a"), ai(["c1", "c2"]), tool("c1")]
|
|
90
|
+
repair = repair_tool_history(history, error_result)
|
|
91
|
+
assert repair is not None and repair.append_only
|
|
92
|
+
assert shape(repair.messages) == ["user", "ai(c1,c2)", "tool:c1:success", "tool:c2:error"]
|
|
93
|
+
assert repair.added == repair.messages[-1:]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_a_result_after_the_next_user_turn_is_moved_back() -> None:
|
|
97
|
+
"""The shape the old repair left behind: every later turn got a provider 400."""
|
|
98
|
+
history = [human("a"), ai(["c1"]), human("b"), tool("c1"), human("c")]
|
|
99
|
+
assert [c["id"] for c in dangling_tool_calls(history[:4])] == ["c1"]
|
|
100
|
+
repair = repair_tool_history(history, error_result)
|
|
101
|
+
assert repair is not None and not repair.append_only and repair.added == []
|
|
102
|
+
assert shape(repair.messages) == ["user", "ai(c1)", "tool:c1:success", "user", "user"]
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_open_calls_before_a_later_turn_get_a_result_in_place() -> None:
|
|
106
|
+
history = [human("a"), ai(["c1"]), human("b"), AIMessage(content="hi", id="x")]
|
|
107
|
+
repair = repair_tool_history(history, error_result)
|
|
108
|
+
assert repair is not None and not repair.append_only
|
|
109
|
+
assert shape(repair.messages) == ["user", "ai(c1)", "tool:c1:error", "user", "ai()"]
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_results_that_answer_no_call_are_dropped() -> None:
|
|
113
|
+
history = [human("a"), tool("orphan"), AIMessage(content="hi", id="x")]
|
|
114
|
+
repair = repair_tool_history(history, error_result)
|
|
115
|
+
assert repair is not None
|
|
116
|
+
assert shape(repair.messages) == ["user", "ai()"]
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_repair_works_on_server_dicts() -> None:
|
|
120
|
+
history: list[Any] = [
|
|
121
|
+
{"type": "human", "id": "m1", "content": "a"},
|
|
122
|
+
{"type": "ai", "id": "m2", "content": "", "tool_calls": [{"id": "c1", "name": "t"}]},
|
|
123
|
+
{"type": "human", "id": "m3", "content": "b"},
|
|
124
|
+
{"type": "tool", "id": "m4", "tool_call_id": "c1", "content": "x"},
|
|
125
|
+
]
|
|
126
|
+
repair = repair_tool_history(history, lambda call: {"type": "tool", "id": "new"})
|
|
127
|
+
assert repair is not None
|
|
128
|
+
assert [m["id"] for m in repair.messages] == ["m1", "m2", "m4", "m3"]
|
|
129
|
+
kept = chat._server_message(
|
|
130
|
+
{**history[1], "usage_metadata": {"input_tokens": 1}, "invalid_tool_calls": []}
|
|
131
|
+
)
|
|
132
|
+
assert set(kept) == {"type", "content", "id", "tool_calls"}
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
# --- repeated tool-call ids ------------------------------------------------------------
|
|
136
|
+
# Models reuse ids across turns: the template's fake model always calls `call_get_weather`,
|
|
137
|
+
# and some providers number the calls of each message `call_0`, `call_1`, ...
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def turn(text: str, call_ids: list[str], n: int) -> list[Any]:
|
|
141
|
+
"""One healthy turn: the user, a tool call, its results, the reply."""
|
|
142
|
+
return [
|
|
143
|
+
human(f"{text}{n}"),
|
|
144
|
+
ai(call_ids, id=f"ai-{n}"),
|
|
145
|
+
*(ToolMessage(content=f"r{n}-{c}", tool_call_id=c, id=f"t{n}-{c}") for c in call_ids),
|
|
146
|
+
AIMessage(content=f"reply {n}", id=f"reply-{n}"),
|
|
147
|
+
]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def test_healthy_turns_with_repeated_ids_are_never_rewritten() -> None:
|
|
151
|
+
history = [*turn("sf", ["call_0"], 1), *turn("paris", ["call_0"], 2)]
|
|
152
|
+
history += turn("both", ["call_0", "call_1"], 3)
|
|
153
|
+
assert repair_tool_history(history, error_result) is None
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def test_random_healthy_histories_are_never_rewritten() -> None:
|
|
157
|
+
"""Any mix of turns, ids reused or not, parallel calls or none: nothing to repair."""
|
|
158
|
+
import random
|
|
159
|
+
|
|
160
|
+
rng = random.Random(1234)
|
|
161
|
+
for _ in range(300):
|
|
162
|
+
history: list[Any] = []
|
|
163
|
+
for n in range(rng.randint(1, 6)):
|
|
164
|
+
width = rng.randint(0, 3)
|
|
165
|
+
ids = [
|
|
166
|
+
rng.choice(["call_0", "call_1", "call_get_weather", f"u{n}{i}"])
|
|
167
|
+
for i in range(width)
|
|
168
|
+
]
|
|
169
|
+
ids = list(dict.fromkeys(ids))
|
|
170
|
+
if ids:
|
|
171
|
+
history += turn("q", ids, n)
|
|
172
|
+
else:
|
|
173
|
+
history += [human(f"q{n}"), AIMessage(content="plain", id=f"plain-{n}")]
|
|
174
|
+
assert repair_tool_history(history, error_result) is None, shape(history)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_a_repeated_id_keeps_every_turns_result_when_the_last_call_is_open() -> None:
|
|
178
|
+
"""The regression: turn 2's result was deleted because turn 1 used the same id."""
|
|
179
|
+
history = [*turn("sf", ["call_0"], 1), *turn("paris", ["call_0"], 2)]
|
|
180
|
+
history += [human("again"), ai(["call_0"], id="ai-3")]
|
|
181
|
+
repair = repair_tool_history(history, error_result)
|
|
182
|
+
assert repair is not None and repair.append_only
|
|
183
|
+
assert [m.id for m in repair.messages[: len(history)]] == [m.id for m in history]
|
|
184
|
+
assert shape(repair.messages[-2:]) == ["ai(call_0)", "tool:call_0:error"]
|
|
185
|
+
assert [m.content for m in repair.messages if isinstance(m, ToolMessage)][:2] == [
|
|
186
|
+
"r1-call_0",
|
|
187
|
+
"r2-call_0",
|
|
188
|
+
]
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_a_misplaced_result_goes_back_to_its_own_turn_when_ids_repeat() -> None:
|
|
192
|
+
first = turn("sf", ["call_0"], 1)
|
|
193
|
+
# Turn 2's result landed after the next user message; turn 3 reuses the id.
|
|
194
|
+
history = [
|
|
195
|
+
*first,
|
|
196
|
+
human("paris"),
|
|
197
|
+
ai(["call_0"], id="ai-2"),
|
|
198
|
+
human("hello?"),
|
|
199
|
+
ToolMessage(content="late", tool_call_id="call_0", id="late"),
|
|
200
|
+
*turn("tokyo", ["call_0"], 3),
|
|
201
|
+
]
|
|
202
|
+
repair = repair_tool_history(history, error_result)
|
|
203
|
+
assert repair is not None and not repair.append_only and repair.added == []
|
|
204
|
+
ids = [m.id for m in repair.messages]
|
|
205
|
+
assert ids[ids.index("ai-2") + 1] == "late"
|
|
206
|
+
assert "t1-call_0" in ids and "t3-call_0" in ids # the other turns keep theirs
|
|
207
|
+
assert repair_tool_history(repair.messages, error_result) is None
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_a_later_turns_result_is_never_taken_for_an_earlier_open_call() -> None:
|
|
211
|
+
history = [human("a"), ai(["call_0"], id="ai-1"), human("b"), *turn("c", ["call_0"], 2)]
|
|
212
|
+
repair = repair_tool_history(history, error_result)
|
|
213
|
+
assert repair is not None
|
|
214
|
+
assert shape(repair.messages)[:3] == ["user", "ai(call_0)", "tool:call_0:error"]
|
|
215
|
+
assert "t2-call_0" in [m.id for m in repair.messages]
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
@pytest.mark.parametrize("call_id", ["dup", ""])
|
|
219
|
+
def test_one_id_for_two_calls_of_one_message_keeps_both_results(call_id: str) -> None:
|
|
220
|
+
"""Some OpenAI-compatible servers repeat an id (or send none) within one message."""
|
|
221
|
+
history = turn("sf and rome", [call_id, call_id], 1)
|
|
222
|
+
history[3] = ToolMessage(content="rome", tool_call_id=call_id, id="t1-second")
|
|
223
|
+
assert repair_tool_history(history, error_result) is None
|
|
224
|
+
assert [c["id"] for c in dangling_tool_calls(history[:4])] == []
|
|
225
|
+
# Only one of the two answered: the other gets an error result, the answer stays.
|
|
226
|
+
repair = repair_tool_history(history[:3], error_result)
|
|
227
|
+
assert repair is not None and repair.append_only and len(repair.added) == 1
|
|
228
|
+
assert shape(repair.messages)[2:] == [f"tool:{call_id}:success", f"tool:{call_id}:error"]
|
|
229
|
+
assert [c["id"] for c in dangling_tool_calls(history[:3])] == [call_id]
|
|
230
|
+
# A third result for the id answers no call: dropped.
|
|
231
|
+
extra = ToolMessage(content="again", tool_call_id=call_id, id="t1-third")
|
|
232
|
+
repair = repair_tool_history([*history[:4], extra, history[4]], error_result)
|
|
233
|
+
assert repair is not None and [m.id for m in repair.messages] == [m.id for m in history]
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def invalid_call(call_id: str, raw: str = "{'query': 'SF'}") -> AIMessage:
|
|
237
|
+
"""An assistant message whose only tool call has arguments that are not valid JSON."""
|
|
238
|
+
return AIMessage(
|
|
239
|
+
content="",
|
|
240
|
+
id=f"ai-invalid-{call_id}",
|
|
241
|
+
invalid_tool_calls=[
|
|
242
|
+
{
|
|
243
|
+
"type": "invalid_tool_call",
|
|
244
|
+
"id": call_id,
|
|
245
|
+
"name": "get_weather",
|
|
246
|
+
"args": raw,
|
|
247
|
+
"error": "not valid JSON",
|
|
248
|
+
}
|
|
249
|
+
],
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def test_a_call_with_invalid_arguments_needs_a_result_too() -> None:
|
|
254
|
+
"""LangChain sends invalid calls back to the provider as calls: each needs a result."""
|
|
255
|
+
|
|
256
|
+
def result(call: Any) -> ToolMessage:
|
|
257
|
+
text = chat.open_call_result_text(call, "did not finish")
|
|
258
|
+
return ToolMessage(content=text, tool_call_id=call["id"], status="error")
|
|
259
|
+
|
|
260
|
+
history = [human("a"), invalid_call("call_0"), human("b")]
|
|
261
|
+
repair = repair_tool_history(history, result)
|
|
262
|
+
assert repair is not None and not repair.append_only
|
|
263
|
+
assert shape(repair.messages) == ["user", "ai()", "tool:call_0:error", "user"]
|
|
264
|
+
assert repair.messages[2].content == INVALID_TOOL_CALL_RESULT
|
|
265
|
+
assert repair_tool_history(repair.messages, result) is None
|
|
266
|
+
assert [c["id"] for c in dangling_tool_calls(history[:2])] == ["call_0"]
|
|
267
|
+
# A valid call next to it keeps the stop reason.
|
|
268
|
+
both = invalid_call("call_1")
|
|
269
|
+
both.tool_calls = [{"id": "call_0", "name": "get_weather", "args": {}}]
|
|
270
|
+
repair = repair_tool_history([human("a"), both], result)
|
|
271
|
+
assert repair is not None and repair.append_only
|
|
272
|
+
assert [m.content for m in repair.added] == ["did not finish", INVALID_TOOL_CALL_RESULT]
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def test_the_server_rebuilds_an_invalid_call_as_a_call_its_result_answers() -> None:
|
|
276
|
+
"""The server has no key for `invalid_tool_calls`: kept as a call with no arguments."""
|
|
277
|
+
message = invalid_call("call_0").model_dump()
|
|
278
|
+
kept = chat._server_message(message)
|
|
279
|
+
assert "invalid_tool_calls" not in kept
|
|
280
|
+
assert kept["tool_calls"] == [
|
|
281
|
+
{"name": "get_weather", "args": {}, "id": "call_0", "type": "tool_call"}
|
|
282
|
+
]
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# --- tool calls whose arguments are not valid JSON --------------------------------------
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def _agent(model: Any, tools: list[Any]) -> Any:
|
|
289
|
+
from langchain.agents import create_agent
|
|
290
|
+
|
|
291
|
+
return create_agent(
|
|
292
|
+
model=model, tools=tools, middleware=[AnswerInvalidToolCalls(), UntrustedToolResults()]
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _weather_tool() -> Any:
|
|
297
|
+
from langchain_core.tools import tool
|
|
298
|
+
|
|
299
|
+
@tool
|
|
300
|
+
def get_weather(query: str) -> str:
|
|
301
|
+
"""Test-only tool: the weather for QUERY."""
|
|
302
|
+
return f"sunny in {query}"
|
|
303
|
+
|
|
304
|
+
return get_weather
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
@pytest.mark.parametrize("script", ["BADARGS", "TRAILINGCOMMA"])
|
|
308
|
+
async def test_invalid_arguments_are_answered_and_the_model_replies(
|
|
309
|
+
openai_compatible: Any, script: str
|
|
310
|
+
) -> None:
|
|
311
|
+
"""Without the answer the run ended with no reply and every later turn was a 400."""
|
|
312
|
+
graph = _agent(openai_compatible.model(), [_weather_tool()])
|
|
313
|
+
state = await graph.ainvoke({"messages": [{"role": "user", "content": f"weather {script}"}]})
|
|
314
|
+
messages = state["messages"]
|
|
315
|
+
assert shape(messages) == ["user", "ai()", "tool:call_0:error", "ai()"]
|
|
316
|
+
assert messages[1].invalid_tool_calls and messages[2].content == INVALID_TOOL_CALL_RESULT
|
|
317
|
+
assert messages[-1].content.startswith("Found: ") and "not valid JSON" in messages[-1].content
|
|
318
|
+
assert openai_compatible.refusals == []
|
|
319
|
+
assert repair_tool_history(messages, error_result) is None
|
|
320
|
+
# The next turn is accepted by the provider.
|
|
321
|
+
state = await graph.ainvoke({"messages": [*messages, HumanMessage("thanks")]})
|
|
322
|
+
assert state["messages"][-1].content == "ok" and openai_compatible.refusals == []
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
async def test_a_model_that_never_gets_the_arguments_right_is_asked_twice_more(
|
|
326
|
+
openai_compatible: Any,
|
|
327
|
+
) -> None:
|
|
328
|
+
graph = _agent(openai_compatible.model(), [_weather_tool()])
|
|
329
|
+
state = await graph.ainvoke({"messages": [{"role": "user", "content": "ALWAYSBAD"}]})
|
|
330
|
+
assert shape(state["messages"]) == [
|
|
331
|
+
"user",
|
|
332
|
+
*(item for n in range(3) for item in ("ai()", f"tool:call_{n}:error")),
|
|
333
|
+
]
|
|
334
|
+
assert len(openai_compatible.requests) == 3 and openai_compatible.refusals == []
|
|
335
|
+
assert repair_tool_history(state["messages"], error_result) is None
|
|
336
|
+
# The three tries take the steps of one plain reply: the step budget counts the same.
|
|
337
|
+
from langgraph.errors import GraphRecursionError
|
|
338
|
+
|
|
339
|
+
budget = 1
|
|
340
|
+
while budget < 20 and isinstance(
|
|
341
|
+
await _outcome(graph.with_config({"recursion_limit": budget}), "hello"),
|
|
342
|
+
GraphRecursionError,
|
|
343
|
+
):
|
|
344
|
+
budget += 1
|
|
345
|
+
state = await _outcome(graph.with_config({"recursion_limit": budget}), "ALWAYSBAD")
|
|
346
|
+
assert not isinstance(state, Exception) and len(state["messages"]) == 7
|
|
347
|
+
|
|
348
|
+
|
|
349
|
+
async def _outcome(graph: Any, text: str) -> Any:
|
|
350
|
+
try:
|
|
351
|
+
return await graph.ainvoke({"messages": [{"role": "user", "content": text}]})
|
|
352
|
+
except Exception as exc:
|
|
353
|
+
return exc
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
async def test_a_valid_call_next_to_an_invalid_one_still_runs(openai_compatible: Any) -> None:
|
|
357
|
+
graph = _agent(openai_compatible.model(), [_weather_tool()])
|
|
358
|
+
state = await graph.ainvoke({"messages": [{"role": "user", "content": "BADANDGOOD"}]})
|
|
359
|
+
results = {m.tool_call_id: m for m in state["messages"] if isinstance(m, ToolMessage)}
|
|
360
|
+
assert results["call_0"].content == "sunny in SF" and results["call_0"].status == "success"
|
|
361
|
+
assert results["call_1"].content == INVALID_TOOL_CALL_RESULT
|
|
362
|
+
assert state["messages"][-1].content.startswith("Found: ")
|
|
363
|
+
assert openai_compatible.refusals == []
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
async def test_one_id_for_two_calls_runs_both_and_the_next_turn_is_accepted(
|
|
367
|
+
openai_compatible: Any,
|
|
368
|
+
) -> None:
|
|
369
|
+
graph = _agent(openai_compatible.model(), [_weather_tool()])
|
|
370
|
+
state = await graph.ainvoke({"messages": [{"role": "user", "content": "DUPIDS"}]})
|
|
371
|
+
results = [m.content for m in state["messages"] if isinstance(m, ToolMessage)]
|
|
372
|
+
assert sorted(results) == ["sunny in Rome", "sunny in SF"]
|
|
373
|
+
assert repair_tool_history(state["messages"], error_result) is None
|
|
374
|
+
state = await graph.ainvoke({"messages": [*state["messages"], HumanMessage("thanks")]})
|
|
375
|
+
assert openai_compatible.refusals == []
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
# --- a tool output UTF-8 cannot encode ---------------------------------------------------
|
|
379
|
+
|
|
380
|
+
LONE_SURROGATE = "\ud800" # what an upstream JSON "\ud800" escape decodes to
|
|
381
|
+
|
|
382
|
+
|
|
383
|
+
async def test_a_tool_output_holding_a_lone_surrogate_reaches_the_model_as_valid_text(
|
|
384
|
+
openai_compatible: Any, monkeypatch: pytest.MonkeyPatch
|
|
385
|
+
) -> None:
|
|
386
|
+
"""The model's next request carried the surrogate, and encoding it failed.
|
|
387
|
+
|
|
388
|
+
The run ended after the tool had acted (`UnicodeEncodeError: ... surrogates
|
|
389
|
+
not allowed`), and under the memory checkpointer every later turn of the
|
|
390
|
+
thread failed the same way.
|
|
391
|
+
"""
|
|
392
|
+
from langchain_core.tools import tool as as_tool
|
|
393
|
+
|
|
394
|
+
runs: list[str] = []
|
|
395
|
+
|
|
396
|
+
@as_tool
|
|
397
|
+
def read_gauge(query: str) -> str:
|
|
398
|
+
"""Test-only tool: the gauge reading for QUERY (it holds a lone surrogate)."""
|
|
399
|
+
runs.append(query)
|
|
400
|
+
return f"gauge at {query}:{LONE_SURROGATE}42"
|
|
401
|
+
|
|
402
|
+
scripts = {**openai_compatible.SCRIPTS, "GAUGE": [("call_0", '{"query": "Oslo"}')]}
|
|
403
|
+
monkeypatch.setattr(openai_compatible, "SCRIPTS", scripts)
|
|
404
|
+
graph = _agent(openai_compatible.model(), [read_gauge])
|
|
405
|
+
state = await graph.ainvoke({"messages": [{"role": "user", "content": "GAUGE please"}]})
|
|
406
|
+
assert runs == ["Oslo"]
|
|
407
|
+
(result,) = [m for m in state["messages"] if isinstance(m, ToolMessage)]
|
|
408
|
+
assert result.content == "gauge at Oslo:\ufffd42"
|
|
409
|
+
assert "gauge at Oslo:\ufffd42" in state["messages"][-1].content
|
|
410
|
+
assert "gauge at Oslo:\ufffd42" in str(openai_compatible.requests[-1]["messages"][-1])
|
|
411
|
+
state = await graph.ainvoke({"messages": [*state["messages"], HumanMessage("thanks")]})
|
|
412
|
+
assert state["messages"][-1].content == "ok" and openai_compatible.refusals == []
|
|
413
|
+
assert runs == ["Oslo"]
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def test_an_sse_event_holding_a_lone_surrogate_is_sent_as_valid_text() -> None:
|
|
417
|
+
"""A lone surrogate anywhere in an event (the model's tool arguments, say) ended the stream."""
|
|
418
|
+
event = chat.sse_encode("tool.call", {"args": {"query": f"x{LONE_SURROGATE}y"}})
|
|
419
|
+
assert event == 'event: tool.call\ndata: {"args": {"query": "x\ufffdy"}}\n\n'
|
|
420
|
+
event.encode("utf-8")
|
|
421
|
+
# Any other character is sent as it is (not as a \u escape).
|
|
422
|
+
text = "caf\u00e9 \u2713 \U0001f600"
|
|
423
|
+
expected = 'event: message.delta\ndata: {"text": "' + text + '"}\n\n'
|
|
424
|
+
assert chat.sse_encode("message.delta", {"text": text}) == expected
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# --- step budget -------------------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def test_the_default_step_budget_fits_the_documented_tool_calls(
|
|
431
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
432
|
+
) -> None:
|
|
433
|
+
monkeypatch.delenv("RECURSION_LIMIT", raising=False)
|
|
434
|
+
assert limits.recursion_limit() == 50
|
|
435
|
+
assert limits.sequential_tool_calls(50) == 24
|
|
436
|
+
assert limits.steps_for_tool_calls(20) == 42
|
|
437
|
+
assert limits.sequential_tool_calls(limits.steps_for_tool_calls(20)) == 20
|
|
438
|
+
assert limits.sequential_tool_calls(1) == 0
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def test_a_policy_limit_beyond_the_step_budget_is_reported_at_startup(
|
|
442
|
+
monkeypatch: pytest.MonkeyPatch, tmp_path: Any, caplog: pytest.LogCaptureFixture
|
|
443
|
+
) -> None:
|
|
444
|
+
from {{cookiecutter.agent_directory}}.app_utils import api_client
|
|
445
|
+
|
|
446
|
+
policy = tmp_path / "api-policy.yaml"
|
|
447
|
+
policy.write_text(
|
|
448
|
+
"apis:\n"
|
|
449
|
+
" orders:\n"
|
|
450
|
+
" base_url_env: ORDERS_URL\n"
|
|
451
|
+
" auth: none\n"
|
|
452
|
+
" allowed_methods: [GET]\n"
|
|
453
|
+
" limits: {max_calls_per_run: 30}\n"
|
|
454
|
+
)
|
|
455
|
+
monkeypatch.setenv("API_POLICY_PATH", str(policy))
|
|
456
|
+
api_client.reset_policy_cache()
|
|
457
|
+
try:
|
|
458
|
+
runtime = chat.ChatRuntime()
|
|
459
|
+
monkeypatch.setenv("RECURSION_LIMIT", "50")
|
|
460
|
+
with caplog.at_level(logging.WARNING):
|
|
461
|
+
runtime._check_step_budget()
|
|
462
|
+
assert "max_calls_per_run=30" in caplog.text and "at least 62" in caplog.text
|
|
463
|
+
caplog.clear()
|
|
464
|
+
monkeypatch.setenv("RECURSION_LIMIT", "62")
|
|
465
|
+
with caplog.at_level(logging.WARNING):
|
|
466
|
+
runtime._check_step_budget()
|
|
467
|
+
assert "max_calls_per_run" not in caplog.text
|
|
468
|
+
finally:
|
|
469
|
+
api_client.reset_policy_cache()
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
# --- connections and database health -------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
def test_connection_defaults_bound_outages_unless_the_dsn_says_otherwise(
|
|
476
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
477
|
+
) -> None:
|
|
478
|
+
monkeypatch.delenv("PGCONNECT_TIMEOUT", raising=False)
|
|
479
|
+
extra = checkpointer.connection_kwargs("postgresql://u:p@db:5432/app")
|
|
480
|
+
assert extra["connect_timeout"] == "5" and extra["keepalives_idle"] == "30"
|
|
481
|
+
extra = checkpointer.connection_kwargs(
|
|
482
|
+
"postgresql://u:p@db:5432/app?connect_timeout=20&keepalives_idle=5"
|
|
483
|
+
)
|
|
484
|
+
assert "connect_timeout" not in extra and "keepalives_idle" not in extra
|
|
485
|
+
assert extra["keepalives_interval"] == "10"
|
|
486
|
+
monkeypatch.setenv("PGCONNECT_TIMEOUT", "9")
|
|
487
|
+
assert "connect_timeout" not in checkpointer.connection_kwargs("host=db dbname=app")
|
|
488
|
+
with pytest.raises(limits.SettingsError):
|
|
489
|
+
checkpointer.connection_kwargs("postgresql://u:p@db:5432/app?nonsense_option=1")
|
|
490
|
+
|
|
491
|
+
|
|
492
|
+
@pytest.mark.parametrize(
|
|
493
|
+
"dsn",
|
|
494
|
+
[
|
|
495
|
+
"postgresql://agent:S3CRETpw%zz@127.0.0.1:5432/app",
|
|
496
|
+
"host=db user=agent password=S3CRETpw dbname",
|
|
497
|
+
"host=db password='S3CRETpw",
|
|
498
|
+
],
|
|
499
|
+
)
|
|
500
|
+
def test_a_dsn_that_does_not_parse_never_shows_its_text(dsn: str) -> None:
|
|
501
|
+
with pytest.raises(limits.SettingsError) as exc:
|
|
502
|
+
checkpointer.connection_kwargs(dsn)
|
|
503
|
+
assert "S3CRET" not in str(exc.value) and "does not parse" in str(exc.value)
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def test_connection_errors_are_told_apart_from_failed_statements() -> None:
|
|
507
|
+
from psycopg import OperationalError, ProgrammingError, errors
|
|
508
|
+
from psycopg_pool import PoolTimeout
|
|
509
|
+
|
|
510
|
+
assert checkpointer.is_connection_error(PoolTimeout("no connection"))
|
|
511
|
+
assert checkpointer.is_connection_error(OperationalError("connection refused"))
|
|
512
|
+
assert checkpointer.is_connection_error(ConnectionRefusedError())
|
|
513
|
+
assert checkpointer.is_connection_error(errors.AdminShutdown("terminating connection"))
|
|
514
|
+
assert not checkpointer.is_connection_error(errors.QueryCanceled("statement timeout"))
|
|
515
|
+
assert not checkpointer.is_connection_error(ProgrammingError("syntax error"))
|
|
516
|
+
assert not checkpointer.is_connection_error(ValueError("x"))
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def test_database_health_changes_are_logged_once_and_shorten_the_wait(
|
|
520
|
+
caplog: pytest.LogCaptureFixture,
|
|
521
|
+
) -> None:
|
|
522
|
+
health = checkpointer.DbHealth()
|
|
523
|
+
assert health.checkout_timeout() == checkpointer.POOL_TIMEOUT_S
|
|
524
|
+
with caplog.at_level(logging.INFO):
|
|
525
|
+
for _ in range(3):
|
|
526
|
+
health.mark_down(OSError("connection refused\nsecond line"))
|
|
527
|
+
assert health.checkout_timeout() == checkpointer.DOWN_POOL_TIMEOUT_S
|
|
528
|
+
assert metrics.DATABASE_UP._value.get() == 0
|
|
529
|
+
health.mark_up()
|
|
530
|
+
health.mark_up()
|
|
531
|
+
assert caplog.text.count("database unreachable: OSError: connection refused") == 1
|
|
532
|
+
assert "second line" not in caplog.text
|
|
533
|
+
assert caplog.text.count("database reachable again") == 1
|
|
534
|
+
assert metrics.DATABASE_UP._value.get() == 1
|
|
535
|
+
|
|
536
|
+
|
|
537
|
+
def test_database_errors_become_a_503_with_one_log_line(
|
|
538
|
+
caplog: pytest.LogCaptureFixture,
|
|
539
|
+
) -> None:
|
|
540
|
+
from fastapi import HTTPException
|
|
541
|
+
from psycopg_pool import PoolTimeout
|
|
542
|
+
|
|
543
|
+
with caplog.at_level(logging.WARNING), pytest.raises(HTTPException) as exc:
|
|
544
|
+
with chat.database_errors():
|
|
545
|
+
raise PoolTimeout("couldn't get a connection after 2.00 sec")
|
|
546
|
+
assert exc.value.status_code == 503 and "Reference: " in exc.value.detail
|
|
547
|
+
(record,) = [r for r in caplog.records if "unavailable" in r.getMessage()]
|
|
548
|
+
assert record.levelno == logging.WARNING and record.exc_info is None
|
|
549
|
+
with pytest.raises(ValueError), chat.database_errors():
|
|
550
|
+
raise ValueError("not a database error") # left to the 500 handler
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
# --- a lost lease stops the run -----------------------------------------------------------
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
async def test_an_interrupted_pump_stops_its_source_and_reports_why() -> None:
|
|
557
|
+
started = asyncio.Event()
|
|
558
|
+
cancelled = asyncio.Event()
|
|
559
|
+
|
|
560
|
+
async def source():
|
|
561
|
+
yield "first"
|
|
562
|
+
started.set()
|
|
563
|
+
try:
|
|
564
|
+
await asyncio.sleep(30)
|
|
565
|
+
except asyncio.CancelledError:
|
|
566
|
+
cancelled.set()
|
|
567
|
+
raise
|
|
568
|
+
yield "never"
|
|
569
|
+
|
|
570
|
+
pump = _Pump(source())
|
|
571
|
+
assert await pump.next(1) == ("item", "first")
|
|
572
|
+
await started.wait()
|
|
573
|
+
lost = LeaseLost("t1", "taken over")
|
|
574
|
+
pump.interrupt(lost)
|
|
575
|
+
assert await pump.next(1) == ("failed", lost)
|
|
576
|
+
await asyncio.wait_for(cancelled.wait(), 1)
|
|
577
|
+
await pump.close()
|
|
578
|
+
|
|
579
|
+
|
|
580
|
+
async def test_a_run_still_stopping_keeps_its_thread_busy() -> None:
|
|
581
|
+
"""A cancelled graph can take a moment to stop: no new run may start on its thread meanwhile."""
|
|
582
|
+
from {{cookiecutter.agent_directory}}.app_utils.threads import ThreadBusy, ThreadLocks
|
|
583
|
+
|
|
584
|
+
locks = ThreadLocks()
|
|
585
|
+
lease = await locks.acquire("t1")
|
|
586
|
+
stopping = asyncio.get_running_loop().create_future()
|
|
587
|
+
lease.hold_until(stopping)
|
|
588
|
+
await lease.release() # the run is over for the client...
|
|
589
|
+
with pytest.raises(ThreadBusy): # ...but its graph has not stopped yet
|
|
590
|
+
await locks.acquire("t1")
|
|
591
|
+
with pytest.raises(LeaseLost): # and whatever it still writes is refused
|
|
592
|
+
locks.fence("t1")
|
|
593
|
+
stopping.set_result(None)
|
|
594
|
+
await asyncio.sleep(0)
|
|
595
|
+
await asyncio.sleep(0)
|
|
596
|
+
again = await locks.acquire("t1")
|
|
597
|
+
await again.release()
|
|
598
|
+
assert locks.held == frozenset()
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def test_a_lease_loss_is_found_behind_the_error_it_caused() -> None:
|
|
602
|
+
lost = LeaseLost("t1", "expired")
|
|
603
|
+
try:
|
|
604
|
+
try:
|
|
605
|
+
raise lost
|
|
606
|
+
except LeaseLost as inner:
|
|
607
|
+
raise RuntimeError("graph failed") from inner
|
|
608
|
+
except RuntimeError as outer:
|
|
609
|
+
assert _lease_lost(outer) is lost
|
|
610
|
+
assert _lease_lost(ValueError("x")) is None
|