llm_gateway 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +12 -0
  3. data/README.md +255 -1
  4. data/docs/migration_guide_0.7.0.md +193 -0
  5. data/lib/llm_gateway/adapters/adapter.rb +1 -1
  6. data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +24 -0
  7. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +31 -8
  8. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +1 -1
  9. data/lib/llm_gateway/adapters/groq/option_mapper.rb +1 -1
  10. data/lib/llm_gateway/adapters/input_message_sanitizer.rb +98 -7
  11. data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +48 -16
  12. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +1 -1
  13. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +47 -31
  14. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +1 -1
  15. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +131 -3
  16. data/lib/llm_gateway/adapters/structs.rb +45 -10
  17. data/lib/llm_gateway/agents/event.rb +105 -0
  18. data/lib/llm_gateway/agents/file_session_manager.rb +100 -0
  19. data/lib/llm_gateway/agents/harness.rb +176 -0
  20. data/lib/llm_gateway/agents/in_memory_session_manager.rb +222 -0
  21. data/lib/llm_gateway/agents/tools/bash_tool.rb +132 -0
  22. data/lib/llm_gateway/agents/tools/edit_tool.rb +215 -0
  23. data/lib/llm_gateway/agents/tools/read_tool.rb +143 -0
  24. data/lib/llm_gateway/agents/tools/tool_utils.rb +164 -0
  25. data/lib/llm_gateway/agents/tools/write_tool.rb +34 -0
  26. data/lib/llm_gateway/base_client.rb +3 -3
  27. data/lib/llm_gateway/clients/anthropic.rb +5 -5
  28. data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +2 -2
  29. data/lib/llm_gateway/clients/openai.rb +2 -2
  30. data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +4 -4
  31. data/lib/llm_gateway/prompt.rb +105 -68
  32. data/lib/llm_gateway/utils.rb +116 -13
  33. data/lib/llm_gateway/version.rb +1 -1
  34. data/lib/llm_gateway.rb +4 -0
  35. metadata +12 -2
@@ -52,17 +52,43 @@ class ToolCall < BaseStruct
52
52
  end
53
53
  end
54
54
 
55
+ class ServerToolCall < ToolCall
56
+ attribute :type, Types::String.enum("server_tool_use")
57
+ end
58
+
55
59
  class ToolResult < BaseStruct
56
- attribute :type, Types::String.enum("tool_result")
60
+ attribute :type, Types::String
57
61
  attribute :tool_use_id, Types::String
58
- attribute :content, Types::String
62
+ attribute :content, Types::Any
63
+
64
+ def to_h
65
+ {
66
+ type: type,
67
+ tool_use_id: tool_use_id,
68
+ content: content
69
+ }
70
+ end
71
+ end
72
+
73
+ class ServerToolResult < ToolResult
74
+ attribute :type, Types::String.enum("server_tool_result")
75
+ attribute? :name, Types::String.optional
76
+
77
+ def to_h
78
+ result = super
79
+ result[:name] = name unless name.nil?
80
+ result
81
+ end
59
82
  end
60
83
 
61
84
  class PartialAssistantMessage < BaseStruct
62
85
  ContentBlock =
63
86
  Types.Instance(TextContent) |
64
87
  Types.Instance(ReasoningContent) |
65
- Types.Instance(ToolCall)
88
+ Types.Instance(ToolCall) |
89
+ Types.Instance(ServerToolCall) |
90
+ Types.Instance(ToolResult) |
91
+ Types.Instance(ServerToolResult)
66
92
 
67
93
  attribute? :id, Types::String.optional
68
94
  attribute? :model, Types::String.optional
@@ -78,7 +104,7 @@ class PartialAssistantMessage < BaseStruct
78
104
  end
79
105
 
80
106
  def self.build_content_block(block)
81
- return block if block.is_a?(TextContent) || block.is_a?(ReasoningContent) || block.is_a?(ToolCall)
107
+ return block if block.is_a?(TextContent) || block.is_a?(ReasoningContent) || block.is_a?(ToolCall) || block.is_a?(ServerToolCall) || block.is_a?(ToolResult) || block.is_a?(ServerToolResult)
82
108
 
83
109
  case block[:type] || block["type"]
84
110
  when "text"
@@ -86,14 +112,16 @@ class PartialAssistantMessage < BaseStruct
86
112
  when "reasoning"
87
113
  ReasoningContent.new(block)
88
114
  when "thinking"
89
- ReasoningContent.new(
90
- type: "reasoning",
91
- reasoning: block[:thinking] || block["thinking"] || block[:reasoning] || block["reasoning"],
92
- signature: block[:signature] || block["signature"]
93
- )
115
+ ReasoningContent.new(type: "reasoning", reasoning: block[:thinking] || block["thinking"] || block[:reasoning] || block["reasoning"], signature: block[:signature] || block["signature"])
94
116
  when "tool_use"
95
117
  ToolCall.new(block)
118
+ when "server_tool_use"
119
+ ServerToolCall.new(block)
96
120
  else
121
+ type = block[:type] || block["type"]
122
+ return ServerToolResult.new(block) if type == "server_tool_result"
123
+ return ToolResult.new(block) if type&.end_with?("_tool_result")
124
+
97
125
  raise ArgumentError, "Unsupported content block type: #{block[:type] || block['type']}"
98
126
  end
99
127
  end
@@ -102,7 +130,7 @@ class PartialAssistantMessage < BaseStruct
102
130
  end
103
131
 
104
132
  class AssistantStreamEvent < BaseStruct
105
- EventType = Types::Coercible::Symbol.enum(:text_start, :text_delta, :text_end, :tool_start, :tool_delta, :tool_end, :reasoning_start, :reasoning_delta, :reasoning_end)
133
+ EventType = Types::Coercible::Symbol.enum(:text_start, :text_delta, :text_end, :tool_start, :tool_delta, :tool_end, :tool_result_start, :tool_result_delta, :tool_result_end, :reasoning_start, :reasoning_delta, :reasoning_end)
106
134
 
107
135
  attribute :type, EventType
108
136
  attribute :delta, Types::Coercible::String.default { "" }
@@ -144,6 +172,13 @@ end
144
172
  class AssistantToolStartEvent < AssistantStreamEvent
145
173
  attribute :id, Types::String
146
174
  attribute :name, Types::String
175
+ attribute :tool_type, Types::String.default("tool_use".freeze).enum("tool_use", "server_tool_use")
176
+ attribute :content_index, Types::Integer
177
+ end
178
+
179
+ class AssistantToolResultStartEvent < AssistantStreamEvent
180
+ attribute :tool_use_id, Types::String
181
+ attribute :name, Types::String
147
182
  attribute :content_index, Types::Integer
148
183
  end
149
184
 
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "../adapters/structs"
4
+
5
+ module LlmGateway
6
+ module Agents
7
+ module Event
8
+ AgentEventType = Types::Coercible::Symbol.enum(
9
+ :agent_start,
10
+ :turn_start,
11
+ :message_start,
12
+ :message_update,
13
+ :message_end,
14
+ :tool_execution_start,
15
+ :tool_execution_end,
16
+ :turn_end,
17
+ :agent_end
18
+ )
19
+
20
+ StreamEvent =
21
+ Types.Instance(AssistantStreamEvent) |
22
+ Types.Instance(AssistantStreamMessageEvent) |
23
+ Types.Instance(AssistantStreamMessageEndEvent)
24
+
25
+ ToolParameters = Types::Hash.schema(
26
+ id: Types::String,
27
+ type: Types::String.enum("tool_use"),
28
+ name: Types::String,
29
+ input: Types::Hash
30
+ )
31
+
32
+ class ToolCallResult < ::BaseStruct
33
+ attribute :type, Types::Coercible::Symbol.enum(:tool_result)
34
+ attribute :tool_use_id, Types::String
35
+ attribute :content, Types::Any
36
+
37
+ def to_h
38
+ {
39
+ type: type.to_s,
40
+ tool_use_id: tool_use_id,
41
+ content: content
42
+ }
43
+ end
44
+
45
+ def dig(*keys)
46
+ to_h.dig(*keys)
47
+ end
48
+ end
49
+
50
+ class Base < ::BaseStruct
51
+ attribute :type, AgentEventType
52
+
53
+ def to_h
54
+ {
55
+ type: type
56
+ }
57
+ end
58
+ end
59
+
60
+ class AgentStart < Base
61
+ attribute :type, Types::Coercible::Symbol.default(:agent_start).enum(:agent_start)
62
+ end
63
+
64
+ class TurnStart < Base
65
+ attribute :type, Types::Coercible::Symbol.default(:turn_start).enum(:turn_start)
66
+ end
67
+
68
+ class MessageStart < Base
69
+ attribute :type, Types::Coercible::Symbol.default(:message_start).enum(:message_start)
70
+ end
71
+
72
+ class MessageUpdate < Base
73
+ attribute :type, Types::Coercible::Symbol.default(:message_update).enum(:message_update)
74
+ attribute :stream_event, StreamEvent
75
+ end
76
+
77
+ class MessageEnd < Base
78
+ attribute :type, Types::Coercible::Symbol.default(:message_end).enum(:message_end)
79
+ attribute :message, Types.Instance(AssistantMessage)
80
+ end
81
+
82
+ class ToolExecutionStart < Base
83
+ attribute :type, Types::Coercible::Symbol.default(:tool_execution_start).enum(:tool_execution_start)
84
+ attribute :parameters, ToolParameters
85
+ end
86
+
87
+ class ToolExecutionEnd < Base
88
+ attribute :type, Types::Coercible::Symbol.default(:tool_execution_end).enum(:tool_execution_end)
89
+ attribute :parameters, ToolParameters
90
+ attribute :result, ToolCallResult
91
+ end
92
+
93
+ class TurnEnd < Base
94
+ attribute :type, Types::Coercible::Symbol.default(:turn_end).enum(:turn_end)
95
+ attribute :message, Types.Instance(AssistantMessage)
96
+ attribute :tool_results, Types::Array.of(Types.Instance(::ToolResult))
97
+ end
98
+
99
+ class AgentEnd < Base
100
+ attribute :type, Types::Coercible::Symbol.default(:agent_end).enum(:agent_end)
101
+ attribute :messages, Types::Array.of(Types::Hash)
102
+ end
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "json"
5
+ require "securerandom"
6
+ require "time"
7
+ require_relative "in_memory_session_manager"
8
+
9
+ module LlmGateway
10
+ module Agents
11
+ class FileSessionManager < InMemorySessionManager
12
+ attr_reader :file_name, :session_path
13
+
14
+ def initialize(file_name = nil, session_id: nil, session_start: nil, session_dir: nil)
15
+ super(session_id)
16
+ @file_name = file_name
17
+ @preset_session_start = session_start
18
+ @session_dir = session_dir
19
+ end
20
+
21
+ def session_id
22
+ events
23
+ @session_id
24
+ end
25
+
26
+ def session_start
27
+ events
28
+ @session_start
29
+ end
30
+
31
+ def normalize_path(file_name)
32
+ File.expand_path(file_name)
33
+ end
34
+
35
+ def events
36
+ @events ||= begin
37
+ @session_path = normalize_path(file_name) if file_name
38
+ if @session_path && File.exist?(@session_path)
39
+ load_session(@session_path)
40
+ else
41
+ create_new_session
42
+ end
43
+ end
44
+ end
45
+
46
+ private
47
+
48
+ def create_new_session
49
+ @session_id ||= SecureRandom.uuid
50
+ @session_start = @preset_session_start || Time.now.strftime("%Y%m%d_%H%M%S")
51
+
52
+ session_event = {
53
+ type: "session",
54
+ id: @session_id,
55
+ timestamp: @session_start
56
+ }
57
+
58
+ @session_path ||= File.join(session_dir, "#{@session_start}_#{@session_id}.jsonl")
59
+ FileUtils.mkdir_p(File.dirname(@session_path))
60
+ File.open(@session_path, "a") do |file|
61
+ file.puts(JSON.generate(session_event))
62
+ end
63
+
64
+ [ session_event ]
65
+ end
66
+
67
+ def load_session(path)
68
+ loaded_events = []
69
+ File.foreach(path).with_index(1) do |line, line_number|
70
+ next if line.strip.empty?
71
+
72
+ loaded_events << JSON.parse(line, symbolize_names: true)
73
+ rescue JSON::ParserError => e
74
+ raise ArgumentError, "Invalid JSONL in #{path} at line #{line_number}: #{e.message}"
75
+ end
76
+
77
+ session_event = loaded_events.find { |event| event[:type] == "session" }
78
+ @session_id = session_event[:id] if session_event&.dig(:id)
79
+ @session_start = session_event[:timestamp] if session_event&.dig(:timestamp)
80
+
81
+ loaded_events
82
+ end
83
+
84
+ def persist_entry(entry)
85
+ attributes = super
86
+
87
+ FileUtils.mkdir_p(File.dirname(session_path))
88
+ File.open(session_path, "a") do |file|
89
+ file.puts(JSON.generate(entry))
90
+ end
91
+
92
+ attributes
93
+ end
94
+
95
+ def session_dir
96
+ File.expand_path(@session_dir || ENV.fetch("LLM_GATEWAY_SESSION_DIR", "~/.llm_gateway/sessions"))
97
+ end
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "event"
4
+ require_relative "../utils"
5
+
6
+ module LlmGateway
7
+ module Agents
8
+ class Harness < LlmGateway::Prompt
9
+ COMPACTION_TOKEN_THRESHOLD = 180_000
10
+ COMPACTION_IDLE_THRESHOLD_SECONDS = 60 * 60
11
+ attr_accessor :provider
12
+ attr_reader :session_manager, :default_queue_mode, :queue_drain_mode,
13
+ :model, :reasoning
14
+
15
+ def initialize(session_manager, provider:, model: nil, reasoning: "high")
16
+ @provider = provider
17
+ super(provider: provider, model: model, reasoning: reasoning)
18
+ @session_manager = session_manager
19
+ sync_initial_configuration_events
20
+ self.default_queue_mode = :next_turn
21
+ self.queue_drain_mode = :all
22
+ end
23
+
24
+ def transcript
25
+ session_manager.build_model_input_messages
26
+ end
27
+ alias :prompt :transcript
28
+
29
+ def prompt_message(message, &block)
30
+ enqueue_or_run_message(message, default_queue_mode, &block)
31
+ end
32
+
33
+ def steer_message(message, &block)
34
+ enqueue_or_run_message(message, :steer, &block)
35
+ end
36
+
37
+ def follow_up_message(message, &block)
38
+ enqueue_or_run_message(message, :follow_up, &block)
39
+ end
40
+
41
+ def next_turn_message(message, &block)
42
+ enqueue_or_run_message(message, :next_turn, &block)
43
+ end
44
+
45
+ def default_queue_mode=(mode)
46
+ @default_queue_mode = session_manager.validate_queue!(mode)
47
+ end
48
+
49
+ def queue_drain_mode=(mode)
50
+ @queue_drain_mode = session_manager.validate_drain_mode!(mode)
51
+ end
52
+
53
+ def model=(model_id)
54
+ return @model if @model == model_id
55
+
56
+ @model = model_id
57
+ publish_session_event(type: "model_change", model_id: model_id)
58
+ @model
59
+ end
60
+
61
+ def reasoning=(level)
62
+ return @reasoning if @reasoning == level
63
+
64
+ @reasoning = level
65
+ publish_session_event(type: "reasoning_change", reasoning: level)
66
+ @reasoning
67
+ end
68
+
69
+ def compact
70
+ session_manager.compaction(provider)
71
+ end
72
+
73
+ def run(&block)
74
+ emit(Event::AgentStart.new, &block)
75
+ drain_queue(:steer)
76
+ emit(Event::TurnStart.new, &block)
77
+ emit(Event::MessageStart.new, &block)
78
+
79
+ assistant_message = stream do |event|
80
+ emit(Event::MessageUpdate.new(stream_event: event), &block)
81
+ end
82
+
83
+ session_manager.push_message(assistant_message.to_h)
84
+ emit(Event::MessageEnd.new(message: assistant_message), &block)
85
+
86
+ tool_results = tool_requests(assistant_message).map do |message|
87
+ parameters = message.to_h
88
+ emit(Event::ToolExecutionStart.new(parameters: parameters), &block)
89
+ tool_result = find_and_execute_tool(message)
90
+ emit(Event::ToolExecutionEnd.new(parameters: parameters, result: tool_result), &block)
91
+ tool_result
92
+ end
93
+
94
+ tool_result_content = tool_results.map(&:to_h)
95
+ session_manager.push_message(
96
+ role: "user",
97
+ content: tool_result_content,
98
+ ) unless tool_result_content.empty?
99
+
100
+ turn_end_event = Event::TurnEnd.new(message: assistant_message, tool_results: tool_results)
101
+ emit(turn_end_event, &block)
102
+
103
+ if tool_results.length.positive?
104
+ return run(&block)
105
+ end
106
+
107
+ if session_manager.queued_messages?(:follow_up)
108
+ compact_if_needed
109
+ return run(&block) if drain_queue(:follow_up).any?
110
+ end
111
+
112
+ emit(Event::AgentEnd.new(messages: []), &block)
113
+ assistant_message
114
+ end
115
+ alias :continue :run
116
+
117
+ private
118
+
119
+ def publish_session_event(type:, **attributes)
120
+ session_manager.push_entry(type: type, **attributes)
121
+ end
122
+
123
+ def sync_initial_configuration_events
124
+ publish_session_event(type: "model_change", model_id: model) if model && !session_manager.last_model_used
125
+ if reasoning && !session_manager.last_reasoning_level_used
126
+ publish_session_event(type: "reasoning_change", reasoning: reasoning)
127
+ end
128
+ end
129
+
130
+ def enqueue_or_run_message(message, queue, &block)
131
+ prepared_input = LlmGateway::Utils.deep_symbolize_keys(message)
132
+ result = session_manager.start_or_enqueue_user_message(prepared_input, queue: queue) do
133
+ compact_if_needed
134
+ end
135
+ return if result == session_manager.class::MESSAGE_QUEUED
136
+
137
+ begin
138
+ continue(&block)
139
+
140
+ loop do
141
+ break unless session_manager.queued_messages?(:next_turn)
142
+
143
+ compact_if_needed
144
+ drain_queue(:next_turn)
145
+ continue(&block)
146
+ end
147
+ ensure
148
+ session_manager.idle!
149
+ end
150
+ end
151
+
152
+ def compact_if_needed
153
+ compact if compaction_needed?
154
+ end
155
+
156
+ def compaction_needed?
157
+ session_manager.total_tokens > COMPACTION_TOKEN_THRESHOLD || last_assistant_message_stale?
158
+ end
159
+
160
+ def last_assistant_message_stale?
161
+ last_assistant_message_at = session_manager.last_assistant_message_at
162
+ last_assistant_message_at && Time.now - last_assistant_message_at > COMPACTION_IDLE_THRESHOLD_SECONDS
163
+ end
164
+
165
+ def drain_queue(queue)
166
+ session_manager.drain_message_queue(queue, mode: queue_drain_mode)
167
+ end
168
+
169
+ def emit(event, &block)
170
+ return unless block
171
+
172
+ block.call(event)
173
+ end
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,222 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module LlmGateway
7
+ module Agents
8
+ class InMemorySessionManager
9
+ MESSAGE_QUEUED = :queued
10
+ MESSAGE_STARTED = :started
11
+ QUEUES = [ :steer, :follow_up, :next_turn ].freeze
12
+ DRAIN_MODES = [ :one_at_a_time, :all ].freeze
13
+
14
+ attr_reader :session_id, :session_start
15
+
16
+ def initialize(session_id = nil)
17
+ @state = :idle
18
+ @session_id = session_id
19
+ @message_queues = Hash.new { |hash, key| hash[key] = [] }
20
+ end
21
+
22
+ def busy!
23
+ @state = :busy
24
+ end
25
+
26
+ def idle!
27
+ @state = :idle
28
+ end
29
+
30
+ def drain_message_queue(queue = :next_turn, mode: :all)
31
+ messages = queued_messages(queue, mode)
32
+ messages.each { |message| push_message(message) }
33
+ messages
34
+ end
35
+
36
+ def queued_messages?(queue = :next_turn)
37
+ @message_queues[validate_queue!(queue)].any?
38
+ end
39
+
40
+ def push_message_to_queue(message, queue = :next_turn)
41
+ @message_queues[validate_queue!(queue)] << message
42
+ end
43
+
44
+ def busy?
45
+ @state == :busy
46
+ end
47
+
48
+ def validate_queue!(queue)
49
+ queue = queue.to_sym
50
+ raise ArgumentError, "Invalid queue mode: #{queue}" unless QUEUES.include?(queue)
51
+
52
+ queue
53
+ end
54
+
55
+ def validate_drain_mode!(mode)
56
+ mode = mode.to_sym
57
+ raise ArgumentError, "Invalid queue drain mode: #{mode}" unless DRAIN_MODES.include?(mode)
58
+
59
+ mode
60
+ end
61
+
62
+ def start_or_enqueue_user_message(payload, queue: :next_turn)
63
+ if busy?
64
+ push_message_to_queue(payload, queue)
65
+ MESSAGE_QUEUED
66
+ else
67
+ yield if block_given?
68
+ push_message(payload)
69
+ busy!
70
+ MESSAGE_STARTED
71
+ end
72
+ end
73
+
74
+ def push_message(payload)
75
+ payload = payload.deep_symbolize_keys
76
+
77
+ push_entry(
78
+ type: "message",
79
+ usage: message_usage(payload),
80
+ data: payload,
81
+ )
82
+ end
83
+
84
+ def push_entry(entry)
85
+ id = SecureRandom.uuid
86
+ new_entry = {
87
+ id: id,
88
+ parent_id: parent_id_for_new_entry,
89
+ timestamp: Time.now.iso8601,
90
+ **entry
91
+ }
92
+
93
+ persist_entry(new_entry)
94
+ new_entry
95
+ end
96
+
97
+ def active_messages
98
+ active_message_events.map { |event| event[:data] }
99
+ end
100
+
101
+ def last_message_id
102
+ message_events.last&.dig(:id)
103
+ end
104
+
105
+ def last_model_used
106
+ events.reverse.find { |event| event[:type] == "model_change" }&.dig(:model_id)
107
+ end
108
+
109
+ def last_reasoning_level_used
110
+ events.reverse.find { |event| event[:type] == "reasoning_change" }&.dig(:reasoning)
111
+ end
112
+
113
+ def events_until(event_id)
114
+ index = events.index { |event| event[:id] == event_id }
115
+ raise ArgumentError, "Event not found in session: #{event_id}" unless index
116
+
117
+ events[0..index]
118
+ end
119
+
120
+ def events
121
+ @events ||= [ new_session_event ]
122
+ end
123
+
124
+ def build_model_input_messages
125
+ return active_messages unless last_compaction_entry
126
+
127
+ [ last_compaction_entry[:data], *active_messages ]
128
+ end
129
+
130
+ def total_tokens
131
+ entry = active_message_events.reverse.find { |event| event.dig(:usage, :total_tokens) }
132
+ entry&.dig(:usage, :total_tokens) || 0
133
+ end
134
+
135
+ def last_assistant_message_at
136
+ entry = active_message_events.reverse.find { |event| event.dig(:data, :role) == "assistant" }
137
+ Time.parse(entry[:timestamp]) if entry
138
+ end
139
+
140
+ def compaction(adapter)
141
+ response = adapter.stream(
142
+ active_messages,
143
+ system: "Summarize the conversation so far for future context.",
144
+ tools: []
145
+ )
146
+ message = response.to_h
147
+
148
+ push_entry(
149
+ type: "compaction",
150
+ usage: message_usage(message),
151
+ data: message
152
+ )
153
+ end
154
+
155
+ private
156
+
157
+ def queued_messages(queue, mode)
158
+ queue = validate_queue!(queue)
159
+ case validate_drain_mode!(mode)
160
+ when :one_at_a_time
161
+ message = @message_queues[queue].shift
162
+ message ? [ message ] : []
163
+ when :all
164
+ @message_queues[queue].shift(@message_queues[queue].length)
165
+ end
166
+ end
167
+
168
+ def parent_id_for_new_entry
169
+ events.length.positive? ? events.last[:id] : nil
170
+ end
171
+
172
+ def message_events
173
+ events.select { |event| event[:type] == "message" }
174
+ end
175
+
176
+ def active_message_events
177
+ compaction_event = last_compaction_entry
178
+ return message_events unless compaction_event
179
+
180
+ compaction_index = events.index(compaction_event)
181
+ events[(compaction_index + 1)..].select { |event| event[:type] == "message" }
182
+ end
183
+
184
+ def last_compaction_entry
185
+ events.reverse.find { |event| event[:type] == "compaction" }
186
+ end
187
+
188
+ def message_usage(message)
189
+ usage = message[:usage] || message["usage"]
190
+ return {} unless usage
191
+
192
+ usage.transform_keys(&:to_sym)
193
+ end
194
+
195
+ def persist_entry(entry)
196
+ attributes = {
197
+ session_id: @session_id,
198
+ position: next_position,
199
+ id: entry[:id],
200
+ parent_id: entry[:parent_id],
201
+ timestamp: entry[:timestamp],
202
+ type: entry[:type],
203
+ usage: entry[:usage],
204
+ data: entry[:data]
205
+ }
206
+
207
+ events << entry
208
+ attributes
209
+ end
210
+
211
+ def next_position
212
+ events.length
213
+ end
214
+
215
+ def new_session_event
216
+ @session_id ||= SecureRandom.uuid
217
+ @session_start = Time.now.strftime("%Y%m%d_%H%M%S")
218
+ { type: "session", id: session_id, timestamp: session_start }
219
+ end
220
+ end
221
+ end
222
+ end