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,247 @@
|
|
|
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
|
+
"""Threads agents start for a user, under a real LangGraph dev server (the native API filters).
|
|
16
|
+
|
|
17
|
+
The server runs this app and its auth handler as `test_approvals_server.py`
|
|
18
|
+
does, with `AUTH_ALLOWED_ACTORS` set. Tokens carry the RFC 8693 `act` claim of
|
|
19
|
+
the agents presenting them. The server's own thread filters are containment
|
|
20
|
+
filters on the thread metadata: the user's (direct) filter must match the
|
|
21
|
+
threads their agents started, an agent's must match only its own, and a thread
|
|
22
|
+
without `actor` (the user's own, or one created before 0.3) must match no
|
|
23
|
+
agent's. A run the native API starts acts for the authenticated caller, with
|
|
24
|
+
its roles and actor, whatever run context the request sends. Skipped when the
|
|
25
|
+
LangGraph CLI and its in-memory server are not installed.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import importlib.util
|
|
31
|
+
import json
|
|
32
|
+
import sys
|
|
33
|
+
import threading
|
|
34
|
+
import time
|
|
35
|
+
import uuid
|
|
36
|
+
from collections.abc import Iterator
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
import httpx
|
|
41
|
+
import jwt
|
|
42
|
+
import pytest
|
|
43
|
+
from test_approvals_server import (
|
|
44
|
+
AUDIENCE,
|
|
45
|
+
BODY,
|
|
46
|
+
ISSUER,
|
|
47
|
+
KEY,
|
|
48
|
+
POLICY,
|
|
49
|
+
Upstream,
|
|
50
|
+
_free_port,
|
|
51
|
+
_server_env,
|
|
52
|
+
_start_dev,
|
|
53
|
+
_stop,
|
|
54
|
+
_write_config,
|
|
55
|
+
parse_sse,
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
LANGGRAPH = Path(sys.executable).with_name("langgraph")
|
|
59
|
+
pytestmark = pytest.mark.skipif(
|
|
60
|
+
not LANGGRAPH.exists() or importlib.util.find_spec("langgraph_api") is None,
|
|
61
|
+
reason="needs the LangGraph CLI with its in-memory server (langgraph-cli[inmem])",
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _token(user: str, *actors: str) -> dict[str, str]:
|
|
66
|
+
"""`user`'s token, presented by `actors` (current first) when any."""
|
|
67
|
+
now = int(time.time())
|
|
68
|
+
claims: dict[str, Any] = {
|
|
69
|
+
"sub": user,
|
|
70
|
+
"iss": ISSUER,
|
|
71
|
+
"aud": AUDIENCE,
|
|
72
|
+
"iat": now,
|
|
73
|
+
"nbf": now,
|
|
74
|
+
"exp": now + 600,
|
|
75
|
+
"roles": ["user"],
|
|
76
|
+
}
|
|
77
|
+
act: dict[str, Any] | None = None
|
|
78
|
+
for actor in reversed(actors):
|
|
79
|
+
act = {"sub": actor} if act is None else {"sub": actor, "act": act}
|
|
80
|
+
if act is not None:
|
|
81
|
+
claims["act"] = act
|
|
82
|
+
return {"Authorization": f"Bearer {jwt.encode(claims, KEY, algorithm='RS256')}"}
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@pytest.fixture(scope="module")
|
|
86
|
+
def server(tmp_path_factory: pytest.TempPathFactory) -> Iterator[str]:
|
|
87
|
+
work = tmp_path_factory.mktemp("cross-actor-server")
|
|
88
|
+
upstream = Upstream()
|
|
89
|
+
threading.Thread(target=upstream.serve_forever, daemon=True).start()
|
|
90
|
+
body_file = work / "body.json"
|
|
91
|
+
body_file.write_text(json.dumps(BODY), encoding="utf-8")
|
|
92
|
+
policy = work / "api-policy.yaml"
|
|
93
|
+
policy.write_text(POLICY, encoding="utf-8")
|
|
94
|
+
_write_config(work)
|
|
95
|
+
env = {
|
|
96
|
+
**_server_env(policy, body_file, upstream),
|
|
97
|
+
"AUTH_ALLOWED_ACTORS": "concierge,billing",
|
|
98
|
+
}
|
|
99
|
+
port = _free_port()
|
|
100
|
+
proc = None
|
|
101
|
+
try:
|
|
102
|
+
proc = _start_dev(work, env, port, work / "server.log")
|
|
103
|
+
yield f"http://127.0.0.1:{port}"
|
|
104
|
+
finally:
|
|
105
|
+
if proc is not None:
|
|
106
|
+
_stop(proc)
|
|
107
|
+
upstream.shutdown()
|
|
108
|
+
upstream.server_close()
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _get(server: str, thread: str, headers: dict[str, str]) -> httpx.Response:
|
|
112
|
+
return httpx.get(f"{server}/threads/{thread}", headers=headers, timeout=30)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _listed(server: str, headers: dict[str, str]) -> set[str]:
|
|
116
|
+
r = httpx.post(f"{server}/threads/search", json={"limit": 100}, headers=headers, timeout=30)
|
|
117
|
+
assert r.status_code == 200, r.text
|
|
118
|
+
return {t["thread_id"] for t in r.json()}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _patch(server: str, thread: str, metadata: dict[str, Any], headers: dict[str, str]) -> Any:
|
|
122
|
+
return httpx.patch(
|
|
123
|
+
f"{server}/threads/{thread}", json={"metadata": metadata}, headers=headers, timeout=30
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def test_native_threads_follow_the_owner_key(server: str) -> None:
|
|
128
|
+
user = f"alice-{uuid.uuid4().hex[:8]}"
|
|
129
|
+
alice, concierge = _token(user), _token(user, "concierge")
|
|
130
|
+
billing = _token(user, "billing", "concierge")
|
|
131
|
+
# The concierge's thread is stamped with its actor, whatever it sends.
|
|
132
|
+
created = httpx.post(
|
|
133
|
+
f"{server}/threads",
|
|
134
|
+
json={"metadata": {"actor": "billing", "topic": "x"}},
|
|
135
|
+
headers=concierge,
|
|
136
|
+
timeout=30,
|
|
137
|
+
)
|
|
138
|
+
assert created.status_code == 200, created.text
|
|
139
|
+
thread = created.json()["thread_id"]
|
|
140
|
+
assert created.json()["metadata"]["actor"] == "concierge"
|
|
141
|
+
assert created.json()["metadata"]["principal_id"] == user
|
|
142
|
+
# The person reads and lists it; another agent of the same user does neither.
|
|
143
|
+
assert _get(server, thread, alice).status_code == 200
|
|
144
|
+
assert thread in _listed(server, alice) and thread in _listed(server, concierge)
|
|
145
|
+
assert _get(server, thread, billing).status_code in (403, 404)
|
|
146
|
+
assert thread not in _listed(server, billing)
|
|
147
|
+
# The person's own thread (as any thread from before 0.3: no actor) is no agent's.
|
|
148
|
+
direct = httpx.post(
|
|
149
|
+
f"{server}/threads", json={"metadata": {"actor": "concierge"}}, headers=alice, timeout=30
|
|
150
|
+
)
|
|
151
|
+
assert direct.status_code == 200 and "actor" not in direct.json()["metadata"]
|
|
152
|
+
mine = direct.json()["thread_id"]
|
|
153
|
+
assert _get(server, mine, concierge).status_code in (403, 404)
|
|
154
|
+
assert mine not in _listed(server, concierge) and mine in _listed(server, alice)
|
|
155
|
+
# Nobody moves or strips the actor.
|
|
156
|
+
moved = _patch(server, thread, {"actor": "billing"}, concierge)
|
|
157
|
+
assert moved.status_code == 200 and moved.json()["metadata"]["actor"] == "concierge"
|
|
158
|
+
stripped = _patch(server, thread, {"actor": ""}, alice)
|
|
159
|
+
assert stripped.status_code == 200 and stripped.json()["metadata"]["actor"] == "concierge"
|
|
160
|
+
assert _patch(server, thread, {"topic": "y"}, billing).status_code in (403, 404)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_the_apps_routes_stamp_the_actor_under_this_runtime(server: str) -> None:
|
|
164
|
+
user = f"alice-{uuid.uuid4().hex[:8]}"
|
|
165
|
+
alice, concierge = _token(user), _token(user, "concierge")
|
|
166
|
+
r = httpx.post(f"{server}/chat", json={"message": "hello"}, headers=concierge, timeout=60)
|
|
167
|
+
assert r.status_code == 200, r.text
|
|
168
|
+
thread = parse_sse(r.text)[0][1]["thread_id"]
|
|
169
|
+
assert _get(server, thread, alice).json()["metadata"]["actor"] == "concierge"
|
|
170
|
+
for who, allowed in ((alice, True), (concierge, True), (_token(user, "billing"), False)):
|
|
171
|
+
messages = httpx.get(f"{server}/threads/{thread}/messages", headers=who, timeout=30)
|
|
172
|
+
assert (messages.status_code == 200) is allowed, messages.text
|
|
173
|
+
# An agent the server does not list is refused before anything runs.
|
|
174
|
+
other = httpx.post(
|
|
175
|
+
f"{server}/chat", json={"message": "hello"}, headers=_token(user, "stranger"), timeout=30
|
|
176
|
+
)
|
|
177
|
+
assert other.status_code == 403
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _native_whoami(
|
|
181
|
+
server: str,
|
|
182
|
+
headers: dict[str, str],
|
|
183
|
+
*,
|
|
184
|
+
context: dict[str, Any] | None = None,
|
|
185
|
+
config: dict[str, Any] | None = None,
|
|
186
|
+
) -> dict[str, Any]:
|
|
187
|
+
"""Who the `whoami` tool of a native run (`POST /threads/{id}/runs/wait`) acts for."""
|
|
188
|
+
created = httpx.post(f"{server}/threads", json={}, headers=headers, timeout=30)
|
|
189
|
+
assert created.status_code == 200, created.text
|
|
190
|
+
body: dict[str, Any] = {
|
|
191
|
+
"assistant_id": "agent",
|
|
192
|
+
"input": {"messages": [{"role": "user", "content": "whoami for me"}]},
|
|
193
|
+
}
|
|
194
|
+
if context is not None:
|
|
195
|
+
body["context"] = context
|
|
196
|
+
if config is not None:
|
|
197
|
+
body["config"] = config
|
|
198
|
+
thread = created.json()["thread_id"]
|
|
199
|
+
r = httpx.post(f"{server}/threads/{thread}/runs/wait", json=body, headers=headers, timeout=60)
|
|
200
|
+
assert r.status_code == 200, r.text
|
|
201
|
+
results = [m for m in r.json().get("messages", []) if m.get("type") == "tool"]
|
|
202
|
+
assert results, r.text
|
|
203
|
+
return json.loads(results[-1]["content"])
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def test_a_native_run_acts_for_the_authenticated_caller_whatever_it_sends(server: str) -> None:
|
|
207
|
+
"""The native run API takes a run context in the request (`context`, or
|
|
208
|
+
`config.configurable`, which the server copies into it); tools read who they act
|
|
209
|
+
for from it. The auth handler puts the caller's own there instead."""
|
|
210
|
+
user = f"alice-{uuid.uuid4().hex[:8]}"
|
|
211
|
+
alice, concierge = _token(user), _token(user, "concierge")
|
|
212
|
+
as_alice = {
|
|
213
|
+
"principal_id": user,
|
|
214
|
+
"roles": ["user"],
|
|
215
|
+
"actor": None,
|
|
216
|
+
"actor_chain": [],
|
|
217
|
+
"direct_only": "allowed",
|
|
218
|
+
}
|
|
219
|
+
# A person names another principal and roles, or poses as an agent: the tools
|
|
220
|
+
# still act for them, with their own roles.
|
|
221
|
+
bob = {"principal_id": "bob", "roles": ["ops"], "attributes": {}}
|
|
222
|
+
posing = {
|
|
223
|
+
"principal_id": user,
|
|
224
|
+
"roles": ["user"],
|
|
225
|
+
"attributes": {"@actor": {"id": "concierge", "chain": ["concierge"], "client": None}},
|
|
226
|
+
}
|
|
227
|
+
assert _native_whoami(server, alice, context=bob) == as_alice
|
|
228
|
+
assert _native_whoami(server, alice, config={"configurable": bob}) == as_alice
|
|
229
|
+
assert _native_whoami(server, alice, context=posing) == as_alice
|
|
230
|
+
assert _native_whoami(server, alice) == as_alice # nothing sent: still the caller's
|
|
231
|
+
# An agent cannot drop its actor (a tool only a person may trigger refuses it),
|
|
232
|
+
# name another one, or take roles it was not lent.
|
|
233
|
+
for sent in (
|
|
234
|
+
None,
|
|
235
|
+
{"principal_id": user, "roles": [], "attributes": {}},
|
|
236
|
+
{"principal_id": user, "roles": ["ops"], "attributes": {"@actor": {"id": "billing"}}},
|
|
237
|
+
):
|
|
238
|
+
seen = _native_whoami(server, concierge, context=sent)
|
|
239
|
+
assert seen["principal_id"] == user and seen["roles"] == [], seen
|
|
240
|
+
assert seen["actor"] == "concierge" and seen["actor_chain"] == ["concierge"], seen
|
|
241
|
+
refusal = "only the user directly may ask for this, not agent 'concierge'"
|
|
242
|
+
assert refusal in seen["direct_only"], seen
|
|
243
|
+
# The whole chain of agents reaches the tools, as through /chat.
|
|
244
|
+
seen = _native_whoami(
|
|
245
|
+
server, _token(user, "billing", "concierge"), context={"principal_id": user}
|
|
246
|
+
)
|
|
247
|
+
assert seen["actor"] == "billing" and seen["actor_chain"] == ["billing", "concierge"], seen
|
|
@@ -0,0 +1,278 @@
|
|
|
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
|
+
"""A thread whose tool-call history a stopped run left invalid is repaired by the next run.
|
|
16
|
+
|
|
17
|
+
Model providers reject an assistant tool call that is not followed right away
|
|
18
|
+
by its result. A process that dies mid tool call (a crash, an OOM kill, a lost
|
|
19
|
+
node, a database outage) never writes the result; older versions also wrote
|
|
20
|
+
the repair after the next user turn. Each run puts the history right before it
|
|
21
|
+
appends its own turn (in-process, fake model, memory checkpointer).
|
|
22
|
+
|
|
23
|
+
A model's tool call whose arguments are not valid JSON needs a result too
|
|
24
|
+
(LangChain sends it back to the provider as a call): the agent answers it in
|
|
25
|
+
the same run, and the repair answers one an older version left open. Those
|
|
26
|
+
tests run the agent on `ChatOpenAI` against an in-process OpenAI-compatible
|
|
27
|
+
server that refuses invalid histories as OpenAI does (`openai_compatible`).
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import os
|
|
33
|
+
import uuid
|
|
34
|
+
from collections.abc import AsyncIterator
|
|
35
|
+
from typing import Any
|
|
36
|
+
|
|
37
|
+
os.environ.update(
|
|
38
|
+
{
|
|
39
|
+
"MODEL_PROVIDER": "fake",
|
|
40
|
+
"MODEL_NAME": "fake",
|
|
41
|
+
"CHECKPOINTER": "memory",
|
|
42
|
+
"AUTH_POLICY": "shared-bearer",
|
|
43
|
+
"API_KEY": "test-key",
|
|
44
|
+
"APP_ENV": "dev",
|
|
45
|
+
"TRACING_ENABLED": "false",
|
|
46
|
+
"RUNTIME": "fastapi",
|
|
47
|
+
"APP_URL": "http://testserver",
|
|
48
|
+
}
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
import httpx
|
|
52
|
+
import pytest
|
|
53
|
+
from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
|
|
54
|
+
|
|
55
|
+
from {{cookiecutter.agent_directory}} import agent as agent_module
|
|
56
|
+
from {{cookiecutter.agent_directory}}.app_utils.chat import repair_tool_history
|
|
57
|
+
from {{cookiecutter.agent_directory}}.app_utils.content import INVALID_TOOL_CALL_RESULT
|
|
58
|
+
from {{cookiecutter.agent_directory}}.fast_api_app import app
|
|
59
|
+
|
|
60
|
+
AUTH = {"Authorization": "Bearer test-key"}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@pytest.fixture
|
|
64
|
+
async def client(monkeypatch: pytest.MonkeyPatch) -> AsyncIterator[httpx.AsyncClient]:
|
|
65
|
+
for name in ("RUN_TIMEOUT_S", "RECURSION_LIMIT", "RETENTION_DAYS", "TRACE_CAPTURE"):
|
|
66
|
+
monkeypatch.delenv(name, raising=False)
|
|
67
|
+
async with app.router.lifespan_context(app):
|
|
68
|
+
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
|
69
|
+
async with httpx.AsyncClient(
|
|
70
|
+
transport=transport, base_url="http://testserver", timeout=30
|
|
71
|
+
) as c:
|
|
72
|
+
yield c
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
async def chat(client: httpx.AsyncClient, message: str, thread_id: str) -> str:
|
|
76
|
+
r = await client.post("/chat", json={"message": message, "thread_id": thread_id}, headers=AUTH)
|
|
77
|
+
assert r.status_code == 200, r.text
|
|
78
|
+
assert "event: message.end" in r.text, r.text
|
|
79
|
+
return r.text
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def history(thread_id: str) -> list[Any]:
|
|
83
|
+
state = await agent_module.graph.aget_state({"configurable": {"thread_id": thread_id}})
|
|
84
|
+
return list(state.values.get("messages", []))
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def roles(messages: list[Any]) -> list[str]:
|
|
88
|
+
out = []
|
|
89
|
+
for m in messages:
|
|
90
|
+
if isinstance(m, ToolMessage):
|
|
91
|
+
out.append("tool!" if m.status == "error" else "tool")
|
|
92
|
+
elif isinstance(m, AIMessage):
|
|
93
|
+
out.append("ai(tc)" if m.tool_calls else "ai")
|
|
94
|
+
else:
|
|
95
|
+
out.append("user")
|
|
96
|
+
return out
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def open_call(call_id: str) -> AIMessage:
|
|
100
|
+
return AIMessage(
|
|
101
|
+
content="",
|
|
102
|
+
id=f"ai-{call_id}",
|
|
103
|
+
tool_calls=[{"id": call_id, "name": "get_weather", "args": {"query": "Paris"}}],
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
async def seed(thread_id: str, messages: list[Any], as_node: str = "model") -> None:
|
|
108
|
+
"""Write `messages` into the thread as a stopped run would have left them."""
|
|
109
|
+
await agent_module.graph.aupdate_state(
|
|
110
|
+
{"configurable": {"thread_id": thread_id}}, {"messages": messages}, as_node=as_node
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
async def test_a_tool_call_left_open_by_a_dead_run_is_answered_before_the_next_turn(
|
|
115
|
+
client: httpx.AsyncClient,
|
|
116
|
+
) -> None:
|
|
117
|
+
thread_id = str(uuid.uuid4())
|
|
118
|
+
await chat(client, "hello", thread_id) # the thread and its owner exist
|
|
119
|
+
# The process died after the model asked for the tool, before its result.
|
|
120
|
+
await seed(thread_id, [HumanMessage(content="weather?", id="u2"), open_call("c1")])
|
|
121
|
+
assert roles(await history(thread_id)) == ["user", "ai", "user", "ai(tc)"]
|
|
122
|
+
await chat(client, "are you there?", thread_id)
|
|
123
|
+
messages = await history(thread_id)
|
|
124
|
+
assert roles(messages) == ["user", "ai", "user", "ai(tc)", "tool!", "user", "ai"]
|
|
125
|
+
assert messages[4].tool_call_id == "c1" and "interrupted" in messages[4].content
|
|
126
|
+
assert repair_tool_history(messages, lambda call: None) is None # valid for providers
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def test_a_result_written_after_the_next_turn_is_moved_back(
|
|
130
|
+
client: httpx.AsyncClient,
|
|
131
|
+
) -> None:
|
|
132
|
+
"""The shape older versions left: every later turn failed with a provider 400."""
|
|
133
|
+
thread_id = str(uuid.uuid4())
|
|
134
|
+
await chat(client, "hello", thread_id)
|
|
135
|
+
await seed(
|
|
136
|
+
thread_id,
|
|
137
|
+
[
|
|
138
|
+
HumanMessage(content="weather?", id="u2"),
|
|
139
|
+
open_call("c1"),
|
|
140
|
+
HumanMessage(content="hello?", id="u3"),
|
|
141
|
+
ToolMessage(content="stopped", tool_call_id="c1", status="error", id="t1"),
|
|
142
|
+
],
|
|
143
|
+
as_node="tools",
|
|
144
|
+
)
|
|
145
|
+
await chat(client, "still there?", thread_id)
|
|
146
|
+
messages = await history(thread_id)
|
|
147
|
+
assert roles(messages) == ["user", "ai", "user", "ai(tc)", "tool!", "user", "user", "ai"]
|
|
148
|
+
assert [m.id for m in messages[2:6]] == ["u2", "ai-c1", "t1", "u3"]
|
|
149
|
+
assert repair_tool_history(messages, lambda call: None) is None
|
|
150
|
+
# Nothing more to repair on the turn after.
|
|
151
|
+
before = [m.id for m in await history(thread_id)]
|
|
152
|
+
await chat(client, "and again", thread_id)
|
|
153
|
+
assert [m.id for m in await history(thread_id)][: len(before)] == before
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
async def test_turns_that_reuse_a_tool_call_id_keep_every_result(
|
|
157
|
+
client: httpx.AsyncClient, use_test_tools, caplog: pytest.LogCaptureFixture
|
|
158
|
+
) -> None:
|
|
159
|
+
"""The fake model calls `call_probe` every turn (models may number ids per message):
|
|
160
|
+
a healthy thread is never rewritten, and each turn keeps its own result."""
|
|
161
|
+
from langchain_core.tools import tool
|
|
162
|
+
|
|
163
|
+
@tool
|
|
164
|
+
def probe(query: str) -> str:
|
|
165
|
+
"""Test-only tool: reports what it was asked about."""
|
|
166
|
+
return f"probe reading for {query}"
|
|
167
|
+
|
|
168
|
+
use_test_tools(probe)
|
|
169
|
+
thread_id = str(uuid.uuid4())
|
|
170
|
+
with caplog.at_level("INFO"):
|
|
171
|
+
await chat(client, "Run the probe for SF", thread_id)
|
|
172
|
+
await chat(client, "Run the probe for Paris", thread_id)
|
|
173
|
+
await chat(client, "thanks", thread_id)
|
|
174
|
+
messages = await history(thread_id)
|
|
175
|
+
assert roles(messages) == ["user", "ai(tc)", "tool", "ai"] * 2 + ["user", "ai"]
|
|
176
|
+
calls = [m.tool_calls[0]["id"] for m in messages if isinstance(m, AIMessage) and m.tool_calls]
|
|
177
|
+
assert calls[0] == calls[1] # the same id in both turns
|
|
178
|
+
results = [m.content for m in messages if isinstance(m, ToolMessage)]
|
|
179
|
+
assert results == ["probe reading for SF", "probe reading for Paris"]
|
|
180
|
+
assert not [r for r in caplog.records if "tool results" in r.getMessage()]
|
|
181
|
+
assert not [r for r in caplog.records if "tool calls" in r.getMessage()]
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# --- a provider that refuses invalid histories -----------------------------------------------
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def serve_openai_compatible(openai_compatible: Any, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
188
|
+
"""Serve the agent's graph on `ChatOpenAI` against the in-process OpenAI-compatible fake."""
|
|
189
|
+
from langchain.agents import create_agent
|
|
190
|
+
from langchain_core.tools import tool
|
|
191
|
+
|
|
192
|
+
@tool
|
|
193
|
+
def probe(query: str) -> str:
|
|
194
|
+
"""Test-only tool: reports what it was asked about."""
|
|
195
|
+
return f"probe reading for {query}"
|
|
196
|
+
|
|
197
|
+
graph = create_agent(
|
|
198
|
+
model=openai_compatible.model(),
|
|
199
|
+
tools=[probe],
|
|
200
|
+
system_prompt=agent_module.SYSTEM_PROMPT,
|
|
201
|
+
middleware=agent_module.middleware(),
|
|
202
|
+
context_schema=agent_module.AgentContext,
|
|
203
|
+
)
|
|
204
|
+
graph.checkpointer = agent_module.graph.checkpointer
|
|
205
|
+
monkeypatch.setattr(agent_module, "graph", graph)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
async def events_of(client: httpx.AsyncClient, message: str, thread_id: str) -> list[str]:
|
|
209
|
+
text = await chat(client, message, thread_id)
|
|
210
|
+
return [line[7:] for line in text.splitlines() if line.startswith("event: ")]
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
@pytest.mark.parametrize("script", ["BADARGS", "TRAILINGCOMMA"])
|
|
214
|
+
async def test_a_tool_call_with_invalid_arguments_leaves_the_thread_usable(
|
|
215
|
+
client: httpx.AsyncClient,
|
|
216
|
+
openai_compatible: Any,
|
|
217
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
218
|
+
script: str,
|
|
219
|
+
) -> None:
|
|
220
|
+
"""Arguments that are not valid JSON: the model is told and replies, every turn after works.
|
|
221
|
+
|
|
222
|
+
Before, the run ended with no reply, and the provider refused every later
|
|
223
|
+
turn (the call had no result).
|
|
224
|
+
"""
|
|
225
|
+
serve_openai_compatible(openai_compatible, monkeypatch)
|
|
226
|
+
thread_id = str(uuid.uuid4())
|
|
227
|
+
text = await chat(client, f"probe {script}", thread_id)
|
|
228
|
+
assert "event: tool.call" in text and "event: tool.result" in text
|
|
229
|
+
assert "Found: " in text and '"status": "ok"' in text
|
|
230
|
+
for message in ("thanks", "and again"):
|
|
231
|
+
assert (await events_of(client, message, thread_id))[-1] == "message.end"
|
|
232
|
+
assert openai_compatible.refusals == []
|
|
233
|
+
messages = await history(thread_id)
|
|
234
|
+
assert roles(messages)[:4] == ["user", "ai", "tool!", "ai"]
|
|
235
|
+
assert messages[1].invalid_tool_calls and messages[2].content == INVALID_TOOL_CALL_RESULT
|
|
236
|
+
assert repair_tool_history(messages, lambda call: None) is None
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
async def test_a_thread_an_invalid_call_broke_before_is_repaired_by_the_next_turn(
|
|
240
|
+
client: httpx.AsyncClient, openai_compatible: Any, monkeypatch: pytest.MonkeyPatch
|
|
241
|
+
) -> None:
|
|
242
|
+
"""The shape older versions left: an invalid call and no result, then more turns."""
|
|
243
|
+
thread_id = str(uuid.uuid4())
|
|
244
|
+
await chat(client, "hello", thread_id)
|
|
245
|
+
broken = AIMessage(
|
|
246
|
+
content="",
|
|
247
|
+
id="ai-bad",
|
|
248
|
+
invalid_tool_calls=[
|
|
249
|
+
{"type": "invalid_tool_call", "id": "call_0", "name": "probe", "args": "query=SF"}
|
|
250
|
+
],
|
|
251
|
+
)
|
|
252
|
+
await seed(thread_id, [HumanMessage(content="probe?", id="u2"), broken])
|
|
253
|
+
await seed(thread_id, [HumanMessage(content="hello?", id="u3")], as_node="tools")
|
|
254
|
+
serve_openai_compatible(openai_compatible, monkeypatch)
|
|
255
|
+
assert (await events_of(client, "still there?", thread_id))[-1] == "message.end"
|
|
256
|
+
assert openai_compatible.refusals == []
|
|
257
|
+
messages = await history(thread_id)
|
|
258
|
+
assert roles(messages)[2:6] == ["user", "ai", "tool!", "user"]
|
|
259
|
+
assert [messages[2].id, messages[3].id, messages[5].id] == ["u2", "ai-bad", "u3"]
|
|
260
|
+
assert messages[4].tool_call_id == "call_0"
|
|
261
|
+
assert messages[4].content == INVALID_TOOL_CALL_RESULT
|
|
262
|
+
assert all(body.get("stream") for body in openai_compatible.requests)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
async def test_one_id_for_two_calls_keeps_both_results_on_the_next_turn(
|
|
266
|
+
client: httpx.AsyncClient, openai_compatible: Any, monkeypatch: pytest.MonkeyPatch
|
|
267
|
+
) -> None:
|
|
268
|
+
"""Some OpenAI-compatible servers give parallel calls one id: no result is dropped."""
|
|
269
|
+
serve_openai_compatible(openai_compatible, monkeypatch)
|
|
270
|
+
thread_id = str(uuid.uuid4())
|
|
271
|
+
await chat(client, "probe DUPIDS", thread_id)
|
|
272
|
+
before = [m.id for m in await history(thread_id)]
|
|
273
|
+
assert (await events_of(client, "thanks", thread_id))[-1] == "message.end"
|
|
274
|
+
assert openai_compatible.refusals == []
|
|
275
|
+
messages = await history(thread_id)
|
|
276
|
+
assert [m.id for m in messages[: len(before)]] == before # nothing rewritten
|
|
277
|
+
results = [m.content for m in messages if isinstance(m, ToolMessage)]
|
|
278
|
+
assert sorted(results) == ["probe reading for Rome", "probe reading for SF"]
|