ruby_llm-agents 3.11.0 → 3.13.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 (48) 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/executions_controller.rb +5 -0
  5. data/app/controllers/ruby_llm/agents/tenants_controller.rb +74 -2
  6. data/app/models/ruby_llm/agents/agent_override.rb +47 -0
  7. data/app/models/ruby_llm/agents/execution/analytics.rb +37 -16
  8. data/app/models/ruby_llm/agents/execution.rb +51 -1
  9. data/app/services/ruby_llm/agents/agent_registry.rb +8 -1
  10. data/app/views/layouts/ruby_llm/agents/application.html.erb +4 -2
  11. data/app/views/ruby_llm/agents/agents/_config_agent.html.erb +93 -4
  12. data/app/views/ruby_llm/agents/agents/show.html.erb +17 -2
  13. data/app/views/ruby_llm/agents/analytics/index.html.erb +398 -0
  14. data/app/views/ruby_llm/agents/executions/_audio_player.html.erb +1 -1
  15. data/app/views/ruby_llm/agents/executions/_filters.html.erb +12 -8
  16. data/app/views/ruby_llm/agents/executions/show.html.erb +26 -12
  17. data/app/views/ruby_llm/agents/shared/_filter_dropdown.html.erb +46 -7
  18. data/app/views/ruby_llm/agents/shared/_tenant_filter.html.erb +2 -2
  19. data/app/views/ruby_llm/agents/system_config/show.html.erb +6 -2
  20. data/app/views/ruby_llm/agents/tenants/index.html.erb +3 -2
  21. data/app/views/ruby_llm/agents/tenants/show.html.erb +225 -0
  22. data/config/routes.rb +12 -4
  23. data/lib/generators/ruby_llm_agents/templates/create_overrides_migration.rb.tt +28 -0
  24. data/lib/generators/ruby_llm_agents/templates/initializer.rb.tt +27 -1
  25. data/lib/generators/ruby_llm_agents/templates/skills/AGENTS.md.tt +1 -1
  26. data/lib/generators/ruby_llm_agents/templates/skills/TOOLS.md.tt +1 -1
  27. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +14 -0
  28. data/lib/ruby_llm/agents/base_agent.rb +90 -133
  29. data/lib/ruby_llm/agents/core/base.rb +9 -0
  30. data/lib/ruby_llm/agents/core/configuration.rb +93 -7
  31. data/lib/ruby_llm/agents/core/version.rb +1 -1
  32. data/lib/ruby_llm/agents/dsl/base.rb +131 -4
  33. data/lib/ruby_llm/agents/dsl/knowledge.rb +157 -0
  34. data/lib/ruby_llm/agents/dsl.rb +1 -1
  35. data/lib/ruby_llm/agents/image/concerns/image_operation_execution.rb +9 -5
  36. data/lib/ruby_llm/agents/infrastructure/retention_job.rb +118 -0
  37. data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +32 -20
  38. data/lib/ruby_llm/agents/pipeline/middleware/instrumentation.rb +22 -1
  39. data/lib/ruby_llm/agents/pipeline/middleware/reliability.rb +1 -1
  40. data/lib/ruby_llm/agents/rails/engine.rb +20 -4
  41. data/lib/ruby_llm/agents/routing.rb +28 -5
  42. data/lib/ruby_llm/agents/stream_event.rb +2 -10
  43. data/lib/ruby_llm/agents/tool.rb +1 -1
  44. data/lib/ruby_llm/agents.rb +1 -3
  45. data/lib/tasks/ruby_llm_agents.rake +7 -0
  46. metadata +9 -5
  47. data/lib/ruby_llm/agents/agent_tool.rb +0 -143
  48. 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
@@ -50,9 +50,26 @@ module RubyLLM
50
50
  # When false, executions are logged synchronously.
51
51
  # @return [Boolean] Enable async logging (default: true)
52
52
 
53
+ # @!attribute [rw] soft_purge_after
54
+ # How long to keep full execution details (prompts, responses, tool calls,
55
+ # attempts) before the retention job destroys them. The executions row is
56
+ # preserved so cost, token, and latency analytics remain intact. A
57
+ # truncated copy of the error message is stamped into metadata for
58
+ # long-term error-rate trend analysis.
59
+ # Set to nil to disable soft purging.
60
+ # @return [ActiveSupport::Duration, nil] Soft-purge window (default: 30.days)
61
+
62
+ # @!attribute [rw] hard_purge_after
63
+ # How long to keep the executions row itself before the retention job
64
+ # destroys it entirely. Must be greater than soft_purge_after when both
65
+ # are set. Set to nil to retain executions indefinitely.
66
+ # @return [ActiveSupport::Duration, nil] Hard-purge window (default: 365.days)
67
+
53
68
  # @!attribute [rw] retention_period
54
- # How long to retain execution records before cleanup.
55
- # @return [ActiveSupport::Duration] Retention period (default: 30.days)
69
+ # Deprecated. Alias for hard_purge_after, kept for backward compatibility.
70
+ # Prefer configuring soft_purge_after and hard_purge_after explicitly.
71
+ # @return [ActiveSupport::Duration, nil] Hard-purge window
72
+ # @deprecated Use {#hard_purge_after} instead.
56
73
 
57
74
  # @!attribute [rw] anomaly_cost_threshold
58
75
  # Cost threshold in dollars that triggers anomaly logging.
@@ -379,7 +396,6 @@ module RubyLLM
379
396
  # Attributes without validation (simple accessors)
380
397
  attr_accessor :default_model,
381
398
  :async_logging,
382
- :retention_period,
383
399
  :dashboard_parent_controller,
384
400
  :basic_auth_username,
385
401
  :basic_auth_password,
@@ -448,7 +464,8 @@ module RubyLLM
448
464
  :helicone_pricing_enabled,
449
465
  :helicone_pricing_url,
450
466
  :llmpricing_enabled,
451
- :llmpricing_url
467
+ :llmpricing_url,
468
+ :knowledge_path
452
469
 
453
470
  # Attributes with validation (readers only, custom setters below)
454
471
  attr_reader :default_temperature,
@@ -463,7 +480,9 @@ module RubyLLM
463
480
  :tenant_resolver,
464
481
  :tenant_config_resolver,
465
482
  :default_retries,
466
- :budgets
483
+ :budgets,
484
+ :soft_purge_after,
485
+ :hard_purge_after
467
486
 
468
487
  attr_writer :cache_store
469
488
 
@@ -593,6 +612,44 @@ module RubyLLM
593
612
  @default_embedding_batch_size = value
594
613
  end
595
614
 
615
+ # Sets soft_purge_after with validation
616
+ #
617
+ # @param value [ActiveSupport::Duration, Numeric, nil] Window or nil to disable
618
+ # @raise [ArgumentError] If value is not a Duration/Numeric or nil, or is negative
619
+ def soft_purge_after=(value)
620
+ validate_purge_window!(:soft_purge_after, value)
621
+ @soft_purge_after = value
622
+ validate_purge_ordering!
623
+ end
624
+
625
+ # Sets hard_purge_after with validation
626
+ #
627
+ # @param value [ActiveSupport::Duration, Numeric, nil] Window or nil to disable
628
+ # @raise [ArgumentError] If value is not a Duration/Numeric or nil, or is negative
629
+ def hard_purge_after=(value)
630
+ validate_purge_window!(:hard_purge_after, value)
631
+ @hard_purge_after = value
632
+ validate_purge_ordering!
633
+ end
634
+
635
+ # Deprecated alias for hard_purge_after.
636
+ #
637
+ # @return [ActiveSupport::Duration, nil]
638
+ # @deprecated Use {#hard_purge_after} instead.
639
+ def retention_period
640
+ hard_purge_after
641
+ end
642
+
643
+ # Deprecated setter for retention_period (maps to hard_purge_after).
644
+ #
645
+ # @param value [ActiveSupport::Duration, Numeric, nil]
646
+ # @deprecated Use {#hard_purge_after=} instead.
647
+ def retention_period=(value)
648
+ warn "[DEPRECATION] RubyLLM::Agents config.retention_period is deprecated. " \
649
+ "Use config.hard_purge_after instead (and set config.soft_purge_after for two-tier retention)."
650
+ self.hard_purge_after = value
651
+ end
652
+
596
653
  # Sets default_embedding_dimensions with validation
597
654
  #
598
655
  # @param value [Integer, nil] Dimensions (must be nil or > 0)
@@ -615,7 +672,8 @@ module RubyLLM
615
672
  @default_timeout = 60
616
673
  @cache_store = nil
617
674
  @async_logging = true
618
- @retention_period = 30.days
675
+ @soft_purge_after = 30.days
676
+ @hard_purge_after = 365.days
619
677
  @anomaly_cost_threshold = 5.00
620
678
  @anomaly_duration_threshold = 10_000
621
679
  @dashboard_auth = ->(_controller) { true }
@@ -752,6 +810,9 @@ module RubyLLM
752
810
  @elevenlabs_base_cost_per_1k = nil
753
811
  # ElevenLabs models cache TTL in seconds (6 hours)
754
812
  @elevenlabs_models_cache_ttl = 21_600
813
+
814
+ # Knowledge defaults
815
+ @knowledge_path = "app/agents/knowledge"
755
816
  end
756
817
 
757
818
  # Returns the configured cache store, falling back to Rails.cache
@@ -956,7 +1017,8 @@ module RubyLLM
956
1017
  },
957
1018
  logging: {
958
1019
  async_logging: async_logging,
959
- retention_period: retention_period,
1020
+ soft_purge_after: soft_purge_after,
1021
+ hard_purge_after: hard_purge_after,
960
1022
  job_retry_attempts: job_retry_attempts,
961
1023
  track_executions: track_executions,
962
1024
  track_cache_hits: track_cache_hits,
@@ -1157,6 +1219,30 @@ module RubyLLM
1157
1219
  raise ArgumentError, "budgets[:enforcement] must be :none, :soft, or :hard"
1158
1220
  end
1159
1221
  end
1222
+
1223
+ # Validates a purge-window value (Duration, Numeric seconds, or nil).
1224
+ #
1225
+ # @param attr [Symbol] Attribute name for error messages
1226
+ # @param value [ActiveSupport::Duration, Numeric, nil] Value to validate
1227
+ # @raise [ArgumentError] If value is neither nil nor a non-negative duration/number
1228
+ def validate_purge_window!(attr, value)
1229
+ return if value.nil?
1230
+ return if value.is_a?(ActiveSupport::Duration) && value.to_i >= 0
1231
+ return if value.is_a?(Numeric) && value >= 0
1232
+
1233
+ raise ArgumentError, "#{attr} must be an ActiveSupport::Duration, non-negative Numeric, or nil"
1234
+ end
1235
+
1236
+ # Ensures soft_purge_after is strictly less than hard_purge_after when both are set.
1237
+ #
1238
+ # @raise [ArgumentError] If ordering is violated
1239
+ def validate_purge_ordering!
1240
+ return if @soft_purge_after.nil? || @hard_purge_after.nil?
1241
+ return if @soft_purge_after.to_i < @hard_purge_after.to_i
1242
+
1243
+ raise ArgumentError, "soft_purge_after (#{@soft_purge_after.inspect}) must be less than " \
1244
+ "hard_purge_after (#{@hard_purge_after.inspect})"
1245
+ end
1160
1246
  end
1161
1247
  end
1162
1248
  end
@@ -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.13.0"
8
8
  end
9
9
  end