Class: AgentHarness::Conversation

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

Overview

Manages multi-turn conversation history with token tracking and transport-specific message formatting.

Encapsulates message storage, token budget awareness, context window truncation, and serialisation to OpenAI and Anthropic API formats.

Examples:

Basic usage

convo = AgentHarness::Conversation.new(system_prompt: "You are helpful.")
convo.add_message(:user, "Hello")
convo.add_message(:assistant, "Hi there!", tokens: { input: 10, output: 5 })
convo.to_openai_messages

Token-aware truncation

convo = AgentHarness::Conversation.new(system_prompt: "...", token_limit: 8000)
# ... add many messages ...
convo.truncate(keep_recent: 4) if convo.approaching_limit?

Constant Summary collapse

VALID_ROLES =
%i[system user assistant tool].freeze

Instance Attribute Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(system_prompt: nil, token_limit: nil) ⇒ Conversation

Returns a new instance of Conversation.

Parameters:

  • system_prompt (String, nil) (defaults to: nil)

    optional system prompt prepended to messages

  • token_limit (Integer, nil) (defaults to: nil)

    optional context-window token budget



30
31
32
33
34
35
36
37
# File 'lib/agent_harness/conversation.rb', line 30

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

  if system_prompt
    add_message(:system, system_prompt)
  end
end

Instance Attribute Details

#token_limitInteger? (readonly)

Returns the token budget for this conversation.

Returns:

  • (Integer, nil)

    the token budget for this conversation



26
27
28
# File 'lib/agent_harness/conversation.rb', line 26

def token_limit
  @token_limit
end

Instance Method Details

#add_message(role, content = nil, **metadata) ⇒ Hash

Append a message to the conversation.

Parameters:

  • role (Symbol)

    one of :system, :user, :assistant, :tool

  • content (String, nil) (defaults to: nil)

    message text

  • metadata (Hash)

    optional fields — :tool_calls, :tool_call_id, :tool_name, :tool_arguments, :tool_result, :model, :tokens

Returns:

  • (Hash)

    the message that was added

Raises:

  • (ArgumentError)

    if role is invalid



47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
# File 'lib/agent_harness/conversation.rb', line 47

def add_message(role, content = nil, **)
  role = role.to_sym
  unless VALID_ROLES.include?(role)
    raise ArgumentError, "Invalid role: #{role}. Must be one of #{VALID_ROLES.join(", ")}"
  end
  if role == :system && !@messages.empty?
    raise ArgumentError, "System messages are only allowed as the first message"
  end

  message = {
    role: role,
    content: content,
    created_at: Time.now
  }

  message[:tool_calls] = [:tool_calls] if [:tool_calls]
  message[:tool_call_id] = [:tool_call_id] if [:tool_call_id]
  message[:tool_name] = [:tool_name] if [:tool_name]
  message[:tool_arguments] = [:tool_arguments] if [:tool_arguments]
  message[:tool_result] = [:tool_result] if [:tool_result]
  message[:model] = [:model] if [:model]
  message[:tokens] = [:tokens] if [:tokens]

  @messages << message
  deep_copy(message)
end

#approaching_limit?(threshold: 0.8) ⇒ Boolean

Whether token usage has reached or exceeded the given threshold of the limit.

Parameters:

  • threshold (Float) (defaults to: 0.8)

    fraction of token_limit (0.0–1.0) at which to warn

Returns:

  • (Boolean)

    true when usage >= threshold * limit; false when no limit set



111
112
113
114
115
# File 'lib/agent_harness/conversation.rb', line 111

def approaching_limit?(threshold: 0.8)
  return false unless @token_limit

  token_count >= (threshold * @token_limit)
end

#clear!void

This method returns an undefined value.

Remove all messages except the system prompt.



226
227
228
229
# File 'lib/agent_harness/conversation.rb', line 226

def clear!
  system_message = initial_system_message
  @messages = system_message ? [system_message] : []
end

#last_assistant_messageHash?

Returns the most recent assistant message, or nil.

Returns:

  • (Hash, nil)


216
217
218
219
220
221
# File 'lib/agent_harness/conversation.rb', line 216

def last_assistant_message
  @messages.reverse_each do |msg|
    return deep_copy(msg) if msg[:role] == :assistant
  end
  nil
end

#message_countInteger

Returns the number of messages in the conversation.

Returns:

  • (Integer)

    the number of messages in the conversation



82
83
84
# File 'lib/agent_harness/conversation.rb', line 82

def message_count
  @messages.size
end

#messagesArray<Hash>

Returns the full message history.

Returns:

  • (Array<Hash>)

    all messages in chronological order



77
78
79
# File 'lib/agent_harness/conversation.rb', line 77

def messages
  deep_copy(@messages)
end

#to_anthropic_messagesHash

Format messages for the Anthropic Messages API.

The system prompt is returned separately; tool results are wrapped as content blocks inside user messages per Anthropic’s schema.

Returns:

  • (Hash)

    :system [String, nil] and :messages [Array<Hash>]



155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
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
211
# File 'lib/agent_harness/conversation.rb', line 155

def to_anthropic_messages
  system_prompt = initial_system_message&.dig(:content)
  result_messages = []

  start_index = system_prompt ? 1 : 0
  @messages.drop(start_index).each do |msg|
    case msg[:role]
    when :user
      result_messages << {
        role: "user",
        content: [{type: "text", text: msg[:content]}]
      }
    when :assistant
      content_blocks = []
      content_blocks << {type: "text", text: msg[:content]} if msg[:content]

      msg[:tool_calls]&.each do |tc|
        arguments = tool_call_arguments(tc)
        parsed_arguments = if arguments.is_a?(String)
          begin
            JSON.parse(arguments)
          rescue JSON::ParserError
            arguments
          end
        else
          arguments
        end

        content_blocks << {
          type: "tool_use",
          id: tool_call_value(tc, :id),
          name: tool_call_name(tc),
          input: parsed_arguments
        }
      end

      result_messages << {role: "assistant", content: content_blocks}
    when :tool
      tool_result_block = {
        type: "tool_result",
        tool_use_id: msg[:tool_call_id],
        content: msg[:content]
      }
      prev = result_messages.last
      if prev && prev[:role] == "user" && prev[:content]&.first&.dig(:type) == "tool_result"
        prev[:content] << tool_result_block
      else
        result_messages << {
          role: "user",
          content: [tool_result_block]
        }
      end
    end
  end

  {system: system_prompt, messages: result_messages}
end

#to_openai_messagesArray<Hash>

Format messages for OpenAI-compatible chat completions APIs.

Returns:

  • (Array<Hash>)

    messages with string roles and content



145
146
147
# File 'lib/agent_harness/conversation.rb', line 145

def to_openai_messages
  @messages.map { |msg| openai_format(msg) }
end

#token_countInteger

Sum of all tracked tokens (input + output) across messages.

Returns:

  • (Integer)

    total tokens consumed



89
90
91
92
93
94
95
96
# File 'lib/agent_harness/conversation.rb', line 89

def token_count
  @messages.sum do |msg|
    tokens = msg[:tokens]
    next 0 unless tokens

    (tokens[:input] || 0) + (tokens[:output] || 0)
  end
end

#token_remainingInteger?

Tokens remaining before hitting the limit.

Returns:

  • (Integer, nil)

    remaining tokens, or nil when no limit is set



101
102
103
104
105
# File 'lib/agent_harness/conversation.rb', line 101

def token_remaining
  return nil unless @token_limit

  @token_limit - token_count
end

#truncate(keep_recent: nil, keep_system_prompt: true) ⇒ Integer

Remove oldest non-system messages to free context window.

keep_recent counts conversational turns, not individual messages. A turn is anchored by a user message and includes any following assistant/tool messages up to the next user message.

Parameters:

  • keep_recent (Integer, nil) (defaults to: nil)

    minimum number of recent turns to preserve

  • keep_system_prompt (Boolean) (defaults to: true)

    whether to preserve the system prompt

Returns:

  • (Integer)

    number of messages removed



126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
# File 'lib/agent_harness/conversation.rb', line 126

def truncate(keep_recent: nil, keep_system_prompt: true)
  original_size = @messages.size
  system_message = initial_system_message
  system_messages = (keep_system_prompt && system_message) ? [system_message] : []
  non_system = system_message ? @messages.drop(1) : @messages

  kept = if keep_recent
    recent_turns(non_system, keep_recent).flatten
  else
    non_system
  end

  @messages = system_messages + kept
  original_size - @messages.size
end