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
|
@@ -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
|