Class: Riffer::Agent::Session

Inherits:
Object
  • Object
show all
Includes:
Enumerable
Defined in:
lib/riffer/agent/session.rb

Overview

Riffer::Agent::Session owns the conversation handle for an agent: the message array, the on_message callback list, and the tool_usetool_result invariant that keeps tool calls and their results consistent.

Access via agent.session. Sessions are constructed by Riffer::Agent and live for the lifetime of the agent.

agent.session.add(msg)                  # append + fire callbacks
agent.session.set([msg1, msg2])         # bulk replace (silent)
agent.session.unset                     # clear (silent)
agent.session.remove(id: "a_1")
agent.session.update(id: "a_1", content: "...")
agent.session.find { |m| m.id == "a_1" }

Defined Under Namespace

Modules: Repair

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(messages: []) ⇒ Session

– : (?messages: Array) -> void



28
29
30
31
# File 'lib/riffer/agent/session.rb', line 28

def initialize(messages: [])
  @messages = messages
  @callbacks = [] #: Array[^(Riffer::Messages::Base) -> void]
end

Instance Attribute Details

#messagesObject (readonly)

The message history.



24
25
26
# File 'lib/riffer/agent/session.rb', line 24

def messages
  @messages
end

Instance Method Details

#add(message, silent: false) ⇒ Object

Appends message and fires every registered callback once with it.

Pass silent: true to skip on_message callbacks — used for non-inference inputs like user messages, which subscribers don’t expect to observe through the callback channel. Inference-produced messages (Assistant, Tool) always go through add without silent.

– : (Riffer::Messages::Base, ?silent: bool) -> Riffer::Messages::Base



57
58
59
60
61
# File 'lib/riffer/agent/session.rb', line 57

def add(message, silent: false)
  @messages << message
  @callbacks.each { |callback| callback.call(message) } unless silent
  message
end

#each(&block) ⇒ Object

– : () -> Enumerator[Riffer::Messages::Base, self] : () { (Riffer::Messages::Base) -> void } -> untyped



197
198
199
200
# File 'lib/riffer/agent/session.rb', line 197

def each(&block)
  return @messages.each unless block
  @messages.each(&block)
end

#final_assistant_messageObject

The most recent Riffer::Messages::Assistant in the session, or nil when none exists.

– : () -> Riffer::Messages::Assistant?



217
218
219
220
221
222
# File 'lib/riffer/agent/session.rb', line 217

def final_assistant_message
  # TODO: Replace with rfind when minimum Ruby is 4.0+
  # rubocop:disable Style/ReverseFind
  @messages.reverse_each.find { |m| m.is_a?(Riffer::Messages::Assistant) } #: Riffer::Messages::Assistant?
  # rubocop:enable Style/ReverseFind
end

#on_message(&block) ⇒ Object

Registers a callback invoked once per message appended via #add.

Callbacks do NOT fire for #set, #unset, #remove, or #update. Returns self to allow chaining.

Raises Riffer::ArgumentError if no block is given.

– : () { (Riffer::Messages::Base) -> void } -> self



42
43
44
45
46
# File 'lib/riffer/agent/session.rb', line 42

def on_message(&block)
  raise Riffer::ArgumentError, "on_message requires a block" unless block_given?
  @callbacks << block
  self
end

#orphaned_tool_call_idsObject

Returns the call_ids of every tool_call on any assistant message that has no matching Riffer::Messages::Tool result anywhere in history.

Zero-cost validation hook for callers that want to check the tool_usetool_result invariant before mutating or persisting.

– : () -> Array



166
167
168
169
170
171
172
# File 'lib/riffer/agent/session.rb', line 166

def orphaned_tool_call_ids
  result_ids = @messages.filter_map { |m| m.tool_call_id if m.is_a?(Riffer::Messages::Tool) }
  @messages.flat_map { |m|
    next [] unless m.is_a?(Riffer::Messages::Assistant)
    m.tool_calls.reject { |tc| result_ids.include?(tc.call_id) }.map(&:call_id)
  }
end

#pending_tool_callsObject

Returns [assistant, pending_tool_calls] for the last assistant message. When there is no assistant message or no pending calls, the second element is an empty array.

– : () -> [Riffer::Messages::Assistant?, Array]



180
181
182
183
184
185
186
187
188
189
190
191
192
# File 'lib/riffer/agent/session.rb', line 180

def pending_tool_calls
  last_assistant_idx = @messages.rindex { |m| m.is_a?(Riffer::Messages::Assistant) }
  return [nil, []] unless last_assistant_idx

  assistant = @messages[last_assistant_idx] #: Riffer::Messages::Assistant
  return [assistant, []] if assistant.tool_calls.empty?

  executed_ids = (@messages[(last_assistant_idx + 1)..] || []).filter_map { |m|
    m.tool_call_id if m.is_a?(Riffer::Messages::Tool)
  }

  [assistant, assistant.tool_calls.reject { |tc| executed_ids.include?(tc.call_id) }]
end

#remove(id:) ⇒ Object

Removes a message by id. When the target is an assistant message that carries tool_calls, every Riffer::Messages::Tool result whose tool_call_id matches one of those calls is removed atomically — keeping the tool_usetool_result invariant intact.

Raises Riffer::ArgumentError when called on a Riffer::Messages::Tool message — that would orphan the parent’s tool_use. Use #update to rewrite a tool result instead.

Returns the removed message, or nil when no message has the given id (idempotent).

– : (id: String) -> Riffer::Messages::Base?



101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
# File 'lib/riffer/agent/session.rb', line 101

def remove(id:)
  idx = @messages.index { |m| m.id == id }
  return nil unless idx

  target = @messages[idx]
  if target.is_a?(Riffer::Messages::Tool)
    raise Riffer::ArgumentError,
      "remove cannot drop a Tool message (would orphan the parent's tool_use); use #update instead"
  end

  if target.is_a?(Riffer::Messages::Assistant) && !target.tool_calls.empty?
    child_ids = target.tool_calls.map(&:call_id)
    @messages.reject! { |m| m.is_a?(Riffer::Messages::Tool) && child_ids.include?(m.tool_call_id) }
    @messages.delete(target)
  else
    @messages.delete_at(idx)
  end
  target
end

#set(messages) ⇒ Object

Replaces the message history wholesale. Does NOT fire on_message callbacks; registered callbacks persist across the swap.

Used for seeding, guardrail rewrites, and history healing — cases where firing callbacks would double-emit messages that subscribers have already observed (or never produced).

– : (Array) -> self



72
73
74
75
# File 'lib/riffer/agent/session.rb', line 72

def set(messages)
  @messages = messages
  self
end

#stepsObject

The number of LLM steps completed in this session, derived from the count of assistant messages. Used by the agent loop to enforce max_steps on resume.

– : () -> Integer



208
209
210
# File 'lib/riffer/agent/session.rb', line 208

def steps
  @messages.count { |m| m.is_a?(Riffer::Messages::Assistant) }
end

#unsetObject

Clears the session. Does NOT fire on_message callbacks; registered callbacks persist.

– : () -> self



82
83
84
85
# File 'lib/riffer/agent/session.rb', line 82

def unset
  @messages = []
  self
end

#update(id: nil, tool_call_id: nil, **attrs) ⇒ Object

Partial in-place update. Looks up a message by either id: or tool_call_id: (exactly one required), constructs a replacement of the same concrete type with attrs overlaid on the existing fields, and swaps it in place.

When the target is an assistant message and the update drops one or more entries from tool_calls, every Riffer::Messages::Tool result whose tool_call_id matches a dropped call is removed atomically — keeping the tool_usetool_result invariant intact.

Raises Riffer::ArgumentError when neither or both lookup keys are provided, or when no message matches.

– : (?id: String?, ?tool_call_id: String?, **untyped) -> Riffer::Messages::Base



136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/riffer/agent/session.rb', line 136

def update(id: nil, tool_call_id: nil, **attrs)
  raise Riffer::ArgumentError, "update requires either id: or tool_call_id:" if id.nil? && tool_call_id.nil?
  raise Riffer::ArgumentError, "update accepts id: or tool_call_id:, not both" if id && tool_call_id

  idx = if id
    @messages.index { |m| m.id == id }
  else
    @messages.index { |m| m.is_a?(Riffer::Messages::Tool) && m.tool_call_id == tool_call_id }
  end

  unless idx
    key = id ? "id #{id.inspect}" : "tool_call_id #{tool_call_id.inspect}"
    raise Riffer::ArgumentError, "no message found for #{key}"
  end

  old = @messages[idx] #: Riffer::Messages::Base
  replacement = rebuild_message(old, attrs)
  @messages[idx] = replacement
  cascade_dropped_tool_calls(old, replacement)
  replacement
end