failproofai 1.0.1 → 1.0.2-beta.0

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 (389) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +6 -6
  4. package/.next/standalone/.next/required-server-files.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
  6. package/.next/standalone/.next/server/app/_global-error/page.js +2 -2
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
  13. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page.js +2 -2
  16. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  18. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  19. package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
  20. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
  21. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
  22. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  23. package/.next/standalone/.next/server/app/api/audit/invite/route.js +3 -2
  24. package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/audit/run/route.js +5 -4
  26. package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/auth/login-request/route.js +2 -2
  29. package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +4 -3
  31. package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/auth/logout/route.js +4 -4
  33. package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/auth/status/route.js +4 -4
  35. package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
  38. package/.next/standalone/.next/server/app/audit/page.js +2 -2
  39. package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
  41. package/.next/standalone/.next/server/app/index.html +1 -1
  42. package/.next/standalone/.next/server/app/index.rsc +15 -15
  43. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
  44. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
  45. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  46. package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
  47. package/.next/standalone/.next/server/app/page.js +2 -2
  48. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  49. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  50. package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +66 -22
  51. package/.next/standalone/.next/server/app/policies/page.js +5 -4
  52. package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
  54. package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
  55. package/.next/standalone/.next/server/app/project/[name]/page.js +2 -2
  56. package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
  58. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
  59. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
  60. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +2 -2
  61. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
  62. package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
  63. package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
  64. package/.next/standalone/.next/server/app/projects/page.js +2 -2
  65. package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
  68. package/.next/standalone/.next/server/app/settings/page.js +1 -1
  69. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  70. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  71. package/.next/standalone/.next/server/chunks/{[externals]__1qzqcio._.js → [externals]__0e32-7c._.js} +1 -1
  72. package/.next/standalone/.next/server/chunks/[externals]__1s462yl._.js +3 -0
  73. package/.next/standalone/.next/server/chunks/[root-of-the-server]__01eqny5._.js +3 -0
  74. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_0xu5z._.js +1 -1
  75. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__1p8poxd._.js → [root-of-the-server]__0igxt32._.js} +2 -2
  76. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0neqlu-._.js +3 -0
  77. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +3 -0
  78. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0t9oqnn._.js +3 -0
  79. package/.next/standalone/.next/server/chunks/_0bs-v8p._.js +3 -0
  80. package/.next/standalone/.next/server/chunks/_0tovk6q._.js +3 -0
  81. package/.next/standalone/.next/server/chunks/_0trp3yc._.js +3 -0
  82. package/.next/standalone/.next/server/chunks/_185gyk9._.js +3 -0
  83. package/.next/standalone/.next/server/chunks/_1ek68ln._.js +5 -6
  84. package/.next/standalone/.next/server/chunks/lib_telemetry-id_ts_1k8gj9o._.js +3 -0
  85. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_09d5c79._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_04op_ou._.js} +1 -1
  86. package/.next/standalone/.next/server/chunks/{node_modules_posthog-node_dist_entrypoints_index_node_mjs_1d2g73o._.js → node_modules_posthog-node_dist_entrypoints_index_node_mjs_0dr20uz._.js} +1 -1
  87. package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
  88. package/.next/standalone/.next/server/chunks/src_hooks_0xbw3bu._.js +12 -0
  89. package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
  90. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +4 -0
  91. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +4 -0
  92. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +4 -0
  93. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0spkm68._.js → [root-of-the-server]__07gm3zl._.js} +21 -20
  94. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +4 -0
  95. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +4 -0
  96. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
  97. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +4 -0
  98. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +6 -0
  99. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__07tbbf3._.js → [root-of-the-server]__0zig5sv._.js} +2 -2
  100. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__192nhvh._.js +5 -0
  101. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +4 -0
  102. package/.next/standalone/.next/server/chunks/ssr/{_0lvyuv4._.js → _00e9tnf._.js} +2 -2
  103. package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
  104. package/.next/standalone/.next/server/chunks/ssr/{_0u5zauv._.js → _12l4a_k._.js} +1 -1
  105. package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
  106. package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
  107. package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +13 -13
  108. package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
  110. package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/src_hooks_08o7bqo._.js +10 -0
  112. package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
  113. package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
  114. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  115. package/.next/standalone/.next/server/middleware.js +1 -1
  116. package/.next/standalone/.next/server/pages/404.html +1 -1
  117. package/.next/standalone/.next/server/pages/500.html +1 -1
  118. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  119. package/.next/standalone/.next/server/server-reference-manifest.json +71 -27
  120. package/.next/standalone/.next/static/chunks/{37fcpc36x9gpe.js → 0eqky-7n7dgdm.js} +1 -1
  121. package/.next/standalone/.next/static/chunks/{1ide1wl2f7zi1.js → 0sa9v5mhc8861.js} +13 -13
  122. package/.next/standalone/.next/static/chunks/1mfzsja6ftim5.css +2 -0
  123. package/.next/standalone/.next/static/chunks/{3k7mxbp9hiom0.js → 1nuqytbta44af.js} +1 -1
  124. package/.next/standalone/.next/static/chunks/1sl2xmn9inx3y.js +6 -0
  125. package/.next/standalone/.next/static/chunks/{2_2lhuojq2xop.js → 2aawsjld9ied_.js} +1 -1
  126. package/.next/standalone/.next/static/chunks/2gn7ut4svofm2.js +1 -0
  127. package/.next/standalone/.next/static/chunks/{31s1smp-g7gks.js → 2hs7ycvsav7v2.js} +1 -1
  128. package/.next/standalone/.next/static/chunks/{0k5050ezjvv49.js → 2i2yt3hzjredm.js} +1 -1
  129. package/.next/standalone/.next/static/chunks/{3c808qrfcq0im.js → 35xg6retpe6rp.js} +1 -1
  130. package/.next/standalone/.next/static/chunks/{2zpp1we2jv6tb.js → 3y54nw0i7dsjl.js} +1 -1
  131. package/.next/standalone/SECURITY.md +5 -4
  132. package/.next/standalone/app/actions/get-hooks-config.ts +108 -17
  133. package/.next/standalone/app/actions/pack-actions.ts +153 -0
  134. package/.next/standalone/app/audit/_components/run-progress.tsx +1 -1
  135. package/.next/standalone/app/audit/_components/share-templates.ts +13 -13
  136. package/.next/standalone/app/policies/hooks-client.tsx +320 -14
  137. package/.next/standalone/components/navbar.tsx +2 -28
  138. package/.next/standalone/components/reach-developers.tsx +8 -1
  139. package/.next/standalone/docs-old/agenteye/python-sdk-skill.mdx +1 -1
  140. package/.next/standalone/docs-old/agenteye/python-sdk.mdx +59 -35
  141. package/.next/standalone/docs-old/i18n/README.ar.md +1 -0
  142. package/.next/standalone/docs-old/i18n/README.de.md +1 -0
  143. package/.next/standalone/docs-old/i18n/README.es.md +1 -0
  144. package/.next/standalone/docs-old/i18n/README.fr.md +1 -0
  145. package/.next/standalone/docs-old/i18n/README.he.md +1 -0
  146. package/.next/standalone/docs-old/i18n/README.hi.md +1 -0
  147. package/.next/standalone/docs-old/i18n/README.it.md +1 -0
  148. package/.next/standalone/docs-old/i18n/README.ja.md +1 -0
  149. package/.next/standalone/docs-old/i18n/README.ko.md +1 -0
  150. package/.next/standalone/docs-old/i18n/README.pt-br.md +1 -0
  151. package/.next/standalone/docs-old/i18n/README.ru.md +1 -0
  152. package/.next/standalone/docs-old/i18n/README.tr.md +1 -0
  153. package/.next/standalone/docs-old/i18n/README.vi.md +1 -0
  154. package/.next/standalone/docs-old/i18n/README.zh.md +1 -0
  155. package/.next/standalone/fp-cloud-cli/CHANGELOG.md +431 -0
  156. package/.next/standalone/fp-cloud-cli/LICENSE +42 -0
  157. package/.next/standalone/fp-cloud-cli/MANIFEST.in +10 -0
  158. package/.next/standalone/fp-cloud-cli/README.md +259 -0
  159. package/.next/standalone/fp-cloud-cli/fp_cli/__init__.py +10 -0
  160. package/.next/standalone/fp-cloud-cli/fp_cli/__main__.py +4 -0
  161. package/.next/standalone/fp-cloud-cli/fp_cli/_click_compat.py +64 -0
  162. package/.next/standalone/fp-cloud-cli/fp_cli/_context.py +332 -0
  163. package/.next/standalone/fp-cloud-cli/fp_cli/_version.py +1 -0
  164. package/.next/standalone/fp-cloud-cli/fp_cli/analytics.py +432 -0
  165. package/.next/standalone/fp-cloud-cli/fp_cli/analytics_config.py +77 -0
  166. package/.next/standalone/fp-cloud-cli/fp_cli/analytics_registry.py +83 -0
  167. package/.next/standalone/fp-cloud-cli/fp_cli/app.py +492 -0
  168. package/.next/standalone/fp-cloud-cli/fp_cli/auth.py +160 -0
  169. package/.next/standalone/fp-cloud-cli/fp_cli/client.py +1694 -0
  170. package/.next/standalone/fp-cloud-cli/fp_cli/commands/__init__.py +0 -0
  171. package/.next/standalone/fp-cloud-cli/fp_cli/commands/_write.py +214 -0
  172. package/.next/standalone/fp-cloud-cli/fp_cli/commands/agent_cmds.py +407 -0
  173. package/.next/standalone/fp-cloud-cli/fp_cli/commands/alerts_cmds.py +445 -0
  174. package/.next/standalone/fp-cloud-cli/fp_cli/commands/audits_cmds.py +1054 -0
  175. package/.next/standalone/fp-cloud-cli/fp_cli/commands/auth_cmds.py +512 -0
  176. package/.next/standalone/fp-cloud-cli/fp_cli/commands/errors_cmds.py +190 -0
  177. package/.next/standalone/fp-cloud-cli/fp_cli/commands/evals_cmds.py +161 -0
  178. package/.next/standalone/fp-cloud-cli/fp_cli/commands/events_cmds.py +159 -0
  179. package/.next/standalone/fp-cloud-cli/fp_cli/commands/fleet_cmds.py +416 -0
  180. package/.next/standalone/fp-cloud-cli/fp_cli/commands/guardrails_cmds.py +148 -0
  181. package/.next/standalone/fp-cloud-cli/fp_cli/commands/incidents_cmds.py +472 -0
  182. package/.next/standalone/fp-cloud-cli/fp_cli/commands/keys_cmds.py +407 -0
  183. package/.next/standalone/fp-cloud-cli/fp_cli/commands/list_cmds.py +63 -0
  184. package/.next/standalone/fp-cloud-cli/fp_cli/commands/orgs_cmds.py +319 -0
  185. package/.next/standalone/fp-cloud-cli/fp_cli/commands/policies_cmds.py +499 -0
  186. package/.next/standalone/fp-cloud-cli/fp_cli/commands/queries_cmds.py +378 -0
  187. package/.next/standalone/fp-cloud-cli/fp_cli/commands/sessions_cmds.py +151 -0
  188. package/.next/standalone/fp-cloud-cli/fp_cli/commands/settings_cmds.py +150 -0
  189. package/.next/standalone/fp-cloud-cli/fp_cli/commands/usage_cmds.py +35 -0
  190. package/.next/standalone/fp-cloud-cli/fp_cli/commands/users_cmds.py +404 -0
  191. package/.next/standalone/fp-cloud-cli/fp_cli/config.py +330 -0
  192. package/.next/standalone/fp-cloud-cli/fp_cli/dates.py +78 -0
  193. package/.next/standalone/fp-cloud-cli/fp_cli/enforcement.py +345 -0
  194. package/.next/standalone/fp-cloud-cli/fp_cli/errors.py +98 -0
  195. package/.next/standalone/fp-cloud-cli/fp_cli/models.py +891 -0
  196. package/.next/standalone/fp-cloud-cli/fp_cli/orgs.py +30 -0
  197. package/.next/standalone/fp-cloud-cli/fp_cli/output.py +6593 -0
  198. package/.next/standalone/fp-cloud-cli/fp_cli/permissions.py +208 -0
  199. package/.next/standalone/fp-cloud-cli/fp_cli/policy_check.py +290 -0
  200. package/.next/standalone/fp-cloud-cli/fp_cli/py.typed +0 -0
  201. package/.next/standalone/fp-cloud-cli/fp_cli/select.py +322 -0
  202. package/.next/standalone/fp-cloud-cli/fp_cli/theme.py +53 -0
  203. package/.next/standalone/fp-cloud-cli/pyproject.toml +106 -0
  204. package/.next/standalone/fp-cloud-cli/skill/SKILL.md +302 -0
  205. package/.next/standalone/fp-cloud-cli/skill/agents/openai.yaml +8 -0
  206. package/.next/standalone/fp-cloud-cli/skill/references/commands.md +254 -0
  207. package/.next/standalone/fp-cloud-cli/tests/__init__.py +0 -0
  208. package/.next/standalone/fp-cloud-cli/tests/conftest.py +92 -0
  209. package/.next/standalone/fp-cloud-cli/tests/test_alerting.py +644 -0
  210. package/.next/standalone/fp-cloud-cli/tests/test_analytics.py +385 -0
  211. package/.next/standalone/fp-cloud-cli/tests/test_audits.py +950 -0
  212. package/.next/standalone/fp-cloud-cli/tests/test_auth.py +98 -0
  213. package/.next/standalone/fp-cloud-cli/tests/test_auth_mode.py +272 -0
  214. package/.next/standalone/fp-cloud-cli/tests/test_click_compat.py +118 -0
  215. package/.next/standalone/fp-cloud-cli/tests/test_client.py +299 -0
  216. package/.next/standalone/fp-cloud-cli/tests/test_commands.py +814 -0
  217. package/.next/standalone/fp-cloud-cli/tests/test_config.py +99 -0
  218. package/.next/standalone/fp-cloud-cli/tests/test_dashboards_agent.py +340 -0
  219. package/.next/standalone/fp-cloud-cli/tests/test_dates.py +76 -0
  220. package/.next/standalone/fp-cloud-cli/tests/test_enforcement_logic.py +481 -0
  221. package/.next/standalone/fp-cloud-cli/tests/test_facets.py +170 -0
  222. package/.next/standalone/fp-cloud-cli/tests/test_failproofai_home.py +722 -0
  223. package/.next/standalone/fp-cloud-cli/tests/test_fp_home_contract.py +198 -0
  224. package/.next/standalone/fp-cloud-cli/tests/test_hardening.py +186 -0
  225. package/.next/standalone/fp-cloud-cli/tests/test_help_table_coverage.py +127 -0
  226. package/.next/standalone/fp-cloud-cli/tests/test_keys_queries.py +496 -0
  227. package/.next/standalone/fp-cloud-cli/tests/test_list.py +59 -0
  228. package/.next/standalone/fp-cloud-cli/tests/test_multivalue.py +183 -0
  229. package/.next/standalone/fp-cloud-cli/tests/test_no_customer_identifiers.py +168 -0
  230. package/.next/standalone/fp-cloud-cli/tests/test_operator.py +492 -0
  231. package/.next/standalone/fp-cloud-cli/tests/test_orgs.py +692 -0
  232. package/.next/standalone/fp-cloud-cli/tests/test_output.py +1732 -0
  233. package/.next/standalone/fp-cloud-cli/tests/test_policy_check.py +191 -0
  234. package/.next/standalone/fp-cloud-cli/tests/test_readme_matches_reality.py +125 -0
  235. package/.next/standalone/fp-cloud-cli/tests/test_review_fixes.py +161 -0
  236. package/.next/standalone/fp-cloud-cli/tests/test_telemetry_completeness.py +90 -0
  237. package/.next/standalone/fp-cloud-cli/tests/test_usage.py +115 -0
  238. package/.next/standalone/fp-cloud-cli/tests/test_v1_origin_diagnostic.py +70 -0
  239. package/.next/standalone/fp-cloud-cli/tests/test_v1_routing.py +214 -0
  240. package/.next/standalone/fp-cloud-cli/tests/test_whoami.py +104 -0
  241. package/.next/standalone/fp-cloud-cli/uv.lock +552 -0
  242. package/.next/standalone/package.json +29 -22
  243. package/.next/standalone/sdk/python/CHANGELOG.md +70 -0
  244. package/.next/standalone/sdk/python/LICENSE +42 -0
  245. package/.next/standalone/sdk/python/MANIFEST.in +21 -0
  246. package/.next/standalone/sdk/python/README.md +466 -0
  247. package/.next/standalone/sdk/python/docs/README.md +249 -0
  248. package/.next/standalone/sdk/python/docs/_shared/README.md +43 -0
  249. package/.next/standalone/sdk/python/docs/_shared/__init__.py +195 -0
  250. package/.next/standalone/sdk/python/docs/crewai/README.md +328 -0
  251. package/.next/standalone/sdk/python/docs/crewai/examples/quickstart.py +61 -0
  252. package/.next/standalone/sdk/python/docs/crewai/examples/research_crew.py +92 -0
  253. package/.next/standalone/sdk/python/docs/langgraph/README.md +481 -0
  254. package/.next/standalone/sdk/python/docs/langgraph/examples/quickstart.py +51 -0
  255. package/.next/standalone/sdk/python/docs/langgraph/examples/research_agent.py +86 -0
  256. package/.next/standalone/sdk/python/docs/langgraph/examples/supervisor_handoff.py +83 -0
  257. package/.next/standalone/sdk/python/docs/llama_index/README.md +332 -0
  258. package/.next/standalone/sdk/python/docs/llama_index/examples/quickstart.py +62 -0
  259. package/.next/standalone/sdk/python/docs/llama_index/examples/research_agent.py +93 -0
  260. package/.next/standalone/sdk/python/docs/manual/README.md +491 -0
  261. package/.next/standalone/sdk/python/docs/manual/examples/quickstart.py +57 -0
  262. package/.next/standalone/sdk/python/docs/manual/examples/research_agent.py +133 -0
  263. package/.next/standalone/sdk/python/docs/pydantic_ai/README.md +323 -0
  264. package/.next/standalone/sdk/python/docs/pydantic_ai/examples/quickstart.py +48 -0
  265. package/.next/standalone/sdk/python/docs/pydantic_ai/examples/research_agent.py +94 -0
  266. package/.next/standalone/sdk/python/failproofai_sdk/__init__.py +131 -0
  267. package/.next/standalone/sdk/python/failproofai_sdk/_context.py +218 -0
  268. package/.next/standalone/sdk/python/failproofai_sdk/_environment.py +78 -0
  269. package/.next/standalone/sdk/python/failproofai_sdk/_events.py +752 -0
  270. package/.next/standalone/sdk/python/failproofai_sdk/_resolver.py +118 -0
  271. package/.next/standalone/sdk/python/failproofai_sdk/_runtime.py +21 -0
  272. package/.next/standalone/sdk/python/failproofai_sdk/_schema.py +317 -0
  273. package/.next/standalone/sdk/python/failproofai_sdk/_scopes.py +422 -0
  274. package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -0
  275. package/.next/standalone/sdk/python/failproofai_sdk/_writer.py +750 -0
  276. package/.next/standalone/sdk/python/failproofai_sdk/integrations/__init__.py +274 -0
  277. package/.next/standalone/sdk/python/failproofai_sdk/integrations/_compat.py +247 -0
  278. package/.next/standalone/sdk/python/failproofai_sdk/integrations/_core.py +1076 -0
  279. package/.next/standalone/sdk/python/failproofai_sdk/integrations/crewai.py +1669 -0
  280. package/.next/standalone/sdk/python/failproofai_sdk/integrations/langchain.py +2151 -0
  281. package/.next/standalone/sdk/python/failproofai_sdk/integrations/llama_index.py +1588 -0
  282. package/.next/standalone/sdk/python/failproofai_sdk/integrations/pydantic_ai.py +977 -0
  283. package/.next/standalone/sdk/python/failproofai_sdk/py.typed +0 -0
  284. package/.next/standalone/sdk/python/pyproject.toml +92 -0
  285. package/.next/standalone/sdk/python/skill/SKILL.md +399 -0
  286. package/.next/standalone/sdk/python/skill/agents/openai.yaml +8 -0
  287. package/.next/standalone/sdk/python/skill/references/events.md +234 -0
  288. package/.next/standalone/sdk/python/skill/references/frameworks.md +314 -0
  289. package/.next/standalone/sdk/python/skill/references/install.md +100 -0
  290. package/.next/standalone/sdk/python/skill/references/integration.md +352 -0
  291. package/.next/standalone/sdk/python/tests/__init__.py +0 -0
  292. package/.next/standalone/sdk/python/tests/conftest.py +169 -0
  293. package/.next/standalone/sdk/python/tests/integrations/__init__.py +6 -0
  294. package/.next/standalone/sdk/python/tests/integrations/test_crewai.py +1299 -0
  295. package/.next/standalone/sdk/python/tests/integrations/test_langchain.py +2338 -0
  296. package/.next/standalone/sdk/python/tests/integrations/test_llama_index.py +1576 -0
  297. package/.next/standalone/sdk/python/tests/integrations/test_pydantic_ai.py +1102 -0
  298. package/.next/standalone/sdk/python/tests/test_context.py +410 -0
  299. package/.next/standalone/sdk/python/tests/test_docs.py +411 -0
  300. package/.next/standalone/sdk/python/tests/test_durability.py +1352 -0
  301. package/.next/standalone/sdk/python/tests/test_encoding.py +571 -0
  302. package/.next/standalone/sdk/python/tests/test_integrations.py +1127 -0
  303. package/.next/standalone/sdk/python/tests/test_no_customer_identifiers.py +214 -0
  304. package/.next/standalone/sdk/python/tests/test_packaging.py +185 -0
  305. package/.next/standalone/sdk/python/tests/test_resolver_umbrella.py +178 -0
  306. package/.next/standalone/sdk/python/tests/test_scopes.py +487 -0
  307. package/.next/standalone/sdk/python/tests/test_sdk.py +923 -0
  308. package/.next/standalone/sdk/python/tests/test_server_contract.py +650 -0
  309. package/.next/standalone/sdk/python/tests/test_site_docs.py +371 -0
  310. package/.next/standalone/sdk/python/tests/test_skill_snippets.py +165 -0
  311. package/.next/standalone/sdk/python/tests/test_spool_contract.py +287 -0
  312. package/.next/standalone/sdk/python/tests/test_spool_creation.py +360 -0
  313. package/.next/standalone/sdk/python/tests/test_wire_format.py +279 -0
  314. package/.next/standalone/sdk/python/tests/test_zero_dependencies.py +378 -0
  315. package/.next/standalone/sdk/python/uv.lock +6005 -0
  316. package/.next/standalone/server.js +1 -1
  317. package/README.md +77 -21
  318. package/bin/failproofai.mjs +145 -11
  319. package/dist/cli.mjs +4482 -2205
  320. package/dist/index.js +5 -5
  321. package/dist/worker.mjs +2399 -1978
  322. package/package.json +29 -22
  323. package/policy-pack/SHA256SUMS +2 -0
  324. package/policy-pack/failproofai-pack.json +732 -0
  325. package/policy-pack/failproofai-pack.mjs +2017 -0
  326. package/scripts/build-policy-pack.mjs +121 -0
  327. package/scripts/changelog-section.py +77 -0
  328. package/scripts/prune-standalone.mjs +3 -0
  329. package/scripts/python-version.py +232 -0
  330. package/scripts/translate-docs/cli.ts +21 -0
  331. package/scripts/translate-docs/mintlify-nav.ts +85 -13
  332. package/scripts/translate-docs/translator.ts +66 -11
  333. package/src/audit/cache.ts +40 -3
  334. package/src/audit/cli.ts +1 -1
  335. package/src/audit/index.ts +1 -1
  336. package/src/audit/replay.ts +89 -3
  337. package/src/audit/report.ts +5 -5
  338. package/src/audit/schedule-cli.ts +49 -53
  339. package/src/hooks/builtin-policies.ts +217 -579
  340. package/src/hooks/cloud-enrollment-cli.ts +66 -27
  341. package/src/hooks/cloud-managed-policies.ts +6 -1
  342. package/src/hooks/configure-wizard.ts +1 -1
  343. package/src/hooks/custom-hooks-loader.ts +89 -6
  344. package/src/hooks/fp-home.ts +92 -4
  345. package/src/hooks/fp-reset.ts +26 -2
  346. package/src/hooks/handler.ts +181 -11
  347. package/src/hooks/harness-cli.ts +79 -25
  348. package/src/hooks/hook-activity-store.ts +13 -3
  349. package/src/hooks/install-prompt.ts +25 -3
  350. package/src/hooks/manager.ts +418 -172
  351. package/src/hooks/migrations.ts +2 -2
  352. package/src/hooks/pack-cli.ts +505 -0
  353. package/src/hooks/pack-failclosed.ts +189 -0
  354. package/src/hooks/pack-manifest.ts +344 -0
  355. package/src/hooks/pack-store.ts +930 -0
  356. package/src/hooks/policy-catalog.ts +535 -0
  357. package/src/hooks/policy-evaluator.ts +24 -16
  358. package/src/hooks/policy-presets.ts +4 -2
  359. package/src/hooks/policy-registry.ts +8 -2
  360. package/src/hooks/policy-types.ts +26 -0
  361. package/src/hooks/session-pause-cli.ts +33 -9
  362. package/src/hooks/tui.ts +570 -0
  363. package/src/hooks/types.ts +15 -1
  364. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_usx0j._.js +0 -3
  365. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_zk340._.js +0 -3
  366. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0agdw2a._.js +0 -3
  367. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0d1u_na._.js +0 -3
  368. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0oy4kxb._.js +0 -3
  369. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zf034w._.js +0 -3
  370. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tvn4wf._.js +0 -3
  371. package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +0 -3
  372. package/.next/standalone/.next/server/chunks/_1ger0a2._.js +0 -3
  373. package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +0 -3
  374. package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_1iiwm5e._.js +0 -3
  375. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kc8_v5._.js +0 -4
  376. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kgy9da._.js +0 -4
  377. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mhqk5a._.js +0 -4
  378. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pltpn8._.js +0 -4
  379. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qkcyf-._.js +0 -4
  380. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0w0x_ok._.js +0 -4
  381. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1_dfocn._.js +0 -4
  382. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1bfex62._.js +0 -5
  383. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1kzapg5._.js +0 -6
  384. package/.next/standalone/.next/static/chunks/36fw6yk1s6x_f.js +0 -1
  385. package/.next/standalone/.next/static/chunks/3tzq0260n17ae.js +0 -6
  386. package/.next/standalone/.next/static/chunks/3wq9x0c-7ld5d.css +0 -2
  387. /package/.next/standalone/.next/static/{ZklXxyA5ygsWSQz2u_zii → nwbyWT06Ud0XdXOrVJyvS}/_buildManifest.js +0 -0
  388. /package/.next/standalone/.next/static/{ZklXxyA5ygsWSQz2u_zii → nwbyWT06Ud0XdXOrVJyvS}/_clientMiddlewareManifest.js +0 -0
  389. /package/.next/standalone/.next/static/{ZklXxyA5ygsWSQz2u_zii → nwbyWT06Ud0XdXOrVJyvS}/_ssgManifest.js +0 -0
@@ -0,0 +1,2151 @@
1
+ """LangChain + LangGraph adapter.
2
+
3
+ Written against **langchain-core 1.5.2** and **langgraph 1.2.10** (2026-07-29),
4
+ and every claim below was read out of those installed packages rather than
5
+ recalled. Where this file disagrees with the LangChain docs or with another
6
+ vendor's integration, the disagreement is deliberate and the reason is in the
7
+ comment next to it.
8
+
9
+ How it attaches
10
+ ---------------
11
+ `langchain_core.tracers.context.register_configure_hook` is public, documented,
12
+ and survived the 0.x -> 1.x rewrite. `CallbackManager.configure` injects the
13
+ handler into **every** callback manager it builds, so no call site changes::
14
+
15
+ failproofai_sdk.instrument("langchain")
16
+ graph.invoke(...) # already recorded
17
+
18
+ Four consequences of that hook shape drive the code:
19
+
20
+ * with the ``env_var`` form a **fresh handler is constructed per callback
21
+ manager** — many times per run — so ``FailproofAITracer.__init__`` is zero-arg
22
+ and cheap and **all** cross-callback state lives in the module-level
23
+ ``_STATE``;
24
+ * ``inheritable=True`` is required or child runs never see it;
25
+ * there is **no deregister API** (``_configure_hooks`` is append-only and
26
+ private), so ``uninstall()`` clears the ContextVar and unsets the env var;
27
+ * we do **not** patch ``BaseCallbackManager.__init__``. OpenInference and
28
+ Traceloop do, and ``BaseCallbackManager.merge()`` builds a new manager with
29
+ handlers already passed in, so their ``isinstance`` dedup misses and the
30
+ handler is added twice. That is a real duplicate-event bug; MLflow patches
31
+ ``merge`` as well to work around it. The configure hook has no such hole.
32
+
33
+ Why `BaseTracer`
34
+ ----------------
35
+ `BaseTracer` assembles the run tree and hands over `Run` objects with inputs,
36
+ outputs, metadata and timings already collected — two override points instead of
37
+ twenty hand-correlated callbacks. `langchain_core.tracers.schemas.Run` *is*
38
+ `langsmith.RunTree`. We also subclass `langgraph.callbacks.GraphCallbackHandler`
39
+ (under try/except: it is new in langgraph 1.2) for first-class interrupt/resume.
40
+
41
+ `run_inline = True` is not optional. `AsyncCallbackManager` dispatches sync
42
+ handlers through `run_in_executor` unless a handler sets it, and that hop can
43
+ **reorder callbacks** — which scrambles timestamp order and breaks every pairing
44
+ in this file. `writer.submit()` is a `deque.append`, so inline on the event loop
45
+ is safe. `raise_error` is normally False: LangChain already firewalls handler
46
+ exceptions in `handle_event`, and so does `_core.safe`. It follows
47
+ `FAILPROOFAI_SDK_STRICT`, because that same firewall otherwise swallows the exception
48
+ `safe()` re-raises under strict and the escape hatch does nothing here.
49
+
50
+ The mapping
51
+ -----------
52
+ =========================== ==========================================
53
+ LangChain / LangGraph Failproof AI
54
+ =========================== ==========================================
55
+ root run (no parent) ``agent_start`` / ``agent_end``
56
+ LangGraph node ``hook_triggered`` / ``hook_completed``
57
+ compiled subgraph nested ``agent_start`` (``root/node``)
58
+ tool run ``tool_use`` / ``tool_result``
59
+ retriever run ``tool_use`` / ``tool_result`` (summarised)
60
+ chat model / LLM run ``model_request`` / ``model_response``
61
+ ``interrupt()`` ``human_wait`` + ``agent_pause``
62
+ ``Command(resume=...)`` ``agent_resume`` + ``human_input``
63
+ intermediate chains *nothing* (see ``include_chains``)
64
+ =========================== ==========================================
65
+
66
+ **A LangGraph node is a hook, not a nested agent.** `agent_id` is a
67
+ `LowCardinality(String)` column and the primary facet on every dashboard
68
+ surface, and `agent_sessions.agent_id = any(...)` returns the first `agent_id`
69
+ by time — so promoting `retrieve`, `grade_documents` and `should_continue` to
70
+ agents would both drown the facet and label the session with a random node.
71
+ Hook spans render structurally identically, and `/hooks` becomes a per-node
72
+ latency page for free.
73
+
74
+ Corrections to received wisdom, both verified here
75
+ --------------------------------------------------
76
+ 1. **`thread_id` IS available to callbacks** on this stack. langchain-core's
77
+ `ensure_config` stopped promoting `configurable` into `metadata`, which is
78
+ what every "thread_id is None" report is about — but langgraph 1.2 re-adds it
79
+ in `langgraph._internal._config` via ``_PROPAGATE_TO_METADATA`` =
80
+ {thread_id, checkpoint_id, checkpoint_ns, task_id, run_id, assistant_id,
81
+ graph_id}. So `metadata["thread_id"]` is populated and is a good default
82
+ session key. It is still only the *fourth* resolution step, because a
83
+ `thread_id` is a conversation, not necessarily a run.
84
+ 2. **`GraphCallbackHandler.on_interrupt` does NOT fire for a handler installed
85
+ through `register_configure_hook`.** `Pregel.stream` builds the lifecycle
86
+ manager with `get_sync_graph_callback_manager_for_config(config)`, which
87
+ reads the **raw** ``config["callbacks"]`` — the configure hooks never touch
88
+ it — and then gates the whole feature on
89
+ ``has_graph_lifecycle_callbacks=bool(manager.handlers)``. So we wrap that
90
+ factory (see `_install_graph_callbacks`) to attach the handler to the manager
91
+ it returns. If the wrap does not apply, the exception-path fallback below
92
+ still produces the full HITL pair; only the resume event needs the wrap, and
93
+ that has its own fallback too.
94
+
95
+ Control flow is not failure
96
+ ---------------------------
97
+ LangGraph's runnable does ``except BaseException as e: run_manager
98
+ .on_chain_error(e); raise`` with no special case for interrupts, so **every**
99
+ HITL pause arrives as an error callback. Reporting it would paint a red error
100
+ plus ``agent_end(outcome="failed")`` on every human approval. Any
101
+ `langgraph.errors.GraphBubbleUp` subclass — `GraphInterrupt`, `NodeInterrupt`,
102
+ `ParentCommand`, `GraphDrained` — is therefore treated as control flow.
103
+ """
104
+
105
+ import asyncio
106
+ import contextvars
107
+ import dataclasses
108
+ import logging
109
+ import os
110
+ import threading
111
+ from datetime import datetime, timezone
112
+ from typing import Any, Iterable
113
+
114
+ from failproofai_sdk import _context
115
+ from failproofai_sdk.integrations import _compat, _core
116
+ from failproofai_sdk.integrations._core import (
117
+ Patcher,
118
+ RunTracker,
119
+ framework_fields,
120
+ fw_fields,
121
+ ms,
122
+ normalize_agent_id,
123
+ safe,
124
+ truncate,
125
+ )
126
+
127
+ logger = logging.getLogger("failproofai_sdk.integrations")
128
+
129
+ NAME = "langchain"
130
+ MODULE = "langchain_core"
131
+ DIST = "langchain-core"
132
+ EXTRA = "langchain"
133
+ ENV_VAR = "FAILPROOFAI_SDK_TRACE_LANGCHAIN"
134
+
135
+ # The documented escape hatch for session stitching:
136
+ # graph.invoke(x, config={"metadata": {"failproofai_sdk_session_id": sid}})
137
+ SESSION_METADATA_KEY = "failproofai_sdk_session_id"
138
+
139
+ # Checked in order after the explicit key above. `thread_id` is last because a
140
+ # thread is a *conversation*; two turns on one thread are two runs, and a user
141
+ # who wants them merged has said so with one of the earlier keys.
142
+ SESSION_METADATA_FALLBACKS = ("session_id", "conversation_id", "thread_id")
143
+
144
+ # LangSmith's convention for "machinery, not user-visible work". We demote these
145
+ # rather than dropping them: they never become a span, but they stay in the
146
+ # parent chain so their children still find the agent above them.
147
+ HIDDEN_TAG = "langsmith:hidden"
148
+
149
+ #: Default characters kept per captured value in this adapter.
150
+ #:
151
+ #: This was 2048, a deliberate tightening below the core's 8192 on the grounds
152
+ #: that "inputs/outputs are graph state". That reasoning holds for a graph-state
153
+ #: blob and not for the two things people actually come here to read — the
154
+ #: prompt and the completion. A real RAG prompt is well over 2 KiB, so the
155
+ #: tightening cut the payload on essentially every production run, and the
156
+ #: adapter documented as the way to see what your agent said showed the first
157
+ #: paragraph of it.
158
+ #:
159
+ #: It is the core default now, and `capture_limit` moves it per install. The
160
+ #: per-event budget follows it automatically (`_core._FIELDS_PER_EVENT`), so
161
+ #: raising this cannot silently convert shortened fields into missing ones.
162
+ _FIELD_LIMIT = _core.FIELD_LIMIT
163
+
164
+
165
+ def _field_limit() -> int:
166
+ """The active per-value limit — `capture_limit`, else `_FIELD_LIMIT`.
167
+
168
+ Read through a function rather than captured at import, because
169
+ `instrument()` may be called after this module is imported and a module
170
+ constant bound into a default argument would ignore the option entirely.
171
+ That is not hypothetical: `_core.FIELD_LIMIT` is exported in `__all__` and
172
+ reassigning it does nothing, for exactly this reason.
173
+ """
174
+ return _STATE.options.capture_limit
175
+
176
+
177
+ # ---------------------------------------------------------------------------
178
+ # Framework imports
179
+ # ---------------------------------------------------------------------------
180
+ # This module is only ever imported by `instrument("langchain")`, so importing
181
+ # the framework at module scope is fine — `import failproofai_sdk` never gets here.
182
+ # `require_module` turns a missing install into an ImportError carrying the
183
+ # literal install command.
184
+ _compat.require_module(MODULE, dist=DIST, extra=EXTRA)
185
+
186
+ from langchain_core.tracers.base import BaseTracer # noqa: E402
187
+ from langchain_core.tracers.context import register_configure_hook # noqa: E402
188
+
189
+ try: # langgraph >= 1.2 only
190
+ from langgraph.callbacks import GraphCallbackHandler as _GraphCallbackHandler
191
+ except ImportError: # pragma: no cover - exercised on langgraph < 1.2 / absent
192
+ _GraphCallbackHandler = None
193
+
194
+ try:
195
+ from langgraph.errors import GraphBubbleUp as _GraphBubbleUp
196
+ except ImportError: # pragma: no cover
197
+ _GraphBubbleUp = None
198
+
199
+ try: # `Command` tells a resume from a fresh turn; `Interrupt` derives a pause id
200
+ from langgraph.types import Command as _Command
201
+ from langgraph.types import Interrupt as _Interrupt
202
+ except ImportError: # pragma: no cover
203
+ _Command = None
204
+ _Interrupt = None
205
+
206
+ # Name-based fallback for the case where `langgraph.errors` moved. Getting this
207
+ # wrong is expensive and silent (a red error on every human approval), so it is
208
+ # worth a belt-and-braces check rather than a bare `isinstance`.
209
+ _CONTROL_FLOW_NAMES = frozenset(
210
+ {"GraphBubbleUp", "GraphInterrupt", "NodeInterrupt", "ParentCommand", "GraphDrained"}
211
+ )
212
+
213
+ #: Python's OWN control flow, which this module's "Control flow is not failure"
214
+ #: section was written about and then did not cover. `GeneratorExit` is thrown
215
+ #: into a generator when a consumer stops iterating — `break` out of
216
+ #: `for chunk in graph.stream(...)`, or a `StreamingResponse` whose client
217
+ #: disconnects — and `CancelledError` is what `task.cancel()` delivers to
218
+ #: `astream`. Both are BaseExceptions, so neither was `_is_control_flow`, and a
219
+ #: stopped stream emitted a standalone `error` event with `error_type:
220
+ #: "GeneratorExit"` plus `agent_end(outcome="failed")`. Every abandoned stream
221
+ #: inflated `sessionSummary.errorCount` and flipped a healthy session to failed.
222
+ #: The adapter already has a "cancelled" outcome for exactly this; it just never
223
+ #: reached it here.
224
+ _CANCELLATION_TYPES: tuple[type[BaseException], ...] = (GeneratorExit, asyncio.CancelledError)
225
+
226
+
227
+ def _is_cancellation(exc: BaseException | None) -> bool:
228
+ return exc is not None and isinstance(exc, _CANCELLATION_TYPES)
229
+
230
+
231
+ def _is_control_flow(exc: BaseException | None) -> bool:
232
+ if exc is None:
233
+ return False
234
+ if _GraphBubbleUp is not None and isinstance(exc, _GraphBubbleUp):
235
+ return True
236
+ return any(cls.__name__ in _CONTROL_FLOW_NAMES for cls in type(exc).__mro__)
237
+
238
+
239
+ if _GraphCallbackHandler is not None:
240
+ _GraphBase: Any = _GraphCallbackHandler
241
+ else: # pragma: no cover - only on langgraph < 1.2
242
+
243
+ class _GraphBase: # type: ignore[no-redef]
244
+ """Stand-in so the two lifecycle overrides always have a home.
245
+
246
+ Deliberately **not** registered anywhere: langgraph's dispatch is an
247
+ `isinstance(h, GraphCallbackHandler)` filter, so on an older langgraph
248
+ these methods are simply never called, which is the correct behaviour.
249
+ """
250
+
251
+ def on_interrupt(self, event: Any) -> Any: ...
252
+
253
+ def on_resume(self, event: Any) -> Any: ...
254
+
255
+
256
+ def _now() -> datetime:
257
+ return datetime.now(timezone.utc)
258
+
259
+
260
+ # ---------------------------------------------------------------------------
261
+ # State
262
+ # ---------------------------------------------------------------------------
263
+
264
+ @dataclasses.dataclass
265
+ class _Options:
266
+ """Everything `instrument("langchain", ...)` accepts."""
267
+
268
+ session_id: str | None = None
269
+ include_chains: frozenset = frozenset()
270
+ capture_content: bool = True
271
+ graph_callbacks: bool = True
272
+ capture_limit: int = _FIELD_LIMIT
273
+
274
+
275
+ @dataclasses.dataclass
276
+ class _Session:
277
+ """One Failproof AI session, which may outlive a single `.invoke()`.
278
+
279
+ It has to: a human-in-the-loop graph runs `invoke()`, interrupts, and is
280
+ resumed by a *second* `invoke()` minutes later. Both are the same session
281
+ and the same root agent, and the agent stays open across the gap so that
282
+ `agent_pause` -> `agent_resume` measures the wait (which is the only thing
283
+ that feeds the dashboard's `pausedMs`).
284
+ """
285
+
286
+ session_id: str
287
+ agent_key: str
288
+ agent_id: str
289
+ open_pauses: dict = dataclasses.field(default_factory=dict)
290
+ reported_error: bool = False
291
+
292
+
293
+ @dataclasses.dataclass
294
+ class _RemoteResume:
295
+ """Bookkeeping for a resume whose pause was opened in ANOTHER process.
296
+
297
+ Set on the ROOT `_RunInfo` only, and only when this process has no open
298
+ pause of its own to close — i.e. exactly the deployment shape where the
299
+ interrupt was served by one worker and the approval by another. See
300
+ `_close_remote_pause` for how the pause id is recovered.
301
+ """
302
+
303
+ value: Any = None
304
+ # checkpoint-ns tuple -> the `langgraph_step` of the first node seen at that
305
+ # level. Only that first superstep re-runs interrupted tasks; everything
306
+ # after it is ordinary downstream work.
307
+ levels: dict = dataclasses.field(default_factory=dict)
308
+ # The deepest level langgraph has told us is resuming. A subgraph host node
309
+ # sits at a shallower level than the task that actually interrupted.
310
+ deepest: tuple | None = None
311
+ done: set = dataclasses.field(default_factory=set)
312
+
313
+
314
+ @dataclasses.dataclass
315
+ class _RunInfo:
316
+ """What we need about a LangChain run after its start callback returns."""
317
+
318
+ id: str
319
+ parent: str | None
320
+ name: str
321
+ run_type: str
322
+ started: datetime = dataclasses.field(default_factory=_now)
323
+ hidden: bool = False
324
+ kind: str = "" # "" | "root" | "node" | "tool" | "retriever" | "model" | "chain"
325
+ # Set only on a ROOT run that is itself a leaf — a bare `llm.invoke()`, a
326
+ # standalone tool. Such a run is both the session's agent and a model/tool
327
+ # call, and it needs both pairs. See `_start_root`.
328
+ leaf_kind: str = ""
329
+ root: str | None = None
330
+ session: _Session | None = None
331
+ node: str | None = None
332
+ tool_call_id: str | None = None
333
+ model: str | None = None
334
+ messages: list | None = None
335
+ ttft_ms: int | None = None
336
+ chunks: int = 0
337
+ remote: _RemoteResume | None = None # root only
338
+
339
+
340
+ class _State:
341
+ """All cross-callback state, module level on purpose.
342
+
343
+ The `env_var` form of `register_configure_hook` constructs a **new**
344
+ `FailproofAITracer` per callback manager, so anything kept on `self` would be
345
+ lost between a start and its end. Nothing here touches contextvars either:
346
+ `ContextVar.reset(token)` raises across asyncio tasks as well as threads, so
347
+ a surface whose start and end are separate calls can never hold a token.
348
+ """
349
+
350
+ MAX_RUNS = 10_000
351
+ MAX_SESSIONS = 1_000
352
+
353
+ def __init__(self) -> None:
354
+ # RLock because a handler body can re-enter through a nested helper.
355
+ self.lock = threading.RLock()
356
+ # The kill switch `uninstall()` needs and neither of its two other
357
+ # levers actually provides. A configure hook cannot be deregistered, so
358
+ # removal is "make the hook produce nothing" — but clearing the
359
+ # ContextVar only reaches contexts derived from the one calling
360
+ # `uninstall()`, and unsetting the env var is skipped whenever the
361
+ # process already had it set (`self._set_env` is False then, so we do
362
+ # not clobber somebody else's environment). Either hole leaves
363
+ # `_configure` constructing a live zero-arg tracer per callback manager
364
+ # — VERIFIED: with FAILPROOFAI_SDK_TRACE_LANGCHAIN=1 exported before
365
+ # `instrument()`, every event was still recorded after `uninstrument()`.
366
+ # Checked at the two entry points that gate everything else: no
367
+ # `_RunInfo` is registered, so `_on_end`, `_count_token`,
368
+ # `_on_interrupt` and `_on_resume` all fall out on their own lookups.
369
+ self.enabled = False
370
+ self.options = _Options()
371
+ self.tracker = RunTracker(NAME, base_fields=_base_fields())
372
+ self.runs: dict[str, _RunInfo] = {}
373
+ self.sessions: dict[str, _Session] = {}
374
+ # run_id -> the LLMResult / exception stashed by a public callback for
375
+ # the `_end_trace` that follows it.
376
+ self.responses: dict[str, Any] = {}
377
+ self.errors: dict[str, BaseException] = {}
378
+
379
+ def reset(self) -> None:
380
+ with self.lock:
381
+ self.tracker.reset()
382
+ self.runs.clear()
383
+ self.sessions.clear()
384
+ self.responses.clear()
385
+ self.errors.clear()
386
+
387
+ def evict(self) -> None:
388
+ """Caller holds the lock. FIFO; dicts keep insertion order.
389
+
390
+ Orphaned entries are normal, not exceptional: a cancelled stream, a
391
+ crashed node, a framework that skipped an end callback. Unbounded, each
392
+ of these dicts is a memory leak in a long-lived server.
393
+ """
394
+ for table, cap in (
395
+ (self.runs, self.MAX_RUNS),
396
+ (self.sessions, self.MAX_SESSIONS),
397
+ (self.responses, self.MAX_RUNS),
398
+ (self.errors, self.MAX_RUNS),
399
+ ):
400
+ while len(table) >= cap:
401
+ table.pop(next(iter(table)), None)
402
+
403
+
404
+ def _base_fields() -> dict:
405
+ fields = framework_fields(NAME, DIST)
406
+ version = _compat.version_string("langgraph")
407
+ if version:
408
+ fields["fw_langgraph_version"] = version
409
+ return fields
410
+
411
+
412
+ _STATE = _State()
413
+
414
+
415
+ # ---------------------------------------------------------------------------
416
+ # Small readers over the Run object
417
+ # ---------------------------------------------------------------------------
418
+
419
+ def _meta(run: Any) -> dict:
420
+ meta = getattr(run, "metadata", None)
421
+ return dict(meta) if isinstance(meta, dict) else {}
422
+
423
+
424
+ def _extra(run: Any) -> dict:
425
+ extra = getattr(run, "extra", None)
426
+ return extra if isinstance(extra, dict) else {}
427
+
428
+
429
+ def _tags(run: Any) -> list:
430
+ tags = getattr(run, "tags", None)
431
+ return list(tags) if tags else []
432
+
433
+
434
+ def _shrink(value: Any) -> Any:
435
+ """Payload discipline for the big three: inputs, outputs, graph state."""
436
+ if not _STATE.options.capture_content:
437
+ return None
438
+ return truncate(value, _field_limit())
439
+
440
+
441
+ # A run of one of these types is a leaf — a model call, a tool call, a
442
+ # retrieval. It is never the LangGraph node's *own* run. Verified against
443
+ # langgraph 1.2.11: whatever you hand `add_node` (a function, a Runnable, a
444
+ # `BaseTool`, a compiled subgraph), the node's own run is always a **chain**
445
+ # run tagged `graph:step:N`, and the thing you passed runs as a child of it.
446
+ _LEAF_RUN_TYPES = frozenset({"llm", "chat_model", "tool", "retriever"})
447
+
448
+ # LangChain tags every step of a `RunnableSequence` `seq:step:N`. LangGraph tags
449
+ # a node's own run `graph:step:N`. Only the second is a node.
450
+ _INNER_STEP_TAG = "seq:step:"
451
+
452
+
453
+ def _node_of(run: Any, meta: dict) -> str | None:
454
+ """The LangGraph node name **iff this run is the node's own run**.
455
+
456
+ Every inner Runnable inherits `langgraph_node` from the node that contains
457
+ it, so the metadata alone matches the node, the chat model inside it, the
458
+ tool it called and each conditional-edge function. `run.name` is exactly the
459
+ `kwargs["name"] or serialized["name"]` the callback was given, and only the
460
+ node's own run has it equal to `langgraph_node`. Verified against
461
+ langgraph 1.2.10: an inner `cond`/`fan` edge function reports
462
+ `name="cond"` with `langgraph_node="act"` and is correctly excluded.
463
+
464
+ The name alone is not enough, because **the name is the user's to choose on
465
+ both sides**. Two collisions were verified on langgraph 1.2.11, and each one
466
+ silently deleted the most valuable event in the trace:
467
+
468
+ * ``add_node("lookup_population", ToolNode([lookup_population]))`` — naming a
469
+ node after the tool it runs, which is the obvious thing to do — made the
470
+ *tool's* run match too. It was recorded as a second node visit, so
471
+ `tool_use`/`tool_result` were never emitted: the arguments, the result and
472
+ the LLM's `tool_call_id` all vanished, and `/tools` showed the call had
473
+ never happened.
474
+ * ``add_node("ChatOpenAI", ...)`` did the same to the chat model run:
475
+ no `model_request`/`model_response`, so the model name, both token counts
476
+ and the latency were dropped while the trace still looked populated.
477
+ * an inner Runnable carrying `run_name` equal to the node key produced
478
+ **two** `hook_triggered`/`hook_completed` pairs for one node visit, which
479
+ doubles that node's visit count and halves its apparent latency.
480
+
481
+ So the run must also be shaped like a node's own run: a non-leaf run type,
482
+ and not an inner step of a `RunnableSequence`. Both are *exclusions* — if
483
+ langgraph ever stops emitting `seq:step:` tags this degrades to the old
484
+ duplicate span rather than to no spans at all, which is the safe direction
485
+ for a check that gates `hook_triggered` **and** `_ensure_subgraph_agent`.
486
+ """
487
+ node = meta.get("langgraph_node")
488
+ if not node or node != (getattr(run, "name", None) or ""):
489
+ return None
490
+ if str(getattr(run, "run_type", "") or "") in _LEAF_RUN_TYPES:
491
+ return None
492
+ if any(str(tag).startswith(_INNER_STEP_TAG) for tag in _tags(run)):
493
+ return None
494
+ return str(node)
495
+
496
+
497
+ def _ns_parts(meta: dict) -> list:
498
+ """`langgraph_checkpoint_ns` split into its `name:uuid` segments.
499
+
500
+ For a top-level node this is one segment (`plan:uuid`); for a node inside a
501
+ compiled subgraph it is `child:uuid|sub_step:uuid`. The number of segments
502
+ beyond the first is the subgraph nesting depth, and the leading segments
503
+ name the subgraphs — which is how nested agents get their ids without
504
+ having to recognise a compiled `Pregel` from a `Run` object.
505
+ """
506
+ ns = meta.get("langgraph_checkpoint_ns")
507
+ if not ns or not isinstance(ns, str):
508
+ return []
509
+ return ns.split("|")
510
+
511
+
512
+ # ---------------------------------------------------------------------------
513
+ # Session resolution
514
+ # ---------------------------------------------------------------------------
515
+
516
+ def _resolve_session_id(run: Any, meta: dict) -> str:
517
+ """Pick the session id for a root run.
518
+
519
+ In order:
520
+
521
+ 1. ``instrument("langchain", session_id=...)`` — an explicit override wins.
522
+ 2. ``config={"metadata": {"failproofai_sdk_session_id": ...}}`` — the documented
523
+ per-call key.
524
+ 3. the ambient `failproofai_sdk.session()` / `failproofai_sdk.agent()` scope, so a
525
+ hand-written outer bracket and the adapter produce **one** session.
526
+ 4. ``metadata["session_id" | "conversation_id" | "thread_id"]``.
527
+ 5. the root run id.
528
+
529
+ Never synthesised from scratch: a made-up id splits one run into many
530
+ sessions, which is a silent wrong answer rather than a loud one.
531
+ """
532
+ if _STATE.options.session_id:
533
+ return str(_STATE.options.session_id)
534
+ explicit = meta.get(SESSION_METADATA_KEY)
535
+ if explicit:
536
+ return str(explicit)
537
+ ambient = _context.session_id()
538
+ if ambient:
539
+ return ambient
540
+ for key in SESSION_METADATA_FALLBACKS:
541
+ value = meta.get(key)
542
+ if value:
543
+ return str(value)
544
+ return str(getattr(run, "id", "")) or _context.DEFAULT_AGENT_ID
545
+
546
+
547
+ # ---------------------------------------------------------------------------
548
+ # Emission helpers
549
+ # ---------------------------------------------------------------------------
550
+
551
+ def _emit(method: str, info: _RunInfo, **fields: Any) -> None:
552
+ _STATE.tracker.emit(method, info.id, parent_key=info.parent, **fields)
553
+
554
+
555
+ def _emit_on_agent(session: _Session, method: str, **fields: Any) -> None:
556
+ _STATE.tracker.emit(method, session.agent_key, **fields)
557
+
558
+
559
+ def _fw_common(run: Any, info: _RunInfo, meta: dict) -> dict:
560
+ """The `fw_*` extras every event from this adapter carries.
561
+
562
+ Namespaced, and that is a **safety** rule rather than a style one:
563
+ `_schema._build()` merges extra fields last, so an extra called `tool_name`,
564
+ `model` or `outcome` silently overwrites the declared field and therefore
565
+ the promoted column. `_core.guard_extras` is the backstop;
566
+ `fw_fields` is how we stay away from the edge.
567
+ """
568
+ return fw_fields(
569
+ run_id=info.id,
570
+ parent_run_id=info.parent,
571
+ node=info.node or meta.get("langgraph_node"),
572
+ step=meta.get("langgraph_step"),
573
+ checkpoint_ns=meta.get("langgraph_checkpoint_ns"),
574
+ thread_id=meta.get("thread_id"),
575
+ tags=_tags(run) or None,
576
+ hidden=True if info.hidden else None,
577
+ )
578
+
579
+
580
+ # ---------------------------------------------------------------------------
581
+ # Start
582
+ # ---------------------------------------------------------------------------
583
+
584
+ def _on_start(run: Any) -> None:
585
+ state = _STATE
586
+ if not state.enabled:
587
+ return
588
+ rid = str(run.id)
589
+ parent = str(run.parent_run_id) if getattr(run, "parent_run_id", None) else None
590
+ meta = _meta(run)
591
+
592
+ with state.lock:
593
+ state.evict()
594
+ info = _RunInfo(
595
+ id=rid,
596
+ parent=parent,
597
+ name=str(getattr(run, "name", "") or ""),
598
+ run_type=str(getattr(run, "run_type", "") or ""),
599
+ hidden=HIDDEN_TAG in _tags(run),
600
+ )
601
+ state.runs[rid] = info
602
+ # Every run is linked, span or not. This is what lets a tool three
603
+ # Runnables deep still find the agent above it: `RunTracker.identity`
604
+ # walks the link chain, and an intermediate chain that emits nothing
605
+ # would otherwise break the walk.
606
+ state.tracker.link(rid, parent)
607
+
608
+ if parent is None:
609
+ _start_root(run, info, meta)
610
+ return
611
+
612
+ holder = state.runs.get(parent)
613
+ info.root = holder.root if holder is not None else None
614
+ info.session = holder.session if holder is not None else None
615
+
616
+ node = _node_of(run, meta)
617
+ if node is not None:
618
+ info.kind = "node"
619
+ info.node = node
620
+ _start_node(run, info, meta)
621
+ return
622
+
623
+ if info.run_type in ("llm", "chat_model"):
624
+ info.kind = "model"
625
+ _start_model(run, info, meta)
626
+ return
627
+ if info.run_type == "tool":
628
+ info.kind = "tool"
629
+ _start_tool(run, info, meta)
630
+ return
631
+ if info.run_type == "retriever":
632
+ info.kind = "retriever"
633
+ _start_retriever(run, info, meta)
634
+ return
635
+
636
+ # Everything else — RunnableSequence, prompt templates, output parsers,
637
+ # conditional-edge functions, the compiled-subgraph Pregel run itself.
638
+ # Emitting these would bury the timeline under machinery, so they are
639
+ # linked and otherwise invisible unless explicitly allowlisted.
640
+ if info.name and info.name in state.options.include_chains and not info.hidden:
641
+ info.kind = "chain"
642
+ _emit(
643
+ "hook_triggered",
644
+ info,
645
+ hook_name=info.name,
646
+ hook_id=info.id,
647
+ trigger_event="pipeline",
648
+ input=_shrink(getattr(run, "inputs", None)),
649
+ **_fw_common(run, info, meta),
650
+ )
651
+
652
+
653
+ def _start_root(run: Any, info: _RunInfo, meta: dict) -> None:
654
+ """The root run becomes the session's agent — and its **first** event.
655
+
656
+ `agent_sessions.agent_id = any(...)` resolves to the first `agent_id` by
657
+ time over `ORDER BY (session_id, ts, ...)`, so anything emitted before this
658
+ would name the session after a node. The dashboard also parents every leaf
659
+ to the open agent with the same `agent_id` and **synthesises a
660
+ never-ending root span** when there is none, so this must not be skipped.
661
+ """
662
+ state = _STATE
663
+ info.kind = "root"
664
+ info.root = info.id
665
+ session_id = _resolve_session_id(run, meta)
666
+
667
+ existing = state.sessions.get(session_id)
668
+ if (
669
+ existing is not None
670
+ and existing.open_pauses
671
+ and existing.agent_key in state.tracker.open_agents()
672
+ and _is_continuation(run)
673
+ ):
674
+ # A resume: the previous `.invoke()` interrupted, we deliberately did
675
+ # not close its agent, and this is the continuation. Reuse the identity
676
+ # instead of opening a second root span for the same logical run.
677
+ #
678
+ # `open_pauses` is the whole test, and leaving it out was a silent
679
+ # data-loss bug rather than a cosmetic one. "The session's agent is
680
+ # still open" is ALSO true of two roots that merely OVERLAP IN TIME
681
+ # under one session id — `.batch()` (langchain-core opens one root run
682
+ # per input), a top-level `RunnableParallel` of chains, or two web
683
+ # requests carrying the same conversation id. Those were read as
684
+ # resumes: the second root got no `agent_start` at all, its work was
685
+ # relabelled with the first root's `agent_id`, the first root to finish
686
+ # closed the shared agent, and every event the other root emitted after
687
+ # that resolved to nothing and was DROPPED — a real model call, with
688
+ # its tokens and its latency, gone with one "could not resolve a
689
+ # session" line at WARNING. A run that is genuinely paused always has an
690
+ # open pause: `_end_root` returns without `agent_end` exactly when
691
+ # `session.open_pauses` is non-empty, which is the only way the agent
692
+ # stays open past its root, and `_suspend` is the only thing that fills
693
+ # it. So this distinguishes the two cases precisely.
694
+ #
695
+ # `_is_continuation` is the second half of that test and it is not
696
+ # redundant: an open pause bounds how long the window lasts, but it
697
+ # does not close it. A HITL turn can sit paused on a human for
698
+ # **minutes**, and any other run that happens to carry the same session
699
+ # id during that window — a second web request on one conversation id,
700
+ # a background summariser, a different graph entirely — was read as the
701
+ # approval. VERIFIED against langgraph 1.2.11: the second run got no
702
+ # `agent_start`, its nodes were folded into the paused run's span, and
703
+ # the adapter emitted `agent_resume` + `human_input` for a human who
704
+ # had answered nothing — `human_input.response` empty, the pause closed,
705
+ # and the paused run's `agent_end` reporting `success`. On a product
706
+ # whose whole job is to gate an action on human approval, fabricating
707
+ # the approval is the worst wrong answer available. LangGraph only ever
708
+ # continues an interrupted thread through `Command(...)` or a `None`
709
+ # input; a fresh state dict is a NEW turn, not an answer.
710
+ info.session = existing
711
+ state.tracker.link(info.id, existing.agent_key)
712
+ _resume(existing, run)
713
+ return
714
+
715
+ identity = state.tracker.start_agent(
716
+ info.id,
717
+ agent_id=normalize_agent_id(info.name, "agent"),
718
+ session_id=session_id,
719
+ goal=_goal_of(run),
720
+ **_fw_common(run, info, meta),
721
+ )
722
+ session = _Session(
723
+ session_id=identity.session_id or session_id,
724
+ agent_key=info.id,
725
+ agent_id=identity.agent_id or "agent",
726
+ )
727
+ info.session = session
728
+ state.sessions[session.session_id] = session
729
+
730
+ # This is a resume, but nothing in THIS process is paused — so the pause was
731
+ # opened somewhere else. That is not an edge case, it is the deployment
732
+ # shape: one worker serves the request that interrupts, a human answers
733
+ # minutes later, and whichever worker picks up that request resumes against
734
+ # the shared checkpointer. Before this, such a resume emitted no
735
+ # `agent_resume` and no `human_input` at all, so the `human_wait` and
736
+ # `agent_pause` from the first process stayed open FOREVER — every
737
+ # cross-process approval left its session reporting "still waiting on a
738
+ # human" after the human had answered, and `pausedMs` never closed.
739
+ # `_close_remote_pause` recovers the id the other process used.
740
+ answer = _resume_values(run)
741
+ if answer is not None:
742
+ info.remote = _RemoteResume(value=answer)
743
+
744
+ # A root run that is ITSELF a leaf still has to be recorded as one.
745
+ #
746
+ # `ChatOpenAI(...).invoke(...)` outside any graph is a single run with no
747
+ # parent and `run_type="chat_model"`. Handled only as a root it produced an
748
+ # `agent_start`/`agent_end` pair and NOTHING ELSE — no `model_request`, no
749
+ # `model_response`, so the model name, both token counts and the latency of
750
+ # a direct model call were dropped on the floor, silently, while the trace
751
+ # still looked populated. Direct `.invoke()` is not an edge case: a
752
+ # classifier, a summariser, a one-shot rewrite are all shaped like this.
753
+ #
754
+ # The agent span stays (the dashboard parents leaves to an open agent with
755
+ # the same `agent_id` and synthesises a never-ending root span when there is
756
+ # none), so this is purely additive: the same run now emits its leaf pair
757
+ # INSIDE its own agent span.
758
+ starter = _ROOT_LEAF_STARTERS.get(info.run_type)
759
+ if starter is not None:
760
+ info.leaf_kind = _LEAF_KIND_OF[info.run_type]
761
+ starter(run, info, meta)
762
+
763
+
764
+ def _goal_of(run: Any) -> str | None:
765
+ inputs = getattr(run, "inputs", None)
766
+ if not _STATE.options.capture_content or inputs is None:
767
+ return None
768
+ if isinstance(inputs, dict):
769
+ messages = inputs.get("messages")
770
+ if isinstance(messages, (list, tuple)) and messages:
771
+ content = getattr(messages[-1], "content", None)
772
+ if isinstance(content, str) and content:
773
+ return truncate(content, 512)
774
+ return truncate(str(inputs), 512)
775
+
776
+
777
+ def _start_node(run: Any, info: _RunInfo, meta: dict) -> None:
778
+ """A LangGraph node -> `hook_triggered`.
779
+
780
+ Also the point at which a compiled **subgraph** becomes a nested agent: a
781
+ node whose checkpoint namespace is more than one segment deep is running
782
+ inside one, and its parent run *is* the subgraph's Pregel run (verified on
783
+ langgraph 1.2.10). Deriving it here means we never have to recognise a
784
+ `Pregel` from a `Run`, and it nests to arbitrary depth for free.
785
+ """
786
+ parts = _ns_parts(meta)
787
+ if len(parts) > 1 and info.parent is not None:
788
+ _ensure_subgraph_agent(info, parts[:-1])
789
+
790
+ remote = _remote_of(info)
791
+ if remote is not None:
792
+ # First node seen at this level wins: langgraph re-runs the interrupted
793
+ # tasks in the level's first superstep and nothing else (VERIFIED on
794
+ # 1.2.11 — a sibling that had already succeeded in the same superstep
795
+ # does NOT re-run), so anything at a later step is downstream work.
796
+ remote.levels.setdefault(tuple(parts[:-1]), meta.get("langgraph_step"))
797
+
798
+ if info.hidden:
799
+ return
800
+ _emit(
801
+ "hook_triggered",
802
+ info,
803
+ hook_name=info.node,
804
+ hook_id=info.id,
805
+ trigger_event="graph_node",
806
+ input=_shrink(getattr(run, "inputs", None)),
807
+ **_fw_common(run, info, meta),
808
+ )
809
+
810
+
811
+ def _ensure_subgraph_agent(info: _RunInfo, prefix: list) -> None:
812
+ state = _STATE
813
+ key = info.parent
814
+ if key is None or key in state.tracker.open_agents():
815
+ return
816
+ holder = state.runs.get(key)
817
+ session = info.session
818
+ if holder is None or session is None:
819
+ return
820
+ names = [part.split(":", 1)[0] for part in prefix if part]
821
+ agent_id = "/".join([session.agent_id, *names])
822
+ state.tracker.start_agent(
823
+ key,
824
+ agent_id=agent_id,
825
+ parent_key=holder.parent,
826
+ session_id=session.session_id,
827
+ **fw_fields(run_id=key, subgraph=names[-1] if names else None, kind="subgraph"),
828
+ )
829
+ holder.kind = "subgraph"
830
+ holder.session = session
831
+
832
+
833
+ def _start_tool(run: Any, info: _RunInfo, meta: dict) -> None:
834
+ # The LLM-issued id when there is one, so our events line up with the
835
+ # provider's logs and with the `tool_calls` on the assistant message. It
836
+ # arrives in the `on_tool_start` kwargs and `_create_tool_run` parks the
837
+ # whole kwargs dict in `run.extra`.
838
+ info.tool_call_id = str(_extra(run).get("tool_call_id") or info.id)
839
+ if info.hidden:
840
+ return
841
+ _emit(
842
+ "tool_use",
843
+ info,
844
+ tool_name=info.name or "tool",
845
+ tool_call_id=info.tool_call_id,
846
+ input=_shrink(getattr(run, "inputs", None)),
847
+ **_fw_common(run, info, meta),
848
+ )
849
+
850
+
851
+ def _start_retriever(run: Any, info: _RunInfo, meta: dict) -> None:
852
+ info.tool_call_id = info.id
853
+ if info.hidden:
854
+ return
855
+ inputs = getattr(run, "inputs", None)
856
+ query = inputs.get("query") if isinstance(inputs, dict) else inputs
857
+ _emit(
858
+ "tool_use",
859
+ info,
860
+ tool_name="retriever:%s" % (info.name or "retriever"),
861
+ tool_call_id=info.tool_call_id,
862
+ input={"query": truncate(query, _field_limit())} if _STATE.options.capture_content else None,
863
+ **_fw_common(run, info, meta),
864
+ )
865
+
866
+
867
+ def _start_model(run: Any, info: _RunInfo, meta: dict) -> None:
868
+ info.model = _model_name(run, info, meta)
869
+ messages = _STATE.responses.pop("messages:" + info.id, None)
870
+ if messages is None:
871
+ messages = _prompts_as_messages(getattr(run, "inputs", None))
872
+ info.messages = messages
873
+ if info.hidden:
874
+ return
875
+ _emit(
876
+ "model_request",
877
+ info,
878
+ # The correlation id the dashboard's detail panel pairs on. No SDK ever
879
+ # set it before, which is why `executionGraph` falls back to FIFO
880
+ # pairing per agent_id and concurrent calls mis-pair.
881
+ request_id=info.id,
882
+ model=info.model,
883
+ messages=messages if _STATE.options.capture_content else None,
884
+ tools=_tools_of(run),
885
+ **_fw_common(run, info, meta),
886
+ )
887
+
888
+
889
+ def _model_name(run: Any, info: _RunInfo, meta: dict) -> str:
890
+ """`ls_model_name` first, then the invocation params, then the class name.
891
+
892
+ `ls_model_name` is the LangSmith standard key and is what a real provider
893
+ integration sets. It is **absent** on the fake chat models used in tests and
894
+ on some community integrations, so the fallbacks are load-bearing rather
895
+ than defensive padding.
896
+ """
897
+ name = meta.get("ls_model_name")
898
+ if name:
899
+ return str(name)
900
+ params = _extra(run).get("invocation_params")
901
+ if isinstance(params, dict):
902
+ for key in ("model_name", "model", "model_id", "deployment_name"):
903
+ value = params.get(key)
904
+ if value:
905
+ return str(value)
906
+ return info.name or "unknown"
907
+
908
+
909
+ def _tools_of(run: Any) -> list | None:
910
+ params = _extra(run).get("invocation_params")
911
+ if not isinstance(params, dict):
912
+ return None
913
+ tools = params.get("tools")
914
+ if isinstance(tools, (list, tuple)) and tools:
915
+ return truncate(list(tools), _field_limit())
916
+ return None
917
+
918
+
919
+ def _prompts_as_messages(inputs: Any) -> list | None:
920
+ """Text-completion runs arrive as `{"prompts": [...]}`.
921
+
922
+ Chat runs are captured from `on_chat_model_start`, where the real
923
+ `BaseMessage` objects are still available — `_create_chat_model_run`
924
+ flattens them to `"System: ...\\nHuman: ..."` strings before they reach the
925
+ `Run`, which would lose the roles.
926
+ """
927
+ if not isinstance(inputs, dict):
928
+ return None
929
+ prompts = inputs.get("prompts")
930
+ if isinstance(prompts, (list, tuple)):
931
+ return [{"role": "user", "content": truncate(p, _field_limit())} for p in prompts]
932
+ return None
933
+
934
+
935
+ _ROLES = {"human": "user", "ai": "assistant", "system": "system", "tool": "tool"}
936
+
937
+
938
+ def _normalize_messages(batches: Any) -> list | None:
939
+ if not batches:
940
+ return None
941
+ batch = batches[-1] if isinstance(batches[-1], (list, tuple)) else batches
942
+ out = []
943
+ for message in batch:
944
+ kind = str(getattr(message, "type", "") or "")
945
+ entry: dict = {
946
+ "role": _ROLES.get(kind, kind or "user"),
947
+ "content": truncate(getattr(message, "content", ""), _field_limit()),
948
+ }
949
+ calls = getattr(message, "tool_calls", None)
950
+ if calls:
951
+ entry["tool_calls"] = truncate(list(calls), _field_limit())
952
+ out.append(entry)
953
+ return out
954
+
955
+
956
+ # A root run whose own `run_type` is one of these is a leaf as well as the
957
+ # session's agent. Keyed by LangChain's `run_type` string.
958
+ _LEAF_KIND_OF = {
959
+ "llm": "model",
960
+ "chat_model": "model",
961
+ "tool": "tool",
962
+ "retriever": "retriever",
963
+ }
964
+ _ROOT_LEAF_STARTERS = {
965
+ "llm": _start_model,
966
+ "chat_model": _start_model,
967
+ "tool": _start_tool,
968
+ "retriever": _start_retriever,
969
+ }
970
+
971
+
972
+ # ---------------------------------------------------------------------------
973
+ # End
974
+ # ---------------------------------------------------------------------------
975
+
976
+ def _on_end(run: Any) -> None:
977
+ state = _STATE
978
+ rid = str(run.id)
979
+ with state.lock:
980
+ info = state.runs.get(rid)
981
+ exc = state.errors.pop(rid, None)
982
+ response = state.responses.pop(rid, None)
983
+ if info is None:
984
+ return
985
+ meta = _meta(run)
986
+
987
+ if info.kind == "root":
988
+ # Close the leaf pair first when the root was also a leaf: the
989
+ # dashboard closes the agent span at `agent_end`, so a
990
+ # `model_response` emitted after it is attributed to nothing.
991
+ if info.leaf_kind:
992
+ _ROOT_LEAF_ENDERS[info.leaf_kind](run, info, meta, exc, response)
993
+ # ...and the leaf pair we just closed OWNS the failure, exactly
994
+ # as it does for a nested tool or model run (see the matching
995
+ # line at the bottom of this function). Without this, a failing
996
+ # `tool.invoke()` or `llm.invoke()` at the top level reported
997
+ # the same exception twice — once as `tool_result.error` and
998
+ # again as a standalone `error` event — so one failure counted
999
+ # as two on `sessionSummary.errorCount`, while the identical
1000
+ # failure one Runnable deeper counted as one.
1001
+ if info.session is not None and not _is_control_flow(exc) and (
1002
+ exc is not None or getattr(run, "error", None)
1003
+ ):
1004
+ info.session.reported_error = True
1005
+ _end_root(run, info, meta, exc)
1006
+ return
1007
+
1008
+ state.runs.pop(rid, None)
1009
+
1010
+ if info.kind == "subgraph" or rid in state.tracker.open_agents():
1011
+ state.tracker.end_agent(
1012
+ rid,
1013
+ outcome=_outcome(run, exc),
1014
+ summary=_error_text(run, exc),
1015
+ )
1016
+ elif info.hidden:
1017
+ pass
1018
+ # `owned` tracks whether a SPAN was actually emitted for this run, which
1019
+ # is what decides `reported_error` at the bottom.
1020
+ owned = False
1021
+ if info.kind == "node" or info.kind == "chain":
1022
+ _end_hook(run, info, meta, exc)
1023
+ owned = True
1024
+ elif info.kind == "tool":
1025
+ _end_tool(run, info, meta, exc)
1026
+ owned = True
1027
+ elif info.kind == "retriever":
1028
+ _end_retriever(run, info, meta, exc)
1029
+ owned = True
1030
+ elif info.kind == "model":
1031
+ _end_model(run, info, meta, exc, response)
1032
+ owned = True
1033
+
1034
+ if info.kind == "node":
1035
+ # Strictly BEFORE the `_suspend` below: a node that answers one
1036
+ # interrupt and immediately raises the next must close the old pause
1037
+ # before opening the new one, and on langgraph 1.2 both carry the
1038
+ # same id (it is derived from the task's namespace, not the call).
1039
+ _close_remote_pause(info, meta)
1040
+
1041
+ # The exception-path HITL fallback, deliberately outside the span
1042
+ # handling above so that it still fires for a `langsmith:hidden` node
1043
+ # and for a subgraph that bubbled the interrupt up. `_suspend` dedups on
1044
+ # `Interrupt.id`, so this and `on_interrupt` cannot double-emit.
1045
+ interrupts = _interrupts_of(exc)
1046
+ if interrupts and info.session is not None:
1047
+ _suspend(info.session, interrupts)
1048
+ if owned and exc is not None and not _is_control_flow(exc) and info.session is not None:
1049
+ # `owned`, because this used to fire for EVERY non-root run carrying
1050
+ # an exception — including the ones the branch above deliberately
1051
+ # emitted nothing for: `info.kind == ""` (a RunnableSequence step, a
1052
+ # prompt template, an output parser, a conditional-edge function) and
1053
+ # `info.hidden`. No span owned those failures, but the root was told
1054
+ # one had, so `_end_root` skipped the standalone `error` and the
1055
+ # failure reached no surface at all: an ordinary
1056
+ # `prompt | model | parser` chain whose parser raised produced only
1057
+ # `agent_start` + `agent_end(failed)`, with no error row, no
1058
+ # `error_type` and no traceback. The same exception in a BARE root
1059
+ # lambda — the one shape with no intermediate run — did emit one,
1060
+ # which is why the sibling test passed.
1061
+ #
1062
+ # When a span DID report it, suppressing here is still right:
1063
+ # otherwise `sessionSummary.errorCount` counts one failure twice.
1064
+ info.session.reported_error = True
1065
+
1066
+
1067
+ def _outcome(run: Any, exc: BaseException | None) -> str:
1068
+ if _is_control_flow(exc):
1069
+ return "paused"
1070
+ # Before `failed`, or an abandoned stream reads as a crash. `_close_open_leaves`
1071
+ # already uses this outcome for a span nobody closed; a consumer that stopped
1072
+ # iterating is the same event seen from the other end.
1073
+ if _is_cancellation(exc):
1074
+ return "cancelled"
1075
+ if exc is not None or getattr(run, "error", None):
1076
+ return "failed"
1077
+ return "success"
1078
+
1079
+
1080
+ def _error_text(run: Any, exc: BaseException | None) -> str | None:
1081
+ """The error as a short string, never the whole stacktrace.
1082
+
1083
+ `run.error` is `repr(exc)` plus the formatted traceback, which is a fine
1084
+ thing to keep on the `traceback` field of an `error` event and a terrible
1085
+ thing to put in `tool_result.error`, where the dashboard renders it inline.
1086
+ """
1087
+ if _is_control_flow(exc):
1088
+ return None
1089
+ if exc is not None:
1090
+ return truncate("%s: %s" % (type(exc).__name__, exc), _field_limit())
1091
+ error = getattr(run, "error", None)
1092
+ if error:
1093
+ return truncate(str(error).splitlines()[0], _field_limit())
1094
+ return None
1095
+
1096
+
1097
+ def _error_message(run: Any, exc: BaseException | None) -> str | None:
1098
+ """`_error_text` minus the type prefix, for the `error` event only.
1099
+
1100
+ `error` is the one event that carries `error_type` as its OWN field, and the
1101
+ server builds the row's `summary` as ``"<error_type>: <message>"``. Feeding
1102
+ it `_error_text` — which prefixes the type because `tool_result.error` and
1103
+ `agent_end.summary` have nowhere else to say it — rendered every entry on
1104
+ the Errors surface as ``ValueError: ValueError: denominator must be
1105
+ non-zero``. The CrewAI, LlamaIndex and Pydantic AI adapters all pass a bare
1106
+ `str(exc)` here; this makes the fourth agree with them.
1107
+ """
1108
+ if _is_control_flow(exc):
1109
+ return None
1110
+ if exc is not None:
1111
+ return truncate(str(exc), _field_limit()) or type(exc).__name__
1112
+ error = getattr(run, "error", None)
1113
+ if error:
1114
+ return truncate(str(error).splitlines()[0], _field_limit())
1115
+ return None
1116
+
1117
+
1118
+ def _end_hook(run: Any, info: _RunInfo, meta: dict, exc: BaseException | None) -> None:
1119
+ _emit(
1120
+ "hook_completed",
1121
+ info,
1122
+ hook_name=info.node or info.name,
1123
+ hook_id=info.id,
1124
+ # `"paused"` for a GraphInterrupt: the node did not fail, it stopped to
1125
+ # ask a human. `"failed"`, never `"failure"` — the server only counts
1126
+ # error|failed|timeout|rejected.
1127
+ outcome=_outcome(run, exc),
1128
+ output=_shrink(getattr(run, "outputs", None)),
1129
+ error=_error_text(run, exc),
1130
+ **_fw_common(run, info, meta),
1131
+ )
1132
+
1133
+
1134
+ def _end_tool(run: Any, info: _RunInfo, meta: dict, exc: BaseException | None) -> None:
1135
+ outputs = getattr(run, "outputs", None)
1136
+ output = outputs.get("output") if isinstance(outputs, dict) else outputs
1137
+ output, failed = _tool_output(output)
1138
+ _emit(
1139
+ "tool_result",
1140
+ info,
1141
+ tool_name=info.name or "tool",
1142
+ tool_call_id=info.tool_call_id or info.id,
1143
+ output=_shrink(output),
1144
+ error=_error_text(run, exc) or failed,
1145
+ **_fw_common(run, info, meta),
1146
+ )
1147
+
1148
+
1149
+ def _tool_output(output: Any) -> tuple:
1150
+ """The tool's actual result, plus an error string when it failed quietly.
1151
+
1152
+ A tool invoked the way every modern tool loop invokes one — handed the
1153
+ LLM's `ToolCall` dict rather than a bare argument dict, which is what
1154
+ `bind_tools` produces and what the docs show — returns a **`ToolMessage`**,
1155
+ not a string. `truncate` has no JSON shape for one, so it fell back to
1156
+ `repr` and the single most-read field in a tool loop rendered as
1157
+ ``ToolMessage(content='37000000', name='lookup_population', tool_call_id=…)``
1158
+ instead of ``37000000``.
1159
+
1160
+ `status` is the second half. A `ToolMessage` carries `status="error"` when
1161
+ the tool failed but the framework converted the exception into a message
1162
+ for the model instead of raising — `run.error` is empty on that path, so the
1163
+ failure had NO representation at all: `is_error` 0, a green span, and the
1164
+ text of the exception sitting in an output field nobody filters on.
1165
+ """
1166
+ if getattr(output, "type", None) != "tool":
1167
+ return output, None
1168
+ content = getattr(output, "content", None)
1169
+ failed = None
1170
+ if getattr(output, "status", None) == "error":
1171
+ failed = truncate(content if isinstance(content, str) else str(content), _field_limit())
1172
+ return content, failed
1173
+
1174
+
1175
+ def _end_retriever(run: Any, info: _RunInfo, meta: dict, exc: BaseException | None) -> None:
1176
+ _emit(
1177
+ "tool_result",
1178
+ info,
1179
+ tool_name="retriever:%s" % (info.name or "retriever"),
1180
+ tool_call_id=info.tool_call_id or info.id,
1181
+ output=_summarize_documents(getattr(run, "outputs", None)),
1182
+ error=_error_text(run, exc),
1183
+ **_fw_common(run, info, meta),
1184
+ )
1185
+
1186
+
1187
+ def _summarize_documents(outputs: Any) -> dict | None:
1188
+ """`{"n": ..., "sources": [...]}` — never the document text.
1189
+
1190
+ A retriever that returns twenty 4KB chunks would otherwise put 80KB of
1191
+ prose into one event, on every hop of every RAG loop. None of it is a
1192
+ promoted column, so querying it means `JSONExtract` over the payload, which
1193
+ has already caused a memory blowup in the events store in this product.
1194
+ """
1195
+ if not isinstance(outputs, dict):
1196
+ return None
1197
+ docs = outputs.get("documents")
1198
+ if not isinstance(docs, (list, tuple)):
1199
+ return None
1200
+ # The count is structure and survives `capture_content=False`; the SOURCES
1201
+ # do not. A retrieval source is a document path — `s3://records/patient-1234
1202
+ # -JOHN-DOE.txt` is the shape this actually takes on regulated data — so it
1203
+ # is content by any reading of the option that promises "message bodies are
1204
+ # not" recorded.
1205
+ if not _STATE.options.capture_content:
1206
+ return {"n": len(docs)}
1207
+ sources = []
1208
+ for index, doc in enumerate(docs[:10]):
1209
+ meta = getattr(doc, "metadata", None) or {}
1210
+ source = meta.get("source") or meta.get("id") or meta.get("file_path")
1211
+ sources.append(truncate(str(source) if source else "doc[%d]" % index, 256))
1212
+ return {"n": len(docs), "sources": sources}
1213
+
1214
+
1215
+ def _end_model(
1216
+ run: Any, info: _RunInfo, meta: dict, exc: BaseException | None, response: Any
1217
+ ) -> None:
1218
+ usage = _usage(response)
1219
+ content, role, stop_reason = _completion(response)
1220
+ if exc is not None:
1221
+ stop_reason = "error"
1222
+ extras = _fw_common(run, info, meta)
1223
+ if info.chunks:
1224
+ extras.update(fw_fields(streamed=True, chunks=info.chunks, ttft_ms=info.ttft_ms))
1225
+ _emit(
1226
+ "model_response",
1227
+ info,
1228
+ request_id=info.id,
1229
+ model=info.model,
1230
+ stop_reason=stop_reason,
1231
+ content=content if _STATE.options.capture_content else None,
1232
+ role=role,
1233
+ input_tokens=usage.get("input_tokens") if usage else None,
1234
+ output_tokens=usage.get("output_tokens") if usage else None,
1235
+ # Shipped as a dict as well: both `event_summary.rs` and
1236
+ # `sessionSummary.ts` fall back to `payload.usage` for tokens.
1237
+ usage=usage or None,
1238
+ error=_error_text(run, exc),
1239
+ # ALWAYS set, and always an `int`. `duration_ms` is not guarded on
1240
+ # `model_response`, and `durationOf` prefers the closing event's value
1241
+ # over end-minus-start — which is what keeps model durations honest even
1242
+ # though the execution graph pairs model events FIFO per agent_id. A
1243
+ # float would silently NULL the promoted u32 column.
1244
+ duration_ms=_duration_ms(run),
1245
+ **extras,
1246
+ )
1247
+
1248
+
1249
+ def _duration_ms(run: Any) -> int:
1250
+ start = getattr(run, "start_time", None)
1251
+ if start is None:
1252
+ return 0
1253
+ # `_errored_llm_run` does not set `end_time`, unlike every other errored
1254
+ # path, so an errored model call would report a 0ms duration without this.
1255
+ end = getattr(run, "end_time", None) or _now()
1256
+ return ms(end - start)
1257
+
1258
+
1259
+ def _usage(response: Any) -> dict:
1260
+ """Normalise token counts across the three shapes providers actually use.
1261
+
1262
+ Primary is `usage_metadata` on the message — the LangChain-standard shape
1263
+ since 0.3 and the only one that carries cache/reasoning detail. The two
1264
+ fallbacks are OpenAI's `prompt_tokens`/`completion_tokens` and Anthropic's
1265
+ `input_tokens`/`output_tokens`, both of which arrive under `llm_output`.
1266
+ """
1267
+ if response is None:
1268
+ return {}
1269
+ message = _first_message(response)
1270
+ data = getattr(message, "usage_metadata", None)
1271
+ if isinstance(data, dict) and data:
1272
+ usage = {
1273
+ "input_tokens": data.get("input_tokens"),
1274
+ "output_tokens": data.get("output_tokens"),
1275
+ "total_tokens": data.get("total_tokens"),
1276
+ }
1277
+ for key in ("input_token_details", "output_token_details"):
1278
+ if data.get(key):
1279
+ usage[key] = dict(data[key])
1280
+ return {k: v for k, v in usage.items() if v is not None}
1281
+
1282
+ output = getattr(response, "llm_output", None)
1283
+ if not isinstance(output, dict):
1284
+ return {}
1285
+ raw = output.get("token_usage") or output.get("usage") or {}
1286
+ if not isinstance(raw, dict):
1287
+ return {}
1288
+ # Built as an explicitly `int`-valued dict rather than filtered in place:
1289
+ # the `isinstance` filter narrows at runtime but not for a type checker, and
1290
+ # the arithmetic below is the kind of thing that must not be `Any`.
1291
+ counts: dict[str, int] = {
1292
+ name: value
1293
+ for name, value in (
1294
+ ("input_tokens", raw.get("prompt_tokens", raw.get("input_tokens"))),
1295
+ ("output_tokens", raw.get("completion_tokens", raw.get("output_tokens"))),
1296
+ ("total_tokens", raw.get("total_tokens")),
1297
+ )
1298
+ if isinstance(value, int) and not isinstance(value, bool)
1299
+ }
1300
+ if counts and "total_tokens" not in counts:
1301
+ counts["total_tokens"] = counts.get("input_tokens", 0) + counts.get("output_tokens", 0)
1302
+ return counts
1303
+
1304
+
1305
+ def _first_generation(response: Any) -> Any:
1306
+ generations = getattr(response, "generations", None)
1307
+ if not generations:
1308
+ return None
1309
+ first = generations[0]
1310
+ if isinstance(first, (list, tuple)):
1311
+ return first[0] if first else None
1312
+ return first
1313
+
1314
+
1315
+ def _first_message(response: Any) -> Any:
1316
+ generation = _first_generation(response)
1317
+ return getattr(generation, "message", None) if generation is not None else None
1318
+
1319
+
1320
+ def _completion(response: Any) -> tuple:
1321
+ generation = _first_generation(response)
1322
+ if generation is None:
1323
+ return None, None, None
1324
+ message = getattr(generation, "message", None)
1325
+ content = getattr(message, "content", None)
1326
+ if content is None:
1327
+ content = getattr(generation, "text", None)
1328
+ info = getattr(generation, "generation_info", None) or {}
1329
+ stop = info.get("finish_reason") or info.get("stop_reason")
1330
+ if not stop and message is not None:
1331
+ response_meta = getattr(message, "response_metadata", None) or {}
1332
+ stop = response_meta.get("finish_reason") or response_meta.get("stop_reason")
1333
+ role = "assistant" if message is not None else None
1334
+ return truncate(content, _field_limit()), role, stop
1335
+
1336
+
1337
+ def _end_root(run: Any, info: _RunInfo, meta: dict, exc: BaseException | None) -> None:
1338
+ state = _STATE
1339
+ session = info.session
1340
+ state.runs.pop(info.id, None)
1341
+ _close_open_leaves(info.id)
1342
+ if session is None:
1343
+ return
1344
+
1345
+ if session.open_pauses:
1346
+ # Interrupted, waiting on a human. Deliberately no `agent_end`: closing
1347
+ # the agent here would force-close the open pause (the graph does that
1348
+ # at `agent_end`), zeroing the one interval that measures how long the
1349
+ # human took. The agent is closed by the resuming `.invoke()`.
1350
+ return
1351
+
1352
+ # A cancelled run is not a failed one: no standalone `error`, and the
1353
+ # `agent_end` below carries `outcome="cancelled"` rather than `"failed"`.
1354
+ cancelled = _is_cancellation(exc)
1355
+ failed = exc is not None and not _is_control_flow(exc) and not cancelled
1356
+ if not failed and not cancelled and getattr(run, "error", None) and not _is_control_flow(exc):
1357
+ failed = True
1358
+
1359
+ if failed and not session.reported_error:
1360
+ # Nothing below reported this failure, so nobody owns it — a standalone
1361
+ # `error` event is the only way it reaches the Errors surface. Strictly
1362
+ # before `agent_end`: the graph closes the agent span at `agent_end`,
1363
+ # so an error after it is attributed to nothing.
1364
+ _emit_on_agent(
1365
+ session,
1366
+ "error",
1367
+ error_type=type(exc).__name__ if exc is not None else "RunError",
1368
+ message=_error_message(run, exc) or "run failed",
1369
+ traceback=truncate(str(getattr(run, "error", "") or ""), _core.FIELD_LIMIT) or None,
1370
+ **_fw_common(run, info, meta),
1371
+ )
1372
+
1373
+ state.tracker.end_agent(
1374
+ session.agent_key,
1375
+ outcome="cancelled" if cancelled else ("failed" if failed else "success"),
1376
+ summary=_error_text(run, exc) if failed else None,
1377
+ **_fw_common(run, info, meta),
1378
+ )
1379
+ state.sessions.pop(session.session_id, None)
1380
+
1381
+
1382
+ # Mirrors `_ROOT_LEAF_STARTERS`. `_end_tool`/`_end_retriever` take no response
1383
+ # argument, so they are adapted to one signature here rather than at the call
1384
+ # site — a mismatch would be swallowed by `safe()` and read as "no events".
1385
+ _ROOT_LEAF_ENDERS = {
1386
+ "model": _end_model,
1387
+ "tool": lambda run, info, meta, exc, response: _end_tool(run, info, meta, exc),
1388
+ "retriever": lambda run, info, meta, exc, response: _end_retriever(run, info, meta, exc),
1389
+ }
1390
+
1391
+
1392
+ def _close_open_leaves(root_id: str) -> None:
1393
+ """Close every leaf still open under this root. Caller holds the lock.
1394
+
1395
+ `agent_end` force-closes open *pauses* but not tools, models or humans, so a
1396
+ run that dies mid-tool leaves the session `ongoing` forever. Closing them
1397
+ here is what keeps that invariant true when a framework skips an end
1398
+ callback — which happens on a hard cancellation, a killed stream, or a
1399
+ handler that was disabled part-way through by the failure policy.
1400
+ """
1401
+ state = _STATE
1402
+ stale = [i for i in state.runs.values() if i.root == root_id and i.id != root_id]
1403
+ for info in reversed(stale):
1404
+ state.runs.pop(info.id, None)
1405
+ if info.hidden or not info.kind:
1406
+ continue
1407
+ marker = fw_fields(incomplete=True)
1408
+ try:
1409
+ if info.kind == "tool" or info.kind == "retriever":
1410
+ _emit(
1411
+ "tool_result",
1412
+ info,
1413
+ tool_name=info.name or "tool",
1414
+ tool_call_id=info.tool_call_id or info.id,
1415
+ **marker,
1416
+ )
1417
+ elif info.kind in ("node", "chain"):
1418
+ _emit(
1419
+ "hook_completed",
1420
+ info,
1421
+ hook_name=info.node or info.name,
1422
+ hook_id=info.id,
1423
+ outcome="cancelled",
1424
+ **marker,
1425
+ )
1426
+ elif info.kind == "model":
1427
+ _emit(
1428
+ "model_response",
1429
+ info,
1430
+ request_id=info.id,
1431
+ model=info.model,
1432
+ stop_reason="incomplete",
1433
+ duration_ms=ms(_now() - info.started),
1434
+ **marker,
1435
+ )
1436
+ elif info.kind == "subgraph":
1437
+ state.tracker.end_agent(info.id, outcome="cancelled", **marker)
1438
+ except Exception: # pragma: no cover - teardown must never raise
1439
+ logger.debug("failproofai_sdk: could not close open span %s", info.id, exc_info=True)
1440
+
1441
+
1442
+ # ---------------------------------------------------------------------------
1443
+ # Human in the loop
1444
+ # ---------------------------------------------------------------------------
1445
+
1446
+ def _interrupts_of(exc: BaseException | None) -> tuple:
1447
+ """The `Interrupt`s carried by a `GraphInterrupt`, if this is one.
1448
+
1449
+ `GraphInterrupt.__init__` does `super().__init__(interrupts)`, so
1450
+ `exc.args[0]` is the sequence. `ParentCommand` and `GraphDrained` are also
1451
+ `GraphBubbleUp` but carry a `Command` / a reason string, so the duck-typed
1452
+ `.value` check is what keeps them out.
1453
+ """
1454
+ if not _is_control_flow(exc):
1455
+ return ()
1456
+ args = getattr(exc, "args", ()) or ()
1457
+ if not args:
1458
+ return ()
1459
+ candidates = args[0]
1460
+ if not isinstance(candidates, (list, tuple)):
1461
+ return ()
1462
+ return tuple(c for c in candidates if hasattr(c, "value"))
1463
+
1464
+
1465
+ def _suspend(session: _Session, interrupts: Iterable) -> None:
1466
+ """`human_wait` + `agent_pause`, one pair per `Interrupt`, in that order.
1467
+
1468
+ **Both** pairs are required and neither is redundant: only
1469
+ `agent_pause` -> `agent_resume` feeds the graph's `pausedMs` (without it the
1470
+ session reports `ongoing` and inflates its active duration by the whole
1471
+ human wait), and only `human_wait` -> `human_input` carries the prompt, the
1472
+ response and the `pendingHuman` count.
1473
+ """
1474
+ for index, interrupt in enumerate(interrupts):
1475
+ pause_id = str(getattr(interrupt, "id", None) or "%s:%d" % (session.agent_key, index))
1476
+ if pause_id in session.open_pauses:
1477
+ continue
1478
+ prompt, options = _prompt_of(getattr(interrupt, "value", None))
1479
+ session.open_pauses[pause_id] = prompt
1480
+ # `capture_content=False` has to cover these. In a real HITL graph the
1481
+ # interrupt payload IS the record being approved, and the answer is the
1482
+ # human's free text — the two most sensitive strings in the run. Both
1483
+ # went to the spool regardless, while the page documenting the option
1484
+ # promised "message bodies are not" recorded.
1485
+ _emit_on_agent(
1486
+ session,
1487
+ "human_wait",
1488
+ input_id=pause_id,
1489
+ prompt=prompt if _STATE.options.capture_content else None,
1490
+ options=options if _STATE.options.capture_content else None,
1491
+ reason="langgraph_interrupt",
1492
+ **fw_fields(interrupt_id=pause_id, kind="interrupt"),
1493
+ )
1494
+ _emit_on_agent(
1495
+ session,
1496
+ "agent_pause",
1497
+ pause_id=pause_id,
1498
+ reason="langgraph_interrupt",
1499
+ **fw_fields(interrupt_id=pause_id),
1500
+ )
1501
+
1502
+
1503
+ def _prompt_of(value: Any) -> tuple:
1504
+ if isinstance(value, dict):
1505
+ prompt = value.get("prompt") or value.get("question") or value.get("message")
1506
+ options = value.get("options")
1507
+ if not isinstance(options, (list, tuple)):
1508
+ options = None
1509
+ else:
1510
+ options = [str(o) for o in options]
1511
+ if prompt is not None:
1512
+ return truncate(str(prompt), _field_limit()), options
1513
+ return truncate(str(value), _field_limit()), options
1514
+ return truncate(str(value), _field_limit()) if value is not None else None, None
1515
+
1516
+
1517
+ def _resume(session: _Session, run: Any) -> None:
1518
+ """`agent_resume` + `human_input`, in that order, one pair per open pause."""
1519
+ if not session.open_pauses:
1520
+ return
1521
+ answers = _resume_values(run)
1522
+ for pause_id, prompt in list(session.open_pauses.items()):
1523
+ session.open_pauses.pop(pause_id, None)
1524
+ _emit_on_agent(
1525
+ session,
1526
+ "agent_resume",
1527
+ pause_id=pause_id,
1528
+ reason="langgraph_resume",
1529
+ **fw_fields(interrupt_id=pause_id),
1530
+ )
1531
+ _emit_on_agent(
1532
+ session,
1533
+ "human_input",
1534
+ input_id=pause_id,
1535
+ response=_answer_for(answers, pause_id) if _STATE.options.capture_content else None,
1536
+ **fw_fields(
1537
+ interrupt_id=pause_id,
1538
+ prompt=prompt if _STATE.options.capture_content else None,
1539
+ ),
1540
+ )
1541
+
1542
+
1543
+ def _is_graph_run(run: Any) -> bool:
1544
+ """Is this run a Pregel/LangGraph invocation at all?
1545
+
1546
+ A resume is always a graph run, so this is a necessary condition and a cheap
1547
+ one: langgraph stamps its checkpoint namespace and thread id into the run's
1548
+ metadata, and neither appears on a plain runnable invoked with `None`.
1549
+ """
1550
+ meta = getattr(run, "metadata", None)
1551
+ if isinstance(meta, dict) and (
1552
+ "langgraph_checkpoint_ns" in meta
1553
+ or "checkpoint_ns" in meta
1554
+ or "thread_id" in meta
1555
+ or "langgraph_step" in meta
1556
+ ):
1557
+ return True
1558
+ # `configurable.thread_id` is where it lands when the run carries the config
1559
+ # rather than the flattened metadata.
1560
+ config = getattr(run, "config", None)
1561
+ if isinstance(config, dict):
1562
+ configurable = config.get("configurable")
1563
+ if isinstance(configurable, dict) and configurable.get("thread_id"):
1564
+ return True
1565
+ return False
1566
+
1567
+
1568
+ _MISSING = object()
1569
+
1570
+
1571
+ def _steering_value(run: Any) -> Any:
1572
+ """The object `.invoke()` was called with, when it was **not** fresh state.
1573
+
1574
+ Verified on langgraph 1.2.11: a fresh turn arrives as the state mapping
1575
+ itself (``{'trail': []}``), while anything that is not a mapping is wrapped
1576
+ under a single ``input`` key — ``{'input': Command(resume='yes')}`` for a
1577
+ resume, ``{'input': None}`` for ``invoke(None, config)``. So the presence of
1578
+ that key is what separates "steering an existing checkpointed run" from
1579
+ "starting a new one", and it is a *positive* test rather than a guess at
1580
+ which state schemas happen to look like a Command.
1581
+ """
1582
+ inputs = getattr(run, "inputs", None)
1583
+ if isinstance(inputs, dict):
1584
+ return inputs.get("input", _MISSING)
1585
+ return inputs if inputs is not None else _MISSING
1586
+
1587
+
1588
+ def _is_continuation(run: Any) -> bool:
1589
+ """True when this root run continues an interrupted thread.
1590
+
1591
+ LangGraph has exactly two of these — ``Command(...)`` and ``None`` — and
1592
+ both are shaped unlike fresh state (see `_steering_value`). Everything else
1593
+ starts a new run even when it lands on a thread that is mid-interrupt: a
1594
+ fresh input discards the pending tasks rather than answering them.
1595
+ """
1596
+ value = _steering_value(run)
1597
+ if value is _MISSING:
1598
+ return False
1599
+ if value is None:
1600
+ # `None` alone is NOT enough. langchain-core wraps any non-mapping input
1601
+ # to any root runnable under `input`, so `some_runnable.invoke(None)`
1602
+ # produces exactly the shape a LangGraph resume does — and a graph whose
1603
+ # state schema happens to have a key named `input` reaches it too.
1604
+ # Combined with the resume branch in `_start_root`, an unrelated
1605
+ # heartbeat or summariser invoked with no argument during a pause was
1606
+ # read as the human's answer: it got no `agent_start` of its own, its
1607
+ # events were folded into the paused root, and the adapter emitted
1608
+ # `agent_resume` + `human_input(response=None)` for a human who answered
1609
+ # nothing, then closed the run `success` on an approval never given.
1610
+ # That comment two frames up calls fabricating the approval "the worst
1611
+ # wrong answer available", so require positive evidence instead.
1612
+ return _is_graph_run(run)
1613
+ if _Command is not None and isinstance(value, _Command):
1614
+ return True
1615
+ # Duck-typed fallback for a moved/renamed `Command`.
1616
+ return all(hasattr(value, name) for name in ("resume", "goto", "update"))
1617
+
1618
+
1619
+ def _resume_values(run: Any) -> Any:
1620
+ """The value handed to `Command(resume=...)`, read off the root run's input.
1621
+
1622
+ On a resume, langgraph calls `on_chain_start` with the `Command` itself as
1623
+ the input, so the human's answer is available to us without any cooperation
1624
+ from the caller. `Command(resume={interrupt_id: value})` (the multi-
1625
+ interrupt form) is handled by `_answer_for`.
1626
+ """
1627
+ value = _steering_value(run)
1628
+ return getattr(value, "resume", None) if value is not _MISSING else None
1629
+
1630
+
1631
+ def _answer_for(answers: Any, pause_id: str) -> str | None:
1632
+ if answers is None:
1633
+ return None
1634
+ if isinstance(answers, dict) and pause_id in answers:
1635
+ return truncate(str(answers[pause_id]), _field_limit())
1636
+ return truncate(str(answers), _field_limit())
1637
+
1638
+
1639
+ def _session_for_run(run_id: Any) -> _Session | None:
1640
+ info = _STATE.runs.get(str(run_id)) if run_id is not None else None
1641
+ return info.session if info is not None else None
1642
+
1643
+
1644
+ # ---------------------------------------------------------------------------
1645
+ # Human in the loop, resumed by a DIFFERENT PROCESS
1646
+ # ---------------------------------------------------------------------------
1647
+ #
1648
+ # Everything above assumes the process that paused is the process that resumes,
1649
+ # because it keys the pause on the `Interrupt` object it saw. Real HITL is not
1650
+ # shaped like that: the interrupt is served by one worker, a human answers
1651
+ # minutes or hours later, and any worker may pick that request up. The resuming
1652
+ # process has no `_Session`, no `open_pauses`, and langgraph's `GraphResumeEvent`
1653
+ # carries a checkpoint id but no interrupt ids — so there was nothing to
1654
+ # correlate on and the pause simply stayed open forever.
1655
+ #
1656
+ # It is recoverable, exactly, because `Interrupt.id` is not random. langgraph
1657
+ # 1.2's `interrupt()` builds it with `Interrupt.from_ns(value, ns)`, i.e.
1658
+ # `xxh3_128(checkpoint_ns)` — a pure function of the interrupted task's
1659
+ # namespace. That namespace is `metadata["langgraph_checkpoint_ns"]`, which this
1660
+ # adapter already reads on every node run, and it is **byte-identical across the
1661
+ # two invocations** (VERIFIED on langgraph 1.2.11: `approve:49c9e42f-…` in both
1662
+ # the interrupting and the resuming process, hashing to the id the first process
1663
+ # reported). So the resuming process can reconstruct the id the pausing process
1664
+ # used without any shared state at all.
1665
+ #
1666
+ # The remaining question is *which* node re-ran because it was interrupted, and
1667
+ # langgraph answers that too, in two parts:
1668
+ #
1669
+ # * `on_resume` fires once per Pregel level, in order, each naming the level's
1670
+ # checkpoint namespace, and always **before** the node runs at that level. An
1671
+ # interrupt inside a subgraph therefore produces `ns=()` then
1672
+ # `ns=('child:…',)`, and the deepest of those is the graph that actually
1673
+ # paused — which is how the subgraph HOST node (a normal node at the shallower
1674
+ # level) is excluded.
1675
+ # * only the level's first superstep re-runs interrupted tasks. A sibling that
1676
+ # had already succeeded in that superstep does not re-run at all, and
1677
+ # downstream nodes are at later steps.
1678
+ #
1679
+ # Deliberately decided at node **end** rather than start: a subgraph host node
1680
+ # starts before the deeper `on_resume` that unmasks it, so at start time it is
1681
+ # indistinguishable from the interrupted task. The cost is that `agent_resume`
1682
+ # lands after the resumed node's own body, which adds that node's duration to
1683
+ # the measured wait — a rounding error against a human, and the only alternative
1684
+ # is guessing.
1685
+
1686
+
1687
+ def _remote_of(info: _RunInfo) -> _RemoteResume | None:
1688
+ """The `_RemoteResume` of this run's root, if the root is one."""
1689
+ root = _STATE.runs.get(info.root) if info.root else None
1690
+ return root.remote if root is not None else None
1691
+
1692
+
1693
+ def _interrupt_id_of(ns: str) -> str | None:
1694
+ if _Interrupt is None or not ns:
1695
+ return None
1696
+ try:
1697
+ return str(_Interrupt.from_ns(None, ns).id)
1698
+ except Exception: # pragma: no cover - a future langgraph changing the shape
1699
+ logger.debug("failproofai_sdk: could not derive an interrupt id", exc_info=True)
1700
+ return None
1701
+
1702
+
1703
+ def _close_remote_pause(info: _RunInfo, meta: dict) -> None:
1704
+ """`agent_resume` + `human_input` for a pause this process never opened."""
1705
+ remote = _remote_of(info)
1706
+ session = info.session
1707
+ if remote is None or session is None or remote.deepest is None:
1708
+ return
1709
+ parts = _ns_parts(meta)
1710
+ level = tuple(parts[:-1])
1711
+ if level != remote.deepest:
1712
+ return
1713
+ if meta.get("langgraph_step") != remote.levels.get(level):
1714
+ return
1715
+ pause_id = _interrupt_id_of(meta.get("langgraph_checkpoint_ns") or "")
1716
+ if pause_id is None or pause_id in remote.done:
1717
+ return
1718
+ remote.done.add(pause_id)
1719
+ marker = fw_fields(interrupt_id=pause_id, resumed_elsewhere=True)
1720
+ _emit_on_agent(
1721
+ session, "agent_resume", pause_id=pause_id, reason="langgraph_resume", **marker
1722
+ )
1723
+ _emit_on_agent(
1724
+ session,
1725
+ "human_input",
1726
+ input_id=pause_id,
1727
+ response=_answer_for(remote.value, pause_id),
1728
+ **marker,
1729
+ )
1730
+
1731
+
1732
+ # ---------------------------------------------------------------------------
1733
+ # The handler
1734
+ # ---------------------------------------------------------------------------
1735
+
1736
+ class FailproofAITracer(BaseTracer, _GraphBase):
1737
+ """The single sync handler. Zero-arg, cheap, and stateless by design.
1738
+
1739
+ Every override is one of two shapes:
1740
+
1741
+ * ``_start_trace`` / ``_end_trace`` — call `super()` and hand the assembled
1742
+ `Run` to a module-level translator wrapped in `_core.safe`. `super()` is
1743
+ called **unconditionally and outside** our own work, so a bug in the
1744
+ translator can never skip LangChain's own bookkeeping.
1745
+ * ``on_*`` — stash the one thing the `Run` object does not preserve (the
1746
+ exception object, the `LLMResult`, the un-flattened chat messages), then
1747
+ delegate. These exist because `Run.error` is a formatted traceback rather
1748
+ than the exception, and we need `isinstance(exc, GraphBubbleUp)` to tell a
1749
+ human-approval pause from a failure.
1750
+
1751
+ Nothing here holds a contextvar token: `ContextVar.reset()` raises across
1752
+ tasks as well as threads, and every one of these callbacks can land on a
1753
+ different task from the one that opened the run.
1754
+ """
1755
+
1756
+ # Non-negotiable. See the module docstring: without it AsyncCallbackManager
1757
+ # dispatches us through run_in_executor and can reorder our callbacks.
1758
+ run_inline = True
1759
+
1760
+ @property
1761
+ def raise_error(self) -> bool: # type: ignore[override]
1762
+ """False normally; True under FAILPROOFAI_SDK_STRICT.
1763
+
1764
+ Normally False so an adapter bug can never take down the customer's
1765
+ graph: LangChain catches, logs and swallows handler exceptions, and
1766
+ `_core.safe` does the same one layer further in.
1767
+
1768
+ But that firewall also made `FAILPROOFAI_SDK_STRICT=1` inert *specifically
1769
+ here*. `safe()` re-raises under strict, and LangChain's `handle_event`
1770
+ then caught it and logged "Error in FailproofAITracer.<cb> callback", so
1771
+ the fault never reached the caller and the escape hatch silently did
1772
+ nothing on the one adapter people are most likely to debug. Following
1773
+ strict mode restores it. Read per callback by LangChain, so toggling
1774
+ the env var takes effect without re-instrumenting.
1775
+ """
1776
+ return _core.strict()
1777
+
1778
+ def _persist_run(self, run: Any) -> None:
1779
+ """Required by `BaseTracer`; we stream, so there is nothing to persist."""
1780
+
1781
+ def _start_trace(self, run: Any) -> None:
1782
+ super()._start_trace(run)
1783
+ _on_start(run)
1784
+
1785
+ def _end_trace(self, run: Any) -> None:
1786
+ _on_end(run)
1787
+ super()._end_trace(run)
1788
+
1789
+ def on_chat_model_start(
1790
+ self,
1791
+ serialized: dict,
1792
+ messages: list,
1793
+ *,
1794
+ run_id: Any,
1795
+ tags: list | None = None,
1796
+ parent_run_id: Any = None,
1797
+ metadata: dict | None = None,
1798
+ name: str | None = None,
1799
+ **kwargs: Any,
1800
+ ) -> Any:
1801
+ _stash_messages(run_id, messages)
1802
+ return super().on_chat_model_start(
1803
+ serialized,
1804
+ messages,
1805
+ run_id=run_id,
1806
+ tags=tags,
1807
+ parent_run_id=parent_run_id,
1808
+ metadata=metadata,
1809
+ name=name,
1810
+ **kwargs,
1811
+ )
1812
+
1813
+ def on_llm_end(self, response: Any, *, run_id: Any, **kwargs: Any) -> Any:
1814
+ _stash(run_id, response)
1815
+ return super().on_llm_end(response, run_id=run_id, **kwargs)
1816
+
1817
+ def on_llm_error(self, error: BaseException, *, run_id: Any, **kwargs: Any) -> Any:
1818
+ _stash_error(run_id, error)
1819
+ return super().on_llm_error(error, run_id=run_id, **kwargs)
1820
+
1821
+ def on_chain_error(
1822
+ self, error: BaseException, *, inputs: dict | None = None, run_id: Any, **kwargs: Any
1823
+ ) -> Any:
1824
+ _stash_error(run_id, error)
1825
+ return super().on_chain_error(error, inputs=inputs, run_id=run_id, **kwargs)
1826
+
1827
+ def on_tool_error(self, error: BaseException, *, run_id: Any, **kwargs: Any) -> Any:
1828
+ _stash_error(run_id, error)
1829
+ return super().on_tool_error(error, run_id=run_id, **kwargs)
1830
+
1831
+ def on_retriever_error(self, error: BaseException, *, run_id: Any, **kwargs: Any) -> Any:
1832
+ _stash_error(run_id, error)
1833
+ return super().on_retriever_error(error, run_id=run_id, **kwargs)
1834
+
1835
+ def _on_llm_new_token(self, run: Any, token: Any, chunk: Any) -> None:
1836
+ """Folded into the closing `model_response`. **Never** an event.
1837
+
1838
+ A 500-token response would otherwise be 500 stored rows and 500 rail
1839
+ rows against a five-lane cap. Langfuse uses this callback only to stamp
1840
+ time-to-first-token; so do we.
1841
+ """
1842
+ _count_token(run)
1843
+
1844
+ def on_interrupt(self, event: Any) -> None:
1845
+ _on_interrupt(event)
1846
+
1847
+ def on_resume(self, event: Any) -> None:
1848
+ _on_resume(event)
1849
+
1850
+
1851
+ # The translators, each individually guarded. `safe()` catches `Exception` and
1852
+ # **not** `BaseException`: `CancelledError`, `KeyboardInterrupt` and
1853
+ # `SystemExit` are BaseExceptions, and swallowing them here would silently break
1854
+ # cancellation in every instrumented async application.
1855
+ _on_start = safe(_on_start)
1856
+ _on_end = safe(_on_end)
1857
+
1858
+
1859
+ @safe
1860
+ def _stash(run_id: Any, response: Any) -> None:
1861
+ with _STATE.lock:
1862
+ _STATE.responses[str(run_id)] = response
1863
+
1864
+
1865
+ @safe
1866
+ def _stash_messages(run_id: Any, messages: Any) -> None:
1867
+ # The one stash `_on_end` does not clean up after itself: it pops `rid` and
1868
+ # `messages:` is a different key, drained only by `_start_model`. So it is
1869
+ # the one that has to honour the kill switch too, or a torn-down adapter
1870
+ # grows a dict forever.
1871
+ if not _STATE.enabled:
1872
+ return
1873
+ with _STATE.lock:
1874
+ _STATE.responses["messages:" + str(run_id)] = _normalize_messages(messages)
1875
+
1876
+
1877
+ @safe
1878
+ def _stash_error(run_id: Any, error: BaseException) -> None:
1879
+ with _STATE.lock:
1880
+ _STATE.errors[str(run_id)] = error
1881
+
1882
+
1883
+ @safe
1884
+ def _count_token(run: Any) -> None:
1885
+ with _STATE.lock:
1886
+ info = _STATE.runs.get(str(run.id))
1887
+ if info is None:
1888
+ return
1889
+ info.chunks += 1
1890
+ if info.ttft_ms is None:
1891
+ start = getattr(run, "start_time", None)
1892
+ info.ttft_ms = ms(_now() - start) if start is not None else 0
1893
+
1894
+
1895
+ @safe
1896
+ def _on_interrupt(event: Any) -> None:
1897
+ with _STATE.lock:
1898
+ session = _session_for_run(getattr(event, "run_id", None))
1899
+ if session is None:
1900
+ return
1901
+ _suspend(session, getattr(event, "interrupts", ()) or ())
1902
+
1903
+
1904
+ @safe
1905
+ def _on_resume(event: Any) -> None:
1906
+ # Two jobs. The first is normally a no-op: the resuming root run starts
1907
+ # *before* langgraph drains its lifecycle queue, so `_start_root` has
1908
+ # already closed a pause this process opened. That is here for the ordering
1909
+ # not holding in some future version, and `_resume` returns immediately when
1910
+ # there is nothing open.
1911
+ #
1912
+ # The second is load-bearing, and is the only signal that separates the
1913
+ # subgraph HOST node from the task that actually paused: this event names
1914
+ # the Pregel level that is resuming, and fires once per level, deepest last.
1915
+ with _STATE.lock:
1916
+ info = _STATE.runs.get(str(getattr(event, "run_id", None) or ""))
1917
+ if info is None:
1918
+ return
1919
+ # `_remote_of` resolves through `info.root`, which a root run sets to
1920
+ # its own id, so this covers both the root's event and a subgraph's.
1921
+ remote = _remote_of(info)
1922
+ if remote is not None:
1923
+ level = tuple(getattr(event, "checkpoint_ns", ()) or ())
1924
+ if remote.deepest is None or len(level) >= len(remote.deepest):
1925
+ remote.deepest = level
1926
+ if info.session is not None:
1927
+ _resume(info.session, None)
1928
+
1929
+
1930
+ # ---------------------------------------------------------------------------
1931
+ # Install / uninstall
1932
+ # ---------------------------------------------------------------------------
1933
+
1934
+ _HANDLER_VAR: contextvars.ContextVar = contextvars.ContextVar(
1935
+ "failproofai_langchain_handler", default=None
1936
+ )
1937
+
1938
+ _hook_lock = threading.Lock()
1939
+ _hook_registered = False
1940
+
1941
+ # The instance `install()` created, kept outside the ContextVar so that a worker
1942
+ # thread — which starts with a fresh context and therefore an empty var — can
1943
+ # still find it. `_configure` itself does not need this (it constructs a fresh
1944
+ # zero-arg handler from the env var), but the graph-lifecycle wrap does, because
1945
+ # langgraph filters on `isinstance`, not on a class.
1946
+ _ACTIVE_HANDLER: Any = None
1947
+
1948
+
1949
+ def _register_hook() -> None:
1950
+ """`register_configure_hook` exactly once per process.
1951
+
1952
+ `_configure_hooks` is a module-level list with no removal API, so calling
1953
+ this twice means two entries — and although `_configure`'s `isinstance`
1954
+ dedup would keep the handler count at one, the list would grow on every
1955
+ `instrument()`/`uninstrument()` cycle in a reloading dev server.
1956
+ """
1957
+ global _hook_registered
1958
+ with _hook_lock:
1959
+ if _hook_registered:
1960
+ return
1961
+ register_configure_hook(_HANDLER_VAR, True, FailproofAITracer, ENV_VAR)
1962
+ _hook_registered = True
1963
+
1964
+
1965
+ @safe
1966
+ def _attach_graph_handler(manager: Any) -> None:
1967
+ handler = _HANDLER_VAR.get() or _ACTIVE_HANDLER
1968
+ if handler is None or manager is None:
1969
+ return
1970
+ handlers = getattr(manager, "handlers", None)
1971
+ if handlers is None or any(isinstance(h, FailproofAITracer) for h in handlers):
1972
+ return
1973
+ manager.add_handler(handler, True)
1974
+
1975
+
1976
+ def _install_graph_callbacks(patcher: Patcher) -> bool:
1977
+ """Make `on_interrupt`/`on_resume` reach a globally-installed handler.
1978
+
1979
+ Verified on langgraph 1.2.10: `Pregel.stream` calls
1980
+ `get_sync_graph_callback_manager_for_config(config)`, which filters the
1981
+ **raw** `config["callbacks"]` for `GraphCallbackHandler` instances. A
1982
+ handler injected by `register_configure_hook` is never in there — the hook
1983
+ runs inside `CallbackManager.configure`, which builds a *different*
1984
+ manager — so without this wrap the lifecycle callbacks are dead code for
1985
+ every user who did not pass the handler by hand. Worse, langgraph gates the
1986
+ feature entirely on `has_graph_lifecycle_callbacks=bool(manager.handlers)`.
1987
+
1988
+ We patch the names as they are bound in `langgraph.pregel.main` (a
1989
+ `from ... import`, so patching `langgraph.callbacks` would have no effect)
1990
+ and only when they are still the same objects, so a refactor upstream
1991
+ degrades to "no lifecycle callbacks" rather than to a wrong patch.
1992
+ """
1993
+ import langgraph.callbacks as lgcb
1994
+ import langgraph.pregel.main as pmain
1995
+
1996
+ names = (
1997
+ "get_sync_graph_callback_manager_for_config",
1998
+ "get_async_graph_callback_manager_for_config",
1999
+ )
2000
+ for name in names:
2001
+ bound = getattr(pmain, name, None)
2002
+ if bound is None or bound is not getattr(lgcb, name, None):
2003
+ return False
2004
+ for name in names:
2005
+ original = getattr(pmain, name)
2006
+ patcher.patch(
2007
+ pmain,
2008
+ name,
2009
+ # `wrap_callable` is the structural guarantee: the original call is
2010
+ # the only thing inside the try, and `_attach_graph_handler` runs
2011
+ # outside it and inside `call_safely`. The manager is mutated in
2012
+ # place, so nothing about the returned object changes.
2013
+ _core.wrap_callable(original, after=lambda _ctx, manager: _attach_graph_handler(manager)),
2014
+ )
2015
+ return True
2016
+
2017
+
2018
+ class _Adapter:
2019
+ """The object `failproofai_sdk.integrations` looks for as `adapter`."""
2020
+
2021
+ name = NAME
2022
+ module = MODULE
2023
+
2024
+ def __init__(self) -> None:
2025
+ self._patcher = Patcher()
2026
+ self._handler: FailproofAITracer | None = None
2027
+ self._set_env = False
2028
+
2029
+ def install(self, **options: Any) -> None:
2030
+ _compat.check_version(
2031
+ NAME,
2032
+ DIST,
2033
+ minimum="1.4.7",
2034
+ below="2",
2035
+ reason="langgraph 1.2's own floor; earlier cores lack the metadata this adapter reads",
2036
+ )
2037
+ _compat.check_version(NAME, "langgraph", minimum="1.2", below="2", reason="GraphCallbackHandler")
2038
+
2039
+ _STATE.options = _read_options(options)
2040
+ _STATE.reset()
2041
+ # `field_limit` reaches the DECLARED parameters too (`input`, `output`,
2042
+ # `messages`), which this adapter never truncated itself — they were cut
2043
+ # at the core default no matter what this module's constant said.
2044
+ _STATE.tracker = RunTracker(
2045
+ NAME, base_fields=_base_fields(), field_limit=_STATE.options.capture_limit
2046
+ )
2047
+ _STATE.enabled = True
2048
+
2049
+ global _ACTIVE_HANDLER
2050
+ _register_hook()
2051
+ self._handler = FailproofAITracer()
2052
+ _ACTIVE_HANDLER = self._handler
2053
+ _HANDLER_VAR.set(self._handler)
2054
+ # The ContextVar only reaches contexts derived from this one, so a
2055
+ # worker thread started later would not see it. The env var is what
2056
+ # covers those: `_configure` constructs a fresh zero-arg handler when
2057
+ # the var is empty, which is safe precisely because all state is in
2058
+ # `_STATE` rather than on the instance.
2059
+ if ENV_VAR not in os.environ:
2060
+ os.environ[ENV_VAR] = "1"
2061
+ self._set_env = True
2062
+
2063
+ if _STATE.options.graph_callbacks and _GraphCallbackHandler is not None:
2064
+ if _compat.probe(NAME, "graph_lifecycle_callbacks", lambda: _install_graph_callbacks(self._patcher)):
2065
+ logger.debug("failproofai_sdk: langgraph interrupt/resume callbacks wired")
2066
+
2067
+ def uninstall(self) -> None:
2068
+ # There is no deregister API for a configure hook — `_configure_hooks`
2069
+ # is append-only and private — so removal is "make the hook produce
2070
+ # nothing": flip the kill switch, clear the ContextVar, unset the env
2071
+ # var. The switch goes FIRST and is the only one of the three that
2072
+ # cannot be routed around (see `_State.enabled`); it is flipped before
2073
+ # `_close_everything()` because that path emits through the tracker
2074
+ # directly and never re-enters `_on_start`.
2075
+ global _ACTIVE_HANDLER
2076
+ _STATE.enabled = False
2077
+ _ACTIVE_HANDLER = None
2078
+ _HANDLER_VAR.set(None)
2079
+ if self._set_env:
2080
+ os.environ.pop(ENV_VAR, None)
2081
+ self._set_env = False
2082
+ self._patcher.restore_all()
2083
+ self._handler = None
2084
+ _close_everything()
2085
+ _STATE.reset()
2086
+ _STATE.options = _Options()
2087
+
2088
+
2089
+ def _read_options(options: dict) -> _Options:
2090
+ include = options.get("include_chains") or ()
2091
+ if isinstance(include, str):
2092
+ include = (include,)
2093
+ unknown = set(options) - {
2094
+ "session_id", "include_chains", "capture_content", "graph_callbacks", "capture_limit",
2095
+ }
2096
+ if unknown:
2097
+ # Not fatal: `instrument()` with no name installs every detected
2098
+ # adapter with the same **options, so an option meant for CrewAI
2099
+ # legitimately arrives here.
2100
+ logger.debug("failproofai_sdk: langchain adapter ignoring options %s", sorted(unknown))
2101
+ return _Options(
2102
+ session_id=options.get("session_id"),
2103
+ include_chains=frozenset(str(name) for name in include),
2104
+ capture_content=bool(options.get("capture_content", True)),
2105
+ graph_callbacks=bool(options.get("graph_callbacks", True)),
2106
+ capture_limit=_capture_limit(options.get("capture_limit")),
2107
+ )
2108
+
2109
+
2110
+ def _capture_limit(value: Any) -> int:
2111
+ """Validate `capture_limit`, falling back rather than raising.
2112
+
2113
+ A bad value here must not take the integration down — `instrument()` with no
2114
+ name installs every detected adapter with the same options, so a typo'd or
2115
+ wrongly-typed value would otherwise break instrumentation for a framework
2116
+ the option was never meant for. Anything unusable is logged and ignored.
2117
+ """
2118
+ if value is None:
2119
+ return _FIELD_LIMIT
2120
+ try:
2121
+ limit = int(value)
2122
+ # OverflowError, because `int(float("inf"))` raises it and neither of the
2123
+ # other two catches it — so `capture_limit=inf`, the obvious spelling of
2124
+ # "capture everything", propagated out of `_read_options` and out of
2125
+ # `install()`, and `instrument()` skipped the adapter entirely while logging
2126
+ # that "the rest of your process is unaffected". Under
2127
+ # FAILPROOFAI_SDK_STRICT=1 it took application startup down. That is the one
2128
+ # outcome this function's docstring says must not happen.
2129
+ except (TypeError, ValueError, OverflowError):
2130
+ logger.warning(
2131
+ "failproofai_sdk: langchain capture_limit=%r is not an integer; using %d", value, _FIELD_LIMIT
2132
+ )
2133
+ return _FIELD_LIMIT
2134
+ if limit < 1:
2135
+ logger.warning(
2136
+ "failproofai_sdk: langchain capture_limit=%d must be >= 1; using %d", limit, _FIELD_LIMIT
2137
+ )
2138
+ return _FIELD_LIMIT
2139
+ return limit
2140
+
2141
+
2142
+ def _close_everything() -> None:
2143
+ """Close every span still open at teardown, leaves before agents."""
2144
+ with _STATE.lock:
2145
+ roots = {info.root for info in _STATE.runs.values() if info.root}
2146
+ for root in roots:
2147
+ _close_open_leaves(root)
2148
+ _STATE.tracker.close_open_agents(outcome="cancelled")
2149
+
2150
+
2151
+ adapter = _Adapter()