graphmind 0.6.0
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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +39 -0
- data/LICENSE +21 -0
- data/README.md +464 -0
- data/lib/graphmind/api.rb +267 -0
- data/lib/graphmind/clock.rb +49 -0
- data/lib/graphmind/edit_guard.rb +87 -0
- data/lib/graphmind/env.rb +83 -0
- data/lib/graphmind/errors.rb +40 -0
- data/lib/graphmind/gate_engine.rb +282 -0
- data/lib/graphmind/held_ledger.rb +209 -0
- data/lib/graphmind/ids.rb +52 -0
- data/lib/graphmind/integrations/ruby_llm.rb +449 -0
- data/lib/graphmind/integrations/ruby_openai.rb +388 -0
- data/lib/graphmind/integrations/support.rb +459 -0
- data/lib/graphmind/jack.rb +201 -0
- data/lib/graphmind/loop_guard.rb +516 -0
- data/lib/graphmind/protocol.rb +123 -0
- data/lib/graphmind/railtie.rb +66 -0
- data/lib/graphmind/redaction.rb +781 -0
- data/lib/graphmind/ring_buffer.rb +75 -0
- data/lib/graphmind/runtime.rb +50 -0
- data/lib/graphmind/safe.rb +48 -0
- data/lib/graphmind/session.rb +1180 -0
- data/lib/graphmind/shrink.rb +787 -0
- data/lib/graphmind/token_batcher.rb +84 -0
- data/lib/graphmind/transport.rb +331 -0
- data/lib/graphmind/version.rb +5 -0
- data/lib/graphmind/websocket.rb +448 -0
- data/lib/graphmind/wrap.rb +295 -0
- data/lib/graphmind.rb +54 -0
- metadata +83 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 65f8a0b777a314a5844f7f7d236c93026246a2c365ae227a1453f720d72a1f2c
|
|
4
|
+
data.tar.gz: 21f074893c16ac7e57a21285497393820a4317ec0d016f348fdb0722e4b05bf8
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 1aeb1bac00a67068549d9195b7b1dcbbe7d9dfb028aff2c08ab1e8290832c60cec61c403ed7238437a4ba2874a4a51247206638f8a910d54a6044adc6f03f51c
|
|
7
|
+
data.tar.gz: 49016c19497543ea0f44d216708879f497a7ceec2608d466b5a5f369f0bbcc739afd4bdff96b467cd6aba134794100f6b423b5d4ac651f00664017765f8ff2c4
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# Changelog — graphmind (Ruby)
|
|
2
|
+
|
|
3
|
+
All notable changes to the Ruby SDK. The wire protocol version is tracked
|
|
4
|
+
separately (`gm: 1`) and is shared with the TypeScript and Python SDKs.
|
|
5
|
+
|
|
6
|
+
## 0.1.0 — unreleased
|
|
7
|
+
|
|
8
|
+
First release of the Ruby SDK.
|
|
9
|
+
|
|
10
|
+
### Core
|
|
11
|
+
|
|
12
|
+
- WebSocket transport to `ws://127.0.0.1:4747/ingest` (`GRAPHMIND_URL` to
|
|
13
|
+
override): lazy connect, `hello` / `hello.ack` handshake carrying breakpoints
|
|
14
|
+
and mode, background reconnect with a fast burst after an established
|
|
15
|
+
attachment drops.
|
|
16
|
+
- Hand-rolled RFC 6455 client over `TCPSocket` — the gem has **no runtime
|
|
17
|
+
dependencies**.
|
|
18
|
+
- Gate engine that genuinely holds the calling thread, and resumes with
|
|
19
|
+
`continue` / `retry` / `inject` / `abort`.
|
|
20
|
+
- Bounded ring buffer (count + bytes, drop-oldest) with replay-on-attach.
|
|
21
|
+
- Fail-open everywhere: disconnect, `dispose`, and interpreter exit release
|
|
22
|
+
every held gate. Optional `pause_timeout:`.
|
|
23
|
+
- Kill switches: `GRAPHMIND_DISABLED=1`, production-environment detection,
|
|
24
|
+
explicit `enabled:`.
|
|
25
|
+
|
|
26
|
+
### Instrumentation
|
|
27
|
+
|
|
28
|
+
- `Graphmind.run`, `Graphmind.span`, `Graphmind.tool`, `Graphmind.wrap_tools`,
|
|
29
|
+
`Graphmind.wrap_method`, and the `Graphmind::Instrument` class macro.
|
|
30
|
+
- `ruby-openai`: `chat` and `responses.create` gated as `llm:step`, with token
|
|
31
|
+
usage and streamed deltas.
|
|
32
|
+
- `ruby_llm`: one `llm:step` per provider round-trip, plus gated
|
|
33
|
+
`RubyLLM::Tool#call` with working `inject`.
|
|
34
|
+
- Injected values are coerced into the type each call site expects.
|
|
35
|
+
|
|
36
|
+
### Rails
|
|
37
|
+
|
|
38
|
+
- A Railtie that names the app, clears the per-thread run context on
|
|
39
|
+
`executor.to_complete`, and disposes at exit.
|
data/LICENSE
ADDED
|
@@ -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.
|
data/README.md
ADDED
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
# graphmind (Ruby)
|
|
2
|
+
|
|
3
|
+
**A live debugger for AI agents.** Phoenix and Langfuse show you what your agent
|
|
4
|
+
*did*. GraphMind attaches while it is happening: your instrumented app streams
|
|
5
|
+
execution events to a local viewer, which renders the run as a live graph and
|
|
6
|
+
can **hold execution** — before an LLM step, before/after a tool call, or on
|
|
7
|
+
error — then resume with **continue / retry / inject / abort**.
|
|
8
|
+
|
|
9
|
+
Local-first (`127.0.0.1`), **zero runtime dependencies**, and it fails open: with
|
|
10
|
+
no debugger attached everything is a no-op, and a debugger that disconnects
|
|
11
|
+
mid-hold releases every gate.
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Install
|
|
16
|
+
|
|
17
|
+
```ruby
|
|
18
|
+
# Gemfile
|
|
19
|
+
group :development, :test do
|
|
20
|
+
gem "graphmind"
|
|
21
|
+
end
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
Then start the debugger (Node, from npm — the viewer and server are shared by
|
|
25
|
+
every language SDK):
|
|
26
|
+
|
|
27
|
+
```sh
|
|
28
|
+
npx graphmind-ai # serves http://127.0.0.1:4747 and opens the viewer
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Quickstart
|
|
32
|
+
|
|
33
|
+
```ruby
|
|
34
|
+
require "graphmind"
|
|
35
|
+
|
|
36
|
+
gm = Graphmind.configure(app: "support-agent")
|
|
37
|
+
|
|
38
|
+
search = gm.tool("search_flights") do |origin:, destination:|
|
|
39
|
+
Flights.search(origin, destination)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
client = gm.instrument_openai(OpenAI::Client.new(access_token: ENV.fetch("OPENAI_API_KEY")))
|
|
43
|
+
|
|
44
|
+
gm.run("handle-ticket") do
|
|
45
|
+
client.chat(parameters: {
|
|
46
|
+
model: "gpt-4o-mini",
|
|
47
|
+
messages: [{ role: "user", content: "cheapest LHR to JFK next Tuesday?" }]
|
|
48
|
+
})
|
|
49
|
+
search.call(origin: "LHR", destination: "JFK")
|
|
50
|
+
end
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
Open the viewer and you get a live graph: an `agent:handle-ticket` node, an
|
|
54
|
+
`llm:step` node with the model, prompt and token usage, and a
|
|
55
|
+
`tool:search_flights` node with its arguments and result. Click a node to set a
|
|
56
|
+
breakpoint; the next run **stops there**, and you can edit the tool's result
|
|
57
|
+
before letting it continue.
|
|
58
|
+
|
|
59
|
+
With nothing attached, every line above is a plain method call.
|
|
60
|
+
|
|
61
|
+
### Waiting for the debugger
|
|
62
|
+
|
|
63
|
+
```ruby
|
|
64
|
+
Graphmind.ready(5) # true once attached, false on timeout — never raises
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
`false` means "carry on detached". It is not an error.
|
|
68
|
+
|
|
69
|
+
---
|
|
70
|
+
|
|
71
|
+
## What you can instrument
|
|
72
|
+
|
|
73
|
+
### Tools and plain methods
|
|
74
|
+
|
|
75
|
+
Blocks:
|
|
76
|
+
|
|
77
|
+
```ruby
|
|
78
|
+
search = Graphmind.tool("search") { |query:| Index.query(query) }
|
|
79
|
+
search.call(query: "flights") # gated: before / error / after
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Any callable, keeping its shape:
|
|
83
|
+
|
|
84
|
+
```ruby
|
|
85
|
+
tools = Graphmind.wrap_tools(
|
|
86
|
+
search: ->(q) { Index.query(q) },
|
|
87
|
+
book: method(:book_flight)
|
|
88
|
+
)
|
|
89
|
+
tools[:search].call("flights")
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
One method on one object, in place:
|
|
93
|
+
|
|
94
|
+
```ruby
|
|
95
|
+
Graphmind.wrap_method(flight_service, :search)
|
|
96
|
+
flight_service.search("LHR", "JFK") # now pauses
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
Every instance of a class:
|
|
100
|
+
|
|
101
|
+
```ruby
|
|
102
|
+
class FlightTools
|
|
103
|
+
include Graphmind::Instrument
|
|
104
|
+
graphmind_tool :search, :book
|
|
105
|
+
|
|
106
|
+
def search(origin, destination) = ...
|
|
107
|
+
end
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
### Anything else: spans
|
|
111
|
+
|
|
112
|
+
```ruby
|
|
113
|
+
Graphmind.span("plan", kind: "chain", input: { goal: goal }) do |span|
|
|
114
|
+
steps = planner.plan(goal)
|
|
115
|
+
span.output = steps # what the node shows; also the span's return value
|
|
116
|
+
steps
|
|
117
|
+
end
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`kind` is any node kind in the wire contract: `agent`, `llm`, `tool`, `chain`,
|
|
121
|
+
`retriever`, `server`, `resource`, `prompt`, `custom`.
|
|
122
|
+
|
|
123
|
+
### ruby-openai
|
|
124
|
+
|
|
125
|
+
```ruby
|
|
126
|
+
client = Graphmind.instrument_openai(OpenAI::Client.new(access_token: ...))
|
|
127
|
+
client.chat(parameters: { model: "gpt-4o-mini", messages: [...] })
|
|
128
|
+
client.responses.create(parameters: { model: "gpt-4o-mini", input: "hi" })
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
One `llm:step` node per call, with the model, every message in full, the
|
|
132
|
+
sampling parameters, the tools by schema hash, the reply text, the requested tool
|
|
133
|
+
calls, the normalized finish reason and token usage. Streaming (`stream:`) forwards deltas to the canvas and still
|
|
134
|
+
calls your own handler with the arity it declared.
|
|
135
|
+
|
|
136
|
+
The module is prepended to **that client's singleton class**: nothing global is
|
|
137
|
+
monkey-patched, and another client in the same process is untouched.
|
|
138
|
+
|
|
139
|
+
### ruby_llm
|
|
140
|
+
|
|
141
|
+
```ruby
|
|
142
|
+
chat = Graphmind.instrument_ruby_llm(RubyLLM.chat.with_tools(Weather))
|
|
143
|
+
chat.ask("what's the weather in Cairo?")
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
One `llm:step` node per provider round-trip (not per `ask`), plus a
|
|
147
|
+
`tool:<name>` node per `RubyLLM::Tool#call` — where `inject` genuinely replaces
|
|
148
|
+
the tool result the model sees next.
|
|
149
|
+
|
|
150
|
+
**Token usage is inclusive** (0.6+) in both integrations: `inputTokens` is the
|
|
151
|
+
whole prompt, cached tokens included, and the usage carries `inclusive: true`.
|
|
152
|
+
ruby_llm counts input WITHOUT cached tokens (2.0's `tokens.input` is "standard,
|
|
153
|
+
non-cached"), so the cache reads and writes are added back; `cacheReadTokens`,
|
|
154
|
+
`cacheWriteTokens` and `reasoningTokens` appear only when reported.
|
|
155
|
+
|
|
156
|
+
### Injecting values from the viewer
|
|
157
|
+
|
|
158
|
+
Whatever you type in the viewer arrives as JSON, so the integrations coerce it
|
|
159
|
+
into what the call site expects:
|
|
160
|
+
|
|
161
|
+
| Gate | Type a string and you get |
|
|
162
|
+
| --- | --- |
|
|
163
|
+
| `tool:*` (blocks, methods, `RubyLLM::Tool`) | the string, as the tool's return value |
|
|
164
|
+
| ruby-openai `llm:step` | a `chat.completion`-shaped Hash whose message content is your string |
|
|
165
|
+
| ruby_llm `llm:step` | a `RubyLLM::Message` with `role: :assistant` |
|
|
166
|
+
|
|
167
|
+
An object works too and is passed through / built as-is.
|
|
168
|
+
|
|
169
|
+
**Refused, whatever the debugger (0.6+):** a value that still holds
|
|
170
|
+
`"__REDACTED__"` or a truncated preview — a hidden or shortened *recording*
|
|
171
|
+
pasted back, not a result anyone meant to substitute — is never injected. The
|
|
172
|
+
call stays paused and the debugger shows why (`exec.refused`; an older debugger
|
|
173
|
+
that cannot show it gets a line in your log instead). Inject the full value, or
|
|
174
|
+
continue, retry or abort. This gem does not run calls with **edited
|
|
175
|
+
arguments** (the TypeScript and Python SDKs do): an edit sent to a Ruby app is
|
|
176
|
+
refused the same way and the call stays paused. Every answer echoes the
|
|
177
|
+
debugger's `requestId`, and the gem says so in its handshake (`request-id`),
|
|
178
|
+
so the debugger never credits a release the app made on its own (a pause
|
|
179
|
+
timeout) to someone's resume.
|
|
180
|
+
|
|
181
|
+
---
|
|
182
|
+
|
|
183
|
+
## Capability matrix
|
|
184
|
+
|
|
185
|
+
| Surface | Node | `before` | `after` | `error` | `inject` | `retry` | `abort` | Streaming |
|
|
186
|
+
| --- | --- | :-: | :-: | :-: | :-: | :-: | :-: | :-: |
|
|
187
|
+
| `Graphmind.tool` / `wrap_tools` / `wrap_method` / `Graphmind::Instrument` | `tool:<name>` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
|
|
188
|
+
| `Graphmind.span` | `<kind>:<name>` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
|
|
189
|
+
| `Graphmind.run` | `agent:<name>` | — | — | — | — | — | ✅¹ | — |
|
|
190
|
+
| ruby-openai `chat` / `responses.create` | `llm:step` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ observe |
|
|
191
|
+
| ruby_llm provider round-trip | `llm:step` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ⚠️² |
|
|
192
|
+
| ruby_llm `Tool#call` | `tool:<name>` | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | — |
|
|
193
|
+
|
|
194
|
+
¹ A run has no gate of its own; `abort` at any gate inside it marks the run
|
|
195
|
+
aborted and raises `Graphmind::AbortError`.
|
|
196
|
+
² A streaming `ask` is gated like any other round-trip, but individual deltas
|
|
197
|
+
are not forwarded to the canvas yet (ruby-openai's are).
|
|
198
|
+
|
|
199
|
+
`after` gates fire only in step mode or with an explicit `after` breakpoint
|
|
200
|
+
(this matches the TypeScript and Python SDKs).
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Rails
|
|
205
|
+
|
|
206
|
+
Add the gem and you get a Railtie. It does three things:
|
|
207
|
+
|
|
208
|
+
1. names the app after your Rails application;
|
|
209
|
+
2. clears the per-thread run context via `Rails.application.executor.to_complete`
|
|
210
|
+
— Puma reuses threads, so a run left behind by a request that died mid-flight
|
|
211
|
+
would otherwise adopt the *next* request's events;
|
|
212
|
+
3. releases held gates and closes the socket at exit.
|
|
213
|
+
|
|
214
|
+
Configure it from `config/application.rb` or an initializer:
|
|
215
|
+
|
|
216
|
+
```ruby
|
|
217
|
+
# config/initializers/graphmind.rb
|
|
218
|
+
Rails.application.configure do
|
|
219
|
+
config.graphmind.enabled = Rails.env.development?
|
|
220
|
+
config.graphmind.app = "support-agent"
|
|
221
|
+
config.graphmind.url = "ws://127.0.0.1:4747/ingest" # or GRAPHMIND_URL
|
|
222
|
+
end
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
Every key is passed to `Graphmind.configure`. A key it does not understand is a
|
|
226
|
+
warning at boot, never a failed deploy. `config.graphmind.autoconfigure = false`
|
|
227
|
+
leaves configuration entirely to you.
|
|
228
|
+
|
|
229
|
+
Without Rails, do the same thing in one line at boot:
|
|
230
|
+
|
|
231
|
+
```ruby
|
|
232
|
+
Graphmind.configure(app: "my-agent", enabled: ENV["RACK_ENV"] != "production")
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
### Threads: Puma, Sidekiq, and friends
|
|
236
|
+
|
|
237
|
+
The run context lives in **fiber-local storage** (`Thread.current[:graphmind_run_context]`).
|
|
238
|
+
Concretely:
|
|
239
|
+
|
|
240
|
+
* **Puma / Falcon / any threaded server** — each request runs on its own thread
|
|
241
|
+
(its root fiber), so `Graphmind.run` inside a controller action is scoped to
|
|
242
|
+
exactly that request. Two concurrent requests get two runs and never see each
|
|
243
|
+
other's context. Verified in `test/test_threading.rb`.
|
|
244
|
+
* **Sidekiq** — a job is a thread. Wrap `perform` in `Graphmind.run("MyJob")`
|
|
245
|
+
and the whole job is one run.
|
|
246
|
+
* **A gate holds only the thread that hit it.** Other Puma threads keep serving
|
|
247
|
+
while one request sits at a breakpoint. This is the single most important
|
|
248
|
+
property to understand: pausing a request in a threaded server does *not*
|
|
249
|
+
freeze the server.
|
|
250
|
+
* **Threads you spawn yourself do not inherit the run context** (this is how
|
|
251
|
+
Ruby's fiber-locals work). Carry it explicitly:
|
|
252
|
+
|
|
253
|
+
```ruby
|
|
254
|
+
Graphmind.run("batch") do |ctx|
|
|
255
|
+
items.map { |i| Thread.new { Graphmind.with_run_context(ctx) { process(i) } } }.each(&:join)
|
|
256
|
+
end
|
|
257
|
+
```
|
|
258
|
+
|
|
259
|
+
The ruby_llm integration does this for you when ruby_llm runs tool calls on a
|
|
260
|
+
thread pool.
|
|
261
|
+
* **Forking servers (Puma clustered, Unicorn, Resque)** — GraphMind's ids are
|
|
262
|
+
re-seeded after a fork, and each worker opens its own WebSocket. Every worker
|
|
263
|
+
shows up as its own client in the viewer.
|
|
264
|
+
* **The transport never blocks your threads.** One background thread owns the
|
|
265
|
+
socket and one drains a bounded outbox; `emit` is a serialize plus an enqueue
|
|
266
|
+
(~2 µs measured, see below).
|
|
267
|
+
|
|
268
|
+
---
|
|
269
|
+
|
|
270
|
+
## Recording less, and the loop hold (0.5)
|
|
271
|
+
|
|
272
|
+
**Recording less.** Four kill switches replace values with `"__REDACTED__"` inside the gem, before the
|
|
273
|
+
event is buffered or sent, so a debugger that attaches late, the database and every export only ever
|
|
274
|
+
see the placeholder. Set them in the environment of the instrumented app, or as options:
|
|
275
|
+
|
|
276
|
+
```ruby
|
|
277
|
+
Graphmind.configure(hide_inputs: true, hide_outputs: true, hide_tool_args: true, hide_tool_results: true)
|
|
278
|
+
```
|
|
279
|
+
|
|
280
|
+
| Switch | Replaces |
|
|
281
|
+
| --- | --- |
|
|
282
|
+
| `GRAPHMIND_HIDE_INPUTS` / `hide_inputs:` | every node's `input`; streamed tool-argument deltas are emptied |
|
|
283
|
+
| `GRAPHMIND_HIDE_OUTPUTS` / `hide_outputs:` | every node's `output`; every streamed delta is emptied (`v: ""`, `chars` kept) |
|
|
284
|
+
| `GRAPHMIND_HIDE_TOOL_ARGS` / `hide_tool_args:` | tool nodes' `input` only |
|
|
285
|
+
| `GRAPHMIND_HIDE_TOOL_RESULTS` / `hide_tool_results:` | tool nodes' `output` only |
|
|
286
|
+
|
|
287
|
+
Any env value except empty, `0`, `false`, `off` and `no` (any case) turns a switch on — an unexpected spelling such as `yes` errs towards hiding; options accept `true`, `1` and the same strings. Either source turning a switch
|
|
288
|
+
on turns it on. Hashes with Symbol keys are redacted too. The same keywords work on
|
|
289
|
+
`Graphmind::Session.new`; `session.redaction_switches` shows what is in effect.
|
|
290
|
+
|
|
291
|
+
**The loop hold.** While a debugger is attached, the third call of the same tool with identical
|
|
292
|
+
arguments made **back-to-back** — no other tool call in between — is held before it runs (`exec.paused` with `reason: "loop"`). It applies to anything
|
|
293
|
+
that goes through `Graphmind::Wrap` — `gm.tool`, `gm.wrap`, `wrap_tools`, `wrap_method`, spans, and the
|
|
294
|
+
ruby_openai / ruby_llm tool paths — and holds threads individually (per Puma worker). Detached, or with
|
|
295
|
+
`GRAPHMIND_ON_LOOP=warn`, nothing is held and one line per streak is logged, naming the tool but never
|
|
296
|
+
its arguments.
|
|
297
|
+
|
|
298
|
+
```ruby
|
|
299
|
+
Graphmind.configure(app: "shop", loop_guard: { threshold: 5, mode: "warn", allow_nodes: [:poll_job] }) # or loop_guard: false
|
|
300
|
+
```
|
|
301
|
+
|
|
302
|
+
Back-to-back is per run: calling any other tool, or the same tool with other arguments, starts the
|
|
303
|
+
count again, while LLM steps in between do not (model → tool → model → tool with identical calls is
|
|
304
|
+
still a loop) and tools in `allow_nodes` / `GRAPHMIND_LOOP_ALLOW` are invisible. So a constant-argument
|
|
305
|
+
tool called now and again across a long session is never held. Accepted limit: an agent alternating
|
|
306
|
+
between two tools (search, read, search, read) is not held.
|
|
307
|
+
|
|
308
|
+
Env: `GRAPHMIND_LOOP_THRESHOLD` (default 3, `0` off), `GRAPHMIND_ON_LOOP=pause|warn|off`,
|
|
309
|
+
`GRAPHMIND_LOOP_ALLOW=poll_job,tool:heartbeat`. Symbol, String and camelCase option keys are accepted.
|
|
310
|
+
"Identical" means canonical JSON: Symbol and String keys and values count as the same, structs and other
|
|
311
|
+
`#to_h` objects compare by `to_h`, other objects by `#inspect`, MCP's `_meta` is ignored and pagination
|
|
312
|
+
keys are not; the fingerprint uses the full arguments even though the recorded input is truncated at 200
|
|
313
|
+
items. Under `hide_inputs` (or `hide_tool_args` on a tool) the fingerprint is sent as `"__REDACTED__"`.
|
|
314
|
+
`session.loop_guard_config` shows the resolved settings.
|
|
315
|
+
|
|
316
|
+
**Limits.**
|
|
317
|
+
- `node.error` (exception messages and tracebacks) is never redacted — error text can echo data.
|
|
318
|
+
- Redaction fails closed: if a payload cannot be inspected safely while a switch is on (a mapping whose
|
|
319
|
+
reads raise, a malformed payload), the event is sent with input/output/token text hidden and
|
|
320
|
+
`redaction.failed: true`, or dropped with one warning if even its identity fields are unreadable. The
|
|
321
|
+
raw value is never sent and nothing is raised into your app.
|
|
322
|
+
- The tool-only switches hide the tool node's own input/output. In an agent loop the same values also
|
|
323
|
+
travel through the model's messages; set `GRAPHMIND_HIDE_INPUTS` and `GRAPHMIND_HIDE_OUTPUTS` to keep
|
|
324
|
+
them out entirely.
|
|
325
|
+
- Hidden streamed text keeps its length (`chars`, counted in UTF-16 units like JavaScript).
|
|
326
|
+
- Tools that legitimately poll with byte-identical arguments need `allow_nodes` or `GRAPHMIND_LOOP_ALLOW`.
|
|
327
|
+
- LLM steps are not watched unless `kinds` includes `"llm"` (each kind keeps its own streak); identical
|
|
328
|
+
calls made in parallel are back-to-back too, so they count.
|
|
329
|
+
- Cross-language equivalence is guaranteed for JSON-shaped arguments and checked against the TypeScript
|
|
330
|
+
client by two shared fixtures and a generated differential file.
|
|
331
|
+
- Ruby strings cannot hold unpaired UTF-16 surrogates; such values are treated as unreadable (never a repeat).
|
|
332
|
+
|
|
333
|
+
## Kill switches and safety
|
|
334
|
+
|
|
335
|
+
| Setting | Effect |
|
|
336
|
+
| --- | --- |
|
|
337
|
+
| `GRAPHMIND_DISABLED=1` | Off, always. Beats an explicit `enabled: true` in code. |
|
|
338
|
+
| production-looking env | Off unless `GRAPHMIND=1`. |
|
|
339
|
+
| `enabled: false` | Off. |
|
|
340
|
+
| `GRAPHMIND_URL` | Where to dial (default `ws://127.0.0.1:4747/ingest`). |
|
|
341
|
+
|
|
342
|
+
"Production-looking" is a boring, documented rule: the first variable that is
|
|
343
|
+
*set* out of `GRAPHMIND_ENV`, `ENVIRONMENT`, `APP_ENV`, `RAILS_ENV`, `RACK_ENV`,
|
|
344
|
+
`ENV`, `NODE_ENV` decides, and it counts as production when its value is
|
|
345
|
+
`production` or `prod`. No hostname or cloud-metadata guessing.
|
|
346
|
+
|
|
347
|
+
The invariants, all covered by tests:
|
|
348
|
+
|
|
349
|
+
* **Never raises into your app.** Internal failures degrade to a no-op plus one
|
|
350
|
+
rate-limited warning per problem per minute. Your own exceptions propagate
|
|
351
|
+
untouched; the only exception GraphMind adds is `Graphmind::AbortError`, and
|
|
352
|
+
only when you asked for it in the viewer.
|
|
353
|
+
* **Fails open.** Disconnect, dispose, or interpreter exit releases every held
|
|
354
|
+
gate with `continue`. There is also an optional `pause_timeout:`.
|
|
355
|
+
* **Bounded memory.** Events buffer in a ring (2,000 frames / 8 MiB by default,
|
|
356
|
+
drop-oldest) for replay when the debugger attaches. Anything dropped while
|
|
357
|
+
detached is counted and warned about.
|
|
358
|
+
* **Local only.** It dials `127.0.0.1`; it never listens, and it opens no port.
|
|
359
|
+
|
|
360
|
+
---
|
|
361
|
+
|
|
362
|
+
## Measured overhead
|
|
363
|
+
|
|
364
|
+
From `test/test_overhead.rb` on the machine this gem was developed on (Apple
|
|
365
|
+
Silicon, Ruby 3.3.12) — run it yourself with
|
|
366
|
+
`GRAPHMIND_REPORT_OVERHEAD=1 rake test`:
|
|
367
|
+
|
|
368
|
+
| Path | Cost |
|
|
369
|
+
| --- | --- |
|
|
370
|
+
| Gate, GraphMind disabled | **0.07 µs** per call |
|
|
371
|
+
| Gate, enabled but detached | **0.12 µs** per call |
|
|
372
|
+
| Wrapped tool call, disabled (added over the raw call) | **1.0 µs** |
|
|
373
|
+
| `emit` while detached (serialize + ring buffer, no socket) | **2.0 µs** |
|
|
374
|
+
|
|
375
|
+
The tests assert loose ceilings (5 µs / 10 µs / 100 µs) so they catch a
|
|
376
|
+
regression in kind — a lock or an allocation creeping onto the fast path —
|
|
377
|
+
without failing on a noisy CI box.
|
|
378
|
+
|
|
379
|
+
---
|
|
380
|
+
|
|
381
|
+
## The WebSocket client
|
|
382
|
+
|
|
383
|
+
The gem speaks RFC 6455 itself, over `TCPSocket`, in ~350 lines
|
|
384
|
+
(`lib/graphmind/websocket.rb`). That is a deliberate trade:
|
|
385
|
+
|
|
386
|
+
* **Zero runtime dependencies.** A debugger that you add to someone else's
|
|
387
|
+
Gemfile must not force a resolution on `faraday`, `websocket-driver` or
|
|
388
|
+
`eventmachine`. Dependency conflicts are the number one reason a dev tool
|
|
389
|
+
never gets installed.
|
|
390
|
+
* **The client half of RFC 6455 for one local link is small**: one masked text
|
|
391
|
+
opcode, ping/pong, close, and fragment reassembly.
|
|
392
|
+
* **Scope, stated plainly**: client role, text frames, `ws://` and `wss://`, no
|
|
393
|
+
`permessage-deflate`, no subprotocol negotiation, no HTTP proxy support.
|
|
394
|
+
|
|
395
|
+
Because a fake viewer built on the same codec could hide a symmetric bug,
|
|
396
|
+
`test/live_server_check.rb` (`rake live`) boots the real Node server
|
|
397
|
+
(`graphmind serve`, which uses `ws`) and drives it with this gem end to end —
|
|
398
|
+
handshake, ingest, a genuine hold at the server's default pause-on-error
|
|
399
|
+
breakpoint, and fail-open when the server is killed.
|
|
400
|
+
|
|
401
|
+
---
|
|
402
|
+
|
|
403
|
+
## Verified on
|
|
404
|
+
|
|
405
|
+
* **Ruby 3.3.12** (arm64-darwin) — the full suite (290 tests), the ruby-openai
|
|
406
|
+
and ruby_llm integrations, the Rails 8.1 Railtie, and the live cross-check
|
|
407
|
+
against the real `graphmind serve`.
|
|
408
|
+
* **The built gem**, installed into a clean `GEM_HOME` containing nothing but
|
|
409
|
+
Ruby's default gems, driven against the real server.
|
|
410
|
+
* **One real `gpt-4o-mini` call** through the instrumented `ruby-openai` client:
|
|
411
|
+
the `llm:step` node recorded the reply and the provider's own token usage.
|
|
412
|
+
* **ruby-openai 8.3.0**, and **ruby_llm 1.16.0 and 2.0.0** — CI runs the suite
|
|
413
|
+
against the newest ruby_llm and, in its own cell, the last 1.x.
|
|
414
|
+
* **railties 8.1.3.1**.
|
|
415
|
+
|
|
416
|
+
The gemspec allows **Ruby >= 3.1** because that is the oldest release whose
|
|
417
|
+
syntax and stdlib this gem uses (endless methods, argument forwarding,
|
|
418
|
+
`io/wait`). CI runs the full suite on **3.1** (the floor) and **3.3** on every
|
|
419
|
+
push; **3.2 is not executed**. If you run it there and something breaks, that
|
|
420
|
+
is a bug worth filing.
|
|
421
|
+
|
|
422
|
+
---
|
|
423
|
+
|
|
424
|
+
## Limitations
|
|
425
|
+
|
|
426
|
+
* **`after`-gate injection replaces the return value, not side effects.** If
|
|
427
|
+
your tool already wrote to the database, `inject` changes what the caller
|
|
428
|
+
sees, not what happened.
|
|
429
|
+
* **ruby_llm streaming deltas are not forwarded** to the canvas yet; the call is
|
|
430
|
+
still gated and recorded. ruby-openai streaming *is* forwarded.
|
|
431
|
+
* **ruby_llm's LLM gate hooks a private method** (`provider_completion`) to get
|
|
432
|
+
one node per HTTP round-trip. If a future ruby_llm renames it, the gem falls
|
|
433
|
+
back to `complete_once` and then to the public `complete` (one coarser node
|
|
434
|
+
per turn). The hook actually used is shown on the node as `hook`. Every patch
|
|
435
|
+
forwards whatever arguments it is given — 2.0 changed these signatures, and a
|
|
436
|
+
patch that pinned the 1.x one raised inside the user's chat.
|
|
437
|
+
* **No `run.gap` marker.** Events dropped while the debugger was unreachable are
|
|
438
|
+
counted and warned about, but the viewer is not told where the hole was (the
|
|
439
|
+
TypeScript client does send a marker; this gem does not yet).
|
|
440
|
+
* **No Anthropic or LangChain.rb integration** — use `Graphmind.tool` /
|
|
441
|
+
`Graphmind.span`, which gate anything.
|
|
442
|
+
* **No `Graphmind.ready` equivalent for "wait for a breakpoint"** — `ready`
|
|
443
|
+
waits for the handshake, not for a pause.
|
|
444
|
+
* **Fiber-scheduler-based servers** (`async`, Falcon) are correct by
|
|
445
|
+
construction because the context is fiber-local, but were not load-tested.
|
|
446
|
+
|
|
447
|
+
---
|
|
448
|
+
|
|
449
|
+
## Development
|
|
450
|
+
|
|
451
|
+
```sh
|
|
452
|
+
bundle install
|
|
453
|
+
rake test # 95 tests, no network beyond loopback
|
|
454
|
+
rake live # cross-check against a real `graphmind serve` (needs Node)
|
|
455
|
+
gem build graphmind.gemspec
|
|
456
|
+
```
|
|
457
|
+
|
|
458
|
+
The test suite validates **every emitted envelope** against
|
|
459
|
+
`packages/schema/schema.json`, the same wire contract the TypeScript and Python
|
|
460
|
+
SDKs are checked against.
|
|
461
|
+
|
|
462
|
+
## License
|
|
463
|
+
|
|
464
|
+
MIT. See [LICENSE](LICENSE).
|