Class: Vangrail::Conversation
- Inherits:
-
Object
- Object
- Vangrail::Conversation
- 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
-
#admission ⇒ Object
readonly
Returns the value of attribute admission.
-
#capabilities ⇒ Object
readonly
Returns the value of attribute capabilities.
-
#engine ⇒ Object
readonly
Returns the value of attribute engine.
-
#intended ⇒ Object
readonly
Returns the value of attribute intended.
-
#invocations ⇒ Object
readonly
Returns the value of attribute invocations.
-
#profile ⇒ Object
readonly
Returns the value of attribute profile.
-
#retrieved ⇒ Object
readonly
Returns the value of attribute retrieved.
-
#session ⇒ Object
readonly
Returns the value of attribute session.
-
#tools ⇒ Object
readonly
Returns the value of attribute tools.
-
#turns ⇒ Object
readonly
Returns the value of attribute turns.
-
#window ⇒ Object
readonly
Returns the value of attribute window.
Instance Method Summary collapse
-
#admit?(capability, arguments: nil) ⇒ Boolean
Whether this dialogue may exercise a capability.
- #answer(text, **context) ⇒ Object
-
#ask(text, **context) ⇒ Object
Checks a question and records it, whatever the verdict.
- #blocked? ⇒ Boolean
- #blocked_turns ⇒ Object
- #child_env(source = ENV) ⇒ Object
-
#extract(pattern) ⇒ Object
A span pulled out of retrieved data.
-
#history ⇒ Object
The window the rails read: role and text, no Result objects, because a rail should not be reasoning about another rail's verdict text.
-
#initialize(engine, window: DEFAULT_WINDOW, session: nil, prior: nil, allow: {}, admission: nil, capabilities: nil, tools: nil, profile: nil, deny: [], hooks: {}, **context) ⇒ Conversation
constructor
A new instance of Conversation.
-
#intend(*names) ⇒ Object
Names the tools this question is allowed to use, before any retrieved page is seen.
-
#invoke(name, arguments: nil) ⇒ Object
Runs a named tool only if Admission grants it.
- #invoked?(name) ⇒ Boolean
- #last_user_turn ⇒ Object
- #locked? ⇒ Boolean
-
#messages(system:, mode: :delimit, mark: Spotlight::DEFAULT_MARK) ⇒ Object
The only assembly this object will produce.
-
#screen(documents, **context) ⇒ Object
Screens retrieved documents with the dialogue in view, so a context rail can see which question they were fetched for.
- #to_h ⇒ Object
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.
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
#admission ⇒ Object (readonly)
Returns the value of attribute admission.
63 64 65 |
# File 'lib/vangrail/conversation.rb', line 63 def admission @admission end |
#capabilities ⇒ Object (readonly)
Returns the value of attribute capabilities.
63 64 65 |
# File 'lib/vangrail/conversation.rb', line 63 def capabilities @capabilities end |
#engine ⇒ Object (readonly)
Returns the value of attribute engine.
63 64 65 |
# File 'lib/vangrail/conversation.rb', line 63 def engine @engine end |
#intended ⇒ Object (readonly)
Returns the value of attribute intended.
63 64 65 |
# File 'lib/vangrail/conversation.rb', line 63 def intended @intended end |
#invocations ⇒ Object (readonly)
Returns the value of attribute invocations.
63 64 65 |
# File 'lib/vangrail/conversation.rb', line 63 def invocations @invocations end |
#profile ⇒ Object (readonly)
Returns the value of attribute profile.
63 64 65 |
# File 'lib/vangrail/conversation.rb', line 63 def profile @profile end |
#retrieved ⇒ Object (readonly)
Returns the value of attribute retrieved.
63 64 65 |
# File 'lib/vangrail/conversation.rb', line 63 def retrieved @retrieved end |
#session ⇒ Object (readonly)
Returns the value of attribute session.
63 64 65 |
# File 'lib/vangrail/conversation.rb', line 63 def session @session end |
#tools ⇒ Object (readonly)
Returns the value of attribute tools.
63 64 65 |
# File 'lib/vangrail/conversation.rb', line 63 def tools @tools end |
#turns ⇒ Object (readonly)
Returns the value of attribute turns.
63 64 65 |
# File 'lib/vangrail/conversation.rb', line 63 def turns @turns end |
#window ⇒ Object (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.
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
234 235 236 |
# File 'lib/vangrail/conversation.rb', line 234 def blocked? !blocked_turns.empty? end |
#blocked_turns ⇒ Object
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 |
#history ⇒ Object
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.
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.
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
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_turn ⇒ Object
238 239 240 |
# File 'lib/vangrail/conversation.rb', line 238 def last_user_turn turns.reverse.detect(&:user?) end |
#locked? ⇒ 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.
163 164 165 166 167 168 169 |
# File 'lib/vangrail/conversation.rb', line 163 def (system:, mode: :delimit, mark: Spotlight::DEFAULT_MARK) turn = last_user_turn raise Error, 'ask before assembling a prompt' unless turn Spotlight.(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_h ⇒ Object
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 |