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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +6 -6
- package/.next/standalone/.next/required-server-files.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page.js +2 -2
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +6 -6
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +7 -7
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js +2 -2
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js +3 -2
- package/.next/standalone/.next/server/app/api/audit/invite/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/run/route.js +5 -4
- package/.next/standalone/.next/server/app/api/audit/run/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/audit/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js +2 -2
- package/.next/standalone/.next/server/app/api/auth/login-request/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js +4 -3
- package/.next/standalone/.next/server/app/api/auth/login-verify/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/logout/route.js +4 -4
- package/.next/standalone/.next/server/app/api/auth/logout/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/auth/status/route.js +4 -4
- package/.next/standalone/.next/server/app/api/auth/status/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/download/[project]/[session]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/audit/page.js +2 -2
- package/.next/standalone/.next/server/app/audit/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/audit/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +15 -15
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/page.js +2 -2
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/policies/page/server-reference-manifest.json +66 -22
- package/.next/standalone/.next/server/app/policies/page.js +5 -4
- package/.next/standalone/.next/server/app/policies/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/policies/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page.js +2 -2
- package/.next/standalone/.next/server/app/project/[name]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/react-loadable-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page/server-reference-manifest.json +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js +2 -2
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/project/[name]/session/[sessionId]/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/projects/page/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/server/app/projects/page.js +2 -2
- package/.next/standalone/.next/server/app/projects/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/projects/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings/page/server-reference-manifest.json +4 -4
- package/.next/standalone/.next/server/app/settings/page.js +1 -1
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/chunks/{[externals]__1qzqcio._.js → [externals]__0e32-7c._.js} +1 -1
- package/.next/standalone/.next/server/chunks/[externals]__1s462yl._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__01eqny5._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_0xu5z._.js +1 -1
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__1p8poxd._.js → [root-of-the-server]__0igxt32._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0neqlu-._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o07qi9._.js +3 -0
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0t9oqnn._.js +3 -0
- package/.next/standalone/.next/server/chunks/_0bs-v8p._.js +3 -0
- package/.next/standalone/.next/server/chunks/_0tovk6q._.js +3 -0
- package/.next/standalone/.next/server/chunks/_0trp3yc._.js +3 -0
- package/.next/standalone/.next/server/chunks/_185gyk9._.js +3 -0
- package/.next/standalone/.next/server/chunks/_1ek68ln._.js +5 -6
- package/.next/standalone/.next/server/chunks/lib_telemetry-id_ts_1k8gj9o._.js +3 -0
- 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
- 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
- package/.next/standalone/.next/server/chunks/package_json_[json]_cjs_1nxcc4v._.js +1 -1
- package/.next/standalone/.next/server/chunks/src_hooks_0xbw3bu._.js +12 -0
- package/.next/standalone/.next/server/chunks/src_hooks_fp-home_ts_09kv0bn._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__013jr2b._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__01wy8d-._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__02npjtd._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0spkm68._.js → [root-of-the-server]__07gm3zl._.js} +21 -20
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0da85px._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ftmoxc._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0oa1lav._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0p-5p8u._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0s740oi._.js +6 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__07tbbf3._.js → [root-of-the-server]__0zig5sv._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__192nhvh._.js +5 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1p2otjt._.js +4 -0
- package/.next/standalone/.next/server/chunks/ssr/{_0lvyuv4._.js → _00e9tnf._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_08x1r5t._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/{_0u5zauv._.js → _12l4a_k._.js} +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1u8-lu2._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1zopuov._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_audit__components_audit-dashboard_tsx_0p9ud47._.js +13 -13
- package/.next/standalone/.next/server/chunks/ssr/app_global-error_tsx_1kp6l3x._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_policies_hooks-client_tsx_19dqvpc._.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/app_settings_settings-client_tsx_20lq-mq._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_08o7bqo._.js +10 -0
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_builtin-policies_ts_09j2ndl._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/src_hooks_fp-home_ts_0je3xkv._.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/middleware.js +1 -1
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +71 -27
- package/.next/standalone/.next/static/chunks/{37fcpc36x9gpe.js → 0eqky-7n7dgdm.js} +1 -1
- package/.next/standalone/.next/static/chunks/{1ide1wl2f7zi1.js → 0sa9v5mhc8861.js} +13 -13
- package/.next/standalone/.next/static/chunks/1mfzsja6ftim5.css +2 -0
- package/.next/standalone/.next/static/chunks/{3k7mxbp9hiom0.js → 1nuqytbta44af.js} +1 -1
- package/.next/standalone/.next/static/chunks/1sl2xmn9inx3y.js +6 -0
- package/.next/standalone/.next/static/chunks/{2_2lhuojq2xop.js → 2aawsjld9ied_.js} +1 -1
- package/.next/standalone/.next/static/chunks/2gn7ut4svofm2.js +1 -0
- package/.next/standalone/.next/static/chunks/{31s1smp-g7gks.js → 2hs7ycvsav7v2.js} +1 -1
- package/.next/standalone/.next/static/chunks/{0k5050ezjvv49.js → 2i2yt3hzjredm.js} +1 -1
- package/.next/standalone/.next/static/chunks/{3c808qrfcq0im.js → 35xg6retpe6rp.js} +1 -1
- package/.next/standalone/.next/static/chunks/{2zpp1we2jv6tb.js → 3y54nw0i7dsjl.js} +1 -1
- package/.next/standalone/SECURITY.md +5 -4
- package/.next/standalone/app/actions/get-hooks-config.ts +108 -17
- package/.next/standalone/app/actions/pack-actions.ts +153 -0
- package/.next/standalone/app/audit/_components/run-progress.tsx +1 -1
- package/.next/standalone/app/audit/_components/share-templates.ts +13 -13
- package/.next/standalone/app/policies/hooks-client.tsx +320 -14
- package/.next/standalone/components/navbar.tsx +2 -28
- package/.next/standalone/components/reach-developers.tsx +8 -1
- package/.next/standalone/docs-old/agenteye/python-sdk-skill.mdx +1 -1
- package/.next/standalone/docs-old/agenteye/python-sdk.mdx +59 -35
- package/.next/standalone/docs-old/i18n/README.ar.md +1 -0
- package/.next/standalone/docs-old/i18n/README.de.md +1 -0
- package/.next/standalone/docs-old/i18n/README.es.md +1 -0
- package/.next/standalone/docs-old/i18n/README.fr.md +1 -0
- package/.next/standalone/docs-old/i18n/README.he.md +1 -0
- package/.next/standalone/docs-old/i18n/README.hi.md +1 -0
- package/.next/standalone/docs-old/i18n/README.it.md +1 -0
- package/.next/standalone/docs-old/i18n/README.ja.md +1 -0
- package/.next/standalone/docs-old/i18n/README.ko.md +1 -0
- package/.next/standalone/docs-old/i18n/README.pt-br.md +1 -0
- package/.next/standalone/docs-old/i18n/README.ru.md +1 -0
- package/.next/standalone/docs-old/i18n/README.tr.md +1 -0
- package/.next/standalone/docs-old/i18n/README.vi.md +1 -0
- package/.next/standalone/docs-old/i18n/README.zh.md +1 -0
- package/.next/standalone/fp-cloud-cli/CHANGELOG.md +431 -0
- package/.next/standalone/fp-cloud-cli/LICENSE +42 -0
- package/.next/standalone/fp-cloud-cli/MANIFEST.in +10 -0
- package/.next/standalone/fp-cloud-cli/README.md +259 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/__init__.py +10 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/__main__.py +4 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/_click_compat.py +64 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/_context.py +332 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/_version.py +1 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/analytics.py +432 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/analytics_config.py +77 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/analytics_registry.py +83 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/app.py +492 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/auth.py +160 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/client.py +1694 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/__init__.py +0 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/_write.py +214 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/agent_cmds.py +407 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/alerts_cmds.py +445 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/audits_cmds.py +1054 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/auth_cmds.py +512 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/errors_cmds.py +190 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/evals_cmds.py +161 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/events_cmds.py +159 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/fleet_cmds.py +416 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/guardrails_cmds.py +148 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/incidents_cmds.py +472 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/keys_cmds.py +407 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/list_cmds.py +63 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/orgs_cmds.py +319 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/policies_cmds.py +499 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/queries_cmds.py +378 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/sessions_cmds.py +151 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/settings_cmds.py +150 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/usage_cmds.py +35 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/commands/users_cmds.py +404 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/config.py +330 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/dates.py +78 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/enforcement.py +345 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/errors.py +98 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/models.py +891 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/orgs.py +30 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/output.py +6593 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/permissions.py +208 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/policy_check.py +290 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/py.typed +0 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/select.py +322 -0
- package/.next/standalone/fp-cloud-cli/fp_cli/theme.py +53 -0
- package/.next/standalone/fp-cloud-cli/pyproject.toml +106 -0
- package/.next/standalone/fp-cloud-cli/skill/SKILL.md +302 -0
- package/.next/standalone/fp-cloud-cli/skill/agents/openai.yaml +8 -0
- package/.next/standalone/fp-cloud-cli/skill/references/commands.md +254 -0
- package/.next/standalone/fp-cloud-cli/tests/__init__.py +0 -0
- package/.next/standalone/fp-cloud-cli/tests/conftest.py +92 -0
- package/.next/standalone/fp-cloud-cli/tests/test_alerting.py +644 -0
- package/.next/standalone/fp-cloud-cli/tests/test_analytics.py +385 -0
- package/.next/standalone/fp-cloud-cli/tests/test_audits.py +950 -0
- package/.next/standalone/fp-cloud-cli/tests/test_auth.py +98 -0
- package/.next/standalone/fp-cloud-cli/tests/test_auth_mode.py +272 -0
- package/.next/standalone/fp-cloud-cli/tests/test_click_compat.py +118 -0
- package/.next/standalone/fp-cloud-cli/tests/test_client.py +299 -0
- package/.next/standalone/fp-cloud-cli/tests/test_commands.py +814 -0
- package/.next/standalone/fp-cloud-cli/tests/test_config.py +99 -0
- package/.next/standalone/fp-cloud-cli/tests/test_dashboards_agent.py +340 -0
- package/.next/standalone/fp-cloud-cli/tests/test_dates.py +76 -0
- package/.next/standalone/fp-cloud-cli/tests/test_enforcement_logic.py +481 -0
- package/.next/standalone/fp-cloud-cli/tests/test_facets.py +170 -0
- package/.next/standalone/fp-cloud-cli/tests/test_failproofai_home.py +722 -0
- package/.next/standalone/fp-cloud-cli/tests/test_fp_home_contract.py +198 -0
- package/.next/standalone/fp-cloud-cli/tests/test_hardening.py +186 -0
- package/.next/standalone/fp-cloud-cli/tests/test_help_table_coverage.py +127 -0
- package/.next/standalone/fp-cloud-cli/tests/test_keys_queries.py +496 -0
- package/.next/standalone/fp-cloud-cli/tests/test_list.py +59 -0
- package/.next/standalone/fp-cloud-cli/tests/test_multivalue.py +183 -0
- package/.next/standalone/fp-cloud-cli/tests/test_no_customer_identifiers.py +168 -0
- package/.next/standalone/fp-cloud-cli/tests/test_operator.py +492 -0
- package/.next/standalone/fp-cloud-cli/tests/test_orgs.py +692 -0
- package/.next/standalone/fp-cloud-cli/tests/test_output.py +1732 -0
- package/.next/standalone/fp-cloud-cli/tests/test_policy_check.py +191 -0
- package/.next/standalone/fp-cloud-cli/tests/test_readme_matches_reality.py +125 -0
- package/.next/standalone/fp-cloud-cli/tests/test_review_fixes.py +161 -0
- package/.next/standalone/fp-cloud-cli/tests/test_telemetry_completeness.py +90 -0
- package/.next/standalone/fp-cloud-cli/tests/test_usage.py +115 -0
- package/.next/standalone/fp-cloud-cli/tests/test_v1_origin_diagnostic.py +70 -0
- package/.next/standalone/fp-cloud-cli/tests/test_v1_routing.py +214 -0
- package/.next/standalone/fp-cloud-cli/tests/test_whoami.py +104 -0
- package/.next/standalone/fp-cloud-cli/uv.lock +552 -0
- package/.next/standalone/package.json +29 -22
- package/.next/standalone/sdk/python/CHANGELOG.md +70 -0
- package/.next/standalone/sdk/python/LICENSE +42 -0
- package/.next/standalone/sdk/python/MANIFEST.in +21 -0
- package/.next/standalone/sdk/python/README.md +466 -0
- package/.next/standalone/sdk/python/docs/README.md +249 -0
- package/.next/standalone/sdk/python/docs/_shared/README.md +43 -0
- package/.next/standalone/sdk/python/docs/_shared/__init__.py +195 -0
- package/.next/standalone/sdk/python/docs/crewai/README.md +328 -0
- package/.next/standalone/sdk/python/docs/crewai/examples/quickstart.py +61 -0
- package/.next/standalone/sdk/python/docs/crewai/examples/research_crew.py +92 -0
- package/.next/standalone/sdk/python/docs/langgraph/README.md +481 -0
- package/.next/standalone/sdk/python/docs/langgraph/examples/quickstart.py +51 -0
- package/.next/standalone/sdk/python/docs/langgraph/examples/research_agent.py +86 -0
- package/.next/standalone/sdk/python/docs/langgraph/examples/supervisor_handoff.py +83 -0
- package/.next/standalone/sdk/python/docs/llama_index/README.md +332 -0
- package/.next/standalone/sdk/python/docs/llama_index/examples/quickstart.py +62 -0
- package/.next/standalone/sdk/python/docs/llama_index/examples/research_agent.py +93 -0
- package/.next/standalone/sdk/python/docs/manual/README.md +491 -0
- package/.next/standalone/sdk/python/docs/manual/examples/quickstart.py +57 -0
- package/.next/standalone/sdk/python/docs/manual/examples/research_agent.py +133 -0
- package/.next/standalone/sdk/python/docs/pydantic_ai/README.md +323 -0
- package/.next/standalone/sdk/python/docs/pydantic_ai/examples/quickstart.py +48 -0
- package/.next/standalone/sdk/python/docs/pydantic_ai/examples/research_agent.py +94 -0
- package/.next/standalone/sdk/python/failproofai_sdk/__init__.py +131 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_context.py +218 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_environment.py +78 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_events.py +752 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_resolver.py +118 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_runtime.py +21 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_schema.py +317 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_scopes.py +422 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_version.py +1 -0
- package/.next/standalone/sdk/python/failproofai_sdk/_writer.py +750 -0
- package/.next/standalone/sdk/python/failproofai_sdk/integrations/__init__.py +274 -0
- package/.next/standalone/sdk/python/failproofai_sdk/integrations/_compat.py +247 -0
- package/.next/standalone/sdk/python/failproofai_sdk/integrations/_core.py +1076 -0
- package/.next/standalone/sdk/python/failproofai_sdk/integrations/crewai.py +1669 -0
- package/.next/standalone/sdk/python/failproofai_sdk/integrations/langchain.py +2151 -0
- package/.next/standalone/sdk/python/failproofai_sdk/integrations/llama_index.py +1588 -0
- package/.next/standalone/sdk/python/failproofai_sdk/integrations/pydantic_ai.py +977 -0
- package/.next/standalone/sdk/python/failproofai_sdk/py.typed +0 -0
- package/.next/standalone/sdk/python/pyproject.toml +92 -0
- package/.next/standalone/sdk/python/skill/SKILL.md +399 -0
- package/.next/standalone/sdk/python/skill/agents/openai.yaml +8 -0
- package/.next/standalone/sdk/python/skill/references/events.md +234 -0
- package/.next/standalone/sdk/python/skill/references/frameworks.md +314 -0
- package/.next/standalone/sdk/python/skill/references/install.md +100 -0
- package/.next/standalone/sdk/python/skill/references/integration.md +352 -0
- package/.next/standalone/sdk/python/tests/__init__.py +0 -0
- package/.next/standalone/sdk/python/tests/conftest.py +169 -0
- package/.next/standalone/sdk/python/tests/integrations/__init__.py +6 -0
- package/.next/standalone/sdk/python/tests/integrations/test_crewai.py +1299 -0
- package/.next/standalone/sdk/python/tests/integrations/test_langchain.py +2338 -0
- package/.next/standalone/sdk/python/tests/integrations/test_llama_index.py +1576 -0
- package/.next/standalone/sdk/python/tests/integrations/test_pydantic_ai.py +1102 -0
- package/.next/standalone/sdk/python/tests/test_context.py +410 -0
- package/.next/standalone/sdk/python/tests/test_docs.py +411 -0
- package/.next/standalone/sdk/python/tests/test_durability.py +1352 -0
- package/.next/standalone/sdk/python/tests/test_encoding.py +571 -0
- package/.next/standalone/sdk/python/tests/test_integrations.py +1127 -0
- package/.next/standalone/sdk/python/tests/test_no_customer_identifiers.py +214 -0
- package/.next/standalone/sdk/python/tests/test_packaging.py +185 -0
- package/.next/standalone/sdk/python/tests/test_resolver_umbrella.py +178 -0
- package/.next/standalone/sdk/python/tests/test_scopes.py +487 -0
- package/.next/standalone/sdk/python/tests/test_sdk.py +923 -0
- package/.next/standalone/sdk/python/tests/test_server_contract.py +650 -0
- package/.next/standalone/sdk/python/tests/test_site_docs.py +371 -0
- package/.next/standalone/sdk/python/tests/test_skill_snippets.py +165 -0
- package/.next/standalone/sdk/python/tests/test_spool_contract.py +287 -0
- package/.next/standalone/sdk/python/tests/test_spool_creation.py +360 -0
- package/.next/standalone/sdk/python/tests/test_wire_format.py +279 -0
- package/.next/standalone/sdk/python/tests/test_zero_dependencies.py +378 -0
- package/.next/standalone/sdk/python/uv.lock +6005 -0
- package/.next/standalone/server.js +1 -1
- package/README.md +77 -21
- package/bin/failproofai.mjs +145 -11
- package/dist/cli.mjs +4482 -2205
- package/dist/index.js +5 -5
- package/dist/worker.mjs +2399 -1978
- package/package.json +29 -22
- package/policy-pack/SHA256SUMS +2 -0
- package/policy-pack/failproofai-pack.json +732 -0
- package/policy-pack/failproofai-pack.mjs +2017 -0
- package/scripts/build-policy-pack.mjs +121 -0
- package/scripts/changelog-section.py +77 -0
- package/scripts/prune-standalone.mjs +3 -0
- package/scripts/python-version.py +232 -0
- package/scripts/translate-docs/cli.ts +21 -0
- package/scripts/translate-docs/mintlify-nav.ts +85 -13
- package/scripts/translate-docs/translator.ts +66 -11
- package/src/audit/cache.ts +40 -3
- package/src/audit/cli.ts +1 -1
- package/src/audit/index.ts +1 -1
- package/src/audit/replay.ts +89 -3
- package/src/audit/report.ts +5 -5
- package/src/audit/schedule-cli.ts +49 -53
- package/src/hooks/builtin-policies.ts +217 -579
- package/src/hooks/cloud-enrollment-cli.ts +66 -27
- package/src/hooks/cloud-managed-policies.ts +6 -1
- package/src/hooks/configure-wizard.ts +1 -1
- package/src/hooks/custom-hooks-loader.ts +89 -6
- package/src/hooks/fp-home.ts +92 -4
- package/src/hooks/fp-reset.ts +26 -2
- package/src/hooks/handler.ts +181 -11
- package/src/hooks/harness-cli.ts +79 -25
- package/src/hooks/hook-activity-store.ts +13 -3
- package/src/hooks/install-prompt.ts +25 -3
- package/src/hooks/manager.ts +418 -172
- package/src/hooks/migrations.ts +2 -2
- package/src/hooks/pack-cli.ts +505 -0
- package/src/hooks/pack-failclosed.ts +189 -0
- package/src/hooks/pack-manifest.ts +344 -0
- package/src/hooks/pack-store.ts +930 -0
- package/src/hooks/policy-catalog.ts +535 -0
- package/src/hooks/policy-evaluator.ts +24 -16
- package/src/hooks/policy-presets.ts +4 -2
- package/src/hooks/policy-registry.ts +8 -2
- package/src/hooks/policy-types.ts +26 -0
- package/src/hooks/session-pause-cli.ts +33 -9
- package/src/hooks/tui.ts +570 -0
- package/src/hooks/types.ts +15 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_usx0j._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0_zk340._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0agdw2a._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0d1u_na._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0oy4kxb._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0zf034w._.js +0 -3
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1tvn4wf._.js +0 -3
- package/.next/standalone/.next/server/chunks/_0lxbzdq._.js +0 -3
- package/.next/standalone/.next/server/chunks/_1ger0a2._.js +0 -3
- package/.next/standalone/.next/server/chunks/_1zuiiy3._.js +0 -3
- package/.next/standalone/.next/server/chunks/node_modules_posthog-node_dist_entrypoints_index_node_mjs_1iiwm5e._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kc8_v5._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0kgy9da._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0mhqk5a._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0pltpn8._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0qkcyf-._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0w0x_ok._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1_dfocn._.js +0 -4
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1bfex62._.js +0 -5
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1kzapg5._.js +0 -6
- package/.next/standalone/.next/static/chunks/36fw6yk1s6x_f.js +0 -1
- package/.next/standalone/.next/static/chunks/3tzq0260n17ae.js +0 -6
- package/.next/standalone/.next/static/chunks/3wq9x0c-7ld5d.css +0 -2
- /package/.next/standalone/.next/static/{ZklXxyA5ygsWSQz2u_zii → nwbyWT06Ud0XdXOrVJyvS}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{ZklXxyA5ygsWSQz2u_zii → nwbyWT06Ud0XdXOrVJyvS}/_clientMiddlewareManifest.js +0 -0
- /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
|