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,770 @@
|
|
|
1
|
+
# Copyright 2026 Google LLC
|
|
2
|
+
# Modifications Copyright 2026 graph-agents-cli contributors
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""FastAPI application: `app` (the chat API, threads, health, metrics, playground and A2A).
|
|
17
|
+
|
|
18
|
+
Routes:
|
|
19
|
+
* `POST /chat` (SSE): `message.start`, `message.delta`, `tool.call`,
|
|
20
|
+
`tool.result`, `message.end`, `error`; `thread_id` continues a thread
|
|
21
|
+
(omit it to start one: the server generates a random id).
|
|
22
|
+
409 `{"code": "thread_busy"}` while the thread has a run in progress, and
|
|
23
|
+
409 `{"code": "approval_pending", "approvals": [...]}` while it waits for
|
|
24
|
+
the approval of a gated API call. A failed tool call's `tool.result`
|
|
25
|
+
carries an `error_id` and, outside `APP_ENV=dev`, a generic `result` (the
|
|
26
|
+
error text is for the model only). A run that pauses before a gated call
|
|
27
|
+
ends with `message.end` status `awaiting_approval`, with `approval` (and
|
|
28
|
+
`approvals`: every one the run waits for). With a response schema
|
|
29
|
+
(`app_utils/structured.py`), a completed run's `message.end` carries the
|
|
30
|
+
answer as `structured_response`, its only `message.delta` is the answer's
|
|
31
|
+
JSON text, and a run whose answer never fits ends with the `error` code
|
|
32
|
+
`invalid_structured_response`.
|
|
33
|
+
* `GET /threads/{thread_id}/approvals` (`approval.read`): the thread's
|
|
34
|
+
approvals (owner and read-across roles: all; a decider: the ones it may
|
|
35
|
+
decide). `GET /approvals?status=&limit=&offset=`: across threads, the
|
|
36
|
+
caller's own and the ones a role of theirs may decide (read-across: all).
|
|
37
|
+
* `POST /threads/{thread_id}/approvals/{approval_id}` (`approval.decide`),
|
|
38
|
+
body `{"decision": "approve" | "reject", "comment": "..."}`: 404, 403 for
|
|
39
|
+
a principal the approvers do not name, 409 `approval_not_pending`, 410
|
|
40
|
+
`approval_expired`, 409 `thread_busy`; else the resumed run streams with
|
|
41
|
+
the `/chat` events (`message.start` names `approval_id` and `decision`).
|
|
42
|
+
* `GET /threads`: the caller's threads, most recent first (`limit`, `offset`);
|
|
43
|
+
`scope=all` lists every principal's (read-across roles only). Each row
|
|
44
|
+
names its owner hashed (`owner`).
|
|
45
|
+
* `GET /threads/{thread_id}/messages`: ordered messages, ownership enforced.
|
|
46
|
+
* `DELETE /threads/{thread_id}`: the thread, its checkpoints, run records and
|
|
47
|
+
approvals (owner only), and the A2A tasks of that conversation. Under
|
|
48
|
+
langgraph-server this is the server's native route (owner-only through the
|
|
49
|
+
auth handler); once it succeeds, the app drops the thread's run records,
|
|
50
|
+
approvals and A2A tasks (`ThreadDeleteHookMiddleware`).
|
|
51
|
+
* `GET /health`: liveness, process only: `{"status", "runtime", "checkpointer"}`.
|
|
52
|
+
* `GET /ready`: readiness: 200 when the database answers within 2 s, else
|
|
53
|
+
503 `{"status": "not_ready"}`.
|
|
54
|
+
* `GET /metrics`: Prometheus text (`METRICS_ENABLED`, default true); with
|
|
55
|
+
`METRICS_TOKEN` set, only for `Authorization: Bearer <METRICS_TOKEN>`.
|
|
56
|
+
* `GET /playground`: dev-only chat page (`APP_ENV=dev`).
|
|
57
|
+
* `GET /openapi.json`, `GET /docs`: dev-only as well (`APP_ENV=dev`); off otherwise.
|
|
58
|
+
* A2A: card at `/a2a/<agent_directory>/.well-known/agent-card.json`, JSON-RPC at `/a2a/<agent_directory>`.
|
|
59
|
+
|
|
60
|
+
Under the fastapi runtime the lifespan binds the checkpointer chosen by
|
|
61
|
+
`CHECKPOINTER` to the graph and opens the app tables. Under langgraph-server
|
|
62
|
+
the same file is mounted as the custom app (`langgraph.json` `http.app`),
|
|
63
|
+
the server owns persistence, and the routes proxy to it (see `app_utils/chat.py`).
|
|
64
|
+
Every route except `/health`, `/ready`, `/metrics`, `/playground` and the
|
|
65
|
+
dev-only docs passes through the policy of `app_utils/auth.py`: the routes
|
|
66
|
+
through the `require(action)` dependency, the A2A endpoints through the ASGI
|
|
67
|
+
middleware below. Under langgraph-server the server's own meta routes
|
|
68
|
+
(`/docs`, `/openapi.json`, `/info`, `/metrics`) come ahead of this app's
|
|
69
|
+
routes unless they are disabled (the server image sets `disable_meta`), and
|
|
70
|
+
the server's native API gets its auth errors from `AuthErrorMiddleware`.
|
|
71
|
+
|
|
72
|
+
Request limits: bodies over `MAX_REQUEST_BYTES` get 413, a message over
|
|
73
|
+
`MAX_MESSAGE_CHARS` (the same cap A2A applies) and `/chat` metadata outside
|
|
74
|
+
`MAX_METADATA_KEYS` / `MAX_METADATA_VALUE_CHARS` get 422 (see
|
|
75
|
+
`app_utils/limits.py`). `CORS_ALLOW_ORIGINS` (comma list; empty = no CORS)
|
|
76
|
+
enables CORS for those origins under fastapi; LangGraph Server reads the same
|
|
77
|
+
variable itself. Unhandled errors answer 500 with an `error_id` that names the
|
|
78
|
+
logged detail (and the request's `X-Request-ID`). A 422 never echoes the
|
|
79
|
+
request's values (`input`), so it cannot fail on text that is not valid
|
|
80
|
+
UTF-8 (an unpaired surrogate) or on the NaN or Infinity Python's JSON parser
|
|
81
|
+
lets through, and never returns a long message back to its sender.
|
|
82
|
+
|
|
83
|
+
`.env` is read (below the process environment) as this module is imported,
|
|
84
|
+
before any setting is: the A2A card, the docs switch, CORS and the auth
|
|
85
|
+
policy's startup check are fixed at import. Under the fastapi runtime
|
|
86
|
+
logging is configured on import too, so import-time warnings and uvicorn's
|
|
87
|
+
startup lines follow `LOG_FORMAT`.
|
|
88
|
+
|
|
89
|
+
No ``from __future__ import annotations`` here: LangGraph Server loads this
|
|
90
|
+
file as ``user_router_module`` without registering it in ``sys.modules``, and
|
|
91
|
+
pydantic cannot resolve string annotations for a module it cannot find (the
|
|
92
|
+
``ChatBody`` schema would then be "not fully defined" and the server's OpenAPI
|
|
93
|
+
generation fails at startup). Real annotations need no lookup.
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
import contextlib
|
|
97
|
+
import json
|
|
98
|
+
import logging
|
|
99
|
+
import math
|
|
100
|
+
import os
|
|
101
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
102
|
+
from contextlib import aclosing, asynccontextmanager
|
|
103
|
+
from typing import Any, Literal
|
|
104
|
+
|
|
105
|
+
from dotenv import dotenv_values
|
|
106
|
+
from fastapi import Depends, FastAPI, HTTPException, Query, Request, Response
|
|
107
|
+
from fastapi.encoders import jsonable_encoder
|
|
108
|
+
from fastapi.exceptions import RequestValidationError
|
|
109
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
110
|
+
from fastapi.responses import HTMLResponse, JSONResponse
|
|
111
|
+
from psycopg import OperationalError
|
|
112
|
+
from psycopg_pool import PoolTimeout
|
|
113
|
+
from pydantic import BaseModel, Field, field_validator
|
|
114
|
+
from starlette.types import ASGIApp, Receive, Scope, Send
|
|
115
|
+
|
|
116
|
+
# `.env` first, below the process environment (as `load_dotenv()` does): the
|
|
117
|
+
# modules imported next, and this one, read settings as they load (A2A_NAME,
|
|
118
|
+
# the A2A card's auth scheme, APP_ENV for the docs, CORS_ALLOW_ORIGINS).
|
|
119
|
+
# PYTHON_DOTENV_DISABLED switches it off, as it does `load_dotenv()`: the
|
|
120
|
+
# project's tests set it so a developer's `.env` never reaches them.
|
|
121
|
+
os.environ.update(
|
|
122
|
+
{
|
|
123
|
+
key: value
|
|
124
|
+
for key, value in dotenv_values().items()
|
|
125
|
+
if value is not None and key not in os.environ
|
|
126
|
+
}
|
|
127
|
+
if os.environ.get("PYTHON_DOTENV_DISABLED", "").strip().lower()
|
|
128
|
+
not in {"1", "true", "t", "yes", "y"}
|
|
129
|
+
else {}
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
from {{cookiecutter.agent_directory}}.app_utils.a2a import (
|
|
133
|
+
A2A_RPC_PATH,
|
|
134
|
+
add_a2a_routes,
|
|
135
|
+
forget_context,
|
|
136
|
+
legacy_request_error,
|
|
137
|
+
task_ttl_s,
|
|
138
|
+
)
|
|
139
|
+
from {{cookiecutter.agent_directory}}.app_utils.a2a_client import client_settings
|
|
140
|
+
from {{cookiecutter.agent_directory}}.app_utils.api_client import set_outbound_headers
|
|
141
|
+
from {{cookiecutter.agent_directory}}.app_utils.approvals import CODE_APPROVAL_PENDING, STATUSES
|
|
142
|
+
from {{cookiecutter.agent_directory}}.app_utils.auth import (
|
|
143
|
+
Principal,
|
|
144
|
+
authenticate_and_authorize,
|
|
145
|
+
caller_note_enabled,
|
|
146
|
+
delegated_mentions,
|
|
147
|
+
delegation_settings,
|
|
148
|
+
require,
|
|
149
|
+
)
|
|
150
|
+
from {{cookiecutter.agent_directory}}.app_utils.chat import (
|
|
151
|
+
EVENT_TOOL_RESULT,
|
|
152
|
+
FASTAPI,
|
|
153
|
+
LANGGRAPH_SERVER,
|
|
154
|
+
RUNTIME,
|
|
155
|
+
ApprovalError,
|
|
156
|
+
ApprovalPending,
|
|
157
|
+
ChatRequest,
|
|
158
|
+
detect_runtime,
|
|
159
|
+
dev_mode,
|
|
160
|
+
forward_header_names,
|
|
161
|
+
log_unavailable,
|
|
162
|
+
new_error_id,
|
|
163
|
+
select_forward_headers,
|
|
164
|
+
sse_encode,
|
|
165
|
+
unavailable,
|
|
166
|
+
unavailable_detail,
|
|
167
|
+
validate_thread_id,
|
|
168
|
+
)
|
|
169
|
+
from {{cookiecutter.agent_directory}}.app_utils.checkpointer import pool_sizes
|
|
170
|
+
from {{cookiecutter.agent_directory}}.app_utils.content import client_message, client_tool_result
|
|
171
|
+
from {{cookiecutter.agent_directory}}.app_utils.db import StorageNotReady, is_database_unavailable
|
|
172
|
+
from {{cookiecutter.agent_directory}}.app_utils.limits import (
|
|
173
|
+
THREAD_ID_PATTERN,
|
|
174
|
+
SettingsError,
|
|
175
|
+
check_metadata,
|
|
176
|
+
check_settings,
|
|
177
|
+
)
|
|
178
|
+
from {{cookiecutter.agent_directory}}.app_utils.metrics import (
|
|
179
|
+
metrics_authorized,
|
|
180
|
+
metrics_enabled,
|
|
181
|
+
render,
|
|
182
|
+
)
|
|
183
|
+
from {{cookiecutter.agent_directory}}.app_utils.middleware import (
|
|
184
|
+
REQUEST_ID_HEADER,
|
|
185
|
+
AuthErrorMiddleware,
|
|
186
|
+
BodySizeLimitMiddleware,
|
|
187
|
+
RequestContextMiddleware,
|
|
188
|
+
RunStreamingResponse,
|
|
189
|
+
ThreadDeleteHookMiddleware,
|
|
190
|
+
max_message_chars,
|
|
191
|
+
read_body,
|
|
192
|
+
replay_body,
|
|
193
|
+
)
|
|
194
|
+
from {{cookiecutter.agent_directory}}.app_utils.model import model_limits, model_options
|
|
195
|
+
from {{cookiecutter.agent_directory}}.app_utils.playground import PLAYGROUND_HTML
|
|
196
|
+
from {{cookiecutter.agent_directory}}.app_utils.structured import structured_settings
|
|
197
|
+
from {{cookiecutter.agent_directory}}.app_utils.telemetry import (
|
|
198
|
+
bind_log_context,
|
|
199
|
+
log_format,
|
|
200
|
+
log_level,
|
|
201
|
+
outbound_trace_headers,
|
|
202
|
+
propagate_to_every_api,
|
|
203
|
+
setup_logging,
|
|
204
|
+
setup_server_logging,
|
|
205
|
+
setup_telemetry,
|
|
206
|
+
trace_capture,
|
|
207
|
+
trace_scope,
|
|
208
|
+
)
|
|
209
|
+
from {{cookiecutter.agent_directory}}.app_utils.threads import (
|
|
210
|
+
SCOPE_ALL,
|
|
211
|
+
SCOPE_OWN,
|
|
212
|
+
THREAD_BUSY,
|
|
213
|
+
ThreadBusy,
|
|
214
|
+
check_scope,
|
|
215
|
+
own_view,
|
|
216
|
+
search_server_threads,
|
|
217
|
+
)
|
|
218
|
+
from {{cookiecutter.agent_directory}}.app_utils.token_exchange import exchange_settings
|
|
219
|
+
|
|
220
|
+
logger = logging.getLogger(__name__)
|
|
221
|
+
|
|
222
|
+
# Calls of another agent (`protocol: a2a`) and of `auth: forward` and `auth: exchange` APIs
|
|
223
|
+
# carry this request's id and trace context (PROPAGATE_TRACE_HEADERS=peers, the default);
|
|
224
|
+
# other APIs receive neither, unless PROPAGATE_TRACE_HEADERS=all; off sends them nowhere.
|
|
225
|
+
set_outbound_headers(outbound_trace_headers, everywhere=propagate_to_every_api)
|
|
226
|
+
|
|
227
|
+
if detect_runtime() == FASTAPI:
|
|
228
|
+
# Now rather than in the lifespan: what is logged before it (the A2A
|
|
229
|
+
# card's APP_URL warning below, uvicorn's startup lines) follows
|
|
230
|
+
# LOG_FORMAT too. A bad LOG_LEVEL or LOG_FORMAT is left to the lifespan's
|
|
231
|
+
# settings check, which reports every bad setting at once.
|
|
232
|
+
with contextlib.suppress(SettingsError):
|
|
233
|
+
setup_logging()
|
|
234
|
+
else:
|
|
235
|
+
# LangGraph Server configures logging itself; keep query strings and
|
|
236
|
+
# outbound URLs out of its lines.
|
|
237
|
+
setup_server_logging()
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
@asynccontextmanager
|
|
241
|
+
async def lifespan(app_instance: FastAPI) -> AsyncIterator[None]:
|
|
242
|
+
# A bad limit, log, metrics, pool, model, trace-capture, A2A or response-schema
|
|
243
|
+
# setting stops startup instead of being guessed.
|
|
244
|
+
check_settings(
|
|
245
|
+
extra=(
|
|
246
|
+
log_level,
|
|
247
|
+
log_format,
|
|
248
|
+
metrics_enabled,
|
|
249
|
+
pool_sizes,
|
|
250
|
+
model_limits,
|
|
251
|
+
model_options,
|
|
252
|
+
trace_capture,
|
|
253
|
+
task_ttl_s,
|
|
254
|
+
max_message_chars,
|
|
255
|
+
delegation_settings,
|
|
256
|
+
delegated_mentions,
|
|
257
|
+
caller_note_enabled,
|
|
258
|
+
exchange_settings,
|
|
259
|
+
trace_scope,
|
|
260
|
+
client_settings,
|
|
261
|
+
structured_settings,
|
|
262
|
+
)
|
|
263
|
+
)
|
|
264
|
+
if RUNTIME.runtime == FASTAPI:
|
|
265
|
+
setup_logging()
|
|
266
|
+
else:
|
|
267
|
+
setup_server_logging() # LangGraph Server configures logging itself
|
|
268
|
+
setup_telemetry()
|
|
269
|
+
await RUNTIME.start()
|
|
270
|
+
try:
|
|
271
|
+
yield
|
|
272
|
+
finally:
|
|
273
|
+
await RUNTIME.stop()
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class A2APolicyMiddleware:
|
|
277
|
+
"""Authenticate and authorize the A2A endpoints with the selected policy.
|
|
278
|
+
|
|
279
|
+
`card.read` for the agent card, `a2a.invoke` for JSON-RPC. The principal
|
|
280
|
+
is stored in the request state for the executor. An A2A 0.3 request that
|
|
281
|
+
fails the SDK's validation, holds text that is not valid Unicode, or sends
|
|
282
|
+
a message that cannot run (no text, an empty text part, over
|
|
283
|
+
`MAX_MESSAGE_CHARS`) is answered here with a JSON-RPC error naming fields,
|
|
284
|
+
never values: past this point the SDK's 0.3 layer would log the values at
|
|
285
|
+
ERROR or turn it into an internal error (1.0 requests are checked by the
|
|
286
|
+
handler).
|
|
287
|
+
"""
|
|
288
|
+
|
|
289
|
+
def __init__(self, app: ASGIApp, prefix: str) -> None:
|
|
290
|
+
self.app = app
|
|
291
|
+
self.prefix = prefix
|
|
292
|
+
|
|
293
|
+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
|
294
|
+
if scope["type"] != "http" or not scope["path"].startswith(self.prefix):
|
|
295
|
+
await self.app(scope, receive, send)
|
|
296
|
+
return
|
|
297
|
+
request = Request(scope, receive)
|
|
298
|
+
action = "card.read" if scope["path"].endswith("agent-card.json") else "a2a.invoke"
|
|
299
|
+
try:
|
|
300
|
+
principal = await authenticate_and_authorize(request, action)
|
|
301
|
+
except HTTPException as exc:
|
|
302
|
+
response = JSONResponse(
|
|
303
|
+
{"detail": exc.detail}, status_code=exc.status_code, headers=exc.headers
|
|
304
|
+
)
|
|
305
|
+
await response(scope, receive, send)
|
|
306
|
+
return
|
|
307
|
+
scope.setdefault("state", {})["principal"] = principal
|
|
308
|
+
if action == "a2a.invoke" and scope.get("method") == "POST":
|
|
309
|
+
try:
|
|
310
|
+
body = await read_body(receive)
|
|
311
|
+
except HTTPException as exc: # the body cap, while reading a chunked body
|
|
312
|
+
await JSONResponse({"detail": exc.detail}, status_code=exc.status_code)(
|
|
313
|
+
scope, receive, send
|
|
314
|
+
)
|
|
315
|
+
return
|
|
316
|
+
refusal = _legacy_refusal(body)
|
|
317
|
+
if refusal is not None:
|
|
318
|
+
await JSONResponse(refusal)(scope, receive, send)
|
|
319
|
+
return
|
|
320
|
+
receive = replay_body(body, receive)
|
|
321
|
+
await self.app(scope, receive, send)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _legacy_refusal(body: bytes) -> dict[str, Any] | None:
|
|
325
|
+
"""The JSON-RPC error answer to an A2A 0.3 request that cannot run, or None.
|
|
326
|
+
|
|
327
|
+
The body is parsed, not searched: a method name may be JSON-escaped
|
|
328
|
+
(`"message\\/send"`, as PHP's json_encode writes it).
|
|
329
|
+
"""
|
|
330
|
+
try:
|
|
331
|
+
payload = json.loads(body)
|
|
332
|
+
except ValueError:
|
|
333
|
+
return None # the SDK answers a parse error itself
|
|
334
|
+
error = legacy_request_error(payload)
|
|
335
|
+
if error is None:
|
|
336
|
+
return None
|
|
337
|
+
request_id = payload.get("id")
|
|
338
|
+
if not isinstance(request_id, str | int) or isinstance(request_id, bool):
|
|
339
|
+
request_id = None
|
|
340
|
+
return {"jsonrpc": "2.0", "id": request_id, "error": error}
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _dev_mode() -> bool:
|
|
344
|
+
"""`APP_ENV` is exactly `dev` (as `auth.dev_mode`)."""
|
|
345
|
+
return os.environ.get("APP_ENV") == "dev"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def cors_origins() -> list[str]:
|
|
349
|
+
"""`CORS_ALLOW_ORIGINS` as a list; empty means no CORS middleware."""
|
|
350
|
+
return [o.strip() for o in (os.environ.get("CORS_ALLOW_ORIGINS") or "").split(",") if o.strip()]
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# The schema and Swagger UI are unauthenticated FastAPI defaults; like
|
|
354
|
+
# /playground they exist only under APP_ENV=dev. app.openapi() still works
|
|
355
|
+
# (LangGraph Server builds its spec from it).
|
|
356
|
+
app = FastAPI(
|
|
357
|
+
title="{{cookiecutter.project_name}}",
|
|
358
|
+
description="LangGraph agent: chat SSE API and A2A protocol",
|
|
359
|
+
version=os.environ.get("AGENT_VERSION", "0.1.0"),
|
|
360
|
+
lifespan=lifespan,
|
|
361
|
+
openapi_url="/openapi.json" if _dev_mode() else None,
|
|
362
|
+
docs_url="/docs" if _dev_mode() else None,
|
|
363
|
+
redoc_url=None,
|
|
364
|
+
)
|
|
365
|
+
# Starlette runs the last-added middleware first: request context, then the
|
|
366
|
+
# body cap, then (langgraph-server) the native API's auth errors and thread
|
|
367
|
+
# deletes, then CORS (answers preflights before auth), then the A2A policy.
|
|
368
|
+
app.add_middleware(A2APolicyMiddleware, prefix=A2A_RPC_PATH)
|
|
369
|
+
if detect_runtime() == FASTAPI and cors_origins():
|
|
370
|
+
_origins = cors_origins()
|
|
371
|
+
app.add_middleware(
|
|
372
|
+
CORSMiddleware,
|
|
373
|
+
allow_origins=_origins,
|
|
374
|
+
# Cookies (custom policies) only with explicit origins, never with "*".
|
|
375
|
+
allow_credentials="*" not in _origins,
|
|
376
|
+
allow_methods=["GET", "POST", "DELETE", "OPTIONS"],
|
|
377
|
+
allow_headers=["authorization", "content-type", "x-request-id", *forward_header_names()],
|
|
378
|
+
expose_headers=["x-request-id"],
|
|
379
|
+
)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
async def _server_thread_deleted(thread_id: str) -> None:
|
|
383
|
+
"""langgraph-server: after the server deleted a thread, its run records, approvals and
|
|
384
|
+
A2A tasks."""
|
|
385
|
+
try:
|
|
386
|
+
await RUNTIME.forget_thread_runs(thread_id)
|
|
387
|
+
finally:
|
|
388
|
+
await forget_context(thread_id)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
if detect_runtime() != FASTAPI:
|
|
392
|
+
app.add_middleware(ThreadDeleteHookMiddleware, on_deleted=_server_thread_deleted)
|
|
393
|
+
app.add_middleware(AuthErrorMiddleware)
|
|
394
|
+
app.add_middleware(BodySizeLimitMiddleware)
|
|
395
|
+
app.add_middleware(RequestContextMiddleware)
|
|
396
|
+
add_a2a_routes(app)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
@app.exception_handler(ThreadBusy)
|
|
400
|
+
async def thread_busy_handler(request: Request, exc: ThreadBusy) -> JSONResponse:
|
|
401
|
+
return JSONResponse(status_code=409, content={"code": THREAD_BUSY, "detail": str(exc)})
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
@app.exception_handler(ApprovalPending)
|
|
405
|
+
async def approval_pending_handler(request: Request, exc: ApprovalPending) -> JSONResponse:
|
|
406
|
+
return JSONResponse(
|
|
407
|
+
status_code=409,
|
|
408
|
+
content={"code": CODE_APPROVAL_PENDING, "detail": str(exc), "approvals": exc.approvals},
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
@app.exception_handler(ApprovalError)
|
|
413
|
+
async def approval_error_handler(request: Request, exc: ApprovalError) -> JSONResponse:
|
|
414
|
+
return JSONResponse(status_code=exc.status_code, content=exc.body())
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _request_id_header(request: Request) -> dict[str, str] | None:
|
|
418
|
+
request_id = getattr(request.state, "request_id", None)
|
|
419
|
+
return {REQUEST_ID_HEADER: request_id} if isinstance(request_id, str) else None
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
async def database_unavailable_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
423
|
+
"""503 with an error id for an unreachable database that reached no route's own handling.
|
|
424
|
+
|
|
425
|
+
Every chat route already turns these into a 503; this covers any other
|
|
426
|
+
route. Registered for the exception classes (not `Exception`), so it
|
|
427
|
+
answers inside the app: one WARNING line, no traceback, nothing re-raised
|
|
428
|
+
for uvicorn to log again.
|
|
429
|
+
"""
|
|
430
|
+
error_id = log_unavailable("Database", exc)
|
|
431
|
+
return JSONResponse(
|
|
432
|
+
status_code=503,
|
|
433
|
+
content={"detail": unavailable_detail("Database", error_id), "error_id": error_id},
|
|
434
|
+
headers=_request_id_header(request),
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
for _unreachable in (OperationalError, PoolTimeout, StorageNotReady):
|
|
439
|
+
app.add_exception_handler(_unreachable, database_unavailable_handler)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@app.exception_handler(Exception)
|
|
443
|
+
async def unhandled_error_handler(request: Request, exc: Exception) -> JSONResponse:
|
|
444
|
+
"""500 (503 when the database is unreachable) with an id naming the logged detail."""
|
|
445
|
+
if is_database_unavailable(exc):
|
|
446
|
+
return await database_unavailable_handler(request, exc)
|
|
447
|
+
error_id = new_error_id()
|
|
448
|
+
logger.error("unhandled error (error_id=%s)", error_id, exc_info=exc)
|
|
449
|
+
# This handler answers from outside RequestContextMiddleware, which adds
|
|
450
|
+
# the header to every other response; it left the request id in the state.
|
|
451
|
+
return JSONResponse(
|
|
452
|
+
status_code=500,
|
|
453
|
+
content={"detail": f"Internal server error. Reference: {error_id}.", "error_id": error_id},
|
|
454
|
+
headers=_request_id_header(request),
|
|
455
|
+
)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _json_safe(value: Any) -> Any:
|
|
459
|
+
"""`value` encodable as UTF-8 JSON: NaN and infinite floats spelled as strings (JSON
|
|
460
|
+
has neither), unpaired surrogates as `\\udXXX` escapes (UTF-8 has none)."""
|
|
461
|
+
if isinstance(value, float) and not math.isfinite(value):
|
|
462
|
+
return str(value)
|
|
463
|
+
if isinstance(value, str):
|
|
464
|
+
return value.encode("utf-8", "backslashreplace").decode("utf-8")
|
|
465
|
+
if isinstance(value, dict):
|
|
466
|
+
return {_json_safe(k): _json_safe(v) for k, v in value.items()}
|
|
467
|
+
if isinstance(value, list | tuple):
|
|
468
|
+
return [_json_safe(v) for v in value]
|
|
469
|
+
return value
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
@app.exception_handler(RequestValidationError)
|
|
473
|
+
async def validation_error_handler(request: Request, exc: RequestValidationError) -> JSONResponse:
|
|
474
|
+
"""FastAPI's 422 without the offending values.
|
|
475
|
+
|
|
476
|
+
The default handler echoes each value back in `input`: a 32,000-character
|
|
477
|
+
message, text with an unpaired surrogate (which then fails to encode: a
|
|
478
|
+
500, logged with the message in its traceback), or a NaN. Only where
|
|
479
|
+
(`loc`), what (`type`, `msg`) and the rule's parameters (`ctx`) are kept.
|
|
480
|
+
"""
|
|
481
|
+
errors = [
|
|
482
|
+
{key: value for key, value in error.items() if key not in ("input", "url")}
|
|
483
|
+
for error in exc.errors()
|
|
484
|
+
]
|
|
485
|
+
return JSONResponse(status_code=422, content={"detail": _json_safe(jsonable_encoder(errors))})
|
|
486
|
+
|
|
487
|
+
|
|
488
|
+
def _has_surrogate(value: Any) -> bool:
|
|
489
|
+
"""Whether a string (or a key or value of a mapping) holds an unpaired surrogate."""
|
|
490
|
+
if isinstance(value, str):
|
|
491
|
+
try:
|
|
492
|
+
value.encode("utf-8")
|
|
493
|
+
except UnicodeEncodeError:
|
|
494
|
+
return True
|
|
495
|
+
return False
|
|
496
|
+
if isinstance(value, dict):
|
|
497
|
+
return any(_has_surrogate(k) or _has_surrogate(v) for k, v in value.items())
|
|
498
|
+
return False
|
|
499
|
+
|
|
500
|
+
|
|
501
|
+
class ChatBody(BaseModel):
|
|
502
|
+
# Omit thread_id to start a thread: the server generates a random id. Ids
|
|
503
|
+
# are one namespace shared by every caller, so an id a client picks must
|
|
504
|
+
# be unguessable (an id another principal used first is theirs: 403).
|
|
505
|
+
thread_id: str | None = Field(default=None, pattern=THREAD_ID_PATTERN)
|
|
506
|
+
message: str = Field(min_length=1)
|
|
507
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
508
|
+
|
|
509
|
+
@field_validator("message")
|
|
510
|
+
@classmethod
|
|
511
|
+
def _cap_message(cls, value: str) -> str:
|
|
512
|
+
cap = max_message_chars()
|
|
513
|
+
if len(value) > cap:
|
|
514
|
+
raise ValueError(f"message is longer than {cap} characters (MAX_MESSAGE_CHARS).")
|
|
515
|
+
if _has_surrogate(value):
|
|
516
|
+
raise ValueError("message is not valid Unicode text (an unpaired surrogate).")
|
|
517
|
+
return value
|
|
518
|
+
|
|
519
|
+
@field_validator("metadata")
|
|
520
|
+
@classmethod
|
|
521
|
+
def _cap_metadata(cls, value: dict[str, Any]) -> dict[str, Any]:
|
|
522
|
+
if _has_surrogate(value):
|
|
523
|
+
raise ValueError("metadata is not valid Unicode text (an unpaired surrogate).")
|
|
524
|
+
return check_metadata(value)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _forward_headers(request: Request) -> dict[str, str]:
|
|
528
|
+
return select_forward_headers(request.headers)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def principal_for(action: str) -> Callable[[Request], Awaitable[Principal]]:
|
|
532
|
+
"""`require(action)`, plus the caller's hashed id on every log record of the request."""
|
|
533
|
+
check = require(action)
|
|
534
|
+
|
|
535
|
+
async def dependency(request: Request) -> Principal:
|
|
536
|
+
principal = await check(request)
|
|
537
|
+
bind_log_context(
|
|
538
|
+
principal_hash=principal.hashed_id(),
|
|
539
|
+
actor=principal.actor.id if principal.actor is not None else None,
|
|
540
|
+
)
|
|
541
|
+
return principal
|
|
542
|
+
|
|
543
|
+
dependency.__name__ = f"principal_for_{action.replace('.', '_')}"
|
|
544
|
+
return dependency
|
|
545
|
+
|
|
546
|
+
|
|
547
|
+
@app.post("/chat")
|
|
548
|
+
async def chat(
|
|
549
|
+
body: ChatBody,
|
|
550
|
+
request: Request,
|
|
551
|
+
principal: Principal = Depends(principal_for("chat.send")),
|
|
552
|
+
) -> RunStreamingResponse:
|
|
553
|
+
req = ChatRequest(
|
|
554
|
+
message=body.message,
|
|
555
|
+
thread_id=body.thread_id,
|
|
556
|
+
metadata=body.metadata,
|
|
557
|
+
forward_headers=_forward_headers(request),
|
|
558
|
+
)
|
|
559
|
+
thread_id = await RUNTIME.resolve_thread(principal, req)
|
|
560
|
+
# ThreadBusy -> 409 before streaming; the owner is checked again under the lock.
|
|
561
|
+
lease = await RUNTIME.acquire_thread(thread_id, principal)
|
|
562
|
+
try:
|
|
563
|
+
# A thread waiting for an approval takes no new message: 409 approval_pending.
|
|
564
|
+
await RUNTIME.assert_no_pending_approval(thread_id)
|
|
565
|
+
except BaseException:
|
|
566
|
+
await lease.release()
|
|
567
|
+
raise
|
|
568
|
+
return _run_stream(RUNTIME.stream(principal, req, thread_id, lease=lease), thread_id, lease)
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
def _run_stream(
|
|
572
|
+
stream: AsyncIterator[tuple[str, dict[str, Any]]], thread_id: str, lease: Any
|
|
573
|
+
) -> RunStreamingResponse:
|
|
574
|
+
"""The SSE response of one run (`/chat`, or a decision's resumed run)."""
|
|
575
|
+
dev = dev_mode()
|
|
576
|
+
|
|
577
|
+
async def events() -> AsyncIterator[str]:
|
|
578
|
+
async with aclosing(stream) as run:
|
|
579
|
+
async for event, data in run:
|
|
580
|
+
if event == EVENT_TOOL_RESULT:
|
|
581
|
+
# A failed call's text (policy rules, limits, upstream
|
|
582
|
+
# errors) is for the model; the client gets an error id.
|
|
583
|
+
data = client_tool_result(data, thread_id=thread_id, dev=dev)
|
|
584
|
+
yield sse_encode(event, data)
|
|
585
|
+
|
|
586
|
+
return RunStreamingResponse(
|
|
587
|
+
events(),
|
|
588
|
+
on_close=lease.release,
|
|
589
|
+
media_type="text/event-stream",
|
|
590
|
+
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
|
|
591
|
+
)
|
|
592
|
+
|
|
593
|
+
|
|
594
|
+
class DecisionBody(BaseModel):
|
|
595
|
+
decision: Literal["approve", "reject"]
|
|
596
|
+
comment: str | None = Field(default=None, max_length=1000)
|
|
597
|
+
# The approval's `digest`: an agent relaying the person's decision must send it.
|
|
598
|
+
digest: str | None = Field(default=None, max_length=80)
|
|
599
|
+
|
|
600
|
+
@field_validator("comment")
|
|
601
|
+
@classmethod
|
|
602
|
+
def _plain_comment(cls, value: str | None) -> str | None:
|
|
603
|
+
if value is not None and _has_surrogate(value):
|
|
604
|
+
raise ValueError("comment is not valid Unicode text (an unpaired surrogate).")
|
|
605
|
+
return value
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
@app.get("/threads/{thread_id}/approvals")
|
|
609
|
+
async def thread_approvals(
|
|
610
|
+
thread_id: str,
|
|
611
|
+
request: Request,
|
|
612
|
+
principal: Principal = Depends(principal_for("approval.read")),
|
|
613
|
+
) -> list[dict[str, Any]]:
|
|
614
|
+
"""The thread's approvals of gated API calls, newest first.
|
|
615
|
+
|
|
616
|
+
The owner and read-across roles see them all, a decider the ones it may
|
|
617
|
+
decide (anyone else: 403). `query` and `body` are shown to the owner and
|
|
618
|
+
the deciders while an approval is pending.
|
|
619
|
+
"""
|
|
620
|
+
return await RUNTIME.thread_approvals(principal, thread_id, _forward_headers(request))
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
@app.post("/threads/{thread_id}/approvals/{approval_id}")
|
|
624
|
+
async def decide_approval(
|
|
625
|
+
thread_id: str,
|
|
626
|
+
approval_id: str,
|
|
627
|
+
body: DecisionBody,
|
|
628
|
+
request: Request,
|
|
629
|
+
principal: Principal = Depends(principal_for("approval.decide")),
|
|
630
|
+
) -> RunStreamingResponse:
|
|
631
|
+
"""Approve or reject a pending approval; stream the resumed run (the `/chat` events).
|
|
632
|
+
|
|
633
|
+
404 `approval_not_found`, 403 `not_an_approver` or `approval_direct_only`,
|
|
634
|
+
409 `approval_not_pending` (decided already), 409 `approval_digest_mismatch`,
|
|
635
|
+
410 `approval_expired`, 409 `thread_busy`.
|
|
636
|
+
"""
|
|
637
|
+
lease, resume, acting = await RUNTIME.decide(
|
|
638
|
+
principal,
|
|
639
|
+
thread_id,
|
|
640
|
+
approval_id,
|
|
641
|
+
body.decision,
|
|
642
|
+
body.comment,
|
|
643
|
+
_forward_headers(request),
|
|
644
|
+
digest=body.digest,
|
|
645
|
+
)
|
|
646
|
+
req = ChatRequest(
|
|
647
|
+
message="", thread_id=lease.thread_id, forward_headers=_forward_headers(request)
|
|
648
|
+
)
|
|
649
|
+
stream = RUNTIME.stream(acting, req, lease.thread_id, lease=lease, resume=resume)
|
|
650
|
+
return _run_stream(stream, lease.thread_id, lease)
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
@app.get("/approvals")
|
|
654
|
+
async def list_approvals(
|
|
655
|
+
status: str | None = Query(default=None, pattern="^(" + "|".join(STATUSES) + ")$"),
|
|
656
|
+
limit: int = Query(default=20, ge=1, le=100),
|
|
657
|
+
offset: int = Query(default=0, ge=0, le=100_000),
|
|
658
|
+
principal: Principal = Depends(principal_for("approval.read")),
|
|
659
|
+
) -> list[dict[str, Any]]:
|
|
660
|
+
"""Approvals across threads: the caller's own, the ones it may decide (a role
|
|
661
|
+
in their approvers), and every one for a read-across role; newest first."""
|
|
662
|
+
return await RUNTIME.visible_approvals(principal, status=status, limit=limit, offset=offset)
|
|
663
|
+
|
|
664
|
+
|
|
665
|
+
@app.get("/health")
|
|
666
|
+
async def health() -> dict[str, str]:
|
|
667
|
+
return {"status": "ok", "runtime": RUNTIME.runtime, "checkpointer": RUNTIME.checkpointer_kind()}
|
|
668
|
+
|
|
669
|
+
|
|
670
|
+
@app.get("/ready")
|
|
671
|
+
async def ready() -> JSONResponse:
|
|
672
|
+
if await RUNTIME.ready():
|
|
673
|
+
return JSONResponse({"status": "ready"})
|
|
674
|
+
return JSONResponse({"status": "not_ready"}, status_code=503)
|
|
675
|
+
|
|
676
|
+
|
|
677
|
+
@app.get("/metrics", include_in_schema=False)
|
|
678
|
+
async def prometheus_metrics(request: Request) -> Response:
|
|
679
|
+
if not metrics_enabled():
|
|
680
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
681
|
+
if not metrics_authorized(request.headers.get("authorization")):
|
|
682
|
+
raise HTTPException(
|
|
683
|
+
status_code=401,
|
|
684
|
+
detail="Missing or invalid metrics token.",
|
|
685
|
+
headers={"WWW-Authenticate": "Bearer"},
|
|
686
|
+
)
|
|
687
|
+
payload, content_type = render()
|
|
688
|
+
return Response(content=payload, media_type=content_type)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
@app.get("/threads")
|
|
692
|
+
async def list_threads(
|
|
693
|
+
request: Request,
|
|
694
|
+
limit: int = Query(default=20, ge=1, le=100),
|
|
695
|
+
offset: int = Query(default=0, ge=0, le=100_000),
|
|
696
|
+
scope: str = Query(default=SCOPE_OWN, pattern="^(own|all)$"),
|
|
697
|
+
principal: Principal = Depends(principal_for("thread.list")),
|
|
698
|
+
) -> list[dict[str, Any]]:
|
|
699
|
+
"""The caller's threads, or with `scope=all` every principal's (read-across roles only).
|
|
700
|
+
|
|
701
|
+
Each row is `{thread_id, owner, created_at, updated_at}`, `owner` being the
|
|
702
|
+
owner's hashed principal id.
|
|
703
|
+
"""
|
|
704
|
+
check_scope(principal, scope)
|
|
705
|
+
headers = _forward_headers(request)
|
|
706
|
+
if scope == SCOPE_ALL and RUNTIME.runtime == LANGGRAPH_SERVER:
|
|
707
|
+
try:
|
|
708
|
+
return await search_server_threads(
|
|
709
|
+
RUNTIME._sdk_client(headers), principal, scope=scope, limit=limit, offset=offset
|
|
710
|
+
)
|
|
711
|
+
except Exception as exc:
|
|
712
|
+
raise unavailable("LangGraph Server", exc) from exc
|
|
713
|
+
rows = await RUNTIME.list_threads(
|
|
714
|
+
principal if scope == SCOPE_ALL else own_view(principal),
|
|
715
|
+
limit=limit,
|
|
716
|
+
offset=offset,
|
|
717
|
+
forward_headers=headers,
|
|
718
|
+
)
|
|
719
|
+
if scope == SCOPE_OWN:
|
|
720
|
+
owner = principal.hashed_id()
|
|
721
|
+
rows = [{**row, "owner": owner} for row in rows]
|
|
722
|
+
return rows
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
@app.get("/threads/{thread_id}/messages")
|
|
726
|
+
async def thread_messages(
|
|
727
|
+
thread_id: str,
|
|
728
|
+
request: Request,
|
|
729
|
+
principal: Principal = Depends(principal_for("thread.read")),
|
|
730
|
+
) -> list[dict[str, Any]]:
|
|
731
|
+
messages = await RUNTIME.messages(principal, thread_id, _forward_headers(request))
|
|
732
|
+
# A failed tool call reads as in the stream: an error id (derived from the
|
|
733
|
+
# thread id in its canonical form, as the stream has it), not the error text.
|
|
734
|
+
canonical = validate_thread_id(thread_id, RUNTIME.runtime)
|
|
735
|
+
dev = dev_mode()
|
|
736
|
+
return [client_message(m, thread_id=canonical, dev=dev) for m in messages]
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
async def delete_thread(
|
|
740
|
+
thread_id: str,
|
|
741
|
+
request: Request,
|
|
742
|
+
principal: Principal = Depends(principal_for("thread.delete")),
|
|
743
|
+
) -> Response:
|
|
744
|
+
await RUNTIME.delete_thread(principal, thread_id, _forward_headers(request))
|
|
745
|
+
return Response(status_code=204)
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
# Under langgraph-server the server owns `DELETE /threads/{thread_id}` (its
|
|
749
|
+
# native API, owner-only through the auth handler); a route of this app would
|
|
750
|
+
# shadow it, the app's own loopback delete included.
|
|
751
|
+
if detect_runtime() == FASTAPI:
|
|
752
|
+
app.delete("/threads/{thread_id}", status_code=204)(delete_thread)
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
@app.get("/playground", response_class=HTMLResponse, include_in_schema=False)
|
|
756
|
+
async def playground() -> HTMLResponse:
|
|
757
|
+
if not _dev_mode():
|
|
758
|
+
raise HTTPException(status_code=404, detail="Not found")
|
|
759
|
+
return HTMLResponse(PLAYGROUND_HTML)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
if __name__ == "__main__":
|
|
763
|
+
import uvicorn
|
|
764
|
+
|
|
765
|
+
uvicorn.run(
|
|
766
|
+
app,
|
|
767
|
+
host=os.environ.get("HOST", "127.0.0.1"),
|
|
768
|
+
port=int(os.environ.get("PORT", "8000")),
|
|
769
|
+
log_config=None, # keep the logging configured above (LOG_FORMAT)
|
|
770
|
+
)
|