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,1421 @@
|
|
|
1
|
+
# Copyright 2026 Google LLC
|
|
2
|
+
# Modifications Copyright 2026 graph-agents-cli contributors
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# https://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
|
|
16
|
+
"""graph-agents-cli run command: run the agent with a single prompt.
|
|
17
|
+
|
|
18
|
+
Client side of the chat API, the auth policies and the local server. ``chat`` mode streams the SSE
|
|
19
|
+
events of ``POST /chat``; ``a2a`` mode talks JSON-RPC through ``a2a-sdk``
|
|
20
|
+
(optional extra). Locally the server is started per the manifest runtime and
|
|
21
|
+
tracked in ``.graph-agents-cli/run_server.json``.
|
|
22
|
+
|
|
23
|
+
A run that reaches a call its API's policy gates (``approval`` in
|
|
24
|
+
``api-policy.yaml``) pauses before sending it. ``run`` prints the call in full;
|
|
25
|
+
on a terminal, when the requester is one of the approvers, it asks
|
|
26
|
+
"Approve? [y/N]" and streams the continuation; otherwise it prints the
|
|
27
|
+
``graph-agents-cli approvals approve|reject`` commands and exits 0 with an
|
|
28
|
+
"Awaiting approval" line.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import asyncio
|
|
34
|
+
import json
|
|
35
|
+
import shlex
|
|
36
|
+
import sys
|
|
37
|
+
import uuid
|
|
38
|
+
from collections.abc import Iterable
|
|
39
|
+
from dataclasses import replace
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Any, NamedTuple
|
|
42
|
+
from urllib.parse import urlsplit
|
|
43
|
+
|
|
44
|
+
import click
|
|
45
|
+
import httpx
|
|
46
|
+
|
|
47
|
+
from graph_agents_cli import _chat_client, _http
|
|
48
|
+
from graph_agents_cli._approvals import (
|
|
49
|
+
DECISION_REFUSALS,
|
|
50
|
+
Approval,
|
|
51
|
+
awaiting_lines,
|
|
52
|
+
safe_text,
|
|
53
|
+
terminal_text,
|
|
54
|
+
)
|
|
55
|
+
from graph_agents_cli._chat_client import (
|
|
56
|
+
APPROVAL_PENDING,
|
|
57
|
+
DECISION_APPROVE,
|
|
58
|
+
DECISION_REJECT,
|
|
59
|
+
EVENT_ERROR,
|
|
60
|
+
EVENT_MESSAGE_DELTA,
|
|
61
|
+
EVENT_MESSAGE_END,
|
|
62
|
+
EVENT_MESSAGE_START,
|
|
63
|
+
EVENT_TOOL_CALL,
|
|
64
|
+
EVENT_TOOL_RESULT,
|
|
65
|
+
STATUS_AWAITING_APPROVAL,
|
|
66
|
+
ChatHTTPError,
|
|
67
|
+
SseEvent,
|
|
68
|
+
post_chat,
|
|
69
|
+
redact_credentials,
|
|
70
|
+
)
|
|
71
|
+
from graph_agents_cli._project import (
|
|
72
|
+
chdir_project_root,
|
|
73
|
+
find_project_root,
|
|
74
|
+
read_project_config,
|
|
75
|
+
require_agent_directory,
|
|
76
|
+
)
|
|
77
|
+
from graph_agents_cli._remote import (
|
|
78
|
+
API_KEY_ENV,
|
|
79
|
+
DEFAULT_RUN_MODE,
|
|
80
|
+
RUN_MODES,
|
|
81
|
+
build_headers,
|
|
82
|
+
classify_url,
|
|
83
|
+
deprecated_session_token,
|
|
84
|
+
fold_session_token,
|
|
85
|
+
)
|
|
86
|
+
from graph_agents_cli.run._local_server import (
|
|
87
|
+
ServerStopError,
|
|
88
|
+
ensure_server,
|
|
89
|
+
stop_server,
|
|
90
|
+
warn_not_stopped,
|
|
91
|
+
)
|
|
92
|
+
from graph_agents_cli.run._signals import terminate_like_interrupt
|
|
93
|
+
|
|
94
|
+
_RESULT_PREVIEW_CHARS = 400
|
|
95
|
+
# Credential-bearing flags are never echoed verbatim in the resume line: the
|
|
96
|
+
# footer lands in CI logs and pasted transcripts.
|
|
97
|
+
REDACTED = "<redacted>"
|
|
98
|
+
_SENSITIVE_HEADER_NAMES = frozenset(
|
|
99
|
+
{
|
|
100
|
+
"authorization",
|
|
101
|
+
"proxy-authorization",
|
|
102
|
+
"cookie",
|
|
103
|
+
"x-session-token",
|
|
104
|
+
"x-api-key",
|
|
105
|
+
"x-auth-token",
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
_SENSITIVE_HEADER_HINTS = ("auth", "token", "secret", "key", "cookie")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _a2a_install_hint() -> str:
|
|
112
|
+
from graph_agents_cli.scaffold.utils.version import get_current_version, install_command
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
"A2A mode needs the optional 'a2a' extra: install it with "
|
|
116
|
+
f"`{install_command('a2a', version=get_current_version())}`, or use --mode chat."
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _require_a2a_sdk() -> None:
|
|
121
|
+
"""Fail with a one-line hint when the optional a2a-sdk is missing.
|
|
122
|
+
|
|
123
|
+
Checked before any server is started so a missing extra costs nothing.
|
|
124
|
+
"""
|
|
125
|
+
import importlib.util
|
|
126
|
+
|
|
127
|
+
try:
|
|
128
|
+
found = importlib.util.find_spec("a2a") is not None
|
|
129
|
+
except (ImportError, ValueError): # a blocked/None entry in sys.modules
|
|
130
|
+
found = False
|
|
131
|
+
if not found:
|
|
132
|
+
raise click.ClickException(_a2a_install_hint())
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class RunTarget(NamedTuple):
|
|
136
|
+
"""Where this invocation sends the prompt."""
|
|
137
|
+
|
|
138
|
+
mode: str
|
|
139
|
+
# Chat base URL (``<base>/chat``) or the resolved A2A base.
|
|
140
|
+
base_url: str
|
|
141
|
+
headers: dict[str, str]
|
|
142
|
+
remote: bool
|
|
143
|
+
# True only when this invocation started the local server; False when a
|
|
144
|
+
# running server was reused or for remote (--url) runs.
|
|
145
|
+
started_server: bool = False
|
|
146
|
+
server_pid: int | None = None
|
|
147
|
+
checkpointer: str = "memory"
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class RunOutcome(NamedTuple):
|
|
151
|
+
"""What a query produced, for the footer and for tests."""
|
|
152
|
+
|
|
153
|
+
thread_id: str | None
|
|
154
|
+
run_id: str | None
|
|
155
|
+
rendered: bool
|
|
156
|
+
usage: dict[str, Any] | None = None
|
|
157
|
+
latency_ms: int | None = None
|
|
158
|
+
status: str | None = None
|
|
159
|
+
# The paused call (``message.end`` ``approval``) when status is awaiting_approval.
|
|
160
|
+
approval: dict[str, Any] | None = None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class _TransportFailure(click.ClickException):
|
|
164
|
+
"""The agent could not be reached or went silent: a tool failure (exit 2).
|
|
165
|
+
|
|
166
|
+
An answer the agent gave (an HTTP error, an ``error`` event) stays exit 1.
|
|
167
|
+
"""
|
|
168
|
+
|
|
169
|
+
exit_code = 2
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class AgentError(Exception):
|
|
173
|
+
"""The server sent an ``error`` event."""
|
|
174
|
+
|
|
175
|
+
def __init__(self, code: str, message: str, *, run_id: str | None = None) -> None:
|
|
176
|
+
self.code = code
|
|
177
|
+
self.message = message
|
|
178
|
+
self.run_id = run_id
|
|
179
|
+
super().__init__(f"[{code}] {message}")
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
# ---------------------------------------------------------------------------
|
|
183
|
+
# Message composition
|
|
184
|
+
# ---------------------------------------------------------------------------
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def compose_message(message: str, files: Iterable[str] = ()) -> str:
|
|
188
|
+
"""Append each ``--file`` as extra context lines after the prompt.
|
|
189
|
+
|
|
190
|
+
Attachments are text only (no multimodal input in this CLI); a file
|
|
191
|
+
that does not decode as UTF-8 is refused with a clear message.
|
|
192
|
+
"""
|
|
193
|
+
parts = [message]
|
|
194
|
+
for raw in files:
|
|
195
|
+
path = Path(raw)
|
|
196
|
+
try:
|
|
197
|
+
content = path.read_text(encoding="utf-8")
|
|
198
|
+
except UnicodeDecodeError as exc:
|
|
199
|
+
raise click.ClickException(
|
|
200
|
+
f"Cannot attach {path}: not a UTF-8 text file. Only text attachments are supported."
|
|
201
|
+
) from exc
|
|
202
|
+
except OSError as exc:
|
|
203
|
+
raise click.ClickException(f"Cannot read {path}: {exc}") from exc
|
|
204
|
+
parts.append(
|
|
205
|
+
f"\n\n--- Attached file: {path.name} ---\n{content.rstrip()}\n--- End of {path.name} ---"
|
|
206
|
+
)
|
|
207
|
+
return "".join(parts)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
# ---------------------------------------------------------------------------
|
|
211
|
+
# Rendering
|
|
212
|
+
# ---------------------------------------------------------------------------
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _json_preview(value: Any, limit: int | None) -> str:
|
|
216
|
+
text = value if isinstance(value, str) else json.dumps(value, ensure_ascii=False, default=str)
|
|
217
|
+
if limit is not None and len(text) > limit:
|
|
218
|
+
return text[:limit] + "..."
|
|
219
|
+
return text
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def _compact_json(value: Any) -> str:
|
|
223
|
+
return json.dumps(value, ensure_ascii=False, default=str, separators=(", ", ": "))
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
class _ChatRenderer:
|
|
227
|
+
"""Prints the chat API's events to the terminal as they stream.
|
|
228
|
+
|
|
229
|
+
With ``verbose`` every event also gets one compact line (``event: <name>
|
|
230
|
+
<json>``); a run of text deltas, whose text is already on screen, is
|
|
231
|
+
summarised as one ``message.delta`` line with its count.
|
|
232
|
+
"""
|
|
233
|
+
|
|
234
|
+
def __init__(self, *, verbose: bool = False) -> None:
|
|
235
|
+
self.verbose = verbose
|
|
236
|
+
self.at_line_start = True
|
|
237
|
+
self.rendered = False
|
|
238
|
+
self.tagged = False
|
|
239
|
+
self.thread_id: str | None = None
|
|
240
|
+
self.run_id: str | None = None
|
|
241
|
+
self.usage: dict[str, Any] | None = None
|
|
242
|
+
self.latency_ms: int | None = None
|
|
243
|
+
self.status: str | None = None
|
|
244
|
+
self.approval: dict[str, Any] | None = None
|
|
245
|
+
# Events received so far: tells a connection that failed before the run
|
|
246
|
+
# started from one that dropped while it streamed.
|
|
247
|
+
self.events = 0
|
|
248
|
+
self._pending_deltas = 0
|
|
249
|
+
self._pending_chars = 0
|
|
250
|
+
|
|
251
|
+
def _newline_if_needed(self) -> None:
|
|
252
|
+
if not self.at_line_start:
|
|
253
|
+
click.echo()
|
|
254
|
+
self.at_line_start = True
|
|
255
|
+
|
|
256
|
+
def _tag(self) -> None:
|
|
257
|
+
if not self.tagged:
|
|
258
|
+
click.echo("[agent]: ", nl=False)
|
|
259
|
+
self.tagged = True
|
|
260
|
+
self.at_line_start = False
|
|
261
|
+
|
|
262
|
+
def _flush_deltas(self) -> None:
|
|
263
|
+
if not self._pending_deltas:
|
|
264
|
+
return
|
|
265
|
+
self._newline_if_needed()
|
|
266
|
+
count, chars = self._pending_deltas, self._pending_chars
|
|
267
|
+
self._pending_deltas = self._pending_chars = 0
|
|
268
|
+
plural = "s" if chars != 1 else ""
|
|
269
|
+
click.secho(f"event: {EVENT_MESSAGE_DELTA} x{count} ({chars} character{plural})", dim=True)
|
|
270
|
+
|
|
271
|
+
def _trace(self, ev: SseEvent) -> None:
|
|
272
|
+
"""The ``-v`` line of one event."""
|
|
273
|
+
if ev.event == EVENT_MESSAGE_DELTA:
|
|
274
|
+
text = ev.data.get("text") if isinstance(ev.data, dict) else ev.data
|
|
275
|
+
self._pending_deltas += 1
|
|
276
|
+
self._pending_chars += len(text) if isinstance(text, str) else 0
|
|
277
|
+
return
|
|
278
|
+
self._flush_deltas()
|
|
279
|
+
self._newline_if_needed()
|
|
280
|
+
click.secho(terminal_text(f"event: {ev.event} {_compact_json(ev.data)}"), dim=True)
|
|
281
|
+
|
|
282
|
+
def write_text(self, text: Any) -> None:
|
|
283
|
+
"""Print answer text inline (after the ``[agent]:`` tag)."""
|
|
284
|
+
if text:
|
|
285
|
+
text = terminal_text(str(text))
|
|
286
|
+
self._tag()
|
|
287
|
+
click.echo(text, nl=False)
|
|
288
|
+
self.at_line_start = text.endswith("\n")
|
|
289
|
+
self.rendered = True
|
|
290
|
+
|
|
291
|
+
def finish(self) -> None:
|
|
292
|
+
"""End the output: pending ``-v`` summary, then a final newline."""
|
|
293
|
+
self._flush_deltas()
|
|
294
|
+
self._newline_if_needed()
|
|
295
|
+
|
|
296
|
+
def resume(self) -> None:
|
|
297
|
+
"""Ready for the continuation of a paused run: a new ``[agent]:`` tag, no pause."""
|
|
298
|
+
self.tagged = False
|
|
299
|
+
self.at_line_start = True
|
|
300
|
+
self.status = None
|
|
301
|
+
self.approval = None
|
|
302
|
+
|
|
303
|
+
def handle(self, ev: SseEvent) -> None:
|
|
304
|
+
self.events += 1
|
|
305
|
+
data = ev.data if isinstance(ev.data, dict) else {}
|
|
306
|
+
if self.verbose and ev.event != EVENT_MESSAGE_DELTA:
|
|
307
|
+
self._trace(ev)
|
|
308
|
+
if ev.event == EVENT_MESSAGE_START:
|
|
309
|
+
self.thread_id = data.get("thread_id") or self.thread_id
|
|
310
|
+
self.run_id = data.get("run_id") or self.run_id
|
|
311
|
+
elif ev.event == EVENT_MESSAGE_DELTA:
|
|
312
|
+
text = data.get("text") if data else (ev.data if isinstance(ev.data, str) else "")
|
|
313
|
+
self.write_text(text)
|
|
314
|
+
elif ev.event == EVENT_TOOL_CALL:
|
|
315
|
+
self._newline_if_needed()
|
|
316
|
+
name = terminal_text(str(data.get("name", "")))
|
|
317
|
+
args = data.get("args")
|
|
318
|
+
rendered_args = "" if args is None else terminal_text(_json_preview(args, None))
|
|
319
|
+
click.secho(f"[tool_call: {name}({rendered_args})]", dim=True)
|
|
320
|
+
self.rendered = True
|
|
321
|
+
elif ev.event == EVENT_TOOL_RESULT:
|
|
322
|
+
self._newline_if_needed()
|
|
323
|
+
name = terminal_text(str(data.get("name", "")))
|
|
324
|
+
limit = None if self.verbose else _RESULT_PREVIEW_CHARS
|
|
325
|
+
result = terminal_text(_json_preview(data.get("result", ""), limit))
|
|
326
|
+
marker = " (error)" if data.get("is_error") else ""
|
|
327
|
+
click.secho(f"[tool_result{marker}: {name} -> {result}]", dim=True)
|
|
328
|
+
self.rendered = True
|
|
329
|
+
elif ev.event == EVENT_MESSAGE_END:
|
|
330
|
+
self.thread_id = data.get("thread_id") or self.thread_id
|
|
331
|
+
self.run_id = data.get("run_id") or self.run_id
|
|
332
|
+
self.usage = data.get("usage") if isinstance(data.get("usage"), dict) else self.usage
|
|
333
|
+
self.latency_ms = data.get("latency_ms", self.latency_ms)
|
|
334
|
+
self.status = data.get("status", self.status)
|
|
335
|
+
approval = data.get("approval")
|
|
336
|
+
if self.status == STATUS_AWAITING_APPROVAL:
|
|
337
|
+
self.approval = approval if isinstance(approval, dict) else {}
|
|
338
|
+
elif ev.event == EVENT_ERROR:
|
|
339
|
+
self._newline_if_needed()
|
|
340
|
+
code = str(data.get("code", "error")) if data else "error"
|
|
341
|
+
message = str(data.get("message", ev.raw)) if data else str(ev.raw)
|
|
342
|
+
self.run_id = data.get("run_id") or self.run_id
|
|
343
|
+
self.thread_id = data.get("thread_id") or self.thread_id
|
|
344
|
+
raise AgentError(code, message, run_id=self.run_id)
|
|
345
|
+
|
|
346
|
+
if self.verbose and ev.event == EVENT_MESSAGE_DELTA:
|
|
347
|
+
self._trace(ev)
|
|
348
|
+
|
|
349
|
+
def outcome(self) -> RunOutcome:
|
|
350
|
+
return RunOutcome(
|
|
351
|
+
thread_id=self.thread_id,
|
|
352
|
+
run_id=self.run_id,
|
|
353
|
+
rendered=self.rendered,
|
|
354
|
+
usage=self.usage,
|
|
355
|
+
latency_ms=self.latency_ms,
|
|
356
|
+
status=self.status,
|
|
357
|
+
approval=self.approval,
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def render_chat_events(
|
|
362
|
+
events: Iterable[SseEvent],
|
|
363
|
+
*,
|
|
364
|
+
verbose: bool = False,
|
|
365
|
+
renderer: _ChatRenderer | None = None,
|
|
366
|
+
) -> RunOutcome:
|
|
367
|
+
"""Render a stream of chat events; raises :class:`AgentError` on ``error``.
|
|
368
|
+
|
|
369
|
+
Pass ``renderer`` to keep what the stream announced (the thread id, how
|
|
370
|
+
many events arrived) when it ends with an exception.
|
|
371
|
+
"""
|
|
372
|
+
renderer = renderer or _ChatRenderer(verbose=verbose)
|
|
373
|
+
try:
|
|
374
|
+
for ev in events:
|
|
375
|
+
renderer.handle(ev)
|
|
376
|
+
finally:
|
|
377
|
+
renderer.finish()
|
|
378
|
+
return renderer.outcome()
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
# ---------------------------------------------------------------------------
|
|
382
|
+
# Target resolution
|
|
383
|
+
# ---------------------------------------------------------------------------
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def _local_project_agent_directory() -> str | None:
|
|
387
|
+
"""The agent directory of the enclosing project, if any (no chdir)."""
|
|
388
|
+
root = find_project_root(Path.cwd())
|
|
389
|
+
if root is None:
|
|
390
|
+
return None
|
|
391
|
+
try:
|
|
392
|
+
return read_project_config(str(root)).agent_directory
|
|
393
|
+
except click.ClickException:
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _local_api_key(project_root: Path) -> str | None:
|
|
398
|
+
"""``API_KEY`` from the project's ``.env`` so local shared-bearer runs just work."""
|
|
399
|
+
env_path = project_root / ".env"
|
|
400
|
+
if not env_path.is_file():
|
|
401
|
+
return None
|
|
402
|
+
from dotenv import dotenv_values
|
|
403
|
+
|
|
404
|
+
try:
|
|
405
|
+
return dotenv_values(env_path).get("API_KEY") or None
|
|
406
|
+
except Exception:
|
|
407
|
+
return None
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def _resolve_target(
|
|
411
|
+
*,
|
|
412
|
+
url: str | None,
|
|
413
|
+
mode: str,
|
|
414
|
+
header: tuple[str, ...],
|
|
415
|
+
cookie: tuple[str, ...],
|
|
416
|
+
session_token: str | None,
|
|
417
|
+
start_server: bool,
|
|
418
|
+
port: int | None = None,
|
|
419
|
+
) -> RunTarget:
|
|
420
|
+
headers = build_headers(header, cookie, session_token)
|
|
421
|
+
|
|
422
|
+
if url:
|
|
423
|
+
agent_dir = _local_project_agent_directory() if mode == "a2a" else None
|
|
424
|
+
remote = classify_url(url, mode=mode, agent_directory=agent_dir, headers=headers)
|
|
425
|
+
base = remote.a2a_base if remote.mode == "a2a" else remote.base_url
|
|
426
|
+
return RunTarget(
|
|
427
|
+
mode=remote.mode, base_url=base or remote.base_url, headers=headers, remote=True
|
|
428
|
+
)
|
|
429
|
+
|
|
430
|
+
chdir_project_root()
|
|
431
|
+
cfg = read_project_config()
|
|
432
|
+
require_agent_directory(cfg)
|
|
433
|
+
runtime = getattr(cfg, "runtime", "fastapi") or "fastapi"
|
|
434
|
+
checkpointer = getattr(cfg, "checkpointer", "memory") or "memory"
|
|
435
|
+
project_root = Path.cwd()
|
|
436
|
+
server = ensure_server(
|
|
437
|
+
project_root,
|
|
438
|
+
cfg.agent_directory,
|
|
439
|
+
runtime=runtime,
|
|
440
|
+
checkpointer=checkpointer,
|
|
441
|
+
keep_running=start_server,
|
|
442
|
+
port=port,
|
|
443
|
+
)
|
|
444
|
+
if not any(k.lower() == "authorization" for k in headers):
|
|
445
|
+
api_key = _local_api_key(project_root)
|
|
446
|
+
if api_key:
|
|
447
|
+
headers["Authorization"] = f"Bearer {api_key}"
|
|
448
|
+
base = server.base_url
|
|
449
|
+
if mode == "a2a":
|
|
450
|
+
base = f"{base}/a2a/{cfg.agent_directory}"
|
|
451
|
+
return RunTarget(
|
|
452
|
+
mode=mode,
|
|
453
|
+
base_url=base,
|
|
454
|
+
headers=headers,
|
|
455
|
+
remote=False,
|
|
456
|
+
started_server=server.started,
|
|
457
|
+
server_pid=server.pid,
|
|
458
|
+
checkpointer=server.checkpointer,
|
|
459
|
+
)
|
|
460
|
+
|
|
461
|
+
|
|
462
|
+
def _redact_header(raw: str) -> str:
|
|
463
|
+
"""``Name: value`` with the value replaced when the header carries a credential."""
|
|
464
|
+
name, sep, _value = raw.partition(":")
|
|
465
|
+
if not sep:
|
|
466
|
+
return raw
|
|
467
|
+
lowered = name.strip().lower()
|
|
468
|
+
if lowered in _SENSITIVE_HEADER_NAMES or any(h in lowered for h in _SENSITIVE_HEADER_HINTS):
|
|
469
|
+
return f"{name.strip()}: {REDACTED}"
|
|
470
|
+
return raw
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _redact_cookie(raw: str) -> str:
|
|
474
|
+
name, sep, _value = raw.partition("=")
|
|
475
|
+
return f"{name}={REDACTED}" if sep else REDACTED
|
|
476
|
+
|
|
477
|
+
|
|
478
|
+
def _build_resume_flags(
|
|
479
|
+
url: str | None,
|
|
480
|
+
mode: str,
|
|
481
|
+
header: tuple[str, ...],
|
|
482
|
+
cookie: tuple[str, ...],
|
|
483
|
+
session_token: str | None,
|
|
484
|
+
) -> str:
|
|
485
|
+
"""Flags a resumed run must repeat, with a leading space (empty for local chat).
|
|
486
|
+
|
|
487
|
+
Credential values (``Authorization``/``X-Api-Key``-style headers, every
|
|
488
|
+
cookie, the session token) are printed as ``<redacted>``; the reader
|
|
489
|
+
re-supplies them. Routing headers such as ``X-Tenant`` stay readable.
|
|
490
|
+
"""
|
|
491
|
+
flags: list[str] = []
|
|
492
|
+
if url:
|
|
493
|
+
flags.append(f"--url {shlex.quote(url)}")
|
|
494
|
+
if mode != DEFAULT_RUN_MODE:
|
|
495
|
+
flags.append(f"--mode {mode}")
|
|
496
|
+
flags.extend(f"--header {shlex.quote(_redact_header(h))}" for h in header)
|
|
497
|
+
flags.extend(f"--cookie {shlex.quote(_redact_cookie(c))}" for c in cookie)
|
|
498
|
+
if session_token:
|
|
499
|
+
flags.append(f"--session-token {REDACTED}")
|
|
500
|
+
return (" " + " ".join(flags)) if flags else ""
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _thread_lines(
|
|
504
|
+
thread_id: str, *, resume_flags: str, resumable: bool, server_stopped: bool = False
|
|
505
|
+
) -> list[str]:
|
|
506
|
+
"""The thread id and how to continue it (or why it cannot be continued).
|
|
507
|
+
|
|
508
|
+
``server_stopped``: the local server went away (crashed, or was stopped
|
|
509
|
+
after a failure) whatever ``--start-server`` said; with an in-memory
|
|
510
|
+
checkpointer its threads went with it.
|
|
511
|
+
"""
|
|
512
|
+
lines = [f"Thread: {thread_id}"]
|
|
513
|
+
if resumable:
|
|
514
|
+
hint = " (re-supply the redacted credential values)" if REDACTED in resume_flags else ""
|
|
515
|
+
lines.append(
|
|
516
|
+
f' Resume with: graph-agents-cli run "<message>"{resume_flags}'
|
|
517
|
+
f" --thread-id {thread_id}{hint}"
|
|
518
|
+
)
|
|
519
|
+
elif server_stopped:
|
|
520
|
+
lines.append(
|
|
521
|
+
" The local server stopped, and its in-memory checkpointer lost this thread: the "
|
|
522
|
+
"next run starts a new one (CHECKPOINTER=postgres keeps threads across restarts)."
|
|
523
|
+
)
|
|
524
|
+
else:
|
|
525
|
+
lines.append(
|
|
526
|
+
" One-off server with an in-memory checkpointer: add --start-server to "
|
|
527
|
+
"keep the server (and its threads) alive so you can resume with --thread-id."
|
|
528
|
+
)
|
|
529
|
+
return lines
|
|
530
|
+
|
|
531
|
+
|
|
532
|
+
def _print_footer(
|
|
533
|
+
outcome: RunOutcome,
|
|
534
|
+
*,
|
|
535
|
+
resume_flags: str,
|
|
536
|
+
keep_server: bool,
|
|
537
|
+
checkpointer: str,
|
|
538
|
+
after_error: bool = False,
|
|
539
|
+
pending: Approval | None = None,
|
|
540
|
+
approval_flags: str = "",
|
|
541
|
+
mode: str = DEFAULT_RUN_MODE,
|
|
542
|
+
kept_for_approval: bool = False,
|
|
543
|
+
) -> None:
|
|
544
|
+
"""Usage, then the thread and the command that continues it.
|
|
545
|
+
|
|
546
|
+
Printed after an ``error`` event as well (without the "no response"
|
|
547
|
+
note): the thread keeps every turn that finished and can be continued.
|
|
548
|
+
A run paused on a gated call (``pending``) cannot take a new message until
|
|
549
|
+
the call is decided: the footer gives the commands that decide it instead.
|
|
550
|
+
"""
|
|
551
|
+
if not outcome.rendered and not after_error:
|
|
552
|
+
click.secho("(no response content)", fg="yellow")
|
|
553
|
+
if outcome.usage or outcome.latency_ms is not None:
|
|
554
|
+
bits = []
|
|
555
|
+
if outcome.usage:
|
|
556
|
+
bits.append(
|
|
557
|
+
f"tokens in/out {outcome.usage.get('input_tokens', '?')}/"
|
|
558
|
+
f"{outcome.usage.get('output_tokens', '?')}"
|
|
559
|
+
)
|
|
560
|
+
if outcome.latency_ms is not None:
|
|
561
|
+
bits.append(f"{outcome.latency_ms} ms")
|
|
562
|
+
click.secho(" ".join(bits), dim=True)
|
|
563
|
+
if after_error and outcome.run_id:
|
|
564
|
+
click.secho(f"Run: {outcome.run_id}", dim=True)
|
|
565
|
+
if pending is not None:
|
|
566
|
+
click.echo()
|
|
567
|
+
lines = (
|
|
568
|
+
_a2a_awaiting_lines(pending)
|
|
569
|
+
if mode == "a2a"
|
|
570
|
+
else awaiting_lines(pending, pending.thread_id or outcome.thread_id, approval_flags)
|
|
571
|
+
)
|
|
572
|
+
click.secho(lines[0], fg="yellow", bold=True)
|
|
573
|
+
for line in lines[1:]:
|
|
574
|
+
click.secho(line, fg="yellow")
|
|
575
|
+
if kept_for_approval:
|
|
576
|
+
click.secho(
|
|
577
|
+
" The local server was kept running: its in-memory checkpointer holds the paused "
|
|
578
|
+
"run. Stop it with `graph-agents-cli run --stop-server` once it is decided.",
|
|
579
|
+
dim=True,
|
|
580
|
+
)
|
|
581
|
+
return
|
|
582
|
+
if not outcome.thread_id:
|
|
583
|
+
return
|
|
584
|
+
click.echo()
|
|
585
|
+
resumable = keep_server or checkpointer == "postgres"
|
|
586
|
+
for line in _thread_lines(outcome.thread_id, resume_flags=resume_flags, resumable=resumable):
|
|
587
|
+
click.secho(line, dim=True)
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
# ---------------------------------------------------------------------------
|
|
591
|
+
# Protocol handlers
|
|
592
|
+
# ---------------------------------------------------------------------------
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def _query_chat(
|
|
596
|
+
target: RunTarget,
|
|
597
|
+
prompt: str,
|
|
598
|
+
*,
|
|
599
|
+
thread_id: str | None,
|
|
600
|
+
renderer: _ChatRenderer,
|
|
601
|
+
display_message: str,
|
|
602
|
+
) -> RunOutcome:
|
|
603
|
+
click.echo(f"[user]: {display_message}")
|
|
604
|
+
events = post_chat(target.base_url, prompt, thread_id=thread_id, headers=target.headers)
|
|
605
|
+
return render_chat_events(events, renderer=renderer)
|
|
606
|
+
|
|
607
|
+
|
|
608
|
+
def _one_line(chunk: Any) -> str:
|
|
609
|
+
"""An A2A stream response on one line (protobuf text format, else ``str``)."""
|
|
610
|
+
try:
|
|
611
|
+
from google.protobuf import text_format
|
|
612
|
+
|
|
613
|
+
return text_format.MessageToString(chunk, as_one_line=True)
|
|
614
|
+
except Exception:
|
|
615
|
+
return " ".join(str(chunk).split())
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
_INPUT_REQUIRED_STATES = frozenset(
|
|
619
|
+
{"TASK_STATE_INPUT_REQUIRED", "input-required", "input_required"}
|
|
620
|
+
)
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _input_required(data: Any) -> bool:
|
|
624
|
+
if isinstance(data, dict):
|
|
625
|
+
if data.get("state") in _INPUT_REQUIRED_STATES:
|
|
626
|
+
return True
|
|
627
|
+
return any(_input_required(value) for value in data.values())
|
|
628
|
+
if isinstance(data, list):
|
|
629
|
+
return any(_input_required(item) for item in data)
|
|
630
|
+
return False
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _approval_in(data: Any) -> dict[str, Any] | None:
|
|
634
|
+
if isinstance(data, dict):
|
|
635
|
+
if isinstance(data.get("approval_id"), str) and ("method" in data or "api" in data):
|
|
636
|
+
return data
|
|
637
|
+
for value in data.values():
|
|
638
|
+
found = _approval_in(value)
|
|
639
|
+
if found is not None:
|
|
640
|
+
return found
|
|
641
|
+
elif isinstance(data, list):
|
|
642
|
+
for item in data:
|
|
643
|
+
found = _approval_in(item)
|
|
644
|
+
if found is not None:
|
|
645
|
+
return found
|
|
646
|
+
return None
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def find_approval_payload(data: Any) -> dict[str, Any] | None:
|
|
650
|
+
"""The approval an A2A stream response (as a dict) asks for, or None.
|
|
651
|
+
|
|
652
|
+
A gated run moves the task to ``input-required`` with a data part holding
|
|
653
|
+
the approval (``approval_id``, ``api``, ``method``, ``path``, ...). A data
|
|
654
|
+
part that only names an approval (a decision sent back) is not one.
|
|
655
|
+
"""
|
|
656
|
+
return _approval_in(data) if _input_required(data) else None
|
|
657
|
+
|
|
658
|
+
|
|
659
|
+
def _a2a_approval(chunk: Any) -> dict[str, Any] | None:
|
|
660
|
+
try:
|
|
661
|
+
from google.protobuf.json_format import MessageToDict
|
|
662
|
+
|
|
663
|
+
data = MessageToDict(chunk, preserving_proto_field_name=True)
|
|
664
|
+
except Exception:
|
|
665
|
+
return None
|
|
666
|
+
return find_approval_payload(data)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def _origin(url: str) -> tuple[str, str, int | None]:
|
|
670
|
+
parts = urlsplit(url)
|
|
671
|
+
scheme = parts.scheme.lower()
|
|
672
|
+
port = parts.port or {"http": 80, "https": 443}.get(scheme)
|
|
673
|
+
return scheme, (parts.hostname or "").lower(), port
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def foreign_a2a_endpoints(
|
|
677
|
+
interfaces: Iterable[tuple[str, str]], base_url: str, bindings: Iterable[str]
|
|
678
|
+
) -> list[str]:
|
|
679
|
+
"""The endpoint URLs an agent card names, for ``bindings``, on another origin than ``base_url``.
|
|
680
|
+
|
|
681
|
+
An A2A client dials the URL the card advertises, not the one it fetched
|
|
682
|
+
the card from; a card naming another scheme, host or port (a stale
|
|
683
|
+
APP_URL, a PORT the server does not listen on, or a card that lies) would
|
|
684
|
+
receive the request and its credentials.
|
|
685
|
+
"""
|
|
686
|
+
wanted = {str(b) for b in bindings}
|
|
687
|
+
origin = _origin(base_url)
|
|
688
|
+
return [url for url, binding in interfaces if str(binding) in wanted and _origin(url) != origin]
|
|
689
|
+
|
|
690
|
+
|
|
691
|
+
def _query_a2a(
|
|
692
|
+
target: RunTarget,
|
|
693
|
+
prompt: str,
|
|
694
|
+
*,
|
|
695
|
+
thread_id: str | None,
|
|
696
|
+
renderer: _ChatRenderer,
|
|
697
|
+
display_message: str,
|
|
698
|
+
) -> RunOutcome:
|
|
699
|
+
try:
|
|
700
|
+
from a2a.client import A2ACardResolver, ClientConfig, create_client
|
|
701
|
+
from a2a.types import Message, Part, Role, SendMessageRequest
|
|
702
|
+
from a2a.utils.constants import (
|
|
703
|
+
PROTOCOL_VERSION_1_0,
|
|
704
|
+
VERSION_HEADER,
|
|
705
|
+
TransportProtocol,
|
|
706
|
+
)
|
|
707
|
+
except ImportError as exc:
|
|
708
|
+
raise click.ClickException(_a2a_install_hint()) from exc
|
|
709
|
+
|
|
710
|
+
click.echo(f"[user]: {display_message}")
|
|
711
|
+
|
|
712
|
+
verbose = renderer.verbose
|
|
713
|
+
|
|
714
|
+
async def _go() -> RunOutcome:
|
|
715
|
+
req_headers = dict(target.headers)
|
|
716
|
+
req_headers.setdefault(VERSION_HEADER, PROTOCOL_VERSION_1_0)
|
|
717
|
+
context_id: str | None = None
|
|
718
|
+
|
|
719
|
+
def _render_parts(parts: Iterable[Any]) -> None:
|
|
720
|
+
for part in parts:
|
|
721
|
+
text = getattr(part, "text", "")
|
|
722
|
+
if text:
|
|
723
|
+
renderer.write_text(text)
|
|
724
|
+
elif getattr(part, "url", ""):
|
|
725
|
+
renderer.write_text(f"\n[file: {part.url}]")
|
|
726
|
+
|
|
727
|
+
# A local target never goes through a proxy; a remote one fails with one line on a
|
|
728
|
+
# proxy setting httpx cannot use (`_http`).
|
|
729
|
+
async with _http.async_client(
|
|
730
|
+
target.base_url, headers=req_headers, timeout=_chat_client.STREAM_TIMEOUT
|
|
731
|
+
) as http_client:
|
|
732
|
+
bindings = [TransportProtocol.JSONRPC, TransportProtocol.HTTP_JSON]
|
|
733
|
+
config = ClientConfig(
|
|
734
|
+
httpx_client=http_client,
|
|
735
|
+
# Keep JSONRPC first: the scaffolded A2A endpoint is JSON-RPC.
|
|
736
|
+
supported_protocol_bindings=bindings,
|
|
737
|
+
)
|
|
738
|
+
try:
|
|
739
|
+
card = await A2ACardResolver(http_client, target.base_url).get_agent_card()
|
|
740
|
+
client = await create_client(card, config)
|
|
741
|
+
except Exception as exc:
|
|
742
|
+
raise click.ClickException(
|
|
743
|
+
f"Could not resolve an A2A agent at {target.base_url}: {exc}\n"
|
|
744
|
+
" If this agent only exposes the chat API, try --mode chat."
|
|
745
|
+
) from exc
|
|
746
|
+
foreign = foreign_a2a_endpoints(
|
|
747
|
+
((i.url, i.protocol_binding) for i in card.supported_interfaces),
|
|
748
|
+
target.base_url,
|
|
749
|
+
(getattr(b, "value", b) for b in bindings),
|
|
750
|
+
)
|
|
751
|
+
if foreign:
|
|
752
|
+
raise click.ClickException(
|
|
753
|
+
f"The agent card at {redact_credentials(target.base_url)} names "
|
|
754
|
+
f"{safe_text(foreign[0])} as its A2A endpoint, another origin: the message "
|
|
755
|
+
"(and your credentials) is not sent there.\n The agent advertises APP_URL, "
|
|
756
|
+
"else http://HOST:PORT: set APP_URL to the agent's own base URL (locally, "
|
|
757
|
+
"check APP_URL and PORT in .env), or pass --url for the agent the card names."
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
msg = Message(
|
|
761
|
+
message_id=str(uuid.uuid4()),
|
|
762
|
+
role=Role.ROLE_USER,
|
|
763
|
+
parts=[Part(text=prompt)],
|
|
764
|
+
context_id=thread_id or "",
|
|
765
|
+
)
|
|
766
|
+
try:
|
|
767
|
+
async for chunk in client.send_message(SendMessageRequest(message=msg)):
|
|
768
|
+
renderer.events += 1
|
|
769
|
+
if chunk.HasField("artifact_update"):
|
|
770
|
+
context_id = context_id or chunk.artifact_update.context_id or None
|
|
771
|
+
_render_parts(chunk.artifact_update.artifact.parts)
|
|
772
|
+
elif chunk.HasField("task"):
|
|
773
|
+
context_id = context_id or chunk.task.context_id or None
|
|
774
|
+
for artifact in chunk.task.artifacts:
|
|
775
|
+
_render_parts(artifact.parts)
|
|
776
|
+
elif chunk.HasField("message"):
|
|
777
|
+
context_id = context_id or chunk.message.context_id or None
|
|
778
|
+
_render_parts(chunk.message.parts)
|
|
779
|
+
approval = _a2a_approval(chunk)
|
|
780
|
+
if approval is not None:
|
|
781
|
+
renderer.status = STATUS_AWAITING_APPROVAL
|
|
782
|
+
renderer.approval = approval
|
|
783
|
+
# Kept on the renderer so a failure mid-stream can still name the thread.
|
|
784
|
+
renderer.thread_id = context_id or thread_id
|
|
785
|
+
if verbose:
|
|
786
|
+
renderer._newline_if_needed()
|
|
787
|
+
click.secho(f"a2a: {_one_line(chunk)}", dim=True)
|
|
788
|
+
finally:
|
|
789
|
+
renderer.finish()
|
|
790
|
+
outcome = renderer.outcome()
|
|
791
|
+
return outcome._replace(thread_id=context_id or thread_id)
|
|
792
|
+
|
|
793
|
+
return asyncio.run(_go())
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
# ---------------------------------------------------------------------------
|
|
797
|
+
# Command
|
|
798
|
+
# ---------------------------------------------------------------------------
|
|
799
|
+
|
|
800
|
+
|
|
801
|
+
def _handle_stop_server(ctx: click.Context, _param: click.Parameter, value: bool) -> None:
|
|
802
|
+
"""Eager callback for ``--stop-server``: stop and exit before argument parsing.
|
|
803
|
+
|
|
804
|
+
A server the operating system does not let this command stop is a
|
|
805
|
+
:class:`ServerStopError` (exit 2) that names what is still running; its
|
|
806
|
+
record is kept.
|
|
807
|
+
"""
|
|
808
|
+
if not value:
|
|
809
|
+
return
|
|
810
|
+
chdir_project_root()
|
|
811
|
+
if stop_server(Path.cwd()):
|
|
812
|
+
ctx.exit(0)
|
|
813
|
+
raise click.ClickException("No local server is running.")
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
def _read_timeout_message(
|
|
817
|
+
thread_id: str | None,
|
|
818
|
+
resume_flags: str,
|
|
819
|
+
*,
|
|
820
|
+
local_server_kept: bool | None = True,
|
|
821
|
+
thread_kept: bool = True,
|
|
822
|
+
) -> str:
|
|
823
|
+
"""``local_server_kept``: None for a remote agent, else whether the local server keeps
|
|
824
|
+
running; ``thread_kept``: whether the thread outlives this command (False for a stopped
|
|
825
|
+
one-off server with an in-memory checkpointer)."""
|
|
826
|
+
seconds = _chat_client.STREAM_TIMEOUT.read
|
|
827
|
+
text = (
|
|
828
|
+
f"No event from the agent for {seconds:.0f} s; the run may still be in progress "
|
|
829
|
+
"on the server (a long tool call or a non-streaming model phase)."
|
|
830
|
+
)
|
|
831
|
+
if local_server_kept is True:
|
|
832
|
+
text += "\n The server was left running."
|
|
833
|
+
elif local_server_kept is False:
|
|
834
|
+
text += "\n The one-off local server was stopped."
|
|
835
|
+
if thread_id and thread_kept:
|
|
836
|
+
text += f'\n Check the thread later with: graph-agents-cli run "<message>"{resume_flags} --thread-id {thread_id}'
|
|
837
|
+
elif thread_id:
|
|
838
|
+
text += (
|
|
839
|
+
"\n Its in-memory thread went with it; pass --start-server to keep the server "
|
|
840
|
+
"(and its threads) between runs."
|
|
841
|
+
)
|
|
842
|
+
return text
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
def _transport_failure_message(
|
|
846
|
+
exc: httpx.TransportError,
|
|
847
|
+
*,
|
|
848
|
+
url: str | None,
|
|
849
|
+
renderer: _ChatRenderer,
|
|
850
|
+
resume_flags: str,
|
|
851
|
+
resumable: bool,
|
|
852
|
+
) -> str:
|
|
853
|
+
"""Tell "never reached" from "dropped after the run started", with the thread to continue."""
|
|
854
|
+
where = f"the remote agent at {url}" if url else "the local server"
|
|
855
|
+
if renderer.events == 0:
|
|
856
|
+
if url is None:
|
|
857
|
+
return (
|
|
858
|
+
f"Could not reach the local server: {exc}\n"
|
|
859
|
+
" It has been stopped; retry to start a fresh one."
|
|
860
|
+
)
|
|
861
|
+
if isinstance(exc, (httpx.ConnectError, httpx.ConnectTimeout)):
|
|
862
|
+
return (
|
|
863
|
+
f"Could not reach remote agent at: {url}\n"
|
|
864
|
+
f" {exc}\n"
|
|
865
|
+
" Check that the URL is correct and the service is running."
|
|
866
|
+
)
|
|
867
|
+
return (
|
|
868
|
+
f"The connection to {where} failed before the run started: {exc}\n"
|
|
869
|
+
" Nothing was answered; retry, and check the service (and any proxy in between)."
|
|
870
|
+
)
|
|
871
|
+
incomplete = "The answer above is incomplete. " if renderer.rendered else ""
|
|
872
|
+
kept = "; the thread keeps every turn that finished." if resumable else "."
|
|
873
|
+
lines = [
|
|
874
|
+
f"The connection to {where} dropped after the run started: {exc}",
|
|
875
|
+
f" {incomplete}The run was interrupted (the server stopped, or the connection was "
|
|
876
|
+
f"cut){kept}",
|
|
877
|
+
]
|
|
878
|
+
if url is None:
|
|
879
|
+
lines.append(
|
|
880
|
+
" The local server has been stopped (its log: .graph-agents-cli/run_server.log)."
|
|
881
|
+
)
|
|
882
|
+
if renderer.thread_id:
|
|
883
|
+
thread = _thread_lines(
|
|
884
|
+
renderer.thread_id,
|
|
885
|
+
resume_flags=resume_flags,
|
|
886
|
+
resumable=resumable,
|
|
887
|
+
server_stopped=url is None,
|
|
888
|
+
)
|
|
889
|
+
lines.extend(f" {line}" for line in thread)
|
|
890
|
+
return "\n".join(lines)
|
|
891
|
+
|
|
892
|
+
|
|
893
|
+
def _project_auth_policy(*, remote: bool) -> str | None:
|
|
894
|
+
"""The auth policy of the project in the current directory, if any (for hints only).
|
|
895
|
+
|
|
896
|
+
For the local server, ``AUTH_POLICY`` from the environment or the project's
|
|
897
|
+
``.env`` (what that server reads) wins over the manifest. A deployed agent
|
|
898
|
+
runs the manifest's policy (the chart's ``AUTH_POLICY``), so ``remote``
|
|
899
|
+
reads the manifest alone.
|
|
900
|
+
"""
|
|
901
|
+
import os
|
|
902
|
+
|
|
903
|
+
root = find_project_root(Path.cwd())
|
|
904
|
+
if root is None:
|
|
905
|
+
return None
|
|
906
|
+
from graph_agents_cli._defaults import normalize_auth_policy
|
|
907
|
+
|
|
908
|
+
policy = "" if remote else (os.environ.get("AUTH_POLICY") or "").strip()
|
|
909
|
+
env_path = root / ".env"
|
|
910
|
+
if not policy and not remote and env_path.is_file():
|
|
911
|
+
from dotenv import dotenv_values
|
|
912
|
+
|
|
913
|
+
try:
|
|
914
|
+
policy = (dotenv_values(env_path).get("AUTH_POLICY") or "").strip()
|
|
915
|
+
except Exception:
|
|
916
|
+
policy = ""
|
|
917
|
+
if not policy:
|
|
918
|
+
try:
|
|
919
|
+
policy = read_project_config(str(root)).auth_policy
|
|
920
|
+
except click.ClickException:
|
|
921
|
+
return None
|
|
922
|
+
return normalize_auth_policy(policy, warn=False)
|
|
923
|
+
|
|
924
|
+
|
|
925
|
+
_TOKEN_IN_ENV = (
|
|
926
|
+
f"export {API_KEY_ENV}=<credential>: sent as 'Authorization: Bearer <credential>', and kept "
|
|
927
|
+
"out of argv and shell history (unlike --header)"
|
|
928
|
+
)
|
|
929
|
+
|
|
930
|
+
|
|
931
|
+
def _auth_hint(policy: str | None, *, remote: bool) -> str:
|
|
932
|
+
"""How to send the credential the project's policy expects (bearer credentials via env)."""
|
|
933
|
+
shared = f"shared-bearer: export {API_KEY_ENV}=<API_KEY>" + (
|
|
934
|
+
"" if remote else " (a local run otherwise sends the API_KEY in .env)"
|
|
935
|
+
)
|
|
936
|
+
jwt = f"jwt: export {API_KEY_ENV}=<token>" + (
|
|
937
|
+
""
|
|
938
|
+
if remote
|
|
939
|
+
else "; for a local token: "
|
|
940
|
+
f'export {API_KEY_ENV}="$(graph-agents-cli auth dev-token --sub <user>)"'
|
|
941
|
+
)
|
|
942
|
+
custom = "custom: pass what the policy reads with --header 'Name: value' or --cookie name=value"
|
|
943
|
+
by_policy = {"shared-bearer": shared, "jwt": jwt, "custom": custom}
|
|
944
|
+
if policy in by_policy:
|
|
945
|
+
return f"\n Authentication failed. {by_policy[policy]}."
|
|
946
|
+
return (
|
|
947
|
+
f"\n Authentication failed. Send a bearer credential with {_TOKEN_IN_ENV}."
|
|
948
|
+
f"\n {shared}\n {jwt}\n {custom}"
|
|
949
|
+
)
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
def _unavailable_hint(body: str, *, remote: bool) -> str:
|
|
953
|
+
"""A 503: which setting the server says it is missing (the body names the policy)."""
|
|
954
|
+
if "AUTH_POLICY=jwt" in body:
|
|
955
|
+
if remote:
|
|
956
|
+
return (
|
|
957
|
+
"\n The server has no usable JWT settings: set AUTH_JWT_JWKS_URL (or "
|
|
958
|
+
"AUTH_JWT_PUBLIC_KEY), AUTH_JWT_ISSUER and AUTH_JWT_AUDIENCE in the "
|
|
959
|
+
"environment's chart values (the server log names the problem)."
|
|
960
|
+
)
|
|
961
|
+
return (
|
|
962
|
+
"\n The local server has no usable JWT settings. For local runs, "
|
|
963
|
+
"`graph-agents-cli auth dev-token --sub <user>` writes a dev key, issuer and "
|
|
964
|
+
"audience to .env and prints a token; then restart a kept server with "
|
|
965
|
+
"`graph-agents-cli run --stop-server`. Or set AUTH_JWT_JWKS_URL to your issuer's keys."
|
|
966
|
+
)
|
|
967
|
+
if "API_KEY" in body:
|
|
968
|
+
if remote:
|
|
969
|
+
return (
|
|
970
|
+
"\n The server has no API_KEY: `graph-agents-cli secrets apply --env <env>` "
|
|
971
|
+
"puts one in the app Secret."
|
|
972
|
+
)
|
|
973
|
+
return (
|
|
974
|
+
"\n API_KEY is not set: `graph-agents-cli login --write-env` generates one into .env."
|
|
975
|
+
)
|
|
976
|
+
if "AUTH_POLICY=custom" in body:
|
|
977
|
+
return (
|
|
978
|
+
"\n The custom auth policy is still the fail-closed stub: implement "
|
|
979
|
+
"CustomPolicy in <agent_directory>/policies/custom.py."
|
|
980
|
+
)
|
|
981
|
+
if "signing keys" in body:
|
|
982
|
+
return "\n The token issuer's keys (AUTH_JWT_JWKS_URL) are unreachable from the server."
|
|
983
|
+
return "\n The server refused the request (see its log)."
|
|
984
|
+
|
|
985
|
+
|
|
986
|
+
def _http_error_hint(
|
|
987
|
+
exc: ChatHTTPError,
|
|
988
|
+
*,
|
|
989
|
+
remote: bool,
|
|
990
|
+
thread_id: str | None,
|
|
991
|
+
policy: str | None = None,
|
|
992
|
+
approval_flags: str = "",
|
|
993
|
+
) -> str:
|
|
994
|
+
if exc.status_code == 401:
|
|
995
|
+
return _auth_hint(policy, remote=remote)
|
|
996
|
+
if exc.status_code == 403:
|
|
997
|
+
return (
|
|
998
|
+
"\n The agent knows who you are but refused the request (another principal's "
|
|
999
|
+
"thread, or an action your roles do not allow)."
|
|
1000
|
+
)
|
|
1001
|
+
if exc.status_code == 404 and thread_id:
|
|
1002
|
+
return f"\n Thread {thread_id} was not found." + (
|
|
1003
|
+
"\n In-memory threads do not survive a one-off local run; use "
|
|
1004
|
+
"--start-server or a postgres checkpointer."
|
|
1005
|
+
if not remote
|
|
1006
|
+
else ""
|
|
1007
|
+
)
|
|
1008
|
+
if exc.status_code in (404, 405):
|
|
1009
|
+
return "\n Check that --url points at the app base URL (the chat API is at <url>/chat)."
|
|
1010
|
+
if exc.status_code == 409 and APPROVAL_PENDING in (exc.body or ""):
|
|
1011
|
+
where = f" --thread-id {shlex.quote(thread_id)}" if thread_id else ""
|
|
1012
|
+
return (
|
|
1013
|
+
"\n A run on this thread is paused on a call waiting for approval: decide it first "
|
|
1014
|
+
f"(graph-agents-cli approvals list{where}{approval_flags}), then send the next message."
|
|
1015
|
+
)
|
|
1016
|
+
if exc.status_code == 409:
|
|
1017
|
+
return "\n The thread already has a run in progress; retry when it has finished."
|
|
1018
|
+
if exc.status_code == 503:
|
|
1019
|
+
return _unavailable_hint(exc.body or "", remote=remote)
|
|
1020
|
+
return ""
|
|
1021
|
+
|
|
1022
|
+
|
|
1023
|
+
def _interactive() -> bool:
|
|
1024
|
+
"""Whether a human can answer a prompt here (stdin and stdout are terminals)."""
|
|
1025
|
+
try:
|
|
1026
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
1027
|
+
except (AttributeError, ValueError):
|
|
1028
|
+
return False
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def print_approval(approval: Approval) -> None:
|
|
1032
|
+
"""The paused call, in full, before anyone decides it."""
|
|
1033
|
+
click.echo()
|
|
1034
|
+
click.secho("Approval required before this call is sent:", fg="yellow", bold=True)
|
|
1035
|
+
for line in approval.lines():
|
|
1036
|
+
click.echo(line)
|
|
1037
|
+
|
|
1038
|
+
|
|
1039
|
+
def _ask_decision() -> str | None:
|
|
1040
|
+
"""``approve`` or ``reject`` from "Approve? [y/N]"; None when the prompt is abandoned."""
|
|
1041
|
+
try:
|
|
1042
|
+
approve = click.confirm("Approve?", default=False)
|
|
1043
|
+
except click.Abort: # Ctrl-C or end of input: the approval stays pending
|
|
1044
|
+
click.echo()
|
|
1045
|
+
return None
|
|
1046
|
+
return DECISION_APPROVE if approve else DECISION_REJECT
|
|
1047
|
+
|
|
1048
|
+
|
|
1049
|
+
class DecisionRefused(click.ClickException):
|
|
1050
|
+
"""The server refused a decision (not an approver, already decided, expired): exit 1."""
|
|
1051
|
+
|
|
1052
|
+
exit_code = 1
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
def decision_refused_message(
|
|
1056
|
+
exc: ChatHTTPError, approval_id: str, *, remote: bool, policy: str | None = None
|
|
1057
|
+
) -> str:
|
|
1058
|
+
"""Why the server refused to record a decision, in words."""
|
|
1059
|
+
if exc.status_code == 401:
|
|
1060
|
+
return "The decision was refused (HTTP 401): not authenticated." + _auth_hint(
|
|
1061
|
+
policy, remote=remote
|
|
1062
|
+
)
|
|
1063
|
+
text = f"The decision on approval {safe_text(approval_id)} was refused (HTTP {exc.status_code})"
|
|
1064
|
+
reason = DECISION_REFUSALS.get(exc.status_code)
|
|
1065
|
+
return f"{text}: {reason}." if reason else f"{text}:\n {exc.body}"
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def _settle_approvals(
|
|
1069
|
+
target: RunTarget,
|
|
1070
|
+
renderer: _ChatRenderer,
|
|
1071
|
+
outcome: RunOutcome,
|
|
1072
|
+
*,
|
|
1073
|
+
interactive: bool,
|
|
1074
|
+
) -> tuple[RunOutcome, Approval | None]:
|
|
1075
|
+
"""Show each call the run pauses on; on a terminal, ask for the decision and resume.
|
|
1076
|
+
|
|
1077
|
+
Returns the final outcome and the approval still pending (None when the
|
|
1078
|
+
run finished). The prompt appears only when the requester is one of the
|
|
1079
|
+
approvers (a requester never decides a call gated for others) and the run
|
|
1080
|
+
used the chat API; otherwise the approval is left pending for the
|
|
1081
|
+
commands the footer prints.
|
|
1082
|
+
"""
|
|
1083
|
+
while outcome.status == STATUS_AWAITING_APPROVAL:
|
|
1084
|
+
approval = Approval.from_payload(outcome.approval, thread_id=outcome.thread_id)
|
|
1085
|
+
if approval is not None and outcome.thread_id:
|
|
1086
|
+
# The decision is about this run's own thread, whatever the payload names.
|
|
1087
|
+
approval = replace(approval, thread_id=outcome.thread_id)
|
|
1088
|
+
if approval is None:
|
|
1089
|
+
raise click.ClickException(
|
|
1090
|
+
"The run paused for an approval, but the server named no approval id, so it "
|
|
1091
|
+
"cannot be decided from here (see the server log)."
|
|
1092
|
+
)
|
|
1093
|
+
print_approval(approval)
|
|
1094
|
+
if not (
|
|
1095
|
+
interactive
|
|
1096
|
+
and target.mode == DEFAULT_RUN_MODE
|
|
1097
|
+
and approval.thread_id
|
|
1098
|
+
and approval.requester_may_decide
|
|
1099
|
+
):
|
|
1100
|
+
return outcome, approval
|
|
1101
|
+
decision = _ask_decision()
|
|
1102
|
+
if decision is None:
|
|
1103
|
+
return outcome, approval
|
|
1104
|
+
if decision == DECISION_APPROVE:
|
|
1105
|
+
click.secho("Approved: the call is sent as shown; the run resumes.", fg="green")
|
|
1106
|
+
else:
|
|
1107
|
+
click.secho("Rejected: the call is not sent; the run resumes.", fg="yellow")
|
|
1108
|
+
renderer.resume()
|
|
1109
|
+
try:
|
|
1110
|
+
events = _chat_client.decide_approval(
|
|
1111
|
+
target.base_url,
|
|
1112
|
+
approval.thread_id,
|
|
1113
|
+
approval.approval_id,
|
|
1114
|
+
decision,
|
|
1115
|
+
headers=target.headers,
|
|
1116
|
+
)
|
|
1117
|
+
outcome = render_chat_events(events, renderer=renderer)
|
|
1118
|
+
except ChatHTTPError as exc:
|
|
1119
|
+
raise DecisionRefused(
|
|
1120
|
+
decision_refused_message(
|
|
1121
|
+
exc,
|
|
1122
|
+
approval.approval_id,
|
|
1123
|
+
remote=target.remote,
|
|
1124
|
+
policy=_project_auth_policy(remote=target.remote),
|
|
1125
|
+
)
|
|
1126
|
+
) from exc
|
|
1127
|
+
outcome = outcome._replace(thread_id=outcome.thread_id or approval.thread_id)
|
|
1128
|
+
return outcome, None
|
|
1129
|
+
|
|
1130
|
+
|
|
1131
|
+
def _a2a_awaiting_lines(approval: Approval) -> list[str]:
|
|
1132
|
+
"""How to decide an approval an A2A task asks for (a message on the same task)."""
|
|
1133
|
+
decision = {"approval_id": approval.approval_id, "decision": "approve"}
|
|
1134
|
+
who = ", ".join(safe_text(a) for a in approval.approvers) or "an allowed approver"
|
|
1135
|
+
return [
|
|
1136
|
+
f"Awaiting approval by {who}: the task is input-required; the call was not sent.",
|
|
1137
|
+
" Decide it with a message on the same task carrying the data part "
|
|
1138
|
+
f'{safe_text(json.dumps(decision))} (or "decision": "reject"); '
|
|
1139
|
+
"`graph-agents-cli run --mode chat` asks on a terminal instead.",
|
|
1140
|
+
]
|
|
1141
|
+
|
|
1142
|
+
|
|
1143
|
+
@click.command("run")
|
|
1144
|
+
@click.argument("message")
|
|
1145
|
+
@click.option(
|
|
1146
|
+
"--mode",
|
|
1147
|
+
type=click.Choice(RUN_MODES, case_sensitive=False),
|
|
1148
|
+
default=DEFAULT_RUN_MODE,
|
|
1149
|
+
show_default=True,
|
|
1150
|
+
help="Protocol: 'chat' (POST /chat, SSE) or 'a2a' (JSON-RPC via a2a-sdk).",
|
|
1151
|
+
)
|
|
1152
|
+
@click.option(
|
|
1153
|
+
"--url",
|
|
1154
|
+
default=None,
|
|
1155
|
+
help="Base URL of a deployed agent. If given, no local server is started.",
|
|
1156
|
+
)
|
|
1157
|
+
@click.option(
|
|
1158
|
+
"--thread-id",
|
|
1159
|
+
default=None,
|
|
1160
|
+
help="Continue an existing thread (printed in the footer of a previous run).",
|
|
1161
|
+
)
|
|
1162
|
+
@click.option(
|
|
1163
|
+
"--header",
|
|
1164
|
+
"-H",
|
|
1165
|
+
"header",
|
|
1166
|
+
multiple=True,
|
|
1167
|
+
help=(
|
|
1168
|
+
"Custom HTTP header ('Key: Value'). Repeatable. An Authorization header overrides "
|
|
1169
|
+
"GRAPH_AGENTS_CLI_API_KEY; prefer the variable for bearer credentials (argv is visible "
|
|
1170
|
+
"to other local users)."
|
|
1171
|
+
),
|
|
1172
|
+
)
|
|
1173
|
+
@click.option(
|
|
1174
|
+
"--cookie",
|
|
1175
|
+
"cookie",
|
|
1176
|
+
multiple=True,
|
|
1177
|
+
help="Cookie ('name=value'), for a custom auth policy that reads cookies. Repeatable.",
|
|
1178
|
+
)
|
|
1179
|
+
@click.option(
|
|
1180
|
+
"--session-token",
|
|
1181
|
+
default=None,
|
|
1182
|
+
hidden=True,
|
|
1183
|
+
callback=deprecated_session_token,
|
|
1184
|
+
help="Deprecated alias of --header 'X-Session-Token: ...'.",
|
|
1185
|
+
)
|
|
1186
|
+
@click.option(
|
|
1187
|
+
"--file",
|
|
1188
|
+
"-f",
|
|
1189
|
+
"files",
|
|
1190
|
+
multiple=True,
|
|
1191
|
+
type=click.Path(exists=True, readable=True, dir_okay=False),
|
|
1192
|
+
help="Attach a UTF-8 text file as extra context. Repeatable.",
|
|
1193
|
+
)
|
|
1194
|
+
@click.option(
|
|
1195
|
+
"--start-server",
|
|
1196
|
+
"start_server",
|
|
1197
|
+
is_flag=True,
|
|
1198
|
+
default=False,
|
|
1199
|
+
help=(
|
|
1200
|
+
"Keep the local server running after execution. It persists until stopped with "
|
|
1201
|
+
"--stop-server, so later runs are faster and in-memory threads survive."
|
|
1202
|
+
),
|
|
1203
|
+
)
|
|
1204
|
+
@click.option(
|
|
1205
|
+
"--stop-server",
|
|
1206
|
+
"stop_server_flag",
|
|
1207
|
+
is_flag=True,
|
|
1208
|
+
default=False,
|
|
1209
|
+
is_eager=True,
|
|
1210
|
+
expose_value=False,
|
|
1211
|
+
callback=_handle_stop_server,
|
|
1212
|
+
help=(
|
|
1213
|
+
"Stop the local background server and exit. Exit 2, naming the processes still "
|
|
1214
|
+
"running, when the operating system refuses to stop them (the record is kept)."
|
|
1215
|
+
),
|
|
1216
|
+
)
|
|
1217
|
+
@click.option(
|
|
1218
|
+
"--port",
|
|
1219
|
+
type=click.IntRange(1, 65535),
|
|
1220
|
+
default=None,
|
|
1221
|
+
help=(
|
|
1222
|
+
"Port for the local server this run starts (default: the first free one of "
|
|
1223
|
+
"18080-18089, or GRAPH_AGENTS_CLI_RUN_PORT). Refused when the port is in use."
|
|
1224
|
+
),
|
|
1225
|
+
)
|
|
1226
|
+
@click.option(
|
|
1227
|
+
"--verbose",
|
|
1228
|
+
"-v",
|
|
1229
|
+
is_flag=True,
|
|
1230
|
+
default=False,
|
|
1231
|
+
help="Also print each event on one compact line (text deltas are counted, not repeated).",
|
|
1232
|
+
)
|
|
1233
|
+
def cmd_run(
|
|
1234
|
+
message: str,
|
|
1235
|
+
*,
|
|
1236
|
+
mode: str,
|
|
1237
|
+
url: str | None,
|
|
1238
|
+
thread_id: str | None,
|
|
1239
|
+
header: tuple[str, ...],
|
|
1240
|
+
cookie: tuple[str, ...],
|
|
1241
|
+
session_token: str | None,
|
|
1242
|
+
files: tuple[str, ...],
|
|
1243
|
+
start_server: bool,
|
|
1244
|
+
port: int | None,
|
|
1245
|
+
verbose: bool,
|
|
1246
|
+
) -> None:
|
|
1247
|
+
"""Run the agent with a single prompt (non-interactive).
|
|
1248
|
+
|
|
1249
|
+
MESSAGE is the prompt to send to the agent.
|
|
1250
|
+
|
|
1251
|
+
\b
|
|
1252
|
+
Run from your project directory to query the agent locally. A plain run
|
|
1253
|
+
starts a one-off server (uvicorn under the fastapi runtime, langgraph dev
|
|
1254
|
+
under langgraph-server) and shuts it down when it finishes; pass
|
|
1255
|
+
--start-server to keep it running. Later plain runs reuse a running
|
|
1256
|
+
server. Stop it with --stop-server. After 30 minutes idle, the next
|
|
1257
|
+
request restarts it, or reuses it with a warning when the operating
|
|
1258
|
+
system refuses to stop it (a sandbox may let a command signal only what
|
|
1259
|
+
it started itself). The server listens on the first free port of
|
|
1260
|
+
18080-18089, or on --port / GRAPH_AGENTS_CLI_RUN_PORT when given.
|
|
1261
|
+
|
|
1262
|
+
\b
|
|
1263
|
+
Use --url to query a deployed agent instead. --mode selects the protocol
|
|
1264
|
+
(default chat). Credentials follow the project's auth policy, locally and
|
|
1265
|
+
with --url. Put a bearer credential in GRAPH_AGENTS_CLI_API_KEY (sent as
|
|
1266
|
+
'Authorization: Bearer <value>'): unlike --header, it stays out of the
|
|
1267
|
+
process list and your shell history.
|
|
1268
|
+
shared-bearer GRAPH_AGENTS_CLI_API_KEY=<API_KEY> (locally: the API_KEY in .env)
|
|
1269
|
+
jwt GRAPH_AGENTS_CLI_API_KEY=<token> (locally: auth dev-token)
|
|
1270
|
+
custom --header 'Name: value' or --cookie name=value
|
|
1271
|
+
|
|
1272
|
+
\b
|
|
1273
|
+
--thread-id continues a conversation; the footer of every run, one that
|
|
1274
|
+
ends with an error included, prints the thread id and the command to
|
|
1275
|
+
resume it. --file attaches UTF-8 text files as extra context.
|
|
1276
|
+
|
|
1277
|
+
\b
|
|
1278
|
+
A call gated by the API policy (an approval block in api-policy.yaml)
|
|
1279
|
+
pauses the run before it is sent, and the call is printed in full. On a
|
|
1280
|
+
terminal, when the requester is an approver, "Approve? [y/N]" decides
|
|
1281
|
+
it (Enter rejects) and the run continues. Otherwise the run ends with an
|
|
1282
|
+
"Awaiting approval" line and the `graph-agents-cli approvals approve` /
|
|
1283
|
+
`reject` commands that decide it; a one-off local server with an
|
|
1284
|
+
in-memory checkpointer is then kept running so the paused run survives.
|
|
1285
|
+
|
|
1286
|
+
\b
|
|
1287
|
+
Exit codes:
|
|
1288
|
+
0 the agent answered, or the run is awaiting an approval
|
|
1289
|
+
1 the agent refused or reported an error (HTTP error, error event), or
|
|
1290
|
+
the decision was refused (not an approver, already decided, expired)
|
|
1291
|
+
2 the agent could not be reached or went silent; --stop-server could
|
|
1292
|
+
not stop the local server (the processes still running are named,
|
|
1293
|
+
and its record is kept)
|
|
1294
|
+
3 configuration error (no project, port unavailable)
|
|
1295
|
+
"""
|
|
1296
|
+
mode = mode.lower()
|
|
1297
|
+
# The deprecated --session-token is an X-Session-Token header from here on.
|
|
1298
|
+
header = fold_session_token(header, session_token)
|
|
1299
|
+
session_token = None
|
|
1300
|
+
if url and start_server:
|
|
1301
|
+
click.secho(
|
|
1302
|
+
"Warning: --start-server has no effect when using --url.", fg="yellow", err=True
|
|
1303
|
+
)
|
|
1304
|
+
if url and port is not None:
|
|
1305
|
+
click.secho("Warning: --port has no effect when using --url.", fg="yellow", err=True)
|
|
1306
|
+
|
|
1307
|
+
# SIGTERM/SIGHUP unwind like Ctrl-C, so the server this run starts is always
|
|
1308
|
+
# stopped and its pid file removed (an IDE stop button, a CI timeout, kill).
|
|
1309
|
+
with terminate_like_interrupt():
|
|
1310
|
+
prompt = compose_message(message, files)
|
|
1311
|
+
if mode == "a2a":
|
|
1312
|
+
_require_a2a_sdk()
|
|
1313
|
+
target = _resolve_target(
|
|
1314
|
+
url=url,
|
|
1315
|
+
mode=mode,
|
|
1316
|
+
header=header,
|
|
1317
|
+
cookie=cookie,
|
|
1318
|
+
session_token=session_token,
|
|
1319
|
+
start_server=start_server,
|
|
1320
|
+
port=port,
|
|
1321
|
+
)
|
|
1322
|
+
if url:
|
|
1323
|
+
click.echo(f"Querying remote agent: {url} (mode: {target.mode})")
|
|
1324
|
+
|
|
1325
|
+
resume_flags = _build_resume_flags(url, target.mode, header, cookie, session_token)
|
|
1326
|
+
# Only tear down a server this invocation started; a reused persistent
|
|
1327
|
+
# server (e.g. from --start-server) is left running.
|
|
1328
|
+
should_stop_server = not url and not start_server and target.started_server
|
|
1329
|
+
keep_server = bool(url) or not should_stop_server
|
|
1330
|
+
|
|
1331
|
+
handler = _query_a2a if target.mode == "a2a" else _query_chat
|
|
1332
|
+
renderer = _ChatRenderer(verbose=verbose)
|
|
1333
|
+
failed: AgentError | None = None
|
|
1334
|
+
pending: Approval | None = None
|
|
1335
|
+
kept_for_approval = False
|
|
1336
|
+
try:
|
|
1337
|
+
try:
|
|
1338
|
+
outcome = handler(
|
|
1339
|
+
target,
|
|
1340
|
+
prompt,
|
|
1341
|
+
thread_id=thread_id,
|
|
1342
|
+
renderer=renderer,
|
|
1343
|
+
display_message=message,
|
|
1344
|
+
)
|
|
1345
|
+
outcome, pending = _settle_approvals(
|
|
1346
|
+
target, renderer, outcome, interactive=_interactive()
|
|
1347
|
+
)
|
|
1348
|
+
if pending is not None and should_stop_server and target.checkpointer != "postgres":
|
|
1349
|
+
# The paused run lives in this server's memory: stopping it
|
|
1350
|
+
# would drop the approval the footer tells the user to decide.
|
|
1351
|
+
should_stop_server = False
|
|
1352
|
+
kept_for_approval = True
|
|
1353
|
+
except AgentError as exc:
|
|
1354
|
+
click.secho(f"[error: {exc.code}]: {exc.message}", fg="red")
|
|
1355
|
+
failed = exc
|
|
1356
|
+
outcome = renderer.outcome()._replace(thread_id=renderer.thread_id or thread_id)
|
|
1357
|
+
except ChatHTTPError as exc:
|
|
1358
|
+
hint = _http_error_hint(
|
|
1359
|
+
exc,
|
|
1360
|
+
remote=bool(url),
|
|
1361
|
+
thread_id=thread_id,
|
|
1362
|
+
policy=_project_auth_policy(remote=bool(url)),
|
|
1363
|
+
approval_flags=_build_resume_flags(url, DEFAULT_RUN_MODE, header, cookie, None),
|
|
1364
|
+
)
|
|
1365
|
+
raise click.ClickException(
|
|
1366
|
+
f"Agent request failed (HTTP {exc.status_code}):\n {exc.body}{hint}"
|
|
1367
|
+
) from exc
|
|
1368
|
+
except httpx.ReadTimeout as exc:
|
|
1369
|
+
# The server answered and then went quiet (a long tool call or a
|
|
1370
|
+
# non-streaming model phase): it is neither unreachable nor wedged,
|
|
1371
|
+
# so a reused/persistent server is left alone and the message
|
|
1372
|
+
# says what happened. ReadTimeout is a TransportError: keep this first.
|
|
1373
|
+
raise _TransportFailure(
|
|
1374
|
+
_read_timeout_message(
|
|
1375
|
+
renderer.thread_id or thread_id,
|
|
1376
|
+
resume_flags,
|
|
1377
|
+
local_server_kept=None if url else not should_stop_server,
|
|
1378
|
+
thread_kept=bool(url)
|
|
1379
|
+
or not should_stop_server
|
|
1380
|
+
or target.checkpointer == "postgres",
|
|
1381
|
+
)
|
|
1382
|
+
) from exc
|
|
1383
|
+
except httpx.TransportError as exc:
|
|
1384
|
+
if not url:
|
|
1385
|
+
# The local server is unreachable, wedged or gone: stop it (even
|
|
1386
|
+
# one we reused) so a later retry starts a fresh one.
|
|
1387
|
+
should_stop_server = True
|
|
1388
|
+
raise _TransportFailure(
|
|
1389
|
+
_transport_failure_message(
|
|
1390
|
+
exc,
|
|
1391
|
+
url=url,
|
|
1392
|
+
renderer=renderer,
|
|
1393
|
+
resume_flags=resume_flags,
|
|
1394
|
+
# A stopped local server keeps only postgres threads.
|
|
1395
|
+
resumable=bool(url) or target.checkpointer == "postgres",
|
|
1396
|
+
)
|
|
1397
|
+
) from exc
|
|
1398
|
+
finally:
|
|
1399
|
+
if should_stop_server:
|
|
1400
|
+
# cwd is the project root here (set by _resolve_target). A server that
|
|
1401
|
+
# cannot be stopped is a warning: it never replaces the run's own outcome.
|
|
1402
|
+
try:
|
|
1403
|
+
stop_server(Path.cwd(), pid=target.server_pid)
|
|
1404
|
+
except ServerStopError as exc:
|
|
1405
|
+
warn_not_stopped(exc)
|
|
1406
|
+
|
|
1407
|
+
# After an error event too: the thread keeps every turn that finished.
|
|
1408
|
+
_print_footer(
|
|
1409
|
+
outcome,
|
|
1410
|
+
resume_flags=resume_flags,
|
|
1411
|
+
keep_server=keep_server,
|
|
1412
|
+
checkpointer=target.checkpointer,
|
|
1413
|
+
after_error=failed is not None,
|
|
1414
|
+
pending=pending,
|
|
1415
|
+
# `approvals` talks to the chat API whatever --mode this run used.
|
|
1416
|
+
approval_flags=_build_resume_flags(url, DEFAULT_RUN_MODE, header, cookie, None),
|
|
1417
|
+
mode=target.mode,
|
|
1418
|
+
kept_for_approval=kept_for_approval,
|
|
1419
|
+
)
|
|
1420
|
+
if failed is not None:
|
|
1421
|
+
raise click.exceptions.Exit(1) from failed
|