[sdk] Native support for LangChain/LangGraph, CrewAI, LlamaIndex and Pydantic AI, on a real identity layer - #730
Conversation
`_pending` correlates a start event with its end so `duration_ms` can be measured. The key it uses has been wrong twice, in opposite directions. Bare ids were the first mistake: tool pairs keyed on `tool_call_id` and hook pairs on `hook_id` shared one flat keyspace, so a caller whose tool call and hook happened to share an id — not exotic, both are frequently the harness's own step id — got a `hook_completed` that consumed the `tool_use` timestamp and then a `tool_result` with no duration at all. Adding the session fixed a second, real collision: `_pending` lives on one process-wide namespace, so two concurrent sessions collided on any shared step id. Starting `step-1` in session A and then in B overwrote A's timestamp; A's result reported B's interval and B's reported none. Adding the AGENT as well was over-tightening, and this commit removes it. Once a framework runs tools inside sub-agents — LangGraph and CrewAI both do — a `tool_use` opened under `planner` and closed under `worker` is the ORDINARY case, and an agent-scoped key makes those pairs miss entirely, silently dropping `duration_ms` for exactly the nested runs that most need it. The rule that survives both: include what makes the id unique (kind, session), exclude what can legitimately change between the two events (the agent). A session cannot change under a pair; an agent can. Applied to all four pair types, since a `human_wait` answered by a supervisor and an `agent_pause` resumed by another agent are the same shape. These are correlation keys only — never emitted, never leaving the process — so no wire format changes. Only `duration_ms` changes, in the colliding cases, from a fabricated or missing value to a correct one. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every event method took `session_id` and `agent_id` as required keyword
arguments and nothing propagated them, so instrumenting a real agent meant
threading two ids through every function that might emit. That is the diff
nobody wants to review, and it is why `skill/references/integration.md` shipped
a ~60-line contextvars wrapper AS MARKDOWN for customers to paste into their own
codebase — the SDK asking users to write the missing half of the SDK.
Three scopes bind identity on contextvars instead:
with failproofai_sdk.session() as sid:
with failproofai_sdk.agent("planner", goal=q): # agent_start/end
with failproofai_sdk.tool_call("search") as t: # tool_use/result
t.output = search(q)
All three work under `with` and `async with` — an agent framework is half-async,
and `@contextmanager` supports only the former, so these are plain classes whose
async pair delegates to the sync pair. No scope awaits anything (`submit()` is a
deque append), so the delegation is not a lie.
`session_id`/`agent_id` are now OPTIONAL on all 15 methods, resolved from the
scope when omitted. Existing call sites are untouched and still pass ids
explicitly, which is why the golden wire-format bytes are unchanged.
Details that are load-bearing rather than incidental:
* The agent stack is a TUPLE. A `ContextVar[list]` is shared by reference across
tasks and threads, so `.append()` in one mutates what every other sees — the
cross-run mixing contextvars exist to prevent, wearing a contextvars costume.
It passes every single-threaded test.
* `propagate(fn)` snapshots VALUES rather than using `copy_context().run`. A
`Context` cannot be entered twice, so the copy_context form crashes the
caller's worker on any reuse — `pool.map`, a retried submit — and mutations
inside `ctx.run` persist, leaking one call's agent stack into the next.
* `agent()` emits `error` strictly BEFORE `agent_end`, because the dashboard
closes the span at `agent_end` and anything after it is attributed to nothing.
A cancellation closes as `cancelled`, not `failed` — a cancelled run is not an
error, and marking it one pollutes the Errors surface.
* Identity is validated AFTER resolution, never before. Validating first would
reject every ambient call; resolving without validating would restore the
silent skip, since ingest drops an event whose `session_id` is not a JSON
string and answers `200 OK` with `{"accepted":0,"skipped":1}`.
* Unresolvable identity raises TypeError, not ValueError — the wrong type, or a
missing required argument, which is exactly what a caller got before this
change. Code catching TypeError keeps working.
* Field validation runs before identity resolution: a reserved `**field` is a
fault in the call itself and reads the same from anywhere, so it gives a
stable message; the identity error depends on where the call was made from.
`conftest.py` gains the suite-wide isolation this makes necessary: a per-test
spool, restored process globals, and an assertion that a test leaking a scope
FAILS rather than quietly misattributing every event after it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The dashboard pairs a model request with its response on `request_id`, but no SDK method accepted one and no doc mentioned it. So every integration written to our own documentation emitted model events that cannot be paired — including `demo-agent/mock_agent.py`, our own reference implementation. Optional on both methods, and appended LAST in the ordered field list, so an event that omits it serialises byte-for-byte as before. That matters twice over: `test_wire_format.py` freezes those bytes, and ingest's dedup key hashes the canonical payload, so a reordering would stop retried batches collapsing and surface as duplicate rows rather than as an error. Only the two model events carry it. The other thirteen have nothing to pair with, and a field most event types cannot use is a field people fill in wrongly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…x, Pydantic AI
failproofai_sdk.configure(environment="prod")
failproofai_sdk.instrument() # auto-detects what is already imported
graph.invoke({"messages": [...]}) # unchanged
Each adapter is a translation table over one shared `RunTracker`, emitting only
the existing 15 event types — nothing fans out to the server, collector, CLI or
the stored schema.
WHY THIS IS NOT THE SAME AS EMITTING BY HAND. Measured on one task, same model,
same tool: hand-written instrumentation produced 4 events and 4 types; the
adapter produced 14 events and 8 types. The manual version reported ONE
model_request/model_response pair for a run that made TWO LLM calls, and zero
tool events for a run whose entire point was calling a tool. That is not
carelessness, it is the ceiling: `graph.invoke()` is one call from outside, and
the ReAct loop, the tool dispatch, the second round-trip and the per-node
timings all happen inside it. You cannot instrument what you cannot see.
AutoGen is deliberately absent. `autogen-core` 0.7.5 last shipped 2025-09-30
with no commits since, and Microsoft's forward path is a separate package; the
live product is AG2, a different distribution whose middleware has no global
auto-instrument hook.
ZERO DEPENDENCIES SURVIVES THIS, and the test got stronger rather than weaker.
The adapters import the frameworks they adapt — there is no other way to
subclass a callback base class — but `integrations/__init__` resolves them by
STRING through `importlib.import_module` at call time. So the source scan is now
scoped to core modules with a per-file allowlist, and the promise is asserted at
runtime instead: a fresh interpreter imports the package and must have no
framework in `sys.modules`. An eager import is not a style problem, it makes
`import failproofai_sdk` raise ImportError on every machine without that
framework — verified by planting one.
Framework extras carry upper bounds. Without one, a clean build a year from now
pulls the next major, the callback API shifts, and the adapter stops receiving
events while raising nothing — an empty dashboard, not a traceback. There is
deliberately no `[all]`: an extra installing four agent frameworks at once is a
resolver problem handed to somebody who wanted a telemetry library.
Verified against the real frameworks, not mocks: 211 adapter tests (langchain
50, crewai 49, llama_index 44, pydantic_ai 68), plus live runs of all four
against a real model, each reaching the daemon and the events store.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`examples/*_quickstart.py` — about 30 lines each, the thing somebody runs in their first five minutes. All four were executed against a real model before being committed; parsing is not evidence that an example works. `tests/test_examples.py` guards them, because nothing else can: they need a framework and an API key, so they cannot run in unit CI, which is exactly why they rot. It checks they parse, that they call API this package actually exports, that each imports only the framework its own extra installs, that the extra they name exists — and that they demonstrate the ergonomics they exist to demonstrate. An example that threads `session_id=` by hand teaches the manual path the scopes were built to remove, so that fails the suite. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`skill/SKILL.md` §3 opened with "There is no ambient session. No decorator, no context manager, no contextvar, no `set_session()`." An agent reads that as the contract and writes against it, so leaving it would have been worse than not documenting the scopes at all — a skill is instructions somebody executes. - `skill/references/frameworks.md` — new. Per-framework mapping tables, what every adapter guarantees, how to mix adapters with hand-written events, how to verify one, and what to do for a framework not on the list. - `README.md` — the frameworks and scopes sections, ahead of the manual event reference, because that is now the order people meet them in. - `skill/SKILL.md` — §3 rewritten to describe the scopes, and pointed at the new reference rather than at the wrapper customers used to paste in by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Thanks @SiddarthAA for your contribution to Failproof AI! 🙌 We'd love to discuss your PR and welcome you to our community: https://discord.befailproof.ai/ |
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Every PR carries an entry, and this one touched nothing outside sdk/python until now — which is exactly how a release note goes missing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Hermes
Two correctness issues remain in the new SDK identity and Pydantic AI integration paths; the explicit Pydantic API is completely inert without auto-instrumentation. What this changesflowchart LR
n0AmbientidentityAPI["+ Ambient identity API"]
n1Eventemission["~ Event emission"]
n2Integrationregistry["+ Integration registry"]
n3PydanticAIadapter["+ Pydantic AI adapter"]
n4Otherframeworkadapters["+ Other framework adapters"]
n5Collectordelivery["~ Collector delivery"]
n6Daemoncollectorlifecycle["~ Daemon collector lifecycle"]
n7CIandSDKdocumentation["~ CI and SDK documentation"]
n0AmbientidentityAPI -- "supplies omitted identities" --> n1Eventemission
n2Integrationregistry -- "loads and enables" --> n3PydanticAIadapter
n2Integrationregistry -- "loads and enables" --> n4Otherframeworkadapters
n3PydanticAIadapter -- "emits run telemetry" --> n1Eventemission
n4Otherframeworkadapters -- "emits framework telemetry" --> n1Eventemission
n1Eventemission -- "writes SDK spool batches" --> n5Collectordelivery
n6Daemoncollectorlifecycle -- "starts delivery tasks" --> n5Collectordelivery
n5Collectordelivery -- "documents delivery behavior" --> n7CIandSDKdocumentation
Rounds
FindingsOpen
|
…ey replaced Three gaps found by diffing this branch against the upstream PR it was ported from (FailproofAI/agenteye#503), of which the first would have failed CI. **uv.lock was stale.** `pyproject` grew `pytest-asyncio` and five framework extras; the lockfile had none of them, so `uv sync --locked --extra dev` — the exact command the `failproofai-sdk` CI job runs — failed with "the lockfile needs to be updated". Regenerated: +5941/-94, which is most of the line-count difference between the two PRs and an omission rather than a saving. All five framework extras resolve, and `uv sync --locked --extra pydantic-ai` installs. **`integration.md` still shipped the wrapper.** Its "## The wrapper" section was ~60 lines of contextvars scaffolding for customers to paste into their own codebase — the thing `session()`/`agent()`/`tool_call()` now are. Worse than redundant: it taught `contextvars.copy_context().run` for thread hand-off, which `_context.propagate` documents as broken, because a `Context` cannot be entered by two threads at once and so the copy-context form crashes the caller's worker on any reuse — `pool.map`, a retried submit. That is now a "do not reach for this" warning next to `propagate()`. **`events.md`** picks up the scopes and the four `human_*` events alongside them. Both ported files re-introduced a bug this branch had already fixed: their lifecycle brackets catch `Exception`, and `asyncio.CancelledError` inherits from `BaseException`, so a cancelled tool emits `tool_use` with no `tool_result` and a cancelled run gets no `agent_end` at all. `tests/test_skill_snippets.py` caught both on the way in, which is the whole reason it parses every fenced block rather than trusting review. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hermes-exosphere
left a comment
There was a problem hiding this comment.
Hermes found blocking issues that should be addressed.
Review coverage was incomplete, but the concrete blocking findings below are sufficient to request changes.
High: Explicit Pydantic AI capabilities are inert unless auto-instrumentation is also enabled
- Rule:
COR-001 - Location:
sdk/python/failproofai_sdk/integrations/pydantic_ai.py:394 - Evidence: The adapter documents
capabilities=[FailproofAI()]as a standalone usage path (lines 345-353), but all three capability wrappers immediately return the underlying handler while module-global_enabledis false (for example lines 394-396)._enabledstarts false and is only set true by_Adapter.install()(lines 671-684). Thus a user following the explicit-capability API without first callinginstrument("pydantic_ai")gets no telemetry at all. The explicit-capability test runs under theinstrumentedfixture, so it cannot expose this path. - Required change: Make explicit
FailproofAI()instances active independently of registry installation; keep any uninstall disablement scoped to auto-injected instances. Add a test that constructsAgent(..., capabilities=[FailproofAI()])without callinginstrument()and asserts emitted events.
1 advisory finding
- Medium/High Falsy invalid session IDs are converted into unrelated generated sessions —
session._enter()selects the requested ID withself._requested or ...(line 97), andagent._enter()does the same (line 197). Consequentlysession("")andagent(..., session_id=0)do not raise identity validation errors; they generate UUID sessions instead. A containerized reproduction printed generated UUIDs for both cases. This silently splits telemetry from the caller's intended session rather than preserving the event API's invalid-identity failure behavior. (sdk/python/failproofai_sdk/_scopes.py:97)
|
|
||
| # -- run -------------------------------------------------------------- | ||
|
|
||
| async def wrap_run(self, ctx, *, handler): |
There was a problem hiding this comment.
Hermes — High/High (COR-001): Explicit Pydantic AI capabilities are inert unless auto-instrumentation is also enabled
The adapter documents capabilities=[FailproofAI()] as a standalone usage path (lines 345-353), but all three capability wrappers immediately return the underlying handler while module-global _enabled is false (for example lines 394-396). _enabled starts false and is only set true by _Adapter.install() (lines 671-684). Thus a user following the explicit-capability API without first calling instrument("pydantic_ai") gets no telemetry at all. The explicit-capability test runs under the instrumented fixture, so it cannot expose this path.
Required change: Make explicit FailproofAI() instances active independently of registry installation; keep any uninstall disablement scoped to auto-injected instances. Add a test that constructs Agent(..., capabilities=[FailproofAI()]) without calling instrument() and asserts emitted events.
| self._agent_token: "contextvars.Token | None" = None | ||
|
|
||
| def _enter(self) -> str: | ||
| sid = self._requested or _context.session_id() or uuid.uuid4().hex |
There was a problem hiding this comment.
Hermes — Medium/High (COR-001): Falsy invalid session IDs are converted into unrelated generated sessions
session._enter() selects the requested ID with self._requested or ... (line 97), and agent._enter() does the same (line 197). Consequently session("") and agent(..., session_id=0) do not raise identity validation errors; they generate UUID sessions instead. A containerized reproduction printed generated UUIDs for both cases. This silently splits telemetry from the caller's intended session rather than preserving the event API's invalid-identity failure behavior.
Required change: Distinguish omission from a supplied value with is None, then validate supplied session IDs before binding them. Cover empty strings and falsy non-string values with unit tests.
A LangChain run with no parent is the session's root, and the adapter turned every root into an `agent_start`/`agent_end` pair — including a root whose own `run_type` is `chat_model`, which is exactly what a direct `ChatOpenAI(...).invoke(...)` outside any graph produces. So that call emitted an agent span and NOTHING ELSE: no `model_request`, no `model_response`, and therefore no model name, no input or output tokens and no latency, while the trace still looked populated and nothing raised. It is not an edge case — a classifier, a summariser and a one-shot rewrite are all shaped like this, and a supervisor that delegates to graphs and then writes its own summary hits it on the summary. That is where this was found. `_start_root` now also dispatches the leaf starter for a leaf-typed root, and `_on_end` closes the leaf before the agent — the dashboard closes the span at `agent_end`, so a `model_response` emitted after it is attributed to nothing. Purely additive: a chain-typed root is untouched. Five tests, four of which fail when the fix is reverted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q645koFdi3k9eZHk9b1qLj
…d it `crewai.flow.runtime` emits `HumanFeedbackRequestedEvent` before it blocks on a person and `HumanFeedbackReceivedEvent` after the answer. The adapter subscribed to neither, so the entire wait was an unexplained gap in the trace and the session's active duration absorbed it. LangChain and LlamaIndex both map their HITL surface onto the same four events; crewai now does too — `human_wait` + `agent_pause`, then `agent_resume` + `human_input`, in that order, because only the first pair carries the prompt and the answer and only the second feeds paused time. Two things surfaced while fixing it, each of which would have left the fix silently inert: * The adapter resolved event classes against `crewai.events.event_types` alone, and the flow events are not in it — they are lazily re-exported from `crewai.events`. The lookup returned None, the capability probe disabled that one hook, and nothing failed. `event_class()` now tries both namespaces, and the anti-drift test resolves through it rather than through a namespace of its own: asserting against the narrower one is what let the gap exist. * crewai sets NO correlation id on either event — `request_id` is None on both and `started_event_id` is None on the received one — so pairing on it raised a TypeError inside the customer's event bus. The join is now `request_id` (which the enterprise async provider does set, and which can interleave), then `(flow_name, method_name)`, then the most recently opened pause, which is sound only because a console prompt blocks. Feedback for a request we never saw records the answer but deliberately withholds `agent_resume`: closing a pause that never opened subtracts a pausedMs interval that was never added. Six tests, all six failing when reverted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q645koFdi3k9eZHk9b1qLj
The docstring said an integration naming its counters something unusual would
show blank token columns, which reads as an exotic case. The common case is
worse and was undocumented: `FunctionAgent` — the agent API LlamaIndex
documents — calls `astream_chat`, and `llama-index-llms-openai` does not send
`stream_options={"include_usage": True}`, so the provider never emits the usage
chunk and `LLMChatEndEvent.response.raw` has no `usage` key to find.
Verified by spying on the dispatcher directly against llama-index-core 0.14.23:
every `LLMChatEndEvent` in a `FunctionAgent` run arrives with usage absent, so
every token count on the default agent path is null and no instrumentation can
recover a number the framework never received.
The one-argument user-side fix is now stated in the docstring. Measured on the
same run: `(None, None)` becomes `(148, 17)`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q645koFdi3k9eZHk9b1qLj
Documentation and examples were two directories kept in agreement by hand, and
the docs half was MDX — which renders as raw JSX tags anywhere except a Mintlify
build, so on disk and on GitHub it read as broken markup.
Both are now one Markdown tree, a directory per framework holding the guide
somebody reads and the `examples/` they run:
docs/<framework>/README.md
docs/<framework>/examples/*.py
Five directories — langgraph, crewai, llama_index, pydantic_ai, and manual for
an agent with no framework, which also carries the three-seam recipe for any
unsupported one and states why AutoGen has no adapter.
Every guide follows one shape: install and supported range, the three-line
integration, how the adapter attaches, a full framework-concept-to-event
mapping, a complete copy-pasteable program, span naming, session resolution,
every `instrument()` option, a real captured event payload, and pitfalls written
as symptom then cause then fix.
The pitfalls are the ones that actually cost time here: construct Pydantic AI
agents AFTER `instrument()` or they carry no capability and record nothing, with
no error; `create_react_agent` aborts the graph on a raising tool unless the
tool node sets `handle_tool_errors`; LlamaIndex needs one `stream_options`
argument or every token count is null; and never read the spool to verify
anything, because a running `failproofaid` deletes each batch within
milliseconds and the read races it.
Eleven example scripts, every one executed against a live model before shipping,
including a supervisor delegating to two workers (38 events, 5 agents) and a
bare OpenAI tool-calling loop instrumented by hand (14 events, no framework).
Each ends by printing the event stream it produced, captured by tapping the
writer in-process rather than reading the spool, for the reason above.
`test_examples.py` becomes `test_docs.py` and walks the whole tree: a framework
with an adapter and no directory fails, a guide linking to an example that does
not exist fails, a guide or example naming SDK API that does not exist fails,
and an example threading `session_id=` by hand fails — checked over the AST, so
the manual guide can still explain the argument its whole purpose is to replace.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q645koFdi3k9eZHk9b1qLj
The site had one page for the Python SDK, titled "Custom agents", documenting
the pre-open-source API: `import failproofai` rather than `failproofai_sdk`, a
private wheel install, `session_id`/`agent_id` required on every call, no
`instrument()` and no adapters. It also carried a claim that is no longer true —
that tool and hook ids share one process-wide pending map and must be globally
unique across both namespaces. Keys are `tool:{session}:{id}` and
`hook:{session}:{id}` now, scoped by kind and session.
Adds a Frameworks section under Start here: an index that leads with all five
integrations, then a page each for LangChain/LangGraph, CrewAI, LlamaIndex,
Pydantic AI, and custom agents, plus a How it works page covering the data
model, who mints which id, when a session ends, and how events reach Cloud —
the questions no per-framework page can answer.
The four framework pages share one section order, so a reader who learns one can
skim the next: Install, Instrument, What gets recorded, Example, Name your
spans, Control the session, Options, Human in the loop, Common problems, Next.
Two diagrams, both the same orientation: the pair structure on the index and the
delivery pipeline on How it works. Everything else is tables — a decision tree
forced into a flowchart sprawls, and a pipeline table can carry a "runs in"
column a diagram cannot.
The old page is retitled "Python SDK reference", keeps the reference material
that belongs in a reference tab, fixes the import name and install, and points
at the new guides. `reference/overview` gains a card so the Integrations tab
keeps an entry point.
Every code sample on these pages was extracted and run against a live model
before shipping.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Q645koFdi3k9eZHk9b1qLj
The published site is a second, hand-maintained copy of claims about this package, and nothing checked it. It had already drifted: it named `capture_content` for CrewAI and `session_id` for LlamaIndex, neither of which those adapters read, and told readers to verify a Pydantic AI install by printing `agent.capabilities`, which raises AttributeError — Pydantic AI merges the list into one `root_capability`. None of that produced an error for a reader. `instrument()` passes one dict to every adapter and drops unknown keys by design, so a wrong option is silently ignored: no error, no effect. Only a test catches it. Parses each adapter's own source for the options it really reads, compares them against every documented `instrument()` call, pins the Pydantic verification snippet to `root_capability`, checks every SDK name the pages mention, and asserts the four framework pages share one section order and are each explicit about human-in-the-loop rather than silent. Same shape as `test_spool_contract.py` and the CLI's `test_fp_home_contract.py`: read the other side's source, skip when it is genuinely absent (an installed sdist has no docs site), and fail when `FAILPROOFAI_SDK_REQUIRE_CONTRACT` says the repository should be there. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q645koFdi3k9eZHk9b1qLj
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Q645koFdi3k9eZHk9b1qLj
hermes-exosphere
left a comment
There was a problem hiding this comment.
Hermes found blocking issues that should be addressed.
Review coverage was incomplete, but the concrete blocking findings below are sufficient to request changes.
High: Explicit Pydantic AI capabilities emit no telemetry unless instrument() is also called
- Rule:
COR-001 - Location:
sdk/python/failproofai_sdk/integrations/pydantic_ai.py:395 - Evidence:
FailproofAI()is documented as a standalonecapabilities=[FailproofAI()]integration, but all three wrappers return the underlying handler while module-global_enabledis false (for examplewrap_runat line 395)._enabledstarts false and is only set by_Adapter.install()at line 684. The explicit-capability test at line 647 receives theinstrumentedfixture, so it only tests the auto-instrumented state. - Required change: Make explicit
FailproofAI()instances active independently of registry installation, while limiting uninstall disablement to auto-injected instances. Add a test that constructs an agent withcapabilities=[FailproofAI()]without callinginstrument()and asserts emitted events.
1 advisory finding
- Medium/High Falsy supplied session IDs are replaced with generated or inherited IDs —
session._enter()andagent._enter()select the requested ID withself._requested or ...(lines 97 and 197). Thereforesession("")andagent("a", session_id=0)do not reach identity validation; a nested scope can inherit another session and an unbound scope generates a UUID. A nested-container reproduction printed generated UUIDs for both inputs. (sdk/python/failproofai_sdk/_scopes.py:97)
`CollectorConfig::is_enabled()` gated the whole collector on
`sessions || hooks`, and `collector_tasks()` returns early when it is
false. On a machine with a credential and both capture sources off, the
daemon therefore started no spool watcher and no sweeper, logged nothing,
and every batch `failproofai-sdk` wrote into `custom-agents/events/` sat
on disk forever — no error on either side, and an unread spool is
indistinguishable from an idle one.
Those two settings gate the daemon's own capture sources, and each is
checked again where its source is registered, so leaving them off still
starts neither. What they must not gate is delivery: the spool also
carries events the user's own instrumented agents produced.
`is_enabled()` is now `ingest.is_some()`. An unconfigured machine still
starts no thread and no runtime.
Verified live against a daemon on an isolated FAILPROOFAI_HOME with
`{"sessions":false,"hooks":false}`: before, silence; after,
`collector started tasks=3` and a pre-existing batch delivered by the
sweeper.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`instrument()` with no argument instruments every framework already in `sys.modules`. Called above the `import langchain` line — the natural place for a setup call — it finds nothing, installs nothing, returns `()` and raises nothing. The process then runs with the SDK imported, the adapter apparently installed, and zero events emitted. The message naming the exact fix already existed, at `logger.debug`, which no default logging config shows. So the one mistake that costs a user all of their telemetry was the one mistake we said nothing about. Now `logger.warning`, and only on the path where somebody explicitly asked for instrumentation and got none. The regression test empties the registry for its duration rather than trusting that no earlier test imported a real framework: tests/integrations/ runs first and imports all four, which would otherwise make this test install them for real and leak `_ACTIVE` into every test after it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SKILL.md's durability section opened by calling SIGTERM "not exotic — it is every rolling deploy", every `docker stop`, every Kubernetes eviction, and then told the reader "Python's default handler exits, so `atexit` *does* run". CPython installs no handler for SIGTERM. `signal.getsignal(SIGTERM)` is `SIG_DFL`, the OS terminates the process where it stands, and the atexit flush never runs. Measured: a child that queues 20 events and sends itself SIGTERM writes zero of them. The readers most likely to act on that paragraph are the ones deploying into a container, i.e. exactly the population it reassured wrongly. The text now states the real behaviour and ships the handler that fixes it — flush_now() then sys.exit(128 + signum), which unwinds so an open agent() scope still emits its agent_end before the flush. Two subprocess tests execute both halves. The bare case asserts events are still lost, so if the SDK ever installs its own handler the recipe is flagged as obsolete instead of quietly standing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ingest splits `environment` on commas to build its filter facets, so it
skips any line containing one — the whole line, not the field — and
answers 200 with {"accepted":0,"skipped":N}. The daemon then deletes the
batch it delivered. The result: no exception in the agent, nothing in its
output, and a dashboard session list that looks exactly like an agent
nobody ran.
Measured against the running stack: AGENTEYE_ENVIRONMENT="prod,eu"
produced accepted:0, skipped:1.
`failproofaid` has always refused a comma in `collector.environment` for
this exact reason. The SDK writes the same field into every event and
never checked.
configure(environment=...) now raises, naming the fix. The env var warns
and falls back to "dev" instead: it is read lazily inside to_dict() on
whatever event is next, so raising there would take the caller's agent
down from a line of telemetry. Landing under a visibly wrong environment
beats vanishing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…vents Found by driving real crews through a live gateway and reading the rows back out of the events store, not by inspection. Each fix was reverted individually against its new test to prove the test fails without it. 1. Hierarchical delegation was flattened. `_tool_start` emitted `tool_use` but never noted the tool as a node, and CrewAI parents a delegated coworker's whole AgentExecutionStartedEvent on the `delegate_work_to_coworker` TOOL event — so `_parent_key` missed it and fell back to `_roots[-1]`. Manager and both coworkers came out as siblings of each other under the crew. They now nest: coworker -> manager -> crew. 2. `FlowFailedEvent` was not in TABLE. A Flow whose method raises emits it and never emits `FlowFinishedEvent`, so the flow's `agent_start` was never closed and the session read `ongoing` forever — in a long-lived process, permanently. 3. A Crew kicked off inside a Flow method became a SECOND session. `_hook_start` did not note the flow-method span, so `on_crew_started` read "no parent" and minted a new root. One logical run, two unlinked sessions, no parent_id on either. 4. Cross-session leak through the process-global `_roots[-1]` fallback. With two crews open, any event whose parent span was gone — closed, or evicted at `_MAX_NODES` — landed in the OTHER run. Reproduced: an orphan tool emitted from crew Alpha's thread was recorded against crew Bravo. Root selection now matches the ambient session. 5. `Task(human_input=True)` recorded nothing at all. CrewAI has two HITL surfaces and only the Flow `@human_feedback` one is on the event bus; `SyncHumanInputProvider._prompt_input` calls `input()` and emits no event of any kind, so the entire human wait was billed as active agent time. Now the full human_wait/human_input/agent_pause/ agent_resume quartet: a real 38s wait measures as 37878ms paused inside a ~43s agent span. This is the adapter's only patch — narrow seam, staticmethod descriptor restored on uninstrument with an identity check, double-patch guarded, exceptions re-raised verbatim. 6. `Agent.kickoff()` (LiteAgent) had no agent span at all — the three LiteAgentExecution events were unmapped. With no ambient session it recorded ZERO rows; with one, everything landed under `agent_id=main`. Also corrects a stale docstring: `_parent_key` claimed async_execution tasks arrive with `parent_event_id=None` because a ThreadPoolExecutor drops contextvars. Measured against 1.15.16 — false; only the two root events have a null parent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by driving real LangChain runs through a live gateway and reading the rows back out of the events store. Each fix is covered by a test that fails when its hunk is reverted, and each has a counterweight test that fails when the fix is pushed too far. 1. Concurrent roots under one session id were mistaken for a HITL resume, and events were DROPPED. `_start_root` reused an existing session's agent whenever that agent was still open — which is also true of two roots that merely overlap in time: `.batch()` (langchain-core opens one root run per input), a top-level `RunnableParallel`, or two web requests carrying one conversation id, i.e. the documented `failproofai_sdk_session_id` stitching key. The second root got no `agent_start`, its work was relabelled with the first root's `agent_id`, the first root to finish closed the shared agent, and every later event from the other root resolved to nothing and was dropped — a real model call, with its tokens and its latency, gone behind one WARNING line. `.batch()` of three recorded 8 rows and one agent pair; it now records 12 and three. The test is the whole fix in miniature: a `threading.Barrier` forces both roots open at once, because without it the race passes against the bug about half the time. `open_pauses` is the discriminator. A genuinely paused run always has one — `_end_root` skips `agent_end` exactly when it is non-empty, and `_suspend` is the only thing that fills it — so it separates the two cases precisely. 2. A root run that is itself a leaf double-reported its failure. `_on_end` returned before setting `session.reported_error`, so a failing top-level `tool.invoke()`/`llm.invoke()` emitted `tool_result.error` AND a standalone `error`: one failure counted twice, while the same failure one Runnable deeper counted once. 3. `uninstrument()` did not stop recording when the trace env var was exported before `instrument()`. A configure hook cannot be deregistered, so teardown means "make the hook produce nothing" — but clearing the ContextVar only reaches contexts derived from the caller's, and the env var is deliberately left alone when the process set it. Either hole leaves `_configure` building live tracers: a full run was recorded after teardown. Now a `_State.enabled` kill switch, checked at the two entry points that gate everything else. 4. `tool_result.output` was a Python repr, and a quietly-failed tool had no error at all. A tool handed the LLM's `ToolCall` dict — what `bind_tools` produces and what every modern tool loop does — returns a `ToolMessage`, which rendered as `ToolMessage(content='37000000', name=…)` instead of `37000000`. And `ToolMessage.status == "error"` leaves `run.error` empty, so a tool whose exception the framework converted into a message for the model had NO representation: `is_error` 0, a green span, and the exception text sitting in a field nobody filters on. 5. Every Errors-surface row read `ValueError: ValueError: …`. `error` is the one event carrying `error_type` as its own field and the server composes `summary` as "<error_type>: <message>", but the adapter passed `_error_text`, which prefixes the type. The other three adapters pass a bare `str(exc)`; this makes the fourth agree. `agent_end.summary` keeps the prefixed form — it has no other column to say it in. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`_core._truncate` and `_size` dispatched on the concrete `dict`, `list`
and `tuple`. That missed every mapping a framework actually hands us
which is not literally a dict — `MappingProxyType`, which is what
`model_json_schema()` and any frozen config returns, `ChainMap`, and any
third-party mapping type — and those fell through to the branch at the
bottom that renders an object with no JSON shape via `repr`.
The result is in the events store. A crewai `model_request` carries
tools[0].function.parameters.properties.from_unit
= "{'title': 'From Unit', 'type': 'string'}"
a JSON string holding a Python repr. `JSONExtract` over it returns
nothing, so the field is unqueryable rather than merely ugly — and a
tool's declared schema is exactly what you go to a model_request to read.
Both functions now dispatch on `collections.abc.Mapping` and
`Sequence`/`Set`. `str` and `bytes` are handled before either check, so
a string cannot be exploded into a list of characters, and an object
that is neither a mapping nor a sequence still reprs — both pinned by a
counterweight test, since widening the check could otherwise leave the
repr branch dead.
`_size` moves with it: a size computed off `repr` for a value
`_truncate` will expand into JSON budgets the wrong number, and the
budget decides which fields survive.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four behaviours changed on this branch and the docs still described the old ones. Each of these is the page a reader lands on when the thing goes wrong, so a stale answer there costs more than elsewhere. - python-sdk reference, `environment`: says it must not contain a comma and why. Ingest splits the field on commas for its facets and skips every event whose label has one, so the run vanishes with no error. `configure()` now refuses it, and the page says so at the row a reader is looking at when they choose a label. - python-sdk reference, shutdown: "hard process termination can lose events" was true and useless — it did not say that SIGTERM is one, and SIGTERM is the one you meet, on every rolling deploy and `docker stop`. Now names it, explains that CPython runs no handler so `atexit` never fires, and ships the handler that fixes it. - how-it-works, auto-detection: calling `instrument()` above the framework import records nothing at all, which is the single most expensive ordering mistake available and was documented nowhere. The page now says where to put the call and that a warning is logged. - crewai: the HITL section described one surface; CrewAI has two, and `Task(human_input=True)` — which emits no event at all and is covered by wrapping CrewAI's input provider — is the more common one. The event table also gains `Agent.kickoff()` and states the nesting rules the adapter now produces: a crew inside a flow method nests under it, and a delegated coworker nests under its manager. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by driving real LlamaIndex workflows through a live gateway and reading the rows back out of the events store. Each fix has a test that fails when its hunk is reverted. 1. AgentWorkflow handoffs were flattened into one agent. AgentWorkflow does not run its agents as nested workflows, so attribution from the span tree alone collapsed a two-agent crew into a single `agent_id="AgentWorkflow"` — 382 events under one label in the audited run. The real names existed only in the payload extra `fw_agent_name`, which is not a groupable column, so the delegation structure was unreadable on every dashboard surface. Each distinct `current_agent_name` now opens a nested agent under the workflow. The name is sticky because a `ToolCall` step carries none, so a `call_tool` keeps the agent that asked for it; a `name == root.agent_id` guard stops a standalone FunctionAgent nesting inside itself, and an A->B->A round trip opens the first agent again as a second, correctly closed turn. 2. A user-cancelled run was reported `outcome="success"`. `cancel_run()` does not drop the span — the runtime catches its own `WorkflowCancelledByUser` and exits the span cleanly with `result=None`. Rather than infer cancellation from a null result, the adapter now reads the framework's own `SpanCancelledEvent`, dispatched with the exact span id immediately before that exit. The run closes `cancelled` with no `error` event, because a stop button is not a failure, and the in-flight step flips from `success` to `cancelled` with it. That event is deliberately outside `_HANDLED_EVENTS`, since the drift test walks only `llama_index.core.instrumentation.events.*` — so it ships with a drift guard of its own, which fails if the class is renamed or moves, or if `span_id` leaves its fields. 3. A failed `agent_end` carried no `summary`. `summary` is a promoted column and the only place a run's outcome is read; the reason lived only on the failing step's `hook_completed` payload, and vanished entirely under `steps=False`. Now carried on both the exception and the timeout paths, matching the LangChain adapter. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`normalize_agent_id` exists because `agent_id` is a
`LowCardinality(String)` and the primary facet on every dashboard
surface, so a per-run value in it degrades the column and fills the
filter dropdown with one entry per run.
It only caught a value that was an id ALL THE WAY THROUGH. `agent-<uuid>`,
`crew_<uuid>`, `task-3f9a1c2b-…` — a readable name carrying a per-run
suffix — went straight through. That is the shape frameworks actually
produce, and it is the exact one the CrewAI page already warns about
("a role containing a UUID, timestamp, or per-run suffix"), so the guard
was missing by far the more common route to the thing it prevents.
The id portion is now stripped and the readable part kept: `agent-<uuid>`
becomes `agent`, not `main` — collapsing it would discard the only
meaningful token in the label. Dashed UUIDs are matched as a substring
before the segment pass, or splitting on separators would break the most
standard shape of all into five pieces that are individually innocent.
A value with nothing left after stripping falls back to the default,
which is what the caller wanted for a bare id anyway; a name where
nothing was stripped is returned unchanged, separators included, so this
cannot quietly rename every `node_a_b` in a process to `node a b`.
Counterweight cases cover `agent-v2`, `step-3`, `node_a1b2` and
`deadbeef`, which must all survive untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- llamaindex: the event table and the naming section described a world where an `AgentWorkflow` was one agent. It is now one span per agent that takes a turn, parented to the workflow, so a handoff reads as two agents; a handoff back opens a second turn rather than reopening the first. The table also gains the cancel row — `cancel_run()` closes `cancelled` with no `error`, because a stop button is not a failure — and says that a failed `agent_end` now names what killed the run. - how-it-works: "Keep `agent_id` low cardinality" was an instruction with no explanation and no statement of what the SDK does about it. It now says why (it is a `LowCardinality` column and the primary facet), what adapters strip on your behalf, and — the part that actually matters to a reader — that the guard applies to labels the FRAMEWORK chose, not to an `agent_id` you pass yourself, which is taken exactly as given. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`collector.redact` scrubs credential-shaped strings — `sk-…`, `ghp_…` — and it is applied in `SpoolWriter::push`, where the daemon writes the events it captures itself. Batches the Python SDK writes go into the same spool directory without passing through that writer, so the daemon ships them byte for byte. Verified against the running stack: a `tool_use` whose `input.command` held `Authorization: Bearer sk-…`, and a `tool_result` holding a `ghp_…`, both arrived in the events store intact — while the daemon's own captures of the same strings are scrubbed by default. Nothing claimed otherwise, which is the problem: the asymmetry is invisible, two events on one delivery path are treated differently by who wrote them, and `redact` sits under `collector` where it reads like a machine-wide policy. A reader who sets it and assumes coverage is wrong and has no way to find out. The behaviour is deliberate — rewriting an SDK payload in transit would mean the events you receive are not the events you emitted — so this documents it and points at the two controls that do work: `capture_content=False`, and not passing the secret to `input=`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The health file answers "is each source producing events" and cannot
answer "is anything arriving". A source's job ends when it writes a batch
into the spool — the POST, the server's verdict and the parking of what
would not go all happen after that — and the SDK's batches have no source
entry at all, because `failproofai-sdk` writes them into the spool from
the user's own process.
So a machine shipping nothing but SDK events wrote a file with an empty,
perfectly healthy-looking `sources` map whether ingest was storing every
event or discarding all of them.
That is not hypothetical. Ingest answers `200` with
`{"accepted":N,"skipped":M}` and the daemon deletes the batch either way,
so one systematically malformed field discards every event on the machine
while every layer reports success. This audit found two such fields. The
only trace was an ERROR line in the daemon's log — journald on a real
install, which nobody reads until they already suspect a problem.
`collector-health.json` gains a `delivery` section carrying the counters
the `Uploader` already kept: accepted, skipped, batches fully skipped,
and the timestamp of the last upload the server accepted. Verified live —
a batch whose `environment` held a comma moved the file to
`skipped: 2, batches_fully_skipped: 1`.
The section is omitted, not zeroed, when there is no uploader: all-zero
counters and "this daemon has no credential" are different facts and must
not render the same. The counters are read through to the `Uploader`
rather than copied at attach time, since it outlives any supervised task
restart — a snapshot would freeze the file at "nothing has happened yet",
which reads exactly like a healthy idle machine.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both example headers taught that a crewai task shows up as its own `hook_triggered`/`hook_completed` pair, `research_crew.py` at length: "task boundaries as hook pairs — crewai tasks are hooks, not nested agents, deliberately". They are not hooks either. The adapter emits nothing for a task on purpose, which `crewai.mdx` states correctly: a task IS the agent execution that runs it, so recording both would double every row and render them as siblings. The task's identity rides along on that agent's events as `fw_task_id` / `fw_task_name`. Checked against the rows rather than the code: across three real crew sessions the only `hook_triggered` is `length_guardrail` — a guardrail — while every one of those sessions carries `fw_task_name` on the agent's events. Re-ran both examples afterwards; neither produces a single hook event, and `research_crew.py` shows exactly the two `agent_id`s its header promises. These are the files a reader copies, so a false claim here is one they carry into their own instrumentation and then cannot find in the dashboard. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found by driving real Pydantic AI runs through a live gateway across all seven ways of driving an agent, and reading the rows back out of the events store. Each fix was reverted individually against its new test. 1. A cancelled leaf closed AFTER the agent it belongs to, and sometimes not at all. `wrap_run` returns before `wrap_tool_execute` / `wrap_model_request` do on the cancellation path: the graph awaits a gather of tool tasks, so the run body unwinds the moment that future is cancelled while each tool task's `CancelledError` lands a loop iteration later. Measured: `agent_end` at .565709 with the matching `tool_result` at .566689. The dashboard closes the agent span at `agent_end`, so anything after it is attributed to nothing — this adapter's own comments say so three times, and a sibling test already asserts that ordering for the model path. In some interleavings the ambient identity was gone by then and the late leaf was dropped outright, leaving a `tool_use` with no `tool_result` at all. Still-open leaves are now closed before `agent_end`, marked `fw_incomplete`, and the real handler becomes a no-op when it finally unwinds. 2. `uninstrument()` during a live run emitted two `agent_end`s for one `agent_start` — `cancelled` from teardown, then `success` from the run five seconds later, with the `tool_result` stranded between them. Whichever closes first now wins. 3. `tool_result.output` was a Python repr of an envelope. A tool returning `ToolReturn` recorded the whole repr, burying the answer next to `metadata` the model is documented never to see; pydantic models and dataclasses recorded as `Weather(city='Faro', celsius=21)`. Unwrapped via the objects' own `model_dump` / `dataclasses.asdict` — deliberately NOT by importing `pydantic_core`, which would put a third-party import in a package whose zero-dependency promise is enforced by a test and a `--no-deps` CI install. 4. A streamed `model_response.duration_ms` is the CONSUMER's time, and said nothing about it. On identical calls (23 in / 7 out both times): 2556ms with no consumer delay, 4059ms with 1.5s of sleep per delta — 1503ms of UI time inside the model's latency. The handler only returns when the caller leaves `async with agent.run_stream(...)`, and no earlier hook is overridable without switching `agent.run()` into streaming mode. The number cannot be made honest, only identifiable, so `fw_streaming` now rides on the response as well as the request — the request carries no `duration_ms` to exclude. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Neither is a Mapping or a Sequence, so both fell to the branch that renders an object with no JSON shape. A tool's argument model, its structured return, a settings object on a model request — every one of them reached the events store looking like `Weather(city='Faro', celsius=21)`: a Python repr inside a JSON string, which `JSONExtract` cannot read and the dashboard cannot filter on. Every framework hands us these, and the adapters had started solving it one at a time — the pydantic_ai adapter unwraps `ToolReturn` and its models in the commit before this one. Doing it once here means an adapter that has not thought about it still records something readable. The unwrap is deliberately SHALLOW. `dataclasses.asdict` and `model_dump` both recurse and both copy, so on a large object they duplicate the whole tree before `_truncate` gets to decide it only wanted the first 8 KB. Reading the top level and handing it back lets the existing walk apply the field limit, the item cap and the depth cap on the way down, exactly as it does for a dict. Guarded, because all of this runs the caller's own code — a validator, a property behind `getattr`. Anything that raises falls through to `repr`, which is what happened before this existed, so the worst case is the old behaviour rather than an exception in someone's agent loop. `model_dump` and not `dict`: pydantic v2 names it distinctively, while half the objects in a typical process have some attribute called `dict`. And a CLASS is excluded explicitly — `dataclasses.is_dataclass` is true of the class as well as its instances, and `fields()` on the class would render a type as though it were data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…oval
Found by driving real LangGraph runs — StateGraph, subgraphs two levels
deep, ReAct loops, interrupts across two processes — through a live
gateway and reading the rows back out of the events store. Every fix has
a test that fails when its hunk is reverted, and the two that could be
pushed too far have counterweights that fail when they are.
1-3. `_node_of` claimed runs that were not the node. It matched on
`run.name == metadata["langgraph_node"]`, and BOTH sides of that are
strings the user chooses. Three distinct silent failures, one cause:
- `add_node("lookup_population", ToolNode([...]))` recorded NO
`tool_use` or `tool_result` at all. The arguments, the result and
the LLM's own `tool_call_id` were dropped, and two hook pairs
appeared where the tool should have been. Naming a node after the
tool it runs is the obvious thing to do.
- `add_node("ChatOpenAI", ...)` recorded no `model_request` or
`model_response` — model name, both token counts and latency gone.
- An inner runnable whose `run_name` matched the node key, or
`sub.compile(name="child")` under `add_node("child", sub)`, emitted
TWO hook pairs per visit: node counts doubled, apparent latency
halved.
A node's own run must now also be a non-leaf `run_type` and carry no
`seq:step:` tag. Verified against 1.2.11: whatever you hand
`add_node`, the node's own run is a `chain` tagged `graph:step:N`, and
the thing you handed it runs beneath tagged `seq:step:N`. Both
conditions are exclusions, so a tag-convention change upstream
degrades to duplicate spans rather than to none — `_node_of` gates
`hook_triggered` and `_ensure_subgraph_agent` both.
4. A run that merely OVERLAPPED a pause fabricated the human's approval.
`_start_root` read "this session has an open pause and its agent is
still open" as a resume — a window that lasts as long as the human
takes. Any other run carrying that session id inside it (a second
request on one conversation id, a background summariser, a different
graph) got no `agent_start`, had its nodes folded into the paused
span, and emitted `agent_resume` + `human_input` with an EMPTY
response, closing the pause and reporting success. The dashboard then
shows an approval that no human gave.
A resume must now also look like one: LangGraph continues an
interrupted thread only via `Command(...)` or `None`, both shaped
unlike fresh state.
5. A cross-process resume never closed the pause — which is the real
deployment shape. Two processes against one checkpointer: the first
emitted `human_wait` + `agent_pause`, the second emitted nothing, so
every cross-process approval left its session reporting "still waiting
on a human" forever and `pausedMs` never closed. The fix rests on
three facts verified against the framework rather than assumed:
`Interrupt.id` is `xxh3_128(checkpoint_ns)` and the interrupted task's
namespace is byte-identical across the two invocations, so the second
process reconstructs the id with no shared state; `on_resume` fires
once per Pregel level, deepest last, which is what excludes a subgraph
host; and only a level's first superstep re-runs interrupted tasks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…framework in the no-op warning
Three things the LangGraph pass surfaced that belong outside the adapter.
- The langchain page now says a node's own run is identified by its
SHAPE, not its name, so `add_node("lookup_population", ToolNode(...))`
records the tool. That naming used to make the tool's events vanish,
and it is the obvious thing to type, so the page should say plainly
that it is safe.
- Streamed token counts need `ChatOpenAI(stream_usage=True)`. OpenAI only
sends usage on a streamed response when asked, so without it
`model_response` carries no tokens — the adapter records what the
framework gives it, and there is nothing to record. Measured both ways:
NULL tokens without the flag, 13/13 with it. Users were reading that
absence as a bug in the adapter.
- `Command(resume=...)` is noted as correlating on the `Interrupt.id`
including across processes, which is the deployment shape and the one
the fix in the previous commit was about.
Also: the "nothing was instrumented" warning suggested
`instrument('crewai')` regardless of what was installed. It now lists
every name the call would have accepted — a reader not using CrewAI had
to work out for themselves whether that line was a suggestion or a
diagnosis. Its test moves from emptying the registry to pointing
detection at an unimportable module, so the list of valid names is real
and asserted rather than rendered as nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keeps this branch stacked on its base after #702 took main's changes. `main` is now an ancestor transitively, so #730 stays mergeable into #702 and #702 into main. One conflict, `docs/docs.json`: main restructured the navigation with an extra nesting level per language (#725), in the same "Start here" group this branch added the integration pages to. Resolved by taking main's structure — the rest of the file already follows it — and putting the `Plug in your agents` group back inside it. All 62 pages the English navigation references were then checked to exist on disk, which is what the docs job validates and what the nav-pruning fix in #725 was about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`disabling_collection_stops_it_and_re_enabling_starts_it_again` exists for `--disconnect`: a machine that has left its organisation must stop shipping without a restart. It simulated that by flipping `collector.hooks` to false, which is not what `--disconnect` does — that clears the ingest credential (`clearIngestCredential` in cloud-enrollment-cli.ts) — and which no longer disables anything, because `hooks` gates the daemon's own capture source and deliberately does not gate delivery of the batches the SDK writes. So the test now removes and restores the credential. That is the real lever for the scenario it was written about, and a stronger assertion than the proxy it replaces. A companion pins what replaced the old behaviour: with both capture sources off the daemon still starts the spool watcher — without which it is a process that reports healthy and delivers nothing — and still starts no hook-activity source, so `hooks = false` keeps meaning what an operator sets it for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ci.yml` triggers on `pull_request` into `main`. #730 targets `feat/fp-cli`, so thirty commits of SDK work ran no unit tests, no build, no lint and no docs check — the only status it produced was the daemon cross-compile, and only because it touched `crates/`. A pull request that cannot go red is not a reviewed pull request. Turning it on immediately found what it had been missing: the `fp-cli` and `failproofai-sdk` jobs declare no `timeout-minutes`, which main made mandatory in #726 and asserts in `release-pipeline.test.ts`. Those two jobs predate the rule and had never been run against it, so #702 would have gone red the moment it merged into main. Both are bounded now, at 10 minutes — a `uv sync` plus pytest, across two interpreters and five. Also rewraps the daemon-skew warning in `fp-reset.ts`. "denies every tool call" is the consequence that message exists to state, and it was split across two hand-wrapped lines, so the test asserting the phrase failed against that branch of the message while the text read perfectly to a human. The other two branches of the same warning keep the phrase whole; this one now matches them. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
hermes-exosphere
left a comment
There was a problem hiding this comment.
Hermes found blocking issues that should be addressed.
Review coverage was incomplete, but the concrete blocking findings below are sufficient to request changes.
High: Explicit Pydantic AI capabilities are inert without instrument()
- Rule:
COR-001 - Location:
sdk/python/failproofai_sdk/integrations/pydantic_ai.py:605 - Evidence: The documented standalone API constructs
capabilities=[FailproofAI()]atpydantic_ai.py:559, butwrap_runreturns directly to the framework whenever module-global_enabledis false (:605-606)._enabledis initialized false and is set only by_Adapter.install()(:913-926), which is reached throughinstrument(). The model and tool wrappers have the same gate. The existing explicit-capability test uses theinstrumentedfixture, so it exercises only the enabled state. - Required change: Make an explicitly constructed
FailproofAIcapability active independently of registry installation, while retaining a separate disable mechanism for auto-injected capabilities after uninstrument(). Add a test that runs an agent with onlycapabilities=[FailproofAI()]and asserts the emitted events.
1 advisory finding
- Medium/High Falsy supplied session IDs are silently replaced —
session._enter()choosesself._requested oran inherited/generated ID (_scopes.py:97), andagent._enter()does the same (:197). Thus a supplied empty string or0is not validated as invalid identity; it is replaced by an unrelated generated session, or by an enclosing session when one exists. A containerized reproduction showedsession("")andagent(session_id=0)both entering with generated UUIDs. (sdk/python/failproofai_sdk/_scopes.py:97)
Merging main and then #730 into this branch left `1.0.1-beta.2` with two `### Docs` headings — the union kept both sides' sections rather than folding them together. Entries are unchanged and all 758 are still there; they now sit under one heading, in the order they were already in. Checked against the base rather than by eye: main carries 8 pre-existing duplicate subsections in older versions, this branch had 9, and it is back to 8 — so the only one removed is the one this PR introduced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What
The Python SDK was a capture surface you had to operate by hand: 15 keyword-only
emit methods, every one requiring
session_id=andagent_id=, and nothingpropagating them. There was no ambient session anywhere in the package — no
decorator, no context manager, no contextvars.
skill/SKILL.mdstated that as adeliberate contract, and
skill/references/integration.mdshipped a ~60-linecontextvars wrapper as markdown for customers to paste into their own
codebase: the SDK asking its users to write the missing half of the SDK.
So every customer on LangGraph, CrewAI or LlamaIndex hand-wrote the same
adapter, got thread propagation wrong, and abandoned it halfway — the exact
failure that reference doc opens by describing.
Now, with the call site unchanged:
Ported from FailproofAI/agenteye#503,
reconciled against the eleven SDK bugs fixed on
feat/fp-clisince that branchwas cut. Ported rather than copied: #503's
_writer.pyis 80 lines againstour ~450, so a straight copy would have silently reverted every one of them.
Why this is not the same as emitting events by hand
Measured, not asserted. The same task, same model, same tool, instrumented two
ways:
instrument()(1 line)The manual version reported one
model_request/model_responsepair. Theagent made two LLM calls. It recorded zero tool events for a run whose
entire point was calling a tool.
That is not carelessness — it is the ceiling of the approach.
graph.invoke()is one call seen from outside; the ReAct loop, the tool dispatch, the second
round-trip and the per-node timings all happen inside it. You cannot instrument
what you cannot see, and you can only emit what you remember to emit.
What is here
Ambient identity (
_context.py,_scopes.py,_runtime.py) —session(),agent()andtool_call()under bothwithandasync with,current(), andpropagate()for thread hand-off.session_id/agent_idbecame optional onall 15 methods, falling back to context, without breaking a single existing
call — which is why the golden wire-format bytes are untouched.
Four adapters — LangChain/LangGraph, CrewAI, LlamaIndex, Pydantic AI. Each
is a translation table over one shared
RunTracker, emitting only the existing15 event types, so nothing fans out to the server, collector, CLI or the stored
schema.
AutoGen is deliberately absent:
autogen-core0.7.5 last shipped 2025-09-30with no commits since, and Microsoft's forward path is a separate package. The
live product is AG2, a different distribution whose middleware has no global
auto-instrument hook.
request_idon the two model events. The dashboard pairs model events onit, but no SDK method accepted one and no doc mentioned it — so every
integration written to our own documentation emitted unpairable model events,
including
demo-agent/mock_agent.py.Three decisions where #503 was right and the current branch was wrong
feat/fp-clihadjust tightened
_pendingkeys tokind:session:agent:idto stop twoconcurrent sessions colliding. That over-tightened: once a framework runs
tools inside sub-agents, a
tool_useopened underplannerand closed underworkeris the ORDINARY case, and an agent-scoped key makes those pairs missentirely — silently dropping
duration_msfor exactly the nested runs thatmost need it. The rule that survives both: include what makes the id unique
(kind, session), exclude what can legitimately change between the two events
(the agent).
TypeError, notValueError, for missing or mistyped identity. It iswhat a caller got before identity became optional, so code catching one keeps
working.
**fieldisa fault in the call itself and reads the same from anywhere; the identity
error depends on where the call was made from.
One decision where the current branch was right and #503 was not
#503 fixes the
_track_pendingKeyError— the same race found independentlyhere — with a
threading.Lock. That lock is kept out: a lock held at theinstant of a
fork()is inherited locked by a thread that does not exist in thechild, which is the exact hazard
_writerrebuilds itsEventand lock toavoid. The tolerant, lock-free eviction on this branch fixes the same crash with
no fork edge.
Zero dependencies survives, and the test got stronger
The adapters import the frameworks they adapt — there is no other way to
subclass a callback base class. But
integrations/__init__resolves them bystring through
importlib.import_moduleat call time, so:file under
integrations/is scanned like core code until it is named there;package and must have no framework in
sys.modules;[project.dependencies]is still empty, and CI still installs the built wheelwith
--no-deps.An eager adapter import is not a style problem — it makes
import failproofai_sdkraiseImportErroron every machine without that framework.Verified by planting one.
Framework extras carry upper bounds, because without one a clean build a year
from now pulls the next major, the callback API shifts, and the adapter stops
receiving events while raising nothing. There is deliberately no
[all].Verification
Not "the tests pass" — the couplings here fail silently, so each layer was
checked against something that could disagree.
/v1/events→ events storeThe live run used a real model. Captured and confirmed in the store:
All 15 event types were confirmed end to end, with promoted columns
populated (
tool_name,model,duration_ms, tokens), theframeworkfieldon every event, and
parent_idnesting intact. 18 batches uploaded, 0failed.
Beyond the happy path, per framework: a tool that raises is recorded on
tool_result(4/4), the async path is captured (3/3 where async applies), andconcurrent sessions across threads stay isolated.
The four quickstarts in
examples/were executed against a real modelbefore being committed; parsing is not evidence that an example works.
Not checked
Streaming (
.astream), CrewAI flows, LlamaIndex workflows beyondFunctionAgent, provider retry/rate-limit paths, long-run memory behaviour, anduninstrument()round-trips under load.For the reviewer
FailproofAIis now public API that users type —capabilities=[FailproofAI()],FailproofAITracer,FailproofAICrewListener. Renamed from #503'sAgentEye*.Worth an explicit yes on the naming before it ships.
AGENTEYE_HOME,AGENTEYE_ENVIRONMENTand~/.agenteyeare untouched — theyare a contract with two separately-released daemons.
AGENTEYE_STRICTwas newin #503 and nothing else reads it, so it became
FAILPROOFAI_SDK_STRICT.#503 also carried two dashboard fixes (
executionGraph.ts,sessionSummary.ts).Those live in the AgentEye repo and are not in this PR; they need a
companion change there.
Hermes review
28044ff26c7a0a5cee5e93ae0a17d283cb9c5d8a1d8f31d926828f3bae215c58f5b35baa44acbff0gpt-5.6-terraSummary
Two correctness issues remain in the new SDK identity and Pydantic AI integration paths; the explicit Pydantic API is completely inert without auto-instrumentation.
Changes
Validation
Passeddocker run --rm -v /review/input/workspace:/work -w /work/sdk/python python:3.12-slim sh -lc 'python -m pip install -q -e ".[dev]" && pytest -q'— SDK test suite completed in an isolated Python 3.12 container. (25s)Passeddocker run --rm -v /review/input/workspace:/work -w /work/sdk/python python:3.12-slim sh -lc 'python -m pip install -q -e ".[dev,pydantic-ai]" && pytest -q tests/integrations/test_pydantic_ai.py'— Pydantic AI adapter suite completed in an isolated Python 3.12 container; it does not cover standalone explicit capability use. (25s)Findings
capabilities=[FailproofAI()]atpydantic_ai.py:559, butwrap_runreturns directly to the framework whenever module-global_enabledis false (:605-606)._enabledis initialized false and is set only by_Adapter.install()(:913-926), which is reached throughinstrument(). The model and tool wrappers have the same gate. The existing explicit-capability test uses theinstrumentedfixture, so it exercises only the enabled state. (sdk/python/failproofai_sdk/integrations/pydantic_ai.py:605)1 advisory finding
session._enter()choosesself._requested oran inherited/generated ID (_scopes.py:97), andagent._enter()does the same (:197). Thus a supplied empty string or0is not validated as invalid identity; it is replaced by an unrelated generated session, or by an enclosing session when one exists. A containerized reproduction showedsession("")andagent(session_id=0)both entering with generated UUIDs. (sdk/python/failproofai_sdk/_scopes.py:97)Open questions
None.
Policy overrides
None.