ruby_llm-agents 3.10.0 → 3.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/app/controllers/ruby_llm/agents/agents_controller.rb +74 -0
  3. data/app/controllers/ruby_llm/agents/analytics_controller.rb +304 -0
  4. data/app/controllers/ruby_llm/agents/tenants_controller.rb +74 -2
  5. data/app/models/ruby_llm/agents/agent_override.rb +47 -0
  6. data/app/models/ruby_llm/agents/execution/analytics.rb +37 -16
  7. data/app/services/ruby_llm/agents/agent_registry.rb +8 -1
  8. data/app/views/layouts/ruby_llm/agents/application.html.erb +4 -2
  9. data/app/views/ruby_llm/agents/agents/_config_agent.html.erb +89 -4
  10. data/app/views/ruby_llm/agents/agents/show.html.erb +14 -0
  11. data/app/views/ruby_llm/agents/analytics/index.html.erb +398 -0
  12. data/app/views/ruby_llm/agents/tenants/index.html.erb +3 -2
  13. data/app/views/ruby_llm/agents/tenants/show.html.erb +225 -0
  14. data/config/routes.rb +12 -4
  15. data/lib/generators/ruby_llm_agents/templates/create_overrides_migration.rb.tt +28 -0
  16. data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +1 -1
  17. data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +1 -1
  18. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +14 -0
  19. data/lib/ruby_llm/agents/base_agent.rb +158 -37
  20. data/lib/ruby_llm/agents/core/base.rb +9 -0
  21. data/lib/ruby_llm/agents/core/configuration.rb +5 -1
  22. data/lib/ruby_llm/agents/core/version.rb +1 -1
  23. data/lib/ruby_llm/agents/dsl/base.rb +131 -4
  24. data/lib/ruby_llm/agents/dsl/knowledge.rb +157 -0
  25. data/lib/ruby_llm/agents/dsl.rb +1 -0
  26. data/lib/ruby_llm/agents/pipeline/context.rb +11 -2
  27. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +32 -20
  28. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +22 -1
  29. data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +1 -1
  30. data/lib/ruby_llm/agents/routing/result.rb +60 -9
  31. data/lib/ruby_llm/agents/routing.rb +19 -0
  32. data/lib/ruby_llm/agents/stream_event.rb +58 -0
  33. data/lib/ruby_llm/agents/tool.rb +1 -1
  34. data/lib/ruby_llm/agents.rb +2 -2
  35. metadata +7 -2
  36. data/lib/ruby_llm/agents/agent_tool.rb +0 -125
@@ -72,8 +72,13 @@ module RubyLLM
72
72
  # @param context [Pipeline::Context] The execution context
73
73
  # @return [void] Sets context.output with the result
74
74
  def execute(context)
75
+ @context = context
75
76
  @execution_started_at = context.started_at || Time.current
76
77
 
78
+ # Make context available to Tool instances during tool execution
79
+ previous_context = Thread.current[:ruby_llm_agents_caller_context]
80
+ Thread.current[:ruby_llm_agents_caller_context] = context
81
+
77
82
  # Run before_call callbacks
78
83
  run_callbacks(:before, context)
79
84
 
@@ -87,10 +92,14 @@ module RubyLLM
87
92
  run_callbacks(:after, context, response)
88
93
 
89
94
  context.output = build_result(processed_content, response, context)
95
+ rescue RubyLLM::Agents::CancelledError
96
+ context.output = Result.new(content: nil, cancelled: true)
90
97
  rescue RubyLLM::UnauthorizedError, RubyLLM::ForbiddenError => e
91
98
  raise_with_setup_hint(e, context)
92
99
  rescue RubyLLM::ModelNotFoundError => e
93
100
  raise_with_model_hint(e, context)
101
+ ensure
102
+ Thread.current[:ruby_llm_agents_caller_context] = previous_context
94
103
  end
95
104
 
96
105
  # Returns the resolved tenant ID for tracking
@@ -448,7 +448,8 @@ module RubyLLM
448
448
  :helicone_pricing_enabled,
449
449
  :helicone_pricing_url,
450
450
  :llmpricing_enabled,
451
- :llmpricing_url
451
+ :llmpricing_url,
452
+ :knowledge_path
452
453
 
453
454
  # Attributes with validation (readers only, custom setters below)
454
455
  attr_reader :default_temperature,
@@ -752,6 +753,9 @@ module RubyLLM
752
753
  @elevenlabs_base_cost_per_1k = nil
753
754
  # ElevenLabs models cache TTL in seconds (6 hours)
754
755
  @elevenlabs_models_cache_ttl = 21_600
756
+
757
+ # Knowledge defaults
758
+ @knowledge_path = "app/agents/knowledge"
755
759
  end
756
760
 
757
761
  # Returns the configured cache store, falling back to Rails.cache
@@ -4,6 +4,6 @@ module RubyLLM
4
4
  module Agents
5
5
  # Current version of the RubyLLM::Agents gem
6
6
  # @return [String] Semantic version string
7
- VERSION = "3.10.0"
7
+ VERSION = "3.12.0"
8
8
  end
9
9
  end
@@ -43,6 +43,13 @@ module RubyLLM
43
43
  #
44
44
  # RubyExpert.ask("What is metaprogramming?")
45
45
  #
46
+ # @example Dashboard-overridable settings
47
+ # class SupportAgent < RubyLLM::Agents::BaseAgent
48
+ # model "gpt-4o", overridable: true # can be changed from the dashboard
49
+ # temperature 0.7, overridable: true # can be changed from the dashboard
50
+ # timeout 30 # locked to code value
51
+ # end
52
+ #
46
53
  # @example Dynamic prompts with method overrides
47
54
  # class SmartAgent < RubyLLM::Agents::BaseAgent
48
55
  # def system_prompt
@@ -63,12 +70,18 @@ module RubyLLM
63
70
  # Sets or returns the LLM model for this agent class
64
71
  #
65
72
  # @param value [String, nil] The model identifier to set
73
+ # @param overridable [Boolean, nil] When true, this field can be changed from the dashboard
66
74
  # @return [String] The current model setting
67
75
  # @example
68
76
  # model "gpt-4o"
69
- def model(value = nil)
77
+ # @example Dashboard-overridable
78
+ # model "gpt-4o", overridable: true
79
+ def model(value = nil, overridable: nil)
70
80
  @model = value if value
71
- @model || inherited_or_default(:model, default_model)
81
+ register_overridable(:model) if overridable
82
+ base = @model || inherited_or_default(:model, default_model)
83
+
84
+ apply_override(:model, base)
72
85
  end
73
86
 
74
87
  # Sets the user prompt template
@@ -206,12 +219,45 @@ module RubyLLM
206
219
  # Sets or returns the timeout in seconds for LLM requests
207
220
  #
208
221
  # @param value [Integer, nil] Timeout in seconds
222
+ # @param overridable [Boolean, nil] When true, this field can be changed from the dashboard
209
223
  # @return [Integer] The current timeout setting
210
224
  # @example
211
225
  # timeout 30
212
- def timeout(value = nil)
226
+ # @example Dashboard-overridable
227
+ # timeout 30, overridable: true
228
+ def timeout(value = nil, overridable: nil)
213
229
  @timeout = value if value
214
- @timeout || inherited_or_default(:timeout, default_timeout)
230
+ register_overridable(:timeout) if overridable
231
+ base = @timeout || inherited_or_default(:timeout, default_timeout)
232
+
233
+ apply_override(:timeout, base)
234
+ end
235
+
236
+ # Enables Anthropic prompt caching for this agent
237
+ #
238
+ # When enabled, adds cache_control breakpoints to the system prompt
239
+ # and the last tool definition so Anthropic caches them across
240
+ # multi-turn agent loops. This reduces input token costs by ~90%
241
+ # on subsequent calls within the same cache window (~5 minutes).
242
+ #
243
+ # Only takes effect when the resolved model is served by Anthropic.
244
+ # Non-Anthropic models silently ignore this setting.
245
+ #
246
+ # @param value [Boolean, nil] Whether to enable prompt caching
247
+ # @return [Boolean] The current setting
248
+ #
249
+ # @example
250
+ # class BuildAgent < ApplicationAgent
251
+ # cache_prompts true
252
+ # system "You are a build assistant."
253
+ # tools BuildTool, TestTool, DeployTool
254
+ # end
255
+ #
256
+ def cache_prompts(value = nil)
257
+ @cache_prompts = value unless value.nil?
258
+ return @cache_prompts if defined?(@cache_prompts) && !@cache_prompts.nil?
259
+
260
+ inherited_or_default(:cache_prompts, false)
215
261
  end
216
262
 
217
263
  # Sets or returns the response schema for structured output
@@ -259,6 +305,47 @@ module RubyLLM
259
305
 
260
306
  # @!endgroup
261
307
 
308
+ # @!group Dashboard Override Support
309
+
310
+ # Returns which fields are overridable for this agent
311
+ #
312
+ # @return [Array<Symbol>] The list of overridable field names
313
+ def overridable_fields
314
+ own = @overridable_fields || []
315
+ inherited = superclass.respond_to?(:overridable_fields) ? superclass.overridable_fields : []
316
+ (own + inherited).uniq
317
+ end
318
+
319
+ # Returns true if any field is overridable from the dashboard
320
+ #
321
+ # @return [Boolean]
322
+ def overridable?
323
+ overridable_fields.any?
324
+ end
325
+
326
+ # Returns the currently active dashboard overrides for this agent
327
+ #
328
+ # Only returns overrides for fields that are declared overridable.
329
+ #
330
+ # @return [Hash{String => Object}] Active override values
331
+ def active_overrides
332
+ return {} unless overridable?
333
+
334
+ raw = load_overrides
335
+ raw.select { |field, _| overridable_fields.include?(field.to_sym) }
336
+ end
337
+
338
+ # Clears the in-memory override cache so the next access reloads from DB
339
+ #
340
+ # Called automatically by AgentOverride after_save/after_destroy callbacks.
341
+ #
342
+ # @return [void]
343
+ def clear_override_cache!
344
+ @_override_cache = nil
345
+ end
346
+
347
+ # @!endgroup
348
+
262
349
  private
263
350
 
264
351
  # Auto-registers parameters found in prompt template placeholders
@@ -310,6 +397,46 @@ module RubyLLM
310
397
  rescue
311
398
  120
312
399
  end
400
+
401
+ # Registers a field as overridable from the dashboard
402
+ #
403
+ # @param field [Symbol] The field name
404
+ # @return [void]
405
+ def register_overridable(field)
406
+ @overridable_fields = (@overridable_fields || []) | [field]
407
+ end
408
+
409
+ # Applies a dashboard override if the field is overridable and an override exists
410
+ #
411
+ # @param field [Symbol] The field name
412
+ # @param base [Object] The code-defined value to use as fallback
413
+ # @return [Object] The override value, or the base value
414
+ def apply_override(field, base)
415
+ return base unless overridable_fields.include?(field)
416
+
417
+ override = resolve_override(field)
418
+ override.nil? ? base : override
419
+ end
420
+
421
+ # Fetches the override value for a single field from the cached override hash
422
+ #
423
+ # @param field [Symbol] The field name
424
+ # @return [Object, nil] The override value, or nil
425
+ def resolve_override(field)
426
+ @_override_cache = load_overrides unless defined?(@_override_cache) && @_override_cache
427
+ @_override_cache[field.to_s]
428
+ end
429
+
430
+ # Loads all overrides for this agent from the database
431
+ #
432
+ # @return [Hash{String => Object}] The override settings hash
433
+ def load_overrides
434
+ return {} unless defined?(RubyLLM::Agents::AgentOverride)
435
+
436
+ RubyLLM::Agents::AgentOverride.find_by(agent_type: name)&.settings || {}
437
+ rescue
438
+ {}
439
+ end
313
440
  end
314
441
  end
315
442
  end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ module DSL
6
+ # Knowledge DSL for declaring domain knowledge to inject into system prompts.
7
+ #
8
+ # Supports two modes:
9
+ # - Static: `knows :name` loads from a file resolved via `knowledge_path`
10
+ # - Dynamic: `knows(:name) { ... }` evaluates a block at call time via instance_exec
11
+ #
12
+ # Optional `if:` condition gates inclusion without forcing static content into blocks.
13
+ #
14
+ # @example Static knowledge from files
15
+ # class MyAgent < RubyLLM::Agents::Base
16
+ # knowledge_path "knowledge"
17
+ # knows :refund_policy
18
+ # knows :pricing
19
+ # end
20
+ #
21
+ # @example Dynamic knowledge
22
+ # class MyAgent < RubyLLM::Agents::Base
23
+ # knows :recent_tickets do
24
+ # Ticket.recent.pluck(:summary)
25
+ # end
26
+ # end
27
+ #
28
+ # @example Conditional knowledge
29
+ # class MyAgent < RubyLLM::Agents::Base
30
+ # param :region, required: true
31
+ # knows :hipaa, if: -> { region == "us" }
32
+ # end
33
+ #
34
+ module Knowledge
35
+ # Registers one or more knowledge entries.
36
+ #
37
+ # @overload knows(name, **options, &block)
38
+ # Register a single entry (static or dynamic)
39
+ # @param name [Symbol] Identifier for this knowledge entry
40
+ # @param options [Hash] Options including `if:` condition lambda
41
+ # @param block [Proc] Optional block for dynamic knowledge (evaluated via instance_exec)
42
+ #
43
+ # @overload knows(name, *more_names)
44
+ # Register multiple static entries at once
45
+ # @param name [Symbol] First entry name
46
+ # @param more_names [Array<Symbol>] Additional entry names
47
+ def knows(name, *more_names, **options, &block)
48
+ if more_names.any?
49
+ # Multi-arg form: knows :a, :b, :c — all static, no block/options
50
+ [name, *more_names].each do |n|
51
+ knowledge_entries.reject! { |e| e[:name] == n }
52
+ knowledge_entries << {name: n, loader: nil, options: {}}
53
+ end
54
+ else
55
+ knowledge_entries.reject! { |e| e[:name] == name }
56
+ knowledge_entries << {
57
+ name: name,
58
+ loader: block,
59
+ options: options
60
+ }
61
+ end
62
+ end
63
+
64
+ # Sets or returns the base path for static knowledge files.
65
+ #
66
+ # @param path [String, nil] Path to set, or nil to read
67
+ # @return [String, nil] The resolved knowledge path
68
+ def knowledge_path(path = nil)
69
+ if path
70
+ @knowledge_path = path
71
+ else
72
+ @knowledge_path ||
73
+ inherited_or_default(:knowledge_path, nil) ||
74
+ RubyLLM::Agents.configuration.knowledge_path
75
+ end
76
+ end
77
+
78
+ # Returns the list of registered knowledge entries, inheriting from superclass.
79
+ #
80
+ # @return [Array<Hash>] Knowledge entries
81
+ def knowledge_entries
82
+ @knowledge_entries ||= if superclass.respond_to?(:knowledge_entries)
83
+ superclass.knowledge_entries.dup
84
+ else
85
+ []
86
+ end
87
+ end
88
+
89
+ # Instance methods mixed into agent instances via include.
90
+ module InstanceMethods
91
+ # Compiles all knowledge entries into a single string with headings and separators.
92
+ #
93
+ # @return [String] Compiled knowledge (empty string if no entries resolve)
94
+ def compiled_knowledge
95
+ self.class.knowledge_entries.filter_map { |entry|
96
+ content = resolve_knowledge(entry)
97
+ next if content.blank?
98
+
99
+ heading = entry[:name].to_s.tr("_", " ").gsub(/\b\w/, &:upcase)
100
+ "## #{heading}\n\n#{content}"
101
+ }.join("\n\n---\n\n")
102
+ end
103
+
104
+ private
105
+
106
+ def resolve_knowledge(entry)
107
+ if (condition = entry[:options][:if])
108
+ return nil unless instance_exec(&condition)
109
+ end
110
+
111
+ if entry[:loader]
112
+ resolve_dynamic_knowledge(entry)
113
+ else
114
+ resolve_static_knowledge(entry)
115
+ end
116
+ end
117
+
118
+ def resolve_dynamic_knowledge(entry)
119
+ result = instance_exec(&entry[:loader])
120
+ case result
121
+ when Array
122
+ result.map { |r| "- #{r}" }.join("\n")
123
+ when String
124
+ result
125
+ when nil
126
+ nil
127
+ else
128
+ result.to_s
129
+ end
130
+ end
131
+
132
+ def resolve_static_knowledge(entry)
133
+ path = find_knowledge_file(entry[:name])
134
+ return nil unless path && File.exist?(path)
135
+ File.read(path)
136
+ end
137
+
138
+ def find_knowledge_file(name)
139
+ base_path = self.class.knowledge_path
140
+ return nil unless base_path
141
+
142
+ candidates = [
143
+ File.join(base_path, "#{name}.md"),
144
+ File.join(base_path, name.to_s)
145
+ ]
146
+
147
+ if defined?(Rails) && Rails.respond_to?(:root) && Rails.root
148
+ candidates.map! { |c| Rails.root.join(c).to_s }
149
+ end
150
+
151
+ candidates.find { |c| File.exist?(c) }
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end
@@ -4,6 +4,7 @@ require_relative "dsl/base"
4
4
  require_relative "dsl/reliability"
5
5
  require_relative "dsl/caching"
6
6
  require_relative "dsl/queryable"
7
+ require_relative "dsl/knowledge"
7
8
 
8
9
  module RubyLLM
9
10
  module Agents
@@ -50,7 +50,7 @@ module RubyLLM
50
50
  attr_accessor :trace
51
51
 
52
52
  # Streaming support
53
- attr_accessor :stream_block, :skip_cache
53
+ attr_accessor :stream_block, :skip_cache, :stream_events
54
54
 
55
55
  # Agent metadata
56
56
  attr_reader :agent_class, :agent_type
@@ -65,7 +65,7 @@ module RubyLLM
65
65
  # @param skip_cache [Boolean] Whether to skip caching
66
66
  # @param stream_block [Proc, nil] Block for streaming
67
67
  # @param options [Hash] Additional options passed to the agent
68
- def initialize(input:, agent_class:, agent_instance: nil, model: nil, tenant: nil, skip_cache: false, stream_block: nil, parent_execution_id: nil, root_execution_id: nil, **options)
68
+ def initialize(input:, agent_class:, agent_instance: nil, model: nil, tenant: nil, skip_cache: false, stream_block: nil, stream_events: false, parent_execution_id: nil, root_execution_id: nil, **options)
69
69
  @input = input
70
70
  @agent_class = agent_class
71
71
  @agent_instance = agent_instance
@@ -87,6 +87,7 @@ module RubyLLM
87
87
  # Execution options
88
88
  @skip_cache = skip_cache
89
89
  @stream_block = stream_block
90
+ @stream_events = stream_events
90
91
 
91
92
  # Debug trace
92
93
  @trace = []
@@ -132,6 +133,13 @@ module RubyLLM
132
133
  @trace << {middleware: middleware_name, started_at: started_at, duration_ms: duration_ms, action: action}.compact
133
134
  end
134
135
 
136
+ # Are stream events enabled?
137
+ #
138
+ # @return [Boolean]
139
+ def stream_events?
140
+ @stream_events == true
141
+ end
142
+
135
143
  # Was the result served from cache?
136
144
  #
137
145
  # @return [Boolean]
@@ -243,6 +251,7 @@ module RubyLLM
243
251
  model: @model,
244
252
  skip_cache: @skip_cache,
245
253
  stream_block: @stream_block,
254
+ stream_events: @stream_events,
246
255
  **opts_without_tenant
247
256
  )
248
257
  # Preserve resolved tenant state
@@ -33,15 +33,18 @@ module RubyLLM
33
33
  return @app.call(context) unless budgets_enabled?
34
34
 
35
35
  trace(context) do
36
+ # Resolve tenant once for both check and record
37
+ tenant = resolve_tenant(context)
38
+
36
39
  # Check budget before execution
37
- check_budget!(context)
40
+ check_budget!(context, tenant)
38
41
 
39
42
  # Execute the chain
40
43
  @app.call(context)
41
44
 
42
45
  # Record spend after successful execution (if not cached)
43
46
  if context.success? && !context.cached?
44
- record_spend!(context)
47
+ record_spend!(context, tenant)
45
48
  emit_budget_notification("ruby_llm_agents.budget.record", context,
46
49
  total_cost: context.total_cost,
47
50
  total_tokens: context.total_tokens)
@@ -80,22 +83,33 @@ module RubyLLM
80
83
  false
81
84
  end
82
85
 
86
+ # Resolves the tenant record once for reuse across check and record
87
+ #
88
+ # @param context [Context] The execution context
89
+ # @return [Tenant, nil] The tenant record or nil
90
+ def resolve_tenant(context)
91
+ return nil unless context.tenant_id.present?
92
+
93
+ RubyLLM::Agents::Tenant.find_by(tenant_id: context.tenant_id)
94
+ rescue => e
95
+ debug("Tenant lookup failed: #{e.message}", context)
96
+ nil
97
+ end
98
+
83
99
  # Checks budget before execution
84
100
  #
85
101
  # For tenants, checks budget via counter columns on the tenant model.
86
102
  # For non-tenant usage, falls back to BudgetTracker (cache-based).
87
103
  #
88
104
  # @param context [Context] The execution context
105
+ # @param tenant [Tenant, nil] Pre-resolved tenant record
89
106
  # @raise [BudgetExceededError] If budget exceeded with hard enforcement
90
- def check_budget!(context)
107
+ def check_budget!(context, tenant = nil)
91
108
  emit_budget_notification("ruby_llm_agents.budget.check", context)
92
109
 
93
- if context.tenant_id.present?
94
- tenant = RubyLLM::Agents::Tenant.find_by(tenant_id: context.tenant_id)
95
- if tenant
96
- tenant.check_budget!(context.agent_class&.name)
97
- return
98
- end
110
+ if tenant
111
+ tenant.check_budget!(context.agent_class&.name)
112
+ return
99
113
  end
100
114
 
101
115
  # Fallback to cache-based checking (non-tenant or no tenant record)
@@ -117,17 +131,15 @@ module RubyLLM
117
131
  # For non-tenant usage, falls back to BudgetTracker (cache-based).
118
132
  #
119
133
  # @param context [Context] The execution context
120
- def record_spend!(context)
121
- if context.tenant_id.present?
122
- tenant = RubyLLM::Agents::Tenant.find_by(tenant_id: context.tenant_id)
123
- if tenant
124
- tenant.record_execution!(
125
- cost: context.total_cost || 0,
126
- tokens: context.total_tokens || 0,
127
- error: context.failed?
128
- )
129
- return
130
- end
134
+ # @param tenant [Tenant, nil] Pre-resolved tenant record
135
+ def record_spend!(context, tenant = nil)
136
+ if tenant
137
+ tenant.record_execution!(
138
+ cost: context.total_cost || 0,
139
+ tokens: context.total_tokens || 0,
140
+ error: context.failed?
141
+ )
142
+ return
131
143
  end
132
144
 
133
145
  # Fallback for non-tenant usage
@@ -278,6 +278,7 @@ module RubyLLM
278
278
  data = {
279
279
  agent_type: context.agent_class&.name,
280
280
  model_id: context.model,
281
+ model_provider: resolve_model_provider(context.model),
281
282
  status: "running",
282
283
  started_at: context.started_at,
283
284
  input_tokens: 0,
@@ -339,7 +340,9 @@ module RubyLLM
339
340
  input_tokens: context.input_tokens || 0,
340
341
  output_tokens: context.output_tokens || 0,
341
342
  total_cost: context.total_cost || 0,
342
- attempts_count: context.attempts_made
343
+ attempts_count: context.attempts_made,
344
+ chosen_model_id: context.model_used,
345
+ finish_reason: context.finish_reason
343
346
  }
344
347
 
345
348
  # Merge metadata: agent metadata (base) < middleware metadata (overlay)
@@ -536,6 +539,24 @@ module RubyLLM
536
539
  {}
537
540
  end
538
541
 
542
+ # Resolves the provider name for a given model ID
543
+ #
544
+ # Uses RubyLLM::Models.find which is an in-process registry lookup
545
+ # (no API keys or network calls needed).
546
+ #
547
+ # @param model_id [String, nil] The model identifier
548
+ # @return [String, nil] Provider name (e.g., "openai", "anthropic") or nil
549
+ def resolve_model_provider(model_id)
550
+ return nil unless model_id
551
+ return nil unless defined?(RubyLLM::Models)
552
+
553
+ model_info = RubyLLM::Models.find(model_id)
554
+ provider = model_info&.provider
555
+ provider&.to_s.presence
556
+ rescue
557
+ nil
558
+ end
559
+
539
560
  # Injects tracker request_id and tags into execution data
540
561
  #
541
562
  # Reads @_track_request_id and @_track_tags from the agent instance,
@@ -223,7 +223,7 @@ module RubyLLM
223
223
  return unless deadline && Time.current > deadline
224
224
 
225
225
  elapsed = Time.current - started_at
226
- timeout_value = deadline - started_at + elapsed
226
+ timeout_value = deadline - started_at
227
227
  raise Agents::Reliability::TotalTimeoutError.new(timeout_value, elapsed)
228
228
  end
229
229