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,673 @@
|
|
|
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
|
+
"""Structured final answers (`app_utils/structured.py`), without a server.
|
|
16
|
+
|
|
17
|
+
The response schema is found and checked (fail closed: a keyword the checker
|
|
18
|
+
does not support stops startup), answers are validated against it, the
|
|
19
|
+
strategy is chosen as LangChain would with a strict provider schema, the fake
|
|
20
|
+
model answers in the shape, and `StructuredAnswer` sends an answer that does
|
|
21
|
+
not fit back to the model, then fails the step. The chat runtime's stream
|
|
22
|
+
mapping keeps the answer and hides the answer tool.
|
|
23
|
+
|
|
24
|
+
The project's own schema, if it declares one, is checked last: the rest of the
|
|
25
|
+
suite runs with `RESPONSE_SCHEMA_PATH=none` (`tests/conftest.py`).
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import subprocess
|
|
33
|
+
import sys
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
import pytest
|
|
38
|
+
from langchain.agents import create_agent
|
|
39
|
+
from langchain.agents.structured_output import ProviderStrategy, ToolStrategy
|
|
40
|
+
from langchain_core.language_models import BaseChatModel
|
|
41
|
+
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
|
42
|
+
from langchain_core.outputs import ChatGeneration, ChatResult
|
|
43
|
+
from langchain_core.tools import tool
|
|
44
|
+
from pydantic import Field
|
|
45
|
+
|
|
46
|
+
from {{cookiecutter.agent_directory}}.app_utils import structured
|
|
47
|
+
from {{cookiecutter.agent_directory}}.app_utils.chat import (
|
|
48
|
+
CODE_INVALID_STRUCTURED_RESPONSE,
|
|
49
|
+
EVENT_DELTA,
|
|
50
|
+
EVENT_TOOL_CALL,
|
|
51
|
+
EVENT_TOOL_RESULT,
|
|
52
|
+
ChatRuntime,
|
|
53
|
+
_RunState,
|
|
54
|
+
_ServerRunError,
|
|
55
|
+
map_stream_item,
|
|
56
|
+
)
|
|
57
|
+
from {{cookiecutter.agent_directory}}.app_utils.limits import SettingsError
|
|
58
|
+
from {{cookiecutter.agent_directory}}.app_utils.model import FakeChatModel, _fake_answer
|
|
59
|
+
from {{cookiecutter.agent_directory}}.app_utils.structured import (
|
|
60
|
+
ANSWER_TOOL,
|
|
61
|
+
StructuredAnswer,
|
|
62
|
+
StructuredAnswerError,
|
|
63
|
+
response_format,
|
|
64
|
+
response_schema,
|
|
65
|
+
response_strategy,
|
|
66
|
+
schema_problems,
|
|
67
|
+
validate,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
SCHEMA: dict[str, Any] = {
|
|
71
|
+
"title": "Weather report",
|
|
72
|
+
"type": "object",
|
|
73
|
+
"properties": {
|
|
74
|
+
"answer": {"type": "string"},
|
|
75
|
+
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
|
|
76
|
+
"city": {"type": ["string", "null"]},
|
|
77
|
+
},
|
|
78
|
+
"required": ["answer", "confidence"],
|
|
79
|
+
"additionalProperties": False,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _write(path: Path, schema: Any) -> Path:
|
|
84
|
+
path.write_text(json.dumps(schema), encoding="utf-8")
|
|
85
|
+
return path
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@pytest.fixture
|
|
89
|
+
def schema_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
|
90
|
+
path = _write(tmp_path / "response_schema.json", SCHEMA)
|
|
91
|
+
monkeypatch.setenv("RESPONSE_SCHEMA_PATH", str(path))
|
|
92
|
+
monkeypatch.delenv("RESPONSE_FORMAT_STRATEGY", raising=False)
|
|
93
|
+
return path
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
# --- the schema -----------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _agent_package(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, schema: Any = None) -> None:
|
|
100
|
+
"""Look for the package's response schema in `tmp_path` (with `schema` in it, if given)."""
|
|
101
|
+
(tmp_path / "app_utils").mkdir()
|
|
102
|
+
monkeypatch.setattr(structured, "__file__", str(tmp_path / "app_utils" / "structured.py"))
|
|
103
|
+
if schema is not None:
|
|
104
|
+
_write(tmp_path / "response_schema.json", schema)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_no_schema_file_means_no_structured_answers(tmp_path, monkeypatch) -> None:
|
|
108
|
+
_agent_package(tmp_path, monkeypatch)
|
|
109
|
+
monkeypatch.delenv("RESPONSE_SCHEMA_PATH", raising=False)
|
|
110
|
+
assert response_schema() is None and not structured.enabled()
|
|
111
|
+
assert response_format(FakeChatModel(), []) is None
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def test_the_agent_packages_schema_file_is_the_default(tmp_path, monkeypatch) -> None:
|
|
115
|
+
_agent_package(tmp_path, monkeypatch, SCHEMA)
|
|
116
|
+
monkeypatch.delenv("RESPONSE_SCHEMA_PATH", raising=False)
|
|
117
|
+
assert response_schema() == SCHEMA and structured.enabled()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@pytest.mark.parametrize("value", ["none", "NONE", " None "])
|
|
121
|
+
def test_response_schema_path_none_switches_the_mode_off(tmp_path, monkeypatch, value) -> None:
|
|
122
|
+
# What the project's tests run with (conftest.py): text answers whatever the project declares.
|
|
123
|
+
_agent_package(tmp_path, monkeypatch, SCHEMA)
|
|
124
|
+
monkeypatch.setenv("RESPONSE_SCHEMA_PATH", value)
|
|
125
|
+
assert response_schema() is None and not structured.enabled()
|
|
126
|
+
assert response_format(FakeChatModel(), []) is None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def test_the_schema_file_is_read_and_checked(schema_file: Path) -> None:
|
|
130
|
+
assert response_schema() == SCHEMA and structured.enabled()
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_a_named_schema_file_that_does_not_exist_stops_startup(tmp_path, monkeypatch) -> None:
|
|
134
|
+
monkeypatch.setenv("RESPONSE_SCHEMA_PATH", str(tmp_path / "missing.json"))
|
|
135
|
+
with pytest.raises(SettingsError, match="RESPONSE_SCHEMA_PATH"):
|
|
136
|
+
response_schema()
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def test_a_file_that_is_not_json_stops_startup(tmp_path, monkeypatch) -> None:
|
|
140
|
+
path = tmp_path / "response_schema.json"
|
|
141
|
+
path.write_text("{'type': 'object'}", encoding="utf-8")
|
|
142
|
+
monkeypatch.setenv("RESPONSE_SCHEMA_PATH", str(path))
|
|
143
|
+
with pytest.raises(SettingsError, match="not a JSON file"):
|
|
144
|
+
response_schema()
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
@pytest.mark.parametrize(
|
|
148
|
+
("schema", "problem"),
|
|
149
|
+
[
|
|
150
|
+
([], "must be a JSON object"),
|
|
151
|
+
({"type": "array", "items": {"type": "string"}}, "root must be an object schema"),
|
|
152
|
+
({"type": ["object", "null"]}, "root must be an object schema"),
|
|
153
|
+
({"type": "object", "if": {}, "then": {}}, "`if` is not a keyword"),
|
|
154
|
+
({"type": "object", "patternProperties": {"^x": {}}}, "`patternProperties`"),
|
|
155
|
+
({"type": "object", "requried": ["a"]}, "`requried` is not a keyword"),
|
|
156
|
+
({"type": "object", "properties": {"a": {"type": "text"}}}, "must name JSON types"),
|
|
157
|
+
({"type": "object", "properties": {"a": {"$ref": "#/$defs/missing"}}}, "does not point"),
|
|
158
|
+
({"type": "object", "properties": {"a": {"$ref": "other.json#/x"}}}, "does not point"),
|
|
159
|
+
({"type": "object", "properties": {"a": {"pattern": "("}}}, "not a regular expression"),
|
|
160
|
+
({"type": "object", "properties": {"a": {"items": [{}, {}]}}}, "a list of schemas"),
|
|
161
|
+
({"type": "object", "properties": {"a": {"minLength": -1}}}, "whole number >= 0"),
|
|
162
|
+
({"type": "object", "properties": {"a": {"multipleOf": 0}}}, "number > 0"),
|
|
163
|
+
({"type": "object", "required": "a"}, "list of property names"),
|
|
164
|
+
({"type": "object", "properties": {"a": {"anyOf": []}}}, "non-empty list of schemas"),
|
|
165
|
+
],
|
|
166
|
+
)
|
|
167
|
+
def test_a_schema_the_checker_cannot_check_is_refused(schema: Any, problem: str) -> None:
|
|
168
|
+
problems = schema_problems(schema)
|
|
169
|
+
assert any(problem in p for p in problems), problems
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def test_a_refused_schema_stops_startup_naming_the_problem(tmp_path, monkeypatch) -> None:
|
|
173
|
+
path = _write(tmp_path / "s.json", {"type": "object", "if": {"type": "object"}})
|
|
174
|
+
monkeypatch.setenv("RESPONSE_SCHEMA_PATH", str(path))
|
|
175
|
+
with pytest.raises(SettingsError, match=r"cannot be the response schema.*`if`"):
|
|
176
|
+
response_schema()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def test_a_supported_schema_passes_the_check() -> None:
|
|
180
|
+
schema = {
|
|
181
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
182
|
+
"type": "object",
|
|
183
|
+
"description": "An order summary.",
|
|
184
|
+
"properties": {
|
|
185
|
+
"order": {"$ref": "#/$defs/order"},
|
|
186
|
+
"tags": {"type": "array", "items": {"type": "string"}, "uniqueItems": True},
|
|
187
|
+
"status": {"enum": ["open", "closed"]},
|
|
188
|
+
"note": {"anyOf": [{"type": "string", "format": "date"}, {"type": "null"}]},
|
|
189
|
+
},
|
|
190
|
+
"required": ["order"],
|
|
191
|
+
"$defs": {
|
|
192
|
+
"order": {
|
|
193
|
+
"type": "object",
|
|
194
|
+
"properties": {"id": {"type": "string", "pattern": "^ORD-[0-9]{5}$"}},
|
|
195
|
+
"required": ["id"],
|
|
196
|
+
}
|
|
197
|
+
},
|
|
198
|
+
}
|
|
199
|
+
assert schema_problems(schema) == []
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
# --- the check --------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
@pytest.mark.parametrize(
|
|
206
|
+
("value", "problem"),
|
|
207
|
+
[
|
|
208
|
+
({"answer": "sunny", "confidence": 0.9}, None),
|
|
209
|
+
({"answer": "sunny", "confidence": 1, "city": None}, None),
|
|
210
|
+
({"answer": "sunny"}, "missing the required property 'confidence'"),
|
|
211
|
+
({"answer": 3, "confidence": 0.5}, "$.answer: must be string, not int"),
|
|
212
|
+
({"answer": "x", "confidence": 2}, "$.confidence: must be <= 1"),
|
|
213
|
+
({"answer": "x", "confidence": True}, "must be number, not bool"),
|
|
214
|
+
({"answer": "x", "confidence": 0.5, "extra": 1}, "'extra' is not allowed"),
|
|
215
|
+
({"answer": "x", "confidence": 0.5, "city": 7}, "must be string or null"),
|
|
216
|
+
([], "$: must be object, not list"),
|
|
217
|
+
],
|
|
218
|
+
)
|
|
219
|
+
def test_answers_are_checked_against_the_schema(value: Any, problem: str | None) -> None:
|
|
220
|
+
errors = validate(SCHEMA, value)
|
|
221
|
+
if problem is None:
|
|
222
|
+
assert errors == []
|
|
223
|
+
else:
|
|
224
|
+
assert any(problem in e for e in errors), errors
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def test_the_check_follows_refs_choices_and_json_equality() -> None:
|
|
228
|
+
schema = {
|
|
229
|
+
"type": "object",
|
|
230
|
+
"properties": {
|
|
231
|
+
"order": {"$ref": "#/$defs/order"},
|
|
232
|
+
"count": {"type": "integer", "multipleOf": 2},
|
|
233
|
+
"flag": {"const": True},
|
|
234
|
+
"kind": {"oneOf": [{"const": "a"}, {"type": "string", "maxLength": 1}]},
|
|
235
|
+
"tags": {"type": "array", "uniqueItems": True, "minItems": 1},
|
|
236
|
+
"other": {"not": {"type": "null"}},
|
|
237
|
+
},
|
|
238
|
+
"$defs": {"order": {"type": "object", "required": ["id"]}},
|
|
239
|
+
}
|
|
240
|
+
assert validate(schema, {"order": {"id": "7"}, "count": 4.0, "flag": True, "kind": "b"}) == []
|
|
241
|
+
errors = validate(
|
|
242
|
+
schema,
|
|
243
|
+
{"order": {}, "count": 3, "flag": 1, "kind": "a", "tags": [1, 1.0], "other": None},
|
|
244
|
+
)
|
|
245
|
+
assert "$.order: missing the required property 'id'" in errors
|
|
246
|
+
assert "$.count: must be a multiple of 2" in errors
|
|
247
|
+
assert "$.flag: must be true" in errors # 1 is not true in JSON
|
|
248
|
+
assert "$.kind: fits 2 of the oneOf choices, not exactly one" in errors
|
|
249
|
+
assert "$.tags: the items must be unique" in errors # 1 equals 1.0 in JSON
|
|
250
|
+
assert "$.other: must not fit the `not` schema" in errors
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# --- the strategy -----------------------------------------------------------------------
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def test_the_strategy_setting(monkeypatch) -> None:
|
|
257
|
+
monkeypatch.delenv("RESPONSE_FORMAT_STRATEGY", raising=False)
|
|
258
|
+
assert response_strategy() == "auto"
|
|
259
|
+
for value in ("provider", "TOOL", " auto "):
|
|
260
|
+
monkeypatch.setenv("RESPONSE_FORMAT_STRATEGY", value)
|
|
261
|
+
assert response_strategy() == value.strip().lower()
|
|
262
|
+
monkeypatch.setenv("RESPONSE_FORMAT_STRATEGY", "json")
|
|
263
|
+
with pytest.raises(SettingsError, match="RESPONSE_FORMAT_STRATEGY"):
|
|
264
|
+
response_strategy()
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def test_auto_picks_the_tool_for_a_model_without_structured_output(schema_file) -> None:
|
|
268
|
+
fmt = response_format(FakeChatModel(), [])
|
|
269
|
+
assert isinstance(fmt, ToolStrategy)
|
|
270
|
+
(spec,) = fmt.schema_specs
|
|
271
|
+
# A fixed name (a title with a space is not a valid tool name), the project's
|
|
272
|
+
# description or a default one, and the project's properties.
|
|
273
|
+
assert spec.name == ANSWER_TOOL and spec.description
|
|
274
|
+
assert spec.json_schema["properties"] == SCHEMA["properties"]
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def test_auto_picks_the_provider_strictly_for_a_model_that_has_it(schema_file) -> None:
|
|
278
|
+
from langchain_openai import ChatOpenAI
|
|
279
|
+
|
|
280
|
+
model = ChatOpenAI(model="gpt-5-mini", api_key="sk-test")
|
|
281
|
+
fmt = response_format(model, [])
|
|
282
|
+
assert isinstance(fmt, ProviderStrategy)
|
|
283
|
+
# LangChain's own AutoStrategy would ask for a best-effort (non-strict) json_schema.
|
|
284
|
+
assert fmt.to_model_kwargs()["response_format"]["json_schema"]["strict"] is True
|
|
285
|
+
assert fmt.to_model_kwargs()["response_format"]["json_schema"]["name"] == ANSWER_TOOL
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def test_an_explicit_strategy_wins(schema_file, monkeypatch) -> None:
|
|
289
|
+
monkeypatch.setenv("RESPONSE_FORMAT_STRATEGY", "provider")
|
|
290
|
+
assert isinstance(response_format(FakeChatModel(), []), ProviderStrategy)
|
|
291
|
+
monkeypatch.setenv("RESPONSE_FORMAT_STRATEGY", "tool")
|
|
292
|
+
from langchain_openai import ChatOpenAI
|
|
293
|
+
|
|
294
|
+
assert isinstance(
|
|
295
|
+
response_format(ChatOpenAI(model="gpt-5-mini", api_key="sk-test"), []), ToolStrategy
|
|
296
|
+
)
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def test_a_tool_named_like_the_answer_is_refused(schema_file) -> None:
|
|
300
|
+
@tool
|
|
301
|
+
def final_answer(text: str) -> str:
|
|
302
|
+
"""A project tool that happens to be called final_answer."""
|
|
303
|
+
return text
|
|
304
|
+
|
|
305
|
+
with pytest.raises(SettingsError, match="rename the tool"):
|
|
306
|
+
response_format(FakeChatModel(), [final_answer])
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
# The shapes Anthropic's client refuses (a type list, an `enum` with no `type`), and the
|
|
310
|
+
# same answer written so that it takes it.
|
|
311
|
+
ANTHROPIC_REFUSES: dict[str, Any] = {
|
|
312
|
+
"type": "object",
|
|
313
|
+
"properties": {
|
|
314
|
+
"category": {"enum": ["billing", "technical", "account", "other"]},
|
|
315
|
+
"order_id": {"type": ["string", "null"], "pattern": "^ORD-[0-9]{5}$"},
|
|
316
|
+
},
|
|
317
|
+
"required": ["category", "order_id"],
|
|
318
|
+
"additionalProperties": False,
|
|
319
|
+
}
|
|
320
|
+
ANTHROPIC_TAKES: dict[str, Any] = {
|
|
321
|
+
"type": "object",
|
|
322
|
+
"properties": {
|
|
323
|
+
"category": {"type": "string", "enum": ["billing", "technical", "account", "other"]},
|
|
324
|
+
"order_id": {"anyOf": [{"type": "string", "pattern": "^ORD-[0-9]{5}$"}, {"type": "null"}]},
|
|
325
|
+
},
|
|
326
|
+
"required": ["category", "order_id"],
|
|
327
|
+
"additionalProperties": False,
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _claude() -> Any:
|
|
332
|
+
from langchain_anthropic import ChatAnthropic
|
|
333
|
+
|
|
334
|
+
# No request is sent: the strategy and the schema conversion happen before any.
|
|
335
|
+
return ChatAnthropic(model="claude-sonnet-5", api_key="sk-test")
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
@pytest.mark.parametrize("schema", [ANTHROPIC_REFUSES, SCHEMA])
|
|
339
|
+
def test_auto_uses_the_tool_where_anthropic_cannot_take_the_schema(
|
|
340
|
+
tmp_path, monkeypatch, schema
|
|
341
|
+
) -> None:
|
|
342
|
+
# Every run would otherwise fail in the client, before its request (run_failed).
|
|
343
|
+
monkeypatch.setenv("RESPONSE_SCHEMA_PATH", str(_write(tmp_path / "s.json", schema)))
|
|
344
|
+
model = _claude()
|
|
345
|
+
assert structured.provider_supported(model, []) # the model has structured output
|
|
346
|
+
assert structured.provider_refusal(model, schema) is not None
|
|
347
|
+
assert isinstance(response_format(model, []), ToolStrategy)
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def test_auto_uses_anthropics_structured_output_for_a_schema_it_takes(
|
|
351
|
+
tmp_path, monkeypatch
|
|
352
|
+
) -> None:
|
|
353
|
+
from anthropic import transform_schema
|
|
354
|
+
|
|
355
|
+
monkeypatch.setenv("RESPONSE_SCHEMA_PATH", str(_write(tmp_path / "s.json", ANTHROPIC_TAKES)))
|
|
356
|
+
fmt = response_format(_claude(), [])
|
|
357
|
+
assert isinstance(fmt, ProviderStrategy)
|
|
358
|
+
# What langchain-anthropic does with it before the request.
|
|
359
|
+
transform_schema(fmt.to_model_kwargs()["response_format"]["json_schema"]["schema"])
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def test_the_provider_strategy_with_a_schema_anthropic_cannot_take_stops_startup(
|
|
363
|
+
tmp_path, monkeypatch
|
|
364
|
+
) -> None:
|
|
365
|
+
monkeypatch.setenv("RESPONSE_SCHEMA_PATH", str(_write(tmp_path / "s.json", ANTHROPIC_REFUSES)))
|
|
366
|
+
monkeypatch.setenv("RESPONSE_FORMAT_STRATEGY", "provider")
|
|
367
|
+
with pytest.raises(SettingsError, match=r"cannot send response_schema\.json.*=tool"):
|
|
368
|
+
response_format(_claude(), [])
|
|
369
|
+
# The tool strategy takes it; other providers' clients are not asked.
|
|
370
|
+
monkeypatch.setenv("RESPONSE_FORMAT_STRATEGY", "tool")
|
|
371
|
+
assert isinstance(response_format(_claude(), []), ToolStrategy)
|
|
372
|
+
assert structured.provider_refusal(FakeChatModel(), ANTHROPIC_REFUSES) is None
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# --- the fake model --------------------------------------------------------------------
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def test_the_fake_answers_in_the_shape() -> None:
|
|
379
|
+
answer = _fake_answer(SCHEMA, "sunny")
|
|
380
|
+
assert answer == {"answer": "sunny", "confidence": 0, "city": "sunny"}
|
|
381
|
+
assert validate(SCHEMA, answer) == []
|
|
382
|
+
# A text that breaks a pattern takes the choice that fits (the developer guide's example).
|
|
383
|
+
for schema in (ANTHROPIC_TAKES, ANTHROPIC_REFUSES):
|
|
384
|
+
answer = _fake_answer(schema, "sunny")
|
|
385
|
+
assert answer == {"category": "billing", "order_id": None}, schema
|
|
386
|
+
assert validate(schema, answer) == []
|
|
387
|
+
assert _fake_answer(ANTHROPIC_TAKES, "ORD-12345")["order_id"] == "ORD-12345"
|
|
388
|
+
# With no choice that fits, the answer does not fit (the tests' failing path).
|
|
389
|
+
failing = {"type": "object", "properties": {"id": {"type": "string", "pattern": "^ORD-"}}}
|
|
390
|
+
assert validate(failing, _fake_answer(failing, "sunny")) != []
|
|
391
|
+
provider = FakeChatModel().bind_tools(
|
|
392
|
+
[], response_format={"type": "json_schema", "json_schema": {"schema": SCHEMA}}
|
|
393
|
+
)
|
|
394
|
+
reply = provider.invoke([HumanMessage("hello")])
|
|
395
|
+
assert json.loads(reply.content)["answer"] == "Hello! How can I help you today?"
|
|
396
|
+
answer_tool = {"type": "function", "function": {"name": ANSWER_TOOL, "parameters": SCHEMA}}
|
|
397
|
+
forced = FakeChatModel().bind_tools([answer_tool], tool_choice="any")
|
|
398
|
+
(call,) = forced.invoke([HumanMessage("hello")]).tool_calls
|
|
399
|
+
assert call["name"] == ANSWER_TOOL and call["args"]["confidence"] == 0
|
|
400
|
+
# A request that names the answer tool does not call it early.
|
|
401
|
+
unforced = FakeChatModel().bind_tools([answer_tool])
|
|
402
|
+
assert unforced.invoke([HumanMessage("give the final answer")]).tool_calls == []
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
# --- the middleware ---------------------------------------------------------------------
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
class Scripted(BaseChatModel):
|
|
409
|
+
"""Returns its replies in order (the last one again when they run out); records requests."""
|
|
410
|
+
|
|
411
|
+
replies: list[AIMessage]
|
|
412
|
+
seen: list[list[Any]] = Field(default_factory=list)
|
|
413
|
+
|
|
414
|
+
@property
|
|
415
|
+
def _llm_type(self) -> str:
|
|
416
|
+
return "scripted"
|
|
417
|
+
|
|
418
|
+
def bind_tools(self, tools: Any, **kwargs: Any) -> Any: # type: ignore[override]
|
|
419
|
+
return self
|
|
420
|
+
|
|
421
|
+
def _generate(
|
|
422
|
+
self, messages: list[Any], stop: Any = None, run_manager: Any = None, **kwargs: Any
|
|
423
|
+
) -> ChatResult:
|
|
424
|
+
self.seen.append(list(messages))
|
|
425
|
+
reply = self.replies[min(len(self.seen), len(self.replies)) - 1]
|
|
426
|
+
return ChatResult(generations=[ChatGeneration(message=reply)])
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def _answer(args: dict[str, Any], tokens: int = 10) -> AIMessage:
|
|
430
|
+
usage = {"input_tokens": tokens, "output_tokens": tokens, "total_tokens": 2 * tokens}
|
|
431
|
+
call = {"name": ANSWER_TOOL, "args": args, "id": f"call_{tokens}"}
|
|
432
|
+
return AIMessage(content="", tool_calls=[call], usage_metadata=usage)
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _graph(model: BaseChatModel, strategy: Any) -> Any:
|
|
436
|
+
return create_agent(
|
|
437
|
+
model=model, tools=[], response_format=strategy, middleware=[StructuredAnswer()]
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _tool_strategy() -> ToolStrategy[Any]:
|
|
442
|
+
return ToolStrategy({**SCHEMA, "title": ANSWER_TOOL})
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
async def test_an_answer_that_fits_passes_untouched(schema_file) -> None:
|
|
446
|
+
model = Scripted(replies=[_answer({"answer": "sunny", "confidence": 0.5})])
|
|
447
|
+
state = await _graph(model, _tool_strategy()).ainvoke({"messages": [HumanMessage("hi")]})
|
|
448
|
+
assert state["structured_response"] == {"answer": "sunny", "confidence": 0.5}
|
|
449
|
+
assert len(model.seen) == 1
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
async def test_an_answer_that_does_not_fit_is_sent_back_and_its_usage_counts(schema_file) -> None:
|
|
453
|
+
model = Scripted(
|
|
454
|
+
replies=[
|
|
455
|
+
_answer({"answer": "sunny", "confidence": 7}, tokens=3),
|
|
456
|
+
_answer({"answer": "sunny", "confidence": 0.7}, tokens=5),
|
|
457
|
+
]
|
|
458
|
+
)
|
|
459
|
+
state = await _graph(model, _tool_strategy()).ainvoke({"messages": [HumanMessage("hi")]})
|
|
460
|
+
assert state["structured_response"] == {"answer": "sunny", "confidence": 0.7}
|
|
461
|
+
# The model read its try and a tool error naming the problem.
|
|
462
|
+
retry = model.seen[1]
|
|
463
|
+
assert isinstance(retry[-1], ToolMessage) and retry[-1].status == "error"
|
|
464
|
+
assert "$.confidence: must be <= 1" in retry[-1].content
|
|
465
|
+
# The failed try is not kept in the thread, but its tokens count.
|
|
466
|
+
answers = [m for m in state["messages"] if isinstance(m, AIMessage)]
|
|
467
|
+
assert len(answers) == 1 and answers[0].usage_metadata["input_tokens"] == 8
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
async def test_a_plain_text_final_reply_is_sent_back_under_the_tool_strategy(schema_file) -> None:
|
|
471
|
+
model = Scripted(
|
|
472
|
+
replies=[AIMessage(content="It is sunny."), _answer({"answer": "x", "confidence": 1})]
|
|
473
|
+
)
|
|
474
|
+
state = await _graph(model, _tool_strategy()).ainvoke({"messages": [HumanMessage("hi")]})
|
|
475
|
+
assert state["structured_response"] == {"answer": "x", "confidence": 1}
|
|
476
|
+
note = model.seen[1][-1]
|
|
477
|
+
assert isinstance(note, HumanMessage) and f"call {ANSWER_TOOL}" in note.content
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
async def test_a_reply_that_is_not_json_is_sent_back_under_the_provider_strategy(
|
|
481
|
+
schema_file,
|
|
482
|
+
) -> None:
|
|
483
|
+
model = Scripted(
|
|
484
|
+
replies=[
|
|
485
|
+
AIMessage(content='{"answer": "sunny", "confidence": '),
|
|
486
|
+
AIMessage(content='{"answer": "sunny", "confidence": 0.2}'),
|
|
487
|
+
]
|
|
488
|
+
)
|
|
489
|
+
strategy = ProviderStrategy({**SCHEMA, "title": ANSWER_TOOL}, strict=True)
|
|
490
|
+
state = await _graph(model, strategy).ainvoke({"messages": [HumanMessage("hi")]})
|
|
491
|
+
assert state["structured_response"] == {"answer": "sunny", "confidence": 0.2}
|
|
492
|
+
assert "not valid JSON" in model.seen[1][-1].content
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
async def test_no_fitting_try_fails_the_step(schema_file) -> None:
|
|
496
|
+
model = Scripted(replies=[_answer({"answer": "sunny"})])
|
|
497
|
+
with pytest.raises(StructuredAnswerError, match=r"after 3 tries.*'confidence'"):
|
|
498
|
+
await _graph(model, _tool_strategy()).ainvoke({"messages": [HumanMessage("hi")]})
|
|
499
|
+
assert len(model.seen) == 3
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
async def test_an_answer_beside_other_tool_calls_is_sent_back_and_none_of_them_runs(
|
|
503
|
+
schema_file,
|
|
504
|
+
) -> None:
|
|
505
|
+
# LangChain would take the answer and still run the other call after it (a gated
|
|
506
|
+
# one would pause the run with the answer already given): the try goes back.
|
|
507
|
+
ran: list[str] = []
|
|
508
|
+
|
|
509
|
+
@tool
|
|
510
|
+
def probe(query: str) -> str:
|
|
511
|
+
"""Run the probe for a place."""
|
|
512
|
+
ran.append(query)
|
|
513
|
+
return f"{query}: 42"
|
|
514
|
+
|
|
515
|
+
fitting = {"answer": "Oslo is fine", "confidence": 1}
|
|
516
|
+
together = AIMessage(
|
|
517
|
+
content="",
|
|
518
|
+
tool_calls=[
|
|
519
|
+
{"name": ANSWER_TOOL, "args": fitting, "id": "call_answer"},
|
|
520
|
+
{"name": "probe", "args": {"query": "Oslo"}, "id": "call_probe"},
|
|
521
|
+
],
|
|
522
|
+
)
|
|
523
|
+
alone = AIMessage(
|
|
524
|
+
content="", tool_calls=[{"name": "probe", "args": {"query": "Oslo"}, "id": "call_again"}]
|
|
525
|
+
)
|
|
526
|
+
model = Scripted(replies=[together, alone, _answer({"answer": "Oslo: 42", "confidence": 1})])
|
|
527
|
+
graph = create_agent(
|
|
528
|
+
model=model,
|
|
529
|
+
tools=[probe],
|
|
530
|
+
response_format=_tool_strategy(),
|
|
531
|
+
middleware=[StructuredAnswer()],
|
|
532
|
+
)
|
|
533
|
+
state = await graph.ainvoke({"messages": [HumanMessage("hi")]})
|
|
534
|
+
assert state["structured_response"] == {"answer": "Oslo: 42", "confidence": 1}
|
|
535
|
+
assert ran == ["Oslo"] and len(model.seen) == 3 # the probe ran once: when called alone
|
|
536
|
+
# Every call of the refused try has a result (a provider refuses a history without):
|
|
537
|
+
# the answer is refused, the other call did not run.
|
|
538
|
+
results = {m.tool_call_id: m for m in model.seen[1] if isinstance(m, ToolMessage)}
|
|
539
|
+
assert set(results) == {"call_answer", "call_probe"}
|
|
540
|
+
assert results["call_answer"].status == "error"
|
|
541
|
+
assert "came with other tool calls" in results["call_answer"].content
|
|
542
|
+
assert results["call_probe"].content == structured.OTHER_CALL_NOT_RUN
|
|
543
|
+
# The refused try is not kept in the thread.
|
|
544
|
+
kept = {c["id"] for m in state["messages"] if isinstance(m, AIMessage) for c in m.tool_calls}
|
|
545
|
+
assert kept == {"call_again", "call_10"}
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def test_the_middleware_does_nothing_without_a_response_format(schema_file) -> None:
|
|
549
|
+
model = Scripted(replies=[AIMessage(content="plain text")])
|
|
550
|
+
graph = create_agent(model=model, tools=[], middleware=[StructuredAnswer()])
|
|
551
|
+
state = graph.invoke({"messages": [HumanMessage("hi")]})
|
|
552
|
+
assert state["messages"][-1].content == "plain text" and len(model.seen) == 1
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
# --- the chat runtime's view of a structured run -----------------------------------------
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def test_a_structured_run_hides_the_models_text_and_the_answer_tool() -> None:
|
|
559
|
+
state = _RunState(structured_mode=True)
|
|
560
|
+
chunk = {"type": "AIMessageChunk", "content": '{"answer": "sun', "id": "a1"}
|
|
561
|
+
assert list(map_stream_item("messages", [chunk, {}], state)) == []
|
|
562
|
+
# As LangGraph Server sends the updates: JSON, not message objects.
|
|
563
|
+
update = {
|
|
564
|
+
"model": {
|
|
565
|
+
"messages": [
|
|
566
|
+
{
|
|
567
|
+
"type": "ai",
|
|
568
|
+
"id": "a2",
|
|
569
|
+
"content": "",
|
|
570
|
+
"tool_calls": [
|
|
571
|
+
{"id": "c1", "name": "probe", "args": {}},
|
|
572
|
+
{"id": "c2", "name": ANSWER_TOOL, "args": {"answer": "x"}},
|
|
573
|
+
],
|
|
574
|
+
}
|
|
575
|
+
],
|
|
576
|
+
"structured_response": {"answer": "x"},
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
events = list(map_stream_item("updates", update, state))
|
|
580
|
+
assert [e for e, _ in events] == [EVENT_TOOL_CALL]
|
|
581
|
+
assert events[0][1]["name"] == "probe" and state.structured == {"answer": "x"}
|
|
582
|
+
result = {"tools": {"messages": [{"type": "tool", "name": ANSWER_TOOL, "tool_call_id": "c2"}]}}
|
|
583
|
+
assert list(map_stream_item("updates", result, state)) == []
|
|
584
|
+
# A later step that gives no answer clears it: the last step's answer counts.
|
|
585
|
+
list(
|
|
586
|
+
map_stream_item("updates", {"model": {"messages": [], "structured_response": None}}, state)
|
|
587
|
+
)
|
|
588
|
+
assert state.structured is None
|
|
589
|
+
|
|
590
|
+
|
|
591
|
+
def test_a_run_without_a_schema_streams_as_before() -> None:
|
|
592
|
+
state = _RunState()
|
|
593
|
+
chunk = {"type": "AIMessageChunk", "content": "sunny", "id": "a1"}
|
|
594
|
+
assert list(map_stream_item("messages", [chunk, {}], state)) == [
|
|
595
|
+
(EVENT_DELTA, {"text": "sunny"})
|
|
596
|
+
]
|
|
597
|
+
update = {"tools": {"messages": [{"type": "tool", "name": ANSWER_TOOL, "tool_call_id": "c"}]}}
|
|
598
|
+
assert [e for e, _ in map_stream_item("updates", update, state)] == [EVENT_TOOL_RESULT]
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
def test_a_failed_answer_from_the_server_has_its_own_error_code() -> None:
|
|
602
|
+
runtime = ChatRuntime()
|
|
603
|
+
for exc in (
|
|
604
|
+
StructuredAnswerError("no try fitted"),
|
|
605
|
+
_ServerRunError({"error": "StructuredAnswerError", "message": "no try fitted"}),
|
|
606
|
+
):
|
|
607
|
+
event = runtime._error_event(exc, "run-1")
|
|
608
|
+
assert event["code"] == CODE_INVALID_STRUCTURED_RESPONSE, event
|
|
609
|
+
assert "did not fit the response schema" in event["message"]
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
# --- the project's own response schema --------------------------------------------------
|
|
613
|
+
|
|
614
|
+
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
|
615
|
+
# One turn of the project's own graph (`agent.py` as written), under the project's own
|
|
616
|
+
# schema: a process of its own, since this one imported the agent with the schema off.
|
|
617
|
+
ONE_TURN = """
|
|
618
|
+
import json
|
|
619
|
+
from {{cookiecutter.agent_directory}} import agent
|
|
620
|
+
from {{cookiecutter.agent_directory}}.app_utils.structured import StructuredAnswerError
|
|
621
|
+
try:
|
|
622
|
+
state = agent.graph.invoke({"messages": [{"role": "user", "content": "Hello"}]})
|
|
623
|
+
except StructuredAnswerError as exc:
|
|
624
|
+
print(json.dumps({"error": str(exc)}))
|
|
625
|
+
else:
|
|
626
|
+
print(json.dumps({"answered": "structured_response" in state,
|
|
627
|
+
"answer": state.get("structured_response")}))
|
|
628
|
+
"""
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _project_schema(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any] | None:
|
|
632
|
+
monkeypatch.delenv("RESPONSE_SCHEMA_PATH", raising=False)
|
|
633
|
+
return response_schema()
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def test_the_projects_response_schema_is_one_the_agent_can_use(monkeypatch) -> None:
|
|
637
|
+
# A schema the checker cannot check stops startup (SettingsError names the problem).
|
|
638
|
+
schema = _project_schema(monkeypatch)
|
|
639
|
+
if schema is not None:
|
|
640
|
+
assert schema_problems(schema) == []
|
|
641
|
+
assert response_format(FakeChatModel(), []) is not None
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def test_the_agent_answers_in_the_projects_shape(monkeypatch) -> None:
|
|
645
|
+
"""`agent.py` is built for the project's schema: its answer is an object in that shape.
|
|
646
|
+
|
|
647
|
+
Without a schema, the agent answers in text (no structured answer).
|
|
648
|
+
"""
|
|
649
|
+
schema = _project_schema(monkeypatch)
|
|
650
|
+
env = {k: v for k, v in os.environ.items() if k != "RESPONSE_SCHEMA_PATH"}
|
|
651
|
+
env["MODEL_PROVIDER"] = "fake"
|
|
652
|
+
done = subprocess.run(
|
|
653
|
+
[sys.executable, "-c", ONE_TURN],
|
|
654
|
+
cwd=PROJECT_ROOT,
|
|
655
|
+
env=env,
|
|
656
|
+
capture_output=True,
|
|
657
|
+
text=True,
|
|
658
|
+
timeout=120,
|
|
659
|
+
check=False,
|
|
660
|
+
)
|
|
661
|
+
assert done.returncode == 0, done.stderr[-3000:]
|
|
662
|
+
result = json.loads(done.stdout.strip().splitlines()[-1])
|
|
663
|
+
if schema is None:
|
|
664
|
+
assert result == {"answered": False, "answer": None}
|
|
665
|
+
return
|
|
666
|
+
if "error" in result and "must match the pattern" in result["error"]:
|
|
667
|
+
pytest.skip(
|
|
668
|
+
"the fake model writes the reply's text where the schema wants a string, and it "
|
|
669
|
+
f"does not match the schema's `pattern` ({result['error']}); the answer check ran"
|
|
670
|
+
)
|
|
671
|
+
assert "error" not in result, result["error"]
|
|
672
|
+
assert result["answered"], "agent.py gives no structured answer: pass response_format"
|
|
673
|
+
assert validate(schema, result["answer"]) == [], result["answer"]
|