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,516 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "set"
5
+
6
+ module Graphmind
7
+ # Loop guard: hold an agent that makes the same tool call again, back-to-back,
8
+ # with the same arguments.
9
+ #
10
+ # Port of packages/client/src/loop-guard.ts (read that file's header for the
11
+ # rationale), held to the conformance fixture
12
+ # packages/client/test/fixtures/loop-guard.json (version 3).
13
+ #
14
+ # The session fingerprints (nodeId, input) where node.started is emitted and
15
+ # CONSULTS the streak at the next `before` gate of that node:
16
+ #
17
+ # * mode pause + debugger attached: the Nth identical back-to-back call is
18
+ # HELD through the normal gate path (pause_timeout and fail-open release
19
+ # apply) with `reason: "loop"` and `loop: {repeats, firstSeq, lastSeq,
20
+ # fingerprint}` on exec.paused;
21
+ # * mode pause + detached, or mode warn: ONE warning per streak, execution
22
+ # continues (the count keeps running, so a debugger that attaches later
23
+ # holds the next identical call);
24
+ # * mode off or threshold 0: nothing at all.
25
+ #
26
+ # Counting, rule v3 (internal/decisions.md "Loop hold v3: a loop is the same
27
+ # call BACK-TO-BACK" — identical in TypeScript and Python):
28
+ #
29
+ # 1. Per run, per node KIND, ONE streak {nodeId, fingerprint, count,
30
+ # firstSeq, lastSeq}.
31
+ # 2. A WATCHED start (guard enabled, kind in `kinds`, neither nodeId nor
32
+ # name in `allow_nodes`) with a readable input: same (nodeId,
33
+ # fingerprint) as its kind's streak -> count += 1; otherwise the streak
34
+ # is REPLACED by a fresh one with count 1.
35
+ # 3. A watched start whose input cannot be read (or whose nodeId is not a
36
+ # String/Symbol) CLEARS its kind's streak.
37
+ # 4. Unwatched starts (other kinds, allow-listed nodes) touch no streak, so
38
+ # an LLM step between two identical tool calls does not break the tool
39
+ # streak.
40
+ # 5. The before-gate holds when its kind's streak belongs to this nodeId
41
+ # and count >= threshold, once per count: a `retry` (no new node.started)
42
+ # runs on; the next identical back-to-back call holds again.
43
+ # 6. Memory: one streak per (run, kind); runs LRU-bounded.
44
+ #
45
+ # Why: under `graphmind mcp-proxy` a whole host session is one run, so a
46
+ # per-node rule held `list_issues({})` called at minute 1, 20 and 45 with
47
+ # dozens of other tools between. Accepted consequence: a model ALTERNATING
48
+ # between tools (search, read, search, read) is not held.
49
+ #
50
+ # Fingerprint = first 32 hex chars of SHA-256 over the canonical JSON of
51
+ # [nodeId, input]: keys sorted by UTF-16 code unit, no whitespace, numbers in
52
+ # JavaScript's shortest round-trip form (1.0 -> 1, 1.0e-07 -> 1e-7,
53
+ # non-finite -> null), strings escaped like JSON.stringify, keys in
54
+ # ignore_keys (default: MCP's `_meta`) removed at every level, cycles and
55
+ # depth > 64 replaced by markers. Ruby values JSON cannot express are
56
+ # canonicalised like the session's sanitizer degrades them: Symbols as
57
+ # strings, Hash keys via #to_s, Sets as sorted arrays, objects with #to_h via
58
+ # it, anything else via #inspect; Procs and Methods are dropped from objects
59
+ # (null in arrays) as JSON.stringify drops functions. An input that raises
60
+ # while being read clears its kind's streak instead of looking identical.
61
+ #
62
+ # Bookkeeping only: never raises into the host, keeps no reference to a host
63
+ # object, thread-safe.
64
+ module LoopGuard
65
+ DEFAULT_THRESHOLD = 3
66
+ DEFAULT_MODE = "pause"
67
+ DEFAULT_KINDS = ["tool"].freeze
68
+ # Keys that change on every request without changing the request: only
69
+ # MCP's reserved `_meta`. Pagination keys are deliberately NOT here.
70
+ DEFAULT_IGNORE_KEYS = ["_meta"].freeze
71
+ MODES = %w[pause warn off].freeze
72
+
73
+ MAX_RUNS = 64
74
+ MAX_DEPTH = 64
75
+ FINGERPRINT_HEX_CHARS = 32
76
+
77
+ DECIMAL_RE = /\A[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?\z/
78
+ RADIX_RE = /\A0([xXoObB])([0-9a-fA-F]+)\z/
79
+
80
+ # Raised internally when a value cannot be read faithfully.
81
+ class Lossy < StandardError; end
82
+
83
+ Config = Struct.new(:threshold, :mode, :ignore_keys, :allow_nodes, :kinds, keyword_init: true)
84
+
85
+ # What exec.paused.loop carries when reason is "loop".
86
+ Info = Struct.new(:repeats, :first_seq, :last_seq, :fingerprint, keyword_init: true) do
87
+ def to_wire
88
+ { "repeats" => repeats, "firstSeq" => first_seq, "lastSeq" => last_seq,
89
+ "fingerprint" => fingerprint }
90
+ end
91
+ end
92
+
93
+ Record = Struct.new(:repeats, :first_seq, :last_seq, :fingerprint, :at_threshold,
94
+ keyword_init: true)
95
+
96
+ # Returned by #fingerprint for an input that could not be read; pass it as
97
+ # the input when reading the input itself raised (rule 3: it clears the
98
+ # kind's streak).
99
+ UNREADABLE = Object.new.freeze
100
+
101
+ module_function
102
+
103
+ # -- configuration -------------------------------------------------------
104
+
105
+ # JavaScript's Number(text) for the spellings that matter; nil where NaN.
106
+ def js_number_from(text)
107
+ return Float(text) if DECIMAL_RE.match?(text)
108
+
109
+ radix = RADIX_RE.match(text)
110
+ if radix
111
+ base = { "x" => 16, "o" => 8, "b" => 2 }[radix[1].downcase]
112
+ return Integer(radix[2], base).to_f
113
+ end
114
+ return Float::INFINITY if ["Infinity", "+Infinity", "-Infinity"].include?(text)
115
+
116
+ nil
117
+ rescue ArgumentError, FloatDomainError
118
+ nil
119
+ end
120
+
121
+ # GRAPHMIND_LOOP_THRESHOLD: a non-negative integer; anything else -> fallback.
122
+ def parse_threshold(raw, fallback = DEFAULT_THRESHOLD)
123
+ return fallback unless raw.is_a?(String)
124
+
125
+ text = raw.strip
126
+ return fallback if text.empty?
127
+
128
+ value = js_number_from(text)
129
+ return fallback if value.nil? || !value.finite? || value != value.floor || value.negative?
130
+
131
+ value.to_i
132
+ rescue StandardError
133
+ fallback
134
+ end
135
+
136
+ # GRAPHMIND_ON_LOOP: pause | warn | off (case-insensitive; 0/false/none -> off).
137
+ def parse_mode(raw, fallback = DEFAULT_MODE)
138
+ return fallback unless raw.is_a?(String)
139
+
140
+ text = raw.strip.downcase
141
+ return text if MODES.include?(text)
142
+ return "off" if %w[0 false none].include?(text)
143
+
144
+ fallback
145
+ rescue StandardError
146
+ fallback
147
+ end
148
+
149
+ # GRAPHMIND_LOOP_ALLOW: comma-separated node ids or names; whitespace
150
+ # around entries ignored, empty entries dropped.
151
+ def parse_allow(raw)
152
+ return [] unless raw.is_a?(String)
153
+
154
+ raw.split(",").map(&:strip).reject(&:empty?)
155
+ rescue StandardError
156
+ []
157
+ end
158
+
159
+ def valid_threshold(value)
160
+ return nil unless value.is_a?(Integer) || value.is_a?(Float)
161
+ return nil if value.is_a?(Float) && (!value.finite? || value != value.floor)
162
+ return nil if value.negative?
163
+
164
+ value.to_i
165
+ end
166
+
167
+ def option(options, name)
168
+ [name, name.to_s, camel(name), camel(name).to_sym].each do |key|
169
+ return options[key] if options.key?(key)
170
+ end
171
+ nil
172
+ end
173
+
174
+ def camel(name)
175
+ head, *rest = name.to_s.split("_")
176
+ head + rest.map(&:capitalize).join
177
+ end
178
+
179
+ # An option list (Symbols count as their names), else the default list.
180
+ def string_set(value, default)
181
+ list =
182
+ if value.is_a?(Array) || value.is_a?(Set)
183
+ value.map { |v| v.is_a?(Symbol) ? v.to_s : v }.select { |v| v.is_a?(String) }
184
+ else
185
+ default
186
+ end
187
+ Set.new(list).freeze
188
+ end
189
+
190
+ def resolve_strict(options, env)
191
+ opts =
192
+ if options == false then { mode: "off" }
193
+ elsif options.is_a?(Hash) then options
194
+ else {}
195
+ end
196
+ env ||= {}
197
+ threshold = valid_threshold(option(opts, :threshold)) || parse_threshold(env["GRAPHMIND_LOOP_THRESHOLD"])
198
+ mode = option(opts, :mode)
199
+ mode = mode.to_s if mode.is_a?(Symbol)
200
+ mode = parse_mode(env["GRAPHMIND_ON_LOOP"]) unless mode.is_a?(String) && MODES.include?(mode)
201
+ Config.new(
202
+ threshold: threshold,
203
+ mode: mode,
204
+ ignore_keys: string_set(option(opts, :ignore_keys), DEFAULT_IGNORE_KEYS),
205
+ allow_nodes: string_set(option(opts, :allow_nodes), parse_allow(env["GRAPHMIND_LOOP_ALLOW"])),
206
+ kinds: string_set(option(opts, :kinds), DEFAULT_KINDS)
207
+ )
208
+ end
209
+
210
+ # Precedence per field: option > environment > default. `options` is
211
+ # `false` (shorthand for mode off), nil, or a Hash with threshold, mode,
212
+ # ignore_keys, allow_nodes, kinds (Symbol or String keys, camelCase
213
+ # accepted). Never raises: unreadable options are ignored as a whole, an
214
+ # unreadable environment falls back to the defaults.
215
+ def resolve(options, env)
216
+ resolve_strict(options, env)
217
+ rescue StandardError
218
+ begin
219
+ resolve_strict(nil, env)
220
+ rescue StandardError
221
+ resolve_strict(nil, {})
222
+ end
223
+ end
224
+
225
+ # -- canonical JSON + fingerprint ----------------------------------------
226
+
227
+ ESCAPES = {
228
+ '"' => '\\"', "\\" => "\\\\", "\b" => "\\b", "\f" => "\\f", "\n" => "\\n", "\r" => "\\r",
229
+ "\t" => "\\t"
230
+ }.freeze
231
+ ESCAPE_RE = /["\\\u0000-\u001f]/
232
+
233
+ def utf8(text)
234
+ str =
235
+ if text.encoding == Encoding::UTF_8 then text
236
+ elsif text.encoding == Encoding::BINARY then text.dup.force_encoding(Encoding::UTF_8)
237
+ else text.encode(Encoding::UTF_8)
238
+ end
239
+ raise Lossy unless str.valid_encoding?
240
+
241
+ str
242
+ rescue Lossy
243
+ raise
244
+ rescue StandardError
245
+ raise Lossy
246
+ end
247
+
248
+ # A JSON string literal escaped exactly like JSON.stringify.
249
+ def quote(text)
250
+ body = utf8(text).gsub(ESCAPE_RE) { |c| ESCAPES[c] || format("\\u%04x", c.ord) }
251
+ "\"#{body}\""
252
+ end
253
+
254
+ # JSON.stringify of a finite double: ECMAScript Number::toString.
255
+ def js_number(value)
256
+ return "0" if value.zero?
257
+
258
+ sign = value.negative? ? "-" : ""
259
+ match = /\A(\d+)\.(\d+)(?:e([+-]\d+))?\z/.match(value.abs.to_s)
260
+ raise Lossy if match.nil?
261
+
262
+ digits = match[1] + match[2]
263
+ point = match[1].length + match[3].to_i
264
+ stripped = digits.sub(/\A0+/, "")
265
+ point -= digits.length - stripped.length
266
+ digits = stripped.sub(/0+\z/, "")
267
+ k = digits.length
268
+ n = point
269
+ body =
270
+ if k <= n && n <= 21 then digits + ("0" * (n - k))
271
+ elsif n.positive? && n <= 21 then "#{digits[0, n]}.#{digits[n..]}"
272
+ elsif n > -6 && n <= 0 then "0.#{'0' * -n}#{digits}"
273
+ else
274
+ e = n - 1
275
+ mantissa = k == 1 ? digits : "#{digits[0]}.#{digits[1..]}"
276
+ "#{mantissa}e#{e.negative? ? '-' : '+'}#{e.abs}"
277
+ end
278
+ sign + body
279
+ end
280
+
281
+ def dropped?(value) = value.is_a?(Proc) || value.is_a?(Method) || value.is_a?(UnboundMethod)
282
+
283
+ # UTF-16 code units compared big-endian byte by byte give JS's default sort.
284
+ def utf16_sort_key(key) = key.encode(Encoding::UTF_16BE).b
285
+
286
+ def canon(value, ignore, depth, seen)
287
+ case value
288
+ when nil then return "null"
289
+ when true then return "true"
290
+ when false then return "false"
291
+ when String then return quote(value)
292
+ when Symbol then return quote(value.to_s)
293
+ when Integer then return value.to_s
294
+ when Float then return value.finite? ? js_number(value) : "null"
295
+ end
296
+ return "null" if dropped?(value)
297
+ return '"[depth]"' if depth > MAX_DEPTH
298
+ return '"[circular]"' if seen.key?(value)
299
+
300
+ seen[value] = true
301
+ begin
302
+ case value
303
+ when Hash
304
+ entries = []
305
+ value.each_pair do |raw_key, item|
306
+ key = utf8(raw_key.is_a?(String) ? raw_key : raw_key.to_s)
307
+ next if ignore.include?(key) || dropped?(item)
308
+
309
+ entries << [key, item]
310
+ end
311
+ # All-ASCII keys (the common case) sort the same by bytes; any other
312
+ # key forces UTF-16 order for all of them (U+E000..U+FFFF sort after
313
+ # astral characters in UTF-16, before them in UTF-8).
314
+ if entries.all? { |(key, _)| key.ascii_only? }
315
+ entries.sort_by!(&:first)
316
+ else
317
+ entries.sort_by! { |(key, _)| utf16_sort_key(key) }
318
+ end
319
+ "{#{entries.map { |(k, v)| "#{quote(k)}:#{canon(v, ignore, depth + 1, seen)}" }.join(',')}}"
320
+ when Array
321
+ "[#{value.map { |item| canon(item, ignore, depth + 1, seen) }.join(',')}]"
322
+ when Set
323
+ # A Set has no JSON order: make one, so equal sets fingerprint equally.
324
+ "[#{value.map { |item| canon(item, ignore, depth + 1, seen) }.sort.join(',')}]"
325
+ else
326
+ if value.respond_to?(:to_h)
327
+ replaced = value.to_h
328
+ raise Lossy if replaced.equal?(value) || !replaced.is_a?(Hash)
329
+
330
+ canon(replaced, ignore, depth + 1, seen)
331
+ else
332
+ quote(value.inspect)
333
+ end
334
+ end
335
+ ensure
336
+ seen.delete(value)
337
+ end
338
+ end
339
+
340
+ def new_seen = {}.compare_by_identity
341
+
342
+ # Canonical JSON of one value; an unreadable value becomes "[unserializable]".
343
+ def canonicalize(value, ignore_keys = [])
344
+ canon(value, Set.new(ignore_keys), 0, new_seen)
345
+ rescue StandardError
346
+ '"[unserializable]"'
347
+ end
348
+
349
+ # The canonical string a fingerprint hashes: [nodeId, input].
350
+ def canonical_call(node_id, input, ignore_keys = [])
351
+ "[#{quote(node_id.to_s)},#{canonicalize(input, ignore_keys)}]"
352
+ end
353
+
354
+ def digest(canonical)
355
+ Digest::SHA256.hexdigest(canonical)[0, FINGERPRINT_HEX_CHARS]
356
+ end
357
+
358
+ # SHA-256 (first 32 hex chars) of canonical_call.
359
+ def fingerprint_call(node_id, input, ignore_keys = [])
360
+ digest(canonical_call(node_id, input, ignore_keys))
361
+ end
362
+
363
+ # The fingerprint the guard compares, or nil when the input could not be
364
+ # read faithfully, was passed as UNREADABLE, or the nodeId is neither a
365
+ # String nor a Symbol (JSON writes a Symbol as its name): such a call is
366
+ # not known to equal anything, so it clears its kind's streak (rule 3).
367
+ def comparable_fingerprint(node_id, input, ignore_keys)
368
+ node_id = node_id.to_s if Symbol === node_id
369
+ return nil unless String === node_id && !input.equal?(UNREADABLE)
370
+
371
+ digest("[#{quote(node_id)},#{canon(input, ignore_keys, 0, new_seen)}]")
372
+ rescue StandardError, SystemStackError
373
+ nil
374
+ end
375
+
376
+ # The per-session counter (rule v3: one back-to-back streak per run per kind).
377
+ class Guard
378
+ # Rule 1. `tripped`: this count already held; `warned`: this streak
379
+ # already produced its one warning.
380
+ Streak = Struct.new(:node_id, :fingerprint, :repeats, :first_seq, :last_seq, :tripped, :warned)
381
+
382
+ attr_reader :config
383
+
384
+ def initialize(config)
385
+ @config = config
386
+ @mutex = Mutex.new
387
+ # run -> kind -> streak; Hashes keep insertion order (LRU by re-insertion)
388
+ @runs = {}
389
+ end
390
+
391
+ def enabled? = @config.mode != "off" && @config.threshold.positive?
392
+ def mode = @config.mode
393
+ def threshold = @config.threshold
394
+ def tracked_runs = @mutex.synchronize { @runs.size }
395
+ # Streaks kept across runs: at most runs x watched kinds.
396
+ def tracked_streaks = @mutex.synchronize { @runs.each_value.sum(&:size) }
397
+
398
+ # Is a start of this kind WATCHED (rules 2-4)? The nodeId may be anything
399
+ # here — a watched start whose nodeId is not a String clears its kind's
400
+ # streak rather than being ignored. A Symbol kind, name or nodeId (`kind:
401
+ # :tool`) is written to the wire as its name, so it counts as that String.
402
+ def watched?(kind, node_id, name)
403
+ return false unless enabled?
404
+
405
+ kind = kind.to_s if Symbol === kind
406
+ return false unless String === kind && @config.kinds.include?(kind)
407
+
408
+ allow = @config.allow_nodes
409
+ return true if allow.empty?
410
+
411
+ node_id = node_id.to_s if Symbol === node_id
412
+ name = name.to_s if Symbol === name
413
+ !((String === node_id && allow.include?(node_id)) || (String === name && allow.include?(name)))
414
+ end
415
+
416
+ # Watched, with a nodeId a gate can name.
417
+ def applies_to?(kind, node_id, name)
418
+ (String === node_id || Symbol === node_id) && watched?(kind, node_id, name)
419
+ end
420
+
421
+ # The comparable fingerprint, or UNREADABLE (input unreadable or passed as
422
+ # UNREADABLE, nodeId not a String/Symbol). Pure: call it outside any lock.
423
+ def fingerprint(node_id, input)
424
+ LoopGuard.comparable_fingerprint(node_id, input, @config.ignore_keys) || UNREADABLE
425
+ end
426
+
427
+ # node.started (rules 2-4): extend or replace the kind's streak. `seq` is
428
+ # that node.started's envelope seq; `fingerprint` comes from #fingerprint.
429
+ # nil when the start is not watched, or when it was unreadable — the
430
+ # latter clears the kind's streak.
431
+ def record(run_id, kind, node_id, name, fingerprint, seq)
432
+ return nil unless watched?(kind, node_id, name)
433
+
434
+ kind = kind.to_s if Symbol === kind
435
+ node_id = node_id.to_s if Symbol === node_id
436
+ @mutex.synchronize do
437
+ unless String === fingerprint && String === node_id
438
+ # Rule 3: the call DID happen, with arguments nobody can compare, so
439
+ # the previous call is no longer "the call right before" the next.
440
+ @runs[run_id]&.delete(kind)
441
+ return nil
442
+ end
443
+ streaks = streaks_for(run_id)
444
+ streak = streaks[kind]
445
+ if streak && streak.node_id == node_id && streak.fingerprint == fingerprint
446
+ streak.repeats += 1
447
+ streak.last_seq = seq
448
+ streak.tripped = false
449
+ else
450
+ streak = Streak.new(node_id, fingerprint, 1, seq, seq, false, false)
451
+ streaks[kind] = streak
452
+ end
453
+ Record.new(repeats: streak.repeats, first_seq: streak.first_seq, last_seq: streak.last_seq,
454
+ fingerprint: fingerprint, at_threshold: streak.repeats >= @config.threshold)
455
+ end
456
+ end
457
+
458
+ # At `before` (rule 5): is this node's latest start the Nth identical
459
+ # back-to-back call of its kind? Trips at most once per count.
460
+ def consult(run_id, kind, node_id, name)
461
+ return nil unless applies_to?(kind, node_id, name)
462
+
463
+ kind = kind.to_s if Symbol === kind
464
+ node_id = node_id.to_s if Symbol === node_id
465
+ @mutex.synchronize do
466
+ streak = @runs[run_id]&.[](kind)
467
+ return nil if streak.nil? || streak.node_id != node_id || streak.tripped ||
468
+ streak.repeats < @config.threshold
469
+
470
+ streak.tripped = true
471
+ Info.new(repeats: streak.repeats, first_seq: streak.first_seq, last_seq: streak.last_seq,
472
+ fingerprint: streak.fingerprint)
473
+ end
474
+ end
475
+
476
+ # The single warning for the current streak of this node — of `kind`, or
477
+ # of whichever kind's streak belongs to `node_id`: true the first time only.
478
+ def claim_warning(run_id, node_id, kind = nil)
479
+ kind = kind.to_s if Symbol === kind
480
+ node_id = node_id.to_s if Symbol === node_id
481
+ @mutex.synchronize do
482
+ streaks = @runs[run_id]
483
+ return false if streaks.nil?
484
+
485
+ candidates = kind.nil? ? streaks.values : [streaks[kind]]
486
+ candidates.each do |streak|
487
+ next if streak.nil? || streak.node_id != node_id
488
+ return false if streak.warned
489
+
490
+ streak.warned = true
491
+ return true
492
+ end
493
+ false
494
+ end
495
+ end
496
+
497
+ def forget(run_id)
498
+ @mutex.synchronize { @runs.delete(run_id) }
499
+ end
500
+
501
+ private
502
+
503
+ # Least-recently-used, not oldest-created: a long run still calling tools
504
+ # must not lose its streak because MAX_RUNS short runs came and went.
505
+ def streaks_for(run_id)
506
+ streaks = @runs.delete(run_id)
507
+ if streaks.nil?
508
+ @runs.delete(@runs.first[0]) if @runs.size >= MAX_RUNS
509
+ streaks = {}
510
+ end
511
+ @runs[run_id] = streaks # re-insert: least recently used is first
512
+ streaks
513
+ end
514
+ end
515
+ end
516
+ end
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Graphmind
6
+ # The wire contract, mirrored from packages/schema.
7
+ #
8
+ # Every message on the socket is one JSON text frame:
9
+ #
10
+ # { "gm": 1, "seq": n, "ts": ms, "runId": "...", "type": "...", "payload": {} }
11
+ #
12
+ # `gm` is the MAJOR version of the contract: peers MUST reject envelopes
13
+ # whose `gm` differs from their own. Backwards-compatible additions (new
14
+ # message types, new payload fields) do NOT bump it — receivers tolerate
15
+ # unknown types and unknown fields instead.
16
+ module Protocol
17
+ PROTOCOL_VERSION = 1
18
+
19
+ # Envelope runId for messages not bound to a run (handshake, breakpoints,
20
+ # mode).
21
+ WILDCARD_RUN_ID = "*"
22
+
23
+ EVENT_TYPES = %w[
24
+ run.started
25
+ run.finished
26
+ graph.hint
27
+ node.started
28
+ node.token
29
+ node.finished
30
+ node.error
31
+ exec.paused
32
+ exec.refused
33
+ exec.resumed
34
+ ].freeze
35
+
36
+ CONTROL_TYPES = %w[exec.resume breakpoint.set breakpoint.clear mode.set].freeze
37
+ HANDSHAKE_TYPES = %w[hello hello.ack].freeze
38
+ MESSAGE_TYPES = (EVENT_TYPES + CONTROL_TYPES + HANDSHAKE_TYPES).freeze
39
+
40
+ NODE_KINDS = %w[agent llm tool chain retriever server resource prompt custom].freeze
41
+ RUN_STATUSES = %w[ok error aborted].freeze
42
+ PAUSE_POINTS = %w[before after error].freeze
43
+ RESUME_ACTIONS = %w[continue retry inject abort].freeze
44
+ RUN_MODES = %w[run step].freeze
45
+ TOKEN_CHANNELS = %w[text reasoning tool-args].freeze
46
+
47
+ # Capabilities this client announces in `hello`.
48
+ # `run-claim`: this client stores the `sessionToken` from `hello.ack` and
49
+ # echoes it as `hello.resumeToken` on reconnect, so the debugger can refuse
50
+ # writes to this app's runs from any other local process — including across
51
+ # a disconnect, which is otherwise the one window a claim cannot be proven in.
52
+ # `request-id` (0.6.0): this client echoes `exec.resume.requestId` on the
53
+ # exec.resumed / exec.refused that answers it, so the debugger can tell a
54
+ # release that answered nobody (a pause timeout) from one that answered a
55
+ # resume. Not `edit-input`: this gem never runs an edited input.
56
+ KNOWN_CAPABILITIES = %w[pause step inject retry abort run-claim request-id].freeze
57
+
58
+ # Result of parsing one inbound frame.
59
+ ParseResult = Struct.new(:kind, :envelope, :reason, :received, keyword_init: true)
60
+
61
+ module_function
62
+
63
+ def message_type?(type) = MESSAGE_TYPES.include?(type)
64
+
65
+ def create_envelope(type, payload, seq, run_id, ts = nil)
66
+ {
67
+ "gm" => PROTOCOL_VERSION,
68
+ "seq" => seq,
69
+ "ts" => ts || (Time.now.to_f * 1000).round,
70
+ "runId" => run_id,
71
+ "type" => type,
72
+ "payload" => payload
73
+ }
74
+ end
75
+
76
+ def serialize_envelope(envelope)
77
+ JSON.generate(envelope)
78
+ end
79
+
80
+ # Parse one inbound JSON text frame.
81
+ #
82
+ # kind is one of:
83
+ # :ok — a known envelope (`envelope`)
84
+ # :unknown_type — well-formed but a type we do not know (tolerate)
85
+ # :version_mismatch — `gm` differs from ours (`received`)
86
+ # :invalid — malformed (`reason`)
87
+ # No nesting limit: json's default (100) dropped a deep but well-formed
88
+ # exec.resume unanswered, where the TypeScript and Python clients apply
89
+ # it. json's parser does not recurse on the machine stack (the deepest
90
+ # frame the WebSocket codec's size cap allows parses on a thread), and
91
+ # EditGuard refuses what is too deep to check, so a deep inject is always
92
+ # answered.
93
+ def parse_envelope_json(text)
94
+ raw =
95
+ begin
96
+ JSON.parse(text, max_nesting: false)
97
+ rescue StandardError => e
98
+ return ParseResult.new(kind: :invalid, reason: "not JSON (#{e.class})")
99
+ end
100
+ parse_envelope(raw)
101
+ end
102
+
103
+ def parse_envelope(raw)
104
+ return ParseResult.new(kind: :invalid, reason: "not an object") unless raw.is_a?(Hash)
105
+
106
+ gm = raw["gm"]
107
+ return ParseResult.new(kind: :invalid, reason: "missing gm") unless gm.is_a?(Integer)
108
+
109
+ if gm != PROTOCOL_VERSION
110
+ return ParseResult.new(kind: :version_mismatch, received: gm)
111
+ end
112
+
113
+ type = raw["type"]
114
+ return ParseResult.new(kind: :invalid, reason: "missing type") unless type.is_a?(String)
115
+ return ParseResult.new(kind: :invalid, reason: "missing seq") unless raw["seq"].is_a?(Integer)
116
+ return ParseResult.new(kind: :invalid, reason: "missing runId") unless raw["runId"].is_a?(String)
117
+ return ParseResult.new(kind: :unknown_type, envelope: raw) unless message_type?(type)
118
+
119
+ raw["payload"] = {} unless raw["payload"].is_a?(Hash)
120
+ ParseResult.new(kind: :ok, envelope: raw)
121
+ end
122
+ end
123
+ end