failproofai-sdk 0.0.1b1__py3-none-any.whl

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.
@@ -0,0 +1,131 @@
1
+ """Telemetry for AI agents: emit events, spool them, let the daemon ship them.
2
+
3
+ Three surfaces, in the order most people meet them:
4
+
5
+ * **Scopes** — `session()`, `agent()`, `tool_call()`. Context managers that bind
6
+ run identity and, for the latter two, bracket a run with its own events. Work
7
+ under `with` and `async with`.
8
+ * **Adapters** — `instrument()`. Auto-detects LangChain/LangGraph, CrewAI,
9
+ LlamaIndex and Pydantic AI in the process and wires them to the scopes above.
10
+ * **`event.*`** — the 15 event methods, for anything the adapters do not cover.
11
+
12
+ `session_id` and `agent_id` are optional on every event method: omitted, they
13
+ resolve from the enclosing scope. Nothing bound and nothing passed is an error,
14
+ never a silent drop — ingest skips an event with no session and answers 200.
15
+ """
16
+
17
+ from typing import Any
18
+
19
+ from failproofai_sdk._version import __version__
20
+ from failproofai_sdk._environment import set_environment
21
+ from failproofai_sdk._resolver import set_base_dir
22
+ from failproofai_sdk._context import Identity, current, propagate
23
+ from failproofai_sdk._runtime import event
24
+ from failproofai_sdk._scopes import agent, session, tool_call
25
+ from failproofai_sdk._environment import _reject_comma
26
+ from failproofai_sdk._writer import _validated_interval
27
+ from failproofai_sdk import _runtime
28
+
29
+ __all__ = [
30
+ "__version__",
31
+ "configure",
32
+ "event",
33
+ "session",
34
+ "agent",
35
+ "tool_call",
36
+ "current",
37
+ "Identity",
38
+ "propagate",
39
+ "instrument",
40
+ "uninstrument",
41
+ "_writer",
42
+ ]
43
+
44
+
45
+ def configure(
46
+ *,
47
+ base_dir=None,
48
+ flush_interval: float = 0.5,
49
+ environment: str | None = None,
50
+ ) -> None:
51
+ """Configure the SDK. Call once at startup before any event.* calls.
52
+
53
+ Args:
54
+ base_dir: Override the spool root. Pass None to resolve it to
55
+ ~/.failproofai/custom-agents (honouring $FAILPROOFAI_HOME,
56
+ which moves the umbrella but cannot take the spool outside
57
+ it — the `custom-agents` segment is always appended).
58
+
59
+ This is the ONLY way to spool anywhere else. No environment
60
+ variable redirects it; $AGENTEYE_HOME used to and no longer
61
+ does, because exporting it for the one component that still
62
+ reads it (the older `agenteye-collector`) moved this SDK's
63
+ spool as an unasked-for side effect.
64
+
65
+ The default moved here from ~/.agenteye. `failproofaid`
66
+ watches both roots, so on a host running it this only
67
+ changes which directory the files appear in, and batches
68
+ already spooled under the old root are still collected.
69
+ On a host running only `agenteye-collector`, give that
70
+ collector AGENTEYE_HOME=~/.failproofai/custom-agents so it
71
+ watches where this SDK writes — or pass base_dir here.
72
+ flush_interval: Seconds between flush cycles. Default 0.5 (500ms).
73
+ environment: Deployment environment label (e.g. "production", "staging").
74
+ Can also be set via the AGENTEYE_ENVIRONMENT env var.
75
+ Defaults to "dev" when neither is set.
76
+
77
+ Raises:
78
+ ValueError: if `flush_interval` is not a finite number greater than zero.
79
+ Checked here, before anything is applied, so a rejected call leaves
80
+ the SDK exactly as it was rather than with a new base_dir and the old
81
+ interval.
82
+ """
83
+ # BOTH validations before ANY application. `set_environment` raises on a
84
+ # comma, and it used to run last — so `configure(base_dir=..., environment=
85
+ # "prod,eu")` raised having already moved the spool and the flush interval,
86
+ # which is precisely the half-applied state the docstring above promises is
87
+ # impossible. A caller who wraps startup in `except ValueError` (a reasonable
88
+ # thing to do for a telemetry library that must not crash the agent) was left
89
+ # shipping from a directory they did not choose.
90
+ flush_interval = _validated_interval(flush_interval)
91
+ if environment:
92
+ _reject_comma(environment, "configure(environment=...)")
93
+ set_base_dir(base_dir)
94
+ _runtime.writer.set_flush_interval(flush_interval)
95
+ set_environment(environment)
96
+
97
+
98
+ def instrument(framework: str | None = None, **options: Any):
99
+ """Install the framework adapters.
100
+
101
+ With no argument, auto-detects the frameworks already imported in this
102
+ process. Pass a name (`"langchain"`, `"crewai"`, `"llama_index"`,
103
+ `"pydantic_ai"`) to install exactly one.
104
+
105
+ The import is inside the function on purpose: `failproofai_sdk.integrations`
106
+ reaches for framework packages, and `import failproofai_sdk` is
107
+ contractually zero-dependency — a promise `tests/test_zero_dependencies.py`
108
+ enforces both by scanning the core modules and by launching a fresh
109
+ interpreter to prove no framework lands in `sys.modules`.
110
+ """
111
+ from failproofai_sdk.integrations import instrument as _impl
112
+
113
+ return _impl(framework, **options)
114
+
115
+
116
+ def uninstrument(framework: str | None = None):
117
+ """Reverse `instrument()`, restoring the original attributes.
118
+
119
+ Lazy-imported for the same reason as `instrument()`.
120
+ """
121
+ from failproofai_sdk.integrations import uninstrument as _impl
122
+
123
+ return _impl(framework)
124
+
125
+
126
+ # MUST be last: any `import failproofai_sdk.<sub>` binds the *module* onto this
127
+ # package as `failproofai_sdk._writer`. Rebinding it here to the instance is what
128
+ # keeps the published `failproofai_sdk._writer.flush_now()` recipe working — and
129
+ # is why a test reaching for the MODULE has to go through
130
+ # `sys.modules["failproofai_sdk._writer"]`.
131
+ _writer = _runtime.writer
@@ -0,0 +1,218 @@
1
+ """Ambient run identity, carried on contextvars.
2
+
3
+ Before this module the SDK had no ambient session: every `event.*` call took
4
+ `session_id` and `agent_id` as required keyword arguments and nothing propagated
5
+ them. Threading both through every function that might emit an event is what makes
6
+ instrumentation sprawl into a diff nobody wants to review.
7
+
8
+ Two contextvars carry it instead. Read them through `current()`, or let
9
+ `failproofai_sdk._events` fall back to them when a caller omits the identity.
10
+
11
+ Why a tuple and not a list
12
+ --------------------------
13
+ `_AGENT_STACK` holds a **tuple**. A `ContextVar[list]` is shared *by reference*
14
+ across tasks and threads, so `.append()` in one task mutates the value every other
15
+ task sees — which is exactly the cross-run event mixing contextvars are here to
16
+ prevent, wearing a contextvars costume. It passes every single-threaded test.
17
+ Push is `set(stack + (aid,))`; pop is `reset(token)`.
18
+
19
+ There is deliberately no `_AGENT_ID` var: the top of the stack *is* the current
20
+ agent id, so the two cannot drift apart, and `parent_id` is `stack[-2]`.
21
+ """
22
+
23
+ import contextvars
24
+ import functools
25
+ import logging
26
+ from dataclasses import dataclass
27
+ from typing import Any, Callable
28
+
29
+ logger = logging.getLogger(__name__)
30
+
31
+ # The agent_id used when events are emitted with a session bound but no agent
32
+ # scope. "main" is the convention the skill and the reference integration already
33
+ # teach, so an un-scoped event lands somewhere sensible rather than raising.
34
+ DEFAULT_AGENT_ID = "main"
35
+
36
+ _SESSION_ID: contextvars.ContextVar[str | None] = contextvars.ContextVar(
37
+ "failproofai_sdk_session_id", default=None
38
+ )
39
+ _AGENT_STACK: contextvars.ContextVar[tuple[str, ...]] = contextvars.ContextVar(
40
+ "failproofai_sdk_agent_stack", default=()
41
+ )
42
+
43
+
44
+ @dataclass(frozen=True, slots=True)
45
+ class Identity:
46
+ """The run identity in scope. Never None — check `session_id is None` instead."""
47
+
48
+ session_id: str | None
49
+ agent_id: str | None
50
+ parent_id: str | None
51
+ depth: int
52
+
53
+
54
+ def current() -> Identity:
55
+ """The identity bound to the current context.
56
+
57
+ `failproofai_sdk.current().session_id is None` means nothing is bound — either no
58
+ scope was entered, or this is a fresh thread that did not inherit one (see
59
+ `propagate`).
60
+ """
61
+ stack = _AGENT_STACK.get()
62
+ return Identity(
63
+ session_id=_SESSION_ID.get(),
64
+ agent_id=stack[-1] if stack else None,
65
+ parent_id=stack[-2] if len(stack) >= 2 else None,
66
+ depth=len(stack),
67
+ )
68
+
69
+
70
+ def session_id() -> str | None:
71
+ """The bound session id, or None. Hot path — allocates no Identity."""
72
+ return _SESSION_ID.get()
73
+
74
+
75
+ def agent_id() -> str:
76
+ """The current agent id, falling back to DEFAULT_AGENT_ID."""
77
+ stack = _AGENT_STACK.get()
78
+ return stack[-1] if stack else DEFAULT_AGENT_ID
79
+
80
+
81
+ def parent_agent_id() -> str | None:
82
+ """The enclosing agent id, or None at depth 0 or 1."""
83
+ stack = _AGENT_STACK.get()
84
+ return stack[-2] if len(stack) >= 2 else None
85
+
86
+
87
+ def bind_session(sid: str) -> contextvars.Token:
88
+ return _SESSION_ID.set(sid)
89
+
90
+
91
+ def push_agent(aid: str) -> contextvars.Token:
92
+ return _AGENT_STACK.set(_AGENT_STACK.get() + (aid,))
93
+
94
+
95
+ def reset(token: contextvars.Token | None) -> bool:
96
+ """Restore a contextvar to its pre-`set` value, tolerating a cross-context token.
97
+
98
+ `ContextVar.reset()` raises `ValueError: Token was created in a different
99
+ Context` when the token was minted in another thread *or another asyncio task*.
100
+ That happens when a scope is entered in one task and exited in another — not
101
+ always the caller's bug, as this used to say: asyncio finalizes an abandoned
102
+ async generator from a task of its own, so an `agent()` scope around a
103
+ `yield` gets its `__aexit__` run somewhere the token is foreign through no
104
+ fault of the code that wrote it.
105
+
106
+ Returns True when the token-based restore succeeded. It is a `bool` rather
107
+ than `None` so the caller can repair the values directly instead of leaving
108
+ a closed span on someone's stack; swallowing the error was never enough on
109
+ its own.
110
+ """
111
+ if token is None:
112
+ return True
113
+ try:
114
+ token.var.reset(token)
115
+ return True
116
+ except ValueError:
117
+ # WARNING, once, not a debug line. The consequence is not cosmetic: the
118
+ # frame this scope pushed stays bound in whatever context it was set in,
119
+ # so identity is wrong for everything that follows there. The caller
120
+ # cannot discover that any other way — there is no exception, and the
121
+ # events look plausible.
122
+ #
123
+ # It cannot always be repaired, either. When the set and the reset happen
124
+ # in different asyncio TASKS — the async-generator case in `agent()`'s
125
+ # docstring — `ContextVar.set` from this task cannot reach the context
126
+ # the value is bound in, so the caller has to change the code. Saying so
127
+ # once is the most this layer can do.
128
+ global _warned_cross_context
129
+ if not _warned_cross_context:
130
+ _warned_cross_context = True
131
+ logger.warning(
132
+ "failproofai_sdk: a scope was entered in one context and exited in "
133
+ "another, so its identity could not be unwound and later events in "
134
+ "the entering context may be attributed to it. The usual cause is an "
135
+ "`agent()`/`session()` scope spanning a `yield` in an async generator; "
136
+ "pass session_id=/agent_id= explicitly there instead.",
137
+ exc_info=True,
138
+ )
139
+ return False
140
+
141
+
142
+ def discard_agent(agent_id: str) -> None:
143
+ """Remove ONE frame for `agent_id` from the current stack, innermost first.
144
+
145
+ The fallback for a token that cannot be reset. Removes a single occurrence
146
+ rather than every match, because the same `agent_id` may legitimately be on
147
+ the stack twice (a recursive agent), and dropping both would corrupt the
148
+ outer one to fix the inner.
149
+ """
150
+ stack = _AGENT_STACK.get()
151
+ for i in range(len(stack) - 1, -1, -1):
152
+ if stack[i] == agent_id:
153
+ _AGENT_STACK.set(stack[:i] + stack[i + 1 :])
154
+ return
155
+
156
+
157
+ def restore_session(session_id: str | None) -> None:
158
+ """Put the session id back to a value captured before the scope was entered."""
159
+ _SESSION_ID.set(session_id)
160
+
161
+
162
+ #: Deduplicates the cross-context warning above — it fires from a scope exit,
163
+ #: which in a streaming server is per request.
164
+ _warned_cross_context = False
165
+
166
+
167
+ Snapshot = tuple[str | None, tuple[str, ...]]
168
+
169
+
170
+ def snapshot() -> Snapshot:
171
+ """Capture the identity *values* currently bound."""
172
+ return (_SESSION_ID.get(), _AGENT_STACK.get())
173
+
174
+
175
+ def restore(snap: Snapshot) -> tuple[contextvars.Token, contextvars.Token]:
176
+ """Bind a snapshot into the calling context."""
177
+ sid, stack = snap
178
+ return (_SESSION_ID.set(sid), _AGENT_STACK.set(stack))
179
+
180
+
181
+ def discard(tokens: tuple[contextvars.Token, contextvars.Token] | None) -> None:
182
+ if tokens is None:
183
+ return
184
+ for token in reversed(tokens):
185
+ reset(token)
186
+
187
+
188
+ def propagate(fn: Callable[..., Any]) -> Callable[..., Any]:
189
+ """Wrap `fn` so it runs with the identity bound *right now*.
190
+
191
+ pool.submit(failproofai_sdk.propagate(work), x)
192
+ pool.map(failproofai_sdk.propagate(work), items)
193
+ threading.Thread(target=failproofai_sdk.propagate(work)).start()
194
+ loop.run_in_executor(None, failproofai_sdk.propagate(work), x)
195
+
196
+ contextvars propagate into asyncio tasks automatically but **not into new
197
+ threads** — a thread starts with an empty context, so without this every event
198
+ a worker emits is dropped (or, since this change, raises TypeError).
199
+
200
+ This deliberately snapshots *values* rather than doing
201
+ `functools.partial(contextvars.copy_context().run, fn)`. A `Context` object
202
+ cannot be entered by two threads at once (`RuntimeError: cannot enter context:
203
+ ... is already entered`), so the copy_context form crashes the caller's worker
204
+ on any reuse — `pool.map`, a retried submit. Mutations made inside `ctx.run`
205
+ also persist in that Context, so a reused one leaks the previous call's agent
206
+ stack into the next.
207
+ """
208
+ snap = snapshot()
209
+
210
+ @functools.wraps(fn)
211
+ def _failproofai_propagated(*args: Any, **kwargs: Any) -> Any:
212
+ tokens = restore(snap)
213
+ try:
214
+ return fn(*args, **kwargs)
215
+ finally:
216
+ discard(tokens)
217
+
218
+ return _failproofai_propagated
@@ -0,0 +1,78 @@
1
+ import logging
2
+
3
+ _DEFAULT_ENVIRONMENT = "dev"
4
+ _environment: str | None = None
5
+
6
+ #: Set once the comma warning below has been emitted. `get_environment()` runs
7
+ #: from `to_dict()`, i.e. once per event on the caller's own thread, and the
8
+ #: warning had no once-flag at all — so an `AGENTEYE_ENVIRONMENT` with a comma
9
+ #: put one WARNING line into the host application's log for every event emitted,
10
+ #: for the life of the process. At the SDK's documented ceiling that is a
11
+ #: logging-driven throughput collapse in a library whose first constraint is not
12
+ #: to disrupt the host agent. The comment two lines below already promised
13
+ #: "Warn once"; this is what makes that true.
14
+ _warned_comma = False
15
+
16
+ logger = logging.getLogger("failproofai_sdk")
17
+
18
+
19
+ def _reject_comma(env: str, source: str) -> None:
20
+ """A comma in `environment` makes ingest skip EVERY event carrying it.
21
+
22
+ The endpoint splits this field on commas to build its filter facets, so a
23
+ line whose `environment` contains one is discarded — the whole line, not the
24
+ field. It answers 200 with `{"accepted":0,"skipped":N}`, the daemon deletes
25
+ the delivered batch, and the run that produced it is simply never in the
26
+ dashboard: no exception here, nothing in the agent's output, and an empty
27
+ session list that looks exactly like an agent nobody ran.
28
+
29
+ `failproofaid` already refuses a comma in `collector.environment` for this
30
+ reason (`crates/fpai-collect/src/config.rs`). The SDK is the other writer of
31
+ the same field and did not, so `AGENTEYE_ENVIRONMENT="prod,eu"` — a wholly
32
+ reasonable thing to type — silently threw away everything the process
33
+ emitted.
34
+ """
35
+ if "," in env:
36
+ raise ValueError(
37
+ f"environment must not contain a comma (got {env!r} from {source}). "
38
+ "The ingest endpoint skips every event whose environment has one, so "
39
+ "this would silently discard all telemetry from this process. Use a "
40
+ "single label, e.g. 'prod-eu'."
41
+ )
42
+
43
+
44
+ def get_environment() -> str:
45
+ if _environment is not None:
46
+ return _environment
47
+ import os
48
+
49
+ raw = os.environ.get("AGENTEYE_ENVIRONMENT")
50
+ if not raw:
51
+ return _DEFAULT_ENVIRONMENT
52
+ if "," in raw:
53
+ # Raising here would blow up inside `to_dict()` on an arbitrary event,
54
+ # far from the thing that set it, and take the caller's agent down with
55
+ # it — a telemetry library must not do that. Warn once and fall back to
56
+ # a label ingest will actually accept, so the events land under a
57
+ # visibly-wrong environment instead of vanishing.
58
+ global _warned_comma
59
+ if not _warned_comma:
60
+ _warned_comma = True
61
+ logger.warning(
62
+ "failproofai_sdk: AGENTEYE_ENVIRONMENT=%r contains a comma, which makes "
63
+ "the ingest endpoint skip every event carrying it. Falling back to %r. "
64
+ "Use a single label, e.g. 'prod-eu'.",
65
+ raw,
66
+ _DEFAULT_ENVIRONMENT,
67
+ )
68
+ return _DEFAULT_ENVIRONMENT
69
+ return raw
70
+
71
+
72
+ def set_environment(env: str | None) -> None:
73
+ global _environment, _warned_comma
74
+ if env:
75
+ _reject_comma(env, "configure(environment=...)")
76
+ _environment = env if env else None
77
+ # A new label means the env var may be worth complaining about again.
78
+ _warned_comma = False