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.
- graphmind_ai-0.2.1/.gitignore +11 -0
- graphmind_ai-0.2.1/LICENSE +21 -0
- graphmind_ai-0.2.1/Makefile +49 -0
- graphmind_ai-0.2.1/PKG-INFO +417 -0
- graphmind_ai-0.2.1/README.md +370 -0
- graphmind_ai-0.2.1/graphmind/__init__.py +188 -0
- graphmind_ai-0.2.1/graphmind/_version.py +5 -0
- graphmind_ai-0.2.1/graphmind/api.py +344 -0
- graphmind_ai-0.2.1/graphmind/env.py +74 -0
- graphmind_ai-0.2.1/graphmind/errors.py +66 -0
- graphmind_ai-0.2.1/graphmind/gate.py +254 -0
- graphmind_ai-0.2.1/graphmind/ids.py +68 -0
- graphmind_ai-0.2.1/graphmind/integrations/__init__.py +36 -0
- graphmind_ai-0.2.1/graphmind/integrations/_common.py +479 -0
- graphmind_ai-0.2.1/graphmind/integrations/anthropic.py +592 -0
- graphmind_ai-0.2.1/graphmind/integrations/langchain.py +632 -0
- graphmind_ai-0.2.1/graphmind/integrations/openai.py +444 -0
- graphmind_ai-0.2.1/graphmind/protocol.py +174 -0
- graphmind_ai-0.2.1/graphmind/py.typed +0 -0
- graphmind_ai-0.2.1/graphmind/ring_buffer.py +45 -0
- graphmind_ai-0.2.1/graphmind/runtime.py +226 -0
- graphmind_ai-0.2.1/graphmind/safe.py +81 -0
- graphmind_ai-0.2.1/graphmind/session.py +760 -0
- graphmind_ai-0.2.1/graphmind/tokens.py +80 -0
- graphmind_ai-0.2.1/graphmind/transport.py +332 -0
- graphmind_ai-0.2.1/graphmind/wrap.py +347 -0
- graphmind_ai-0.2.1/pyproject.toml +96 -0
- graphmind_ai-0.2.1/tests/__init__.py +0 -0
- graphmind_ai-0.2.1/tests/conftest.py +119 -0
- graphmind_ai-0.2.1/tests/helpers/__init__.py +0 -0
- graphmind_ai-0.2.1/tests/helpers/fake_viewer.py +256 -0
- graphmind_ai-0.2.1/tests/helpers/providers.py +284 -0
- graphmind_ai-0.2.1/tests/test_anthropic.py +268 -0
- graphmind_ai-0.2.1/tests/test_api.py +268 -0
- graphmind_ai-0.2.1/tests/test_failopen.py +196 -0
- graphmind_ai-0.2.1/tests/test_gates_async.py +235 -0
- graphmind_ai-0.2.1/tests/test_gates_sync.py +309 -0
- graphmind_ai-0.2.1/tests/test_handshake.py +106 -0
- graphmind_ai-0.2.1/tests/test_langchain.py +319 -0
- graphmind_ai-0.2.1/tests/test_openai.py +347 -0
- graphmind_ai-0.2.1/tests/test_overhead.py +93 -0
- graphmind_ai-0.2.1/tests/test_schema.py +173 -0
|
@@ -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.
|