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,1669 @@
|
|
|
1
|
+
"""CrewAI adapter — written against crewai 1.15.8 (2026-07-29), floor 1.13.0.
|
|
2
|
+
|
|
3
|
+
import failproofai_sdk, crewai
|
|
4
|
+
failproofai_sdk.instrument("crewai")
|
|
5
|
+
crew.kickoff()
|
|
6
|
+
|
|
7
|
+
Everything here is a translation table over `crewai.events`, with exactly one
|
|
8
|
+
exception (6. below). Identity, correlation, payload budgets and the never-raise
|
|
9
|
+
policy live in `_core`.
|
|
10
|
+
|
|
11
|
+
Six things about CrewAI that this file is shaped by, each verified against the
|
|
12
|
+
installed package rather than recalled:
|
|
13
|
+
|
|
14
|
+
1. **The module is `crewai.events`, not `crewai.utilities.events`.** The old
|
|
15
|
+
shim was removed in 1.0.0.
|
|
16
|
+
|
|
17
|
+
2. **Handlers are keyed by EXACT type, with no MRO walk**
|
|
18
|
+
(`event_bus._sync_handlers.get(type(event))`), so there is no `BaseEvent`
|
|
19
|
+
catch-all to register. Every event class is registered individually, which
|
|
20
|
+
is also what makes the anti-drift test possible.
|
|
21
|
+
|
|
22
|
+
3. **Our handlers are `async def`, and that is a correctness requirement, not a
|
|
23
|
+
style choice.** `CrewAIEventsBus.emit()` dispatches *sync* handlers onto a
|
|
24
|
+
ten-worker `ThreadPoolExecutor`; with more than one worker, submission order
|
|
25
|
+
is not execution order. Measured on this version, 500 events through a sync
|
|
26
|
+
handler come back **out of order every single run**, while the same 500
|
|
27
|
+
through an async handler are strictly ordered — async handlers are scheduled
|
|
28
|
+
with `run_coroutine_threadsafe` onto one background event loop, and
|
|
29
|
+
`call_soon_threadsafe` is FIFO. Out-of-order handling would break two of the
|
|
30
|
+
four rendering invariants at once: a `tool_result` could be written before
|
|
31
|
+
its `tool_use`, and an `agent_start` before the root's. Keep these `async`.
|
|
32
|
+
They must also never `await` anything, or that ordering guarantee is gone.
|
|
33
|
+
|
|
34
|
+
4. **`BaseEvent` already carries the span tree** — `event_id`,
|
|
35
|
+
`parent_event_id`, and `started_event_id` on every `*Completed`/`*Failed`
|
|
36
|
+
event. Verified populated on tool, LLM, agent, task and crew events here, so
|
|
37
|
+
nesting is *read*, never reconstructed. (A per-`(role, tool)` LIFO is kept as
|
|
38
|
+
a fallback for the case where the pairing stack has been unwound by an
|
|
39
|
+
exception — that does happen, see 5.)
|
|
40
|
+
|
|
41
|
+
5. **CrewAI's own agent executor emits `FlowStartedEvent`/`FlowFinishedEvent`
|
|
42
|
+
with `flow_name="AgentExecutor"`, nested inside every single
|
|
43
|
+
`agent_execution_started`.** Mapping flow events to agents unconditionally —
|
|
44
|
+
which is what the obvious reading of the API suggests — would put a spurious
|
|
45
|
+
`AgentExecutor` agent inside every agent execution, doubling the span tree and
|
|
46
|
+
poisoning the `agent_id` facet. A flow whose parent is an agent execution is
|
|
47
|
+
therefore a pass-through link, not an agent. Its `flow_finished` is also
|
|
48
|
+
*missing* when the agent's LLM call raises, so it can never be relied on to
|
|
49
|
+
close anything.
|
|
50
|
+
|
|
51
|
+
6. **Not everything CrewAI does is on the bus.** `human_input=True` on a Task
|
|
52
|
+
goes through `crewai.core.providers.human_input`, which calls `input()` and
|
|
53
|
+
emits no event of any kind — verified on 1.15.16. That one surface is
|
|
54
|
+
instrumented by wrapping (`_patch_task_human_input`), which is the only patch
|
|
55
|
+
in this file; everything else here is a subscription.
|
|
56
|
+
|
|
57
|
+
**Two kinds of node.** `_nodes` holds more than the spans that become agents:
|
|
58
|
+
tool calls and hook spans are recorded as *links* because CrewAI hangs real work
|
|
59
|
+
underneath them — a `delegate_work_to_coworker` call parents the coworker's whole
|
|
60
|
+
agent execution, and a flow method parents a `Crew.kickoff()` inside it. A leaf
|
|
61
|
+
that is not in `_nodes` is invisible to `_parent_key`, which then falls back to
|
|
62
|
+
the open root; the visible symptom is a flattened tree or a second session, never
|
|
63
|
+
an error.
|
|
64
|
+
|
|
65
|
+
`agent_id` is the crew name or the agent **role** — `LowCardinality(String)`,
|
|
66
|
+
the primary dashboard facet. CrewAI's `agent.id` is a UUID and goes to
|
|
67
|
+
`fw_agent_id`, never here.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
import json
|
|
71
|
+
import logging
|
|
72
|
+
import threading
|
|
73
|
+
import uuid
|
|
74
|
+
from collections import OrderedDict
|
|
75
|
+
from datetime import datetime, timezone
|
|
76
|
+
from typing import Any
|
|
77
|
+
|
|
78
|
+
from failproofai_sdk.integrations import _compat
|
|
79
|
+
from failproofai_sdk.integrations._core import (
|
|
80
|
+
RunTracker,
|
|
81
|
+
framework_fields,
|
|
82
|
+
fw_fields,
|
|
83
|
+
ms,
|
|
84
|
+
normalize_agent_id,
|
|
85
|
+
safe,
|
|
86
|
+
truncate,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
logger = logging.getLogger("failproofai_sdk.integrations")
|
|
90
|
+
|
|
91
|
+
__all__ = ["adapter", "FailproofAICrewListener"]
|
|
92
|
+
|
|
93
|
+
NAME = "crewai"
|
|
94
|
+
DIST = "crewai"
|
|
95
|
+
|
|
96
|
+
# 1.13.0 is the release where `started_event_id` and a normalized `usage` dict
|
|
97
|
+
# are both present; below it the span tree has to be reconstructed by hand.
|
|
98
|
+
MIN_VERSION = "1.13.0"
|
|
99
|
+
# Ceiling: without one, a clean build a year from now pulls the next major, the
|
|
100
|
+
# event classes shift, and this adapter stops recording while raising nothing.
|
|
101
|
+
BELOW_VERSION = "2"
|
|
102
|
+
|
|
103
|
+
_INSTALL_HINT = (
|
|
104
|
+
"failproofai_sdk: cannot instrument 'crewai' because 'crewai.events' is not importable. "
|
|
105
|
+
"Install it with: pip install 'failproofai_sdk[crewai]' (or install crewai>=1.13 directly). "
|
|
106
|
+
"Note that the events package moved: it is 'crewai.events', not "
|
|
107
|
+
"'crewai.utilities.events', which was removed in crewai 1.0.0."
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
try: # only ever executed by `instrument("crewai")` — `import failproofai_sdk` never gets here
|
|
111
|
+
from crewai.events import event_types as _ct
|
|
112
|
+
from crewai import events as _ce
|
|
113
|
+
from crewai.events.base_event_listener import BaseEventListener
|
|
114
|
+
from crewai.events.event_bus import crewai_event_bus
|
|
115
|
+
except ImportError as _exc: # pragma: no cover - exercised by uninstalling crewai
|
|
116
|
+
raise ImportError(_INSTALL_HINT) from _exc
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
# ---------------------------------------------------------------------------
|
|
120
|
+
# The translation table
|
|
121
|
+
# ---------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
# event class name -> translator method name. One entry per class, because the
|
|
124
|
+
# bus does no MRO walk. Ordered as the dashboard reads them: structure, then
|
|
125
|
+
# leaves.
|
|
126
|
+
TABLE: tuple[tuple[str, str], ...] = (
|
|
127
|
+
# --- structure: these become agent_start / agent_end -------------------
|
|
128
|
+
("CrewKickoffStartedEvent", "on_crew_started"),
|
|
129
|
+
("CrewKickoffCompletedEvent", "on_crew_completed"),
|
|
130
|
+
("CrewKickoffFailedEvent", "on_crew_failed"),
|
|
131
|
+
("FlowStartedEvent", "on_flow_started"),
|
|
132
|
+
("FlowFinishedEvent", "on_flow_finished"),
|
|
133
|
+
# A flow whose method raises emits `FlowFailedEvent` and NEVER
|
|
134
|
+
# `FlowFinishedEvent`. Without this row the flow's `agent_start` is never
|
|
135
|
+
# closed and the session renders `ongoing` forever — the worst outcome
|
|
136
|
+
# available here, and the one every other `*Failed` row in this table exists
|
|
137
|
+
# to avoid.
|
|
138
|
+
("FlowFailedEvent", "on_flow_failed"),
|
|
139
|
+
("AgentExecutionStartedEvent", "on_agent_started"),
|
|
140
|
+
("AgentExecutionCompletedEvent", "on_agent_completed"),
|
|
141
|
+
("AgentExecutionErrorEvent", "on_agent_error"),
|
|
142
|
+
# `Agent.kickoff()` — crewai's LiteAgent surface, an agent run with no Crew
|
|
143
|
+
# and no Task. It emits its OWN execution events, not the AgentExecution
|
|
144
|
+
# ones, and it is a ROOT: `parent_event_id` is None on it. Without these
|
|
145
|
+
# rows such a run produces no agent span at all — every LLM and tool event
|
|
146
|
+
# falls through to whatever ambient scope exists (agent_id "main"), or is
|
|
147
|
+
# dropped entirely when there is none. Nothing raises either way.
|
|
148
|
+
("LiteAgentExecutionStartedEvent", "on_lite_agent_started"),
|
|
149
|
+
("LiteAgentExecutionCompletedEvent", "on_agent_completed"),
|
|
150
|
+
("LiteAgentExecutionErrorEvent", "on_agent_error"),
|
|
151
|
+
# --- structure we deliberately do NOT turn into spans ------------------
|
|
152
|
+
# A CrewAI Task is a subset of the agent execution that runs it; emitting
|
|
153
|
+
# both would double every row and render them as siblings. The task is
|
|
154
|
+
# recorded as a link so its children still find the crew above them, and
|
|
155
|
+
# its id/name ride along on the agent's own events as fw_task_*.
|
|
156
|
+
("TaskStartedEvent", "on_task_started"),
|
|
157
|
+
("TaskCompletedEvent", "on_task_finished"),
|
|
158
|
+
("TaskFailedEvent", "on_task_finished"),
|
|
159
|
+
# --- framework machinery around the agent: hook pairs ------------------
|
|
160
|
+
("MethodExecutionStartedEvent", "on_method_started"),
|
|
161
|
+
("MethodExecutionFinishedEvent", "on_method_finished"),
|
|
162
|
+
("MethodExecutionFailedEvent", "on_method_failed"),
|
|
163
|
+
("LLMGuardrailStartedEvent", "on_guardrail_started"),
|
|
164
|
+
("LLMGuardrailCompletedEvent", "on_guardrail_completed"),
|
|
165
|
+
# --- human in the loop -------------------------------------------------
|
|
166
|
+
# A crew blocked on a person is otherwise an unexplained gap: no event
|
|
167
|
+
# fires for the whole wait, so the session reads as `ongoing` and its
|
|
168
|
+
# active duration absorbs however long the human took. The LangChain and
|
|
169
|
+
# LlamaIndex adapters both map their HITL surface onto the same four
|
|
170
|
+
# events, and this is the crewai one — `crewai.flow.runtime` emits the
|
|
171
|
+
# pair around every `@human_feedback` method.
|
|
172
|
+
("HumanFeedbackRequestedEvent", "on_human_requested"),
|
|
173
|
+
("HumanFeedbackReceivedEvent", "on_human_received"),
|
|
174
|
+
# --- things the agent calls: tool pairs --------------------------------
|
|
175
|
+
("ToolUsageStartedEvent", "on_tool_started"),
|
|
176
|
+
("ToolUsageFinishedEvent", "on_tool_finished"),
|
|
177
|
+
("ToolUsageErrorEvent", "on_tool_error"),
|
|
178
|
+
("MemoryQueryStartedEvent", "on_store_started"),
|
|
179
|
+
("MemoryQueryCompletedEvent", "on_store_finished"),
|
|
180
|
+
("MemoryQueryFailedEvent", "on_store_failed"),
|
|
181
|
+
("MemorySaveStartedEvent", "on_store_started"),
|
|
182
|
+
("MemorySaveCompletedEvent", "on_store_finished"),
|
|
183
|
+
("MemorySaveFailedEvent", "on_store_failed"),
|
|
184
|
+
("MemoryRetrievalStartedEvent", "on_store_started"),
|
|
185
|
+
("MemoryRetrievalCompletedEvent", "on_store_finished"),
|
|
186
|
+
("MemoryRetrievalFailedEvent", "on_store_failed"),
|
|
187
|
+
("KnowledgeQueryStartedEvent", "on_store_started"),
|
|
188
|
+
("KnowledgeQueryCompletedEvent", "on_store_finished"),
|
|
189
|
+
("KnowledgeQueryFailedEvent", "on_store_failed"),
|
|
190
|
+
("KnowledgeRetrievalStartedEvent", "on_store_started"),
|
|
191
|
+
("KnowledgeRetrievalCompletedEvent", "on_store_finished"),
|
|
192
|
+
("KnowledgeSearchQueryFailedEvent", "on_store_failed"),
|
|
193
|
+
# --- model calls -------------------------------------------------------
|
|
194
|
+
("LLMCallStartedEvent", "on_llm_started"),
|
|
195
|
+
("LLMCallCompletedEvent", "on_llm_completed"),
|
|
196
|
+
("LLMCallFailedEvent", "on_llm_failed"),
|
|
197
|
+
# A streaming chunk emits NOTHING. A 500-token response would otherwise be
|
|
198
|
+
# 500 stored rows and 500 rail rows against a five-lane cap. It is
|
|
199
|
+
# folded into fw_chunks / fw_ttft_ms / fw_streamed on the model_response.
|
|
200
|
+
("LLMStreamChunkEvent", "on_llm_chunk"),
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
# Memory and knowledge operations are retrieval calls the agent makes, so they
|
|
204
|
+
# are tools, named for the surface they hit rather than the class that fired.
|
|
205
|
+
STORE_TOOLS: dict[str, str] = {
|
|
206
|
+
"MemoryQueryStartedEvent": "memory.query",
|
|
207
|
+
"MemoryQueryCompletedEvent": "memory.query",
|
|
208
|
+
"MemoryQueryFailedEvent": "memory.query",
|
|
209
|
+
"MemorySaveStartedEvent": "memory.save",
|
|
210
|
+
"MemorySaveCompletedEvent": "memory.save",
|
|
211
|
+
"MemorySaveFailedEvent": "memory.save",
|
|
212
|
+
"MemoryRetrievalStartedEvent": "memory.retrieve",
|
|
213
|
+
"MemoryRetrievalCompletedEvent": "memory.retrieve",
|
|
214
|
+
"MemoryRetrievalFailedEvent": "memory.retrieve",
|
|
215
|
+
"KnowledgeQueryStartedEvent": "knowledge.query",
|
|
216
|
+
"KnowledgeQueryCompletedEvent": "knowledge.query",
|
|
217
|
+
"KnowledgeQueryFailedEvent": "knowledge.query",
|
|
218
|
+
"KnowledgeRetrievalStartedEvent": "knowledge.search",
|
|
219
|
+
"KnowledgeRetrievalCompletedEvent": "knowledge.search",
|
|
220
|
+
"KnowledgeSearchQueryFailedEvent": "knowledge.search",
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
# Flow names CrewAI uses for its own internal machinery. Belt and braces on top
|
|
224
|
+
# of the structural check in `on_flow_started` — the structural one is the rule,
|
|
225
|
+
# this is the fallback if a future release re-parents the executor flow.
|
|
226
|
+
INTERNAL_FLOW_NAMES = frozenset({"AgentExecutor"})
|
|
227
|
+
|
|
228
|
+
# Node kinds. "crew"/"flow"/"agent" are real Failproof AI agents; the rest are
|
|
229
|
+
# links that exist so a child can find the agent above it.
|
|
230
|
+
_AGENT_KINDS = frozenset({"crew", "flow", "agent"})
|
|
231
|
+
|
|
232
|
+
_MAX_NODES = 20_000
|
|
233
|
+
_MAX_LEAVES = 10_000
|
|
234
|
+
_MAX_STREAMS = 1_000
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
class _Node:
|
|
238
|
+
"""One CrewAI span, as far as we care about it."""
|
|
239
|
+
|
|
240
|
+
__slots__ = ("kind", "parent", "task", "agent_id")
|
|
241
|
+
|
|
242
|
+
def __init__(self, kind, parent, task=None, agent_id=None):
|
|
243
|
+
self.kind = kind
|
|
244
|
+
self.parent = parent
|
|
245
|
+
self.task = task # (task_id, task_name), inherited from the parent
|
|
246
|
+
self.agent_id = agent_id
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
class _Leaf:
|
|
250
|
+
"""An emitted-but-unclosed leaf span: a tool, a model call, a hook.
|
|
251
|
+
|
|
252
|
+
`agent_end` force-closes open *pauses* but not tools, models or humans, so a
|
|
253
|
+
run that dies mid-tool leaves the session `ongoing` forever. Every open leaf
|
|
254
|
+
is remembered here and closed when the span that owns it ends.
|
|
255
|
+
"""
|
|
256
|
+
|
|
257
|
+
__slots__ = ("method", "key", "parent_key", "match", "started", "fields")
|
|
258
|
+
|
|
259
|
+
def __init__(self, method, key, parent_key, match, started, fields):
|
|
260
|
+
self.method = method
|
|
261
|
+
self.key = key
|
|
262
|
+
self.parent_key = parent_key
|
|
263
|
+
self.match = match # for the LIFO fallback when started_event_id is absent
|
|
264
|
+
self.started = started
|
|
265
|
+
self.fields = fields
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
class _CrewAIAdapter:
|
|
269
|
+
"""The object `failproofai_sdk.integrations` loads as `adapter`."""
|
|
270
|
+
|
|
271
|
+
name = NAME
|
|
272
|
+
module = "crewai"
|
|
273
|
+
|
|
274
|
+
def __init__(self) -> None:
|
|
275
|
+
# RLock: teardown resolves parents while already holding it.
|
|
276
|
+
self._lock = threading.RLock()
|
|
277
|
+
self._tracker: RunTracker | None = None
|
|
278
|
+
self._listener: "FailproofAICrewListener | None" = None
|
|
279
|
+
# (class, attribute, original descriptor, installed function) for the
|
|
280
|
+
# one wrapped surface — see `_patch_task_human_input`.
|
|
281
|
+
self._patched: list[tuple] = []
|
|
282
|
+
self._nodes: "OrderedDict[str, _Node]" = OrderedDict()
|
|
283
|
+
self._leaves: "OrderedDict[str, _Leaf]" = OrderedDict()
|
|
284
|
+
self._streams: "OrderedDict[str, list]" = OrderedDict()
|
|
285
|
+
# pause id -> (emit key, parent key, prompt). Bounded like the rest:
|
|
286
|
+
# a run that is cancelled while a human is still thinking never sends
|
|
287
|
+
# the matching `received`, and unbounded that is a leak in a gateway.
|
|
288
|
+
self._pauses: "OrderedDict[str, tuple]" = OrderedDict()
|
|
289
|
+
self._roots: list[str] = []
|
|
290
|
+
self._session_id: str | None = None
|
|
291
|
+
|
|
292
|
+
# -- install / uninstall ------------------------------------------------
|
|
293
|
+
|
|
294
|
+
def install(self, **options: Any) -> None:
|
|
295
|
+
"""Register the listener on the module-level `crewai_event_bus`.
|
|
296
|
+
|
|
297
|
+
`options` is whatever was passed to `failproofai_sdk.instrument()`, and the same
|
|
298
|
+
dict reaches every adapter, so unknown keys are ignored rather than
|
|
299
|
+
raising a TypeError that would take out the other frameworks.
|
|
300
|
+
"""
|
|
301
|
+
_compat.check_version(
|
|
302
|
+
NAME,
|
|
303
|
+
DIST,
|
|
304
|
+
minimum=MIN_VERSION,
|
|
305
|
+
below=BELOW_VERSION,
|
|
306
|
+
reason="started_event_id on *Completed events, and a normalized usage dict",
|
|
307
|
+
)
|
|
308
|
+
self._session_id = options.get("session_id")
|
|
309
|
+
self._tracker = RunTracker(
|
|
310
|
+
NAME,
|
|
311
|
+
base_fields=framework_fields(NAME, DIST),
|
|
312
|
+
)
|
|
313
|
+
# Must be kept alive in a module-level global. `BaseEventListener` holds
|
|
314
|
+
# no reference back from the bus other than the bound handlers, so a
|
|
315
|
+
# listener that goes out of scope keeps "working" only by accident.
|
|
316
|
+
self._listener = FailproofAICrewListener(self)
|
|
317
|
+
self._patch_task_human_input()
|
|
318
|
+
|
|
319
|
+
def uninstall(self) -> None:
|
|
320
|
+
listener, self._listener = self._listener, None
|
|
321
|
+
if listener is not None:
|
|
322
|
+
listener.teardown()
|
|
323
|
+
self._restore_patches()
|
|
324
|
+
# A run that is still open when the customer uninstruments would render
|
|
325
|
+
# `ongoing` forever. Close it, then forget everything.
|
|
326
|
+
self._close_everything(outcome="cancelled")
|
|
327
|
+
with self._lock:
|
|
328
|
+
self._nodes.clear()
|
|
329
|
+
self._leaves.clear()
|
|
330
|
+
self._streams.clear()
|
|
331
|
+
self._pauses.clear()
|
|
332
|
+
self._roots.clear()
|
|
333
|
+
if self._tracker is not None:
|
|
334
|
+
self._tracker.reset()
|
|
335
|
+
self._tracker = None
|
|
336
|
+
|
|
337
|
+
# -- bookkeeping --------------------------------------------------------
|
|
338
|
+
|
|
339
|
+
def _note(self, event_id, parent, kind, task=None, agent_id=None) -> None:
|
|
340
|
+
with self._lock:
|
|
341
|
+
while len(self._nodes) >= _MAX_NODES:
|
|
342
|
+
self._nodes.pop(next(iter(self._nodes)), None)
|
|
343
|
+
inherited = task
|
|
344
|
+
if inherited is None and parent is not None:
|
|
345
|
+
node = self._nodes.get(parent)
|
|
346
|
+
inherited = node.task if node is not None else None
|
|
347
|
+
self._nodes[event_id] = _Node(kind, parent, inherited, agent_id)
|
|
348
|
+
|
|
349
|
+
def _parent_key(self, parent_event_id, *, allow_root_fallback: bool = True):
|
|
350
|
+
"""Which run a child should hang off.
|
|
351
|
+
|
|
352
|
+
CrewAI's `parent_event_id` comes off a contextvar scope stack. On 1.15.16
|
|
353
|
+
it survives crewai's own thread pool — measured: an `async_execution`
|
|
354
|
+
task's events all arrive with it set — so the fallback is for the events
|
|
355
|
+
whose parent span is genuinely gone: one that arrives after
|
|
356
|
+
`_close_span` popped its parent, or after `_MAX_NODES` evicted it.
|
|
357
|
+
Falling back to the open root keeps those inside the session instead of
|
|
358
|
+
minting a second one, which is the failure that splits one run into many.
|
|
359
|
+
|
|
360
|
+
Root-capable events (`crew_kickoff_started`, `flow_started`) pass
|
|
361
|
+
`allow_root_fallback=False`: for them a missing parent genuinely means
|
|
362
|
+
"this is the top".
|
|
363
|
+
"""
|
|
364
|
+
with self._lock:
|
|
365
|
+
if parent_event_id is not None and parent_event_id in self._nodes:
|
|
366
|
+
return parent_event_id
|
|
367
|
+
if not allow_root_fallback or not self._roots:
|
|
368
|
+
return None
|
|
369
|
+
roots = list(self._roots)
|
|
370
|
+
if len(roots) == 1:
|
|
371
|
+
return roots[0]
|
|
372
|
+
return self._root_for_current_context(roots)
|
|
373
|
+
|
|
374
|
+
def _root_for_current_context(self, roots):
|
|
375
|
+
"""Which open root an orphaned event belongs to, with several open.
|
|
376
|
+
|
|
377
|
+
`_roots` is process-global, so two crews kicked off on two threads leave
|
|
378
|
+
two entries in it and `roots[-1]` is a coin flip — one that files one
|
|
379
|
+
run's events under the OTHER run's session id. That is silent
|
|
380
|
+
cross-session corruption, which is strictly worse than a missing row,
|
|
381
|
+
and it is reachable without any `async_execution`: a late
|
|
382
|
+
`ToolUsageFinished` whose agent span has already been closed, or an
|
|
383
|
+
eviction at `_MAX_NODES`, is enough to reach this fallback.
|
|
384
|
+
|
|
385
|
+
`CrewAIEventsBus.emit` copies the EMITTING thread's contextvars onto the
|
|
386
|
+
handler task, so an ambient `failproofai_sdk.session()` is a real signal about
|
|
387
|
+
which of the open runs we are inside. Prefer the newest root that agrees
|
|
388
|
+
with it; only guess when there is no ambient session to check against.
|
|
389
|
+
"""
|
|
390
|
+
tracker = self._tracker
|
|
391
|
+
if tracker is None:
|
|
392
|
+
return roots[-1]
|
|
393
|
+
ambient = tracker.identity(None, None, warn=False)
|
|
394
|
+
if ambient is None:
|
|
395
|
+
return roots[-1]
|
|
396
|
+
for key in reversed(roots):
|
|
397
|
+
identity = tracker.identity(key, warn=False)
|
|
398
|
+
if identity is not None and identity.session_id == ambient.session_id:
|
|
399
|
+
return key
|
|
400
|
+
return roots[-1]
|
|
401
|
+
|
|
402
|
+
def _task_of(self, event):
|
|
403
|
+
"""(task_id, task_name) for an event, from the event or its ancestors."""
|
|
404
|
+
task_id = getattr(event, "task_id", None)
|
|
405
|
+
task_name = getattr(event, "task_name", None)
|
|
406
|
+
if task_id or task_name:
|
|
407
|
+
return (task_id, task_name)
|
|
408
|
+
with self._lock:
|
|
409
|
+
node = self._nodes.get(getattr(event, "parent_event_id", None))
|
|
410
|
+
return node.task if node is not None else None
|
|
411
|
+
|
|
412
|
+
# -- leaf spans ---------------------------------------------------------
|
|
413
|
+
|
|
414
|
+
def _open_leaf(self, method, key, parent_key, match, started, fields) -> None:
|
|
415
|
+
with self._lock:
|
|
416
|
+
while len(self._leaves) >= _MAX_LEAVES:
|
|
417
|
+
self._leaves.pop(next(iter(self._leaves)), None)
|
|
418
|
+
self._leaves[key] = _Leaf(method, key, parent_key, match, started, fields)
|
|
419
|
+
|
|
420
|
+
def _pop_leaf(self, start_id, match):
|
|
421
|
+
"""The open leaf this ending event closes.
|
|
422
|
+
|
|
423
|
+
`started_event_id` is the authority — it is populated on every
|
|
424
|
+
`*Completed`/`*Failed` event on this version. The `match` scan is the
|
|
425
|
+
documented fallback for the case where the scope stack was unwound by an
|
|
426
|
+
exception before the ending event was emitted: last-opened wins, which is
|
|
427
|
+
the right answer for a nested retry.
|
|
428
|
+
"""
|
|
429
|
+
with self._lock:
|
|
430
|
+
if start_id is not None:
|
|
431
|
+
leaf = self._leaves.pop(start_id, None)
|
|
432
|
+
if leaf is not None:
|
|
433
|
+
return leaf
|
|
434
|
+
for key in reversed(self._leaves):
|
|
435
|
+
if self._leaves[key].match == match:
|
|
436
|
+
return self._leaves.pop(key)
|
|
437
|
+
return None
|
|
438
|
+
|
|
439
|
+
def _descends_from(self, node_id, ancestor) -> bool:
|
|
440
|
+
seen = set()
|
|
441
|
+
key = node_id
|
|
442
|
+
while key is not None and key not in seen:
|
|
443
|
+
if key == ancestor:
|
|
444
|
+
return True
|
|
445
|
+
seen.add(key)
|
|
446
|
+
node = self._nodes.get(key)
|
|
447
|
+
key = node.parent if node is not None else None
|
|
448
|
+
return False
|
|
449
|
+
|
|
450
|
+
def _close_span(self, node_id, *, outcome, summary=None, **fields) -> None:
|
|
451
|
+
"""End an agent span, after closing everything still open beneath it."""
|
|
452
|
+
tracker = self._tracker
|
|
453
|
+
if tracker is None or node_id is None:
|
|
454
|
+
return
|
|
455
|
+
with self._lock:
|
|
456
|
+
leaves = [
|
|
457
|
+
leaf
|
|
458
|
+
for leaf in reversed(list(self._leaves.values()))
|
|
459
|
+
if self._descends_from(leaf.parent_key, node_id)
|
|
460
|
+
]
|
|
461
|
+
for leaf in leaves:
|
|
462
|
+
self._leaves.pop(leaf.key, None)
|
|
463
|
+
descendants = [
|
|
464
|
+
key
|
|
465
|
+
for key in reversed(tracker.open_agents())
|
|
466
|
+
if key != node_id and self._descends_from(key, node_id)
|
|
467
|
+
]
|
|
468
|
+
for leaf in leaves:
|
|
469
|
+
self._force_close(leaf)
|
|
470
|
+
for key in descendants:
|
|
471
|
+
tracker.end_agent(key, outcome="cancelled", **fw_fields(closed_by="teardown"))
|
|
472
|
+
tracker.end_agent(node_id, outcome=outcome, summary=summary, **fields)
|
|
473
|
+
with self._lock:
|
|
474
|
+
self._roots = [key for key in self._roots if key != node_id]
|
|
475
|
+
for key in [k for k in self._nodes if self._descends_from(k, node_id)]:
|
|
476
|
+
self._nodes.pop(key, None)
|
|
477
|
+
|
|
478
|
+
def _force_close(self, leaf) -> None:
|
|
479
|
+
"""Close a leaf whose framework never reported an end."""
|
|
480
|
+
tracker = self._tracker
|
|
481
|
+
if tracker is None:
|
|
482
|
+
return
|
|
483
|
+
fields = dict(leaf.fields)
|
|
484
|
+
fields.update(fw_fields(incomplete=True, closed_by="teardown"))
|
|
485
|
+
if leaf.method == "hook_completed":
|
|
486
|
+
fields["outcome"] = "cancelled"
|
|
487
|
+
if leaf.method == "model_response":
|
|
488
|
+
# Invariant: EVERY model_response carries an int duration_ms, including
|
|
489
|
+
# the ones we synthesize. Without it the dashboard shows no duration
|
|
490
|
+
# for exactly the calls that went wrong.
|
|
491
|
+
fields["duration_ms"] = ms(datetime.now(timezone.utc) - leaf.started)
|
|
492
|
+
tracker.emit(leaf.method, leaf.key, parent_key=leaf.parent_key, **fields)
|
|
493
|
+
|
|
494
|
+
def _close_everything(self, *, outcome: str) -> None:
|
|
495
|
+
for node_id in list(reversed(self._roots)):
|
|
496
|
+
self._close_span(node_id, outcome=outcome)
|
|
497
|
+
tracker = self._tracker
|
|
498
|
+
if tracker is None:
|
|
499
|
+
return
|
|
500
|
+
with self._lock:
|
|
501
|
+
leaves = list(reversed(list(self._leaves.values())))
|
|
502
|
+
self._leaves.clear()
|
|
503
|
+
for leaf in leaves:
|
|
504
|
+
self._force_close(leaf)
|
|
505
|
+
tracker.close_open_agents(outcome=outcome)
|
|
506
|
+
|
|
507
|
+
# -- crew ---------------------------------------------------------------
|
|
508
|
+
|
|
509
|
+
@safe
|
|
510
|
+
def on_crew_started(self, source, event) -> None:
|
|
511
|
+
tracker = self._tracker
|
|
512
|
+
if tracker is None:
|
|
513
|
+
return
|
|
514
|
+
# A fresh uuid4 per kickoff, NOT crew.id: crew.id is stable across
|
|
515
|
+
# kickoffs, so reusing it would merge every run of the same crew into one
|
|
516
|
+
# never-ending session. RunTracker mints it when no parent and no
|
|
517
|
+
# ambient scope supply one.
|
|
518
|
+
parent = self._parent_key(event.parent_event_id, allow_root_fallback=False)
|
|
519
|
+
agent_id = normalize_agent_id(getattr(event, "crew_name", None), default="crew")
|
|
520
|
+
tracker.start_agent(
|
|
521
|
+
event.event_id,
|
|
522
|
+
agent_id=agent_id,
|
|
523
|
+
parent_key=parent,
|
|
524
|
+
session_id=self._session_id if parent is None else None,
|
|
525
|
+
goal=_first_text(getattr(event, "inputs", None)),
|
|
526
|
+
**fw_fields(
|
|
527
|
+
kind="crew",
|
|
528
|
+
crew_name=getattr(event, "crew_name", None),
|
|
529
|
+
inputs=getattr(event, "inputs", None),
|
|
530
|
+
event_id=event.event_id,
|
|
531
|
+
),
|
|
532
|
+
)
|
|
533
|
+
self._note(event.event_id, parent, "crew", agent_id=agent_id)
|
|
534
|
+
with self._lock:
|
|
535
|
+
if parent is None:
|
|
536
|
+
self._roots.append(event.event_id)
|
|
537
|
+
|
|
538
|
+
@safe
|
|
539
|
+
def on_crew_completed(self, source, event) -> None:
|
|
540
|
+
self._close_span(
|
|
541
|
+
self._crew_key(event),
|
|
542
|
+
outcome="success",
|
|
543
|
+
summary=_text(getattr(event, "output", None)),
|
|
544
|
+
**fw_fields(total_tokens=getattr(event, "total_tokens", None)),
|
|
545
|
+
)
|
|
546
|
+
|
|
547
|
+
@safe
|
|
548
|
+
def on_crew_failed(self, source, event) -> None:
|
|
549
|
+
# The error is reported on the span that OWNS it. No standalone `error`
|
|
550
|
+
# event: `sessionSummary.errorCount` counts both, so emitting one here
|
|
551
|
+
# would double-count every failed crew.
|
|
552
|
+
self._close_span(
|
|
553
|
+
self._crew_key(event),
|
|
554
|
+
outcome="failed",
|
|
555
|
+
summary=_text(getattr(event, "error", None)),
|
|
556
|
+
**fw_fields(error=_text(getattr(event, "error", None))),
|
|
557
|
+
)
|
|
558
|
+
|
|
559
|
+
def _crew_key(self, event):
|
|
560
|
+
"""The crew span this ending event closes.
|
|
561
|
+
|
|
562
|
+
`started_event_id` is set explicitly by `Crew._finish_execution`, so it
|
|
563
|
+
is reliable; the root fallback exists because a session left with an open
|
|
564
|
+
root `agent_start` renders `ongoing` forever, which is the single worst
|
|
565
|
+
outcome available here.
|
|
566
|
+
"""
|
|
567
|
+
with self._lock:
|
|
568
|
+
node_id = getattr(event, "started_event_id", None)
|
|
569
|
+
if node_id is not None and node_id in self._nodes:
|
|
570
|
+
return node_id
|
|
571
|
+
roots = list(self._roots)
|
|
572
|
+
if not roots:
|
|
573
|
+
return None
|
|
574
|
+
# Same reasoning as `_root_for_current_context`: closing "the newest
|
|
575
|
+
# root" while a second crew is open on another thread ends the WRONG
|
|
576
|
+
# crew, which reads as one run finishing early and one hanging forever.
|
|
577
|
+
return roots[0] if len(roots) == 1 else self._root_for_current_context(roots)
|
|
578
|
+
|
|
579
|
+
# -- flows --------------------------------------------------------------
|
|
580
|
+
|
|
581
|
+
@safe
|
|
582
|
+
def on_flow_started(self, source, event) -> None:
|
|
583
|
+
tracker = self._tracker
|
|
584
|
+
if tracker is None:
|
|
585
|
+
return
|
|
586
|
+
parent_id = getattr(event, "parent_event_id", None)
|
|
587
|
+
with self._lock:
|
|
588
|
+
parent_node = self._nodes.get(parent_id) if parent_id else None
|
|
589
|
+
internal = (parent_node is not None and parent_node.kind == "agent") or (
|
|
590
|
+
getattr(event, "flow_name", None) in INTERNAL_FLOW_NAMES
|
|
591
|
+
)
|
|
592
|
+
if internal:
|
|
593
|
+
# CrewAI's own agent executor. Link it so the LLM and tool events
|
|
594
|
+
# underneath resolve to the agent, but emit nothing: it is not a
|
|
595
|
+
# flow the user wrote, and its flow_finished is missing entirely
|
|
596
|
+
# when the agent's LLM call raises.
|
|
597
|
+
parent = self._parent_key(parent_id)
|
|
598
|
+
tracker.link(event.event_id, parent)
|
|
599
|
+
self._note(event.event_id, parent, "flow_internal")
|
|
600
|
+
return
|
|
601
|
+
parent = self._parent_key(parent_id, allow_root_fallback=False)
|
|
602
|
+
agent_id = normalize_agent_id(getattr(event, "flow_name", None), default="flow")
|
|
603
|
+
tracker.start_agent(
|
|
604
|
+
event.event_id,
|
|
605
|
+
agent_id=agent_id,
|
|
606
|
+
parent_key=parent,
|
|
607
|
+
session_id=self._session_id if parent is None else None,
|
|
608
|
+
goal=_first_text(getattr(event, "inputs", None)),
|
|
609
|
+
**fw_fields(
|
|
610
|
+
kind="flow",
|
|
611
|
+
flow_name=getattr(event, "flow_name", None),
|
|
612
|
+
inputs=getattr(event, "inputs", None),
|
|
613
|
+
event_id=event.event_id,
|
|
614
|
+
),
|
|
615
|
+
)
|
|
616
|
+
self._note(event.event_id, parent, "flow", agent_id=agent_id)
|
|
617
|
+
with self._lock:
|
|
618
|
+
if parent is None:
|
|
619
|
+
self._roots.append(event.event_id)
|
|
620
|
+
|
|
621
|
+
@safe
|
|
622
|
+
def on_flow_finished(self, source, event) -> None:
|
|
623
|
+
node_id = self._flow_key(event)
|
|
624
|
+
if node_id is None:
|
|
625
|
+
return
|
|
626
|
+
self._close_span(
|
|
627
|
+
node_id, outcome="success", summary=_text(getattr(event, "result", None))
|
|
628
|
+
)
|
|
629
|
+
|
|
630
|
+
@safe
|
|
631
|
+
def on_flow_failed(self, source, event) -> None:
|
|
632
|
+
node_id = self._flow_key(event)
|
|
633
|
+
if node_id is None:
|
|
634
|
+
return
|
|
635
|
+
error = _text(getattr(event, "error", None))
|
|
636
|
+
self._close_span(
|
|
637
|
+
node_id,
|
|
638
|
+
outcome="failed",
|
|
639
|
+
summary=error,
|
|
640
|
+
**fw_fields(error=error),
|
|
641
|
+
)
|
|
642
|
+
|
|
643
|
+
def _flow_key(self, event):
|
|
644
|
+
"""The flow span this ending event closes, or None if there is nothing to close.
|
|
645
|
+
|
|
646
|
+
`flow_internal` is the AgentExecutor pass-through link (see 5. in the
|
|
647
|
+
module docstring): it opened no span, so its end just drops the node.
|
|
648
|
+
"""
|
|
649
|
+
node_id = getattr(event, "started_event_id", None)
|
|
650
|
+
with self._lock:
|
|
651
|
+
node = self._nodes.get(node_id) if node_id else None
|
|
652
|
+
if node is not None and node.kind == "flow_internal":
|
|
653
|
+
self._nodes.pop(node_id, None)
|
|
654
|
+
return None
|
|
655
|
+
if node is None:
|
|
656
|
+
return None
|
|
657
|
+
return node_id
|
|
658
|
+
|
|
659
|
+
# -- tasks: recorded, never emitted -------------------------------------
|
|
660
|
+
|
|
661
|
+
@safe
|
|
662
|
+
def on_task_started(self, source, event) -> None:
|
|
663
|
+
tracker = self._tracker
|
|
664
|
+
if tracker is None:
|
|
665
|
+
return
|
|
666
|
+
parent = self._parent_key(event.parent_event_id)
|
|
667
|
+
tracker.link(event.event_id, parent)
|
|
668
|
+
task = (getattr(event, "task_id", None), getattr(event, "task_name", None))
|
|
669
|
+
self._note(event.event_id, parent, "task", task=task)
|
|
670
|
+
|
|
671
|
+
@safe
|
|
672
|
+
def on_task_finished(self, source, event) -> None:
|
|
673
|
+
with self._lock:
|
|
674
|
+
self._nodes.pop(event.started_event_id, None)
|
|
675
|
+
|
|
676
|
+
# -- agents -------------------------------------------------------------
|
|
677
|
+
|
|
678
|
+
@safe
|
|
679
|
+
def on_agent_started(self, source, event) -> None:
|
|
680
|
+
tracker = self._tracker
|
|
681
|
+
if tracker is None:
|
|
682
|
+
return
|
|
683
|
+
agent = getattr(event, "agent", None)
|
|
684
|
+
parent = self._parent_key(event.parent_event_id)
|
|
685
|
+
# `event.agent_role` is None on this event (only the tool and LLM events
|
|
686
|
+
# get it filled in), so the role comes off the agent object. The UUID in
|
|
687
|
+
# `agent.id` must never reach agent_id.
|
|
688
|
+
role = getattr(event, "agent_role", None) or getattr(agent, "role", None)
|
|
689
|
+
agent_id = normalize_agent_id(role, default="agent")
|
|
690
|
+
task = self._task_of(event) or (None, None)
|
|
691
|
+
tracker.start_agent(
|
|
692
|
+
event.event_id,
|
|
693
|
+
agent_id=agent_id,
|
|
694
|
+
parent_key=parent,
|
|
695
|
+
goal=_text(getattr(agent, "goal", None)),
|
|
696
|
+
**fw_fields(
|
|
697
|
+
kind="agent",
|
|
698
|
+
agent_id=str(getattr(agent, "id", "")) or None,
|
|
699
|
+
agent_role=role,
|
|
700
|
+
task_id=task[0],
|
|
701
|
+
task_name=task[1],
|
|
702
|
+
tools=_tool_names(getattr(event, "tools", None)),
|
|
703
|
+
allow_delegation=getattr(agent, "allow_delegation", None),
|
|
704
|
+
event_id=event.event_id,
|
|
705
|
+
),
|
|
706
|
+
)
|
|
707
|
+
self._note(event.event_id, parent, "agent", task=task, agent_id=agent_id)
|
|
708
|
+
|
|
709
|
+
@safe
|
|
710
|
+
def on_lite_agent_started(self, source, event) -> None:
|
|
711
|
+
"""`Agent.kickoff()`. A root, like a crew or a user-written flow.
|
|
712
|
+
|
|
713
|
+
The role is only in `agent_info` here — `agent_role` is None on this
|
|
714
|
+
event, exactly as it is on `AgentExecutionStartedEvent`, and
|
|
715
|
+
`agent_info["id"]` is a UUID that must not reach `agent_id`.
|
|
716
|
+
"""
|
|
717
|
+
tracker = self._tracker
|
|
718
|
+
if tracker is None:
|
|
719
|
+
return
|
|
720
|
+
info = getattr(event, "agent_info", None)
|
|
721
|
+
info = info if isinstance(info, dict) else {}
|
|
722
|
+
parent = self._parent_key(
|
|
723
|
+
getattr(event, "parent_event_id", None), allow_root_fallback=False
|
|
724
|
+
)
|
|
725
|
+
role = getattr(event, "agent_role", None) or info.get("role")
|
|
726
|
+
agent_id = normalize_agent_id(role, default="agent")
|
|
727
|
+
tracker.start_agent(
|
|
728
|
+
event.event_id,
|
|
729
|
+
agent_id=agent_id,
|
|
730
|
+
parent_key=parent,
|
|
731
|
+
session_id=self._session_id if parent is None else None,
|
|
732
|
+
goal=_text(info.get("goal")),
|
|
733
|
+
**fw_fields(
|
|
734
|
+
kind="agent",
|
|
735
|
+
lite=True,
|
|
736
|
+
agent_id=str(info.get("id") or "") or None,
|
|
737
|
+
agent_role=role,
|
|
738
|
+
tools=_tool_names(getattr(event, "tools", None)),
|
|
739
|
+
event_id=event.event_id,
|
|
740
|
+
),
|
|
741
|
+
)
|
|
742
|
+
self._note(event.event_id, parent, "agent", agent_id=agent_id)
|
|
743
|
+
with self._lock:
|
|
744
|
+
if parent is None:
|
|
745
|
+
self._roots.append(event.event_id)
|
|
746
|
+
|
|
747
|
+
@safe
|
|
748
|
+
def on_agent_completed(self, source, event) -> None:
|
|
749
|
+
self._close_span(
|
|
750
|
+
event.started_event_id,
|
|
751
|
+
outcome="success",
|
|
752
|
+
summary=_text(getattr(event, "output", None)),
|
|
753
|
+
)
|
|
754
|
+
|
|
755
|
+
@safe
|
|
756
|
+
def on_agent_error(self, source, event) -> None:
|
|
757
|
+
self._close_span(
|
|
758
|
+
event.started_event_id,
|
|
759
|
+
outcome="failed",
|
|
760
|
+
summary=_text(getattr(event, "error", None)),
|
|
761
|
+
**fw_fields(error=_text(getattr(event, "error", None))),
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
# -- flow methods and guardrails: hook pairs ----------------------------
|
|
765
|
+
|
|
766
|
+
@safe
|
|
767
|
+
def on_method_started(self, source, event) -> None:
|
|
768
|
+
self._hook_start(
|
|
769
|
+
event,
|
|
770
|
+
hook_name=getattr(event, "method_name", None) or "flow_method",
|
|
771
|
+
trigger_event="flow_method",
|
|
772
|
+
input=_dictify(getattr(event, "params", None)),
|
|
773
|
+
extra=fw_fields(flow_name=getattr(event, "flow_name", None)),
|
|
774
|
+
)
|
|
775
|
+
|
|
776
|
+
@safe
|
|
777
|
+
def on_method_finished(self, source, event) -> None:
|
|
778
|
+
self._hook_end(event, outcome="success", output=_text(getattr(event, "result", None)))
|
|
779
|
+
|
|
780
|
+
@safe
|
|
781
|
+
def on_method_failed(self, source, event) -> None:
|
|
782
|
+
self._hook_end(
|
|
783
|
+
event, outcome="failed", error=_text(getattr(event, "error", None))
|
|
784
|
+
)
|
|
785
|
+
|
|
786
|
+
# -- human in the loop --------------------------------------------------
|
|
787
|
+
#
|
|
788
|
+
# The same four events, in the same order, as the LangChain and LlamaIndex
|
|
789
|
+
# adapters. Neither pair is redundant and neither is optional:
|
|
790
|
+
#
|
|
791
|
+
# human_wait -> human_input carries the prompt, the answer and the
|
|
792
|
+
# pendingHuman count;
|
|
793
|
+
# agent_pause -> agent_resume is the ONLY thing that feeds pausedMs, so
|
|
794
|
+
# without it the wait is billed as active
|
|
795
|
+
# time on the agent.
|
|
796
|
+
#
|
|
797
|
+
# PAIRING is the whole difficulty here. Verified against crewai 1.15.16:
|
|
798
|
+
# `crewai.flow.runtime` constructs both events with **no correlation id at
|
|
799
|
+
# all** — `request_id` is None on both and `started_event_id` is None on the
|
|
800
|
+
# received one, so there is nothing to join on. What they do share is
|
|
801
|
+
# `(flow_name, method_name)`, and the interaction is strictly sequential:
|
|
802
|
+
# the runtime emits `requested`, blocks on `input()`, then emits `received`.
|
|
803
|
+
#
|
|
804
|
+
# So the lookup is `request_id` -> `(flow_name, method_name)` -> most
|
|
805
|
+
# recently opened, in that order. The last fallback is sound rather than a
|
|
806
|
+
# guess, because a blocking console prompt cannot interleave with another.
|
|
807
|
+
# `request_id` is tried first anyway: the enterprise async provider in
|
|
808
|
+
# `crewai.flow.async_feedback` does set it, and that one CAN interleave.
|
|
809
|
+
|
|
810
|
+
@staticmethod
|
|
811
|
+
def _pause_lookup_keys(event) -> tuple:
|
|
812
|
+
"""Join keys for one HITL event, most specific first."""
|
|
813
|
+
keys = []
|
|
814
|
+
request_id = getattr(event, "request_id", None)
|
|
815
|
+
if request_id:
|
|
816
|
+
keys.append(("request", str(request_id)))
|
|
817
|
+
started = getattr(event, "started_event_id", None)
|
|
818
|
+
if started:
|
|
819
|
+
keys.append(("request", str(started)))
|
|
820
|
+
flow = getattr(event, "flow_name", None)
|
|
821
|
+
method = getattr(event, "method_name", None)
|
|
822
|
+
if flow or method:
|
|
823
|
+
keys.append(("method", str(flow), str(method)))
|
|
824
|
+
return tuple(keys)
|
|
825
|
+
|
|
826
|
+
@safe
|
|
827
|
+
def on_human_requested(self, source, event) -> None:
|
|
828
|
+
tracker = self._tracker
|
|
829
|
+
if tracker is None:
|
|
830
|
+
return
|
|
831
|
+
# The id the SDK pairs on. `event_id` is always present and unique;
|
|
832
|
+
# `request_id` is usually None, so it cannot serve as this.
|
|
833
|
+
pause_id = str(getattr(event, "event_id", None) or uuid.uuid4().hex)
|
|
834
|
+
parent = self._parent_key(getattr(event, "parent_event_id", None))
|
|
835
|
+
prompt = _text(getattr(event, "message", None))
|
|
836
|
+
record = (pause_id, parent, prompt)
|
|
837
|
+
|
|
838
|
+
with self._lock:
|
|
839
|
+
while len(self._pauses) >= _MAX_NODES:
|
|
840
|
+
self._pauses.pop(next(iter(self._pauses)), None)
|
|
841
|
+
for key in self._pause_lookup_keys(event):
|
|
842
|
+
self._pauses[key] = record
|
|
843
|
+
# Always keyed by its own id too, so a `received` that does carry an
|
|
844
|
+
# id finds it even when flow and method are both blank.
|
|
845
|
+
self._pauses[("request", pause_id)] = record
|
|
846
|
+
|
|
847
|
+
options = [str(o) for o in (getattr(event, "emit", None) or [])] or None
|
|
848
|
+
extra = fw_fields(
|
|
849
|
+
flow_name=getattr(event, "flow_name", None),
|
|
850
|
+
method_name=getattr(event, "method_name", None),
|
|
851
|
+
output=_text(getattr(event, "output", None)),
|
|
852
|
+
)
|
|
853
|
+
tracker.emit(
|
|
854
|
+
"human_wait",
|
|
855
|
+
pause_id,
|
|
856
|
+
parent_key=parent,
|
|
857
|
+
input_id=pause_id,
|
|
858
|
+
prompt=prompt,
|
|
859
|
+
options=options,
|
|
860
|
+
reason="crewai_human_feedback",
|
|
861
|
+
**extra,
|
|
862
|
+
)
|
|
863
|
+
tracker.emit(
|
|
864
|
+
"agent_pause",
|
|
865
|
+
pause_id,
|
|
866
|
+
parent_key=parent,
|
|
867
|
+
pause_id=pause_id,
|
|
868
|
+
reason="crewai_human_feedback",
|
|
869
|
+
**extra,
|
|
870
|
+
)
|
|
871
|
+
|
|
872
|
+
@safe
|
|
873
|
+
def on_human_received(self, source, event) -> None:
|
|
874
|
+
tracker = self._tracker
|
|
875
|
+
if tracker is None:
|
|
876
|
+
return
|
|
877
|
+
feedback = _text(getattr(event, "feedback", None))
|
|
878
|
+
extra = fw_fields(
|
|
879
|
+
flow_name=getattr(event, "flow_name", None),
|
|
880
|
+
method_name=getattr(event, "method_name", None),
|
|
881
|
+
outcome_hint=getattr(event, "outcome", None),
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
record = None
|
|
885
|
+
with self._lock:
|
|
886
|
+
for key in self._pause_lookup_keys(event):
|
|
887
|
+
record = self._pauses.pop(key, None)
|
|
888
|
+
if record is not None:
|
|
889
|
+
break
|
|
890
|
+
if record is None and self._pauses:
|
|
891
|
+
# Sequential fallback: the most recently opened pause.
|
|
892
|
+
_, record = self._pauses.popitem(last=True)
|
|
893
|
+
if record is not None:
|
|
894
|
+
# Drop this record's other alias keys so it cannot pair twice.
|
|
895
|
+
for key in [k for k, v in self._pauses.items() if v is record]:
|
|
896
|
+
self._pauses.pop(key, None)
|
|
897
|
+
|
|
898
|
+
if record is None:
|
|
899
|
+
# Feedback for a request we never saw — a flow resumed in another
|
|
900
|
+
# process, or one that predates instrument(). Record the answer but
|
|
901
|
+
# NOT agent_resume: closing a pause that never opened subtracts a
|
|
902
|
+
# pausedMs interval that was never added. `input_id` falls back to
|
|
903
|
+
# this event's own id and is NEVER None, which is a hard TypeError
|
|
904
|
+
# on `human_input` rather than a quietly dropped field.
|
|
905
|
+
own_id = str(getattr(event, "event_id", None) or uuid.uuid4().hex)
|
|
906
|
+
tracker.emit(
|
|
907
|
+
"human_input",
|
|
908
|
+
own_id,
|
|
909
|
+
parent_key=self._parent_key(getattr(event, "parent_event_id", None)),
|
|
910
|
+
input_id=own_id,
|
|
911
|
+
response=feedback,
|
|
912
|
+
**fw_fields(orphaned=True),
|
|
913
|
+
**extra,
|
|
914
|
+
)
|
|
915
|
+
return
|
|
916
|
+
|
|
917
|
+
pause_id, parent, _prompt = record
|
|
918
|
+
# agent_resume FIRST: the dashboard closes the pause on it, and
|
|
919
|
+
# `duration_ms` on both closing events is measured from the matching
|
|
920
|
+
# opening one.
|
|
921
|
+
tracker.emit(
|
|
922
|
+
"agent_resume",
|
|
923
|
+
pause_id,
|
|
924
|
+
parent_key=parent,
|
|
925
|
+
pause_id=pause_id,
|
|
926
|
+
reason="crewai_human_feedback",
|
|
927
|
+
**extra,
|
|
928
|
+
)
|
|
929
|
+
tracker.emit(
|
|
930
|
+
"human_input",
|
|
931
|
+
pause_id,
|
|
932
|
+
parent_key=parent,
|
|
933
|
+
input_id=pause_id,
|
|
934
|
+
response=feedback,
|
|
935
|
+
**extra,
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
# -- the OTHER human-in-the-loop surface: Task(human_input=True) ---------
|
|
939
|
+
#
|
|
940
|
+
# crewai has TWO of them and only one is on the event bus.
|
|
941
|
+
# `@human_feedback` on a Flow method emits the pair mapped above.
|
|
942
|
+
# `human_input=True` on a crew Task — the surface crewai's own Crew
|
|
943
|
+
# documentation teaches, and the one most people mean by "HITL in crewai" —
|
|
944
|
+
# runs through `crewai.core.providers.human_input.SyncHumanInputProvider`,
|
|
945
|
+
# which prints a rich panel, calls `input()`, and emits NOTHING. Verified
|
|
946
|
+
# against crewai 1.15.16: no event class in `crewai.events` fires for it.
|
|
947
|
+
#
|
|
948
|
+
# Subscribing therefore cannot reach it, so this one surface is instrumented
|
|
949
|
+
# by WRAPPING. It is the only patch in this adapter. Three properties keep
|
|
950
|
+
# that acceptable:
|
|
951
|
+
#
|
|
952
|
+
# * `_prompt_input` is the narrowest possible seam — it is exactly the
|
|
953
|
+
# blocking call, so the pause interval is the human's wait and nothing
|
|
954
|
+
# else. Wrapping `handle_feedback` instead would fold the agent's
|
|
955
|
+
# re-invocation LLM calls into pausedMs.
|
|
956
|
+
# * the call is inside one `try` whose only job is to re-raise, and both
|
|
957
|
+
# of our own hooks are `@safe`, so nothing here can change what the
|
|
958
|
+
# customer's crew returns or raises.
|
|
959
|
+
# * if crewai moves it, `_compat.probe` disables this pair and the rest of
|
|
960
|
+
# the adapter is untouched.
|
|
961
|
+
|
|
962
|
+
_HITL_PROMPT = (
|
|
963
|
+
"crewai is blocked on human feedback for this task's result "
|
|
964
|
+
"(an empty answer accepts it)."
|
|
965
|
+
)
|
|
966
|
+
_HITL_REASON = "crewai_task_human_input"
|
|
967
|
+
|
|
968
|
+
def _patch_task_human_input(self) -> None:
|
|
969
|
+
try:
|
|
970
|
+
from crewai.core.providers.human_input import SyncHumanInputProvider
|
|
971
|
+
except Exception: # pragma: no cover - exercised by a crewai that moved it
|
|
972
|
+
_compat.probe(NAME, "Task(human_input=True)", lambda: False)
|
|
973
|
+
return
|
|
974
|
+
for attr, is_async in (("_prompt_input", False), ("_prompt_input_async", True)):
|
|
975
|
+
original = getattr(SyncHumanInputProvider, attr, None)
|
|
976
|
+
if original is None:
|
|
977
|
+
_compat.probe(NAME, f"SyncHumanInputProvider.{attr}", lambda: False)
|
|
978
|
+
continue
|
|
979
|
+
if getattr(original, "__failproofai_wrapped__", None) is not None:
|
|
980
|
+
# Already ours — an install that ran without its uninstall.
|
|
981
|
+
# Wrapping again would emit the pause twice per prompt.
|
|
982
|
+
continue
|
|
983
|
+
wrapper = self._wrap_prompt(original, is_async=is_async)
|
|
984
|
+
wrapper.__failproofai_wrapped__ = original
|
|
985
|
+
# `staticmethod(...)`, not the bare function: `_prompt_input` is a
|
|
986
|
+
# staticmethod and the call site is `self._prompt_input(crew)`, so a
|
|
987
|
+
# plain function would bind and arrive with `self` as `crew`.
|
|
988
|
+
descriptor = SyncHumanInputProvider.__dict__.get(attr)
|
|
989
|
+
setattr(SyncHumanInputProvider, attr, staticmethod(wrapper))
|
|
990
|
+
self._patched.append((SyncHumanInputProvider, attr, descriptor, wrapper))
|
|
991
|
+
|
|
992
|
+
def _restore_patches(self) -> None:
|
|
993
|
+
for klass, attr, descriptor, installed in reversed(self._patched):
|
|
994
|
+
try:
|
|
995
|
+
if getattr(klass, attr, None) is not installed:
|
|
996
|
+
# Somebody patched on top of us; restoring would delete
|
|
997
|
+
# their patch. Same rule as `_core.Patcher`.
|
|
998
|
+
logger.warning(
|
|
999
|
+
"failproofai_sdk: not restoring %s.%s — it is no longer the object "
|
|
1000
|
+
"failproofai_sdk installed.",
|
|
1001
|
+
klass.__name__,
|
|
1002
|
+
attr,
|
|
1003
|
+
)
|
|
1004
|
+
continue
|
|
1005
|
+
if descriptor is not None:
|
|
1006
|
+
setattr(klass, attr, descriptor)
|
|
1007
|
+
else:
|
|
1008
|
+
delattr(klass, attr)
|
|
1009
|
+
except Exception: # pragma: no cover - teardown must never raise
|
|
1010
|
+
logger.warning(
|
|
1011
|
+
"failproofai_sdk: failed to restore %s.%s", klass, attr, exc_info=True
|
|
1012
|
+
)
|
|
1013
|
+
self._patched.clear()
|
|
1014
|
+
|
|
1015
|
+
def _wrap_prompt(self, original, *, is_async):
|
|
1016
|
+
adapter = self
|
|
1017
|
+
|
|
1018
|
+
if is_async:
|
|
1019
|
+
async def _failproofai_prompt(*args, **kwargs):
|
|
1020
|
+
record = adapter._human_wait_start(args[0] if args else None)
|
|
1021
|
+
try:
|
|
1022
|
+
answer = await original(*args, **kwargs)
|
|
1023
|
+
except BaseException as exc: # noqa: BLE001 - re-raised below
|
|
1024
|
+
adapter._human_wait_end(record, None, exc)
|
|
1025
|
+
raise
|
|
1026
|
+
adapter._human_wait_end(record, answer, None)
|
|
1027
|
+
return answer
|
|
1028
|
+
else:
|
|
1029
|
+
def _failproofai_prompt(*args, **kwargs):
|
|
1030
|
+
record = adapter._human_wait_start(args[0] if args else None)
|
|
1031
|
+
try:
|
|
1032
|
+
answer = original(*args, **kwargs)
|
|
1033
|
+
except BaseException as exc: # noqa: BLE001 - re-raised below
|
|
1034
|
+
adapter._human_wait_end(record, None, exc)
|
|
1035
|
+
raise
|
|
1036
|
+
adapter._human_wait_end(record, answer, None)
|
|
1037
|
+
return answer
|
|
1038
|
+
|
|
1039
|
+
_failproofai_prompt.__name__ = getattr(original, "__name__", "_prompt_input")
|
|
1040
|
+
_failproofai_prompt.__qualname__ = "failproofai_sdk.crewai.task_human_input"
|
|
1041
|
+
return _failproofai_prompt
|
|
1042
|
+
|
|
1043
|
+
@safe
|
|
1044
|
+
def _human_wait_start(self, crew):
|
|
1045
|
+
tracker = self._tracker
|
|
1046
|
+
if tracker is None:
|
|
1047
|
+
return None
|
|
1048
|
+
pause_id = uuid.uuid4().hex
|
|
1049
|
+
parent = self._current_agent_key()
|
|
1050
|
+
extra = fw_fields(
|
|
1051
|
+
surface="task_human_input",
|
|
1052
|
+
crew_name=getattr(crew, "name", None),
|
|
1053
|
+
)
|
|
1054
|
+
tracker.emit(
|
|
1055
|
+
"human_wait",
|
|
1056
|
+
pause_id,
|
|
1057
|
+
parent_key=parent,
|
|
1058
|
+
input_id=pause_id,
|
|
1059
|
+
prompt=self._HITL_PROMPT,
|
|
1060
|
+
reason=self._HITL_REASON,
|
|
1061
|
+
**extra,
|
|
1062
|
+
)
|
|
1063
|
+
tracker.emit(
|
|
1064
|
+
"agent_pause",
|
|
1065
|
+
pause_id,
|
|
1066
|
+
parent_key=parent,
|
|
1067
|
+
pause_id=pause_id,
|
|
1068
|
+
reason=self._HITL_REASON,
|
|
1069
|
+
**extra,
|
|
1070
|
+
)
|
|
1071
|
+
return (pause_id, parent)
|
|
1072
|
+
|
|
1073
|
+
@safe
|
|
1074
|
+
def _human_wait_end(self, record, answer, error):
|
|
1075
|
+
tracker = self._tracker
|
|
1076
|
+
if tracker is None or record is None:
|
|
1077
|
+
return
|
|
1078
|
+
pause_id, parent = record
|
|
1079
|
+
extra = fw_fields(surface="task_human_input", error=_text(str(error)) if error else None)
|
|
1080
|
+
# agent_resume FIRST, for the same reason as the flow pair above.
|
|
1081
|
+
tracker.emit(
|
|
1082
|
+
"agent_resume",
|
|
1083
|
+
pause_id,
|
|
1084
|
+
parent_key=parent,
|
|
1085
|
+
pause_id=pause_id,
|
|
1086
|
+
reason=self._HITL_REASON,
|
|
1087
|
+
**extra,
|
|
1088
|
+
)
|
|
1089
|
+
tracker.emit(
|
|
1090
|
+
"human_input",
|
|
1091
|
+
pause_id,
|
|
1092
|
+
parent_key=parent,
|
|
1093
|
+
input_id=pause_id,
|
|
1094
|
+
response=_text(answer),
|
|
1095
|
+
**extra,
|
|
1096
|
+
)
|
|
1097
|
+
|
|
1098
|
+
def _current_agent_key(self):
|
|
1099
|
+
"""The innermost open agent span — what a blocking prompt sits inside.
|
|
1100
|
+
|
|
1101
|
+
There is no event to read a parent off here, so it comes from the node
|
|
1102
|
+
table. Newest agent wins (a console prompt cannot interleave with
|
|
1103
|
+
another), except that with two crews open on two threads the newest is a
|
|
1104
|
+
coin flip, so an ambient session narrows it the same way
|
|
1105
|
+
`_root_for_current_context` does.
|
|
1106
|
+
"""
|
|
1107
|
+
tracker = self._tracker
|
|
1108
|
+
with self._lock:
|
|
1109
|
+
candidates = [key for key, node in self._nodes.items() if node.kind == "agent"]
|
|
1110
|
+
if not candidates:
|
|
1111
|
+
return self._parent_key(None)
|
|
1112
|
+
if len(candidates) == 1 or tracker is None:
|
|
1113
|
+
return candidates[-1]
|
|
1114
|
+
ambient = tracker.identity(None, None, warn=False)
|
|
1115
|
+
if ambient is None:
|
|
1116
|
+
return candidates[-1]
|
|
1117
|
+
for key in reversed(candidates):
|
|
1118
|
+
identity = tracker.identity(key, warn=False)
|
|
1119
|
+
if identity is not None and identity.session_id == ambient.session_id:
|
|
1120
|
+
return key
|
|
1121
|
+
return candidates[-1]
|
|
1122
|
+
|
|
1123
|
+
@safe
|
|
1124
|
+
def on_guardrail_started(self, source, event) -> None:
|
|
1125
|
+
self._hook_start(
|
|
1126
|
+
event,
|
|
1127
|
+
hook_name=getattr(event, "guardrail_name", None) or "guardrail",
|
|
1128
|
+
trigger_event="guardrail",
|
|
1129
|
+
input=None,
|
|
1130
|
+
extra=fw_fields(
|
|
1131
|
+
guardrail_type=getattr(event, "guardrail_type", None),
|
|
1132
|
+
retry_count=getattr(event, "retry_count", None),
|
|
1133
|
+
),
|
|
1134
|
+
)
|
|
1135
|
+
|
|
1136
|
+
@safe
|
|
1137
|
+
def on_guardrail_completed(self, source, event) -> None:
|
|
1138
|
+
# "rejected" is in the server's failure vocabulary
|
|
1139
|
+
# (error|failed|timeout|rejected), so a tripped guardrail paints red
|
|
1140
|
+
# rather than reading as a successful hook that happened to say no.
|
|
1141
|
+
passed = bool(getattr(event, "success", False))
|
|
1142
|
+
self._hook_end(
|
|
1143
|
+
event,
|
|
1144
|
+
outcome="success" if passed else "rejected",
|
|
1145
|
+
output=_text(getattr(event, "result", None)),
|
|
1146
|
+
error=_text(getattr(event, "error", None)),
|
|
1147
|
+
extra=fw_fields(retry_count=getattr(event, "retry_count", None)),
|
|
1148
|
+
)
|
|
1149
|
+
|
|
1150
|
+
def _hook_start(self, event, *, hook_name, trigger_event, input, extra) -> None:
|
|
1151
|
+
tracker = self._tracker
|
|
1152
|
+
if tracker is None:
|
|
1153
|
+
return
|
|
1154
|
+
parent = self._parent_key(event.parent_event_id)
|
|
1155
|
+
fields = dict(
|
|
1156
|
+
hook_name=hook_name,
|
|
1157
|
+
hook_id=event.event_id,
|
|
1158
|
+
trigger_event=trigger_event,
|
|
1159
|
+
input=input,
|
|
1160
|
+
**extra,
|
|
1161
|
+
)
|
|
1162
|
+
tracker.emit("hook_triggered", event.event_id, parent_key=parent, **fields)
|
|
1163
|
+
# A flow method is a PARENT: a `Crew.kickoff()` inside one arrives with
|
|
1164
|
+
# `parent_event_id` set to this event. Unless the id is a node,
|
|
1165
|
+
# `_parent_key` cannot see it, `on_crew_started` reads "no parent" and
|
|
1166
|
+
# mints a SECOND ROOT — a whole separate session for a crew that ran
|
|
1167
|
+
# inside the flow. See `_tool_start` for the same rule.
|
|
1168
|
+
self._note(event.event_id, parent, "hook")
|
|
1169
|
+
self._open_leaf(
|
|
1170
|
+
"hook_completed",
|
|
1171
|
+
event.event_id,
|
|
1172
|
+
parent,
|
|
1173
|
+
("hook", hook_name),
|
|
1174
|
+
event.timestamp,
|
|
1175
|
+
{"hook_name": hook_name, "hook_id": event.event_id},
|
|
1176
|
+
)
|
|
1177
|
+
|
|
1178
|
+
def _hook_end(self, event, *, outcome, output=None, error=None, extra=None) -> None:
|
|
1179
|
+
tracker = self._tracker
|
|
1180
|
+
if tracker is None:
|
|
1181
|
+
return
|
|
1182
|
+
leaf = self._pop_leaf(event.started_event_id, ("hook", _hook_name_of(event)))
|
|
1183
|
+
if leaf is None:
|
|
1184
|
+
return
|
|
1185
|
+
with self._lock:
|
|
1186
|
+
self._nodes.pop(leaf.key, None)
|
|
1187
|
+
# duration_ms is auto-computed by the SDK from the hook_triggered we
|
|
1188
|
+
# emitted, and is hard-rejected from callers on hook_completed.
|
|
1189
|
+
tracker.emit(
|
|
1190
|
+
"hook_completed",
|
|
1191
|
+
leaf.key,
|
|
1192
|
+
parent_key=leaf.parent_key,
|
|
1193
|
+
hook_name=leaf.fields["hook_name"],
|
|
1194
|
+
hook_id=leaf.fields["hook_id"],
|
|
1195
|
+
outcome=outcome,
|
|
1196
|
+
output=output,
|
|
1197
|
+
error=error,
|
|
1198
|
+
**(extra or {}),
|
|
1199
|
+
)
|
|
1200
|
+
|
|
1201
|
+
# -- tools --------------------------------------------------------------
|
|
1202
|
+
|
|
1203
|
+
@safe
|
|
1204
|
+
def on_tool_started(self, source, event) -> None:
|
|
1205
|
+
self._tool_start(
|
|
1206
|
+
event,
|
|
1207
|
+
tool_name=getattr(event, "tool_name", None) or "tool",
|
|
1208
|
+
input=_dictify(getattr(event, "tool_args", None)),
|
|
1209
|
+
extra=fw_fields(
|
|
1210
|
+
tool_class=getattr(event, "tool_class", None),
|
|
1211
|
+
run_attempts=getattr(event, "run_attempts", None),
|
|
1212
|
+
agent_role=getattr(event, "agent_role", None),
|
|
1213
|
+
agent_id=getattr(event, "agent_id", None),
|
|
1214
|
+
),
|
|
1215
|
+
)
|
|
1216
|
+
|
|
1217
|
+
@safe
|
|
1218
|
+
def on_tool_finished(self, source, event) -> None:
|
|
1219
|
+
self._tool_end(
|
|
1220
|
+
event,
|
|
1221
|
+
output=_text(getattr(event, "output", None)),
|
|
1222
|
+
error=None,
|
|
1223
|
+
extra=fw_fields(
|
|
1224
|
+
from_cache=getattr(event, "from_cache", None),
|
|
1225
|
+
run_attempts=getattr(event, "run_attempts", None),
|
|
1226
|
+
),
|
|
1227
|
+
)
|
|
1228
|
+
|
|
1229
|
+
@safe
|
|
1230
|
+
def on_tool_error(self, source, event) -> None:
|
|
1231
|
+
# A tool failure the agent loop catches and retries is not a run-level
|
|
1232
|
+
# error, so it is reported on the tool_result and nowhere else.
|
|
1233
|
+
self._tool_end(
|
|
1234
|
+
event,
|
|
1235
|
+
output=None,
|
|
1236
|
+
error=_text(getattr(event, "error", None)) or "tool failed",
|
|
1237
|
+
extra=fw_fields(run_attempts=getattr(event, "run_attempts", None)),
|
|
1238
|
+
)
|
|
1239
|
+
|
|
1240
|
+
@safe
|
|
1241
|
+
def on_store_started(self, source, event) -> None:
|
|
1242
|
+
name = STORE_TOOLS.get(type(event).__name__, "memory")
|
|
1243
|
+
self._tool_start(
|
|
1244
|
+
event,
|
|
1245
|
+
tool_name=name,
|
|
1246
|
+
input=_dictify(getattr(event, "query", None) or getattr(event, "value", None)),
|
|
1247
|
+
extra=fw_fields(limit=getattr(event, "limit", None), store=name),
|
|
1248
|
+
)
|
|
1249
|
+
|
|
1250
|
+
@safe
|
|
1251
|
+
def on_store_finished(self, source, event) -> None:
|
|
1252
|
+
results = (
|
|
1253
|
+
getattr(event, "results", None)
|
|
1254
|
+
or getattr(event, "memory_content", None)
|
|
1255
|
+
or getattr(event, "retrieved_knowledge", None)
|
|
1256
|
+
)
|
|
1257
|
+
self._tool_end(
|
|
1258
|
+
event,
|
|
1259
|
+
output=truncate(results),
|
|
1260
|
+
error=None,
|
|
1261
|
+
extra=fw_fields(
|
|
1262
|
+
query_time_ms=getattr(event, "query_time_ms", None),
|
|
1263
|
+
save_time_ms=getattr(event, "save_time_ms", None),
|
|
1264
|
+
retrieval_time_ms=getattr(event, "retrieval_time_ms", None),
|
|
1265
|
+
),
|
|
1266
|
+
)
|
|
1267
|
+
|
|
1268
|
+
@safe
|
|
1269
|
+
def on_store_failed(self, source, event) -> None:
|
|
1270
|
+
self._tool_end(
|
|
1271
|
+
event,
|
|
1272
|
+
output=None,
|
|
1273
|
+
error=_text(getattr(event, "error", None)) or "store operation failed",
|
|
1274
|
+
extra={},
|
|
1275
|
+
)
|
|
1276
|
+
|
|
1277
|
+
def _tool_start(self, event, *, tool_name, input, extra) -> None:
|
|
1278
|
+
tracker = self._tracker
|
|
1279
|
+
if tracker is None:
|
|
1280
|
+
return
|
|
1281
|
+
parent = self._parent_key(event.parent_event_id)
|
|
1282
|
+
# tool_call_id is CrewAI's own event_id, verbatim: it is a uuid4, so it
|
|
1283
|
+
# cannot collide inside `_pending`, and it lines our rows up with the
|
|
1284
|
+
# framework's own logs.
|
|
1285
|
+
tracker.emit(
|
|
1286
|
+
"tool_use",
|
|
1287
|
+
event.event_id,
|
|
1288
|
+
parent_key=parent,
|
|
1289
|
+
tool_name=tool_name,
|
|
1290
|
+
tool_call_id=event.event_id,
|
|
1291
|
+
input=input,
|
|
1292
|
+
**extra,
|
|
1293
|
+
)
|
|
1294
|
+
# A tool call is a PARENT, not only a leaf. `delegate_work_to_coworker`
|
|
1295
|
+
# and `ask_question_to_coworker` run a whole coworker underneath the
|
|
1296
|
+
# call: verified on crewai 1.15.16, the delegate's
|
|
1297
|
+
# `AgentExecutionStartedEvent.parent_event_id` is exactly this event's id.
|
|
1298
|
+
# Unless that id is a node, `_parent_key` misses it, falls back to the
|
|
1299
|
+
# open root, and every delegated agent is re-parented onto the CREW —
|
|
1300
|
+
# manager and coworker rendered as siblings, which is the whole
|
|
1301
|
+
# hierarchical process flattened into one level.
|
|
1302
|
+
self._note(event.event_id, parent, "tool")
|
|
1303
|
+
self._open_leaf(
|
|
1304
|
+
"tool_result",
|
|
1305
|
+
event.event_id,
|
|
1306
|
+
parent,
|
|
1307
|
+
("tool", getattr(event, "agent_role", None), tool_name),
|
|
1308
|
+
event.timestamp,
|
|
1309
|
+
{"tool_name": tool_name, "tool_call_id": event.event_id},
|
|
1310
|
+
)
|
|
1311
|
+
|
|
1312
|
+
def _tool_end(self, event, *, output, error, extra) -> None:
|
|
1313
|
+
tracker = self._tracker
|
|
1314
|
+
if tracker is None:
|
|
1315
|
+
return
|
|
1316
|
+
name = getattr(event, "tool_name", None) or STORE_TOOLS.get(
|
|
1317
|
+
type(event).__name__, "tool"
|
|
1318
|
+
)
|
|
1319
|
+
leaf = self._pop_leaf(
|
|
1320
|
+
event.started_event_id, ("tool", getattr(event, "agent_role", None), name)
|
|
1321
|
+
)
|
|
1322
|
+
if leaf is None:
|
|
1323
|
+
return
|
|
1324
|
+
with self._lock:
|
|
1325
|
+
self._nodes.pop(leaf.key, None)
|
|
1326
|
+
# duration_ms is hard-rejected on tool_result and is computed by the SDK
|
|
1327
|
+
# from the tool_use we emitted a moment ago.
|
|
1328
|
+
tracker.emit(
|
|
1329
|
+
"tool_result",
|
|
1330
|
+
leaf.key,
|
|
1331
|
+
parent_key=leaf.parent_key,
|
|
1332
|
+
tool_name=leaf.fields["tool_name"],
|
|
1333
|
+
tool_call_id=leaf.fields["tool_call_id"],
|
|
1334
|
+
output=output,
|
|
1335
|
+
error=error,
|
|
1336
|
+
**extra,
|
|
1337
|
+
)
|
|
1338
|
+
|
|
1339
|
+
# -- model calls --------------------------------------------------------
|
|
1340
|
+
|
|
1341
|
+
@safe
|
|
1342
|
+
def on_llm_started(self, source, event) -> None:
|
|
1343
|
+
tracker = self._tracker
|
|
1344
|
+
if tracker is None:
|
|
1345
|
+
return
|
|
1346
|
+
parent = self._parent_key(event.parent_event_id)
|
|
1347
|
+
call_id = getattr(event, "call_id", None) or event.event_id
|
|
1348
|
+
task = self._task_of(event) or (None, None)
|
|
1349
|
+
tracker.emit(
|
|
1350
|
+
"model_request",
|
|
1351
|
+
event.event_id,
|
|
1352
|
+
parent_key=parent,
|
|
1353
|
+
model=getattr(event, "model", None),
|
|
1354
|
+
messages=_messages(getattr(event, "messages", None)),
|
|
1355
|
+
tools=_listify(getattr(event, "tools", None)),
|
|
1356
|
+
request_id=call_id,
|
|
1357
|
+
**fw_fields(
|
|
1358
|
+
call_id=call_id,
|
|
1359
|
+
temperature=getattr(event, "temperature", None),
|
|
1360
|
+
max_tokens=getattr(event, "max_tokens", None),
|
|
1361
|
+
stream=getattr(event, "stream", None),
|
|
1362
|
+
task_id=task[0],
|
|
1363
|
+
task_name=task[1],
|
|
1364
|
+
),
|
|
1365
|
+
)
|
|
1366
|
+
self._open_leaf(
|
|
1367
|
+
"model_response",
|
|
1368
|
+
event.event_id,
|
|
1369
|
+
parent,
|
|
1370
|
+
("llm", call_id),
|
|
1371
|
+
event.timestamp,
|
|
1372
|
+
{"model": getattr(event, "model", None), "request_id": call_id},
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
@safe
|
|
1376
|
+
def on_llm_completed(self, source, event) -> None:
|
|
1377
|
+
usage = getattr(event, "usage", None)
|
|
1378
|
+
input_tokens, output_tokens, normalized = _tokens(usage)
|
|
1379
|
+
self._model_end(
|
|
1380
|
+
event,
|
|
1381
|
+
content=_text(getattr(event, "response", None)),
|
|
1382
|
+
stop_reason=getattr(event, "finish_reason", None),
|
|
1383
|
+
error=None,
|
|
1384
|
+
extra=dict(
|
|
1385
|
+
input_tokens=input_tokens,
|
|
1386
|
+
output_tokens=output_tokens,
|
|
1387
|
+
usage=normalized or None,
|
|
1388
|
+
**fw_fields(
|
|
1389
|
+
usage_raw=usage if isinstance(usage, dict) else None,
|
|
1390
|
+
response_id=getattr(event, "response_id", None),
|
|
1391
|
+
call_type=_enum_value(getattr(event, "call_type", None)),
|
|
1392
|
+
),
|
|
1393
|
+
),
|
|
1394
|
+
)
|
|
1395
|
+
|
|
1396
|
+
@safe
|
|
1397
|
+
def on_llm_failed(self, source, event) -> None:
|
|
1398
|
+
# Reported on the model_response, which is why `error` was added to that
|
|
1399
|
+
# event: a failed LLM call otherwise paints the row red but not the span.
|
|
1400
|
+
self._model_end(
|
|
1401
|
+
event,
|
|
1402
|
+
content=None,
|
|
1403
|
+
stop_reason="error",
|
|
1404
|
+
error=_text(getattr(event, "error", None)) or "llm call failed",
|
|
1405
|
+
extra={},
|
|
1406
|
+
)
|
|
1407
|
+
|
|
1408
|
+
def _model_end(self, event, *, content, stop_reason, error, extra) -> None:
|
|
1409
|
+
tracker = self._tracker
|
|
1410
|
+
if tracker is None:
|
|
1411
|
+
return
|
|
1412
|
+
call_id = getattr(event, "call_id", None)
|
|
1413
|
+
leaf = self._pop_leaf(event.started_event_id, ("llm", call_id))
|
|
1414
|
+
if leaf is None:
|
|
1415
|
+
return
|
|
1416
|
+
stream = self._pop_stream(call_id)
|
|
1417
|
+
fields = dict(extra)
|
|
1418
|
+
if stream is not None:
|
|
1419
|
+
chunks, first_ts = stream
|
|
1420
|
+
fields.update(
|
|
1421
|
+
fw_fields(
|
|
1422
|
+
streamed=True,
|
|
1423
|
+
chunks=chunks,
|
|
1424
|
+
ttft_ms=ms(first_ts - leaf.started) if first_ts is not None else None,
|
|
1425
|
+
)
|
|
1426
|
+
)
|
|
1427
|
+
# duration_ms is NOT guarded on model_response, and `durationOf` prefers
|
|
1428
|
+
# the closing event's value over end-start — which is what keeps model
|
|
1429
|
+
# durations honest even when the dashboard's FIFO pairing brackets the
|
|
1430
|
+
# wrong pair. It must be an int: the server's JSON parser drops floats
|
|
1431
|
+
# and would silently NULL the column.
|
|
1432
|
+
tracker.emit(
|
|
1433
|
+
"model_response",
|
|
1434
|
+
leaf.key,
|
|
1435
|
+
parent_key=leaf.parent_key,
|
|
1436
|
+
model=getattr(event, "model", None) or leaf.fields["model"],
|
|
1437
|
+
request_id=leaf.fields["request_id"],
|
|
1438
|
+
content=content,
|
|
1439
|
+
stop_reason=stop_reason,
|
|
1440
|
+
error=error,
|
|
1441
|
+
duration_ms=ms(event.timestamp - leaf.started),
|
|
1442
|
+
**fields,
|
|
1443
|
+
)
|
|
1444
|
+
|
|
1445
|
+
@safe
|
|
1446
|
+
def on_llm_chunk(self, source, event) -> None:
|
|
1447
|
+
"""Emits nothing. Counts chunks and stamps time-to-first-token, once."""
|
|
1448
|
+
key = getattr(event, "call_id", None) or getattr(event, "parent_event_id", None)
|
|
1449
|
+
if key is None:
|
|
1450
|
+
return
|
|
1451
|
+
with self._lock:
|
|
1452
|
+
entry = self._streams.get(key)
|
|
1453
|
+
if entry is None:
|
|
1454
|
+
while len(self._streams) >= _MAX_STREAMS:
|
|
1455
|
+
self._streams.pop(next(iter(self._streams)), None)
|
|
1456
|
+
self._streams[key] = [1, event.timestamp]
|
|
1457
|
+
else:
|
|
1458
|
+
entry[0] += 1
|
|
1459
|
+
|
|
1460
|
+
def _pop_stream(self, call_id):
|
|
1461
|
+
if call_id is None:
|
|
1462
|
+
return None
|
|
1463
|
+
with self._lock:
|
|
1464
|
+
entry = self._streams.pop(call_id, None)
|
|
1465
|
+
return (entry[0], entry[1]) if entry else None
|
|
1466
|
+
|
|
1467
|
+
|
|
1468
|
+
# ---------------------------------------------------------------------------
|
|
1469
|
+
# The listener
|
|
1470
|
+
# ---------------------------------------------------------------------------
|
|
1471
|
+
|
|
1472
|
+
def event_class(name: str):
|
|
1473
|
+
"""The event class `name`, or None.
|
|
1474
|
+
|
|
1475
|
+
TWO namespaces, because crewai has two and neither is complete.
|
|
1476
|
+
`crewai.events.event_types` is the flat re-export the bulk of the table
|
|
1477
|
+
resolves against, but the flow events — `HumanFeedbackRequestedEvent` among
|
|
1478
|
+
them — are NOT in it; they are reachable only through `crewai.events`, whose
|
|
1479
|
+
module-level `__getattr__` imports them lazily from
|
|
1480
|
+
`crewai.events.types.flow_events`.
|
|
1481
|
+
|
|
1482
|
+
Resolving against `event_types` alone is not an error you would see: the
|
|
1483
|
+
lookup returns None, `probe()` disables that one hook, and the adapter
|
|
1484
|
+
carries on recording everything else. The HITL events were mapped and
|
|
1485
|
+
silently never registered until this existed. `getattr`, never `dir()` —
|
|
1486
|
+
a lazy re-export does not appear in `dir()`.
|
|
1487
|
+
"""
|
|
1488
|
+
return getattr(_ct, name, None) or getattr(_ce, name, None)
|
|
1489
|
+
|
|
1490
|
+
|
|
1491
|
+
class FailproofAICrewListener(BaseEventListener):
|
|
1492
|
+
"""Registers one handler per event class on the module-level bus.
|
|
1493
|
+
|
|
1494
|
+
`BaseEventListener.__init__` is what performs the registration (it calls
|
|
1495
|
+
`setup_listeners` with the `crewai_event_bus` singleton), so constructing
|
|
1496
|
+
the instance *is* the install step — and the instance has to be kept alive
|
|
1497
|
+
by the adapter or it is garbage collected and silently stops working.
|
|
1498
|
+
|
|
1499
|
+
Every handler is `async def` for the ordering reason in the module
|
|
1500
|
+
docstring, and does nothing but call a `safe`-wrapped translator, so a bug
|
|
1501
|
+
in this file can never take down the customer's crew.
|
|
1502
|
+
"""
|
|
1503
|
+
|
|
1504
|
+
def __init__(self, adapter: _CrewAIAdapter) -> None:
|
|
1505
|
+
self._adapter = adapter
|
|
1506
|
+
self._registered: list[tuple[type, Any]] = []
|
|
1507
|
+
super().__init__()
|
|
1508
|
+
|
|
1509
|
+
def setup_listeners(self, crewai_event_bus) -> None:
|
|
1510
|
+
for class_name, method_name in TABLE:
|
|
1511
|
+
klass = event_class(class_name)
|
|
1512
|
+
if klass is None:
|
|
1513
|
+
# Tier 3: one missing event class disables one hook, never the
|
|
1514
|
+
# whole adapter — the other 90% of the events are still correct.
|
|
1515
|
+
_compat.probe(NAME, class_name, lambda: False)
|
|
1516
|
+
continue
|
|
1517
|
+
handler = self._make_handler(getattr(self._adapter, method_name), method_name)
|
|
1518
|
+
crewai_event_bus.register_handler(klass, handler)
|
|
1519
|
+
self._registered.append((klass, handler))
|
|
1520
|
+
|
|
1521
|
+
@staticmethod
|
|
1522
|
+
def _make_handler(translator, method_name):
|
|
1523
|
+
async def _failproofai_handler(source, event):
|
|
1524
|
+
translator(source, event)
|
|
1525
|
+
|
|
1526
|
+
_failproofai_handler.__name__ = f"failproofai_{method_name}"
|
|
1527
|
+
_failproofai_handler.__qualname__ = f"failproofai_sdk.crewai.{method_name}"
|
|
1528
|
+
return _failproofai_handler
|
|
1529
|
+
|
|
1530
|
+
def teardown(self) -> None:
|
|
1531
|
+
"""`crewai_event_bus.off()` for everything `setup_listeners` added."""
|
|
1532
|
+
for event_class, handler in self._registered:
|
|
1533
|
+
try:
|
|
1534
|
+
crewai_event_bus.off(event_class, handler)
|
|
1535
|
+
except Exception: # pragma: no cover - teardown must never raise
|
|
1536
|
+
logger.warning(
|
|
1537
|
+
"failproofai_sdk: could not unregister the crewai handler for %s",
|
|
1538
|
+
getattr(event_class, "__name__", event_class),
|
|
1539
|
+
exc_info=True,
|
|
1540
|
+
)
|
|
1541
|
+
self._registered.clear()
|
|
1542
|
+
|
|
1543
|
+
def handlers(self) -> tuple[tuple[type, Any], ...]:
|
|
1544
|
+
"""(event class, handler) pairs currently registered. For tests."""
|
|
1545
|
+
return tuple(self._registered)
|
|
1546
|
+
|
|
1547
|
+
|
|
1548
|
+
# ---------------------------------------------------------------------------
|
|
1549
|
+
# Payload helpers
|
|
1550
|
+
# ---------------------------------------------------------------------------
|
|
1551
|
+
|
|
1552
|
+
def _text(value) -> "str | None":
|
|
1553
|
+
if value is None:
|
|
1554
|
+
return None
|
|
1555
|
+
if isinstance(value, str):
|
|
1556
|
+
return truncate(value)
|
|
1557
|
+
raw = getattr(value, "raw", None)
|
|
1558
|
+
return truncate(raw if isinstance(raw, str) else str(value))
|
|
1559
|
+
|
|
1560
|
+
|
|
1561
|
+
def _first_text(mapping) -> "str | None":
|
|
1562
|
+
"""A goal string for a crew or flow, from whatever inputs it was given."""
|
|
1563
|
+
if isinstance(mapping, dict):
|
|
1564
|
+
for value in mapping.values():
|
|
1565
|
+
if isinstance(value, str) and value.strip():
|
|
1566
|
+
return truncate(value)
|
|
1567
|
+
return None
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
def _dictify(value) -> "dict | None":
|
|
1571
|
+
"""`input=` is declared `dict | None`; a bare string would break the shape.
|
|
1572
|
+
|
|
1573
|
+
CrewAI hands `tool_args` over as the raw JSON string the model produced, so
|
|
1574
|
+
it is decoded when it decodes: an object in the payload is queryable with
|
|
1575
|
+
`payload_key_expr`, a JSON-string-inside-a-string is not.
|
|
1576
|
+
"""
|
|
1577
|
+
if value is None:
|
|
1578
|
+
return None
|
|
1579
|
+
if isinstance(value, dict):
|
|
1580
|
+
return truncate(value)
|
|
1581
|
+
if isinstance(value, str):
|
|
1582
|
+
stripped = value.strip()
|
|
1583
|
+
if stripped.startswith("{"):
|
|
1584
|
+
try:
|
|
1585
|
+
decoded = json.loads(stripped)
|
|
1586
|
+
except ValueError:
|
|
1587
|
+
decoded = None
|
|
1588
|
+
if isinstance(decoded, dict):
|
|
1589
|
+
return truncate(decoded)
|
|
1590
|
+
return {"input": truncate(value)}
|
|
1591
|
+
return {"input": truncate(str(value))}
|
|
1592
|
+
|
|
1593
|
+
|
|
1594
|
+
def _listify(value) -> "list | None":
|
|
1595
|
+
if value is None:
|
|
1596
|
+
return None
|
|
1597
|
+
return truncate(list(value) if isinstance(value, (list, tuple)) else [value])
|
|
1598
|
+
|
|
1599
|
+
|
|
1600
|
+
def _messages(value) -> "list | None":
|
|
1601
|
+
"""`messages=` is declared `list[dict]`; CrewAI also allows a bare string."""
|
|
1602
|
+
if value is None:
|
|
1603
|
+
return None
|
|
1604
|
+
if isinstance(value, str):
|
|
1605
|
+
return [{"role": "user", "content": truncate(value)}]
|
|
1606
|
+
return _listify(value)
|
|
1607
|
+
|
|
1608
|
+
|
|
1609
|
+
def _tool_names(tools) -> "list | None":
|
|
1610
|
+
if not tools:
|
|
1611
|
+
return None
|
|
1612
|
+
return [getattr(tool, "name", None) or type(tool).__name__ for tool in tools]
|
|
1613
|
+
|
|
1614
|
+
|
|
1615
|
+
def _enum_value(value):
|
|
1616
|
+
return getattr(value, "value", value) if value is not None else None
|
|
1617
|
+
|
|
1618
|
+
|
|
1619
|
+
def _hook_name_of(event) -> "str | None":
|
|
1620
|
+
return (
|
|
1621
|
+
getattr(event, "method_name", None)
|
|
1622
|
+
or getattr(event, "guardrail_name", None)
|
|
1623
|
+
or "guardrail"
|
|
1624
|
+
)
|
|
1625
|
+
|
|
1626
|
+
|
|
1627
|
+
# The usage dict is whatever the provider returned, so both spellings are in the
|
|
1628
|
+
# wild and neither is guaranteed. Reading only one of them reports zero tokens
|
|
1629
|
+
# for half the providers, at HTTP 200, forever.
|
|
1630
|
+
_INPUT_KEYS = ("prompt_tokens", "input_tokens", "promptTokens", "inputTokens")
|
|
1631
|
+
_OUTPUT_KEYS = ("completion_tokens", "output_tokens", "completionTokens", "outputTokens")
|
|
1632
|
+
_TOTAL_KEYS = ("total_tokens", "totalTokens")
|
|
1633
|
+
|
|
1634
|
+
|
|
1635
|
+
def _tokens(usage):
|
|
1636
|
+
"""(input_tokens, output_tokens, normalized_usage_dict)."""
|
|
1637
|
+
if not isinstance(usage, dict):
|
|
1638
|
+
return None, None, {}
|
|
1639
|
+
input_tokens = _count(usage, _INPUT_KEYS)
|
|
1640
|
+
output_tokens = _count(usage, _OUTPUT_KEYS)
|
|
1641
|
+
total = _count(usage, _TOTAL_KEYS)
|
|
1642
|
+
if total is None and (input_tokens is not None or output_tokens is not None):
|
|
1643
|
+
total = (input_tokens or 0) + (output_tokens or 0)
|
|
1644
|
+
normalized = {}
|
|
1645
|
+
for key, value in (
|
|
1646
|
+
("input_tokens", input_tokens),
|
|
1647
|
+
("output_tokens", output_tokens),
|
|
1648
|
+
("total_tokens", total),
|
|
1649
|
+
):
|
|
1650
|
+
if value is not None:
|
|
1651
|
+
normalized[key] = value
|
|
1652
|
+
return input_tokens, output_tokens, normalized
|
|
1653
|
+
|
|
1654
|
+
|
|
1655
|
+
def _count(usage, keys):
|
|
1656
|
+
for key in keys:
|
|
1657
|
+
value = usage.get(key)
|
|
1658
|
+
if isinstance(value, bool):
|
|
1659
|
+
continue
|
|
1660
|
+
if isinstance(value, int):
|
|
1661
|
+
return value
|
|
1662
|
+
if isinstance(value, float):
|
|
1663
|
+
return int(value)
|
|
1664
|
+
if isinstance(value, str) and value.isdigit():
|
|
1665
|
+
return int(value)
|
|
1666
|
+
return None
|
|
1667
|
+
|
|
1668
|
+
|
|
1669
|
+
adapter = _CrewAIAdapter()
|