graphmind-ai 0.2.1__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.
- graphmind/__init__.py +188 -0
- graphmind/_version.py +5 -0
- graphmind/api.py +344 -0
- graphmind/env.py +74 -0
- graphmind/errors.py +66 -0
- graphmind/gate.py +254 -0
- graphmind/ids.py +68 -0
- graphmind/integrations/__init__.py +36 -0
- graphmind/integrations/_common.py +479 -0
- graphmind/integrations/anthropic.py +592 -0
- graphmind/integrations/langchain.py +632 -0
- graphmind/integrations/openai.py +444 -0
- graphmind/protocol.py +174 -0
- graphmind/py.typed +0 -0
- graphmind/ring_buffer.py +45 -0
- graphmind/runtime.py +226 -0
- graphmind/safe.py +81 -0
- graphmind/session.py +760 -0
- graphmind/tokens.py +80 -0
- graphmind/transport.py +332 -0
- graphmind/wrap.py +347 -0
- graphmind_ai-0.2.1.dist-info/METADATA +417 -0
- graphmind_ai-0.2.1.dist-info/RECORD +25 -0
- graphmind_ai-0.2.1.dist-info/WHEEL +4 -0
- graphmind_ai-0.2.1.dist-info/licenses/LICENSE +21 -0
graphmind/__init__.py
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
"""GraphMind — a live debugger for AI agents.
|
|
2
|
+
|
|
3
|
+
Phoenix and Langfuse show you what your agent *did*. GraphMind attaches while
|
|
4
|
+
it's happening: an instrumented app streams execution events to a local viewer,
|
|
5
|
+
which renders the run as a live graph and can **hold execution** — before an
|
|
6
|
+
LLM step, before/after a tool call, or on error — then resume with
|
|
7
|
+
``continue`` / ``retry`` / ``inject`` / ``abort``.
|
|
8
|
+
|
|
9
|
+
Quickstart::
|
|
10
|
+
|
|
11
|
+
import graphmind as gm
|
|
12
|
+
from openai import OpenAI
|
|
13
|
+
|
|
14
|
+
client = gm.instrument_openai(OpenAI())
|
|
15
|
+
|
|
16
|
+
@gm.tool
|
|
17
|
+
def search_flights(origin: str, destination: str) -> list[dict]:
|
|
18
|
+
...
|
|
19
|
+
|
|
20
|
+
with gm.run("handle-ticket"):
|
|
21
|
+
client.chat.completions.create(model="gpt-5", messages=[...])
|
|
22
|
+
|
|
23
|
+
Then run ``graphmind serve`` (from the ``graphmind-ai`` npm CLI) and open the
|
|
24
|
+
viewer. With nothing attached, all of the above is a no-op.
|
|
25
|
+
|
|
26
|
+
Everything fails open: no debugger means near-zero overhead, and a debugger
|
|
27
|
+
that disconnects mid-hold auto-continues every held gate.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
from collections.abc import Callable, Iterable
|
|
33
|
+
from typing import Any
|
|
34
|
+
|
|
35
|
+
from ._version import __version__
|
|
36
|
+
from .api import GraphMind, Span, configure, init, instance, is_configured, reset
|
|
37
|
+
from .env import DEFAULT_URL
|
|
38
|
+
from .errors import GraphMindAbortError, is_abort_error
|
|
39
|
+
from .gate import GateDecision, GateNode
|
|
40
|
+
from .protocol import PROTOCOL_VERSION
|
|
41
|
+
from .session import RunContext, RunScope, Session, SessionStats
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"DEFAULT_URL",
|
|
45
|
+
"PROTOCOL_VERSION",
|
|
46
|
+
"AsyncGraphMindCallbackHandler",
|
|
47
|
+
"GateDecision",
|
|
48
|
+
"GateNode",
|
|
49
|
+
"GraphMind",
|
|
50
|
+
"GraphMindAbortError",
|
|
51
|
+
"GraphMindCallbackHandler",
|
|
52
|
+
"RunContext",
|
|
53
|
+
"RunScope",
|
|
54
|
+
"Session",
|
|
55
|
+
"SessionStats",
|
|
56
|
+
"Span",
|
|
57
|
+
"__version__",
|
|
58
|
+
"async_callback_handler",
|
|
59
|
+
"async_handler",
|
|
60
|
+
"callback_handler",
|
|
61
|
+
"configure",
|
|
62
|
+
"dispose",
|
|
63
|
+
"emit",
|
|
64
|
+
"graph_hint",
|
|
65
|
+
"handler",
|
|
66
|
+
"init",
|
|
67
|
+
"instance",
|
|
68
|
+
"instrument_anthropic",
|
|
69
|
+
"instrument_openai",
|
|
70
|
+
"is_abort_error",
|
|
71
|
+
"is_configured",
|
|
72
|
+
"ready",
|
|
73
|
+
"ready_async",
|
|
74
|
+
"reset",
|
|
75
|
+
"run",
|
|
76
|
+
"session",
|
|
77
|
+
"span",
|
|
78
|
+
"stats",
|
|
79
|
+
"tool",
|
|
80
|
+
"wrap_anthropic",
|
|
81
|
+
"wrap_openai",
|
|
82
|
+
"wrap_tools",
|
|
83
|
+
]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# -- module-level convenience API (delegates to the default instance) ---------
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def run(name: str, meta: dict[str, Any] | None = None) -> RunScope:
|
|
90
|
+
"""Run boundary. Works as ``with gm.run(...)`` and ``async with gm.run(...)``."""
|
|
91
|
+
return instance().run(name, meta)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def span(name: str, kind: str = "custom", input: Any = None, parent_id: str | None = None) -> Span:
|
|
95
|
+
"""A gated, timed node for code GraphMind cannot see by itself."""
|
|
96
|
+
return instance().span(name, kind, input, parent_id)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def tool(
|
|
100
|
+
fn: Callable[..., Any] | None = None,
|
|
101
|
+
*,
|
|
102
|
+
name: str | None = None,
|
|
103
|
+
kind: str = "tool",
|
|
104
|
+
parent_id: str | None = None,
|
|
105
|
+
) -> Any:
|
|
106
|
+
"""Decorate a tool function so the debugger can pause, retry and inject it."""
|
|
107
|
+
return instance().tool(fn, name=name, kind=kind, parent_id=parent_id)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def wrap_tools(tools: Any) -> Any:
|
|
111
|
+
"""Wrap a ``{name: fn}`` mapping, a list of callables, or one callable."""
|
|
112
|
+
return instance().wrap_tools(tools)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def instrument_openai(client: Any) -> Any:
|
|
116
|
+
"""Instrument an ``openai`` client in place and return it."""
|
|
117
|
+
return instance().instrument_openai(client)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
#: Alias for people used to other tooling's naming.
|
|
121
|
+
wrap_openai = instrument_openai
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def instrument_anthropic(client: Any) -> Any:
|
|
125
|
+
"""Instrument an ``anthropic`` client in place and return it."""
|
|
126
|
+
return instance().instrument_anthropic(client)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
wrap_anthropic = instrument_anthropic
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def ready(timeout: float = 2.0) -> bool:
|
|
133
|
+
"""Block until a debugger is attached. ``False`` means "carry on detached"."""
|
|
134
|
+
return instance().ready(timeout)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
async def ready_async(timeout: float = 2.0) -> bool:
|
|
138
|
+
"""Async twin of :func:`ready`."""
|
|
139
|
+
return await instance().ready_async(timeout)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def emit(type: str, payload: dict[str, Any]) -> None:
|
|
143
|
+
"""Emit a raw protocol event (advanced)."""
|
|
144
|
+
instance().emit(type, payload)
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def graph_hint(nodes: Iterable[dict[str, Any]]) -> None:
|
|
148
|
+
"""Pre-announce static graph structure so the viewer renders it grey."""
|
|
149
|
+
instance().graph_hint(nodes)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def stats() -> SessionStats:
|
|
153
|
+
"""Diagnostics snapshot of the default session."""
|
|
154
|
+
return instance().stats()
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def session() -> Session:
|
|
158
|
+
"""The default instance's underlying session (advanced)."""
|
|
159
|
+
return instance().session
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def dispose() -> None:
|
|
163
|
+
"""Release held gates, flush events, close the socket. Idempotent."""
|
|
164
|
+
instance().dispose()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def callback_handler(**kwargs: Any) -> Any:
|
|
168
|
+
"""A sync LangChain/LangGraph handler bound to the default session."""
|
|
169
|
+
return instance().callback_handler(**kwargs)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def async_callback_handler(**kwargs: Any) -> Any:
|
|
173
|
+
"""An async LangChain/LangGraph handler bound to the default session."""
|
|
174
|
+
return instance().async_callback_handler(**kwargs)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
#: Short aliases matching the docs and `graphmind init` scaffolding.
|
|
178
|
+
handler = callback_handler
|
|
179
|
+
async_handler = async_callback_handler
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def __getattr__(name: str) -> Any:
|
|
183
|
+
"""Lazily expose the LangChain handlers without importing langchain_core."""
|
|
184
|
+
if name in ("GraphMindCallbackHandler", "AsyncGraphMindCallbackHandler"):
|
|
185
|
+
from .integrations import langchain
|
|
186
|
+
|
|
187
|
+
return getattr(langchain, name)
|
|
188
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
graphmind/_version.py
ADDED
graphmind/api.py
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
"""The public surface: :class:`GraphMind` plus the module-level default instance.
|
|
2
|
+
|
|
3
|
+
import graphmind as gm
|
|
4
|
+
|
|
5
|
+
gm.configure(app="support-agent") # optional
|
|
6
|
+
|
|
7
|
+
@gm.tool
|
|
8
|
+
def search_flights(origin: str, dest: str) -> list[dict]:
|
|
9
|
+
...
|
|
10
|
+
|
|
11
|
+
client = gm.instrument_openai(OpenAI())
|
|
12
|
+
|
|
13
|
+
with gm.run("handle-ticket"):
|
|
14
|
+
client.chat.completions.create(...)
|
|
15
|
+
|
|
16
|
+
Everything degrades to a no-op when no debugger is attached, and to literally
|
|
17
|
+
nothing when GraphMind is disabled.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import threading
|
|
23
|
+
import time
|
|
24
|
+
from collections.abc import Callable, Iterable
|
|
25
|
+
from typing import Any, TypeVar
|
|
26
|
+
|
|
27
|
+
from ._version import __version__
|
|
28
|
+
from .gate import GateDecision, GateNode
|
|
29
|
+
from .ids import next_id
|
|
30
|
+
from .protocol import NODE_KINDS
|
|
31
|
+
from .session import RunContext, RunScope, Session, SessionStats
|
|
32
|
+
from .wrap import ToolsInput, gate_callable
|
|
33
|
+
from .wrap import wrap_tools as _wrap_tools
|
|
34
|
+
|
|
35
|
+
F = TypeVar("F", bound=Callable[..., Any])
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class GraphMind:
|
|
39
|
+
"""One debugger attachment point for a process."""
|
|
40
|
+
|
|
41
|
+
def __init__(
|
|
42
|
+
self,
|
|
43
|
+
*,
|
|
44
|
+
app: str = "python-app",
|
|
45
|
+
sdk: dict[str, str] | None = None,
|
|
46
|
+
**session_options: Any,
|
|
47
|
+
) -> None:
|
|
48
|
+
self.session = Session(
|
|
49
|
+
app_name=app,
|
|
50
|
+
sdk=sdk if sdk is not None else {"name": "python", "version": __version__},
|
|
51
|
+
**session_options,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
# -- attach ---------------------------------------------------------------
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def enabled(self) -> bool:
|
|
58
|
+
return self.session.enabled
|
|
59
|
+
|
|
60
|
+
@property
|
|
61
|
+
def attached(self) -> bool:
|
|
62
|
+
return self.session.attached
|
|
63
|
+
|
|
64
|
+
def ready(self, timeout: float = 2.0) -> bool:
|
|
65
|
+
"""Block until the debugger handshake completes (breakpoints armed)."""
|
|
66
|
+
return self.session.ready(timeout)
|
|
67
|
+
|
|
68
|
+
async def ready_async(self, timeout: float = 2.0) -> bool:
|
|
69
|
+
"""Async twin of :meth:`ready`."""
|
|
70
|
+
return await self.session.ready_async(timeout)
|
|
71
|
+
|
|
72
|
+
# -- runs & spans ---------------------------------------------------------
|
|
73
|
+
|
|
74
|
+
def run(self, name: str, meta: dict[str, Any] | None = None) -> RunScope:
|
|
75
|
+
"""Run boundary; works as ``with`` and ``async with``."""
|
|
76
|
+
return self.session.run(name, meta)
|
|
77
|
+
|
|
78
|
+
def span(
|
|
79
|
+
self,
|
|
80
|
+
name: str,
|
|
81
|
+
kind: str = "custom",
|
|
82
|
+
input: Any = None,
|
|
83
|
+
parent_id: str | None = None,
|
|
84
|
+
) -> Span:
|
|
85
|
+
"""An arbitrary node on the canvas: gated, timed, sync **and** async.
|
|
86
|
+
|
|
87
|
+
Use it for the parts of a graph GraphMind cannot see by itself — a
|
|
88
|
+
LangGraph node body, a retrieval step, a hand-rolled planner loop.
|
|
89
|
+
"""
|
|
90
|
+
return Span(self.session, name, kind, input, parent_id)
|
|
91
|
+
|
|
92
|
+
def current_run(self) -> RunContext | None:
|
|
93
|
+
return self.session.current_run()
|
|
94
|
+
|
|
95
|
+
# -- tools ----------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
def tool(
|
|
98
|
+
self,
|
|
99
|
+
fn: F | None = None,
|
|
100
|
+
*,
|
|
101
|
+
name: str | None = None,
|
|
102
|
+
kind: str = "tool",
|
|
103
|
+
parent_id: str | None = None,
|
|
104
|
+
) -> Any:
|
|
105
|
+
"""Decorator. Usable bare (``@gm.tool``) or called (``@gm.tool(name=...)``)."""
|
|
106
|
+
|
|
107
|
+
def decorate(target: F) -> F:
|
|
108
|
+
return gate_callable(
|
|
109
|
+
target,
|
|
110
|
+
lambda: self.session,
|
|
111
|
+
name=name,
|
|
112
|
+
kind=kind,
|
|
113
|
+
parent_id=parent_id,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if fn is None:
|
|
117
|
+
return decorate
|
|
118
|
+
return decorate(fn)
|
|
119
|
+
|
|
120
|
+
def wrap_tools(self, tools: ToolsInput) -> Any:
|
|
121
|
+
"""Wrap a ``{name: fn}`` mapping, a list of callables, or one callable."""
|
|
122
|
+
return _wrap_tools(tools, lambda: self.session)
|
|
123
|
+
|
|
124
|
+
# -- integrations ---------------------------------------------------------
|
|
125
|
+
|
|
126
|
+
def instrument_openai(self, client: Any) -> Any:
|
|
127
|
+
"""Instrument an ``openai.OpenAI`` / ``AsyncOpenAI`` client in place."""
|
|
128
|
+
from .integrations.openai import instrument_openai
|
|
129
|
+
|
|
130
|
+
return instrument_openai(client, self.session)
|
|
131
|
+
|
|
132
|
+
#: Alias matching the naming other tools use.
|
|
133
|
+
wrap_openai = instrument_openai
|
|
134
|
+
|
|
135
|
+
def instrument_anthropic(self, client: Any) -> Any:
|
|
136
|
+
"""Instrument an ``anthropic.Anthropic`` / ``AsyncAnthropic`` client in place."""
|
|
137
|
+
from .integrations.anthropic import instrument_anthropic
|
|
138
|
+
|
|
139
|
+
return instrument_anthropic(client, self.session)
|
|
140
|
+
|
|
141
|
+
wrap_anthropic = instrument_anthropic
|
|
142
|
+
|
|
143
|
+
def callback_handler(self, **kwargs: Any) -> Any:
|
|
144
|
+
"""A sync LangChain/LangGraph ``BaseCallbackHandler`` bound to this session."""
|
|
145
|
+
from .integrations.langchain import GraphMindCallbackHandler
|
|
146
|
+
|
|
147
|
+
return GraphMindCallbackHandler(session=self.session, **kwargs)
|
|
148
|
+
|
|
149
|
+
def async_callback_handler(self, **kwargs: Any) -> Any:
|
|
150
|
+
"""An async LangChain/LangGraph ``AsyncCallbackHandler`` bound to this session."""
|
|
151
|
+
from .integrations.langchain import AsyncGraphMindCallbackHandler
|
|
152
|
+
|
|
153
|
+
return AsyncGraphMindCallbackHandler(session=self.session, **kwargs)
|
|
154
|
+
|
|
155
|
+
#: Short aliases: the names the docs and `graphmind init` scaffolding use.
|
|
156
|
+
handler = callback_handler
|
|
157
|
+
async_handler = async_callback_handler
|
|
158
|
+
|
|
159
|
+
# -- low level ------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
def emit(self, type: str, payload: dict[str, Any]) -> None:
|
|
162
|
+
self.session.emit(type, payload)
|
|
163
|
+
|
|
164
|
+
def gate(self, point: str, node: GateNode) -> GateDecision:
|
|
165
|
+
return self.session.gate(point, node)
|
|
166
|
+
|
|
167
|
+
async def gate_async(self, point: str, node: GateNode) -> GateDecision:
|
|
168
|
+
return await self.session.gate_async(point, node)
|
|
169
|
+
|
|
170
|
+
def graph_hint(self, nodes: Iterable[dict[str, Any]]) -> None:
|
|
171
|
+
"""Pre-announce static graph structure so the viewer renders it grey."""
|
|
172
|
+
self.session.graph_hint(nodes)
|
|
173
|
+
|
|
174
|
+
def stats(self) -> SessionStats:
|
|
175
|
+
return self.session.stats()
|
|
176
|
+
|
|
177
|
+
def dispose(self) -> None:
|
|
178
|
+
"""Release held gates, flush events, close the socket. Idempotent."""
|
|
179
|
+
self.session.dispose()
|
|
180
|
+
|
|
181
|
+
def __enter__(self) -> GraphMind:
|
|
182
|
+
return self
|
|
183
|
+
|
|
184
|
+
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
185
|
+
self.dispose()
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
class Span:
|
|
189
|
+
"""A user-declared node, gated like a tool. ``with`` and ``async with``."""
|
|
190
|
+
|
|
191
|
+
__slots__ = (
|
|
192
|
+
"_done",
|
|
193
|
+
"_input",
|
|
194
|
+
"_instance_id",
|
|
195
|
+
"_kind",
|
|
196
|
+
"_name",
|
|
197
|
+
"_node",
|
|
198
|
+
"_output",
|
|
199
|
+
"_parent_id",
|
|
200
|
+
"_session",
|
|
201
|
+
"_started",
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
def __init__(
|
|
205
|
+
self,
|
|
206
|
+
session: Session,
|
|
207
|
+
name: str,
|
|
208
|
+
kind: str,
|
|
209
|
+
input: Any,
|
|
210
|
+
parent_id: str | None,
|
|
211
|
+
) -> None:
|
|
212
|
+
self._session = session
|
|
213
|
+
self._name = name
|
|
214
|
+
self._kind = kind if kind in NODE_KINDS else "custom"
|
|
215
|
+
self._input = input
|
|
216
|
+
self._parent_id = parent_id
|
|
217
|
+
self._node = GateNode(f"{self._kind}:{name}", self._kind, name)
|
|
218
|
+
self._instance_id = ""
|
|
219
|
+
self._started = 0.0
|
|
220
|
+
self._output: Any = None
|
|
221
|
+
self._done = False
|
|
222
|
+
|
|
223
|
+
@property
|
|
224
|
+
def node_id(self) -> str:
|
|
225
|
+
return self._node.node_id
|
|
226
|
+
|
|
227
|
+
def set_output(self, output: Any) -> None:
|
|
228
|
+
"""Record what this span produced (shown on the node in the viewer)."""
|
|
229
|
+
self._output = output
|
|
230
|
+
|
|
231
|
+
def __enter__(self) -> Span:
|
|
232
|
+
self._begin()
|
|
233
|
+
decision = self._session.gate("before", self._node)
|
|
234
|
+
self._check(decision)
|
|
235
|
+
return self
|
|
236
|
+
|
|
237
|
+
def __exit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
238
|
+
self._end(exc)
|
|
239
|
+
|
|
240
|
+
async def __aenter__(self) -> Span:
|
|
241
|
+
self._begin()
|
|
242
|
+
decision = await self._session.gate_async("before", self._node)
|
|
243
|
+
self._check(decision)
|
|
244
|
+
return self
|
|
245
|
+
|
|
246
|
+
async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None:
|
|
247
|
+
self._end(exc)
|
|
248
|
+
|
|
249
|
+
def _begin(self) -> None:
|
|
250
|
+
self._instance_id = next_id("span")
|
|
251
|
+
self._started = time.monotonic()
|
|
252
|
+
self._session.start_node(
|
|
253
|
+
node_id=self._node.node_id,
|
|
254
|
+
kind=self._kind,
|
|
255
|
+
name=self._name,
|
|
256
|
+
instance_id=self._instance_id,
|
|
257
|
+
parent_id=self._parent_id,
|
|
258
|
+
input=self._input,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def _check(self, decision: GateDecision) -> None:
|
|
262
|
+
if decision.action == "abort":
|
|
263
|
+
self._finish(None, "aborted")
|
|
264
|
+
raise self._session.abort_error()
|
|
265
|
+
if decision.action == "inject":
|
|
266
|
+
# A span owns no return value, so an injected one is surfaced as
|
|
267
|
+
# the span's output rather than silently dropped.
|
|
268
|
+
self._output = decision.output
|
|
269
|
+
|
|
270
|
+
def _end(self, error: BaseException | None) -> None:
|
|
271
|
+
from .errors import is_abort_error
|
|
272
|
+
|
|
273
|
+
if error is not None:
|
|
274
|
+
status = "aborted" if is_abort_error(error) else "error"
|
|
275
|
+
if status == "error":
|
|
276
|
+
self._session.error_node(self._node.node_id, self._instance_id, error)
|
|
277
|
+
self._finish(self._output, status)
|
|
278
|
+
return
|
|
279
|
+
self._finish(self._output, "ok")
|
|
280
|
+
|
|
281
|
+
def _finish(self, output: Any, status: str) -> None:
|
|
282
|
+
if self._done:
|
|
283
|
+
return
|
|
284
|
+
self._done = True
|
|
285
|
+
self._session.finish_node(
|
|
286
|
+
node_id=self._node.node_id,
|
|
287
|
+
instance_id=self._instance_id,
|
|
288
|
+
duration_ms=(time.monotonic() - self._started) * 1000.0,
|
|
289
|
+
status=status,
|
|
290
|
+
output=output,
|
|
291
|
+
)
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# -- module-level default instance -------------------------------------------
|
|
295
|
+
|
|
296
|
+
_default: GraphMind | None = None
|
|
297
|
+
_default_lock = threading.Lock()
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
def configure(**options: Any) -> GraphMind:
|
|
301
|
+
"""Create (or replace) the process-wide default instance.
|
|
302
|
+
|
|
303
|
+
Accepts everything :class:`GraphMind` accepts: ``app``, ``sdk``, ``url``,
|
|
304
|
+
``enabled``, ``meta``, ``connect_timeout``, ``handshake_timeout``,
|
|
305
|
+
``retry_interval``, ``buffer_size``, ``pause_timeout``, ``token_interval``,
|
|
306
|
+
``env``, ``logger``.
|
|
307
|
+
"""
|
|
308
|
+
global _default
|
|
309
|
+
with _default_lock:
|
|
310
|
+
previous = _default
|
|
311
|
+
_default = GraphMind(**options)
|
|
312
|
+
if previous is not None:
|
|
313
|
+
previous.dispose()
|
|
314
|
+
return _default
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def instance() -> GraphMind:
|
|
318
|
+
"""The default instance, created with defaults on first use."""
|
|
319
|
+
global _default
|
|
320
|
+
current = _default
|
|
321
|
+
if current is not None:
|
|
322
|
+
return current
|
|
323
|
+
with _default_lock:
|
|
324
|
+
if _default is None:
|
|
325
|
+
_default = GraphMind()
|
|
326
|
+
return _default
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
#: `init` is the name the docs and `graphmind init` scaffolding use.
|
|
330
|
+
init = configure
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def is_configured() -> bool:
|
|
334
|
+
return _default is not None
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def reset() -> None:
|
|
338
|
+
"""Dispose and forget the default instance (mainly for tests)."""
|
|
339
|
+
global _default
|
|
340
|
+
with _default_lock:
|
|
341
|
+
current = _default
|
|
342
|
+
_default = None
|
|
343
|
+
if current is not None:
|
|
344
|
+
current.dispose()
|
graphmind/env.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""Kill switches and environment-derived defaults.
|
|
2
|
+
|
|
3
|
+
Precedence for "is GraphMind enabled" (mirrors ``packages/client/src/env.ts``,
|
|
4
|
+
with a Python-flavoured production check because there is no ``NODE_ENV``):
|
|
5
|
+
|
|
6
|
+
1. ``GRAPHMIND_DISABLED=1`` -> disabled, always. Ops-level kill switch; beats
|
|
7
|
+
an explicit ``enabled=True`` passed in code.
|
|
8
|
+
2. explicit ``enabled=`` argument -> as given.
|
|
9
|
+
3. **looks like production** -> disabled unless ``GRAPHMIND=1``.
|
|
10
|
+
4. otherwise -> enabled.
|
|
11
|
+
|
|
12
|
+
"Looks like production" is deliberately a *documented, boring* rule: the first
|
|
13
|
+
variable that is set out of ``GRAPHMIND_ENV``, ``ENVIRONMENT``, ``APP_ENV``,
|
|
14
|
+
``PYTHON_ENV``, ``ENV``, ``DJANGO_ENV``, ``FLASK_ENV``, ``NODE_ENV`` decides,
|
|
15
|
+
and it counts as production when its value is ``production``/``prod``
|
|
16
|
+
(case-insensitive). No heuristics over hostnames, cloud metadata or TTYs — a
|
|
17
|
+
debugger that silently turns itself off for surprising reasons is worse than
|
|
18
|
+
one you must switch on.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
from collections.abc import Mapping
|
|
25
|
+
|
|
26
|
+
EnvLike = Mapping[str, str]
|
|
27
|
+
|
|
28
|
+
DEFAULT_URL = "ws://127.0.0.1:4747/ingest"
|
|
29
|
+
|
|
30
|
+
#: Checked in order; the first one that is *set* decides the environment.
|
|
31
|
+
ENV_VARS = (
|
|
32
|
+
"GRAPHMIND_ENV",
|
|
33
|
+
"ENVIRONMENT",
|
|
34
|
+
"APP_ENV",
|
|
35
|
+
"PYTHON_ENV",
|
|
36
|
+
"ENV",
|
|
37
|
+
"DJANGO_ENV",
|
|
38
|
+
"FLASK_ENV",
|
|
39
|
+
"NODE_ENV",
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
_PRODUCTION_VALUES = frozenset({"production", "prod"})
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _env(env: EnvLike | None) -> EnvLike:
|
|
46
|
+
return os.environ if env is None else env
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def looks_like_production(env: EnvLike | None = None) -> bool:
|
|
50
|
+
"""True when the first set environment variable in :data:`ENV_VARS` is production."""
|
|
51
|
+
source = _env(env)
|
|
52
|
+
for key in ENV_VARS:
|
|
53
|
+
value = source.get(key)
|
|
54
|
+
if value is None or value == "":
|
|
55
|
+
continue
|
|
56
|
+
return value.strip().lower() in _PRODUCTION_VALUES
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_enabled(explicit: bool | None = None, env: EnvLike | None = None) -> bool:
|
|
61
|
+
"""Apply the kill-switch precedence documented in this module."""
|
|
62
|
+
source = _env(env)
|
|
63
|
+
if source.get("GRAPHMIND_DISABLED") == "1":
|
|
64
|
+
return False
|
|
65
|
+
if explicit is not None:
|
|
66
|
+
return explicit
|
|
67
|
+
return not (looks_like_production(source) and source.get("GRAPHMIND") != "1")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def resolve_url(explicit: str | None = None, env: EnvLike | None = None) -> str:
|
|
71
|
+
"""Viewer endpoint: explicit argument, then ``GRAPHMIND_URL``, then the default."""
|
|
72
|
+
if explicit is not None:
|
|
73
|
+
return explicit
|
|
74
|
+
return _env(env).get("GRAPHMIND_URL") or DEFAULT_URL
|
graphmind/errors.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Abort plumbing and error serialization.
|
|
2
|
+
|
|
3
|
+
Mirrors ``packages/client/src/errors.ts``. The debugger's ``abort`` action
|
|
4
|
+
must surface as an error the host's SDK treats as *terminal* — never as a
|
|
5
|
+
transient failure that provider retry logic will re-issue. In Python there is
|
|
6
|
+
no ``AbortController``; the run context carries a flag plus an event, and
|
|
7
|
+
instrumentation raises :class:`GraphMindAbortError`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import traceback
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
# Names that mean "this was cancelled on purpose, do not retry". The TS client
|
|
16
|
+
# recognises the same two, plus Python's own cancellation exceptions.
|
|
17
|
+
_ABORT_NAMES = frozenset({"AbortError", "GraphMindAbortError", "CancelledError"})
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class GraphMindAbortError(Exception):
|
|
21
|
+
"""Raised into the host when the debugger resolves a gate with ``abort``.
|
|
22
|
+
|
|
23
|
+
``.name`` is ``"AbortError"`` so wire payloads match what the TypeScript
|
|
24
|
+
client emits for the same situation.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
name = "AbortError"
|
|
28
|
+
|
|
29
|
+
def __init__(self, message: str = "Run aborted by GraphMind debugger") -> None:
|
|
30
|
+
super().__init__(message)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def is_abort_error(error: BaseException | None) -> bool:
|
|
34
|
+
"""True for GraphMind aborts and for stdlib cancellation exceptions."""
|
|
35
|
+
if error is None:
|
|
36
|
+
return False
|
|
37
|
+
if isinstance(error, GraphMindAbortError):
|
|
38
|
+
return True
|
|
39
|
+
# asyncio.CancelledError inherits BaseException on 3.8+, so name-match too.
|
|
40
|
+
return type(error).__name__ in _ABORT_NAMES
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _short_stack(error: BaseException, limit: int = 4096) -> str | None:
|
|
44
|
+
try:
|
|
45
|
+
text = "".join(traceback.format_exception(type(error), error, error.__traceback__))
|
|
46
|
+
except Exception: # pragma: no cover - formatting a broken traceback
|
|
47
|
+
return None
|
|
48
|
+
if not text:
|
|
49
|
+
return None
|
|
50
|
+
return text[:limit]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def to_error_info(error: Any) -> dict[str, Any]:
|
|
54
|
+
"""Serialize any raised object into the wire ``ErrorInfo`` shape."""
|
|
55
|
+
if isinstance(error, BaseException):
|
|
56
|
+
# A custom `.name` (GraphMindAbortError) wins over the class name so
|
|
57
|
+
# the viewer shows "AbortError" like the TypeScript client does.
|
|
58
|
+
name = getattr(error, "name", None)
|
|
59
|
+
if not isinstance(name, str) or not name:
|
|
60
|
+
name = type(error).__name__
|
|
61
|
+
info: dict[str, Any] = {"name": name, "message": str(error)}
|
|
62
|
+
stack = _short_stack(error)
|
|
63
|
+
if stack is not None:
|
|
64
|
+
info["stack"] = stack
|
|
65
|
+
return info
|
|
66
|
+
return {"name": "Error", "message": str(error)}
|