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