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,1588 @@
1
+ """LlamaIndex adapter — written against llama-index-core 0.14.23 (2026-07-29).
2
+
3
+ import failproofai_sdk
4
+ from llama_index.core.agent.workflow import FunctionAgent
5
+
6
+ failproofai_sdk.instrument("llama_index")
7
+ await FunctionAgent(name="researcher", tools=[...], llm=llm).run("...")
8
+
9
+ Everything is registered on the **root dispatcher** (`get_dispatcher()` with no
10
+ argument). Child dispatchers propagate upward, so one handler pair on the root
11
+ sees every span and every event in the process — no per-object wiring, no call
12
+ site changes.
13
+
14
+ What most tutorials (and several shipping vendors) get wrong here
15
+ -----------------------------------------------------------------
16
+ Each of these was verified against the installed package, not recalled:
17
+
18
+ 1. ``llama_index.core.instrumentation`` is a **shim** over the separately
19
+ released ``llama-index-instrumentation`` distribution. Import through the
20
+ ``llama_index.core`` path anyway — it is the stable name.
21
+
22
+ 2. **The classic agent events are dead.** ``AgentRunStepStartEvent``,
23
+ ``AgentChatWithStepStartEvent`` and ``AgentToolCallEvent`` still *import*,
24
+ but nothing has emitted them since the 0.13.0 agent rewrite, so an adapter
25
+ built on them records nothing and raises nothing. Agent structure now lives
26
+ in the **workflow** stream, and we reach the typed objects through the span
27
+ handler: a workflow step span carries its input event in
28
+ ``bound_args.arguments["ev"]`` and hands back its output event as the span
29
+ ``result``. That is where ``AgentInput`` / ``AgentSetup`` / ``AgentOutput`` /
30
+ ``ToolCall`` / ``ToolCallResult`` actually are.
31
+
32
+ 3. **The dispatcher swallows handler exceptions** with a bare
33
+ ``except BaseException: pass`` **and no logging** (verified in
34
+ ``llama_index_instrumentation.dispatcher``). A bug in a handler is therefore
35
+ completely invisible. Every entry point below is wrapped in ``_core.safe``,
36
+ whose whole job is to log the thing the dispatcher would have eaten.
37
+
38
+ 4. **Never read ``event.model_dict["model"]``.** PR #22130 (shipped in 0.14.23)
39
+ replaced ``to_dict()`` with ``to_payload()`` and the ``"model"`` key is gone;
40
+ reading it yields ``None`` silently. Traceloop and MLflow are broken on this
41
+ today. We read ``instance.metadata.model_name`` off the LLM span instead, and
42
+ only fall back to ``model_dict.get("model_name")``.
43
+
44
+ 5. ``new_span`` takes **``parent_span_id``** while ``span_enter`` takes
45
+ ``parent_id``. Getting that wrong gives a flat trace with no error.
46
+
47
+ 6. Teardown needs **in-place slice assignment** — ``add_span_handler`` does
48
+ ``self.span_handlers += [h]``, so a plain ``=`` rebinds a pydantic field and
49
+ other handlers can be lost.
50
+
51
+ Mapping
52
+ -------
53
+ ============================ ==========================================
54
+ LlamaIndex Failproof AI
55
+ ============================ ==========================================
56
+ ``Workflow.run`` root span session + ``agent_start``/``agent_end``
57
+ nested ``Workflow.run`` span nested ``agent_start``/``agent_end``
58
+ ``AgentWorkflow`` handoff nested ``agent_start``/``agent_end`` per
59
+ ``current_agent_name`` (see ``_sub_agent``)
60
+ workflow step span ``hook_triggered``/``hook_completed``
61
+ (``trigger_event="workflow_step"``)
62
+ ``SpanCancelledEvent`` ``outcome="cancelled"`` on the run or the step
63
+ ``LLMChatStart/EndEvent`` ``model_request``/``model_response``
64
+ (``request_id=event.span_id``)
65
+ ``FunctionTool.call`` span ``tool_use``/``tool_result``
66
+ ``RetrievalStart/EndEvent`` ``tool_use``/``tool_result``, output summarized
67
+ embeddings nothing, unless ``embeddings=True``
68
+ ``WaitingForEvent`` drop ``human_wait``+``agent_pause``, then
69
+ ``agent_resume``+``human_input`` on retry
70
+ ============================ ==========================================
71
+
72
+ ``agent_id`` is the ``FunctionAgent.name`` when there is one and the workflow
73
+ class name otherwise — never a span id. It is a ``LowCardinality`` column and
74
+ the primary dashboard facet; a uuid in it poisons that facet permanently.
75
+
76
+ ``AgentWorkflow`` needs one more step to keep that promise. It does **not** run
77
+ its agents as nested workflows — there is a single ``AgentWorkflow.run`` span
78
+ and the agents are steps inside it — so read off the span tree alone a two-agent
79
+ crew lands as one ``agent_id="AgentWorkflow"`` and the handoff is invisible. The
80
+ runtime does say who holds the turn, on every ``AgentInput``/``AgentSetup``/
81
+ ``AgentOutput`` a step is invoked with: ``current_agent_name``. Each distinct
82
+ name therefore opens a nested agent under the workflow and a handoff closes the
83
+ previous one, which is what puts ``researcher`` and ``analyst`` in the facet
84
+ rather than in a payload extra nobody can group by.
85
+
86
+ Token fidelity is genuinely lower on LlamaIndex than on the other frameworks
87
+ -----------------------------------------------------------------------------
88
+ There is no standard usage field. We try ``response.raw["usage"]``, then
89
+ ``raw["usage_metadata"]``, then ``response.additional_kwargs``, calling
90
+ ``model_dump()`` first when ``raw`` is a pydantic model. The top-level
91
+ ``input_tokens``/``output_tokens`` are set **only** when a key we recognise is
92
+ present; the raw dict always ships as ``usage`` so the server and the dashboard
93
+ can both fall back to it. A model integration that names its counters something
94
+ new will show a populated ``usage`` and blank token columns — that is the
95
+ honest outcome, and much better than a confident wrong number.
96
+
97
+ **Streaming has no usage at all, and that is the default path.**
98
+ ``FunctionAgent`` — the agent api LlamaIndex documents — calls
99
+ ``astream_chat``, and llama-index-llms-openai does not send
100
+ ``stream_options={"include_usage": True}``, so the provider never emits the
101
+ usage chunk and ``LLMChatEndEvent.response.raw`` has no ``usage`` key to find.
102
+ Verified against llama-index-core 0.14.23 by spying on the dispatcher directly:
103
+ every ``LLMChatEndEvent`` in a ``FunctionAgent`` run arrives with usage absent.
104
+ Nothing in this adapter can recover a number the framework never received. The
105
+ user-side fix is one argument, and it works::
106
+
107
+ OpenAI(model=..., additional_kwargs={"stream_options": {"include_usage": True}})
108
+
109
+ Non-streaming calls (``llm.chat`` / ``llm.achat``) extract usage correctly with
110
+ no extra configuration.
111
+
112
+ Known gap: human-in-the-loop is only visible when the wait happens **inside a
113
+ tool**. ``ctx.wait_for_event`` in a plain workflow step is caught by the runtime
114
+ before it reaches the dispatcher, so that step simply exits with ``None`` and
115
+ re-runs later; there is no signal to key a pause on. The FunctionAgent pattern
116
+ (the one LlamaIndex documents) waits inside a tool and is captured.
117
+ """
118
+
119
+ from __future__ import annotations
120
+
121
+ import logging
122
+ import re
123
+ import threading
124
+ import time
125
+ import uuid
126
+ from dataclasses import dataclass, field
127
+ from typing import Any, Callable
128
+
129
+ from failproofai_sdk.integrations import _compat, _core
130
+
131
+ logger = logging.getLogger("failproofai_sdk.integrations")
132
+
133
+ FRAMEWORK = "llama_index"
134
+ DIST = "llama-index-core"
135
+ EXTRA = "llamaindex"
136
+
137
+ # 0.14.23 is a CAPABILITY floor, not a guess: it is the release where
138
+ # `to_payload()` replaced `to_dict()` (PR #22130) and where the workflow event
139
+ # stream carries the typed agent events this adapter reads. Below it, model
140
+ # names and agent structure both go missing.
141
+ MIN_VERSION = "0.14.23"
142
+ BELOW_VERSION = "0.15"
143
+
144
+ # "{ClassName}.{method}-{uuid4}" — the dispatcher's span id format.
145
+ _SPAN_ID = re.compile(
146
+ r"^(?P<cls>[^.]+)\.(?P<method>.+)"
147
+ r"-[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
148
+ )
149
+
150
+ # LLM methods worth an event. `_`-prefixed methods (`_prepare_chat_with_tools`)
151
+ # are framework plumbing and are ignored everywhere.
152
+ _LLM_METHODS = frozenset(
153
+ {
154
+ "chat",
155
+ "achat",
156
+ "stream_chat",
157
+ "astream_chat",
158
+ "complete",
159
+ "acomplete",
160
+ "stream_complete",
161
+ "astream_complete",
162
+ "predict",
163
+ "apredict",
164
+ }
165
+ )
166
+
167
+ #: Default cap for a captured value. `_core.FIELD_LIMIT`, not the 512 this
168
+ #: carried: `_summarize` renders every user-visible payload in this adapter —
169
+ #: message content, the model's completion, tool output, the retrieval query,
170
+ #: workflow-step input/output and the agent goal — and 512 is a quarter of the
171
+ #: 2048 the LangChain adapter had just rejected as too small on the grounds that
172
+ #: "a real RAG prompt is well over 2 KiB". This is the RAG-first framework. It
173
+ #: is `capture_limit` on `instrument("llama_index", ...)` now, like the others.
174
+ _SUMMARY_LIMIT = _core.FIELD_LIMIT
175
+ _MAX_SPANS = 20_000
176
+ _MAX_NODES_IN_SUMMARY = 5
177
+
178
+ # Token key aliases, widest first. LlamaIndex normalises nothing, so this is the
179
+ # union of what the popular model integrations actually put in `raw`.
180
+ _INPUT_TOKEN_KEYS = (
181
+ "prompt_tokens",
182
+ "input_tokens",
183
+ "inputTokens",
184
+ "prompt_token_count",
185
+ "promptTokenCount",
186
+ )
187
+ _OUTPUT_TOKEN_KEYS = (
188
+ "completion_tokens",
189
+ "output_tokens",
190
+ "outputTokens",
191
+ "candidates_token_count",
192
+ "candidatesTokenCount",
193
+ )
194
+
195
+
196
+ # ---------------------------------------------------------------------------
197
+ # Helpers — no framework import in any of these
198
+ # ---------------------------------------------------------------------------
199
+
200
+ def _span_parts(span_id: str) -> tuple[str, str]:
201
+ """`("FunctionAgent", "run")` from a dispatcher span id. Never raises."""
202
+ match = _SPAN_ID.match(span_id or "")
203
+ if match is None:
204
+ text = str(span_id or "")
205
+ head = text.rsplit("-", 5)[0] if "-" in text else text
206
+ cls, _, method = head.partition(".")
207
+ return cls, method or head
208
+ return match.group("cls"), match.group("method")
209
+
210
+
211
+ def _summarize(value: Any, limit: int = _SUMMARY_LIMIT) -> str | None:
212
+ """A short, human-readable rendering. `None` in, `None` out."""
213
+ if value is None:
214
+ return None
215
+ try:
216
+ text = str(value)
217
+ except Exception:
218
+ text = repr(value)
219
+ if not text:
220
+ # Several workflow events override `__str__` to return the response
221
+ # text, which is empty on a pure tool-call turn — and a free-form
222
+ # `Event(prefix="ok?")` keeps its payload in `_data`, so both `str()`
223
+ # and `repr()` render it as `InputRequiredEvent()`. An empty string in
224
+ # the dashboard reads as "we captured nothing".
225
+ try:
226
+ data = getattr(value, "_data", None)
227
+ if isinstance(data, dict) and data:
228
+ text = f"{type(value).__name__}({data})"
229
+ else:
230
+ text = repr(value)
231
+ except Exception:
232
+ text = type(value).__name__
233
+ return _core.truncate(text, limit)
234
+
235
+
236
+ def _as_dict(value: Any) -> dict | None:
237
+ """A dict view of a pydantic model, a dict, or nothing."""
238
+ if isinstance(value, dict):
239
+ return value
240
+ dump = getattr(value, "model_dump", None)
241
+ if callable(dump):
242
+ try:
243
+ dumped = dump()
244
+ except Exception:
245
+ return None
246
+ return dumped if isinstance(dumped, dict) else None
247
+ return None
248
+
249
+
250
+ def _first_int(source: dict, keys: tuple[str, ...]) -> int | None:
251
+ for key in keys:
252
+ value = source.get(key)
253
+ if isinstance(value, bool):
254
+ continue
255
+ if isinstance(value, int):
256
+ return value
257
+ if isinstance(value, float) and value.is_integer():
258
+ return int(value)
259
+ # Bedrock hands back list-valued counts on some models.
260
+ if isinstance(value, (list, tuple)) and len(value) == 1:
261
+ inner = value[0]
262
+ if isinstance(inner, int) and not isinstance(inner, bool):
263
+ return inner
264
+ return None
265
+
266
+
267
+ def extract_usage(response: Any) -> tuple[dict | None, int | None, int | None]:
268
+ """`(usage_dict, input_tokens, output_tokens)` from a ChatResponse.
269
+
270
+ Deliberately conservative: the token ints are returned **only** when a key
271
+ we recognise is present. Everything found ships as `usage` regardless, so a
272
+ model whose counters we cannot name still reports something the server's
273
+ summary and the dashboard can fall back to.
274
+ """
275
+ if response is None:
276
+ return None, None, None
277
+ raw = _as_dict(getattr(response, "raw", None)) or {}
278
+ additional = getattr(response, "additional_kwargs", None)
279
+ additional = additional if isinstance(additional, dict) else {}
280
+
281
+ usage: dict | None = None
282
+ for candidate in (raw.get("usage"), raw.get("usage_metadata"), additional.get("usage")):
283
+ as_dict = _as_dict(candidate)
284
+ if as_dict:
285
+ usage = as_dict
286
+ break
287
+ if usage is None and any(k in additional for k in _INPUT_TOKEN_KEYS + _OUTPUT_TOKEN_KEYS):
288
+ usage = {
289
+ k: v for k, v in additional.items() if k in _INPUT_TOKEN_KEYS + _OUTPUT_TOKEN_KEYS
290
+ }
291
+ if not usage:
292
+ return None, None, None
293
+ return usage, _first_int(usage, _INPUT_TOKEN_KEYS), _first_int(usage, _OUTPUT_TOKEN_KEYS)
294
+
295
+
296
+ def _messages(items: Any, limit: int = _SUMMARY_LIMIT) -> list[dict] | None:
297
+ """ChatMessages -> the list-of-dicts `model_request(messages=...)` wants."""
298
+ if not isinstance(items, (list, tuple)):
299
+ return None
300
+ out: list[dict] = []
301
+ for item in items:
302
+ role = getattr(item, "role", None)
303
+ out.append(
304
+ {
305
+ "role": getattr(role, "value", None) or str(role or "user"),
306
+ "content": _summarize(getattr(item, "content", None) or "", limit),
307
+ }
308
+ )
309
+ return out or None
310
+
311
+
312
+ def summarize_nodes(nodes: Any) -> dict:
313
+ """A retrieval result small enough to store.
314
+
315
+ Retrieved documents are the largest strings in the process and the payload
316
+ is not a promoted column, so querying it means `JSONExtract` over the whole
317
+ blob. We keep the count, the scores and a prefix of the top few.
318
+ """
319
+ items = list(nodes) if isinstance(nodes, (list, tuple)) else []
320
+ top = []
321
+ for node in items[:_MAX_NODES_IN_SUMMARY]:
322
+ inner = getattr(node, "node", node)
323
+ text = None
324
+ getter = getattr(inner, "get_content", None)
325
+ if callable(getter):
326
+ try:
327
+ text = getter()
328
+ except Exception:
329
+ text = None
330
+ if text is None:
331
+ text = getattr(inner, "text", None)
332
+ top.append(
333
+ {
334
+ "id": _summarize(getattr(inner, "node_id", None), 128),
335
+ "score": getattr(node, "score", None),
336
+ "text": _core.truncate(str(text or ""), 200),
337
+ }
338
+ )
339
+ return {"num_nodes": len(items), "top": top}
340
+
341
+
342
+ def _error_text(exc: BaseException) -> str:
343
+ return f"{type(exc).__name__}: {exc}"
344
+
345
+
346
+ def _is_waiting(exc: BaseException | None) -> bool:
347
+ """True for the runtime's `WaitingForEvent` — a PAUSE, never an error.
348
+
349
+ Name-based on purpose. `WaitingForEvent` lives in
350
+ `workflows.runtime.types.results`, is not re-exported from
351
+ `workflows.errors`, and has moved before; an `isinstance` against an import
352
+ that quietly failed would go always-False and turn every human-in-the-loop
353
+ pause into a red error event, which is exactly the failure this adapter
354
+ exists to avoid.
355
+ """
356
+ if exc is None:
357
+ return False
358
+ return any(cls.__name__ == "WaitingForEvent" for cls in type(exc).__mro__)
359
+
360
+
361
+ def _is_cancellation(exc: BaseException | None) -> bool:
362
+ if exc is None:
363
+ return False
364
+ names = {cls.__name__ for cls in type(exc).__mro__}
365
+ return bool(names & {"CancelledError", "GeneratorExit"})
366
+
367
+
368
+ # ---------------------------------------------------------------------------
369
+ # State
370
+ # ---------------------------------------------------------------------------
371
+
372
+ @dataclass(frozen=True)
373
+ class _Span:
374
+ span_id: str
375
+ parent_id: str | None
376
+ run_id: str | None # the enclosing agent span, or None if we never saw one
377
+ kind: str # agent | step | tool | model | retrieval | embedding | other
378
+ cls: str # the class half of the span id
379
+ name: str # the method half
380
+ started: float
381
+
382
+
383
+ @dataclass
384
+ class _Leaf:
385
+ """An emitted opener with no closer yet. Invariant 4 lives here."""
386
+
387
+ kind: str # tool | model | retrieval | embedding
388
+ span_id: str
389
+ parent_id: str | None
390
+ name: str
391
+ call_id: str
392
+ started: float
393
+ model: str | None = None
394
+
395
+
396
+ @dataclass
397
+ class _Run:
398
+ span_id: str
399
+ agent_id: str
400
+ open_leaves: dict[str, _Leaf] = field(default_factory=dict)
401
+ pauses: dict[str, str] = field(default_factory=dict) # waiter_id -> pause_id
402
+ used_tool_ids: dict[str, int] = field(default_factory=dict)
403
+ errors: int = 0
404
+ # The `AgentWorkflow` sub-agent currently holding the turn, if any. See
405
+ # `_sub_agent`. Only ever set on a workflow run's own `_Run`; a sub-agent's
406
+ # `_Run` never opens a sub-agent of its own.
407
+ sub_key: str | None = None
408
+ sub_name: str | None = None
409
+ sub_seq: int = 0
410
+
411
+
412
+ def _capture_limit(value: Any) -> int:
413
+ """Validate `capture_limit`, falling back rather than raising.
414
+
415
+ Mirrors the LangChain adapter: `instrument()` with no name installs every
416
+ detected adapter with the same options, so a value meant for one framework
417
+ must never take another one down. `OverflowError` is in the tuple because
418
+ `int(float("inf"))` raises it and `inf` is the obvious spelling of "capture
419
+ everything".
420
+ """
421
+ if value is None:
422
+ return _core.FIELD_LIMIT
423
+ try:
424
+ limit = int(value)
425
+ except (TypeError, ValueError, OverflowError):
426
+ logger.warning(
427
+ "failproofai_sdk: llama_index capture_limit=%r is not an integer; using %d",
428
+ value,
429
+ _core.FIELD_LIMIT,
430
+ )
431
+ return _core.FIELD_LIMIT
432
+ if limit < 1:
433
+ logger.warning(
434
+ "failproofai_sdk: llama_index capture_limit=%d must be >= 1; using %d",
435
+ limit,
436
+ _core.FIELD_LIMIT,
437
+ )
438
+ return _core.FIELD_LIMIT
439
+ return limit
440
+
441
+
442
+ class _State:
443
+ """Everything the two handlers share. One lock, no contextvars.
444
+
445
+ A span's start and its end are separate dispatcher calls that may land on
446
+ different tasks, so `ContextVar.reset(token)` is unusable here (it raises
447
+ across tasks as well as threads). Identity therefore comes from
448
+ `_core.RunTracker`, which passes `session_id=`/`agent_id=` explicitly.
449
+ """
450
+
451
+ def __init__(self, **options: Any) -> None:
452
+ self.embeddings = bool(options.get("embeddings", False))
453
+ self.steps = bool(options.get("steps", True))
454
+ self.capture_messages = bool(options.get("capture_messages", True))
455
+ self.stale_after = float(options.get("stale_after", 600.0))
456
+ self.reaper_interval = float(options.get("reaper_interval", 30.0))
457
+ self.capture_limit = _capture_limit(options.get("capture_limit"))
458
+
459
+ self.tracker = _core.RunTracker(
460
+ FRAMEWORK,
461
+ base_fields=_core.framework_fields(FRAMEWORK, DIST),
462
+ field_limit=self.capture_limit,
463
+ )
464
+ self._lock = threading.RLock()
465
+ self._spans: dict[str, _Span] = {}
466
+ self._runs: dict[str, _Run] = {}
467
+ # Per-instance, NOT class attributes: two _State objects (an
468
+ # install/uninstall cycle, or a test) must not share a span table.
469
+ self._step_inputs: dict[str, Any] = {}
470
+ self._model_names: dict[str, str] = {}
471
+ # span_id -> the run that owns its open leaf. A parked streaming span
472
+ # has already EXITED (and been forgotten from `_spans`) by the time its
473
+ # LLMChatEndEvent arrives, so the leaf cannot be found by walking the
474
+ # span tree — without this, every streaming model_response would be
475
+ # deferred to teardown and report the whole run as its duration.
476
+ self._leaf_run: dict[str, str] = {}
477
+ # Span ids the runtime told us were CANCELLED. `SpanCancelledEvent` is
478
+ # dispatched immediately before the matching `span_exit`, which the
479
+ # runtime deliberately performs with `result=None` and no error ("exit
480
+ # the span cleanly so it shows as OK rather than ERROR in traces" —
481
+ # workflows/runtime/types/step_function.py). Without this mark a
482
+ # user-pressed stop button is indistinguishable from a completed run,
483
+ # and every cancellation is reported as a success.
484
+ self._cancelled: set[str] = set()
485
+ self._stop = threading.Event()
486
+ self._reaper: threading.Thread | None = None
487
+
488
+ # -- bookkeeping ------------------------------------------------------
489
+
490
+ def capture(self, value, limit: int | None = None):
491
+ """One gate for every payload this adapter records.
492
+
493
+ `capture_messages` used to be consulted in exactly ONE place — the
494
+ messages and system prompt on `model_start` — while the model's
495
+ completion, every tool call's arguments, every tool's return value,
496
+ every workflow-step input and output, every retrieval query and the
497
+ agent's goal went to the spool regardless. Prompts stopped being
498
+ recorded, so the setting looked like it had worked.
499
+
500
+ That is the switch `docs/start/integrations/llamaindex.mdx` presents as
501
+ the control for regulated data, and `collector.redact` explicitly does
502
+ not apply to SDK events — so there is no second line of defence behind
503
+ it. The sibling adapters route every payload through one helper
504
+ (LangChain's `_shrink`, Pydantic AI's `capture_content` checks); this is
505
+ that helper.
506
+ """
507
+ if not self.capture_messages:
508
+ return None
509
+ return _summarize(value, limit if limit is not None else self.capture_limit)
510
+ def _remember(self, span: _Span) -> None:
511
+ while len(self._spans) >= _MAX_SPANS:
512
+ self._spans.pop(next(iter(self._spans)), None)
513
+ self._spans[span.span_id] = span
514
+
515
+ def _run_of(self, span_id: str | None) -> _Run | None:
516
+ if span_id is None:
517
+ return None
518
+ span = self._spans.get(span_id)
519
+ if span is None or span.run_id is None:
520
+ return None
521
+ return self._runs.get(span.run_id)
522
+
523
+ def _tool_call_id(self, run: _Run | None, raw_id: str | None, span_id: str) -> str:
524
+ """The framework's own tool id, kept verbatim where it is unambiguous.
525
+
526
+ Passing the framework id through unchanged is what makes our events line
527
+ up with the customer's provider logs. But a human-in-the-loop tool is
528
+ re-run with the *same* `tool_id` after the human answers, and two pairs
529
+ sharing a `tool_call_id` in one session would pair wrongly, so a repeat
530
+ gets a `#n` suffix rather than a collision.
531
+ """
532
+ if not raw_id:
533
+ return span_id
534
+ if run is None:
535
+ return raw_id
536
+ seen = run.used_tool_ids.get(raw_id, 0)
537
+ run.used_tool_ids[raw_id] = seen + 1
538
+ return raw_id if seen == 0 else f"{raw_id}#{seen}"
539
+
540
+ # -- spans ------------------------------------------------------------
541
+
542
+ def span_enter(
543
+ self,
544
+ span_id: str,
545
+ bound_args: Any,
546
+ instance: Any,
547
+ parent_span_id: str | None,
548
+ tags: dict | None,
549
+ ) -> None:
550
+ cls_name, method = _span_parts(span_id)
551
+ arguments = getattr(bound_args, "arguments", None) or {}
552
+ kind = self._classify(method, instance, parent_span_id)
553
+
554
+ with self._lock:
555
+ parent = self._spans.get(parent_span_id) if parent_span_id else None
556
+ run_id = span_id if kind == "agent" else (parent.run_id if parent else None)
557
+ # An `AgentWorkflow` step belongs to the sub-agent holding the turn,
558
+ # not to the workflow. Resolving that BEFORE the span is remembered
559
+ # is what makes everything underneath it — the step's own hook pair,
560
+ # its tool calls, its LLM calls — resolve to the sub-agent too,
561
+ # because they all reach identity through this span's parent chain.
562
+ parent_key = parent_span_id
563
+ if kind == "step":
564
+ sub = self._sub_agent(parent, arguments)
565
+ if sub is not None:
566
+ parent_key = sub
567
+ run_id = sub
568
+ # Link every span, including the ones we emit nothing for: a chain
569
+ # of "other" spans between a leaf and its agent must not break
570
+ # identity resolution.
571
+ self.tracker.link(span_id, parent_key)
572
+ self._remember(
573
+ _Span(
574
+ span_id=span_id,
575
+ parent_id=parent_key,
576
+ run_id=run_id,
577
+ kind=kind,
578
+ cls=cls_name,
579
+ name=method,
580
+ started=time.monotonic(),
581
+ )
582
+ )
583
+ if kind == "agent":
584
+ self._start_agent(span_id, parent_span_id, instance, cls_name, arguments)
585
+ elif kind == "step":
586
+ self._start_step(span_id, parent_key, method, arguments)
587
+ elif kind == "tool":
588
+ self._start_tool(span_id, parent_span_id, instance, arguments)
589
+ elif kind == "model":
590
+ # The model leaf opens on LLMChatStartEvent, not here — the
591
+ # event is what carries the messages. All we need from the span
592
+ # is the model NAME, which the event no longer has (see the
593
+ # `to_payload()` note at the top).
594
+ pass
595
+
596
+ def span_exit(self, span_id: str, bound_args: Any, instance: Any, result: Any) -> None:
597
+ with self._lock:
598
+ span = self._spans.pop(span_id, None)
599
+ cancelled = span_id in self._cancelled
600
+ self._cancelled.discard(span_id)
601
+ if span is None:
602
+ return
603
+ if span.kind == "agent":
604
+ self._end_agent(
605
+ span,
606
+ result=result,
607
+ outcome="cancelled" if cancelled else "success",
608
+ )
609
+ elif span.kind == "step":
610
+ self._end_step(span, result=result, cancelled=cancelled)
611
+ elif span.kind == "tool":
612
+ self._close_leaf(span, output=result)
613
+ # A streaming LLM span exits the moment the generator is created,
614
+ # long before the stream is consumed. Its model leaf stays parked
615
+ # until LLMChatEndEvent, teardown, or the reaper.
616
+
617
+ def span_drop(self, span_id: str, bound_args: Any, instance: Any, err: BaseException | None) -> None:
618
+ with self._lock:
619
+ span = self._spans.pop(span_id, None)
620
+ self._cancelled.discard(span_id)
621
+ if span is None:
622
+ return
623
+ if _is_waiting(err):
624
+ self._pause(span, err)
625
+ return
626
+ cancelled = _is_cancellation(err)
627
+ if span.kind == "agent":
628
+ self._end_agent(
629
+ span,
630
+ result=None,
631
+ outcome="cancelled" if cancelled else "failed",
632
+ error=None if cancelled else err,
633
+ )
634
+ elif span.kind == "step":
635
+ self._end_step(span, result=None, error=None if cancelled else err)
636
+ else:
637
+ self._close_leaf(span, output=None, error=None if cancelled else err)
638
+
639
+ def _classify(self, method: str, instance: Any, parent_span_id: str | None) -> str:
640
+ if method.startswith("_"):
641
+ return "other"
642
+ bases = _bases()
643
+ workflow = bases.get("Workflow")
644
+ if workflow is not None and isinstance(instance, workflow) and method == "run":
645
+ # Root or nested: a compiled sub-workflow is a nested agent, which
646
+ # is what the framework itself calls it.
647
+ return "agent"
648
+ if parent_span_id is None and instance is not None:
649
+ # Any other top-level instrumented call — `query_engine.query()`,
650
+ # a bare `llm.chat()` — opens the session and becomes its root
651
+ # agent, named after its class. The alternative is emitting leaves
652
+ # with no open agent above them, and the dashboard answers that by
653
+ # synthesizing a root span that stays `ongoing` forever.
654
+ return "agent"
655
+ if instance is None:
656
+ # A workflow step: the runtime wraps the step function, so there is
657
+ # no instance, and its parent is the workflow run span. Checking the
658
+ # parent (rather than the `llamaindex.step.*` tags) is what keeps
659
+ # this precise — those tags are inherited by every child span,
660
+ # including the LLM call inside the step.
661
+ parent = self._spans.get(parent_span_id) if parent_span_id else None
662
+ if parent is not None and parent.kind == "agent":
663
+ return "step"
664
+ return "other"
665
+ for kind, key in (
666
+ ("tool", "BaseTool"),
667
+ ("model", "BaseLLM"),
668
+ ("retrieval", "BaseRetriever"),
669
+ ("embedding", "BaseEmbedding"),
670
+ ):
671
+ base = bases.get(key)
672
+ if base is not None and isinstance(instance, base):
673
+ if kind == "model" and method not in _LLM_METHODS:
674
+ return "other"
675
+ return kind
676
+ return "other"
677
+
678
+ # -- agents -----------------------------------------------------------
679
+
680
+ def _start_agent(
681
+ self,
682
+ span_id: str,
683
+ parent_span_id: str | None,
684
+ instance: Any,
685
+ cls_name: str,
686
+ arguments: dict,
687
+ ) -> None:
688
+ # `FunctionAgent.name` when there is one, the class name otherwise.
689
+ # Never the span id: `agent_id` is a LowCardinality column and the
690
+ # global dashboard facet.
691
+ raw_name = getattr(instance, "name", None)
692
+ label = raw_name if isinstance(raw_name, str) and raw_name else cls_name
693
+ start_event = arguments.get("start_event")
694
+ goal = self.capture(getattr(start_event, "user_msg", None) or start_event)
695
+
696
+ identity = self.tracker.start_agent(
697
+ span_id,
698
+ agent_id=label,
699
+ parent_key=parent_span_id,
700
+ goal=goal,
701
+ **_core.fw_fields(
702
+ span_id=span_id,
703
+ workflow=cls_name,
704
+ agent_name=raw_name if isinstance(raw_name, str) else None,
705
+ ),
706
+ )
707
+ # `start_agent` always returns a real `agent_id` (it normalizes and falls
708
+ # back to the default); the `| None` is only on the shared `Identity`.
709
+ self._runs[span_id] = _Run(
710
+ span_id=span_id, agent_id=identity.agent_id or _core.DEFAULT_AGENT_ID
711
+ )
712
+
713
+ def _end_agent(
714
+ self,
715
+ span: _Span,
716
+ *,
717
+ result: Any,
718
+ outcome: str,
719
+ error: BaseException | None = None,
720
+ ) -> None:
721
+ run = self._runs.pop(span.span_id, None)
722
+ if run is not None:
723
+ # Inner-first: a sub-agent still holding the turn is closed (and its
724
+ # error count folded into ours) before we decide whether this run
725
+ # owns the failure, or it would outlive the workflow that opened it
726
+ # and render `ongoing` forever.
727
+ self._close_sub_agent(run, outcome=outcome)
728
+ # Invariant 4: `agent_end` force-closes open pauses but NOT open
729
+ # tools or models. A run that dies holding one leaves the session
730
+ # `ongoing` forever.
731
+ self._close_all_leaves(run, reason="run_ended")
732
+ if error is not None and run.errors == 0:
733
+ # Nothing below us reported this, so the run itself owns it.
734
+ # If a leaf already did, a second event would double-count on
735
+ # `sessionSummary.errorCount`.
736
+ self.tracker.emit(
737
+ "error",
738
+ span.span_id,
739
+ parent_key=span.parent_id,
740
+ error_type=type(error).__name__,
741
+ message=str(error) or type(error).__name__,
742
+ **_core.fw_fields(span_id=span.span_id),
743
+ )
744
+ elif error is not None:
745
+ self.tracker.emit(
746
+ "error",
747
+ span.span_id,
748
+ parent_key=span.parent_id,
749
+ error_type=type(error).__name__,
750
+ message=str(error) or type(error).__name__,
751
+ )
752
+ # The run's final answer — payload like any other, and the last one that
753
+ # was still going out under `capture_messages=False`.
754
+ summary = self.capture(getattr(result, "result", None) if result is not None else None)
755
+ if summary is None and error is not None:
756
+ # `summary` is a promoted column and `agent_end` is where the
757
+ # dashboard reads a run's outcome. Without this a failed run says
758
+ # only "failed": the reason lives on the failing step's
759
+ # `hook_completed`, which is payload-only, and is gone entirely when
760
+ # `steps=False`. The sibling LangChain adapter already does this.
761
+ summary = _error_text(error)
762
+ self.tracker.end_agent(
763
+ span.span_id,
764
+ outcome=outcome,
765
+ summary=summary,
766
+ **_core.fw_fields(span_id=span.span_id),
767
+ )
768
+
769
+ # -- AgentWorkflow sub-agents -------------------------------------------
770
+
771
+ def _sub_agent(self, parent: _Span | None, arguments: dict) -> str | None:
772
+ """The nested agent an `AgentWorkflow` step belongs to, or `None`.
773
+
774
+ `AgentWorkflow` does NOT run its `FunctionAgent`s as nested workflows —
775
+ there is one `AgentWorkflow.run` span and the agents are steps inside
776
+ it. Read off the span tree alone, a handoff is therefore invisible: a
777
+ two-agent crew lands as one `agent_id="AgentWorkflow"` and the names the
778
+ user actually facets by (`researcher`, `analyst`) never reach the
779
+ column. The framework does tell us, on every `AgentInput`/`AgentSetup`/
780
+ `AgentOutput` the steps are invoked with: `current_agent_name`.
781
+
782
+ So each distinct `current_agent_name` opens a nested agent under the
783
+ workflow, and a handoff closes the previous one. The name is **sticky**:
784
+ `ToolCall` carries no `current_agent_name`, so a `call_tool` step keeps
785
+ whichever agent asked for the tool, which is the correct attribution.
786
+
787
+ A standalone `FunctionAgent.run` runs those same steps with its own name
788
+ in `current_agent_name`, which is already this run's `agent_id` — hence
789
+ the `name != root.agent_id` guard, without which every single-agent run
790
+ would nest an agent inside an identically-named agent.
791
+ """
792
+ if parent is None or parent.kind != "agent" or parent.run_id is None:
793
+ return None
794
+ root = self._runs.get(parent.run_id)
795
+ if root is None:
796
+ return None
797
+ name = getattr(arguments.get("ev"), "current_agent_name", None)
798
+ if isinstance(name, str) and name and name != root.agent_id and name != root.sub_name:
799
+ self._close_sub_agent(root)
800
+ root.sub_seq += 1
801
+ # Keyed per turn, not per name: an A -> B -> A handoff must not
802
+ # reuse the key of the A we already ended.
803
+ key = f"{root.span_id}#sub{root.sub_seq}"
804
+ identity = self.tracker.start_agent(
805
+ key,
806
+ agent_id=name,
807
+ parent_key=root.span_id,
808
+ **_core.fw_fields(
809
+ span_id=root.span_id, agent_name=name, workflow=root.agent_id
810
+ ),
811
+ )
812
+ self._runs[key] = _Run(span_id=key, agent_id=identity.agent_id or name)
813
+ root.sub_key, root.sub_name = key, name
814
+ return root.sub_key
815
+
816
+ def _close_sub_agent(self, root: _Run, *, outcome: str = "success") -> None:
817
+ key, root.sub_key, root.sub_name = root.sub_key, None, None
818
+ if key is None:
819
+ return
820
+ sub = self._runs.pop(key, None)
821
+ if sub is not None:
822
+ self._close_all_leaves(sub, reason="agent_switch")
823
+ # The workflow still has to know something below it failed, or
824
+ # `_end_agent` would emit a second `error` event for a failure a
825
+ # sub-agent's leaf already reported.
826
+ root.errors += sub.errors
827
+ self.tracker.end_agent(
828
+ key, outcome=outcome, **_core.fw_fields(span_id=root.span_id)
829
+ )
830
+
831
+ # -- steps ------------------------------------------------------------
832
+
833
+ def _start_step(self, span_id: str, parent_span_id: str | None, method: str, arguments: dict) -> None:
834
+ if not self.steps:
835
+ return
836
+ incoming = arguments.get("ev")
837
+ self.tracker.emit(
838
+ "hook_triggered",
839
+ span_id,
840
+ parent_key=parent_span_id,
841
+ hook_name=method,
842
+ hook_id=span_id,
843
+ trigger_event="workflow_step",
844
+ input=self.capture(incoming),
845
+ **_core.fw_fields(
846
+ step=method,
847
+ input_event=type(incoming).__name__ if incoming is not None else None,
848
+ agent_name=getattr(incoming, "current_agent_name", None),
849
+ ),
850
+ )
851
+
852
+ def _end_step(
853
+ self,
854
+ span: _Span,
855
+ *,
856
+ result: Any,
857
+ error: BaseException | None = None,
858
+ cancelled: bool = False,
859
+ ) -> None:
860
+ if not self.steps:
861
+ return
862
+ run = self._runs.get(span.run_id) if span.run_id else None
863
+ if error is not None and run is not None:
864
+ run.errors += 1
865
+ if error is not None:
866
+ outcome = "failed"
867
+ elif cancelled:
868
+ # A cancelled step exits with `result=None` and no error, so without
869
+ # the mark it reports `success` with an empty output.
870
+ outcome = "cancelled"
871
+ else:
872
+ outcome = "success"
873
+ self.tracker.emit(
874
+ "hook_completed",
875
+ span.span_id,
876
+ parent_key=span.parent_id,
877
+ hook_name=span.name,
878
+ hook_id=span.span_id,
879
+ outcome=outcome,
880
+ output=self.capture(result),
881
+ error=_error_text(error) if error is not None else None,
882
+ **_core.fw_fields(
883
+ step=span.name,
884
+ output_event=type(result).__name__ if result is not None else None,
885
+ agent_name=getattr(result, "current_agent_name", None),
886
+ ),
887
+ )
888
+
889
+ # -- tools ------------------------------------------------------------
890
+
891
+ def _start_tool(self, span_id: str, parent_span_id: str | None, instance: Any, arguments: dict) -> None:
892
+ run = self._run_of(span_id)
893
+ metadata = getattr(instance, "metadata", None)
894
+ tool_name = getattr(metadata, "name", None) or type(instance).__name__
895
+
896
+ # The enclosing `call_tool` step carries the typed `ToolCall`, which is
897
+ # where the LLM's own tool id lives. Reusing it keeps our events lined
898
+ # up with the provider's.
899
+ raw_id = None
900
+ parent = self._spans.get(parent_span_id) if parent_span_id else None
901
+ if parent is not None and parent.kind == "step":
902
+ raw_id = getattr(self._step_input(parent_span_id), "tool_id", None)
903
+ call_id = self._tool_call_id(run, raw_id, span_id)
904
+
905
+ # A resumed run announces itself by re-entering the tool that paused,
906
+ # so `agent_resume`/`human_input` go out BEFORE this attempt's
907
+ # `tool_use` — that keeps the paused window (which the graph measures
908
+ # from `agent_pause` to `agent_resume`) free of the retry.
909
+ if run is not None and run.pauses:
910
+ self._resume(run, span_id, parent_span_id)
911
+
912
+ kwargs = arguments.get("kwargs")
913
+ self._open_leaf(
914
+ run,
915
+ _Leaf(
916
+ kind="tool",
917
+ span_id=span_id,
918
+ parent_id=parent_span_id,
919
+ name=tool_name,
920
+ call_id=call_id,
921
+ started=time.monotonic(),
922
+ ),
923
+ )
924
+ self.tracker.emit(
925
+ "tool_use",
926
+ span_id,
927
+ parent_key=parent_span_id,
928
+ tool_name=tool_name,
929
+ tool_call_id=call_id,
930
+ input=(kwargs if isinstance(kwargs, dict) else None) if self.capture_messages else None,
931
+ **_core.fw_fields(span_id=span_id, tool_id=raw_id),
932
+ )
933
+
934
+ def _step_input(self, span_id: str | None) -> Any:
935
+ """The typed workflow event a step was invoked with.
936
+
937
+ This is the whole point of reading the span handler rather than the
938
+ (dead) agent events: `bound_args.arguments["ev"]` on a step span is the
939
+ real `ToolCall` / `AgentInput` / `AgentOutput` object.
940
+ """
941
+ return self._step_inputs.get(span_id) if span_id else None
942
+
943
+ # -- leaves -----------------------------------------------------------
944
+
945
+ def _open_leaf(self, run: _Run | None, leaf: _Leaf) -> None:
946
+ if run is not None:
947
+ run.open_leaves[leaf.span_id] = leaf
948
+ self._leaf_run[leaf.span_id] = run.span_id
949
+
950
+ def _take_leaf(self, span_id: str) -> tuple[_Run | None, _Leaf | None]:
951
+ """Detach an open leaf by span id, wherever its run is."""
952
+ run = self._runs.get(self._leaf_run.pop(span_id, "") or "")
953
+ if run is None:
954
+ return None, None
955
+ return run, run.open_leaves.pop(span_id, None)
956
+
957
+ def _close_leaf(
958
+ self,
959
+ span: _Span,
960
+ *,
961
+ output: Any,
962
+ error: BaseException | None = None,
963
+ ) -> None:
964
+ run, leaf = self._take_leaf(span.span_id)
965
+ if leaf is None:
966
+ return
967
+ if error is not None and run is not None:
968
+ run.errors += 1
969
+ self._emit_leaf_close(
970
+ leaf,
971
+ output=output,
972
+ error=_error_text(error) if error is not None else None,
973
+ )
974
+
975
+ def _emit_leaf_close(
976
+ self,
977
+ leaf: _Leaf,
978
+ *,
979
+ output: Any,
980
+ error: str | None,
981
+ reason: str | None = None,
982
+ ) -> None:
983
+ if leaf.kind == "model":
984
+ usage, input_tokens, output_tokens = extract_usage(output)
985
+ message = getattr(output, "message", None)
986
+ content = getattr(message, "content", None)
987
+ if content is None:
988
+ content = getattr(output, "text", None)
989
+ self.tracker.emit(
990
+ "model_response",
991
+ leaf.span_id,
992
+ parent_key=leaf.parent_id,
993
+ model=leaf.model,
994
+ request_id=leaf.call_id,
995
+ role=getattr(getattr(message, "role", None), "value", None),
996
+ content=self.capture(content),
997
+ input_tokens=input_tokens,
998
+ output_tokens=output_tokens,
999
+ usage=usage,
1000
+ error=error,
1001
+ # Invariant 3: ALWAYS an int. `durationOf` prefers the closing
1002
+ # event's value over end-start, which is what keeps model
1003
+ # durations right even when the dashboard's FIFO pairing
1004
+ # brackets the wrong pair. A float silently NULLs the column.
1005
+ duration_ms=_core.ms(time.monotonic() - leaf.started),
1006
+ **_core.fw_fields(span_id=leaf.span_id, closed_by=reason),
1007
+ )
1008
+ return
1009
+ # tool / retrieval / embedding all close as a tool_result. `duration_ms`
1010
+ # is auto-computed from the pending `tool_call_id` and is REJECTED if we
1011
+ # pass it, so it is deliberately absent here.
1012
+ payload: Any
1013
+ if leaf.kind == "retrieval":
1014
+ payload = (summarize_nodes(output) if error is None else None) if self.capture_messages else None
1015
+ else:
1016
+ payload = self.capture(getattr(output, "content", None) or output)
1017
+ self.tracker.emit(
1018
+ "tool_result",
1019
+ leaf.span_id,
1020
+ parent_key=leaf.parent_id,
1021
+ tool_name=leaf.name,
1022
+ tool_call_id=leaf.call_id,
1023
+ output=payload,
1024
+ error=error,
1025
+ **_core.fw_fields(span_id=leaf.span_id, closed_by=reason),
1026
+ )
1027
+
1028
+ def _close_all_leaves(self, run: _Run, *, reason: str) -> None:
1029
+ for leaf in list(run.open_leaves.values()):
1030
+ run.open_leaves.pop(leaf.span_id, None)
1031
+ self._leaf_run.pop(leaf.span_id, None)
1032
+ self._emit_leaf_close(leaf, output=None, error=None, reason=reason)
1033
+
1034
+ # -- human in the loop -------------------------------------------------
1035
+
1036
+ def _pause(self, span: _Span, err: BaseException | None) -> None:
1037
+ """A `WaitingForEvent` drop: the run is waiting on a human.
1038
+
1039
+ Both pairs are emitted, in this order, because neither alone is enough:
1040
+ only `agent_pause`/`agent_resume` feeds the graph's paused time, and
1041
+ only `human_wait`/`human_input` carries the prompt and the pending-human
1042
+ badge.
1043
+ """
1044
+ run = self._runs.get(span.run_id) if span.run_id else None
1045
+ waiter = getattr(err, "add", None)
1046
+ waiter_id = getattr(waiter, "waiter_id", None) or uuid.uuid4().hex
1047
+ prompt = self.capture(getattr(waiter, "waiter_event", None))
1048
+ pause_id = f"{waiter_id}:{uuid.uuid4().hex[:8]}"
1049
+
1050
+ # The tool that paused will be re-run from scratch when the human
1051
+ # answers, so close its leaf now rather than leaving it open forever.
1052
+ # This is honest: LlamaIndex really does call the tool twice.
1053
+ if run is not None:
1054
+ self._leaf_run.pop(span.span_id, None)
1055
+ leaf = run.open_leaves.pop(span.span_id, None)
1056
+ if leaf is not None:
1057
+ self._emit_leaf_close(leaf, output=None, error=None, reason="human_wait")
1058
+ run.pauses[pause_id] = waiter_id
1059
+
1060
+ self.tracker.emit(
1061
+ "human_wait",
1062
+ span.span_id,
1063
+ parent_key=span.parent_id,
1064
+ input_id=pause_id,
1065
+ prompt=prompt,
1066
+ reason="workflow is waiting for a human response",
1067
+ **_core.fw_fields(waiter_id=waiter_id, span_id=span.span_id),
1068
+ )
1069
+ self.tracker.emit(
1070
+ "agent_pause",
1071
+ span.span_id,
1072
+ parent_key=span.parent_id,
1073
+ pause_id=pause_id,
1074
+ reason="human_input",
1075
+ **_core.fw_fields(waiter_id=waiter_id),
1076
+ )
1077
+
1078
+ def _resume(self, run: _Run, span_id: str, parent_id: str | None) -> None:
1079
+ for pause_id, waiter_id in list(run.pauses.items()):
1080
+ run.pauses.pop(pause_id, None)
1081
+ self.tracker.emit(
1082
+ "agent_resume",
1083
+ span_id,
1084
+ parent_key=parent_id,
1085
+ pause_id=pause_id,
1086
+ reason="human_input",
1087
+ **_core.fw_fields(waiter_id=waiter_id),
1088
+ )
1089
+ # `response` is left unset: the human's answer arrives as a
1090
+ # `HumanResponseEvent` sent straight into the workflow context,
1091
+ # which never reaches the dispatcher. It surfaces on the paired
1092
+ # `tool_result` instead.
1093
+ self.tracker.emit(
1094
+ "human_input",
1095
+ span_id,
1096
+ parent_key=parent_id,
1097
+ input_id=pause_id,
1098
+ **_core.fw_fields(waiter_id=waiter_id),
1099
+ )
1100
+
1101
+ # -- dispatcher events -------------------------------------------------
1102
+
1103
+ def model_start(self, span_id: str, messages: Any, model_dict: Any, prompt: Any = None) -> None:
1104
+ with self._lock:
1105
+ span = self._spans.get(span_id)
1106
+ run = self._run_of(span_id)
1107
+ parent_id = span.parent_id if span is not None else None
1108
+ model = self._model_name(span_id, model_dict)
1109
+ self._open_leaf(
1110
+ run,
1111
+ _Leaf(
1112
+ kind="model",
1113
+ span_id=span_id,
1114
+ parent_id=parent_id,
1115
+ name=model or "llm",
1116
+ call_id=span_id,
1117
+ started=time.monotonic(),
1118
+ model=model,
1119
+ ),
1120
+ )
1121
+ self.tracker.emit(
1122
+ "model_request",
1123
+ span_id,
1124
+ parent_key=parent_id,
1125
+ model=model,
1126
+ # `request_id` is the LLM span id. It is what pairs the two
1127
+ # model events in the dashboard's detail panel; no SDK set it
1128
+ # before this work, so nothing was pairing.
1129
+ request_id=span_id,
1130
+ messages=_messages(messages, self.capture_limit) if self.capture_messages else None,
1131
+ system=self.capture(prompt),
1132
+ **_core.fw_fields(span_id=span_id),
1133
+ )
1134
+
1135
+ def model_end(self, span_id: str, response: Any, error: str | None = None) -> None:
1136
+ with self._lock:
1137
+ run, leaf = self._take_leaf(span_id)
1138
+ if leaf is None:
1139
+ return
1140
+ if error is not None and run is not None:
1141
+ run.errors += 1
1142
+ self._emit_leaf_close(leaf, output=response, error=error)
1143
+
1144
+ def retrieval_start(self, span_id: str, query: Any) -> None:
1145
+ with self._lock:
1146
+ span = self._spans.get(span_id)
1147
+ run = self._run_of(span_id)
1148
+ parent_id = span.parent_id if span is not None else None
1149
+ name = span.cls if span is not None else "retriever"
1150
+ self._open_leaf(
1151
+ run,
1152
+ _Leaf(
1153
+ kind="retrieval",
1154
+ span_id=span_id,
1155
+ parent_id=parent_id,
1156
+ name=name,
1157
+ call_id=span_id,
1158
+ started=time.monotonic(),
1159
+ ),
1160
+ )
1161
+ self.tracker.emit(
1162
+ "tool_use",
1163
+ span_id,
1164
+ parent_key=parent_id,
1165
+ tool_name=name,
1166
+ tool_call_id=span_id,
1167
+ input=({"query": self.capture(query)} if self.capture_messages else None),
1168
+ **_core.fw_fields(span_id=span_id, kind="retrieval"),
1169
+ )
1170
+
1171
+ def retrieval_end(self, span_id: str, nodes: Any) -> None:
1172
+ with self._lock:
1173
+ _run, leaf = self._take_leaf(span_id)
1174
+ if leaf is None:
1175
+ return
1176
+ self._emit_leaf_close(leaf, output=nodes, error=None)
1177
+
1178
+ def cancel(self, span_id: str | None) -> None:
1179
+ """`SpanCancelledEvent` — remember it for the `span_exit` right behind it."""
1180
+ if span_id is None:
1181
+ return
1182
+ with self._lock:
1183
+ while len(self._cancelled) >= _MAX_SPANS:
1184
+ self._cancelled.discard(next(iter(self._cancelled)))
1185
+ self._cancelled.add(span_id)
1186
+
1187
+ def exception(self, span_id: str | None, exc: Any) -> None:
1188
+ """`ExceptionEvent` — close whatever leaf that span owns.
1189
+
1190
+ No standalone `error` event: the span that owns the failure reports it,
1191
+ and the enclosing agent reports it once more only if nothing below it
1192
+ did.
1193
+ """
1194
+ if span_id is None:
1195
+ return
1196
+ with self._lock:
1197
+ run, leaf = self._take_leaf(span_id)
1198
+ if leaf is None:
1199
+ return
1200
+ if run is not None:
1201
+ run.errors += 1
1202
+ text = _error_text(exc) if isinstance(exc, BaseException) else str(exc)
1203
+ self._emit_leaf_close(leaf, output=None, error=text)
1204
+
1205
+ def _model_name(self, span_id: str, model_dict: Any) -> str | None:
1206
+ """`instance.metadata.model_name`, captured when the span opened.
1207
+
1208
+ NOT `model_dict["model"]`: `to_payload()` replaced `to_dict()` in
1209
+ 0.14.23 and that key no longer exists, so reading it returns None
1210
+ silently — the bug Traceloop and MLflow are shipping today.
1211
+ """
1212
+ name = self._model_names.get(span_id)
1213
+ if name:
1214
+ return name
1215
+ if isinstance(model_dict, dict):
1216
+ candidate = model_dict.get("model_name")
1217
+ if isinstance(candidate, str) and candidate:
1218
+ return candidate
1219
+ return None
1220
+
1221
+ # -- reaper ------------------------------------------------------------
1222
+
1223
+ def sweep(self) -> int:
1224
+ """Close leaves nobody is going to close. Returns how many.
1225
+
1226
+ A streaming response that is never consumed produces an
1227
+ `LLMChatStartEvent` with no end, and its span has already exited. Left
1228
+ alone that is an open `model_request` and a session the dashboard shows
1229
+ as `ongoing` forever.
1230
+ """
1231
+ closed = 0
1232
+ cutoff = time.monotonic() - self.stale_after
1233
+ with self._lock:
1234
+ for run in list(self._runs.values()):
1235
+ for leaf in list(run.open_leaves.values()):
1236
+ if leaf.started > cutoff:
1237
+ continue
1238
+ run.open_leaves.pop(leaf.span_id, None)
1239
+ self._leaf_run.pop(leaf.span_id, None)
1240
+ self._emit_leaf_close(leaf, output=None, error=None, reason="stale")
1241
+ closed += 1
1242
+ return closed
1243
+
1244
+ def start_reaper(self) -> None:
1245
+ if self.reaper_interval <= 0 or self._reaper is not None:
1246
+ return
1247
+ thread = threading.Thread(
1248
+ target=self._reap_loop, name="failproofai_sdk-llamaindex-reaper", daemon=True
1249
+ )
1250
+ self._reaper = thread
1251
+ thread.start()
1252
+
1253
+ def _reap_loop(self) -> None:
1254
+ while not self._stop.wait(self.reaper_interval):
1255
+ _core.call_safely(self.sweep, (), {}, "llama_index.reaper")
1256
+
1257
+ def shutdown(self) -> None:
1258
+ """Close everything still open, then stop the reaper. Never raises."""
1259
+ self._stop.set()
1260
+ with self._lock:
1261
+ for run in list(self._runs.values()):
1262
+ self._close_all_leaves(run, reason="uninstrument")
1263
+ # Newest first, so a sub-agent closes before the workflow that
1264
+ # opened it rather than after it.
1265
+ for span_id in reversed(list(self._runs)):
1266
+ self.tracker.end_agent(span_id, outcome="cancelled")
1267
+ self._runs.clear()
1268
+ self._spans.clear()
1269
+ self._leaf_run.clear()
1270
+ self._cancelled.clear()
1271
+ self._step_inputs.clear()
1272
+ self._model_names.clear()
1273
+ self.tracker.reset()
1274
+ thread = self._reaper
1275
+ self._reaper = None
1276
+ if thread is not None and thread.is_alive():
1277
+ thread.join(timeout=1.0)
1278
+
1279
+
1280
+ # ---------------------------------------------------------------------------
1281
+ # Framework base classes, imported once and only when asked
1282
+ # ---------------------------------------------------------------------------
1283
+
1284
+ _BASES: dict[str, Any] = {}
1285
+
1286
+
1287
+ def _bases() -> dict[str, Any]:
1288
+ """The isinstance targets used to classify a span.
1289
+
1290
+ `isinstance` rather than a class-name string: a rename lands as a hard
1291
+ ImportError at `instrument()` time instead of a classifier that quietly
1292
+ stops matching. Each import is individually optional, because a partial
1293
+ LlamaIndex install should cost one span kind, not the adapter.
1294
+ """
1295
+ if _BASES:
1296
+ return _BASES
1297
+ targets = {
1298
+ "Workflow": ("llama_index.core.workflow", "Workflow"),
1299
+ "BaseTool": ("llama_index.core.tools.types", "BaseTool"),
1300
+ "BaseLLM": ("llama_index.core.base.llms.base", "BaseLLM"),
1301
+ "BaseRetriever": ("llama_index.core.base.base_retriever", "BaseRetriever"),
1302
+ "BaseEmbedding": ("llama_index.core.base.embeddings.base", "BaseEmbedding"),
1303
+ }
1304
+ import importlib
1305
+
1306
+ for key, (module_name, attribute) in targets.items():
1307
+ try:
1308
+ _BASES[key] = getattr(importlib.import_module(module_name), attribute)
1309
+ except Exception:
1310
+ _compat.warn(
1311
+ f"failproofai_sdk: llama_index could not resolve {module_name}.{attribute}; "
1312
+ f"spans of that kind will be recorded as untyped.",
1313
+ key=f"llama_index:base:{key}",
1314
+ )
1315
+ _BASES[key] = None
1316
+ return _BASES
1317
+
1318
+
1319
+ # ---------------------------------------------------------------------------
1320
+ # Handlers
1321
+ # ---------------------------------------------------------------------------
1322
+
1323
+ # The dispatcher dispatches on the concrete event class, so these names ARE the
1324
+ # API. `test_llama_index.py` asserts every one of them still exists in the
1325
+ # framework's event modules — a rename would otherwise leave this table looking
1326
+ # perfectly healthy while recording nothing.
1327
+ MODEL_START_EVENTS = ("LLMChatStartEvent", "LLMCompletionStartEvent")
1328
+ MODEL_END_EVENTS = ("LLMChatEndEvent", "LLMCompletionEndEvent", "StreamChatEndEvent")
1329
+ MODEL_ERROR_EVENTS = ("StreamChatErrorEvent",)
1330
+ RETRIEVAL_START_EVENTS = ("RetrievalStartEvent",)
1331
+ RETRIEVAL_END_EVENTS = ("RetrievalEndEvent",)
1332
+ EMBEDDING_START_EVENTS = ("EmbeddingStartEvent",)
1333
+ EMBEDDING_END_EVENTS = ("EmbeddingEndEvent",)
1334
+ EXCEPTION_EVENTS = ("ExceptionEvent",)
1335
+ # NOT part of `_HANDLED_EVENTS`: this one is dispatched by the workflows runtime
1336
+ # (`workflows.runtime.types.step_function`), not from
1337
+ # `llama_index.core.instrumentation.events.*` like every name above it, so the
1338
+ # drift test that walks those modules cannot cover it.
1339
+ CANCEL_EVENTS = ("SpanCancelledEvent",)
1340
+
1341
+ _HANDLED_EVENTS = (
1342
+ MODEL_START_EVENTS
1343
+ + MODEL_END_EVENTS
1344
+ + MODEL_ERROR_EVENTS
1345
+ + RETRIEVAL_START_EVENTS
1346
+ + RETRIEVAL_END_EVENTS
1347
+ + EMBEDDING_START_EVENTS
1348
+ + EMBEDDING_END_EVENTS
1349
+ + EXCEPTION_EVENTS
1350
+ )
1351
+
1352
+ _CLASSES: dict[str, Any] = {}
1353
+
1354
+
1355
+ def handler_classes() -> tuple[Any, Any]:
1356
+ """`(FailproofAIEventHandler, FailproofAISpanHandler)`, built on first use.
1357
+
1358
+ They subclass framework base classes, so they cannot exist at module import
1359
+ time — and `import failproofai_sdk` must stay free of LlamaIndex. Memoised so
1360
+ repeated install/uninstall cycles do not rebuild pydantic models.
1361
+ """
1362
+ if _CLASSES:
1363
+ return _CLASSES["event"], _CLASSES["span"]
1364
+
1365
+ from llama_index_instrumentation.event_handlers.base import BaseEventHandler
1366
+ from llama_index_instrumentation.span_handlers.base import BaseSpanHandler
1367
+ from pydantic import PrivateAttr
1368
+
1369
+ class FailproofAIEventHandler(BaseEventHandler):
1370
+ """Model, retrieval and embedding events.
1371
+
1372
+ `handle` is wrapped in `_core.safe` because the dispatcher's own
1373
+ `except BaseException: pass` has NO logging: without this, a bug here is
1374
+ undetectable in production.
1375
+ """
1376
+
1377
+ _state: Any = PrivateAttr(default=None)
1378
+
1379
+ def __init__(self, state: Any = None, **kwargs: Any) -> None:
1380
+ super().__init__(**kwargs)
1381
+ self._state = state
1382
+
1383
+ @classmethod
1384
+ def class_name(cls) -> str:
1385
+ return "FailproofAIEventHandler"
1386
+
1387
+ @_core.safe
1388
+ def handle(self, event: Any, **kwargs: Any) -> None:
1389
+ state = self._state
1390
+ if state is None:
1391
+ return
1392
+ name = type(event).__name__
1393
+ span_id = getattr(event, "span_id", None)
1394
+ if span_id is None:
1395
+ return
1396
+ if name in MODEL_START_EVENTS:
1397
+ state.model_start(
1398
+ span_id,
1399
+ getattr(event, "messages", None),
1400
+ getattr(event, "model_dict", None),
1401
+ prompt=getattr(event, "prompt", None),
1402
+ )
1403
+ elif name in MODEL_END_EVENTS:
1404
+ state.model_end(span_id, getattr(event, "response", None))
1405
+ elif name in MODEL_ERROR_EVENTS:
1406
+ state.model_end(
1407
+ span_id, None, error=_summarize(getattr(event, "exception", None)) or name
1408
+ )
1409
+ elif name in RETRIEVAL_START_EVENTS:
1410
+ state.retrieval_start(span_id, getattr(event, "str_or_query_bundle", None))
1411
+ elif name in RETRIEVAL_END_EVENTS:
1412
+ state.retrieval_end(span_id, getattr(event, "nodes", None))
1413
+ elif state.embeddings and name in EMBEDDING_START_EVENTS:
1414
+ state.retrieval_start(span_id, "embedding")
1415
+ elif state.embeddings and name in EMBEDDING_END_EVENTS:
1416
+ state.retrieval_end(span_id, getattr(event, "embeddings", None))
1417
+ elif name in EXCEPTION_EVENTS:
1418
+ state.exception(span_id, getattr(event, "exception", None))
1419
+ elif name in CANCEL_EVENTS:
1420
+ state.cancel(span_id)
1421
+
1422
+ class FailproofAISpanHandler(BaseSpanHandler):
1423
+ """The span tree: agents, workflow steps, tools, retrievers.
1424
+
1425
+ Note `new_span` takes **`parent_span_id`** while the dispatcher's
1426
+ `span_enter` takes `parent_id`. Declaring the wrong one here gives a
1427
+ flat trace and no error at all.
1428
+
1429
+ Every override returns a truthy value so the base class's `span_exit`
1430
+ can `del self.open_spans[id_]` — returning `None` from
1431
+ `prepare_to_exit_span` deliberately LEAKS the entry (that is how the
1432
+ framework parks streaming spans), and returning it from `new_span` would
1433
+ make the matching `del` raise.
1434
+ """
1435
+
1436
+ _state: Any = PrivateAttr(default=None)
1437
+
1438
+ def __init__(self, state: Any = None, **kwargs: Any) -> None:
1439
+ super().__init__(**kwargs)
1440
+ self._state = state
1441
+
1442
+ @classmethod
1443
+ def class_name(cls) -> str:
1444
+ return "FailproofAISpanHandler"
1445
+
1446
+ @_core.safe
1447
+ def new_span(
1448
+ self,
1449
+ id_: str,
1450
+ bound_args: Any,
1451
+ instance: Any = None,
1452
+ parent_span_id: str | None = None,
1453
+ tags: dict | None = None,
1454
+ **kwargs: Any,
1455
+ ) -> str:
1456
+ state = self._state
1457
+ if state is not None:
1458
+ arguments = getattr(bound_args, "arguments", None) or {}
1459
+ if "ev" in arguments:
1460
+ state._step_inputs[id_] = arguments["ev"]
1461
+ metadata = getattr(instance, "metadata", None)
1462
+ model_name = getattr(metadata, "model_name", None)
1463
+ if isinstance(model_name, str) and model_name:
1464
+ state._model_names[id_] = model_name
1465
+ state.span_enter(id_, bound_args, instance, parent_span_id, tags)
1466
+ return id_
1467
+
1468
+ @_core.safe
1469
+ def prepare_to_exit_span(
1470
+ self,
1471
+ id_: str,
1472
+ bound_args: Any,
1473
+ instance: Any = None,
1474
+ result: Any = None,
1475
+ **kwargs: Any,
1476
+ ) -> str | None:
1477
+ state = self._state
1478
+ if state is not None:
1479
+ state.span_exit(id_, bound_args, instance, result)
1480
+ state._step_inputs.pop(id_, None)
1481
+ state._model_names.pop(id_, None)
1482
+ # Truthy tells the base class to `del self.open_spans[id_]`, which
1483
+ # raises if `new_span` never stored it (it returns None when
1484
+ # `safe()` swallowed an exception). Returning None here parks the
1485
+ # span instead — the framework's own mechanism, not a leak.
1486
+ return id_ if id_ in self.open_spans else None
1487
+
1488
+ @_core.safe
1489
+ def prepare_to_drop_span(
1490
+ self,
1491
+ id_: str,
1492
+ bound_args: Any,
1493
+ instance: Any = None,
1494
+ err: BaseException | None = None,
1495
+ **kwargs: Any,
1496
+ ) -> str | None:
1497
+ state = self._state
1498
+ if state is not None:
1499
+ state.span_drop(id_, bound_args, instance, err)
1500
+ state._step_inputs.pop(id_, None)
1501
+ state._model_names.pop(id_, None)
1502
+ return id_ if id_ in self.open_spans else None
1503
+
1504
+ _CLASSES["event"] = FailproofAIEventHandler
1505
+ _CLASSES["span"] = FailproofAISpanHandler
1506
+ return FailproofAIEventHandler, FailproofAISpanHandler
1507
+
1508
+
1509
+ # ---------------------------------------------------------------------------
1510
+ # The adapter
1511
+ # ---------------------------------------------------------------------------
1512
+
1513
+ class _LlamaIndexAdapter:
1514
+ """Registered as `llama_index`; see `failproofai_sdk.integrations.__init__`."""
1515
+
1516
+ name = FRAMEWORK
1517
+ module = "llama_index"
1518
+
1519
+ def __init__(self) -> None:
1520
+ self.state: _State | None = None
1521
+ self._handlers: tuple[Any, Any] | None = None
1522
+
1523
+ def install(self, **options: Any) -> None:
1524
+ instrumentation = _compat.require_module(
1525
+ "llama_index.core.instrumentation", dist=DIST, extra=EXTRA
1526
+ )
1527
+ _compat.check_version(
1528
+ FRAMEWORK,
1529
+ DIST,
1530
+ minimum=MIN_VERSION,
1531
+ below=BELOW_VERSION,
1532
+ reason="0.14.23 replaced to_dict() with to_payload() and is the first "
1533
+ "release whose workflow spans carry the typed agent events",
1534
+ )
1535
+ if not _compat.probe(
1536
+ FRAMEWORK, "get_dispatcher", lambda: instrumentation.get_dispatcher
1537
+ ):
1538
+ return
1539
+
1540
+ state = _State(**options)
1541
+ event_cls, span_cls = handler_classes()
1542
+ event_handler = event_cls(state=state)
1543
+ span_handler = span_cls(state=state)
1544
+
1545
+ # The ROOT dispatcher. Child dispatchers propagate upward, so one
1546
+ # registration here sees the whole process.
1547
+ dispatcher = instrumentation.get_dispatcher()
1548
+ dispatcher.add_event_handler(event_handler)
1549
+ dispatcher.add_span_handler(span_handler)
1550
+
1551
+ self.state = state
1552
+ self._handlers = (event_handler, span_handler)
1553
+ state.start_reaper()
1554
+
1555
+ def uninstall(self) -> None:
1556
+ handlers = self._handlers
1557
+ self._handlers = None
1558
+ state = self.state
1559
+ self.state = None
1560
+ if handlers is not None:
1561
+ try:
1562
+ import llama_index.core.instrumentation as instrumentation
1563
+
1564
+ dispatcher = instrumentation.get_dispatcher()
1565
+ ours = set(id(handler) for handler in handlers)
1566
+ # IN-PLACE slice assignment. `add_span_handler` does
1567
+ # `self.span_handlers += [h]`, so a plain `=` rebinds the
1568
+ # pydantic field and can drop handlers added by someone else.
1569
+ dispatcher.event_handlers[:] = [
1570
+ handler for handler in dispatcher.event_handlers if id(handler) not in ours
1571
+ ]
1572
+ dispatcher.span_handlers[:] = [
1573
+ handler for handler in dispatcher.span_handlers if id(handler) not in ours
1574
+ ]
1575
+ except Exception:
1576
+ _core.logger.warning(
1577
+ "failproofai_sdk: could not detach the llama_index handlers", exc_info=True
1578
+ )
1579
+ if state is not None:
1580
+ # Close every open leaf and agent before we stop listening: a run
1581
+ # abandoned mid-flight would otherwise render `ongoing` forever.
1582
+ _core.call_safely(state.shutdown, (), {}, "llama_index.shutdown")
1583
+
1584
+
1585
+ adapter = _LlamaIndexAdapter()
1586
+
1587
+ install: Callable[..., None] = adapter.install
1588
+ uninstall: Callable[..., None] = adapter.uninstall