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,415 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "base"
|
|
4
|
+
|
|
5
|
+
module DexterLlm
|
|
6
|
+
module Adapters
|
|
7
|
+
class OpenAI < Base
|
|
8
|
+
DEFAULT_BASE_URL = "https://api.openai.com".freeze
|
|
9
|
+
|
|
10
|
+
def initialize(api_key:, base_url: nil, open_timeout: nil, read_timeout: nil, write_timeout: nil)
|
|
11
|
+
super(
|
|
12
|
+
api_key: api_key,
|
|
13
|
+
base_url: base_url || DEFAULT_BASE_URL,
|
|
14
|
+
open_timeout: open_timeout,
|
|
15
|
+
read_timeout: read_timeout,
|
|
16
|
+
write_timeout: write_timeout
|
|
17
|
+
)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def stream(model:, messages:, system_prompt: nil, tools: [], max_tokens: nil, thinking_config: nil)
|
|
21
|
+
Enumerator.new do |yielder|
|
|
22
|
+
body = build_request_body(
|
|
23
|
+
model: model,
|
|
24
|
+
messages: messages,
|
|
25
|
+
system_prompt: system_prompt,
|
|
26
|
+
tools: tools,
|
|
27
|
+
max_tokens: max_tokens,
|
|
28
|
+
thinking_config: thinking_config
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
state = StreamState.new(model_id: model.id)
|
|
32
|
+
|
|
33
|
+
stream_request("/v1/responses", body) do |event_type, data|
|
|
34
|
+
state.ensure_started!(yielder)
|
|
35
|
+
state.handle_event!(yielder, event_type, data)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def complete(model:, messages:, system_prompt: nil, tools: [], max_tokens: nil)
|
|
41
|
+
final = nil
|
|
42
|
+
|
|
43
|
+
stream(model: model, messages: messages, system_prompt: system_prompt, tools: tools, max_tokens: max_tokens).each do |event|
|
|
44
|
+
case event
|
|
45
|
+
when StreamEvent::Done
|
|
46
|
+
final = event.message
|
|
47
|
+
when StreamEvent::Error
|
|
48
|
+
raise event.error
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
final
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
protected
|
|
56
|
+
|
|
57
|
+
def build_request_body(model:, messages:, system_prompt:, tools:, max_tokens:, thinking_config: nil)
|
|
58
|
+
body = {
|
|
59
|
+
model: model.id,
|
|
60
|
+
input: convert_messages(messages),
|
|
61
|
+
stream: true
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
body[:instructions] = system_prompt if system_prompt
|
|
65
|
+
|
|
66
|
+
resolved_max = max_tokens || (model.respond_to?(:max_tokens) ? model.max_tokens : nil)
|
|
67
|
+
body[:max_output_tokens] = resolved_max if resolved_max
|
|
68
|
+
|
|
69
|
+
body[:tools] = convert_tools(tools) if tools.any?
|
|
70
|
+
|
|
71
|
+
apply_reasoning_config!(body, thinking_config)
|
|
72
|
+
|
|
73
|
+
body
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def apply_reasoning_config!(body, thinking_config)
|
|
77
|
+
level = thinking_config&.dig(:level)
|
|
78
|
+
return if level.nil? || level == ThinkingLevel::OFF
|
|
79
|
+
|
|
80
|
+
body[:reasoning] = { effort: openai_reasoning_effort(level) }
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def openai_reasoning_effort(level)
|
|
84
|
+
case level
|
|
85
|
+
when ThinkingLevel::MINIMAL then "low"
|
|
86
|
+
when ThinkingLevel::LOW then "low"
|
|
87
|
+
when ThinkingLevel::MEDIUM then "medium"
|
|
88
|
+
when ThinkingLevel::HIGH then "high"
|
|
89
|
+
else "medium"
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def convert_messages(messages)
|
|
94
|
+
items = []
|
|
95
|
+
|
|
96
|
+
Array(messages).each do |msg|
|
|
97
|
+
case msg
|
|
98
|
+
when UserMessage
|
|
99
|
+
items << { role: "user", content: convert_user_content(msg.content) }
|
|
100
|
+
when AssistantMessage
|
|
101
|
+
items << { role: "assistant", content: convert_assistant_content(msg.content) }
|
|
102
|
+
|
|
103
|
+
msg.content
|
|
104
|
+
.select { |c| c.is_a?(Content::ToolCall) }
|
|
105
|
+
.each do |tc|
|
|
106
|
+
items << {
|
|
107
|
+
type: "function_call",
|
|
108
|
+
call_id: tc.id,
|
|
109
|
+
name: tc.name,
|
|
110
|
+
arguments: JSON.generate(tc.arguments)
|
|
111
|
+
}
|
|
112
|
+
end
|
|
113
|
+
when ToolResultMessage
|
|
114
|
+
items << {
|
|
115
|
+
type: "function_call_output",
|
|
116
|
+
call_id: msg.tool_call_id,
|
|
117
|
+
output: extract_text_content(msg.content)
|
|
118
|
+
}
|
|
119
|
+
else
|
|
120
|
+
items << { role: "user", content: [ { type: "input_text", text: msg.to_s } ] }
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
items
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def convert_user_content(content)
|
|
128
|
+
Array(content).map do |block|
|
|
129
|
+
case block
|
|
130
|
+
when Content::Text
|
|
131
|
+
{ type: "input_text", text: block.text }
|
|
132
|
+
when Content::Image
|
|
133
|
+
{ type: "input_image", image_url: "data:#{block.mime_type};base64,#{block.data}" }
|
|
134
|
+
when Content::Document
|
|
135
|
+
convert_document(block)
|
|
136
|
+
else
|
|
137
|
+
raise UnsupportedContentError, "Unsupported content block: #{block.class}"
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def convert_document(block)
|
|
143
|
+
case block.source
|
|
144
|
+
when Content::DocumentSource::Url
|
|
145
|
+
{ type: "input_file", file_url: block.source.url }
|
|
146
|
+
when Content::DocumentSource::ProviderFile
|
|
147
|
+
{ type: "input_file", file_id: block.source.file_id }
|
|
148
|
+
when Content::DocumentSource::Base64
|
|
149
|
+
{ type: "input_file", file_data: block.source.data }
|
|
150
|
+
else
|
|
151
|
+
raise UnsupportedContentError, "Unsupported document source: #{block.source.class}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def convert_assistant_content(content)
|
|
156
|
+
text = Array(content)
|
|
157
|
+
.select { |c| c.is_a?(Content::Text) }
|
|
158
|
+
.map(&:text)
|
|
159
|
+
.join
|
|
160
|
+
|
|
161
|
+
text.empty? ? [] : [ { type: "output_text", text: text } ]
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def extract_text_content(content)
|
|
165
|
+
Array(content)
|
|
166
|
+
.select { |c| c.is_a?(Content::Text) }
|
|
167
|
+
.map(&:text)
|
|
168
|
+
.join("\n")
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
def convert_tools(tools)
|
|
172
|
+
tools.filter_map do |tool|
|
|
173
|
+
if tool.respond_to?(:built_in?) && tool.built_in?
|
|
174
|
+
# Built-in tools have provider-specific config
|
|
175
|
+
# Returns nil for unsupported tools (e.g., web_fetch on OpenAI)
|
|
176
|
+
tool.provider_config(:openai)
|
|
177
|
+
else
|
|
178
|
+
# User-defined tools use standard function format
|
|
179
|
+
{
|
|
180
|
+
type: "function",
|
|
181
|
+
name: tool.name,
|
|
182
|
+
description: tool.description,
|
|
183
|
+
parameters: make_all_properties_required(tool.parameters_schema),
|
|
184
|
+
strict: true
|
|
185
|
+
}
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
# OpenAI requires all properties to be required in strict mode.
|
|
191
|
+
# For optional properties, we wrap them in anyOf: [original_type, null]
|
|
192
|
+
def make_all_properties_required(schema)
|
|
193
|
+
return schema unless schema.is_a?(Hash) && schema["type"] == "object"
|
|
194
|
+
|
|
195
|
+
properties = schema["properties"] || {}
|
|
196
|
+
required = schema["required"] || []
|
|
197
|
+
|
|
198
|
+
new_properties = properties.transform_values do |prop_schema|
|
|
199
|
+
transform_property_schema(prop_schema)
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Wrap optional properties in anyOf with null
|
|
203
|
+
new_properties = new_properties.map do |name, prop_schema|
|
|
204
|
+
if required.include?(name)
|
|
205
|
+
[ name, prop_schema ]
|
|
206
|
+
else
|
|
207
|
+
[ name, { "anyOf" => [ prop_schema, { "type" => "null" } ] } ]
|
|
208
|
+
end
|
|
209
|
+
end.to_h
|
|
210
|
+
|
|
211
|
+
{
|
|
212
|
+
"type" => "object",
|
|
213
|
+
"properties" => new_properties,
|
|
214
|
+
"required" => properties.keys,
|
|
215
|
+
"additionalProperties" => false
|
|
216
|
+
}
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def transform_property_schema(prop_schema)
|
|
220
|
+
return prop_schema unless prop_schema.is_a?(Hash)
|
|
221
|
+
|
|
222
|
+
case prop_schema["type"]
|
|
223
|
+
when "object"
|
|
224
|
+
make_all_properties_required(prop_schema)
|
|
225
|
+
when "array"
|
|
226
|
+
items = prop_schema["items"]
|
|
227
|
+
if items&.dig("type") == "object"
|
|
228
|
+
prop_schema.merge("items" => make_all_properties_required(items))
|
|
229
|
+
else
|
|
230
|
+
prop_schema
|
|
231
|
+
end
|
|
232
|
+
else
|
|
233
|
+
prop_schema
|
|
234
|
+
end
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def stream_request(path, body)
|
|
238
|
+
uri = build_uri(path)
|
|
239
|
+
|
|
240
|
+
post_json(
|
|
241
|
+
uri,
|
|
242
|
+
body,
|
|
243
|
+
headers: {
|
|
244
|
+
"Authorization" => "Bearer #{api_key}",
|
|
245
|
+
"Accept" => "text/event-stream"
|
|
246
|
+
}
|
|
247
|
+
) do |response|
|
|
248
|
+
status = response.code.to_i
|
|
249
|
+
unless status == 200
|
|
250
|
+
raise ApiError.for_status(status, "OpenAI HTTP #{status}", body: response.body)
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
parse_sse_response(response) do |event_type, data|
|
|
254
|
+
yield(event_type, data)
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
class StreamState
|
|
260
|
+
def initialize(model_id:)
|
|
261
|
+
@model_id = model_id
|
|
262
|
+
@started = false
|
|
263
|
+
|
|
264
|
+
@content_blocks = []
|
|
265
|
+
|
|
266
|
+
@text_block_index_by_ref = {} # [output_index, content_index] => content_block_index
|
|
267
|
+
@tool_call_block_index_by_id = {} # call_id => content_block_index
|
|
268
|
+
@tool_call_args_json = Hash.new { |h, k| h[k] = "" }
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def ensure_started!(yielder)
|
|
272
|
+
return if @started
|
|
273
|
+
|
|
274
|
+
@started = true
|
|
275
|
+
yielder << StreamEvent::Start.new(
|
|
276
|
+
AssistantMessage.new(content: [], model: @model_id, usage: Usage.new, stop_reason: nil)
|
|
277
|
+
)
|
|
278
|
+
end
|
|
279
|
+
|
|
280
|
+
def handle_event!(yielder, event_type, data)
|
|
281
|
+
case event_type
|
|
282
|
+
when "response.output_text.delta"
|
|
283
|
+
handle_text_delta!(yielder, data)
|
|
284
|
+
when "response.output_text.done"
|
|
285
|
+
handle_text_done!(yielder, data)
|
|
286
|
+
when "response.output_item.added"
|
|
287
|
+
handle_output_item_added!(yielder, data)
|
|
288
|
+
when "response.function_call_arguments.delta"
|
|
289
|
+
handle_function_call_arguments_delta!(yielder, data)
|
|
290
|
+
when "response.function_call_arguments.done"
|
|
291
|
+
handle_function_call_arguments_done!(yielder, data)
|
|
292
|
+
when "response.completed"
|
|
293
|
+
handle_completed!(yielder, data)
|
|
294
|
+
when "response.failed"
|
|
295
|
+
raise ApiError, (data.dig("error", "message") || "OpenAI response failed")
|
|
296
|
+
when "error"
|
|
297
|
+
raise ApiError, (data.dig("error", "message") || "OpenAI stream error")
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
private
|
|
302
|
+
|
|
303
|
+
def handle_text_delta!(yielder, data)
|
|
304
|
+
output_index = data["output_index"]
|
|
305
|
+
content_index = data["content_index"]
|
|
306
|
+
delta = data["delta"] || ""
|
|
307
|
+
|
|
308
|
+
ref = [ output_index, content_index ]
|
|
309
|
+
block_index = @text_block_index_by_ref[ref]
|
|
310
|
+
if block_index.nil?
|
|
311
|
+
block_index = @content_blocks.length
|
|
312
|
+
@content_blocks << Content::Text.new("")
|
|
313
|
+
@text_block_index_by_ref[ref] = block_index
|
|
314
|
+
yielder << StreamEvent::TextStart.new(index: block_index)
|
|
315
|
+
end
|
|
316
|
+
|
|
317
|
+
existing = @content_blocks[block_index]
|
|
318
|
+
@content_blocks[block_index] = Content::Text.new(existing.text + delta)
|
|
319
|
+
yielder << StreamEvent::TextDelta.new(index: block_index, text: delta)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
def handle_text_done!(yielder, data)
|
|
323
|
+
output_index = data["output_index"]
|
|
324
|
+
content_index = data["content_index"]
|
|
325
|
+
ref = [ output_index, content_index ]
|
|
326
|
+
block_index = @text_block_index_by_ref[ref]
|
|
327
|
+
yielder << StreamEvent::TextEnd.new(index: block_index) if block_index
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
def handle_output_item_added!(yielder, data)
|
|
331
|
+
item = data["item"] || {}
|
|
332
|
+
return unless [ "function_call", "tool_call" ].include?(item["type"])
|
|
333
|
+
|
|
334
|
+
item_id = item["id"]
|
|
335
|
+
call_id = item["call_id"] || item_id
|
|
336
|
+
name = item["name"] || ""
|
|
337
|
+
return if call_id.nil?
|
|
338
|
+
|
|
339
|
+
block_index = @content_blocks.length
|
|
340
|
+
@content_blocks << Content::ToolCall.new(id: call_id, name: name, arguments: {})
|
|
341
|
+
@tool_call_block_index_by_id[call_id] = block_index
|
|
342
|
+
@tool_call_block_index_by_id[item_id] = block_index if item_id
|
|
343
|
+
yielder << StreamEvent::ToolCallStart.new(index: block_index, id: call_id, name: name)
|
|
344
|
+
end
|
|
345
|
+
|
|
346
|
+
def handle_function_call_arguments_delta!(yielder, data)
|
|
347
|
+
call_id = data["item_id"] || data["call_id"] || data["id"]
|
|
348
|
+
delta = data["delta"] || ""
|
|
349
|
+
return if call_id.nil?
|
|
350
|
+
|
|
351
|
+
@tool_call_args_json[call_id] += delta
|
|
352
|
+
block_index = @tool_call_block_index_by_id[call_id]
|
|
353
|
+
return if block_index.nil?
|
|
354
|
+
|
|
355
|
+
yielder << StreamEvent::ToolCallDelta.new(index: block_index, arguments_json: delta)
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
def handle_function_call_arguments_done!(yielder, data)
|
|
359
|
+
call_id = data["item_id"] || data["call_id"] || data["id"]
|
|
360
|
+
return if call_id.nil?
|
|
361
|
+
|
|
362
|
+
block_index = @tool_call_block_index_by_id[call_id]
|
|
363
|
+
return if block_index.nil?
|
|
364
|
+
|
|
365
|
+
parsed_args = JSON.parse(@tool_call_args_json[call_id]) rescue {}
|
|
366
|
+
tc = @content_blocks[block_index]
|
|
367
|
+
@content_blocks[block_index] = Content::ToolCall.new(id: tc.id, name: tc.name, arguments: parsed_args)
|
|
368
|
+
yielder << StreamEvent::ToolCallEnd.new(index: block_index)
|
|
369
|
+
end
|
|
370
|
+
|
|
371
|
+
def handle_completed!(yielder, data)
|
|
372
|
+
response = data["response"] || data
|
|
373
|
+
usage = parse_usage(response["usage"])
|
|
374
|
+
|
|
375
|
+
final_message = AssistantMessage.new(
|
|
376
|
+
content: @content_blocks.compact,
|
|
377
|
+
model: @model_id,
|
|
378
|
+
usage: usage,
|
|
379
|
+
stop_reason: map_stop_reason(response)
|
|
380
|
+
)
|
|
381
|
+
|
|
382
|
+
yielder << StreamEvent::Done.new(message: final_message, stop_reason: final_message.stop_reason)
|
|
383
|
+
end
|
|
384
|
+
|
|
385
|
+
def map_stop_reason(response)
|
|
386
|
+
status = response["status"]
|
|
387
|
+
case status
|
|
388
|
+
when "incomplete" then StopReason::LENGTH
|
|
389
|
+
when "failed" then StopReason::ERROR
|
|
390
|
+
else StopReason::STOP
|
|
391
|
+
end
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
def parse_usage(usage_data)
|
|
395
|
+
return Usage.new if usage_data.nil?
|
|
396
|
+
|
|
397
|
+
input_details = usage_data["input_tokens_details"] || {}
|
|
398
|
+
output_details = usage_data["output_tokens_details"] || {}
|
|
399
|
+
|
|
400
|
+
Usage.new(
|
|
401
|
+
input_tokens: usage_data["input_tokens"] || 0,
|
|
402
|
+
output_tokens: usage_data["output_tokens"] || 0,
|
|
403
|
+
cached_input_tokens: input_details["cached_tokens"] || 0,
|
|
404
|
+
input_audio_tokens: input_details["audio_tokens"] || 0,
|
|
405
|
+
input_image_tokens: input_details["image_tokens"] || 0,
|
|
406
|
+
output_audio_tokens: output_details["audio_tokens"] || 0,
|
|
407
|
+
reasoning_tokens: output_details["reasoning_tokens"] || 0,
|
|
408
|
+
raw: usage_data,
|
|
409
|
+
meta: { provider: :openai }
|
|
410
|
+
)
|
|
411
|
+
end
|
|
412
|
+
end
|
|
413
|
+
end
|
|
414
|
+
end
|
|
415
|
+
end
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DexterLlm::Agent
|
|
4
|
+
# Autonomous LLM agent with multi-turn conversations and tool execution.
|
|
5
|
+
#
|
|
6
|
+
# The Agent maintains conversation state, executes tool calls, and emits
|
|
7
|
+
# events for monitoring. It uses an agent loop that continues until the
|
|
8
|
+
# model stops calling tools.
|
|
9
|
+
#
|
|
10
|
+
# @example Basic usage
|
|
11
|
+
# model = DexterLlm::Models.find("claude-sonnet-4-20250514")
|
|
12
|
+
# agent = Agent::Agent.new(model: model)
|
|
13
|
+
# response = agent.prompt("Hello!")
|
|
14
|
+
# puts response.text
|
|
15
|
+
#
|
|
16
|
+
# @example With tools
|
|
17
|
+
# agent = Agent::Agent.new(
|
|
18
|
+
# model: model,
|
|
19
|
+
# tools: [WeatherTool.new, CalculatorTool.new],
|
|
20
|
+
# system_prompt: "You are a helpful assistant."
|
|
21
|
+
# )
|
|
22
|
+
# agent.prompt("What's 25 * 4?") # Will use calculator
|
|
23
|
+
#
|
|
24
|
+
# @example Streaming with events
|
|
25
|
+
# agent.on_event do |event|
|
|
26
|
+
# case event.type
|
|
27
|
+
# when "stream_event"
|
|
28
|
+
# print event.data["text"] if event.data["type"] == "text_delta"
|
|
29
|
+
# when "tool_call_start"
|
|
30
|
+
# puts "[Calling #{event.data['name']}]"
|
|
31
|
+
# end
|
|
32
|
+
# end
|
|
33
|
+
# agent.prompt("What's the weather?")
|
|
34
|
+
#
|
|
35
|
+
class Agent
|
|
36
|
+
# @return [State] The current agent state (immutable)
|
|
37
|
+
attr_reader :state
|
|
38
|
+
|
|
39
|
+
# Create a new agent.
|
|
40
|
+
#
|
|
41
|
+
# @param model [DexterLlm::Model] The model to use for completions
|
|
42
|
+
# @param system_prompt [String, nil] System prompt to set model behavior
|
|
43
|
+
# @param tools [Array<DexterLlm::Tool>] Tools available to the agent
|
|
44
|
+
# @param messages [Array<Message>] Initial conversation history
|
|
45
|
+
# @param client [DexterLlm::Client, nil] Client instance (creates default if nil)
|
|
46
|
+
# @param max_iterations [Integer] Maximum agent loop iterations
|
|
47
|
+
# @param thinking_config [Hash, nil] Extended thinking configuration
|
|
48
|
+
#
|
|
49
|
+
# @example
|
|
50
|
+
# agent = Agent::Agent.new(
|
|
51
|
+
# model: DexterLlm::Models.find("claude-sonnet-4-20250514"),
|
|
52
|
+
# system_prompt: "You are helpful.",
|
|
53
|
+
# tools: [my_tool],
|
|
54
|
+
# max_iterations: 50
|
|
55
|
+
# )
|
|
56
|
+
#
|
|
57
|
+
def initialize(model:, system_prompt: nil, tools: [], messages: [], client: nil, max_iterations: State::DEFAULT_MAX_ITERATIONS, thinking_config: nil)
|
|
58
|
+
@client = client || DexterLlm::Client.new
|
|
59
|
+
@state = State.new(
|
|
60
|
+
model: model,
|
|
61
|
+
messages: messages,
|
|
62
|
+
system_prompt: system_prompt,
|
|
63
|
+
tools: tools,
|
|
64
|
+
max_iterations: max_iterations,
|
|
65
|
+
thinking_config: thinking_config
|
|
66
|
+
)
|
|
67
|
+
@event_handlers = []
|
|
68
|
+
@signal = nil
|
|
69
|
+
@running = false
|
|
70
|
+
@mutex = Mutex.new
|
|
71
|
+
@session_id = SecureRandom.uuid
|
|
72
|
+
@steering_queue = Queue.new
|
|
73
|
+
@follow_up_queue = Queue.new
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Send a prompt to the agent and get the final response.
|
|
77
|
+
#
|
|
78
|
+
# The agent will continue executing tool calls until the model
|
|
79
|
+
# stops requesting them. All events are emitted to registered handlers.
|
|
80
|
+
#
|
|
81
|
+
# @param text [String] The user message text
|
|
82
|
+
# @param attachments [Array<Content>] Optional attachments (documents, images)
|
|
83
|
+
#
|
|
84
|
+
# @return [DexterLlm::AssistantMessage] The final assistant response
|
|
85
|
+
#
|
|
86
|
+
# @raise [AgentBusyError] If another prompt is already running
|
|
87
|
+
# @raise [MaxIterationsError] If max_iterations is exceeded
|
|
88
|
+
# @raise [DexterLlm::CancelledError] If the operation is cancelled
|
|
89
|
+
# @raise [DexterLlm::ApiError] For API errors
|
|
90
|
+
#
|
|
91
|
+
# @example
|
|
92
|
+
# response = agent.prompt("What is Ruby?")
|
|
93
|
+
# puts response.text
|
|
94
|
+
#
|
|
95
|
+
# @example With attachments
|
|
96
|
+
# doc = DexterLlm::Content::Document.new(source: ..., filename: "doc.pdf", ...)
|
|
97
|
+
# response = agent.prompt("Summarize this", attachments: [doc])
|
|
98
|
+
#
|
|
99
|
+
def prompt(text, attachments: [])
|
|
100
|
+
@mutex.synchronize do
|
|
101
|
+
raise AgentBusyError.new if @running
|
|
102
|
+
@running = true
|
|
103
|
+
@signal = DexterLlm::CancellationSignal.new
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
begin
|
|
107
|
+
final_message = run_prompt(text, attachments:)
|
|
108
|
+
|
|
109
|
+
# Process follow-up queue
|
|
110
|
+
while (follow_up_text = dequeue_follow_up)
|
|
111
|
+
final_message = run_prompt(follow_up_text)
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
final_message
|
|
115
|
+
ensure
|
|
116
|
+
@mutex.synchronize do
|
|
117
|
+
@running = false
|
|
118
|
+
@signal = nil
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Register an event handler for agent events.
|
|
124
|
+
#
|
|
125
|
+
# The handler is called for all events during prompt execution,
|
|
126
|
+
# including streaming events, tool calls, and state changes.
|
|
127
|
+
#
|
|
128
|
+
# @yield [Event] Block called for each event
|
|
129
|
+
#
|
|
130
|
+
# @example
|
|
131
|
+
# agent.on_event do |event|
|
|
132
|
+
# case event.type
|
|
133
|
+
# when "user_message"
|
|
134
|
+
# puts "User: #{event.data['content']}"
|
|
135
|
+
# when "stream_event"
|
|
136
|
+
# print event.data["text"] if event.data["type"] == "text_delta"
|
|
137
|
+
# when "tool_call_start"
|
|
138
|
+
# puts "[Calling #{event.data['name']}]"
|
|
139
|
+
# when "tool_call_end"
|
|
140
|
+
# puts "[Result: #{event.data['result']}]"
|
|
141
|
+
# end
|
|
142
|
+
# end
|
|
143
|
+
#
|
|
144
|
+
def on_event(&handler)
|
|
145
|
+
@mutex.synchronize { @event_handlers << handler }
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Reset the agent's conversation history.
|
|
149
|
+
#
|
|
150
|
+
# Clears all messages while preserving the model, system prompt,
|
|
151
|
+
# tools, and settings.
|
|
152
|
+
#
|
|
153
|
+
# @raise [AgentBusyError] If the agent is currently running
|
|
154
|
+
#
|
|
155
|
+
def reset
|
|
156
|
+
@mutex.synchronize do
|
|
157
|
+
raise AgentBusyError.new if @running
|
|
158
|
+
@state = State.new(
|
|
159
|
+
model: @state.model,
|
|
160
|
+
system_prompt: @state.system_prompt,
|
|
161
|
+
tools: @state.tools,
|
|
162
|
+
max_iterations: @state.max_iterations
|
|
163
|
+
)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Abort a running prompt operation.
|
|
168
|
+
#
|
|
169
|
+
# This cancels the current operation, causing prompt() to raise
|
|
170
|
+
# DexterLlm::CancelledError. Safe to call from another thread.
|
|
171
|
+
#
|
|
172
|
+
# @param reason [String] The cancellation reason
|
|
173
|
+
#
|
|
174
|
+
# @example
|
|
175
|
+
# # In a signal handler or timeout thread
|
|
176
|
+
# agent.abort("User cancelled")
|
|
177
|
+
#
|
|
178
|
+
def abort(reason = "User aborted")
|
|
179
|
+
signal = @mutex.synchronize { @signal }
|
|
180
|
+
signal&.cancel!(reason)
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Check if the agent is currently running a prompt.
|
|
184
|
+
#
|
|
185
|
+
# @return [Boolean] True if a prompt is in progress
|
|
186
|
+
#
|
|
187
|
+
def running?
|
|
188
|
+
@mutex.synchronize { @running }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Inject a steering message that will be delivered after the current tool
|
|
192
|
+
# execution completes, skipping any remaining pending tools in this turn.
|
|
193
|
+
#
|
|
194
|
+
# @param text [String] The steering message text
|
|
195
|
+
# @raise [Agent::Error] If the agent is not running
|
|
196
|
+
def steer(text)
|
|
197
|
+
@mutex.synchronize do
|
|
198
|
+
raise Error, "Cannot steer: agent is not running" unless @running
|
|
199
|
+
end
|
|
200
|
+
@steering_queue << text
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
# Queue a follow-up message to be processed after the current run completes.
|
|
204
|
+
# The agent will automatically process this as a new prompt.
|
|
205
|
+
#
|
|
206
|
+
# @param text [String] The follow-up message text
|
|
207
|
+
def follow_up(text)
|
|
208
|
+
@follow_up_queue << text
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
# Clear all pending steering messages.
|
|
212
|
+
def clear_steering_queue
|
|
213
|
+
@steering_queue.clear
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Clear all pending follow-up messages.
|
|
217
|
+
def clear_follow_up_queue
|
|
218
|
+
@follow_up_queue.clear
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
# Get the conversation message history.
|
|
222
|
+
#
|
|
223
|
+
# @return [Array<Message>] All messages in the conversation
|
|
224
|
+
#
|
|
225
|
+
def messages
|
|
226
|
+
@state.messages
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
# Get the current model.
|
|
230
|
+
#
|
|
231
|
+
# @return [DexterLlm::Model] The model being used
|
|
232
|
+
#
|
|
233
|
+
def model
|
|
234
|
+
@state.model
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Get the agent's session ID.
|
|
238
|
+
#
|
|
239
|
+
# The session ID is constant for the lifetime of this agent instance.
|
|
240
|
+
#
|
|
241
|
+
# @return [String] UUID for this agent session
|
|
242
|
+
#
|
|
243
|
+
def session_id
|
|
244
|
+
@session_id
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
private
|
|
248
|
+
|
|
249
|
+
def run_prompt(text, attachments: [])
|
|
250
|
+
run_id = SecureRandom.uuid
|
|
251
|
+
loop = Loop.new(
|
|
252
|
+
state: @state,
|
|
253
|
+
client: @client,
|
|
254
|
+
session_id: @session_id,
|
|
255
|
+
run_id: run_id,
|
|
256
|
+
signal: @signal,
|
|
257
|
+
steering_queue: @steering_queue,
|
|
258
|
+
emit_event: ->(event) { notify_handlers(event) }
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
new_state, final_message = loop.run(text, attachments:)
|
|
262
|
+
@state = new_state
|
|
263
|
+
final_message
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
def dequeue_follow_up
|
|
267
|
+
@follow_up_queue.pop(true)
|
|
268
|
+
rescue ThreadError
|
|
269
|
+
nil
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def notify_handlers(event)
|
|
273
|
+
handlers = @mutex.synchronize { @event_handlers.dup }
|
|
274
|
+
handlers.each { |handler| handler.call(event) }
|
|
275
|
+
end
|
|
276
|
+
end
|
|
277
|
+
end
|