Class: Vangrail::Conversation

Inherits:
Object
  • Object
show all
Includes:
ConversationTools
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: {}, plan: nil, monitor: nil, **context) ⇒ Conversation

Returns a new instance of Conversation.

Raises:

  • (ArgumentError)


78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
# File 'lib/vangrail/conversation.rb', line 78

def initialize(engine, window: DEFAULT_WINDOW, session: nil, prior: nil,
               allow: {}, admission: nil, capabilities: nil, tools: nil,
               profile: nil, deny: [], hooks: {}, plan: nil, monitor: nil,
               **context)
  raise ArgumentError, 'pass session: or prior:, not both' if session && prior
  if plan && monitor && !monitor.plan.equal?(plan)
    raise ArgumentError, 'plan and monitor must refer to the same plan'
  end

  @engine = engine
  @window = window
  @base_context = context
  @turns = []
  @retrieved = []
  @invocations = []
  @locked = false
  @pinned = false
  @hooks = hooks
  @tools = tools || Tools.new
  @profile = Profile.resolve(profile, allow: allow, deny: deny)
  @plan = plan || monitor&.plan || Plan.from_allow(@profile.allow, tools: @tools)
  @monitor = monitor || ReferenceMonitor.new(@plan)
  @intended = plan || monitor ? @plan.grants.map(&:tool).uniq : []
  @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.



75
76
77
# File 'lib/vangrail/conversation.rb', line 75

def admission
  @admission
end

#capabilitiesObject (readonly)

Returns the value of attribute capabilities.



75
76
77
# File 'lib/vangrail/conversation.rb', line 75

def capabilities
  @capabilities
end

#engineObject (readonly)

Returns the value of attribute engine.



75
76
77
# File 'lib/vangrail/conversation.rb', line 75

def engine
  @engine
end

#invocationsObject (readonly)

Returns the value of attribute invocations.



75
76
77
# File 'lib/vangrail/conversation.rb', line 75

def invocations
  @invocations
end

#monitorObject (readonly)

Returns the value of attribute monitor.



75
76
77
# File 'lib/vangrail/conversation.rb', line 75

def monitor
  @monitor
end

#planObject (readonly)

Returns the value of attribute plan.



75
76
77
# File 'lib/vangrail/conversation.rb', line 75

def plan
  @plan
end

#profileObject (readonly)

Returns the value of attribute profile.



75
76
77
# File 'lib/vangrail/conversation.rb', line 75

def profile
  @profile
end

#retrievedObject (readonly)

Returns the value of attribute retrieved.



75
76
77
# File 'lib/vangrail/conversation.rb', line 75

def retrieved
  @retrieved
end

#sessionObject (readonly)

Returns the value of attribute session.



75
76
77
# File 'lib/vangrail/conversation.rb', line 75

def session
  @session
end

#toolsObject (readonly)

Returns the value of attribute tools.



75
76
77
# File 'lib/vangrail/conversation.rb', line 75

def tools
  @tools
end

#turnsObject (readonly)

Returns the value of attribute turns.



75
76
77
# File 'lib/vangrail/conversation.rb', line 75

def turns
  @turns
end

#windowObject (readonly)

Returns the value of attribute window.



75
76
77
# File 'lib/vangrail/conversation.rb', line 75

def window
  @window
end

Instance Method Details

#admit?(capability, arguments: nil) ⇒ Boolean Originally defined in module ConversationTools

Compatibility query for the coarse Admission API. Actual execution also requires a matching structured Grant from the ReferenceMonitor.

Returns:

  • (Boolean)

#answer(text, **context) ⇒ Object



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

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.



112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
# File 'lib/vangrail/conversation.rb', line 112

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)


184
185
186
# File 'lib/vangrail/conversation.rb', line 184

def blocked?
  !blocked_turns.empty?
end

#blocked_turnsObject



180
181
182
# File 'lib/vangrail/conversation.rb', line 180

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

#child_env(source = ENV) ⇒ Object Originally defined in module ConversationTools

#confirm(call, actor:) ⇒ Object Originally defined in module ConversationTools

#extract(pattern) ⇒ Object

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



167
168
169
170
171
172
# File 'lib/vangrail/conversation.rb', line 167

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.



176
177
178
# File 'lib/vangrail/conversation.rb', line 176

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

#intend(*names) ⇒ Object Originally defined in module ConversationTools

Names the tools this question is allowed to use before retrieved data is visible. Selecting a name never creates a Grant.

Raises:

#intendedObject Originally defined in module ConversationTools

#invoke(target, arguments: nil, sink: nil, confirmation: nil, transaction: false, idempotency_key: nil, risk: nil) ⇒ Object Originally defined in module ConversationTools

Runs a handler only after profile, plan, hook, and reference-monitor authorization agree. Every refusal is recorded without calling the handler.

Raises:

  • (ArgumentError)

#invoked?(name) ⇒ Boolean Originally defined in module ConversationTools

Returns:

  • (Boolean)

#last_user_turnObject



188
189
190
# File 'lib/vangrail/conversation.rb', line 188

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

#locked?Boolean Originally defined in module ConversationTools

Returns:

  • (Boolean)

#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:



158
159
160
161
162
163
164
# File 'lib/vangrail/conversation.rb', line 158

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.



142
143
144
145
146
147
148
149
150
151
152
# File 'lib/vangrail/conversation.rb', line 142

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



192
193
194
195
196
197
198
199
200
201
202
203
# File 'lib/vangrail/conversation.rb', line 192

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?,
    'plan' => plan.to_h,
    'profile' => profile.name.to_s,
    'session' => session&.to_h,
  }.compact
end