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