llm_gateway 0.5.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 (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +38 -0
  3. data/README.md +350 -43
  4. data/docs/migration_guide_0.6.0.md +386 -0
  5. data/docs/migration_guide_0.7.0.md +193 -0
  6. data/lib/llm_gateway/adapters/adapter.rb +8 -11
  7. data/lib/llm_gateway/adapters/anthropic/input_mapper.rb +24 -0
  8. data/lib/llm_gateway/adapters/anthropic/stream_mapper.rb +61 -11
  9. data/lib/llm_gateway/adapters/anthropic_option_mapper.rb +1 -1
  10. data/lib/llm_gateway/adapters/groq/option_mapper.rb +1 -1
  11. data/lib/llm_gateway/adapters/input_message_sanitizer.rb +98 -7
  12. data/lib/llm_gateway/adapters/normalized_stream_accumulator.rb +132 -39
  13. data/lib/llm_gateway/adapters/openai/chat_completions/option_mapper.rb +1 -1
  14. data/lib/llm_gateway/adapters/openai/chat_completions/stream_mapper.rb +40 -16
  15. data/lib/llm_gateway/adapters/openai/responses/input_mapper.rb +47 -31
  16. data/lib/llm_gateway/adapters/openai/responses/option_mapper.rb +1 -1
  17. data/lib/llm_gateway/adapters/openai/responses/stream_mapper.rb +173 -24
  18. data/lib/llm_gateway/adapters/stream_mapper.rb +9 -2
  19. data/lib/llm_gateway/adapters/structs.rb +140 -55
  20. data/lib/llm_gateway/agents/event.rb +105 -0
  21. data/lib/llm_gateway/agents/file_session_manager.rb +100 -0
  22. data/lib/llm_gateway/agents/harness.rb +176 -0
  23. data/lib/llm_gateway/agents/in_memory_session_manager.rb +222 -0
  24. data/lib/llm_gateway/agents/tools/bash_tool.rb +132 -0
  25. data/lib/llm_gateway/agents/tools/edit_tool.rb +215 -0
  26. data/lib/llm_gateway/agents/tools/read_tool.rb +143 -0
  27. data/lib/llm_gateway/agents/tools/tool_utils.rb +164 -0
  28. data/lib/llm_gateway/agents/tools/write_tool.rb +34 -0
  29. data/lib/llm_gateway/base_client.rb +5 -7
  30. data/lib/llm_gateway/clients/anthropic.rb +10 -9
  31. data/lib/llm_gateway/clients/claude_code/oauth_flow.rb +2 -2
  32. data/lib/llm_gateway/clients/groq.rb +8 -6
  33. data/lib/llm_gateway/clients/openai.rb +22 -20
  34. data/lib/llm_gateway/clients/openai_codex/oauth_flow.rb +4 -4
  35. data/lib/llm_gateway/prompt.rb +107 -52
  36. data/lib/llm_gateway/utils.rb +116 -13
  37. data/lib/llm_gateway/version.rb +1 -1
  38. data/lib/llm_gateway.rb +7 -21
  39. metadata +13 -2
@@ -9,35 +9,6 @@ class BaseStruct < Dry::Struct
9
9
  transform_keys(&:to_sym)
10
10
  end
11
11
 
12
- class AssistantStreamEvent < BaseStruct
13
- EventType = Types::Coercible::Symbol.enum(:text_start, :text_delta, :text_end, :tool_start, :tool_delta, :tool_end, :reasoning_start, :reasoning_delta, :reasoning_end)
14
-
15
- attribute :type, EventType
16
- attribute :delta, Types::Coercible::String.default { "" }
17
- attribute :content_index, Types::Integer
18
- end
19
-
20
-
21
- class AssistantToolStartEvent < AssistantStreamEvent
22
- attribute :id, Types::String
23
- attribute :name, Types::String
24
- attribute :content_index, Types::Integer
25
- end
26
-
27
-
28
- class AssistantStreamReasoningEvent < AssistantStreamEvent
29
- attribute :signature, Types::Coercible::String.default { "" }
30
- attribute :content_index, Types::Integer
31
- end
32
-
33
- class AssistantStreamMessageEvent < BaseStruct
34
- EventType = Types::Coercible::Symbol.enum(:message_start, :message_delta, :message_end)
35
-
36
- attribute :type, EventType
37
- attribute :delta, Types::Coercible::Hash.default { {} }
38
- attribute :usage_increment, Types::Coercible::Hash.default { {} }
39
- end
40
-
41
12
  class TextContent < BaseStruct
42
13
  attribute :type, Types::String.enum("text")
43
14
  attribute :text, Types::String
@@ -81,18 +52,142 @@ class ToolCall < BaseStruct
81
52
  end
82
53
  end
83
54
 
55
+ class ServerToolCall < ToolCall
56
+ attribute :type, Types::String.enum("server_tool_use")
57
+ end
58
+
84
59
  class ToolResult < BaseStruct
85
- attribute :type, Types::String.enum("tool_result")
60
+ attribute :type, Types::String
86
61
  attribute :tool_use_id, Types::String
87
- 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
88
71
  end
89
72
 
90
- class AssistantMessage < BaseStruct
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
82
+ end
83
+
84
+ class PartialAssistantMessage < BaseStruct
91
85
  ContentBlock =
92
86
  Types.Instance(TextContent) |
93
87
  Types.Instance(ReasoningContent) |
94
- Types.Instance(ToolCall)
88
+ Types.Instance(ToolCall) |
89
+ Types.Instance(ServerToolCall) |
90
+ Types.Instance(ToolResult) |
91
+ Types.Instance(ServerToolResult)
92
+
93
+ attribute? :id, Types::String.optional
94
+ attribute? :model, Types::String.optional
95
+ attribute? :role, Types::String.enum("assistant").optional
96
+ attribute :timestamp, Types::Integer
97
+ attribute? :stop_reason, Types::String.enum("stop", "length", "tool_use", "toolUse", "error", "aborted").optional
98
+ attribute? :content, Types::Array.of(ContentBlock).optional
99
+
100
+ def self.new(attributes = {})
101
+ attrs = attributes.to_h.transform_keys(&:to_sym)
102
+ attrs[:content] = Array(attrs[:content]).map { |block| build_content_block(block) } if attrs.key?(:content)
103
+ super(attrs)
104
+ end
105
+
106
+ def self.build_content_block(block)
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)
108
+
109
+ case block[:type] || block["type"]
110
+ when "text"
111
+ TextContent.new(block)
112
+ when "reasoning"
113
+ ReasoningContent.new(block)
114
+ when "thinking"
115
+ ReasoningContent.new(type: "reasoning", reasoning: block[:thinking] || block["thinking"] || block[:reasoning] || block["reasoning"], signature: block[:signature] || block["signature"])
116
+ when "tool_use"
117
+ ToolCall.new(block)
118
+ when "server_tool_use"
119
+ ServerToolCall.new(block)
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
+
125
+ raise ArgumentError, "Unsupported content block type: #{block[:type] || block['type']}"
126
+ end
127
+ end
128
+
129
+ private_class_method :build_content_block
130
+ end
131
+
132
+ class AssistantStreamEvent < BaseStruct
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)
134
+
135
+ attribute :type, EventType
136
+ attribute :delta, Types::Coercible::String.default { "" }
137
+ attribute :content_index, Types::Integer
138
+ attribute :partial, Types.Instance(PartialAssistantMessage)
139
+
140
+ def content
141
+ case type
142
+ when :text_end
143
+ finalized_content_block&.text
144
+ when :reasoning_end
145
+ finalized_content_block&.reasoning
146
+ when :tool_end
147
+ finalized_content_block
148
+ end
149
+ end
150
+
151
+ def text
152
+ content if type == :text_end
153
+ end
154
+
155
+ def reasoning
156
+ content if type == :reasoning_end
157
+ end
158
+
159
+ def tool_call
160
+ finalized_content_block if type == :tool_end
161
+ end
95
162
 
163
+ alias tool tool_call
164
+
165
+ private
166
+
167
+ def finalized_content_block
168
+ partial.content&.[](content_index)
169
+ end
170
+ end
171
+
172
+ class AssistantToolStartEvent < AssistantStreamEvent
173
+ attribute :id, Types::String
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
182
+ attribute :content_index, Types::Integer
183
+ end
184
+
185
+ class AssistantStreamReasoningEvent < AssistantStreamEvent
186
+ attribute :signature, Types::Coercible::String.default { "" }
187
+ attribute :content_index, Types::Integer
188
+ end
189
+
190
+ class AssistantMessage < PartialAssistantMessage
96
191
  attribute :id, Types::String
97
192
  attribute :model, Types::String
98
193
  attribute :usage, Types::Hash
@@ -103,12 +198,6 @@ class AssistantMessage < BaseStruct
103
198
  attribute? :error_message, Types::String.optional
104
199
  attribute :content, Types::Array.of(ContentBlock)
105
200
 
106
- def self.new(attributes)
107
- attrs = attributes.to_h.transform_keys(&:to_sym)
108
- attrs[:content] = Array(attrs[:content]).map { |block| build_content_block(block) }
109
- super(attrs)
110
- end
111
-
112
201
  def to_h
113
202
  result = {
114
203
  id: id,
@@ -120,26 +209,22 @@ class AssistantMessage < BaseStruct
120
209
  api: api,
121
210
  content: content.map(&:to_h)
122
211
  }
212
+ result[:timestamp] = timestamp unless timestamp.nil?
123
213
  result[:error_message] = error_message unless error_message.nil?
124
214
  result
125
215
  end
216
+ end
126
217
 
127
- def self.build_content_block(block)
128
- return block if block.is_a?(TextContent) || block.is_a?(ReasoningContent) || block.is_a?(ToolCall)
218
+ class AssistantStreamMessageEvent < BaseStruct
219
+ EventType = Types::Coercible::Symbol.enum(:message_start, :message_delta)
129
220
 
130
- case block[:type] || block["type"]
131
- when "text"
132
- TextContent.new(block)
133
- when "reasoning"
134
- ReasoningContent.new(block)
135
- when "thinking"
136
- ReasoningContent.new(type: "reasoning", reasoning: block[:thinking] || block["thinking"] || block[:reasoning] || block["reasoning"], signature: block[:signature] || block["signature"])
137
- when "tool_use"
138
- ToolCall.new(block)
139
- else
140
- raise ArgumentError, "Unsupported content block type: #{block[:type] || block['type']}"
141
- end
142
- end
221
+ attribute :type, EventType
222
+ attribute :delta, Types::Coercible::Hash.default { {} }
223
+ attribute :usage, Types::Coercible::Hash.default { {} }
224
+ attribute :partial, Types.Instance(PartialAssistantMessage)
225
+ end
143
226
 
144
- private_class_method :build_content_block
227
+ class AssistantStreamMessageEndEvent < BaseStruct
228
+ attribute :type, Types::Coercible::Symbol.enum(:message_end)
229
+ attribute :message, Types.Instance(AssistantMessage)
145
230
  end
@@ -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