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



26
27
28
29
# File 'lib/riffer/agent/session.rb', line 26

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

Instance Attribute Details

#messagesObject (readonly)

The message history.



22
23
24
# File 'lib/riffer/agent/session.rb', line 22

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



55
56
57
58
59
# File 'lib/riffer/agent/session.rb', line 55

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



195
196
197
198
# File 'lib/riffer/agent/session.rb', line 195

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?



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

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



40
41
42
43
44
# File 'lib/riffer/agent/session.rb', line 40

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



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

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]



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

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?



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

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



70
71
72
73
# File 'lib/riffer/agent/session.rb', line 70

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



206
207
208
# File 'lib/riffer/agent/session.rb', line 206

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



80
81
82
83
# File 'lib/riffer/agent/session.rb', line 80

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



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

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