dexter_llm 0.1.2

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 (73) hide show
  1. checksums.yaml +7 -0
  2. data/LICENSE +21 -0
  3. data/README.md +1246 -0
  4. data/lib/dexter_llm/adapters/anthropic.rb +513 -0
  5. data/lib/dexter_llm/adapters/base.rb +61 -0
  6. data/lib/dexter_llm/adapters/google.rb +392 -0
  7. data/lib/dexter_llm/adapters/openai.rb +415 -0
  8. data/lib/dexter_llm/agent/agent.rb +277 -0
  9. data/lib/dexter_llm/agent/agent_busy_error.rb +9 -0
  10. data/lib/dexter_llm/agent/console.rb +525 -0
  11. data/lib/dexter_llm/agent/error.rb +5 -0
  12. data/lib/dexter_llm/agent/event.rb +27 -0
  13. data/lib/dexter_llm/agent/loop.rb +256 -0
  14. data/lib/dexter_llm/agent/max_iterations_error.rb +9 -0
  15. data/lib/dexter_llm/agent/session.rb +271 -0
  16. data/lib/dexter_llm/agent/state.rb +75 -0
  17. data/lib/dexter_llm/api.rb +9 -0
  18. data/lib/dexter_llm/api_error.rb +55 -0
  19. data/lib/dexter_llm/assistant_message.rb +47 -0
  20. data/lib/dexter_llm/authentication_error.rb +5 -0
  21. data/lib/dexter_llm/built_in_tool.rb +68 -0
  22. data/lib/dexter_llm/built_in_tools/web_fetch.rb +92 -0
  23. data/lib/dexter_llm/built_in_tools/web_search.rb +84 -0
  24. data/lib/dexter_llm/cancellation_signal.rb +31 -0
  25. data/lib/dexter_llm/cancelled_error.rb +12 -0
  26. data/lib/dexter_llm/client.rb +410 -0
  27. data/lib/dexter_llm/configuration.rb +119 -0
  28. data/lib/dexter_llm/content.rb +338 -0
  29. data/lib/dexter_llm/context_overflow_error.rb +5 -0
  30. data/lib/dexter_llm/documents/ingestor.rb +107 -0
  31. data/lib/dexter_llm/documents/store.rb +46 -0
  32. data/lib/dexter_llm/documents/stored_document.rb +27 -0
  33. data/lib/dexter_llm/documents/stores/file_system.rb +131 -0
  34. data/lib/dexter_llm/error.rb +5 -0
  35. data/lib/dexter_llm/instrumentation.rb +11 -0
  36. data/lib/dexter_llm/invalid_request_error.rb +5 -0
  37. data/lib/dexter_llm/message.rb +30 -0
  38. data/lib/dexter_llm/message_transformer.rb +90 -0
  39. data/lib/dexter_llm/model.rb +52 -0
  40. data/lib/dexter_llm/models/catalog.yml +324 -0
  41. data/lib/dexter_llm/models.rb +99 -0
  42. data/lib/dexter_llm/pricing.rb +46 -0
  43. data/lib/dexter_llm/prompt/materializer.rb +121 -0
  44. data/lib/dexter_llm/provider.rb +9 -0
  45. data/lib/dexter_llm/rate_limit_error.rb +5 -0
  46. data/lib/dexter_llm/retry_policy.rb +25 -0
  47. data/lib/dexter_llm/schema/builder.rb +258 -0
  48. data/lib/dexter_llm/schema/coercer.rb +159 -0
  49. data/lib/dexter_llm/schema/validator.rb +212 -0
  50. data/lib/dexter_llm/schema.rb +66 -0
  51. data/lib/dexter_llm/session/compaction.rb +216 -0
  52. data/lib/dexter_llm/session/compaction_settings.rb +17 -0
  53. data/lib/dexter_llm/session/entry.rb +589 -0
  54. data/lib/dexter_llm/session/error.rb +10 -0
  55. data/lib/dexter_llm/session/loaded_session.rb +18 -0
  56. data/lib/dexter_llm/session/manager.rb +181 -0
  57. data/lib/dexter_llm/session/store.rb +17 -0
  58. data/lib/dexter_llm/session/stores/jsonl_file.rb +99 -0
  59. data/lib/dexter_llm/stop_reason.rb +11 -0
  60. data/lib/dexter_llm/stream_event.rb +225 -0
  61. data/lib/dexter_llm/streaming/events.rb +7 -0
  62. data/lib/dexter_llm/streaming/sse_parser.rb +69 -0
  63. data/lib/dexter_llm/summary_message.rb +27 -0
  64. data/lib/dexter_llm/thinking_level.rb +31 -0
  65. data/lib/dexter_llm/token_estimator.rb +58 -0
  66. data/lib/dexter_llm/tool.rb +208 -0
  67. data/lib/dexter_llm/tool_result_message.rb +32 -0
  68. data/lib/dexter_llm/unsupported_content_error.rb +5 -0
  69. data/lib/dexter_llm/usage.rb +107 -0
  70. data/lib/dexter_llm/user_message.rb +23 -0
  71. data/lib/dexter_llm/version.rb +5 -0
  72. data/lib/dexter_llm.rb +103 -0
  73. metadata +158 -0
@@ -0,0 +1,256 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "async"
4
+
5
+ module DexterLlm::Agent
6
+ class Loop
7
+ def initialize(state:, client:, session_id:, run_id:, signal: nil, steering_queue: nil, emit_event: nil)
8
+ @state = state
9
+ @client = client
10
+ @session_id = session_id
11
+ @run_id = run_id
12
+ @signal = signal
13
+ @steering_queue = steering_queue
14
+ @emit_event = emit_event
15
+ @emit_mutex = Mutex.new
16
+ @turn = 0
17
+ end
18
+
19
+ def run(prompt, attachments: [])
20
+ @signal&.throw_if_cancelled!
21
+
22
+ user_content = build_user_content(prompt, attachments)
23
+ user_message = DexterLlm::UserMessage.new(user_content)
24
+
25
+ state = @state.add_message(user_message)
26
+ emit(:user_message, user_message.to_h)
27
+
28
+ loop do
29
+ @signal&.throw_if_cancelled!
30
+ raise MaxIterationsError.new(state.max_iterations) if state.max_iterations_reached?
31
+
32
+ @turn += 1
33
+ emit(:turn_start, { turn: @turn })
34
+
35
+ state, assistant_message = run_turn(state)
36
+ emit(:turn_end, { turn: @turn, stop_reason: assistant_message.stop_reason.to_s })
37
+
38
+ break unless should_continue?(assistant_message)
39
+ end
40
+
41
+ [ state, state.last_message ]
42
+ end
43
+
44
+ private
45
+
46
+ def build_user_content(prompt, attachments)
47
+ content = [ DexterLlm::Content::Text.new(prompt) ]
48
+ attachments.each { |att| content << att }
49
+ content
50
+ end
51
+
52
+ def run_turn(state)
53
+ assistant_message = stream_completion(state)
54
+ new_state = state.add_message(assistant_message)
55
+
56
+ # Execute tool calls if present
57
+ if assistant_message.has_tool_calls?
58
+ new_state = execute_tool_calls(new_state, assistant_message.tool_calls)
59
+ end
60
+
61
+ [ new_state, assistant_message ]
62
+ end
63
+
64
+ def execute_tool_calls(state, tool_calls)
65
+ # Filter out built-in tools (executed server-side)
66
+ user_tool_calls = tool_calls.reject do |tool_call|
67
+ tool = state.tool_by_name(tool_call.name)
68
+ tool&.respond_to?(:built_in?) && tool.built_in?
69
+ end
70
+
71
+ return state if user_tool_calls.empty?
72
+
73
+ # Collect results keyed by tool call ID to preserve ordering
74
+ result_messages = {}
75
+ results_mutex = Mutex.new
76
+ steered = false
77
+
78
+ # Execute all tools in parallel using fibers
79
+ Sync do
80
+ user_tool_calls.each do |tool_call|
81
+ # Check for steering message before starting next tool
82
+ if @steering_queue && !@steering_queue.empty?
83
+ steered = true
84
+ break
85
+ end
86
+
87
+ Async do
88
+ @signal&.throw_if_cancelled!
89
+
90
+ # Emit start event
91
+ emit(:tool_call_start, {
92
+ id: tool_call.id,
93
+ name: tool_call.name,
94
+ arguments: tool_call.arguments
95
+ })
96
+
97
+ # Execute tool
98
+ result_message = execute_single_tool(state, tool_call)
99
+
100
+ # Store result keyed by tool call ID (thread-safe)
101
+ results_mutex.synchronize do
102
+ result_messages[tool_call.id] = result_message if result_message
103
+ end
104
+
105
+ # Emit end event
106
+ if result_message
107
+ emit(:tool_call_end, {
108
+ id: tool_call.id,
109
+ name: tool_call.name,
110
+ is_error: result_message.is_error,
111
+ result: result_message.content.map { |c| c.respond_to?(:text) ? c.text : c.to_s }.join
112
+ })
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ # Add completed results to state in original tool call order
119
+ user_tool_calls.each do |tool_call|
120
+ result_message = result_messages[tool_call.id]
121
+ state = state.add_message(result_message) if result_message
122
+ end
123
+
124
+ # If steered, add synthetic error results for skipped tools and inject steering message
125
+ if steered
126
+ skipped_calls = user_tool_calls.reject { |tc| result_messages.key?(tc.id) }
127
+ skipped_calls.each do |tool_call|
128
+ state = state.add_message(
129
+ DexterLlm::ToolResultMessage.new(
130
+ tool_call_id: tool_call.id,
131
+ tool_name: tool_call.name,
132
+ content: "Tool execution skipped: user sent a steering message",
133
+ is_error: true
134
+ )
135
+ )
136
+ end
137
+
138
+ # Inject steering message as a user message
139
+ steering_text = @steering_queue.pop(true) rescue nil
140
+ if steering_text
141
+ user_message = DexterLlm::UserMessage.new([ DexterLlm::Content::Text.new(steering_text) ])
142
+ state = state.add_message(user_message)
143
+ emit(:user_message, user_message.to_h)
144
+ end
145
+ end
146
+
147
+ state
148
+ end
149
+
150
+ def execute_single_tool(state, tool_call)
151
+ tool = state.tool_by_name(tool_call.name)
152
+
153
+ unless tool
154
+ return DexterLlm::ToolResultMessage.new(
155
+ tool_call_id: tool_call.id,
156
+ tool_name: tool_call.name,
157
+ content: "Unknown tool: #{tool_call.name}",
158
+ is_error: true
159
+ )
160
+ end
161
+
162
+ # Built-in tools are executed server-side by the provider.
163
+ # We should not attempt to execute them locally.
164
+ if tool.respond_to?(:built_in?) && tool.built_in?
165
+ return nil
166
+ end
167
+
168
+ begin
169
+ result = tool.call(tool_call.arguments)
170
+ result_content = result.is_a?(String) ? result : result.to_s
171
+
172
+ DexterLlm::ToolResultMessage.new(
173
+ tool_call_id: tool_call.id,
174
+ tool_name: tool_call.name,
175
+ content: result_content,
176
+ is_error: false
177
+ )
178
+ rescue DexterLlm::ToolExecutionError => e
179
+ DexterLlm::ToolResultMessage.new(
180
+ tool_call_id: tool_call.id,
181
+ tool_name: tool_call.name,
182
+ content: e.message,
183
+ is_error: true
184
+ )
185
+ rescue StandardError => e
186
+ DexterLlm::ToolResultMessage.new(
187
+ tool_call_id: tool_call.id,
188
+ tool_name: tool_call.name,
189
+ content: "Error: #{e.class}: #{e.message}",
190
+ is_error: true
191
+ )
192
+ end
193
+ end
194
+
195
+ def stream_completion(state)
196
+ @signal&.throw_if_cancelled!
197
+
198
+ stream = @client.stream(
199
+ model: state.model,
200
+ messages: state.messages,
201
+ system_prompt: state.system_prompt,
202
+ tools: state.tools,
203
+ thinking_config: state.thinking_config
204
+ )
205
+
206
+ final_message = nil
207
+
208
+ stream.each do |event|
209
+ @signal&.throw_if_cancelled!
210
+ emit(:stream_event, event.to_h)
211
+
212
+ case event
213
+ when DexterLlm::StreamEvent::Done
214
+ final_message = event.message
215
+ when DexterLlm::StreamEvent::Error
216
+ raise event.error
217
+ end
218
+ end
219
+
220
+ raise DexterLlm::Error, "Stream completed without final message" if final_message.nil?
221
+
222
+ emit(:assistant_message, final_message.to_h)
223
+ final_message
224
+ end
225
+
226
+ def should_continue?(assistant_message)
227
+ # Continue if there were tool calls (agent loop continues until no more tools)
228
+ return true if assistant_message.has_tool_calls?
229
+ return true if assistant_message.stop_reason == DexterLlm::StopReason::TOOL_USE
230
+
231
+ # Stop on terminal reasons
232
+ return false if assistant_message.stop_reason == DexterLlm::StopReason::STOP
233
+ return false if assistant_message.stop_reason == DexterLlm::StopReason::LENGTH
234
+ return false if assistant_message.stop_reason == DexterLlm::StopReason::ERROR
235
+ return false if assistant_message.stop_reason == DexterLlm::StopReason::ABORTED
236
+
237
+ false
238
+ end
239
+
240
+ def emit(type, data)
241
+ return unless @emit_event
242
+
243
+ event = Event.new(
244
+ session_id: @session_id,
245
+ run_id: @run_id,
246
+ turn: @turn,
247
+ type: type.to_s,
248
+ data: data
249
+ )
250
+
251
+ @emit_mutex.synchronize do
252
+ @emit_event.call(event)
253
+ end
254
+ end
255
+ end
256
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DexterLlm::Agent
4
+ class MaxIterationsError < Error
5
+ def initialize(max_iterations)
6
+ super("Agent exceeded maximum iterations (#{max_iterations})")
7
+ end
8
+ end
9
+ end
@@ -0,0 +1,271 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DexterLlm::Agent
4
+ class Session
5
+ attr_reader :agent, :session_manager, :compaction_settings
6
+
7
+ def initialize(agent:, session_manager:, compaction_settings: nil, client: nil)
8
+ @agent = agent
9
+ @session_manager = session_manager
10
+ @compaction_settings = compaction_settings || DexterLlm::Session::CompactionSettings.new
11
+ @client = client
12
+ @event_listeners = []
13
+ @last_assistant_message = nil
14
+ @retry_pending = false
15
+
16
+ # Subscribe to agent events for auto-persistence
17
+ @agent.on_event { |event| handle_agent_event(event) }
18
+ end
19
+
20
+ def session_id
21
+ @session_manager.session_id
22
+ end
23
+
24
+ def prompt(text, attachments: [])
25
+ if @retry_pending
26
+ @retry_pending = false
27
+ return @agent.prompt(text, attachments:)
28
+ end
29
+
30
+ begin
31
+ @agent.prompt(text, attachments:)
32
+ rescue DexterLlm::ContextOverflowError
33
+ handle_overflow_compaction
34
+ @agent.prompt(text, attachments:)
35
+ end
36
+ end
37
+
38
+ def abort(reason = "User aborted")
39
+ @agent.abort(reason)
40
+ end
41
+
42
+ def running?
43
+ @agent.running?
44
+ end
45
+
46
+ def messages
47
+ @agent.messages
48
+ end
49
+
50
+ def model
51
+ @agent.model
52
+ end
53
+
54
+ def reset
55
+ @agent.reset
56
+ end
57
+
58
+ def steer(text)
59
+ @agent.steer(text)
60
+ end
61
+
62
+ def follow_up(text)
63
+ @agent.follow_up(text)
64
+ end
65
+
66
+ def clear_steering_queue
67
+ @agent.clear_steering_queue
68
+ end
69
+
70
+ def clear_follow_up_queue
71
+ @agent.clear_follow_up_queue
72
+ end
73
+
74
+ def on_event(&handler)
75
+ @event_listeners << handler
76
+ -> { @event_listeners.delete(handler) }
77
+ end
78
+
79
+ def load
80
+ loaded = @session_manager.load
81
+ return loaded if loaded.empty?
82
+
83
+ # Replace agent state with loaded messages
84
+ new_state = State.new(
85
+ model: @agent.state.model,
86
+ messages: loaded.messages,
87
+ system_prompt: @agent.state.system_prompt,
88
+ tools: @agent.state.tools,
89
+ max_iterations: @agent.state.max_iterations
90
+ )
91
+
92
+ @agent.instance_variable_set(:@state, new_state)
93
+ @session_manager.instance_variable_set(:@session_initialized, true)
94
+
95
+ loaded
96
+ end
97
+
98
+ def compact(custom_instructions: nil)
99
+ # Ensure agent is not running
100
+ raise Agent::AgentBusyError.new if @agent.running?
101
+
102
+ entries = @session_manager.load_entries
103
+ return nil if entries.empty?
104
+
105
+ compaction_entry = DexterLlm::Session::Compaction.compact(
106
+ entries: entries,
107
+ model: @agent.model,
108
+ client: client,
109
+ settings: @compaction_settings,
110
+ custom_instructions: custom_instructions
111
+ )
112
+
113
+ @session_manager.save_compaction(compaction_entry)
114
+
115
+ # Reload session with compacted state
116
+ loaded = @session_manager.load
117
+
118
+ # Replace agent state
119
+ new_state = State.new(
120
+ model: @agent.state.model,
121
+ messages: loaded.messages,
122
+ system_prompt: @agent.state.system_prompt,
123
+ tools: @agent.state.tools,
124
+ max_iterations: @agent.state.max_iterations
125
+ )
126
+
127
+ @agent.instance_variable_set(:@state, new_state)
128
+
129
+ {
130
+ tokens_before: compaction_entry.tokens_before,
131
+ summary: compaction_entry.summary
132
+ }
133
+ end
134
+
135
+ private
136
+
137
+ def client
138
+ @client ||= @agent.instance_variable_get(:@client) || DexterLlm::Client.new
139
+ end
140
+
141
+ def handle_agent_event(event)
142
+ emit(event)
143
+
144
+ case event.type
145
+ when "user_message"
146
+ ensure_session_started!
147
+ save_message_from_event(event.data)
148
+
149
+ when "assistant_message"
150
+ ensure_session_started!
151
+ @last_assistant_message = create_assistant_message_from_event(event.data)
152
+ save_message_from_event(event.data)
153
+
154
+ # Initialize session after first exchange
155
+ if @session_manager.should_initialize_session?(@agent.messages)
156
+ @session_manager.start_session(@agent.state)
157
+ end
158
+
159
+ when "tool_call_end"
160
+ ensure_session_started!
161
+ # Tool results are emitted as tool_call_end events, need to construct message
162
+ tool_result = DexterLlm::ToolResultMessage.new(
163
+ tool_call_id: event.data["id"] || event.data[:id],
164
+ tool_name: event.data["name"] || event.data[:name],
165
+ content: event.data["result"] || event.data[:result],
166
+ is_error: event.data["is_error"] || event.data[:is_error] || false
167
+ )
168
+ @session_manager.save_message(tool_result)
169
+
170
+ when "turn_end"
171
+ # Check for auto-compaction after turn completes
172
+ check_auto_compaction if @last_assistant_message
173
+ @last_assistant_message = nil
174
+ end
175
+ end
176
+
177
+ def save_message_from_event(data)
178
+ # data is already a hash from message.to_h, reconstruct the message
179
+ message = DexterLlm::Session::Entry::Message.send(:deserialize_message, data)
180
+ @session_manager.save_message(message) if message
181
+ end
182
+
183
+ def ensure_session_started!
184
+ return if @session_manager.session_initialized?
185
+
186
+ @session_manager.start_session(@agent.state)
187
+ end
188
+
189
+ def create_assistant_message_from_event(data)
190
+ DexterLlm::Session::Entry::Message.send(:deserialize_message, data)
191
+ end
192
+
193
+ def check_auto_compaction
194
+ return unless @compaction_settings.enabled
195
+ return unless @last_assistant_message
196
+
197
+ message = @last_assistant_message
198
+ return if message.stop_reason == :aborted
199
+
200
+ context_window = @agent.model.respond_to?(:context_window) ? @agent.model.context_window : 128_000
201
+
202
+ # Case 1: Check for context overflow
203
+ if context_overflow?(message)
204
+ handle_overflow_compaction
205
+ return
206
+ end
207
+
208
+ # Case 2: Threshold exceeded - compact (no retry)
209
+ return if message.stop_reason == :error
210
+
211
+ estimated_prompt_tokens = DexterLlm::Session::Compaction.estimate_prompt_tokens(@agent.messages)
212
+ usage_prompt_tokens = DexterLlm::Session::Compaction.calculate_prompt_tokens_from_usage(message.usage)
213
+ prompt_tokens = [ estimated_prompt_tokens, usage_prompt_tokens ].max
214
+
215
+ if DexterLlm::Session::Compaction.should_compact?(prompt_tokens, context_window, @compaction_settings)
216
+ run_auto_compaction(reason: :threshold, will_retry: false)
217
+ end
218
+ end
219
+
220
+ def context_overflow?(message)
221
+ return false unless message.stop_reason == :error
222
+
223
+ text = message.respond_to?(:text) ? message.text : ""
224
+ DexterLlm::ApiError.context_overflow_body?(text)
225
+ end
226
+
227
+ def handle_overflow_compaction
228
+ # Remove the error message from agent state
229
+ current_messages = @agent.messages[0..-2]
230
+ new_state = State.new(
231
+ model: @agent.state.model,
232
+ messages: current_messages,
233
+ system_prompt: @agent.state.system_prompt,
234
+ tools: @agent.state.tools,
235
+ max_iterations: @agent.state.max_iterations
236
+ )
237
+ @agent.instance_variable_set(:@state, new_state)
238
+
239
+ run_auto_compaction(reason: :overflow, will_retry: true)
240
+ end
241
+
242
+ def run_auto_compaction(reason:, will_retry:)
243
+ emit_compaction_event(:auto_compaction_start, { reason: reason })
244
+
245
+ begin
246
+ result = compact
247
+ emit_compaction_event(:auto_compaction_end, { result: result, will_retry: will_retry })
248
+
249
+ @retry_pending = will_retry
250
+ rescue StandardError => e
251
+ emit_compaction_event(:auto_compaction_end, { result: nil, error: e.message })
252
+ raise if reason == :overflow
253
+ end
254
+ end
255
+
256
+ def emit_compaction_event(type, data)
257
+ event = Event.new(
258
+ session_id: session_id,
259
+ run_id: "compaction",
260
+ turn: 0,
261
+ type: type.to_s,
262
+ data: data
263
+ )
264
+ emit(event)
265
+ end
266
+
267
+ def emit(event)
268
+ @event_listeners.each { |l| l.call(event) }
269
+ end
270
+ end
271
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DexterLlm::Agent
4
+ class State
5
+ DEFAULT_MAX_ITERATIONS = 50
6
+
7
+ attr_reader :messages, :model, :system_prompt, :tools, :max_iterations, :thinking_config
8
+
9
+ def initialize(model:, messages: [], system_prompt: nil, tools: [], max_iterations: DEFAULT_MAX_ITERATIONS, thinking_config: nil)
10
+ @messages = messages.freeze
11
+ @model = model
12
+ @system_prompt = system_prompt
13
+ @tools = tools.freeze
14
+ @max_iterations = max_iterations
15
+ @thinking_config = thinking_config
16
+ end
17
+
18
+ def add_message(message)
19
+ State.new(
20
+ model:,
21
+ messages: messages + [ message ],
22
+ system_prompt:,
23
+ tools:,
24
+ max_iterations:,
25
+ thinking_config:
26
+ )
27
+ end
28
+
29
+ def with_model(new_model)
30
+ State.new(
31
+ model: new_model,
32
+ messages:,
33
+ system_prompt:,
34
+ tools:,
35
+ max_iterations:,
36
+ thinking_config:
37
+ )
38
+ end
39
+
40
+ def with_system_prompt(new_prompt)
41
+ State.new(
42
+ model:,
43
+ messages:,
44
+ system_prompt: new_prompt,
45
+ tools:,
46
+ max_iterations:,
47
+ thinking_config:
48
+ )
49
+ end
50
+
51
+ def tool_by_name(name)
52
+ tools.find { |t| t.respond_to?(:name) && t.name.to_s == name.to_s }
53
+ end
54
+
55
+ def iteration_count
56
+ messages.count { |m| m.role == :assistant }
57
+ end
58
+
59
+ def max_iterations_reached?
60
+ iteration_count >= max_iterations
61
+ end
62
+
63
+ def last_message
64
+ messages.last
65
+ end
66
+
67
+ def last_assistant_message
68
+ messages.reverse_each.find { |m| m.role == :assistant }
69
+ end
70
+
71
+ def empty?
72
+ messages.empty?
73
+ end
74
+ end
75
+ end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DexterLlm
4
+ module Api
5
+ ANTHROPIC_MESSAGES = :anthropic_messages
6
+ OPENAI_RESPONSES = :openai_responses
7
+ GOOGLE_GENERATIVE = :google_generative
8
+ end
9
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DexterLlm
4
+ class ApiError < Error
5
+ def initialize(message = nil, status: nil, body: nil)
6
+ @status = status&.to_i
7
+ @body = body
8
+ super(message)
9
+ end
10
+
11
+ attr_reader :status, :body
12
+
13
+ CONTEXT_OVERFLOW_PATTERNS = [
14
+ /prompt is too long/i,
15
+ /exceeds? the (model's )?maximum context/i,
16
+ /context.length.exceeded/i,
17
+ /maximum.context.length/i,
18
+ /token limit/i,
19
+ /input is too long/i,
20
+ /request too large/i,
21
+ /exceeds? [\d,]+ tokens?/i,
22
+ /content.too.large/i,
23
+ /max_tokens.*must be less/i,
24
+ /input.*exceeds.*limit/i,
25
+ /context window/i
26
+ ].freeze
27
+
28
+ def self.for_status(status, message = nil, body: nil)
29
+ status = status&.to_i
30
+
31
+ klass =
32
+ case status
33
+ when 400
34
+ context_overflow_body?(body) ? ContextOverflowError : InvalidRequestError
35
+ when 401, 403
36
+ AuthenticationError
37
+ when 413
38
+ ContextOverflowError
39
+ when 429
40
+ RateLimitError
41
+ else
42
+ ApiError
43
+ end
44
+
45
+ klass.new(message, status: status, body: body)
46
+ end
47
+
48
+ def self.context_overflow_body?(body)
49
+ return false if body.nil? || body.to_s.empty?
50
+
51
+ text = body.to_s
52
+ CONTEXT_OVERFLOW_PATTERNS.any? { |pattern| text.match?(pattern) }
53
+ end
54
+ end
55
+ end