graphmind-ai 0.2.1__tar.gz

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.
Files changed (42) hide show
  1. graphmind_ai-0.2.1/.gitignore +11 -0
  2. graphmind_ai-0.2.1/LICENSE +21 -0
  3. graphmind_ai-0.2.1/Makefile +49 -0
  4. graphmind_ai-0.2.1/PKG-INFO +417 -0
  5. graphmind_ai-0.2.1/README.md +370 -0
  6. graphmind_ai-0.2.1/graphmind/__init__.py +188 -0
  7. graphmind_ai-0.2.1/graphmind/_version.py +5 -0
  8. graphmind_ai-0.2.1/graphmind/api.py +344 -0
  9. graphmind_ai-0.2.1/graphmind/env.py +74 -0
  10. graphmind_ai-0.2.1/graphmind/errors.py +66 -0
  11. graphmind_ai-0.2.1/graphmind/gate.py +254 -0
  12. graphmind_ai-0.2.1/graphmind/ids.py +68 -0
  13. graphmind_ai-0.2.1/graphmind/integrations/__init__.py +36 -0
  14. graphmind_ai-0.2.1/graphmind/integrations/_common.py +479 -0
  15. graphmind_ai-0.2.1/graphmind/integrations/anthropic.py +592 -0
  16. graphmind_ai-0.2.1/graphmind/integrations/langchain.py +632 -0
  17. graphmind_ai-0.2.1/graphmind/integrations/openai.py +444 -0
  18. graphmind_ai-0.2.1/graphmind/protocol.py +174 -0
  19. graphmind_ai-0.2.1/graphmind/py.typed +0 -0
  20. graphmind_ai-0.2.1/graphmind/ring_buffer.py +45 -0
  21. graphmind_ai-0.2.1/graphmind/runtime.py +226 -0
  22. graphmind_ai-0.2.1/graphmind/safe.py +81 -0
  23. graphmind_ai-0.2.1/graphmind/session.py +760 -0
  24. graphmind_ai-0.2.1/graphmind/tokens.py +80 -0
  25. graphmind_ai-0.2.1/graphmind/transport.py +332 -0
  26. graphmind_ai-0.2.1/graphmind/wrap.py +347 -0
  27. graphmind_ai-0.2.1/pyproject.toml +96 -0
  28. graphmind_ai-0.2.1/tests/__init__.py +0 -0
  29. graphmind_ai-0.2.1/tests/conftest.py +119 -0
  30. graphmind_ai-0.2.1/tests/helpers/__init__.py +0 -0
  31. graphmind_ai-0.2.1/tests/helpers/fake_viewer.py +256 -0
  32. graphmind_ai-0.2.1/tests/helpers/providers.py +284 -0
  33. graphmind_ai-0.2.1/tests/test_anthropic.py +268 -0
  34. graphmind_ai-0.2.1/tests/test_api.py +268 -0
  35. graphmind_ai-0.2.1/tests/test_failopen.py +196 -0
  36. graphmind_ai-0.2.1/tests/test_gates_async.py +235 -0
  37. graphmind_ai-0.2.1/tests/test_gates_sync.py +309 -0
  38. graphmind_ai-0.2.1/tests/test_handshake.py +106 -0
  39. graphmind_ai-0.2.1/tests/test_langchain.py +319 -0
  40. graphmind_ai-0.2.1/tests/test_openai.py +347 -0
  41. graphmind_ai-0.2.1/tests/test_overhead.py +93 -0
  42. graphmind_ai-0.2.1/tests/test_schema.py +173 -0
@@ -0,0 +1,11 @@
1
+ .venv/
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ __pycache__/
6
+ *.py[cod]
7
+ .pytest_cache/
8
+ .mypy_cache/
9
+ .ruff_cache/
10
+ .coverage
11
+ htmlcov/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 GraphMind (Mohamed Hegazy)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,49 @@
1
+ # GraphMind Python SDK — development tasks.
2
+ # Everything runs inside python/.venv so the host interpreter stays untouched.
3
+
4
+ PY ?= python3
5
+ VENV := .venv
6
+ BIN := $(VENV)/bin
7
+ PYTHON := $(BIN)/python
8
+
9
+ .DEFAULT_GOAL := help
10
+ .PHONY: help install test test-verbose lint format typecheck check build clean
11
+
12
+ help: ## Show this help
13
+ @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) \
14
+ | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-12s\033[0m %s\n", $$1, $$2}'
15
+
16
+ $(BIN)/python:
17
+ $(PY) -m venv $(VENV)
18
+ $(PYTHON) -m pip install --upgrade pip
19
+
20
+ install: $(BIN)/python ## Create the venv and install the package + dev deps (editable)
21
+ $(PYTHON) -m pip install -e ".[dev]" pytest-timeout
22
+
23
+ test: ## Run the test suite (no network, no API keys)
24
+ $(PYTHON) -m pytest -q --timeout=60 --timeout-method=thread
25
+
26
+ test-verbose: ## Run the test suite with per-test output
27
+ $(PYTHON) -m pytest -vv -s --timeout=60 --timeout-method=thread
28
+
29
+ lint: ## Lint and check formatting
30
+ $(BIN)/ruff check .
31
+ $(BIN)/ruff format --check .
32
+
33
+ format: ## Apply formatting and safe lint fixes
34
+ $(BIN)/ruff check --fix .
35
+ $(BIN)/ruff format .
36
+
37
+ typecheck: ## Type-check the package
38
+ $(BIN)/mypy
39
+
40
+ check: lint typecheck test ## Everything CI runs
41
+
42
+ build: ## Build the wheel and sdist into dist/
43
+ rm -rf dist
44
+ $(PYTHON) -m build
45
+ @ls -la dist
46
+
47
+ clean: ## Remove build artifacts and caches
48
+ rm -rf dist build *.egg-info .pytest_cache .mypy_cache .ruff_cache
49
+ find . -name __pycache__ -type d -prune -exec rm -rf {} +
@@ -0,0 +1,417 @@
1
+ Metadata-Version: 2.5
2
+ Name: graphmind-ai
3
+ Version: 0.2.1
4
+ Summary: Live debugger for AI agents — attach to a running Python agent, watch it as a graph, and pause/resume/inject at LLM and tool boundaries.
5
+ Project-URL: Homepage, https://graphmind.ai
6
+ Project-URL: Repository, https://github.com/Hegazy360/GraphMind
7
+ Project-URL: Issues, https://github.com/Hegazy360/GraphMind/issues
8
+ Author: GraphMind
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: agents,ai,anthropic,debugger,langchain,langgraph,llm,observability,openai,tracing
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Programming Language :: Python :: 3.14
21
+ Classifier: Topic :: Software Development :: Debuggers
22
+ Classifier: Typing :: Typed
23
+ Requires-Python: >=3.10
24
+ Requires-Dist: websockets>=12
25
+ Provides-Extra: all
26
+ Requires-Dist: anthropic>=0.34; extra == 'all'
27
+ Requires-Dist: langchain-core>=0.3; extra == 'all'
28
+ Requires-Dist: openai>=1.40; extra == 'all'
29
+ Provides-Extra: anthropic
30
+ Requires-Dist: anthropic>=0.34; extra == 'anthropic'
31
+ Provides-Extra: dev
32
+ Requires-Dist: anthropic>=0.34; extra == 'dev'
33
+ Requires-Dist: build>=1.2; extra == 'dev'
34
+ Requires-Dist: jsonschema>=4.20; extra == 'dev'
35
+ Requires-Dist: langchain-core>=0.3; extra == 'dev'
36
+ Requires-Dist: mypy>=1.11; extra == 'dev'
37
+ Requires-Dist: openai>=1.40; extra == 'dev'
38
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
39
+ Requires-Dist: pytest-timeout>=2.3; extra == 'dev'
40
+ Requires-Dist: pytest>=8; extra == 'dev'
41
+ Requires-Dist: ruff>=0.6; extra == 'dev'
42
+ Provides-Extra: langchain
43
+ Requires-Dist: langchain-core>=0.3; extra == 'langchain'
44
+ Provides-Extra: openai
45
+ Requires-Dist: openai>=1.40; extra == 'openai'
46
+ Description-Content-Type: text/markdown
47
+
48
+ # graphmind-ai (Python)
49
+
50
+ **A live debugger for AI agents.** Phoenix and Langfuse show you what your agent
51
+ *did*. GraphMind attaches while it's happening.
52
+
53
+ Your instrumented app streams execution events over a local WebSocket to the
54
+ GraphMind viewer, which renders the run as a live graph — and can **hold
55
+ execution**: before an LLM step, before/after a tool call, or on error. From the
56
+ viewer you then resume with `continue`, `retry`, `inject` (substitute a result)
57
+ or `abort`.
58
+
59
+ Everything fails open. With no debugger attached the instrumentation is a
60
+ no-op measured in *microseconds*; if the debugger disconnects mid-hold, every
61
+ held gate auto-continues in under 100 ms.
62
+
63
+ ```
64
+ pip install graphmind-ai
65
+ ```
66
+
67
+ Then run the viewer (from the [`graphmind-ai` npm CLI](https://www.npmjs.com/package/graphmind-ai)):
68
+
69
+ ```
70
+ npx graphmind-ai serve
71
+ ```
72
+
73
+ - **Distribution name:** `graphmind-ai` · **import name:** `graphmind`
74
+ - Python **3.10+**, one runtime dependency (`websockets`), MIT licensed.
75
+ - Wire protocol v1 — byte-identical to the TypeScript client, so Python and
76
+ TypeScript runs render in the same viewer.
77
+
78
+ ---
79
+
80
+ ## 60-second quickstart
81
+
82
+ ```python
83
+ import graphmind as gm
84
+ from openai import OpenAI
85
+
86
+ client = gm.instrument_openai(OpenAI())
87
+
88
+
89
+ @gm.tool
90
+ def search_flights(origin: str, destination: str) -> list[dict]:
91
+ return [{"flight": "TP1234", "price": 218}]
92
+
93
+
94
+ with gm.run("book-trip"):
95
+ response = client.chat.completions.create(
96
+ model="gpt-5",
97
+ messages=[{"role": "user", "content": "Cheapest VIE -> LIS next Friday?"}],
98
+ tools=[{"type": "function", "function": {"name": "search_flights"}}],
99
+ )
100
+ ...
101
+ ```
102
+
103
+ That's it. No config file, no exporter, no collector. Set a breakpoint on
104
+ `search_flights` in the viewer, run it again, and execution stops **before the
105
+ function body runs** — nothing is in flight, so you can sit on a breakpoint for
106
+ as long as you like.
107
+
108
+ **Sync and async are both first-class.** Most production Python agent code is
109
+ synchronous, so nothing here requires an event loop:
110
+
111
+ ```python
112
+ async def main():
113
+ async with gm.run("book-trip"): # same object, `async with`
114
+ await client.chat.completions.create(...)
115
+ ```
116
+
117
+ Under the hood the transport lives on one dedicated daemon thread with its own
118
+ event loop. Your loop is never touched — no `nest_asyncio`, no
119
+ `run_until_complete`, no hijacking — and sync code never needs a loop at all.
120
+
121
+ ---
122
+
123
+ ## Integrations
124
+
125
+ ### OpenAI
126
+
127
+ ```python
128
+ import graphmind as gm
129
+ from openai import OpenAI, AsyncOpenAI
130
+
131
+ client = gm.instrument_openai(OpenAI()) # sync
132
+ aclient = gm.instrument_openai(AsyncOpenAI()) # async
133
+ ```
134
+
135
+ Patches `chat.completions.create` (and `.parse`), and `responses.create`, on the
136
+ *instance* — no library monkey-patching, no import hooks. Streaming responses
137
+ are teed: your code receives exactly the provider's stream while GraphMind
138
+ observes deltas. `tools=[...]` is pre-announced as a `graph.hint` so the viewer
139
+ renders the tool roster before anything runs.
140
+
141
+ ### Anthropic
142
+
143
+ ```python
144
+ import graphmind as gm
145
+ from anthropic import Anthropic
146
+
147
+ client = gm.instrument_anthropic(Anthropic())
148
+
149
+ with client.messages.stream(model="claude-sonnet-4-5", max_tokens=1024, messages=[...]) as stream:
150
+ for text in stream.text_stream:
151
+ print(text, end="")
152
+ ```
153
+
154
+ Patches `messages.create` (including `stream=True`) and `messages.stream`. For
155
+ `messages.stream` the HTTP request happens in `__enter__`, so that is where the
156
+ gate holds. The stream proxy observes **both** consumption styles — raw event
157
+ iteration and `.text_stream` — and recovers final token usage from the SDK's own
158
+ message snapshot either way.
159
+
160
+ ### LangChain / LangGraph
161
+
162
+ ```python
163
+ import graphmind as gm
164
+
165
+ handler = gm.callback_handler() # sync chains
166
+ ahandler = gm.async_callback_handler() # async chains / LangGraph
167
+
168
+ result = chain.invoke(payload, config={"callbacks": [handler]})
169
+ result = await graph.ainvoke(payload, config={"callbacks": [ahandler]})
170
+ ```
171
+
172
+ Chains, LLMs, chat models, tools and retrievers become graph nodes, parented by
173
+ LangChain's `parent_run_id`, with token streaming from `on_llm_new_token`.
174
+
175
+ | LangChain concept | node kind | node id |
176
+ |-------------------|-----------|--------------------|
177
+ | chain / runnable | `chain` | `chain:<name>` |
178
+ | LLM / chat model | `llm` | `llm:<name>` |
179
+ | tool | `tool` | `tool:<name>` |
180
+ | retriever | `retriever` | `retriever:<name>` |
181
+
182
+ ### Plain functions — where `inject` and `retry` really work
183
+
184
+ ```python
185
+ @gm.tool
186
+ def search_flights(origin: str, destination: str) -> list[dict]: ...
187
+
188
+
189
+ @gm.tool # async functions stay async
190
+ async def fetch(url: str) -> str: ...
191
+
192
+
193
+ tools = gm.wrap_tools({"search": search, "book": book}) # or a list, or one callable
194
+ ```
195
+
196
+ ### Anything else: spans
197
+
198
+ ```python
199
+ with gm.span("plan", kind="chain") as span: # `async with` too
200
+ plan = build_plan(state)
201
+ span.set_output(plan)
202
+ ```
203
+
204
+ Use a span for the parts of a graph GraphMind cannot see by itself — a LangGraph
205
+ node body, a hand-rolled planner loop, a retrieval step in your own framework.
206
+
207
+ ---
208
+
209
+ ## Capability matrix
210
+
211
+ What each attachment point can actually do. This is measured, not aspirational:
212
+ every ✅ below is covered by a test in `tests/`.
213
+
214
+ | | observe | `before` hold | `error` hold | `after` hold | `inject` | `retry` | `abort` |
215
+ |---|---|---|---|---|---|---|---|
216
+ | `@gm.tool` / `gm.wrap_tools` (sync + async) | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
217
+ | `gm.span` (sync + async) | ✅ | ✅ | — | — | as span output | — | ✅ |
218
+ | OpenAI `chat.completions` / `responses` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
219
+ | Anthropic `messages.create` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
220
+ | Anthropic `messages.stream` | ✅ | ✅ (in `__enter__`) | ✅ | — | ❌ | ❌ | ✅ |
221
+ | LangChain sync handler | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
222
+ | LangChain async handler | ✅ | ✅ | ✅ | ✅ | ❌ | ❌ | ✅ |
223
+
224
+ **Why `inject`/`retry` are ❌ for callbacks.** LangChain callbacks are
225
+ *observers*: the framework ignores their return value, so nothing in a callback
226
+ can substitute a chain's result. GraphMind accepts those actions, warns once,
227
+ and treats them as `continue`. To inject or retry a result, wrap the call site —
228
+ `@gm.tool` on the tool function, or `gm.span` around the code you want to
229
+ replace. Same story for Anthropic's `messages.stream`: GraphMind cannot
230
+ fabricate a provider stream object, so it holds and warns rather than lying.
231
+
232
+ **Holding really holds — verified, not assumed.** Against `langchain_core` 0.3:
233
+ sync callbacks are invoked inline by `handle_event` on the executing thread, so
234
+ blocking there holds the chain; async callbacks are `await`ed directly by
235
+ `ahandle_event`. (A *sync* handler inside an *async* chain is dispatched to the
236
+ default executor and still awaited via `asyncio.gather`, so it holds too — at
237
+ the cost of parking a thread-pool thread per concurrent run. Prefer
238
+ `gm.async_callback_handler()` there.) Both handlers set `raise_error = True` so
239
+ an `abort` can terminate the chain; every handler body is fully guarded, so the
240
+ only exception that ever escapes is GraphMind's own `GraphMindAbortError`.
241
+
242
+ ---
243
+
244
+ ## API at a glance
245
+
246
+ | call | what it does |
247
+ |---|---|
248
+ | `gm.configure(app=..., **opts)` — alias `gm.init` | Create/replace the process-wide instance. |
249
+ | `gm.instrument_openai(client)` — alias `wrap_openai` | Gate every OpenAI request; returns the client. |
250
+ | `gm.instrument_anthropic(client)` — alias `wrap_anthropic` | Gate every Anthropic request; returns the client. |
251
+ | `gm.callback_handler()` — alias `gm.handler` | LangChain `BaseCallbackHandler` for sync chains. |
252
+ | `gm.async_callback_handler()` — alias `gm.async_handler` | `AsyncCallbackHandler` for async chains / LangGraph. |
253
+ | `@gm.tool` | Gate a function: `tool:<name>` node with inject/retry/abort. |
254
+ | `gm.wrap_tools({...})` | Same, for a mapping / list / single callable. |
255
+ | `with gm.run("name"):` | Open a run. `async with` works on the same object. |
256
+ | `with gm.span("name", kind=...):` | A gated node for anything else. `async with` too. |
257
+ | `gm.ready(timeout=2.0)` / `gm.ready_async(...)` | Wait for the handshake. `False` means detached, not an error. |
258
+ | `gm.stats()` | Diagnostics: enabled, attached, buffered, dropped, held gates, seq. |
259
+ | `gm.dispose()` | Release held gates, flush events, close the socket. |
260
+
261
+ Every call above also exists as a method on an explicit instance
262
+ (`gm.GraphMind(app=...)`), which is what you want when one process debugs more
263
+ than one agent.
264
+
265
+ ---
266
+
267
+ ## Node identity
268
+
269
+ One node per *code location*; executions light it up.
270
+
271
+ | node | `nodeId` | `instanceId` |
272
+ |---|---|---|
273
+ | run / agent | `agent:<run name>` | run id |
274
+ | provider LLM call | `llm:step` | per call |
275
+ | tool call | `tool:<tool name>` | per call |
276
+ | LangChain node | `<kind>:<name>` | LangChain `run_id` |
277
+ | span | `<kind>:<name>` | per entry |
278
+
279
+ Every `node.finished` and `node.error` this package emits carries its
280
+ `instanceId`, so concurrent executions of the same logical node are never
281
+ mis-attributed.
282
+
283
+ ---
284
+
285
+ ## Attaching, and the kill switches
286
+
287
+ The transport is lazy: it connects on first use with a 300 ms budget, then
288
+ retries in the background every 10 s. An agent that starts instantly can
289
+ therefore run past its first gate before the handshake lands. When you want
290
+ pause guarantees from the very first event:
291
+
292
+ ```python
293
+ gm.ready(timeout=2.0) # blocks; True once breakpoints are armed
294
+ await gm.ready_async(timeout=2.0) # async twin
295
+ ```
296
+
297
+ `ready()` never raises. `False` means "carry on detached" — it is not an error.
298
+
299
+ ```python
300
+ gm.configure(
301
+ app="support-agent", # name shown in the viewer
302
+ url="ws://127.0.0.1:4747/ingest",
303
+ meta={"git_sha": SHA},
304
+ )
305
+ ```
306
+
307
+ | switch | effect |
308
+ |---|---|
309
+ | `GRAPHMIND_DISABLED=1` | Disabled, always. Beats an explicit `enabled=True` in code. |
310
+ | `enabled=False` | Disabled for this instance. |
311
+ | production-looking env | Disabled **unless** `GRAPHMIND=1`. |
312
+ | `GRAPHMIND_URL` | Overrides the viewer endpoint. |
313
+
314
+ "Production-looking" is a deliberately boring, documented rule: the **first**
315
+ variable that is set out of `GRAPHMIND_ENV`, `ENVIRONMENT`, `APP_ENV`,
316
+ `PYTHON_ENV`, `ENV`, `DJANGO_ENV`, `FLASK_ENV`, `NODE_ENV` decides, and it counts
317
+ as production when its value is `production` or `prod` (case-insensitive). No
318
+ hostname sniffing, no cloud metadata probes — a debugger that turns itself off
319
+ for surprising reasons is worse than one you have to switch on.
320
+
321
+ A disabled session never opens a socket, never allocates a buffer, and never
322
+ touches your objects: `instrument_openai` returns the client untouched.
323
+
324
+ ---
325
+
326
+ ## Overhead
327
+
328
+ Measured by `tests/test_overhead.py` on an M-series laptop (CPython 3.9, 2000
329
+ iterations, per call):
330
+
331
+ | state | overhead per wrapped call |
332
+ |---|---|
333
+ | disabled (kill switch) | **0.2 µs** |
334
+ | enabled but detached | **18 µs** (two envelopes into the replay ring buffer) |
335
+ | detached gate check | **0.37 µs** |
336
+
337
+ The suite asserts budgets of 20 µs / 1 ms / 20 µs respectively, so a regression
338
+ that puts real work on the hot path fails CI.
339
+
340
+ ---
341
+
342
+ ## Fail-open guarantees
343
+
344
+ - **Never raises into your app.** Internal failures degrade to a rate-limited
345
+ warning on stderr and uninstrumented behaviour. Your own exceptions propagate
346
+ untouched.
347
+ - **Disconnect auto-continues.** Killing the viewer mid-hold releases every held
348
+ gate with `continue` in well under 100 ms (asserted in
349
+ `tests/test_failopen.py`). Blocked threads also poll every 250 ms as a
350
+ belt-and-braces backstop, so a held gate can never outlive the debugger.
351
+ - **Interpreter exit auto-continues.** An `atexit` hook releases held gates, and
352
+ the transport thread is a daemon, so GraphMind can never keep a process alive.
353
+ - **Bounded memory.** Events emitted while detached go into a ring buffer
354
+ (default 2000) and are replayed, oldest first with their original `seq`, when
355
+ a viewer attaches — the viewer deduplicates.
356
+ - **Bounded payloads.** Prompts, tool arguments and results are depth-, width-
357
+ and length-capped before serialization, so a vision agent's base64 images
358
+ cannot melt the socket. Anything unserializable degrades to a bounded `repr`.
359
+ - **`fork()`-safe.** The loop thread is re-created in the child, so pre-forking
360
+ servers (gunicorn, uvicorn workers, Celery) keep working.
361
+ - **Ctrl-C works** while a gate is held.
362
+
363
+ ---
364
+
365
+ ## Limitations
366
+
367
+ - **`inject` / `retry` are unavailable at observer-only attachment points** —
368
+ LangChain callbacks and Anthropic's `messages.stream`. See the capability
369
+ matrix. Wrap the call site to get them.
370
+ - **No mid-stream gates.** A streamed response is observed, not pausable, once
371
+ it has started. The gate is at the start of the call (matching the TypeScript
372
+ adapter's documented behaviour for streaming tools).
373
+ - **LangChain child-config propagation is LangChain's.** A manual `.ainvoke()`
374
+ made *inside* an async lambda body inherits no run config, so that child
375
+ produces no callbacks — for any handler, not just this one. Compose with `|`
376
+ or pass `config=` explicitly. Pinned by a test so this note stays honest.
377
+ - **Thread hand-offs lose the run context.** Run context lives in a
378
+ `contextvars.ContextVar`, which propagates to asyncio tasks but not across a
379
+ bare `ThreadPoolExecutor.submit`. Use `contextvars.copy_context().run(...)`,
380
+ or open a `gm.run(...)` inside the worker.
381
+ - **`instrument_*` patches an instance.** Clients created *after* the call are
382
+ not instrumented; call it on each client you build. It is idempotent, so
383
+ calling it twice is safe.
384
+ - **Streaming usage needs the provider to send it.** For OpenAI chat streams,
385
+ pass `stream_options={"include_usage": True}` or the node shows no token
386
+ counts.
387
+ - **No provider-side timeout neutralization yet.** The TypeScript adapter
388
+ neutralizes SDK `timeout` configs while attached; the Python SDKs' timeouts
389
+ are still live, so a long hold can trip a client-side timeout after the gate
390
+ releases. Remove aggressive `timeout=` settings while debugging.
391
+ - **CrewAI / LlamaIndex** are not yet instrumented directly. Both run on top of
392
+ provider clients, so `instrument_openai` / `instrument_anthropic` plus
393
+ `gm.span` already give you a usable graph today.
394
+
395
+ ---
396
+
397
+ ## Development
398
+
399
+ ```
400
+ make install # venv + dev dependencies (editable install)
401
+ make test # pytest
402
+ make lint # ruff check + ruff format --check
403
+ make typecheck # mypy
404
+ make check # all of the above
405
+ make build # wheel + sdist into dist/
406
+ make clean
407
+ ```
408
+
409
+ The test suite needs **no API keys and no network**: provider calls run through
410
+ the real SDKs against an `httpx.MockTransport`, and the only socket is a
411
+ loopback WebSocket to a fake viewer that speaks the real protocol. Emitted
412
+ frames are validated against `packages/schema/schema.json` — the same artifact
413
+ the CLI, the viewer and the TypeScript client are built from.
414
+
415
+ ## License
416
+
417
+ MIT.