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. Escalation sees the refusals; the posterior sees the sequence that never refused. They are different questions and they share one history.

convo = Vangrail::Conversation.new(engine, prior: 1e-3)
convo.ask(question)
convo.session.posterior

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)


66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
# File 'lib/vangrail/conversation.rb', line 66

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.



63
64
65
# File 'lib/vangrail/conversation.rb', line 63

def admission
  @admission
end

#capabilitiesObject (readonly)

Returns the value of attribute capabilities.



63
64
65
# File 'lib/vangrail/conversation.rb', line 63

def capabilities
  @capabilities
end

#engineObject (readonly)

Returns the value of attribute engine.



63
64
65
# File 'lib/vangrail/conversation.rb', line 63

def engine
  @engine
end

#intendedObject (readonly)

Returns the value of attribute intended.



63
64
65
# File 'lib/vangrail/conversation.rb', line 63

def intended
  @intended
end

#invocationsObject (readonly)

Returns the value of attribute invocations.



63
64
65
# File 'lib/vangrail/conversation.rb', line 63

def invocations
  @invocations
end

#profileObject (readonly)

Returns the value of attribute profile.



63
64
65
# File 'lib/vangrail/conversation.rb', line 63

def profile
  @profile
end

#retrievedObject (readonly)

Returns the value of attribute retrieved.



63
64
65
# File 'lib/vangrail/conversation.rb', line 63

def retrieved
  @retrieved
end

#sessionObject (readonly)

Returns the value of attribute session.



63
64
65
# File 'lib/vangrail/conversation.rb', line 63

def session
  @session
end

#toolsObject (readonly)

Returns the value of attribute tools.



63
64
65
# File 'lib/vangrail/conversation.rb', line 63

def tools
  @tools
end

#turnsObject (readonly)

Returns the value of attribute turns.



63
64
65
# File 'lib/vangrail/conversation.rb', line 63

def turns
  @turns
end

#windowObject (readonly)

Returns the value of attribute window.



63
64
65
# File 'lib/vangrail/conversation.rb', line 63

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)


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

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

  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



99
100
101
102
103
104
# File 'lib/vangrail/conversation.rb', line 99

def answer(text, **context)
  result = engine.check_output(text, history: history, **@base_context, **context)
  @turns << Turn.new(role: :assistant, text: content_of(result, text), result: result,
                     origin: Origin.tool)
  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.



90
91
92
93
94
95
96
97
# File 'lib/vangrail/conversation.rb', line 90

def ask(text, **context)
  @pinned = true
  seen = history
  result = engine.check_input(text, history: seen, **@base_context, **context)
  @turns << Turn.new(role: :user, text: text.to_s, result: result, origin: Origin.user)
  @session&.observe(text, side: :input, origin: :user, history: seen)
  result
end

#blocked?Boolean

Returns:

  • (Boolean)


234
235
236
# File 'lib/vangrail/conversation.rb', line 234

def blocked?
  !blocked_turns.empty?
end

#blocked_turnsObject



230
231
232
# File 'lib/vangrail/conversation.rb', line 230

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

#child_env(source = ENV) ⇒ Object



242
243
244
# File 'lib/vangrail/conversation.rb', line 242

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.



217
218
219
220
221
222
# File 'lib/vangrail/conversation.rb', line 217

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.



226
227
228
# File 'lib/vangrail/conversation.rb', line 226

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:



125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/vangrail/conversation.rb', line 125

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)

    @intended << name unless @intended.include?(name)
  end
  @intended
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)


174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
# File 'lib/vangrail/conversation.rb', line 174

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.call(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)


212
213
214
# File 'lib/vangrail/conversation.rb', line 212

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

#last_user_turnObject



238
239
240
# File 'lib/vangrail/conversation.rb', line 238

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

#locked?Boolean

Returns:

  • (Boolean)


138
139
140
# File 'lib/vangrail/conversation.rb', line 138

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:



163
164
165
166
167
168
169
# File 'lib/vangrail/conversation.rb', line 163

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 each page on the contamination track: instruction-shaped data is poisoned retrieval, not a user attack.



110
111
112
113
114
115
116
117
118
119
# File 'lib/vangrail/conversation.rb', line 110

def screen(documents, **context)
  seen = history
  result = engine.screen(documents, history: seen, **@base_context, **context)
  @retrieved = result.cells
  @locked = true
  @retrieved.each do |cell|
    @session&.observe(cell.value, side: :context, origin: :data, history: seen)
  end
  result
end

#to_hObject



246
247
248
249
250
251
252
253
254
255
256
# File 'lib/vangrail/conversation.rb', line 246

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