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,267 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "session"
4
+ require_relative "wrap"
5
+
6
+ module Graphmind
7
+ # One debugger attachment point for a process.
8
+ #
9
+ # gm = Graphmind.configure(app: "support-agent")
10
+ #
11
+ # search = gm.tool("search_flights") { |origin:, destination:| ... }
12
+ #
13
+ # gm.run("handle-ticket") do
14
+ # search.call(origin: "LHR", destination: "JFK")
15
+ # end
16
+ #
17
+ # Everything degrades to a no-op when no debugger is attached, and to
18
+ # literally nothing when GraphMind is disabled.
19
+ class Client
20
+ attr_reader :session
21
+
22
+ def initialize(app: nil, sdk: nil, **session_options)
23
+ @session = Session.new(
24
+ app_name: app || default_app_name,
25
+ sdk: sdk || { "name" => "ruby", "version" => Graphmind::VERSION },
26
+ **session_options
27
+ )
28
+ @session_of = -> { @session }
29
+ end
30
+
31
+ # -- attach --------------------------------------------------------------
32
+
33
+ def enabled? = @session.enabled?
34
+ def attached? = @session.attached?
35
+ def url = @session.url
36
+
37
+ # Block until the debugger handshake completes (breakpoints armed).
38
+ # `false` means "carry on detached" — it is never an error.
39
+ def ready(timeout = Session::DEFAULT_READY_TIMEOUT) = @session.ready(timeout)
40
+
41
+ # -- runs & spans --------------------------------------------------------
42
+
43
+ # Run boundary: `gm.run("name") { |ctx| ... }`.
44
+ def run(name, meta: nil, &block)
45
+ @session.run(name, meta: meta, &block)
46
+ end
47
+
48
+ def current_run = @session.current_run
49
+ def with_run_context(ctx, &block) = @session.with_run_context(ctx, &block)
50
+ def clear_run_context = @session.clear_run_context
51
+
52
+ # An arbitrary node on the canvas: gated, timed, named. Use it for the
53
+ # parts of a graph GraphMind cannot see by itself — a retrieval step, a
54
+ # hand-rolled planner loop, a Sidekiq job body.
55
+ #
56
+ # The block's value becomes the node's output and the span's value, unless
57
+ # `span.output = ...` sets one explicitly.
58
+ def span(name, kind: "custom", input: nil, parent_id: nil)
59
+ kind = "custom" unless Protocol::NODE_KINDS.include?(kind)
60
+ holder = Span.new
61
+ Wrap.invoke(@session,
62
+ node_id: "#{kind}:#{name}", kind: kind, name: name.to_s,
63
+ parent_id: parent_id, input: input) do
64
+ value = yield holder
65
+ holder.output_set? ? holder.output : value
66
+ end
67
+ end
68
+
69
+ # -- tools ---------------------------------------------------------------
70
+
71
+ # Wrap a block as a gated tool:
72
+ #
73
+ # search = gm.tool("search") { |query:| index.query(query) }
74
+ # search.call(query: "flights")
75
+ def tool(name = nil, kind: "tool", parent_id: nil, &block)
76
+ raise ArgumentError, "Graphmind#tool needs a block" if block.nil?
77
+
78
+ Wrap.gate_callable(block, @session_of, name: name, kind: kind, parent_id: parent_id)
79
+ end
80
+
81
+ # Wrap any callable (Proc, Method, or an object responding to #call).
82
+ def wrap(callable, name: nil, kind: "tool", node_id: nil, parent_id: nil)
83
+ Wrap.gate_callable(callable, @session_of, name: name, kind: kind, node_id: node_id,
84
+ parent_id: parent_id)
85
+ end
86
+
87
+ # Wrap a {name => callable} Hash, an Array of callables, or one callable.
88
+ def wrap_tools(tools) = Wrap.wrap_tools(tools, @session_of)
89
+
90
+ # Gate one method on one object, in place.
91
+ def wrap_method(target, method_name, name: nil, kind: "tool", parent_id: nil)
92
+ Wrap.wrap_method(target, method_name, @session_of, name: name, kind: kind,
93
+ parent_id: parent_id)
94
+ end
95
+
96
+ # -- integrations --------------------------------------------------------
97
+
98
+ # Instrument a ruby-openai `OpenAI::Client` in place and return it.
99
+ def instrument_openai(client, **options)
100
+ require_relative "integrations/ruby_openai"
101
+ Integrations::RubyOpenAI.instrument(client, @session, **options)
102
+ end
103
+
104
+ # Instrument a `RubyLLM::Chat` (and its tools) in place and return it.
105
+ def instrument_ruby_llm(chat, **options)
106
+ require_relative "integrations/ruby_llm"
107
+ Integrations::RubyLLM.instrument(chat, @session, **options)
108
+ end
109
+
110
+ # -- low level -----------------------------------------------------------
111
+
112
+ def emit(type, payload) = @session.emit(type, payload)
113
+ def gate(point, node) = @session.gate(point, node)
114
+ def graph_hint(nodes) = @session.graph_hint(nodes)
115
+ def push_token(node_id, channel, value) = @session.push_token(node_id, channel, value)
116
+ def stats = @session.stats
117
+ def dispose = @session.dispose
118
+
119
+ private
120
+
121
+ def default_app_name
122
+ if defined?(::Rails) && ::Rails.respond_to?(:application) && ::Rails.application
123
+ ::Rails.application.class.name.to_s.split("::").first.to_s.downcase
124
+ else
125
+ File.basename(Dir.pwd)
126
+ end
127
+ rescue StandardError
128
+ "ruby"
129
+ end
130
+ end
131
+
132
+ # Yielded by Client#span so the block can name what the node produced.
133
+ class Span
134
+ attr_reader :output
135
+
136
+ def initialize
137
+ @output = nil
138
+ @set = false
139
+ end
140
+
141
+ def output=(value)
142
+ @output = value
143
+ @set = true
144
+ end
145
+
146
+ def output_set? = @set
147
+ end
148
+
149
+ # -- module-level default instance -----------------------------------------
150
+
151
+ @default = nil
152
+ @default_mutex = Mutex.new
153
+
154
+ class << self
155
+ # Create (or replace) the process-wide default instance. Accepts
156
+ # everything Session accepts plus `app:` and `sdk:`.
157
+ def configure(**options)
158
+ previous = nil
159
+ client = nil
160
+ @default_mutex.synchronize do
161
+ previous = @default
162
+ client = Client.new(**options)
163
+ @default = client
164
+ end
165
+ previous&.dispose
166
+ client
167
+ end
168
+ alias init configure
169
+
170
+ # The default instance, created with defaults on first use.
171
+ def instance
172
+ current = @default
173
+ return current if current
174
+
175
+ @default_mutex.synchronize do
176
+ @default ||= Client.new
177
+ end
178
+ end
179
+
180
+ def configured? = !@default.nil?
181
+
182
+ # Dispose and forget the default instance (mainly for tests).
183
+ def reset
184
+ current = @default_mutex.synchronize do
185
+ previous = @default
186
+ @default = nil
187
+ previous
188
+ end
189
+ current&.dispose
190
+ nil
191
+ end
192
+
193
+ def session = instance.session
194
+ def enabled? = instance.enabled?
195
+ def attached? = instance.attached?
196
+ def ready(timeout = Session::DEFAULT_READY_TIMEOUT) = instance.ready(timeout)
197
+ def run(name, meta: nil, &block) = instance.run(name, meta: meta, &block)
198
+ def span(name, **kwargs, &block) = instance.span(name, **kwargs, &block)
199
+ def tool(name = nil, **kwargs, &block) = instance.tool(name, **kwargs, &block)
200
+ def wrap(callable, **kwargs) = instance.wrap(callable, **kwargs)
201
+ def wrap_tools(tools) = instance.wrap_tools(tools)
202
+ def wrap_method(target, method_name, **kwargs) = instance.wrap_method(target, method_name, **kwargs)
203
+ def instrument_openai(client, **kwargs) = instance.instrument_openai(client, **kwargs)
204
+ def instrument_ruby_llm(chat, **kwargs) = instance.instrument_ruby_llm(chat, **kwargs)
205
+ def emit(type, payload) = instance.emit(type, payload)
206
+ def graph_hint(nodes) = instance.graph_hint(nodes)
207
+ def current_run = instance.current_run
208
+ def with_run_context(ctx, &block) = instance.with_run_context(ctx, &block)
209
+ def clear_run_context = instance.clear_run_context
210
+ def stats = instance.stats
211
+ def dispose = instance.dispose
212
+ end
213
+
214
+ # Class-level macro for gating instance methods:
215
+ #
216
+ # class Tools
217
+ # include Graphmind::Instrument
218
+ # graphmind_tool :search, :book
219
+ #
220
+ # def search(query) = ...
221
+ # end
222
+ #
223
+ # Every instance of the class now pauses at `tool:search` / `tool:book`.
224
+ module Instrument
225
+ def self.included(base)
226
+ base.extend(ClassMethods)
227
+ end
228
+
229
+ module ClassMethods
230
+ def graphmind_tool(*method_names, kind: "tool", session: nil)
231
+ gates = (@graphmind_gate_module ||= begin
232
+ mod = Module.new
233
+ prepend(mod)
234
+ mod
235
+ end)
236
+ method_names.each do |method_name|
237
+ node_name = method_name.to_s
238
+ gates.define_method(method_name) do |*args, **kwargs, &block|
239
+ active = session ? session.call : Graphmind.instance.session
240
+ body = lambda do
241
+ kwargs.empty? ? super(*args, &block) : super(*args, **kwargs, &block)
242
+ end
243
+ if active.nil? || !active.enabled? || active.disposed?
244
+ body.call
245
+ else
246
+ parameters =
247
+ begin
248
+ method(method_name).super_method&.parameters || []
249
+ rescue StandardError
250
+ []
251
+ end
252
+ Graphmind::Wrap.invoke(
253
+ active,
254
+ node_id: Graphmind::Ids.tool_node_id(node_name),
255
+ kind: kind,
256
+ name: node_name,
257
+ input: Graphmind::Wrap.describe(parameters, args, kwargs),
258
+ &body
259
+ )
260
+ end
261
+ end
262
+ end
263
+ method_names
264
+ end
265
+ end
266
+ end
267
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Graphmind
4
+ # Duration clock.
5
+ #
6
+ # Every GraphMind duration (`durationMs`, `heldMs`) is measured on
7
+ # `Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)` —
8
+ # monotonic and sub-microsecond — never on `Time.now`, which steps under NTP
9
+ # adjustment and reads a 80µs handler as `0ms`. Wall-clock fields on the wire
10
+ # (envelope `ts`) stay integer epoch milliseconds; only elapsed-time
11
+ # measurement lives here.
12
+ #
13
+ # Port of packages/client/src/clock.ts. The wire contract for a duration:
14
+ # * rounded to 0.01 ms (two decimals),
15
+ # * clamped to >= 0,
16
+ # * never NaN / infinite (those become 0.0).
17
+ #
18
+ # The clock is read through `Clock.now_ms` at call time so tests can
19
+ # substitute it with `Clock.override=`.
20
+ module Clock
21
+ class << self
22
+ # Installed by tests; nil means the real monotonic clock.
23
+ attr_accessor :override
24
+
25
+ # Milliseconds on the monotonic clock. Fractional; only differences mean anything.
26
+ def now_ms
27
+ o = @override
28
+ return o.call.to_f if o
29
+
30
+ Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond)
31
+ end
32
+
33
+ # Coerce a raw millisecond measurement into the wire contract.
34
+ def normalize_duration_ms(raw)
35
+ return 0.0 unless raw.is_a?(Numeric)
36
+
37
+ value = raw.to_f
38
+ return 0.0 if value.nan? || value.infinite? || value <= 0.0
39
+
40
+ value.round(2)
41
+ end
42
+
43
+ # Elapsed since a `now_ms` reading, normalised for the wire.
44
+ def elapsed_ms(started_at, now = nil)
45
+ normalize_duration_ms((now || now_ms) - started_at)
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+ require_relative "redaction"
5
+ require_relative "shrink"
6
+
7
+ module Graphmind
8
+ # The client-side inject guard (0.6.0, contract C2; refute-security C2.3 /
9
+ # S5): a value that still carries the redaction placeholder or a truncation
10
+ # marker is a pre-filled copy of a RECORDING its sender never saw in full —
11
+ # not a result anyone meant to substitute. Refused (the gate stays held) under
12
+ # every debugger, a 0.5 hub included, whose own guard knows only the
13
+ # placeholder.
14
+ #
15
+ # Port of `proposedValueRefusal` in packages/client/src/edit-input.ts — one
16
+ # marker list for every SDK and the hub, pinned by the shared fixture
17
+ # packages/client/test/fixtures/edit-input.json (its `proposedValue`
18
+ # section). This gem does not announce `edit-input`, so it never runs an
19
+ # edited input; only injected values need the guard.
20
+ #
21
+ # The markers, over the value's compact JSON text:
22
+ # * "__REDACTED__" anywhere -> placeholder
23
+ # * the shrink's `__graphmindTruncated` key and "…[graphmind: truncated]"
24
+ # suffix, LangGraph's `"__graphmind":"truncated"` / `"unserializable"`,
25
+ # the MCP get_node preview's `"note":"payload truncated: showing first `
26
+ # * the Python SDK's recording bounds (`…[truncated]"`, `"<N bytes>"`,
27
+ # `"…[depth limit]"`, a `"…"` key holding `"[N more keys]"`, `"…[N more]"`)
28
+ # -> truncated
29
+ module EditGuard
30
+ # A refusal the client decided (codes and messages never quote values).
31
+ Refusal = Struct.new(:code, :message)
32
+
33
+ ELLIPSIS = "\xE2\x80\xA6".dup.force_encoding(Encoding::UTF_8).freeze
34
+ MCP_PREVIEW_NOTE_PREFIX = "payload truncated: showing first "
35
+
36
+ TRUNCATION_MARKERS = [
37
+ "__graphmindTruncated",
38
+ Shrink::TRUNCATION_SUFFIX,
39
+ '"__graphmind":"truncated"',
40
+ '"__graphmind":"unserializable"',
41
+ %("note":"#{MCP_PREVIEW_NOTE_PREFIX})
42
+ ].map { |marker| marker.dup.force_encoding(Encoding::UTF_8).freeze }.freeze
43
+
44
+ # `PYTHON_PREVIEW_MARKERS` in edit-input.ts, verbatim (`\A` / `\z` for its
45
+ # `^` / `$`, which in Ruby would match at any line; ASCII digits only).
46
+ E = Regexp.escape(ELLIPSIS)
47
+ PYTHON_PREVIEW_MARKERS = [
48
+ Regexp.new(%(#{E}\\[truncated\\]")),
49
+ Regexp.new(%((?:\\A|[\\[:,])"<[0-9]+ bytes>"(?=[,\\]}]|\\z))),
50
+ Regexp.new(%((?:\\A|[\\[:,])"#{E}\\[depth limit\\]"(?=[,\\]}]|\\z))),
51
+ Regexp.new(%([{,]"#{E}":"\\[[0-9]+ more keys\\]"(?=[,}]))),
52
+ Regexp.new(%((?:\\A|[\\[:,])"#{E}\\[[0-9]+ more\\]"(?=[,\\]}]|\\z)))
53
+ ].freeze
54
+
55
+ PLACEHOLDER_MESSAGE = 'the value contains redacted content ("__REDACTED__"); replace it before running'
56
+ TRUNCATED_MESSAGE = "the value contains a truncated preview, not the full value; replace it before running"
57
+ UNREADABLE_MESSAGE = "the value could not be read as JSON"
58
+
59
+ module_function
60
+
61
+ # How deep a value this guard serialises to check (json's default, 100,
62
+ # refused values the TypeScript client and the Python SDK inject). Finite
63
+ # on purpose: json's generator recurses on the machine stack, which on a
64
+ # thread (the transport's, where this runs) runs out between 1,000 and
65
+ # 2,000 levels, and running out is not always a catchable
66
+ # SystemStackError. Deeper, or cyclic, is a `shape` refusal — answered,
67
+ # never dropped.
68
+ MAX_NESTING = 512
69
+
70
+ # Refusal for a proposed inject output that must never run: `placeholder`,
71
+ # `truncated`, or `shape` when it cannot be serialised to be checked
72
+ # (nested past MAX_NESTING included). nil when it is clean. Never raises.
73
+ def proposed_value_refusal(value)
74
+ text = JSON.generate(value, max_nesting: MAX_NESTING)
75
+ text = text.dup.force_encoding(Encoding::UTF_8) unless text.encoding == Encoding::UTF_8
76
+ return Refusal.new("placeholder", PLACEHOLDER_MESSAGE) if text.include?(Redaction::REDACTED)
77
+ if TRUNCATION_MARKERS.any? { |marker| text.include?(marker) } ||
78
+ PYTHON_PREVIEW_MARKERS.any? { |pattern| pattern.match?(text) }
79
+ return Refusal.new("truncated", TRUNCATED_MESSAGE)
80
+ end
81
+
82
+ nil
83
+ rescue StandardError, SystemStackError
84
+ Refusal.new("shape", UNREADABLE_MESSAGE)
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Graphmind
4
+ # Kill switches and environment-derived defaults.
5
+ #
6
+ # Precedence for "is GraphMind enabled" (mirrors packages/client/src/env.ts,
7
+ # with a Ruby-flavoured production check because there is no NODE_ENV):
8
+ #
9
+ # 1. GRAPHMIND_DISABLED=1 -> disabled, always. Ops-level kill switch; it
10
+ # beats an explicit `enabled: true` passed in code.
11
+ # 2. explicit `enabled:` -> as given.
12
+ # 3. looks like production -> disabled unless GRAPHMIND=1.
13
+ # 4. otherwise -> enabled.
14
+ #
15
+ # "Looks like production" is deliberately a boring, documented rule: the
16
+ # first variable that is *set* out of ENV_VARS decides, and it counts as
17
+ # production when its value is production/prod (case-insensitive). No
18
+ # heuristics over hostnames, cloud metadata or TTYs — a debugger that
19
+ # silently turns itself off for surprising reasons is worse than one you
20
+ # have to switch on.
21
+ module Env
22
+ DEFAULT_URL = "ws://127.0.0.1:4747/ingest"
23
+
24
+ # Checked in order; the first one that is set decides the environment.
25
+ ENV_VARS = %w[
26
+ GRAPHMIND_ENV
27
+ ENVIRONMENT
28
+ APP_ENV
29
+ RAILS_ENV
30
+ RACK_ENV
31
+ ENV
32
+ NODE_ENV
33
+ ].freeze
34
+
35
+ PRODUCTION_VALUES = %w[production prod].freeze
36
+
37
+ module_function
38
+
39
+ def source(env)
40
+ env.nil? ? ENV : env
41
+ end
42
+
43
+ # True when the first *set* variable in ENV_VARS names a production env.
44
+ def production?(env = nil)
45
+ src = source(env)
46
+ ENV_VARS.each do |key|
47
+ value = src[key]
48
+ next if value.nil? || value.to_s.empty?
49
+
50
+ return PRODUCTION_VALUES.include?(value.to_s.strip.downcase)
51
+ end
52
+ false
53
+ end
54
+
55
+ KILL_SWITCH_OFF = ["", "0", "false", "off", "no"].freeze
56
+
57
+ # A kill switch or privacy switch read from the environment: on for any
58
+ # String except empty, `0`, `false`, `off` and `no` (case-insensitive,
59
+ # surrounding whitespace ignored); nil is off. An unexpected spelling
60
+ # (`yes`, `on`) must err towards the switch being on. Mirrors
61
+ # `killSwitchOn` in the TS client.
62
+ def kill_switch_on?(value)
63
+ return false unless value.is_a?(String)
64
+
65
+ !KILL_SWITCH_OFF.include?(value.strip.downcase)
66
+ end
67
+
68
+ def resolve_enabled(explicit = nil, env = nil)
69
+ src = source(env)
70
+ return false if kill_switch_on?(src["GRAPHMIND_DISABLED"])
71
+ return explicit unless explicit.nil?
72
+
73
+ !(production?(src) && src["GRAPHMIND"] != "1")
74
+ end
75
+
76
+ def resolve_url(explicit = nil, env = nil)
77
+ return explicit unless explicit.nil?
78
+
79
+ value = source(env)["GRAPHMIND_URL"]
80
+ value.nil? || value.empty? ? DEFAULT_URL : value
81
+ end
82
+ end
83
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Graphmind
4
+ # Raised into the host **only** when the debugger explicitly aborts a run.
5
+ # Everything else in this gem degrades to a no-op.
6
+ class AbortError < StandardError
7
+ def initialize(message = "Run aborted by the GraphMind debugger")
8
+ super
9
+ end
10
+ end
11
+
12
+ module Errors
13
+ MAX_STACK_FRAMES = 40
14
+
15
+ module_function
16
+
17
+ def abort_error?(error)
18
+ return false if error.nil?
19
+ return true if error.is_a?(Graphmind::AbortError)
20
+
21
+ name = error.class.name.to_s
22
+ name.end_with?("AbortError") || name.end_with?("Abort")
23
+ end
24
+
25
+ # Serialize an exception into the wire contract's ErrorInfo shape.
26
+ def to_error_info(error)
27
+ return { "name" => "Error", "message" => error.to_s } unless error.is_a?(Exception)
28
+
29
+ info = {
30
+ "name" => error.class.name.to_s,
31
+ "message" => error.message.to_s
32
+ }
33
+ backtrace = error.backtrace
34
+ if backtrace.is_a?(Array) && !backtrace.empty?
35
+ info["stack"] = ([error.message.to_s] + backtrace.first(MAX_STACK_FRAMES)).join("\n")
36
+ end
37
+ info
38
+ end
39
+ end
40
+ end