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,673 @@
1
+ # Copyright 2026 graph-agents-cli contributors
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ """Structured final answers (`app_utils/structured.py`), without a server.
16
+
17
+ The response schema is found and checked (fail closed: a keyword the checker
18
+ does not support stops startup), answers are validated against it, the
19
+ strategy is chosen as LangChain would with a strict provider schema, the fake
20
+ model answers in the shape, and `StructuredAnswer` sends an answer that does
21
+ not fit back to the model, then fails the step. The chat runtime's stream
22
+ mapping keeps the answer and hides the answer tool.
23
+
24
+ The project's own schema, if it declares one, is checked last: the rest of the
25
+ suite runs with `RESPONSE_SCHEMA_PATH=none` (`tests/conftest.py`).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import json
31
+ import os
32
+ import subprocess
33
+ import sys
34
+ from pathlib import Path
35
+ from typing import Any
36
+
37
+ import pytest
38
+ from langchain.agents import create_agent
39
+ from langchain.agents.structured_output import ProviderStrategy, ToolStrategy
40
+ from langchain_core.language_models import BaseChatModel
41
+ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
42
+ from langchain_core.outputs import ChatGeneration, ChatResult
43
+ from langchain_core.tools import tool
44
+ from pydantic import Field
45
+
46
+ from {{cookiecutter.agent_directory}}.app_utils import structured
47
+ from {{cookiecutter.agent_directory}}.app_utils.chat import (
48
+ CODE_INVALID_STRUCTURED_RESPONSE,
49
+ EVENT_DELTA,
50
+ EVENT_TOOL_CALL,
51
+ EVENT_TOOL_RESULT,
52
+ ChatRuntime,
53
+ _RunState,
54
+ _ServerRunError,
55
+ map_stream_item,
56
+ )
57
+ from {{cookiecutter.agent_directory}}.app_utils.limits import SettingsError
58
+ from {{cookiecutter.agent_directory}}.app_utils.model import FakeChatModel, _fake_answer
59
+ from {{cookiecutter.agent_directory}}.app_utils.structured import (
60
+ ANSWER_TOOL,
61
+ StructuredAnswer,
62
+ StructuredAnswerError,
63
+ response_format,
64
+ response_schema,
65
+ response_strategy,
66
+ schema_problems,
67
+ validate,
68
+ )
69
+
70
+ SCHEMA: dict[str, Any] = {
71
+ "title": "Weather report",
72
+ "type": "object",
73
+ "properties": {
74
+ "answer": {"type": "string"},
75
+ "confidence": {"type": "number", "minimum": 0, "maximum": 1},
76
+ "city": {"type": ["string", "null"]},
77
+ },
78
+ "required": ["answer", "confidence"],
79
+ "additionalProperties": False,
80
+ }
81
+
82
+
83
+ def _write(path: Path, schema: Any) -> Path:
84
+ path.write_text(json.dumps(schema), encoding="utf-8")
85
+ return path
86
+
87
+
88
+ @pytest.fixture
89
+ def schema_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
90
+ path = _write(tmp_path / "response_schema.json", SCHEMA)
91
+ monkeypatch.setenv("RESPONSE_SCHEMA_PATH", str(path))
92
+ monkeypatch.delenv("RESPONSE_FORMAT_STRATEGY", raising=False)
93
+ return path
94
+
95
+
96
+ # --- the schema -----------------------------------------------------------------------
97
+
98
+
99
+ def _agent_package(tmp_path: Path, monkeypatch: pytest.MonkeyPatch, schema: Any = None) -> None:
100
+ """Look for the package's response schema in `tmp_path` (with `schema` in it, if given)."""
101
+ (tmp_path / "app_utils").mkdir()
102
+ monkeypatch.setattr(structured, "__file__", str(tmp_path / "app_utils" / "structured.py"))
103
+ if schema is not None:
104
+ _write(tmp_path / "response_schema.json", schema)
105
+
106
+
107
+ def test_no_schema_file_means_no_structured_answers(tmp_path, monkeypatch) -> None:
108
+ _agent_package(tmp_path, monkeypatch)
109
+ monkeypatch.delenv("RESPONSE_SCHEMA_PATH", raising=False)
110
+ assert response_schema() is None and not structured.enabled()
111
+ assert response_format(FakeChatModel(), []) is None
112
+
113
+
114
+ def test_the_agent_packages_schema_file_is_the_default(tmp_path, monkeypatch) -> None:
115
+ _agent_package(tmp_path, monkeypatch, SCHEMA)
116
+ monkeypatch.delenv("RESPONSE_SCHEMA_PATH", raising=False)
117
+ assert response_schema() == SCHEMA and structured.enabled()
118
+
119
+
120
+ @pytest.mark.parametrize("value", ["none", "NONE", " None "])
121
+ def test_response_schema_path_none_switches_the_mode_off(tmp_path, monkeypatch, value) -> None:
122
+ # What the project's tests run with (conftest.py): text answers whatever the project declares.
123
+ _agent_package(tmp_path, monkeypatch, SCHEMA)
124
+ monkeypatch.setenv("RESPONSE_SCHEMA_PATH", value)
125
+ assert response_schema() is None and not structured.enabled()
126
+ assert response_format(FakeChatModel(), []) is None
127
+
128
+
129
+ def test_the_schema_file_is_read_and_checked(schema_file: Path) -> None:
130
+ assert response_schema() == SCHEMA and structured.enabled()
131
+
132
+
133
+ def test_a_named_schema_file_that_does_not_exist_stops_startup(tmp_path, monkeypatch) -> None:
134
+ monkeypatch.setenv("RESPONSE_SCHEMA_PATH", str(tmp_path / "missing.json"))
135
+ with pytest.raises(SettingsError, match="RESPONSE_SCHEMA_PATH"):
136
+ response_schema()
137
+
138
+
139
+ def test_a_file_that_is_not_json_stops_startup(tmp_path, monkeypatch) -> None:
140
+ path = tmp_path / "response_schema.json"
141
+ path.write_text("{'type': 'object'}", encoding="utf-8")
142
+ monkeypatch.setenv("RESPONSE_SCHEMA_PATH", str(path))
143
+ with pytest.raises(SettingsError, match="not a JSON file"):
144
+ response_schema()
145
+
146
+
147
+ @pytest.mark.parametrize(
148
+ ("schema", "problem"),
149
+ [
150
+ ([], "must be a JSON object"),
151
+ ({"type": "array", "items": {"type": "string"}}, "root must be an object schema"),
152
+ ({"type": ["object", "null"]}, "root must be an object schema"),
153
+ ({"type": "object", "if": {}, "then": {}}, "`if` is not a keyword"),
154
+ ({"type": "object", "patternProperties": {"^x": {}}}, "`patternProperties`"),
155
+ ({"type": "object", "requried": ["a"]}, "`requried` is not a keyword"),
156
+ ({"type": "object", "properties": {"a": {"type": "text"}}}, "must name JSON types"),
157
+ ({"type": "object", "properties": {"a": {"$ref": "#/$defs/missing"}}}, "does not point"),
158
+ ({"type": "object", "properties": {"a": {"$ref": "other.json#/x"}}}, "does not point"),
159
+ ({"type": "object", "properties": {"a": {"pattern": "("}}}, "not a regular expression"),
160
+ ({"type": "object", "properties": {"a": {"items": [{}, {}]}}}, "a list of schemas"),
161
+ ({"type": "object", "properties": {"a": {"minLength": -1}}}, "whole number >= 0"),
162
+ ({"type": "object", "properties": {"a": {"multipleOf": 0}}}, "number > 0"),
163
+ ({"type": "object", "required": "a"}, "list of property names"),
164
+ ({"type": "object", "properties": {"a": {"anyOf": []}}}, "non-empty list of schemas"),
165
+ ],
166
+ )
167
+ def test_a_schema_the_checker_cannot_check_is_refused(schema: Any, problem: str) -> None:
168
+ problems = schema_problems(schema)
169
+ assert any(problem in p for p in problems), problems
170
+
171
+
172
+ def test_a_refused_schema_stops_startup_naming_the_problem(tmp_path, monkeypatch) -> None:
173
+ path = _write(tmp_path / "s.json", {"type": "object", "if": {"type": "object"}})
174
+ monkeypatch.setenv("RESPONSE_SCHEMA_PATH", str(path))
175
+ with pytest.raises(SettingsError, match=r"cannot be the response schema.*`if`"):
176
+ response_schema()
177
+
178
+
179
+ def test_a_supported_schema_passes_the_check() -> None:
180
+ schema = {
181
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
182
+ "type": "object",
183
+ "description": "An order summary.",
184
+ "properties": {
185
+ "order": {"$ref": "#/$defs/order"},
186
+ "tags": {"type": "array", "items": {"type": "string"}, "uniqueItems": True},
187
+ "status": {"enum": ["open", "closed"]},
188
+ "note": {"anyOf": [{"type": "string", "format": "date"}, {"type": "null"}]},
189
+ },
190
+ "required": ["order"],
191
+ "$defs": {
192
+ "order": {
193
+ "type": "object",
194
+ "properties": {"id": {"type": "string", "pattern": "^ORD-[0-9]{5}$"}},
195
+ "required": ["id"],
196
+ }
197
+ },
198
+ }
199
+ assert schema_problems(schema) == []
200
+
201
+
202
+ # --- the check --------------------------------------------------------------------------
203
+
204
+
205
+ @pytest.mark.parametrize(
206
+ ("value", "problem"),
207
+ [
208
+ ({"answer": "sunny", "confidence": 0.9}, None),
209
+ ({"answer": "sunny", "confidence": 1, "city": None}, None),
210
+ ({"answer": "sunny"}, "missing the required property 'confidence'"),
211
+ ({"answer": 3, "confidence": 0.5}, "$.answer: must be string, not int"),
212
+ ({"answer": "x", "confidence": 2}, "$.confidence: must be <= 1"),
213
+ ({"answer": "x", "confidence": True}, "must be number, not bool"),
214
+ ({"answer": "x", "confidence": 0.5, "extra": 1}, "'extra' is not allowed"),
215
+ ({"answer": "x", "confidence": 0.5, "city": 7}, "must be string or null"),
216
+ ([], "$: must be object, not list"),
217
+ ],
218
+ )
219
+ def test_answers_are_checked_against_the_schema(value: Any, problem: str | None) -> None:
220
+ errors = validate(SCHEMA, value)
221
+ if problem is None:
222
+ assert errors == []
223
+ else:
224
+ assert any(problem in e for e in errors), errors
225
+
226
+
227
+ def test_the_check_follows_refs_choices_and_json_equality() -> None:
228
+ schema = {
229
+ "type": "object",
230
+ "properties": {
231
+ "order": {"$ref": "#/$defs/order"},
232
+ "count": {"type": "integer", "multipleOf": 2},
233
+ "flag": {"const": True},
234
+ "kind": {"oneOf": [{"const": "a"}, {"type": "string", "maxLength": 1}]},
235
+ "tags": {"type": "array", "uniqueItems": True, "minItems": 1},
236
+ "other": {"not": {"type": "null"}},
237
+ },
238
+ "$defs": {"order": {"type": "object", "required": ["id"]}},
239
+ }
240
+ assert validate(schema, {"order": {"id": "7"}, "count": 4.0, "flag": True, "kind": "b"}) == []
241
+ errors = validate(
242
+ schema,
243
+ {"order": {}, "count": 3, "flag": 1, "kind": "a", "tags": [1, 1.0], "other": None},
244
+ )
245
+ assert "$.order: missing the required property 'id'" in errors
246
+ assert "$.count: must be a multiple of 2" in errors
247
+ assert "$.flag: must be true" in errors # 1 is not true in JSON
248
+ assert "$.kind: fits 2 of the oneOf choices, not exactly one" in errors
249
+ assert "$.tags: the items must be unique" in errors # 1 equals 1.0 in JSON
250
+ assert "$.other: must not fit the `not` schema" in errors
251
+
252
+
253
+ # --- the strategy -----------------------------------------------------------------------
254
+
255
+
256
+ def test_the_strategy_setting(monkeypatch) -> None:
257
+ monkeypatch.delenv("RESPONSE_FORMAT_STRATEGY", raising=False)
258
+ assert response_strategy() == "auto"
259
+ for value in ("provider", "TOOL", " auto "):
260
+ monkeypatch.setenv("RESPONSE_FORMAT_STRATEGY", value)
261
+ assert response_strategy() == value.strip().lower()
262
+ monkeypatch.setenv("RESPONSE_FORMAT_STRATEGY", "json")
263
+ with pytest.raises(SettingsError, match="RESPONSE_FORMAT_STRATEGY"):
264
+ response_strategy()
265
+
266
+
267
+ def test_auto_picks_the_tool_for_a_model_without_structured_output(schema_file) -> None:
268
+ fmt = response_format(FakeChatModel(), [])
269
+ assert isinstance(fmt, ToolStrategy)
270
+ (spec,) = fmt.schema_specs
271
+ # A fixed name (a title with a space is not a valid tool name), the project's
272
+ # description or a default one, and the project's properties.
273
+ assert spec.name == ANSWER_TOOL and spec.description
274
+ assert spec.json_schema["properties"] == SCHEMA["properties"]
275
+
276
+
277
+ def test_auto_picks_the_provider_strictly_for_a_model_that_has_it(schema_file) -> None:
278
+ from langchain_openai import ChatOpenAI
279
+
280
+ model = ChatOpenAI(model="gpt-5-mini", api_key="sk-test")
281
+ fmt = response_format(model, [])
282
+ assert isinstance(fmt, ProviderStrategy)
283
+ # LangChain's own AutoStrategy would ask for a best-effort (non-strict) json_schema.
284
+ assert fmt.to_model_kwargs()["response_format"]["json_schema"]["strict"] is True
285
+ assert fmt.to_model_kwargs()["response_format"]["json_schema"]["name"] == ANSWER_TOOL
286
+
287
+
288
+ def test_an_explicit_strategy_wins(schema_file, monkeypatch) -> None:
289
+ monkeypatch.setenv("RESPONSE_FORMAT_STRATEGY", "provider")
290
+ assert isinstance(response_format(FakeChatModel(), []), ProviderStrategy)
291
+ monkeypatch.setenv("RESPONSE_FORMAT_STRATEGY", "tool")
292
+ from langchain_openai import ChatOpenAI
293
+
294
+ assert isinstance(
295
+ response_format(ChatOpenAI(model="gpt-5-mini", api_key="sk-test"), []), ToolStrategy
296
+ )
297
+
298
+
299
+ def test_a_tool_named_like_the_answer_is_refused(schema_file) -> None:
300
+ @tool
301
+ def final_answer(text: str) -> str:
302
+ """A project tool that happens to be called final_answer."""
303
+ return text
304
+
305
+ with pytest.raises(SettingsError, match="rename the tool"):
306
+ response_format(FakeChatModel(), [final_answer])
307
+
308
+
309
+ # The shapes Anthropic's client refuses (a type list, an `enum` with no `type`), and the
310
+ # same answer written so that it takes it.
311
+ ANTHROPIC_REFUSES: dict[str, Any] = {
312
+ "type": "object",
313
+ "properties": {
314
+ "category": {"enum": ["billing", "technical", "account", "other"]},
315
+ "order_id": {"type": ["string", "null"], "pattern": "^ORD-[0-9]{5}$"},
316
+ },
317
+ "required": ["category", "order_id"],
318
+ "additionalProperties": False,
319
+ }
320
+ ANTHROPIC_TAKES: dict[str, Any] = {
321
+ "type": "object",
322
+ "properties": {
323
+ "category": {"type": "string", "enum": ["billing", "technical", "account", "other"]},
324
+ "order_id": {"anyOf": [{"type": "string", "pattern": "^ORD-[0-9]{5}$"}, {"type": "null"}]},
325
+ },
326
+ "required": ["category", "order_id"],
327
+ "additionalProperties": False,
328
+ }
329
+
330
+
331
+ def _claude() -> Any:
332
+ from langchain_anthropic import ChatAnthropic
333
+
334
+ # No request is sent: the strategy and the schema conversion happen before any.
335
+ return ChatAnthropic(model="claude-sonnet-5", api_key="sk-test")
336
+
337
+
338
+ @pytest.mark.parametrize("schema", [ANTHROPIC_REFUSES, SCHEMA])
339
+ def test_auto_uses_the_tool_where_anthropic_cannot_take_the_schema(
340
+ tmp_path, monkeypatch, schema
341
+ ) -> None:
342
+ # Every run would otherwise fail in the client, before its request (run_failed).
343
+ monkeypatch.setenv("RESPONSE_SCHEMA_PATH", str(_write(tmp_path / "s.json", schema)))
344
+ model = _claude()
345
+ assert structured.provider_supported(model, []) # the model has structured output
346
+ assert structured.provider_refusal(model, schema) is not None
347
+ assert isinstance(response_format(model, []), ToolStrategy)
348
+
349
+
350
+ def test_auto_uses_anthropics_structured_output_for_a_schema_it_takes(
351
+ tmp_path, monkeypatch
352
+ ) -> None:
353
+ from anthropic import transform_schema
354
+
355
+ monkeypatch.setenv("RESPONSE_SCHEMA_PATH", str(_write(tmp_path / "s.json", ANTHROPIC_TAKES)))
356
+ fmt = response_format(_claude(), [])
357
+ assert isinstance(fmt, ProviderStrategy)
358
+ # What langchain-anthropic does with it before the request.
359
+ transform_schema(fmt.to_model_kwargs()["response_format"]["json_schema"]["schema"])
360
+
361
+
362
+ def test_the_provider_strategy_with_a_schema_anthropic_cannot_take_stops_startup(
363
+ tmp_path, monkeypatch
364
+ ) -> None:
365
+ monkeypatch.setenv("RESPONSE_SCHEMA_PATH", str(_write(tmp_path / "s.json", ANTHROPIC_REFUSES)))
366
+ monkeypatch.setenv("RESPONSE_FORMAT_STRATEGY", "provider")
367
+ with pytest.raises(SettingsError, match=r"cannot send response_schema\.json.*=tool"):
368
+ response_format(_claude(), [])
369
+ # The tool strategy takes it; other providers' clients are not asked.
370
+ monkeypatch.setenv("RESPONSE_FORMAT_STRATEGY", "tool")
371
+ assert isinstance(response_format(_claude(), []), ToolStrategy)
372
+ assert structured.provider_refusal(FakeChatModel(), ANTHROPIC_REFUSES) is None
373
+
374
+
375
+ # --- the fake model --------------------------------------------------------------------
376
+
377
+
378
+ def test_the_fake_answers_in_the_shape() -> None:
379
+ answer = _fake_answer(SCHEMA, "sunny")
380
+ assert answer == {"answer": "sunny", "confidence": 0, "city": "sunny"}
381
+ assert validate(SCHEMA, answer) == []
382
+ # A text that breaks a pattern takes the choice that fits (the developer guide's example).
383
+ for schema in (ANTHROPIC_TAKES, ANTHROPIC_REFUSES):
384
+ answer = _fake_answer(schema, "sunny")
385
+ assert answer == {"category": "billing", "order_id": None}, schema
386
+ assert validate(schema, answer) == []
387
+ assert _fake_answer(ANTHROPIC_TAKES, "ORD-12345")["order_id"] == "ORD-12345"
388
+ # With no choice that fits, the answer does not fit (the tests' failing path).
389
+ failing = {"type": "object", "properties": {"id": {"type": "string", "pattern": "^ORD-"}}}
390
+ assert validate(failing, _fake_answer(failing, "sunny")) != []
391
+ provider = FakeChatModel().bind_tools(
392
+ [], response_format={"type": "json_schema", "json_schema": {"schema": SCHEMA}}
393
+ )
394
+ reply = provider.invoke([HumanMessage("hello")])
395
+ assert json.loads(reply.content)["answer"] == "Hello! How can I help you today?"
396
+ answer_tool = {"type": "function", "function": {"name": ANSWER_TOOL, "parameters": SCHEMA}}
397
+ forced = FakeChatModel().bind_tools([answer_tool], tool_choice="any")
398
+ (call,) = forced.invoke([HumanMessage("hello")]).tool_calls
399
+ assert call["name"] == ANSWER_TOOL and call["args"]["confidence"] == 0
400
+ # A request that names the answer tool does not call it early.
401
+ unforced = FakeChatModel().bind_tools([answer_tool])
402
+ assert unforced.invoke([HumanMessage("give the final answer")]).tool_calls == []
403
+
404
+
405
+ # --- the middleware ---------------------------------------------------------------------
406
+
407
+
408
+ class Scripted(BaseChatModel):
409
+ """Returns its replies in order (the last one again when they run out); records requests."""
410
+
411
+ replies: list[AIMessage]
412
+ seen: list[list[Any]] = Field(default_factory=list)
413
+
414
+ @property
415
+ def _llm_type(self) -> str:
416
+ return "scripted"
417
+
418
+ def bind_tools(self, tools: Any, **kwargs: Any) -> Any: # type: ignore[override]
419
+ return self
420
+
421
+ def _generate(
422
+ self, messages: list[Any], stop: Any = None, run_manager: Any = None, **kwargs: Any
423
+ ) -> ChatResult:
424
+ self.seen.append(list(messages))
425
+ reply = self.replies[min(len(self.seen), len(self.replies)) - 1]
426
+ return ChatResult(generations=[ChatGeneration(message=reply)])
427
+
428
+
429
+ def _answer(args: dict[str, Any], tokens: int = 10) -> AIMessage:
430
+ usage = {"input_tokens": tokens, "output_tokens": tokens, "total_tokens": 2 * tokens}
431
+ call = {"name": ANSWER_TOOL, "args": args, "id": f"call_{tokens}"}
432
+ return AIMessage(content="", tool_calls=[call], usage_metadata=usage)
433
+
434
+
435
+ def _graph(model: BaseChatModel, strategy: Any) -> Any:
436
+ return create_agent(
437
+ model=model, tools=[], response_format=strategy, middleware=[StructuredAnswer()]
438
+ )
439
+
440
+
441
+ def _tool_strategy() -> ToolStrategy[Any]:
442
+ return ToolStrategy({**SCHEMA, "title": ANSWER_TOOL})
443
+
444
+
445
+ async def test_an_answer_that_fits_passes_untouched(schema_file) -> None:
446
+ model = Scripted(replies=[_answer({"answer": "sunny", "confidence": 0.5})])
447
+ state = await _graph(model, _tool_strategy()).ainvoke({"messages": [HumanMessage("hi")]})
448
+ assert state["structured_response"] == {"answer": "sunny", "confidence": 0.5}
449
+ assert len(model.seen) == 1
450
+
451
+
452
+ async def test_an_answer_that_does_not_fit_is_sent_back_and_its_usage_counts(schema_file) -> None:
453
+ model = Scripted(
454
+ replies=[
455
+ _answer({"answer": "sunny", "confidence": 7}, tokens=3),
456
+ _answer({"answer": "sunny", "confidence": 0.7}, tokens=5),
457
+ ]
458
+ )
459
+ state = await _graph(model, _tool_strategy()).ainvoke({"messages": [HumanMessage("hi")]})
460
+ assert state["structured_response"] == {"answer": "sunny", "confidence": 0.7}
461
+ # The model read its try and a tool error naming the problem.
462
+ retry = model.seen[1]
463
+ assert isinstance(retry[-1], ToolMessage) and retry[-1].status == "error"
464
+ assert "$.confidence: must be <= 1" in retry[-1].content
465
+ # The failed try is not kept in the thread, but its tokens count.
466
+ answers = [m for m in state["messages"] if isinstance(m, AIMessage)]
467
+ assert len(answers) == 1 and answers[0].usage_metadata["input_tokens"] == 8
468
+
469
+
470
+ async def test_a_plain_text_final_reply_is_sent_back_under_the_tool_strategy(schema_file) -> None:
471
+ model = Scripted(
472
+ replies=[AIMessage(content="It is sunny."), _answer({"answer": "x", "confidence": 1})]
473
+ )
474
+ state = await _graph(model, _tool_strategy()).ainvoke({"messages": [HumanMessage("hi")]})
475
+ assert state["structured_response"] == {"answer": "x", "confidence": 1}
476
+ note = model.seen[1][-1]
477
+ assert isinstance(note, HumanMessage) and f"call {ANSWER_TOOL}" in note.content
478
+
479
+
480
+ async def test_a_reply_that_is_not_json_is_sent_back_under_the_provider_strategy(
481
+ schema_file,
482
+ ) -> None:
483
+ model = Scripted(
484
+ replies=[
485
+ AIMessage(content='{"answer": "sunny", "confidence": '),
486
+ AIMessage(content='{"answer": "sunny", "confidence": 0.2}'),
487
+ ]
488
+ )
489
+ strategy = ProviderStrategy({**SCHEMA, "title": ANSWER_TOOL}, strict=True)
490
+ state = await _graph(model, strategy).ainvoke({"messages": [HumanMessage("hi")]})
491
+ assert state["structured_response"] == {"answer": "sunny", "confidence": 0.2}
492
+ assert "not valid JSON" in model.seen[1][-1].content
493
+
494
+
495
+ async def test_no_fitting_try_fails_the_step(schema_file) -> None:
496
+ model = Scripted(replies=[_answer({"answer": "sunny"})])
497
+ with pytest.raises(StructuredAnswerError, match=r"after 3 tries.*'confidence'"):
498
+ await _graph(model, _tool_strategy()).ainvoke({"messages": [HumanMessage("hi")]})
499
+ assert len(model.seen) == 3
500
+
501
+
502
+ async def test_an_answer_beside_other_tool_calls_is_sent_back_and_none_of_them_runs(
503
+ schema_file,
504
+ ) -> None:
505
+ # LangChain would take the answer and still run the other call after it (a gated
506
+ # one would pause the run with the answer already given): the try goes back.
507
+ ran: list[str] = []
508
+
509
+ @tool
510
+ def probe(query: str) -> str:
511
+ """Run the probe for a place."""
512
+ ran.append(query)
513
+ return f"{query}: 42"
514
+
515
+ fitting = {"answer": "Oslo is fine", "confidence": 1}
516
+ together = AIMessage(
517
+ content="",
518
+ tool_calls=[
519
+ {"name": ANSWER_TOOL, "args": fitting, "id": "call_answer"},
520
+ {"name": "probe", "args": {"query": "Oslo"}, "id": "call_probe"},
521
+ ],
522
+ )
523
+ alone = AIMessage(
524
+ content="", tool_calls=[{"name": "probe", "args": {"query": "Oslo"}, "id": "call_again"}]
525
+ )
526
+ model = Scripted(replies=[together, alone, _answer({"answer": "Oslo: 42", "confidence": 1})])
527
+ graph = create_agent(
528
+ model=model,
529
+ tools=[probe],
530
+ response_format=_tool_strategy(),
531
+ middleware=[StructuredAnswer()],
532
+ )
533
+ state = await graph.ainvoke({"messages": [HumanMessage("hi")]})
534
+ assert state["structured_response"] == {"answer": "Oslo: 42", "confidence": 1}
535
+ assert ran == ["Oslo"] and len(model.seen) == 3 # the probe ran once: when called alone
536
+ # Every call of the refused try has a result (a provider refuses a history without):
537
+ # the answer is refused, the other call did not run.
538
+ results = {m.tool_call_id: m for m in model.seen[1] if isinstance(m, ToolMessage)}
539
+ assert set(results) == {"call_answer", "call_probe"}
540
+ assert results["call_answer"].status == "error"
541
+ assert "came with other tool calls" in results["call_answer"].content
542
+ assert results["call_probe"].content == structured.OTHER_CALL_NOT_RUN
543
+ # The refused try is not kept in the thread.
544
+ kept = {c["id"] for m in state["messages"] if isinstance(m, AIMessage) for c in m.tool_calls}
545
+ assert kept == {"call_again", "call_10"}
546
+
547
+
548
+ def test_the_middleware_does_nothing_without_a_response_format(schema_file) -> None:
549
+ model = Scripted(replies=[AIMessage(content="plain text")])
550
+ graph = create_agent(model=model, tools=[], middleware=[StructuredAnswer()])
551
+ state = graph.invoke({"messages": [HumanMessage("hi")]})
552
+ assert state["messages"][-1].content == "plain text" and len(model.seen) == 1
553
+
554
+
555
+ # --- the chat runtime's view of a structured run -----------------------------------------
556
+
557
+
558
+ def test_a_structured_run_hides_the_models_text_and_the_answer_tool() -> None:
559
+ state = _RunState(structured_mode=True)
560
+ chunk = {"type": "AIMessageChunk", "content": '{"answer": "sun', "id": "a1"}
561
+ assert list(map_stream_item("messages", [chunk, {}], state)) == []
562
+ # As LangGraph Server sends the updates: JSON, not message objects.
563
+ update = {
564
+ "model": {
565
+ "messages": [
566
+ {
567
+ "type": "ai",
568
+ "id": "a2",
569
+ "content": "",
570
+ "tool_calls": [
571
+ {"id": "c1", "name": "probe", "args": {}},
572
+ {"id": "c2", "name": ANSWER_TOOL, "args": {"answer": "x"}},
573
+ ],
574
+ }
575
+ ],
576
+ "structured_response": {"answer": "x"},
577
+ }
578
+ }
579
+ events = list(map_stream_item("updates", update, state))
580
+ assert [e for e, _ in events] == [EVENT_TOOL_CALL]
581
+ assert events[0][1]["name"] == "probe" and state.structured == {"answer": "x"}
582
+ result = {"tools": {"messages": [{"type": "tool", "name": ANSWER_TOOL, "tool_call_id": "c2"}]}}
583
+ assert list(map_stream_item("updates", result, state)) == []
584
+ # A later step that gives no answer clears it: the last step's answer counts.
585
+ list(
586
+ map_stream_item("updates", {"model": {"messages": [], "structured_response": None}}, state)
587
+ )
588
+ assert state.structured is None
589
+
590
+
591
+ def test_a_run_without_a_schema_streams_as_before() -> None:
592
+ state = _RunState()
593
+ chunk = {"type": "AIMessageChunk", "content": "sunny", "id": "a1"}
594
+ assert list(map_stream_item("messages", [chunk, {}], state)) == [
595
+ (EVENT_DELTA, {"text": "sunny"})
596
+ ]
597
+ update = {"tools": {"messages": [{"type": "tool", "name": ANSWER_TOOL, "tool_call_id": "c"}]}}
598
+ assert [e for e, _ in map_stream_item("updates", update, state)] == [EVENT_TOOL_RESULT]
599
+
600
+
601
+ def test_a_failed_answer_from_the_server_has_its_own_error_code() -> None:
602
+ runtime = ChatRuntime()
603
+ for exc in (
604
+ StructuredAnswerError("no try fitted"),
605
+ _ServerRunError({"error": "StructuredAnswerError", "message": "no try fitted"}),
606
+ ):
607
+ event = runtime._error_event(exc, "run-1")
608
+ assert event["code"] == CODE_INVALID_STRUCTURED_RESPONSE, event
609
+ assert "did not fit the response schema" in event["message"]
610
+
611
+
612
+ # --- the project's own response schema --------------------------------------------------
613
+
614
+ PROJECT_ROOT = Path(__file__).resolve().parents[2]
615
+ # One turn of the project's own graph (`agent.py` as written), under the project's own
616
+ # schema: a process of its own, since this one imported the agent with the schema off.
617
+ ONE_TURN = """
618
+ import json
619
+ from {{cookiecutter.agent_directory}} import agent
620
+ from {{cookiecutter.agent_directory}}.app_utils.structured import StructuredAnswerError
621
+ try:
622
+ state = agent.graph.invoke({"messages": [{"role": "user", "content": "Hello"}]})
623
+ except StructuredAnswerError as exc:
624
+ print(json.dumps({"error": str(exc)}))
625
+ else:
626
+ print(json.dumps({"answered": "structured_response" in state,
627
+ "answer": state.get("structured_response")}))
628
+ """
629
+
630
+
631
+ def _project_schema(monkeypatch: pytest.MonkeyPatch) -> dict[str, Any] | None:
632
+ monkeypatch.delenv("RESPONSE_SCHEMA_PATH", raising=False)
633
+ return response_schema()
634
+
635
+
636
+ def test_the_projects_response_schema_is_one_the_agent_can_use(monkeypatch) -> None:
637
+ # A schema the checker cannot check stops startup (SettingsError names the problem).
638
+ schema = _project_schema(monkeypatch)
639
+ if schema is not None:
640
+ assert schema_problems(schema) == []
641
+ assert response_format(FakeChatModel(), []) is not None
642
+
643
+
644
+ def test_the_agent_answers_in_the_projects_shape(monkeypatch) -> None:
645
+ """`agent.py` is built for the project's schema: its answer is an object in that shape.
646
+
647
+ Without a schema, the agent answers in text (no structured answer).
648
+ """
649
+ schema = _project_schema(monkeypatch)
650
+ env = {k: v for k, v in os.environ.items() if k != "RESPONSE_SCHEMA_PATH"}
651
+ env["MODEL_PROVIDER"] = "fake"
652
+ done = subprocess.run(
653
+ [sys.executable, "-c", ONE_TURN],
654
+ cwd=PROJECT_ROOT,
655
+ env=env,
656
+ capture_output=True,
657
+ text=True,
658
+ timeout=120,
659
+ check=False,
660
+ )
661
+ assert done.returncode == 0, done.stderr[-3000:]
662
+ result = json.loads(done.stdout.strip().splitlines()[-1])
663
+ if schema is None:
664
+ assert result == {"answered": False, "answer": None}
665
+ return
666
+ if "error" in result and "must match the pattern" in result["error"]:
667
+ pytest.skip(
668
+ "the fake model writes the reply's text where the schema wants a string, and it "
669
+ f"does not match the schema's `pattern` ({result['error']}); the answer check ran"
670
+ )
671
+ assert "error" not in result, result["error"]
672
+ assert result["answered"], "agent.py gives no structured answer: pass response_format"
673
+ assert validate(schema, result["answer"]) == [], result["answer"]