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.
- checksums.yaml +7 -0
- data/LICENSE +21 -0
- data/README.md +1246 -0
- data/lib/dexter_llm/adapters/anthropic.rb +513 -0
- data/lib/dexter_llm/adapters/base.rb +61 -0
- data/lib/dexter_llm/adapters/google.rb +392 -0
- data/lib/dexter_llm/adapters/openai.rb +415 -0
- data/lib/dexter_llm/agent/agent.rb +277 -0
- data/lib/dexter_llm/agent/agent_busy_error.rb +9 -0
- data/lib/dexter_llm/agent/console.rb +525 -0
- data/lib/dexter_llm/agent/error.rb +5 -0
- data/lib/dexter_llm/agent/event.rb +27 -0
- data/lib/dexter_llm/agent/loop.rb +256 -0
- data/lib/dexter_llm/agent/max_iterations_error.rb +9 -0
- data/lib/dexter_llm/agent/session.rb +271 -0
- data/lib/dexter_llm/agent/state.rb +75 -0
- data/lib/dexter_llm/api.rb +9 -0
- data/lib/dexter_llm/api_error.rb +55 -0
- data/lib/dexter_llm/assistant_message.rb +47 -0
- data/lib/dexter_llm/authentication_error.rb +5 -0
- data/lib/dexter_llm/built_in_tool.rb +68 -0
- data/lib/dexter_llm/built_in_tools/web_fetch.rb +92 -0
- data/lib/dexter_llm/built_in_tools/web_search.rb +84 -0
- data/lib/dexter_llm/cancellation_signal.rb +31 -0
- data/lib/dexter_llm/cancelled_error.rb +12 -0
- data/lib/dexter_llm/client.rb +410 -0
- data/lib/dexter_llm/configuration.rb +119 -0
- data/lib/dexter_llm/content.rb +338 -0
- data/lib/dexter_llm/context_overflow_error.rb +5 -0
- data/lib/dexter_llm/documents/ingestor.rb +107 -0
- data/lib/dexter_llm/documents/store.rb +46 -0
- data/lib/dexter_llm/documents/stored_document.rb +27 -0
- data/lib/dexter_llm/documents/stores/file_system.rb +131 -0
- data/lib/dexter_llm/error.rb +5 -0
- data/lib/dexter_llm/instrumentation.rb +11 -0
- data/lib/dexter_llm/invalid_request_error.rb +5 -0
- data/lib/dexter_llm/message.rb +30 -0
- data/lib/dexter_llm/message_transformer.rb +90 -0
- data/lib/dexter_llm/model.rb +52 -0
- data/lib/dexter_llm/models/catalog.yml +324 -0
- data/lib/dexter_llm/models.rb +99 -0
- data/lib/dexter_llm/pricing.rb +46 -0
- data/lib/dexter_llm/prompt/materializer.rb +121 -0
- data/lib/dexter_llm/provider.rb +9 -0
- data/lib/dexter_llm/rate_limit_error.rb +5 -0
- data/lib/dexter_llm/retry_policy.rb +25 -0
- data/lib/dexter_llm/schema/builder.rb +258 -0
- data/lib/dexter_llm/schema/coercer.rb +159 -0
- data/lib/dexter_llm/schema/validator.rb +212 -0
- data/lib/dexter_llm/schema.rb +66 -0
- data/lib/dexter_llm/session/compaction.rb +216 -0
- data/lib/dexter_llm/session/compaction_settings.rb +17 -0
- data/lib/dexter_llm/session/entry.rb +589 -0
- data/lib/dexter_llm/session/error.rb +10 -0
- data/lib/dexter_llm/session/loaded_session.rb +18 -0
- data/lib/dexter_llm/session/manager.rb +181 -0
- data/lib/dexter_llm/session/store.rb +17 -0
- data/lib/dexter_llm/session/stores/jsonl_file.rb +99 -0
- data/lib/dexter_llm/stop_reason.rb +11 -0
- data/lib/dexter_llm/stream_event.rb +225 -0
- data/lib/dexter_llm/streaming/events.rb +7 -0
- data/lib/dexter_llm/streaming/sse_parser.rb +69 -0
- data/lib/dexter_llm/summary_message.rb +27 -0
- data/lib/dexter_llm/thinking_level.rb +31 -0
- data/lib/dexter_llm/token_estimator.rb +58 -0
- data/lib/dexter_llm/tool.rb +208 -0
- data/lib/dexter_llm/tool_result_message.rb +32 -0
- data/lib/dexter_llm/unsupported_content_error.rb +5 -0
- data/lib/dexter_llm/usage.rb +107 -0
- data/lib/dexter_llm/user_message.rb +23 -0
- data/lib/dexter_llm/version.rb +5 -0
- data/lib/dexter_llm.rb +103 -0
- 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,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,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
|