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.
@@ -0,0 +1,295 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "clock"
4
+ require_relative "errors"
5
+ require_relative "gate_engine"
6
+ require_relative "ids"
7
+
8
+ module Graphmind
9
+ # Gating a plain callable — where `inject` and `retry` actually work.
10
+ #
11
+ # A callback or a patched provider client can *observe* and *hold*, but only
12
+ # a wrapper that owns the call site can substitute a result. So this is the
13
+ # sharp end of the debugger:
14
+ #
15
+ # * `before` gate before the body runs — `inject` returns the debugger's
16
+ # value instead of calling it at all;
17
+ # * `error` gate when the body raises, BEFORE the caller sees the error —
18
+ # `inject` swallows the error and returns a value, `retry` re-runs the
19
+ # body, `continue` re-raises the original, `abort` aborts the run;
20
+ # * `after` gate post-body, pre-return (fires only in step mode or with an
21
+ # explicit `after` breakpoint — decisions.md #2).
22
+ #
23
+ # Gates hold the CALLING thread, so a wrapped tool called from a Puma worker
24
+ # holds exactly that request and nothing else.
25
+ module Wrap
26
+ MAX_ARGS_PREVIEW = 32
27
+
28
+ module_function
29
+
30
+ # The core gated invocation. `body` is the real work.
31
+ #
32
+ # `output_for` maps the body's result to what the viewer should show (an
33
+ # LLM response object is not something you want rendered raw), and
34
+ # `finish_extra` contributes extra node.finished fields (token usage).
35
+ # Both are optional and never allowed to break the call.
36
+ # `inject_as` adapts a value the debugger injected — which arrived as
37
+ # plain JSON over the socket — into whatever the call site expects
38
+ # (a provider response object, say). Without it, `inject` is only usable
39
+ # where the host already speaks Hash/String.
40
+ # `full_input: true` records `input` as given — it must already be
41
+ # JSON-safe (Integrations::Support.record) — instead of through the
42
+ # session's bounded preview: an LLM prompt is recorded in full (contract
43
+ # C1; the 512 KB per-event shrink is the only bound).
44
+ def invoke(session, node_id:, kind:, name:, parent_id: nil, input: nil, output_for: nil,
45
+ finish_extra: nil, inject_as: nil, full_input: false, &body)
46
+ return body.call if session.nil? || !session.enabled? || session.disposed?
47
+
48
+ instance_id = Ids.next_id("call")
49
+ # exec.paused names this execution (parallel calls of one tool stay apart).
50
+ node = GateNode.new(node_id, kind, name, instance_id)
51
+ ctx = session.current_run
52
+ started = Clock.now_ms
53
+ if full_input && !input.nil?
54
+ session.start_node(node_id: node_id, kind: kind, name: name, instance_id: instance_id,
55
+ parent_id: parent_id, extra: { "input" => input })
56
+ else
57
+ session.start_node(node_id: node_id, kind: kind, name: name, instance_id: instance_id,
58
+ parent_id: parent_id, input: input)
59
+ end
60
+
61
+ finish = lambda do |output, status, extra = nil|
62
+ session.finish_node(
63
+ node_id: node_id,
64
+ instance_id: instance_id,
65
+ duration_ms: Clock.elapsed_ms(started),
66
+ status: status,
67
+ output: output,
68
+ extra: extra
69
+ )
70
+ end
71
+
72
+ loop do
73
+ pre = session.gate("before", node)
74
+ if pre.abort?
75
+ finish.call(nil, "aborted")
76
+ raise session.abort_error(ctx)
77
+ end
78
+ if pre.inject?
79
+ finish.call(pre.output, "ok", { "injected" => true })
80
+ return coerce_injected(inject_as, pre.output)
81
+ end
82
+ # `retry` before execution is equivalent to continue.
83
+
84
+ begin
85
+ result = body.call
86
+ rescue Exception => e # rubocop:disable Lint/RescueException
87
+ unless gateable_error?(e)
88
+ finish.call(nil, Errors.abort_error?(e) ? "aborted" : "error")
89
+ raise
90
+ end
91
+ session.error_node(node_id, instance_id, e)
92
+ decision = session.gate("error", node)
93
+ if decision.inject?
94
+ finish.call(decision.output, "ok",
95
+ { "injected" => true, "recoveredFromError" => true })
96
+ return coerce_injected(inject_as, decision.output)
97
+ end
98
+ next if decision.retry?
99
+
100
+ if decision.abort?
101
+ finish.call(nil, "aborted")
102
+ raise session.abort_error(ctx)
103
+ end
104
+ finish.call(nil, "error")
105
+ raise
106
+ end
107
+
108
+ post = session.gate("after", node)
109
+ if post.inject?
110
+ finish.call(post.output, "ok", { "injected" => true })
111
+ return coerce_injected(inject_as, post.output)
112
+ end
113
+ next if post.retry?
114
+
115
+ output = output_for ? project(output_for, result) : result
116
+ if post.abort?
117
+ finish.call(output, "aborted")
118
+ raise session.abort_error(ctx)
119
+ end
120
+
121
+ finish.call(output, "ok", project(finish_extra, result))
122
+ return result
123
+ end
124
+ end
125
+
126
+ # Adapt an injected JSON value to the host's expected type. A coercion
127
+ # that fails falls back to the raw value rather than breaking the call.
128
+ def coerce_injected(coercer, value)
129
+ return value if coercer.nil?
130
+
131
+ coercer.call(value)
132
+ rescue StandardError
133
+ value
134
+ end
135
+
136
+ # A viewer-facing projection must never break the host's call.
137
+ def project(mapper, result)
138
+ return nil if mapper.nil?
139
+
140
+ mapper.call(result)
141
+ rescue StandardError
142
+ nil
143
+ end
144
+
145
+ # Only ordinary errors are gated. A debugger-driven abort surfacing from
146
+ # the body is terminal, and Interrupt / SystemExit / SignalException must
147
+ # never be held.
148
+ def gateable_error?(error)
149
+ return false unless error.is_a?(StandardError)
150
+
151
+ !Errors.abort_error?(error)
152
+ end
153
+
154
+ # A readable `input` payload for the viewer, keyed by parameter name when
155
+ # the callable exposes one.
156
+ def describe(parameters, args, kwargs)
157
+ out = {}
158
+ index = 0
159
+ Array(parameters).each do |kind, pname|
160
+ case kind
161
+ when :req, :opt
162
+ break if index >= args.length
163
+
164
+ out[pname.to_s] = args[index]
165
+ index += 1
166
+ when :rest
167
+ rest = args[index..] || []
168
+ out[(pname || :args).to_s] = rest unless rest.empty?
169
+ index = args.length
170
+ end
171
+ end
172
+ leftover = args[index..] || []
173
+ out["args"] = leftover.first(MAX_ARGS_PREVIEW) unless leftover.empty?
174
+ kwargs.each { |k, v| out[k.to_s] = v } if kwargs.is_a?(Hash)
175
+ out
176
+ rescue StandardError
177
+ { "args" => Array(args).first(MAX_ARGS_PREVIEW) }
178
+ end
179
+
180
+ def callable_name(callable)
181
+ return callable.name.to_s if callable.respond_to?(:name) && callable.name
182
+
183
+ "callable"
184
+ rescue StandardError
185
+ "callable"
186
+ end
187
+
188
+ def parameters_of(callable)
189
+ callable.respond_to?(:parameters) ? callable.parameters : []
190
+ rescue StandardError
191
+ []
192
+ end
193
+ end
194
+
195
+ # A callable wrapped with GraphMind gates. Quacks like the original: #call,
196
+ # #to_proc, #arity, and `.()`.
197
+ class GatedCallable
198
+ attr_reader :name, :node_id, :kind, :original
199
+
200
+ def initialize(callable, session_of, name: nil, kind: "tool", node_id: nil, parent_id: nil)
201
+ @original = callable
202
+ @session_of = session_of
203
+ @name = (name || Wrap.callable_name(callable)).to_s
204
+ @kind = kind
205
+ @node_id = node_id || Ids.tool_node_id(@name)
206
+ @parent_id = parent_id
207
+ @parameters = Wrap.parameters_of(callable)
208
+ end
209
+
210
+ def call(*args, **kwargs, &block)
211
+ session = @session_of.call
212
+ body = -> { kwargs.empty? ? @original.call(*args, &block) : @original.call(*args, **kwargs, &block) }
213
+ return body.call if session.nil? || !session.enabled? || session.disposed?
214
+
215
+ Wrap.invoke(
216
+ session,
217
+ node_id: @node_id,
218
+ kind: @kind,
219
+ name: @name,
220
+ parent_id: @parent_id,
221
+ input: Wrap.describe(@parameters, args, kwargs),
222
+ &body
223
+ )
224
+ end
225
+
226
+ alias [] call
227
+ alias === call
228
+
229
+ def to_proc
230
+ method(:call).to_proc
231
+ end
232
+
233
+ def arity
234
+ @original.respond_to?(:arity) ? @original.arity : -1
235
+ end
236
+
237
+ def parameters = @parameters
238
+ def graphmind_wrapped? = true
239
+
240
+ def respond_to_missing?(symbol, include_private = false)
241
+ @original.respond_to?(symbol, include_private) || super
242
+ end
243
+
244
+ def method_missing(symbol, ...)
245
+ return @original.public_send(symbol, ...) if @original.respond_to?(symbol)
246
+
247
+ super
248
+ end
249
+ end
250
+
251
+ module Wrap
252
+ module_function
253
+
254
+ def gate_callable(callable, session_of, name: nil, kind: "tool", node_id: nil, parent_id: nil)
255
+ return callable if callable.is_a?(GatedCallable)
256
+
257
+ GatedCallable.new(callable, session_of, name: name, kind: kind, node_id: node_id,
258
+ parent_id: parent_id)
259
+ end
260
+
261
+ def wrapped?(callable)
262
+ callable.is_a?(GatedCallable) ||
263
+ (callable.respond_to?(:graphmind_wrapped?) && callable.graphmind_wrapped?)
264
+ end
265
+
266
+ # Wrap a Hash of {name => callable}, an Array of callables, or a single
267
+ # callable. Shape in, shape out.
268
+ def wrap_tools(tools, session_of)
269
+ case tools
270
+ when Hash
271
+ tools.each_with_object({}) do |(key, fn), out|
272
+ out[key] = wrapped?(fn) ? fn : gate_callable(fn, session_of, name: key.to_s)
273
+ end
274
+ when Array
275
+ tools.map { |fn| wrapped?(fn) ? fn : gate_callable(fn, session_of) }
276
+ else
277
+ raise ArgumentError, "wrap_tools expects a callable, an Array, or a {name => callable} Hash" unless tools.respond_to?(:call)
278
+
279
+ gate_callable(tools, session_of)
280
+ end
281
+ end
282
+
283
+ # Gate one method on one object, in place. The object keeps its API; the
284
+ # method now pauses.
285
+ def wrap_method(target, method_name, session_of, name: nil, kind: "tool", parent_id: nil)
286
+ original = target.method(method_name)
287
+ gated = gate_callable(original, session_of, name: name || method_name.to_s, kind: kind,
288
+ parent_id: parent_id)
289
+ target.define_singleton_method(method_name) do |*args, **kwargs, &block|
290
+ gated.call(*args, **kwargs, &block)
291
+ end
292
+ target
293
+ end
294
+ end
295
+ end
data/lib/graphmind.rb ADDED
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ # GraphMind — a live debugger for AI agents.
4
+ #
5
+ # Phoenix and Langfuse show you what your agent *did*. GraphMind attaches while
6
+ # it is happening: an instrumented app streams execution events to a local
7
+ # viewer, which renders the run as a live graph and can HOLD execution —
8
+ # before an LLM step, before/after a tool call, or on error — then resume with
9
+ # continue / retry / inject / abort.
10
+ #
11
+ # Quickstart:
12
+ #
13
+ # require "graphmind"
14
+ #
15
+ # gm = Graphmind.configure(app: "support-agent")
16
+ # client = gm.instrument_openai(OpenAI::Client.new(access_token: ENV["OPENAI_API_KEY"]))
17
+ #
18
+ # search = gm.tool("search_flights") { |origin:, destination:| Flights.search(origin, destination) }
19
+ #
20
+ # gm.run("handle-ticket") do
21
+ # client.chat(parameters: { model: "gpt-4o-mini", messages: [...] })
22
+ # end
23
+ #
24
+ # Then run `npx graphmind-ai` and open http://127.0.0.1:4747. With nothing
25
+ # attached, all of the above is a no-op.
26
+ #
27
+ # Everything fails open: no debugger means near-zero overhead, and a debugger
28
+ # that disconnects mid-hold auto-continues every held gate.
29
+ module Graphmind
30
+ end
31
+
32
+ require_relative "graphmind/version"
33
+ require_relative "graphmind/errors"
34
+ require_relative "graphmind/env"
35
+ require_relative "graphmind/ids"
36
+ require_relative "graphmind/protocol"
37
+ require_relative "graphmind/safe"
38
+ require_relative "graphmind/ring_buffer"
39
+ require_relative "graphmind/gate_engine"
40
+ require_relative "graphmind/clock"
41
+ require_relative "graphmind/held_ledger"
42
+ require_relative "graphmind/redaction"
43
+ require_relative "graphmind/loop_guard"
44
+ require_relative "graphmind/shrink"
45
+ require_relative "graphmind/edit_guard"
46
+ require_relative "graphmind/websocket"
47
+ require_relative "graphmind/transport"
48
+ require_relative "graphmind/token_batcher"
49
+ require_relative "graphmind/runtime"
50
+ require_relative "graphmind/session"
51
+ require_relative "graphmind/wrap"
52
+ require_relative "graphmind/api"
53
+
54
+ require_relative "graphmind/railtie" if defined?(::Rails::Railtie)
metadata ADDED
@@ -0,0 +1,83 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: graphmind
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.6.0
5
+ platform: ruby
6
+ authors:
7
+ - GraphMind
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies: []
12
+ description: |
13
+ GraphMind attaches to an agent while it is running. Your instrumented app
14
+ streams execution events to a local viewer, which renders the run as a live
15
+ graph and can hold execution — before an LLM step, before/after a tool call,
16
+ or on error — then resume with continue / retry / inject / abort.
17
+
18
+ Local-first (127.0.0.1), zero runtime dependencies, fails open: with no
19
+ debugger attached everything is a no-op, and a debugger that disconnects
20
+ mid-hold releases every gate.
21
+ executables: []
22
+ extensions: []
23
+ extra_rdoc_files: []
24
+ files:
25
+ - CHANGELOG.md
26
+ - LICENSE
27
+ - README.md
28
+ - lib/graphmind.rb
29
+ - lib/graphmind/api.rb
30
+ - lib/graphmind/clock.rb
31
+ - lib/graphmind/edit_guard.rb
32
+ - lib/graphmind/env.rb
33
+ - lib/graphmind/errors.rb
34
+ - lib/graphmind/gate_engine.rb
35
+ - lib/graphmind/held_ledger.rb
36
+ - lib/graphmind/ids.rb
37
+ - lib/graphmind/integrations/ruby_llm.rb
38
+ - lib/graphmind/integrations/ruby_openai.rb
39
+ - lib/graphmind/integrations/support.rb
40
+ - lib/graphmind/jack.rb
41
+ - lib/graphmind/loop_guard.rb
42
+ - lib/graphmind/protocol.rb
43
+ - lib/graphmind/railtie.rb
44
+ - lib/graphmind/redaction.rb
45
+ - lib/graphmind/ring_buffer.rb
46
+ - lib/graphmind/runtime.rb
47
+ - lib/graphmind/safe.rb
48
+ - lib/graphmind/session.rb
49
+ - lib/graphmind/shrink.rb
50
+ - lib/graphmind/token_batcher.rb
51
+ - lib/graphmind/transport.rb
52
+ - lib/graphmind/version.rb
53
+ - lib/graphmind/websocket.rb
54
+ - lib/graphmind/wrap.rb
55
+ homepage: https://graphmind.ai
56
+ licenses:
57
+ - MIT
58
+ metadata:
59
+ homepage_uri: https://graphmind.ai
60
+ source_code_uri: https://github.com/Hegazy360/GraphMind
61
+ bug_tracker_uri: https://github.com/Hegazy360/GraphMind/issues
62
+ changelog_uri: https://github.com/Hegazy360/GraphMind/blob/master/CHANGELOG.md
63
+ documentation_uri: https://github.com/Hegazy360/GraphMind/blob/master/ruby/README.md
64
+ rubygems_mfa_required: 'true'
65
+ rdoc_options: []
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ requirements:
70
+ - - ">="
71
+ - !ruby/object:Gem::Version
72
+ version: 3.1.0
73
+ required_rubygems_version: !ruby/object:Gem::Requirement
74
+ requirements:
75
+ - - ">="
76
+ - !ruby/object:Gem::Version
77
+ version: '0'
78
+ requirements: []
79
+ rubygems_version: 4.0.20
80
+ specification_version: 4
81
+ summary: Live debugger for AI agents — attach to a running Ruby agent, watch it as
82
+ a graph, pause and inject at LLM and tool boundaries.
83
+ test_files: []