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,584 @@
|
|
|
1
|
+
# Copyright 2026 graph-agents-cli contributors
|
|
2
|
+
#
|
|
3
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
4
|
+
# you may not use this file except in compliance with the License.
|
|
5
|
+
# You may obtain a copy of the License at
|
|
6
|
+
#
|
|
7
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
|
8
|
+
#
|
|
9
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
10
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
11
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
12
|
+
# See the License for the specific language governing permissions and
|
|
13
|
+
# limitations under the License.
|
|
14
|
+
|
|
15
|
+
"""Structured final answers through the app, in-process (the fastapi runtime).
|
|
16
|
+
|
|
17
|
+
With a response schema (`RESPONSE_SCHEMA_PATH` here), a completed `/chat` run
|
|
18
|
+
ends with the answer in `message.end` (`structured_response`) after exactly
|
|
19
|
+
one `message.delta` holding its JSON text; the answer tool never shows as a
|
|
20
|
+
tool call; an answer that never fits ends the run with the error code
|
|
21
|
+
`invalid_structured_response` and leaves the thread usable. A run that pauses
|
|
22
|
+
for an approval gives its answer once resumed. Over A2A the reply's artifact
|
|
23
|
+
holds the JSON text and a data part with the answer, whether streamed or not,
|
|
24
|
+
and after an approval. Both strategies run on the fake model; every test runs
|
|
25
|
+
with the in-memory checkpointer, and on Postgres when `TEST_POSTGRES_DSN` is
|
|
26
|
+
set. `test_structured_server.py` runs the same under the langgraph-server
|
|
27
|
+
runtime.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import asyncio
|
|
33
|
+
import json
|
|
34
|
+
import os
|
|
35
|
+
import uuid
|
|
36
|
+
from collections.abc import AsyncIterator
|
|
37
|
+
from pathlib import Path
|
|
38
|
+
from typing import Any
|
|
39
|
+
from urllib.parse import urlsplit
|
|
40
|
+
|
|
41
|
+
os.environ.update(
|
|
42
|
+
{
|
|
43
|
+
"MODEL_PROVIDER": "fake",
|
|
44
|
+
"MODEL_NAME": "fake",
|
|
45
|
+
"CHECKPOINTER": "memory",
|
|
46
|
+
"AUTH_POLICY": "shared-bearer",
|
|
47
|
+
"API_KEY": "test-key",
|
|
48
|
+
"APP_ENV": "dev",
|
|
49
|
+
"TRACING_ENABLED": "false",
|
|
50
|
+
"RUNTIME": "fastapi",
|
|
51
|
+
"APP_URL": "http://testserver",
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
import httpx
|
|
56
|
+
import pytest
|
|
57
|
+
from a2a.client import ClientConfig, create_client
|
|
58
|
+
from a2a.types import GetTaskRequest, Message, Part, Role, SendMessageRequest, TaskState
|
|
59
|
+
from fastapi import HTTPException
|
|
60
|
+
from google.protobuf import json_format
|
|
61
|
+
from langchain.tools import ToolRuntime
|
|
62
|
+
from langchain_core.tools import tool
|
|
63
|
+
from starlette.requests import Request
|
|
64
|
+
|
|
65
|
+
from {{cookiecutter.agent_directory}}.app_utils import a2a as a2a_module
|
|
66
|
+
from {{cookiecutter.agent_directory}}.app_utils import auth as auth_module
|
|
67
|
+
from {{cookiecutter.agent_directory}}.app_utils.api_client import get_client, reset_policy_cache
|
|
68
|
+
from {{cookiecutter.agent_directory}}.app_utils.auth import ACTIONS, Principal
|
|
69
|
+
from {{cookiecutter.agent_directory}}.app_utils.structured import ANSWER_TOOL, validate
|
|
70
|
+
from {{cookiecutter.agent_directory}}.fast_api_app import app
|
|
71
|
+
|
|
72
|
+
A2A_PATH = "/a2a/{{cookiecutter.agent_directory}}"
|
|
73
|
+
A2A_URL = f"http://testserver{A2A_PATH}"
|
|
74
|
+
SCHEMA: dict[str, Any] = {
|
|
75
|
+
"title": "Order answer",
|
|
76
|
+
"type": "object",
|
|
77
|
+
"properties": {
|
|
78
|
+
# The fake answers with its text reply, which holds the request when no tool
|
|
79
|
+
# ran: a request that says FAILME never fits.
|
|
80
|
+
"answer": {"type": "string", "pattern": "^(?![\\s\\S]*FAILME)"},
|
|
81
|
+
"done": {"type": "boolean"},
|
|
82
|
+
"items": {"type": "array", "items": {"type": "string"}},
|
|
83
|
+
},
|
|
84
|
+
"required": ["answer", "done"],
|
|
85
|
+
"additionalProperties": False,
|
|
86
|
+
}
|
|
87
|
+
POLICY = """
|
|
88
|
+
apis:
|
|
89
|
+
shop:
|
|
90
|
+
base_url_env: SHOP_API_BASE_URL
|
|
91
|
+
auth: none
|
|
92
|
+
allowed_methods: [GET, POST]
|
|
93
|
+
approval:
|
|
94
|
+
required_for:
|
|
95
|
+
operations:
|
|
96
|
+
- operationId: cancelOrder
|
|
97
|
+
path: /orders/{order_id}/cancel
|
|
98
|
+
methods: [POST]
|
|
99
|
+
approvers: [requester]
|
|
100
|
+
timeout_s: 60
|
|
101
|
+
"""
|
|
102
|
+
SENT: list[httpx.Request] = []
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _upstream(request: httpx.Request) -> httpx.Response:
|
|
106
|
+
SENT.append(request)
|
|
107
|
+
return httpx.Response(200, json={"cancelled": request.url.path})
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@tool
|
|
111
|
+
async def cancel_order(order_id: str, runtime: ToolRuntime[Any]) -> str:
|
|
112
|
+
"""Cancel an order by its id."""
|
|
113
|
+
context = getattr(runtime, "context", None)
|
|
114
|
+
client = get_client("shop", context=context, transport=httpx.MockTransport(_upstream))
|
|
115
|
+
data = await client.post(
|
|
116
|
+
"/orders/{order_id}/cancel",
|
|
117
|
+
operation_id="cancelOrder",
|
|
118
|
+
path_params={"order_id": order_id},
|
|
119
|
+
json_body={"reason": "asked"},
|
|
120
|
+
)
|
|
121
|
+
return json.dumps(data)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@tool
|
|
125
|
+
def probe(query: str) -> str:
|
|
126
|
+
"""Run the probe for a place."""
|
|
127
|
+
return f"{query}: 42"
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
class HeaderPolicy:
|
|
131
|
+
"""Test policy: `X-User` is the principal id."""
|
|
132
|
+
|
|
133
|
+
async def authenticate(self, request: Request) -> Principal:
|
|
134
|
+
user = request.headers.get("x-user")
|
|
135
|
+
if not user:
|
|
136
|
+
raise HTTPException(401, "no user", headers={"WWW-Authenticate": "Bearer"})
|
|
137
|
+
return Principal(id=user, roles=["user"], permissions=set(ACTIONS))
|
|
138
|
+
|
|
139
|
+
async def authorize(self, principal: Principal, action: str, resource: str | None) -> None:
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _as(user: str) -> dict[str, str]:
|
|
144
|
+
return {"X-User": user}
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def parse_sse(text: str) -> list[tuple[str, dict[str, Any]]]:
|
|
148
|
+
events: list[tuple[str, dict[str, Any]]] = []
|
|
149
|
+
event = None
|
|
150
|
+
for line in text.splitlines():
|
|
151
|
+
if line.startswith("event:"):
|
|
152
|
+
event = line[6:].strip()
|
|
153
|
+
elif line.startswith("data:") and event:
|
|
154
|
+
events.append((event, json.loads(line[5:].strip())))
|
|
155
|
+
event = None
|
|
156
|
+
return events
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
ADMIN_DSN = os.environ.get("TEST_POSTGRES_DSN", "")
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
@pytest.fixture(params=["memory", "postgres"])
|
|
163
|
+
async def database(
|
|
164
|
+
request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch
|
|
165
|
+
) -> AsyncIterator[str]:
|
|
166
|
+
if request.param == "memory":
|
|
167
|
+
yield "memory"
|
|
168
|
+
return
|
|
169
|
+
if not ADMIN_DSN:
|
|
170
|
+
pytest.skip("TEST_POSTGRES_DSN is not set")
|
|
171
|
+
import psycopg
|
|
172
|
+
|
|
173
|
+
name = f"gac_test_{uuid.uuid4().hex[:12]}"
|
|
174
|
+
async with await psycopg.AsyncConnection.connect(ADMIN_DSN, autocommit=True) as admin:
|
|
175
|
+
await admin.execute(f'CREATE DATABASE "{name}"')
|
|
176
|
+
monkeypatch.setenv("CHECKPOINTER", "postgres")
|
|
177
|
+
monkeypatch.setenv("POSTGRES_DSN", urlsplit(ADMIN_DSN)._replace(path=f"/{name}").geturl())
|
|
178
|
+
yield "postgres"
|
|
179
|
+
async with await psycopg.AsyncConnection.connect(ADMIN_DSN, autocommit=True) as admin:
|
|
180
|
+
await admin.execute(f'DROP DATABASE IF EXISTS "{name}" WITH (FORCE)')
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
@pytest.fixture(params=["tool", "provider"])
|
|
184
|
+
async def client(
|
|
185
|
+
request: pytest.FixtureRequest,
|
|
186
|
+
database: str,
|
|
187
|
+
tmp_path: Path,
|
|
188
|
+
monkeypatch: pytest.MonkeyPatch,
|
|
189
|
+
use_test_tools,
|
|
190
|
+
) -> AsyncIterator[httpx.AsyncClient]:
|
|
191
|
+
schema = tmp_path / "response_schema.json"
|
|
192
|
+
schema.write_text(json.dumps(SCHEMA), encoding="utf-8")
|
|
193
|
+
monkeypatch.setenv("RESPONSE_SCHEMA_PATH", str(schema))
|
|
194
|
+
monkeypatch.setenv("RESPONSE_FORMAT_STRATEGY", request.param)
|
|
195
|
+
policy = tmp_path / "api-policy.yaml"
|
|
196
|
+
policy.write_text(POLICY, encoding="utf-8")
|
|
197
|
+
reset_policy_cache()
|
|
198
|
+
monkeypatch.setenv("API_POLICY_PATH", str(policy))
|
|
199
|
+
monkeypatch.setenv("SHOP_API_BASE_URL", "http://shop.test")
|
|
200
|
+
monkeypatch.setattr(auth_module, "get_policy", lambda: HeaderPolicy())
|
|
201
|
+
SENT.clear()
|
|
202
|
+
async with app.router.lifespan_context(app):
|
|
203
|
+
use_test_tools(cancel_order, probe)
|
|
204
|
+
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
|
|
205
|
+
async with httpx.AsyncClient(
|
|
206
|
+
transport=transport, base_url="http://testserver", timeout=30
|
|
207
|
+
) as c:
|
|
208
|
+
started = asyncio.get_running_loop().time()
|
|
209
|
+
while (await c.get("/ready")).status_code != 200: # postgres: the schema set up
|
|
210
|
+
assert asyncio.get_running_loop().time() - started < 30, "not ready"
|
|
211
|
+
await asyncio.sleep(0.1)
|
|
212
|
+
yield c
|
|
213
|
+
reset_policy_cache()
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
async def _chat(
|
|
217
|
+
client: httpx.AsyncClient, message: str, thread_id: str | None = None
|
|
218
|
+
) -> list[tuple[str, dict[str, Any]]]:
|
|
219
|
+
body: dict[str, Any] = {"message": message}
|
|
220
|
+
if thread_id:
|
|
221
|
+
body["thread_id"] = thread_id
|
|
222
|
+
r = await client.post("/chat", json=body, headers=_as("alice"))
|
|
223
|
+
assert r.status_code == 200, r.text
|
|
224
|
+
return parse_sse(r.text)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _answered(events: list[tuple[str, dict[str, Any]]]) -> dict[str, Any]:
|
|
228
|
+
"""The run's answer, checking how it was delivered: one delta, its JSON text, then the end."""
|
|
229
|
+
names = [e for e, _ in events]
|
|
230
|
+
assert names[-2:] == ["message.delta", "message.end"], names
|
|
231
|
+
assert names.count("message.delta") == 1, names
|
|
232
|
+
end = events[-1][1]
|
|
233
|
+
assert end["status"] == "ok", end
|
|
234
|
+
answer = end["structured_response"]
|
|
235
|
+
assert json.loads(events[-2][1]["text"]) == answer
|
|
236
|
+
assert validate(SCHEMA, answer) == []
|
|
237
|
+
# The answer tool is how the model answers, never a tool call a client sees.
|
|
238
|
+
assert ANSWER_TOOL not in {d.get("name") for e, d in events if e.startswith("tool.")}
|
|
239
|
+
return answer
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
async def test_a_completed_run_ends_with_the_answer(client: httpx.AsyncClient) -> None:
|
|
243
|
+
events = await _chat(client, "Run the probe for Paris")
|
|
244
|
+
names = [e for e, _ in events]
|
|
245
|
+
assert names[:3] == ["message.start", "tool.call", "tool.result"], names
|
|
246
|
+
answer = _answered(events)
|
|
247
|
+
assert answer["answer"] == "Here is what I found: Paris: 42" and answer["done"] is False
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
async def test_every_turn_of_a_thread_has_its_own_answer(client: httpx.AsyncClient) -> None:
|
|
251
|
+
first = await _chat(client, "hello")
|
|
252
|
+
thread_id = first[0][1]["thread_id"]
|
|
253
|
+
assert _answered(first)["answer"] == "Hello! How can I help you today?"
|
|
254
|
+
second = await _chat(client, "Run the probe for Oslo", thread_id)
|
|
255
|
+
assert _answered(second)["answer"] == "Here is what I found: Oslo: 42"
|
|
256
|
+
r = await client.get(f"/threads/{thread_id}/messages", headers=_as("alice"))
|
|
257
|
+
assert r.status_code == 200 and [m["role"] for m in r.json()].count("user") == 2
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
async def test_an_answer_that_never_fits_fails_the_run_and_the_thread_goes_on(
|
|
261
|
+
client: httpx.AsyncClient,
|
|
262
|
+
) -> None:
|
|
263
|
+
events = await _chat(client, "FAILME please")
|
|
264
|
+
names = [e for e, _ in events]
|
|
265
|
+
assert names == ["message.start", "error"], events
|
|
266
|
+
error = events[-1][1]
|
|
267
|
+
assert error["code"] == "invalid_structured_response", error
|
|
268
|
+
assert "did not fit the response schema" in error["message"] and error["error_id"]
|
|
269
|
+
# Nothing of the failed step was kept: the thread takes the next message.
|
|
270
|
+
again = await _chat(client, "hello", error.get("thread_id") or events[0][1]["thread_id"])
|
|
271
|
+
assert _answered(again)["answer"] == "Hello! How can I help you today?"
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
async def test_a_run_paused_for_approval_answers_once_resumed(client: httpx.AsyncClient) -> None:
|
|
275
|
+
paused = await _chat(client, "Cancel the order for 7")
|
|
276
|
+
names = [e for e, _ in paused]
|
|
277
|
+
assert "message.delta" not in names, names # no answer yet
|
|
278
|
+
end = paused[-1][1]
|
|
279
|
+
assert end["status"] == "awaiting_approval" and "structured_response" not in end
|
|
280
|
+
approval = end["approval"]
|
|
281
|
+
r = await client.post(
|
|
282
|
+
f"/threads/{end['thread_id']}/approvals/{approval['approval_id']}",
|
|
283
|
+
json={"decision": "approve"},
|
|
284
|
+
headers=_as("alice"),
|
|
285
|
+
)
|
|
286
|
+
assert r.status_code == 200, r.text
|
|
287
|
+
resumed = parse_sse(r.text)
|
|
288
|
+
answer = _answered(resumed)
|
|
289
|
+
assert "/orders/7/cancel" in answer["answer"] and len(SENT) == 1
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _serve_model(monkeypatch: pytest.MonkeyPatch, model: Any, tools: list[Any]) -> None:
|
|
293
|
+
"""Serve a graph on `model` with `tools`, built as agent.py builds it.
|
|
294
|
+
|
|
295
|
+
The answer check is last in the middleware even when the project's agent.py
|
|
296
|
+
predates it: this tests the runtime, not the project's wiring.
|
|
297
|
+
"""
|
|
298
|
+
from langchain.agents import create_agent
|
|
299
|
+
|
|
300
|
+
from {{cookiecutter.agent_directory}} import agent
|
|
301
|
+
from {{cookiecutter.agent_directory}}.app_utils.structured import (
|
|
302
|
+
StructuredAnswer,
|
|
303
|
+
response_format,
|
|
304
|
+
)
|
|
305
|
+
|
|
306
|
+
middleware = agent.middleware()
|
|
307
|
+
if not any(isinstance(m, StructuredAnswer) for m in middleware):
|
|
308
|
+
middleware.append(StructuredAnswer())
|
|
309
|
+
graph = create_agent(
|
|
310
|
+
model=model,
|
|
311
|
+
tools=tools,
|
|
312
|
+
system_prompt=agent.SYSTEM_PROMPT,
|
|
313
|
+
middleware=middleware,
|
|
314
|
+
context_schema=agent.AgentContext,
|
|
315
|
+
response_format=response_format(model, tools),
|
|
316
|
+
)
|
|
317
|
+
graph.checkpointer = agent.graph.checkpointer
|
|
318
|
+
monkeypatch.setattr(agent, "graph", graph)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
async def test_an_answer_given_beside_a_gated_call_waits_for_the_decision(
|
|
322
|
+
client: httpx.AsyncClient, openai_compatible: Any, monkeypatch: pytest.MonkeyPatch
|
|
323
|
+
) -> None:
|
|
324
|
+
# A model answers and calls a gated API in one message (parallel tool calls, on by
|
|
325
|
+
# default at OpenAI): nothing is sent before the decision, and the resumed run answers.
|
|
326
|
+
fake = openai_compatible
|
|
327
|
+
_serve_model(monkeypatch, fake.model(), [cancel_order, probe])
|
|
328
|
+
tool_strategy = os.environ["RESPONSE_FORMAT_STRATEGY"] == "tool"
|
|
329
|
+
early = json.dumps({"answer": "I cancelled order 7", "done": True})
|
|
330
|
+
cancel = ("call_cancel", "cancel_order", '{"order_id": "7"}')
|
|
331
|
+
if tool_strategy:
|
|
332
|
+
# The try goes back (its cancel did not run); the model then calls the tool alone.
|
|
333
|
+
fake.queue = [
|
|
334
|
+
(None, [("call_answer", ANSWER_TOOL, early), cancel]),
|
|
335
|
+
(None, [("call_again", "cancel_order", '{"order_id": "7"}')]),
|
|
336
|
+
]
|
|
337
|
+
else:
|
|
338
|
+
# The provider strategy: a reply with tool calls is no answer, and its calls run.
|
|
339
|
+
fake.queue = [(early, [cancel])]
|
|
340
|
+
paused = await _chat(client, "Cancel the order for 7")
|
|
341
|
+
end = paused[-1][1]
|
|
342
|
+
assert end["status"] == "awaiting_approval" and "structured_response" not in end, paused
|
|
343
|
+
assert [e for e, _ in paused].count("message.delta") == 0 and SENT == []
|
|
344
|
+
done = {"answer": "Cancelled order 7", "done": True}
|
|
345
|
+
fake.queue = [(None, [("call_done", ANSWER_TOOL, json.dumps(done))])]
|
|
346
|
+
if not tool_strategy:
|
|
347
|
+
fake.queue = [(json.dumps(done), [])]
|
|
348
|
+
r = await client.post(
|
|
349
|
+
f"/threads/{end['thread_id']}/approvals/{end['approval']['approval_id']}",
|
|
350
|
+
json={"decision": "approve"},
|
|
351
|
+
headers=_as("alice"),
|
|
352
|
+
)
|
|
353
|
+
assert r.status_code == 200, r.text
|
|
354
|
+
assert _answered(parse_sse(r.text)) == done
|
|
355
|
+
# The cancel was sent once, and every call the model made got a result.
|
|
356
|
+
assert len(SENT) == 1 and fake.refusals == [] and fake.queue == []
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
async def test_an_agent_not_built_for_its_schema_ends_every_run_with_the_error(
|
|
360
|
+
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
361
|
+
) -> None:
|
|
362
|
+
# A project that added the schema file but not `response_format=` in agent.py (one
|
|
363
|
+
# created before structured answers, say): the runtime still expects an answer.
|
|
364
|
+
from langchain.agents import create_agent
|
|
365
|
+
|
|
366
|
+
from {{cookiecutter.agent_directory}} import agent
|
|
367
|
+
from {{cookiecutter.agent_directory}}.app_utils.model import get_model
|
|
368
|
+
|
|
369
|
+
graph = create_agent(
|
|
370
|
+
model=get_model(),
|
|
371
|
+
tools=[probe],
|
|
372
|
+
system_prompt=agent.SYSTEM_PROMPT,
|
|
373
|
+
middleware=agent.middleware(),
|
|
374
|
+
context_schema=agent.AgentContext,
|
|
375
|
+
)
|
|
376
|
+
graph.checkpointer = agent.graph.checkpointer
|
|
377
|
+
monkeypatch.setattr(agent, "graph", graph)
|
|
378
|
+
events = await _chat(client, "Run the probe for Kyiv")
|
|
379
|
+
# The tool ran and shows; the model's text is not the answer, so nothing else is sent.
|
|
380
|
+
assert [e for e, _ in events] == ["message.start", "tool.call", "tool.result", "error"]
|
|
381
|
+
error = events[-1][1]
|
|
382
|
+
assert error["code"] == "invalid_structured_response", error
|
|
383
|
+
assert "ended without an answer" in error["message"]
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
async def test_an_unchecked_answer_that_does_not_fit_is_never_delivered(
|
|
387
|
+
client: httpx.AsyncClient, monkeypatch: pytest.MonkeyPatch
|
|
388
|
+
) -> None:
|
|
389
|
+
# A project whose agent.py passes `response_format=` but has no `StructuredAnswer()`
|
|
390
|
+
# in its middleware (half wired, an upgraded one say): nothing sends a bad answer
|
|
391
|
+
# back to the model, and the runtime refuses to deliver it.
|
|
392
|
+
from langchain.agents import create_agent
|
|
393
|
+
|
|
394
|
+
from {{cookiecutter.agent_directory}} import agent
|
|
395
|
+
from {{cookiecutter.agent_directory}}.app_utils.model import get_model
|
|
396
|
+
from {{cookiecutter.agent_directory}}.app_utils.structured import (
|
|
397
|
+
StructuredAnswer,
|
|
398
|
+
response_format,
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
model = get_model()
|
|
402
|
+
graph = create_agent(
|
|
403
|
+
model=model,
|
|
404
|
+
tools=[probe],
|
|
405
|
+
system_prompt=agent.SYSTEM_PROMPT,
|
|
406
|
+
middleware=[m for m in agent.middleware() if not isinstance(m, StructuredAnswer)],
|
|
407
|
+
context_schema=agent.AgentContext,
|
|
408
|
+
response_format=response_format(model, [probe]),
|
|
409
|
+
)
|
|
410
|
+
graph.checkpointer = agent.graph.checkpointer
|
|
411
|
+
monkeypatch.setattr(agent, "graph", graph)
|
|
412
|
+
events = await _chat(client, "FAILME please")
|
|
413
|
+
assert [e for e, _ in events] == ["message.start", "error"], events
|
|
414
|
+
error = events[-1][1]
|
|
415
|
+
assert error["code"] == "invalid_structured_response", error
|
|
416
|
+
assert "did not fit the response schema" in error["message"] and error["error_id"]
|
|
417
|
+
# An answer that fits is delivered as ever.
|
|
418
|
+
again = await _chat(client, "hello", events[0][1]["thread_id"])
|
|
419
|
+
assert _answered(again)["answer"] == "Hello! How can I help you today?"
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
# --- A2A ------------------------------------------------------------------------------
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
async def _a2a(streaming: bool) -> tuple[httpx.AsyncClient, Any]:
|
|
426
|
+
http = httpx.AsyncClient(
|
|
427
|
+
transport=httpx.ASGITransport(app=app),
|
|
428
|
+
base_url="http://testserver",
|
|
429
|
+
headers=_as("alice"),
|
|
430
|
+
timeout=30,
|
|
431
|
+
)
|
|
432
|
+
return http, await create_client(A2A_URL, ClientConfig(streaming=streaming, httpx_client=http))
|
|
433
|
+
|
|
434
|
+
|
|
435
|
+
def _answer_parts(parts: Any) -> tuple[str, dict[str, Any]]:
|
|
436
|
+
"""The reply's JSON text and its data part (the answer), checking the pair."""
|
|
437
|
+
text, data = parts
|
|
438
|
+
assert text.HasField("text") and data.HasField("data")
|
|
439
|
+
assert data.media_type == "application/json"
|
|
440
|
+
answer = json_format.MessageToDict(data.data)
|
|
441
|
+
assert json.loads(text.text) == answer
|
|
442
|
+
return text.text, answer
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
async def test_a2a_send_message_returns_the_answer_as_text_and_data(
|
|
446
|
+
client: httpx.AsyncClient,
|
|
447
|
+
) -> None:
|
|
448
|
+
http, alice = await _a2a(streaming=False)
|
|
449
|
+
try:
|
|
450
|
+
request = SendMessageRequest(
|
|
451
|
+
message=Message(
|
|
452
|
+
message_id=uuid.uuid4().hex, role=Role.ROLE_USER, parts=[Part(text="hello")]
|
|
453
|
+
)
|
|
454
|
+
)
|
|
455
|
+
task = None
|
|
456
|
+
async for chunk in alice.send_message(request):
|
|
457
|
+
if chunk.HasField("task"):
|
|
458
|
+
task = chunk.task
|
|
459
|
+
assert task is not None and task.status.state == TaskState.TASK_STATE_COMPLETED
|
|
460
|
+
(artifact,) = task.artifacts
|
|
461
|
+
_, answer = _answer_parts(artifact.parts)
|
|
462
|
+
assert answer == {"answer": "Hello! How can I help you today?", "done": False, "items": []}
|
|
463
|
+
finally:
|
|
464
|
+
await http.aclose()
|
|
465
|
+
|
|
466
|
+
|
|
467
|
+
async def test_a2a_streamed_reply_ends_with_the_answer_and_is_stored_whole(
|
|
468
|
+
client: httpx.AsyncClient,
|
|
469
|
+
) -> None:
|
|
470
|
+
http, alice = await _a2a(streaming=True)
|
|
471
|
+
try:
|
|
472
|
+
request = SendMessageRequest(
|
|
473
|
+
message=Message(
|
|
474
|
+
message_id=uuid.uuid4().hex,
|
|
475
|
+
role=Role.ROLE_USER,
|
|
476
|
+
parts=[Part(text="Run the probe for Lima")],
|
|
477
|
+
)
|
|
478
|
+
)
|
|
479
|
+
updates = [
|
|
480
|
+
chunk.artifact_update
|
|
481
|
+
async for chunk in alice.send_message(request)
|
|
482
|
+
if chunk.HasField("artifact_update")
|
|
483
|
+
]
|
|
484
|
+
assert updates and updates[-1].last_chunk
|
|
485
|
+
text, answer = _answer_parts(updates[-1].artifact.parts)
|
|
486
|
+
assert answer["answer"] == "Here is what I found: Lima: 42"
|
|
487
|
+
stored = await alice.get_task(GetTaskRequest(id=updates[0].task_id))
|
|
488
|
+
(artifact,) = stored.artifacts
|
|
489
|
+
assert _answer_parts(artifact.parts) == (text, answer)
|
|
490
|
+
finally:
|
|
491
|
+
await http.aclose()
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
async def test_a2a_task_paused_for_approval_completes_with_the_answer(
|
|
495
|
+
client: httpx.AsyncClient,
|
|
496
|
+
) -> None:
|
|
497
|
+
async def rpc(params: dict[str, Any]) -> dict[str, Any]:
|
|
498
|
+
r = await client.post(
|
|
499
|
+
A2A_PATH,
|
|
500
|
+
json={"jsonrpc": "2.0", "id": 1, "method": "SendMessage", "params": params},
|
|
501
|
+
headers={**_as("alice"), "A2A-Version": "1.0"},
|
|
502
|
+
)
|
|
503
|
+
assert r.status_code == 200, r.text
|
|
504
|
+
return r.json()["result"]["task"]
|
|
505
|
+
|
|
506
|
+
message = {"messageId": uuid.uuid4().hex, "role": "ROLE_USER"}
|
|
507
|
+
task = await rpc({"message": {**message, "parts": [{"text": "Cancel the order for 9"}]}})
|
|
508
|
+
assert task["status"]["state"] == "TASK_STATE_INPUT_REQUIRED", task
|
|
509
|
+
[request] = [p["data"] for p in task["status"]["message"]["parts"] if "data" in p]
|
|
510
|
+
[approval] = json.loads(request["approval_json"])
|
|
511
|
+
decision = {"approval_id": approval["approval_id"], "decision": "approve"}
|
|
512
|
+
done = await rpc(
|
|
513
|
+
{
|
|
514
|
+
"message": {
|
|
515
|
+
"messageId": uuid.uuid4().hex,
|
|
516
|
+
"role": "ROLE_USER",
|
|
517
|
+
"taskId": task["id"],
|
|
518
|
+
"contextId": task["contextId"],
|
|
519
|
+
"parts": [{"data": decision}],
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
)
|
|
523
|
+
assert done["status"]["state"] == "TASK_STATE_COMPLETED", done
|
|
524
|
+
# The paused run's reply (empty) and the resumed run's: the answer is the last one.
|
|
525
|
+
text, data = done["artifacts"][-1]["parts"]
|
|
526
|
+
assert data["mediaType"] == "application/json"
|
|
527
|
+
assert json.loads(text["text"]) == data["data"]
|
|
528
|
+
assert "/orders/9/cancel" in data["data"]["answer"] and len(SENT) == 1
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
async def test_a2a_task_decided_over_http_completes_with_the_whole_answer(
|
|
532
|
+
client: httpx.AsyncClient, openai_compatible: Any, monkeypatch: pytest.MonkeyPatch
|
|
533
|
+
) -> None:
|
|
534
|
+
# The person decides outside the task, over HTTP with their own credentials (what
|
|
535
|
+
# `decide_with: direct` asks of an agent's request): the task that waited takes the
|
|
536
|
+
# answer whole, as its last `response` artifact, like a decision sent on the task.
|
|
537
|
+
fake = openai_compatible
|
|
538
|
+
_serve_model(monkeypatch, fake.model(), [cancel_order, probe])
|
|
539
|
+
tool_strategy = os.environ["RESPONSE_FORMAT_STRATEGY"] == "tool"
|
|
540
|
+
|
|
541
|
+
async def rpc(method: str, params: dict[str, Any]) -> dict[str, Any]:
|
|
542
|
+
r = await client.post(
|
|
543
|
+
A2A_PATH,
|
|
544
|
+
json={"jsonrpc": "2.0", "id": 1, "method": method, "params": params},
|
|
545
|
+
headers={**_as("alice"), "A2A-Version": "1.0"},
|
|
546
|
+
)
|
|
547
|
+
assert r.status_code == 200, r.text
|
|
548
|
+
return r.json()["result"]
|
|
549
|
+
|
|
550
|
+
fake.queue = [(None, [("call_cancel", "cancel_order", '{"order_id": "12"}')])]
|
|
551
|
+
message = {"messageId": uuid.uuid4().hex, "role": "ROLE_USER"}
|
|
552
|
+
sent = await rpc("SendMessage", {"message": {**message, "parts": [{"text": "Cancel 12"}]}})
|
|
553
|
+
task = sent["task"]
|
|
554
|
+
assert task["status"]["state"] == "TASK_STATE_INPUT_REQUIRED", task
|
|
555
|
+
[request] = [p["data"] for p in task["status"]["message"]["parts"] if "data" in p]
|
|
556
|
+
[approval] = json.loads(request["approval_json"])
|
|
557
|
+
# Longer than the 2,000 characters of a reply a followed task's status would quote.
|
|
558
|
+
long = {"answer": "Cancelled order 12. " + "x" * 3000, "done": True, "items": ["12"]}
|
|
559
|
+
fake.queue = [(None, [("call_done", ANSWER_TOOL, json.dumps(long))])]
|
|
560
|
+
if not tool_strategy:
|
|
561
|
+
fake.queue = [(json.dumps(long), [])]
|
|
562
|
+
r = await client.post(
|
|
563
|
+
f"/threads/{task['contextId']}/approvals/{approval['approval_id']}",
|
|
564
|
+
json={"decision": "approve"},
|
|
565
|
+
headers=_as("alice"),
|
|
566
|
+
)
|
|
567
|
+
assert r.status_code == 200, r.text
|
|
568
|
+
assert _answered(parse_sse(r.text)) == long and len(SENT) == 1
|
|
569
|
+
followed = await rpc("GetTask", {"id": task["id"]})
|
|
570
|
+
assert followed["status"]["state"] == "TASK_STATE_COMPLETED", followed
|
|
571
|
+
[note] = followed["status"]["message"]["parts"]
|
|
572
|
+
assert note["text"] == (
|
|
573
|
+
f"Approval {approval['approval_id']} was approved outside this task; "
|
|
574
|
+
"the run continued there."
|
|
575
|
+
)
|
|
576
|
+
text, data = followed["artifacts"][-1]["parts"]
|
|
577
|
+
assert followed["artifacts"][-1]["name"] == "response"
|
|
578
|
+
assert data["mediaType"] == "application/json"
|
|
579
|
+
assert json.loads(text["text"]) == data["data"] == long
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
async def test_the_card_lists_json_among_the_output_modes(client: httpx.AsyncClient) -> None:
|
|
583
|
+
card = a2a_module.agent_card()
|
|
584
|
+
assert list(card.default_output_modes) == ["text/plain", "application/json"]
|