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,1151 @@
|
|
|
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
|
+
"""graph-agents-cli peer commands: the other agents this agent asks, over A2A.
|
|
16
|
+
|
|
17
|
+
A peer is an API in ``api-policy.yaml`` with ``protocol: a2a``. ``peer add``
|
|
18
|
+
writes one, with everything the template's A2A client needs: the peer's
|
|
19
|
+
endpoint and card, the JSON-RPC methods it may send, the approve gate (or the
|
|
20
|
+
denial), the credential, limits for a model-bound peer, and the project's
|
|
21
|
+
files that follow (the manifest's ``secrets.keys``, ``.env.example``, the
|
|
22
|
+
chart's values). It then regenerates ``<agent_dir>/tools/a2a_peers.py`` from
|
|
23
|
+
the policy (``_generate.py``), the module that hands the model ``ask_agent``
|
|
24
|
+
and ``approve_agent_action``. ``peer remove``, ``peer sync`` keep that module in
|
|
25
|
+
step; ``peer list`` and ``peer show [--check]`` report. Every change is
|
|
26
|
+
validated with the runtime's rules, shown as one diff and written atomically
|
|
27
|
+
(``--dry-run`` stops after the diff). ``.env`` is never touched.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import copy
|
|
33
|
+
import json
|
|
34
|
+
from collections.abc import Callable
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import Any
|
|
38
|
+
|
|
39
|
+
import click
|
|
40
|
+
from rich.markup import escape
|
|
41
|
+
from rich.table import Table
|
|
42
|
+
|
|
43
|
+
from graph_agents_cli._api_policy import (
|
|
44
|
+
A2A_APPROVE,
|
|
45
|
+
A2A_KEY,
|
|
46
|
+
A2A_OPERATION_KEY,
|
|
47
|
+
ALLOW_ACTORLESS_KEY,
|
|
48
|
+
DESCRIPTION_KEY,
|
|
49
|
+
DESCRIPTION_MAX_CHARS,
|
|
50
|
+
EXCHANGE_KEY,
|
|
51
|
+
MAX_APPROVAL_TIMEOUT_S,
|
|
52
|
+
MAX_RESPONSE_BYTES_LIMIT,
|
|
53
|
+
MIN_APPROVAL_TIMEOUT_S,
|
|
54
|
+
POLICY_FILENAME,
|
|
55
|
+
PROTOCOL_A2A,
|
|
56
|
+
PROTOCOL_KEY,
|
|
57
|
+
REQUESTER_APPROVER,
|
|
58
|
+
RPC_METHOD_KEY,
|
|
59
|
+
TOKEN_EXCHANGE_CLIENT_ID_ENV,
|
|
60
|
+
api_protocol,
|
|
61
|
+
auth_policy_findings,
|
|
62
|
+
summarize,
|
|
63
|
+
uses_exchange,
|
|
64
|
+
)
|
|
65
|
+
from graph_agents_cli._click import LazyGroup
|
|
66
|
+
from graph_agents_cli._output import Console, print_table
|
|
67
|
+
from graph_agents_cli.api import _changes as ch
|
|
68
|
+
from graph_agents_cli.api._files import (
|
|
69
|
+
Plan,
|
|
70
|
+
chart_values_files,
|
|
71
|
+
env_example_add,
|
|
72
|
+
env_example_remove,
|
|
73
|
+
print_diff,
|
|
74
|
+
read_text,
|
|
75
|
+
sync_manifest,
|
|
76
|
+
values_add,
|
|
77
|
+
values_env_set,
|
|
78
|
+
values_exchange_add,
|
|
79
|
+
values_exchange_remove,
|
|
80
|
+
values_remove,
|
|
81
|
+
)
|
|
82
|
+
from graph_agents_cli.api.cmd_api import (
|
|
83
|
+
_API_KEY_ORDER,
|
|
84
|
+
_edit,
|
|
85
|
+
_load_project,
|
|
86
|
+
_policy_header,
|
|
87
|
+
_Project,
|
|
88
|
+
_validate,
|
|
89
|
+
)
|
|
90
|
+
from graph_agents_cli.peer import _generate as gen
|
|
91
|
+
from graph_agents_cli.scaffold.utils.keyedit import YamlText, block_lines
|
|
92
|
+
|
|
93
|
+
PEER_LOCAL_URL = "http://localhost:8001"
|
|
94
|
+
DEFAULT_CALLS = ("ask", "status")
|
|
95
|
+
CALL_CHOICES = ("ask", "status", "cancel")
|
|
96
|
+
RELAY, DENY = gen.RELAY, gen.DENY
|
|
97
|
+
AUTH_BY_POLICY = {"jwt": "exchange", "custom": "forward", "shared-bearer": "bearer"}
|
|
98
|
+
DEFAULT_MAX_CALLS = 12
|
|
99
|
+
DEFAULT_READ_TIMEOUT_MS = 120_000
|
|
100
|
+
DEFAULT_CONNECT_TIMEOUT_MS = 2000
|
|
101
|
+
DEFAULT_MAX_RESPONSE_BYTES = 1_048_576
|
|
102
|
+
DEFAULT_APPROVAL_TIMEOUT_S = 900
|
|
103
|
+
CARD_TIMEOUT_S = 5.0
|
|
104
|
+
RUNTIME_MODULE = "app_utils/a2a_client.py"
|
|
105
|
+
NOTHING = "Nothing to change."
|
|
106
|
+
SHARED_BEARER_NOTE = (
|
|
107
|
+
"under shared-bearer, any holder of API_KEY (another agent included) can decide requester "
|
|
108
|
+
"gates: the agent behind {api} cannot tell this agent from the person"
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
class PeerCommandError(click.ClickException):
|
|
113
|
+
"""A check failed or the project cannot take the change (exit 3)."""
|
|
114
|
+
|
|
115
|
+
exit_code = 3
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@click.group("peer", cls=LazyGroup)
|
|
119
|
+
def peer_group() -> None:
|
|
120
|
+
"""Declare the other agents this agent asks, over A2A (its peers).
|
|
121
|
+
|
|
122
|
+
A peer is an api-policy.yaml API with `protocol: a2a`. `peer add NAME`
|
|
123
|
+
writes it (the endpoint, the JSON-RPC methods it may send, the approve gate
|
|
124
|
+
or its denial, the credential, the limits) with the manifest, .env.example
|
|
125
|
+
and chart values that follow, and regenerates <agent_dir>/tools/a2a_peers.py,
|
|
126
|
+
which gives the model `ask_agent` and, for peers it relays approvals to,
|
|
127
|
+
`approve_agent_action`. It then prints what is left to set, here and on the
|
|
128
|
+
peer. Every change is validated, shown as one diff and written atomically;
|
|
129
|
+
--dry-run prints the diff only. .env is never touched.
|
|
130
|
+
|
|
131
|
+
\b
|
|
132
|
+
Exit codes:
|
|
133
|
+
0 changed, or nothing to change
|
|
134
|
+
1 show --check: the peer is unreachable, or its card names another endpoint
|
|
135
|
+
2 usage error
|
|
136
|
+
3 not in a project, a 0.2 runtime, an invalid policy, or a change it cannot make
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def _dry_run_option(function: Callable[..., Any]) -> Callable[..., Any]:
|
|
141
|
+
return click.option(
|
|
142
|
+
"--dry-run", "dry_run", is_flag=True, default=False, help="Print the diff only."
|
|
143
|
+
)(function)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ---------------------------------------------------------------------------
|
|
147
|
+
# The project
|
|
148
|
+
# ---------------------------------------------------------------------------
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _project() -> _Project:
|
|
152
|
+
project = _load_project()
|
|
153
|
+
problem = runtime_problem(project.root, project.config.agent_directory)
|
|
154
|
+
if problem:
|
|
155
|
+
raise PeerCommandError(problem)
|
|
156
|
+
return project
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def runtime_problem(root: Path, agent_directory: str) -> str | None:
|
|
160
|
+
"""Why the project's runtime cannot have peers (a 0.2 runtime), or None."""
|
|
161
|
+
if (root / agent_directory / RUNTIME_MODULE).is_file():
|
|
162
|
+
return None
|
|
163
|
+
return (
|
|
164
|
+
f"this project's runtime predates A2A peers (no {agent_directory}/{RUNTIME_MODULE}): run "
|
|
165
|
+
"`graph-agents-cli scaffold upgrade` first. A 0.2 runtime refuses every call once "
|
|
166
|
+
"api-policy.yaml uses protocol: a2a (as KI-007)."
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _module(project: _Project) -> tuple[str, str | None]:
|
|
171
|
+
"""The generated module's path (from the root) and its text (None: absent)."""
|
|
172
|
+
path = gen.module_path(project.root, project.config.agent_directory)
|
|
173
|
+
rel = path.relative_to(project.root).as_posix()
|
|
174
|
+
text = read_text(path)
|
|
175
|
+
if text is not None and not gen.is_generated(text):
|
|
176
|
+
raise PeerCommandError(
|
|
177
|
+
f"{rel} exists and was not generated by graph-agents-cli (no marker line): move "
|
|
178
|
+
f"it; graph-agents-cli owns tools/{gen.MODULE_NAME}"
|
|
179
|
+
)
|
|
180
|
+
return rel, text
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _names(project: _Project, extra: dict[str, str] | None = None) -> dict[str, str]:
|
|
184
|
+
"""The peer name of each API: the generated module's, plus `extra` (API -> name)."""
|
|
185
|
+
_rel, text = _module(project)
|
|
186
|
+
return {**gen.names_in(text), **(extra or {})}
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _sync_module(
|
|
190
|
+
plan: Plan,
|
|
191
|
+
project: _Project,
|
|
192
|
+
document: dict[str, Any] | None,
|
|
193
|
+
extra: dict[str, str] | None = None,
|
|
194
|
+
) -> None:
|
|
195
|
+
rel, before = _module(project)
|
|
196
|
+
peers = gen.peers_of(document, _names(project, extra))
|
|
197
|
+
plan.set_text(rel, before, gen.module_text(peers, project.config.agent_directory))
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _peers(project: _Project) -> dict[str, gen.Peer]:
|
|
201
|
+
return {peer.name: peer for peer in gen.peers_of(project.document, _names(project))}
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _peer(project: _Project, name: str) -> gen.Peer:
|
|
205
|
+
peers = _peers(project)
|
|
206
|
+
if name not in peers:
|
|
207
|
+
known = ", ".join(peers) or "none"
|
|
208
|
+
raise PeerCommandError(f"no peer {name!r} in {POLICY_FILENAME} (peers: {known})")
|
|
209
|
+
return peers[name]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _client_id(project: _Project) -> str:
|
|
213
|
+
"""This agent's client id at the issuer, as the chart sets it (else the project's name)."""
|
|
214
|
+
files = chart_values_files(project.root, project.config)
|
|
215
|
+
if files:
|
|
216
|
+
try:
|
|
217
|
+
env = YamlText(read_text(files[0]) or "").get(("env",))
|
|
218
|
+
except Exception:
|
|
219
|
+
env = None
|
|
220
|
+
value = env.get(TOKEN_EXCHANGE_CLIENT_ID_ENV) if isinstance(env, dict) else None
|
|
221
|
+
if isinstance(value, str) and value and "CHANGE-ME" not in value:
|
|
222
|
+
return value
|
|
223
|
+
return project.config.project_name or "<this agent's client id>"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ---------------------------------------------------------------------------
|
|
227
|
+
# The card (--card on add, --check on show)
|
|
228
|
+
# ---------------------------------------------------------------------------
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _read_card(source: str) -> dict[str, Any]:
|
|
232
|
+
"""A card from a file, or fetched (no credential) from a URL; ValueError when unreadable."""
|
|
233
|
+
if source.startswith(("http://", "https://")):
|
|
234
|
+
import httpx
|
|
235
|
+
|
|
236
|
+
response = httpx.get(
|
|
237
|
+
source,
|
|
238
|
+
headers={"A2A-Version": "1.0"},
|
|
239
|
+
timeout=CARD_TIMEOUT_S,
|
|
240
|
+
follow_redirects=False,
|
|
241
|
+
)
|
|
242
|
+
if response.status_code == 401:
|
|
243
|
+
raise ValueError("401: the card needs a credential (KI-120)")
|
|
244
|
+
response.raise_for_status()
|
|
245
|
+
data = response.json()
|
|
246
|
+
else:
|
|
247
|
+
data = json.loads(Path(source).read_text(encoding="utf-8"))
|
|
248
|
+
if not isinstance(data, dict):
|
|
249
|
+
raise ValueError("not an agent card (a JSON object)")
|
|
250
|
+
return data
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _card_interface(card: dict[str, Any]) -> dict[str, Any] | None:
|
|
254
|
+
for interface in card.get("supportedInterfaces") or []:
|
|
255
|
+
if (
|
|
256
|
+
isinstance(interface, dict)
|
|
257
|
+
and str(interface.get("protocolBinding", "")).upper() == "JSONRPC"
|
|
258
|
+
and str(interface.get("protocolVersion", "")).startswith("1.")
|
|
259
|
+
):
|
|
260
|
+
return interface
|
|
261
|
+
return None
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _card_reads_origin(card: dict[str, Any]) -> bool:
|
|
265
|
+
capabilities = card.get("capabilities") if isinstance(card.get("capabilities"), dict) else {}
|
|
266
|
+
return any(
|
|
267
|
+
isinstance(e, dict) and e.get("uri") == ORIGIN_EXTENSION
|
|
268
|
+
for e in capabilities.get("extensions") or []
|
|
269
|
+
)
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
ORIGIN_EXTENSION = "https://ss7172.github.io/graph-agents-cli/a2a/ext/origin/v1"
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
# ---------------------------------------------------------------------------
|
|
276
|
+
# add
|
|
277
|
+
# ---------------------------------------------------------------------------
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _parse_calls(value: str) -> list[str]:
|
|
281
|
+
calls = [part.strip() for part in value.split(",") if part.strip()]
|
|
282
|
+
unknown = [c for c in calls if c not in CALL_CHOICES]
|
|
283
|
+
if unknown or "ask" not in calls:
|
|
284
|
+
raise click.UsageError(
|
|
285
|
+
f"--calls takes a comma list of {', '.join(CALL_CHOICES)}, with ask (got {value!r})"
|
|
286
|
+
)
|
|
287
|
+
return calls
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _entry(
|
|
291
|
+
*,
|
|
292
|
+
description: str | None,
|
|
293
|
+
path: str,
|
|
294
|
+
url_env: str,
|
|
295
|
+
auth: str,
|
|
296
|
+
token_env: str | None,
|
|
297
|
+
audience: str | None,
|
|
298
|
+
scope: str | None,
|
|
299
|
+
resource: str | None,
|
|
300
|
+
allow_actorless: bool,
|
|
301
|
+
forward_audience: bool,
|
|
302
|
+
calls: list[str],
|
|
303
|
+
approvals: str,
|
|
304
|
+
approval_timeout_s: int,
|
|
305
|
+
max_calls_per_run: int,
|
|
306
|
+
read_timeout_ms: int,
|
|
307
|
+
max_response_bytes: int,
|
|
308
|
+
) -> dict[str, Any]:
|
|
309
|
+
"""The api-policy entry of a peer, its keys in the schema's order."""
|
|
310
|
+
api: dict[str, Any] = {}
|
|
311
|
+
if description:
|
|
312
|
+
api[DESCRIPTION_KEY] = description
|
|
313
|
+
api[PROTOCOL_KEY] = PROTOCOL_A2A
|
|
314
|
+
api[A2A_KEY] = {"path": path}
|
|
315
|
+
api["base_url_env"] = url_env
|
|
316
|
+
api["auth"] = auth
|
|
317
|
+
if auth == "bearer":
|
|
318
|
+
api["token_env"] = token_env
|
|
319
|
+
if auth == "forward" and forward_audience and audience:
|
|
320
|
+
api["forward_audience"] = audience
|
|
321
|
+
if auth == "exchange":
|
|
322
|
+
exchange: dict[str, Any] = {"audience": audience}
|
|
323
|
+
if scope:
|
|
324
|
+
exchange["scope"] = scope
|
|
325
|
+
if resource:
|
|
326
|
+
exchange["resource"] = resource
|
|
327
|
+
if allow_actorless:
|
|
328
|
+
exchange[ALLOW_ACTORLESS_KEY] = True
|
|
329
|
+
api[EXCHANGE_KEY] = exchange
|
|
330
|
+
api["allowed_methods"] = ["GET", "POST"]
|
|
331
|
+
operations: list[dict[str, Any]] = [
|
|
332
|
+
{
|
|
333
|
+
"operationId": gen.CARD_OPERATION,
|
|
334
|
+
"methods": ["GET"],
|
|
335
|
+
"path": f"{path}{gen.CARD_SUFFIX}",
|
|
336
|
+
},
|
|
337
|
+
{RPC_METHOD_KEY: "SendMessage", "methods": ["POST"], "path": path},
|
|
338
|
+
]
|
|
339
|
+
if "status" in calls or approvals == RELAY: # the relay reads the task first
|
|
340
|
+
operations.append({RPC_METHOD_KEY: "GetTask", "methods": ["POST"], "path": path})
|
|
341
|
+
if "cancel" in calls:
|
|
342
|
+
operations.append({RPC_METHOD_KEY: "CancelTask", "methods": ["POST"], "path": path})
|
|
343
|
+
if approvals == RELAY:
|
|
344
|
+
operations.append(
|
|
345
|
+
{"operationId": gen.LEDGER_OPERATION, "methods": ["GET"], "path": gen.LEDGER_PATH}
|
|
346
|
+
)
|
|
347
|
+
api[ch.ALLOWED] = operations
|
|
348
|
+
if approvals == DENY:
|
|
349
|
+
api[ch.DENIED] = [{A2A_OPERATION_KEY: A2A_APPROVE}]
|
|
350
|
+
api["timeouts_ms"] = {"connect": DEFAULT_CONNECT_TIMEOUT_MS, "read": read_timeout_ms}
|
|
351
|
+
api["limits"] = {
|
|
352
|
+
"max_calls_per_run": max_calls_per_run,
|
|
353
|
+
"max_response_bytes": max_response_bytes,
|
|
354
|
+
}
|
|
355
|
+
if approvals == RELAY:
|
|
356
|
+
api["approval"] = [
|
|
357
|
+
{
|
|
358
|
+
"required_for": {"operations": [{A2A_OPERATION_KEY: A2A_APPROVE}]},
|
|
359
|
+
"approvers": [REQUESTER_APPROVER],
|
|
360
|
+
"timeout_s": approval_timeout_s,
|
|
361
|
+
}
|
|
362
|
+
]
|
|
363
|
+
return api
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
def _same_peer(existing: dict[str, Any], path: str) -> bool:
|
|
367
|
+
return (
|
|
368
|
+
api_protocol(existing) == PROTOCOL_A2A and (existing.get(A2A_KEY) or {}).get("path") == path
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def left_for_you(
|
|
373
|
+
project: _Project,
|
|
374
|
+
name: str,
|
|
375
|
+
api_name: str,
|
|
376
|
+
api: dict[str, Any],
|
|
377
|
+
*,
|
|
378
|
+
first_exchange: bool,
|
|
379
|
+
cluster_envs: list[str],
|
|
380
|
+
) -> list[str]:
|
|
381
|
+
"""What `peer add` cannot do: settings here, at the issuer, and on the peer (printed)."""
|
|
382
|
+
config = project.config
|
|
383
|
+
client = _client_id(project)
|
|
384
|
+
url_env = api["base_url_env"]
|
|
385
|
+
items = []
|
|
386
|
+
where = "in .env (local runs)"
|
|
387
|
+
if config.deployment_target == "kubernetes":
|
|
388
|
+
missing = [e for e in _environments(project) if e not in cluster_envs]
|
|
389
|
+
if missing:
|
|
390
|
+
where += (
|
|
391
|
+
f" and in values-{{{','.join(missing)}}}.yaml (values.yaml holds a placeholder)"
|
|
392
|
+
)
|
|
393
|
+
items.append(f"set {url_env} (the base URL of {name}) {where}")
|
|
394
|
+
auth = api["auth"]
|
|
395
|
+
audience = (api.get(EXCHANGE_KEY) or {}).get("audience") or api.get("forward_audience")
|
|
396
|
+
if auth == "exchange":
|
|
397
|
+
if first_exchange:
|
|
398
|
+
items.append(
|
|
399
|
+
"set TOKEN_EXCHANGE_URL (the issuer's token endpoint) and TOKEN_EXCHANGE_CLIENT_ID "
|
|
400
|
+
"(this agent's client there) in .env and in the chart's values-<env>.yaml; put "
|
|
401
|
+
"TOKEN_EXCHANGE_CLIENT_SECRET in .env (and .env.<env>, then `graph-agents-cli "
|
|
402
|
+
"secrets apply --env <env>`)"
|
|
403
|
+
)
|
|
404
|
+
items.append(
|
|
405
|
+
f"at the issuer: let this agent's client ({client}) exchange users' tokens for "
|
|
406
|
+
f"audience {audience}, naming it in the exchanged token's act claim"
|
|
407
|
+
)
|
|
408
|
+
items.append(
|
|
409
|
+
f"on {name}: AUTH_JWT_AUDIENCE includes {audience}, and AUTH_ALLOWED_ACTORS "
|
|
410
|
+
f"includes {client} (the actor id {name} sees is the act.sub of the exchanged "
|
|
411
|
+
f"token: if the issuer names this agent otherwise there, for example agent:{client}, "
|
|
412
|
+
"list that id instead, and give it to --relayers below)"
|
|
413
|
+
)
|
|
414
|
+
actorless = (api.get(EXCHANGE_KEY) or {}).get(ALLOW_ACTORLESS_KEY) is True
|
|
415
|
+
if actorless:
|
|
416
|
+
items.append(
|
|
417
|
+
f"on {name} (required: this agent sends tokens that name no actor, "
|
|
418
|
+
f"exchange.allow_actorless): AUTH_JWT_DIRECT_CLIENTS=<the clients people sign in "
|
|
419
|
+
f"with>, and client:{client} in AUTH_ALLOWED_ACTORS; otherwise {name} reads this "
|
|
420
|
+
"agent's calls as the person's own"
|
|
421
|
+
)
|
|
422
|
+
else:
|
|
423
|
+
items.append(
|
|
424
|
+
"if the issuer's exchanged tokens name no actor (no act claim; Keycloak adds "
|
|
425
|
+
"none: decode one to see), this agent refuses them: then add --allow-actorless "
|
|
426
|
+
f"(exchange.allow_actorless) and, on {name}, set AUTH_JWT_DIRECT_CLIENTS=<the "
|
|
427
|
+
f"clients people sign in with> and list client:{client} in AUTH_ALLOWED_ACTORS"
|
|
428
|
+
)
|
|
429
|
+
relayer = f"client:{client}" if actorless else client
|
|
430
|
+
elif auth == "bearer":
|
|
431
|
+
items.append(
|
|
432
|
+
f"put {api['token_env']} (the key {name} accepts, its API_KEY) in .env"
|
|
433
|
+
+ (
|
|
434
|
+
" and .env.<env>, then `graph-agents-cli secrets apply --env <env>`"
|
|
435
|
+
if config.deployment_target == "kubernetes"
|
|
436
|
+
else ""
|
|
437
|
+
)
|
|
438
|
+
)
|
|
439
|
+
items.append(SHARED_BEARER_NOTE.format(api=name))
|
|
440
|
+
relayer = client
|
|
441
|
+
else:
|
|
442
|
+
items.append(
|
|
443
|
+
f"make the auth policy give callers attributes['credentials']['{api_name}'] (the "
|
|
444
|
+
f"credential forwarded to {name}), and have {name}'s policy set the actor for it "
|
|
445
|
+
"(a custom policy that forwards users' credentials must, or the callee treats this "
|
|
446
|
+
"agent as the person)"
|
|
447
|
+
)
|
|
448
|
+
relayer = client
|
|
449
|
+
if api.get("approval"):
|
|
450
|
+
items.append(
|
|
451
|
+
f"on {name}, to let this agent relay the person's decisions: `graph-agents-cli api "
|
|
452
|
+
f"approval <its gated API> --decide-with relayed --relayers {relayer}` for each gate "
|
|
453
|
+
f"a relay should reach (a reviewed loosening there; without it the person approves "
|
|
454
|
+
f"at {name} directly)"
|
|
455
|
+
)
|
|
456
|
+
items.append(
|
|
457
|
+
f"set PRINCIPAL_HASH_SALT (a secret): the conversation ids sent to {name} are keyed with it"
|
|
458
|
+
)
|
|
459
|
+
items.append(
|
|
460
|
+
f"then `graph-agents-cli lint`, `graph-agents-cli peer show {name} --check`, and an eval "
|
|
461
|
+
"case at the agent people talk to"
|
|
462
|
+
)
|
|
463
|
+
return items
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _environments(project: _Project) -> list[str]:
|
|
467
|
+
return [
|
|
468
|
+
path.stem.removeprefix("values-")
|
|
469
|
+
for path in chart_values_files(project.root, project.config)[1:]
|
|
470
|
+
]
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _finish(
|
|
474
|
+
project: _Project,
|
|
475
|
+
plan: Plan,
|
|
476
|
+
*,
|
|
477
|
+
dry_run: bool,
|
|
478
|
+
notes: list[str],
|
|
479
|
+
todos: list[str],
|
|
480
|
+
) -> None:
|
|
481
|
+
console = Console()
|
|
482
|
+
if not plan.effective:
|
|
483
|
+
console.print(NOTHING)
|
|
484
|
+
return
|
|
485
|
+
print_diff(plan.diff())
|
|
486
|
+
click.echo()
|
|
487
|
+
for note in notes:
|
|
488
|
+
console.print(f"Note: {escape(note)}", style="yellow", highlight=False)
|
|
489
|
+
if dry_run:
|
|
490
|
+
console.print("Dry run: nothing was written.", style="yellow")
|
|
491
|
+
else:
|
|
492
|
+
plan.write()
|
|
493
|
+
written = ", ".join(change.path for change in plan.effective)
|
|
494
|
+
console.print(f"Wrote {escape(written)}.", style="green")
|
|
495
|
+
left = [*plan.left_for_you, *todos]
|
|
496
|
+
if left:
|
|
497
|
+
console.print("Left for you:", style="bold")
|
|
498
|
+
for item in left:
|
|
499
|
+
console.print(f" - {escape(item)}", highlight=False)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
@peer_group.command("add")
|
|
503
|
+
@click.argument("name")
|
|
504
|
+
@click.option("--api-name", "api_name", default=None, help="The API's name (default <NAME>_agent).")
|
|
505
|
+
@click.option(
|
|
506
|
+
"--url-env", "url_env", default=None, help="Base URL variable (default <NAME>_AGENT_URL)."
|
|
507
|
+
)
|
|
508
|
+
@click.option(
|
|
509
|
+
"--path",
|
|
510
|
+
"path",
|
|
511
|
+
default=None,
|
|
512
|
+
help="The peer's A2A endpoint (default /a2a/<NAME>: its agent directory or A2A_NAME).",
|
|
513
|
+
)
|
|
514
|
+
@click.option(
|
|
515
|
+
"--auth",
|
|
516
|
+
"auth",
|
|
517
|
+
type=click.Choice(["exchange", "forward", "bearer"]),
|
|
518
|
+
default=None,
|
|
519
|
+
help="Default by the auth policy: jwt exchange, custom forward, shared-bearer bearer.",
|
|
520
|
+
)
|
|
521
|
+
@click.option(
|
|
522
|
+
"--audience",
|
|
523
|
+
"audience",
|
|
524
|
+
default=None,
|
|
525
|
+
help="exchange: the audience the issuer mints for (default NAME); forward (jwt): forward_audience.",
|
|
526
|
+
)
|
|
527
|
+
@click.option("--scope", "scope", default=None, help="exchange: the scopes to ask for.")
|
|
528
|
+
@click.option("--resource", "resource", default=None, help="exchange: the resource indicator.")
|
|
529
|
+
@click.option(
|
|
530
|
+
"--allow-actorless",
|
|
531
|
+
"allow_actorless",
|
|
532
|
+
is_flag=True,
|
|
533
|
+
default=False,
|
|
534
|
+
help=(
|
|
535
|
+
"exchange: accept exchanged tokens that name no actor; the peer must then set "
|
|
536
|
+
"AUTH_JWT_DIRECT_CLIENTS and list client:<this agent's client id>."
|
|
537
|
+
),
|
|
538
|
+
)
|
|
539
|
+
@click.option(
|
|
540
|
+
"--token-env",
|
|
541
|
+
"token_env",
|
|
542
|
+
default=None,
|
|
543
|
+
help="bearer: the key's variable (default <NAME>_AGENT_KEY).",
|
|
544
|
+
)
|
|
545
|
+
@click.option(
|
|
546
|
+
"--description",
|
|
547
|
+
"description",
|
|
548
|
+
default=None,
|
|
549
|
+
help=f"What the peer does, for the model's roster (at most {DESCRIPTION_MAX_CHARS} characters).",
|
|
550
|
+
)
|
|
551
|
+
@click.option(
|
|
552
|
+
"--card",
|
|
553
|
+
"card",
|
|
554
|
+
default=None,
|
|
555
|
+
metavar="URL|FILE",
|
|
556
|
+
help="Read the description, the path and origin support from the peer's agent card.",
|
|
557
|
+
)
|
|
558
|
+
@click.option(
|
|
559
|
+
"--calls",
|
|
560
|
+
"calls",
|
|
561
|
+
default=",".join(DEFAULT_CALLS),
|
|
562
|
+
show_default=True,
|
|
563
|
+
help="What the agent may send: ask (SendMessage), status (GetTask), cancel (CancelTask).",
|
|
564
|
+
)
|
|
565
|
+
@click.option(
|
|
566
|
+
"--approvals",
|
|
567
|
+
"approvals",
|
|
568
|
+
type=click.Choice([RELAY, DENY]),
|
|
569
|
+
default=RELAY,
|
|
570
|
+
show_default=True,
|
|
571
|
+
help=(
|
|
572
|
+
"relay: messages that approve the peer's pending approvals wait for the person here "
|
|
573
|
+
"(gated: requester); deny: this agent never sends them."
|
|
574
|
+
),
|
|
575
|
+
)
|
|
576
|
+
@click.option(
|
|
577
|
+
"--approval-timeout-s",
|
|
578
|
+
type=click.IntRange(MIN_APPROVAL_TIMEOUT_S, MAX_APPROVAL_TIMEOUT_S),
|
|
579
|
+
default=DEFAULT_APPROVAL_TIMEOUT_S,
|
|
580
|
+
show_default=True,
|
|
581
|
+
)
|
|
582
|
+
@click.option(
|
|
583
|
+
"--max-calls-per-run", type=click.IntRange(min=1), default=DEFAULT_MAX_CALLS, show_default=True
|
|
584
|
+
)
|
|
585
|
+
@click.option(
|
|
586
|
+
"--read-timeout-ms",
|
|
587
|
+
type=click.IntRange(min=1),
|
|
588
|
+
default=DEFAULT_READ_TIMEOUT_MS,
|
|
589
|
+
show_default=True,
|
|
590
|
+
help="A peer runs a model: its answers take time.",
|
|
591
|
+
)
|
|
592
|
+
@click.option(
|
|
593
|
+
"--max-response-bytes",
|
|
594
|
+
type=click.IntRange(1, MAX_RESPONSE_BYTES_LIMIT),
|
|
595
|
+
default=DEFAULT_MAX_RESPONSE_BYTES,
|
|
596
|
+
show_default=True,
|
|
597
|
+
)
|
|
598
|
+
@click.option(
|
|
599
|
+
"--cluster-url",
|
|
600
|
+
"cluster_url",
|
|
601
|
+
default=None,
|
|
602
|
+
metavar="TEMPLATE",
|
|
603
|
+
help=(
|
|
604
|
+
"The peer's URL per environment, with {env}, for values-<env>.yaml "
|
|
605
|
+
"(e.g. http://orders-agent.orders-agent-{env}.svc.cluster.local)."
|
|
606
|
+
),
|
|
607
|
+
)
|
|
608
|
+
@_dry_run_option
|
|
609
|
+
def cmd_add(
|
|
610
|
+
name: str,
|
|
611
|
+
api_name: str | None,
|
|
612
|
+
url_env: str | None,
|
|
613
|
+
path: str | None,
|
|
614
|
+
auth: str | None,
|
|
615
|
+
audience: str | None,
|
|
616
|
+
scope: str | None,
|
|
617
|
+
resource: str | None,
|
|
618
|
+
allow_actorless: bool,
|
|
619
|
+
token_env: str | None,
|
|
620
|
+
description: str | None,
|
|
621
|
+
card: str | None,
|
|
622
|
+
calls: str,
|
|
623
|
+
approvals: str,
|
|
624
|
+
approval_timeout_s: int,
|
|
625
|
+
max_calls_per_run: int,
|
|
626
|
+
read_timeout_ms: int,
|
|
627
|
+
max_response_bytes: int,
|
|
628
|
+
cluster_url: str | None,
|
|
629
|
+
dry_run: bool,
|
|
630
|
+
) -> None:
|
|
631
|
+
"""Add another agent this agent asks (a protocol: a2a API), and regenerate tools/a2a_peers.py.
|
|
632
|
+
|
|
633
|
+
Defaults fit a peer made with graph-agents-cli: its endpoint /a2a/NAME, the credential
|
|
634
|
+
by this project's auth policy (jwt: a token exchanged for the user's, audience NAME),
|
|
635
|
+
SendMessage and GetTask, the person's approval relayed through a gate here, 12 calls per
|
|
636
|
+
run, a 120 s read timeout and a 1 MiB answer cap. It prints what is left to set: here,
|
|
637
|
+
at the issuer, and on the peer (AUTH_ALLOWED_ACTORS, and the `api approval
|
|
638
|
+
--decide-with relayed --relayers` line that lets this agent relay there).
|
|
639
|
+
"""
|
|
640
|
+
if not gen.PEER_NAME_RE.fullmatch(name):
|
|
641
|
+
raise click.UsageError(
|
|
642
|
+
f"peer name {name!r}: lowercase letters, digits and underscores, starting with a "
|
|
643
|
+
"letter, at most 26 characters"
|
|
644
|
+
)
|
|
645
|
+
parsed_calls = _parse_calls(calls)
|
|
646
|
+
project = _project()
|
|
647
|
+
plan = Plan(project.root)
|
|
648
|
+
added = plan_add(
|
|
649
|
+
project,
|
|
650
|
+
plan,
|
|
651
|
+
PeerOptions(
|
|
652
|
+
name=name,
|
|
653
|
+
api_name=api_name,
|
|
654
|
+
url_env=url_env,
|
|
655
|
+
path=path,
|
|
656
|
+
auth=auth,
|
|
657
|
+
audience=audience,
|
|
658
|
+
scope=scope,
|
|
659
|
+
resource=resource,
|
|
660
|
+
allow_actorless=allow_actorless,
|
|
661
|
+
token_env=token_env,
|
|
662
|
+
description=description,
|
|
663
|
+
card=card,
|
|
664
|
+
calls=tuple(parsed_calls),
|
|
665
|
+
approvals=approvals,
|
|
666
|
+
approval_timeout_s=approval_timeout_s,
|
|
667
|
+
max_calls_per_run=max_calls_per_run,
|
|
668
|
+
read_timeout_ms=read_timeout_ms,
|
|
669
|
+
max_response_bytes=max_response_bytes,
|
|
670
|
+
cluster_url=cluster_url,
|
|
671
|
+
),
|
|
672
|
+
)
|
|
673
|
+
_finish(project, plan, dry_run=dry_run, notes=added.notes, todos=added.todos)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
@dataclass
|
|
677
|
+
class PeerOptions:
|
|
678
|
+
"""What `peer add` is asked for (its options), for `plan_add`."""
|
|
679
|
+
|
|
680
|
+
name: str
|
|
681
|
+
api_name: str | None = None
|
|
682
|
+
url_env: str | None = None
|
|
683
|
+
path: str | None = None
|
|
684
|
+
auth: str | None = None
|
|
685
|
+
audience: str | None = None
|
|
686
|
+
scope: str | None = None
|
|
687
|
+
resource: str | None = None
|
|
688
|
+
allow_actorless: bool = False
|
|
689
|
+
token_env: str | None = None
|
|
690
|
+
description: str | None = None
|
|
691
|
+
card: str | None = None
|
|
692
|
+
calls: tuple[str, ...] = DEFAULT_CALLS
|
|
693
|
+
approvals: str = RELAY
|
|
694
|
+
approval_timeout_s: int = DEFAULT_APPROVAL_TIMEOUT_S
|
|
695
|
+
max_calls_per_run: int = DEFAULT_MAX_CALLS
|
|
696
|
+
read_timeout_ms: int = DEFAULT_READ_TIMEOUT_MS
|
|
697
|
+
max_response_bytes: int = DEFAULT_MAX_RESPONSE_BYTES
|
|
698
|
+
cluster_url: str | None = None
|
|
699
|
+
# The peer's URL in .env.example (a local run).
|
|
700
|
+
local_url: str = PEER_LOCAL_URL
|
|
701
|
+
|
|
702
|
+
|
|
703
|
+
@dataclass
|
|
704
|
+
class PeerAdded:
|
|
705
|
+
"""What `plan_add` planned: the project as it will be, and what to tell the user."""
|
|
706
|
+
|
|
707
|
+
project: _Project # its policy text and document with the peer in
|
|
708
|
+
api_name: str
|
|
709
|
+
api: dict[str, Any]
|
|
710
|
+
notes: list[str]
|
|
711
|
+
todos: list[str]
|
|
712
|
+
names: dict[str, str] # API -> peer name for the generated module, this peer's included
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
# An existing peer's settings `plan_add(keep_tuning=True)` keeps when it rewrites the entry
|
|
716
|
+
# (`system apply`): what the system file does not say. The rest follows the file.
|
|
717
|
+
TUNED_KEYS = ("timeouts_ms", "limits", "forward_header")
|
|
718
|
+
|
|
719
|
+
|
|
720
|
+
def _retuned(api: dict[str, Any], existing: dict[str, Any]) -> dict[str, Any]:
|
|
721
|
+
"""``api`` with the tuning of the ``existing`` entry it replaces carried over."""
|
|
722
|
+
new = copy.deepcopy(api)
|
|
723
|
+
for key in TUNED_KEYS:
|
|
724
|
+
if key in existing:
|
|
725
|
+
new[key] = copy.deepcopy(existing[key])
|
|
726
|
+
old_exchange = existing.get(EXCHANGE_KEY) or {}
|
|
727
|
+
if EXCHANGE_KEY in new and "resource" in old_exchange and "resource" not in new[EXCHANGE_KEY]:
|
|
728
|
+
new[EXCHANGE_KEY]["resource"] = old_exchange["resource"]
|
|
729
|
+
old_rules, new_rules = existing.get("approval"), new.get("approval")
|
|
730
|
+
if isinstance(old_rules, list) and isinstance(new_rules, list):
|
|
731
|
+
for old_rule, new_rule in zip(old_rules, new_rules, strict=False):
|
|
732
|
+
if isinstance(old_rule, dict) and "timeout_s" in old_rule:
|
|
733
|
+
new_rule["timeout_s"] = old_rule["timeout_s"]
|
|
734
|
+
if DESCRIPTION_KEY not in new and existing.get(DESCRIPTION_KEY):
|
|
735
|
+
new[DESCRIPTION_KEY] = existing[DESCRIPTION_KEY]
|
|
736
|
+
# The schema's key order, as every command writes an entry.
|
|
737
|
+
rank = {key: index for index, key in enumerate(_API_KEY_ORDER)}
|
|
738
|
+
return {key: new[key] for key in sorted(new, key=lambda k: rank.get(k, len(rank)))}
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
def plan_add(
|
|
742
|
+
project: _Project,
|
|
743
|
+
plan: Plan,
|
|
744
|
+
options: PeerOptions,
|
|
745
|
+
*,
|
|
746
|
+
names: dict[str, str] | None = None,
|
|
747
|
+
keep_tuning: bool = False,
|
|
748
|
+
) -> PeerAdded:
|
|
749
|
+
"""Plan a peer's entry and every file that follows it into ``plan``.
|
|
750
|
+
|
|
751
|
+
``project`` holds the policy as earlier plans left it, so several peers can be
|
|
752
|
+
planned into one ``plan`` (``names``: the peer names planned so far, API -> name).
|
|
753
|
+
An entry of the same name that differs is refused, unless ``keep_tuning``
|
|
754
|
+
(``system apply``): a peer's entry is then rewritten to the options, keeping the
|
|
755
|
+
settings the options do not decide (``TUNED_KEYS``, the approval timeout, the
|
|
756
|
+
exchange resource and a description the options do not give).
|
|
757
|
+
"""
|
|
758
|
+
config = project.config
|
|
759
|
+
name = options.name
|
|
760
|
+
if name in (config.agent_directory, config.project_name):
|
|
761
|
+
raise click.UsageError(f"an agent cannot be its own peer ({name})")
|
|
762
|
+
auth = options.auth or AUTH_BY_POLICY.get(config.auth_policy, "bearer")
|
|
763
|
+
if auth != "exchange" and (options.scope or options.resource or options.allow_actorless):
|
|
764
|
+
raise click.UsageError("--scope, --resource and --allow-actorless go with --auth exchange")
|
|
765
|
+
if auth != "bearer" and options.token_env:
|
|
766
|
+
raise click.UsageError("--token-env goes with --auth bearer")
|
|
767
|
+
notes: list[str] = []
|
|
768
|
+
path, description = options.path, options.description
|
|
769
|
+
if options.card is not None:
|
|
770
|
+
card = options.card
|
|
771
|
+
try:
|
|
772
|
+
data = _read_card(card)
|
|
773
|
+
except Exception as exc:
|
|
774
|
+
notes.append(f"the card {card} could not be read ({exc}); continuing without it")
|
|
775
|
+
data = None
|
|
776
|
+
if data is not None:
|
|
777
|
+
interface = _card_interface(data)
|
|
778
|
+
if path is None and interface is not None:
|
|
779
|
+
import urllib.parse
|
|
780
|
+
|
|
781
|
+
path = urllib.parse.urlsplit(str(interface.get("url"))).path.rstrip("/") or None
|
|
782
|
+
if description is None and isinstance(data.get("description"), str):
|
|
783
|
+
description = data["description"]
|
|
784
|
+
notes.append(
|
|
785
|
+
f"{name}'s card "
|
|
786
|
+
+ (
|
|
787
|
+
"declares the origin extension: the user's own words go with each request"
|
|
788
|
+
if _card_reads_origin(data)
|
|
789
|
+
else "does not declare the origin extension: the user's words stay here"
|
|
790
|
+
)
|
|
791
|
+
)
|
|
792
|
+
api_name = options.api_name or f"{name}{gen.AGENT_SUFFIX}"
|
|
793
|
+
url_env = options.url_env or f"{name.upper()}_AGENT_URL"
|
|
794
|
+
path = path or f"/a2a/{name}"
|
|
795
|
+
token_env = options.token_env or (f"{name.upper()}_AGENT_KEY" if auth == "bearer" else None)
|
|
796
|
+
if description is not None:
|
|
797
|
+
description = " ".join(description.split())
|
|
798
|
+
if len(description) > DESCRIPTION_MAX_CHARS:
|
|
799
|
+
description = description[: DESCRIPTION_MAX_CHARS - 3].rstrip() + "..."
|
|
800
|
+
notes.append(f"the description was cut to {DESCRIPTION_MAX_CHARS} characters")
|
|
801
|
+
api = _entry(
|
|
802
|
+
description=description,
|
|
803
|
+
path=path,
|
|
804
|
+
url_env=url_env,
|
|
805
|
+
auth=auth,
|
|
806
|
+
token_env=token_env,
|
|
807
|
+
audience=options.audience or name,
|
|
808
|
+
scope=options.scope,
|
|
809
|
+
resource=options.resource,
|
|
810
|
+
allow_actorless=options.allow_actorless,
|
|
811
|
+
forward_audience=auth == "forward"
|
|
812
|
+
and (options.audience is not None or config.auth_policy == "jwt"),
|
|
813
|
+
calls=list(options.calls),
|
|
814
|
+
approvals=options.approvals,
|
|
815
|
+
approval_timeout_s=options.approval_timeout_s,
|
|
816
|
+
max_calls_per_run=options.max_calls_per_run,
|
|
817
|
+
read_timeout_ms=options.read_timeout_ms,
|
|
818
|
+
max_response_bytes=options.max_response_bytes,
|
|
819
|
+
)
|
|
820
|
+
existing = (project.document or {}).get("apis", {}).get(api_name)
|
|
821
|
+
if existing is not None and existing != api:
|
|
822
|
+
if keep_tuning and api_protocol(existing) == PROTOCOL_A2A:
|
|
823
|
+
api = _retuned(api, existing)
|
|
824
|
+
elif _same_peer(existing, path):
|
|
825
|
+
raise PeerCommandError(
|
|
826
|
+
f"peer {name} exists with other settings; change it with `graph-agents-cli api "
|
|
827
|
+
f"...` ({api_name}), or `peer remove {name}` then `peer add {name}`"
|
|
828
|
+
)
|
|
829
|
+
else:
|
|
830
|
+
raise PeerCommandError(f"API {api_name} exists; pick --api-name")
|
|
831
|
+
if DESCRIPTION_KEY not in api:
|
|
832
|
+
notes.append(
|
|
833
|
+
f"no --description: the model's roster says {name} has none (lint warns); give one "
|
|
834
|
+
"with --description or --card"
|
|
835
|
+
)
|
|
836
|
+
document = ch.with_api(project.document, api_name, api)
|
|
837
|
+
_validate(project, document)
|
|
838
|
+
errors, matrix_notes = auth_policy_findings({"apis": {api_name: api}}, config.auth_policy)
|
|
839
|
+
if errors:
|
|
840
|
+
lines = "\n".join(f" - {error}" for error in errors)
|
|
841
|
+
raise PeerCommandError(f"{name} cannot work in this project; nothing was written:\n{lines}")
|
|
842
|
+
extra = {**(names or {}), api_name: name}
|
|
843
|
+
named = {p.api: p.name for p in gen.peers_of(document, _names(project, extra))}
|
|
844
|
+
if named.get(api_name) != name:
|
|
845
|
+
clash = next((api for api, n in named.items() if n == name and api != api_name), "?")
|
|
846
|
+
raise PeerCommandError(f"another peer is already called {name} (API {clash})")
|
|
847
|
+
text = project.text
|
|
848
|
+
if existing != api:
|
|
849
|
+
if project.text is None:
|
|
850
|
+
lines = [*_policy_header(config), "apis:", *block_lines({api_name: api}, 2)]
|
|
851
|
+
text = "\n".join(lines) + "\n"
|
|
852
|
+
else:
|
|
853
|
+
editor = project.editor()
|
|
854
|
+
verb = "add" if existing is None else "rewrite"
|
|
855
|
+
_edit(lambda: editor.set(("apis", api_name), api), f"{verb} apis.{api_name}")
|
|
856
|
+
text = editor.text
|
|
857
|
+
if YamlText(text).data != document:
|
|
858
|
+
raise PeerCommandError(f"could not write {POLICY_FILENAME} (internal check)")
|
|
859
|
+
plan.set_text(POLICY_FILENAME, read_text(project.root / POLICY_FILENAME), text)
|
|
860
|
+
# The same peer again: nothing in the policy changes, and the files that follow it are
|
|
861
|
+
# brought back in step (each edit below finds its work done when it is).
|
|
862
|
+
sync_manifest(plan, config, document=document, previous=project.document)
|
|
863
|
+
env_example_add(plan, api_name, api, peer=name, base_url=options.local_url)
|
|
864
|
+
values_add(plan, config, document, api_name)
|
|
865
|
+
values_exchange_add(plan, config, document, api_name)
|
|
866
|
+
cluster_envs = (
|
|
867
|
+
values_env_set(plan, config, url_env, options.cluster_url) if options.cluster_url else []
|
|
868
|
+
)
|
|
869
|
+
_sync_module(plan, project, document, extra)
|
|
870
|
+
previous = copy.deepcopy(project.document)
|
|
871
|
+
if previous is not None and existing is not None:
|
|
872
|
+
del previous["apis"][api_name]
|
|
873
|
+
first_exchange = auth == "exchange" and not (
|
|
874
|
+
previous is not None and uses_exchange(summarize(previous))
|
|
875
|
+
)
|
|
876
|
+
todos = left_for_you(
|
|
877
|
+
project, name, api_name, api, first_exchange=first_exchange, cluster_envs=cluster_envs
|
|
878
|
+
)
|
|
879
|
+
notes.extend(matrix_notes)
|
|
880
|
+
if options.approvals == RELAY:
|
|
881
|
+
notes.append(
|
|
882
|
+
f"messages that approve {name}'s pending approvals wait for the person here (gated: "
|
|
883
|
+
"requester): this agent relays their decision, never its own"
|
|
884
|
+
)
|
|
885
|
+
return PeerAdded(
|
|
886
|
+
project=_Project(project.root, config, text, document),
|
|
887
|
+
api_name=api_name,
|
|
888
|
+
api=api,
|
|
889
|
+
notes=notes,
|
|
890
|
+
todos=todos,
|
|
891
|
+
names=extra,
|
|
892
|
+
)
|
|
893
|
+
|
|
894
|
+
|
|
895
|
+
# ---------------------------------------------------------------------------
|
|
896
|
+
# remove, sync
|
|
897
|
+
# ---------------------------------------------------------------------------
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
@peer_group.command("remove")
|
|
901
|
+
@click.argument("name")
|
|
902
|
+
@_dry_run_option
|
|
903
|
+
def cmd_remove(name: str, dry_run: bool) -> None:
|
|
904
|
+
"""Remove a peer (its API and variables), and regenerate or delete tools/a2a_peers.py."""
|
|
905
|
+
project = _project()
|
|
906
|
+
plan = Plan(project.root)
|
|
907
|
+
removed = plan_remove(project, plan, name)
|
|
908
|
+
_finish(project, plan, dry_run=dry_run, notes=removed.notes, todos=[])
|
|
909
|
+
|
|
910
|
+
|
|
911
|
+
@dataclass
|
|
912
|
+
class PeerRemoved:
|
|
913
|
+
"""What `plan_remove` planned: the project as it will be, and what to tell the user."""
|
|
914
|
+
|
|
915
|
+
project: _Project
|
|
916
|
+
notes: list[str]
|
|
917
|
+
|
|
918
|
+
|
|
919
|
+
def plan_remove(
|
|
920
|
+
project: _Project, plan: Plan, name: str, *, names: dict[str, str] | None = None
|
|
921
|
+
) -> PeerRemoved:
|
|
922
|
+
"""Plan removing peer ``name`` and the files that follow it into ``plan`` (``project``
|
|
923
|
+
as earlier plans left it; ``names``: the peer names planned so far, API -> name)."""
|
|
924
|
+
peers = {p.name: p for p in gen.peers_of(project.document, _names(project, names))}
|
|
925
|
+
if name not in peers:
|
|
926
|
+
known = ", ".join(peers) or "none"
|
|
927
|
+
raise PeerCommandError(f"no peer {name!r} in {POLICY_FILENAME} (peers: {known})")
|
|
928
|
+
peer = peers[name]
|
|
929
|
+
assert project.document is not None
|
|
930
|
+
api = project.document["apis"][peer.api]
|
|
931
|
+
document = copy.deepcopy(project.document)
|
|
932
|
+
del document["apis"][peer.api]
|
|
933
|
+
after: dict[str, Any] | None = document if document["apis"] else None
|
|
934
|
+
stored = read_text(project.root / POLICY_FILENAME)
|
|
935
|
+
text: str | None
|
|
936
|
+
if after is None:
|
|
937
|
+
text = None
|
|
938
|
+
else:
|
|
939
|
+
editor = project.editor()
|
|
940
|
+
_edit(lambda: editor.delete(("apis", peer.api)), f"delete apis.{peer.api}")
|
|
941
|
+
text = editor.text
|
|
942
|
+
plan.set_text(POLICY_FILENAME, stored, text)
|
|
943
|
+
sync_manifest(plan, project.config, document=after, previous=project.document)
|
|
944
|
+
others = list(document["apis"].values())
|
|
945
|
+
keep = {str(o["base_url_env"]) for o in others} | {
|
|
946
|
+
str(o["token_env"]) for o in others if o.get("token_env")
|
|
947
|
+
}
|
|
948
|
+
env_example_remove(plan, peer.api, api, keep, document["apis"])
|
|
949
|
+
if api["base_url_env"] not in keep:
|
|
950
|
+
values_remove(
|
|
951
|
+
plan, project.config, api["base_url_env"], {str(o["base_url_env"]) for o in others}
|
|
952
|
+
)
|
|
953
|
+
if api["auth"] == EXCHANGE_KEY and not (after and uses_exchange(summarize(after))):
|
|
954
|
+
values_exchange_remove(plan, project.config)
|
|
955
|
+
_sync_module(plan, project, after, names)
|
|
956
|
+
notes = []
|
|
957
|
+
if peer.approvals == RELAY:
|
|
958
|
+
notes.append(
|
|
959
|
+
f"approvals this agent is relaying to {name} and still has pending will fail when "
|
|
960
|
+
"decided (the API they call is gone)"
|
|
961
|
+
)
|
|
962
|
+
if after is None:
|
|
963
|
+
notes.append(f"{name} was the only API: {POLICY_FILENAME} goes")
|
|
964
|
+
return PeerRemoved(_Project(project.root, project.config, text, after), notes)
|
|
965
|
+
|
|
966
|
+
|
|
967
|
+
@peer_group.command("sync")
|
|
968
|
+
@_dry_run_option
|
|
969
|
+
def cmd_sync(dry_run: bool) -> None:
|
|
970
|
+
"""Regenerate tools/a2a_peers.py from api-policy.yaml (after `scaffold upgrade`, or `api` edits)."""
|
|
971
|
+
project = _project()
|
|
972
|
+
plan = Plan(project.root)
|
|
973
|
+
_sync_module(plan, project, project.document)
|
|
974
|
+
_finish(project, plan, dry_run=dry_run, notes=[], todos=[])
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
# ---------------------------------------------------------------------------
|
|
978
|
+
# list, show
|
|
979
|
+
# ---------------------------------------------------------------------------
|
|
980
|
+
|
|
981
|
+
|
|
982
|
+
def _url_of(project: _Project, variable: str) -> str | None:
|
|
983
|
+
"""The peer's URL from the environment or .env (a URL only; nothing else is read)."""
|
|
984
|
+
import os
|
|
985
|
+
|
|
986
|
+
value = os.environ.get(variable)
|
|
987
|
+
if value:
|
|
988
|
+
return value
|
|
989
|
+
env_file = project.root / ".env"
|
|
990
|
+
if env_file.is_file():
|
|
991
|
+
from dotenv import dotenv_values
|
|
992
|
+
|
|
993
|
+
try:
|
|
994
|
+
found = dotenv_values(env_file).get(variable)
|
|
995
|
+
except Exception:
|
|
996
|
+
return None
|
|
997
|
+
return found or None
|
|
998
|
+
return None
|
|
999
|
+
|
|
1000
|
+
|
|
1001
|
+
def _row(project: _Project, peer: gen.Peer) -> dict[str, Any]:
|
|
1002
|
+
assert project.document is not None
|
|
1003
|
+
api = project.document["apis"][peer.api]
|
|
1004
|
+
exchange = api.get(EXCHANGE_KEY) or {}
|
|
1005
|
+
return {
|
|
1006
|
+
"name": peer.name,
|
|
1007
|
+
"api": peer.api,
|
|
1008
|
+
"url_env": api["base_url_env"],
|
|
1009
|
+
"url": _url_of(project, api["base_url_env"]),
|
|
1010
|
+
"path": peer.path,
|
|
1011
|
+
"auth": api["auth"],
|
|
1012
|
+
"audience": exchange.get("audience") or api.get("forward_audience"),
|
|
1013
|
+
"approvals": peer.approvals,
|
|
1014
|
+
"calls": [
|
|
1015
|
+
" ".join(
|
|
1016
|
+
str(part)
|
|
1017
|
+
for part in (
|
|
1018
|
+
c.get(RPC_METHOD_KEY) or c.get("operation_id"),
|
|
1019
|
+
c.get(A2A_OPERATION_KEY),
|
|
1020
|
+
)
|
|
1021
|
+
if part
|
|
1022
|
+
)
|
|
1023
|
+
for c in peer.calls
|
|
1024
|
+
],
|
|
1025
|
+
"limits": api.get("limits") or {},
|
|
1026
|
+
"description": peer.description,
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
|
|
1030
|
+
@peer_group.command("list")
|
|
1031
|
+
@click.option("--json", "as_json", is_flag=True, default=False, help="Print JSON.")
|
|
1032
|
+
def cmd_list(as_json: bool) -> None:
|
|
1033
|
+
"""List the peers: name, API, URL, auth, audience, approvals and limits."""
|
|
1034
|
+
project = _project()
|
|
1035
|
+
rows = [_row(project, peer) for peer in _peers(project).values()]
|
|
1036
|
+
if as_json:
|
|
1037
|
+
click.echo(json.dumps(rows, indent=2))
|
|
1038
|
+
return
|
|
1039
|
+
console = Console()
|
|
1040
|
+
if not rows:
|
|
1041
|
+
console.print("No peers: add one with `graph-agents-cli peer add NAME`.")
|
|
1042
|
+
return
|
|
1043
|
+
table = Table(title="A2A peers")
|
|
1044
|
+
for header in ("Peer", "API", "URL", "Auth", "Audience", "Approvals", "Limits"):
|
|
1045
|
+
table.add_column(header, overflow="fold")
|
|
1046
|
+
for row in rows:
|
|
1047
|
+
limits = ", ".join(f"{k} {v}" for k, v in row["limits"].items())
|
|
1048
|
+
table.add_row(
|
|
1049
|
+
escape(row["name"]),
|
|
1050
|
+
escape(row["api"]),
|
|
1051
|
+
escape(f"{row['url_env']}={row['url'] or '(unset)'}"),
|
|
1052
|
+
row["auth"],
|
|
1053
|
+
escape(str(row["audience"] or "-")),
|
|
1054
|
+
row["approvals"],
|
|
1055
|
+
escape(limits or "-"),
|
|
1056
|
+
)
|
|
1057
|
+
print_table(console, table)
|
|
1058
|
+
if project.config.auth_policy == "shared-bearer":
|
|
1059
|
+
console.print(
|
|
1060
|
+
"Note: under shared-bearer, any holder of API_KEY (another agent included) can "
|
|
1061
|
+
"decide requester gates at a peer.",
|
|
1062
|
+
style="yellow",
|
|
1063
|
+
)
|
|
1064
|
+
|
|
1065
|
+
|
|
1066
|
+
def check_card(url: str, path: str, *, transport: Any = None) -> tuple[bool, list[str]]:
|
|
1067
|
+
"""GET a peer's card (no credential) and check it; `(ok, lines to print)`."""
|
|
1068
|
+
import httpx
|
|
1069
|
+
|
|
1070
|
+
card_url = f"{url.rstrip('/')}{path}{gen.CARD_SUFFIX}"
|
|
1071
|
+
try:
|
|
1072
|
+
with httpx.Client(transport=transport, timeout=CARD_TIMEOUT_S) as client:
|
|
1073
|
+
response = client.get(card_url, headers={"A2A-Version": "1.0"})
|
|
1074
|
+
except httpx.HTTPError as exc:
|
|
1075
|
+
return False, [f"unreachable ({type(exc).__name__}): {card_url}"]
|
|
1076
|
+
if response.status_code == 401:
|
|
1077
|
+
return True, ["reachable (401: the card needs a credential; KI-120)"]
|
|
1078
|
+
if response.status_code != 200:
|
|
1079
|
+
return False, [f"unreachable (HTTP {response.status_code}): {card_url}"]
|
|
1080
|
+
try:
|
|
1081
|
+
card = response.json()
|
|
1082
|
+
except ValueError:
|
|
1083
|
+
return False, ["reachable, but its card is not JSON"]
|
|
1084
|
+
lines = ["reachable"]
|
|
1085
|
+
interface = _card_interface(card) if isinstance(card, dict) else None
|
|
1086
|
+
if interface is None:
|
|
1087
|
+
return False, [*lines, "its card offers no A2A 1.x JSON-RPC interface"]
|
|
1088
|
+
lines.append(f"protocol {interface.get('protocolVersion')}")
|
|
1089
|
+
expected = f"{url.rstrip('/')}{path}".rstrip("/")
|
|
1090
|
+
if str(interface.get("url", "")).rstrip("/") != expected:
|
|
1091
|
+
return False, [
|
|
1092
|
+
*lines,
|
|
1093
|
+
f"foreign endpoint: the card names {interface.get('url')}, not {expected} (set the "
|
|
1094
|
+
"peer's APP_URL, appUrl in its chart values)",
|
|
1095
|
+
]
|
|
1096
|
+
name = path.rstrip("/").rsplit("/", 1)[-1]
|
|
1097
|
+
if card.get("name") != name:
|
|
1098
|
+
return False, [*lines, f"the card is agent {card.get('name')!r}, not {name!r}"]
|
|
1099
|
+
lines.append(
|
|
1100
|
+
"declares the origin extension"
|
|
1101
|
+
if _card_reads_origin(card)
|
|
1102
|
+
else "does not declare the origin extension (the user's words stay here)"
|
|
1103
|
+
)
|
|
1104
|
+
return True, lines
|
|
1105
|
+
|
|
1106
|
+
|
|
1107
|
+
@peer_group.command("show")
|
|
1108
|
+
@click.argument("name")
|
|
1109
|
+
@click.option("--json", "as_json", is_flag=True, default=False, help="Print JSON.")
|
|
1110
|
+
@click.option(
|
|
1111
|
+
"--check",
|
|
1112
|
+
"check",
|
|
1113
|
+
is_flag=True,
|
|
1114
|
+
default=False,
|
|
1115
|
+
help="Read the peer's card (no credential) and check its endpoint; exit 1 when it fails.",
|
|
1116
|
+
)
|
|
1117
|
+
def cmd_show(name: str, as_json: bool, check: bool) -> None:
|
|
1118
|
+
"""Show a peer's entry and what is left to set; --check reads its card."""
|
|
1119
|
+
project = _project()
|
|
1120
|
+
peer = _peer(project, name)
|
|
1121
|
+
assert project.document is not None
|
|
1122
|
+
api = project.document["apis"][peer.api]
|
|
1123
|
+
todos = left_for_you(project, name, peer.api, api, first_exchange=False, cluster_envs=[])
|
|
1124
|
+
result: dict[str, Any] = {"peer": _row(project, peer), "entry": api, "left_for_you": todos}
|
|
1125
|
+
ok = True
|
|
1126
|
+
if check:
|
|
1127
|
+
url = _url_of(project, api["base_url_env"])
|
|
1128
|
+
if not url:
|
|
1129
|
+
ok, lines = False, [f"{api['base_url_env']} is not set (in the environment or .env)"]
|
|
1130
|
+
else:
|
|
1131
|
+
ok, lines = check_card(url, peer.path)
|
|
1132
|
+
result["check"] = {"ok": ok, "lines": lines}
|
|
1133
|
+
if as_json:
|
|
1134
|
+
click.echo(json.dumps(result, indent=2))
|
|
1135
|
+
else:
|
|
1136
|
+
console = Console()
|
|
1137
|
+
console.print(f"{escape(name)} ({escape(peer.api)}):", style="bold")
|
|
1138
|
+
click.echo(yaml_text({peer.api: api}))
|
|
1139
|
+
console.print("Left for you:", style="bold")
|
|
1140
|
+
for item in todos:
|
|
1141
|
+
console.print(f" - {escape(item)}", highlight=False)
|
|
1142
|
+
if check:
|
|
1143
|
+
console.print("Check:", style="bold")
|
|
1144
|
+
for line in result["check"]["lines"]:
|
|
1145
|
+
console.print(f" {escape(line)}", style="green" if ok else "red", highlight=False)
|
|
1146
|
+
if not ok:
|
|
1147
|
+
raise SystemExit(1)
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
def yaml_text(value: dict[str, Any]) -> str:
|
|
1151
|
+
return "\n".join(block_lines(value, 2))
|