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,449 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../ids"
4
+ require_relative "../wrap"
5
+ require_relative "support"
6
+
7
+ module Graphmind
8
+ module Integrations
9
+ # Instrumentation for the `ruby_llm` gem.
10
+ #
11
+ # chat = Graphmind.instrument_ruby_llm(RubyLLM.chat.with_tool(Weather))
12
+ # chat.ask("what's the weather in Cairo?")
13
+ #
14
+ # NOTE: inside this module `::RubyLLM` is the gem and `RubyLLM` (unqualified)
15
+ # would be this module — always qualify.
16
+ #
17
+ # It prepends modules to *this chat object's* singleton class and to the
18
+ # singleton class of each tool instance it holds. Nothing global is
19
+ # patched, and another chat in the same process is untouched.
20
+ #
21
+ # What you get:
22
+ # * a `llm:step` node per provider round-trip (one per API call, not one
23
+ # per `ask`), with the model, every message in full, the sampling
24
+ # parameters and tools (by schema hash), the reply text, the requested
25
+ # tool calls, the normalized finish reason (2.0) and inclusive usage
26
+ # (contract C1: ruby_llm counts input WITHOUT cache, GraphMind adds the
27
+ # cache reads and writes back);
28
+ # * a `tool:<name>` node per `RubyLLM::Tool#call`, with real `inject`
29
+ # and `retry` — the debugger can replace a tool result and let the
30
+ # model carry on with it;
31
+ # * gates at before / error / after on both.
32
+ #
33
+ # Hook choice: the LLM gate wraps `provider_completion`, which is one HTTP
34
+ # round-trip. It is a private method of RubyLLM::Chat, so if a future
35
+ # version renames it this falls back to `complete_once` and then to the
36
+ # public `complete` (coarser: one node for the whole turn). The hook that
37
+ # was used is reported on the node as `hook`.
38
+ #
39
+ # Every patch forwards whatever arguments it is given. The hooks are
40
+ # private API and their signatures move between major versions (1.x:
41
+ # `provider_completion(&)` and `Tool#call(args)`; 2.0:
42
+ # `provider_completion(usage_recorder:, stream_tracker:, &)` and
43
+ # `Tool#call(tool_call:, **arguments)`). A patch that pinned one signature
44
+ # raised ArgumentError inside the user's chat on the other — even with no
45
+ # viewer attached — which is the one thing instrumentation must never do.
46
+ module RubyLLM
47
+ CHAT_MARKER = :@__graphmind_ruby_llm_chat
48
+ TOOL_MARKER = :@__graphmind_ruby_llm_tool
49
+
50
+ LLM_HOOKS = %i[provider_completion complete_once complete].freeze
51
+
52
+ module_function
53
+
54
+ def instrument(chat, session, node_id: Graphmind::Ids::LLM_NODE_ID,
55
+ name: Graphmind::Ids::LLM_NODE_NAME, tools: true)
56
+ return chat if chat.nil?
57
+
58
+ config = chat.instance_variable_get(CHAT_MARKER)
59
+ if config.nil?
60
+ hook = LLM_HOOKS.find { |candidate| chat.respond_to?(candidate, true) }
61
+ config = { session: session, node_id: node_id, name: name, hook: hook,
62
+ mutex: Mutex.new, run_context: nil }
63
+ chat.instance_variable_set(CHAT_MARKER, config)
64
+ chat.singleton_class.prepend(chat_patch(hook))
65
+ prepend_registration_patches(chat) if tools
66
+ end
67
+ instrument_tools(chat, session, config) if tools
68
+ chat
69
+ end
70
+
71
+ def instrumented?(chat) = !chat.instance_variable_get(CHAT_MARKER).nil?
72
+
73
+ # Gate tools registered after instrumentation — through whichever of the
74
+ # registration methods this version has (2.0 removed `with_tool`).
75
+ # Patching a missing one would make `respond_to?(:with_tool)` lie.
76
+ def prepend_registration_patches(chat)
77
+ chat.singleton_class.prepend(WithToolPatch) if chat.respond_to?(:with_tool)
78
+ chat.singleton_class.prepend(WithToolsPatch) if chat.respond_to?(:with_tools)
79
+ end
80
+
81
+ # What the tool node shows as its input. 1.x calls `tool.call(args)` with
82
+ # one positional Hash; 2.0 calls `tool.call(**args, tool_call:)`, where
83
+ # `tool_call` is RubyLLM's own ToolCall object, not a model argument.
84
+ def tool_input(args, kwargs)
85
+ return args.length == 1 ? args.first : args if kwargs.empty?
86
+
87
+ named = kwargs.each_with_object({}) do |(key, value), out|
88
+ out[key.to_s] = value unless key.to_s == "tool_call"
89
+ end
90
+ args.empty? ? named : { "args" => args, "kwargs" => named }
91
+ end
92
+
93
+ # Wrap every tool the chat currently holds.
94
+ def instrument_tools(chat, session, chat_config = nil)
95
+ registry = chat.respond_to?(:tools) ? chat.tools : nil
96
+ return unless registry.respond_to?(:each_value)
97
+
98
+ registry.each_value { |tool| instrument_tool(tool, session, chat_config) }
99
+ nil
100
+ rescue StandardError
101
+ nil
102
+ end
103
+
104
+ def instrument_tool(tool, session, chat_config = nil)
105
+ return tool if tool.nil? || tool.instance_variable_get(TOOL_MARKER)
106
+
107
+ name = begin
108
+ tool.name.to_s
109
+ rescue StandardError
110
+ tool.class.name.to_s
111
+ end
112
+ tool.instance_variable_set(TOOL_MARKER,
113
+ { session: session, name: name, chat: chat_config })
114
+ tool.singleton_class.prepend(ToolPatch)
115
+ tool
116
+ rescue StandardError
117
+ tool
118
+ end
119
+
120
+ # ruby_llm can run tool calls on a thread pool (`tool_concurrency`), and
121
+ # a fresh thread has no run context. Fall back to the run the chat was
122
+ # completing in, so a concurrently-executed tool still lands on the right
123
+ # run instead of an implicit one.
124
+ def run_context_for(session, config)
125
+ current = session.current_run
126
+ return current unless current.nil?
127
+
128
+ chat_config = config[:chat]
129
+ return nil if chat_config.nil?
130
+
131
+ chat_config[:mutex].synchronize { chat_config[:run_context] }
132
+ rescue StandardError
133
+ nil
134
+ end
135
+
136
+ # One gated provider round-trip.
137
+ def call_llm(config, chat, &body)
138
+ session = config[:session]
139
+ return body.call if session.nil? || !session.enabled? || session.disposed?
140
+
141
+ config[:mutex].synchronize { config[:run_context] = session.current_run }
142
+
143
+ Graphmind::Wrap.invoke(
144
+ session,
145
+ node_id: config[:node_id],
146
+ kind: "llm",
147
+ name: config[:name],
148
+ input: describe(chat, config),
149
+ full_input: true,
150
+ output_for: ->(message) { summarize(message) },
151
+ finish_extra: ->(message) { extra_for(message) },
152
+ inject_as: ->(value) { coerce_message(value) },
153
+ &body
154
+ )
155
+ end
156
+
157
+ # An injected reply arrives as JSON, but ruby_llm expects a
158
+ # RubyLLM::Message. Accept the shapes a human would actually type in the
159
+ # viewer: a bare string, or an object with role/content.
160
+ # `model_id` is 1.x's name for the model, `model` is 2.0's; Message.new
161
+ # ignores the one it does not know in both.
162
+ MESSAGE_KEYS = %i[role content model_id model input_tokens output_tokens cached_tokens
163
+ tool_calls tool_call_id].freeze
164
+
165
+ def coerce_message(value)
166
+ return value if value.is_a?(::RubyLLM::Message)
167
+
168
+ if value.is_a?(Hash)
169
+ attrs = value.each_with_object({}) do |(key, item), out|
170
+ symbol = key.to_sym
171
+ out[symbol] = item if MESSAGE_KEYS.include?(symbol)
172
+ end
173
+ attrs[:role] ||= :assistant
174
+ attrs[:content] = "" unless attrs.key?(:content)
175
+ return ::RubyLLM::Message.new(attrs)
176
+ end
177
+
178
+ ::RubyLLM::Message.new(role: :assistant, content: value.to_s)
179
+ end
180
+
181
+ # node.started.input (contract C1): the hook, the model, EVERY message
182
+ # in full (no 12-message / 2,000-char trim; the 512 KB shrink bounds the
183
+ # event), the sampling parameters the chat will send (temperature, 2.0's
184
+ # max_output_tokens, allow-listed `with_params` / provider options), and
185
+ # the tools as {name, schemaHash} with each definition ({name,
186
+ # description, parameters}) sent once per run as toolSchemas.
187
+ def describe(chat, config)
188
+ out = { "hook" => config[:hook].to_s }
189
+ out["model"] = chat.model.id if chat.respond_to?(:model) && chat.model.respond_to?(:id)
190
+ messages = message_hashes(chat)
191
+ out["messages"] = messages unless messages.nil?
192
+ out.merge!(sampling_params(chat))
193
+ tools = tool_capture(chat, config)
194
+ out.merge!(tools) unless tools.nil?
195
+ out
196
+ rescue StandardError
197
+ { "hook" => config[:hook].to_s }
198
+ end
199
+
200
+ def message_hashes(chat)
201
+ return nil unless chat.respond_to?(:messages)
202
+
203
+ chat.messages.map do |message|
204
+ entry = { "role" => message.role.to_s, "content" => text_of(message.content) }
205
+ calls = message.respond_to?(:tool_calls) ? message.tool_calls : nil
206
+ if calls.respond_to?(:each_value) && !calls.empty?
207
+ entry["tool_calls"] = calls.each_value.filter_map { |call| requested_call(call) }
208
+ end
209
+ if message.respond_to?(:tool_call_id) && !message.tool_call_id.nil?
210
+ entry["tool_call_id"] = message.tool_call_id.to_s
211
+ end
212
+ entry
213
+ end
214
+ rescue StandardError
215
+ nil
216
+ end
217
+
218
+ # temperature (2.0 reader, 1.x ivar), 2.0's max_output_tokens, and the
219
+ # allow-listed keys of 1.x `params` / 2.0 `provider_options`.
220
+ def sampling_params(chat)
221
+ out = {}
222
+ temperature = chat.respond_to?(:temperature) ? chat.temperature : chat.instance_variable_get(:@temperature)
223
+ out["temperature"] = temperature if temperature.is_a?(Numeric)
224
+ max = chat.respond_to?(:max_output_tokens) ? chat.max_output_tokens : nil
225
+ out["max_output_tokens"] = max if max.is_a?(Integer)
226
+ %i[params provider_options].each do |reader|
227
+ extra = chat.respond_to?(reader) ? chat.public_send(reader) : nil
228
+ out.merge!(Support.pick_params(extra)) if extra.is_a?(Hash)
229
+ end
230
+ out
231
+ rescue StandardError
232
+ {}
233
+ end
234
+
235
+ def tool_capture(chat, config)
236
+ registry = chat.respond_to?(:tools) ? chat.tools : nil
237
+ return nil unless registry.respond_to?(:each_value) && !registry.empty?
238
+
239
+ definitions = registry.each_value.filter_map { |tool| tool_definition(tool) }
240
+ session = config[:session]
241
+ run = session.respond_to?(:current_run) ? session.current_run : nil
242
+ Support.capture_tools(session, run.nil? ? "implicit" : run.run_id, definitions)
243
+ rescue StandardError
244
+ nil
245
+ end
246
+
247
+ # {name, description, parameters}: 1.x `params_schema`, 2.0 `parameters_schema`.
248
+ def tool_definition(tool)
249
+ name = tool.respond_to?(:name) ? tool.name.to_s : nil
250
+ return nil if name.nil? || name.empty?
251
+
252
+ definition = { "name" => name }
253
+ description = tool.respond_to?(:description) ? tool.description : nil
254
+ definition["description"] = description.to_s unless description.nil?
255
+ schema = if tool.respond_to?(:parameters_schema)
256
+ tool.parameters_schema
257
+ elsif tool.respond_to?(:params_schema)
258
+ tool.params_schema
259
+ end
260
+ definition["parameters"] = Support.record(schema) unless schema.nil?
261
+ definition
262
+ rescue StandardError
263
+ nil
264
+ end
265
+
266
+ # One RubyLLM::ToolCall as recorded in output.toolCalls.
267
+ def requested_call(call)
268
+ return nil unless call.respond_to?(:name)
269
+
270
+ Support.tool_call(call.respond_to?(:id) ? call.id : nil, call.name.to_s,
271
+ call.respond_to?(:arguments) ? call.arguments : nil)
272
+ rescue StandardError
273
+ nil
274
+ end
275
+
276
+ def text_of(content)
277
+ return content if content.is_a?(String)
278
+ return content.text if content.respond_to?(:text)
279
+
280
+ content.to_s
281
+ rescue StandardError
282
+ ""
283
+ end
284
+
285
+ # node.finished.output (contract C1): the full reply text, the tool calls
286
+ # the model requested as {id, name, input}, 2.0's finish reason normalized
287
+ # (+ rawFinishReason; 1.x reports none), and the model.
288
+ def summarize(message)
289
+ return nil if message.nil?
290
+
291
+ out = {}
292
+ out["text"] = text_of(message.content) if message.respond_to?(:content)
293
+ calls = []
294
+ if message.respond_to?(:tool_call?) && message.tool_call?
295
+ registry = message.tool_calls
296
+ calls = registry.each_value.filter_map { |call| requested_call(call) } if registry.respond_to?(:each_value)
297
+ out["toolCalls"] = calls unless calls.empty?
298
+ end
299
+ raw = message.respond_to?(:finish_reason) ? message.finish_reason : nil
300
+ out.merge!(Support.finish_fields(raw, !calls.empty?))
301
+ model = model_of(message)
302
+ out["model"] = model unless model.nil?
303
+ out.empty? ? nil : out
304
+ rescue StandardError
305
+ nil
306
+ end
307
+
308
+ # 1.x: Message#model_id. 2.0: Message#model (the ID string).
309
+ def model_of(message)
310
+ model = if message.respond_to?(:model_id)
311
+ message.model_id
312
+ elsif message.respond_to?(:model)
313
+ message.model
314
+ end
315
+ model = model.id if !model.nil? && !model.is_a?(String) && model.respond_to?(:id)
316
+ model.nil? || model.to_s.empty? ? nil : model.to_s
317
+ rescue StandardError
318
+ nil
319
+ end
320
+
321
+ # Inclusive usage (contract C1). ruby_llm's input count EXCLUDES cached
322
+ # tokens in both majors (2.0 Tokens#input is "standard (non-cached)"; 1.x
323
+ # providers report or subtract to the uncached tail), so the cache reads
324
+ # and writes are added back. 1.16+ and 2.0: Message#tokens (2.0:
325
+ # cache_read / cache_write / thinking; 1.16: cached / cache_creation, with
326
+ # cache_read / cache_write aliases). Older 1.x: the Message readers.
327
+ def extra_for(message)
328
+ usage = Support.ruby_llm_usage(**token_counts(message))
329
+ usage.nil? ? nil : { "usage" => usage }
330
+ rescue StandardError
331
+ nil
332
+ end
333
+
334
+ def token_counts(message)
335
+ tokens = message.respond_to?(:tokens) ? message.tokens : nil
336
+ source = tokens.respond_to?(:input) ? tokens : message
337
+ {
338
+ input: read_first(source, :input, :input_tokens),
339
+ output: read_first(source, :output, :output_tokens),
340
+ cache_read: read_first(source, :cache_read, :cached, :cache_read_tokens, :cached_tokens),
341
+ cache_write: read_first(source, :cache_write, :cache_creation, :cache_write_tokens,
342
+ :cache_creation_tokens),
343
+ thinking: read_first(source, :thinking, :thinking_tokens, :reasoning_tokens)
344
+ }
345
+ end
346
+
347
+ def read_first(source, *names)
348
+ names.each do |name|
349
+ next unless source.respond_to?(name)
350
+
351
+ value = source.public_send(name)
352
+ return value unless value.nil? || !value.is_a?(Numeric)
353
+ end
354
+ nil
355
+ rescue StandardError
356
+ nil
357
+ end
358
+
359
+ # Build the singleton patch for whichever hook this ruby_llm version has.
360
+ def chat_patch(hook)
361
+ case hook
362
+ when :provider_completion
363
+ ProviderCompletionPatch
364
+ when :complete_once
365
+ CompleteOncePatch
366
+ else
367
+ CompletePatch
368
+ end
369
+ end
370
+
371
+ # -- patches -------------------------------------------------------------
372
+
373
+ module ProviderCompletionPatch
374
+ def provider_completion(*args, **kwargs, &block)
375
+ config = instance_variable_get(CHAT_MARKER)
376
+ return super if config.nil?
377
+
378
+ Graphmind::Integrations::RubyLLM.call_llm(config, self) { super(*args, **kwargs, &block) }
379
+ end
380
+ private :provider_completion
381
+ end
382
+
383
+ module CompleteOncePatch
384
+ def complete_once(*args, **kwargs, &block)
385
+ config = instance_variable_get(CHAT_MARKER)
386
+ return super if config.nil?
387
+
388
+ Graphmind::Integrations::RubyLLM.call_llm(config, self) { super(*args, **kwargs, &block) }
389
+ end
390
+ private :complete_once
391
+ end
392
+
393
+ module CompletePatch
394
+ def complete(*args, **kwargs, &block)
395
+ config = instance_variable_get(CHAT_MARKER)
396
+ return super if config.nil?
397
+
398
+ Graphmind::Integrations::RubyLLM.call_llm(config, self) { super(*args, **kwargs, &block) }
399
+ end
400
+ end
401
+
402
+ # Tools added after instrumentation are gated too.
403
+ module WithToolPatch
404
+ def with_tool(*, **)
405
+ result = super
406
+ config = instance_variable_get(CHAT_MARKER)
407
+ Graphmind::Integrations::RubyLLM.instrument_tools(self, config[:session], config) if config
408
+ result
409
+ end
410
+ end
411
+
412
+ module WithToolsPatch
413
+ def with_tools(*, **)
414
+ result = super
415
+ config = instance_variable_get(CHAT_MARKER)
416
+ Graphmind::Integrations::RubyLLM.instrument_tools(self, config[:session], config) if config
417
+ result
418
+ end
419
+ end
420
+
421
+ # The sharp end: a gated RubyLLM::Tool#call, where `inject` replaces the
422
+ # result the model sees next.
423
+ module ToolPatch
424
+ def call(*args, **kwargs, &block)
425
+ config = instance_variable_get(TOOL_MARKER)
426
+ return super if config.nil?
427
+
428
+ session = config[:session]
429
+ return super if session.nil? || !session.enabled? || session.disposed?
430
+
431
+ gated = lambda do
432
+ Graphmind::Wrap.invoke(
433
+ session,
434
+ node_id: Graphmind::Ids.tool_node_id(config[:name]),
435
+ kind: "tool",
436
+ name: config[:name],
437
+ input: Graphmind::Integrations::RubyLLM.tool_input(args, kwargs)
438
+ ) { super(*args, **kwargs, &block) }
439
+ end
440
+
441
+ ctx = Graphmind::Integrations::RubyLLM.run_context_for(session, config)
442
+ return gated.call if ctx.nil? || !session.current_run.nil?
443
+
444
+ session.with_run_context(ctx) { gated.call }
445
+ end
446
+ end
447
+ end
448
+ end
449
+ end