vangrail 0.1.0 → 0.2.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.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +655 -43
  3. data/lib/vangrail/actions.rb +10 -3
  4. data/lib/vangrail/assessor.rb +249 -0
  5. data/lib/vangrail/bayes_data.rb +340 -0
  6. data/lib/vangrail/beta.rb +102 -0
  7. data/lib/vangrail/builder.rb +354 -0
  8. data/lib/vangrail/chat.rb +17 -15
  9. data/lib/vangrail/client/{completion.rb → turn.rb} +3 -3
  10. data/lib/vangrail/client.rb +27 -18
  11. data/lib/vangrail/colang/ast.rb +29 -3
  12. data/lib/vangrail/colang/interpreter.rb +55 -31
  13. data/lib/vangrail/colang/parser.rb +19 -61
  14. data/lib/vangrail/colang/value_parser.rb +161 -0
  15. data/lib/vangrail/completion.rb +86 -0
  16. data/lib/vangrail/config.rb +35 -15
  17. data/lib/vangrail/conversation.rb +240 -11
  18. data/lib/vangrail/dojo.rb +126 -0
  19. data/lib/vangrail/embeddings.rb +87 -0
  20. data/lib/vangrail/engine.rb +29 -70
  21. data/lib/vangrail/errors.rb +6 -1
  22. data/lib/vangrail/evidence.rb +303 -0
  23. data/lib/vangrail/evidence_data.rb +113 -0
  24. data/lib/vangrail/http.rb +18 -13
  25. data/lib/vangrail/judgement.rb +151 -0
  26. data/lib/vangrail/known_attacks.rb +45 -0
  27. data/lib/vangrail/linear_model.rb +124 -0
  28. data/lib/vangrail/nlp.rb +596 -0
  29. data/lib/vangrail/origin.rb +249 -0
  30. data/lib/vangrail/parsers.rb +5 -5
  31. data/lib/vangrail/profile.rb +114 -0
  32. data/lib/vangrail/prompt.rb +14 -3
  33. data/lib/vangrail/provider.rb +106 -75
  34. data/lib/vangrail/providers/gateway.rb +15 -14
  35. data/lib/vangrail/providers/llmlite.rb +25 -10
  36. data/lib/vangrail/providers.rb +6 -8
  37. data/lib/vangrail/rail.rb +46 -8
  38. data/lib/vangrail/rails/alignment.rb +91 -0
  39. data/lib/vangrail/rails/bayes.rb +115 -0
  40. data/lib/vangrail/rails/budget.rb +2 -2
  41. data/lib/vangrail/rails/canary.rb +2 -2
  42. data/lib/vangrail/rails/colang_flow.rb +9 -1
  43. data/lib/vangrail/rails/escalation.rb +15 -8
  44. data/lib/vangrail/rails/exfiltration.rb +2 -2
  45. data/lib/vangrail/rails/grounding.rb +8 -5
  46. data/lib/vangrail/rails/guard_model.rb +7 -4
  47. data/lib/vangrail/rails/hidden.rb +52 -9
  48. data/lib/vangrail/rails/injected_instructions.rb +29 -9
  49. data/lib/vangrail/rails/jailbreak.rb +2 -6
  50. data/lib/vangrail/rails/known_answer.rb +6 -2
  51. data/lib/vangrail/rails/language.rb +87 -0
  52. data/lib/vangrail/rails/linear.rb +80 -0
  53. data/lib/vangrail/rails/many_shot.rb +2 -6
  54. data/lib/vangrail/rails/markup.rb +3 -3
  55. data/lib/vangrail/rails/missing.rb +1 -5
  56. data/lib/vangrail/rails/obfuscation.rb +81 -13
  57. data/lib/vangrail/rails/paraphrase.rb +189 -0
  58. data/lib/vangrail/rails/pattern.rb +2 -6
  59. data/lib/vangrail/rails/perplexity.rb +100 -0
  60. data/lib/vangrail/rails/personal_data.rb +41 -9
  61. data/lib/vangrail/rails/prompt_leak.rb +132 -0
  62. data/lib/vangrail/rails/remote.rb +5 -1
  63. data/lib/vangrail/rails/secrets.rb +2 -2
  64. data/lib/vangrail/rails/self_check.rb +9 -6
  65. data/lib/vangrail/rails/semantic.rb +132 -0
  66. data/lib/vangrail/rails/similarity.rb +96 -0
  67. data/lib/vangrail/rails/trajectory.rb +10 -5
  68. data/lib/vangrail/result.rb +3 -3
  69. data/lib/vangrail/result_cache.rb +0 -0
  70. data/lib/vangrail/screening.rb +68 -0
  71. data/lib/vangrail/session.rb +365 -0
  72. data/lib/vangrail/spotlight.rb +48 -8
  73. data/lib/vangrail/stream_guard.rb +8 -6
  74. data/lib/vangrail/tools.rb +58 -0
  75. data/lib/vangrail/version.rb +1 -1
  76. data/lib/vangrail.rb +39 -258
  77. metadata +34 -5
@@ -0,0 +1,102 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vangrail
4
+ # The Beta distribution, enough of it to put an honest interval on a rate.
5
+ #
6
+ # A rail that fired on 0 of 48 benign texts has a false-alarm rate somewhere
7
+ # below about one in twenty, and nothing in the corpus says where. Reporting
8
+ # the point estimate treats "I measured nothing" as "the rate is the smoothing
9
+ # constant", which is exactly the direction that flatters a detector.
10
+ #
11
+ # The Bayesian answer is the one from the estimation literature that language
12
+ # modelling has used since Good: the rate is not a number, it is a posterior,
13
+ # and with a Beta prior over a binomial count that posterior is a Beta. Taking
14
+ # its pessimistic tail rather than its mean gives a bound that shrinks as the
15
+ # corpus grows and stays conservative while it is small.
16
+ #
17
+ # Implemented here rather than pulled in, because the runtime has no
18
+ # dependencies: the regularised incomplete beta function by the standard
19
+ # continued fraction, and its inverse by bisection, which is slow and exact
20
+ # enough for a table computed once.
21
+ module Beta
22
+ ITERATIONS = 200
23
+ EPSILON = 1e-12
24
+ TINY = 1e-300
25
+
26
+ module_function
27
+
28
+ # P(X <= x) for X ~ Beta(a, b): the regularised incomplete beta function.
29
+ def cdf(x, a, b)
30
+ return 0.0 if x <= 0
31
+ return 1.0 if x >= 1
32
+
33
+ front = Math.exp(Math.lgamma(a + b).first - Math.lgamma(a).first - Math.lgamma(b).first +
34
+ (a * Math.log(x)) + (b * Math.log(1 - x)))
35
+ # The continued fraction converges quickly on one side of the mode and
36
+ # slowly on the other, so the far side is computed from the symmetry.
37
+ if x < (a + 1) / (a + b + 2)
38
+ front * continued_fraction(x, a, b) / a
39
+ else
40
+ 1 - (Math.exp(Math.lgamma(a + b).first - Math.lgamma(a).first - Math.lgamma(b).first +
41
+ (b * Math.log(1 - x)) + (a * Math.log(x))) *
42
+ continued_fraction(1 - x, b, a) / b)
43
+ end
44
+ end
45
+
46
+ # The value below which a Beta(a, b) sits with probability `p`.
47
+ #
48
+ # Bisection rather than Newton: this runs once per rail per confidence
49
+ # level, the function is monotone, and fifty halvings put it well inside any
50
+ # precision a likelihood ratio needs.
51
+ def quantile(p, a, b)
52
+ return 0.0 if p <= 0
53
+ return 1.0 if p >= 1
54
+
55
+ low = 0.0
56
+ high = 1.0
57
+ 60.times do
58
+ mid = (low + high) / 2
59
+ if cdf(mid, a, b) < p
60
+ low = mid
61
+ else
62
+ high = mid
63
+ end
64
+ end
65
+ (low + high) / 2
66
+ end
67
+
68
+ # Lentz's algorithm for the continued fraction of the incomplete beta.
69
+ def continued_fraction(x, a, b)
70
+ qab = a + b
71
+ qap = a + 1
72
+ qam = a - 1
73
+ c = 1.0
74
+ d = 1 - (qab * x / qap)
75
+ d = TINY if d.abs < TINY
76
+ d = 1 / d
77
+ h = d
78
+
79
+ (1..ITERATIONS).each do |m|
80
+ m2 = 2 * m
81
+ numerator = m * (b - m) * x / ((qam + m2) * (a + m2))
82
+ d = 1 + (numerator * d)
83
+ d = TINY if d.abs < TINY
84
+ c = 1 + (numerator / c)
85
+ c = TINY if c.abs < TINY
86
+ d = 1 / d
87
+ h *= d * c
88
+
89
+ numerator = -(a + m) * (qab + m) * x / ((a + m2) * (qap + m2))
90
+ d = 1 + (numerator * d)
91
+ d = TINY if d.abs < TINY
92
+ c = 1 + (numerator / c)
93
+ c = TINY if c.abs < TINY
94
+ d = 1 / d
95
+ step = d * c
96
+ h *= step
97
+ break if (step - 1).abs < EPSILON
98
+ end
99
+ h
100
+ end
101
+ end
102
+ end
@@ -0,0 +1,354 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Vangrail
4
+ # Reads the environment into an engine. A class rather than a method so each
5
+ # decision is separable and testable on its own.
6
+ class Builder
7
+ DEFAULT_RAILS = %i[input context output].freeze
8
+ ALL_RAILS = %i[input context output grounding secrets patterns links multiturn privacy
9
+ markup budget semantic perplexity bayes linear].freeze
10
+
11
+ # Deterministic input patterns, kept small on purpose. Each is a phrase
12
+ # whose presence is itself the violation; anything needing judgement belongs
13
+ # in a policy rail, where a false positive is a model's opinion rather than
14
+ # a hard rule.
15
+ INJECTION_PATTERNS = {
16
+ 'instruction_override' => /\bignore\s+(?:all\s+|any\s+)?(?:previous|prior|above|earlier)\s+instructions?\b/i,
17
+ 'prompt_disclosure' => /\b(?:reveal|print|repeat|show|output)\s+(?:me\s+)?(?:your|the|its)?\s*
18
+ (?:system\s+prompt|initial\s+instructions|developer\s+message)\b/xi,
19
+ 'role_reset' => /\byou\s+are\s+now\s+(?:a|an|in)\b.{0,40}\b(?:mode|persona|dan|jailbreak)\b/i,
20
+ }.freeze
21
+
22
+ attr_reader :env
23
+
24
+ def initialize(env = ENV)
25
+ @env = env
26
+ end
27
+
28
+ def engine
29
+ return Engine.new(on_error: on_error, cache: cache?) if off?
30
+ return config_engine if config_dir
31
+
32
+ Engine.new(input: input_rails, context: context_rails, output: output_rails,
33
+ on_error: on_error, cache: cache?)
34
+ end
35
+
36
+ def session(prior:, **kwargs)
37
+ Session.new(engine: engine, prior: prior, **kwargs)
38
+ end
39
+
40
+ # The offline stack that does not need a folder or an endpoint: patterns,
41
+ # concepts, near-copies, the decoding pass, and the language posture.
42
+ # `Config#engine(stdlib: true)` prepends the same list so a NeMo folder
43
+ # does not certain-pass a question nothing here can read.
44
+ def self.deterministic(side)
45
+ side = side.to_sym
46
+ return [] if side == :output
47
+
48
+ core = [
49
+ (Rails::InjectedInstructions.new if side == :context),
50
+ (if side == :input
51
+ Rails::Pattern.new(patterns: INJECTION_PATTERNS, name: 'injection_patterns',
52
+ sides: [side])
53
+ end),
54
+ Rails::Jailbreak.new(sides: [side]),
55
+ Rails::Paraphrase.new(sides: [side]),
56
+ Rails::Alignment.new(sides: [side]),
57
+ Rails::Similarity.new(sides: [side]),
58
+ Rails::ManyShot.new(sides: [side]),
59
+ ].compact
60
+ extras = [Rails::Obfuscation.new(rails: core, sides: [side])]
61
+ extras << Rails::Hidden.new(rails: core) if side == :context
62
+ extras << Rails::Language.new(sides: [side])
63
+ core + extras
64
+ end
65
+
66
+ def enabled
67
+ text = env['GUARDRAILS_RAILS'].to_s.strip
68
+ return DEFAULT_RAILS if text.empty?
69
+ return [] if off_value?(text) || text.casecmp('none').zero?
70
+ return ALL_RAILS.dup if text.casecmp('all').zero?
71
+
72
+ names = text.split(/[,\s]+/).map { |s| s.strip.downcase.to_sym }
73
+ unknown = names - ALL_RAILS
74
+ raise ArgumentError, "unknown rail name(s): #{unknown.join(', ')}" unless unknown.empty?
75
+
76
+ names & ALL_RAILS
77
+ end
78
+
79
+ def on?(rail)
80
+ enabled.include?(rail)
81
+ end
82
+
83
+ def off?
84
+ off_value?(env['GUARDRAILS'])
85
+ end
86
+
87
+ def on_error
88
+ env['GUARDRAILS_ON_ERROR'].to_s.strip.casecmp('block').zero? ? :block : :allow
89
+ end
90
+
91
+ def cache?
92
+ !off_value?(env['GUARDRAILS_CACHE'])
93
+ end
94
+
95
+ def config_dir
96
+ present(env['GUARDRAILS_CONFIG'])
97
+ end
98
+
99
+ def server_url
100
+ present(env['GUARDRAILS_SERVER'])
101
+ end
102
+
103
+ # The endpoint the model-backed rails call, or nil when none is reachable.
104
+ def provider
105
+ return @provider if defined?(@provider)
106
+
107
+ @provider = Provider.resolve(env)
108
+ end
109
+
110
+ private
111
+
112
+ def config_engine
113
+ Config.load(config_dir).engine(provider: provider, on_error: on_error, cache: cache?)
114
+ end
115
+
116
+ # The pattern rail runs whenever any input rail does. It costs microseconds
117
+ # and it is the only part of the input side that still works when the
118
+ # endpoint is down.
119
+ def input_rails
120
+ return [] unless on?(:input) || on?(:patterns)
121
+
122
+ rails = self.class.deterministic(:input)
123
+ # A question carrying the canary is too late to prevent and worth
124
+ # knowing: the prompt is already out.
125
+ rails << canary(:input) if canary_token
126
+ # Opt-in: it rewrites the question before the model sees it, which is a
127
+ # deployment's call rather than a default. Where the endpoint is a third
128
+ # party it is close to obligatory, and where it is a local proxy it buys
129
+ # little.
130
+ rails << Rails::Bayes.new(sides: [:input]) if on?(:bayes)
131
+ rails << Rails::Linear.new(sides: [:input]) if on?(:linear)
132
+ rails << semantic(:input) if on?(:semantic)
133
+ rails << perplexity(:input) if on?(:perplexity)
134
+ rails << Rails::PersonalData.new if on?(:privacy)
135
+ rails << Rails::Budget.new(sides: [:input]) if on?(:budget)
136
+ # Off unless asked for: they read history, and a caller that threads none
137
+ # would have every input check come back uncertain, which is true and
138
+ # useless. Conversation is what makes them worth having.
139
+ #
140
+ # The deterministic one goes first so a refused question asked again
141
+ # never reaches the judge: it is free, and the round trip is not.
142
+ rails.concat(multiturn_rails) if on?(:multiturn)
143
+ rails << judged(:input) if on?(:input)
144
+ rails.compact
145
+ end
146
+
147
+ # Deterministic, offline, and free, so it is on by default. The document a
148
+ # retrieval step just fetched is the side an attacker can usually reach
149
+ # without touching the application, and nothing else in this engine reads it.
150
+ #
151
+ # The decoding pass matters more here than anywhere else: a page an
152
+ # attacker edits is a page they can base64.
153
+ def context_rails
154
+ return [] unless on?(:context)
155
+
156
+ # Two passes over the same definitions, for the two ways a page hides
157
+ # one: encoded so the patterns cannot read it, or in markup a reader
158
+ # never sees. Language sits outside the decoding pass and last among
159
+ # the free rails: every rail above is a rule about English or Dutch
160
+ # words, and a page in neither has been passed by all of them without
161
+ # being read. Reporting that costs a token count and keeps `certain?`
162
+ # honest.
163
+ rails = self.class.deterministic(:context)
164
+ rails << Rails::Bayes.new(sides: [:context]) if on?(:bayes)
165
+ rails << Rails::Linear.new(sides: [:context]) if on?(:linear)
166
+ rails << semantic(:context) if on?(:semantic)
167
+ rails << perplexity(:context) if on?(:perplexity)
168
+ rails << Rails::Budget.new(sides: [:context]) if on?(:budget)
169
+ rails
170
+ end
171
+
172
+ def output_rails
173
+ rails = []
174
+ rails << Rails::Secrets.new if on?(:secrets) || on?(:output)
175
+ rails << canary(:output) if canary_token
176
+ # Naming the file is the opt-in, the same way the canary token is. Only
177
+ # the application knows what its prompt says, and a rail that guessed
178
+ # would be guarding a text nobody wrote.
179
+ rails << prompt_leak if protected_prompt
180
+ # Off by default: a desk whose client renders answers as plain text does
181
+ # not need it, and stripping markup nobody would have executed is noise
182
+ # in the result.
183
+ rails << Rails::Markup.new if on?(:markup)
184
+ rails << exfiltration if link_hosts || on?(:links)
185
+ rails << judged(:output) if on?(:output)
186
+ rails << grounding if on?(:grounding)
187
+ rails.compact
188
+ end
189
+
190
+ # The hosts an answer may link to. Not defaulted to anything, because the
191
+ # empty allowlist means "no links at all", which is right for an
192
+ # application that said so and wrong to impose on one that never mentioned
193
+ # links. Naming the variable is the opt-in.
194
+ def link_hosts
195
+ present(env['GUARDRAILS_LINK_HOSTS'])
196
+ end
197
+
198
+ # The pair, because they cover different halves. Escalation sees a retry
199
+ # after a refusal and nothing before one; the judge reads a sequence that
200
+ # has never been refused, which is what the published multi-turn methods
201
+ # are built to produce.
202
+ def multiturn_rails
203
+ rails = [Rails::Escalation.new]
204
+ rails << if provider&.available? && provider.model(:judge)
205
+ Rails::Trajectory.new(provider: provider, every: judge_every)
206
+ else
207
+ missing('trajectory', :input)
208
+ end
209
+ rails
210
+ end
211
+
212
+ # One round trip per turn is the honest cost, and a desk may not want to
213
+ # pay it every turn. A staged escalation takes several turns by
214
+ # construction and cannot finish inside a gap of two.
215
+ def judge_every
216
+ value = env['GUARDRAILS_TRAJECTORY_EVERY'].to_i
217
+ value.positive? ? value : 1
218
+ end
219
+
220
+ # The rail class follows what the endpoint can actually serve. A provider
221
+ # hosting a classifier gets one; a provider serving only instruct models
222
+ # gets a written policy in front of one, which is the same job done
223
+ # differently rather than the same job skipped.
224
+ def judged(side)
225
+ return remote(side) if server_url
226
+ return missing("#{side}_model", side) unless provider&.available?
227
+ return guard_model(side) if provider.guard?
228
+ return missing("#{side}_model", side) unless provider.model(:judge)
229
+
230
+ Rails::SelfCheck.new(provider: provider, sides: [side], name: "policy_#{side}")
231
+ end
232
+
233
+ # Asked for and unbuildable. Kept in the list rather than dropped, so the
234
+ # engine's pass stays uncertain instead of resting on the offline rails.
235
+ #
236
+ # `name` is what the rail would have been; `side` is where it would have
237
+ # run. They are not the same thing, and conflating them is how a grounding
238
+ # placeholder ends up claiming a side that does not exist.
239
+ def missing(name, side)
240
+ reason =
241
+ if provider.nil?
242
+ 'no endpoint resolved: set GUARDRAILS_API_BASE, or start a local one'
243
+ elsif provider.available? && provider.model(:judge).nil?
244
+ 'no judge model; set LLMLITE_MODEL or GUARDRAILS_JUDGE_MODEL'
245
+ else
246
+ "#{provider.name} is not available at #{provider.base_url}"
247
+ end
248
+ Rails::Missing.new(reason: reason, name: name.to_s, sides: [side])
249
+ end
250
+
251
+ def remote(side)
252
+ Rails::Remote.new(base_url: server_url, config_id: present(env['GUARDRAILS_CONFIG_ID']),
253
+ api_key: present(env['GUARDRAILS_SERVER_API_KEY']), sides: [side])
254
+ end
255
+
256
+ def guard_model(side)
257
+ Rails::GuardModel.new(provider: provider, reasoning: truthy?(env['GUARDRAILS_REASONING']),
258
+ sides: [side])
259
+ end
260
+
261
+ # The application generates the token, puts it in its prompt, and names it
262
+ # here. Nothing can be checked without one, so its absence is the off
263
+ # switch.
264
+ def canary_token
265
+ present(env['GUARDRAILS_CANARY'])
266
+ end
267
+
268
+ def canary(side)
269
+ Rails::Canary.new(tokens: canary_token.split(/[,\s]+/), sides: [side])
270
+ end
271
+
272
+ # The text that must not come back out, read from a file because a system
273
+ # prompt in an environment variable is a system prompt nobody can read.
274
+ # An unreadable path raises rather than silently building no rail: a
275
+ # guardrail that was asked for and quietly absent is the failure this gem
276
+ # exists to prevent.
277
+ def protected_prompt
278
+ return @protected_prompt if defined?(@protected_prompt)
279
+
280
+ path = present(env['GUARDRAILS_PROMPT_FILE'])
281
+ @protected_prompt =
282
+ if path.nil?
283
+ nil
284
+ else
285
+ begin
286
+ File.read(path)
287
+ rescue SystemCallError => e
288
+ raise ConfigError, "GUARDRAILS_PROMPT_FILE #{path.inspect} could not be read: #{e.message}"
289
+ end
290
+ end
291
+ end
292
+
293
+ # Asked for by name, because it costs a round trip per check and because
294
+ # every document embedded on a third-party endpoint is a document sent
295
+ # there. A provider serving no embedding model leaves the placeholder, so
296
+ # the pass stays uncertain rather than resting on the offline rails.
297
+ def semantic(side)
298
+ return missing('semantic', side) unless provider&.available? && provider.embed?
299
+
300
+ Rails::Semantic.new(embeddings: provider.embeddings, sides: [side],
301
+ threshold: semantic_threshold)
302
+ end
303
+
304
+ # Off unless asked for, and not only for the round trip: the threshold is a
305
+ # property of the endpoint's model, and an uncalibrated detector switched on
306
+ # by default is a detector that blocks somebody's shell transcript.
307
+ def perplexity(side)
308
+ return missing('perplexity', side) unless provider&.available? && provider.model(:judge)
309
+
310
+ Rails::Perplexity.new(completion: provider.completion, sides: [side],
311
+ threshold: perplexity_threshold)
312
+ end
313
+
314
+ def perplexity_threshold
315
+ value = env['GUARDRAILS_PERPLEXITY_THRESHOLD'].to_f
316
+ value.positive? ? value : Rails::Perplexity::THRESHOLD
317
+ end
318
+
319
+ def semantic_threshold
320
+ value = env['GUARDRAILS_SEMANTIC_THRESHOLD'].to_f
321
+ value.positive? ? value : Rails::Semantic::THRESHOLD
322
+ end
323
+
324
+ def prompt_leak
325
+ Rails::PromptLeak.new(protected_text: protected_prompt)
326
+ end
327
+
328
+ def exfiltration
329
+ hosts = link_hosts.to_s.split(/[,\s]+/).reject(&:empty?)
330
+ images = present(env['GUARDRAILS_IMAGE_HOSTS'])
331
+ Rails::Exfiltration.new(allow_hosts: hosts,
332
+ allow_images: images&.split(/[,\s]+/)&.reject(&:empty?))
333
+ end
334
+
335
+ def grounding
336
+ return missing('grounding', :output) unless provider&.available? && provider.model(:judge)
337
+
338
+ Rails::Grounding.new(provider: provider)
339
+ end
340
+
341
+ def off_value?(value)
342
+ %w[0 off false no].include?(value.to_s.strip.downcase)
343
+ end
344
+
345
+ def truthy?(value)
346
+ %w[1 on true yes].include?(value.to_s.strip.downcase)
347
+ end
348
+
349
+ def present(value)
350
+ s = value.to_s.strip
351
+ s.empty? ? nil : s
352
+ end
353
+ end
354
+ end
data/lib/vangrail/chat.rb CHANGED
@@ -19,24 +19,16 @@ module Vangrail
19
19
 
20
20
  attr_reader :model, :http, :max_tokens, :temperature, :extra
21
21
 
22
- def initialize(model:, base_url: nil, api_key: nil, http: nil, max_tokens: 128,
22
+ def initialize(model:, http: nil, base_url: nil, api_key: nil, max_tokens: 128,
23
23
  temperature: 0, extra: {}, open_timeout: HTTP::DEFAULT_OPEN_TIMEOUT,
24
- read_timeout: 20)
25
- if http.nil? && base_url.to_s.strip.empty?
26
- raise ArgumentError,
27
- 'a Chat needs a base_url or an http client'
28
- end
29
-
24
+ read_timeout: HTTP::DEFAULT_READ_TIMEOUT)
30
25
  @model = model
31
26
  @max_tokens = max_tokens
32
27
  @temperature = temperature
33
28
  @extra = extra
34
- @http = http || HTTP.new(
35
- base_url: base_url,
36
- api_key: api_key,
37
- open_timeout: open_timeout,
38
- read_timeout: read_timeout
39
- )
29
+ @http = HTTP.build(http: http, base_url: base_url, api_key: api_key,
30
+ open_timeout: open_timeout, read_timeout: read_timeout,
31
+ missing: 'a Chat needs a base_url or an http client')
40
32
  end
41
33
 
42
34
  # A copy pointed at a different model on the same endpoint and credentials.
@@ -47,13 +39,23 @@ module Vangrail
47
39
  )
48
40
  end
49
41
 
50
- def ask(messages, max_tokens: nil)
42
+ # `conversation:` is the application path: the messages are whatever
43
+ # Conversation#messages will assemble, so a retrieved page cannot be
44
+ # spliced into the instruction by handing this method a raw array.
45
+ def ask(messages = nil, conversation: nil, system: nil, max_tokens: nil)
46
+ if conversation
47
+ raise ArgumentError, 'pass conversation: or messages, not both' if messages
48
+
49
+ messages = conversation.messages(system: system.to_s)
50
+ end
51
+ raise ArgumentError, 'ask needs messages or conversation:' if messages.nil?
52
+
51
53
  payload = {
52
54
  'model' => model,
53
55
  'messages' => normalize(messages),
54
56
  'temperature' => temperature,
55
57
  'max_tokens' => max_tokens || @max_tokens,
56
- 'stream' => false
58
+ 'stream' => false,
57
59
  }.merge(extra)
58
60
 
59
61
  t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC)
@@ -2,14 +2,14 @@
2
2
 
3
3
  module Vangrail
4
4
  class Client
5
- # A guardrailed chat completion, read out of either server response shape.
5
+ # A guardrailed chat turn, read out of either server response shape.
6
6
  #
7
7
  # The OpenAI-compatible shape puts the answer in choices[0].message.content
8
8
  # and rail bookkeeping under a top-level `guardrails` object. The older shape
9
9
  # answers with a bare {role, content} message (or a list of them) and puts
10
10
  # bookkeeping at the top level. Both appear in the wild depending on the
11
11
  # server version, so this reads whichever is present.
12
- class Completion
12
+ class Turn
13
13
  # Server-side names for the variables holding the rail that stopped a turn.
14
14
  INPUT_RAIL_VAR = 'triggered_input_rail'
15
15
  OUTPUT_RAIL_VAR = 'triggered_output_rail'
@@ -30,7 +30,7 @@ module Vangrail
30
30
 
31
31
  messages = raw['messages']
32
32
  if messages.is_a?(Array)
33
- last = messages.reverse.find { |m| m.is_a?(Hash) && m['role'].to_s == 'assistant' }
33
+ last = messages.reverse.detect { |m| m.is_a?(Hash) && m['role'].to_s == 'assistant' }
34
34
  return last['content'].to_s if last
35
35
  end
36
36
  ''
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require_relative 'client/completion'
3
+ require_relative 'client/turn'
4
4
  require_relative 'errors'
5
5
  require_relative 'http'
6
6
  require_relative 'result'
@@ -23,7 +23,11 @@ module Vangrail
23
23
  COMPLETIONS_PATH = '/v1/chat/completions'
24
24
  PROTOCOLS = %i[auto nested flat].freeze
25
25
 
26
- RAIL_VARS = [Completion::INPUT_RAIL_VAR, Completion::OUTPUT_RAIL_VAR].freeze
26
+ RAIL_VARS = [Turn::INPUT_RAIL_VAR, Turn::OUTPUT_RAIL_VAR].freeze
27
+
28
+ # One guarded completion's arguments, so the wire methods take one value.
29
+ Request = Struct.new(:messages, :config_id, :config_ids, :options, :context,
30
+ :thread_id, :model, :extra, keyword_init: true)
27
31
 
28
32
  attr_reader :config_id, :model, :protocol, :http
29
33
 
@@ -31,9 +35,12 @@ module Vangrail
31
35
  # of those happens, so a caller can report "not yet known" honestly.
32
36
  attr_reader :checks_supported
33
37
 
34
- def initialize(base_url:, config_id: nil, model: nil, api_key: nil, protocol: :auto,
35
- open_timeout: HTTP::DEFAULT_OPEN_TIMEOUT, read_timeout: HTTP::DEFAULT_READ_TIMEOUT,
36
- http: nil)
38
+ # The 0.1.0 name. Prefer Turn in new code.
39
+ Completion = Turn
40
+
41
+ def initialize(http: nil, base_url: nil, config_id: nil, model: nil, api_key: nil,
42
+ protocol: :auto, open_timeout: HTTP::DEFAULT_OPEN_TIMEOUT,
43
+ read_timeout: HTTP::DEFAULT_READ_TIMEOUT)
37
44
  unless PROTOCOLS.include?(protocol)
38
45
  raise ArgumentError,
39
46
  "protocol must be one of #{PROTOCOLS.join(', ')}"
@@ -43,10 +50,9 @@ module Vangrail
43
50
  @model = model
44
51
  @protocol = protocol
45
52
  @checks_supported = nil
46
- @http = http || HTTP.new(
47
- base_url: base_url, api_key: api_key,
48
- open_timeout: open_timeout, read_timeout: read_timeout
49
- )
53
+ @http = HTTP.build(http: http, base_url: base_url, api_key: api_key,
54
+ open_timeout: open_timeout, read_timeout: read_timeout,
55
+ missing: 'a Client needs a base_url or an http client')
50
56
  end
51
57
 
52
58
  def base_url
@@ -93,10 +99,13 @@ module Vangrail
93
99
  # answer as well as checking it.
94
100
  def chat(messages:, config_id: nil, config_ids: nil, options: nil, context: nil,
95
101
  thread_id: nil, model: nil, **extra)
96
- opts = merge_options(options)
97
- body = extra.merge(messages: normalize(messages))
98
- chosen = { config_id: config_id || @config_id, config_ids: config_ids }
99
- Completion.new(send_payload(body, chosen, opts, context, thread_id, model))
102
+ request = Request.new(messages: messages, config_id: config_id, config_ids: config_ids,
103
+ options: options, context: context, thread_id: thread_id,
104
+ model: model, extra: extra)
105
+ opts = merge_options(request.options)
106
+ body = request.extra.merge(messages: normalize(request.messages))
107
+ chosen = { config_id: request.config_id || @config_id, config_ids: request.config_ids }
108
+ Turn.new(send_payload(body, chosen, opts, request.context, request.thread_id, request.model))
100
109
  end
101
110
 
102
111
  private
@@ -119,12 +128,12 @@ module Vangrail
119
128
  content: body['content'], raw: body)
120
129
  end
121
130
 
122
- def from_completion(completion, rail)
123
- return Result.passed(rail: rail.to_s, raw: completion.raw) if completion.allowed?
131
+ def from_completion(turn, rail)
132
+ return Result.passed(rail: rail.to_s, raw: turn.raw) if turn.allowed?
124
133
 
125
- reason = completion.triggered_rail || completion.stopped_rails.first&.dig('name')
126
- Result.blocked(rail: reason || rail.to_s, content: completion.content, reason: reason,
127
- raw: completion.raw)
134
+ reason = turn.triggered_rail || turn.stopped_rails.first&.dig('name')
135
+ Result.blocked(rail: reason || rail.to_s, content: turn.content, reason: reason,
136
+ raw: turn.raw)
128
137
  end
129
138
 
130
139
  def check_options(rail)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative '../errors'
4
+
3
5
  module Vangrail
4
6
  module Colang
5
7
  # The whole grammar this gem executes, as data. Everything the parser can
@@ -23,14 +25,38 @@ module Vangrail
23
25
  Program.new(
24
26
  flows: flows.merge(other.flows),
25
27
  bot_messages: bot_messages.merge(other.bot_messages),
26
- user_messages: user_messages.merge(other.user_messages)
28
+ user_messages: user_messages.merge(other.user_messages),
27
29
  )
28
30
  end
31
+
32
+ # Every `bot <name>` in every flow has a `define bot`. The walk covers
33
+ # unreached `if` branches and flows no config names: a half-loaded
34
+ # guardrail must not load, so a dead-branch bot in a merged `.co` file
35
+ # fails the folder. Called after parse/merge, not during parse, so a
36
+ # bot defined in another file is visible once the programs are joined.
37
+ def check!
38
+ flows.each { |name, flow| check_body(flow.body, name) }
39
+ self
40
+ end
41
+
42
+ def check_body(body, flow_name)
43
+ Array(body).each do |node|
44
+ case node
45
+ when Bot
46
+ next if bot_message(node.message)
47
+
48
+ raise ColangError, "no `define bot #{node.message}` for flow #{flow_name.inspect}"
49
+ when If
50
+ check_body(node.then_body, flow_name)
51
+ check_body(node.else_body, flow_name)
52
+ end
53
+ end
54
+ end
29
55
  end
30
56
 
31
57
  Flow = Struct.new(:name, :body, :subflow, keyword_init: true)
32
58
 
33
- # $var = execute action(key="value")
59
+ # $var = <value>
34
60
  Assign = Struct.new(:variable, :expression, keyword_init: true)
35
61
 
36
62
  # execute action(key="value")
@@ -44,7 +70,7 @@ module Vangrail
44
70
 
45
71
  If = Struct.new(:condition, :then_body, :else_body, keyword_init: true)
46
72
 
47
- # Conditions
73
+ # Values
48
74
  Var = Struct.new(:name, keyword_init: true)
49
75
  Not = Struct.new(:expression, keyword_init: true)
50
76
  Compare = Struct.new(:left, :operator, :right, keyword_init: true)