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. 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
-
#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.
-
#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.
- #intended ⇒ Object
-
#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.
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
#admission ⇒ Object (readonly)
Returns the value of attribute admission.
70 71 72 |
# File 'lib/vangrail/conversation.rb', line 70 def admission @admission end |
#capabilities ⇒ Object (readonly)
Returns the value of attribute capabilities.
70 71 72 |
# File 'lib/vangrail/conversation.rb', line 70 def capabilities @capabilities end |
#engine ⇒ Object (readonly)
Returns the value of attribute engine.
70 71 72 |
# File 'lib/vangrail/conversation.rb', line 70 def engine @engine end |
#invocations ⇒ Object (readonly)
Returns the value of attribute invocations.
70 71 72 |
# File 'lib/vangrail/conversation.rb', line 70 def invocations @invocations end |
#profile ⇒ Object (readonly)
Returns the value of attribute profile.
70 71 72 |
# File 'lib/vangrail/conversation.rb', line 70 def profile @profile end |
#retrieved ⇒ Object (readonly)
Returns the value of attribute retrieved.
70 71 72 |
# File 'lib/vangrail/conversation.rb', line 70 def retrieved @retrieved end |
#session ⇒ Object (readonly)
Returns the value of attribute session.
70 71 72 |
# File 'lib/vangrail/conversation.rb', line 70 def session @session end |
#tools ⇒ Object (readonly)
Returns the value of attribute tools.
70 71 72 |
# File 'lib/vangrail/conversation.rb', line 70 def tools @tools end |
#turns ⇒ Object (readonly)
Returns the value of attribute turns.
70 71 72 |
# File 'lib/vangrail/conversation.rb', line 70 def turns @turns end |
#window ⇒ Object (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.
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
262 263 264 |
# File 'lib/vangrail/conversation.rb', line 262 def blocked? !blocked_turns.empty? end |
#blocked_turns ⇒ Object
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 |
#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.
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.
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 |
#intended ⇒ Object
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.
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
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_turn ⇒ Object
266 267 268 |
# File 'lib/vangrail/conversation.rb', line 266 def last_user_turn turns.reverse.detect(&:user?) end |
#locked? ⇒ 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.
191 192 193 194 195 196 197 |
# File 'lib/vangrail/conversation.rb', line 191 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 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_h ⇒ Object
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 |