Class: Vangrail::Conversation

Inherits:
Object
  • Object
show all
Defined in:
lib/vangrail/conversation.rb

Overview

A dialogue, so rails can see more than the turn in front of them.

Every rail so far reads one string. That is enough for the attacks that fit in one string, and it is exactly wrong for the ones built out of turns that are individually unremarkable: ask something harmless, ask for more detail about the part of the answer that helps, keep going until the thing you wanted is on screen. No single message in that sequence looks like an attack, because none of them is one.

So this holds the turns and threads them into the rail context as :history, which the rail protocol has always carried and nothing has ever filled in. A rail that ignores history behaves exactly as before.

convo = Vangrail::Conversation.new(engine)
verdict = convo.ask(question)
convo.answer(text) if verdict.allowed?

What it also does is remember the verdicts. A refusal is the most informative event in a dialogue: the next message is either an ordinary follow-up or the same request rewritten, and telling those apart is impossible without knowing a refusal happened.

Pass prior: and the same turns also feed a Session. One engine walk per turn: assess when a session is present, otherwise check_input. Escalation is not an assess term, so a retry after a refusal is caught on the path without a session.

After ask and screen both tracks have turns. Name the channel; block? is true if either would block.

convo = Vangrail::Conversation.new(engine, prior: 1e-3)
convo.ask(question)
convo.screen(documents)
convo.session.posterior(:attack)
convo.session.posterior(:contamination)
convo.session.block?

Defined Under Namespace

Classes: Turn

Constant Summary collapse

DEFAULT_WINDOW =

How many turns of history the rails see. A dialogue that has been running for an hour is mostly irrelevant to whether this message is a retry, and an unbounded window makes the cost of a check grow with the session.

12

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(engine, window: DEFAULT_WINDOW, session: nil, prior: nil, allow: {}, admission: nil, capabilities: nil, tools: nil, profile: nil, deny: [], hooks: {}, **context) ⇒ Conversation

Returns a new instance of Conversation.

Raises:

  • (ArgumentError)


73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
# File 'lib/vangrail/conversation.rb', line 73

def initialize(engine, window: DEFAULT_WINDOW, session: nil, prior: nil,
               allow: {}, admission: nil, capabilities: nil, tools: nil,
               profile: nil, deny: [], hooks: {}, **context)
  raise ArgumentError, 'pass session: or prior:, not both' if session && prior

  @engine = engine
  @window = window
  @base_context = context
  @turns = []
  @retrieved = []
  @invocations = []
  @intended = []
  @locked = false
  @pinned = false
  @hooks = hooks
  @tools = tools || Tools.new
  @profile = Profile.resolve(profile, allow: allow, deny: deny)
  @capabilities = capabilities.nil? ? nil : Array(capabilities).map(&:to_sym).freeze
  @session = session || (prior && Session.new(engine: engine, prior: prior))
  @admission = admission || Admission.new(allow: @profile.allow)
end

Instance Attribute Details

#admissionObject (readonly)

Returns the value of attribute admission.



70
71
72
# File 'lib/vangrail/conversation.rb', line 70

def admission
  @admission
end

#capabilitiesObject (readonly)

Returns the value of attribute capabilities.



70
71
72
# File 'lib/vangrail/conversation.rb', line 70

def capabilities
  @capabilities
end

#engineObject (readonly)

Returns the value of attribute engine.



70
71
72
# File 'lib/vangrail/conversation.rb', line 70

def engine
  @engine
end

#invocationsObject (readonly)

Returns the value of attribute invocations.



70
71
72
# File 'lib/vangrail/conversation.rb', line 70

def invocations
  @invocations
end

#profileObject (readonly)

Returns the value of attribute profile.



70
71
72
# File 'lib/vangrail/conversation.rb', line 70

def profile
  @profile
end

#retrievedObject (readonly)

Returns the value of attribute retrieved.



70
71
72
# File 'lib/vangrail/conversation.rb', line 70

def retrieved
  @retrieved
end

#sessionObject (readonly)

Returns the value of attribute session.



70
71
72
# File 'lib/vangrail/conversation.rb', line 70

def session
  @session
end

#toolsObject (readonly)

Returns the value of attribute tools.



70
71
72
# File 'lib/vangrail/conversation.rb', line 70

def tools
  @tools
end

#turnsObject (readonly)

Returns the value of attribute turns.



70
71
72
# File 'lib/vangrail/conversation.rb', line 70

def turns
  @turns
end

#windowObject (readonly)

Returns the value of attribute window.



70
71
72
# File 'lib/vangrail/conversation.rb', line 70

def window
  @window
end

Instance Method Details

#admit?(capability, arguments: nil) ⇒ Boolean

Whether this dialogue may exercise a capability. The request is the last user turn, carrying the conversation's capability set. A bare argument string is data. Nothing is admitted before anyone has asked, and a name that is not in the allowlist is not admitted either.

Returns:

  • (Boolean)


173
174
175
176
177
178
179
180
181
182
183
184
185
# File 'lib/vangrail/conversation.rb', line 173

def admit?(capability, arguments: nil)
  turn = last_user_turn
  return false unless turn
  return false if profile.denied?(capability)

  args = case arguments
         when nil then nil
         when Cell then arguments
         else Cell.data(arguments)
         end
  admission.permit?(capability, request: Cell.user(turn.text, capabilities: capabilities),
                                arguments: args)
end

#answer(text, **context) ⇒ Object



117
118
119
120
121
122
123
124
# File 'lib/vangrail/conversation.rb', line 117

def answer(text, **context)
  result = engine.check_output(text, history: history, **@base_context, **context)
  turn = Turn.new(role: :assistant, text: content_of(result, text), result: result,
                  origin: Origin.tool)
  @turns << turn
  @session&.fold(result, origin: turn.origin, side: :output)
  result
end

#ask(text, **context) ⇒ Object

Checks a question and records it, whatever the verdict. A blocked turn stays in the history: it is the part the next check needs most.

One engine walk: assess when a session is present, check_input otherwise. Assess does not run Escalation. That object is folded onto the Turn and the Session.



101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
# File 'lib/vangrail/conversation.rb', line 101

def ask(text, **context)
  @pinned = true
  seen = history
  ctx = { history: seen, **@base_context, **context }
  result = if @session
             judgement = engine.assess(text, side: :input, origin: Origin.user,
                                       **session_assess, **ctx)
             @session.fold(judgement)
             result_from(judgement)
           else
             engine.check_input(text, **ctx)
           end
  @turns << Turn.new(role: :user, text: text.to_s, result: result, origin: Origin.user)
  result
end

#blocked?Boolean

Returns:

  • (Boolean)


262
263
264
# File 'lib/vangrail/conversation.rb', line 262

def blocked?
  !blocked_turns.empty?
end

#blocked_turnsObject



258
259
260
# File 'lib/vangrail/conversation.rb', line 258

def blocked_turns
  turns.select { |t| t.user? && t.blocked? }
end

#child_env(source = ENV) ⇒ Object



270
271
272
# File 'lib/vangrail/conversation.rb', line 270

def child_env(source = ENV)
  profile.strip_secrets? ? Profile.strip_secrets(source) : source.to_h
end

#extract(pattern) ⇒ Object

A span pulled out of retrieved data. The result is still data.



245
246
247
248
249
250
# File 'lib/vangrail/conversation.rb', line 245

def extract(pattern)
  retrieved.filter_map do |cell|
    match = cell.value[pattern]
    Cell.data(match) if match
  end
end

#historyObject

The window the rails read: role and text, no Result objects, because a rail should not be reasoning about another rail's verdict text.



254
255
256
# File 'lib/vangrail/conversation.rb', line 254

def history
  turns.last(window).map { |t| { role: t.role, text: t.text, blocked: t.blocked? } }
end

#intend(*names) ⇒ Object

Names the tools this question is allowed to use, before any retrieved page is seen. That is the privileged planner: the plan is fixed from the user turn. After screen, the plan is locked. A page that names a new tool cannot add it.

Raises:



147
148
149
150
151
152
153
154
155
156
157
158
159
# File 'lib/vangrail/conversation.rb', line 147

def intend(*names)
  raise Error, 'ask before intending a tool' unless last_user_turn
  raise PrivilegeError, 'the plan is locked: data has already been seen' if locked?

  names.each do |name|
    name = name.to_sym
    raise ArgumentError, "unknown tool #{name}" unless tools.key?(name)
    raise PrivilegeError, "capability #{name} is denied by profile" if profile.denied?(name)

    @intended << name unless @intended.include?(name)
  end
  intended
end

#intendedObject



161
162
163
# File 'lib/vangrail/conversation.rb', line 161

def intended
  @intended.dup.freeze
end

#invoke(name, arguments: nil) ⇒ Object

Runs a named tool only if Admission grants it. A refused call is a blocked turn, not a handler that almost ran. The return value of a granted handler is wrapped as a tool-origin cell.

Raises:

  • (ArgumentError)


202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
# File 'lib/vangrail/conversation.rb', line 202

def invoke(name, arguments: nil)
  name = name.to_sym
  raise ArgumentError, "unknown tool #{name}" unless tools.key?(name)

  if profile.denied?(name)
    result = Result.blocked(rail: 'deny', reason: "capability #{name} is denied by profile")
    record_invocation(name, arguments, result, nil)
    return result
  end

  if profile.readonly? && !tools.readonly?(name)
    result = Result.blocked(rail: 'profile', reason: "profile #{profile.name} is read-only")
    record_invocation(name, arguments, result, nil)
    return result
  end

  hook = run_pre_invoke(name, arguments)
  return hook if hook

  unless @intended.include?(name)
    result = Result.blocked(rail: 'plan', reason: "capability #{name} was not intended")
    record_invocation(name, arguments, result, nil)
    return result
  end

  unless admit?(name, arguments: arguments)
    result = Result.blocked(rail: 'admission', reason: "capability #{name} refused")
    record_invocation(name, arguments, result, nil)
    return result
  end

  value = tools.fire(name, arguments, self)
  cell = value.is_a?(Cell) ? value : Cell.tool(value)
  result = Result.passed(rail: name.to_s)
  record_invocation(name, arguments, result, cell)
  result
end

#invoked?(name) ⇒ Boolean

Returns:

  • (Boolean)


240
241
242
# File 'lib/vangrail/conversation.rb', line 240

def invoked?(name)
  invocations.any? { |row| row[:name] == name.to_sym && row[:result].allowed? }
end

#last_user_turnObject



266
267
268
# File 'lib/vangrail/conversation.rb', line 266

def last_user_turn
  turns.reverse.detect(&:user?)
end

#locked?Boolean

Returns:

  • (Boolean)


165
166
167
# File 'lib/vangrail/conversation.rb', line 165

def locked?
  @locked
end

#messages(system:, mode: :delimit, mark: Spotlight::DEFAULT_MARK) ⇒ Object

The only assembly this object will produce. The question is the last user turn; the passages are the cells screen kept. A caller who pastes retrieved text into system: or question: has to do it without this method, which is the point.

Raises:



191
192
193
194
195
196
197
# File 'lib/vangrail/conversation.rb', line 191

def messages(system:, mode: :delimit, mark: Spotlight::DEFAULT_MARK)
  turn = last_user_turn
  raise Error, 'ask before assembling a prompt' unless turn

  Spotlight.messages(system: system, question: Cell.user(turn.text),
                     passages: retrieved, mode: mode, mark: mark)
end

#screen(documents, **context) ⇒ Object

Screens retrieved documents with the dialogue in view, so a context rail can see which question they were fetched for. A session, if any, records every judged page on the contamination track, rejected ones included: instruction-shaped data is poisoned retrieval, not a user attack. Retrieved cells stay the survivors.



131
132
133
134
135
136
137
138
139
140
141
# File 'lib/vangrail/conversation.rb', line 131

def screen(documents, **context)
  seen = history
  result = engine.screen(documents, history: seen, **@base_context, **context)
  @retrieved = result.cells
  @locked = true
  @intended.freeze
  Array(documents).each do |document|
    @session&.observe(Cell.text_of(document), side: :context, origin: :data, history: seen)
  end
  result
end

#to_hObject



274
275
276
277
278
279
280
281
282
283
284
# File 'lib/vangrail/conversation.rb', line 274

def to_h
  {
    'turns' => turns.map(&:to_h),
    'blocked' => blocked_turns.size,
    'invoked' => invocations.select { |row| row[:result].allowed? }.map { |row| row[:name].to_s },
    'intended' => @intended.map(&:to_s),
    'locked' => locked?,
    'profile' => profile.name.to_s,
    'session' => session&.to_h,
  }.compact
end