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