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,989 @@
|
|
|
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 langgraph-server branch of ChatRuntime against a fake SDK client (no server needed).
|
|
16
|
+
|
|
17
|
+
The loopback SDK client runs under the server's `/noauth` root path, so the
|
|
18
|
+
server's own auth filters never see the custom routes' calls: ownership has to
|
|
19
|
+
be enforced by the app from the thread metadata it wrote at creation.
|
|
20
|
+
|
|
21
|
+
The fake raises the SDK's real error types (`NotFoundError`, `ConflictError`),
|
|
22
|
+
whose messages do not contain the status code: the runtime must go by the
|
|
23
|
+
status, not the text.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
import asyncio
|
|
29
|
+
import contextlib
|
|
30
|
+
import sys
|
|
31
|
+
from collections.abc import AsyncIterator
|
|
32
|
+
from types import SimpleNamespace
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
import httpx
|
|
36
|
+
import pytest
|
|
37
|
+
from fastapi import HTTPException
|
|
38
|
+
|
|
39
|
+
errors = pytest.importorskip("langgraph_sdk.errors")
|
|
40
|
+
|
|
41
|
+
from {{cookiecutter.agent_directory}}.app_utils import chat as chat_module # noqa: E402
|
|
42
|
+
from {{cookiecutter.agent_directory}}.app_utils.auth import Actor, Principal # noqa: E402
|
|
43
|
+
from {{cookiecutter.agent_directory}}.app_utils.chat import ( # noqa: E402
|
|
44
|
+
LANGGRAPH_SERVER,
|
|
45
|
+
ChatRequest,
|
|
46
|
+
ChatRuntime,
|
|
47
|
+
)
|
|
48
|
+
from {{cookiecutter.agent_directory}}.app_utils.content import INVALID_TOOL_CALL_RESULT # noqa: E402
|
|
49
|
+
from {{cookiecutter.agent_directory}}.app_utils.db import Database, RunRecord, RunStore # noqa: E402
|
|
50
|
+
from {{cookiecutter.agent_directory}}.app_utils.threads import ThreadLocks, ThreadStore # noqa: E402
|
|
51
|
+
|
|
52
|
+
OWNER = Principal(
|
|
53
|
+
id="A",
|
|
54
|
+
roles=["viewer"],
|
|
55
|
+
attributes={"tenant": "t1", "credentials": {"example": "secret-token-A"}},
|
|
56
|
+
)
|
|
57
|
+
STRANGER = Principal(id="B", roles=["viewer"])
|
|
58
|
+
AUDITOR = Principal(id="C", roles=["auditor"])
|
|
59
|
+
THREAD = "11111111-1111-1111-1111-111111111111"
|
|
60
|
+
OTHER = "44444444-4444-4444-4444-444444444444"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def sdk_error(cls: type[Exception], status: int, message: str) -> Exception:
|
|
64
|
+
"""An SDK error exactly as langgraph_sdk raises it (the text has no status code)."""
|
|
65
|
+
request = httpx.Request("GET", "http://loopback/threads/x")
|
|
66
|
+
response = httpx.Response(status, request=request, json={"detail": message})
|
|
67
|
+
return cls(message, response=response, body={"detail": message})
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def stored_messages(dumps: list[dict[str, Any]]) -> list[Any]:
|
|
71
|
+
"""Messages as the server stores them, from their JSON form (every field kept)."""
|
|
72
|
+
from langchain_core.messages import AIMessage, HumanMessage, SystemMessage, ToolMessage
|
|
73
|
+
|
|
74
|
+
classes = {"human": HumanMessage, "ai": AIMessage, "tool": ToolMessage, "system": SystemMessage}
|
|
75
|
+
return [classes[m["type"]].model_validate(m) for m in dumps]
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class FakeSdk:
|
|
79
|
+
"""`langgraph_sdk.get_client` stand-in: one thread owned by A, recording every call."""
|
|
80
|
+
|
|
81
|
+
def __init__(self) -> None:
|
|
82
|
+
self.headers: list[dict[str, str] | None] = []
|
|
83
|
+
self.created: list[dict[str, Any]] = []
|
|
84
|
+
self.streams: list[dict[str, Any]] = []
|
|
85
|
+
self.cancelled: list[tuple[str, str]] = []
|
|
86
|
+
self.deleted: list[str] = []
|
|
87
|
+
self.searches: list[dict[str, Any]] = []
|
|
88
|
+
self.state_updates: list[dict[str, Any]] = []
|
|
89
|
+
self.slow_stream = False
|
|
90
|
+
self.stream_error: dict[str, Any] | None = None
|
|
91
|
+
# Seconds the thread stays busy after the stream's error part (the real
|
|
92
|
+
# server sends the error before it marks the run done); None: idle at once.
|
|
93
|
+
self.busy_after_error_s: float | None = None
|
|
94
|
+
# The message types of the thread when each run stream started.
|
|
95
|
+
self.history_at_stream: list[list[Any]] = []
|
|
96
|
+
self.threads_by_id: dict[str, dict[str, Any]] = {
|
|
97
|
+
THREAD: {
|
|
98
|
+
"thread_id": THREAD,
|
|
99
|
+
"metadata": {"principal_id": "A", "tenant": None},
|
|
100
|
+
"created_at": "2026-01-01T00:00:00+00:00",
|
|
101
|
+
"updated_at": "2026-01-02T00:00:00+00:00",
|
|
102
|
+
"status": "idle",
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
self.messages = [
|
|
106
|
+
{"type": "human", "id": "m1", "content": "hello"},
|
|
107
|
+
{
|
|
108
|
+
"type": "ai",
|
|
109
|
+
"id": "m2",
|
|
110
|
+
"content": "",
|
|
111
|
+
"tool_calls": [{"id": "c1", "name": "probe", "args": {"query": "SF"}}],
|
|
112
|
+
},
|
|
113
|
+
{
|
|
114
|
+
"type": "tool",
|
|
115
|
+
"id": "m3",
|
|
116
|
+
"tool_call_id": "c1",
|
|
117
|
+
"name": "probe",
|
|
118
|
+
"content": "sunny",
|
|
119
|
+
},
|
|
120
|
+
{"type": "ai", "id": "m4", "content": "It is sunny."},
|
|
121
|
+
]
|
|
122
|
+
|
|
123
|
+
def apply(self, values: Any) -> None:
|
|
124
|
+
"""Apply a state update as LangGraph Server does.
|
|
125
|
+
|
|
126
|
+
The server turns each message dict of the update into a message with
|
|
127
|
+
langchain's coercion (a dict it cannot read is a 400; a key it does not
|
|
128
|
+
know, such as `invalid_tool_calls`, lands in `additional_kwargs`) and
|
|
129
|
+
merges them into the stored messages, which keep every field, with
|
|
130
|
+
`add_messages` (a remove-all marker replaces the history).
|
|
131
|
+
"""
|
|
132
|
+
from langchain_core.messages import convert_to_messages
|
|
133
|
+
from langgraph.graph.message import add_messages
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
update = convert_to_messages((values or {}).get("messages", []))
|
|
137
|
+
except ValueError as exc:
|
|
138
|
+
raise sdk_error(errors.BadRequestError, 400, str(exc)) from exc
|
|
139
|
+
merged = add_messages(stored_messages(self.messages), update)
|
|
140
|
+
self.messages = [m.model_dump(exclude_none=True) for m in merged]
|
|
141
|
+
|
|
142
|
+
def get_client(
|
|
143
|
+
self, *, url: str | None = None, headers: dict[str, str] | None = None, **_: Any
|
|
144
|
+
):
|
|
145
|
+
self.headers.append(headers)
|
|
146
|
+
sdk = self
|
|
147
|
+
|
|
148
|
+
class _Threads:
|
|
149
|
+
async def get(self, thread_id: str, **_: Any) -> dict[str, Any]:
|
|
150
|
+
if thread_id not in sdk.threads_by_id:
|
|
151
|
+
raise sdk_error(
|
|
152
|
+
errors.NotFoundError, 404, f"Thread with ID {thread_id} not found"
|
|
153
|
+
)
|
|
154
|
+
return sdk.threads_by_id[thread_id]
|
|
155
|
+
|
|
156
|
+
async def create(
|
|
157
|
+
self,
|
|
158
|
+
*,
|
|
159
|
+
thread_id: str | None = None,
|
|
160
|
+
metadata: Any = None,
|
|
161
|
+
if_exists: str | None = None,
|
|
162
|
+
**_: Any,
|
|
163
|
+
):
|
|
164
|
+
thread_id = thread_id or "22222222-2222-2222-2222-222222222222"
|
|
165
|
+
if thread_id in sdk.threads_by_id:
|
|
166
|
+
if if_exists != "do_nothing":
|
|
167
|
+
raise sdk_error(errors.ConflictError, 409, "Thread already exists")
|
|
168
|
+
return sdk.threads_by_id[thread_id]
|
|
169
|
+
record = {"thread_id": thread_id, "metadata": dict(metadata or {})}
|
|
170
|
+
sdk.threads_by_id[thread_id] = record
|
|
171
|
+
sdk.created.append(record)
|
|
172
|
+
return record
|
|
173
|
+
|
|
174
|
+
async def get_state(self, thread_id: str, **_: Any) -> dict[str, Any]:
|
|
175
|
+
if thread_id not in sdk.threads_by_id:
|
|
176
|
+
raise sdk_error(errors.NotFoundError, 404, "Thread not found")
|
|
177
|
+
return {"values": {"messages": sdk.messages}}
|
|
178
|
+
|
|
179
|
+
async def search(self, **kwargs: Any) -> list[dict[str, Any]]:
|
|
180
|
+
sdk.searches.append(kwargs)
|
|
181
|
+
wanted = (kwargs.get("metadata") or {}).get("principal_id")
|
|
182
|
+
found = [
|
|
183
|
+
t
|
|
184
|
+
for t in sdk.threads_by_id.values()
|
|
185
|
+
if wanted is None or (t.get("metadata") or {}).get("principal_id") == wanted
|
|
186
|
+
]
|
|
187
|
+
found.sort(
|
|
188
|
+
key=lambda t: t.get("updated_at") or "",
|
|
189
|
+
reverse=kwargs.get("sort_order") == "desc",
|
|
190
|
+
)
|
|
191
|
+
return found
|
|
192
|
+
|
|
193
|
+
async def delete(self, thread_id: str, **_: Any) -> None:
|
|
194
|
+
sdk.deleted.append(thread_id)
|
|
195
|
+
sdk.threads_by_id.pop(thread_id, None)
|
|
196
|
+
|
|
197
|
+
async def update_state(self, thread_id: str, values: Any, **kwargs: Any) -> None:
|
|
198
|
+
sdk.state_updates.append({"thread_id": thread_id, "values": values, **kwargs})
|
|
199
|
+
sdk.apply(values)
|
|
200
|
+
|
|
201
|
+
class _Runs:
|
|
202
|
+
async def stream(
|
|
203
|
+
self, thread_id: str, assistant_id: str, **kwargs: Any
|
|
204
|
+
) -> AsyncIterator[Any]:
|
|
205
|
+
sdk.streams.append({"thread_id": thread_id, "assistant_id": assistant_id, **kwargs})
|
|
206
|
+
sdk.history_at_stream.append([m.get("type") for m in sdk.messages])
|
|
207
|
+
yield SimpleNamespace(event="metadata", data={"run_id": "srv-run-1", "attempt": 1})
|
|
208
|
+
if sdk.stream_error is not None:
|
|
209
|
+
if sdk.busy_after_error_s is not None:
|
|
210
|
+
thread = sdk.threads_by_id[thread_id]
|
|
211
|
+
thread["status"] = "busy"
|
|
212
|
+
asyncio.get_running_loop().call_later(
|
|
213
|
+
sdk.busy_after_error_s, thread.__setitem__, "status", "idle"
|
|
214
|
+
)
|
|
215
|
+
yield SimpleNamespace(event="error", data=sdk.stream_error)
|
|
216
|
+
return
|
|
217
|
+
if sdk.slow_stream:
|
|
218
|
+
await asyncio.sleep(30)
|
|
219
|
+
yield SimpleNamespace(
|
|
220
|
+
event="updates",
|
|
221
|
+
data={
|
|
222
|
+
"agent": {
|
|
223
|
+
"messages": [
|
|
224
|
+
{
|
|
225
|
+
"type": "ai",
|
|
226
|
+
"id": "ai-1",
|
|
227
|
+
"content": "",
|
|
228
|
+
"tool_calls": [
|
|
229
|
+
{"id": "c1", "name": "probe", "args": {"query": "SF"}}
|
|
230
|
+
],
|
|
231
|
+
"usage_metadata": {"input_tokens": 3, "output_tokens": 4},
|
|
232
|
+
}
|
|
233
|
+
]
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
)
|
|
237
|
+
yield SimpleNamespace(
|
|
238
|
+
event="updates",
|
|
239
|
+
data={
|
|
240
|
+
"tools": {
|
|
241
|
+
"messages": [
|
|
242
|
+
{
|
|
243
|
+
"type": "tool",
|
|
244
|
+
"tool_call_id": "c1",
|
|
245
|
+
"name": "probe",
|
|
246
|
+
"content": "sunny",
|
|
247
|
+
}
|
|
248
|
+
]
|
|
249
|
+
}
|
|
250
|
+
},
|
|
251
|
+
)
|
|
252
|
+
yield SimpleNamespace(
|
|
253
|
+
event="messages",
|
|
254
|
+
data=[
|
|
255
|
+
{"type": "AIMessageChunk", "id": "ai-2", "content": "It is "},
|
|
256
|
+
{"langgraph_node": "agent"},
|
|
257
|
+
],
|
|
258
|
+
)
|
|
259
|
+
yield SimpleNamespace(
|
|
260
|
+
event="messages",
|
|
261
|
+
data=[
|
|
262
|
+
{"type": "AIMessageChunk", "id": "ai-2", "content": "sunny."},
|
|
263
|
+
{"langgraph_node": "agent"},
|
|
264
|
+
],
|
|
265
|
+
)
|
|
266
|
+
|
|
267
|
+
async def cancel(self, thread_id: str, run_id: str, **_: Any) -> None:
|
|
268
|
+
sdk.cancelled.append((thread_id, run_id))
|
|
269
|
+
|
|
270
|
+
class _Assistants:
|
|
271
|
+
async def search(self, **_: Any) -> list[dict[str, Any]]:
|
|
272
|
+
return [{"assistant_id": "agent"}]
|
|
273
|
+
|
|
274
|
+
return SimpleNamespace(threads=_Threads(), runs=_Runs(), assistants=_Assistants())
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@pytest.fixture
|
|
278
|
+
def server(monkeypatch: pytest.MonkeyPatch) -> tuple[ChatRuntime, FakeSdk]:
|
|
279
|
+
sdk = FakeSdk()
|
|
280
|
+
monkeypatch.setitem(sys.modules, "langgraph_sdk", SimpleNamespace(get_client=sdk.get_client))
|
|
281
|
+
monkeypatch.setenv("RUNTIME", "langgraph-server")
|
|
282
|
+
for name in ("AUTH_READ_ACROSS_ROLES", "TRACE_CAPTURE", "AUTH_FORWARD_HEADERS", "APP_ENV"):
|
|
283
|
+
monkeypatch.delenv(name, raising=False)
|
|
284
|
+
rt = ChatRuntime()
|
|
285
|
+
assert rt.runtime == LANGGRAPH_SERVER
|
|
286
|
+
# What start() sets up, without a server: in-memory run records and locks.
|
|
287
|
+
rt.db = Database("memory", runs_table="agent_runs", with_threads=False)
|
|
288
|
+
rt.runs = RunStore(rt.db)
|
|
289
|
+
rt.threads = ThreadStore(rt.db)
|
|
290
|
+
rt.locks = ThreadLocks()
|
|
291
|
+
rt.started = True
|
|
292
|
+
return rt, sdk
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
async def _events(rt: ChatRuntime, principal: Principal, req: ChatRequest, thread_id: str):
|
|
296
|
+
return [(e, d) async for e, d in rt.stream(principal, req, thread_id)]
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
async def test_owner_continues_its_thread_and_the_stream_maps_the_contract_events(server) -> None:
|
|
300
|
+
rt, sdk = server
|
|
301
|
+
req = ChatRequest(
|
|
302
|
+
message="probe?",
|
|
303
|
+
thread_id=THREAD,
|
|
304
|
+
metadata={"source": "web"},
|
|
305
|
+
forward_headers={
|
|
306
|
+
"authorization": "Bearer k",
|
|
307
|
+
"cookie": "sid=1",
|
|
308
|
+
"x-session-token": "not-forwarded-by-default",
|
|
309
|
+
"x-request-id": "ignored",
|
|
310
|
+
},
|
|
311
|
+
)
|
|
312
|
+
assert await rt.resolve_thread(OWNER, req) == THREAD
|
|
313
|
+
events = await _events(rt, OWNER, req, THREAD)
|
|
314
|
+
names = [e for e, _ in events]
|
|
315
|
+
assert names == [
|
|
316
|
+
"message.start",
|
|
317
|
+
"tool.call",
|
|
318
|
+
"tool.result",
|
|
319
|
+
"message.delta",
|
|
320
|
+
"message.delta",
|
|
321
|
+
"message.end",
|
|
322
|
+
]
|
|
323
|
+
assert events[1][1] == {"id": "c1", "name": "probe", "args": {"query": "SF"}}
|
|
324
|
+
end = events[-1][1]
|
|
325
|
+
assert end["usage"] == {"input_tokens": 3, "output_tokens": 4} and end["status"] == "ok"
|
|
326
|
+
assert end["thread_id"] == THREAD and end["run_id"] == events[0][1]["run_id"]
|
|
327
|
+
(stream,) = sdk.streams
|
|
328
|
+
assert stream["assistant_id"] == "agent" and stream["stream_mode"] == [
|
|
329
|
+
"messages-tuple",
|
|
330
|
+
"updates",
|
|
331
|
+
]
|
|
332
|
+
assert stream["multitask_strategy"] == "reject" and stream["on_disconnect"] == "cancel"
|
|
333
|
+
assert stream["config"] == {"recursion_limit": 50}
|
|
334
|
+
# The server persists run context and metadata: no credentials, no raw
|
|
335
|
+
# principal id in metadata, no client metadata under metadata capture.
|
|
336
|
+
assert stream["context"] == {
|
|
337
|
+
"principal_id": "A",
|
|
338
|
+
"roles": ["viewer"],
|
|
339
|
+
"attributes": {"tenant": "t1"},
|
|
340
|
+
}
|
|
341
|
+
assert "secret-token-A" not in repr(stream)
|
|
342
|
+
# `principal_id` overrides the raw owner id the server merges in from the
|
|
343
|
+
# thread metadata (and copies into traces and checkpoint metadata).
|
|
344
|
+
assert stream["metadata"] == {
|
|
345
|
+
"thread_id": THREAD,
|
|
346
|
+
"run_id": end["run_id"],
|
|
347
|
+
"principal_hash": OWNER.hashed_id(),
|
|
348
|
+
"principal_id": OWNER.hashed_id(),
|
|
349
|
+
}
|
|
350
|
+
# Only the configured credential headers reach the SDK client.
|
|
351
|
+
assert sdk.headers[0] == {"authorization": "Bearer k", "cookie": "sid=1"}
|
|
352
|
+
record = await rt.runs.get(end["run_id"])
|
|
353
|
+
assert record is not None and record.status == "ok" and record.metadata == {"source": "web"}
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
async def test_client_metadata_reaches_traces_only_under_full_capture(server, monkeypatch) -> None:
|
|
357
|
+
rt, sdk = server
|
|
358
|
+
monkeypatch.setenv("TRACE_CAPTURE", "full")
|
|
359
|
+
req = ChatRequest(message="x", thread_id=THREAD, metadata={"run_id": "SPOOFED"})
|
|
360
|
+
await _events(rt, OWNER, req, THREAD)
|
|
361
|
+
meta = sdk.streams[-1]["metadata"]
|
|
362
|
+
assert meta["run_id"] != "SPOOFED" and meta["client_metadata"] == {"run_id": "SPOOFED"}
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
async def test_forwarded_headers_follow_auth_forward_headers(server, monkeypatch) -> None:
|
|
366
|
+
rt, sdk = server
|
|
367
|
+
headers = {"authorization": "Bearer k", "x-api-key": "k2", "cookie": "c"}
|
|
368
|
+
monkeypatch.setenv("AUTH_FORWARD_HEADERS", "X-Api-Key")
|
|
369
|
+
await rt.messages(OWNER, THREAD, headers)
|
|
370
|
+
assert sdk.headers[-1] == {"x-api-key": "k2"}
|
|
371
|
+
monkeypatch.setenv("AUTH_FORWARD_HEADERS", "")
|
|
372
|
+
await rt.messages(OWNER, THREAD, headers)
|
|
373
|
+
assert sdk.headers[-1] is None
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
async def test_stranger_cannot_continue_or_read_another_principals_thread(server) -> None:
|
|
377
|
+
rt, sdk = server
|
|
378
|
+
with pytest.raises(HTTPException) as exc:
|
|
379
|
+
await rt.resolve_thread(STRANGER, ChatRequest(message="x", thread_id=THREAD))
|
|
380
|
+
assert exc.value.status_code == 403
|
|
381
|
+
assert sdk.streams == [] and sdk.created == []
|
|
382
|
+
with pytest.raises(HTTPException) as exc:
|
|
383
|
+
await rt.messages(STRANGER, THREAD)
|
|
384
|
+
assert exc.value.status_code == 403
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
async def test_read_across_role_reads_without_tool_args_and_cannot_write(
|
|
388
|
+
server, monkeypatch
|
|
389
|
+
) -> None:
|
|
390
|
+
rt, _sdk = server
|
|
391
|
+
monkeypatch.setenv("AUTH_READ_ACROSS_ROLES", "auditor")
|
|
392
|
+
messages = await rt.messages(AUDITOR, THREAD)
|
|
393
|
+
assert [m["role"] for m in messages] == ["user", "assistant", "tool", "assistant"]
|
|
394
|
+
assert messages[1]["tool_calls"] == [{"id": "c1", "name": "probe"}] # args omitted
|
|
395
|
+
monkeypatch.setenv("TRACE_CAPTURE", "full")
|
|
396
|
+
messages = await rt.messages(AUDITOR, THREAD)
|
|
397
|
+
assert messages[1]["tool_calls"][0]["args"] == {"query": "SF"}
|
|
398
|
+
with pytest.raises(HTTPException) as exc:
|
|
399
|
+
await rt.resolve_thread(AUDITOR, ChatRequest(message="x", thread_id=THREAD))
|
|
400
|
+
assert exc.value.status_code == 403
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
async def test_owner_reads_tool_args_under_metadata_capture(server) -> None:
|
|
404
|
+
rt, sdk = server
|
|
405
|
+
messages = await rt.messages(OWNER, THREAD, {"authorization": "Bearer k"})
|
|
406
|
+
assert messages[1]["tool_calls"][0]["args"] == {"query": "SF"}
|
|
407
|
+
assert messages[2]["tool_call_id"] == "c1" and messages[2]["is_error"] is False
|
|
408
|
+
assert sdk.headers[-1] == {"authorization": "Bearer k"}
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
async def test_unknown_thread_is_created_for_the_caller_and_404_on_read(server) -> None:
|
|
412
|
+
"""The SDK's NotFoundError (no '404' in its text) means 'absent', never 503."""
|
|
413
|
+
rt, sdk = server
|
|
414
|
+
new_id = "33333333-3333-3333-3333-333333333333"
|
|
415
|
+
with pytest.raises(HTTPException) as exc:
|
|
416
|
+
await rt.messages(STRANGER, new_id)
|
|
417
|
+
assert exc.value.status_code == 404
|
|
418
|
+
assert await rt.resolve_thread(STRANGER, ChatRequest(message="x", thread_id=new_id)) == new_id
|
|
419
|
+
assert sdk.created == [{"thread_id": new_id, "metadata": {"principal_id": "B", "tenant": None}}]
|
|
420
|
+
# A thread the server holds without ownership metadata fails closed.
|
|
421
|
+
bare = "55555555-5555-5555-5555-555555555555"
|
|
422
|
+
sdk.threads_by_id[bare] = {"thread_id": bare, "metadata": {}}
|
|
423
|
+
with pytest.raises(HTTPException) as exc:
|
|
424
|
+
await rt.resolve_thread(OWNER, ChatRequest(message="x", thread_id=bare))
|
|
425
|
+
assert exc.value.status_code == 403
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
async def test_a_thread_created_in_between_by_someone_else_is_refused(server) -> None:
|
|
429
|
+
"""get() says absent, but another request creates the id first: its owner wins (403)."""
|
|
430
|
+
rt, sdk = server
|
|
431
|
+
racing = "66666666-6666-6666-6666-666666666666"
|
|
432
|
+
original_get_client = sdk.get_client
|
|
433
|
+
|
|
434
|
+
def get_client(**kwargs: Any):
|
|
435
|
+
client = original_get_client(**kwargs)
|
|
436
|
+
real_get = client.threads.get
|
|
437
|
+
|
|
438
|
+
async def get_then_race(thread_id: str, **kw: Any):
|
|
439
|
+
try:
|
|
440
|
+
return await real_get(thread_id, **kw)
|
|
441
|
+
finally:
|
|
442
|
+
sdk.threads_by_id.setdefault(
|
|
443
|
+
thread_id, {"thread_id": thread_id, "metadata": {"principal_id": "B"}}
|
|
444
|
+
)
|
|
445
|
+
|
|
446
|
+
client.threads.get = get_then_race
|
|
447
|
+
return client
|
|
448
|
+
|
|
449
|
+
sys.modules["langgraph_sdk"].get_client = get_client
|
|
450
|
+
with pytest.raises(HTTPException) as exc:
|
|
451
|
+
await rt.resolve_thread(OWNER, ChatRequest(message="x", thread_id=racing))
|
|
452
|
+
assert exc.value.status_code == 403
|
|
453
|
+
|
|
454
|
+
|
|
455
|
+
@pytest.mark.parametrize("thread_id", ["t-demo-1", "not a uuid", "x" * 200])
|
|
456
|
+
async def test_thread_ids_must_be_uuids_under_the_server_runtime(server, thread_id: str) -> None:
|
|
457
|
+
rt, sdk = server
|
|
458
|
+
for call in (
|
|
459
|
+
rt.resolve_thread(OWNER, ChatRequest(message="x", thread_id=thread_id)),
|
|
460
|
+
rt.messages(OWNER, thread_id),
|
|
461
|
+
rt.delete_thread(OWNER, thread_id),
|
|
462
|
+
):
|
|
463
|
+
with pytest.raises(HTTPException) as exc:
|
|
464
|
+
await call
|
|
465
|
+
assert exc.value.status_code == 422
|
|
466
|
+
assert sdk.created == [] and sdk.deleted == []
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
async def test_uuid_spellings_name_one_thread(server) -> None:
|
|
470
|
+
"""Canonical ids: the run lock and ownership cannot be split by upper/lower case."""
|
|
471
|
+
rt, sdk = server
|
|
472
|
+
upper = "AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"
|
|
473
|
+
resolved = await rt.resolve_thread(OWNER, ChatRequest(message="x", thread_id=upper))
|
|
474
|
+
assert resolved == upper.lower() and sdk.created[-1]["thread_id"] == upper.lower()
|
|
475
|
+
lease = await rt.acquire_thread(resolved)
|
|
476
|
+
again = await rt.resolve_thread(OWNER, ChatRequest(message="x", thread_id=upper))
|
|
477
|
+
events = await _events(rt, OWNER, ChatRequest(message="x"), again)
|
|
478
|
+
assert events[-1][1]["code"] == "thread_busy"
|
|
479
|
+
await lease.release()
|
|
480
|
+
|
|
481
|
+
|
|
482
|
+
async def test_server_failure_is_a_generic_503_not_403(server, monkeypatch) -> None:
|
|
483
|
+
rt, _sdk = server
|
|
484
|
+
|
|
485
|
+
def broken(**_: Any):
|
|
486
|
+
class _Threads:
|
|
487
|
+
async def get(self, thread_id: str, **_: Any):
|
|
488
|
+
raise httpx.ConnectError("loopback down at 10.0.0.7:8123")
|
|
489
|
+
|
|
490
|
+
return SimpleNamespace(threads=_Threads(), runs=None)
|
|
491
|
+
|
|
492
|
+
monkeypatch.setitem(sys.modules, "langgraph_sdk", SimpleNamespace(get_client=broken))
|
|
493
|
+
with pytest.raises(HTTPException) as exc:
|
|
494
|
+
await rt.resolve_thread(OWNER, ChatRequest(message="x", thread_id=THREAD))
|
|
495
|
+
assert exc.value.status_code == 503
|
|
496
|
+
assert "10.0.0.7" not in exc.value.detail and "Reference:" in exc.value.detail
|
|
497
|
+
|
|
498
|
+
|
|
499
|
+
async def test_stream_error_part_becomes_a_generic_error_event(server, monkeypatch) -> None:
|
|
500
|
+
rt, _sdk = server
|
|
501
|
+
|
|
502
|
+
def failing(**_: Any):
|
|
503
|
+
class _Runs:
|
|
504
|
+
async def stream(self, *a: Any, **k: Any) -> AsyncIterator[Any]:
|
|
505
|
+
yield SimpleNamespace(
|
|
506
|
+
event="error", data={"error": "ValueError", "message": "db at 10.0.0.7 down"}
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
return SimpleNamespace(threads=None, runs=_Runs())
|
|
510
|
+
|
|
511
|
+
monkeypatch.setitem(sys.modules, "langgraph_sdk", SimpleNamespace(get_client=failing))
|
|
512
|
+
events = await _events(rt, OWNER, ChatRequest(message="x"), THREAD)
|
|
513
|
+
assert [e for e, _ in events] == ["message.start", "error"]
|
|
514
|
+
error = events[1][1]
|
|
515
|
+
assert error["code"] == "run_failed" and error["error_id"] in error["message"]
|
|
516
|
+
assert "10.0.0.7" not in repr(error) and "detail" not in error
|
|
517
|
+
record = await rt.runs.get(events[0][1]["run_id"])
|
|
518
|
+
assert record is not None and record.status == "error"
|
|
519
|
+
|
|
520
|
+
|
|
521
|
+
async def test_recursion_limit_error_part_maps_to_its_code(server, monkeypatch) -> None:
|
|
522
|
+
rt, _sdk = server
|
|
523
|
+
|
|
524
|
+
def failing(**_: Any):
|
|
525
|
+
class _Runs:
|
|
526
|
+
async def stream(self, *a: Any, **k: Any) -> AsyncIterator[Any]:
|
|
527
|
+
yield SimpleNamespace(
|
|
528
|
+
event="error", data={"error": "GraphRecursionError", "message": "..."}
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
return SimpleNamespace(threads=None, runs=_Runs())
|
|
532
|
+
|
|
533
|
+
monkeypatch.setitem(sys.modules, "langgraph_sdk", SimpleNamespace(get_client=failing))
|
|
534
|
+
events = await _events(rt, OWNER, ChatRequest(message="x"), THREAD)
|
|
535
|
+
assert events[-1][1]["code"] == "recursion_limit"
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
async def test_server_conflict_is_reported_as_thread_busy(server, monkeypatch) -> None:
|
|
539
|
+
"""A run started through the native API holds the thread: the server rejects ours."""
|
|
540
|
+
rt, _sdk = server
|
|
541
|
+
|
|
542
|
+
def busy(**_: Any):
|
|
543
|
+
class _Runs:
|
|
544
|
+
async def stream(self, *a: Any, **k: Any) -> AsyncIterator[Any]:
|
|
545
|
+
raise sdk_error(errors.ConflictError, 409, "Thread is busy")
|
|
546
|
+
yield # pragma: no cover
|
|
547
|
+
|
|
548
|
+
return SimpleNamespace(threads=None, runs=_Runs())
|
|
549
|
+
|
|
550
|
+
monkeypatch.setitem(sys.modules, "langgraph_sdk", SimpleNamespace(get_client=busy))
|
|
551
|
+
events = await _events(rt, OWNER, ChatRequest(message="x"), THREAD)
|
|
552
|
+
assert events[-1][0] == "error" and events[-1][1]["code"] == "thread_busy"
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
async def test_second_run_on_a_busy_thread_is_refused_before_the_server(server) -> None:
|
|
556
|
+
rt, sdk = server
|
|
557
|
+
lease = await rt.acquire_thread(THREAD)
|
|
558
|
+
events = await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
|
|
559
|
+
assert events == [
|
|
560
|
+
("error", {"code": "thread_busy", "message": "This thread already has a run in progress."})
|
|
561
|
+
]
|
|
562
|
+
assert sdk.streams == []
|
|
563
|
+
await lease.release()
|
|
564
|
+
events = await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
|
|
565
|
+
assert events[-1][0] == "message.end"
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
async def test_timeout_cancels_the_server_run(server, monkeypatch) -> None:
|
|
569
|
+
rt, sdk = server
|
|
570
|
+
sdk.slow_stream = True
|
|
571
|
+
monkeypatch.setenv("RUN_TIMEOUT_S", "0.2")
|
|
572
|
+
events = await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
|
|
573
|
+
assert events[-1][0] == "error" and events[-1][1]["code"] == "timeout"
|
|
574
|
+
assert sdk.cancelled == [(THREAD, "srv-run-1")]
|
|
575
|
+
record = await rt.runs.get(events[0][1]["run_id"])
|
|
576
|
+
assert record is not None and record.status == "timeout"
|
|
577
|
+
assert THREAD not in rt.locks.held
|
|
578
|
+
assert sdk.state_updates == [] # every tool call of the thread has its result
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
async def test_a_stopped_run_gets_its_open_tool_calls_answered(server, monkeypatch) -> None:
|
|
582
|
+
rt, sdk = server
|
|
583
|
+
sdk.slow_stream = True
|
|
584
|
+
sdk.messages = sdk.messages[:2] # the assistant's tool call, no result yet
|
|
585
|
+
monkeypatch.setenv("RUN_TIMEOUT_S", "0.2")
|
|
586
|
+
await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
|
|
587
|
+
# Answered before the run started, right after the call; nothing left for the end.
|
|
588
|
+
(update,) = sdk.state_updates
|
|
589
|
+
assert update["as_node"] == "tools"
|
|
590
|
+
(patch,) = update["values"]["messages"]
|
|
591
|
+
assert patch["tool_call_id"] == "c1" and patch["status"] == "error"
|
|
592
|
+
assert "did not finish" in patch["content"]
|
|
593
|
+
assert sdk.history_at_stream == [["human", "ai", "tool"]]
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
async def test_a_result_after_the_next_turn_is_moved_back_before_the_run(server) -> None:
|
|
597
|
+
"""The shape older versions left on the server: every later turn got a provider 400."""
|
|
598
|
+
rt, sdk = server
|
|
599
|
+
human, call = sdk.messages[:2]
|
|
600
|
+
sdk.messages = [
|
|
601
|
+
human,
|
|
602
|
+
{
|
|
603
|
+
**call,
|
|
604
|
+
"usage_metadata": {"input_tokens": 1, "output_tokens": 1, "total_tokens": 2},
|
|
605
|
+
"invalid_tool_calls": [],
|
|
606
|
+
},
|
|
607
|
+
{"type": "human", "id": "m9", "content": "hello?"},
|
|
608
|
+
{"type": "tool", "id": "t1", "tool_call_id": "c1", "name": "get_weather", "content": "x"},
|
|
609
|
+
]
|
|
610
|
+
events = await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
|
|
611
|
+
assert events[-1][0] == "message.end"
|
|
612
|
+
(update,) = sdk.state_updates
|
|
613
|
+
rewritten = update["values"]["messages"]
|
|
614
|
+
assert rewritten[0] == {"type": "remove", "id": "__remove_all__", "content": ""}
|
|
615
|
+
assert [m["id"] for m in rewritten[1:]] == ["m1", "m2", "t1", "m9"]
|
|
616
|
+
assert "usage_metadata" not in rewritten[2] # only keys the server reads back as-is
|
|
617
|
+
assert sdk.history_at_stream == [["human", "ai", "tool", "human"]]
|
|
618
|
+
assert [m["id"] for m in sdk.messages[:4]] == ["m1", "m2", "t1", "m9"]
|
|
619
|
+
|
|
620
|
+
|
|
621
|
+
async def test_a_healthy_thread_with_repeated_tool_call_ids_is_never_rewritten(server) -> None:
|
|
622
|
+
"""Models reuse ids across turns (`call_0` in every message): each turn's result stays."""
|
|
623
|
+
rt, sdk = server
|
|
624
|
+
human, call, result, reply = sdk.messages
|
|
625
|
+
sdk.messages = [
|
|
626
|
+
human,
|
|
627
|
+
call,
|
|
628
|
+
result,
|
|
629
|
+
reply,
|
|
630
|
+
{"type": "human", "id": "m5", "content": "and in Paris?"},
|
|
631
|
+
{**call, "id": "m6"},
|
|
632
|
+
{**result, "id": "m7", "content": "rainy"},
|
|
633
|
+
{"type": "ai", "id": "m8", "content": "It is rainy."},
|
|
634
|
+
]
|
|
635
|
+
events = await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
|
|
636
|
+
assert events[-1][0] == "message.end"
|
|
637
|
+
assert sdk.state_updates == []
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _invalid_call(message_id: str, call_id: str) -> dict[str, Any]:
|
|
641
|
+
"""An assistant message whose tool call has arguments that are not valid JSON, as stored."""
|
|
642
|
+
return {
|
|
643
|
+
"type": "ai",
|
|
644
|
+
"id": message_id,
|
|
645
|
+
"content": "",
|
|
646
|
+
"tool_calls": [],
|
|
647
|
+
"invalid_tool_calls": [
|
|
648
|
+
{
|
|
649
|
+
"type": "invalid_tool_call",
|
|
650
|
+
"id": call_id,
|
|
651
|
+
"name": "probe",
|
|
652
|
+
"args": "{'query': 'SF'}",
|
|
653
|
+
"error": "not valid JSON",
|
|
654
|
+
}
|
|
655
|
+
],
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _refusals(openai_compatible: Any, messages: list[dict[str, Any]]) -> list[str]:
|
|
660
|
+
"""What an OpenAI-compatible provider refuses of the thread's history, as it is stored."""
|
|
661
|
+
with contextlib.suppress(Exception): # the refusal is recorded; the error is expected
|
|
662
|
+
openai_compatible.model().invoke([*stored_messages(messages), ("user", "next")])
|
|
663
|
+
return openai_compatible.refusals
|
|
664
|
+
|
|
665
|
+
|
|
666
|
+
async def test_an_open_call_with_invalid_arguments_is_answered_before_the_run(
|
|
667
|
+
server, openai_compatible
|
|
668
|
+
) -> None:
|
|
669
|
+
"""LangChain sends an invalid call back to the provider as a call: it needs a result."""
|
|
670
|
+
rt, sdk = server
|
|
671
|
+
human = sdk.messages[0]
|
|
672
|
+
sdk.messages = [human, _invalid_call("m2", "call_0")]
|
|
673
|
+
assert _refusals(openai_compatible, sdk.messages) != [] # the broken thread
|
|
674
|
+
openai_compatible.refusals.clear()
|
|
675
|
+
events = await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
|
|
676
|
+
assert events[-1][0] == "message.end"
|
|
677
|
+
(update,) = sdk.state_updates
|
|
678
|
+
(patch,) = update["values"]["messages"] # appended right after the call
|
|
679
|
+
assert patch["tool_call_id"] == "call_0" and patch["content"] == INVALID_TOOL_CALL_RESULT
|
|
680
|
+
assert sdk.history_at_stream == [["human", "ai", "tool"]]
|
|
681
|
+
assert _refusals(openai_compatible, sdk.messages) == []
|
|
682
|
+
|
|
683
|
+
|
|
684
|
+
async def test_a_rewritten_history_keeps_an_invalid_call_answered(
|
|
685
|
+
server, openai_compatible
|
|
686
|
+
) -> None:
|
|
687
|
+
"""A rewrite rebuilds every message from a dict: the invalid call must stay a call."""
|
|
688
|
+
rt, sdk = server
|
|
689
|
+
human, call = sdk.messages[:2]
|
|
690
|
+
sdk.messages = [
|
|
691
|
+
human,
|
|
692
|
+
_invalid_call("m2", "call_0"),
|
|
693
|
+
{"type": "human", "id": "m3", "content": "hello?"},
|
|
694
|
+
call, # c1, answered only after the next turn: the history is rewritten
|
|
695
|
+
{"type": "human", "id": "m5", "content": "still there?"},
|
|
696
|
+
{"type": "tool", "id": "t1", "tool_call_id": "c1", "name": "probe", "content": "x"},
|
|
697
|
+
]
|
|
698
|
+
events = await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
|
|
699
|
+
assert events[-1][0] == "message.end"
|
|
700
|
+
(update,) = sdk.state_updates
|
|
701
|
+
rewritten = update["values"]["messages"]
|
|
702
|
+
assert rewritten[0]["type"] == "remove"
|
|
703
|
+
assert rewritten[2]["tool_calls"] == [
|
|
704
|
+
{"name": "probe", "args": {}, "id": "call_0", "type": "tool_call"}
|
|
705
|
+
]
|
|
706
|
+
assert rewritten[3]["tool_call_id"] == "call_0"
|
|
707
|
+
assert rewritten[3]["content"] == INVALID_TOOL_CALL_RESULT
|
|
708
|
+
assert [m["id"] for m in rewritten[1:]][:3] == ["m1", "m2", rewritten[3]["id"]]
|
|
709
|
+
assert _refusals(openai_compatible, sdk.messages) == []
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
async def test_one_id_for_two_calls_of_a_message_is_never_rewritten(server) -> None:
|
|
713
|
+
rt, sdk = server
|
|
714
|
+
human, call, result, reply = sdk.messages
|
|
715
|
+
two = {**call, "tool_calls": [call["tool_calls"][0], {**call["tool_calls"][0]}]}
|
|
716
|
+
sdk.messages = [human, two, result, {**result, "id": "m3b", "content": "rainy"}, reply]
|
|
717
|
+
events = await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
|
|
718
|
+
assert events[-1][0] == "message.end"
|
|
719
|
+
assert sdk.state_updates == []
|
|
720
|
+
|
|
721
|
+
|
|
722
|
+
async def test_a_thread_busy_with_a_native_run_is_left_alone(server) -> None:
|
|
723
|
+
"""Its open tool call belongs to the run in progress: never answer it for that run."""
|
|
724
|
+
rt, sdk = server
|
|
725
|
+
sdk.messages = sdk.messages[:2]
|
|
726
|
+
sdk.threads_by_id[THREAD]["status"] = "busy"
|
|
727
|
+
await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
|
|
728
|
+
assert sdk.state_updates == []
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
async def test_the_step_limit_ends_a_server_run_with_a_reply(server) -> None:
|
|
732
|
+
rt, sdk = server
|
|
733
|
+
sdk.stream_error = {"error": "GraphRecursionError", "message": "..."}
|
|
734
|
+
events = await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
|
|
735
|
+
assert [e for e, _ in events] == ["message.start", "message.delta", "message.end"]
|
|
736
|
+
assert events[-1][1]["status"] == "step_limit"
|
|
737
|
+
(update,) = sdk.state_updates
|
|
738
|
+
assert update["as_node"] == "model"
|
|
739
|
+
assert update["values"]["messages"][-1]["content"] == events[1][1]["text"]
|
|
740
|
+
record = await rt.runs.get(events[0][1]["run_id"])
|
|
741
|
+
assert record is not None and record.status == "step_limit"
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
async def test_the_step_limit_reply_waits_for_the_server_to_mark_the_run_done(server) -> None:
|
|
745
|
+
"""The server streams the recursion error before the thread stops being busy."""
|
|
746
|
+
rt, sdk = server
|
|
747
|
+
sdk.stream_error = {"error": "GraphRecursionError", "message": "..."}
|
|
748
|
+
sdk.busy_after_error_s = 0.3
|
|
749
|
+
events = await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
|
|
750
|
+
assert events[-1][0] == "message.end" and events[-1][1]["status"] == "step_limit"
|
|
751
|
+
assert sdk.state_updates and sdk.state_updates[-1]["as_node"] == "model"
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
async def test_a_thread_that_stays_busy_ends_the_run_with_the_recursion_error(
|
|
755
|
+
server, monkeypatch
|
|
756
|
+
) -> None:
|
|
757
|
+
rt, sdk = server
|
|
758
|
+
monkeypatch.setattr(chat_module, "SERVER_IDLE_WAIT_S", 0.2)
|
|
759
|
+
sdk.stream_error = {"error": "GraphRecursionError", "message": "..."}
|
|
760
|
+
sdk.busy_after_error_s = 30
|
|
761
|
+
events = await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
|
|
762
|
+
assert events[-1][0] == "error" and events[-1][1]["code"] == "recursion_limit"
|
|
763
|
+
assert sdk.state_updates == [] # nothing written to a thread another run holds
|
|
764
|
+
record = await rt.runs.get(events[0][1]["run_id"])
|
|
765
|
+
assert record is not None and record.status == "error"
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
async def test_list_and_delete_threads_through_the_server(server, monkeypatch) -> None:
|
|
769
|
+
rt, sdk = server
|
|
770
|
+
await rt.resolve_thread(STRANGER, ChatRequest(message="x", thread_id=OTHER))
|
|
771
|
+
listed = await rt.list_threads(OWNER, limit=10, offset=0)
|
|
772
|
+
assert [t["thread_id"] for t in listed] == [THREAD]
|
|
773
|
+
assert sdk.searches[-1]["metadata"] == {"principal_id": "A"}
|
|
774
|
+
assert sdk.searches[-1]["sort_by"] == "updated_at"
|
|
775
|
+
monkeypatch.setenv("AUTH_READ_ACROSS_ROLES", "auditor")
|
|
776
|
+
listed = await rt.list_threads(AUDITOR, limit=10, offset=0)
|
|
777
|
+
assert {t["thread_id"] for t in listed} == {THREAD, OTHER}
|
|
778
|
+
with pytest.raises(HTTPException) as exc:
|
|
779
|
+
await rt.delete_thread(AUDITOR, THREAD) # read-across is read-only
|
|
780
|
+
assert exc.value.status_code == 403
|
|
781
|
+
await _events(rt, OWNER, ChatRequest(message="x", thread_id=THREAD), THREAD)
|
|
782
|
+
assert await rt.runs.list_for_thread(THREAD)
|
|
783
|
+
await rt.delete_thread(OWNER, THREAD)
|
|
784
|
+
assert sdk.deleted == [THREAD] and await rt.runs.list_for_thread(THREAD) == []
|
|
785
|
+
with pytest.raises(HTTPException) as exc:
|
|
786
|
+
await rt.delete_thread(OWNER, THREAD)
|
|
787
|
+
assert exc.value.status_code == 404
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
async def test_retention_purges_idle_server_threads(server) -> None:
|
|
791
|
+
rt, sdk = server
|
|
792
|
+
fresh = "77777777-7777-7777-7777-777777777777"
|
|
793
|
+
gone = "88888888-8888-8888-8888-888888888888" # deleted through the native API
|
|
794
|
+
sdk.threads_by_id[fresh] = {
|
|
795
|
+
"thread_id": fresh,
|
|
796
|
+
"metadata": {"principal_id": "A"},
|
|
797
|
+
"updated_at": "2999-01-01T00:00:00+00:00",
|
|
798
|
+
}
|
|
799
|
+
old = "2000-01-01T00:00:00+00:00"
|
|
800
|
+
for run_id, thread_id in (("r-fresh", fresh), ("r-gone", gone)):
|
|
801
|
+
await rt.runs.record(
|
|
802
|
+
RunRecord(
|
|
803
|
+
run_id=run_id,
|
|
804
|
+
thread_id=thread_id,
|
|
805
|
+
principal_hash="h",
|
|
806
|
+
model="m",
|
|
807
|
+
status="ok",
|
|
808
|
+
created_at=old,
|
|
809
|
+
)
|
|
810
|
+
)
|
|
811
|
+
assert await rt.purge_expired(30) == 1
|
|
812
|
+
assert sdk.deleted == [THREAD] and fresh in sdk.threads_by_id
|
|
813
|
+
assert await rt.runs.get("r-gone") is None and await rt.runs.get("r-fresh") is not None
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
async def test_run_metadata_hides_the_raw_owner_id_the_server_merges_in(
|
|
817
|
+
server, monkeypatch
|
|
818
|
+
) -> None:
|
|
819
|
+
"""What the server would store: thread metadata merged under the run's own metadata."""
|
|
820
|
+
rt, sdk = server
|
|
821
|
+
monkeypatch.setenv("PRINCIPAL_HASH_SALT", "pepper")
|
|
822
|
+
email = Principal(id="alice@example.com")
|
|
823
|
+
thread = "99999999-9999-9999-9999-999999999999"
|
|
824
|
+
await rt.resolve_thread(email, ChatRequest(message="x", thread_id=thread))
|
|
825
|
+
await _events(rt, email, ChatRequest(message="x", thread_id=thread), thread)
|
|
826
|
+
thread_metadata = sdk.threads_by_id[thread]["metadata"]
|
|
827
|
+
assert thread_metadata["principal_id"] == "alice@example.com" # the ownership stamp
|
|
828
|
+
merged = {**thread_metadata, **sdk.streams[-1]["metadata"]}
|
|
829
|
+
assert "alice@example.com" not in repr(merged)
|
|
830
|
+
assert merged["principal_id"] == email.hashed_id() == merged["principal_hash"]
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
async def test_retention_rechecks_idleness_under_the_lock_on_the_server(server) -> None:
|
|
834
|
+
"""A server thread continued after the purge listed it is kept."""
|
|
835
|
+
rt, sdk = server
|
|
836
|
+
idle_threads = rt._server_idle_threads
|
|
837
|
+
|
|
838
|
+
async def list_then_resume(cutoff: Any, batch: int) -> list[str]:
|
|
839
|
+
candidates = await idle_threads(cutoff, batch)
|
|
840
|
+
assert THREAD in candidates
|
|
841
|
+
sdk.threads_by_id[THREAD]["updated_at"] = "2999-01-01T00:00:00+00:00"
|
|
842
|
+
return candidates
|
|
843
|
+
|
|
844
|
+
rt._server_idle_threads = list_then_resume # type: ignore[method-assign]
|
|
845
|
+
assert await rt.purge_expired(30) == 0
|
|
846
|
+
assert sdk.deleted == [] and THREAD in sdk.threads_by_id
|
|
847
|
+
|
|
848
|
+
|
|
849
|
+
async def test_orphaned_run_records_are_swept_page_by_page(server) -> None:
|
|
850
|
+
"""Old run records of live threads never hide those of deleted ones on later pages."""
|
|
851
|
+
rt, sdk = server
|
|
852
|
+
old = "2000-01-01T00:00:00+00:00"
|
|
853
|
+
live = [f"00000000-0000-0000-0000-00000000000{i}" for i in range(5)]
|
|
854
|
+
for thread_id in live:
|
|
855
|
+
sdk.threads_by_id[thread_id] = {"thread_id": thread_id, "metadata": {"principal_id": "A"}}
|
|
856
|
+
gone = "ffffffff-ffff-ffff-ffff-ffffffffffff" # sorts after every live id
|
|
857
|
+
for n, thread_id in enumerate([*live, gone]):
|
|
858
|
+
await rt.runs.record(
|
|
859
|
+
RunRecord(
|
|
860
|
+
run_id=f"r{n}",
|
|
861
|
+
thread_id=thread_id,
|
|
862
|
+
principal_hash="h",
|
|
863
|
+
model="m",
|
|
864
|
+
status="ok",
|
|
865
|
+
created_at=old,
|
|
866
|
+
)
|
|
867
|
+
)
|
|
868
|
+
assert await rt.sweep_orphaned_runs(30, batch=2) == 1
|
|
869
|
+
assert await rt.runs.list_for_thread(gone) == []
|
|
870
|
+
assert all([await rt.runs.list_for_thread(t) for t in live])
|
|
871
|
+
assert await rt.sweep_orphaned_runs(0) == 0 # RETENTION_DAYS=0 keeps everything
|
|
872
|
+
|
|
873
|
+
|
|
874
|
+
async def test_the_native_thread_delete_drops_the_run_records(server) -> None:
|
|
875
|
+
"""The server's own DELETE /threads/{id} (not the app's) removes the app's run records."""
|
|
876
|
+
rt, _sdk = server
|
|
877
|
+
from {{cookiecutter.agent_directory}}.app_utils.middleware import ThreadDeleteHookMiddleware
|
|
878
|
+
|
|
879
|
+
for run_id, thread_id in (("r1", THREAD), ("r2", OTHER)):
|
|
880
|
+
await rt.runs.record(
|
|
881
|
+
RunRecord(
|
|
882
|
+
run_id=run_id, thread_id=thread_id, principal_hash="h", model="m", status="ok"
|
|
883
|
+
)
|
|
884
|
+
)
|
|
885
|
+
answers = {THREAD: 204, OTHER: 404}
|
|
886
|
+
|
|
887
|
+
async def native_api(scope: Any, receive: Any, send: Any) -> None:
|
|
888
|
+
status = answers.get(scope["path"].rsplit("/", 1)[-1].lower(), 404)
|
|
889
|
+
await send({"type": "http.response.start", "status": status, "headers": []})
|
|
890
|
+
await send({"type": "http.response.body", "body": b""})
|
|
891
|
+
|
|
892
|
+
app = ThreadDeleteHookMiddleware(native_api, on_deleted=rt.forget_thread_runs)
|
|
893
|
+
async with httpx.AsyncClient(
|
|
894
|
+
transport=httpx.ASGITransport(app=app), base_url="http://server"
|
|
895
|
+
) as client:
|
|
896
|
+
assert (await client.get(f"/threads/{THREAD}")).status_code == 204 # not a delete
|
|
897
|
+
assert await rt.runs.get("r1") is not None
|
|
898
|
+
assert (await client.delete(f"/threads/{OTHER}")).status_code == 404 # refused
|
|
899
|
+
assert await rt.runs.get("r2") is not None
|
|
900
|
+
# Any spelling of the id the server accepts names the canonical records.
|
|
901
|
+
assert (await client.delete(f"/threads/{THREAD.upper()}")).status_code == 204
|
|
902
|
+
assert await rt.runs.get("r1") is None and await rt.runs.get("r2") is not None
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def test_the_server_runtime_leaves_thread_deletion_to_the_native_api(monkeypatch) -> None:
|
|
906
|
+
"""A DELETE /threads/{id} route of the app would shadow the server's own (and its loopback)."""
|
|
907
|
+
import importlib
|
|
908
|
+
|
|
909
|
+
from {{cookiecutter.agent_directory}} import fast_api_app as module
|
|
910
|
+
|
|
911
|
+
def routes(app: Any) -> set[tuple[str, str]]:
|
|
912
|
+
return {(r.path, m) for r in app.routes for m in (getattr(r, "methods", None) or ())}
|
|
913
|
+
|
|
914
|
+
assert ("/threads/{thread_id}", "DELETE") in routes(module.app)
|
|
915
|
+
assert not {"AuthErrorMiddleware", "ThreadDeleteHookMiddleware"} & {
|
|
916
|
+
m.cls.__name__ for m in module.app.user_middleware
|
|
917
|
+
}
|
|
918
|
+
monkeypatch.setenv("RUNTIME", "langgraph-server")
|
|
919
|
+
try:
|
|
920
|
+
server_app = importlib.reload(module).app
|
|
921
|
+
server_routes = routes(server_app)
|
|
922
|
+
assert ("/threads/{thread_id}", "DELETE") not in server_routes
|
|
923
|
+
assert {("/threads", "GET"), ("/ready", "GET"), ("/chat", "POST")} <= server_routes
|
|
924
|
+
# The native API's auth errors and thread deletes pass through the app.
|
|
925
|
+
assert {"AuthErrorMiddleware", "ThreadDeleteHookMiddleware"} <= {
|
|
926
|
+
m.cls.__name__ for m in server_app.user_middleware
|
|
927
|
+
}
|
|
928
|
+
finally:
|
|
929
|
+
monkeypatch.setenv("RUNTIME", "fastapi")
|
|
930
|
+
importlib.reload(module)
|
|
931
|
+
|
|
932
|
+
|
|
933
|
+
def test_persistence_kind_follows_database_uri(server, monkeypatch) -> None:
|
|
934
|
+
rt, _sdk = server
|
|
935
|
+
monkeypatch.setenv("DATABASE_URI", ":memory:") # what `langgraph dev` sets
|
|
936
|
+
assert rt.checkpointer_kind() == "memory" and not Database.for_server().is_postgres
|
|
937
|
+
monkeypatch.setenv("DATABASE_URI", "postgresql://u:p@db:5432/agent")
|
|
938
|
+
assert rt.checkpointer_kind() == "postgres"
|
|
939
|
+
db = Database.for_server()
|
|
940
|
+
assert db.is_postgres and db.runs_table == "agent_runs" and not db.with_threads
|
|
941
|
+
|
|
942
|
+
|
|
943
|
+
async def test_ready_checks_the_server(server, monkeypatch) -> None:
|
|
944
|
+
rt, _sdk = server
|
|
945
|
+
assert await rt.ready() is True
|
|
946
|
+
|
|
947
|
+
def broken(**_: Any):
|
|
948
|
+
class _Assistants:
|
|
949
|
+
async def search(self, **_: Any):
|
|
950
|
+
raise httpx.ConnectError("down")
|
|
951
|
+
|
|
952
|
+
return SimpleNamespace(assistants=_Assistants())
|
|
953
|
+
|
|
954
|
+
monkeypatch.setitem(sys.modules, "langgraph_sdk", SimpleNamespace(get_client=broken))
|
|
955
|
+
assert await rt.ready() is False
|
|
956
|
+
|
|
957
|
+
|
|
958
|
+
# --- threads an agent starts for the user (0.3) ------------------------------------------------
|
|
959
|
+
|
|
960
|
+
AGENT_A = Principal(id="A", actor=Actor(id="concierge", chain=("concierge",)))
|
|
961
|
+
OTHER_AGENT_A = Principal(id="A", actor=Actor(id="billing", chain=("billing",)))
|
|
962
|
+
|
|
963
|
+
|
|
964
|
+
async def test_an_agents_thread_is_stamped_and_kept_from_other_agents(server) -> None:
|
|
965
|
+
rt, sdk = server
|
|
966
|
+
new_thread = "55555555-5555-5555-5555-555555555555"
|
|
967
|
+
await rt.resolve_thread(AGENT_A, ChatRequest(message="x", thread_id=new_thread))
|
|
968
|
+
assert sdk.threads_by_id[new_thread]["metadata"]["actor"] == "concierge"
|
|
969
|
+
assert sdk.threads_by_id[new_thread]["metadata"]["principal_id"] == "A"
|
|
970
|
+
# The person continues and reads it; another agent of theirs does neither.
|
|
971
|
+
assert await rt.resolve_thread(OWNER, ChatRequest(message="x", thread_id=new_thread))
|
|
972
|
+
await rt.messages(OWNER, new_thread)
|
|
973
|
+
for action in (
|
|
974
|
+
rt.resolve_thread(OTHER_AGENT_A, ChatRequest(message="x", thread_id=new_thread)),
|
|
975
|
+
rt.messages(OTHER_AGENT_A, new_thread),
|
|
976
|
+
rt.delete_thread(OTHER_AGENT_A, new_thread),
|
|
977
|
+
):
|
|
978
|
+
with pytest.raises(HTTPException) as exc:
|
|
979
|
+
await action
|
|
980
|
+
assert exc.value.status_code == 403
|
|
981
|
+
# The fixture's thread (no actor: the person's own, or one from before 0.3) is no agent's.
|
|
982
|
+
with pytest.raises(HTTPException) as exc:
|
|
983
|
+
await rt.resolve_thread(AGENT_A, ChatRequest(message="x", thread_id=THREAD))
|
|
984
|
+
assert exc.value.status_code == 403
|
|
985
|
+
# An agent's listing names its actor; the person's does not.
|
|
986
|
+
await rt.list_threads(AGENT_A, limit=10, offset=0)
|
|
987
|
+
assert sdk.searches[-1]["metadata"] == {"principal_id": "A", "actor": "concierge"}
|
|
988
|
+
await rt.list_threads(OWNER, limit=10, offset=0)
|
|
989
|
+
assert sdk.searches[-1]["metadata"] == {"principal_id": "A"}
|