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,2962 @@
|
|
|
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
|
+
"""The one graph invocation path behind `/chat`, A2A and the playground.
|
|
16
|
+
|
|
17
|
+
`ChatRuntime.stream()` yields the events of the chat API
|
|
18
|
+
(`message.start`, `message.delta`, `tool.call`, `tool.result`, `message.end`,
|
|
19
|
+
`error`, plus internal heartbeats sent as SSE `: keep-alive` comments) from either:
|
|
20
|
+
|
|
21
|
+
* the in-process graph with the checkpointer bound at startup (`fastapi`), or
|
|
22
|
+
* the LangGraph Server this app is mounted in (`langgraph-server`), through the
|
|
23
|
+
SDK's loopback client, so the server keeps owning persistence and threads.
|
|
24
|
+
|
|
25
|
+
Both runtimes apply the same rules:
|
|
26
|
+
|
|
27
|
+
* Thread ownership (one principal per thread): through the `threads` table
|
|
28
|
+
under fastapi, and through the thread metadata `{principal_id, tenant}` under
|
|
29
|
+
langgraph-server (plus `actor` for a thread an agent started for its user:
|
|
30
|
+
the user owns it too, that agent only its own, see `threads.is_owner`). The SDK loopback client runs under the server's `/noauth`
|
|
31
|
+
root path, so the server's own `@auth.on` filters never see these calls; the
|
|
32
|
+
check has to live here.
|
|
33
|
+
* One run per thread: a second run while one is in progress gets
|
|
34
|
+
`ThreadBusy` (HTTP 409 `{"code": "thread_busy"}` on `/chat`). Deleting a
|
|
35
|
+
thread (and the retention purge) takes the same lock, and a run checks the
|
|
36
|
+
thread's owner again once it holds it, so a turn is never written to a
|
|
37
|
+
thread that has no owner (fastapi). Across replicas the lock is a lease
|
|
38
|
+
(`run_locks.py`): a run whose lease cannot be renewed is stopped (status
|
|
39
|
+
`interrupted`) and its checkpoint writes are refused (fastapi).
|
|
40
|
+
* A valid history: model providers reject an assistant tool call that is not
|
|
41
|
+
followed by its result. A run cut short (a timeout, a disconnect, a crash, a
|
|
42
|
+
database outage, an OOM kill) can leave one, so every run first answers the
|
|
43
|
+
open tool calls of its thread with an error result placed right after the
|
|
44
|
+
call (and puts misplaced results back in place), before its own turn. A
|
|
45
|
+
call whose arguments are not valid JSON counts as a call too (providers
|
|
46
|
+
are sent it back as one); the agent answers those in the run that made
|
|
47
|
+
them (`content.AnswerInvalidToolCalls`).
|
|
48
|
+
* Guardrails: `RUN_TIMEOUT_S` cancels a run (status `timeout`), a client that
|
|
49
|
+
disconnects cancels its run (status `cancelled`), and `SSE_HEARTBEAT_S`
|
|
50
|
+
keeps idle streams alive. `RECURSION_LIMIT` caps graph steps: a run that
|
|
51
|
+
reaches it ends with a final message saying so (`message.end` status
|
|
52
|
+
`step_limit`) and keeps what it did in the thread.
|
|
53
|
+
* Errors reach clients as a generic message with an `error_id`; the detail
|
|
54
|
+
goes to the log under that id (and to the event under `APP_ENV=dev` only).
|
|
55
|
+
An unreachable database is a 503 (or an `unavailable` event) after a few
|
|
56
|
+
seconds, logged as one line without a traceback.
|
|
57
|
+
* Client metadata is kept in the run record and, under `TRACE_CAPTURE=full`
|
|
58
|
+
only, in traces under `client_metadata` (never over the server-set
|
|
59
|
+
`thread_id`, `run_id`, `principal_hash`); it is not written into checkpoints.
|
|
60
|
+
* Run records are durable in Postgres when the runtime has one (see `db.py`):
|
|
61
|
+
written as `running` when a run starts and updated when it ends; a run that
|
|
62
|
+
never ended (its process died) is marked `interrupted` by the next
|
|
63
|
+
reconciliation (every minute, on any replica). `RETENTION_DAYS` purges
|
|
64
|
+
threads idle longer than that. Under langgraph-server the server's own
|
|
65
|
+
`DELETE /threads/{id}` removes the thread's run records too
|
|
66
|
+
(`forget_thread_runs`, called by `middleware.ThreadDeleteHookMiddleware`).
|
|
67
|
+
* Startup does not wait for the database: the app starts, `/ready` answers
|
|
68
|
+
503 and the schema setup is retried in the background until the database
|
|
69
|
+
answers; requests meanwhile get 503.
|
|
70
|
+
* Human approval of gated API calls (`approvals.py`): a run whose tool made a
|
|
71
|
+
gated call pauses (a LangGraph interrupt, kept in the checkpoint); the run
|
|
72
|
+
records one pending approval per interrupt and ends with `message.end`
|
|
73
|
+
status `awaiting_approval` carrying `approval` (and `approvals`, all of
|
|
74
|
+
them). While one is pending, a new message on the thread is refused
|
|
75
|
+
(`ApprovalPending`, HTTP 409 `{"code": "approval_pending"}`). `decide()`
|
|
76
|
+
checks the decider and the approval, decides it atomically under the
|
|
77
|
+
thread's run lock, and `stream(..., resume=...)` resumes the paused run with
|
|
78
|
+
the decision (under langgraph-server through the server's native resume),
|
|
79
|
+
acting as the requester; the run's other paused calls get their own
|
|
80
|
+
approval's state with it (a decision binds its call whatever the policy says
|
|
81
|
+
about gating it by then, see `api_client`). A pending approval that expires is closed by the
|
|
82
|
+
next resume (the tool gets "expired") or, when a new message comes instead,
|
|
83
|
+
by the history repair (the call's result says the approval expired).
|
|
84
|
+
Deleting a thread deletes its approvals.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
from __future__ import annotations
|
|
88
|
+
|
|
89
|
+
import asyncio
|
|
90
|
+
import contextlib
|
|
91
|
+
import json
|
|
92
|
+
import logging
|
|
93
|
+
import os
|
|
94
|
+
import time
|
|
95
|
+
import uuid
|
|
96
|
+
from collections import Counter, deque
|
|
97
|
+
from collections.abc import AsyncIterator, Callable, Iterator, Mapping
|
|
98
|
+
from contextlib import AsyncExitStack
|
|
99
|
+
from dataclasses import dataclass, field
|
|
100
|
+
from datetime import UTC, datetime, timedelta
|
|
101
|
+
from typing import Any
|
|
102
|
+
|
|
103
|
+
from fastapi import HTTPException
|
|
104
|
+
|
|
105
|
+
from {{cookiecutter.agent_directory}}.app_utils import metrics
|
|
106
|
+
from {{cookiecutter.agent_directory}}.app_utils.api_client import (
|
|
107
|
+
DECISION_APPROVE,
|
|
108
|
+
DECISION_EXPIRED,
|
|
109
|
+
DECISION_PENDING,
|
|
110
|
+
DECISION_REJECT,
|
|
111
|
+
approval_ledger,
|
|
112
|
+
set_approval_ledger,
|
|
113
|
+
)
|
|
114
|
+
from {{cookiecutter.agent_directory}}.app_utils.api_client import end_run as end_api_run
|
|
115
|
+
from {{cookiecutter.agent_directory}}.app_utils.approvals import (
|
|
116
|
+
APPROVE,
|
|
117
|
+
APPROVED,
|
|
118
|
+
CODE_APPROVAL_PENDING,
|
|
119
|
+
CODE_DIGEST_MISMATCH,
|
|
120
|
+
CODE_EXPIRED,
|
|
121
|
+
CODE_NOT_PENDING,
|
|
122
|
+
DECISIONS,
|
|
123
|
+
DIGEST_MISMATCH_DETAIL,
|
|
124
|
+
EXPIRED,
|
|
125
|
+
ORIGIN_PAYLOAD_KEY,
|
|
126
|
+
PENDING,
|
|
127
|
+
REJECTED,
|
|
128
|
+
SWEEP_INTERVAL_S,
|
|
129
|
+
ApprovalRecord,
|
|
130
|
+
ApprovalStore,
|
|
131
|
+
decide_refusal,
|
|
132
|
+
decision_value,
|
|
133
|
+
dev_ledger_path,
|
|
134
|
+
digest_refusal,
|
|
135
|
+
is_approval_interrupt,
|
|
136
|
+
may_view,
|
|
137
|
+
record_from_interrupt,
|
|
138
|
+
resume_principal,
|
|
139
|
+
sees_call,
|
|
140
|
+
)
|
|
141
|
+
from {{cookiecutter.agent_directory}}.app_utils.auth import Principal, origin_of, run_context_of
|
|
142
|
+
from {{cookiecutter.agent_directory}}.app_utils.checkpointer import (
|
|
143
|
+
checkpointer_kind,
|
|
144
|
+
get_checkpointer,
|
|
145
|
+
postgres_saver,
|
|
146
|
+
)
|
|
147
|
+
from {{cookiecutter.agent_directory}}.app_utils.content import (
|
|
148
|
+
INVALID_TOOL_CALL_RESULT,
|
|
149
|
+
INVALID_TOOL_CALL_TYPE,
|
|
150
|
+
content_to_text,
|
|
151
|
+
valid_text,
|
|
152
|
+
valid_value,
|
|
153
|
+
)
|
|
154
|
+
from {{cookiecutter.agent_directory}}.app_utils.db import (
|
|
155
|
+
RUN_INTERRUPTED,
|
|
156
|
+
Database,
|
|
157
|
+
RunRecord,
|
|
158
|
+
RunStore,
|
|
159
|
+
StorageNotReady,
|
|
160
|
+
capture_full,
|
|
161
|
+
is_database_unavailable,
|
|
162
|
+
is_postgres_url,
|
|
163
|
+
)
|
|
164
|
+
from {{cookiecutter.agent_directory}}.app_utils.limits import (
|
|
165
|
+
recursion_limit,
|
|
166
|
+
retention_days,
|
|
167
|
+
run_timeout_s,
|
|
168
|
+
sequential_tool_calls,
|
|
169
|
+
sse_heartbeat_s,
|
|
170
|
+
steps_for_tool_calls,
|
|
171
|
+
valid_thread_id,
|
|
172
|
+
)
|
|
173
|
+
from {{cookiecutter.agent_directory}}.app_utils.model import model_label
|
|
174
|
+
from {{cookiecutter.agent_directory}}.app_utils.structured import (
|
|
175
|
+
ANSWER_TOOL,
|
|
176
|
+
MAX_ANSWER_ATTEMPTS,
|
|
177
|
+
MAX_REPORTED_PROBLEMS,
|
|
178
|
+
StructuredAnswerError,
|
|
179
|
+
answer_text,
|
|
180
|
+
response_schema,
|
|
181
|
+
validate,
|
|
182
|
+
)
|
|
183
|
+
from {{cookiecutter.agent_directory}}.app_utils.telemetry import bind_log_context
|
|
184
|
+
from {{cookiecutter.agent_directory}}.app_utils.threads import (
|
|
185
|
+
OUTCOME_COMPLETED,
|
|
186
|
+
OUTCOME_FAILED,
|
|
187
|
+
OUTCOME_INPUT_REQUIRED,
|
|
188
|
+
THREAD_BUSY,
|
|
189
|
+
ApprovalOutcome,
|
|
190
|
+
LeaseLost,
|
|
191
|
+
ThreadBusy,
|
|
192
|
+
ThreadLease,
|
|
193
|
+
ThreadLocks,
|
|
194
|
+
ThreadRecord,
|
|
195
|
+
ThreadStore,
|
|
196
|
+
approval_outcome,
|
|
197
|
+
assert_access,
|
|
198
|
+
assert_owner,
|
|
199
|
+
is_owner,
|
|
200
|
+
owner_metadata_filter,
|
|
201
|
+
reads_across,
|
|
202
|
+
thread_deleted,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
logger = logging.getLogger(__name__)
|
|
206
|
+
|
|
207
|
+
FASTAPI = "fastapi"
|
|
208
|
+
LANGGRAPH_SERVER = "langgraph-server"
|
|
209
|
+
GRAPH_ID = "agent" # the key in langgraph.json "graphs"
|
|
210
|
+
|
|
211
|
+
EVENT_START = "message.start"
|
|
212
|
+
EVENT_DELTA = "message.delta"
|
|
213
|
+
EVENT_TOOL_CALL = "tool.call"
|
|
214
|
+
EVENT_TOOL_RESULT = "tool.result"
|
|
215
|
+
EVENT_END = "message.end"
|
|
216
|
+
EVENT_ERROR = "error"
|
|
217
|
+
# Internal: sent as an SSE comment line, ignored by A2A.
|
|
218
|
+
EVENT_HEARTBEAT = "heartbeat"
|
|
219
|
+
|
|
220
|
+
# Run statuses in run records and metrics (`running` while a run is in progress).
|
|
221
|
+
STATUS_OK = "ok"
|
|
222
|
+
# The run paused before a gated API call: it waits for a human decision.
|
|
223
|
+
STATUS_AWAITING_APPROVAL = "awaiting_approval"
|
|
224
|
+
STATUS_STEP_LIMIT = "step_limit"
|
|
225
|
+
STATUS_ERROR = "error"
|
|
226
|
+
STATUS_TIMEOUT = "timeout"
|
|
227
|
+
STATUS_CANCELLED = "cancelled"
|
|
228
|
+
STATUS_INTERRUPTED = RUN_INTERRUPTED
|
|
229
|
+
|
|
230
|
+
# Error codes clients see in `error` events.
|
|
231
|
+
CODE_RUN_FAILED = "run_failed"
|
|
232
|
+
CODE_TIMEOUT = "timeout"
|
|
233
|
+
CODE_RECURSION = "recursion_limit"
|
|
234
|
+
CODE_UNAVAILABLE = "unavailable"
|
|
235
|
+
CODE_FORBIDDEN = "forbidden"
|
|
236
|
+
# The graph paused for input this server cannot collect (an interrupt that is
|
|
237
|
+
# not a gated API call's).
|
|
238
|
+
CODE_UNSUPPORTED_INTERRUPT = "unsupported_interrupt"
|
|
239
|
+
# A project with a response schema: no try of the answer fitted it (or the graph gave none).
|
|
240
|
+
CODE_INVALID_STRUCTURED_RESPONSE = "invalid_structured_response"
|
|
241
|
+
# The state key (and `message.end` field) of a structured final answer.
|
|
242
|
+
STRUCTURED_KEY = "structured_response"
|
|
243
|
+
|
|
244
|
+
# Request headers passed on to the LangGraph Server under langgraph-server.
|
|
245
|
+
# They matter when LANGGRAPH_SERVER_URL points at a real HTTP endpoint (its
|
|
246
|
+
# auth handler runs there); the default in-process loopback ignores them.
|
|
247
|
+
DEFAULT_FORWARD_HEADERS = ("authorization", "cookie")
|
|
248
|
+
|
|
249
|
+
RETENTION_INTERVAL_S = 3600.0
|
|
250
|
+
RETENTION_FIRST_DELAY_S = 60.0
|
|
251
|
+
RETENTION_BATCH = 500
|
|
252
|
+
RETENTION_MAX_BATCHES = 20
|
|
253
|
+
|
|
254
|
+
# Runs of dead processes are closed (`interrupted`) this often, by every replica.
|
|
255
|
+
RECONCILE_INTERVAL_S = 60.0
|
|
256
|
+
# A `running` record younger than this is never reconciled (clock skew margin).
|
|
257
|
+
RECONCILE_GRACE_S = 60.0
|
|
258
|
+
# Final run records that could not be written are retried (at most this many kept).
|
|
259
|
+
MAX_UNRECORDED_RUNS = 1000
|
|
260
|
+
# Database setup at startup: retried with this back-off while the database is down.
|
|
261
|
+
INIT_RETRY_FIRST_S = 0.5
|
|
262
|
+
INIT_RETRY_MAX_S = 2.0
|
|
263
|
+
# Each bookkeeping step after a run (history repair, run record) is bounded.
|
|
264
|
+
FINISH_STEP_TIMEOUT_S = 5.0
|
|
265
|
+
# How long the end of a run waits for its cancelled graph to stop.
|
|
266
|
+
PUMP_STOP_WAIT_S = 1.0
|
|
267
|
+
|
|
268
|
+
# Error results for tool calls a run left open, by why they were left open.
|
|
269
|
+
OPEN_CALL_INTERRUPTED = (
|
|
270
|
+
"The tool call did not finish: the run was interrupted before its result was saved."
|
|
271
|
+
)
|
|
272
|
+
OPEN_CALL_STEP_LIMIT = "The tool call did not run: the run reached its step limit."
|
|
273
|
+
# Results for a tool call left open by a run that paused for approval, when a
|
|
274
|
+
# new message comes instead of a resume, by the approval's status.
|
|
275
|
+
OPEN_CALL_BY_APPROVAL = {
|
|
276
|
+
EXPIRED: "The call was not approved: the approval request expired before anyone decided, "
|
|
277
|
+
"so nothing was sent.",
|
|
278
|
+
REJECTED: "The call was not approved: an approver rejected it, so nothing was sent.",
|
|
279
|
+
APPROVED: "The call was approved, but the run did not continue, so nothing was sent.",
|
|
280
|
+
}
|
|
281
|
+
OPEN_CALL_SENT_UNSAVED = (
|
|
282
|
+
"The call was approved and sent, but the run stopped before its result was saved."
|
|
283
|
+
)
|
|
284
|
+
# The resume value a paused call gets for its approval's state (see `decide`).
|
|
285
|
+
# How much of a resumed run's reply the A2A tasks that waited on its approval say.
|
|
286
|
+
OUTCOME_REPLY_MAX_CHARS = 2000
|
|
287
|
+
RESUME_AS = {
|
|
288
|
+
PENDING: DECISION_PENDING,
|
|
289
|
+
APPROVED: DECISION_APPROVE,
|
|
290
|
+
REJECTED: DECISION_REJECT,
|
|
291
|
+
EXPIRED: DECISION_EXPIRED,
|
|
292
|
+
}
|
|
293
|
+
UNSUPPORTED_INTERRUPT_MESSAGE = (
|
|
294
|
+
"The agent paused for input this server cannot collect. Send a new message to continue."
|
|
295
|
+
)
|
|
296
|
+
STEP_LIMIT_MESSAGE = (
|
|
297
|
+
"I had to stop before finishing: this request needs more steps than one run may take "
|
|
298
|
+
"({limit}). What I did so far is kept in this conversation, so you can ask me to "
|
|
299
|
+
"continue, or narrow the request."
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def detect_runtime() -> str:
|
|
304
|
+
"""`langgraph-server` when mounted inside the server, else `fastapi`.
|
|
305
|
+
|
|
306
|
+
The server Dockerfile sets `LANGGRAPH_SERVER=1`; `langgraph dev` and the
|
|
307
|
+
server image also export `LANGSERVE_GRAPHS`. `RUNTIME` overrides both.
|
|
308
|
+
"""
|
|
309
|
+
explicit = (os.environ.get("RUNTIME") or "").strip().lower()
|
|
310
|
+
if explicit in (FASTAPI, LANGGRAPH_SERVER):
|
|
311
|
+
return explicit
|
|
312
|
+
if os.environ.get("LANGGRAPH_SERVER", "").lower() in ("1", "true", "yes"):
|
|
313
|
+
return LANGGRAPH_SERVER
|
|
314
|
+
if os.environ.get("LANGSERVE_GRAPHS"):
|
|
315
|
+
return LANGGRAPH_SERVER
|
|
316
|
+
return FASTAPI
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def forward_header_names() -> frozenset[str]:
|
|
320
|
+
"""`AUTH_FORWARD_HEADERS` (comma list, case-insensitive); default `authorization,cookie`.
|
|
321
|
+
|
|
322
|
+
Set it to the headers your auth policy reads when the LangGraph Server is
|
|
323
|
+
reached over HTTP; an empty value forwards nothing.
|
|
324
|
+
"""
|
|
325
|
+
raw = os.environ.get("AUTH_FORWARD_HEADERS")
|
|
326
|
+
names = DEFAULT_FORWARD_HEADERS if raw is None else raw.split(",")
|
|
327
|
+
return frozenset(n.strip().lower() for n in names if n.strip())
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def select_forward_headers(headers: Mapping[str, str]) -> dict[str, str]:
|
|
331
|
+
allowed = forward_header_names()
|
|
332
|
+
return {k: v for k, v in headers.items() if k.lower() in allowed}
|
|
333
|
+
|
|
334
|
+
|
|
335
|
+
def dev_mode() -> bool:
|
|
336
|
+
"""`APP_ENV` is exactly `dev` (as `auth.dev_mode`)."""
|
|
337
|
+
return os.environ.get("APP_ENV") == "dev"
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def sse_encode(event: str, data: Mapping[str, Any]) -> str:
|
|
341
|
+
"""One SSE event. A lone surrogate anywhere in `data` is sent as U+FFFD
|
|
342
|
+
(`valid_text`): UTF-8 cannot encode one, and it would end the stream."""
|
|
343
|
+
if event == EVENT_HEARTBEAT:
|
|
344
|
+
return ": keep-alive\n\n"
|
|
345
|
+
return f"event: {event}\ndata: {valid_text(json.dumps(data, ensure_ascii=False))}\n\n"
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def new_error_id() -> str:
|
|
349
|
+
return uuid.uuid4().hex[:16]
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _first_line(exc: BaseException) -> str:
|
|
353
|
+
text = str(exc).strip()
|
|
354
|
+
return text.splitlines()[0][:300] if text else ""
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def unavailable(what: str, exc: BaseException) -> HTTPException:
|
|
358
|
+
"""A 503 whose detail names an error id, never the exception text (logged instead)."""
|
|
359
|
+
error_id = log_unavailable(what, exc)
|
|
360
|
+
return HTTPException(status_code=503, detail=unavailable_detail(what, error_id))
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def unavailable_detail(what: str, error_id: str) -> str:
|
|
364
|
+
return f"{what} unavailable. Reference: {error_id}."
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def log_unavailable(what: str, exc: BaseException) -> str:
|
|
368
|
+
"""Log `exc` under a new error id, which it returns.
|
|
369
|
+
|
|
370
|
+
An unreachable database is expected during an outage: one WARNING line,
|
|
371
|
+
no traceback. Anything else is logged as an ERROR with its traceback.
|
|
372
|
+
"""
|
|
373
|
+
error_id = new_error_id()
|
|
374
|
+
if is_database_unavailable(exc):
|
|
375
|
+
logger.warning(
|
|
376
|
+
"%s unavailable (error_id=%s): %s: %s",
|
|
377
|
+
what,
|
|
378
|
+
error_id,
|
|
379
|
+
type(exc).__name__,
|
|
380
|
+
_first_line(exc),
|
|
381
|
+
)
|
|
382
|
+
else:
|
|
383
|
+
logger.error(
|
|
384
|
+
"%s unavailable (error_id=%s): %s", what, error_id, type(exc).__name__, exc_info=exc
|
|
385
|
+
)
|
|
386
|
+
return error_id
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
@contextlib.contextmanager
|
|
390
|
+
def database_errors() -> Iterator[None]:
|
|
391
|
+
"""Turn an unreachable database inside the block into a 503 with an error id."""
|
|
392
|
+
try:
|
|
393
|
+
yield
|
|
394
|
+
except HTTPException:
|
|
395
|
+
raise
|
|
396
|
+
except Exception as exc:
|
|
397
|
+
if is_database_unavailable(exc):
|
|
398
|
+
raise unavailable("Database", exc) from exc
|
|
399
|
+
raise
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def validate_thread_id(thread_id: str, runtime: str) -> str:
|
|
403
|
+
"""The thread id in canonical form, or 422 for an id outside the accepted form.
|
|
404
|
+
|
|
405
|
+
LangGraph Server needs a UUID; it is canonicalised (lower case, hyphens)
|
|
406
|
+
so one thread never has two spellings, e.g. for the run lock.
|
|
407
|
+
"""
|
|
408
|
+
if not valid_thread_id(thread_id):
|
|
409
|
+
raise HTTPException(
|
|
410
|
+
status_code=422,
|
|
411
|
+
detail="thread_id must be 1-128 letters, digits or '_ . : -'.",
|
|
412
|
+
)
|
|
413
|
+
if runtime == LANGGRAPH_SERVER:
|
|
414
|
+
try:
|
|
415
|
+
return str(uuid.UUID(thread_id))
|
|
416
|
+
except ValueError:
|
|
417
|
+
raise HTTPException(
|
|
418
|
+
status_code=422,
|
|
419
|
+
detail="thread_id must be a UUID under the langgraph-server runtime.",
|
|
420
|
+
) from None
|
|
421
|
+
return thread_id
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def http_status(exc: BaseException) -> int | None:
|
|
425
|
+
"""The HTTP status of an SDK/httpx error (`NotFoundError`, `ConflictError`, ...), if any."""
|
|
426
|
+
status = getattr(exc, "status_code", None)
|
|
427
|
+
if isinstance(status, int):
|
|
428
|
+
return status
|
|
429
|
+
response = getattr(exc, "response", None)
|
|
430
|
+
status = getattr(response, "status_code", None)
|
|
431
|
+
return status if isinstance(status, int) else None
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
@dataclass
|
|
435
|
+
class ChatRequest:
|
|
436
|
+
message: str
|
|
437
|
+
thread_id: str | None = None
|
|
438
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
439
|
+
forward_headers: dict[str, str] = field(default_factory=dict)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
@dataclass
|
|
443
|
+
class _RunState:
|
|
444
|
+
text: list[str] = field(default_factory=list)
|
|
445
|
+
tool_calls: list[dict[str, Any]] = field(default_factory=list)
|
|
446
|
+
input_tokens: int = 0
|
|
447
|
+
output_tokens: int = 0
|
|
448
|
+
seen_ai_ids: set[str] = field(default_factory=set)
|
|
449
|
+
server_run_id: str | None = None
|
|
450
|
+
# The graph's interrupts (`{"id", "value"}`) when the run paused.
|
|
451
|
+
interrupts: list[dict[str, Any]] = field(default_factory=list)
|
|
452
|
+
# The project has a response schema: the model's text is not streamed, and the
|
|
453
|
+
# run's answer is the last `structured_response` the graph's updates carried.
|
|
454
|
+
structured_mode: bool = False
|
|
455
|
+
structured: Any = None
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
@dataclass
|
|
459
|
+
class Resume:
|
|
460
|
+
"""A paused run to resume (`ChatRuntime.decide`): the resume value per interrupt id.
|
|
461
|
+
|
|
462
|
+
`continued_in` is the A2A task that carries the decision (the executor sets
|
|
463
|
+
it), and `references` the tasks its message named (`referenceTaskIds`): the
|
|
464
|
+
tasks still waiting on the approval follow the resumed run's outcome
|
|
465
|
+
(`ApprovalOutcome`), pointing at that task.
|
|
466
|
+
"""
|
|
467
|
+
|
|
468
|
+
values: dict[str, Any]
|
|
469
|
+
approval: ApprovalRecord
|
|
470
|
+
decision: str
|
|
471
|
+
continued_in: str | None = None
|
|
472
|
+
references: tuple[str, ...] = ()
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
class ApprovalPending(Exception):
|
|
476
|
+
"""The thread has a pending approval: no new message until it is decided or expires.
|
|
477
|
+
|
|
478
|
+
HTTP 409 `{"code": "approval_pending"}` on `/chat`.
|
|
479
|
+
"""
|
|
480
|
+
|
|
481
|
+
def __init__(self, thread_id: str, approvals: list[dict[str, Any]]) -> None:
|
|
482
|
+
super().__init__(
|
|
483
|
+
"This thread is waiting for the approval of an action; decide it "
|
|
484
|
+
"(POST /threads/{thread_id}/approvals/{approval_id}) or wait until it expires."
|
|
485
|
+
)
|
|
486
|
+
self.thread_id = thread_id
|
|
487
|
+
self.approvals = approvals
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
class ApprovalError(Exception):
|
|
491
|
+
"""A decision that cannot be taken: `status_code` 404, 403, 409 or 410 with a `code`.
|
|
492
|
+
|
|
493
|
+
The app answers `{"code", "detail", ...extra}` (`status`: the approval's
|
|
494
|
+
status for 409 and 410).
|
|
495
|
+
"""
|
|
496
|
+
|
|
497
|
+
def __init__(self, status_code: int, code: str, detail: str, **extra: Any) -> None:
|
|
498
|
+
super().__init__(detail)
|
|
499
|
+
self.status_code = status_code
|
|
500
|
+
self.code = code
|
|
501
|
+
self.detail = detail
|
|
502
|
+
self.extra = extra
|
|
503
|
+
|
|
504
|
+
def body(self) -> dict[str, Any]:
|
|
505
|
+
return {"code": self.code, "detail": self.detail, **self.extra}
|
|
506
|
+
|
|
507
|
+
|
|
508
|
+
class RunTimeout(Exception):
|
|
509
|
+
"""The run passed `RUN_TIMEOUT_S`."""
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
# ---------------------------------------------------------------------------
|
|
513
|
+
# Message accessors that work for LangChain objects and the server's JSON dicts
|
|
514
|
+
# ---------------------------------------------------------------------------
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _get(m: Any, key: str, default: Any = None) -> Any:
|
|
518
|
+
if isinstance(m, Mapping):
|
|
519
|
+
return m.get(key, default)
|
|
520
|
+
return getattr(m, key, default)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _msg_type(m: Any) -> str:
|
|
524
|
+
return str(_get(m, "type", "") or "")
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _is_ai(m: Any) -> bool:
|
|
528
|
+
return _msg_type(m) in ("ai", "AIMessage", "AIMessageChunk")
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
def _is_tool(m: Any) -> bool:
|
|
532
|
+
return _msg_type(m) in ("tool", "ToolMessage", "ToolMessageChunk")
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _is_human(m: Any) -> bool:
|
|
536
|
+
return _msg_type(m) in ("human", "HumanMessage")
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _role(m: Any) -> str:
|
|
540
|
+
if _is_ai(m):
|
|
541
|
+
return "assistant"
|
|
542
|
+
if _is_tool(m):
|
|
543
|
+
return "tool"
|
|
544
|
+
if _is_human(m):
|
|
545
|
+
return "user"
|
|
546
|
+
t = _msg_type(m)
|
|
547
|
+
return "system" if t in ("system", "SystemMessage") else t
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def tool_calls_of(m: Any) -> list[Any]:
|
|
551
|
+
"""Every tool call of an assistant message, in the order providers are sent them.
|
|
552
|
+
|
|
553
|
+
The calls whose arguments did not parse (`invalid_tool_calls`, marked
|
|
554
|
+
`"type": "invalid_tool_call"`) count too: LangChain sends them back to
|
|
555
|
+
the provider as tool calls (langchain-openai does), so each needs a
|
|
556
|
+
result like any other call.
|
|
557
|
+
"""
|
|
558
|
+
if not _is_ai(m):
|
|
559
|
+
return []
|
|
560
|
+
calls = list(_get(m, "tool_calls") or [])
|
|
561
|
+
for call in _get(m, "invalid_tool_calls") or []:
|
|
562
|
+
if isinstance(call, Mapping) and call.get("type") != INVALID_TOOL_CALL_TYPE:
|
|
563
|
+
call = {**call, "type": INVALID_TOOL_CALL_TYPE}
|
|
564
|
+
calls.append(call)
|
|
565
|
+
return calls
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
def is_invalid_tool_call(call: Any) -> bool:
|
|
569
|
+
"""Whether `call` is one whose arguments did not parse (never run)."""
|
|
570
|
+
return _get(call, "type") == INVALID_TOOL_CALL_TYPE
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def open_call_result_text(call: Any, reason: str) -> str:
|
|
574
|
+
"""The error result for a call that has none: `reason`, or why an invalid call never ran."""
|
|
575
|
+
return INVALID_TOOL_CALL_RESULT if is_invalid_tool_call(call) else reason
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _call_id(call: Any) -> str:
|
|
579
|
+
return str(_get(call, "id") or "")
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
def _call_args(call: Any) -> dict[str, Any]:
|
|
583
|
+
"""A call's arguments as a dict; `{}` for a call whose arguments did not parse."""
|
|
584
|
+
args = _get(call, "args")
|
|
585
|
+
return dict(args) if isinstance(args, Mapping) else {}
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def _iter_messages(update: Any) -> Iterator[Any]:
|
|
589
|
+
if isinstance(update, Mapping):
|
|
590
|
+
messages = update.get("messages")
|
|
591
|
+
if isinstance(messages, list):
|
|
592
|
+
yield from messages
|
|
593
|
+
elif messages is not None:
|
|
594
|
+
yield messages
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _accumulate_usage(state: _RunState, m: Any) -> None:
|
|
598
|
+
usage = _get(m, "usage_metadata") or {}
|
|
599
|
+
if isinstance(usage, Mapping):
|
|
600
|
+
state.input_tokens += int(usage.get("input_tokens") or 0)
|
|
601
|
+
state.output_tokens += int(usage.get("output_tokens") or 0)
|
|
602
|
+
|
|
603
|
+
|
|
604
|
+
# The key of an `updates` stream item that carries the graph's interrupts.
|
|
605
|
+
INTERRUPT_KEY = "__interrupt__"
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def interrupts_of(value: Any) -> list[dict[str, Any]]:
|
|
609
|
+
"""Interrupts as `{"id", "value"}`, from LangGraph `Interrupt` objects or the server's JSON."""
|
|
610
|
+
items = value if isinstance(value, list | tuple) else [value]
|
|
611
|
+
out: list[dict[str, Any]] = []
|
|
612
|
+
for item in items:
|
|
613
|
+
interrupt_id = _get(item, "id")
|
|
614
|
+
if interrupt_id:
|
|
615
|
+
out.append({"id": str(interrupt_id), "value": _get(item, "value")})
|
|
616
|
+
return out
|
|
617
|
+
|
|
618
|
+
|
|
619
|
+
def map_stream_item(mode: str, data: Any, state: _RunState) -> Iterator[tuple[str, dict[str, Any]]]:
|
|
620
|
+
"""Map one LangGraph stream item (`messages` or `updates` mode) to chat events.
|
|
621
|
+
|
|
622
|
+
The graph's interrupts (an `updates` item under `__interrupt__`) are
|
|
623
|
+
collected in `state.interrupts`, not sent: the run's end reports them.
|
|
624
|
+
|
|
625
|
+
Under a response schema (`state.structured_mode`) the model's text is not
|
|
626
|
+
sent (the run's reply is its answer's JSON text, sent when it ends), the
|
|
627
|
+
answer tool's call and result are not either (the tool strategy's answer
|
|
628
|
+
is no tool call to a client), and the last `structured_response` an update
|
|
629
|
+
carries is kept in `state.structured`.
|
|
630
|
+
"""
|
|
631
|
+
if mode == "messages":
|
|
632
|
+
chunk = data[0] if isinstance(data, list | tuple) and data else data
|
|
633
|
+
if _is_ai(chunk) and not (
|
|
634
|
+
_get(chunk, "tool_call_chunks")
|
|
635
|
+
or _get(chunk, "tool_calls")
|
|
636
|
+
or _get(chunk, "invalid_tool_calls")
|
|
637
|
+
):
|
|
638
|
+
text = content_to_text(_get(chunk, "content", ""))
|
|
639
|
+
if text and not state.structured_mode:
|
|
640
|
+
state.text.append(text)
|
|
641
|
+
yield EVENT_DELTA, {"text": text}
|
|
642
|
+
return
|
|
643
|
+
if mode != "updates" or not isinstance(data, Mapping):
|
|
644
|
+
return
|
|
645
|
+
for key, update in data.items():
|
|
646
|
+
if key == INTERRUPT_KEY:
|
|
647
|
+
state.interrupts.extend(interrupts_of(update))
|
|
648
|
+
continue
|
|
649
|
+
if state.structured_mode and isinstance(update, Mapping) and STRUCTURED_KEY in update:
|
|
650
|
+
# Each model step sets it (None when the step gave no answer): the last one counts.
|
|
651
|
+
state.structured = update[STRUCTURED_KEY]
|
|
652
|
+
for m in _iter_messages(update):
|
|
653
|
+
if _is_ai(m):
|
|
654
|
+
msg_id = str(_get(m, "id") or "")
|
|
655
|
+
if msg_id and msg_id in state.seen_ai_ids:
|
|
656
|
+
continue
|
|
657
|
+
if msg_id:
|
|
658
|
+
state.seen_ai_ids.add(msg_id)
|
|
659
|
+
_accumulate_usage(state, m)
|
|
660
|
+
for call in tool_calls_of(m):
|
|
661
|
+
if state.structured_mode and _get(call, "name") == ANSWER_TOOL:
|
|
662
|
+
continue
|
|
663
|
+
entry = {
|
|
664
|
+
"id": str(_get(call, "id") or uuid.uuid4()),
|
|
665
|
+
"name": str(_get(call, "name") or ""),
|
|
666
|
+
"args": _call_args(call),
|
|
667
|
+
}
|
|
668
|
+
state.tool_calls.append({**entry, "result": None, "is_error": False})
|
|
669
|
+
yield EVENT_TOOL_CALL, entry
|
|
670
|
+
elif _is_tool(m):
|
|
671
|
+
if state.structured_mode and _get(m, "name") == ANSWER_TOOL:
|
|
672
|
+
continue
|
|
673
|
+
call_id = str(_get(m, "tool_call_id") or "")
|
|
674
|
+
result = content_to_text(_get(m, "content", ""))
|
|
675
|
+
is_error = str(_get(m, "status") or "success") == "error"
|
|
676
|
+
for tc in state.tool_calls:
|
|
677
|
+
if tc["id"] == call_id:
|
|
678
|
+
tc["result"] = result
|
|
679
|
+
tc["is_error"] = is_error
|
|
680
|
+
yield (
|
|
681
|
+
EVENT_TOOL_RESULT,
|
|
682
|
+
{
|
|
683
|
+
"id": call_id,
|
|
684
|
+
"name": str(_get(m, "name") or ""),
|
|
685
|
+
"result": result,
|
|
686
|
+
"is_error": is_error,
|
|
687
|
+
},
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def trace_metadata(
|
|
692
|
+
thread_id: str, run_id: str, principal: Principal, client_metadata: Mapping[str, Any]
|
|
693
|
+
) -> dict[str, Any]:
|
|
694
|
+
"""Run metadata for traces (and, for its scalar keys, checkpoints).
|
|
695
|
+
|
|
696
|
+
The server-set ids cannot be overwritten: client metadata only ever sits
|
|
697
|
+
under `client_metadata`, a nested object, which LangGraph does not copy
|
|
698
|
+
into checkpoint metadata. It is included only under `TRACE_CAPTURE=full`.
|
|
699
|
+
"""
|
|
700
|
+
meta: dict[str, Any] = {
|
|
701
|
+
"thread_id": thread_id,
|
|
702
|
+
"run_id": run_id,
|
|
703
|
+
"principal_hash": principal.hashed_id(),
|
|
704
|
+
}
|
|
705
|
+
if principal.actor is not None:
|
|
706
|
+
# The agent presenting the request (a client name, not personal data).
|
|
707
|
+
meta["actor"] = principal.actor.id
|
|
708
|
+
meta["actor_chain"] = ",".join(principal.actor.chain)
|
|
709
|
+
if client_metadata and capture_full():
|
|
710
|
+
meta["client_metadata"] = dict(client_metadata)
|
|
711
|
+
return meta
|
|
712
|
+
|
|
713
|
+
|
|
714
|
+
def serialize_message(m: Any, *, include_tool_args: bool = True) -> dict[str, Any]:
|
|
715
|
+
out: dict[str, Any] = {
|
|
716
|
+
"id": _get(m, "id"),
|
|
717
|
+
"role": _role(m),
|
|
718
|
+
"content": content_to_text(_get(m, "content", "")),
|
|
719
|
+
}
|
|
720
|
+
if _is_ai(m):
|
|
721
|
+
calls = []
|
|
722
|
+
for call in tool_calls_of(m):
|
|
723
|
+
entry: dict[str, Any] = {"id": _get(call, "id"), "name": _get(call, "name")}
|
|
724
|
+
if include_tool_args:
|
|
725
|
+
entry["args"] = _call_args(call)
|
|
726
|
+
calls.append(entry)
|
|
727
|
+
if calls:
|
|
728
|
+
out["tool_calls"] = calls
|
|
729
|
+
if _is_tool(m):
|
|
730
|
+
out["tool_call_id"] = _get(m, "tool_call_id")
|
|
731
|
+
out["name"] = _get(m, "name")
|
|
732
|
+
out["is_error"] = str(_get(m, "status") or "success") == "error"
|
|
733
|
+
return out
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
def dangling_tool_calls(messages: list[Any]) -> list[Any]:
|
|
737
|
+
"""Tool calls of the last assistant message with no result right after it.
|
|
738
|
+
|
|
739
|
+
Only results that directly follow the call count: a result placed after
|
|
740
|
+
a later message does not make the history valid for model providers.
|
|
741
|
+
"""
|
|
742
|
+
for i in range(len(messages) - 1, -1, -1):
|
|
743
|
+
if _is_ai(messages[i]):
|
|
744
|
+
answered: Counter[str] = Counter()
|
|
745
|
+
for m in messages[i + 1 :]:
|
|
746
|
+
if not _is_tool(m):
|
|
747
|
+
break
|
|
748
|
+
answered[str(_get(m, "tool_call_id") or "")] += 1
|
|
749
|
+
open_calls = []
|
|
750
|
+
for c in tool_calls_of(messages[i]):
|
|
751
|
+
if answered[_call_id(c)] > 0:
|
|
752
|
+
answered[_call_id(c)] -= 1
|
|
753
|
+
else:
|
|
754
|
+
open_calls.append(c)
|
|
755
|
+
return open_calls
|
|
756
|
+
return []
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
@dataclass
|
|
760
|
+
class HistoryRepair:
|
|
761
|
+
"""A tool-call history put right: see `repair_tool_history`."""
|
|
762
|
+
|
|
763
|
+
messages: list[Any] # the whole corrected history
|
|
764
|
+
added: list[Any] # error results created for calls that had none
|
|
765
|
+
append_only: bool # `messages` is the old history plus `added` at its end
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def repair_tool_history(
|
|
769
|
+
messages: list[Any], make_result: Callable[[Any], Any]
|
|
770
|
+
) -> HistoryRepair | None:
|
|
771
|
+
"""The history with every tool call followed by its result, or None when it already is.
|
|
772
|
+
|
|
773
|
+
Model providers reject an assistant message whose tool calls are not
|
|
774
|
+
answered right after it, and a tool result that answers no call. Every
|
|
775
|
+
call counts, the ones whose arguments did not parse included
|
|
776
|
+
(`tool_calls_of`). Calls and results are paired by position, turn by
|
|
777
|
+
turn, since tool-call ids repeat across turns (a model may number its
|
|
778
|
+
calls `call_0`, `call_1`, ... in every message) and can repeat within one
|
|
779
|
+
message (some OpenAI-compatible servers send the same id, or an empty
|
|
780
|
+
one, for parallel calls):
|
|
781
|
+
|
|
782
|
+
* a call's result is a tool message with its id in the block of tool
|
|
783
|
+
messages right after the call's assistant message, one per call with
|
|
784
|
+
that id; these are kept where they are, in their order;
|
|
785
|
+
* a call without one takes the first unclaimed tool message with its id
|
|
786
|
+
that sits after the call and before the next assistant message that
|
|
787
|
+
makes a call with the same id (a result a crash or an old repair left
|
|
788
|
+
behind a later message), moved right after the call;
|
|
789
|
+
* a call with neither gets `make_result(call)` (an error result);
|
|
790
|
+
* a tool message no call claims is dropped.
|
|
791
|
+
|
|
792
|
+
A history in which every call is answered right after it, and every tool
|
|
793
|
+
message answers a call, is returned as None and never rewritten.
|
|
794
|
+
"""
|
|
795
|
+
count = len(messages)
|
|
796
|
+
|
|
797
|
+
def call_ids(m: Any) -> list[str]:
|
|
798
|
+
return [_call_id(call) for call in tool_calls_of(m)]
|
|
799
|
+
|
|
800
|
+
def result_id(m: Any) -> str:
|
|
801
|
+
return str(_get(m, "tool_call_id") or "")
|
|
802
|
+
|
|
803
|
+
# Pass 1: the results that answer an assistant message right after it.
|
|
804
|
+
claimed: set[int] = set()
|
|
805
|
+
direct: dict[int, list[int]] = {} # assistant index -> indexes of its direct results
|
|
806
|
+
for i, m in enumerate(messages):
|
|
807
|
+
if not _is_ai(m):
|
|
808
|
+
continue
|
|
809
|
+
pending = Counter(call_ids(m))
|
|
810
|
+
block: list[int] = []
|
|
811
|
+
j = i + 1
|
|
812
|
+
while j < count and _is_tool(messages[j]):
|
|
813
|
+
if pending[result_id(messages[j])] > 0:
|
|
814
|
+
pending[result_id(messages[j])] -= 1
|
|
815
|
+
block.append(j)
|
|
816
|
+
claimed.add(j)
|
|
817
|
+
j += 1
|
|
818
|
+
direct[i] = block
|
|
819
|
+
|
|
820
|
+
def later_result(i: int, call_id: str) -> int | None:
|
|
821
|
+
"""An unclaimed result for `call_id` after message i, before the id is called again."""
|
|
822
|
+
for k in range(i + 1, count):
|
|
823
|
+
m = messages[k]
|
|
824
|
+
if _is_ai(m) and call_id in call_ids(m):
|
|
825
|
+
return None
|
|
826
|
+
if _is_tool(m) and k not in claimed and result_id(m) == call_id:
|
|
827
|
+
return k
|
|
828
|
+
return None
|
|
829
|
+
|
|
830
|
+
# Pass 2: rebuild, each assistant message followed by its results.
|
|
831
|
+
out: list[Any] = []
|
|
832
|
+
added: list[Any] = []
|
|
833
|
+
for i, m in enumerate(messages):
|
|
834
|
+
if _is_tool(m):
|
|
835
|
+
continue # placed after its call below, or dropped
|
|
836
|
+
out.append(m)
|
|
837
|
+
if not _is_ai(m):
|
|
838
|
+
continue
|
|
839
|
+
answered = Counter(result_id(messages[k]) for k in direct[i])
|
|
840
|
+
out.extend(messages[k] for k in direct[i])
|
|
841
|
+
for call in tool_calls_of(m):
|
|
842
|
+
call_id = _call_id(call)
|
|
843
|
+
if answered[call_id] > 0:
|
|
844
|
+
answered[call_id] -= 1
|
|
845
|
+
continue
|
|
846
|
+
k = later_result(i, call_id)
|
|
847
|
+
if k is not None:
|
|
848
|
+
claimed.add(k)
|
|
849
|
+
out.append(messages[k])
|
|
850
|
+
else:
|
|
851
|
+
result = make_result(call)
|
|
852
|
+
out.append(result)
|
|
853
|
+
added.append(result)
|
|
854
|
+
if len(out) == count and all(a is b for a, b in zip(out, messages, strict=True)):
|
|
855
|
+
return None
|
|
856
|
+
append_only = len(out) >= count and all(
|
|
857
|
+
a is b for a, b in zip(out[:count], messages, strict=True)
|
|
858
|
+
)
|
|
859
|
+
return HistoryRepair(messages=out, added=added, append_only=append_only)
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def _server_message(m: Any) -> dict[str, Any]:
|
|
863
|
+
"""A server state message as a dict the server turns back into the same message.
|
|
864
|
+
|
|
865
|
+
The server rebuilds messages from dicts by their known keys and puts any
|
|
866
|
+
other key into `additional_kwargs`; only the keys that matter for the
|
|
867
|
+
history are kept. It has no key for `invalid_tool_calls`, so a call
|
|
868
|
+
whose arguments did not parse is kept as a call with no arguments: its
|
|
869
|
+
error result must still answer a call the provider is sent.
|
|
870
|
+
"""
|
|
871
|
+
if not isinstance(m, Mapping):
|
|
872
|
+
return m
|
|
873
|
+
keep = ["type", "content", "id", "name", "additional_kwargs", "response_metadata"]
|
|
874
|
+
if _is_tool(m):
|
|
875
|
+
keep.extend(("tool_call_id", "status", "artifact"))
|
|
876
|
+
out = {k: m[k] for k in keep if m.get(k) is not None}
|
|
877
|
+
if _is_ai(m):
|
|
878
|
+
calls = [
|
|
879
|
+
{
|
|
880
|
+
"name": str(_get(c, "name") or ""),
|
|
881
|
+
"args": {},
|
|
882
|
+
"id": _get(c, "id"),
|
|
883
|
+
"type": "tool_call",
|
|
884
|
+
}
|
|
885
|
+
if is_invalid_tool_call(c)
|
|
886
|
+
else c
|
|
887
|
+
for c in tool_calls_of(m)
|
|
888
|
+
]
|
|
889
|
+
if calls:
|
|
890
|
+
out["tool_calls"] = calls
|
|
891
|
+
return out
|
|
892
|
+
|
|
893
|
+
|
|
894
|
+
# The remove-everything marker of LangGraph's `add_messages` reducer
|
|
895
|
+
# (`langgraph.graph.message.REMOVE_ALL_MESSAGES`).
|
|
896
|
+
REMOVE_ALL_MESSAGES = "__remove_all__"
|
|
897
|
+
# How long a step-limit reply waits for the server to mark our run done.
|
|
898
|
+
SERVER_IDLE_WAIT_S = 3.0
|
|
899
|
+
|
|
900
|
+
|
|
901
|
+
def _server_busy(thread: Any) -> bool:
|
|
902
|
+
return isinstance(thread, Mapping) and thread.get("status") == "busy"
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def thread_busy_error() -> dict[str, Any]:
|
|
906
|
+
return {"code": THREAD_BUSY, "message": "This thread already has a run in progress."}
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _lease_lost(exc: BaseException | None) -> LeaseLost | None:
|
|
910
|
+
"""The `LeaseLost` behind `exc` (itself, or what it was raised from), if any."""
|
|
911
|
+
seen: set[int] = set()
|
|
912
|
+
while exc is not None and id(exc) not in seen:
|
|
913
|
+
if isinstance(exc, LeaseLost):
|
|
914
|
+
return exc
|
|
915
|
+
seen.add(id(exc))
|
|
916
|
+
exc = exc.__cause__ or exc.__context__
|
|
917
|
+
return None
|
|
918
|
+
|
|
919
|
+
|
|
920
|
+
def _is_structured_answer_error(exc: BaseException) -> bool:
|
|
921
|
+
if isinstance(exc, StructuredAnswerError):
|
|
922
|
+
return True
|
|
923
|
+
# Under langgraph-server the error arrives as the stream's `error` part.
|
|
924
|
+
return isinstance(exc, _ServerRunError) and exc.error_type == "StructuredAnswerError"
|
|
925
|
+
|
|
926
|
+
|
|
927
|
+
def _is_recursion_error(exc: BaseException) -> bool:
|
|
928
|
+
if type(exc).__name__ == "GraphRecursionError":
|
|
929
|
+
return True
|
|
930
|
+
# Under langgraph-server the error arrives as the stream's `error` part.
|
|
931
|
+
return isinstance(exc, _ServerRunError) and exc.error_type == "GraphRecursionError"
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
class _ServerRunError(RuntimeError):
|
|
935
|
+
"""An `error` part of a LangGraph Server run stream."""
|
|
936
|
+
|
|
937
|
+
def __init__(self, data: Any) -> None:
|
|
938
|
+
super().__init__(str(data))
|
|
939
|
+
self.error_type = str(data.get("error") or "") if isinstance(data, Mapping) else ""
|
|
940
|
+
|
|
941
|
+
|
|
942
|
+
# ---------------------------------------------------------------------------
|
|
943
|
+
# Pacing: run timeout and heartbeats around a stream of graph events
|
|
944
|
+
# ---------------------------------------------------------------------------
|
|
945
|
+
|
|
946
|
+
_ITEM, _DONE, _FAILED, _IDLE = "item", "done", "failed", "idle"
|
|
947
|
+
|
|
948
|
+
|
|
949
|
+
class _Pump:
|
|
950
|
+
"""Drive an async iterator in a task of its own and hand its items over a queue.
|
|
951
|
+
|
|
952
|
+
The consumer can then wait with a timeout (for heartbeats and the run
|
|
953
|
+
deadline) without cancelling the graph mid-step, and cancel it outright
|
|
954
|
+
when the run times out or the client leaves.
|
|
955
|
+
"""
|
|
956
|
+
|
|
957
|
+
def __init__(self, source: AsyncIterator[Any]) -> None:
|
|
958
|
+
self._queue: asyncio.Queue[tuple[str, Any]] = asyncio.Queue(maxsize=64)
|
|
959
|
+
self._interrupted: BaseException | None = None
|
|
960
|
+
self._task = asyncio.ensure_future(self._run(source))
|
|
961
|
+
|
|
962
|
+
async def _run(self, source: AsyncIterator[Any]) -> None:
|
|
963
|
+
try:
|
|
964
|
+
async for item in source:
|
|
965
|
+
await self._queue.put((_ITEM, item))
|
|
966
|
+
except asyncio.CancelledError:
|
|
967
|
+
raise
|
|
968
|
+
except BaseException as exc:
|
|
969
|
+
await self._queue.put((_FAILED, exc))
|
|
970
|
+
return
|
|
971
|
+
await self._queue.put((_DONE, None))
|
|
972
|
+
|
|
973
|
+
def interrupt(self, exc: BaseException) -> None:
|
|
974
|
+
"""Stop the source now; the consumer's next `next()` fails with `exc`."""
|
|
975
|
+
if self._interrupted is not None:
|
|
976
|
+
return
|
|
977
|
+
self._interrupted = exc
|
|
978
|
+
self.cancel()
|
|
979
|
+
with contextlib.suppress(asyncio.QueueFull): # a full queue wakes nobody anyway
|
|
980
|
+
self._queue.put_nowait((_FAILED, exc))
|
|
981
|
+
|
|
982
|
+
async def next(self, timeout: float) -> tuple[str, Any]:
|
|
983
|
+
if self._interrupted is not None:
|
|
984
|
+
return _FAILED, self._interrupted
|
|
985
|
+
try:
|
|
986
|
+
item = await asyncio.wait_for(self._queue.get(), timeout)
|
|
987
|
+
except TimeoutError:
|
|
988
|
+
return _IDLE, None
|
|
989
|
+
if self._interrupted is not None:
|
|
990
|
+
return _FAILED, self._interrupted
|
|
991
|
+
return item
|
|
992
|
+
|
|
993
|
+
@property
|
|
994
|
+
def task(self) -> asyncio.Future[None]:
|
|
995
|
+
return self._task
|
|
996
|
+
|
|
997
|
+
def cancel(self) -> None:
|
|
998
|
+
if not self._task.done():
|
|
999
|
+
self._task.cancel()
|
|
1000
|
+
|
|
1001
|
+
async def close(self, timeout: float = 10) -> bool:
|
|
1002
|
+
"""Cancel the source and wait up to `timeout` s for it to stop; True when it has."""
|
|
1003
|
+
self.cancel()
|
|
1004
|
+
if not self._task.done():
|
|
1005
|
+
await asyncio.wait({self._task}, timeout=timeout)
|
|
1006
|
+
return self._task.done()
|
|
1007
|
+
|
|
1008
|
+
|
|
1009
|
+
class ChatRuntime:
|
|
1010
|
+
"""Process-wide chat runtime, started and stopped by the app lifespan."""
|
|
1011
|
+
|
|
1012
|
+
def __init__(self) -> None:
|
|
1013
|
+
self.runtime = detect_runtime()
|
|
1014
|
+
self.db: Database | None = None
|
|
1015
|
+
self.runs: RunStore | None = None
|
|
1016
|
+
self.threads: ThreadStore | None = None
|
|
1017
|
+
self.approvals: ApprovalStore | None = None
|
|
1018
|
+
self.locks: ThreadLocks = ThreadLocks()
|
|
1019
|
+
self._exit: AsyncExitStack | None = None
|
|
1020
|
+
self._retention_task: asyncio.Task[None] | None = None
|
|
1021
|
+
self._init_task: asyncio.Task[None] | None = None
|
|
1022
|
+
self._maintenance_task: asyncio.Task[None] | None = None
|
|
1023
|
+
self._approvals_task: asyncio.Task[None] | None = None
|
|
1024
|
+
self._saver: Any = None
|
|
1025
|
+
# Final run records whose write failed, retried by the maintenance loop.
|
|
1026
|
+
self._unrecorded: deque[RunRecord] = deque(maxlen=MAX_UNRECORDED_RUNS)
|
|
1027
|
+
self._was_ready: bool | None = None
|
|
1028
|
+
self.started = False
|
|
1029
|
+
# True from startup until the database schema is set up (postgres).
|
|
1030
|
+
self.initialising = False
|
|
1031
|
+
|
|
1032
|
+
# -- lifecycle ---------------------------------------------------------
|
|
1033
|
+
|
|
1034
|
+
async def start(self) -> None:
|
|
1035
|
+
"""Bind storage and start the background work; never waits for the database.
|
|
1036
|
+
|
|
1037
|
+
Configuration errors (an unknown `CHECKPOINTER`, a missing or
|
|
1038
|
+
unparsable DSN) stop startup. An unreachable database does not: the
|
|
1039
|
+
schema setup is retried in the background (`initialising`), `/ready`
|
|
1040
|
+
answers 503 and requests get 503 until it succeeds.
|
|
1041
|
+
"""
|
|
1042
|
+
if self.started:
|
|
1043
|
+
return
|
|
1044
|
+
self.runtime = detect_runtime()
|
|
1045
|
+
self._exit = AsyncExitStack()
|
|
1046
|
+
try:
|
|
1047
|
+
if self.runtime == FASTAPI:
|
|
1048
|
+
self.db = Database.from_env()
|
|
1049
|
+
else:
|
|
1050
|
+
# The server binds persistence; run records go to its Postgres
|
|
1051
|
+
# (DATABASE_URI) when it has one.
|
|
1052
|
+
self.db = Database.for_server()
|
|
1053
|
+
await self.db.open_pool()
|
|
1054
|
+
self._exit.push_async_callback(self.db.close)
|
|
1055
|
+
self.locks = ThreadLocks(
|
|
1056
|
+
self.db.dsn if self.db.is_postgres else None,
|
|
1057
|
+
table=self.db.locks_table,
|
|
1058
|
+
health=self.db.health,
|
|
1059
|
+
)
|
|
1060
|
+
self._exit.push_async_callback(self.locks.close)
|
|
1061
|
+
if self.runtime == FASTAPI:
|
|
1062
|
+
from {{cookiecutter.agent_directory}}.agent import graph
|
|
1063
|
+
|
|
1064
|
+
if self.db.is_postgres:
|
|
1065
|
+
# Set up with the app tables once the database answers.
|
|
1066
|
+
self._saver = postgres_saver(self.db.pool, fence=self.locks.fence)
|
|
1067
|
+
else:
|
|
1068
|
+
self._saver = await self._exit.enter_async_context(get_checkpointer())
|
|
1069
|
+
graph.checkpointer = self._saver
|
|
1070
|
+
self.runs = RunStore(self.db)
|
|
1071
|
+
self.threads = ThreadStore(self.db)
|
|
1072
|
+
# Under `langgraph dev` the approvals are kept in a file beside the
|
|
1073
|
+
# server's own threads, which outlive this process (a restart, a
|
|
1074
|
+
# hot reload): the records binding their tool calls must too.
|
|
1075
|
+
self.approvals = ApprovalStore(
|
|
1076
|
+
self.db, path=dev_ledger_path() if self.runtime == LANGGRAPH_SERVER else None
|
|
1077
|
+
)
|
|
1078
|
+
await self.approvals.load()
|
|
1079
|
+
# The ledger the API client marks approvals used in, once, before a
|
|
1080
|
+
# gated call is sent (the graph runs in this process under both runtimes).
|
|
1081
|
+
set_approval_ledger(self.approvals)
|
|
1082
|
+
except BaseException:
|
|
1083
|
+
await self._exit.aclose()
|
|
1084
|
+
self._exit = None
|
|
1085
|
+
raise
|
|
1086
|
+
self.started = True
|
|
1087
|
+
self._check_step_budget()
|
|
1088
|
+
if self.db.is_postgres:
|
|
1089
|
+
self.initialising = True
|
|
1090
|
+
self._init_task = asyncio.create_task(self._initialise(), name="database-setup")
|
|
1091
|
+
else:
|
|
1092
|
+
self._storage_ready()
|
|
1093
|
+
logger.info(
|
|
1094
|
+
"chat runtime started: runtime=%s checkpointer=%s retention_days=%s",
|
|
1095
|
+
self.runtime,
|
|
1096
|
+
self.checkpointer_kind(),
|
|
1097
|
+
retention_days(),
|
|
1098
|
+
)
|
|
1099
|
+
|
|
1100
|
+
async def _initialise(self) -> None:
|
|
1101
|
+
"""Set up the schema, retrying with back-off until the database answers."""
|
|
1102
|
+
assert self.db is not None
|
|
1103
|
+
extra = self._saver.setup if self.runtime == FASTAPI and self.db.is_postgres else None
|
|
1104
|
+
delay = INIT_RETRY_FIRST_S
|
|
1105
|
+
attempt = 0
|
|
1106
|
+
while True:
|
|
1107
|
+
attempt += 1
|
|
1108
|
+
try:
|
|
1109
|
+
await self.db.setup(extra)
|
|
1110
|
+
break
|
|
1111
|
+
except asyncio.CancelledError:
|
|
1112
|
+
raise
|
|
1113
|
+
except Exception as exc:
|
|
1114
|
+
if attempt == 1 or attempt % 30 == 0: # about once a minute
|
|
1115
|
+
if is_database_unavailable(exc):
|
|
1116
|
+
logger.warning(
|
|
1117
|
+
"database not reachable yet (%s: %s); not ready, retrying every %g s",
|
|
1118
|
+
type(exc).__name__,
|
|
1119
|
+
_first_line(exc),
|
|
1120
|
+
INIT_RETRY_MAX_S,
|
|
1121
|
+
)
|
|
1122
|
+
else:
|
|
1123
|
+
logger.error("database setup failed; retrying", exc_info=exc)
|
|
1124
|
+
await asyncio.sleep(delay)
|
|
1125
|
+
delay = min(delay * 2, INIT_RETRY_MAX_S)
|
|
1126
|
+
self.initialising = False
|
|
1127
|
+
if attempt > 1:
|
|
1128
|
+
logger.info("database set up after %d attempts", attempt)
|
|
1129
|
+
self._storage_ready()
|
|
1130
|
+
|
|
1131
|
+
def _storage_ready(self) -> None:
|
|
1132
|
+
"""Start the background work that needs the database."""
|
|
1133
|
+
days = retention_days()
|
|
1134
|
+
if days > 0:
|
|
1135
|
+
self._retention_task = asyncio.create_task(self._retention_loop(days))
|
|
1136
|
+
if self.db is not None and self.db.is_postgres:
|
|
1137
|
+
self._maintenance_task = asyncio.create_task(self._maintenance_loop())
|
|
1138
|
+
self._approvals_task = asyncio.create_task(self._approvals_loop())
|
|
1139
|
+
|
|
1140
|
+
async def stop(self) -> None:
|
|
1141
|
+
tasks = [
|
|
1142
|
+
self._init_task,
|
|
1143
|
+
self._maintenance_task,
|
|
1144
|
+
self._retention_task,
|
|
1145
|
+
self._approvals_task,
|
|
1146
|
+
]
|
|
1147
|
+
self._init_task = self._maintenance_task = self._retention_task = None
|
|
1148
|
+
self._approvals_task = None
|
|
1149
|
+
for task in tasks:
|
|
1150
|
+
if task is not None:
|
|
1151
|
+
task.cancel()
|
|
1152
|
+
await asyncio.gather(*(t for t in tasks if t is not None), return_exceptions=True)
|
|
1153
|
+
if self.approvals is not None and approval_ledger() is self.approvals:
|
|
1154
|
+
set_approval_ledger(None)
|
|
1155
|
+
if self._exit is not None:
|
|
1156
|
+
await self._exit.aclose()
|
|
1157
|
+
self._exit = None
|
|
1158
|
+
self.started = False
|
|
1159
|
+
self.initialising = False
|
|
1160
|
+
|
|
1161
|
+
def checkpointer_kind(self) -> str:
|
|
1162
|
+
if self.runtime == LANGGRAPH_SERVER:
|
|
1163
|
+
return "postgres" if is_postgres_url(os.environ.get("DATABASE_URI")) else "memory"
|
|
1164
|
+
return checkpointer_kind()
|
|
1165
|
+
|
|
1166
|
+
def _check_step_budget(self) -> None:
|
|
1167
|
+
"""Warn when an API's `max_calls_per_run` cannot be reached within `RECURSION_LIMIT`."""
|
|
1168
|
+
try:
|
|
1169
|
+
from {{cookiecutter.agent_directory}}.app_utils.api_client import load_policy
|
|
1170
|
+
|
|
1171
|
+
policy = load_policy()
|
|
1172
|
+
except Exception: # no or invalid policy: reported where it is used
|
|
1173
|
+
return
|
|
1174
|
+
limit = recursion_limit()
|
|
1175
|
+
fits = sequential_tool_calls(limit)
|
|
1176
|
+
for name, settings in sorted(policy.apis.items()):
|
|
1177
|
+
max_calls = (settings.get("limits") or {}).get("max_calls_per_run")
|
|
1178
|
+
if isinstance(max_calls, int) and max_calls > fits:
|
|
1179
|
+
logger.warning(
|
|
1180
|
+
"RECURSION_LIMIT=%d allows about %d sequential tool calls per run, fewer "
|
|
1181
|
+
"than limits.max_calls_per_run=%d of API %r: runs stop at the step limit "
|
|
1182
|
+
"first. Set RECURSION_LIMIT to at least %d, or lower the API's limit.",
|
|
1183
|
+
limit,
|
|
1184
|
+
fits,
|
|
1185
|
+
max_calls,
|
|
1186
|
+
name,
|
|
1187
|
+
steps_for_tool_calls(max_calls),
|
|
1188
|
+
)
|
|
1189
|
+
|
|
1190
|
+
def _require_storage(self) -> None:
|
|
1191
|
+
"""503 while the database is not set up yet (startup during an outage)."""
|
|
1192
|
+
if not self.started or self.initialising:
|
|
1193
|
+
raise unavailable("Database", StorageNotReady("the database is not set up yet"))
|
|
1194
|
+
|
|
1195
|
+
async def ready(self, timeout: float = 2.0) -> bool:
|
|
1196
|
+
"""True when the runtime's storage answers a trivial query within `timeout` seconds."""
|
|
1197
|
+
ok = False
|
|
1198
|
+
reason = "starting"
|
|
1199
|
+
if self.started and self.db is not None and not self.initialising:
|
|
1200
|
+
try:
|
|
1201
|
+
async with asyncio.timeout(timeout):
|
|
1202
|
+
await self.db.ping()
|
|
1203
|
+
if self.runtime == LANGGRAPH_SERVER:
|
|
1204
|
+
await self._sdk_client({}).assistants.search(limit=1)
|
|
1205
|
+
ok = True
|
|
1206
|
+
except Exception as exc:
|
|
1207
|
+
reason = type(exc).__name__
|
|
1208
|
+
elif self.initialising:
|
|
1209
|
+
reason = "the database is not set up yet"
|
|
1210
|
+
if ok != self._was_ready: # log changes only, not every probe
|
|
1211
|
+
if ok:
|
|
1212
|
+
logger.info("ready")
|
|
1213
|
+
else:
|
|
1214
|
+
logger.warning("not ready: %s", reason)
|
|
1215
|
+
self._was_ready = ok
|
|
1216
|
+
return ok
|
|
1217
|
+
|
|
1218
|
+
async def _maintenance_loop(self) -> None:
|
|
1219
|
+
"""Every `RECONCILE_INTERVAL_S`: write failed run records, close runs of dead processes."""
|
|
1220
|
+
failing = False
|
|
1221
|
+
while True:
|
|
1222
|
+
try:
|
|
1223
|
+
await self.reconcile_runs()
|
|
1224
|
+
if failing:
|
|
1225
|
+
failing = False
|
|
1226
|
+
logger.info("run reconciliation works again")
|
|
1227
|
+
except asyncio.CancelledError:
|
|
1228
|
+
raise
|
|
1229
|
+
except Exception as exc:
|
|
1230
|
+
if not failing: # once per outage, not every round
|
|
1231
|
+
failing = True
|
|
1232
|
+
logger.warning(
|
|
1233
|
+
"run reconciliation failed (%s); retrying every %g s",
|
|
1234
|
+
type(exc).__name__,
|
|
1235
|
+
RECONCILE_INTERVAL_S,
|
|
1236
|
+
)
|
|
1237
|
+
await asyncio.sleep(RECONCILE_INTERVAL_S)
|
|
1238
|
+
|
|
1239
|
+
async def _approvals_loop(self) -> None:
|
|
1240
|
+
"""Every `SWEEP_INTERVAL_S`: mark pending approvals past their expiry `expired`."""
|
|
1241
|
+
failing = False
|
|
1242
|
+
while True:
|
|
1243
|
+
try:
|
|
1244
|
+
await self.sweep_approvals()
|
|
1245
|
+
failing = False
|
|
1246
|
+
except asyncio.CancelledError:
|
|
1247
|
+
raise
|
|
1248
|
+
except Exception as exc:
|
|
1249
|
+
if not failing:
|
|
1250
|
+
failing = True
|
|
1251
|
+
logger.warning(
|
|
1252
|
+
"approval expiry sweep failed (%s); retrying every %g s",
|
|
1253
|
+
type(exc).__name__,
|
|
1254
|
+
SWEEP_INTERVAL_S,
|
|
1255
|
+
)
|
|
1256
|
+
await asyncio.sleep(SWEEP_INTERVAL_S)
|
|
1257
|
+
|
|
1258
|
+
async def sweep_approvals(self) -> list[ApprovalRecord]:
|
|
1259
|
+
"""Mark pending approvals past their expiry `expired` (= rejected); return them."""
|
|
1260
|
+
if self.approvals is None:
|
|
1261
|
+
return []
|
|
1262
|
+
expired = await self.approvals.expire_due()
|
|
1263
|
+
if expired:
|
|
1264
|
+
metrics.observe_approvals("expired", len(expired))
|
|
1265
|
+
logger.info("%d pending approvals expired", len(expired))
|
|
1266
|
+
await self._approvals_expired(expired)
|
|
1267
|
+
return expired
|
|
1268
|
+
|
|
1269
|
+
async def _approvals_expired(self, records: list[ApprovalRecord]) -> None:
|
|
1270
|
+
"""The A2A tasks waiting on these (now expired) approvals fail (`ApprovalOutcome`)."""
|
|
1271
|
+
for record in records:
|
|
1272
|
+
await approval_outcome(
|
|
1273
|
+
ApprovalOutcome(
|
|
1274
|
+
thread_id=record.thread_id,
|
|
1275
|
+
approval_id=record.approval_id,
|
|
1276
|
+
requester_hash=record.requester_hash,
|
|
1277
|
+
requester_actor=record.requester_actor,
|
|
1278
|
+
state=OUTCOME_FAILED,
|
|
1279
|
+
text=f"Approval {record.approval_id} expired before anyone decided.",
|
|
1280
|
+
)
|
|
1281
|
+
)
|
|
1282
|
+
|
|
1283
|
+
async def _resumed_outcome(
|
|
1284
|
+
self,
|
|
1285
|
+
thread_id: str,
|
|
1286
|
+
resume: Resume,
|
|
1287
|
+
status: str,
|
|
1288
|
+
paused: list[ApprovalRecord],
|
|
1289
|
+
reply: str,
|
|
1290
|
+
answer: Any = None,
|
|
1291
|
+
) -> None:
|
|
1292
|
+
"""The A2A tasks waiting on the decided approval follow the run it resumed (KI-025).
|
|
1293
|
+
|
|
1294
|
+
They end as the run did (`completed`, `failed`, or `input-required` with
|
|
1295
|
+
the approvals it paused for again), saying where it continued: in the
|
|
1296
|
+
A2A task that carried the decision, or outside any task (the HTTP
|
|
1297
|
+
route), with the run's reply. A structured run's `answer` goes whole in
|
|
1298
|
+
the outcome (the tasks add it as a `response` artifact), not in the text.
|
|
1299
|
+
"""
|
|
1300
|
+
record = resume.approval
|
|
1301
|
+
if status == STATUS_AWAITING_APPROVAL:
|
|
1302
|
+
state = OUTCOME_INPUT_REQUIRED
|
|
1303
|
+
elif status in (STATUS_OK, STATUS_STEP_LIMIT):
|
|
1304
|
+
state = OUTCOME_COMPLETED
|
|
1305
|
+
else:
|
|
1306
|
+
state = OUTCOME_FAILED
|
|
1307
|
+
verdict = "approved" if resume.decision == APPROVE else "rejected"
|
|
1308
|
+
if resume.continued_in:
|
|
1309
|
+
text = f"Continued in task {resume.continued_in}."
|
|
1310
|
+
if verdict == "rejected":
|
|
1311
|
+
text = f"Approval {record.approval_id} was rejected. {text}"
|
|
1312
|
+
else:
|
|
1313
|
+
text = (
|
|
1314
|
+
f"Approval {record.approval_id} was {verdict} outside this task; the run "
|
|
1315
|
+
"continued there."
|
|
1316
|
+
)
|
|
1317
|
+
if state == OUTCOME_FAILED:
|
|
1318
|
+
text += f" The run ended with status {status}."
|
|
1319
|
+
if reply.strip():
|
|
1320
|
+
text += "\n\n" + reply.strip()[:OUTCOME_REPLY_MAX_CHARS]
|
|
1321
|
+
await approval_outcome(
|
|
1322
|
+
ApprovalOutcome(
|
|
1323
|
+
thread_id=thread_id,
|
|
1324
|
+
approval_id=record.approval_id,
|
|
1325
|
+
requester_hash=record.requester_hash,
|
|
1326
|
+
requester_actor=record.requester_actor,
|
|
1327
|
+
state=state,
|
|
1328
|
+
text=text,
|
|
1329
|
+
approvals=tuple(r.public() for r in paused),
|
|
1330
|
+
references=resume.references,
|
|
1331
|
+
continued_in=resume.continued_in,
|
|
1332
|
+
answer=answer if state == OUTCOME_COMPLETED else None,
|
|
1333
|
+
)
|
|
1334
|
+
)
|
|
1335
|
+
|
|
1336
|
+
async def reconcile_runs(self, grace_s: float | None = None) -> list[str]:
|
|
1337
|
+
"""Write run records that failed earlier, then mark runs of dead processes `interrupted`."""
|
|
1338
|
+
if self.runs is None:
|
|
1339
|
+
return []
|
|
1340
|
+
grace_s = RECONCILE_GRACE_S if grace_s is None else grace_s
|
|
1341
|
+
while self._unrecorded:
|
|
1342
|
+
record = self._unrecorded[0]
|
|
1343
|
+
await self.runs.record(record)
|
|
1344
|
+
self._unrecorded.popleft()
|
|
1345
|
+
run_ids = await self.runs.reconcile(grace_s)
|
|
1346
|
+
if run_ids:
|
|
1347
|
+
metrics.observe_interrupted_runs(len(run_ids))
|
|
1348
|
+
logger.warning(
|
|
1349
|
+
"%d runs never finished (their process stopped); recorded as interrupted: %s",
|
|
1350
|
+
len(run_ids),
|
|
1351
|
+
", ".join(run_ids[:20]),
|
|
1352
|
+
)
|
|
1353
|
+
return run_ids
|
|
1354
|
+
|
|
1355
|
+
# -- threads -----------------------------------------------------------
|
|
1356
|
+
|
|
1357
|
+
async def resolve_thread(self, principal: Principal, req: ChatRequest) -> str:
|
|
1358
|
+
"""The thread id for this request, after the ownership check (403 before streaming)."""
|
|
1359
|
+
if req.thread_id is not None:
|
|
1360
|
+
req.thread_id = validate_thread_id(req.thread_id, self.runtime)
|
|
1361
|
+
self._require_storage()
|
|
1362
|
+
if self.runtime == LANGGRAPH_SERVER:
|
|
1363
|
+
return await self._server_resolve_thread(principal, req)
|
|
1364
|
+
thread_id = req.thread_id or str(uuid.uuid4())
|
|
1365
|
+
await self._ensure_owner(principal, thread_id)
|
|
1366
|
+
return thread_id
|
|
1367
|
+
|
|
1368
|
+
async def _ensure_owner(self, principal: Principal, thread_id: str) -> None:
|
|
1369
|
+
"""fastapi: claim the thread for `principal` when it has no owner, else check the owner.
|
|
1370
|
+
|
|
1371
|
+
403 for someone else's thread, and for a thread id whose checkpoints
|
|
1372
|
+
have no owner row (none should exist; this keeps any that do from
|
|
1373
|
+
passing to whoever claims the id next).
|
|
1374
|
+
"""
|
|
1375
|
+
assert self.threads is not None
|
|
1376
|
+
try:
|
|
1377
|
+
await self.threads.ensure(thread_id, principal, has_state=self._has_checkpoints)
|
|
1378
|
+
except HTTPException:
|
|
1379
|
+
raise
|
|
1380
|
+
except Exception as exc: # the database: a 503 naming an error id, not its text
|
|
1381
|
+
raise unavailable("Database", exc) from exc
|
|
1382
|
+
|
|
1383
|
+
async def _has_checkpoints(self, thread_id: str) -> bool:
|
|
1384
|
+
from {{cookiecutter.agent_directory}}.agent import graph
|
|
1385
|
+
|
|
1386
|
+
saver = graph.checkpointer
|
|
1387
|
+
if saver is None or not hasattr(saver, "aget_tuple"):
|
|
1388
|
+
return False
|
|
1389
|
+
return await saver.aget_tuple({"configurable": {"thread_id": thread_id}}) is not None
|
|
1390
|
+
|
|
1391
|
+
async def acquire_thread(
|
|
1392
|
+
self, thread_id: str, principal: Principal | None = None
|
|
1393
|
+
) -> ThreadLease:
|
|
1394
|
+
"""The thread's run lock; `ThreadBusy` when a run is in progress on it.
|
|
1395
|
+
|
|
1396
|
+
With `principal` (the sender of the run about to start), the owner is
|
|
1397
|
+
checked again once the lock is held (fastapi): a DELETE can take the
|
|
1398
|
+
lock, and remove the thread, between `resolve_thread` and this call.
|
|
1399
|
+
The run then starts the thread afresh for its sender, or gets 403 when
|
|
1400
|
+
another principal claimed the id meanwhile, instead of writing a turn
|
|
1401
|
+
that no owner row covers. Deletion and retention hold the same lock,
|
|
1402
|
+
so the owner cannot change while the run holds it. (Under
|
|
1403
|
+
langgraph-server the server keeps thread and state together: a run on
|
|
1404
|
+
a deleted thread fails there with 404.)
|
|
1405
|
+
"""
|
|
1406
|
+
self._require_storage()
|
|
1407
|
+
try:
|
|
1408
|
+
lease = await self.locks.acquire(thread_id)
|
|
1409
|
+
except ThreadBusy:
|
|
1410
|
+
raise
|
|
1411
|
+
except Exception as exc:
|
|
1412
|
+
raise unavailable("Database", exc) from exc
|
|
1413
|
+
if principal is not None and self.runtime == FASTAPI:
|
|
1414
|
+
try:
|
|
1415
|
+
await self._ensure_owner(principal, thread_id)
|
|
1416
|
+
except BaseException:
|
|
1417
|
+
await lease.release()
|
|
1418
|
+
raise
|
|
1419
|
+
return lease
|
|
1420
|
+
|
|
1421
|
+
async def list_threads(
|
|
1422
|
+
self,
|
|
1423
|
+
principal: Principal,
|
|
1424
|
+
*,
|
|
1425
|
+
limit: int,
|
|
1426
|
+
offset: int,
|
|
1427
|
+
forward_headers: Mapping[str, str] | None = None,
|
|
1428
|
+
) -> list[dict[str, Any]]:
|
|
1429
|
+
"""The caller's threads (every thread for a read-across role), most recent first."""
|
|
1430
|
+
self._require_storage()
|
|
1431
|
+
if self.runtime == LANGGRAPH_SERVER:
|
|
1432
|
+
return await self._server_list_threads(principal, limit, offset, forward_headers or {})
|
|
1433
|
+
assert self.threads is not None
|
|
1434
|
+
with database_errors():
|
|
1435
|
+
records = await self.threads.list_for(principal, limit=limit, offset=offset)
|
|
1436
|
+
return [r.public() for r in records]
|
|
1437
|
+
|
|
1438
|
+
async def delete_thread(
|
|
1439
|
+
self,
|
|
1440
|
+
principal: Principal,
|
|
1441
|
+
thread_id: str,
|
|
1442
|
+
forward_headers: Mapping[str, str] | None = None,
|
|
1443
|
+
) -> None:
|
|
1444
|
+
"""Delete a thread with its checkpoints and run records: the owner only.
|
|
1445
|
+
|
|
1446
|
+
404 for an unknown thread, 403 for someone else's, `ThreadBusy` while
|
|
1447
|
+
a run is in progress on it. The owner is checked before the run lock
|
|
1448
|
+
(a stranger never takes it) and again once it is held: the thread may
|
|
1449
|
+
have been deleted, and its id claimed by someone else, in between.
|
|
1450
|
+
"""
|
|
1451
|
+
thread_id = validate_thread_id(thread_id, self.runtime)
|
|
1452
|
+
self._require_storage()
|
|
1453
|
+
headers = forward_headers or {}
|
|
1454
|
+
with database_errors():
|
|
1455
|
+
self._assert_deletable(principal, await self._thread_record(thread_id, headers))
|
|
1456
|
+
lease = await self.acquire_thread(thread_id)
|
|
1457
|
+
try:
|
|
1458
|
+
with database_errors():
|
|
1459
|
+
self._assert_deletable(principal, await self._thread_record(thread_id, headers))
|
|
1460
|
+
await self._delete_thread_data(thread_id, headers)
|
|
1461
|
+
finally:
|
|
1462
|
+
await lease.release()
|
|
1463
|
+
logger.info("thread deleted", extra={"thread_id": thread_id})
|
|
1464
|
+
|
|
1465
|
+
async def _thread_record(
|
|
1466
|
+
self, thread_id: str, forward_headers: Mapping[str, str]
|
|
1467
|
+
) -> ThreadRecord | None:
|
|
1468
|
+
if self.runtime == LANGGRAPH_SERVER:
|
|
1469
|
+
return await self._server_thread_record(self._sdk_client(forward_headers), thread_id)
|
|
1470
|
+
assert self.threads is not None
|
|
1471
|
+
return await self.threads.get(thread_id)
|
|
1472
|
+
|
|
1473
|
+
@staticmethod
|
|
1474
|
+
def _assert_deletable(principal: Principal, record: ThreadRecord | None) -> None:
|
|
1475
|
+
if record is None:
|
|
1476
|
+
raise HTTPException(status_code=404, detail="Unknown thread.")
|
|
1477
|
+
assert_owner(principal, record)
|
|
1478
|
+
|
|
1479
|
+
async def _delete_thread_data(
|
|
1480
|
+
self, thread_id: str, forward_headers: Mapping[str, str] | None = None
|
|
1481
|
+
) -> None:
|
|
1482
|
+
if self.runtime == LANGGRAPH_SERVER:
|
|
1483
|
+
client = self._sdk_client(forward_headers or {})
|
|
1484
|
+
try:
|
|
1485
|
+
await client.threads.delete(thread_id)
|
|
1486
|
+
except Exception as exc:
|
|
1487
|
+
if http_status(exc) != 404:
|
|
1488
|
+
raise unavailable("LangGraph Server", exc) from exc
|
|
1489
|
+
# The app keeps no thread rows here; tell the listeners itself (the
|
|
1490
|
+
# retention purge lands here; the A2A task store drops the thread's
|
|
1491
|
+
# tasks). Under fastapi `ThreadStore.delete` below does it.
|
|
1492
|
+
await thread_deleted(thread_id)
|
|
1493
|
+
else:
|
|
1494
|
+
from {{cookiecutter.agent_directory}}.agent import graph
|
|
1495
|
+
|
|
1496
|
+
if graph.checkpointer is not None:
|
|
1497
|
+
await graph.checkpointer.adelete_thread(thread_id)
|
|
1498
|
+
if self.runs is not None:
|
|
1499
|
+
await self.runs.delete_for_thread(thread_id)
|
|
1500
|
+
if self.approvals is not None:
|
|
1501
|
+
await self.approvals.delete_for_thread(thread_id)
|
|
1502
|
+
if self.threads is not None and self.runtime == FASTAPI:
|
|
1503
|
+
await self.threads.delete(thread_id)
|
|
1504
|
+
|
|
1505
|
+
async def forget_thread_runs(self, thread_id: str) -> None:
|
|
1506
|
+
"""Drop a deleted thread's run records and approvals (after the server's own
|
|
1507
|
+
DELETE succeeded)."""
|
|
1508
|
+
if self.runs is None and self.approvals is None:
|
|
1509
|
+
return
|
|
1510
|
+
try:
|
|
1511
|
+
thread_id = str(uuid.UUID(thread_id)) # run records use the canonical form
|
|
1512
|
+
except ValueError:
|
|
1513
|
+
return
|
|
1514
|
+
if self.runs is not None:
|
|
1515
|
+
await self.runs.delete_for_thread(thread_id)
|
|
1516
|
+
if self.approvals is not None:
|
|
1517
|
+
await self.approvals.delete_for_thread(thread_id)
|
|
1518
|
+
logger.info(
|
|
1519
|
+
"run records and approvals of a deleted thread removed", extra={"thread_id": thread_id}
|
|
1520
|
+
)
|
|
1521
|
+
|
|
1522
|
+
# -- approvals -------------------------------------------------------------
|
|
1523
|
+
|
|
1524
|
+
async def pending_approvals(self, thread_id: str) -> list[dict[str, Any]]:
|
|
1525
|
+
"""The thread's pending approvals as its owner sees them (oldest first)."""
|
|
1526
|
+
if self.approvals is None:
|
|
1527
|
+
return []
|
|
1528
|
+
with database_errors():
|
|
1529
|
+
return [r.public() for r in await self.approvals.pending_for_thread(thread_id)]
|
|
1530
|
+
|
|
1531
|
+
async def replay_refusal(self, thread_id: str) -> str | None:
|
|
1532
|
+
"""Why a run without input, or from a checkpoint, is refused on this thread; None if not.
|
|
1533
|
+
|
|
1534
|
+
Such a run (LangGraph Server's own API) runs a paused step's tool calls
|
|
1535
|
+
again without a decision: refused on a thread that recorded approvals
|
|
1536
|
+
or waits on a gated call's interrupt, so a decided call is sent only
|
|
1537
|
+
through its decision (the API client refuses it too). Fails closed:
|
|
1538
|
+
when this cannot be checked, the run is refused.
|
|
1539
|
+
"""
|
|
1540
|
+
refusal = await self.copy_refusal(thread_id)
|
|
1541
|
+
if refusal:
|
|
1542
|
+
return refusal
|
|
1543
|
+
try:
|
|
1544
|
+
paused = await self._paused_interrupts(thread_id, {})
|
|
1545
|
+
except Exception as exc:
|
|
1546
|
+
logger.warning(
|
|
1547
|
+
"could not check a thread's gated calls (%s): run refused", type(exc).__name__
|
|
1548
|
+
)
|
|
1549
|
+
return "its gated API calls could not be checked"
|
|
1550
|
+
if any(is_approval_interrupt(value) for value in paused.values()):
|
|
1551
|
+
return "the thread waits on a gated API call"
|
|
1552
|
+
return None
|
|
1553
|
+
|
|
1554
|
+
async def copy_refusal(self, thread_id: str) -> str | None:
|
|
1555
|
+
"""Why this thread is not copied (LangGraph Server's native copy); None if it may be.
|
|
1556
|
+
|
|
1557
|
+
A copy keeps the thread's tool calls but not their approvals, which
|
|
1558
|
+
belong to the source (and go when it is deleted): refused on a thread
|
|
1559
|
+
that recorded approvals. Only the records are read: the server holds
|
|
1560
|
+
the source while it copies it. (A copy of a thread that waits on a
|
|
1561
|
+
gated call it never recorded waits on it too, and `replay_refusal`
|
|
1562
|
+
refuses the runs that would run it again there.) Fails closed.
|
|
1563
|
+
"""
|
|
1564
|
+
if self.approvals is None:
|
|
1565
|
+
return "the approvals store is not ready"
|
|
1566
|
+
try:
|
|
1567
|
+
if await self.approvals.for_thread(thread_id):
|
|
1568
|
+
return "the thread has approvals of gated API calls"
|
|
1569
|
+
except Exception as exc:
|
|
1570
|
+
logger.warning("could not check a thread's approvals (%s): refused", type(exc).__name__)
|
|
1571
|
+
return "its approvals could not be checked"
|
|
1572
|
+
return None
|
|
1573
|
+
|
|
1574
|
+
async def assert_no_pending_approval(self, thread_id: str) -> None:
|
|
1575
|
+
"""`ApprovalPending` (409) while the thread waits for an approval decision."""
|
|
1576
|
+
pending = await self.pending_approvals(thread_id)
|
|
1577
|
+
if pending:
|
|
1578
|
+
raise ApprovalPending(thread_id, pending)
|
|
1579
|
+
|
|
1580
|
+
async def thread_approvals(
|
|
1581
|
+
self,
|
|
1582
|
+
principal: Principal,
|
|
1583
|
+
thread_id: str,
|
|
1584
|
+
forward_headers: Mapping[str, str] | None = None,
|
|
1585
|
+
) -> list[dict[str, Any]]:
|
|
1586
|
+
"""A thread's approvals the caller may see, newest first.
|
|
1587
|
+
|
|
1588
|
+
The owner and read-across roles see every one; a decider the ones it
|
|
1589
|
+
may decide. Anyone else gets 403, an unknown thread 404.
|
|
1590
|
+
"""
|
|
1591
|
+
thread_id = validate_thread_id(thread_id, self.runtime)
|
|
1592
|
+
self._require_storage()
|
|
1593
|
+
assert self.approvals is not None
|
|
1594
|
+
with database_errors():
|
|
1595
|
+
await self.sweep_approvals()
|
|
1596
|
+
thread = await self._thread_record(thread_id, forward_headers or {})
|
|
1597
|
+
if thread is None:
|
|
1598
|
+
raise HTTPException(status_code=404, detail="Unknown thread.")
|
|
1599
|
+
with database_errors():
|
|
1600
|
+
records = await self.approvals.for_thread(thread_id)
|
|
1601
|
+
visible = [r for r in records if may_view(principal, thread, r.approvers)]
|
|
1602
|
+
if not visible and not (is_owner(principal, thread) or reads_across(principal)):
|
|
1603
|
+
raise HTTPException(status_code=403, detail="This thread belongs to another principal.")
|
|
1604
|
+
return [r.public(include_call=sees_call(principal, thread, r.approvers)) for r in visible]
|
|
1605
|
+
|
|
1606
|
+
async def visible_approvals(
|
|
1607
|
+
self, principal: Principal, *, status: str | None, limit: int, offset: int
|
|
1608
|
+
) -> list[dict[str, Any]]:
|
|
1609
|
+
"""Approvals across threads the caller requested, may decide, or reads across."""
|
|
1610
|
+
self._require_storage()
|
|
1611
|
+
assert self.approvals is not None
|
|
1612
|
+
with database_errors():
|
|
1613
|
+
await self.sweep_approvals()
|
|
1614
|
+
records = await self.approvals.visible(
|
|
1615
|
+
principal, status=status, limit=limit, offset=offset
|
|
1616
|
+
)
|
|
1617
|
+
# The requester and the deciders see the call; read-across roles only
|
|
1618
|
+
# under TRACE_CAPTURE=full (as `sees_call`). The store lists a delegated
|
|
1619
|
+
# principal's own approvals only, and its roles never decide.
|
|
1620
|
+
own = principal.hashed_id()
|
|
1621
|
+
roles = set() if principal.delegated else {f"role:{r}" for r in principal.roles}
|
|
1622
|
+
return [
|
|
1623
|
+
r.public(
|
|
1624
|
+
include_call=r.requester_hash == own
|
|
1625
|
+
or bool(roles & set(r.approvers))
|
|
1626
|
+
or capture_full()
|
|
1627
|
+
)
|
|
1628
|
+
for r in records
|
|
1629
|
+
]
|
|
1630
|
+
|
|
1631
|
+
async def decide(
|
|
1632
|
+
self,
|
|
1633
|
+
principal: Principal,
|
|
1634
|
+
thread_id: str,
|
|
1635
|
+
approval_id: str,
|
|
1636
|
+
decision: str,
|
|
1637
|
+
comment: str | None = None,
|
|
1638
|
+
forward_headers: Mapping[str, str] | None = None,
|
|
1639
|
+
digest: str | None = None,
|
|
1640
|
+
) -> tuple[ThreadLease, Resume, Principal]:
|
|
1641
|
+
"""Approve or reject a pending approval; the thread's run lock and what to resume.
|
|
1642
|
+
|
|
1643
|
+
`ApprovalError` 404 (no such approval on this thread), 403 (the caller
|
|
1644
|
+
may not decide it: `not_an_approver`, or `approval_direct_only` for an
|
|
1645
|
+
agent at a gate the person decides directly), 410 (expired), 409
|
|
1646
|
+
(decided already, the run no longer waits for it, or
|
|
1647
|
+
`approval_digest_mismatch`: a relayed decision must name the digest of
|
|
1648
|
+
the call the person saw, and a direct one is checked when it sends
|
|
1649
|
+
one); `ThreadBusy` while a run is in progress on the thread. The decision is one atomic change under the thread's run lock,
|
|
1650
|
+
so of two concurrent decisions one wins. Returns the held lease (the
|
|
1651
|
+
caller streams the resumed run with it), the resume values and the
|
|
1652
|
+
principal the resumed run acts as (the requester, never the decider).
|
|
1653
|
+
"""
|
|
1654
|
+
thread_id = validate_thread_id(thread_id, self.runtime)
|
|
1655
|
+
self._require_storage()
|
|
1656
|
+
assert self.approvals is not None
|
|
1657
|
+
verdict = DECISIONS.get(decision)
|
|
1658
|
+
if verdict is None:
|
|
1659
|
+
raise HTTPException(status_code=422, detail="decision must be 'approve' or 'reject'.")
|
|
1660
|
+
headers = forward_headers or {}
|
|
1661
|
+
with database_errors():
|
|
1662
|
+
await self.sweep_approvals()
|
|
1663
|
+
record = await self.approvals.get(approval_id)
|
|
1664
|
+
if record is None or record.thread_id != thread_id:
|
|
1665
|
+
raise ApprovalError(404, "approval_not_found", "Unknown approval.")
|
|
1666
|
+
with database_errors():
|
|
1667
|
+
thread = await self._thread_record(thread_id, headers)
|
|
1668
|
+
if thread is None:
|
|
1669
|
+
raise ApprovalError(404, "approval_not_found", "Unknown approval.")
|
|
1670
|
+
refusal = decide_refusal(
|
|
1671
|
+
principal,
|
|
1672
|
+
thread,
|
|
1673
|
+
record.approvers,
|
|
1674
|
+
decide_with=record.decide_with,
|
|
1675
|
+
relayers=record.relayers,
|
|
1676
|
+
)
|
|
1677
|
+
if refusal is not None:
|
|
1678
|
+
raise ApprovalError(403, *refusal)
|
|
1679
|
+
self._check_decidable(record)
|
|
1680
|
+
if digest_refusal(principal, record, digest):
|
|
1681
|
+
raise ApprovalError(409, CODE_DIGEST_MISMATCH, DIGEST_MISMATCH_DETAIL)
|
|
1682
|
+
# The user's words of the request that paused (the resumed run acts on them), read
|
|
1683
|
+
# before the decision drops them from the record.
|
|
1684
|
+
paused_origin = record.payload.get(ORIGIN_PAYLOAD_KEY)
|
|
1685
|
+
relayed_by = principal.actor.id if principal.actor is not None else None
|
|
1686
|
+
lease = await self.acquire_thread(thread_id)
|
|
1687
|
+
try:
|
|
1688
|
+
with database_errors():
|
|
1689
|
+
paused = await self._paused_interrupts(thread_id, headers)
|
|
1690
|
+
if record.interrupt_id not in paused:
|
|
1691
|
+
# The thread went on without this approval (nothing waits for it).
|
|
1692
|
+
gone = await self.approvals.expire(approval_id)
|
|
1693
|
+
if gone is not None:
|
|
1694
|
+
metrics.observe_approvals("expired")
|
|
1695
|
+
await self._approvals_expired([gone])
|
|
1696
|
+
raise ApprovalError(
|
|
1697
|
+
409,
|
|
1698
|
+
CODE_NOT_PENDING,
|
|
1699
|
+
"The run no longer waits for this approval.",
|
|
1700
|
+
status=EXPIRED,
|
|
1701
|
+
)
|
|
1702
|
+
decided = await self.approvals.decide(
|
|
1703
|
+
approval_id, verdict, principal.hashed_id(), comment, decided_via=relayed_by
|
|
1704
|
+
)
|
|
1705
|
+
if decided is None:
|
|
1706
|
+
current = await self.approvals.get(approval_id)
|
|
1707
|
+
if current is None:
|
|
1708
|
+
raise ApprovalError(404, "approval_not_found", "Unknown approval.")
|
|
1709
|
+
self._check_decidable(current)
|
|
1710
|
+
raise ApprovalError(
|
|
1711
|
+
409, CODE_NOT_PENDING, "The approval is decided already.", status=PENDING
|
|
1712
|
+
)
|
|
1713
|
+
metrics.observe_approvals(verdict)
|
|
1714
|
+
resume_as = DECISION_APPROVE if decision == APPROVE else DECISION_REJECT
|
|
1715
|
+
values = {decided.interrupt_id: decision_value(decided, resume_as)}
|
|
1716
|
+
# The paused run's other interrupts get their own approval's state
|
|
1717
|
+
# too: their tools run again on this resume, and each decision is
|
|
1718
|
+
# bound to its call whatever the policy now says about gating it.
|
|
1719
|
+
# An expired (or rejected) one ends its tool, a pending one pauses
|
|
1720
|
+
# again for the same approval, and an approved one whose run never
|
|
1721
|
+
# continued is sent only as an approval allows.
|
|
1722
|
+
latest: dict[str, ApprovalRecord] = {}
|
|
1723
|
+
for other in await self.approvals.for_thread(thread_id): # newest first
|
|
1724
|
+
latest.setdefault(other.interrupt_id, other)
|
|
1725
|
+
for interrupt_id in paused:
|
|
1726
|
+
other = latest.get(interrupt_id)
|
|
1727
|
+
if interrupt_id not in values and other is not None:
|
|
1728
|
+
state = other.effective_status(self.approvals.now())
|
|
1729
|
+
values[interrupt_id] = decision_value(other, RESUME_AS[state])
|
|
1730
|
+
except BaseException:
|
|
1731
|
+
await lease.release()
|
|
1732
|
+
raise
|
|
1733
|
+
logger.info(
|
|
1734
|
+
"approval decided: %s%s",
|
|
1735
|
+
verdict,
|
|
1736
|
+
f" (relayed by {relayed_by})" if relayed_by else "",
|
|
1737
|
+
extra={"thread_id": thread_id, "approval_id": approval_id},
|
|
1738
|
+
)
|
|
1739
|
+
acting = resume_principal(
|
|
1740
|
+
decided,
|
|
1741
|
+
thread,
|
|
1742
|
+
principal,
|
|
1743
|
+
origin=paused_origin if isinstance(paused_origin, dict) else None,
|
|
1744
|
+
)
|
|
1745
|
+
return lease, Resume(values=values, approval=decided, decision=decision), acting
|
|
1746
|
+
|
|
1747
|
+
@staticmethod
|
|
1748
|
+
def _check_decidable(record: ApprovalRecord) -> None:
|
|
1749
|
+
status = record.effective_status()
|
|
1750
|
+
if status == EXPIRED:
|
|
1751
|
+
raise ApprovalError(410, CODE_EXPIRED, "The approval expired.", status=status)
|
|
1752
|
+
if status != PENDING:
|
|
1753
|
+
raise ApprovalError(
|
|
1754
|
+
409, CODE_NOT_PENDING, f"The approval is {status} already.", status=status
|
|
1755
|
+
)
|
|
1756
|
+
|
|
1757
|
+
async def _paused_interrupts(
|
|
1758
|
+
self, thread_id: str, forward_headers: Mapping[str, str]
|
|
1759
|
+
) -> dict[str, Any]:
|
|
1760
|
+
"""The interrupts the thread's paused run waits on: id -> value."""
|
|
1761
|
+
if self.runtime == LANGGRAPH_SERVER:
|
|
1762
|
+
client = self._sdk_client(forward_headers)
|
|
1763
|
+
try:
|
|
1764
|
+
state = await client.threads.get_state(thread_id)
|
|
1765
|
+
except Exception as exc:
|
|
1766
|
+
if http_status(exc) == 404:
|
|
1767
|
+
return {}
|
|
1768
|
+
raise unavailable("LangGraph Server", exc) from exc
|
|
1769
|
+
items = state.get("interrupts") if isinstance(state, Mapping) else None
|
|
1770
|
+
return {i["id"]: i["value"] for i in interrupts_of(items or [])}
|
|
1771
|
+
from {{cookiecutter.agent_directory}}.agent import graph
|
|
1772
|
+
|
|
1773
|
+
snapshot = await graph.aget_state({"configurable": {"thread_id": thread_id}})
|
|
1774
|
+
found = list(getattr(snapshot, "interrupts", None) or ())
|
|
1775
|
+
return {i["id"]: i["value"] for i in interrupts_of(found)}
|
|
1776
|
+
|
|
1777
|
+
async def _record_approvals(
|
|
1778
|
+
self, principal: Principal, thread_id: str, run_id: str, interrupts: list[dict[str, Any]]
|
|
1779
|
+
) -> list[ApprovalRecord]:
|
|
1780
|
+
"""A pending approval per interrupt of a paused run (one already pending is kept).
|
|
1781
|
+
|
|
1782
|
+
Under the fastapi runtime each keeps the user's words the request carried
|
|
1783
|
+
(the resumed run acts on them); LangGraph Server never passes them to tools.
|
|
1784
|
+
"""
|
|
1785
|
+
assert self.approvals is not None
|
|
1786
|
+
records: list[ApprovalRecord] = []
|
|
1787
|
+
origin = origin_of(principal) if self.runtime != LANGGRAPH_SERVER else None
|
|
1788
|
+
for item in interrupts:
|
|
1789
|
+
record, created, superseded = await self.approvals.add(
|
|
1790
|
+
record_from_interrupt(
|
|
1791
|
+
item["value"],
|
|
1792
|
+
interrupt_id=item["id"],
|
|
1793
|
+
thread_id=thread_id,
|
|
1794
|
+
run_id=run_id,
|
|
1795
|
+
requester=principal,
|
|
1796
|
+
now=self.approvals.now(),
|
|
1797
|
+
origin=origin,
|
|
1798
|
+
)
|
|
1799
|
+
)
|
|
1800
|
+
if created:
|
|
1801
|
+
metrics.observe_approvals("requested")
|
|
1802
|
+
metrics.observe_approvals("expired", superseded)
|
|
1803
|
+
if all(r.approval_id != record.approval_id for r in records):
|
|
1804
|
+
records.append(record)
|
|
1805
|
+
logger.info("run paused for %d approval(s)", len(records), extra={"thread_id": thread_id})
|
|
1806
|
+
return records
|
|
1807
|
+
|
|
1808
|
+
async def _approval_results(self, thread_id: str) -> dict[str, str]:
|
|
1809
|
+
"""Error results, by tool call id, for calls a paused run left open (see the repair)."""
|
|
1810
|
+
if self.approvals is None:
|
|
1811
|
+
return {}
|
|
1812
|
+
texts: dict[str, str] = {}
|
|
1813
|
+
for record in await self.approvals.for_thread(thread_id): # newest first
|
|
1814
|
+
text = (
|
|
1815
|
+
OPEN_CALL_SENT_UNSAVED
|
|
1816
|
+
if record.used_at is not None
|
|
1817
|
+
else OPEN_CALL_BY_APPROVAL.get(record.effective_status())
|
|
1818
|
+
)
|
|
1819
|
+
if record.tool_call_id and text and record.tool_call_id not in texts:
|
|
1820
|
+
texts[record.tool_call_id] = text
|
|
1821
|
+
return texts
|
|
1822
|
+
|
|
1823
|
+
# -- retention -----------------------------------------------------------
|
|
1824
|
+
|
|
1825
|
+
async def _retention_loop(self, days: int) -> None:
|
|
1826
|
+
await asyncio.sleep(RETENTION_FIRST_DELAY_S)
|
|
1827
|
+
while True:
|
|
1828
|
+
try:
|
|
1829
|
+
purged = 0
|
|
1830
|
+
for _ in range(RETENTION_MAX_BATCHES): # a backlog drains over a few rounds
|
|
1831
|
+
removed = await self.purge_expired(
|
|
1832
|
+
days, batch=RETENTION_BATCH, sweep_run_records=False
|
|
1833
|
+
)
|
|
1834
|
+
purged += removed
|
|
1835
|
+
if removed < RETENTION_BATCH:
|
|
1836
|
+
break
|
|
1837
|
+
if purged:
|
|
1838
|
+
logger.info("retention purge removed %d idle threads", purged)
|
|
1839
|
+
swept = await self.sweep_orphaned_runs(days, batch=RETENTION_BATCH)
|
|
1840
|
+
if swept:
|
|
1841
|
+
logger.info("retention removed the run records of %d deleted threads", swept)
|
|
1842
|
+
except asyncio.CancelledError:
|
|
1843
|
+
raise
|
|
1844
|
+
except Exception:
|
|
1845
|
+
logger.exception("retention purge failed; retrying in an hour")
|
|
1846
|
+
await asyncio.sleep(RETENTION_INTERVAL_S)
|
|
1847
|
+
|
|
1848
|
+
async def purge_expired(
|
|
1849
|
+
self, days: int | None = None, *, batch: int = 500, sweep_run_records: bool = True
|
|
1850
|
+
) -> int:
|
|
1851
|
+
"""Delete threads (checkpoints, run records) idle for more than `days`; best effort.
|
|
1852
|
+
|
|
1853
|
+
A thread with a run in progress is skipped this round, and so is one
|
|
1854
|
+
continued since it was listed: idleness is checked again under the
|
|
1855
|
+
thread's run lock, which a new turn needs too. Returns how many
|
|
1856
|
+
threads were removed. `sweep_run_records` also runs
|
|
1857
|
+
`sweep_orphaned_runs` (the hourly loop runs it once per round instead).
|
|
1858
|
+
"""
|
|
1859
|
+
days = retention_days() if days is None else days
|
|
1860
|
+
if days <= 0:
|
|
1861
|
+
return 0
|
|
1862
|
+
cutoff = datetime.now(tz=UTC) - timedelta(days=days)
|
|
1863
|
+
if self.runtime == LANGGRAPH_SERVER:
|
|
1864
|
+
candidates = await self._server_idle_threads(cutoff, batch)
|
|
1865
|
+
else:
|
|
1866
|
+
assert self.threads is not None
|
|
1867
|
+
candidates = await self.threads.idle_before(cutoff.isoformat(), limit=batch)
|
|
1868
|
+
purged = 0
|
|
1869
|
+
for thread_id in candidates:
|
|
1870
|
+
try:
|
|
1871
|
+
lease = await self.locks.acquire(thread_id)
|
|
1872
|
+
except ThreadBusy:
|
|
1873
|
+
continue
|
|
1874
|
+
try:
|
|
1875
|
+
if not await self._still_idle(thread_id, cutoff):
|
|
1876
|
+
continue
|
|
1877
|
+
await self._delete_thread_data(thread_id)
|
|
1878
|
+
purged += 1
|
|
1879
|
+
finally:
|
|
1880
|
+
await lease.release()
|
|
1881
|
+
if sweep_run_records:
|
|
1882
|
+
await self.sweep_orphaned_runs(days, batch=batch)
|
|
1883
|
+
return purged
|
|
1884
|
+
|
|
1885
|
+
async def _still_idle(self, thread_id: str, cutoff: datetime) -> bool:
|
|
1886
|
+
"""True when the thread still exists and has not been continued since `cutoff`."""
|
|
1887
|
+
if self.runtime == LANGGRAPH_SERVER:
|
|
1888
|
+
try:
|
|
1889
|
+
thread = await self._sdk_client({}).threads.get(thread_id)
|
|
1890
|
+
except Exception as exc:
|
|
1891
|
+
if http_status(exc) == 404:
|
|
1892
|
+
return False
|
|
1893
|
+
raise
|
|
1894
|
+
if not isinstance(thread, Mapping) or thread.get("status") == "busy":
|
|
1895
|
+
return False
|
|
1896
|
+
updated = _parse_time(thread.get("updated_at"))
|
|
1897
|
+
else:
|
|
1898
|
+
assert self.threads is not None
|
|
1899
|
+
record = await self.threads.get(thread_id)
|
|
1900
|
+
if record is None:
|
|
1901
|
+
return False
|
|
1902
|
+
updated = _parse_time(record.updated_at or record.created_at)
|
|
1903
|
+
return updated is not None and updated < cutoff
|
|
1904
|
+
|
|
1905
|
+
async def sweep_orphaned_runs(self, days: int | None = None, *, batch: int = 500) -> int:
|
|
1906
|
+
"""langgraph-server: drop run records, older than `days`, of threads the server no longer has.
|
|
1907
|
+
|
|
1908
|
+
The server's own `DELETE /threads/{id}` removes a thread's run records
|
|
1909
|
+
(`forget_thread_runs`); this catches the rest (for example threads
|
|
1910
|
+
deleted while this app could not reach its database). Paged by
|
|
1911
|
+
thread id, so later pages are reached however many live threads have
|
|
1912
|
+
old run records. Returns how many threads' records were removed.
|
|
1913
|
+
"""
|
|
1914
|
+
days = retention_days() if days is None else days
|
|
1915
|
+
if days <= 0 or self.runtime != LANGGRAPH_SERVER or self.runs is None:
|
|
1916
|
+
return 0
|
|
1917
|
+
cutoff_iso = (datetime.now(tz=UTC) - timedelta(days=days)).isoformat()
|
|
1918
|
+
client = self._sdk_client({})
|
|
1919
|
+
removed = 0
|
|
1920
|
+
after: str | None = None
|
|
1921
|
+
for _ in range(RETENTION_MAX_BATCHES):
|
|
1922
|
+
thread_ids = await self.runs.thread_ids_before(cutoff_iso, after=after, limit=batch)
|
|
1923
|
+
for thread_id in thread_ids:
|
|
1924
|
+
if await self._server_thread_record(client, thread_id) is None:
|
|
1925
|
+
await self.runs.delete_for_thread(thread_id)
|
|
1926
|
+
if self.approvals is not None:
|
|
1927
|
+
await self.approvals.delete_for_thread(thread_id)
|
|
1928
|
+
removed += 1
|
|
1929
|
+
if len(thread_ids) < batch:
|
|
1930
|
+
break
|
|
1931
|
+
after = thread_ids[-1]
|
|
1932
|
+
return removed
|
|
1933
|
+
|
|
1934
|
+
# -- streaming ---------------------------------------------------------
|
|
1935
|
+
|
|
1936
|
+
async def stream(
|
|
1937
|
+
self,
|
|
1938
|
+
principal: Principal,
|
|
1939
|
+
req: ChatRequest,
|
|
1940
|
+
thread_id: str,
|
|
1941
|
+
lease: ThreadLease | None = None,
|
|
1942
|
+
resume: Resume | None = None,
|
|
1943
|
+
) -> AsyncIterator[tuple[str, dict[str, Any]]]:
|
|
1944
|
+
"""Run the graph once on `thread_id` and yield the chat events.
|
|
1945
|
+
|
|
1946
|
+
`lease` is the thread's run lock when the caller took it already
|
|
1947
|
+
(`/chat` does, to answer 409 before streaming); otherwise it is taken
|
|
1948
|
+
here and a busy thread yields a single `thread_busy` error event. The
|
|
1949
|
+
lock is released when the run ends, however it ends.
|
|
1950
|
+
|
|
1951
|
+
The run is recorded (`running`) before it starts: a run that cannot be
|
|
1952
|
+
recorded does not start (an `unavailable` error event). A new message
|
|
1953
|
+
on a thread with a pending approval does not start either (an
|
|
1954
|
+
`approval_pending` error event). With `resume` (from `decide()`) the
|
|
1955
|
+
paused run continues with the decision instead of a new message, and
|
|
1956
|
+
`principal` is the requester it acts as. A run that pauses for approval
|
|
1957
|
+
ends with `message.end` status `awaiting_approval` and the approvals.
|
|
1958
|
+
"""
|
|
1959
|
+
if lease is None:
|
|
1960
|
+
try:
|
|
1961
|
+
lease = await self.acquire_thread(thread_id, principal)
|
|
1962
|
+
except ThreadBusy:
|
|
1963
|
+
yield EVENT_ERROR, thread_busy_error()
|
|
1964
|
+
return
|
|
1965
|
+
except HTTPException as exc: # not the owner any more, or the database is down
|
|
1966
|
+
code = CODE_FORBIDDEN if exc.status_code == 403 else CODE_UNAVAILABLE
|
|
1967
|
+
yield EVENT_ERROR, {"code": code, "message": str(exc.detail)}
|
|
1968
|
+
return
|
|
1969
|
+
if resume is None:
|
|
1970
|
+
try:
|
|
1971
|
+
await self.assert_no_pending_approval(thread_id)
|
|
1972
|
+
except (ApprovalPending, HTTPException) as exc:
|
|
1973
|
+
await lease.release()
|
|
1974
|
+
if isinstance(exc, ApprovalPending):
|
|
1975
|
+
yield (
|
|
1976
|
+
EVENT_ERROR,
|
|
1977
|
+
{
|
|
1978
|
+
"code": CODE_APPROVAL_PENDING,
|
|
1979
|
+
"message": str(exc),
|
|
1980
|
+
"approvals": exc.approvals,
|
|
1981
|
+
},
|
|
1982
|
+
)
|
|
1983
|
+
else:
|
|
1984
|
+
yield EVENT_ERROR, {"code": CODE_UNAVAILABLE, "message": str(exc.detail)}
|
|
1985
|
+
return
|
|
1986
|
+
except BaseException:
|
|
1987
|
+
await lease.release()
|
|
1988
|
+
raise
|
|
1989
|
+
run_id = str(uuid.uuid4())
|
|
1990
|
+
actor = principal.actor.id if principal.actor is not None else None
|
|
1991
|
+
bind_log_context(
|
|
1992
|
+
run_id=run_id, thread_id=thread_id, principal_hash=principal.hashed_id(), actor=actor
|
|
1993
|
+
)
|
|
1994
|
+
record = RunRecord(
|
|
1995
|
+
run_id=run_id,
|
|
1996
|
+
thread_id=thread_id,
|
|
1997
|
+
principal_hash=principal.hashed_id(),
|
|
1998
|
+
model=model_label(),
|
|
1999
|
+
status=STATUS_OK,
|
|
2000
|
+
metadata=dict(req.metadata) or None,
|
|
2001
|
+
actor=actor,
|
|
2002
|
+
)
|
|
2003
|
+
try:
|
|
2004
|
+
if self.runs is not None:
|
|
2005
|
+
async with asyncio.timeout(FINISH_STEP_TIMEOUT_S):
|
|
2006
|
+
await self.runs.start(record)
|
|
2007
|
+
except Exception as exc:
|
|
2008
|
+
# Not recorded, not run: every run that starts leaves a record.
|
|
2009
|
+
await lease.release()
|
|
2010
|
+
error_id = new_error_id()
|
|
2011
|
+
logger.log(
|
|
2012
|
+
logging.WARNING if is_database_unavailable(exc) else logging.ERROR,
|
|
2013
|
+
"run not started: its run record could not be written (error_id=%s): %s: %s",
|
|
2014
|
+
error_id,
|
|
2015
|
+
type(exc).__name__,
|
|
2016
|
+
_first_line(exc),
|
|
2017
|
+
exc_info=None if is_database_unavailable(exc) else exc,
|
|
2018
|
+
)
|
|
2019
|
+
yield (
|
|
2020
|
+
EVENT_ERROR,
|
|
2021
|
+
{
|
|
2022
|
+
"code": CODE_UNAVAILABLE,
|
|
2023
|
+
"message": f"The run could not start: the database is unavailable. "
|
|
2024
|
+
f"Reference: {error_id}.",
|
|
2025
|
+
"error_id": error_id,
|
|
2026
|
+
"run_id": run_id,
|
|
2027
|
+
},
|
|
2028
|
+
)
|
|
2029
|
+
return
|
|
2030
|
+
loop = asyncio.get_running_loop()
|
|
2031
|
+
started = time.perf_counter()
|
|
2032
|
+
deadline = loop.time() + run_timeout_s()
|
|
2033
|
+
heartbeat = sse_heartbeat_s()
|
|
2034
|
+
state = _RunState()
|
|
2035
|
+
status = STATUS_OK
|
|
2036
|
+
error: BaseException | None = None
|
|
2037
|
+
error_event: dict[str, Any] | None = None
|
|
2038
|
+
final_text: str | None = None
|
|
2039
|
+
# A structured run's answer (the object) and its JSON text, the run's reply.
|
|
2040
|
+
answer: Any = None
|
|
2041
|
+
answer_json: str | None = None
|
|
2042
|
+
pump: _Pump | None = None
|
|
2043
|
+
paused: list[ApprovalRecord] = []
|
|
2044
|
+
# A resumed run's reply, for the A2A tasks that waited on its approval.
|
|
2045
|
+
reply: list[str] = []
|
|
2046
|
+
metrics.ACTIVE_RUNS.inc()
|
|
2047
|
+
try:
|
|
2048
|
+
start: dict[str, Any] = {"thread_id": thread_id, "run_id": run_id}
|
|
2049
|
+
if resume is not None:
|
|
2050
|
+
start.update(approval_id=resume.approval.approval_id, decision=resume.decision)
|
|
2051
|
+
yield EVENT_START, start
|
|
2052
|
+
lease.check()
|
|
2053
|
+
schema = response_schema()
|
|
2054
|
+
state.structured_mode = schema is not None
|
|
2055
|
+
if self.runtime == LANGGRAPH_SERVER:
|
|
2056
|
+
source = self._server_events(principal, req, thread_id, run_id, state, resume)
|
|
2057
|
+
else:
|
|
2058
|
+
source = self._local_events(principal, req, thread_id, run_id, resume)
|
|
2059
|
+
pump = _Pump(source)
|
|
2060
|
+
# A lease lost mid-run (this replica cannot confirm it still owns
|
|
2061
|
+
# the thread) stops the run now, not at its next write.
|
|
2062
|
+
lease.on_lost(lambda: pump.interrupt(LeaseLost(thread_id, lease.lost_reason or "lost")))
|
|
2063
|
+
while True:
|
|
2064
|
+
remaining = deadline - loop.time()
|
|
2065
|
+
if remaining <= 0:
|
|
2066
|
+
raise RunTimeout()
|
|
2067
|
+
kind, payload = await pump.next(min(heartbeat, remaining))
|
|
2068
|
+
if kind == _IDLE:
|
|
2069
|
+
if loop.time() >= deadline:
|
|
2070
|
+
raise RunTimeout()
|
|
2071
|
+
yield EVENT_HEARTBEAT, {}
|
|
2072
|
+
continue
|
|
2073
|
+
if kind == _DONE:
|
|
2074
|
+
break
|
|
2075
|
+
if kind == _FAILED:
|
|
2076
|
+
raise payload
|
|
2077
|
+
mode, data = payload
|
|
2078
|
+
for event in map_stream_item(mode, data, state):
|
|
2079
|
+
if resume is not None and event[0] == EVENT_DELTA:
|
|
2080
|
+
reply.append(str(event[1].get("text") or ""))
|
|
2081
|
+
yield event
|
|
2082
|
+
if state.interrupts:
|
|
2083
|
+
# The graph paused. A gated API call's interrupt waits for a
|
|
2084
|
+
# decision (recorded before the lock is released, so no new
|
|
2085
|
+
# message can slip in first); any other kind cannot be answered here.
|
|
2086
|
+
lease.check()
|
|
2087
|
+
if all(is_approval_interrupt(i["value"]) for i in state.interrupts):
|
|
2088
|
+
paused = await self._record_approvals(
|
|
2089
|
+
principal, thread_id, run_id, state.interrupts
|
|
2090
|
+
)
|
|
2091
|
+
status = STATUS_AWAITING_APPROVAL
|
|
2092
|
+
else:
|
|
2093
|
+
status = STATUS_ERROR
|
|
2094
|
+
error_event = {
|
|
2095
|
+
"code": CODE_UNSUPPORTED_INTERRUPT,
|
|
2096
|
+
"message": UNSUPPORTED_INTERRUPT_MESSAGE,
|
|
2097
|
+
"run_id": run_id,
|
|
2098
|
+
}
|
|
2099
|
+
logger.warning("run paused for input this server cannot collect")
|
|
2100
|
+
elif state.structured_mode:
|
|
2101
|
+
if state.structured is None:
|
|
2102
|
+
status = STATUS_ERROR
|
|
2103
|
+
error_event = self._no_answer_event(run_id)
|
|
2104
|
+
else:
|
|
2105
|
+
# A lone surrogate cannot go out (UTF-8, protobuf): sent as U+FFFD.
|
|
2106
|
+
answer = valid_value(state.structured)
|
|
2107
|
+
# `StructuredAnswer` checks every answer before the graph takes it: one
|
|
2108
|
+
# that reaches delivery and does not fit is refused, never sent.
|
|
2109
|
+
unfit = validate(schema, answer)
|
|
2110
|
+
if unfit:
|
|
2111
|
+
status = STATUS_ERROR
|
|
2112
|
+
error_event = self._unfit_answer_event(run_id, unfit)
|
|
2113
|
+
answer = None
|
|
2114
|
+
else:
|
|
2115
|
+
answer_json = answer_text(answer)
|
|
2116
|
+
state.text = [answer_json]
|
|
2117
|
+
except RunTimeout:
|
|
2118
|
+
status = STATUS_TIMEOUT
|
|
2119
|
+
error_id = new_error_id()
|
|
2120
|
+
error_event = {
|
|
2121
|
+
"code": CODE_TIMEOUT,
|
|
2122
|
+
"message": f"The run took longer than {run_timeout_s():g} s and was cancelled. "
|
|
2123
|
+
f"Reference: {error_id}.",
|
|
2124
|
+
"error_id": error_id,
|
|
2125
|
+
"run_id": run_id,
|
|
2126
|
+
}
|
|
2127
|
+
logger.warning("run timed out (error_id=%s)", error_id)
|
|
2128
|
+
except (asyncio.CancelledError, GeneratorExit):
|
|
2129
|
+
status = STATUS_CANCELLED
|
|
2130
|
+
logger.info("run cancelled: the client went away")
|
|
2131
|
+
raise
|
|
2132
|
+
except Exception as exc:
|
|
2133
|
+
error = exc
|
|
2134
|
+
if _lease_lost(exc) is not None:
|
|
2135
|
+
status = STATUS_INTERRUPTED
|
|
2136
|
+
elif _is_recursion_error(exc):
|
|
2137
|
+
if pump is not None:
|
|
2138
|
+
await pump.close() # the graph has stopped; make sure before writing
|
|
2139
|
+
final_text = await self._end_at_step_limit(req, thread_id, lease)
|
|
2140
|
+
status = STATUS_STEP_LIMIT if final_text is not None else STATUS_ERROR
|
|
2141
|
+
else:
|
|
2142
|
+
status = STATUS_ERROR
|
|
2143
|
+
if final_text is None:
|
|
2144
|
+
error_event = self._error_event(exc, run_id)
|
|
2145
|
+
finally:
|
|
2146
|
+
if pump is not None:
|
|
2147
|
+
pump.cancel()
|
|
2148
|
+
latency_ms = int((time.perf_counter() - started) * 1000)
|
|
2149
|
+
metrics.ACTIVE_RUNS.dec()
|
|
2150
|
+
metrics.observe_run(status, latency_ms / 1000, state.input_tokens, state.output_tokens)
|
|
2151
|
+
# The run's outbound-API call counts (limits.max_calls_per_run) go with it.
|
|
2152
|
+
end_api_run(run_id)
|
|
2153
|
+
finish = asyncio.ensure_future(
|
|
2154
|
+
self._finish_run(
|
|
2155
|
+
principal,
|
|
2156
|
+
req,
|
|
2157
|
+
record,
|
|
2158
|
+
state,
|
|
2159
|
+
status,
|
|
2160
|
+
error,
|
|
2161
|
+
latency_ms,
|
|
2162
|
+
pump,
|
|
2163
|
+
lease,
|
|
2164
|
+
resumed=resume is not None,
|
|
2165
|
+
)
|
|
2166
|
+
)
|
|
2167
|
+
# Shielded: the record and the lock release complete even when the
|
|
2168
|
+
# consumer is cancelled again while waiting.
|
|
2169
|
+
await asyncio.shield(finish)
|
|
2170
|
+
if resume is not None:
|
|
2171
|
+
await self._resumed_outcome(
|
|
2172
|
+
thread_id,
|
|
2173
|
+
resume,
|
|
2174
|
+
status,
|
|
2175
|
+
paused,
|
|
2176
|
+
"".join(reply) + (final_text or ""),
|
|
2177
|
+
answer if answer_json is not None else None,
|
|
2178
|
+
)
|
|
2179
|
+
if error_event is not None:
|
|
2180
|
+
yield EVENT_ERROR, error_event
|
|
2181
|
+
return
|
|
2182
|
+
if final_text is not None:
|
|
2183
|
+
yield EVENT_DELTA, {"text": final_text}
|
|
2184
|
+
if answer_json is not None:
|
|
2185
|
+
yield EVENT_DELTA, {"text": answer_json}
|
|
2186
|
+
end: dict[str, Any] = {
|
|
2187
|
+
"thread_id": thread_id,
|
|
2188
|
+
"run_id": run_id,
|
|
2189
|
+
"usage": {
|
|
2190
|
+
"input_tokens": state.input_tokens,
|
|
2191
|
+
"output_tokens": state.output_tokens,
|
|
2192
|
+
},
|
|
2193
|
+
"latency_ms": latency_ms,
|
|
2194
|
+
"status": status,
|
|
2195
|
+
}
|
|
2196
|
+
if paused:
|
|
2197
|
+
# The requester sees what it is asked to approve (it asked for it).
|
|
2198
|
+
end["approval"] = paused[0].public()
|
|
2199
|
+
end["approvals"] = [record.public() for record in paused]
|
|
2200
|
+
if answer_json is not None:
|
|
2201
|
+
end[STRUCTURED_KEY] = answer
|
|
2202
|
+
yield EVENT_END, end
|
|
2203
|
+
|
|
2204
|
+
@staticmethod
|
|
2205
|
+
def _no_answer_event(run_id: str) -> dict[str, Any]:
|
|
2206
|
+
"""A structured run that ended with no answer (the graph gave no `structured_response`)."""
|
|
2207
|
+
error_id = new_error_id()
|
|
2208
|
+
logger.warning(
|
|
2209
|
+
"run ended without a structured answer: the project has a response schema, but the "
|
|
2210
|
+
"graph gave no structured_response (is agent.py built with response_format()?) "
|
|
2211
|
+
"(error_id=%s)",
|
|
2212
|
+
error_id,
|
|
2213
|
+
)
|
|
2214
|
+
return {
|
|
2215
|
+
"code": CODE_INVALID_STRUCTURED_RESPONSE,
|
|
2216
|
+
"message": "The run ended without an answer in the shape of the response schema. "
|
|
2217
|
+
f"Reference: {error_id}.",
|
|
2218
|
+
"error_id": error_id,
|
|
2219
|
+
"run_id": run_id,
|
|
2220
|
+
}
|
|
2221
|
+
|
|
2222
|
+
@staticmethod
|
|
2223
|
+
def _unfit_answer_event(run_id: str, problems: list[str]) -> dict[str, Any]:
|
|
2224
|
+
"""A structured run whose answer does not fit the schema when it is delivered.
|
|
2225
|
+
|
|
2226
|
+
`StructuredAnswer` sends such an answer back to the model; one that gets
|
|
2227
|
+
here was never checked (agent.py's `middleware()` has no
|
|
2228
|
+
`StructuredAnswer()`), so the run fails rather than deliver it.
|
|
2229
|
+
"""
|
|
2230
|
+
error_id = new_error_id()
|
|
2231
|
+
logger.warning(
|
|
2232
|
+
"run failed: its answer does not fit the response schema and was never checked "
|
|
2233
|
+
"(is StructuredAnswer() last in agent.py's middleware()?) (error_id=%s): %s",
|
|
2234
|
+
error_id,
|
|
2235
|
+
"; ".join(problems[:MAX_REPORTED_PROBLEMS]),
|
|
2236
|
+
)
|
|
2237
|
+
return {
|
|
2238
|
+
"code": CODE_INVALID_STRUCTURED_RESPONSE,
|
|
2239
|
+
"message": "The run failed: the agent's answer did not fit the response schema. "
|
|
2240
|
+
f"Reference: {error_id}.",
|
|
2241
|
+
"error_id": error_id,
|
|
2242
|
+
"run_id": run_id,
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
def _error_event(self, exc: BaseException, run_id: str) -> dict[str, Any]:
|
|
2246
|
+
"""The client-facing error: a code and a generic message; the detail is logged."""
|
|
2247
|
+
if isinstance(exc, ThreadBusy) or (
|
|
2248
|
+
http_status(exc) == 409 and self.runtime == LANGGRAPH_SERVER
|
|
2249
|
+
):
|
|
2250
|
+
logger.info("run refused: the thread is busy")
|
|
2251
|
+
return {**thread_busy_error(), "run_id": run_id}
|
|
2252
|
+
error_id = new_error_id()
|
|
2253
|
+
lost = _lease_lost(exc)
|
|
2254
|
+
if _is_recursion_error(exc):
|
|
2255
|
+
logger.warning("run reached the recursion limit (error_id=%s)", error_id)
|
|
2256
|
+
event: dict[str, Any] = {
|
|
2257
|
+
"code": CODE_RECURSION,
|
|
2258
|
+
"message": f"The run reached the step limit ({recursion_limit()} steps) and "
|
|
2259
|
+
f"was stopped. Reference: {error_id}.",
|
|
2260
|
+
}
|
|
2261
|
+
elif _is_structured_answer_error(exc):
|
|
2262
|
+
logger.warning(
|
|
2263
|
+
"run failed: no answer fitted the response schema (error_id=%s): %s",
|
|
2264
|
+
error_id,
|
|
2265
|
+
_first_line(exc),
|
|
2266
|
+
)
|
|
2267
|
+
event = {
|
|
2268
|
+
"code": CODE_INVALID_STRUCTURED_RESPONSE,
|
|
2269
|
+
"message": "The run failed: the agent's answer did not fit the response schema "
|
|
2270
|
+
f"in {MAX_ANSWER_ATTEMPTS} tries. Reference: {error_id}.",
|
|
2271
|
+
}
|
|
2272
|
+
elif lost is not None:
|
|
2273
|
+
logger.warning("run stopped (error_id=%s): %s", error_id, lost)
|
|
2274
|
+
event = {
|
|
2275
|
+
"code": CODE_UNAVAILABLE,
|
|
2276
|
+
"message": "The run was stopped: it could no longer confirm it was the only "
|
|
2277
|
+
f"run on this thread. Reference: {error_id}.",
|
|
2278
|
+
}
|
|
2279
|
+
elif is_database_unavailable(exc):
|
|
2280
|
+
logger.warning(
|
|
2281
|
+
"run failed: the database is unavailable (error_id=%s): %s: %s",
|
|
2282
|
+
error_id,
|
|
2283
|
+
type(exc).__name__,
|
|
2284
|
+
_first_line(exc),
|
|
2285
|
+
)
|
|
2286
|
+
event = {
|
|
2287
|
+
"code": CODE_UNAVAILABLE,
|
|
2288
|
+
"message": f"The run failed: the database is unavailable. Reference: {error_id}.",
|
|
2289
|
+
}
|
|
2290
|
+
else:
|
|
2291
|
+
logger.error("run failed (error_id=%s)", error_id, exc_info=exc)
|
|
2292
|
+
event = {
|
|
2293
|
+
"code": CODE_RUN_FAILED,
|
|
2294
|
+
"message": f"The run failed. Reference: {error_id}.",
|
|
2295
|
+
}
|
|
2296
|
+
event.update({"error_id": error_id, "run_id": run_id})
|
|
2297
|
+
if dev_mode():
|
|
2298
|
+
event["detail"] = f"{type(exc).__name__}: {exc}"
|
|
2299
|
+
return event
|
|
2300
|
+
|
|
2301
|
+
async def _finish_run(
|
|
2302
|
+
self,
|
|
2303
|
+
principal: Principal,
|
|
2304
|
+
req: ChatRequest,
|
|
2305
|
+
record: RunRecord,
|
|
2306
|
+
state: _RunState,
|
|
2307
|
+
status: str,
|
|
2308
|
+
error: BaseException | None,
|
|
2309
|
+
latency_ms: int,
|
|
2310
|
+
pump: _Pump | None,
|
|
2311
|
+
lease: ThreadLease,
|
|
2312
|
+
*,
|
|
2313
|
+
resumed: bool = False,
|
|
2314
|
+
) -> None:
|
|
2315
|
+
"""Bookkeeping after a run, each step bounded: the client's last event is not held up.
|
|
2316
|
+
|
|
2317
|
+
`resumed`: the run continued a paused one with a decision (`decide()`).
|
|
2318
|
+
"""
|
|
2319
|
+
thread_id = record.thread_id
|
|
2320
|
+
try:
|
|
2321
|
+
if pump is not None and not await pump.close(PUMP_STOP_WAIT_S):
|
|
2322
|
+
# Still stopping (it waits for writes in flight, which the lease
|
|
2323
|
+
# fence refuses): the thread stays busy until it has stopped,
|
|
2324
|
+
# but the client need not wait for it.
|
|
2325
|
+
lease.hold_until(pump.task)
|
|
2326
|
+
if (
|
|
2327
|
+
status in (STATUS_TIMEOUT, STATUS_CANCELLED, STATUS_INTERRUPTED)
|
|
2328
|
+
and self.runtime == LANGGRAPH_SERVER
|
|
2329
|
+
):
|
|
2330
|
+
await self._cancel_server_run(req, thread_id, state)
|
|
2331
|
+
jobs = [self._record_run(principal, req, record, state, status, error, latency_ms)]
|
|
2332
|
+
stopped = pump is None or pump.task.done()
|
|
2333
|
+
# A graph still stopping could write after the repair: the next run repairs then.
|
|
2334
|
+
if stopped and self._repairs_after(status, error, lease):
|
|
2335
|
+
reason = f"The tool call did not finish: the run stopped ({status})."
|
|
2336
|
+
jobs.append(self._close_dangling_tool_calls(req, thread_id, reason))
|
|
2337
|
+
await asyncio.gather(*jobs)
|
|
2338
|
+
if stopped and not lease.lost:
|
|
2339
|
+
if status not in (STATUS_OK, STATUS_AWAITING_APPROVAL):
|
|
2340
|
+
await self._expire_orphaned_approvals(req, thread_id, record.run_id)
|
|
2341
|
+
elif resumed:
|
|
2342
|
+
# A resumed call a later denial (or narrower policy) refused
|
|
2343
|
+
# did not pause again: its approval waits for nothing now.
|
|
2344
|
+
await self._expire_orphaned_approvals(req, thread_id)
|
|
2345
|
+
finally:
|
|
2346
|
+
await lease.release()
|
|
2347
|
+
logger.info(
|
|
2348
|
+
"run finished",
|
|
2349
|
+
extra={"status": status, "latency_ms": latency_ms, "run_id": record.run_id},
|
|
2350
|
+
)
|
|
2351
|
+
|
|
2352
|
+
async def _expire_orphaned_approvals(
|
|
2353
|
+
self, req: ChatRequest, thread_id: str, run_id: str | None = None
|
|
2354
|
+
) -> None:
|
|
2355
|
+
"""Expire the thread's pending approvals that nothing waits for (best effort).
|
|
2356
|
+
|
|
2357
|
+
A run that ends without pausing (cancelled, failed, timed out) can
|
|
2358
|
+
leave pending approvals behind: ones it recorded before it was cut
|
|
2359
|
+
short (its client never saw them: `run_id`, the stopped run), or ones
|
|
2360
|
+
of the paused run it resumed whose calls the repair has since
|
|
2361
|
+
answered. A resumed run that ends (paused again or not) can too: a
|
|
2362
|
+
sibling call whose approval was still pending runs again on the
|
|
2363
|
+
resume and, when the policy now refuses it (a denial added, or
|
|
2364
|
+
narrower `allowed_methods`), ends with that error instead of pausing
|
|
2365
|
+
again for its approval. Left pending, they would refuse the thread's
|
|
2366
|
+
next message (409 `approval_pending`) until they expire; expired, the
|
|
2367
|
+
next message goes through and the history repair tells the model the
|
|
2368
|
+
call was not approved.
|
|
2369
|
+
"""
|
|
2370
|
+
if self.approvals is None or (self.db is not None and not self.db.health.up):
|
|
2371
|
+
return
|
|
2372
|
+
expired: list[ApprovalRecord] = []
|
|
2373
|
+
try:
|
|
2374
|
+
async with asyncio.timeout(FINISH_STEP_TIMEOUT_S):
|
|
2375
|
+
pending = await self.approvals.pending_for_thread(thread_id)
|
|
2376
|
+
if not pending:
|
|
2377
|
+
return
|
|
2378
|
+
waiting = await self._paused_interrupts(thread_id, req.forward_headers)
|
|
2379
|
+
for item in pending:
|
|
2380
|
+
if (run_id is not None and item.run_id == run_id) or (
|
|
2381
|
+
item.interrupt_id not in waiting
|
|
2382
|
+
):
|
|
2383
|
+
gone = await self.approvals.expire(item.approval_id)
|
|
2384
|
+
if gone is not None:
|
|
2385
|
+
expired.append(gone)
|
|
2386
|
+
except Exception as exc:
|
|
2387
|
+
logger.warning(
|
|
2388
|
+
"could not expire the approvals of a stopped run (%s); they expire on time",
|
|
2389
|
+
type(exc).__name__,
|
|
2390
|
+
)
|
|
2391
|
+
if expired:
|
|
2392
|
+
metrics.observe_approvals("expired", len(expired))
|
|
2393
|
+
logger.info("expired %d approval(s) the thread no longer waits for", len(expired))
|
|
2394
|
+
await self._approvals_expired(expired)
|
|
2395
|
+
|
|
2396
|
+
def _repairs_after(self, status: str, error: BaseException | None, lease: ThreadLease) -> bool:
|
|
2397
|
+
"""Whether a stopped run should answer the tool calls it left open.
|
|
2398
|
+
|
|
2399
|
+
Not after a clean end, not after a pause for approval (the open call
|
|
2400
|
+
waits for its decision), not when the run never started (the thread
|
|
2401
|
+
was busy: the open calls belong to the run in progress) and not when
|
|
2402
|
+
this process no longer owns the thread.
|
|
2403
|
+
"""
|
|
2404
|
+
if (
|
|
2405
|
+
status in (STATUS_OK, STATUS_AWAITING_APPROVAL, STATUS_STEP_LIMIT, STATUS_INTERRUPTED)
|
|
2406
|
+
or lease.lost
|
|
2407
|
+
):
|
|
2408
|
+
return False
|
|
2409
|
+
if error is None:
|
|
2410
|
+
return True
|
|
2411
|
+
return not (isinstance(error, ThreadBusy) or http_status(error) == 409)
|
|
2412
|
+
|
|
2413
|
+
async def _close_dangling_tool_calls(
|
|
2414
|
+
self, req: ChatRequest, thread_id: str, reason: str
|
|
2415
|
+
) -> None:
|
|
2416
|
+
"""Answer the tool calls a stopped run left open, so the thread stays usable (best effort).
|
|
2417
|
+
|
|
2418
|
+
A run cut short between the model's tool call and the tool's result
|
|
2419
|
+
leaves an assistant message whose tool calls have no results; model
|
|
2420
|
+
providers reject such a history on the next turn. Each open call gets
|
|
2421
|
+
an error result right after it. The next run repairs anything this
|
|
2422
|
+
misses (for example when the database is down right now).
|
|
2423
|
+
"""
|
|
2424
|
+
if self.db is not None and not self.db.health.up:
|
|
2425
|
+
logger.info("the database is down: the next run answers the open tool calls")
|
|
2426
|
+
return
|
|
2427
|
+
try:
|
|
2428
|
+
async with asyncio.timeout(FINISH_STEP_TIMEOUT_S):
|
|
2429
|
+
closed = await self._repair_history(req, thread_id, reason)
|
|
2430
|
+
except Exception as exc:
|
|
2431
|
+
logger.warning(
|
|
2432
|
+
"could not close the tool calls of a stopped run (%s); the next run will",
|
|
2433
|
+
type(exc).__name__,
|
|
2434
|
+
)
|
|
2435
|
+
return
|
|
2436
|
+
if closed:
|
|
2437
|
+
logger.info("closed %d tool calls left open by a stopped run", closed)
|
|
2438
|
+
|
|
2439
|
+
async def _repair_history(
|
|
2440
|
+
self,
|
|
2441
|
+
req: ChatRequest,
|
|
2442
|
+
thread_id: str,
|
|
2443
|
+
reason: str,
|
|
2444
|
+
final_text: str | None = None,
|
|
2445
|
+
texts: Mapping[str, str] | None = None,
|
|
2446
|
+
) -> int:
|
|
2447
|
+
"""Put the thread's tool-call history right, then add `final_text` as the last reply.
|
|
2448
|
+
|
|
2449
|
+
Every tool call gets its result right after it (an error result saying
|
|
2450
|
+
`reason` when it has none, `texts[call id]` for a call named there,
|
|
2451
|
+
or that the arguments were not valid JSON for a call whose arguments
|
|
2452
|
+
did not parse) and results that answer no call go; see
|
|
2453
|
+
`repair_tool_history`. Only the owner of the thread's run lease writes
|
|
2454
|
+
(fastapi: the checkpointer's fence). Returns how many open calls got a
|
|
2455
|
+
result. Under langgraph-server a thread with a run in progress (started
|
|
2456
|
+
through the server's own API) is left alone.
|
|
2457
|
+
"""
|
|
2458
|
+
if self.runtime == LANGGRAPH_SERVER:
|
|
2459
|
+
return await self._server_repair_history(req, thread_id, reason, final_text, texts)
|
|
2460
|
+
from langchain_core.messages import AIMessage, RemoveMessage, ToolMessage
|
|
2461
|
+
from langgraph.constants import END
|
|
2462
|
+
|
|
2463
|
+
from {{cookiecutter.agent_directory}}.agent import graph
|
|
2464
|
+
|
|
2465
|
+
def error_result(call: Any) -> Any:
|
|
2466
|
+
return ToolMessage(
|
|
2467
|
+
content=open_call_result_text(call, (texts or {}).get(_call_id(call), reason)),
|
|
2468
|
+
tool_call_id=str(_get(call, "id") or ""),
|
|
2469
|
+
name=str(_get(call, "name") or ""),
|
|
2470
|
+
status="error",
|
|
2471
|
+
id=str(uuid.uuid4()),
|
|
2472
|
+
)
|
|
2473
|
+
|
|
2474
|
+
config = {"configurable": {"thread_id": thread_id}}
|
|
2475
|
+
snapshot = await graph.aget_state(config)
|
|
2476
|
+
if snapshot.tasks:
|
|
2477
|
+
# A step that never finished (the process stopped mid-step): the
|
|
2478
|
+
# state shown includes what its tasks already wrote, but an update
|
|
2479
|
+
# is applied to the last checkpoint without them. Make them part
|
|
2480
|
+
# of the thread first, as the next run's input would.
|
|
2481
|
+
await graph.aupdate_state(config, None, as_node=END)
|
|
2482
|
+
snapshot = await graph.aget_state(config)
|
|
2483
|
+
messages = list((snapshot.values or {}).get("messages") or [])
|
|
2484
|
+
repair = repair_tool_history(messages, error_result)
|
|
2485
|
+
update: list[Any] = []
|
|
2486
|
+
if repair is not None:
|
|
2487
|
+
update = (
|
|
2488
|
+
list(repair.added)
|
|
2489
|
+
if repair.append_only
|
|
2490
|
+
else [RemoveMessage(id=REMOVE_ALL_MESSAGES), *repair.messages]
|
|
2491
|
+
)
|
|
2492
|
+
if final_text is not None:
|
|
2493
|
+
update.append(AIMessage(content=final_text, id=str(uuid.uuid4())))
|
|
2494
|
+
if not update:
|
|
2495
|
+
return 0
|
|
2496
|
+
if final_text is not None and "model" in graph.nodes:
|
|
2497
|
+
as_node: str | None = "model"
|
|
2498
|
+
else:
|
|
2499
|
+
as_node = "tools" if "tools" in graph.nodes else None
|
|
2500
|
+
await graph.aupdate_state(config, {"messages": update}, as_node=as_node)
|
|
2501
|
+
if repair is not None and not repair.append_only:
|
|
2502
|
+
logger.warning("moved misplaced tool results back after their calls")
|
|
2503
|
+
return len(repair.added) if repair is not None else 0
|
|
2504
|
+
|
|
2505
|
+
async def _server_repair_history(
|
|
2506
|
+
self,
|
|
2507
|
+
req: ChatRequest,
|
|
2508
|
+
thread_id: str,
|
|
2509
|
+
reason: str,
|
|
2510
|
+
final_text: str | None,
|
|
2511
|
+
texts: Mapping[str, str] | None = None,
|
|
2512
|
+
) -> int:
|
|
2513
|
+
client = self._sdk_client(req.forward_headers)
|
|
2514
|
+
thread = await client.threads.get(thread_id)
|
|
2515
|
+
if final_text is not None and _server_busy(thread):
|
|
2516
|
+
# Ending our own run at the step limit: the server sends the run's
|
|
2517
|
+
# error before it marks the run done and the thread idle.
|
|
2518
|
+
thread = await self._server_wait_idle(client, thread_id, SERVER_IDLE_WAIT_S)
|
|
2519
|
+
if _server_busy(thread):
|
|
2520
|
+
# A run owns the thread (one started through the server's own API,
|
|
2521
|
+
# or ours still winding down): its open tool calls are its own.
|
|
2522
|
+
if final_text is not None:
|
|
2523
|
+
raise ThreadBusy(thread_id)
|
|
2524
|
+
return 0
|
|
2525
|
+
snapshot = await client.threads.get_state(thread_id)
|
|
2526
|
+
if isinstance(snapshot, Mapping) and snapshot.get("tasks"):
|
|
2527
|
+
# Writes of a step that never finished: part of the thread first
|
|
2528
|
+
# (see `_repair_history`).
|
|
2529
|
+
await client.threads.update_state(thread_id, None, as_node="__end__")
|
|
2530
|
+
snapshot = await client.threads.get_state(thread_id)
|
|
2531
|
+
values = snapshot.get("values") if isinstance(snapshot, Mapping) else None
|
|
2532
|
+
messages = list((values or {}).get("messages") or [])
|
|
2533
|
+
|
|
2534
|
+
def error_result(call: Any) -> dict[str, Any]:
|
|
2535
|
+
return {
|
|
2536
|
+
"type": "tool",
|
|
2537
|
+
"content": open_call_result_text(call, (texts or {}).get(_call_id(call), reason)),
|
|
2538
|
+
"tool_call_id": str(_get(call, "id") or ""),
|
|
2539
|
+
"name": str(_get(call, "name") or ""),
|
|
2540
|
+
"status": "error",
|
|
2541
|
+
"id": str(uuid.uuid4()),
|
|
2542
|
+
}
|
|
2543
|
+
|
|
2544
|
+
repair = repair_tool_history(messages, error_result)
|
|
2545
|
+
update: list[Any] = []
|
|
2546
|
+
if repair is not None:
|
|
2547
|
+
update = (
|
|
2548
|
+
list(repair.added)
|
|
2549
|
+
if repair.append_only
|
|
2550
|
+
else [
|
|
2551
|
+
# The server turns each dict back into a message and needs a
|
|
2552
|
+
# `content` on every one, a RemoveMessage's included.
|
|
2553
|
+
{"type": "remove", "id": REMOVE_ALL_MESSAGES, "content": ""},
|
|
2554
|
+
*(_server_message(m) for m in repair.messages),
|
|
2555
|
+
]
|
|
2556
|
+
)
|
|
2557
|
+
if final_text is not None:
|
|
2558
|
+
update.append({"type": "ai", "content": final_text, "id": str(uuid.uuid4())})
|
|
2559
|
+
if not update:
|
|
2560
|
+
return 0
|
|
2561
|
+
as_node = "model" if final_text is not None else "tools"
|
|
2562
|
+
await client.threads.update_state(thread_id, {"messages": update}, as_node=as_node)
|
|
2563
|
+
return len(repair.added) if repair is not None else 0
|
|
2564
|
+
|
|
2565
|
+
async def _server_wait_idle(self, client: Any, thread_id: str, timeout_s: float) -> Any:
|
|
2566
|
+
"""The server thread once it is no longer busy, or as it is after `timeout_s`."""
|
|
2567
|
+
deadline = time.monotonic() + timeout_s
|
|
2568
|
+
thread = await client.threads.get(thread_id)
|
|
2569
|
+
while _server_busy(thread) and time.monotonic() < deadline:
|
|
2570
|
+
await asyncio.sleep(0.1)
|
|
2571
|
+
thread = await client.threads.get(thread_id)
|
|
2572
|
+
return thread
|
|
2573
|
+
|
|
2574
|
+
async def _end_at_step_limit(
|
|
2575
|
+
self, req: ChatRequest, thread_id: str, lease: ThreadLease
|
|
2576
|
+
) -> str | None:
|
|
2577
|
+
"""End a run that reached `RECURSION_LIMIT` with a final reply saying so.
|
|
2578
|
+
|
|
2579
|
+
The thread keeps everything the run did (its open tool calls get an
|
|
2580
|
+
error result), then the reply; the client gets the reply and
|
|
2581
|
+
`message.end` with status `step_limit`. None when that cannot be
|
|
2582
|
+
written: the run then ends with the `recursion_limit` error.
|
|
2583
|
+
"""
|
|
2584
|
+
text = STEP_LIMIT_MESSAGE.format(limit=recursion_limit())
|
|
2585
|
+
try:
|
|
2586
|
+
lease.check()
|
|
2587
|
+
async with asyncio.timeout(FINISH_STEP_TIMEOUT_S):
|
|
2588
|
+
await self._repair_history(req, thread_id, OPEN_CALL_STEP_LIMIT, final_text=text)
|
|
2589
|
+
except Exception as exc:
|
|
2590
|
+
logger.warning("could not end the run with a step-limit reply (%s)", type(exc).__name__)
|
|
2591
|
+
return None
|
|
2592
|
+
logger.warning(
|
|
2593
|
+
"run reached the step limit (%d steps); ended it with a reply", recursion_limit()
|
|
2594
|
+
)
|
|
2595
|
+
return text
|
|
2596
|
+
|
|
2597
|
+
async def _local_events(
|
|
2598
|
+
self,
|
|
2599
|
+
principal: Principal,
|
|
2600
|
+
req: ChatRequest,
|
|
2601
|
+
thread_id: str,
|
|
2602
|
+
run_id: str,
|
|
2603
|
+
resume: Resume | None = None,
|
|
2604
|
+
) -> AsyncIterator[tuple[str, Any]]:
|
|
2605
|
+
from langgraph.types import Command
|
|
2606
|
+
|
|
2607
|
+
from {{cookiecutter.agent_directory}}.agent import AgentContext, graph
|
|
2608
|
+
|
|
2609
|
+
if resume is None:
|
|
2610
|
+
# A run cut short earlier (a crash, an outage) may have left tool calls
|
|
2611
|
+
# without results: answer them before this run appends its turn.
|
|
2612
|
+
# (A resume continues the paused step: its open call is the one decided.)
|
|
2613
|
+
await self._repair_before_run(req, thread_id)
|
|
2614
|
+
config = {
|
|
2615
|
+
"configurable": {"thread_id": thread_id},
|
|
2616
|
+
"run_id": uuid.UUID(run_id),
|
|
2617
|
+
"recursion_limit": recursion_limit(),
|
|
2618
|
+
"metadata": trace_metadata(thread_id, run_id, principal, req.metadata),
|
|
2619
|
+
}
|
|
2620
|
+
# In-process only (never persisted): tools may need the caller's
|
|
2621
|
+
# credentials for `auth: forward` APIs.
|
|
2622
|
+
context = AgentContext(
|
|
2623
|
+
principal_id=principal.id,
|
|
2624
|
+
roles=list(principal.roles),
|
|
2625
|
+
attributes=dict(principal.attributes),
|
|
2626
|
+
)
|
|
2627
|
+
graph_input: Any = (
|
|
2628
|
+
Command(resume=resume.values)
|
|
2629
|
+
if resume is not None
|
|
2630
|
+
else {"messages": [{"role": "user", "content": req.message}]}
|
|
2631
|
+
)
|
|
2632
|
+
async for mode, data in graph.astream(
|
|
2633
|
+
graph_input,
|
|
2634
|
+
config=config,
|
|
2635
|
+
context=context,
|
|
2636
|
+
stream_mode=["messages", "updates"],
|
|
2637
|
+
):
|
|
2638
|
+
yield mode, data
|
|
2639
|
+
|
|
2640
|
+
async def _repair_before_run(self, req: ChatRequest, thread_id: str) -> None:
|
|
2641
|
+
"""Repair the thread's history before a run appends to it.
|
|
2642
|
+
|
|
2643
|
+
Under fastapi a failure fails the run (its database is this app's).
|
|
2644
|
+
Under langgraph-server the repair is best effort: the server may be
|
|
2645
|
+
reached through a client that cannot read the state.
|
|
2646
|
+
"""
|
|
2647
|
+
try:
|
|
2648
|
+
# A call a paused run left waiting for an approval that expired (or
|
|
2649
|
+
# was decided but never resumed) says so in its result.
|
|
2650
|
+
texts = await self._approval_results(thread_id)
|
|
2651
|
+
closed = await self._repair_history(req, thread_id, OPEN_CALL_INTERRUPTED, texts=texts)
|
|
2652
|
+
except Exception as exc:
|
|
2653
|
+
if self.runtime == FASTAPI:
|
|
2654
|
+
raise
|
|
2655
|
+
logger.warning(
|
|
2656
|
+
"could not check the thread's tool calls before the run: %s", type(exc).__name__
|
|
2657
|
+
)
|
|
2658
|
+
return
|
|
2659
|
+
if closed:
|
|
2660
|
+
logger.warning("answered %d tool calls an interrupted run left open", closed)
|
|
2661
|
+
|
|
2662
|
+
async def _record_run(
|
|
2663
|
+
self,
|
|
2664
|
+
principal: Principal,
|
|
2665
|
+
req: ChatRequest,
|
|
2666
|
+
record: RunRecord,
|
|
2667
|
+
state: _RunState,
|
|
2668
|
+
status: str,
|
|
2669
|
+
error: BaseException | None,
|
|
2670
|
+
latency_ms: int,
|
|
2671
|
+
) -> None:
|
|
2672
|
+
if self.runs is None:
|
|
2673
|
+
return
|
|
2674
|
+
payload: dict[str, Any] | None = None
|
|
2675
|
+
if capture_full():
|
|
2676
|
+
payload = {
|
|
2677
|
+
"message": req.message,
|
|
2678
|
+
"response": "".join(state.text),
|
|
2679
|
+
"tool_calls": state.tool_calls,
|
|
2680
|
+
"error": str(error) if error else None,
|
|
2681
|
+
}
|
|
2682
|
+
final = RunRecord(
|
|
2683
|
+
run_id=record.run_id,
|
|
2684
|
+
thread_id=record.thread_id,
|
|
2685
|
+
principal_hash=principal.hashed_id(),
|
|
2686
|
+
model=record.model,
|
|
2687
|
+
status=status,
|
|
2688
|
+
input_tokens=state.input_tokens,
|
|
2689
|
+
output_tokens=state.output_tokens,
|
|
2690
|
+
latency_ms=latency_ms,
|
|
2691
|
+
error_type=type(error).__name__ if error else None,
|
|
2692
|
+
metadata=dict(req.metadata) or None,
|
|
2693
|
+
payload=payload,
|
|
2694
|
+
created_at=record.created_at,
|
|
2695
|
+
actor=record.actor,
|
|
2696
|
+
)
|
|
2697
|
+
if self.db is not None and not self.db.health.up:
|
|
2698
|
+
# Known down: do not hold the reply up; the maintenance loop writes it.
|
|
2699
|
+
self._unrecorded.append(final)
|
|
2700
|
+
logger.warning("the run record is written once the database is back")
|
|
2701
|
+
return
|
|
2702
|
+
try:
|
|
2703
|
+
async with asyncio.timeout(FINISH_STEP_TIMEOUT_S):
|
|
2704
|
+
await self.runs.record(final)
|
|
2705
|
+
except Exception as exc: # a failed run record must not break the reply
|
|
2706
|
+
self._unrecorded.append(final)
|
|
2707
|
+
logger.warning(
|
|
2708
|
+
"could not write the run record (%s); retrying in the background",
|
|
2709
|
+
type(exc).__name__,
|
|
2710
|
+
)
|
|
2711
|
+
|
|
2712
|
+
# -- reading a thread ----------------------------------------------------
|
|
2713
|
+
|
|
2714
|
+
async def messages(
|
|
2715
|
+
self,
|
|
2716
|
+
principal: Principal,
|
|
2717
|
+
thread_id: str,
|
|
2718
|
+
forward_headers: Mapping[str, str] | None = None,
|
|
2719
|
+
) -> list[dict[str, Any]]:
|
|
2720
|
+
thread_id = validate_thread_id(thread_id, self.runtime)
|
|
2721
|
+
self._require_storage()
|
|
2722
|
+
if self.runtime == LANGGRAPH_SERVER:
|
|
2723
|
+
return await self._server_messages(principal, thread_id, forward_headers or {})
|
|
2724
|
+
assert self.threads is not None
|
|
2725
|
+
with database_errors():
|
|
2726
|
+
record = await self.threads.get(thread_id)
|
|
2727
|
+
if record is None:
|
|
2728
|
+
raise HTTPException(status_code=404, detail="Unknown thread.")
|
|
2729
|
+
assert_access(principal, record)
|
|
2730
|
+
# Tool arguments reach a non-owner only under TRACE_CAPTURE=full.
|
|
2731
|
+
include_args = is_owner(principal, record) or capture_full()
|
|
2732
|
+
from {{cookiecutter.agent_directory}}.agent import graph
|
|
2733
|
+
|
|
2734
|
+
with database_errors():
|
|
2735
|
+
snapshot = await graph.aget_state({"configurable": {"thread_id": thread_id}})
|
|
2736
|
+
values = snapshot.values if snapshot is not None else {}
|
|
2737
|
+
return [
|
|
2738
|
+
serialize_message(m, include_tool_args=include_args) for m in values.get("messages", [])
|
|
2739
|
+
]
|
|
2740
|
+
|
|
2741
|
+
# -- langgraph-server: proxy through the loopback SDK client --------------
|
|
2742
|
+
|
|
2743
|
+
def _sdk_client(self, req_headers: Mapping[str, str]) -> Any:
|
|
2744
|
+
from langgraph_sdk import get_client
|
|
2745
|
+
|
|
2746
|
+
headers = select_forward_headers(req_headers)
|
|
2747
|
+
return get_client(
|
|
2748
|
+
url=os.environ.get("LANGGRAPH_SERVER_URL") or None, headers=headers or None
|
|
2749
|
+
)
|
|
2750
|
+
|
|
2751
|
+
@staticmethod
|
|
2752
|
+
def _record_of(thread: Any, thread_id: str) -> ThreadRecord:
|
|
2753
|
+
meta = (thread.get("metadata") or {}) if isinstance(thread, Mapping) else {}
|
|
2754
|
+
return ThreadRecord(
|
|
2755
|
+
thread_id=str(thread.get("thread_id") or thread_id)
|
|
2756
|
+
if isinstance(thread, Mapping)
|
|
2757
|
+
else thread_id,
|
|
2758
|
+
principal_id=str(meta.get("principal_id") or ""),
|
|
2759
|
+
tenant=meta.get("tenant"),
|
|
2760
|
+
# No `actor` (a direct thread, or one created before 0.3): no agent's thread.
|
|
2761
|
+
actor=str(meta.get("actor") or ""),
|
|
2762
|
+
)
|
|
2763
|
+
|
|
2764
|
+
async def _server_thread_record(self, client: Any, thread_id: str) -> ThreadRecord | None:
|
|
2765
|
+
"""The thread's ownership metadata as the app wrote it at creation, or None when absent.
|
|
2766
|
+
|
|
2767
|
+
The loopback client is unauthenticated on the server, so any error
|
|
2768
|
+
other than 404 is a transport/server failure (503), never an ownership
|
|
2769
|
+
signal. A thread without `principal_id` metadata (created through the
|
|
2770
|
+
native API without this app) fails closed: nobody but a read-across
|
|
2771
|
+
role reads it.
|
|
2772
|
+
"""
|
|
2773
|
+
try:
|
|
2774
|
+
thread = await client.threads.get(thread_id)
|
|
2775
|
+
except Exception as exc:
|
|
2776
|
+
if http_status(exc) == 404:
|
|
2777
|
+
return None
|
|
2778
|
+
raise unavailable("LangGraph Server", exc) from exc
|
|
2779
|
+
return self._record_of(thread, thread_id)
|
|
2780
|
+
|
|
2781
|
+
async def _server_resolve_thread(self, principal: Principal, req: ChatRequest) -> str:
|
|
2782
|
+
client = self._sdk_client(req.forward_headers)
|
|
2783
|
+
metadata = {
|
|
2784
|
+
"principal_id": principal.id,
|
|
2785
|
+
"tenant": principal.public_attributes().get("tenant"),
|
|
2786
|
+
}
|
|
2787
|
+
if principal.actor is not None:
|
|
2788
|
+
# The agent that starts the thread for its user (see `threads.is_owner`).
|
|
2789
|
+
metadata["actor"] = principal.actor.id
|
|
2790
|
+
if req.thread_id:
|
|
2791
|
+
record = await self._server_thread_record(client, req.thread_id)
|
|
2792
|
+
if record is not None:
|
|
2793
|
+
# chat.send is a write: the owner only (403 before message.start).
|
|
2794
|
+
assert_owner(principal, record)
|
|
2795
|
+
return record.thread_id
|
|
2796
|
+
try:
|
|
2797
|
+
# `do_nothing` returns the existing thread when another request
|
|
2798
|
+
# created it in between: its metadata then decides, not ours.
|
|
2799
|
+
thread = await client.threads.create(
|
|
2800
|
+
thread_id=req.thread_id, metadata=metadata, if_exists="do_nothing"
|
|
2801
|
+
)
|
|
2802
|
+
except Exception as exc: # loopback not configured, server down, ...
|
|
2803
|
+
raise unavailable("LangGraph Server", exc) from exc
|
|
2804
|
+
record = self._record_of(thread, req.thread_id or "")
|
|
2805
|
+
assert_owner(principal, record)
|
|
2806
|
+
return record.thread_id
|
|
2807
|
+
|
|
2808
|
+
async def _server_events(
|
|
2809
|
+
self,
|
|
2810
|
+
principal: Principal,
|
|
2811
|
+
req: ChatRequest,
|
|
2812
|
+
thread_id: str,
|
|
2813
|
+
run_id: str,
|
|
2814
|
+
state: _RunState,
|
|
2815
|
+
resume: Resume | None = None,
|
|
2816
|
+
) -> AsyncIterator[tuple[str, Any]]:
|
|
2817
|
+
if resume is None:
|
|
2818
|
+
# As under fastapi: answer tool calls an interrupted run left open first.
|
|
2819
|
+
await self._repair_before_run(req, thread_id)
|
|
2820
|
+
client = self._sdk_client(req.forward_headers)
|
|
2821
|
+
# A resume continues the paused run through the server's native resume
|
|
2822
|
+
# (the server's auth handler refuses a `command` from outside the app).
|
|
2823
|
+
run_input: dict[str, Any] = (
|
|
2824
|
+
{"command": {"resume": resume.values}}
|
|
2825
|
+
if resume is not None
|
|
2826
|
+
else {"input": {"messages": [{"role": "user", "content": req.message}]}}
|
|
2827
|
+
)
|
|
2828
|
+
metadata = {
|
|
2829
|
+
**trace_metadata(thread_id, run_id, principal, req.metadata),
|
|
2830
|
+
# The server merges the thread's metadata, whose `principal_id` is
|
|
2831
|
+
# the raw owner id (the ownership check needs it there), into the
|
|
2832
|
+
# run's metadata, and from there into the traced config metadata
|
|
2833
|
+
# and each checkpoint's metadata. This key overrides it there with
|
|
2834
|
+
# the hashed id; the thread itself keeps the raw one.
|
|
2835
|
+
"principal_id": principal.hashed_id(),
|
|
2836
|
+
}
|
|
2837
|
+
async for part in client.runs.stream(
|
|
2838
|
+
thread_id,
|
|
2839
|
+
GRAPH_ID,
|
|
2840
|
+
**run_input,
|
|
2841
|
+
stream_mode=["messages-tuple", "updates"],
|
|
2842
|
+
metadata=metadata,
|
|
2843
|
+
config={"recursion_limit": recursion_limit()},
|
|
2844
|
+
# The server persists run context: never the principal's credentials.
|
|
2845
|
+
# The raw id stays here (tools act on the caller's behalf); run
|
|
2846
|
+
# context is not traced, and the metadata key above keeps it out of
|
|
2847
|
+
# checkpoint metadata too. The server's auth handler puts the same
|
|
2848
|
+
# context on a run it authorizes (LANGGRAPH_SERVER_URL, native runs).
|
|
2849
|
+
context=run_context_of(principal),
|
|
2850
|
+
multitask_strategy="reject",
|
|
2851
|
+
on_disconnect="cancel",
|
|
2852
|
+
):
|
|
2853
|
+
event = str(getattr(part, "event", ""))
|
|
2854
|
+
data = getattr(part, "data", None)
|
|
2855
|
+
if event == "metadata" and isinstance(data, Mapping):
|
|
2856
|
+
state.server_run_id = str(data.get("run_id") or "") or None
|
|
2857
|
+
elif event.startswith("messages"):
|
|
2858
|
+
yield "messages", data
|
|
2859
|
+
elif event.startswith("updates"):
|
|
2860
|
+
yield "updates", data
|
|
2861
|
+
elif event == "error":
|
|
2862
|
+
raise _ServerRunError(data)
|
|
2863
|
+
|
|
2864
|
+
async def _cancel_server_run(self, req: ChatRequest, thread_id: str, state: _RunState) -> None:
|
|
2865
|
+
"""Stop the server-side run behind a timed-out or abandoned stream (best effort)."""
|
|
2866
|
+
if not state.server_run_id:
|
|
2867
|
+
return
|
|
2868
|
+
try:
|
|
2869
|
+
client = self._sdk_client(req.forward_headers)
|
|
2870
|
+
async with asyncio.timeout(10):
|
|
2871
|
+
await client.runs.cancel(thread_id, state.server_run_id, wait=True)
|
|
2872
|
+
except Exception as exc:
|
|
2873
|
+
if http_status(exc) not in (404, 409):
|
|
2874
|
+
logger.warning("could not cancel the server run: %s", type(exc).__name__)
|
|
2875
|
+
|
|
2876
|
+
async def _server_messages(
|
|
2877
|
+
self, principal: Principal, thread_id: str, forward_headers: Mapping[str, str]
|
|
2878
|
+
) -> list[dict[str, Any]]:
|
|
2879
|
+
client = self._sdk_client(forward_headers)
|
|
2880
|
+
record = await self._server_thread_record(client, thread_id)
|
|
2881
|
+
if record is None:
|
|
2882
|
+
raise HTTPException(status_code=404, detail="Unknown thread.")
|
|
2883
|
+
assert_access(principal, record)
|
|
2884
|
+
# Tool arguments reach a non-owner only under TRACE_CAPTURE=full.
|
|
2885
|
+
include_args = is_owner(principal, record) or capture_full()
|
|
2886
|
+
try:
|
|
2887
|
+
state = await client.threads.get_state(thread_id)
|
|
2888
|
+
except Exception as exc:
|
|
2889
|
+
if http_status(exc) == 404:
|
|
2890
|
+
raise HTTPException(status_code=404, detail="Unknown thread.") from exc
|
|
2891
|
+
raise unavailable("LangGraph Server", exc) from exc
|
|
2892
|
+
values = state.get("values") or {}
|
|
2893
|
+
return [
|
|
2894
|
+
serialize_message(m, include_tool_args=include_args) for m in values.get("messages", [])
|
|
2895
|
+
]
|
|
2896
|
+
|
|
2897
|
+
async def _server_list_threads(
|
|
2898
|
+
self,
|
|
2899
|
+
principal: Principal,
|
|
2900
|
+
limit: int,
|
|
2901
|
+
offset: int,
|
|
2902
|
+
forward_headers: Mapping[str, str],
|
|
2903
|
+
) -> list[dict[str, Any]]:
|
|
2904
|
+
client = self._sdk_client(forward_headers)
|
|
2905
|
+
filters: dict[str, Any] = {}
|
|
2906
|
+
if not reads_across(principal):
|
|
2907
|
+
filters["metadata"] = owner_metadata_filter(principal)
|
|
2908
|
+
try:
|
|
2909
|
+
threads = await client.threads.search(
|
|
2910
|
+
limit=limit, offset=offset, sort_by="updated_at", sort_order="desc", **filters
|
|
2911
|
+
)
|
|
2912
|
+
except Exception as exc:
|
|
2913
|
+
raise unavailable("LangGraph Server", exc) from exc
|
|
2914
|
+
out = []
|
|
2915
|
+
for thread in threads:
|
|
2916
|
+
if not isinstance(thread, Mapping):
|
|
2917
|
+
continue
|
|
2918
|
+
out.append(
|
|
2919
|
+
{
|
|
2920
|
+
"thread_id": str(thread.get("thread_id")),
|
|
2921
|
+
"created_at": _iso(thread.get("created_at")),
|
|
2922
|
+
"updated_at": _iso(thread.get("updated_at")),
|
|
2923
|
+
}
|
|
2924
|
+
)
|
|
2925
|
+
return out
|
|
2926
|
+
|
|
2927
|
+
async def _server_idle_threads(self, cutoff: datetime, batch: int) -> list[str]:
|
|
2928
|
+
client = self._sdk_client({})
|
|
2929
|
+
threads = await client.threads.search(
|
|
2930
|
+
limit=batch, offset=0, sort_by="updated_at", sort_order="asc"
|
|
2931
|
+
)
|
|
2932
|
+
idle: list[str] = []
|
|
2933
|
+
for thread in threads:
|
|
2934
|
+
updated = _parse_time(thread.get("updated_at")) if isinstance(thread, Mapping) else None
|
|
2935
|
+
if updated is None or updated >= cutoff:
|
|
2936
|
+
break
|
|
2937
|
+
if thread.get("status") == "busy":
|
|
2938
|
+
continue
|
|
2939
|
+
idle.append(str(thread.get("thread_id")))
|
|
2940
|
+
return idle
|
|
2941
|
+
|
|
2942
|
+
|
|
2943
|
+
def _iso(value: Any) -> str | None:
|
|
2944
|
+
if value is None:
|
|
2945
|
+
return None
|
|
2946
|
+
return value.isoformat() if isinstance(value, datetime) else str(value)
|
|
2947
|
+
|
|
2948
|
+
|
|
2949
|
+
def _parse_time(value: Any) -> datetime | None:
|
|
2950
|
+
if isinstance(value, datetime):
|
|
2951
|
+
parsed = value
|
|
2952
|
+
elif isinstance(value, str):
|
|
2953
|
+
with contextlib.suppress(ValueError):
|
|
2954
|
+
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
2955
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
|
2956
|
+
return None
|
|
2957
|
+
else:
|
|
2958
|
+
return None
|
|
2959
|
+
return parsed if parsed.tzinfo else parsed.replace(tzinfo=UTC)
|
|
2960
|
+
|
|
2961
|
+
|
|
2962
|
+
RUNTIME = ChatRuntime()
|