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,388 @@
|
|
|
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-openai` gem (`OpenAI::Client`).
|
|
10
|
+
#
|
|
11
|
+
# client = Graphmind.instrument_openai(OpenAI::Client.new(access_token: ...))
|
|
12
|
+
#
|
|
13
|
+
# It prepends a module to *that client's singleton class*, so nothing
|
|
14
|
+
# global is monkey-patched: an uninstrumented client in the same process is
|
|
15
|
+
# untouched, and the gem is never loaded unless you ask for it.
|
|
16
|
+
#
|
|
17
|
+
# What you get:
|
|
18
|
+
# * a `llm:step` node for every `chat` / `responses.create` call, with
|
|
19
|
+
# the request as sent (messages / input in full, the sampling
|
|
20
|
+
# parameters, tools by schema hash), the response text, the requested
|
|
21
|
+
# tool calls, the normalized finish reason and inclusive token usage
|
|
22
|
+
# (contract C1: prompt tokens INCLUDE cached ones);
|
|
23
|
+
# * a `before` gate — the debugger can pause and `inject` a response
|
|
24
|
+
# without the request ever leaving the process;
|
|
25
|
+
# * an `error` gate — a 429 or a timeout pauses instead of raising, and
|
|
26
|
+
# `retry` re-sends the request;
|
|
27
|
+
# * streamed deltas forwarded to the canvas when you pass `stream:`.
|
|
28
|
+
#
|
|
29
|
+
# Tool *execution* is your code, not the gem's, so gate it with
|
|
30
|
+
# `Graphmind.tool` / `wrap_tools` (see the README).
|
|
31
|
+
module RubyOpenAI
|
|
32
|
+
MARKER = :@__graphmind_openai
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
def instrument(client, session, node_id: Graphmind::Ids::LLM_NODE_ID,
|
|
37
|
+
name: Graphmind::Ids::LLM_NODE_NAME)
|
|
38
|
+
return client if client.nil?
|
|
39
|
+
return client if client.instance_variable_get(MARKER)
|
|
40
|
+
|
|
41
|
+
config = { session: session, node_id: node_id, name: name }
|
|
42
|
+
client.instance_variable_set(MARKER, config)
|
|
43
|
+
client.singleton_class.prepend(ClientPatch)
|
|
44
|
+
client
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def instrumented?(client)
|
|
48
|
+
!client.instance_variable_get(MARKER).nil?
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# What a `stream:` proc saw: the gem hands the caller chunks, and what
|
|
52
|
+
# `chat` returns for a streamed call is not the completion.
|
|
53
|
+
class StreamState
|
|
54
|
+
attr_reader :text, :calls, :usage, :finish_reason, :final
|
|
55
|
+
|
|
56
|
+
def initialize
|
|
57
|
+
@text = +""
|
|
58
|
+
@calls = {}
|
|
59
|
+
@usage = nil
|
|
60
|
+
@finish_reason = nil
|
|
61
|
+
@final = nil
|
|
62
|
+
@seen = false
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def seen? = @seen
|
|
66
|
+
|
|
67
|
+
# The stream carried refusal deltas (a stop that is a refusal).
|
|
68
|
+
def refused? = @refused == true
|
|
69
|
+
|
|
70
|
+
def observe(chunk)
|
|
71
|
+
return unless chunk.is_a?(Hash)
|
|
72
|
+
|
|
73
|
+
@seen = true
|
|
74
|
+
@usage = chunk["usage"] if chunk["usage"].is_a?(Hash)
|
|
75
|
+
choice = chunk.dig("choices", 0)
|
|
76
|
+
observe_choice(choice) if choice.is_a?(Hash)
|
|
77
|
+
type = chunk["type"].to_s
|
|
78
|
+
@final = chunk["response"] if type.start_with?("response.") && chunk["response"].is_a?(Hash) &&
|
|
79
|
+
%w[response.completed response.incomplete response.failed].include?(type)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def observe_choice(choice)
|
|
83
|
+
@finish_reason = choice["finish_reason"] if choice["finish_reason"].is_a?(String)
|
|
84
|
+
delta = choice["delta"]
|
|
85
|
+
return unless delta.is_a?(Hash)
|
|
86
|
+
|
|
87
|
+
content = delta["content"]
|
|
88
|
+
@text << content if content.is_a?(String)
|
|
89
|
+
@refused = true if delta["refusal"].is_a?(String) && !delta["refusal"].empty?
|
|
90
|
+
Array(delta["tool_calls"]).each do |call|
|
|
91
|
+
next unless call.is_a?(Hash)
|
|
92
|
+
|
|
93
|
+
entry = (@calls[call["index"].is_a?(Integer) ? call["index"] : 0] ||= [nil, nil, +"", false])
|
|
94
|
+
entry[0] = call["id"] if call["id"].is_a?(String) && !call["id"].empty?
|
|
95
|
+
# A custom (freeform) tool streams `custom: {name, input}`.
|
|
96
|
+
entry[3] = true if call["type"] == "custom" || call["custom"].is_a?(Hash)
|
|
97
|
+
source_key, text_key = entry[3] ? %w[custom input] : %w[function arguments]
|
|
98
|
+
source = call[source_key].is_a?(Hash) ? call[source_key] : {}
|
|
99
|
+
entry[1] = source["name"] if source["name"].is_a?(String) && !source["name"].empty?
|
|
100
|
+
entry[2] << source[text_key] if source[text_key].is_a?(String)
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# A custom tool's input is text by design: recorded as the string, as
|
|
105
|
+
# chat_tool_calls does for a non-streamed one.
|
|
106
|
+
def tool_calls
|
|
107
|
+
@calls.keys.sort.filter_map do |index|
|
|
108
|
+
id, name, text, custom = @calls[index]
|
|
109
|
+
next Support.tool_call(id, name, text) unless custom
|
|
110
|
+
next nil unless name.is_a?(String) && !name.empty?
|
|
111
|
+
|
|
112
|
+
id.nil? ? { "name" => name, "input" => text.dup } : { "id" => id, "name" => name, "input" => text.dup }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# One gated LLM call.
|
|
118
|
+
def call(config, parameters, operation)
|
|
119
|
+
session = config[:session]
|
|
120
|
+
node_id = config[:node_id]
|
|
121
|
+
return yield(parameters) if session.nil? || !session.enabled? || session.disposed?
|
|
122
|
+
|
|
123
|
+
stream = StreamState.new
|
|
124
|
+
Graphmind::Wrap.invoke(
|
|
125
|
+
session,
|
|
126
|
+
node_id: node_id,
|
|
127
|
+
kind: "llm",
|
|
128
|
+
name: config[:name],
|
|
129
|
+
input: describe(parameters, operation, session),
|
|
130
|
+
full_input: true,
|
|
131
|
+
output_for: ->(response) { summarize_response(response, stream) },
|
|
132
|
+
finish_extra: ->(response) { extra_for(response, stream) },
|
|
133
|
+
inject_as: ->(value) { coerce_response(value, operation) }
|
|
134
|
+
) do
|
|
135
|
+
yield(instrument_stream(parameters, session, node_id, stream))
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# An injected reply arrives as JSON. A Hash is already response-shaped
|
|
140
|
+
# and passes straight through; a bare string — what a human actually
|
|
141
|
+
# types in the viewer — is wrapped so the caller's `dig("choices", ...)`
|
|
142
|
+
# keeps working.
|
|
143
|
+
def coerce_response(value, operation)
|
|
144
|
+
return value unless value.is_a?(String)
|
|
145
|
+
|
|
146
|
+
if operation == "responses.create"
|
|
147
|
+
{
|
|
148
|
+
"id" => "graphmind-injected",
|
|
149
|
+
"object" => "response",
|
|
150
|
+
"output" => [{ "type" => "message", "role" => "assistant",
|
|
151
|
+
"content" => [{ "type" => "output_text", "text" => value }] }],
|
|
152
|
+
"graphmindInjected" => true
|
|
153
|
+
}
|
|
154
|
+
else
|
|
155
|
+
{
|
|
156
|
+
"id" => "graphmind-injected",
|
|
157
|
+
"object" => "chat.completion",
|
|
158
|
+
"choices" => [{ "index" => 0, "finish_reason" => "stop",
|
|
159
|
+
"message" => { "role" => "assistant", "content" => value } }],
|
|
160
|
+
"graphmindInjected" => true
|
|
161
|
+
}
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
# node.started.input: the request as sent (contract C1) — model,
|
|
166
|
+
# instructions and messages / input in FULL (no message-count or length
|
|
167
|
+
# trim; the 512 KB shrink bounds the event), the sampling parameters under
|
|
168
|
+
# the API's own names (an allow-list: `user`, `metadata` and the stream
|
|
169
|
+
# proc are never read), and tools as {name, schemaHash} with each
|
|
170
|
+
# definition sent once per run as toolSchemas.
|
|
171
|
+
def describe(parameters, operation, session = nil)
|
|
172
|
+
out = { "operation" => operation }
|
|
173
|
+
%i[model instructions previous_response_id].each do |key|
|
|
174
|
+
value = Support.param(parameters, key)
|
|
175
|
+
out[key.to_s] = Support.record(value) unless value.nil?
|
|
176
|
+
end
|
|
177
|
+
messages = Support.param(parameters, :messages)
|
|
178
|
+
out["messages"] = Support.record(messages) unless messages.nil?
|
|
179
|
+
input = Support.param(parameters, :input)
|
|
180
|
+
out["input"] = Support.record(input) unless input.nil?
|
|
181
|
+
out.merge!(Support.pick_params(parameters))
|
|
182
|
+
tools = capture_tools(session, Support.param(parameters, :tools))
|
|
183
|
+
out.merge!(tools) unless tools.nil?
|
|
184
|
+
out["stream"] = true if Support.param(parameters, :stream)
|
|
185
|
+
out
|
|
186
|
+
rescue StandardError
|
|
187
|
+
{ "operation" => operation }
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def capture_tools(session, tools)
|
|
191
|
+
return nil unless tools.is_a?(Array)
|
|
192
|
+
|
|
193
|
+
run = session&.current_run
|
|
194
|
+
Support.capture_tools(session || self, run.nil? ? "implicit" : run.run_id, tools)
|
|
195
|
+
rescue StandardError
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Wrap a caller-supplied `stream:` proc so deltas also reach the canvas
|
|
200
|
+
# (and the step's tool calls, finish reason and last-chunk usage reach
|
|
201
|
+
# `stream`). The caller's proc still runs, with the same arity it declared.
|
|
202
|
+
def instrument_stream(parameters, session, node_id, stream = nil)
|
|
203
|
+
return parameters unless parameters.is_a?(Hash)
|
|
204
|
+
|
|
205
|
+
key = Support.param_key(parameters, :stream)
|
|
206
|
+
original = parameters[key]
|
|
207
|
+
return parameters unless original.respond_to?(:call)
|
|
208
|
+
|
|
209
|
+
copy = parameters.dup
|
|
210
|
+
copy[key] = proc do |chunk, bytesize|
|
|
211
|
+
begin
|
|
212
|
+
text = delta_text(chunk)
|
|
213
|
+
session.push_token(node_id, "text", text) if text
|
|
214
|
+
stream&.observe(chunk)
|
|
215
|
+
rescue StandardError
|
|
216
|
+
nil
|
|
217
|
+
end
|
|
218
|
+
original.arity == 1 ? original.call(chunk) : original.call(chunk, bytesize)
|
|
219
|
+
end
|
|
220
|
+
copy
|
|
221
|
+
rescue StandardError
|
|
222
|
+
parameters
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def delta_text(chunk)
|
|
226
|
+
return nil unless chunk.is_a?(Hash)
|
|
227
|
+
|
|
228
|
+
# Chat Completions streaming.
|
|
229
|
+
delta = chunk.dig("choices", 0, "delta")
|
|
230
|
+
text = delta.is_a?(Hash) ? (delta["content"] || delta["reasoning_content"]) : nil
|
|
231
|
+
return text if text.is_a?(String) && !text.empty?
|
|
232
|
+
|
|
233
|
+
# Responses API streaming.
|
|
234
|
+
text = chunk["delta"] if chunk["type"].to_s.include?("output_text")
|
|
235
|
+
text.is_a?(String) && !text.empty? ? text : nil
|
|
236
|
+
rescue StandardError
|
|
237
|
+
nil
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
# node.finished.output (contract C1): text, the requested tool calls as
|
|
241
|
+
# {id, name, input, inputText?}, the normalized finishReason and the API's
|
|
242
|
+
# own value as rawFinishReason (chat: finish_reason; Responses: the
|
|
243
|
+
# incomplete reason, else the status). A streamed call reports what the
|
|
244
|
+
# stream proc saw.
|
|
245
|
+
def summarize_response(response, stream = nil)
|
|
246
|
+
response = stream.final if stream&.final && !completion?(response)
|
|
247
|
+
return streamed_output(stream) if stream&.seen? && !completion?(response)
|
|
248
|
+
return Support.truncate(response.to_s) unless response.is_a?(Hash)
|
|
249
|
+
|
|
250
|
+
out = response.key?("choices") ? chat_output(response) : responses_output(response)
|
|
251
|
+
out["id"] = response["id"] if response["id"]
|
|
252
|
+
out["model"] = response["model"] if response["model"]
|
|
253
|
+
out.empty? ? Support.truncate(response.to_s) : out
|
|
254
|
+
rescue StandardError
|
|
255
|
+
nil
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
def completion?(response)
|
|
259
|
+
response.is_a?(Hash) && (response.key?("choices") || response.key?("output"))
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
def chat_output(response)
|
|
263
|
+
choice = response.dig("choices", 0) || {}
|
|
264
|
+
message = choice.is_a?(Hash) ? choice["message"] : nil
|
|
265
|
+
text = message.is_a?(Hash) ? message["content"] : nil
|
|
266
|
+
text = choice["text"] if text.nil? && choice.is_a?(Hash)
|
|
267
|
+
calls = chat_tool_calls(message.is_a?(Hash) ? message["tool_calls"] : nil)
|
|
268
|
+
out = {}
|
|
269
|
+
out["text"] = text if text.is_a?(String)
|
|
270
|
+
out["toolCalls"] = calls unless calls.empty?
|
|
271
|
+
refusal = message.is_a?(Hash) ? message["refusal"] : nil
|
|
272
|
+
out.merge!(Support.finish_fields(choice.is_a?(Hash) ? choice["finish_reason"] : nil, !calls.empty?,
|
|
273
|
+
refusal.is_a?(String) && !refusal.empty?))
|
|
274
|
+
end
|
|
275
|
+
|
|
276
|
+
def chat_tool_calls(calls)
|
|
277
|
+
Array(calls).filter_map do |call|
|
|
278
|
+
next nil unless call.is_a?(Hash)
|
|
279
|
+
|
|
280
|
+
function = call["function"]
|
|
281
|
+
if function.is_a?(Hash)
|
|
282
|
+
Support.tool_call(call["id"], function["name"], function["arguments"])
|
|
283
|
+
elsif call["custom"].is_a?(Hash) && call["custom"]["name"].is_a?(String)
|
|
284
|
+
entry = { "name" => call["custom"]["name"], "input" => call["custom"]["input"] || "" }
|
|
285
|
+
call["id"].is_a?(String) ? { "id" => call["id"] }.merge(entry) : entry
|
|
286
|
+
end
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
|
|
290
|
+
def responses_output(response)
|
|
291
|
+
calls = Array(response["output"]).filter_map do |item|
|
|
292
|
+
next nil unless item.is_a?(Hash)
|
|
293
|
+
|
|
294
|
+
if item["type"] == "function_call"
|
|
295
|
+
Support.tool_call(item["call_id"], item["name"], item["arguments"])
|
|
296
|
+
elsif item["type"] == "custom_tool_call" && item["name"].is_a?(String) && !item["name"].empty?
|
|
297
|
+
# A custom (freeform) tool's input is text by design.
|
|
298
|
+
entry = { "name" => item["name"], "input" => item["input"] || "" }
|
|
299
|
+
item["call_id"].is_a?(String) ? { "id" => item["call_id"] }.merge(entry) : entry
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
text = output_text(response)
|
|
303
|
+
reason = response.dig("incomplete_details", "reason")
|
|
304
|
+
raw = reason.is_a?(String) && !reason.empty? ? reason : response["status"]
|
|
305
|
+
out = {}
|
|
306
|
+
out["text"] = text if text.is_a?(String)
|
|
307
|
+
out["toolCalls"] = calls unless calls.empty?
|
|
308
|
+
refused = Array(response["output"]).any? do |item|
|
|
309
|
+
item.is_a?(Hash) && Array(item["content"]).any? { |part| part.is_a?(Hash) && part["type"] == "refusal" }
|
|
310
|
+
end
|
|
311
|
+
out.merge!(Support.finish_fields(raw, !calls.empty?, refused))
|
|
312
|
+
out["status"] = response["status"] if response["status"].is_a?(String)
|
|
313
|
+
out
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
def streamed_output(stream)
|
|
317
|
+
calls = stream.tool_calls
|
|
318
|
+
out = { "text" => stream.text.dup }
|
|
319
|
+
out["toolCalls"] = calls unless calls.empty?
|
|
320
|
+
out.merge!(Support.finish_fields(stream.finish_reason, !calls.empty?, stream.refused?))
|
|
321
|
+
out["streamed"] = true
|
|
322
|
+
out
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def output_text(response)
|
|
326
|
+
output = response["output"]
|
|
327
|
+
return nil unless output.is_a?(Array)
|
|
328
|
+
|
|
329
|
+
output.filter_map do |item|
|
|
330
|
+
content = item.is_a?(Hash) ? item["content"] : nil
|
|
331
|
+
next nil unless content.is_a?(Array)
|
|
332
|
+
|
|
333
|
+
content.filter_map { |part| part["text"] if part.is_a?(Hash) }.join
|
|
334
|
+
end.join
|
|
335
|
+
rescue StandardError
|
|
336
|
+
nil
|
|
337
|
+
end
|
|
338
|
+
|
|
339
|
+
# {"usage" => ...}: inclusive, cache and reasoning counts only when
|
|
340
|
+
# reported. A streamed chat call's usage rides its last chunk (with
|
|
341
|
+
# `stream_options: {include_usage: true}`).
|
|
342
|
+
def extra_for(response, stream = nil)
|
|
343
|
+
usage = response["usage"] if response.is_a?(Hash)
|
|
344
|
+
usage = stream.usage if usage.nil? && stream
|
|
345
|
+
usage = stream.final["usage"] if usage.nil? && stream&.final
|
|
346
|
+
tokens = Support.openai_usage(usage)
|
|
347
|
+
tokens.nil? ? nil : { "usage" => tokens }
|
|
348
|
+
rescue StandardError
|
|
349
|
+
nil
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
# Prepended onto one client instance.
|
|
353
|
+
module ClientPatch
|
|
354
|
+
def chat(parameters: {})
|
|
355
|
+
config = instance_variable_get(MARKER)
|
|
356
|
+
return super(parameters: parameters) if config.nil?
|
|
357
|
+
|
|
358
|
+
RubyOpenAI.call(config, parameters, "chat.completions") do |params|
|
|
359
|
+
super(parameters: params)
|
|
360
|
+
end
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
def responses
|
|
364
|
+
object = super
|
|
365
|
+
config = instance_variable_get(MARKER)
|
|
366
|
+
return object if config.nil? || object.nil?
|
|
367
|
+
return object if object.instance_variable_get(RubyOpenAI::MARKER)
|
|
368
|
+
|
|
369
|
+
object.instance_variable_set(RubyOpenAI::MARKER, config)
|
|
370
|
+
object.singleton_class.prepend(RubyOpenAI::ResponsesPatch)
|
|
371
|
+
object
|
|
372
|
+
end
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
# Prepended onto the client's memoized `responses` helper.
|
|
376
|
+
module ResponsesPatch
|
|
377
|
+
def create(parameters: {})
|
|
378
|
+
config = instance_variable_get(RubyOpenAI::MARKER)
|
|
379
|
+
return super(parameters: parameters) if config.nil?
|
|
380
|
+
|
|
381
|
+
RubyOpenAI.call(config, parameters, "responses.create") do |params|
|
|
382
|
+
super(parameters: params)
|
|
383
|
+
end
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|