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,755 @@
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: the agent's final answer is JSON in the shape the project declares.
16
+
17
+ The project declares the shape in `<agent directory>/response_schema.json`
18
+ (`RESPONSE_SCHEMA_PATH` names another file), a JSON Schema whose root is an
19
+ object. Without the file the agent answers in text, as before, and so it does
20
+ with `RESPONSE_SCHEMA_PATH=none` whatever file there is: the project's tests
21
+ set that (`tests/conftest.py`), so they exercise the runtime with text answers,
22
+ and check the project's own schema in `tests/unit/test_structured.py`. With it:
23
+
24
+ * The model is made to answer in that shape through LangChain's
25
+ `response_format` strategies (`response_format()`): the provider's own
26
+ structured output (OpenAI's `json_schema` response format, strict; Anthropic's
27
+ and Gemini's native equivalents) where the model supports it, else a tool the
28
+ model must call with its answer (`final_answer`, with `tool_choice` forcing a
29
+ call at every step). `RESPONSE_FORMAT_STRATEGY` picks: `auto` (default: the
30
+ provider's own when LangChain's model profile says the model has it, with
31
+ the agent's tools bound, and the model's client can send the schema; the
32
+ tool otherwise), `provider` or `tool`. Anthropic's client refuses a type
33
+ list and a schema with no `type` (an `enum` alone): `auto` then uses the
34
+ tool, and `provider` stops startup (`provider_refusal`).
35
+ * Every answer is checked against the schema here (`StructuredAnswer`):
36
+ LangChain returns a raw JSON-schema answer unchecked under both strategies.
37
+ An answer that does not fit, a reply that is not JSON, a final reply in
38
+ plain text, or an answer given beside other tool calls (none of which runs)
39
+ is sent back to the model with what is wrong, up to
40
+ `MAX_ANSWER_ATTEMPTS` tries in the same step (the failed tries are not kept
41
+ in the thread; their token usage is added to the answer's). When no try
42
+ fits, the step fails with `StructuredAnswerError` and the run ends with the
43
+ error code `invalid_structured_response`.
44
+ * The chat runtime (`chat.py`) delivers the answer: a completed run's
45
+ `message.end` carries `structured_response` (the object), and the run's
46
+ reply text is exactly its JSON text (one `message.delta`): nothing else the
47
+ model wrote on the way is streamed. The A2A reply adds a data part with the
48
+ object. It checks the answer again first: one that does not fit (an
49
+ `agent.py` without `StructuredAnswer()` in its middleware never checked it)
50
+ ends the run with `invalid_structured_response` and is never delivered.
51
+
52
+ The checker supports a documented subset of JSON Schema (`SUPPORTED_KEYWORDS`):
53
+ a schema that uses anything else (`if`/`then`, `patternProperties`,
54
+ `prefixItems`, a remote `$ref`, a misspelt keyword, ...) stops startup rather
55
+ than being half-checked. `format` is an annotation only (as JSON Schema 2020-12
56
+ says by default), and `pattern` uses Python's regular expressions.
57
+
58
+ With the provider strategy on OpenAI Chat Completions, strict structured
59
+ output applies: langchain-openai makes every property of the answer required
60
+ and forbids extra ones (the model fills an optional property; give it a
61
+ `null` type where it may have no value), and makes every tool strict too (a
62
+ tool's optional arguments become required: the model passes a value for
63
+ each). A schema the provider refuses fails every run with the provider's
64
+ error: use `RESPONSE_FORMAT_STRATEGY=tool` for it.
65
+ """
66
+
67
+ from __future__ import annotations
68
+
69
+ import copy
70
+ import json
71
+ import logging
72
+ import math
73
+ import os
74
+ import re
75
+ from pathlib import Path
76
+ from typing import Any
77
+
78
+ from langchain.agents.middleware import AgentMiddleware, ModelResponse
79
+ from langchain.agents.structured_output import (
80
+ ProviderStrategy,
81
+ StructuredOutputValidationError,
82
+ ToolStrategy,
83
+ )
84
+ from langchain_core.messages import AIMessage, HumanMessage, ToolMessage
85
+
86
+ from {{cookiecutter.agent_directory}}.app_utils.limits import SettingsError
87
+
88
+ logger = logging.getLogger(__name__)
89
+
90
+ SCHEMA_FILENAME = "response_schema.json"
91
+ # `RESPONSE_SCHEMA_PATH=none`: no response schema (text answers), whatever file there is.
92
+ SCHEMA_OFF = "none"
93
+ # The name of the answer's schema at the provider and of the answer tool (the tool
94
+ # strategy): the project's own title stays in the file, not in the name a provider checks.
95
+ ANSWER_TOOL = "final_answer"
96
+ DEFAULT_DESCRIPTION = (
97
+ "Your final answer to the user, in this shape. Give it once you have what you need; "
98
+ "it ends your turn."
99
+ )
100
+ STRATEGY_AUTO = "auto"
101
+ STRATEGY_PROVIDER = "provider"
102
+ STRATEGY_TOOL = "tool"
103
+ STRATEGIES = (STRATEGY_AUTO, STRATEGY_PROVIDER, STRATEGY_TOOL)
104
+ # Tries of one answer (the first and two corrections) before the run fails.
105
+ MAX_ANSWER_ATTEMPTS = 3
106
+ # How many schema problems one correction names.
107
+ MAX_REPORTED_PROBLEMS = 5
108
+ # What the answer tool's result says (the model reads it on the thread's later turns).
109
+ ANSWER_RECORDED = "The answer was given to the user."
110
+ # The result of a tool call made beside an answer (it did not run: an answer ends the turn).
111
+ OTHER_CALL_NOT_RUN = (
112
+ f"Not run: it came with a {ANSWER_TOOL} call. Call tools first, then {ANSWER_TOOL} alone."
113
+ )
114
+ # What is wrong with an answer given beside other tool calls (the tool strategy): the
115
+ # calls would run after the answer was written, or pause the run for an approval with
116
+ # the answer already given, so the try is sent back and none of its calls runs.
117
+ ANSWER_NOT_ALONE = (
118
+ "your answer came with other tool calls, which did not run: an answer ends your turn, "
119
+ f"so call the tools you need first, then {ANSWER_TOOL} alone once you have their results"
120
+ )
121
+
122
+ # --- BEGIN SHARED RESPONSE SCHEMA RULES ---
123
+ # The JSON Schema subset a response schema may use, and the check of a schema
124
+ # against it. The same block is in graph_agents_cli/_response_schema.py, which
125
+ # `create --response-schema` and `lint` use: keep the two byte-identical
126
+ # (tests/dev/test_response_schema_parity.py), so a schema the CLI accepts
127
+ # starts the agent. No Jinja, no imports but json, math, re and typing.Any.
128
+
129
+ # Keywords that are checked, and annotations that are allowed and ignored.
130
+ VALIDATION_KEYWORDS = frozenset(
131
+ {
132
+ "type",
133
+ "enum",
134
+ "const",
135
+ "properties",
136
+ "required",
137
+ "additionalProperties",
138
+ "minProperties",
139
+ "maxProperties",
140
+ "items",
141
+ "minItems",
142
+ "maxItems",
143
+ "uniqueItems",
144
+ "minLength",
145
+ "maxLength",
146
+ "pattern",
147
+ "minimum",
148
+ "maximum",
149
+ "exclusiveMinimum",
150
+ "exclusiveMaximum",
151
+ "multipleOf",
152
+ "anyOf",
153
+ "oneOf",
154
+ "allOf",
155
+ "not",
156
+ "$ref",
157
+ "$defs",
158
+ "definitions",
159
+ }
160
+ )
161
+ ANNOTATION_KEYWORDS = frozenset(
162
+ {
163
+ "$schema",
164
+ "$id",
165
+ "$comment",
166
+ "title",
167
+ "description",
168
+ "default",
169
+ "examples",
170
+ "deprecated",
171
+ "readOnly",
172
+ "writeOnly",
173
+ "format",
174
+ }
175
+ )
176
+ SUPPORTED_KEYWORDS = VALIDATION_KEYWORDS | ANNOTATION_KEYWORDS
177
+ JSON_TYPES = frozenset({"object", "array", "string", "integer", "number", "boolean", "null"})
178
+ _SCHEMA_LISTS = ("anyOf", "oneOf", "allOf")
179
+ _SCHEMA_MAPS = ("properties", "$defs", "definitions")
180
+ _SCHEMA_VALUES = ("items", "additionalProperties", "not")
181
+ _COUNTS = ("minProperties", "maxProperties", "minItems", "maxItems", "minLength", "maxLength")
182
+ _NUMBERS = ("minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf")
183
+
184
+
185
+ def _pointer(schema: Any, ref: str) -> Any:
186
+ """The subschema a local `$ref` (`#`, `#/$defs/name`, ...) names; KeyError when none."""
187
+ if ref == "#":
188
+ return schema
189
+ if not ref.startswith("#/"):
190
+ raise KeyError(ref)
191
+ node = schema
192
+ for raw in ref[2:].split("/"):
193
+ key = raw.replace("~1", "/").replace("~0", "~")
194
+ if isinstance(node, dict) and key in node:
195
+ node = node[key]
196
+ elif isinstance(node, list) and key.isdigit() and int(key) < len(node):
197
+ node = node[int(key)]
198
+ else:
199
+ raise KeyError(ref)
200
+ return node
201
+
202
+
203
+ def _is_number(value: Any) -> bool:
204
+ return isinstance(value, int | float) and not isinstance(value, bool) and math.isfinite(value)
205
+
206
+
207
+ def schema_problems(schema: Any) -> list[str]:
208
+ """What stops `schema` from being a response schema; empty when it can be one.
209
+
210
+ The root is an object schema (`"type": "object"`); every keyword anywhere
211
+ is one `SUPPORTED_KEYWORDS` lists, with a value of the right kind; every
212
+ `$ref` points inside the document; every `pattern` compiles.
213
+ """
214
+ if not isinstance(schema, dict):
215
+ return ["the schema must be a JSON object"]
216
+ problems: list[str] = []
217
+ if schema.get("type") != "object":
218
+ problems.append('$: the root must be an object schema ("type": "object")')
219
+
220
+ def walk(node: Any, where: str) -> None:
221
+ if isinstance(node, bool):
222
+ return
223
+ if not isinstance(node, dict):
224
+ problems.append(f"{where}: a schema must be an object or a boolean")
225
+ return
226
+ for key, value in node.items():
227
+ at = f"{where}.{key}"
228
+ if key not in SUPPORTED_KEYWORDS:
229
+ problems.append(f"{at}: `{key}` is not a keyword the answer check supports")
230
+ elif key == "type":
231
+ types = value if isinstance(value, list) else [value]
232
+ if not types or not all(t in JSON_TYPES for t in types):
233
+ problems.append(f"{at}: must name JSON types ({', '.join(sorted(JSON_TYPES))})")
234
+ elif key == "required":
235
+ if not isinstance(value, list) or not all(isinstance(v, str) for v in value):
236
+ problems.append(f"{at}: must be a list of property names")
237
+ elif key == "enum":
238
+ if not isinstance(value, list) or not value:
239
+ problems.append(f"{at}: must be a non-empty list")
240
+ elif key in _COUNTS:
241
+ if not isinstance(value, int) or isinstance(value, bool) or value < 0:
242
+ problems.append(f"{at}: must be a whole number >= 0")
243
+ elif key in _NUMBERS:
244
+ if not _is_number(value) or (key == "multipleOf" and value <= 0):
245
+ problems.append(
246
+ f"{at}: must be a number" + (" > 0" if key == "multipleOf" else "")
247
+ )
248
+ elif key == "uniqueItems":
249
+ if not isinstance(value, bool):
250
+ problems.append(f"{at}: must be true or false")
251
+ elif key == "pattern":
252
+ try:
253
+ re.compile(value)
254
+ except (re.error, TypeError) as exc:
255
+ problems.append(f"{at}: not a regular expression ({exc})")
256
+ elif key == "$ref":
257
+ try:
258
+ _pointer(schema, value if isinstance(value, str) else "")
259
+ except KeyError:
260
+ problems.append(f"{at}: {value!r} does not point inside this schema")
261
+ elif key in _SCHEMA_LISTS:
262
+ if not isinstance(value, list) or not value:
263
+ problems.append(f"{at}: must be a non-empty list of schemas")
264
+ else:
265
+ for i, sub in enumerate(value):
266
+ walk(sub, f"{at}[{i}]")
267
+ elif key in _SCHEMA_MAPS:
268
+ if not isinstance(value, dict):
269
+ problems.append(f"{at}: must map names to schemas")
270
+ else:
271
+ for name, sub in value.items():
272
+ walk(sub, f"{at}.{name}")
273
+ elif key in _SCHEMA_VALUES:
274
+ if key == "items" and isinstance(value, list):
275
+ problems.append(
276
+ f"{at}: must be one schema (a list of schemas is not supported)"
277
+ )
278
+ else:
279
+ walk(value, at)
280
+
281
+ walk(schema, "$")
282
+ return problems
283
+
284
+
285
+ # --- END SHARED RESPONSE SCHEMA RULES ---
286
+
287
+
288
+ class StructuredAnswerError(RuntimeError):
289
+ """No try of the model's final answer fitted the response schema."""
290
+
291
+
292
+ # ---------------------------------------------------------------------------
293
+ # The schema: where it is, and whether this checker can check it
294
+ # ---------------------------------------------------------------------------
295
+
296
+
297
+ def schema_path() -> Path | None:
298
+ """`RESPONSE_SCHEMA_PATH` when set (it must exist); else the agent package's file, if any.
299
+
300
+ `RESPONSE_SCHEMA_PATH=none` means no schema, whatever file the package has.
301
+ """
302
+ raw = (os.environ.get("RESPONSE_SCHEMA_PATH") or "").strip()
303
+ if raw.lower() == SCHEMA_OFF:
304
+ return None
305
+ if raw:
306
+ path = Path(raw)
307
+ if not path.is_file():
308
+ raise SettingsError(f"RESPONSE_SCHEMA_PATH={raw!r} is not a file.")
309
+ return path
310
+ path = Path(__file__).resolve().parent.parent / SCHEMA_FILENAME
311
+ return path if path.is_file() else None
312
+
313
+
314
+ _CACHE: dict[tuple[str, int], dict[str, Any]] = {}
315
+
316
+
317
+ def response_schema() -> dict[str, Any] | None:
318
+ """The project's response schema, checked; None when it declares none (the mode is off).
319
+
320
+ `SettingsError` for a file that is not JSON or not a schema this checker
321
+ can check (startup names it with every other bad setting).
322
+ """
323
+ path = schema_path()
324
+ if path is None:
325
+ return None
326
+ key = (str(path.resolve()), path.stat().st_mtime_ns)
327
+ cached = _CACHE.get(key)
328
+ if cached is not None:
329
+ return cached
330
+ try:
331
+ schema = json.loads(path.read_text(encoding="utf-8"))
332
+ except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
333
+ raise SettingsError(f"{path.name}: not a JSON file ({exc}).") from None
334
+ problems = schema_problems(schema)
335
+ if problems:
336
+ raise SettingsError(f"{path.name} cannot be the response schema: " + "; ".join(problems))
337
+ _CACHE.clear()
338
+ _CACHE[key] = schema
339
+ return schema
340
+
341
+
342
+ def enabled() -> bool:
343
+ """Whether the project declares a response schema (a bad one raises `SettingsError`)."""
344
+ return response_schema() is not None
345
+
346
+
347
+ def response_strategy() -> str:
348
+ """`RESPONSE_FORMAT_STRATEGY`: auto (default), provider or tool."""
349
+ raw = (os.environ.get("RESPONSE_FORMAT_STRATEGY") or "").strip().lower()
350
+ if not raw:
351
+ return STRATEGY_AUTO
352
+ if raw not in STRATEGIES:
353
+ raise SettingsError(
354
+ f"RESPONSE_FORMAT_STRATEGY={raw!r} must be one of {', '.join(STRATEGIES)}."
355
+ )
356
+ return raw
357
+
358
+
359
+ def structured_settings() -> tuple[dict[str, Any] | None, str]:
360
+ """The schema and the strategy, validated (startup check)."""
361
+ return response_schema(), response_strategy()
362
+
363
+
364
+ # ---------------------------------------------------------------------------
365
+ # The strategy the agent is built with
366
+ # ---------------------------------------------------------------------------
367
+
368
+
369
+ def _model_schema(schema: dict[str, Any]) -> dict[str, Any]:
370
+ """The schema as the model sees it: named `ANSWER_TOOL`, with a description."""
371
+ return {
372
+ **schema,
373
+ "title": ANSWER_TOOL,
374
+ "description": str(schema.get("description") or DEFAULT_DESCRIPTION),
375
+ }
376
+
377
+
378
+ def provider_supported(model: Any, tools: list[Any]) -> bool:
379
+ """Whether LangChain would give `model` (with `tools` bound) its provider's structured output.
380
+
381
+ LangChain's own rule (`create_agent`'s `AutoStrategy`): the model's profile
382
+ says it has structured output, except Gemini before 3 with tools.
383
+ """
384
+ try:
385
+ from langchain.agents.factory import _supports_provider_strategy
386
+ except ImportError: # moved in a later LangChain: read the model profile
387
+ profile = getattr(model, "profile", None) or {}
388
+ return bool(profile.get("structured_output"))
389
+ return bool(_supports_provider_strategy(model, tools=tools))
390
+
391
+
392
+ def provider_refusal(model: Any, schema: dict[str, Any]) -> str | None:
393
+ """Why `model`'s client would refuse `schema` as its provider's structured output; None if not.
394
+
395
+ The client converts the schema before any request is sent, and a schema it
396
+ cannot convert fails every run there. Anthropic: langchain-anthropic
397
+ converts it with the Anthropic SDK's `transform_schema`, which refuses a
398
+ type list (`"type": ["string", "null"]`) and a schema with no `type`,
399
+ `anyOf`, `oneOf` or `allOf` (an `enum` alone, a `true`). Other providers'
400
+ clients take any schema this checker accepts (their provider may still
401
+ refuse one: the run then fails with the provider's error).
402
+ """
403
+ if not type(model).__module__.startswith("langchain_anthropic"):
404
+ return None
405
+ try:
406
+ from anthropic import transform_schema
407
+ except ImportError: # an SDK without it: nothing to ask
408
+ return None
409
+ try:
410
+ transform_schema(copy.deepcopy(schema))
411
+ except Exception as exc:
412
+ return f"{type(exc).__name__}: {exc}"
413
+ return None
414
+
415
+
416
+ def response_format(
417
+ model: Any, tools: list[Any]
418
+ ) -> ToolStrategy[Any] | ProviderStrategy[Any] | None:
419
+ """The `response_format` to build the agent with; None when there is no response schema.
420
+
421
+ `auto` decides here, from the model and tools the agent is built with, so
422
+ that the provider strategy is strict (LangChain's own `AutoStrategy` asks
423
+ OpenAI for a best-effort `json_schema`, which the model need not follow),
424
+ and only for a schema the model's client can send (`provider_refusal`):
425
+ else the tool. `provider` with such a schema stops startup, naming why.
426
+ """
427
+ schema, strategy = structured_settings()
428
+ if schema is None:
429
+ return None
430
+ names = {getattr(t, "name", None) for t in tools}
431
+ if ANSWER_TOOL in names:
432
+ raise SettingsError(
433
+ f"A tool is named {ANSWER_TOOL!r}, the name of the structured answer: rename the tool."
434
+ )
435
+ answer_schema = _model_schema(schema)
436
+ if strategy == STRATEGY_AUTO:
437
+ strategy = STRATEGY_TOOL
438
+ if provider_supported(model, tools):
439
+ refusal = provider_refusal(model, answer_schema)
440
+ if refusal is None:
441
+ strategy = STRATEGY_PROVIDER
442
+ else:
443
+ logger.info(
444
+ "structured answers: the model's client cannot send %s as its provider's "
445
+ "structured output (%s)",
446
+ SCHEMA_FILENAME,
447
+ refusal,
448
+ )
449
+ elif strategy == STRATEGY_PROVIDER:
450
+ refusal = provider_refusal(model, answer_schema)
451
+ if refusal is not None:
452
+ raise SettingsError(
453
+ f"RESPONSE_FORMAT_STRATEGY=provider: the model's client cannot send "
454
+ f"{SCHEMA_FILENAME} as its provider's structured output ({refusal}). Give "
455
+ 'every schema in it a "type" (or "anyOf"), write a value that may be null '
456
+ 'as "anyOf" with {"type": "null"} rather than a type list, or set '
457
+ "RESPONSE_FORMAT_STRATEGY=tool (or auto)."
458
+ )
459
+ logger.info("structured answers: the %s strategy", strategy)
460
+ if strategy == STRATEGY_PROVIDER:
461
+ return ProviderStrategy(answer_schema, strict=True)
462
+ return ToolStrategy(answer_schema, tool_message_content=ANSWER_RECORDED)
463
+
464
+
465
+ # ---------------------------------------------------------------------------
466
+ # The check: a JSON Schema subset validator
467
+ # ---------------------------------------------------------------------------
468
+
469
+
470
+ def _type_matches(value: Any, name: str) -> bool:
471
+ if name == "object":
472
+ return isinstance(value, dict)
473
+ if name == "array":
474
+ return isinstance(value, list)
475
+ if name == "string":
476
+ return isinstance(value, str)
477
+ if name == "boolean":
478
+ return isinstance(value, bool)
479
+ if name == "null":
480
+ return value is None
481
+ if name == "number":
482
+ return _is_number(value)
483
+ # integer: 1.0 is one too (JSON Schema counts a number with no fraction).
484
+ return _is_number(value) and float(value).is_integer()
485
+
486
+
487
+ def _equal(a: Any, b: Any) -> bool:
488
+ """JSON equality: `true` is not `1`, and `1` equals `1.0`."""
489
+ if isinstance(a, bool) or isinstance(b, bool):
490
+ return isinstance(a, bool) and isinstance(b, bool) and a == b
491
+ if _is_number(a) and _is_number(b):
492
+ return a == b
493
+ if isinstance(a, dict) and isinstance(b, dict):
494
+ return a.keys() == b.keys() and all(_equal(a[k], b[k]) for k in a)
495
+ if isinstance(a, list) and isinstance(b, list):
496
+ return len(a) == len(b) and all(_equal(x, y) for x, y in zip(a, b, strict=True))
497
+ return type(a) is type(b) and a == b
498
+
499
+
500
+ def validate(schema: Any, value: Any, *, root: Any = None, path: str = "$") -> list[str]:
501
+ """Where `value` does not fit `schema` (one checked by `schema_problems`); empty when it fits."""
502
+ root = schema if root is None else root
503
+ if schema is True:
504
+ return []
505
+ if schema is False:
506
+ return [f"{path}: no value is allowed here"]
507
+ errors: list[str] = []
508
+ if "$ref" in schema:
509
+ errors += validate(_pointer(root, schema["$ref"]), value, root=root, path=path)
510
+ if "type" in schema:
511
+ types = schema["type"] if isinstance(schema["type"], list) else [schema["type"]]
512
+ if not any(_type_matches(value, t) for t in types):
513
+ got = "null" if value is None else type(value).__name__
514
+ return [*errors, f"{path}: must be {' or '.join(types)}, not {got}"]
515
+ if "enum" in schema and not any(_equal(value, option) for option in schema["enum"]):
516
+ errors.append(f"{path}: must be one of {json.dumps(schema['enum'], ensure_ascii=False)}")
517
+ if "const" in schema and not _equal(value, schema["const"]):
518
+ errors.append(f"{path}: must be {json.dumps(schema['const'], ensure_ascii=False)}")
519
+ if isinstance(value, dict):
520
+ properties = schema.get("properties") or {}
521
+ for name in schema.get("required") or []:
522
+ if name not in value:
523
+ errors.append(f"{path}: missing the required property {name!r}")
524
+ for name, item in value.items():
525
+ if name in properties:
526
+ errors += validate(properties[name], item, root=root, path=f"{path}.{name}")
527
+ elif "additionalProperties" in schema:
528
+ extra = schema["additionalProperties"]
529
+ if extra is False:
530
+ errors.append(f"{path}: the property {name!r} is not allowed")
531
+ else:
532
+ errors += validate(extra, item, root=root, path=f"{path}.{name}")
533
+ if len(value) < schema.get("minProperties", 0):
534
+ errors.append(f"{path}: needs at least {schema['minProperties']} properties")
535
+ if "maxProperties" in schema and len(value) > schema["maxProperties"]:
536
+ errors.append(f"{path}: may have at most {schema['maxProperties']} properties")
537
+ if isinstance(value, list):
538
+ if len(value) < schema.get("minItems", 0):
539
+ errors.append(f"{path}: needs at least {schema['minItems']} items")
540
+ if "maxItems" in schema and len(value) > schema["maxItems"]:
541
+ errors.append(f"{path}: may have at most {schema['maxItems']} items")
542
+ if schema.get("uniqueItems") and any(
543
+ _equal(value[i], value[j]) for i in range(len(value)) for j in range(i)
544
+ ):
545
+ errors.append(f"{path}: the items must be unique")
546
+ if "items" in schema:
547
+ for i, item in enumerate(value):
548
+ errors += validate(schema["items"], item, root=root, path=f"{path}[{i}]")
549
+ if isinstance(value, str):
550
+ if len(value) < schema.get("minLength", 0):
551
+ errors.append(f"{path}: must be at least {schema['minLength']} characters")
552
+ if "maxLength" in schema and len(value) > schema["maxLength"]:
553
+ errors.append(f"{path}: must be at most {schema['maxLength']} characters")
554
+ if "pattern" in schema and re.search(schema["pattern"], value) is None:
555
+ errors.append(f"{path}: must match the pattern {schema['pattern']!r}")
556
+ if _is_number(value) and not isinstance(value, bool):
557
+ if "minimum" in schema and value < schema["minimum"]:
558
+ errors.append(f"{path}: must be >= {schema['minimum']}")
559
+ if "maximum" in schema and value > schema["maximum"]:
560
+ errors.append(f"{path}: must be <= {schema['maximum']}")
561
+ if "exclusiveMinimum" in schema and value <= schema["exclusiveMinimum"]:
562
+ errors.append(f"{path}: must be > {schema['exclusiveMinimum']}")
563
+ if "exclusiveMaximum" in schema and value >= schema["exclusiveMaximum"]:
564
+ errors.append(f"{path}: must be < {schema['exclusiveMaximum']}")
565
+ if "multipleOf" in schema:
566
+ ratio = value / schema["multipleOf"]
567
+ if not math.isclose(ratio, round(ratio), rel_tol=0, abs_tol=1e-9):
568
+ errors.append(f"{path}: must be a multiple of {schema['multipleOf']}")
569
+ for sub in schema.get("allOf") or []:
570
+ errors += validate(sub, value, root=root, path=path)
571
+ if "anyOf" in schema and all(
572
+ validate(sub, value, root=root, path=path) for sub in schema["anyOf"]
573
+ ):
574
+ errors.append(f"{path}: fits none of the anyOf choices")
575
+ if "oneOf" in schema:
576
+ fits = sum(1 for sub in schema["oneOf"] if not validate(sub, value, root=root, path=path))
577
+ if fits != 1:
578
+ errors.append(f"{path}: fits {fits} of the oneOf choices, not exactly one")
579
+ if "not" in schema and not validate(schema["not"], value, root=root, path=path):
580
+ errors.append(f"{path}: must not fit the `not` schema")
581
+ return errors
582
+
583
+
584
+ # ---------------------------------------------------------------------------
585
+ # The middleware: check each final answer, and ask again when it does not fit
586
+ # ---------------------------------------------------------------------------
587
+
588
+
589
+ def _last_ai(messages: list[Any]) -> AIMessage | None:
590
+ for message in reversed(messages):
591
+ if isinstance(message, AIMessage):
592
+ return message
593
+ return None
594
+
595
+
596
+ def _answer_call(message: AIMessage) -> dict[str, Any] | None:
597
+ for call in message.tool_calls:
598
+ if call.get("name") == ANSWER_TOOL:
599
+ return call
600
+ return None
601
+
602
+
603
+ def _correction(bad: AIMessage, problem: str) -> list[Any]:
604
+ """The try and what the model reads about it: a tool error, or a note from the user's side."""
605
+ call = _answer_call(bad)
606
+ if call is not None:
607
+ if problem == ANSWER_NOT_ALONE:
608
+ note = f"Error: {problem}."
609
+ else:
610
+ note = (
611
+ f"Error: your answer does not fit the required schema: {problem}. "
612
+ f"Call {ANSWER_TOOL} again with a corrected answer."
613
+ )
614
+ # Every call of the try gets a result, or the provider refuses the request.
615
+ return [
616
+ bad,
617
+ *(
618
+ ToolMessage(
619
+ note if other is call else OTHER_CALL_NOT_RUN,
620
+ tool_call_id=other["id"],
621
+ name=other["name"],
622
+ status="error",
623
+ )
624
+ for other in bad.tool_calls
625
+ ),
626
+ ]
627
+ note = (
628
+ f"Your final answer does not fit the required JSON schema: {problem}. "
629
+ "Reply again with only the corrected JSON answer."
630
+ )
631
+ return [bad, HumanMessage(note)]
632
+
633
+
634
+ def _add_usage(total: dict[str, Any] | None, usage: Any) -> dict[str, Any] | None:
635
+ if not isinstance(usage, dict):
636
+ return total
637
+ summed = dict(total or {})
638
+ for key in ("input_tokens", "output_tokens", "total_tokens"):
639
+ summed[key] = int(summed.get(key) or 0) + int(usage.get(key) or 0)
640
+ return summed
641
+
642
+
643
+ class StructuredAnswer(AgentMiddleware):
644
+ """Check the model's final answer against the response schema; ask again when it does not fit.
645
+
646
+ A no-op without a response schema. Put it last in the middleware list: it
647
+ wraps the model call innermost, so the correction it adds is not fenced as
648
+ a tool result, and it sees the model's reply before any other middleware.
649
+ """
650
+
651
+ def __init__(self, attempts: int = MAX_ANSWER_ATTEMPTS) -> None:
652
+ super().__init__()
653
+ self.attempts = attempts
654
+
655
+ def _problem(
656
+ self, response: Any, schema: dict[str, Any]
657
+ ) -> tuple[str | None, AIMessage | None]:
658
+ """What is wrong with this reply as a final answer (None: nothing), and the reply.
659
+
660
+ An answer must come alone: under the tool strategy LangChain takes an
661
+ answer given beside other tool calls and still runs those calls, after
662
+ the answer was written (a gated one pauses the run with the answer
663
+ already given, and the resumed run then ends with none), so such a try
664
+ is sent back whether or not the answer fits.
665
+ """
666
+ bad = _last_ai(response.result)
667
+ answer = response.structured_response
668
+ if answer is not None:
669
+ if bad is not None and any(c.get("name") != ANSWER_TOOL for c in bad.tool_calls):
670
+ return ANSWER_NOT_ALONE, bad
671
+ errors = validate(schema, answer)
672
+ if not errors:
673
+ return None, bad
674
+ return "; ".join(errors[:MAX_REPORTED_PROBLEMS]), bad
675
+ if bad is None or bad.tool_calls or bad.invalid_tool_calls:
676
+ return None, bad # not a final reply: tools run, or AnswerInvalidToolCalls answers
677
+ return (
678
+ f"the reply is plain text, not an answer: call {ANSWER_TOOL} with your answer",
679
+ bad,
680
+ )
681
+
682
+ def _prepare(self, request: Any) -> dict[str, Any] | None:
683
+ if request.response_format is None:
684
+ return None
685
+ return response_schema()
686
+
687
+ def _settle(self, response: Any, tries: list[AIMessage]) -> Any:
688
+ """The reply that fits, with the failed tries' token usage added to its own."""
689
+ if not tries:
690
+ return response
691
+ answer = _last_ai(response.result)
692
+ usage = answer.usage_metadata if answer is not None else None
693
+ for bad in tries:
694
+ usage = _add_usage(usage, bad.usage_metadata)
695
+ logger.info("structured answer: it fitted the schema after %d correction(s)", len(tries))
696
+ result = [
697
+ m.model_copy(update={"usage_metadata": usage}) if m is answer and usage else m
698
+ for m in response.result
699
+ ]
700
+ return ModelResponse(result=result, structured_response=response.structured_response)
701
+
702
+ def _next(self, request: Any, extra: list[Any], problem: str, attempt: int) -> Any:
703
+ if attempt + 1 >= self.attempts:
704
+ raise StructuredAnswerError(
705
+ f"The answer did not fit the response schema after {self.attempts} tries: {problem}"
706
+ )
707
+ logger.info("structured answer: try %d did not fit; asking again", attempt + 1)
708
+ return request.override(messages=[*request.messages, *extra])
709
+
710
+ def wrap_model_call(self, request: Any, handler: Any) -> Any:
711
+ schema = self._prepare(request)
712
+ if schema is None:
713
+ return handler(request)
714
+ tries: list[AIMessage] = []
715
+ extra: list[Any] = []
716
+ current = request
717
+ for attempt in range(self.attempts):
718
+ try:
719
+ response = handler(current)
720
+ except StructuredOutputValidationError as exc:
721
+ problem, bad = f"the reply is not valid JSON ({exc.source})", exc.ai_message
722
+ else:
723
+ problem, bad = self._problem(response, schema)
724
+ if problem is None:
725
+ return self._settle(response, tries)
726
+ tries.append(bad)
727
+ extra += _correction(bad, problem)
728
+ current = self._next(request, extra, problem, attempt)
729
+ raise AssertionError("unreachable")
730
+
731
+ async def awrap_model_call(self, request: Any, handler: Any) -> Any:
732
+ schema = self._prepare(request)
733
+ if schema is None:
734
+ return await handler(request)
735
+ tries: list[AIMessage] = []
736
+ extra: list[Any] = []
737
+ current = request
738
+ for attempt in range(self.attempts):
739
+ try:
740
+ response = await handler(current)
741
+ except StructuredOutputValidationError as exc:
742
+ problem, bad = f"the reply is not valid JSON ({exc.source})", exc.ai_message
743
+ else:
744
+ problem, bad = self._problem(response, schema)
745
+ if problem is None:
746
+ return self._settle(response, tries)
747
+ tries.append(bad)
748
+ extra += _correction(bad, problem)
749
+ current = self._next(request, extra, problem, attempt)
750
+ raise AssertionError("unreachable")
751
+
752
+
753
+ def answer_text(answer: Any) -> str:
754
+ """The answer's JSON text, as the reply text of a structured run."""
755
+ return json.dumps(answer, ensure_ascii=False)