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 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
@@ -0,0 +1,5 @@
1
+ """Single source of truth for the package version (mirrors pyproject.toml)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
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)}