Class: Yorishiro::Conversation

Inherits:
Object
  • Object
show all
Defined in:
lib/yorishiro/conversation.rb

Constant Summary collapse

CHARS_PER_TOKEN =

Rough heuristic used to estimate token counts without a real tokenizer. English/code averages ~4 characters per token.

4
ELIDED_TOOL_RESULT =
"[Old tool result removed to free context. Re-run the tool if the output is needed again.]"

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(system_prompt: nil) ⇒ Conversation

Returns a new instance of Conversation.



11
12
13
14
# File 'lib/yorishiro/conversation.rb', line 11

def initialize(system_prompt: nil)
  @messages = []
  @system_prompt = system_prompt
end

Instance Attribute Details

#messagesObject (readonly)

Returns the value of attribute messages.



9
10
11
# File 'lib/yorishiro/conversation.rb', line 9

def messages
  @messages
end

Instance Method Details

#add_message(role, content, tool_calls: nil, tool_call_id: nil) ⇒ Object



16
17
18
19
20
21
22
23
24
# File 'lib/yorishiro/conversation.rb', line 16

def add_message(role, content, tool_calls: nil, tool_call_id: nil)
  validate_role!(role)
  @messages << {
    role: role,
    content: content,
    tool_calls: tool_calls,
    tool_call_id: tool_call_id
  }.compact
end

#add_tool_result(tool_call_id:, content:) ⇒ Object



26
27
28
29
30
31
32
# File 'lib/yorishiro/conversation.rb', line 26

def add_tool_result(tool_call_id:, content:)
  @messages << {
    role: :tool,
    content: content,
    tool_call_id: tool_call_id
  }
end

#clearObject



47
48
49
# File 'lib/yorishiro/conversation.rb', line 47

def clear
  @messages.clear
end

#compact!(keep_recent_rounds: 2) ⇒ Object

Replace the oldest rounds with a single summary while keeping the most recent keep_recent_rounds rounds verbatim. The block receives the old messages and must return the summary text; rounds are handled whole so a tool call is never split from its result, and the summary is inserted as a leading :user message (keeping the user-first ordering providers expect). Returns the number of messages that were compacted away (0 if there was nothing old enough to compact or the summary came back empty).



98
99
100
101
102
103
104
105
106
107
108
109
110
111
# File 'lib/yorishiro/conversation.rb', line 98

def compact!(keep_recent_rounds: 2)
  starts = user_message_indices
  return 0 if starts.length <= keep_recent_rounds

  cut = starts[starts.length - keep_recent_rounds]
  old = @messages[0...cut]
  return 0 if old.empty?

  summary = yield(old)
  return 0 if summary.nil? || summary.strip.empty?

  @messages = [summary_message(summary)] + (@messages[cut..] || [])
  old.length
end

#elide_old_tool_results!(max_tokens:, keep_recent: 2) ⇒ Object

Replace the contents of the oldest tool results with a short placeholder until the conversation fits within max_tokens, keeping the most recent keep_recent tool results verbatim. Unlike trim_to_budget!, this frees space inside a single round — a long tool loop after one user message — where whole-round trimming cannot drop anything. Message structure is preserved, so an assistant tool_call is never left without its result. Returns the number of results elided.



122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
# File 'lib/yorishiro/conversation.rb', line 122

def elide_old_tool_results!(max_tokens:, keep_recent: 2)
  tool_indices = @messages.each_index.select { |i| @messages[i][:role] == :tool }
  candidates = keep_recent.positive? ? tool_indices[0...-keep_recent] : tool_indices
  elided = 0

  (candidates || []).each do |index|
    break if estimated_tokens <= max_tokens
    next if @messages[index][:content] == ELIDED_TOOL_RESULT

    @messages[index][:content] = ELIDED_TOOL_RESULT
    elided += 1
  end

  elided
end

#estimated_tokensObject

Rough estimate of the prompt size in tokens (system prompt + all messages).



85
86
87
88
89
# File 'lib/yorishiro/conversation.rb', line 85

def estimated_tokens
  total = @system_prompt.to_s.length
  total += @messages.sum { |msg| message_char_size(msg) }
  (total.to_f / CHARS_PER_TOKEN).ceil
end

#last_roleObject



76
77
78
# File 'lib/yorishiro/conversation.rb', line 76

def last_role
  @messages.last&.fetch(:role, nil)
end

#lengthObject



80
81
82
# File 'lib/yorishiro/conversation.rb', line 80

def length
  @messages.length
end

#restore_messages!(raw_messages) ⇒ Object

Replace the messages with ones loaded from a saved session (string-keyed hashes as produced by #serializable_messages). Tool-call arguments keep their string keys: providers pass them through as-is and the CLI symbolizes at execution time, matching the live message flow.



63
64
65
66
67
68
69
70
71
72
73
74
# File 'lib/yorishiro/conversation.rb', line 63

def restore_messages!(raw_messages)
  @messages = raw_messages.map do |msg|
    role = msg["role"].to_sym
    validate_role!(role)
    {
      role: role,
      content: msg["content"],
      tool_calls: msg["tool_calls"]&.map { |tc| { id: tc["id"], name: tc["name"], arguments: tc["arguments"] } },
      tool_call_id: msg["tool_call_id"]
    }.compact
  end
end

#serializable_messagesObject

JSON-safe deep copy of the messages (string keys) for session persistence. The system prompt is intentionally excluded — a resumed session uses whatever the current configuration provides.



54
55
56
# File 'lib/yorishiro/conversation.rb', line 54

def serializable_messages
  JSON.parse(JSON.generate(@messages))
end

#to_api_messagesObject



34
35
36
37
38
39
40
41
42
43
44
45
# File 'lib/yorishiro/conversation.rb', line 34

def to_api_messages
  msgs = @messages.map do |msg|
    converted = { role: msg[:role].to_s, content: msg[:content] }
    converted[:tool_calls] = msg[:tool_calls] if msg[:tool_calls]
    converted[:tool_call_id] = msg[:tool_call_id] if msg[:tool_call_id]
    converted
  end

  msgs.unshift({ role: "system", content: @system_prompt }) if @system_prompt

  msgs
end

#trim_to_budget!(max_tokens:) ⇒ Object

Drop the oldest conversation rounds until the estimated size fits within max_tokens. A "round" starts at a :user message and includes the assistant reply and any tool results that follow, so whole rounds are removed together — never splitting an assistant tool_call from its tool result. The system prompt and the most recent round are always kept. Returns the number of messages removed.



144
145
146
147
148
149
150
151
152
153
154
155
156
# File 'lib/yorishiro/conversation.rb', line 144

def trim_to_budget!(max_tokens:)
  removed_count = 0

  while estimated_tokens > max_tokens
    round_starts = user_message_indices
    break if round_starts.length <= 1 # keep at least the latest round

    removed = @messages.slice!(0, round_starts[1])
    removed_count += removed.length
  end

  removed_count
end