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,781 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "env"
|
|
4
|
+
require_relative "protocol"
|
|
5
|
+
|
|
6
|
+
module Graphmind
|
|
7
|
+
# Coarse redaction: four kill switches, applied at the one emit choke point.
|
|
8
|
+
#
|
|
9
|
+
# Port of packages/client/src/redaction.ts (read that file's header for the
|
|
10
|
+
# rationale). Whole-field replacement of `node.started.input` and
|
|
11
|
+
# `node.finished.output` (and the text of `node.token` deltas) with the
|
|
12
|
+
# placeholder "__REDACTED__", chosen by node kind. No deny lists, no regexes,
|
|
13
|
+
# no callbacks.
|
|
14
|
+
#
|
|
15
|
+
# GRAPHMIND_HIDE_INPUTS (hide_inputs:) node.started.input, every kind;
|
|
16
|
+
# streamed `tool-args` deltas; the
|
|
17
|
+
# arguments of output.toolCalls[]
|
|
18
|
+
# GRAPHMIND_HIDE_OUTPUTS (hide_outputs:) node.finished.output, every kind;
|
|
19
|
+
# every streamed delta
|
|
20
|
+
# GRAPHMIND_HIDE_TOOL_ARGS (hide_tool_args:) node.started.input when kind is
|
|
21
|
+
# tool; streamed `tool-args` deltas;
|
|
22
|
+
# the arguments of output.toolCalls[]
|
|
23
|
+
# (input / inputText, and the older
|
|
24
|
+
# arguments / args) — the calls a
|
|
25
|
+
# model requested carry the tool
|
|
26
|
+
# node's own input
|
|
27
|
+
# GRAPHMIND_HIDE_TOOL_RESULTS (hide_tool_results:) node.finished.output when the
|
|
28
|
+
# instance's kind is tool; deltas a
|
|
29
|
+
# tool node streams
|
|
30
|
+
#
|
|
31
|
+
# Env values `1` / `true` (case-insensitive, surrounding whitespace ignored)
|
|
32
|
+
# turn a switch on; each is also a Session option. Either source turning a
|
|
33
|
+
# switch on turns it on: an environment switch is a floor code cannot lower.
|
|
34
|
+
#
|
|
35
|
+
# Every affected event carries `redaction: {count, keys}`. `node.error` is
|
|
36
|
+
# deliberately NOT redacted (error messages may echo data). A field that is
|
|
37
|
+
# absent, or already the placeholder, is left alone and not counted; an
|
|
38
|
+
# existing `redaction` summary is merged. A key present with `nil` is JSON
|
|
39
|
+
# null, which counts as a value. Symbol keys (`input:`) are honoured too:
|
|
40
|
+
# JSON.generate would write them as "input".
|
|
41
|
+
#
|
|
42
|
+
# What the TOOL-only switches do not cover: in an agent loop the same tool
|
|
43
|
+
# arguments and results also travel through the LLM node (the model's tool
|
|
44
|
+
# calls are its output, the results are the next request's input), and those
|
|
45
|
+
# are recorded unless hide_inputs / hide_outputs are on as well.
|
|
46
|
+
#
|
|
47
|
+
# The session applies this inside emit_internal BEFORE the ring buffer, so
|
|
48
|
+
# replay-on-attach, the socket and everything downstream see only the
|
|
49
|
+
# redacted event. Never raises, never mutates the caller's Hash, zero cost
|
|
50
|
+
# when every switch is off. Thread-safe.
|
|
51
|
+
#
|
|
52
|
+
# FAILS CLOSED (internal/decisions.md "Redaction fails closed on internal
|
|
53
|
+
# error", binding for every port). With any switch on, node.started,
|
|
54
|
+
# node.finished and node.token are redacted from a one-read SNAPSHOT: a plain
|
|
55
|
+
# Hash built from the payload's real storage (Hash#each_pair, bound, so an
|
|
56
|
+
# overridden #[] / #key? / #each_pair / #to_json cannot show the redactor one
|
|
57
|
+
# thing and JSON.generate another), with every key under the name JSON.generate
|
|
58
|
+
# writes for it (#to_s, read once), so what was inspected is exactly what is
|
|
59
|
+
# sent. When that cannot be done — the payload is not a Hash, a key's #to_s or
|
|
60
|
+
# #hash raises, token `deltas` a hiding switch could cover is missing or not
|
|
61
|
+
# an Array, a covered delta is neither nil nor a Hash or has a `v` that is
|
|
62
|
+
# present and not a String, an identity field a switch decides by
|
|
63
|
+
# (nodeId/instanceId/kind of a start, nodeId/instanceId of a result, nodeId of
|
|
64
|
+
# a token, t of each delta of a batch a switch could cover) is present, not nil
|
|
65
|
+
# and neither a String nor a Symbol (JSON.generate writes any other object as
|
|
66
|
+
# its #to_s), or any internal error — the event is replaced by
|
|
67
|
+
# its FAILED FORM, built without reading input/output/deltas:
|
|
68
|
+
#
|
|
69
|
+
# node.started {nodeId, parentId?, kind, name, instanceId, input: "__REDACTED__"}
|
|
70
|
+
# node.finished {nodeId, instanceId?, durationMs, heldMs?, status, usage?, output: "__REDACTED__"}
|
|
71
|
+
# node.token {nodeId, instanceId?, deltas: []}
|
|
72
|
+
#
|
|
73
|
+
# each with `redaction: {count: 0, keys: ["input","output","deltas"], failed:
|
|
74
|
+
# true}`. An optional field that cannot be read or is not schema-valid is
|
|
75
|
+
# omitted; when a required one cannot be (nodeId/name/instanceId Strings, kind
|
|
76
|
+
# a node kind, durationMs a finite number >= 0, status ok|error|aborted),
|
|
77
|
+
# #apply returns DROP and the session does not emit the event. Both outcomes
|
|
78
|
+
# are reported through the `warn:` callback without quoting the payload or
|
|
79
|
+
# the error.
|
|
80
|
+
module Redaction
|
|
81
|
+
REDACTED = "__REDACTED__"
|
|
82
|
+
|
|
83
|
+
# Returned by Redactor#apply when the event must NOT be emitted.
|
|
84
|
+
DROP = Object.new.tap { |o| o.define_singleton_method(:inspect) { "Graphmind::Redaction::DROP" } }.freeze
|
|
85
|
+
|
|
86
|
+
# `redaction.keys` of a failed form: nothing counted, every hideable field named.
|
|
87
|
+
FAILED_KEYS = %w[input output deltas].freeze
|
|
88
|
+
|
|
89
|
+
# Event types the redactor inspects; every other type passes through.
|
|
90
|
+
REDACTED_TYPES = %w[node.started node.finished node.token].freeze
|
|
91
|
+
|
|
92
|
+
# The debugger's answers to a pause (0.6.0, contract C2), covered exactly
|
|
93
|
+
# when the paused node's input is — hide_inputs, or hide_tool_args when the
|
|
94
|
+
# paused node is a tool (an unknown kind counts as a tool):
|
|
95
|
+
# exec.resumed.edited -> {"after" => "__REDACTED__"}, counted as "edited"
|
|
96
|
+
# exec.refused.message -> omitted, counted as "message"
|
|
97
|
+
# Their failed forms keep pauseId, action / code and requestId, hide
|
|
98
|
+
# `edited` and drop `message`, with redaction.failed. This gem never edits
|
|
99
|
+
# an input (it does not announce edit-input), but its refusals and the
|
|
100
|
+
# shared fixture follow the same rule as every other port.
|
|
101
|
+
PAUSE_ANSWER_TYPES = %w[exec.resumed exec.refused].freeze
|
|
102
|
+
|
|
103
|
+
# `RefusalCode` (schema events.ts).
|
|
104
|
+
REFUSAL_CODES = %w[schema shape placeholder truncated disabled unsupported].freeze
|
|
105
|
+
|
|
106
|
+
# Payload fields the failed form may copy (identity and timing only).
|
|
107
|
+
IDENTITY_FIELDS = %w[nodeId parentId kind name instanceId durationMs heldMs status usage].freeze
|
|
108
|
+
|
|
109
|
+
# Fields whose String value decides what is hidden: copied as plain Strings
|
|
110
|
+
# into the snapshot, so a String subclass cannot compare as one value and
|
|
111
|
+
# serialise (#to_json) as another.
|
|
112
|
+
DECIDING_FIELDS = %w[nodeId instanceId kind t].freeze
|
|
113
|
+
|
|
114
|
+
# Hash's and Array's own readers, bound per call: they read the real storage
|
|
115
|
+
# (what JSON.generate walks), whatever a subclass or singleton overrides.
|
|
116
|
+
HASH_EACH_PAIR = Hash.instance_method(:each_pair)
|
|
117
|
+
ARRAY_TO_A = Array.instance_method(:to_a)
|
|
118
|
+
STRING_BYTESIZE = String.instance_method(:bytesize)
|
|
119
|
+
|
|
120
|
+
# Raised (and caught) inside #apply when a payload cannot be inspected safely.
|
|
121
|
+
class Uninspectable < StandardError; end
|
|
122
|
+
|
|
123
|
+
SWITCH_ENV = {
|
|
124
|
+
hide_inputs: "GRAPHMIND_HIDE_INPUTS",
|
|
125
|
+
hide_outputs: "GRAPHMIND_HIDE_OUTPUTS",
|
|
126
|
+
hide_tool_args: "GRAPHMIND_HIDE_TOOL_ARGS",
|
|
127
|
+
hide_tool_results: "GRAPHMIND_HIDE_TOOL_RESULTS"
|
|
128
|
+
}.freeze
|
|
129
|
+
|
|
130
|
+
# Open instances tracked at once, across all runs; oldest evicted past this.
|
|
131
|
+
DEFAULT_MAX_TRACKED_INSTANCES = 10_000
|
|
132
|
+
MAX_TRACKED_NODES = 10_000
|
|
133
|
+
MAX_SAFE_INTEGER = (2**53) - 1
|
|
134
|
+
|
|
135
|
+
Switches = Struct.new(:hide_inputs, :hide_outputs, :hide_tool_args, :hide_tool_results,
|
|
136
|
+
keyword_init: true) do
|
|
137
|
+
def any? = hide_inputs || hide_outputs || hide_tool_args || hide_tool_results ? true : false
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
module_function
|
|
141
|
+
|
|
142
|
+
# A GRAPHMIND_HIDE_* value that turns its switch ON: anything but unset,
|
|
143
|
+
# empty, `0`, `false`, `off` and `no` (Graphmind::Env.kill_switch_on?).
|
|
144
|
+
def env_flag_on?(value)
|
|
145
|
+
Graphmind::Env.kill_switch_on?(value)
|
|
146
|
+
rescue StandardError
|
|
147
|
+
false
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# An option value that turns a switch ON: true, 1, or an env spelling.
|
|
151
|
+
def option_flag_on?(value)
|
|
152
|
+
return true if value == true
|
|
153
|
+
return true if value.is_a?(Numeric) && !value.is_a?(Complex) && value == 1
|
|
154
|
+
|
|
155
|
+
env_flag_on?(value)
|
|
156
|
+
rescue StandardError
|
|
157
|
+
false
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Either the option or the environment turning a switch on turns it on.
|
|
161
|
+
# Never raises: an unreadable source counts as "not on" for that source.
|
|
162
|
+
def resolve(options, env)
|
|
163
|
+
values = SWITCH_ENV.to_h do |name, env_name|
|
|
164
|
+
on = begin
|
|
165
|
+
options.is_a?(Hash) && (option_flag_on?(options[name]) || option_flag_on?(options[name.to_s]))
|
|
166
|
+
rescue StandardError
|
|
167
|
+
false
|
|
168
|
+
end
|
|
169
|
+
unless on
|
|
170
|
+
on = begin
|
|
171
|
+
env_flag_on?(env && env[env_name])
|
|
172
|
+
rescue StandardError
|
|
173
|
+
false
|
|
174
|
+
end
|
|
175
|
+
end
|
|
176
|
+
[name, on ? true : false]
|
|
177
|
+
end
|
|
178
|
+
Switches.new(**values)
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# JavaScript `string.length`: UTF-16 code units, so an emoji counts 2.
|
|
182
|
+
def utf16_length(text)
|
|
183
|
+
text.encode(Encoding::UTF_16LE).bytesize / 2
|
|
184
|
+
rescue StandardError
|
|
185
|
+
text.length
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
# A JS number that is a non-negative safe integer (1.0 counts, like JS).
|
|
189
|
+
def safe_count(raw)
|
|
190
|
+
return nil unless Integer === raw || Float === raw
|
|
191
|
+
return nil if Float === raw && (!raw.finite? || raw != raw.floor)
|
|
192
|
+
return nil if raw.negative? || raw > MAX_SAFE_INTEGER
|
|
193
|
+
|
|
194
|
+
raw.to_i
|
|
195
|
+
rescue StandardError
|
|
196
|
+
nil
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
# Merge a fresh summary into whatever `redaction` the payload already had.
|
|
200
|
+
def merge_summary(existing, count, key)
|
|
201
|
+
if existing.is_a?(Hash)
|
|
202
|
+
raw_count = fetch(existing, "count")
|
|
203
|
+
raw_keys = fetch(existing, "keys")
|
|
204
|
+
if raw_count.is_a?(Numeric) && !raw_count.is_a?(Complex) && raw_keys.is_a?(Array)
|
|
205
|
+
prior = raw_keys.select { |k| k.is_a?(String) }
|
|
206
|
+
keys = prior.include?(key) ? prior : prior + [key]
|
|
207
|
+
total = (safe_count(raw_count) || 0) + count
|
|
208
|
+
return { "count" => total <= MAX_SAFE_INTEGER ? total : count, "keys" => keys }
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
{ "count" => count, "keys" => [key] }
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Every key under which `name` reaches the JSON text, in insertion order:
|
|
215
|
+
# the String and/or the Symbol. JSON.generate writes BOTH when both are
|
|
216
|
+
# present, and a JSON parser (the hub's included) keeps the LAST, so a
|
|
217
|
+
# switch must hide every one of them, and a read must take the last.
|
|
218
|
+
def keys_of(hash, name)
|
|
219
|
+
sym = name.to_sym
|
|
220
|
+
has_string = hash.key?(name)
|
|
221
|
+
has_symbol = hash.key?(sym)
|
|
222
|
+
return [] unless has_string || has_symbol
|
|
223
|
+
return [has_string ? name : sym] unless has_string && has_symbol
|
|
224
|
+
|
|
225
|
+
hash.keys.select { |k| k.equal?(sym) || (k.is_a?(String) && k == name) }
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
# The key whose value the JSON reader will see for `name`, or nil.
|
|
229
|
+
def key_of(hash, name) = keys_of(hash, name).last
|
|
230
|
+
|
|
231
|
+
def fetch(hash, name)
|
|
232
|
+
key = key_of(hash, name)
|
|
233
|
+
key.nil? ? nil : hash[key]
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
# A kind (or delta channel) as it reaches the wire: a Symbol (`kind: :tool`,
|
|
237
|
+
# `:"tool-args"`) is written as its name, so it IS that value for every switch.
|
|
238
|
+
def wire_kind(kind) = Symbol === kind ? kind.name : kind
|
|
239
|
+
|
|
240
|
+
# `String ===` and `REDACTED ==` run String's own code, never a method the
|
|
241
|
+
# value defines (a lying #is_a? or #== must not make a secret "already hidden").
|
|
242
|
+
def placeholder?(value) = String === value && REDACTED == value
|
|
243
|
+
|
|
244
|
+
# A value equal to `literal` as JSON writes it (a Symbol as its name).
|
|
245
|
+
def wire_eq?(value, literal)
|
|
246
|
+
value = value.name if Symbol === value
|
|
247
|
+
String === value && literal == value
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
# The name JSON.generate writes for a Hash key: it calls #to_s on EVERY key
|
|
251
|
+
# (a String's own singleton #to_s included), so that is the name to inspect.
|
|
252
|
+
# A Symbol stays a Symbol (its name cannot be overridden per object).
|
|
253
|
+
def json_key(key)
|
|
254
|
+
return key if Symbol === key
|
|
255
|
+
|
|
256
|
+
text = key.to_s
|
|
257
|
+
raise Uninspectable, "a key's #to_s is not a String" unless String === text
|
|
258
|
+
|
|
259
|
+
String.new(text).freeze
|
|
260
|
+
end
|
|
261
|
+
|
|
262
|
+
# A plain String copy of a String value (a subclass's #to_json dropped).
|
|
263
|
+
def plain_string(value) = String === value ? String.new(value).freeze : value
|
|
264
|
+
|
|
265
|
+
# An identity field a switch decides by (a start's kind, the nodeId /
|
|
266
|
+
# instanceId a result's kind is looked up by, a delta's t), read from a
|
|
267
|
+
# SNAPSHOT: absent or nil -> nil, a String -> itself (the snapshot already
|
|
268
|
+
# made it plain), a Symbol -> its name (JSON writes it so). Anything else is
|
|
269
|
+
# compared as one value but serialised as another — JSON.generate writes an
|
|
270
|
+
# object as its #to_s — so a kind whose #to_s is "tool" would keep a tool's
|
|
271
|
+
# arguments visible under `"kind":"tool"`. Such a payload cannot be
|
|
272
|
+
# inspected: raise, and the caller fails closed (TS parity).
|
|
273
|
+
def identity(snap, name)
|
|
274
|
+
value = fetch(snap, name)
|
|
275
|
+
return nil if value.nil?
|
|
276
|
+
return value if String === value
|
|
277
|
+
return value.name if Symbol === value
|
|
278
|
+
|
|
279
|
+
raise Uninspectable, "#{name} is not a String"
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# One read of a Hash's real storage into a plain Hash under the key names
|
|
283
|
+
# JSON.generate writes (see the module comment). Raises when a key cannot
|
|
284
|
+
# be named or hashed; the caller fails closed.
|
|
285
|
+
def snapshot(hash)
|
|
286
|
+
copy = {}
|
|
287
|
+
HASH_EACH_PAIR.bind_call(hash) do |key, value|
|
|
288
|
+
name = json_key(key)
|
|
289
|
+
field = Symbol === name ? name.name : name
|
|
290
|
+
copy[name] = DECIDING_FIELDS.include?(field) ? plain_string(value) : value
|
|
291
|
+
end
|
|
292
|
+
copy
|
|
293
|
+
end
|
|
294
|
+
|
|
295
|
+
# The identity/timing fields of a payload for the failed form, read
|
|
296
|
+
# best-effort: a key that cannot be named is skipped; the last spelling of a
|
|
297
|
+
# field wins, as it does for the JSON reader.
|
|
298
|
+
def identity_fields(hash, wanted = IDENTITY_FIELDS)
|
|
299
|
+
fields = {}
|
|
300
|
+
HASH_EACH_PAIR.bind_call(hash) do |key, value|
|
|
301
|
+
begin
|
|
302
|
+
name = json_key(key)
|
|
303
|
+
rescue StandardError
|
|
304
|
+
next
|
|
305
|
+
end
|
|
306
|
+
name = name.name if Symbol === name
|
|
307
|
+
fields[name] = plain_string(value) if wanted.include?(name)
|
|
308
|
+
end
|
|
309
|
+
fields
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
def string_field(value)
|
|
313
|
+
return value.name.dup.freeze if Symbol === value
|
|
314
|
+
|
|
315
|
+
String === value ? value : nil
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
def duration_field(value)
|
|
319
|
+
ok = Integer === value || (Float === value && value.finite?)
|
|
320
|
+
ok && value >= 0 ? value : nil
|
|
321
|
+
end
|
|
322
|
+
|
|
323
|
+
# The optional usage counts the failed form keeps (with `inclusive`), in wire order.
|
|
324
|
+
OPTIONAL_USAGE_COUNTS = %w[cacheReadTokens cacheWriteTokens reasoningTokens].freeze
|
|
325
|
+
USAGE_FIELDS = (%w[inputTokens outputTokens inclusive] + OPTIONAL_USAGE_COUNTS).freeze
|
|
326
|
+
|
|
327
|
+
# The fields of an output.toolCalls[] entry that carry the model's tool
|
|
328
|
+
# arguments: input / inputText (0.6.0+) and the spellings older senders used
|
|
329
|
+
# (arguments — the 0.5 OpenAI adapters; args — LangChain's own).
|
|
330
|
+
TOOL_CALL_ARG_KEYS = %w[input inputText arguments args].freeze
|
|
331
|
+
|
|
332
|
+
# {inputTokens, outputTokens} (both safe integers >= 0, else nil), plus
|
|
333
|
+
# `inclusive` when it is a boolean and cacheReadTokens / cacheWriteTokens /
|
|
334
|
+
# reasoningTokens when each is a safe integer >= 0 (0.6.0+). Nothing else.
|
|
335
|
+
def usage_field(value)
|
|
336
|
+
return nil unless Hash === value
|
|
337
|
+
|
|
338
|
+
counts = identity_fields(value, USAGE_FIELDS)
|
|
339
|
+
input = safe_count(counts["inputTokens"])
|
|
340
|
+
output = safe_count(counts["outputTokens"])
|
|
341
|
+
return nil unless input && output
|
|
342
|
+
|
|
343
|
+
out = { "inputTokens" => input, "outputTokens" => output }
|
|
344
|
+
inclusive = counts["inclusive"]
|
|
345
|
+
out["inclusive"] = inclusive if TrueClass === inclusive || FalseClass === inclusive
|
|
346
|
+
OPTIONAL_USAGE_COUNTS.each do |name|
|
|
347
|
+
count = safe_count(counts[name])
|
|
348
|
+
out[name] = count unless count.nil?
|
|
349
|
+
end
|
|
350
|
+
out
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# Applies the switches to one event at a time. One per session.
|
|
354
|
+
class Redactor
|
|
355
|
+
attr_reader :switches
|
|
356
|
+
|
|
357
|
+
# `warn:` receives the fail-closed reports as (key, message); keys
|
|
358
|
+
# "redaction:failed" and "redaction:dropped". A raising sink is swallowed.
|
|
359
|
+
def initialize(switches, max_instances: DEFAULT_MAX_TRACKED_INSTANCES, warn: nil)
|
|
360
|
+
@switches = switches
|
|
361
|
+
@max_instances = [max_instances.to_i, 1].max
|
|
362
|
+
@warn = warn
|
|
363
|
+
@mutex = Mutex.new
|
|
364
|
+
# Hashes keep insertion order -> oldest-first eviction
|
|
365
|
+
@instances = {}
|
|
366
|
+
@nodes = {}
|
|
367
|
+
end
|
|
368
|
+
|
|
369
|
+
def active? = @switches.any?
|
|
370
|
+
|
|
371
|
+
def tracked_instances = @mutex.synchronize { @instances.size }
|
|
372
|
+
|
|
373
|
+
# Does a switch hide the input of a paused node of this kind? hide_inputs,
|
|
374
|
+
# or hide_tool_args on a tool — an unknown kind (nil, or anything that is
|
|
375
|
+
# neither a String nor a Symbol) counts as a tool.
|
|
376
|
+
def covers_pause_input?(node_kind)
|
|
377
|
+
return true if @switches.hide_inputs
|
|
378
|
+
return false unless @switches.hide_tool_args
|
|
379
|
+
|
|
380
|
+
kind = Symbol === node_kind ? node_kind.name : node_kind
|
|
381
|
+
# The literal's own String#==, never a #== the kind object defines.
|
|
382
|
+
!(String === kind) || "tool" == kind
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
# Redact one event. Every switch off, or a type other than node.started /
|
|
386
|
+
# node.finished / node.token / exec.resumed / exec.refused: the very same
|
|
387
|
+
# object. Otherwise a plain Hash (see the module comment) — redacted,
|
|
388
|
+
# unchanged, or the failed form — or DROP, meaning the event must NOT be
|
|
389
|
+
# emitted. `node_kind` is the paused node's kind, for exec.resumed /
|
|
390
|
+
# exec.refused (which do not name their node). Never raises.
|
|
391
|
+
def apply(type, payload, run_id, node_kind = nil)
|
|
392
|
+
return payload unless @switches.any?
|
|
393
|
+
|
|
394
|
+
type = type.name if Symbol === type # JSON writes :"node.started" as "node.started"
|
|
395
|
+
return on_pause_answer(type, payload, node_kind) if String === type && PAUSE_ANSWER_TYPES.include?(type)
|
|
396
|
+
return payload unless String === type && REDACTED_TYPES.include?(type)
|
|
397
|
+
|
|
398
|
+
begin
|
|
399
|
+
raise Uninspectable, "payload is not a Hash" unless Hash === payload
|
|
400
|
+
|
|
401
|
+
snap = Redaction.snapshot(payload)
|
|
402
|
+
case type
|
|
403
|
+
when "node.started" then on_started(snap, run_id)
|
|
404
|
+
when "node.finished" then on_finished(snap, run_id)
|
|
405
|
+
else on_token(snap, run_id)
|
|
406
|
+
end
|
|
407
|
+
rescue StandardError, SystemStackError
|
|
408
|
+
fail_closed(type, payload, run_id)
|
|
409
|
+
end
|
|
410
|
+
rescue StandardError, SystemStackError
|
|
411
|
+
DROP
|
|
412
|
+
end
|
|
413
|
+
|
|
414
|
+
private
|
|
415
|
+
|
|
416
|
+
# -- exec.resumed / exec.refused ------------------------------------------
|
|
417
|
+
|
|
418
|
+
# The input-shaped parts of a pause's answer, hidden exactly when the
|
|
419
|
+
# paused node's input is. Not covered: the very same object. Covered: a
|
|
420
|
+
# snapshot copy, or the failed form.
|
|
421
|
+
def on_pause_answer(type, payload, node_kind)
|
|
422
|
+
return payload unless covers_pause_input?(node_kind)
|
|
423
|
+
|
|
424
|
+
begin
|
|
425
|
+
raise Uninspectable, "payload is not a Hash" unless Hash === payload
|
|
426
|
+
|
|
427
|
+
snap = Redaction.snapshot(payload)
|
|
428
|
+
Redaction.identity(snap, "pauseId")
|
|
429
|
+
if type == "exec.resumed"
|
|
430
|
+
keys = Redaction.keys_of(snap, "edited")
|
|
431
|
+
return snap if keys.empty?
|
|
432
|
+
# Already the placeholder: left alone, not counted.
|
|
433
|
+
return snap if keys.length == 1 && hidden_edit?(snap[keys.first])
|
|
434
|
+
|
|
435
|
+
keys.each { |key| snap[key] = { "after" => REDACTED } }
|
|
436
|
+
with_summary(snap, 1, "edited")
|
|
437
|
+
else
|
|
438
|
+
keys = Redaction.keys_of(snap, "message")
|
|
439
|
+
return snap if keys.empty?
|
|
440
|
+
|
|
441
|
+
keys.each { |key| snap.delete(key) }
|
|
442
|
+
with_summary(snap, 1, "message")
|
|
443
|
+
end
|
|
444
|
+
rescue StandardError, SystemStackError
|
|
445
|
+
fail_closed_answer(type, payload)
|
|
446
|
+
end
|
|
447
|
+
end
|
|
448
|
+
|
|
449
|
+
# `edited` is exactly {after: placeholder}.
|
|
450
|
+
def hidden_edit?(edited)
|
|
451
|
+
return false unless Hash === edited
|
|
452
|
+
|
|
453
|
+
copy = Redaction.snapshot(edited)
|
|
454
|
+
copy.size == 1 && Redaction.placeholder?(Redaction.fetch(copy, "after"))
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def fail_closed_answer(type, payload)
|
|
458
|
+
out = begin
|
|
459
|
+
failed_answer_form(type, payload)
|
|
460
|
+
rescue StandardError, SystemStackError
|
|
461
|
+
nil
|
|
462
|
+
end
|
|
463
|
+
if out.nil?
|
|
464
|
+
report("redaction:dropped",
|
|
465
|
+
"a #{type} event could not be redacted and its identity fields could not be read; " \
|
|
466
|
+
"dropped it rather than send data a GRAPHMIND_HIDE_* switch hides")
|
|
467
|
+
return DROP
|
|
468
|
+
end
|
|
469
|
+
report("redaction:failed",
|
|
470
|
+
"redaction failed on a #{type} event (unreadable or malformed payload); " \
|
|
471
|
+
"sent it with the edited input / refusal message hidden and redaction.failed set")
|
|
472
|
+
out
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
ANSWER_FIELDS = %w[pauseId action code requestId].freeze
|
|
476
|
+
|
|
477
|
+
# The failed form of a pause answer, or nil when pauseId and action / code
|
|
478
|
+
# cannot be read as valid values (the event is then dropped).
|
|
479
|
+
def failed_answer_form(type, payload)
|
|
480
|
+
return nil unless Hash === payload
|
|
481
|
+
|
|
482
|
+
fields = Redaction.identity_fields(payload, ANSWER_FIELDS)
|
|
483
|
+
pause_id = Redaction.string_field(fields["pauseId"])
|
|
484
|
+
return nil if pause_id.nil?
|
|
485
|
+
|
|
486
|
+
request_id = Redaction.string_field(fields["requestId"])
|
|
487
|
+
echo = request_id.nil? ? {} : { "requestId" => request_id }
|
|
488
|
+
if type == "exec.resumed"
|
|
489
|
+
action = Redaction.string_field(fields["action"])
|
|
490
|
+
return nil unless action && Protocol::RESUME_ACTIONS.include?(action)
|
|
491
|
+
|
|
492
|
+
out = { "pauseId" => pause_id, "action" => action }
|
|
493
|
+
# Unreadable counts as present: an edit the record cannot rule out.
|
|
494
|
+
out["edited"] = { "after" => REDACTED } if may_have_edit?(payload)
|
|
495
|
+
return out.merge(echo).merge("redaction" => { "count" => 0, "keys" => ["edited"], "failed" => true })
|
|
496
|
+
end
|
|
497
|
+
code = Redaction.string_field(fields["code"])
|
|
498
|
+
return nil unless code && REFUSAL_CODES.include?(code)
|
|
499
|
+
|
|
500
|
+
{ "pauseId" => pause_id, "code" => code }
|
|
501
|
+
.merge(echo)
|
|
502
|
+
.merge("redaction" => { "count" => 0, "keys" => ["message"], "failed" => true })
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
def may_have_edit?(payload)
|
|
506
|
+
found = false
|
|
507
|
+
HASH_EACH_PAIR.bind_call(payload) do |key, _value|
|
|
508
|
+
name = begin
|
|
509
|
+
Redaction.json_key(key)
|
|
510
|
+
rescue StandardError
|
|
511
|
+
found = true
|
|
512
|
+
next
|
|
513
|
+
end
|
|
514
|
+
name = name.name if Symbol === name
|
|
515
|
+
found = true if name == "edited"
|
|
516
|
+
end
|
|
517
|
+
found
|
|
518
|
+
rescue StandardError
|
|
519
|
+
true
|
|
520
|
+
end
|
|
521
|
+
|
|
522
|
+
# -- fail closed ----------------------------------------------------------
|
|
523
|
+
|
|
524
|
+
def fail_closed(type, payload, run_id)
|
|
525
|
+
out = begin
|
|
526
|
+
failed_form(type, payload, run_id)
|
|
527
|
+
rescue StandardError, SystemStackError
|
|
528
|
+
nil
|
|
529
|
+
end
|
|
530
|
+
if out.nil?
|
|
531
|
+
report("redaction:dropped",
|
|
532
|
+
"a #{type} event could not be redacted and its identity fields could not be read; " \
|
|
533
|
+
"dropped it rather than send data a GRAPHMIND_HIDE_* switch hides")
|
|
534
|
+
return DROP
|
|
535
|
+
end
|
|
536
|
+
report("redaction:failed",
|
|
537
|
+
"redaction failed on a #{type} event (unreadable or malformed payload); " \
|
|
538
|
+
"sent it with input/output/deltas hidden and redaction.failed set")
|
|
539
|
+
out
|
|
540
|
+
end
|
|
541
|
+
|
|
542
|
+
# The failed form (module comment), or nil when it cannot be valid.
|
|
543
|
+
# Never reads input, output or deltas.
|
|
544
|
+
def failed_form(type, payload, run_id)
|
|
545
|
+
return nil unless Hash === payload
|
|
546
|
+
|
|
547
|
+
fields = Redaction.identity_fields(payload)
|
|
548
|
+
node_id = Redaction.string_field(fields["nodeId"])
|
|
549
|
+
return nil if node_id.nil?
|
|
550
|
+
|
|
551
|
+
out = { "nodeId" => node_id }
|
|
552
|
+
failed = { "count" => 0, "keys" => FAILED_KEYS.dup, "failed" => true }
|
|
553
|
+
case type
|
|
554
|
+
when "node.started"
|
|
555
|
+
parent_id = Redaction.string_field(fields["parentId"])
|
|
556
|
+
out["parentId"] = parent_id unless parent_id.nil?
|
|
557
|
+
kind = Redaction.string_field(fields["kind"])
|
|
558
|
+
name = Redaction.string_field(fields["name"])
|
|
559
|
+
instance_id = Redaction.string_field(fields["instanceId"])
|
|
560
|
+
return nil unless kind && Protocol::NODE_KINDS.include?(kind) && name && instance_id
|
|
561
|
+
|
|
562
|
+
# Still learn the kind, so this instance's node.finished is judged right.
|
|
563
|
+
remember(run_id, node_id, instance_id, kind)
|
|
564
|
+
out.merge!("kind" => kind, "name" => name, "instanceId" => instance_id,
|
|
565
|
+
"input" => REDACTED, "redaction" => failed)
|
|
566
|
+
when "node.finished"
|
|
567
|
+
instance_id = Redaction.string_field(fields["instanceId"])
|
|
568
|
+
unless instance_id.nil?
|
|
569
|
+
out["instanceId"] = instance_id
|
|
570
|
+
@mutex.synchronize { @instances.delete([run_id, node_id, instance_id]) }
|
|
571
|
+
end
|
|
572
|
+
duration = Redaction.duration_field(fields["durationMs"])
|
|
573
|
+
status = Redaction.string_field(fields["status"])
|
|
574
|
+
return nil unless duration && status && Protocol::RUN_STATUSES.include?(status)
|
|
575
|
+
|
|
576
|
+
out["durationMs"] = duration
|
|
577
|
+
held = Redaction.duration_field(fields["heldMs"])
|
|
578
|
+
out["heldMs"] = held unless held.nil?
|
|
579
|
+
out["status"] = status
|
|
580
|
+
usage = Redaction.usage_field(fields["usage"])
|
|
581
|
+
out["usage"] = usage unless usage.nil?
|
|
582
|
+
out.merge!("output" => REDACTED, "redaction" => failed)
|
|
583
|
+
else
|
|
584
|
+
instance_id = Redaction.string_field(fields["instanceId"])
|
|
585
|
+
out["instanceId"] = instance_id unless instance_id.nil?
|
|
586
|
+
out.merge!("deltas" => [], "redaction" => failed)
|
|
587
|
+
end
|
|
588
|
+
end
|
|
589
|
+
|
|
590
|
+
def report(key, message)
|
|
591
|
+
@warn&.call(key, message)
|
|
592
|
+
rescue StandardError
|
|
593
|
+
nil # a raising sink must not turn a safe outcome into a raise
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
# -- events ----------------------------------------------------------------
|
|
597
|
+
# Each handler receives the SNAPSHOT (a plain Hash the redactor owns) and
|
|
598
|
+
# returns it, changed in place. A raise means "fail closed".
|
|
599
|
+
|
|
600
|
+
def on_started(snap, run_id)
|
|
601
|
+
kind = Redaction.identity(snap, "kind")
|
|
602
|
+
node_id = Redaction.identity(snap, "nodeId")
|
|
603
|
+
instance_id = Redaction.identity(snap, "instanceId")
|
|
604
|
+
remember(run_id, node_id, instance_id, kind) if node_id && kind
|
|
605
|
+
hide = @switches.hide_inputs || (@switches.hide_tool_args && Redaction.wire_eq?(kind, "tool"))
|
|
606
|
+
replace(snap, "input", 1, hide)
|
|
607
|
+
end
|
|
608
|
+
|
|
609
|
+
def on_finished(snap, run_id)
|
|
610
|
+
node_id = Redaction.identity(snap, "nodeId")
|
|
611
|
+
instance_id = Redaction.identity(snap, "instanceId")
|
|
612
|
+
kind = node_id ? kind_of(run_id, node_id, instance_id) : nil
|
|
613
|
+
@mutex.synchronize { @instances.delete([run_id, node_id, instance_id]) } if node_id && instance_id
|
|
614
|
+
hide = @switches.hide_outputs || (@switches.hide_tool_results && Redaction.wire_eq?(kind, "tool"))
|
|
615
|
+
return replace(snap, "output", 1, true) if hide
|
|
616
|
+
# The tool calls a model requested carry the very arguments the tool
|
|
617
|
+
# node will receive: hide them wherever tool arguments are hidden (the
|
|
618
|
+
# same switches as `tool-args` deltas).
|
|
619
|
+
return hide_tool_call_args(snap) if @switches.hide_tool_args || @switches.hide_inputs
|
|
620
|
+
|
|
621
|
+
snap
|
|
622
|
+
end
|
|
623
|
+
|
|
624
|
+
# output.toolCalls[*] with every argument field (TOOL_CALL_ARG_KEYS)
|
|
625
|
+
# replaced by the placeholder, from one-read snapshots of the output and of
|
|
626
|
+
# each call. A toolCalls that is not an Array, or an entry that is neither
|
|
627
|
+
# nil nor a Hash, is replaced whole. Every spelling (String/Symbol key) is
|
|
628
|
+
# covered; the count is the one the reader keeps (the last). TS parity:
|
|
629
|
+
# hideToolCallArgs.
|
|
630
|
+
def hide_tool_call_args(snap)
|
|
631
|
+
count = 0
|
|
632
|
+
Redaction.keys_of(snap, "output").each do |output_key|
|
|
633
|
+
output = snap[output_key]
|
|
634
|
+
next unless Hash === output
|
|
635
|
+
|
|
636
|
+
copy = Redaction.snapshot(output)
|
|
637
|
+
call_keys = Redaction.keys_of(copy, "toolCalls")
|
|
638
|
+
next if call_keys.empty?
|
|
639
|
+
|
|
640
|
+
call_keys.each do |call_key|
|
|
641
|
+
calls = copy[call_key]
|
|
642
|
+
next if calls.nil? || Redaction.placeholder?(calls)
|
|
643
|
+
|
|
644
|
+
if Array === calls
|
|
645
|
+
count, copy[call_key] = redact_tool_calls(ARRAY_TO_A.bind_call(calls))
|
|
646
|
+
else
|
|
647
|
+
copy[call_key] = REDACTED
|
|
648
|
+
count = 1
|
|
649
|
+
end
|
|
650
|
+
end
|
|
651
|
+
snap[output_key] = copy
|
|
652
|
+
end
|
|
653
|
+
count.zero? ? snap : with_summary(snap, count, "output.toolCalls")
|
|
654
|
+
end
|
|
655
|
+
|
|
656
|
+
# [count, copied calls].
|
|
657
|
+
def redact_tool_calls(calls)
|
|
658
|
+
count = 0
|
|
659
|
+
out = calls.map do |call|
|
|
660
|
+
next call if call.nil? || Redaction.placeholder?(call)
|
|
661
|
+
unless Hash === call
|
|
662
|
+
count += 1
|
|
663
|
+
next REDACTED
|
|
664
|
+
end
|
|
665
|
+
|
|
666
|
+
entry = Redaction.snapshot(call)
|
|
667
|
+
TOOL_CALL_ARG_KEYS.each do |name|
|
|
668
|
+
keys = Redaction.keys_of(entry, name)
|
|
669
|
+
next if keys.empty?
|
|
670
|
+
|
|
671
|
+
count += 1 unless Redaction.placeholder?(entry[keys.last])
|
|
672
|
+
keys.each { |key| entry[key] = REDACTED }
|
|
673
|
+
end
|
|
674
|
+
entry
|
|
675
|
+
end
|
|
676
|
+
[count, out]
|
|
677
|
+
end
|
|
678
|
+
|
|
679
|
+
def replace(snap, field, count, hide)
|
|
680
|
+
return snap unless hide
|
|
681
|
+
|
|
682
|
+
keys = Redaction.keys_of(snap, field)
|
|
683
|
+
return snap if keys.empty?
|
|
684
|
+
|
|
685
|
+
counted = !keys.all? { |key| Redaction.placeholder?(snap[key]) }
|
|
686
|
+
# Every spelling becomes the plain placeholder — one that already equals
|
|
687
|
+
# it too (not counted), so a String subclass that compares equal cannot
|
|
688
|
+
# serialise as something else.
|
|
689
|
+
keys.each { |key| snap[key] = REDACTED }
|
|
690
|
+
counted ? with_summary(snap, count, field) : snap
|
|
691
|
+
end
|
|
692
|
+
|
|
693
|
+
# Merge the summary into the `redaction` the reader will see, and write it
|
|
694
|
+
# under every key that spells `redaction` so no stale copy wins the parse.
|
|
695
|
+
def with_summary(out, count, field)
|
|
696
|
+
summary_keys = Redaction.keys_of(out, "redaction")
|
|
697
|
+
summary = Redaction.merge_summary(summary_keys.empty? ? nil : out[summary_keys.last], count, field)
|
|
698
|
+
(summary_keys.empty? ? ["redaction"] : summary_keys).each { |key| out[key] = summary }
|
|
699
|
+
out
|
|
700
|
+
end
|
|
701
|
+
|
|
702
|
+
def on_token(snap, run_id)
|
|
703
|
+
node_id = Redaction.identity(snap, "nodeId")
|
|
704
|
+
node_is_tool = @switches.hide_tool_results && node_id &&
|
|
705
|
+
kind_of(run_id, node_id, nil) == "tool"
|
|
706
|
+
hide_all = @switches.hide_outputs || node_is_tool
|
|
707
|
+
hide_tool_args = @switches.hide_tool_args || @switches.hide_inputs
|
|
708
|
+
return snap unless hide_all || hide_tool_args
|
|
709
|
+
|
|
710
|
+
deltas_keys = Redaction.keys_of(snap, "deltas")
|
|
711
|
+
raise Uninspectable, "deltas is missing" if deltas_keys.empty?
|
|
712
|
+
|
|
713
|
+
# Every spelling of `deltas` is inspected and replaced by its copy; the
|
|
714
|
+
# count is the one the reader keeps (the last).
|
|
715
|
+
count = 0
|
|
716
|
+
deltas_keys.each do |key|
|
|
717
|
+
list = snap[key]
|
|
718
|
+
raise Uninspectable, "deltas is not an Array" unless Array === list
|
|
719
|
+
|
|
720
|
+
count, snap[key] = redact_deltas(ARRAY_TO_A.bind_call(list), hide_all, hide_tool_args)
|
|
721
|
+
end
|
|
722
|
+
count.zero? ? snap : with_summary(snap, count, "deltas")
|
|
723
|
+
end
|
|
724
|
+
|
|
725
|
+
# [count, copied deltas]. One read of the Array and of each delta; the
|
|
726
|
+
# copies are what is sent.
|
|
727
|
+
def redact_deltas(deltas, hide_all, hide_tool_args)
|
|
728
|
+
count = 0
|
|
729
|
+
out = deltas.map do |delta|
|
|
730
|
+
next nil if delta.nil?
|
|
731
|
+
# A bare String (or number, Array...) where a delta belongs: its
|
|
732
|
+
# channel is unknown, so every hiding switch may cover it.
|
|
733
|
+
raise Uninspectable, "a delta is not a Hash" unless Hash === delta
|
|
734
|
+
|
|
735
|
+
copy = Redaction.snapshot(delta)
|
|
736
|
+
channel = Redaction.identity(copy, "t") # read even under hide_all: one rule for every port
|
|
737
|
+
hide = hide_all || (hide_tool_args && Redaction.wire_eq?(channel, "tool-args"))
|
|
738
|
+
next copy unless hide
|
|
739
|
+
|
|
740
|
+
v_keys = Redaction.keys_of(copy, "v")
|
|
741
|
+
next copy if v_keys.empty?
|
|
742
|
+
raise Uninspectable, "a covered delta value is not a String" unless v_keys.all? { |key| String === copy[key] }
|
|
743
|
+
|
|
744
|
+
visible = copy[v_keys.last]
|
|
745
|
+
counted = !STRING_BYTESIZE.bind_call(visible).zero?
|
|
746
|
+
chars = counted ? Redaction.utf16_length(String.new(visible)) : nil
|
|
747
|
+
v_keys.each { |key| copy[key] = "" unless STRING_BYTESIZE.bind_call(copy[key]).zero? }
|
|
748
|
+
if counted
|
|
749
|
+
count += 1
|
|
750
|
+
copy["chars"] = chars
|
|
751
|
+
end
|
|
752
|
+
copy
|
|
753
|
+
end
|
|
754
|
+
[count, out]
|
|
755
|
+
end
|
|
756
|
+
|
|
757
|
+
def remember(run_id, node_id, instance_id, kind)
|
|
758
|
+
@mutex.synchronize do
|
|
759
|
+
node_key = [run_id, node_id]
|
|
760
|
+
@nodes.delete(node_key) # re-insert so the newest is last
|
|
761
|
+
@nodes[node_key] = kind
|
|
762
|
+
@nodes.delete(@nodes.first[0]) while @nodes.size > MAX_TRACKED_NODES
|
|
763
|
+
next if instance_id.nil?
|
|
764
|
+
|
|
765
|
+
@instances[[run_id, node_id, instance_id]] = kind
|
|
766
|
+
@instances.delete(@instances.first[0]) while @instances.size > @max_instances
|
|
767
|
+
end
|
|
768
|
+
end
|
|
769
|
+
|
|
770
|
+
def kind_of(run_id, node_id, instance_id)
|
|
771
|
+
found = @mutex.synchronize do
|
|
772
|
+
(instance_id && @instances[[run_id, node_id, instance_id]]) || @nodes[[run_id, node_id]]
|
|
773
|
+
end
|
|
774
|
+
return found unless found.nil?
|
|
775
|
+
|
|
776
|
+
# Last resort: the nodeId convention every adapter follows (decisions.md #1).
|
|
777
|
+
node_id.start_with?("tool:") ? "tool" : nil
|
|
778
|
+
end
|
|
779
|
+
end
|
|
780
|
+
end
|
|
781
|
+
end
|