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,755 @@
|
|
|
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: the agent's final answer is JSON in the shape the project declares.
|
|
16
|
+
|
|
17
|
+
The project declares the shape in `<agent directory>/response_schema.json`
|
|
18
|
+
(`RESPONSE_SCHEMA_PATH` names another file), a JSON Schema whose root is an
|
|
19
|
+
object. Without the file the agent answers in text, as before, and so it does
|
|
20
|
+
with `RESPONSE_SCHEMA_PATH=none` whatever file there is: the project's tests
|
|
21
|
+
set that (`tests/conftest.py`), so they exercise the runtime with text answers,
|
|
22
|
+
and check the project's own schema in `tests/unit/test_structured.py`. With it:
|
|
23
|
+
|
|
24
|
+
* The model is made to answer in that shape through LangChain's
|
|
25
|
+
`response_format` strategies (`response_format()`): the provider's own
|
|
26
|
+
structured output (OpenAI's `json_schema` response format, strict; Anthropic's
|
|
27
|
+
and Gemini's native equivalents) where the model supports it, else a tool the
|
|
28
|
+
model must call with its answer (`final_answer`, with `tool_choice` forcing a
|
|
29
|
+
call at every step). `RESPONSE_FORMAT_STRATEGY` picks: `auto` (default: the
|
|
30
|
+
provider's own when LangChain's model profile says the model has it, with
|
|
31
|
+
the agent's tools bound, and the model's client can send the schema; the
|
|
32
|
+
tool otherwise), `provider` or `tool`. Anthropic's client refuses a type
|
|
33
|
+
list and a schema with no `type` (an `enum` alone): `auto` then uses the
|
|
34
|
+
tool, and `provider` stops startup (`provider_refusal`).
|
|
35
|
+
* Every answer is checked against the schema here (`StructuredAnswer`):
|
|
36
|
+
LangChain returns a raw JSON-schema answer unchecked under both strategies.
|
|
37
|
+
An answer that does not fit, a reply that is not JSON, a final reply in
|
|
38
|
+
plain text, or an answer given beside other tool calls (none of which runs)
|
|
39
|
+
is sent back to the model with what is wrong, up to
|
|
40
|
+
`MAX_ANSWER_ATTEMPTS` tries in the same step (the failed tries are not kept
|
|
41
|
+
in the thread; their token usage is added to the answer's). When no try
|
|
42
|
+
fits, the step fails with `StructuredAnswerError` and the run ends with the
|
|
43
|
+
error code `invalid_structured_response`.
|
|
44
|
+
* The chat runtime (`chat.py`) delivers the answer: a completed run's
|
|
45
|
+
`message.end` carries `structured_response` (the object), and the run's
|
|
46
|
+
reply text is exactly its JSON text (one `message.delta`): nothing else the
|
|
47
|
+
model wrote on the way is streamed. The A2A reply adds a data part with the
|
|
48
|
+
object. It checks the answer again first: one that does not fit (an
|
|
49
|
+
`agent.py` without `StructuredAnswer()` in its middleware never checked it)
|
|
50
|
+
ends the run with `invalid_structured_response` and is never delivered.
|
|
51
|
+
|
|
52
|
+
The checker supports a documented subset of JSON Schema (`SUPPORTED_KEYWORDS`):
|
|
53
|
+
a schema that uses anything else (`if`/`then`, `patternProperties`,
|
|
54
|
+
`prefixItems`, a remote `$ref`, a misspelt keyword, ...) stops startup rather
|
|
55
|
+
than being half-checked. `format` is an annotation only (as JSON Schema 2020-12
|
|
56
|
+
says by default), and `pattern` uses Python's regular expressions.
|
|
57
|
+
|
|
58
|
+
With the provider strategy on OpenAI Chat Completions, strict structured
|
|
59
|
+
output applies: langchain-openai makes every property of the answer required
|
|
60
|
+
and forbids extra ones (the model fills an optional property; give it a
|
|
61
|
+
`null` type where it may have no value), and makes every tool strict too (a
|
|
62
|
+
tool's optional arguments become required: the model passes a value for
|
|
63
|
+
each). A schema the provider refuses fails every run with the provider's
|
|
64
|
+
error: use `RESPONSE_FORMAT_STRATEGY=tool` for it.
|
|
65
|
+
"""
|
|
66
|
+
|
|
67
|
+
from __future__ import annotations
|
|
68
|
+
|
|
69
|
+
import copy
|
|
70
|
+
import json
|
|
71
|
+
import logging
|
|
72
|
+
import math
|
|
73
|
+
import os
|
|
74
|
+
import re
|
|
75
|
+
from pathlib import Path
|
|
76
|
+
from typing import Any
|
|
77
|
+
|
|
78
|
+
from langchain.agents.middleware import AgentMiddleware, ModelResponse
|
|
79
|
+
from langchain.agents.structured_output import (
|
|
80
|
+
ProviderStrategy,
|
|
81
|
+
StructuredOutputValidationError,
|
|
82
|
+
ToolStrategy,
|
|
83
|
+
)
|
|
84
|
+
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
|
85
|
+
|
|
86
|
+
from {{cookiecutter.agent_directory}}.app_utils.limits import SettingsError
|
|
87
|
+
|
|
88
|
+
logger = logging.getLogger(__name__)
|
|
89
|
+
|
|
90
|
+
SCHEMA_FILENAME = "response_schema.json"
|
|
91
|
+
# `RESPONSE_SCHEMA_PATH=none`: no response schema (text answers), whatever file there is.
|
|
92
|
+
SCHEMA_OFF = "none"
|
|
93
|
+
# The name of the answer's schema at the provider and of the answer tool (the tool
|
|
94
|
+
# strategy): the project's own title stays in the file, not in the name a provider checks.
|
|
95
|
+
ANSWER_TOOL = "final_answer"
|
|
96
|
+
DEFAULT_DESCRIPTION = (
|
|
97
|
+
"Your final answer to the user, in this shape. Give it once you have what you need; "
|
|
98
|
+
"it ends your turn."
|
|
99
|
+
)
|
|
100
|
+
STRATEGY_AUTO = "auto"
|
|
101
|
+
STRATEGY_PROVIDER = "provider"
|
|
102
|
+
STRATEGY_TOOL = "tool"
|
|
103
|
+
STRATEGIES = (STRATEGY_AUTO, STRATEGY_PROVIDER, STRATEGY_TOOL)
|
|
104
|
+
# Tries of one answer (the first and two corrections) before the run fails.
|
|
105
|
+
MAX_ANSWER_ATTEMPTS = 3
|
|
106
|
+
# How many schema problems one correction names.
|
|
107
|
+
MAX_REPORTED_PROBLEMS = 5
|
|
108
|
+
# What the answer tool's result says (the model reads it on the thread's later turns).
|
|
109
|
+
ANSWER_RECORDED = "The answer was given to the user."
|
|
110
|
+
# The result of a tool call made beside an answer (it did not run: an answer ends the turn).
|
|
111
|
+
OTHER_CALL_NOT_RUN = (
|
|
112
|
+
f"Not run: it came with a {ANSWER_TOOL} call. Call tools first, then {ANSWER_TOOL} alone."
|
|
113
|
+
)
|
|
114
|
+
# What is wrong with an answer given beside other tool calls (the tool strategy): the
|
|
115
|
+
# calls would run after the answer was written, or pause the run for an approval with
|
|
116
|
+
# the answer already given, so the try is sent back and none of its calls runs.
|
|
117
|
+
ANSWER_NOT_ALONE = (
|
|
118
|
+
"your answer came with other tool calls, which did not run: an answer ends your turn, "
|
|
119
|
+
f"so call the tools you need first, then {ANSWER_TOOL} alone once you have their results"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
# --- BEGIN SHARED RESPONSE SCHEMA RULES ---
|
|
123
|
+
# The JSON Schema subset a response schema may use, and the check of a schema
|
|
124
|
+
# against it. The same block is in graph_agents_cli/_response_schema.py, which
|
|
125
|
+
# `create --response-schema` and `lint` use: keep the two byte-identical
|
|
126
|
+
# (tests/dev/test_response_schema_parity.py), so a schema the CLI accepts
|
|
127
|
+
# starts the agent. No Jinja, no imports but json, math, re and typing.Any.
|
|
128
|
+
|
|
129
|
+
# Keywords that are checked, and annotations that are allowed and ignored.
|
|
130
|
+
VALIDATION_KEYWORDS = frozenset(
|
|
131
|
+
{
|
|
132
|
+
"type",
|
|
133
|
+
"enum",
|
|
134
|
+
"const",
|
|
135
|
+
"properties",
|
|
136
|
+
"required",
|
|
137
|
+
"additionalProperties",
|
|
138
|
+
"minProperties",
|
|
139
|
+
"maxProperties",
|
|
140
|
+
"items",
|
|
141
|
+
"minItems",
|
|
142
|
+
"maxItems",
|
|
143
|
+
"uniqueItems",
|
|
144
|
+
"minLength",
|
|
145
|
+
"maxLength",
|
|
146
|
+
"pattern",
|
|
147
|
+
"minimum",
|
|
148
|
+
"maximum",
|
|
149
|
+
"exclusiveMinimum",
|
|
150
|
+
"exclusiveMaximum",
|
|
151
|
+
"multipleOf",
|
|
152
|
+
"anyOf",
|
|
153
|
+
"oneOf",
|
|
154
|
+
"allOf",
|
|
155
|
+
"not",
|
|
156
|
+
"$ref",
|
|
157
|
+
"$defs",
|
|
158
|
+
"definitions",
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
ANNOTATION_KEYWORDS = frozenset(
|
|
162
|
+
{
|
|
163
|
+
"$schema",
|
|
164
|
+
"$id",
|
|
165
|
+
"$comment",
|
|
166
|
+
"title",
|
|
167
|
+
"description",
|
|
168
|
+
"default",
|
|
169
|
+
"examples",
|
|
170
|
+
"deprecated",
|
|
171
|
+
"readOnly",
|
|
172
|
+
"writeOnly",
|
|
173
|
+
"format",
|
|
174
|
+
}
|
|
175
|
+
)
|
|
176
|
+
SUPPORTED_KEYWORDS = VALIDATION_KEYWORDS | ANNOTATION_KEYWORDS
|
|
177
|
+
JSON_TYPES = frozenset({"object", "array", "string", "integer", "number", "boolean", "null"})
|
|
178
|
+
_SCHEMA_LISTS = ("anyOf", "oneOf", "allOf")
|
|
179
|
+
_SCHEMA_MAPS = ("properties", "$defs", "definitions")
|
|
180
|
+
_SCHEMA_VALUES = ("items", "additionalProperties", "not")
|
|
181
|
+
_COUNTS = ("minProperties", "maxProperties", "minItems", "maxItems", "minLength", "maxLength")
|
|
182
|
+
_NUMBERS = ("minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf")
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _pointer(schema: Any, ref: str) -> Any:
|
|
186
|
+
"""The subschema a local `$ref` (`#`, `#/$defs/name`, ...) names; KeyError when none."""
|
|
187
|
+
if ref == "#":
|
|
188
|
+
return schema
|
|
189
|
+
if not ref.startswith("#/"):
|
|
190
|
+
raise KeyError(ref)
|
|
191
|
+
node = schema
|
|
192
|
+
for raw in ref[2:].split("/"):
|
|
193
|
+
key = raw.replace("~1", "/").replace("~0", "~")
|
|
194
|
+
if isinstance(node, dict) and key in node:
|
|
195
|
+
node = node[key]
|
|
196
|
+
elif isinstance(node, list) and key.isdigit() and int(key) < len(node):
|
|
197
|
+
node = node[int(key)]
|
|
198
|
+
else:
|
|
199
|
+
raise KeyError(ref)
|
|
200
|
+
return node
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _is_number(value: Any) -> bool:
|
|
204
|
+
return isinstance(value, int | float) and not isinstance(value, bool) and math.isfinite(value)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def schema_problems(schema: Any) -> list[str]:
|
|
208
|
+
"""What stops `schema` from being a response schema; empty when it can be one.
|
|
209
|
+
|
|
210
|
+
The root is an object schema (`"type": "object"`); every keyword anywhere
|
|
211
|
+
is one `SUPPORTED_KEYWORDS` lists, with a value of the right kind; every
|
|
212
|
+
`$ref` points inside the document; every `pattern` compiles.
|
|
213
|
+
"""
|
|
214
|
+
if not isinstance(schema, dict):
|
|
215
|
+
return ["the schema must be a JSON object"]
|
|
216
|
+
problems: list[str] = []
|
|
217
|
+
if schema.get("type") != "object":
|
|
218
|
+
problems.append('$: the root must be an object schema ("type": "object")')
|
|
219
|
+
|
|
220
|
+
def walk(node: Any, where: str) -> None:
|
|
221
|
+
if isinstance(node, bool):
|
|
222
|
+
return
|
|
223
|
+
if not isinstance(node, dict):
|
|
224
|
+
problems.append(f"{where}: a schema must be an object or a boolean")
|
|
225
|
+
return
|
|
226
|
+
for key, value in node.items():
|
|
227
|
+
at = f"{where}.{key}"
|
|
228
|
+
if key not in SUPPORTED_KEYWORDS:
|
|
229
|
+
problems.append(f"{at}: `{key}` is not a keyword the answer check supports")
|
|
230
|
+
elif key == "type":
|
|
231
|
+
types = value if isinstance(value, list) else [value]
|
|
232
|
+
if not types or not all(t in JSON_TYPES for t in types):
|
|
233
|
+
problems.append(f"{at}: must name JSON types ({', '.join(sorted(JSON_TYPES))})")
|
|
234
|
+
elif key == "required":
|
|
235
|
+
if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
|
|
236
|
+
problems.append(f"{at}: must be a list of property names")
|
|
237
|
+
elif key == "enum":
|
|
238
|
+
if not isinstance(value, list) or not value:
|
|
239
|
+
problems.append(f"{at}: must be a non-empty list")
|
|
240
|
+
elif key in _COUNTS:
|
|
241
|
+
if not isinstance(value, int) or isinstance(value, bool) or value < 0:
|
|
242
|
+
problems.append(f"{at}: must be a whole number >= 0")
|
|
243
|
+
elif key in _NUMBERS:
|
|
244
|
+
if not _is_number(value) or (key == "multipleOf" and value <= 0):
|
|
245
|
+
problems.append(
|
|
246
|
+
f"{at}: must be a number" + (" > 0" if key == "multipleOf" else "")
|
|
247
|
+
)
|
|
248
|
+
elif key == "uniqueItems":
|
|
249
|
+
if not isinstance(value, bool):
|
|
250
|
+
problems.append(f"{at}: must be true or false")
|
|
251
|
+
elif key == "pattern":
|
|
252
|
+
try:
|
|
253
|
+
re.compile(value)
|
|
254
|
+
except (re.error, TypeError) as exc:
|
|
255
|
+
problems.append(f"{at}: not a regular expression ({exc})")
|
|
256
|
+
elif key == "$ref":
|
|
257
|
+
try:
|
|
258
|
+
_pointer(schema, value if isinstance(value, str) else "")
|
|
259
|
+
except KeyError:
|
|
260
|
+
problems.append(f"{at}: {value!r} does not point inside this schema")
|
|
261
|
+
elif key in _SCHEMA_LISTS:
|
|
262
|
+
if not isinstance(value, list) or not value:
|
|
263
|
+
problems.append(f"{at}: must be a non-empty list of schemas")
|
|
264
|
+
else:
|
|
265
|
+
for i, sub in enumerate(value):
|
|
266
|
+
walk(sub, f"{at}[{i}]")
|
|
267
|
+
elif key in _SCHEMA_MAPS:
|
|
268
|
+
if not isinstance(value, dict):
|
|
269
|
+
problems.append(f"{at}: must map names to schemas")
|
|
270
|
+
else:
|
|
271
|
+
for name, sub in value.items():
|
|
272
|
+
walk(sub, f"{at}.{name}")
|
|
273
|
+
elif key in _SCHEMA_VALUES:
|
|
274
|
+
if key == "items" and isinstance(value, list):
|
|
275
|
+
problems.append(
|
|
276
|
+
f"{at}: must be one schema (a list of schemas is not supported)"
|
|
277
|
+
)
|
|
278
|
+
else:
|
|
279
|
+
walk(value, at)
|
|
280
|
+
|
|
281
|
+
walk(schema, "$")
|
|
282
|
+
return problems
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
# --- END SHARED RESPONSE SCHEMA RULES ---
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
class StructuredAnswerError(RuntimeError):
|
|
289
|
+
"""No try of the model's final answer fitted the response schema."""
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
# ---------------------------------------------------------------------------
|
|
293
|
+
# The schema: where it is, and whether this checker can check it
|
|
294
|
+
# ---------------------------------------------------------------------------
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def schema_path() -> Path | None:
|
|
298
|
+
"""`RESPONSE_SCHEMA_PATH` when set (it must exist); else the agent package's file, if any.
|
|
299
|
+
|
|
300
|
+
`RESPONSE_SCHEMA_PATH=none` means no schema, whatever file the package has.
|
|
301
|
+
"""
|
|
302
|
+
raw = (os.environ.get("RESPONSE_SCHEMA_PATH") or "").strip()
|
|
303
|
+
if raw.lower() == SCHEMA_OFF:
|
|
304
|
+
return None
|
|
305
|
+
if raw:
|
|
306
|
+
path = Path(raw)
|
|
307
|
+
if not path.is_file():
|
|
308
|
+
raise SettingsError(f"RESPONSE_SCHEMA_PATH={raw!r} is not a file.")
|
|
309
|
+
return path
|
|
310
|
+
path = Path(__file__).resolve().parent.parent / SCHEMA_FILENAME
|
|
311
|
+
return path if path.is_file() else None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
_CACHE: dict[tuple[str, int], dict[str, Any]] = {}
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def response_schema() -> dict[str, Any] | None:
|
|
318
|
+
"""The project's response schema, checked; None when it declares none (the mode is off).
|
|
319
|
+
|
|
320
|
+
`SettingsError` for a file that is not JSON or not a schema this checker
|
|
321
|
+
can check (startup names it with every other bad setting).
|
|
322
|
+
"""
|
|
323
|
+
path = schema_path()
|
|
324
|
+
if path is None:
|
|
325
|
+
return None
|
|
326
|
+
key = (str(path.resolve()), path.stat().st_mtime_ns)
|
|
327
|
+
cached = _CACHE.get(key)
|
|
328
|
+
if cached is not None:
|
|
329
|
+
return cached
|
|
330
|
+
try:
|
|
331
|
+
schema = json.loads(path.read_text(encoding="utf-8"))
|
|
332
|
+
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
333
|
+
raise SettingsError(f"{path.name}: not a JSON file ({exc}).") from None
|
|
334
|
+
problems = schema_problems(schema)
|
|
335
|
+
if problems:
|
|
336
|
+
raise SettingsError(f"{path.name} cannot be the response schema: " + "; ".join(problems))
|
|
337
|
+
_CACHE.clear()
|
|
338
|
+
_CACHE[key] = schema
|
|
339
|
+
return schema
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def enabled() -> bool:
|
|
343
|
+
"""Whether the project declares a response schema (a bad one raises `SettingsError`)."""
|
|
344
|
+
return response_schema() is not None
|
|
345
|
+
|
|
346
|
+
|
|
347
|
+
def response_strategy() -> str:
|
|
348
|
+
"""`RESPONSE_FORMAT_STRATEGY`: auto (default), provider or tool."""
|
|
349
|
+
raw = (os.environ.get("RESPONSE_FORMAT_STRATEGY") or "").strip().lower()
|
|
350
|
+
if not raw:
|
|
351
|
+
return STRATEGY_AUTO
|
|
352
|
+
if raw not in STRATEGIES:
|
|
353
|
+
raise SettingsError(
|
|
354
|
+
f"RESPONSE_FORMAT_STRATEGY={raw!r} must be one of {', '.join(STRATEGIES)}."
|
|
355
|
+
)
|
|
356
|
+
return raw
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def structured_settings() -> tuple[dict[str, Any] | None, str]:
|
|
360
|
+
"""The schema and the strategy, validated (startup check)."""
|
|
361
|
+
return response_schema(), response_strategy()
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
# ---------------------------------------------------------------------------
|
|
365
|
+
# The strategy the agent is built with
|
|
366
|
+
# ---------------------------------------------------------------------------
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _model_schema(schema: dict[str, Any]) -> dict[str, Any]:
|
|
370
|
+
"""The schema as the model sees it: named `ANSWER_TOOL`, with a description."""
|
|
371
|
+
return {
|
|
372
|
+
**schema,
|
|
373
|
+
"title": ANSWER_TOOL,
|
|
374
|
+
"description": str(schema.get("description") or DEFAULT_DESCRIPTION),
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def provider_supported(model: Any, tools: list[Any]) -> bool:
|
|
379
|
+
"""Whether LangChain would give `model` (with `tools` bound) its provider's structured output.
|
|
380
|
+
|
|
381
|
+
LangChain's own rule (`create_agent`'s `AutoStrategy`): the model's profile
|
|
382
|
+
says it has structured output, except Gemini before 3 with tools.
|
|
383
|
+
"""
|
|
384
|
+
try:
|
|
385
|
+
from langchain.agents.factory import _supports_provider_strategy
|
|
386
|
+
except ImportError: # moved in a later LangChain: read the model profile
|
|
387
|
+
profile = getattr(model, "profile", None) or {}
|
|
388
|
+
return bool(profile.get("structured_output"))
|
|
389
|
+
return bool(_supports_provider_strategy(model, tools=tools))
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def provider_refusal(model: Any, schema: dict[str, Any]) -> str | None:
|
|
393
|
+
"""Why `model`'s client would refuse `schema` as its provider's structured output; None if not.
|
|
394
|
+
|
|
395
|
+
The client converts the schema before any request is sent, and a schema it
|
|
396
|
+
cannot convert fails every run there. Anthropic: langchain-anthropic
|
|
397
|
+
converts it with the Anthropic SDK's `transform_schema`, which refuses a
|
|
398
|
+
type list (`"type": ["string", "null"]`) and a schema with no `type`,
|
|
399
|
+
`anyOf`, `oneOf` or `allOf` (an `enum` alone, a `true`). Other providers'
|
|
400
|
+
clients take any schema this checker accepts (their provider may still
|
|
401
|
+
refuse one: the run then fails with the provider's error).
|
|
402
|
+
"""
|
|
403
|
+
if not type(model).__module__.startswith("langchain_anthropic"):
|
|
404
|
+
return None
|
|
405
|
+
try:
|
|
406
|
+
from anthropic import transform_schema
|
|
407
|
+
except ImportError: # an SDK without it: nothing to ask
|
|
408
|
+
return None
|
|
409
|
+
try:
|
|
410
|
+
transform_schema(copy.deepcopy(schema))
|
|
411
|
+
except Exception as exc:
|
|
412
|
+
return f"{type(exc).__name__}: {exc}"
|
|
413
|
+
return None
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def response_format(
|
|
417
|
+
model: Any, tools: list[Any]
|
|
418
|
+
) -> ToolStrategy[Any] | ProviderStrategy[Any] | None:
|
|
419
|
+
"""The `response_format` to build the agent with; None when there is no response schema.
|
|
420
|
+
|
|
421
|
+
`auto` decides here, from the model and tools the agent is built with, so
|
|
422
|
+
that the provider strategy is strict (LangChain's own `AutoStrategy` asks
|
|
423
|
+
OpenAI for a best-effort `json_schema`, which the model need not follow),
|
|
424
|
+
and only for a schema the model's client can send (`provider_refusal`):
|
|
425
|
+
else the tool. `provider` with such a schema stops startup, naming why.
|
|
426
|
+
"""
|
|
427
|
+
schema, strategy = structured_settings()
|
|
428
|
+
if schema is None:
|
|
429
|
+
return None
|
|
430
|
+
names = {getattr(t, "name", None) for t in tools}
|
|
431
|
+
if ANSWER_TOOL in names:
|
|
432
|
+
raise SettingsError(
|
|
433
|
+
f"A tool is named {ANSWER_TOOL!r}, the name of the structured answer: rename the tool."
|
|
434
|
+
)
|
|
435
|
+
answer_schema = _model_schema(schema)
|
|
436
|
+
if strategy == STRATEGY_AUTO:
|
|
437
|
+
strategy = STRATEGY_TOOL
|
|
438
|
+
if provider_supported(model, tools):
|
|
439
|
+
refusal = provider_refusal(model, answer_schema)
|
|
440
|
+
if refusal is None:
|
|
441
|
+
strategy = STRATEGY_PROVIDER
|
|
442
|
+
else:
|
|
443
|
+
logger.info(
|
|
444
|
+
"structured answers: the model's client cannot send %s as its provider's "
|
|
445
|
+
"structured output (%s)",
|
|
446
|
+
SCHEMA_FILENAME,
|
|
447
|
+
refusal,
|
|
448
|
+
)
|
|
449
|
+
elif strategy == STRATEGY_PROVIDER:
|
|
450
|
+
refusal = provider_refusal(model, answer_schema)
|
|
451
|
+
if refusal is not None:
|
|
452
|
+
raise SettingsError(
|
|
453
|
+
f"RESPONSE_FORMAT_STRATEGY=provider: the model's client cannot send "
|
|
454
|
+
f"{SCHEMA_FILENAME} as its provider's structured output ({refusal}). Give "
|
|
455
|
+
'every schema in it a "type" (or "anyOf"), write a value that may be null '
|
|
456
|
+
'as "anyOf" with {"type": "null"} rather than a type list, or set '
|
|
457
|
+
"RESPONSE_FORMAT_STRATEGY=tool (or auto)."
|
|
458
|
+
)
|
|
459
|
+
logger.info("structured answers: the %s strategy", strategy)
|
|
460
|
+
if strategy == STRATEGY_PROVIDER:
|
|
461
|
+
return ProviderStrategy(answer_schema, strict=True)
|
|
462
|
+
return ToolStrategy(answer_schema, tool_message_content=ANSWER_RECORDED)
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
# ---------------------------------------------------------------------------
|
|
466
|
+
# The check: a JSON Schema subset validator
|
|
467
|
+
# ---------------------------------------------------------------------------
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _type_matches(value: Any, name: str) -> bool:
|
|
471
|
+
if name == "object":
|
|
472
|
+
return isinstance(value, dict)
|
|
473
|
+
if name == "array":
|
|
474
|
+
return isinstance(value, list)
|
|
475
|
+
if name == "string":
|
|
476
|
+
return isinstance(value, str)
|
|
477
|
+
if name == "boolean":
|
|
478
|
+
return isinstance(value, bool)
|
|
479
|
+
if name == "null":
|
|
480
|
+
return value is None
|
|
481
|
+
if name == "number":
|
|
482
|
+
return _is_number(value)
|
|
483
|
+
# integer: 1.0 is one too (JSON Schema counts a number with no fraction).
|
|
484
|
+
return _is_number(value) and float(value).is_integer()
|
|
485
|
+
|
|
486
|
+
|
|
487
|
+
def _equal(a: Any, b: Any) -> bool:
|
|
488
|
+
"""JSON equality: `true` is not `1`, and `1` equals `1.0`."""
|
|
489
|
+
if isinstance(a, bool) or isinstance(b, bool):
|
|
490
|
+
return isinstance(a, bool) and isinstance(b, bool) and a == b
|
|
491
|
+
if _is_number(a) and _is_number(b):
|
|
492
|
+
return a == b
|
|
493
|
+
if isinstance(a, dict) and isinstance(b, dict):
|
|
494
|
+
return a.keys() == b.keys() and all(_equal(a[k], b[k]) for k in a)
|
|
495
|
+
if isinstance(a, list) and isinstance(b, list):
|
|
496
|
+
return len(a) == len(b) and all(_equal(x, y) for x, y in zip(a, b, strict=True))
|
|
497
|
+
return type(a) is type(b) and a == b
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def validate(schema: Any, value: Any, *, root: Any = None, path: str = "$") -> list[str]:
|
|
501
|
+
"""Where `value` does not fit `schema` (one checked by `schema_problems`); empty when it fits."""
|
|
502
|
+
root = schema if root is None else root
|
|
503
|
+
if schema is True:
|
|
504
|
+
return []
|
|
505
|
+
if schema is False:
|
|
506
|
+
return [f"{path}: no value is allowed here"]
|
|
507
|
+
errors: list[str] = []
|
|
508
|
+
if "$ref" in schema:
|
|
509
|
+
errors += validate(_pointer(root, schema["$ref"]), value, root=root, path=path)
|
|
510
|
+
if "type" in schema:
|
|
511
|
+
types = schema["type"] if isinstance(schema["type"], list) else [schema["type"]]
|
|
512
|
+
if not any(_type_matches(value, t) for t in types):
|
|
513
|
+
got = "null" if value is None else type(value).__name__
|
|
514
|
+
return [*errors, f"{path}: must be {' or '.join(types)}, not {got}"]
|
|
515
|
+
if "enum" in schema and not any(_equal(value, option) for option in schema["enum"]):
|
|
516
|
+
errors.append(f"{path}: must be one of {json.dumps(schema['enum'], ensure_ascii=False)}")
|
|
517
|
+
if "const" in schema and not _equal(value, schema["const"]):
|
|
518
|
+
errors.append(f"{path}: must be {json.dumps(schema['const'], ensure_ascii=False)}")
|
|
519
|
+
if isinstance(value, dict):
|
|
520
|
+
properties = schema.get("properties") or {}
|
|
521
|
+
for name in schema.get("required") or []:
|
|
522
|
+
if name not in value:
|
|
523
|
+
errors.append(f"{path}: missing the required property {name!r}")
|
|
524
|
+
for name, item in value.items():
|
|
525
|
+
if name in properties:
|
|
526
|
+
errors += validate(properties[name], item, root=root, path=f"{path}.{name}")
|
|
527
|
+
elif "additionalProperties" in schema:
|
|
528
|
+
extra = schema["additionalProperties"]
|
|
529
|
+
if extra is False:
|
|
530
|
+
errors.append(f"{path}: the property {name!r} is not allowed")
|
|
531
|
+
else:
|
|
532
|
+
errors += validate(extra, item, root=root, path=f"{path}.{name}")
|
|
533
|
+
if len(value) < schema.get("minProperties", 0):
|
|
534
|
+
errors.append(f"{path}: needs at least {schema['minProperties']} properties")
|
|
535
|
+
if "maxProperties" in schema and len(value) > schema["maxProperties"]:
|
|
536
|
+
errors.append(f"{path}: may have at most {schema['maxProperties']} properties")
|
|
537
|
+
if isinstance(value, list):
|
|
538
|
+
if len(value) < schema.get("minItems", 0):
|
|
539
|
+
errors.append(f"{path}: needs at least {schema['minItems']} items")
|
|
540
|
+
if "maxItems" in schema and len(value) > schema["maxItems"]:
|
|
541
|
+
errors.append(f"{path}: may have at most {schema['maxItems']} items")
|
|
542
|
+
if schema.get("uniqueItems") and any(
|
|
543
|
+
_equal(value[i], value[j]) for i in range(len(value)) for j in range(i)
|
|
544
|
+
):
|
|
545
|
+
errors.append(f"{path}: the items must be unique")
|
|
546
|
+
if "items" in schema:
|
|
547
|
+
for i, item in enumerate(value):
|
|
548
|
+
errors += validate(schema["items"], item, root=root, path=f"{path}[{i}]")
|
|
549
|
+
if isinstance(value, str):
|
|
550
|
+
if len(value) < schema.get("minLength", 0):
|
|
551
|
+
errors.append(f"{path}: must be at least {schema['minLength']} characters")
|
|
552
|
+
if "maxLength" in schema and len(value) > schema["maxLength"]:
|
|
553
|
+
errors.append(f"{path}: must be at most {schema['maxLength']} characters")
|
|
554
|
+
if "pattern" in schema and re.search(schema["pattern"], value) is None:
|
|
555
|
+
errors.append(f"{path}: must match the pattern {schema['pattern']!r}")
|
|
556
|
+
if _is_number(value) and not isinstance(value, bool):
|
|
557
|
+
if "minimum" in schema and value < schema["minimum"]:
|
|
558
|
+
errors.append(f"{path}: must be >= {schema['minimum']}")
|
|
559
|
+
if "maximum" in schema and value > schema["maximum"]:
|
|
560
|
+
errors.append(f"{path}: must be <= {schema['maximum']}")
|
|
561
|
+
if "exclusiveMinimum" in schema and value <= schema["exclusiveMinimum"]:
|
|
562
|
+
errors.append(f"{path}: must be > {schema['exclusiveMinimum']}")
|
|
563
|
+
if "exclusiveMaximum" in schema and value >= schema["exclusiveMaximum"]:
|
|
564
|
+
errors.append(f"{path}: must be < {schema['exclusiveMaximum']}")
|
|
565
|
+
if "multipleOf" in schema:
|
|
566
|
+
ratio = value / schema["multipleOf"]
|
|
567
|
+
if not math.isclose(ratio, round(ratio), rel_tol=0, abs_tol=1e-9):
|
|
568
|
+
errors.append(f"{path}: must be a multiple of {schema['multipleOf']}")
|
|
569
|
+
for sub in schema.get("allOf") or []:
|
|
570
|
+
errors += validate(sub, value, root=root, path=path)
|
|
571
|
+
if "anyOf" in schema and all(
|
|
572
|
+
validate(sub, value, root=root, path=path) for sub in schema["anyOf"]
|
|
573
|
+
):
|
|
574
|
+
errors.append(f"{path}: fits none of the anyOf choices")
|
|
575
|
+
if "oneOf" in schema:
|
|
576
|
+
fits = sum(1 for sub in schema["oneOf"] if not validate(sub, value, root=root, path=path))
|
|
577
|
+
if fits != 1:
|
|
578
|
+
errors.append(f"{path}: fits {fits} of the oneOf choices, not exactly one")
|
|
579
|
+
if "not" in schema and not validate(schema["not"], value, root=root, path=path):
|
|
580
|
+
errors.append(f"{path}: must not fit the `not` schema")
|
|
581
|
+
return errors
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
# ---------------------------------------------------------------------------
|
|
585
|
+
# The middleware: check each final answer, and ask again when it does not fit
|
|
586
|
+
# ---------------------------------------------------------------------------
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def _last_ai(messages: list[Any]) -> AIMessage | None:
|
|
590
|
+
for message in reversed(messages):
|
|
591
|
+
if isinstance(message, AIMessage):
|
|
592
|
+
return message
|
|
593
|
+
return None
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
def _answer_call(message: AIMessage) -> dict[str, Any] | None:
|
|
597
|
+
for call in message.tool_calls:
|
|
598
|
+
if call.get("name") == ANSWER_TOOL:
|
|
599
|
+
return call
|
|
600
|
+
return None
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def _correction(bad: AIMessage, problem: str) -> list[Any]:
|
|
604
|
+
"""The try and what the model reads about it: a tool error, or a note from the user's side."""
|
|
605
|
+
call = _answer_call(bad)
|
|
606
|
+
if call is not None:
|
|
607
|
+
if problem == ANSWER_NOT_ALONE:
|
|
608
|
+
note = f"Error: {problem}."
|
|
609
|
+
else:
|
|
610
|
+
note = (
|
|
611
|
+
f"Error: your answer does not fit the required schema: {problem}. "
|
|
612
|
+
f"Call {ANSWER_TOOL} again with a corrected answer."
|
|
613
|
+
)
|
|
614
|
+
# Every call of the try gets a result, or the provider refuses the request.
|
|
615
|
+
return [
|
|
616
|
+
bad,
|
|
617
|
+
*(
|
|
618
|
+
ToolMessage(
|
|
619
|
+
note if other is call else OTHER_CALL_NOT_RUN,
|
|
620
|
+
tool_call_id=other["id"],
|
|
621
|
+
name=other["name"],
|
|
622
|
+
status="error",
|
|
623
|
+
)
|
|
624
|
+
for other in bad.tool_calls
|
|
625
|
+
),
|
|
626
|
+
]
|
|
627
|
+
note = (
|
|
628
|
+
f"Your final answer does not fit the required JSON schema: {problem}. "
|
|
629
|
+
"Reply again with only the corrected JSON answer."
|
|
630
|
+
)
|
|
631
|
+
return [bad, HumanMessage(note)]
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def _add_usage(total: dict[str, Any] | None, usage: Any) -> dict[str, Any] | None:
|
|
635
|
+
if not isinstance(usage, dict):
|
|
636
|
+
return total
|
|
637
|
+
summed = dict(total or {})
|
|
638
|
+
for key in ("input_tokens", "output_tokens", "total_tokens"):
|
|
639
|
+
summed[key] = int(summed.get(key) or 0) + int(usage.get(key) or 0)
|
|
640
|
+
return summed
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
class StructuredAnswer(AgentMiddleware):
|
|
644
|
+
"""Check the model's final answer against the response schema; ask again when it does not fit.
|
|
645
|
+
|
|
646
|
+
A no-op without a response schema. Put it last in the middleware list: it
|
|
647
|
+
wraps the model call innermost, so the correction it adds is not fenced as
|
|
648
|
+
a tool result, and it sees the model's reply before any other middleware.
|
|
649
|
+
"""
|
|
650
|
+
|
|
651
|
+
def __init__(self, attempts: int = MAX_ANSWER_ATTEMPTS) -> None:
|
|
652
|
+
super().__init__()
|
|
653
|
+
self.attempts = attempts
|
|
654
|
+
|
|
655
|
+
def _problem(
|
|
656
|
+
self, response: Any, schema: dict[str, Any]
|
|
657
|
+
) -> tuple[str | None, AIMessage | None]:
|
|
658
|
+
"""What is wrong with this reply as a final answer (None: nothing), and the reply.
|
|
659
|
+
|
|
660
|
+
An answer must come alone: under the tool strategy LangChain takes an
|
|
661
|
+
answer given beside other tool calls and still runs those calls, after
|
|
662
|
+
the answer was written (a gated one pauses the run with the answer
|
|
663
|
+
already given, and the resumed run then ends with none), so such a try
|
|
664
|
+
is sent back whether or not the answer fits.
|
|
665
|
+
"""
|
|
666
|
+
bad = _last_ai(response.result)
|
|
667
|
+
answer = response.structured_response
|
|
668
|
+
if answer is not None:
|
|
669
|
+
if bad is not None and any(c.get("name") != ANSWER_TOOL for c in bad.tool_calls):
|
|
670
|
+
return ANSWER_NOT_ALONE, bad
|
|
671
|
+
errors = validate(schema, answer)
|
|
672
|
+
if not errors:
|
|
673
|
+
return None, bad
|
|
674
|
+
return "; ".join(errors[:MAX_REPORTED_PROBLEMS]), bad
|
|
675
|
+
if bad is None or bad.tool_calls or bad.invalid_tool_calls:
|
|
676
|
+
return None, bad # not a final reply: tools run, or AnswerInvalidToolCalls answers
|
|
677
|
+
return (
|
|
678
|
+
f"the reply is plain text, not an answer: call {ANSWER_TOOL} with your answer",
|
|
679
|
+
bad,
|
|
680
|
+
)
|
|
681
|
+
|
|
682
|
+
def _prepare(self, request: Any) -> dict[str, Any] | None:
|
|
683
|
+
if request.response_format is None:
|
|
684
|
+
return None
|
|
685
|
+
return response_schema()
|
|
686
|
+
|
|
687
|
+
def _settle(self, response: Any, tries: list[AIMessage]) -> Any:
|
|
688
|
+
"""The reply that fits, with the failed tries' token usage added to its own."""
|
|
689
|
+
if not tries:
|
|
690
|
+
return response
|
|
691
|
+
answer = _last_ai(response.result)
|
|
692
|
+
usage = answer.usage_metadata if answer is not None else None
|
|
693
|
+
for bad in tries:
|
|
694
|
+
usage = _add_usage(usage, bad.usage_metadata)
|
|
695
|
+
logger.info("structured answer: it fitted the schema after %d correction(s)", len(tries))
|
|
696
|
+
result = [
|
|
697
|
+
m.model_copy(update={"usage_metadata": usage}) if m is answer and usage else m
|
|
698
|
+
for m in response.result
|
|
699
|
+
]
|
|
700
|
+
return ModelResponse(result=result, structured_response=response.structured_response)
|
|
701
|
+
|
|
702
|
+
def _next(self, request: Any, extra: list[Any], problem: str, attempt: int) -> Any:
|
|
703
|
+
if attempt + 1 >= self.attempts:
|
|
704
|
+
raise StructuredAnswerError(
|
|
705
|
+
f"The answer did not fit the response schema after {self.attempts} tries: {problem}"
|
|
706
|
+
)
|
|
707
|
+
logger.info("structured answer: try %d did not fit; asking again", attempt + 1)
|
|
708
|
+
return request.override(messages=[*request.messages, *extra])
|
|
709
|
+
|
|
710
|
+
def wrap_model_call(self, request: Any, handler: Any) -> Any:
|
|
711
|
+
schema = self._prepare(request)
|
|
712
|
+
if schema is None:
|
|
713
|
+
return handler(request)
|
|
714
|
+
tries: list[AIMessage] = []
|
|
715
|
+
extra: list[Any] = []
|
|
716
|
+
current = request
|
|
717
|
+
for attempt in range(self.attempts):
|
|
718
|
+
try:
|
|
719
|
+
response = handler(current)
|
|
720
|
+
except StructuredOutputValidationError as exc:
|
|
721
|
+
problem, bad = f"the reply is not valid JSON ({exc.source})", exc.ai_message
|
|
722
|
+
else:
|
|
723
|
+
problem, bad = self._problem(response, schema)
|
|
724
|
+
if problem is None:
|
|
725
|
+
return self._settle(response, tries)
|
|
726
|
+
tries.append(bad)
|
|
727
|
+
extra += _correction(bad, problem)
|
|
728
|
+
current = self._next(request, extra, problem, attempt)
|
|
729
|
+
raise AssertionError("unreachable")
|
|
730
|
+
|
|
731
|
+
async def awrap_model_call(self, request: Any, handler: Any) -> Any:
|
|
732
|
+
schema = self._prepare(request)
|
|
733
|
+
if schema is None:
|
|
734
|
+
return await handler(request)
|
|
735
|
+
tries: list[AIMessage] = []
|
|
736
|
+
extra: list[Any] = []
|
|
737
|
+
current = request
|
|
738
|
+
for attempt in range(self.attempts):
|
|
739
|
+
try:
|
|
740
|
+
response = await handler(current)
|
|
741
|
+
except StructuredOutputValidationError as exc:
|
|
742
|
+
problem, bad = f"the reply is not valid JSON ({exc.source})", exc.ai_message
|
|
743
|
+
else:
|
|
744
|
+
problem, bad = self._problem(response, schema)
|
|
745
|
+
if problem is None:
|
|
746
|
+
return self._settle(response, tries)
|
|
747
|
+
tries.append(bad)
|
|
748
|
+
extra += _correction(bad, problem)
|
|
749
|
+
current = self._next(request, extra, problem, attempt)
|
|
750
|
+
raise AssertionError("unreachable")
|
|
751
|
+
|
|
752
|
+
|
|
753
|
+
def answer_text(answer: Any) -> str:
|
|
754
|
+
"""The answer's JSON text, as the reply text of a structured run."""
|
|
755
|
+
return json.dumps(answer, ensure_ascii=False)
|