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.
Files changed (291) hide show
  1. graph_agents_cli/__init__.py +26 -0
  2. graph_agents_cli/_api_policy.py +2145 -0
  3. graph_agents_cli/_approvals.py +400 -0
  4. graph_agents_cli/_build.py +186 -0
  5. graph_agents_cli/_build_info.json +7 -0
  6. graph_agents_cli/_chat_client.py +462 -0
  7. graph_agents_cli/_click.py +157 -0
  8. graph_agents_cli/_defaults.py +139 -0
  9. graph_agents_cli/_experiments.py +64 -0
  10. graph_agents_cli/_http.py +192 -0
  11. graph_agents_cli/_output.py +83 -0
  12. graph_agents_cli/_project.py +462 -0
  13. graph_agents_cli/_remote.py +220 -0
  14. graph_agents_cli/_response_schema.py +264 -0
  15. graph_agents_cli/_runner.py +319 -0
  16. graph_agents_cli/_skills_check.py +274 -0
  17. graph_agents_cli/_tools.py +189 -0
  18. graph_agents_cli/_trust.py +66 -0
  19. graph_agents_cli/api/__init__.py +15 -0
  20. graph_agents_cli/api/_changes.py +506 -0
  21. graph_agents_cli/api/_files.py +658 -0
  22. graph_agents_cli/api/cmd_api.py +2480 -0
  23. graph_agents_cli/deploy/__init__.py +15 -0
  24. graph_agents_cli/deploy/_config.py +171 -0
  25. graph_agents_cli/deploy/_image.py +128 -0
  26. graph_agents_cli/deploy/_kube.py +286 -0
  27. graph_agents_cli/deploy/_modes.py +234 -0
  28. graph_agents_cli/deploy/_preflight.py +370 -0
  29. graph_agents_cli/deploy/_values.py +168 -0
  30. graph_agents_cli/deploy/cmd_deploy.py +1866 -0
  31. graph_agents_cli/deploy/gitops.py +562 -0
  32. graph_agents_cli/deploy/local_load.py +273 -0
  33. graph_agents_cli/dev/__init__.py +13 -0
  34. graph_agents_cli/dev/cmd_build.py +131 -0
  35. graph_agents_cli/dev/cmd_install.py +78 -0
  36. graph_agents_cli/dev/cmd_lint.py +119 -0
  37. graph_agents_cli/dev/cmd_playground.py +297 -0
  38. graph_agents_cli/dev/policy_check.py +1287 -0
  39. graph_agents_cli/eval/__init__.py +22 -0
  40. graph_agents_cli/eval/_client.py +670 -0
  41. graph_agents_cli/eval/_common.py +177 -0
  42. graph_agents_cli/eval/_judge.py +168 -0
  43. graph_agents_cli/eval/_judge_runner.py +238 -0
  44. graph_agents_cli/eval/_paths.py +212 -0
  45. graph_agents_cli/eval/checks.py +581 -0
  46. graph_agents_cli/eval/cmd_analyze.py +278 -0
  47. graph_agents_cli/eval/cmd_compare.py +284 -0
  48. graph_agents_cli/eval/cmd_eval_group.py +80 -0
  49. graph_agents_cli/eval/cmd_generate.py +558 -0
  50. graph_agents_cli/eval/cmd_grade.py +466 -0
  51. graph_agents_cli/eval/cmd_metric.py +156 -0
  52. graph_agents_cli/eval/cmd_run.py +370 -0
  53. graph_agents_cli/eval/cmd_submit.py +400 -0
  54. graph_agents_cli/eval/config.py +435 -0
  55. graph_agents_cli/eval/dataset.py +350 -0
  56. graph_agents_cli/eval/gate.py +420 -0
  57. graph_agents_cli/eval/transcript.py +192 -0
  58. graph_agents_cli/extension/__init__.py +13 -0
  59. graph_agents_cli/extension/_compat.py +86 -0
  60. graph_agents_cli/extension/_loader.py +293 -0
  61. graph_agents_cli/extension/_manifest.py +135 -0
  62. graph_agents_cli/extension/_overrides.py +195 -0
  63. graph_agents_cli/extension/_paths.py +91 -0
  64. graph_agents_cli/extension/_refs.py +193 -0
  65. graph_agents_cli/extension/_resolver.py +453 -0
  66. graph_agents_cli/extension/_schema.py +106 -0
  67. graph_agents_cli/extension/_spec.py +253 -0
  68. graph_agents_cli/extension/_sync.py +102 -0
  69. graph_agents_cli/extension/_trust.py +58 -0
  70. graph_agents_cli/extension/cmd_extension_add.py +259 -0
  71. graph_agents_cli/extension/cmd_extension_group.py +57 -0
  72. graph_agents_cli/extension/cmd_extension_list.py +56 -0
  73. graph_agents_cli/extension/cmd_extension_remove.py +61 -0
  74. graph_agents_cli/extension/cmd_extension_update.py +195 -0
  75. graph_agents_cli/info/__init__.py +13 -0
  76. graph_agents_cli/info/cmd_info.py +222 -0
  77. graph_agents_cli/infra/__init__.py +15 -0
  78. graph_agents_cli/infra/checks.py +1169 -0
  79. graph_agents_cli/infra/cmd_infra.py +103 -0
  80. graph_agents_cli/main.py +591 -0
  81. graph_agents_cli/peer/__init__.py +15 -0
  82. graph_agents_cli/peer/_generate.py +254 -0
  83. graph_agents_cli/peer/cmd_peer.py +1151 -0
  84. graph_agents_cli/run/__init__.py +13 -0
  85. graph_agents_cli/run/_local_server.py +1157 -0
  86. graph_agents_cli/run/_signals.py +141 -0
  87. graph_agents_cli/run/cmd_approvals.py +530 -0
  88. graph_agents_cli/run/cmd_run.py +1421 -0
  89. graph_agents_cli/scaffold/__init__.py +19 -0
  90. graph_agents_cli/scaffold/agents/README.md +24 -0
  91. graph_agents_cli/scaffold/agents/empty_py/.template/templateconfig.yaml +22 -0
  92. graph_agents_cli/scaffold/agents/langgraph/.env.example +292 -0
  93. graph_agents_cli/scaffold/agents/langgraph/.template/templateconfig.yaml +28 -0
  94. graph_agents_cli/scaffold/agents/langgraph/Dockerfile +59 -0
  95. graph_agents_cli/scaffold/agents/langgraph/Dockerfile.langgraph-server +59 -0
  96. graph_agents_cli/scaffold/agents/langgraph/README.md +571 -0
  97. graph_agents_cli/scaffold/agents/langgraph/api-policy.yaml +60 -0
  98. graph_agents_cli/scaffold/agents/langgraph/app/__init__.py +20 -0
  99. graph_agents_cli/scaffold/agents/langgraph/app/agent.py +174 -0
  100. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/__init__.py +15 -0
  101. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a.py +2162 -0
  102. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/a2a_client.py +1167 -0
  103. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/api_client.py +4220 -0
  104. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/approvals.py +1349 -0
  105. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/auth.py +1986 -0
  106. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/chat.py +2962 -0
  107. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/checkpointer.py +432 -0
  108. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/content.py +569 -0
  109. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/db.py +580 -0
  110. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/limits.py +203 -0
  111. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/metrics.py +231 -0
  112. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/middleware.py +361 -0
  113. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/model.py +611 -0
  114. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/playground.py +230 -0
  115. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/run_locks.py +459 -0
  116. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/structured.py +755 -0
  117. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/telemetry.py +681 -0
  118. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/threads.py +493 -0
  119. graph_agents_cli/scaffold/agents/langgraph/app/app_utils/token_exchange.py +959 -0
  120. graph_agents_cli/scaffold/agents/langgraph/app/fast_api_app.py +770 -0
  121. graph_agents_cli/scaffold/agents/langgraph/app/policies/__init__.py +55 -0
  122. graph_agents_cli/scaffold/agents/langgraph/app/policies/custom.py +97 -0
  123. graph_agents_cli/scaffold/agents/langgraph/app/tools/__init__.py +46 -0
  124. graph_agents_cli/scaffold/agents/langgraph/app/tools/example_api.py +92 -0
  125. graph_agents_cli/scaffold/agents/langgraph/app/tools/weather.py +33 -0
  126. graph_agents_cli/scaffold/agents/langgraph/langgraph.json +14 -0
  127. graph_agents_cli/scaffold/agents/langgraph/pyproject.toml +78 -0
  128. graph_agents_cli/scaffold/agents/langgraph/tests/conftest.py +376 -0
  129. graph_agents_cli/scaffold/agents/langgraph/tests/eval/datasets/basic-dataset.json +53 -0
  130. graph_agents_cli/scaffold/agents/langgraph/tests/eval/eval_config.yaml +32 -0
  131. graph_agents_cli/scaffold/agents/langgraph/tests/integration/approval_graph.py +137 -0
  132. graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_issuer.py +216 -0
  133. graph_agents_cli/scaffold/agents/langgraph/tests/integration/fake_openai.py +357 -0
  134. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_outcomes.py +569 -0
  135. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_a2a_relay.py +479 -0
  136. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_api_surface.py +812 -0
  137. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals.py +1367 -0
  138. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_approvals_server.py +794 -0
  139. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor.py +497 -0
  140. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_cross_actor_server.py +247 -0
  141. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_history_repair.py +278 -0
  142. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_model_apis.py +242 -0
  143. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_postgres.py +637 -0
  144. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_resilience_postgres.py +770 -0
  145. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_runtime_guardrails.py +854 -0
  146. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_e2e.py +340 -0
  147. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_server_runtime.py +989 -0
  148. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_answers.py +584 -0
  149. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_structured_server.py +222 -0
  150. graph_agents_cli/scaffold/agents/langgraph/tests/integration/test_token_exchange_issuer.py +650 -0
  151. graph_agents_cli/scaffold/agents/langgraph/tests/load_test/.results/.placeholder +0 -0
  152. graph_agents_cli/scaffold/agents/langgraph/tests/load_test/README.md +22 -0
  153. graph_agents_cli/scaffold/agents/langgraph/tests/load_test/conftest.py +21 -0
  154. graph_agents_cli/scaffold/agents/langgraph/tests/load_test/load_test.py +81 -0
  155. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_client.py +824 -0
  156. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_a2a_scoping.py +724 -0
  157. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client.py +1214 -0
  158. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_client_hardening.py +716 -0
  159. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_api_policy_rpc.py +767 -0
  160. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_approval_ledger.py +1536 -0
  161. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_fake_model.py +115 -0
  162. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_jwt_policy.py +991 -0
  163. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_limits.py +310 -0
  164. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging.py +148 -0
  165. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_logging_hardening.py +271 -0
  166. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_policy.py +378 -0
  167. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_resilience.py +610 -0
  168. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_server_auth.py +702 -0
  169. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_structured.py +673 -0
  170. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_telemetry.py +404 -0
  171. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_thread_listing.py +255 -0
  172. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_threads.py +268 -0
  173. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_token_exchange.py +1320 -0
  174. graph_agents_cli/scaffold/agents/langgraph/tests/unit/test_untrusted_content.py +393 -0
  175. graph_agents_cli/scaffold/agents/langgraph/uv-fastapi.lock +2084 -0
  176. graph_agents_cli/scaffold/agents/langgraph/uv-langgraph-server.lock +2106 -0
  177. graph_agents_cli/scaffold/agents/langgraph/{{cookiecutter.agent_guidance_filename}} +129 -0
  178. graph_agents_cli/scaffold/base_templates/_shared/graph-agents-cli-manifest.yaml +36 -0
  179. graph_agents_cli/scaffold/base_templates/python/.dockerignore +32 -0
  180. graph_agents_cli/scaffold/base_templates/python/.github/CODEOWNERS +30 -0
  181. graph_agents_cli/scaffold/base_templates/python/.github/agent.env +7 -0
  182. graph_agents_cli/scaffold/base_templates/python/.github/workflows/pr_checks.yaml +214 -0
  183. graph_agents_cli/scaffold/base_templates/python/.gitignore +209 -0
  184. graph_agents_cli/scaffold/base_templates/python/tests/unit/test_dummy.py +23 -0
  185. graph_agents_cli/scaffold/base_templates/python/{{cookiecutter.agent_guidance_filename}} +35 -0
  186. graph_agents_cli/scaffold/cmd_scaffold_group.py +49 -0
  187. graph_agents_cli/scaffold/commands/__init__.py +13 -0
  188. graph_agents_cli/scaffold/commands/create.py +1424 -0
  189. graph_agents_cli/scaffold/commands/enhance.py +1652 -0
  190. graph_agents_cli/scaffold/commands/upgrade.py +570 -0
  191. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/agent.env +12 -0
  192. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/promote-to-prod.yaml +371 -0
  193. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/.github/workflows/staging.yaml +450 -0
  194. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-dev.yaml +43 -0
  195. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-prod.yaml +41 -0
  196. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/argocd/application-staging.yaml +43 -0
  197. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/.helmignore +14 -0
  198. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/Chart.yaml +21 -0
  199. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/examples/networkpolicy.yaml +103 -0
  200. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/NOTES.txt +48 -0
  201. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/_helpers.tpl +189 -0
  202. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/certificate.yaml +15 -0
  203. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/configmap.yaml +10 -0
  204. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/deployment.yaml +199 -0
  205. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/hpa.yaml +22 -0
  206. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/httproute.yaml +30 -0
  207. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/ingress.yaml +39 -0
  208. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/networkpolicy.yaml +48 -0
  209. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/pdb.yaml +13 -0
  210. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/postgresql-secret.yaml +37 -0
  211. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/service.yaml +15 -0
  212. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/serviceaccount.yaml +13 -0
  213. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/templates/servicemonitor.yaml +42 -0
  214. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-dev.yaml +22 -0
  215. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-prod.yaml +45 -0
  216. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values-staging.yaml +29 -0
  217. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/deployment/helm/{{cookiecutter.project_name}}/values.yaml +396 -0
  218. graph_agents_cli/scaffold/deployment_targets/kubernetes/python/tests/integration/test_chart.py +269 -0
  219. graph_agents_cli/scaffold/deployment_targets/none/README.md +5 -0
  220. graph_agents_cli/scaffold/deployment_targets/none/python/README.md +6 -0
  221. graph_agents_cli/scaffold/utils/__init__.py +13 -0
  222. graph_agents_cli/scaffold/utils/backup.py +212 -0
  223. graph_agents_cli/scaffold/utils/build_record.py +257 -0
  224. graph_agents_cli/scaffold/utils/cli_options.py +184 -0
  225. graph_agents_cli/scaffold/utils/fs.py +83 -0
  226. graph_agents_cli/scaffold/utils/generate_locks.py +214 -0
  227. graph_agents_cli/scaffold/utils/generation_metadata.py +88 -0
  228. graph_agents_cli/scaffold/utils/keyedit.py +768 -0
  229. graph_agents_cli/scaffold/utils/keymerge.py +537 -0
  230. graph_agents_cli/scaffold/utils/language.py +138 -0
  231. graph_agents_cli/scaffold/utils/lock_utils.py +94 -0
  232. graph_agents_cli/scaffold/utils/logging.py +77 -0
  233. graph_agents_cli/scaffold/utils/manifest.py +292 -0
  234. graph_agents_cli/scaffold/utils/merge.py +970 -0
  235. graph_agents_cli/scaffold/utils/merge3.py +216 -0
  236. graph_agents_cli/scaffold/utils/openapi_seed.py +199 -0
  237. graph_agents_cli/scaffold/utils/remote_template.py +376 -0
  238. graph_agents_cli/scaffold/utils/template.py +1352 -0
  239. graph_agents_cli/scaffold/utils/upgrade.py +894 -0
  240. graph_agents_cli/scaffold/utils/version.py +438 -0
  241. graph_agents_cli/secrets/__init__.py +15 -0
  242. graph_agents_cli/secrets/_apply.py +954 -0
  243. graph_agents_cli/secrets/_required.py +188 -0
  244. graph_agents_cli/secrets/cmd_secrets.py +211 -0
  245. graph_agents_cli/setup/__init__.py +13 -0
  246. graph_agents_cli/setup/_antigravity.py +221 -0
  247. graph_agents_cli/setup/cmd_auth.py +1030 -0
  248. graph_agents_cli/setup/cmd_dev_token.py +513 -0
  249. graph_agents_cli/setup/cmd_setup.py +428 -0
  250. graph_agents_cli/setup/cmd_update.py +140 -0
  251. graph_agents_cli/skills/__init__.py +13 -0
  252. graph_agents_cli/skills/_bundle.py +65 -0
  253. graph_agents_cli/skills/data/README.md +19 -0
  254. graph_agents_cli/skills/data/graph-agents-cli-deploy/SKILL.md +357 -0
  255. graph_agents_cli/skills/data/graph-agents-cli-deploy/references/github-settings.md +113 -0
  256. graph_agents_cli/skills/data/graph-agents-cli-deploy/references/gitops.md +137 -0
  257. graph_agents_cli/skills/data/graph-agents-cli-deploy/references/kubernetes.md +315 -0
  258. graph_agents_cli/skills/data/graph-agents-cli-deploy/references/secrets.md +160 -0
  259. graph_agents_cli/skills/data/graph-agents-cli-eval/SKILL.md +303 -0
  260. graph_agents_cli/skills/data/graph-agents-cli-eval/references/dataset_schema.md +282 -0
  261. graph_agents_cli/skills/data/graph-agents-cli-eval/references/metrics-guide.md +143 -0
  262. graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/SKILL.md +659 -0
  263. graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langchain-models.md +124 -0
  264. graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/langgraph.md +235 -0
  265. graph_agents_cli/skills/data/graph-agents-cli-langgraph-code/references/template-contract.md +477 -0
  266. graph_agents_cli/skills/data/graph-agents-cli-observability/SKILL.md +231 -0
  267. graph_agents_cli/skills/data/graph-agents-cli-observability/references/langsmith.md +46 -0
  268. graph_agents_cli/skills/data/graph-agents-cli-observability/references/otel.md +59 -0
  269. graph_agents_cli/skills/data/graph-agents-cli-scaffold/SKILL.md +414 -0
  270. graph_agents_cli/skills/data/graph-agents-cli-scaffold/references/flags.md +134 -0
  271. graph_agents_cli/skills/data/graph-agents-cli-workflow/SKILL.md +478 -0
  272. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/brainstorming.md +118 -0
  273. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/commands.md +419 -0
  274. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/extension.md +156 -0
  275. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/internals.md +67 -0
  276. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/spec-template.md +56 -0
  277. graph_agents_cli/skills/data/graph-agents-cli-workflow/references/terminology.md +119 -0
  278. graph_agents_cli/system/__init__.py +15 -0
  279. graph_agents_cli/system/_apply.py +519 -0
  280. graph_agents_cli/system/_checks.py +1023 -0
  281. graph_agents_cli/system/_deploy.py +215 -0
  282. graph_agents_cli/system/_model.py +363 -0
  283. graph_agents_cli/system/_system.py +664 -0
  284. graph_agents_cli/system/_views.py +208 -0
  285. graph_agents_cli/system/cmd_system.py +423 -0
  286. graph_agents_cli-0.3.1.dist-info/METADATA +162 -0
  287. graph_agents_cli-0.3.1.dist-info/RECORD +291 -0
  288. graph_agents_cli-0.3.1.dist-info/WHEEL +4 -0
  289. graph_agents_cli-0.3.1.dist-info/entry_points.txt +2 -0
  290. graph_agents_cli-0.3.1.dist-info/licenses/LICENSE +201 -0
  291. 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