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,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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DexterLlm::Agent
4
+ class AgentBusyError < Error
5
+ def initialize(msg = "Agent is already running")
6
+ super
7
+ end
8
+ end
9
+ end