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,1576 @@
|
|
|
1
|
+
"""The LlamaIndex adapter, against the real framework and a stub model.
|
|
2
|
+
|
|
3
|
+
No network, no API key, no mock of our own code: every assertion below reads
|
|
4
|
+
the **JSONL the writer actually produced** after driving a real
|
|
5
|
+
`FunctionAgent` / `Workflow`. Asserting on mock call args would happily pass
|
|
6
|
+
against an adapter that emits nothing the ingest pipeline can use.
|
|
7
|
+
|
|
8
|
+
The single highest-value test in the file is
|
|
9
|
+
`test_our_overrides_still_exist_on_the_framework_base_classes`. Every other
|
|
10
|
+
test here can stay green while the adapter is completely dead: if upstream
|
|
11
|
+
renames `prepare_to_exit_span`, our override is never called, the span handler
|
|
12
|
+
records nothing, and a fake-based suite notices nothing at all.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
import inspect
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
from dataclasses import fields as dataclass_fields
|
|
20
|
+
from typing import Any, List, Sequence
|
|
21
|
+
|
|
22
|
+
import pytest
|
|
23
|
+
|
|
24
|
+
import failproofai_sdk
|
|
25
|
+
from failproofai_sdk import _runtime, _schema
|
|
26
|
+
from failproofai_sdk.integrations import _compat, _core
|
|
27
|
+
|
|
28
|
+
# `pytest.importorskip` is fail-open: misspell the module and every leg skips
|
|
29
|
+
# while CI stays green having tested nothing. The framework CI leg sets
|
|
30
|
+
# AGENTEYE_TESTS_REQUIRE_FRAMEWORKS=1, which turns the skip into a hard error.
|
|
31
|
+
if os.environ.get("AGENTEYE_TESTS_REQUIRE_FRAMEWORKS", "").strip().lower() in {"1", "true", "yes"}:
|
|
32
|
+
import llama_index.core # noqa: F401
|
|
33
|
+
else:
|
|
34
|
+
pytest.importorskip("llama_index.core", reason="llama-index-core is not installed")
|
|
35
|
+
|
|
36
|
+
from llama_index.core.base.llms.types import ( # noqa: E402
|
|
37
|
+
ChatMessage,
|
|
38
|
+
ChatResponse,
|
|
39
|
+
ChatResponseAsyncGen,
|
|
40
|
+
ChatResponseGen,
|
|
41
|
+
CompletionResponse,
|
|
42
|
+
CompletionResponseAsyncGen,
|
|
43
|
+
CompletionResponseGen,
|
|
44
|
+
LLMMetadata,
|
|
45
|
+
MessageRole,
|
|
46
|
+
)
|
|
47
|
+
from llama_index.core.llms.callbacks import llm_chat_callback, llm_completion_callback # noqa: E402
|
|
48
|
+
from llama_index.core.llms.function_calling import FunctionCallingLLM # noqa: E402
|
|
49
|
+
from llama_index.core.tools import ToolSelection # noqa: E402
|
|
50
|
+
from llama_index.core.tools.types import BaseTool # noqa: E402
|
|
51
|
+
from pydantic import Field, PrivateAttr # noqa: E402
|
|
52
|
+
|
|
53
|
+
from failproofai_sdk.integrations import llama_index as adapter_module # noqa: E402
|
|
54
|
+
|
|
55
|
+
pytestmark = pytest.mark.framework
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# ---------------------------------------------------------------------------
|
|
59
|
+
# A stub function-calling model. Real FunctionAgent, fake LLM.
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
class StubLLM(FunctionCallingLLM):
|
|
63
|
+
"""Replays a script of tool calls, then answers. Never touches a network."""
|
|
64
|
+
|
|
65
|
+
model: str = "stub-model-1"
|
|
66
|
+
script: List[Any] = Field(default_factory=list)
|
|
67
|
+
final: str = "done"
|
|
68
|
+
_turn: int = PrivateAttr(default=0)
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def metadata(self) -> LLMMetadata:
|
|
72
|
+
return LLMMetadata(
|
|
73
|
+
model_name=self.model, is_chat_model=True, is_function_calling_model=True
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def _next(self) -> ChatResponse:
|
|
77
|
+
turn = self._turn
|
|
78
|
+
self._turn += 1
|
|
79
|
+
if turn < len(self.script):
|
|
80
|
+
name, kwargs = self.script[turn]
|
|
81
|
+
message = ChatMessage(
|
|
82
|
+
role=MessageRole.ASSISTANT,
|
|
83
|
+
content="",
|
|
84
|
+
additional_kwargs={
|
|
85
|
+
"tool_calls": [{"id": f"call_{turn}", "name": name, "kwargs": kwargs}]
|
|
86
|
+
},
|
|
87
|
+
)
|
|
88
|
+
else:
|
|
89
|
+
message = ChatMessage(role=MessageRole.ASSISTANT, content=self.final)
|
|
90
|
+
return ChatResponse(
|
|
91
|
+
message=message,
|
|
92
|
+
raw={"usage": {"prompt_tokens": 11, "completion_tokens": 7, "total_tokens": 18}},
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
@llm_chat_callback()
|
|
96
|
+
def chat(self, messages: Sequence[ChatMessage], **kwargs: Any) -> ChatResponse:
|
|
97
|
+
return self._next()
|
|
98
|
+
|
|
99
|
+
@llm_chat_callback()
|
|
100
|
+
async def achat(self, messages: Sequence[ChatMessage], **kwargs: Any) -> ChatResponse:
|
|
101
|
+
return self._next()
|
|
102
|
+
|
|
103
|
+
@llm_chat_callback()
|
|
104
|
+
def stream_chat(self, messages: Sequence[ChatMessage], **kwargs: Any) -> ChatResponseGen:
|
|
105
|
+
def gen():
|
|
106
|
+
yield self._next()
|
|
107
|
+
|
|
108
|
+
return gen()
|
|
109
|
+
|
|
110
|
+
@llm_chat_callback()
|
|
111
|
+
async def astream_chat(
|
|
112
|
+
self, messages: Sequence[ChatMessage], **kwargs: Any
|
|
113
|
+
) -> ChatResponseAsyncGen:
|
|
114
|
+
async def gen():
|
|
115
|
+
yield self._next()
|
|
116
|
+
|
|
117
|
+
return gen()
|
|
118
|
+
|
|
119
|
+
@llm_completion_callback()
|
|
120
|
+
def complete(self, prompt: str, formatted: bool = False, **kwargs: Any) -> CompletionResponse:
|
|
121
|
+
return CompletionResponse(text=self.final)
|
|
122
|
+
|
|
123
|
+
@llm_completion_callback()
|
|
124
|
+
async def acomplete(
|
|
125
|
+
self, prompt: str, formatted: bool = False, **kwargs: Any
|
|
126
|
+
) -> CompletionResponse:
|
|
127
|
+
return CompletionResponse(text=self.final)
|
|
128
|
+
|
|
129
|
+
@llm_completion_callback()
|
|
130
|
+
def stream_complete(
|
|
131
|
+
self, prompt: str, formatted: bool = False, **kwargs: Any
|
|
132
|
+
) -> CompletionResponseGen:
|
|
133
|
+
def gen():
|
|
134
|
+
yield CompletionResponse(text=self.final, delta=self.final)
|
|
135
|
+
|
|
136
|
+
return gen()
|
|
137
|
+
|
|
138
|
+
@llm_completion_callback()
|
|
139
|
+
async def astream_complete(
|
|
140
|
+
self, prompt: str, formatted: bool = False, **kwargs: Any
|
|
141
|
+
) -> CompletionResponseAsyncGen:
|
|
142
|
+
async def gen():
|
|
143
|
+
yield CompletionResponse(text=self.final, delta=self.final)
|
|
144
|
+
|
|
145
|
+
return gen()
|
|
146
|
+
|
|
147
|
+
def _prepare_chat_with_tools(
|
|
148
|
+
self,
|
|
149
|
+
tools: Sequence["BaseTool"],
|
|
150
|
+
user_msg: Any = None,
|
|
151
|
+
chat_history: Any = None,
|
|
152
|
+
verbose: bool = False,
|
|
153
|
+
allow_parallel_tool_calls: bool = False,
|
|
154
|
+
tool_required: bool = False,
|
|
155
|
+
**kwargs: Any,
|
|
156
|
+
) -> dict:
|
|
157
|
+
messages = list(chat_history or [])
|
|
158
|
+
if user_msg is not None:
|
|
159
|
+
messages.append(
|
|
160
|
+
ChatMessage(role=MessageRole.USER, content=user_msg)
|
|
161
|
+
if isinstance(user_msg, str)
|
|
162
|
+
else user_msg
|
|
163
|
+
)
|
|
164
|
+
return {"messages": messages, **kwargs}
|
|
165
|
+
|
|
166
|
+
def get_tool_calls_from_response(
|
|
167
|
+
self, response: ChatResponse, error_on_no_tool_call: bool = True, **kwargs: Any
|
|
168
|
+
) -> List[ToolSelection]:
|
|
169
|
+
calls = response.message.additional_kwargs.get("tool_calls", [])
|
|
170
|
+
if not calls and error_on_no_tool_call:
|
|
171
|
+
raise ValueError("no tool calls")
|
|
172
|
+
return [
|
|
173
|
+
ToolSelection(tool_id=c["id"], tool_name=c["name"], tool_kwargs=c["kwargs"])
|
|
174
|
+
for c in calls
|
|
175
|
+
]
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def add(a: int, b: int) -> int:
|
|
179
|
+
"""Add two numbers."""
|
|
180
|
+
return a + b
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def boom(x: int) -> int:
|
|
184
|
+
"""Always raises."""
|
|
185
|
+
raise RuntimeError("tool exploded")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def _await(awaitable):
|
|
189
|
+
return await awaitable
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def drive(coro_factory):
|
|
193
|
+
"""Run a workflow from sync test code.
|
|
194
|
+
|
|
195
|
+
`Workflow.run()` schedules tasks eagerly, so it must be *called* with a
|
|
196
|
+
loop already running — `asyncio.run(workflow.run())` raises
|
|
197
|
+
"no running event loop" before the adapter ever sees anything.
|
|
198
|
+
"""
|
|
199
|
+
|
|
200
|
+
async def _main():
|
|
201
|
+
return await coro_factory()
|
|
202
|
+
|
|
203
|
+
return asyncio.run(_main())
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def run_agent(agent, prompt: str) -> str:
|
|
207
|
+
return str(drive(lambda: agent.run(prompt)))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def run_workflow(workflow, **kwargs) -> str:
|
|
211
|
+
return str(drive(lambda: workflow.run(**kwargs)))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def calculator(llm, **kwargs):
|
|
215
|
+
from llama_index.core.agent.workflow import FunctionAgent
|
|
216
|
+
|
|
217
|
+
kwargs.setdefault("tools", [add])
|
|
218
|
+
kwargs.setdefault("streaming", False)
|
|
219
|
+
return FunctionAgent(name="calc", description="does math", llm=llm, **kwargs)
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# ---------------------------------------------------------------------------
|
|
223
|
+
# Fixtures
|
|
224
|
+
# ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
@pytest.fixture()
|
|
227
|
+
def instrumented(tmp_path):
|
|
228
|
+
"""Instrument for one test, then put everything back.
|
|
229
|
+
|
|
230
|
+
The flush interval is enormous on purpose: the writer names files with
|
|
231
|
+
millisecond resolution, so two automatic flushes inside the same
|
|
232
|
+
millisecond overwrite each other. Tests flush once, explicitly, at the end.
|
|
233
|
+
"""
|
|
234
|
+
_core.set_strict(False)
|
|
235
|
+
_compat.set_strict_integrations(False)
|
|
236
|
+
_core.reset_failures()
|
|
237
|
+
_runtime.writer.set_flush_interval(3600)
|
|
238
|
+
assert failproofai_sdk.instrument("llama_index") == ("llama_index",)
|
|
239
|
+
try:
|
|
240
|
+
yield adapter_module.adapter
|
|
241
|
+
finally:
|
|
242
|
+
failproofai_sdk.uninstrument("llama_index")
|
|
243
|
+
_core.set_strict(None)
|
|
244
|
+
_compat.set_strict_integrations(None)
|
|
245
|
+
_core.reset_failures()
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@pytest.fixture()
|
|
249
|
+
def instrumented_without_steps(tmp_path):
|
|
250
|
+
"""`instrumented`, with the workflow-step hooks switched off.
|
|
251
|
+
|
|
252
|
+
Spelled out rather than parameterising the fixture above: `instrumented` is
|
|
253
|
+
used by nearly every test in this file, and a signature change there is a
|
|
254
|
+
change to all of them.
|
|
255
|
+
"""
|
|
256
|
+
_core.set_strict(False)
|
|
257
|
+
_compat.set_strict_integrations(False)
|
|
258
|
+
_core.reset_failures()
|
|
259
|
+
_runtime.writer.set_flush_interval(3600)
|
|
260
|
+
assert failproofai_sdk.instrument("llama_index", steps=False) == ("llama_index",)
|
|
261
|
+
try:
|
|
262
|
+
yield adapter_module.adapter
|
|
263
|
+
finally:
|
|
264
|
+
failproofai_sdk.uninstrument("llama_index")
|
|
265
|
+
_core.set_strict(None)
|
|
266
|
+
_compat.set_strict_integrations(None)
|
|
267
|
+
_core.reset_failures()
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@pytest.fixture(autouse=True)
|
|
271
|
+
def _isolate_writer_queue():
|
|
272
|
+
"""Start and end every test with an empty writer queue.
|
|
273
|
+
|
|
274
|
+
The queue is process-global while `base_dir` is per-test, so events a test
|
|
275
|
+
never flushed are written into the NEXT test's directory the first time
|
|
276
|
+
anything flushes — which reads as a mystery second session id and is
|
|
277
|
+
exactly the kind of cross-test bleed this suite exists to rule out.
|
|
278
|
+
"""
|
|
279
|
+
_runtime.writer._queue.clear()
|
|
280
|
+
yield
|
|
281
|
+
_runtime.writer._queue.clear()
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
@pytest.fixture(autouse=True)
|
|
285
|
+
def _no_silent_adapter_failure(request):
|
|
286
|
+
"""Fail the test if the adapter swallowed an exception.
|
|
287
|
+
|
|
288
|
+
`safe()` exists so a bug costs one log line instead of the process — which
|
|
289
|
+
also means a broken adapter passes every behavioural test that only checks
|
|
290
|
+
"the run finished". This makes that invisible failure visible in the suite.
|
|
291
|
+
"""
|
|
292
|
+
yield
|
|
293
|
+
if _EXPECTS_FAILURE in request.node.name:
|
|
294
|
+
return
|
|
295
|
+
assert not _core._disabled, f"adapter self-disabled a call site: {_core._disabled}"
|
|
296
|
+
assert not _core._failures, f"adapter swallowed exceptions: {_core._failures}"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# pyproject sets `--strict-markers`, so a bespoke marker is not available here:
|
|
300
|
+
# the two tests that deliberately make the adapter fail are named instead.
|
|
301
|
+
_EXPECTS_FAILURE = "raises_on_every_call"
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def read_events(tmp_path) -> list[dict]:
|
|
305
|
+
_runtime.writer.flush_now()
|
|
306
|
+
rows: list[dict] = []
|
|
307
|
+
for path in sorted((tmp_path / "events").glob("*.jsonl")):
|
|
308
|
+
rows.extend(json.loads(line) for line in path.read_text().splitlines() if line)
|
|
309
|
+
return rows
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def types_of(events: list[dict]) -> list[str]:
|
|
313
|
+
return [event["type"] for event in events]
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def only(events: list[dict], kind: str) -> list[dict]:
|
|
317
|
+
return [event for event in events if event["type"] == kind]
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
# The exact sequence a one-tool FunctionAgent turn produces. Written out rather
|
|
321
|
+
# than computed: a change here should require a human to look at it.
|
|
322
|
+
ONE_TOOL_SEQUENCE = [
|
|
323
|
+
"agent_start",
|
|
324
|
+
"hook_triggered", # init_run
|
|
325
|
+
"hook_completed",
|
|
326
|
+
"hook_triggered", # setup_agent
|
|
327
|
+
"hook_completed",
|
|
328
|
+
"hook_triggered", # run_agent_step
|
|
329
|
+
"model_request",
|
|
330
|
+
"model_response",
|
|
331
|
+
"hook_completed",
|
|
332
|
+
"hook_triggered", # parse_agent_output
|
|
333
|
+
"hook_completed",
|
|
334
|
+
"hook_triggered", # call_tool
|
|
335
|
+
"tool_use",
|
|
336
|
+
"tool_result",
|
|
337
|
+
"hook_completed",
|
|
338
|
+
"hook_triggered", # aggregate_tool_results
|
|
339
|
+
"hook_completed",
|
|
340
|
+
"hook_triggered", # setup_agent
|
|
341
|
+
"hook_completed",
|
|
342
|
+
"hook_triggered", # run_agent_step
|
|
343
|
+
"model_request",
|
|
344
|
+
"model_response",
|
|
345
|
+
"hook_completed",
|
|
346
|
+
"hook_triggered", # parse_agent_output
|
|
347
|
+
"hook_completed",
|
|
348
|
+
"agent_end",
|
|
349
|
+
]
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
# ---------------------------------------------------------------------------
|
|
353
|
+
# The representative run
|
|
354
|
+
# ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
@pytest.mark.parametrize("streaming", [False, True], ids=["blocking", "streaming"])
|
|
357
|
+
def test_exact_event_sequence_for_a_one_tool_run(instrumented, tmp_path, streaming):
|
|
358
|
+
llm = StubLLM(script=[("add", {"a": 2, "b": 3})], final="5")
|
|
359
|
+
agent = calculator(llm, streaming=streaming)
|
|
360
|
+
assert str(run_agent(agent, "what is 2+3?")) == "5"
|
|
361
|
+
|
|
362
|
+
events = read_events(tmp_path)
|
|
363
|
+
# Streaming produces the same shape: the model span exits the instant the
|
|
364
|
+
# generator is created, so the response is parked and closed by
|
|
365
|
+
# LLMChatEndEvent rather than by the span.
|
|
366
|
+
assert types_of(events) == ONE_TOOL_SEQUENCE
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def test_the_root_agent_start_is_the_sessions_first_event(instrumented, tmp_path):
|
|
370
|
+
llm = StubLLM(script=[("add", {"a": 1, "b": 1})], final="2")
|
|
371
|
+
run_agent(calculator(llm), "1+1?")
|
|
372
|
+
|
|
373
|
+
events = read_events(tmp_path)
|
|
374
|
+
# `agent_sessions.agent_id = any(...)` over an ORDER BY (session_id, ts)
|
|
375
|
+
# table returns the FIRST agent_id by time. If anything preceded the root
|
|
376
|
+
# agent_start the sessions list would name a workflow step instead.
|
|
377
|
+
assert events[0]["type"] == "agent_start"
|
|
378
|
+
assert events[0]["agent_id"] == "calc"
|
|
379
|
+
assert {event["session_id"] for event in events} == {events[0]["session_id"]}
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def test_every_event_carries_the_framework_triple(instrumented, tmp_path):
|
|
383
|
+
llm = StubLLM(script=[("add", {"a": 1, "b": 1})], final="2")
|
|
384
|
+
run_agent(calculator(llm), "1+1?")
|
|
385
|
+
|
|
386
|
+
for event in read_events(tmp_path):
|
|
387
|
+
assert event["framework"] == "llama_index"
|
|
388
|
+
assert event["framework_version"]
|
|
389
|
+
assert event["integration_version"]
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def test_agent_ids_are_readable_names_not_span_ids(instrumented, tmp_path):
|
|
393
|
+
llm = StubLLM(script=[("add", {"a": 1, "b": 1})], final="2")
|
|
394
|
+
run_agent(calculator(llm), "1+1?")
|
|
395
|
+
|
|
396
|
+
for event in read_events(tmp_path):
|
|
397
|
+
agent_id = event["agent_id"]
|
|
398
|
+
# agent_id is LowCardinality(String) and the global dashboard facet; a
|
|
399
|
+
# span id in it fills the filter dropdown with one entry per run.
|
|
400
|
+
assert agent_id == "calc"
|
|
401
|
+
assert "-" not in agent_id and len(agent_id) < 32
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def test_model_requests_and_responses_pair_on_request_id(instrumented, tmp_path):
|
|
405
|
+
llm = StubLLM(script=[("add", {"a": 2, "b": 3})], final="5")
|
|
406
|
+
run_agent(calculator(llm), "2+3?")
|
|
407
|
+
|
|
408
|
+
events = read_events(tmp_path)
|
|
409
|
+
requests = only(events, "model_request")
|
|
410
|
+
responses = only(events, "model_response")
|
|
411
|
+
assert len(requests) == len(responses) == 2
|
|
412
|
+
assert [r["request_id"] for r in requests] == [r["request_id"] for r in responses]
|
|
413
|
+
assert len({r["request_id"] for r in requests}) == 2
|
|
414
|
+
|
|
415
|
+
for response in responses:
|
|
416
|
+
# Invariant 3: an int, always. The server stores duration_ms as a u32
|
|
417
|
+
# and its JSON parser drops floats, so a float silently NULLs it.
|
|
418
|
+
assert isinstance(response["duration_ms"], int)
|
|
419
|
+
assert not isinstance(response["duration_ms"], bool)
|
|
420
|
+
assert response["model"] == "stub-model-1"
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def test_model_name_comes_from_metadata_not_the_gutted_model_dict(instrumented, tmp_path):
|
|
424
|
+
llm = StubLLM(script=[], final="hi")
|
|
425
|
+
run_agent(calculator(llm), "hi")
|
|
426
|
+
|
|
427
|
+
events = read_events(tmp_path)
|
|
428
|
+
assert {e["model"] for e in only(events, "model_request")} == {"stub-model-1"}
|
|
429
|
+
# `to_payload()` replaced `to_dict()` in 0.14.23 and the "model" key is gone.
|
|
430
|
+
start_payload = llm.to_payload()
|
|
431
|
+
assert "model" not in start_payload
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def test_tokens_are_extracted_and_the_raw_usage_dict_ships_too(instrumented, tmp_path):
|
|
435
|
+
llm = StubLLM(script=[], final="hi")
|
|
436
|
+
run_agent(calculator(llm), "hi")
|
|
437
|
+
|
|
438
|
+
response = only(read_events(tmp_path), "model_response")[0]
|
|
439
|
+
assert response["input_tokens"] == 11
|
|
440
|
+
assert response["output_tokens"] == 7
|
|
441
|
+
# Both event_summary.rs and sessionSummary.ts fall back to `usage`.
|
|
442
|
+
assert response["usage"]["total_tokens"] == 18
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def test_tool_use_and_tool_result_pair_and_carry_a_duration(instrumented, tmp_path):
|
|
446
|
+
llm = StubLLM(script=[("add", {"a": 2, "b": 3})], final="5")
|
|
447
|
+
run_agent(calculator(llm), "2+3?")
|
|
448
|
+
|
|
449
|
+
events = read_events(tmp_path)
|
|
450
|
+
uses = only(events, "tool_use")
|
|
451
|
+
results = only(events, "tool_result")
|
|
452
|
+
assert len(uses) == len(results) == 1
|
|
453
|
+
assert uses[0]["tool_call_id"] == results[0]["tool_call_id"] == "call_0"
|
|
454
|
+
assert uses[0]["tool_name"] == results[0]["tool_name"] == "add"
|
|
455
|
+
assert uses[0]["input"] == {"a": 2, "b": 3}
|
|
456
|
+
assert results[0]["output"] == "5"
|
|
457
|
+
assert isinstance(results[0]["duration_ms"], int)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def test_tool_call_id_is_the_frameworks_own_id(instrumented, tmp_path):
|
|
461
|
+
llm = StubLLM(script=[("add", {"a": 2, "b": 3})], final="5")
|
|
462
|
+
run_agent(calculator(llm), "2+3?")
|
|
463
|
+
|
|
464
|
+
use = only(read_events(tmp_path), "tool_use")[0]
|
|
465
|
+
# Passing the framework's id through verbatim is what makes our events line
|
|
466
|
+
# up with the customer's provider logs.
|
|
467
|
+
assert use["tool_call_id"] == "call_0"
|
|
468
|
+
assert use["fw_tool_id"] == "call_0"
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def test_workflow_steps_become_hooks_not_agents(instrumented, tmp_path):
|
|
472
|
+
llm = StubLLM(script=[("add", {"a": 2, "b": 3})], final="5")
|
|
473
|
+
run_agent(calculator(llm), "2+3?")
|
|
474
|
+
|
|
475
|
+
events = read_events(tmp_path)
|
|
476
|
+
hooks = only(events, "hook_triggered")
|
|
477
|
+
names = [hook["hook_name"] for hook in hooks]
|
|
478
|
+
assert names[:3] == ["init_run", "setup_agent", "run_agent_step"]
|
|
479
|
+
assert all(hook["trigger_event"] == "workflow_step" for hook in hooks)
|
|
480
|
+
# Steps as agents would drown the agent_id facet with `parse_agent_output`.
|
|
481
|
+
assert {e["agent_id"] for e in events} == {"calc"}
|
|
482
|
+
for completed in only(events, "hook_completed"):
|
|
483
|
+
assert isinstance(completed["duration_ms"], int)
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# ---------------------------------------------------------------------------
|
|
487
|
+
# Rendering invariants
|
|
488
|
+
# ---------------------------------------------------------------------------
|
|
489
|
+
|
|
490
|
+
def assert_rendering_invariants(events: list[dict]) -> None:
|
|
491
|
+
"""The four properties that make the dashboard draw the session correctly."""
|
|
492
|
+
assert events, "no events at all"
|
|
493
|
+
assert events[0]["type"] == "agent_start", "invariant 2: root agent_start must be first"
|
|
494
|
+
|
|
495
|
+
open_agents: dict[str, int] = {}
|
|
496
|
+
open_leaves: dict[tuple[str, str], int] = {}
|
|
497
|
+
for event in events:
|
|
498
|
+
kind = event["type"]
|
|
499
|
+
agent_id = event["agent_id"]
|
|
500
|
+
if kind == "agent_start":
|
|
501
|
+
open_agents[agent_id] = open_agents.get(agent_id, 0) + 1
|
|
502
|
+
else:
|
|
503
|
+
# Invariant 1: an event under an agent_id with no open agent_start
|
|
504
|
+
# makes executionGraph synthesize a root span that stays `ongoing`.
|
|
505
|
+
assert open_agents.get(agent_id, 0) > 0, f"{kind} under a closed agent {agent_id!r}"
|
|
506
|
+
if kind == "agent_end":
|
|
507
|
+
open_agents[agent_id] -= 1
|
|
508
|
+
elif kind == "tool_use":
|
|
509
|
+
open_leaves[("tool", event["tool_call_id"])] = 1
|
|
510
|
+
elif kind == "tool_result":
|
|
511
|
+
tool_leaf = open_leaves.pop(("tool", event["tool_call_id"]), None)
|
|
512
|
+
assert tool_leaf, "unpaired tool_result"
|
|
513
|
+
elif kind == "model_request":
|
|
514
|
+
open_leaves[("model", event["request_id"])] = 1
|
|
515
|
+
elif kind == "model_response":
|
|
516
|
+
model_leaf = open_leaves.pop(("model", event["request_id"]), None)
|
|
517
|
+
assert model_leaf, "unpaired model_response"
|
|
518
|
+
assert isinstance(event["duration_ms"], int), "invariant 3"
|
|
519
|
+
elif kind == "hook_triggered":
|
|
520
|
+
open_leaves[("hook", event["hook_id"])] = 1
|
|
521
|
+
elif kind == "hook_completed":
|
|
522
|
+
hook_leaf = open_leaves.pop(("hook", event["hook_id"]), None)
|
|
523
|
+
assert hook_leaf, "unpaired hook_completed"
|
|
524
|
+
|
|
525
|
+
assert not any(open_agents.values()), f"agents left open: {open_agents}"
|
|
526
|
+
# Invariant 4: agent_end force-closes open pauses but NOT tools or models,
|
|
527
|
+
# so a leaf left open renders the session `ongoing` forever.
|
|
528
|
+
assert not open_leaves, f"leaves left open: {sorted(open_leaves)}"
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
@pytest.mark.parametrize("streaming", [False, True], ids=["blocking", "streaming"])
|
|
532
|
+
def test_rendering_invariants_hold_for_a_normal_run(instrumented, tmp_path, streaming):
|
|
533
|
+
llm = StubLLM(script=[("add", {"a": 2, "b": 3})], final="5")
|
|
534
|
+
run_agent(calculator(llm, streaming=streaming), "2+3?")
|
|
535
|
+
assert_rendering_invariants(read_events(tmp_path))
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
def test_rendering_invariants_hold_when_a_tool_fails(instrumented, tmp_path):
|
|
539
|
+
llm = StubLLM(script=[("boom", {"x": 1})], final="recovered")
|
|
540
|
+
agent = calculator(llm, tools=[boom])
|
|
541
|
+
run_agent(agent, "blow up")
|
|
542
|
+
assert_rendering_invariants(read_events(tmp_path))
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
# ---------------------------------------------------------------------------
|
|
546
|
+
# Failure paths
|
|
547
|
+
# ---------------------------------------------------------------------------
|
|
548
|
+
|
|
549
|
+
def test_a_failing_tool_is_reported_on_the_tool_and_not_double_counted(instrumented, tmp_path):
|
|
550
|
+
llm = StubLLM(script=[("boom", {"x": 1})], final="recovered")
|
|
551
|
+
result = run_agent(calculator(llm, tools=[boom]), "blow up")
|
|
552
|
+
assert str(result) == "recovered"
|
|
553
|
+
|
|
554
|
+
events = read_events(tmp_path)
|
|
555
|
+
result_event = only(events, "tool_result")[0]
|
|
556
|
+
assert result_event["error"] == "RuntimeError: tool exploded"
|
|
557
|
+
# The span that owns the failure reports it. A standalone `error` event
|
|
558
|
+
# would double-count on sessionSummary.errorCount.
|
|
559
|
+
assert only(events, "error") == []
|
|
560
|
+
# The agent recovered, so the run did not fail.
|
|
561
|
+
assert only(events, "agent_end")[0]["outcome"] == "success"
|
|
562
|
+
|
|
563
|
+
|
|
564
|
+
def test_a_failing_workflow_step_reports_once_and_fails_the_agent(instrumented, tmp_path):
|
|
565
|
+
from llama_index.core.workflow import StartEvent, StopEvent, Workflow, step
|
|
566
|
+
|
|
567
|
+
class Bad(Workflow):
|
|
568
|
+
@step
|
|
569
|
+
async def go(self, ev: StartEvent) -> StopEvent:
|
|
570
|
+
raise ValueError("workflow blew up")
|
|
571
|
+
|
|
572
|
+
with pytest.raises(ValueError):
|
|
573
|
+
run_workflow(Bad(timeout=5))
|
|
574
|
+
|
|
575
|
+
events = read_events(tmp_path)
|
|
576
|
+
completed = only(events, "hook_completed")[0]
|
|
577
|
+
assert completed["outcome"] == "failed"
|
|
578
|
+
assert completed["error"] == "ValueError: workflow blew up"
|
|
579
|
+
assert only(events, "error") == [], "the step owns this failure; do not count it twice"
|
|
580
|
+
assert only(events, "agent_end")[0]["outcome"] == "failed"
|
|
581
|
+
assert_rendering_invariants(events)
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def test_a_run_level_failure_nobody_owns_gets_one_error_event(instrumented, tmp_path):
|
|
585
|
+
from llama_index.core.workflow import StartEvent, StopEvent, Workflow, step
|
|
586
|
+
|
|
587
|
+
class Slow(Workflow):
|
|
588
|
+
@step
|
|
589
|
+
async def go(self, ev: StartEvent) -> StopEvent:
|
|
590
|
+
await asyncio.sleep(5)
|
|
591
|
+
return StopEvent(result="never")
|
|
592
|
+
|
|
593
|
+
with pytest.raises(Exception):
|
|
594
|
+
run_workflow(Slow(timeout=0.3))
|
|
595
|
+
|
|
596
|
+
events = read_events(tmp_path)
|
|
597
|
+
errors = only(events, "error")
|
|
598
|
+
# Nothing below the run reported the timeout, so exactly one standalone
|
|
599
|
+
# `error` carries it — and it comes strictly BEFORE agent_end, because the
|
|
600
|
+
# graph closes the agent span at agent_end and an error after it is
|
|
601
|
+
# attributed to nothing.
|
|
602
|
+
assert len(errors) == 1
|
|
603
|
+
assert errors[0]["error_type"] == "WorkflowTimeoutError"
|
|
604
|
+
assert types_of(events).index("error") < types_of(events).index("agent_end")
|
|
605
|
+
assert only(events, "agent_end")[0]["outcome"] == "failed"
|
|
606
|
+
assert_rendering_invariants(events)
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def test_a_failed_agent_end_says_what_killed_the_run(instrumented, tmp_path):
|
|
610
|
+
"""`outcome="failed"` is not a reason, and `summary` is a promoted column.
|
|
611
|
+
|
|
612
|
+
The failing step's `hook_completed` does carry the error, but that is
|
|
613
|
+
payload-only — and with `steps=False` it is not emitted at all, which leaves
|
|
614
|
+
a failed run with its cause recorded precisely nowhere.
|
|
615
|
+
"""
|
|
616
|
+
from llama_index.core.workflow import StartEvent, StopEvent, Workflow, step
|
|
617
|
+
|
|
618
|
+
class Bad(Workflow):
|
|
619
|
+
@step
|
|
620
|
+
async def go(self, ev: StartEvent) -> StopEvent:
|
|
621
|
+
raise ValueError("workflow blew up")
|
|
622
|
+
|
|
623
|
+
with pytest.raises(ValueError):
|
|
624
|
+
run_workflow(Bad(timeout=5))
|
|
625
|
+
|
|
626
|
+
end = only(read_events(tmp_path), "agent_end")[0]
|
|
627
|
+
assert end["outcome"] == "failed"
|
|
628
|
+
assert end["summary"] == "ValueError: workflow blew up"
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def test_a_timed_out_agent_end_says_what_killed_the_run(instrumented, tmp_path):
|
|
632
|
+
"""Same promise on the other failure path, where there IS an error event."""
|
|
633
|
+
from llama_index.core.workflow import StartEvent, StopEvent, Workflow, step
|
|
634
|
+
|
|
635
|
+
class Slow(Workflow):
|
|
636
|
+
@step
|
|
637
|
+
async def go(self, ev: StartEvent) -> StopEvent:
|
|
638
|
+
await asyncio.sleep(5)
|
|
639
|
+
return StopEvent(result="never")
|
|
640
|
+
|
|
641
|
+
with pytest.raises(Exception):
|
|
642
|
+
run_workflow(Slow(timeout=0.3))
|
|
643
|
+
|
|
644
|
+
end = only(read_events(tmp_path), "agent_end")[0]
|
|
645
|
+
assert end["outcome"] == "failed"
|
|
646
|
+
assert end["summary"].startswith("WorkflowTimeoutError:")
|
|
647
|
+
|
|
648
|
+
|
|
649
|
+
def test_a_translator_that_raises_on_every_call_does_not_break_the_run(
|
|
650
|
+
instrumented, tmp_path, monkeypatch, caplog
|
|
651
|
+
):
|
|
652
|
+
"""The whole failure policy, proved rather than asserted.
|
|
653
|
+
|
|
654
|
+
This also covers the LlamaIndex-specific hazard: the dispatcher wraps every
|
|
655
|
+
handler call in `except BaseException: pass` **with no logging**, so an
|
|
656
|
+
adapter bug is invisible unless we log it ourselves.
|
|
657
|
+
"""
|
|
658
|
+
state = instrumented.state
|
|
659
|
+
|
|
660
|
+
def explode(*args, **kwargs):
|
|
661
|
+
raise RuntimeError("translator is broken")
|
|
662
|
+
|
|
663
|
+
monkeypatch.setattr(type(state), "span_enter", explode)
|
|
664
|
+
monkeypatch.setattr(type(state), "span_exit", explode)
|
|
665
|
+
monkeypatch.setattr(type(state), "span_drop", explode)
|
|
666
|
+
monkeypatch.setattr(type(state), "model_start", explode)
|
|
667
|
+
monkeypatch.setattr(type(state), "model_end", explode)
|
|
668
|
+
|
|
669
|
+
caplog.set_level("WARNING", logger="failproofai_sdk.integrations")
|
|
670
|
+
llm = StubLLM(script=[("add", {"a": 2, "b": 3})], final="5")
|
|
671
|
+
assert str(run_agent(calculator(llm), "2+3?")) == "5"
|
|
672
|
+
|
|
673
|
+
logged = " ".join(record.getMessage() for record in caplog.records)
|
|
674
|
+
# BOTH handlers, named individually: the span handler alone satisfying this
|
|
675
|
+
# would let an unwrapped `handle()` through, and the event handler is where
|
|
676
|
+
# every model event comes from.
|
|
677
|
+
# Match the qualname exactly: `.handle` alone is a substring of
|
|
678
|
+
# `.handler_classes`, which appears in every site name in this module.
|
|
679
|
+
assert "FailproofAISpanHandler.new_span" in logged, (
|
|
680
|
+
"the span handler swallowed its exception silently"
|
|
681
|
+
)
|
|
682
|
+
assert "FailproofAIEventHandler.handle" in logged, (
|
|
683
|
+
"the EVENT handler swallowed its exception without logging — the "
|
|
684
|
+
"dispatcher already does that for us, with no traceback and no name"
|
|
685
|
+
)
|
|
686
|
+
_core.reset_failures()
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def test_strict_mode_still_cannot_take_the_run_down(instrumented, tmp_path, monkeypatch):
|
|
690
|
+
"""FAILPROOFAI_SDK_STRICT re-raises out of `safe()` — and the dispatcher eats it.
|
|
691
|
+
|
|
692
|
+
Worth pinning: strict mode is a debugging switch, not a way to make an
|
|
693
|
+
instrumented LlamaIndex application fail fast. Anyone reaching for it to
|
|
694
|
+
"make errors loud in prod" should read this test first.
|
|
695
|
+
"""
|
|
696
|
+
state = instrumented.state
|
|
697
|
+
_core.set_strict(True)
|
|
698
|
+
|
|
699
|
+
def explode(*args, **kwargs):
|
|
700
|
+
raise RuntimeError("translator is broken")
|
|
701
|
+
|
|
702
|
+
monkeypatch.setattr(type(state), "span_enter", explode)
|
|
703
|
+
llm = StubLLM(script=[], final="ok")
|
|
704
|
+
assert str(run_agent(calculator(llm), "hi")) == "ok"
|
|
705
|
+
|
|
706
|
+
|
|
707
|
+
# ---------------------------------------------------------------------------
|
|
708
|
+
# Human in the loop
|
|
709
|
+
# ---------------------------------------------------------------------------
|
|
710
|
+
|
|
711
|
+
def test_human_in_the_loop_emits_both_pairs(instrumented, tmp_path):
|
|
712
|
+
from llama_index.core.workflow import Context, HumanResponseEvent, InputRequiredEvent
|
|
713
|
+
|
|
714
|
+
async def ask_human(ctx: Context, question: str) -> str:
|
|
715
|
+
"""Ask the human a question."""
|
|
716
|
+
answer = await ctx.wait_for_event(
|
|
717
|
+
HumanResponseEvent,
|
|
718
|
+
waiter_id="ask",
|
|
719
|
+
waiter_event=InputRequiredEvent(prefix=question),
|
|
720
|
+
)
|
|
721
|
+
return answer.response
|
|
722
|
+
|
|
723
|
+
async def converse() -> str:
|
|
724
|
+
llm = StubLLM(script=[("ask_human", {"question": "ok?"})], final="the human said yes")
|
|
725
|
+
agent = calculator(llm, tools=[ask_human])
|
|
726
|
+
handler = agent.run("ask them")
|
|
727
|
+
async for event in handler.stream_events():
|
|
728
|
+
if isinstance(event, InputRequiredEvent):
|
|
729
|
+
handler.ctx.send_event(HumanResponseEvent(response="yes"))
|
|
730
|
+
return str(await handler)
|
|
731
|
+
|
|
732
|
+
assert asyncio.run(converse()) == "the human said yes"
|
|
733
|
+
|
|
734
|
+
events = read_events(tmp_path)
|
|
735
|
+
kinds = types_of(events)
|
|
736
|
+
# Both pairs, in this order. Only agent_pause/agent_resume feeds the
|
|
737
|
+
# graph's paused time; only human_wait/human_input carries the prompt and
|
|
738
|
+
# the pending-human badge. Neither alone is sufficient.
|
|
739
|
+
assert kinds.index("human_wait") < kinds.index("agent_pause")
|
|
740
|
+
assert kinds.index("agent_pause") < kinds.index("agent_resume")
|
|
741
|
+
assert kinds.index("agent_resume") < kinds.index("human_input")
|
|
742
|
+
|
|
743
|
+
wait = only(events, "human_wait")[0]
|
|
744
|
+
pause = only(events, "agent_pause")[0]
|
|
745
|
+
resume = only(events, "agent_resume")[0]
|
|
746
|
+
given = only(events, "human_input")[0]
|
|
747
|
+
assert wait["input_id"] == pause["pause_id"] == resume["pause_id"] == given["input_id"]
|
|
748
|
+
assert wait["prompt"] and "ok?" in wait["prompt"]
|
|
749
|
+
assert isinstance(resume["duration_ms"], int)
|
|
750
|
+
assert isinstance(given["duration_ms"], int)
|
|
751
|
+
assert_rendering_invariants(events)
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def test_a_paused_tool_does_not_collide_with_its_retry(instrumented, tmp_path):
|
|
755
|
+
"""LlamaIndex re-runs a paused tool from scratch, reusing its tool_id.
|
|
756
|
+
|
|
757
|
+
Two `tool_use`/`tool_result` pairs sharing one `tool_call_id` in a session
|
|
758
|
+
would pair wrongly and report a nonsense duration, so the retry is suffixed.
|
|
759
|
+
"""
|
|
760
|
+
from llama_index.core.workflow import Context, HumanResponseEvent, InputRequiredEvent
|
|
761
|
+
|
|
762
|
+
async def ask_human(ctx: Context, question: str) -> str:
|
|
763
|
+
"""Ask the human a question."""
|
|
764
|
+
answer = await ctx.wait_for_event(
|
|
765
|
+
HumanResponseEvent,
|
|
766
|
+
waiter_id="ask",
|
|
767
|
+
waiter_event=InputRequiredEvent(prefix=question),
|
|
768
|
+
)
|
|
769
|
+
return answer.response
|
|
770
|
+
|
|
771
|
+
async def converse() -> None:
|
|
772
|
+
llm = StubLLM(script=[("ask_human", {"question": "ok?"})], final="done")
|
|
773
|
+
handler = calculator(llm, tools=[ask_human]).run("ask them")
|
|
774
|
+
async for event in handler.stream_events():
|
|
775
|
+
if isinstance(event, InputRequiredEvent):
|
|
776
|
+
handler.ctx.send_event(HumanResponseEvent(response="yes"))
|
|
777
|
+
await handler
|
|
778
|
+
|
|
779
|
+
asyncio.run(converse())
|
|
780
|
+
|
|
781
|
+
events = read_events(tmp_path)
|
|
782
|
+
ids = [event["tool_call_id"] for event in only(events, "tool_use")]
|
|
783
|
+
assert ids == ["call_0", "call_0#1"]
|
|
784
|
+
assert [e["tool_call_id"] for e in only(events, "tool_result")] == ids
|
|
785
|
+
assert only(events, "tool_result")[0]["fw_closed_by"] == "human_wait"
|
|
786
|
+
|
|
787
|
+
|
|
788
|
+
# ---------------------------------------------------------------------------
|
|
789
|
+
# Structure
|
|
790
|
+
# ---------------------------------------------------------------------------
|
|
791
|
+
|
|
792
|
+
def test_a_nested_workflow_becomes_a_nested_agent(instrumented, tmp_path):
|
|
793
|
+
from llama_index.core.workflow import Event, StartEvent, StopEvent, Workflow, step
|
|
794
|
+
|
|
795
|
+
class Mid(Event):
|
|
796
|
+
payload: str
|
|
797
|
+
|
|
798
|
+
class Inner(Workflow):
|
|
799
|
+
@step
|
|
800
|
+
async def go(self, ev: StartEvent) -> StopEvent:
|
|
801
|
+
return StopEvent(result="inner-done")
|
|
802
|
+
|
|
803
|
+
class Outer(Workflow):
|
|
804
|
+
@step
|
|
805
|
+
async def first(self, ev: StartEvent) -> Mid:
|
|
806
|
+
return Mid(payload="hi")
|
|
807
|
+
|
|
808
|
+
@step
|
|
809
|
+
async def second(self, ev: Mid) -> StopEvent:
|
|
810
|
+
return StopEvent(result=str(await Inner(timeout=5).run()))
|
|
811
|
+
|
|
812
|
+
assert str(run_workflow(Outer(timeout=5))) == "inner-done"
|
|
813
|
+
|
|
814
|
+
events = read_events(tmp_path)
|
|
815
|
+
starts = only(events, "agent_start")
|
|
816
|
+
assert [start["agent_id"] for start in starts] == ["Outer", "Inner"]
|
|
817
|
+
assert "parent_id" not in starts[0]
|
|
818
|
+
assert starts[1]["parent_id"] == "Outer"
|
|
819
|
+
assert len({event["session_id"] for event in events}) == 1
|
|
820
|
+
assert_rendering_invariants(events)
|
|
821
|
+
|
|
822
|
+
|
|
823
|
+
# ---------------------------------------------------------------------------
|
|
824
|
+
# AgentWorkflow handoffs
|
|
825
|
+
#
|
|
826
|
+
# `AgentWorkflow` does NOT run its agents as nested workflows: there is one
|
|
827
|
+
# `AgentWorkflow.run` span and the agents are steps inside it. Read off the span
|
|
828
|
+
# tree alone a two-agent crew is one flat `agent_id="AgentWorkflow"` and the
|
|
829
|
+
# handoff is invisible, so the adapter keys nested agents off the
|
|
830
|
+
# `current_agent_name` the runtime puts on every AgentInput/AgentSetup/
|
|
831
|
+
# AgentOutput instead.
|
|
832
|
+
# ---------------------------------------------------------------------------
|
|
833
|
+
|
|
834
|
+
HANDOFF_TO_ANALYST = ("handoff", {"to_agent": "analyst", "reason": "over to you"})
|
|
835
|
+
HANDOFF_TO_RESEARCHER = ("handoff", {"to_agent": "researcher", "reason": "back to you"})
|
|
836
|
+
|
|
837
|
+
|
|
838
|
+
def crew(researcher_llm, analyst_llm, *, handoff_back=False):
|
|
839
|
+
"""A real two-agent `AgentWorkflow` — the API LlamaIndex documents."""
|
|
840
|
+
from llama_index.core.agent.workflow import AgentWorkflow, FunctionAgent
|
|
841
|
+
|
|
842
|
+
researcher = FunctionAgent(
|
|
843
|
+
name="researcher",
|
|
844
|
+
description="looks numbers up",
|
|
845
|
+
tools=[add],
|
|
846
|
+
llm=researcher_llm,
|
|
847
|
+
streaming=False,
|
|
848
|
+
can_handoff_to=["analyst"],
|
|
849
|
+
)
|
|
850
|
+
analyst = FunctionAgent(
|
|
851
|
+
name="analyst",
|
|
852
|
+
description="does the maths",
|
|
853
|
+
tools=[add],
|
|
854
|
+
llm=analyst_llm,
|
|
855
|
+
streaming=False,
|
|
856
|
+
# `None` here would mean "may hand off to anyone", which loops.
|
|
857
|
+
can_handoff_to=["researcher"] if handoff_back else [],
|
|
858
|
+
)
|
|
859
|
+
return AgentWorkflow(agents=[researcher, analyst], root_agent="researcher")
|
|
860
|
+
|
|
861
|
+
|
|
862
|
+
def test_an_agent_workflow_handoff_is_two_nested_agents_not_one_flat_one(
|
|
863
|
+
instrumented, tmp_path
|
|
864
|
+
):
|
|
865
|
+
"""The names a customer facets by are `researcher` and `analyst`.
|
|
866
|
+
|
|
867
|
+
Flattened, every event in the session carries `agent_id="AgentWorkflow"` and
|
|
868
|
+
the two agents are distinguishable only by a payload extra, which
|
|
869
|
+
`agent_id`-keyed surfaces cannot group by at all.
|
|
870
|
+
"""
|
|
871
|
+
workflow = crew(
|
|
872
|
+
StubLLM(script=[("add", {"a": 1, "b": 1}), HANDOFF_TO_ANALYST]),
|
|
873
|
+
StubLLM(script=[("add", {"a": 2, "b": 3})], final="5"),
|
|
874
|
+
)
|
|
875
|
+
assert str(run_workflow(workflow, user_msg="add things")) == "5"
|
|
876
|
+
|
|
877
|
+
events = read_events(tmp_path)
|
|
878
|
+
starts = only(events, "agent_start")
|
|
879
|
+
assert [start["agent_id"] for start in starts] == [
|
|
880
|
+
"AgentWorkflow",
|
|
881
|
+
"researcher",
|
|
882
|
+
"analyst",
|
|
883
|
+
]
|
|
884
|
+
assert "parent_id" not in starts[0]
|
|
885
|
+
assert starts[1]["parent_id"] == "AgentWorkflow"
|
|
886
|
+
assert starts[2]["parent_id"] == "AgentWorkflow"
|
|
887
|
+
assert len({event["session_id"] for event in events}) == 1
|
|
888
|
+
|
|
889
|
+
# Sticky, and this is the subtle half: `ToolCall` carries no
|
|
890
|
+
# `current_agent_name`, so a `call_tool` step has to keep whichever agent
|
|
891
|
+
# asked for the tool.
|
|
892
|
+
assert [(e["agent_id"], e["tool_name"]) for e in only(events, "tool_use")] == [
|
|
893
|
+
("researcher", "add"),
|
|
894
|
+
("researcher", "handoff"),
|
|
895
|
+
("analyst", "add"),
|
|
896
|
+
]
|
|
897
|
+
assert {e["agent_id"] for e in only(events, "model_request")} == {
|
|
898
|
+
"researcher",
|
|
899
|
+
"analyst",
|
|
900
|
+
}
|
|
901
|
+
assert_rendering_invariants(events)
|
|
902
|
+
|
|
903
|
+
|
|
904
|
+
def test_a_standalone_function_agent_does_not_nest_inside_itself(instrumented, tmp_path):
|
|
905
|
+
"""The guard on the rule above, and the reason it is `name != agent_id`.
|
|
906
|
+
|
|
907
|
+
A standalone `FunctionAgent.run` drives those same `AgentWorkflow` steps
|
|
908
|
+
with its OWN name in `current_agent_name`. Without the guard every
|
|
909
|
+
single-agent run would open a `calc` nested inside a `calc` — doubling the
|
|
910
|
+
agent count on every LlamaIndex session in the product.
|
|
911
|
+
"""
|
|
912
|
+
llm = StubLLM(script=[("add", {"a": 2, "b": 3})], final="5")
|
|
913
|
+
run_agent(calculator(llm), "2+3?")
|
|
914
|
+
|
|
915
|
+
events = read_events(tmp_path)
|
|
916
|
+
assert [start["agent_id"] for start in only(events, "agent_start")] == ["calc"]
|
|
917
|
+
assert types_of(events) == ONE_TOOL_SEQUENCE
|
|
918
|
+
assert_rendering_invariants(events)
|
|
919
|
+
|
|
920
|
+
|
|
921
|
+
def test_a_handoff_back_opens_the_first_agent_again_as_a_second_turn(
|
|
922
|
+
instrumented, tmp_path
|
|
923
|
+
):
|
|
924
|
+
"""A -> B -> A: two turns for `researcher`, each opened and closed on its own.
|
|
925
|
+
|
|
926
|
+
The nested agent is keyed per turn rather than per name. Reusing the key of
|
|
927
|
+
the `researcher` we already ended would collide in the tracker, and one of
|
|
928
|
+
the two `agent_start`s would never be closed.
|
|
929
|
+
"""
|
|
930
|
+
workflow = crew(
|
|
931
|
+
StubLLM(script=[HANDOFF_TO_ANALYST], final="done"),
|
|
932
|
+
StubLLM(script=[HANDOFF_TO_RESEARCHER]),
|
|
933
|
+
handoff_back=True,
|
|
934
|
+
)
|
|
935
|
+
assert str(run_workflow(workflow, user_msg="round trip")) == "done"
|
|
936
|
+
|
|
937
|
+
events = read_events(tmp_path)
|
|
938
|
+
assert [start["agent_id"] for start in only(events, "agent_start")] == [
|
|
939
|
+
"AgentWorkflow",
|
|
940
|
+
"researcher",
|
|
941
|
+
"analyst",
|
|
942
|
+
"researcher",
|
|
943
|
+
]
|
|
944
|
+
# Inner-first, and every open closed: the invariant check below fails on an
|
|
945
|
+
# agent left open, which is what a key collision would produce.
|
|
946
|
+
assert [end["agent_id"] for end in only(events, "agent_end")] == [
|
|
947
|
+
"researcher",
|
|
948
|
+
"analyst",
|
|
949
|
+
"researcher",
|
|
950
|
+
"AgentWorkflow",
|
|
951
|
+
]
|
|
952
|
+
assert_rendering_invariants(events)
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def test_sub_agents_are_still_attributed_with_the_step_hooks_off(
|
|
956
|
+
instrumented_without_steps, tmp_path
|
|
957
|
+
):
|
|
958
|
+
"""`steps=False` drops the hook pairs, not the agents.
|
|
959
|
+
|
|
960
|
+
The sub-agent is resolved when the step span OPENS, which is a different
|
|
961
|
+
code path from the `hook_triggered` the option suppresses — so it is worth
|
|
962
|
+
proving rather than assuming.
|
|
963
|
+
"""
|
|
964
|
+
workflow = crew(
|
|
965
|
+
StubLLM(script=[HANDOFF_TO_ANALYST]),
|
|
966
|
+
StubLLM(script=[("add", {"a": 2, "b": 3})], final="5"),
|
|
967
|
+
)
|
|
968
|
+
assert str(run_workflow(workflow, user_msg="add things")) == "5"
|
|
969
|
+
|
|
970
|
+
events = read_events(tmp_path)
|
|
971
|
+
assert only(events, "hook_triggered") == []
|
|
972
|
+
assert only(events, "hook_completed") == []
|
|
973
|
+
assert [start["agent_id"] for start in only(events, "agent_start")] == [
|
|
974
|
+
"AgentWorkflow",
|
|
975
|
+
"researcher",
|
|
976
|
+
"analyst",
|
|
977
|
+
]
|
|
978
|
+
assert [(e["agent_id"], e["tool_name"]) for e in only(events, "tool_use")] == [
|
|
979
|
+
("researcher", "handoff"),
|
|
980
|
+
("analyst", "add"),
|
|
981
|
+
]
|
|
982
|
+
assert_rendering_invariants(events)
|
|
983
|
+
|
|
984
|
+
|
|
985
|
+
def test_two_concurrent_runs_do_not_mix(instrumented, tmp_path):
|
|
986
|
+
"""Two overlapping runs, one process, one dispatcher, no contextvars.
|
|
987
|
+
|
|
988
|
+
The whole reason `RunTracker` passes `session_id=` explicitly instead of
|
|
989
|
+
reading a contextvar: a start and its end are separate dispatcher calls,
|
|
990
|
+
and interleaved runs would otherwise attribute events to whichever run
|
|
991
|
+
happened to touch the variable last.
|
|
992
|
+
"""
|
|
993
|
+
|
|
994
|
+
async def both():
|
|
995
|
+
left = calculator(StubLLM(script=[("add", {"a": 1, "b": 1})], final="2"))
|
|
996
|
+
right = calculator(StubLLM(script=[("add", {"a": 3, "b": 4})], final="7"))
|
|
997
|
+
return await asyncio.gather(left.run("1+1?"), right.run("3+4?"))
|
|
998
|
+
|
|
999
|
+
answers = {str(answer) for answer in asyncio.run(both())}
|
|
1000
|
+
assert answers == {"2", "7"}
|
|
1001
|
+
|
|
1002
|
+
events = read_events(tmp_path)
|
|
1003
|
+
sessions = {event["session_id"] for event in events}
|
|
1004
|
+
assert len(sessions) == 2
|
|
1005
|
+
|
|
1006
|
+
for session in sessions:
|
|
1007
|
+
rows = [event for event in events if event["session_id"] == session]
|
|
1008
|
+
assert types_of(rows) == ONE_TOOL_SEQUENCE
|
|
1009
|
+
assert_rendering_invariants(rows)
|
|
1010
|
+
# Each session saw exactly one of the two tool calls, whole.
|
|
1011
|
+
inputs = [event["input"] for event in rows if event["type"] == "tool_use"]
|
|
1012
|
+
assert inputs in ([{"a": 1, "b": 1}], [{"a": 3, "b": 4}])
|
|
1013
|
+
|
|
1014
|
+
|
|
1015
|
+
def test_a_retrieval_becomes_a_tool_with_a_summarized_output(instrumented, tmp_path):
|
|
1016
|
+
from llama_index.core.base.base_retriever import BaseRetriever
|
|
1017
|
+
from llama_index.core.schema import NodeWithScore, QueryBundle, TextNode
|
|
1018
|
+
from llama_index.core.workflow import StartEvent, StopEvent, Workflow, step
|
|
1019
|
+
|
|
1020
|
+
class Fake(BaseRetriever):
|
|
1021
|
+
def _retrieve(self, query_bundle: QueryBundle):
|
|
1022
|
+
return [
|
|
1023
|
+
NodeWithScore(node=TextNode(text="x" * 5000, id_=f"n{i}"), score=1.0)
|
|
1024
|
+
for i in range(12)
|
|
1025
|
+
]
|
|
1026
|
+
|
|
1027
|
+
class Rag(Workflow):
|
|
1028
|
+
@step
|
|
1029
|
+
async def go(self, ev: StartEvent) -> StopEvent:
|
|
1030
|
+
return StopEvent(result=f"{len(Fake().retrieve('what?'))} nodes")
|
|
1031
|
+
|
|
1032
|
+
assert str(run_workflow(Rag(timeout=5))) == "12 nodes"
|
|
1033
|
+
|
|
1034
|
+
events = read_events(tmp_path)
|
|
1035
|
+
use = only(events, "tool_use")[0]
|
|
1036
|
+
result = only(events, "tool_result")[0]
|
|
1037
|
+
assert use["tool_name"] == "Fake"
|
|
1038
|
+
assert use["input"] == {"query": "what?"}
|
|
1039
|
+
assert use["fw_kind"] == "retrieval"
|
|
1040
|
+
# Retrieved documents are the largest strings in the process and the
|
|
1041
|
+
# payload is not a promoted column: summarize, never ship them whole.
|
|
1042
|
+
assert result["output"]["num_nodes"] == 12
|
|
1043
|
+
assert len(result["output"]["top"]) == 5
|
|
1044
|
+
assert len(result["output"]["top"][0]["text"]) <= 200
|
|
1045
|
+
assert_rendering_invariants(events)
|
|
1046
|
+
|
|
1047
|
+
|
|
1048
|
+
def test_embeddings_are_off_by_default(instrumented, tmp_path):
|
|
1049
|
+
assert instrumented.state.embeddings is False
|
|
1050
|
+
|
|
1051
|
+
|
|
1052
|
+
def test_no_event_carries_an_extra_that_shadows_a_declared_field(instrumented, tmp_path):
|
|
1053
|
+
"""`_schema._build()` merges extras LAST, at the top level.
|
|
1054
|
+
|
|
1055
|
+
So an extra called `tool_name`, `model`, `outcome` or `input_tokens`
|
|
1056
|
+
silently overwrites the declared field — changing the promoted the events store
|
|
1057
|
+
column and the server's computed summary while every test still passes.
|
|
1058
|
+
"""
|
|
1059
|
+
llm = StubLLM(script=[("add", {"a": 2, "b": 3})], final="5")
|
|
1060
|
+
run_agent(calculator(llm), "2+3?")
|
|
1061
|
+
|
|
1062
|
+
by_type = {}
|
|
1063
|
+
for name, obj in vars(_schema).items():
|
|
1064
|
+
if name.endswith("Event") and hasattr(obj, "__dataclass_fields__"):
|
|
1065
|
+
kind = "".join("_" + c.lower() if c.isupper() else c for c in name[:-5]).lstrip("_")
|
|
1066
|
+
by_type[kind] = {f.name for f in dataclass_fields(obj)} - {"extra_fields"}
|
|
1067
|
+
|
|
1068
|
+
for event in read_events(tmp_path):
|
|
1069
|
+
declared = by_type[event["type"]] | {"type", "environment"}
|
|
1070
|
+
extras = set(event) - declared
|
|
1071
|
+
assert not (extras & _core.FORBIDDEN_EXTRAS), f"{event['type']} shadows {extras}"
|
|
1072
|
+
for key in extras:
|
|
1073
|
+
assert key.startswith("fw_") or key in _core.ALLOWED_TOP_LEVEL, (
|
|
1074
|
+
f"{event['type']} carries un-namespaced extra {key!r}"
|
|
1075
|
+
)
|
|
1076
|
+
|
|
1077
|
+
|
|
1078
|
+
# ---------------------------------------------------------------------------
|
|
1079
|
+
# Teardown
|
|
1080
|
+
# ---------------------------------------------------------------------------
|
|
1081
|
+
|
|
1082
|
+
def test_uninstrument_detaches_only_our_handlers(tmp_path):
|
|
1083
|
+
from llama_index.core.instrumentation import get_dispatcher
|
|
1084
|
+
from llama_index_instrumentation.event_handlers.base import BaseEventHandler
|
|
1085
|
+
|
|
1086
|
+
class Foreign(BaseEventHandler):
|
|
1087
|
+
def handle(self, event, **kwargs):
|
|
1088
|
+
return None
|
|
1089
|
+
|
|
1090
|
+
dispatcher = get_dispatcher()
|
|
1091
|
+
foreign = Foreign()
|
|
1092
|
+
dispatcher.add_event_handler(foreign)
|
|
1093
|
+
before_events = list(dispatcher.event_handlers)
|
|
1094
|
+
before_spans = list(dispatcher.span_handlers)
|
|
1095
|
+
try:
|
|
1096
|
+
failproofai_sdk.instrument("llama_index")
|
|
1097
|
+
assert len(dispatcher.event_handlers) == len(before_events) + 1
|
|
1098
|
+
assert len(dispatcher.span_handlers) == len(before_spans) + 1
|
|
1099
|
+
failproofai_sdk.uninstrument("llama_index")
|
|
1100
|
+
# In-place slice assignment: `add_span_handler` does `+= [h]`, so a
|
|
1101
|
+
# plain `=` rebinds the pydantic field and can drop someone else's.
|
|
1102
|
+
assert list(dispatcher.event_handlers) == before_events
|
|
1103
|
+
assert list(dispatcher.span_handlers) == before_spans
|
|
1104
|
+
assert foreign in dispatcher.event_handlers
|
|
1105
|
+
finally:
|
|
1106
|
+
dispatcher.event_handlers[:] = [h for h in dispatcher.event_handlers if h is not foreign]
|
|
1107
|
+
|
|
1108
|
+
|
|
1109
|
+
ROOT_SPAN = "Empty.run-11111111-1111-4111-8111-111111111111"
|
|
1110
|
+
TOOL_SPAN = "FunctionTool.acall-22222222-2222-4222-8222-222222222222"
|
|
1111
|
+
LLM_SPAN = "StubLLM.astream_chat-44444444-4444-4444-8444-444444444444"
|
|
1112
|
+
|
|
1113
|
+
|
|
1114
|
+
def _bound_args():
|
|
1115
|
+
"""A real `inspect.BoundArguments`, which is what the dispatcher passes."""
|
|
1116
|
+
|
|
1117
|
+
def target(a=None, b=None):
|
|
1118
|
+
return None
|
|
1119
|
+
|
|
1120
|
+
return inspect.signature(target).bind()
|
|
1121
|
+
|
|
1122
|
+
|
|
1123
|
+
def _empty_workflow():
|
|
1124
|
+
from llama_index.core.workflow import StartEvent, StopEvent, Workflow, step
|
|
1125
|
+
|
|
1126
|
+
class Empty(Workflow):
|
|
1127
|
+
@step
|
|
1128
|
+
async def go(self, ev: StartEvent) -> StopEvent:
|
|
1129
|
+
return StopEvent(result="ok")
|
|
1130
|
+
|
|
1131
|
+
return Empty()
|
|
1132
|
+
|
|
1133
|
+
|
|
1134
|
+
def _fake_tool():
|
|
1135
|
+
class _Metadata:
|
|
1136
|
+
name = "adder"
|
|
1137
|
+
|
|
1138
|
+
class _Tool(BaseTool):
|
|
1139
|
+
metadata = _Metadata()
|
|
1140
|
+
|
|
1141
|
+
def __call__(self, *args, **kwargs):
|
|
1142
|
+
return None
|
|
1143
|
+
|
|
1144
|
+
return _Tool()
|
|
1145
|
+
|
|
1146
|
+
|
|
1147
|
+
def _open_a_run_holding_a_tool(state):
|
|
1148
|
+
"""Drive the real span handler to a run with one leaf still open.
|
|
1149
|
+
|
|
1150
|
+
Synthetic span ids, real handler, real base class: this is the state a
|
|
1151
|
+
process is in when a run dies mid-tool, which no cooperative workflow will
|
|
1152
|
+
produce on demand.
|
|
1153
|
+
"""
|
|
1154
|
+
_, span_cls = adapter_module.handler_classes()
|
|
1155
|
+
handler = span_cls(state=state)
|
|
1156
|
+
bound = _bound_args()
|
|
1157
|
+
handler.span_enter(id_=ROOT_SPAN, bound_args=bound, instance=_empty_workflow(), parent_id=None)
|
|
1158
|
+
handler.span_enter(id_=TOOL_SPAN, bound_args=bound, instance=_fake_tool(), parent_id=ROOT_SPAN)
|
|
1159
|
+
assert state._runs[ROOT_SPAN].open_leaves, "the tool leaf should be open"
|
|
1160
|
+
return handler, bound
|
|
1161
|
+
|
|
1162
|
+
|
|
1163
|
+
def test_a_run_that_ends_holding_an_open_tool_closes_it(instrumented, tmp_path):
|
|
1164
|
+
"""Invariant 4, at the run boundary.
|
|
1165
|
+
|
|
1166
|
+
`agent_end` force-closes open *pauses* but not tools or models, so a run
|
|
1167
|
+
that finishes while a leaf is open leaves the session `ongoing` forever —
|
|
1168
|
+
and nothing else in the suite notices, because a cooperative workflow
|
|
1169
|
+
always closes its own leaves.
|
|
1170
|
+
"""
|
|
1171
|
+
state = instrumented.state
|
|
1172
|
+
handler, bound = _open_a_run_holding_a_tool(state)
|
|
1173
|
+
|
|
1174
|
+
handler.span_exit(id_=ROOT_SPAN, bound_args=bound, instance=None, result=None)
|
|
1175
|
+
|
|
1176
|
+
events = read_events(tmp_path)
|
|
1177
|
+
assert types_of(events) == ["agent_start", "tool_use", "tool_result", "agent_end"]
|
|
1178
|
+
assert only(events, "tool_result")[0]["fw_closed_by"] == "run_ended"
|
|
1179
|
+
assert only(events, "agent_end")[0]["outcome"] == "success"
|
|
1180
|
+
assert_rendering_invariants(events)
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def test_shutdown_closes_every_leaf_a_dead_run_left_open(instrumented, tmp_path):
|
|
1184
|
+
"""Same invariant at the uninstrument boundary: nothing is left dangling."""
|
|
1185
|
+
state = instrumented.state
|
|
1186
|
+
_open_a_run_holding_a_tool(state)
|
|
1187
|
+
|
|
1188
|
+
failproofai_sdk.uninstrument("llama_index")
|
|
1189
|
+
|
|
1190
|
+
events = read_events(tmp_path)
|
|
1191
|
+
assert types_of(events) == ["agent_start", "tool_use", "tool_result", "agent_end"]
|
|
1192
|
+
assert only(events, "tool_result")[0]["fw_closed_by"] == "uninstrument"
|
|
1193
|
+
assert only(events, "agent_end")[0]["outcome"] == "cancelled"
|
|
1194
|
+
assert_rendering_invariants(events)
|
|
1195
|
+
|
|
1196
|
+
|
|
1197
|
+
def test_the_reaper_closes_a_stale_parked_stream(instrumented, tmp_path):
|
|
1198
|
+
"""A streaming response nobody consumes never gets an LLMChatEndEvent.
|
|
1199
|
+
|
|
1200
|
+
Its span has already exited, so nothing else will ever close it: without
|
|
1201
|
+
the sweep the model_request stays open and the session reads `ongoing`
|
|
1202
|
+
forever.
|
|
1203
|
+
"""
|
|
1204
|
+
state = instrumented.state
|
|
1205
|
+
event_cls, span_cls = adapter_module.handler_classes()
|
|
1206
|
+
spans = span_cls(state=state)
|
|
1207
|
+
events_handler = event_cls(state=state)
|
|
1208
|
+
|
|
1209
|
+
from llama_index.core.instrumentation.events.llm import LLMChatStartEvent
|
|
1210
|
+
|
|
1211
|
+
bound = _bound_args()
|
|
1212
|
+
spans.span_enter(id_=ROOT_SPAN, bound_args=bound, instance=_empty_workflow(), parent_id=None)
|
|
1213
|
+
spans.span_enter(id_=LLM_SPAN, bound_args=bound, instance=StubLLM(), parent_id=ROOT_SPAN)
|
|
1214
|
+
events_handler.handle(
|
|
1215
|
+
LLMChatStartEvent(span_id=LLM_SPAN, messages=[], additional_kwargs={}, model_dict={})
|
|
1216
|
+
)
|
|
1217
|
+
# The span exits the instant the generator is created; nobody consumes it.
|
|
1218
|
+
spans.span_exit(id_=LLM_SPAN, bound_args=bound, instance=None, result=iter([]))
|
|
1219
|
+
|
|
1220
|
+
assert state._leaf_run.get(LLM_SPAN) == ROOT_SPAN
|
|
1221
|
+
state.stale_after = 0.0
|
|
1222
|
+
assert state.sweep() == 1
|
|
1223
|
+
assert state.sweep() == 0
|
|
1224
|
+
|
|
1225
|
+
response = only(read_events(tmp_path), "model_response")[0]
|
|
1226
|
+
assert response["fw_closed_by"] == "stale"
|
|
1227
|
+
assert isinstance(response["duration_ms"], int)
|
|
1228
|
+
|
|
1229
|
+
|
|
1230
|
+
# ---------------------------------------------------------------------------
|
|
1231
|
+
# Cancellation
|
|
1232
|
+
#
|
|
1233
|
+
# `handler.cancel_run()` does NOT drop the run span. The runtime catches its own
|
|
1234
|
+
# `WorkflowCancelledByUser` and exits the span cleanly, with `result=None` and
|
|
1235
|
+
# no error — "so it shows as OK rather than ERROR in traces". Read off the span
|
|
1236
|
+
# alone, a user pressing stop is indistinguishable from a completed run, which
|
|
1237
|
+
# is why the adapter listens for `SpanCancelledEvent`.
|
|
1238
|
+
# ---------------------------------------------------------------------------
|
|
1239
|
+
|
|
1240
|
+
def _cancel_a_run_mid_step() -> None:
|
|
1241
|
+
"""Cancel a real run while a step is in flight, the way a stop button does."""
|
|
1242
|
+
from llama_index.core.workflow import StartEvent, StopEvent, Workflow, step
|
|
1243
|
+
|
|
1244
|
+
started = asyncio.Event()
|
|
1245
|
+
|
|
1246
|
+
class Slow(Workflow):
|
|
1247
|
+
@step
|
|
1248
|
+
async def go(self, ev: StartEvent) -> StopEvent:
|
|
1249
|
+
started.set()
|
|
1250
|
+
await asyncio.sleep(30)
|
|
1251
|
+
return StopEvent(result="never")
|
|
1252
|
+
|
|
1253
|
+
async def _main() -> None:
|
|
1254
|
+
handler = Slow(timeout=30).run()
|
|
1255
|
+
await asyncio.wait_for(started.wait(), timeout=5)
|
|
1256
|
+
await handler.cancel_run()
|
|
1257
|
+
with pytest.raises(BaseException):
|
|
1258
|
+
await handler
|
|
1259
|
+
|
|
1260
|
+
asyncio.run(_main())
|
|
1261
|
+
|
|
1262
|
+
|
|
1263
|
+
def test_a_cancelled_run_is_not_reported_as_a_success(instrumented, tmp_path):
|
|
1264
|
+
"""Reporting a cancellation as success inflates the completion rate.
|
|
1265
|
+
|
|
1266
|
+
`cancelled` is deliberately not `failed` either: the server counts only
|
|
1267
|
+
`error|failed|timeout|rejected` as a failure, and a stop button is neither.
|
|
1268
|
+
"""
|
|
1269
|
+
_cancel_a_run_mid_step()
|
|
1270
|
+
|
|
1271
|
+
events = read_events(tmp_path)
|
|
1272
|
+
assert only(events, "agent_end")[0]["outcome"] == "cancelled"
|
|
1273
|
+
# A cancellation is not an error, so nothing may report one.
|
|
1274
|
+
assert only(events, "error") == []
|
|
1275
|
+
assert_rendering_invariants(events)
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
def test_a_step_cancelled_mid_flight_is_not_reported_as_a_success(
|
|
1279
|
+
instrumented, tmp_path
|
|
1280
|
+
):
|
|
1281
|
+
"""Same signal one level down: the step exits with `result=None`, no error."""
|
|
1282
|
+
_cancel_a_run_mid_step()
|
|
1283
|
+
|
|
1284
|
+
completed = only(read_events(tmp_path), "hook_completed")
|
|
1285
|
+
assert completed, "the in-flight step still has to close"
|
|
1286
|
+
assert [hook["outcome"] for hook in completed] == ["cancelled"]
|
|
1287
|
+
|
|
1288
|
+
|
|
1289
|
+
# ---------------------------------------------------------------------------
|
|
1290
|
+
# Structural anti-drift — the one that catches a silently dead adapter
|
|
1291
|
+
# ---------------------------------------------------------------------------
|
|
1292
|
+
|
|
1293
|
+
def _our_overrides(cls, base) -> set[str]:
|
|
1294
|
+
return {
|
|
1295
|
+
name
|
|
1296
|
+
for name in vars(cls)
|
|
1297
|
+
if not name.startswith("__")
|
|
1298
|
+
and inspect.isroutine(getattr(cls, name, None))
|
|
1299
|
+
and hasattr(base, name)
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
|
|
1303
|
+
def _named_params(func) -> set[str]:
|
|
1304
|
+
return {
|
|
1305
|
+
name
|
|
1306
|
+
for name, parameter in inspect.signature(func).parameters.items()
|
|
1307
|
+
if name not in {"self", "cls"}
|
|
1308
|
+
and parameter.kind
|
|
1309
|
+
not in (inspect.Parameter.VAR_KEYWORD, inspect.Parameter.VAR_POSITIONAL)
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
|
|
1313
|
+
def test_our_overrides_still_exist_on_the_framework_base_classes():
|
|
1314
|
+
"""If upstream renames a callback, our override becomes DEAD CODE.
|
|
1315
|
+
|
|
1316
|
+
It is never called, nothing raises, and every other test in this file still
|
|
1317
|
+
passes because they exercise the adapter through our own objects. This is
|
|
1318
|
+
the only test that notices.
|
|
1319
|
+
"""
|
|
1320
|
+
from llama_index_instrumentation.event_handlers.base import BaseEventHandler
|
|
1321
|
+
from llama_index_instrumentation.span_handlers.base import BaseSpanHandler
|
|
1322
|
+
|
|
1323
|
+
event_cls, span_cls = adapter_module.handler_classes()
|
|
1324
|
+
|
|
1325
|
+
for cls, base, expected in (
|
|
1326
|
+
(event_cls, BaseEventHandler, {"handle"}),
|
|
1327
|
+
(span_cls, BaseSpanHandler, {"new_span", "prepare_to_exit_span", "prepare_to_drop_span"}),
|
|
1328
|
+
):
|
|
1329
|
+
overrides = _our_overrides(cls, base)
|
|
1330
|
+
assert expected <= overrides, f"{cls.__name__} no longer overrides {expected - overrides}"
|
|
1331
|
+
for name in overrides:
|
|
1332
|
+
ours = getattr(cls, name)
|
|
1333
|
+
theirs = getattr(base, name, None)
|
|
1334
|
+
assert inspect.isroutine(theirs), f"{base.__name__}.{name} is gone"
|
|
1335
|
+
missing = _named_params(ours) - _named_params(theirs)
|
|
1336
|
+
assert not missing, (
|
|
1337
|
+
f"{cls.__name__}.{name} declares {sorted(missing)}, which "
|
|
1338
|
+
f"{base.__name__}.{name} no longer accepts by name"
|
|
1339
|
+
)
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
def test_the_span_enter_to_new_span_kwarg_rename_still_holds():
|
|
1343
|
+
"""`span_enter(parent_id=...)` calls `new_span(parent_span_id=...)`.
|
|
1344
|
+
|
|
1345
|
+
Declaring the wrong one is not an error — it lands in `**kwargs`, every
|
|
1346
|
+
parent comes through as None, and the whole trace renders flat.
|
|
1347
|
+
"""
|
|
1348
|
+
from llama_index_instrumentation.span_handlers.base import BaseSpanHandler
|
|
1349
|
+
|
|
1350
|
+
assert "parent_id" in _named_params(BaseSpanHandler.span_enter)
|
|
1351
|
+
assert "parent_span_id" in _named_params(BaseSpanHandler.new_span)
|
|
1352
|
+
assert "parent_span_id" not in _named_params(BaseSpanHandler.span_enter)
|
|
1353
|
+
|
|
1354
|
+
_, span_cls = adapter_module.handler_classes()
|
|
1355
|
+
assert "parent_span_id" in _named_params(span_cls.new_span)
|
|
1356
|
+
|
|
1357
|
+
|
|
1358
|
+
def test_every_dispatcher_event_class_we_dispatch_on_still_exists():
|
|
1359
|
+
"""We dispatch on the event's class NAME, so these names are the API.
|
|
1360
|
+
|
|
1361
|
+
A rename upstream leaves the table below looking perfectly healthy while
|
|
1362
|
+
the adapter records nothing at all.
|
|
1363
|
+
"""
|
|
1364
|
+
import importlib
|
|
1365
|
+
|
|
1366
|
+
modules = [
|
|
1367
|
+
importlib.import_module(f"llama_index.core.instrumentation.events.{name}")
|
|
1368
|
+
for name in ("llm", "chat_engine", "retrieval", "embedding", "exception")
|
|
1369
|
+
]
|
|
1370
|
+
for name in adapter_module._HANDLED_EVENTS:
|
|
1371
|
+
assert any(hasattr(module, name) for module in modules), (
|
|
1372
|
+
f"{name} no longer exists in llama_index.core.instrumentation.events"
|
|
1373
|
+
)
|
|
1374
|
+
|
|
1375
|
+
|
|
1376
|
+
def test_the_cancel_event_we_dispatch_on_still_exists_where_we_expect_it():
|
|
1377
|
+
"""`CANCEL_EVENTS` has no other guard in this file, by construction.
|
|
1378
|
+
|
|
1379
|
+
Every other name we dispatch on lives under
|
|
1380
|
+
`llama_index.core.instrumentation.events.*`, which the test above walks.
|
|
1381
|
+
This one is dispatched by the workflows RUNTIME, so that test cannot see it
|
|
1382
|
+
— and if it is renamed or moved, nothing raises: cancelled runs quietly go
|
|
1383
|
+
back to being reported as successes.
|
|
1384
|
+
"""
|
|
1385
|
+
import importlib
|
|
1386
|
+
|
|
1387
|
+
from llama_index.core.instrumentation.events.base import BaseEvent
|
|
1388
|
+
|
|
1389
|
+
module = importlib.import_module("workflows.runtime.types.step_function")
|
|
1390
|
+
for name in adapter_module.CANCEL_EVENTS:
|
|
1391
|
+
cls = getattr(module, name, None)
|
|
1392
|
+
assert cls is not None, f"{name} is gone from {module.__name__}"
|
|
1393
|
+
assert issubclass(cls, BaseEvent), f"{name} is no longer a dispatcher event"
|
|
1394
|
+
# We match on the class name and pair the mark with the span_exit behind
|
|
1395
|
+
# it using `span_id`, which the dispatcher stamps from the active span.
|
|
1396
|
+
assert cls.class_name() == name
|
|
1397
|
+
assert "span_id" in cls.model_fields
|
|
1398
|
+
|
|
1399
|
+
# And the exception that path exists to serve, which `cancel_run()` raises.
|
|
1400
|
+
from workflows.errors import WorkflowCancelledByUser
|
|
1401
|
+
|
|
1402
|
+
assert issubclass(WorkflowCancelledByUser, BaseException)
|
|
1403
|
+
|
|
1404
|
+
|
|
1405
|
+
@pytest.mark.parametrize(
|
|
1406
|
+
("module", "event", "attributes"),
|
|
1407
|
+
[
|
|
1408
|
+
("llm", "LLMChatStartEvent", ("messages", "model_dict")),
|
|
1409
|
+
("llm", "LLMChatEndEvent", ("messages", "response")),
|
|
1410
|
+
("retrieval", "RetrievalStartEvent", ("str_or_query_bundle",)),
|
|
1411
|
+
("retrieval", "RetrievalEndEvent", ("nodes",)),
|
|
1412
|
+
("exception", "ExceptionEvent", ("exception",)),
|
|
1413
|
+
],
|
|
1414
|
+
)
|
|
1415
|
+
def test_the_event_attributes_we_read_still_exist(module, event, attributes):
|
|
1416
|
+
import importlib
|
|
1417
|
+
|
|
1418
|
+
cls = getattr(importlib.import_module(f"llama_index.core.instrumentation.events.{module}"), event)
|
|
1419
|
+
for attribute in attributes:
|
|
1420
|
+
assert attribute in cls.model_fields, f"{event}.{attribute} is gone"
|
|
1421
|
+
|
|
1422
|
+
|
|
1423
|
+
def test_the_waiting_for_event_signal_still_looks_like_we_think_it_does():
|
|
1424
|
+
"""HITL hinges on a name match: `WaitingForEvent` is a pause, not an error.
|
|
1425
|
+
|
|
1426
|
+
It is not exported from `workflows.errors` and has moved before, so we
|
|
1427
|
+
match on the class name. If it is renamed, every human-in-the-loop pause
|
|
1428
|
+
becomes a red error event and a failed run.
|
|
1429
|
+
"""
|
|
1430
|
+
from workflows.runtime.types.results import AddWaiter, WaitingForEvent
|
|
1431
|
+
|
|
1432
|
+
assert WaitingForEvent.__name__ == "WaitingForEvent"
|
|
1433
|
+
assert issubclass(WaitingForEvent, Exception)
|
|
1434
|
+
assert "waiter_id" in AddWaiter.model_fields
|
|
1435
|
+
assert "waiter_event" in AddWaiter.model_fields
|
|
1436
|
+
assert adapter_module._is_waiting(WaitingForEvent.__new__(WaitingForEvent)) is True
|
|
1437
|
+
assert adapter_module._is_waiting(RuntimeError("nope")) is False
|
|
1438
|
+
|
|
1439
|
+
|
|
1440
|
+
def test_the_dispatcher_surface_we_register_on_still_exists():
|
|
1441
|
+
from llama_index.core.instrumentation import get_dispatcher
|
|
1442
|
+
|
|
1443
|
+
dispatcher = get_dispatcher()
|
|
1444
|
+
assert dispatcher.name == "root", "get_dispatcher() must return the ROOT dispatcher"
|
|
1445
|
+
assert callable(dispatcher.add_event_handler)
|
|
1446
|
+
assert callable(dispatcher.add_span_handler)
|
|
1447
|
+
# Child dispatchers propagate upward; that is why one registration is enough.
|
|
1448
|
+
assert get_dispatcher("llama_index.core.something").propagate is True
|
|
1449
|
+
|
|
1450
|
+
|
|
1451
|
+
# ---------------------------------------------------------------------------
|
|
1452
|
+
# Registry wiring
|
|
1453
|
+
# ---------------------------------------------------------------------------
|
|
1454
|
+
|
|
1455
|
+
def test_the_registry_reaches_this_adapter_by_all_its_spellings():
|
|
1456
|
+
from failproofai_sdk.integrations import _canonical
|
|
1457
|
+
|
|
1458
|
+
for spelling in ("llama_index", "llamaindex", "llama-index", "LlamaIndex"):
|
|
1459
|
+
assert _canonical(spelling) == "llama_index"
|
|
1460
|
+
|
|
1461
|
+
|
|
1462
|
+
def test_instrumenting_twice_is_a_no_op(tmp_path):
|
|
1463
|
+
from failproofai_sdk.integrations import active
|
|
1464
|
+
|
|
1465
|
+
try:
|
|
1466
|
+
assert failproofai_sdk.instrument("llama_index") == ("llama_index",)
|
|
1467
|
+
assert failproofai_sdk.instrument("llama_index") == ()
|
|
1468
|
+
assert "llama_index" in active()
|
|
1469
|
+
finally:
|
|
1470
|
+
failproofai_sdk.uninstrument("llama_index")
|
|
1471
|
+
|
|
1472
|
+
|
|
1473
|
+
def test_uninstrumenting_something_that_was_never_installed_is_a_no_op():
|
|
1474
|
+
assert failproofai_sdk.uninstrument("llama_index") == ()
|
|
1475
|
+
|
|
1476
|
+
|
|
1477
|
+
def test_the_adapter_joins_a_hand_written_agent_scope(instrumented, tmp_path):
|
|
1478
|
+
"""The interop story: adapter events land in the ambient session.
|
|
1479
|
+
|
|
1480
|
+
`RunTracker.identity()` falls back to `failproofai_sdk.current()`, so mixing the
|
|
1481
|
+
manual API with an adapter produces one tree rather than two.
|
|
1482
|
+
"""
|
|
1483
|
+
llm = StubLLM(script=[], final="5")
|
|
1484
|
+
with failproofai_sdk.agent("planner", goal="do maths") as identity:
|
|
1485
|
+
run_agent(calculator(llm), "2+3?")
|
|
1486
|
+
session_id = identity.session_id
|
|
1487
|
+
|
|
1488
|
+
events = read_events(tmp_path)
|
|
1489
|
+
assert {event["session_id"] for event in events} == {session_id}
|
|
1490
|
+
starts = only(events, "agent_start")
|
|
1491
|
+
assert [start["agent_id"] for start in starts] == ["planner", "calc"]
|
|
1492
|
+
assert starts[1]["parent_id"] == "planner"
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
@pytest.fixture
|
|
1496
|
+
def instrumented_without_content(tmp_path):
|
|
1497
|
+
"""`instrumented`, with the documented regulated-data switch turned off."""
|
|
1498
|
+
_core.set_strict(False)
|
|
1499
|
+
_compat.set_strict_integrations(False)
|
|
1500
|
+
_core.reset_failures()
|
|
1501
|
+
_runtime.writer.set_flush_interval(3600)
|
|
1502
|
+
assert failproofai_sdk.instrument("llama_index", capture_messages=False) == (
|
|
1503
|
+
"llama_index",
|
|
1504
|
+
)
|
|
1505
|
+
try:
|
|
1506
|
+
yield adapter_module.adapter
|
|
1507
|
+
finally:
|
|
1508
|
+
failproofai_sdk.uninstrument("llama_index")
|
|
1509
|
+
_core.set_strict(None)
|
|
1510
|
+
_compat.set_strict_integrations(None)
|
|
1511
|
+
_core.reset_failures()
|
|
1512
|
+
|
|
1513
|
+
|
|
1514
|
+
def test_capture_messages_off_records_no_payload_anywhere(
|
|
1515
|
+
instrumented_without_content, tmp_path
|
|
1516
|
+
):
|
|
1517
|
+
"""The switch was consulted in ONE place and documented as covering all of them.
|
|
1518
|
+
|
|
1519
|
+
`capture_messages` gated only the messages and system prompt on
|
|
1520
|
+
`model_start`. The model's completion, every tool call's arguments, every
|
|
1521
|
+
tool's return value, every workflow-step input and output, the retrieval
|
|
1522
|
+
query and the agent's goal were all still written to the spool and shipped —
|
|
1523
|
+
so the setting looked like it had worked (prompts did stop) while the
|
|
1524
|
+
answers, the arguments and the outputs did not.
|
|
1525
|
+
|
|
1526
|
+
`docs/start/integrations/llamaindex.mdx` presents this as the control for
|
|
1527
|
+
regulated data, and `collector.redact` explicitly does not apply to SDK
|
|
1528
|
+
events, so there was no second line of defence behind it.
|
|
1529
|
+
"""
|
|
1530
|
+
llm = StubLLM(script=[("add", {"a": 987654321, "b": 123456789})], final="SECRET-COMPLETION")
|
|
1531
|
+
run_agent(calculator(llm), "SECRET-PROMPT: add them")
|
|
1532
|
+
|
|
1533
|
+
events = read_events(tmp_path)
|
|
1534
|
+
assert events, "nothing was recorded at all; the test proves nothing"
|
|
1535
|
+
|
|
1536
|
+
blob = json.dumps(events)
|
|
1537
|
+
# Distinctive on purpose: short values collide with the hex in a random
|
|
1538
|
+
# span/session id and make this flaky rather than wrong.
|
|
1539
|
+
for secret in ("SECRET-COMPLETION", "SECRET-PROMPT", "987654321", "123456789"):
|
|
1540
|
+
assert secret not in blob, f"{secret!r} reached the spool with capture_messages=False"
|
|
1541
|
+
|
|
1542
|
+
# And on the fields themselves, so this still fails if a payload is recorded
|
|
1543
|
+
# in some form the substrings above happen not to match.
|
|
1544
|
+
for row in events:
|
|
1545
|
+
for field in ("input", "output", "content", "messages", "system", "goal", "prompt", "response"):
|
|
1546
|
+
assert row.get(field) is None, (
|
|
1547
|
+
f"{row['type']}.{field} = {row[field]!r} with capture_messages=False"
|
|
1548
|
+
)
|
|
1549
|
+
|
|
1550
|
+
# Structure, timings and outcomes are still recorded — that is the whole
|
|
1551
|
+
# bargain the option offers, and an adapter that recorded nothing would
|
|
1552
|
+
# pass the assertions above for the wrong reason.
|
|
1553
|
+
kinds = types_of(events)
|
|
1554
|
+
assert "agent_start" in kinds and "tool_use" in kinds and "model_request" in kinds
|
|
1555
|
+
|
|
1556
|
+
|
|
1557
|
+
def test_captured_values_are_not_capped_at_a_quarter_of_the_core_limit():
|
|
1558
|
+
"""512 was a quarter of the limit the LangChain adapter had just rejected.
|
|
1559
|
+
|
|
1560
|
+
`_summarize` renders every user-visible payload here, and this is the
|
|
1561
|
+
RAG-first framework: a real retrieved context clears 512 characters many
|
|
1562
|
+
times over, so the adapter documented as the way to see what your agent said
|
|
1563
|
+
showed the first two sentences of it — with no option to raise it.
|
|
1564
|
+
"""
|
|
1565
|
+
assert adapter_module._SUMMARY_LIMIT == _core.FIELD_LIMIT
|
|
1566
|
+
|
|
1567
|
+
state = adapter_module._State(capture_limit=32768)
|
|
1568
|
+
assert len(state.capture("z" * 30000)) == 30000
|
|
1569
|
+
assert state.tracker._field_limit == 32768
|
|
1570
|
+
|
|
1571
|
+
# A bad value must not take the adapter down: `instrument()` with no name
|
|
1572
|
+
# installs every detected adapter with the same options, and `inf` is the
|
|
1573
|
+
# obvious spelling of "capture everything".
|
|
1574
|
+
assert adapter_module._capture_limit(float("inf")) == _core.FIELD_LIMIT
|
|
1575
|
+
assert adapter_module._capture_limit("nope") == _core.FIELD_LIMIT
|
|
1576
|
+
assert adapter_module._capture_limit(0) == _core.FIELD_LIMIT
|