ruby_llm-agents 3.11.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 (34) 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 +90 -133
  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 -1
  26. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +32 -20
  27. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +22 -1
  28. data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +1 -1
  29. data/lib/ruby_llm/agents/stream_event.rb +2 -10
  30. data/lib/ruby_llm/agents/tool.rb +1 -1
  31. data/lib/ruby_llm/agents.rb +0 -3
  32. metadata +6 -3
  33. data/lib/ruby_llm/agents/agent_tool.rb +0 -143
  34. data/lib/ruby_llm/agents/dsl/agents.rb +0 -141
@@ -47,7 +47,8 @@ module RubyLLM
47
47
  extend DSL::Reliability
48
48
  extend DSL::Caching
49
49
  extend DSL::Queryable
50
- extend DSL::Agents
50
+ extend DSL::Knowledge
51
+ include DSL::Knowledge::InstanceMethods
51
52
  include CacheHelper
52
53
 
53
54
  class << self
@@ -169,9 +170,9 @@ module RubyLLM
169
170
  description: description,
170
171
  schema: schema&.respond_to?(:name) ? schema.name : schema&.class&.name,
171
172
  tools: tools.map { |t| t.respond_to?(:name) ? t.name : t.to_s },
172
- agents: agents_config.agent_entries.map { |e| e[:agent_class].name },
173
173
  parameters: params.transform_values { |v| v.slice(:type, :required, :default, :desc) },
174
174
  thinking: thinking_config,
175
+ cache_prompts: cache_prompts || nil,
175
176
  caching: caching_config,
176
177
  reliability: reliability_configured? ? reliability_config : nil
177
178
  }.compact
@@ -234,12 +235,18 @@ module RubyLLM
234
235
  # Enables or returns streaming mode for this agent
235
236
  #
236
237
  # @param value [Boolean, nil] Whether to enable streaming
238
+ # @param overridable [Boolean, nil] When true, this field can be changed from the dashboard
237
239
  # @return [Boolean] The current streaming setting
238
- def streaming(value = nil)
240
+ def streaming(value = nil, overridable: nil)
239
241
  @streaming = value unless value.nil?
240
- return @streaming unless @streaming.nil?
242
+ register_overridable(:streaming) if overridable
243
+ base = if @streaming.nil?
244
+ superclass.respond_to?(:streaming) ? superclass.streaming : default_streaming
245
+ else
246
+ @streaming
247
+ end
241
248
 
242
- superclass.respond_to?(:streaming) ? superclass.streaming : default_streaming
249
+ apply_override(:streaming, base)
243
250
  end
244
251
 
245
252
  # @!endgroup
@@ -248,10 +255,10 @@ module RubyLLM
248
255
 
249
256
  # Sets or returns the tools available to this agent
250
257
  #
251
- # @param tool_classes [Array<Class>] Tool classes to make available
258
+ # @param tool_classes [Class, Array<Class>] Tool classes to make available
252
259
  # @return [Array<Class>] The current tools
253
- def tools(tool_classes = nil)
254
- @tools = Array(tool_classes) if tool_classes
260
+ def tools(*tool_classes)
261
+ @tools = tool_classes.flatten if tool_classes.any?
255
262
  @tools || (superclass.respond_to?(:tools) ? superclass.tools : [])
256
263
  end
257
264
 
@@ -262,10 +269,14 @@ module RubyLLM
262
269
  # Sets or returns the temperature for LLM responses
263
270
  #
264
271
  # @param value [Float, nil] Temperature value (0.0-2.0)
272
+ # @param overridable [Boolean, nil] When true, this field can be changed from the dashboard
265
273
  # @return [Float] The current temperature setting
266
- def temperature(value = nil)
274
+ def temperature(value = nil, overridable: nil)
267
275
  @temperature = value if value
268
- @temperature || (superclass.respond_to?(:temperature) ? superclass.temperature : default_temperature)
276
+ register_overridable(:temperature) if overridable
277
+ base = @temperature || (superclass.respond_to?(:temperature) ? superclass.temperature : default_temperature)
278
+
279
+ apply_override(:temperature, base)
269
280
  end
270
281
 
271
282
  # @!endgroup
@@ -389,23 +400,19 @@ module RubyLLM
389
400
  # System prompt for LLM instructions
390
401
  #
391
402
  # If a class-level `system` DSL is defined, it will be used.
392
- # When `agents` are declared, auto-generated sections describing
393
- # direct tools and agent delegates are appended.
403
+ # Knowledge entries declared via `knows` are auto-appended.
394
404
  #
395
405
  # @return [String, nil] System instructions, or nil for none
396
406
  def system_prompt
397
- base = base_system_prompt
398
- append_agents_system_prompt(base)
399
- end
400
-
401
- # Returns the raw system prompt without agent/tool sections
402
- #
403
- # @return [String, nil]
404
- def base_system_prompt
405
407
  system_config = self.class.system_config
406
- return resolve_prompt_from_config(system_config) if system_config
408
+ base = system_config ? resolve_prompt_from_config(system_config) : nil
407
409
 
408
- nil
410
+ knowledge = compiled_knowledge
411
+ if knowledge.present?
412
+ base ? "#{base}\n\n#{knowledge}" : knowledge
413
+ else
414
+ base
415
+ end
409
416
  end
410
417
 
411
418
  # Assistant prefill to prime the model's response
@@ -564,41 +571,6 @@ module RubyLLM
564
571
  end
565
572
  end
566
573
 
567
- # Appends auto-generated agent/tool sections to the system prompt
568
- # when agents are declared via the `agents` DSL.
569
- #
570
- # @param base [String, nil] The base system prompt
571
- # @return [String, nil] System prompt with agent sections appended
572
- def append_agents_system_prompt(base)
573
- config = self.class.agents_config
574
- return base if config.agent_entries.empty?
575
-
576
- all_tools = resolved_tools
577
- tools = all_tools.reject { |t| t.respond_to?(:agent_delegate?) && t.agent_delegate? }
578
- agents = all_tools.select { |t| t.respond_to?(:agent_delegate?) && t.agent_delegate? }
579
-
580
- sections = [base].compact
581
-
582
- if tools.any?
583
- sections << "\n## Direct Tools"
584
- sections << "Fast, local operations:\n"
585
- sections << tools.map { |t| "- #{tool_name_for(t)}: #{tool_description_for(t)}" }.join("\n")
586
- end
587
-
588
- if agents.any?
589
- sections << "\n## Agents"
590
- sections << "Specialized AI agents for substantial tasks."
591
- sections << "Multiple agents can work simultaneously when called in the same turn.\n"
592
- sections << agents.map { |t| "- #{tool_name_for(t)}: #{tool_description_for(t)}" }.join("\n")
593
- end
594
-
595
- if config.instructions_text
596
- sections << "\n#{config.instructions_text}"
597
- end
598
-
599
- sections.join("\n")
600
- end
601
-
602
574
  # Returns the description for a tool class
603
575
  #
604
576
  # @param tool [Class] A tool class
@@ -615,58 +587,19 @@ module RubyLLM
615
587
 
616
588
  # Resolves tools for this execution
617
589
  #
618
- # Agent classes in the tools list are automatically wrapped as
619
- # RubyLLM::Tool subclasses via AgentTool.for. Regular tool classes
620
- # pass through unchanged.
621
- #
622
590
  # @return [Array<Class>] Tool classes to use
623
591
  # @raise [ArgumentError] If duplicate tool names are detected
624
592
  def resolved_tools
625
- raw = if self.class.method_defined?(:tools, false)
593
+ all_tools = if self.class.method_defined?(:tools, false)
626
594
  tools
627
595
  else
628
596
  self.class.tools
629
597
  end
630
598
 
631
- regular_tools = raw.map { |tool_class| wrap_if_agent(tool_class) }
632
- agent_tools = resolve_agent_list
633
-
634
- all_tools = regular_tools + agent_tools
635
599
  detect_duplicate_tool_names!(all_tools)
636
600
  all_tools
637
601
  end
638
602
 
639
- # Wraps agent classes from the `agents` DSL as AgentTool instances
640
- # with `agent_delegate?` marker, forwarded params, and description overrides.
641
- #
642
- # @return [Array<Class>] Wrapped agent tool classes
643
- def resolve_agent_list
644
- config = self.class.agents_config
645
- return [] if config.agent_entries.empty?
646
-
647
- config.agent_entries.map do |entry|
648
- agent_class = entry[:agent_class]
649
- AgentTool.for(
650
- agent_class,
651
- forwarded_params: config.forwarded_params,
652
- description_override: config.description_for(agent_class),
653
- delegate: true
654
- )
655
- end
656
- end
657
-
658
- # Wraps an agent class as a tool, or returns the tool class as-is.
659
- #
660
- # @param tool_class [Class] A tool or agent class
661
- # @return [Class] The original or wrapped class
662
- def wrap_if_agent(tool_class)
663
- if tool_class.respond_to?(:ancestors) && tool_class.ancestors.include?(RubyLLM::Agents::BaseAgent)
664
- AgentTool.for(tool_class)
665
- else
666
- tool_class
667
- end
668
- end
669
-
670
603
  # Raises if two tools resolve to the same name.
671
604
  #
672
605
  # @param tools [Array] Resolved tool classes or instances
@@ -806,7 +739,7 @@ module RubyLLM
806
739
  @context = context
807
740
  client = build_client(context)
808
741
 
809
- # Make context available to AgentTool instances during tool execution
742
+ # Make context available to Tool instances during tool execution
810
743
  previous_context = Thread.current[:ruby_llm_agents_caller_context]
811
744
  Thread.current[:ruby_llm_agents_caller_context] = context
812
745
 
@@ -843,9 +776,20 @@ module RubyLLM
843
776
  end
844
777
  client = client.with_temperature(temperature)
845
778
 
846
- client = client.with_instructions(system_prompt) if system_prompt
779
+ use_prompt_caching = self.class.cache_prompts && anthropic_model?(effective_model)
780
+
781
+ if system_prompt
782
+ sys_content = if use_prompt_caching
783
+ RubyLLM::Providers::Anthropic::Content.new(system_prompt, cache: true)
784
+ else
785
+ system_prompt
786
+ end
787
+ client = client.with_instructions(sys_content)
788
+ end
789
+
847
790
  client = client.with_schema(schema) if schema
848
791
  client = client.with_tools(*resolved_tools) if resolved_tools.any?
792
+ apply_tool_prompt_caching(client) if use_prompt_caching && resolved_tools.any?
849
793
  client = setup_tool_tracking(client) if resolved_tools.any?
850
794
  client = apply_messages(client, resolved_messages) if resolved_messages.any?
851
795
  client = client.with_thinking(**resolved_thinking) if resolved_thinking
@@ -959,6 +903,14 @@ module RubyLLM
959
903
  # Store tracked tool calls in context for instrumentation
960
904
  context[:tool_calls] = @tracked_tool_calls if @tracked_tool_calls.any?
961
905
 
906
+ # Capture Anthropic prompt caching metrics
907
+ if response.respond_to?(:cached_tokens) && response.cached_tokens&.positive?
908
+ context[:cached_tokens] = response.cached_tokens
909
+ end
910
+ if response.respond_to?(:cache_creation_tokens) && response.cache_creation_tokens&.positive?
911
+ context[:cache_creation_tokens] = response.cache_creation_tokens
912
+ end
913
+
962
914
  calculate_costs(response, context) if context.input_tokens
963
915
  end
964
916
 
@@ -993,6 +945,37 @@ module RubyLLM
993
945
  nil
994
946
  end
995
947
 
948
+ # Checks whether the given model is served by Anthropic
949
+ #
950
+ # Looks up the model's provider in the registry, falling back to
951
+ # model ID pattern matching when the registry is unavailable.
952
+ #
953
+ # @param model_id [String] The model ID
954
+ # @return [Boolean]
955
+ def anthropic_model?(model_id)
956
+ info = find_model_info(model_id)
957
+ return info.provider.to_s == "anthropic" if info&.provider
958
+
959
+ # Fallback: match common Anthropic model ID patterns
960
+ model_id.to_s.match?(/\Aclaude/i)
961
+ end
962
+
963
+ # Adds cache_control to the last tool so Anthropic caches all tool definitions
964
+ #
965
+ # Uses a singleton method override on the last tool instance so the
966
+ # cache_control is merged into the API payload by RubyLLM's
967
+ # Tools.function_for without mutating the tool class.
968
+ #
969
+ # @param client [RubyLLM::Chat] The chat client with tools already added
970
+ def apply_tool_prompt_caching(client)
971
+ last_tool = client.tools.values.last
972
+ return unless last_tool
973
+
974
+ last_tool.define_singleton_method(:provider_params) do
975
+ super().merge(cache_control: {type: "ephemeral"})
976
+ end
977
+ end
978
+
996
979
  # Builds a Result object from the response
997
980
  #
998
981
  # @param content [Object] The processed content
@@ -1087,35 +1070,18 @@ module RubyLLM
1087
1070
  client
1088
1071
  .on_tool_call do |tool_call|
1089
1072
  start_tracking_tool_call(tool_call)
1090
- name = extract_tool_call_value(tool_call, :name)
1091
- event_type = agent_delegate_name?(name) ? :agent_start : :tool_start
1092
- emit_stream_event(event_type, tool_call_start_data(tool_call))
1073
+ emit_stream_event(:tool_start, tool_call_start_data(tool_call))
1093
1074
  end
1094
1075
  .on_tool_result do |result|
1095
- name = @pending_tool_call&.dig(:name)
1096
- event_type = agent_delegate_name?(name) ? :agent_end : :tool_end
1097
1076
  end_data = tool_call_end_data(result)
1098
1077
  complete_tool_call_tracking(result)
1099
- emit_stream_event(event_type, end_data)
1078
+ emit_stream_event(:tool_end, end_data)
1100
1079
  end
1101
1080
  end
1102
1081
 
1103
- # Checks whether a tool name corresponds to an agent delegate
1104
- #
1105
- # @param tool_name [String] The tool name
1106
- # @return [Boolean]
1107
- def agent_delegate_name?(tool_name)
1108
- return false unless tool_name
1109
-
1110
- resolved_tools.any? do |t|
1111
- t.respond_to?(:agent_delegate?) && t.agent_delegate? &&
1112
- tool_name_for(t) == tool_name
1113
- end
1114
- end
1115
-
1116
1082
  # Emits a StreamEvent to the caller's stream block when stream_events is enabled
1117
1083
  #
1118
- # @param type [Symbol] Event type (:chunk, :tool_start, :tool_end, :agent_start, :agent_end, :error)
1084
+ # @param type [Symbol] Event type (:chunk, :tool_start, :tool_end, :error)
1119
1085
  # @param data [Hash] Event-specific data
1120
1086
  def emit_stream_event(type, data)
1121
1087
  return unless @context&.stream_block && @context.stream_events?
@@ -1123,27 +1089,18 @@ module RubyLLM
1123
1089
  @context.stream_block.call(StreamEvent.new(type, data))
1124
1090
  end
1125
1091
 
1126
- # Builds data hash for a tool_start/agent_start event
1092
+ # Builds data hash for a tool_start event
1127
1093
  #
1128
1094
  # @param tool_call [Object] The tool call object from RubyLLM
1129
1095
  # @return [Hash] Event data
1130
1096
  def tool_call_start_data(tool_call)
1131
- name = extract_tool_call_value(tool_call, :name)
1132
- data = {
1133
- tool_name: name,
1097
+ {
1098
+ tool_name: extract_tool_call_value(tool_call, :name),
1134
1099
  input: extract_tool_call_value(tool_call, :arguments) || {}
1135
- }
1136
-
1137
- if agent_delegate_name?(name)
1138
- agent_tool = resolved_tools.find { |t| tool_name_for(t) == name && t.respond_to?(:agent_class) }
1139
- data[:agent_name] = name
1140
- data[:agent_class] = agent_tool&.agent_class&.name
1141
- end
1142
-
1143
- data.compact
1100
+ }.compact
1144
1101
  end
1145
1102
 
1146
- # Builds data hash for a tool_end/agent_end event from the pending tool call
1103
+ # Builds data hash for a tool_end event from the pending tool call
1147
1104
  #
1148
1105
  # @param result [Object] The tool result
1149
1106
  # @return [Hash] Event data
@@ -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.11.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