ruby_llm-agents 3.9.0 → 3.11.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6925b14509a50c3bbf5efb8d0e37f5a38b4d2f52f13f3c7ecd7af588f832c5c1
4
- data.tar.gz: 4332d03ebaf4bd94f3e314656e7f6755475c5ad45661a8c1e76e6e324d37ec2e
3
+ metadata.gz: 2db1aa75b826b63dae9aec7ee8bee4e3597ce9dab4509e49dcd5cc6bdd8b3aa8
4
+ data.tar.gz: aa5b53d9558b0724ba89a39575b2ac2c3ba2eda550c87d3388f3f66cbda6d0e3
5
5
  SHA512:
6
- metadata.gz: e12ea68a14b7c9ec683ae7b07a14d9d422922fc8e5233d7296fd42bc6100097bb929da87b6c52fe55929779a17c04e59253fa490c40147558392a32a79023fa6
7
- data.tar.gz: 9d0d5d249ee0bb6bac741d5c1087ade27ba0a6d032184bb01e1ecfc0d43f79453fbd12cd22e9e4f3eeca7b457bfb242ebca7ebc4e010f44387b9ee8a13bf0865
6
+ metadata.gz: c5537dd90aaceadb9b0948687d3a1ed6635e5c93d8b82a2eae23f805dcb3fc14c32c37aa3fa7bf56a581b48ea72e0d8b0c730f750fc866a6fc4461d784a3ac49
7
+ data.tar.gz: ebbaa8c6c7cf7d27163fbd33c1436cd3332ebbc7069eef6a6b24b50d12e75fd7b962dfaba21faf94d52e8a7ba67a37073c33dc82c91058beb32d486ef3565fc8
@@ -74,6 +74,10 @@ module RubyLLM
74
74
  has_one :detail, class_name: "RubyLLM::Agents::ExecutionDetail",
75
75
  foreign_key: :execution_id, dependent: :destroy
76
76
 
77
+ # Individual tool call records (real-time tracking)
78
+ has_many :tool_executions, class_name: "RubyLLM::Agents::ToolExecution",
79
+ foreign_key: :execution_id, dependent: :destroy
80
+
77
81
  # Delegations so existing code keeps working transparently
78
82
  delegate :system_prompt, :user_prompt, :assistant_prompt, :response, :error_message,
79
83
  :messages_summary, :tool_calls, :attempts, :fallback_chain,
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Tracks individual tool calls within an agent execution.
6
+ #
7
+ # Created in real-time as each tool runs (INSERT on start, UPDATE on complete),
8
+ # enabling live dashboard views and queryable tool-level analytics.
9
+ #
10
+ # @example Querying tool executions
11
+ # execution.tool_executions.where(status: "error")
12
+ # ToolExecution.where(tool_name: "bash").where("duration_ms > ?", 10_000)
13
+ #
14
+ class ToolExecution < ::ActiveRecord::Base
15
+ self.table_name = "ruby_llm_agents_tool_executions"
16
+
17
+ VALID_STATUSES = %w[running success error timed_out cancelled].freeze
18
+
19
+ belongs_to :execution, class_name: "RubyLLM::Agents::Execution"
20
+
21
+ validates :tool_name, presence: true
22
+ validates :status, presence: true, inclusion: {in: VALID_STATUSES}
23
+ end
24
+ end
25
+ end
@@ -11,19 +11,25 @@ module RubyLLM
11
11
  # Wraps an agent class as a RubyLLM::Tool subclass.
12
12
  #
13
13
  # @param agent_class [Class] A BaseAgent subclass
14
+ # @param forwarded_params [Array<Symbol>] Params auto-injected from parent (excluded from LLM schema)
15
+ # @param description_override [String, nil] Custom description for the tool
16
+ # @param delegate [Boolean] Whether this tool represents an agent delegate (from `agents` DSL)
14
17
  # @return [Class] An anonymous RubyLLM::Tool subclass
15
- def self.for(agent_class)
18
+ def self.for(agent_class, forwarded_params: [], description_override: nil, delegate: false)
16
19
  tool_name = derive_tool_name(agent_class)
17
- tool_desc = agent_class.respond_to?(:description) ? agent_class.description : nil
20
+ tool_desc = description_override || (agent_class.respond_to?(:description) ? agent_class.description : nil)
18
21
  agent_params = agent_class.respond_to?(:params) ? agent_class.params : {}
19
22
  captured_agent_class = agent_class
23
+ captured_forwarded = Array(forwarded_params).map(&:to_sym)
24
+ is_delegate = delegate
20
25
 
21
26
  Class.new(RubyLLM::Tool) do
22
27
  description tool_desc if tool_desc
23
28
 
24
- # Map agent params to tool params
29
+ # Map agent params to tool params, excluding forwarded ones
25
30
  agent_params.each do |name, config|
26
31
  next if name.to_s.start_with?("_")
32
+ next if captured_forwarded.include?(name.to_sym)
27
33
 
28
34
  param name,
29
35
  desc: config[:desc] || "#{name} parameter",
@@ -34,6 +40,8 @@ module RubyLLM
34
40
  # Store references on the class
35
41
  define_singleton_method(:agent_class) { captured_agent_class }
36
42
  define_singleton_method(:tool_name) { tool_name }
43
+ define_singleton_method(:agent_delegate?) { is_delegate }
44
+ define_singleton_method(:forwarded_params) { captured_forwarded }
37
45
 
38
46
  # Instance #name returns the derived tool name
39
47
  define_method(:name) { tool_name }
@@ -54,6 +62,16 @@ module RubyLLM
54
62
  call_kwargs[:_parent_execution_id] = caller_ctx.execution_id
55
63
  call_kwargs[:_root_execution_id] = caller_ctx.root_execution_id || caller_ctx.execution_id
56
64
  call_kwargs[:tenant] = caller_ctx.tenant_object if caller_ctx.tenant_id && !call_kwargs.key?(:tenant)
65
+
66
+ # Inject forwarded params from the parent agent instance
67
+ if captured_forwarded.any? && caller_ctx.agent_instance
68
+ captured_forwarded.each do |param_name|
69
+ next if call_kwargs.key?(param_name)
70
+ if caller_ctx.agent_instance.respond_to?(param_name)
71
+ call_kwargs[param_name] = caller_ctx.agent_instance.send(param_name)
72
+ end
73
+ end
74
+ end
57
75
  end
58
76
 
59
77
  result = captured_agent_class.call(**call_kwargs)
@@ -47,6 +47,7 @@ module RubyLLM
47
47
  extend DSL::Reliability
48
48
  extend DSL::Caching
49
49
  extend DSL::Queryable
50
+ extend DSL::Agents
50
51
  include CacheHelper
51
52
 
52
53
  class << self
@@ -168,6 +169,7 @@ module RubyLLM
168
169
  description: description,
169
170
  schema: schema&.respond_to?(:name) ? schema.name : schema&.class&.name,
170
171
  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 },
171
173
  parameters: params.transform_values { |v| v.slice(:type, :required, :default, :desc) },
172
174
  thinking: thinking_config,
173
175
  caching: caching_config,
@@ -387,10 +389,19 @@ module RubyLLM
387
389
  # System prompt for LLM instructions
388
390
  #
389
391
  # If a class-level `system` DSL is defined, it will be used.
390
- # Otherwise returns nil.
392
+ # When `agents` are declared, auto-generated sections describing
393
+ # direct tools and agent delegates are appended.
391
394
  #
392
395
  # @return [String, nil] System instructions, or nil for none
393
396
  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
394
405
  system_config = self.class.system_config
395
406
  return resolve_prompt_from_config(system_config) if system_config
396
407
 
@@ -512,6 +523,7 @@ module RubyLLM
512
523
  tenant: resolve_tenant,
513
524
  skip_cache: @options[:skip_cache],
514
525
  stream_block: (block if streaming_enabled?),
526
+ stream_events: @options[:stream_events] == true,
515
527
  parent_execution_id: @parent_execution_id,
516
528
  root_execution_id: @root_execution_id,
517
529
  debug: @options[:debug],
@@ -552,6 +564,55 @@ module RubyLLM
552
564
  end
553
565
  end
554
566
 
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
+ # Returns the description for a tool class
603
+ #
604
+ # @param tool [Class] A tool class
605
+ # @return [String] The tool's description
606
+ def tool_description_for(tool)
607
+ if tool.respond_to?(:description) && tool.description
608
+ tool.description
609
+ elsif tool.is_a?(Class) && tool < RubyLLM::Tool
610
+ tool.new.respond_to?(:description) ? tool.new.description : tool.name.to_s
611
+ else
612
+ tool.name.to_s
613
+ end
614
+ end
615
+
555
616
  # Resolves tools for this execution
556
617
  #
557
618
  # Agent classes in the tools list are automatically wrapped as
@@ -567,9 +628,31 @@ module RubyLLM
567
628
  self.class.tools
568
629
  end
569
630
 
570
- wrapped = raw.map { |tool_class| wrap_if_agent(tool_class) }
571
- detect_duplicate_tool_names!(wrapped)
572
- wrapped
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
+ detect_duplicate_tool_names!(all_tools)
636
+ all_tools
637
+ end
638
+
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
573
656
  end
574
657
 
575
658
  # Wraps an agent class as a tool, or returns the tool class as-is.
@@ -720,6 +803,7 @@ module RubyLLM
720
803
  # @param context [Pipeline::Context] The execution context
721
804
  # @return [void] Sets context.output with the result
722
805
  def execute(context)
806
+ @context = context
723
807
  client = build_client(context)
724
808
 
725
809
  # Make context available to AgentTool instances during tool execution
@@ -730,6 +814,8 @@ module RubyLLM
730
814
  capture_response(response, context)
731
815
  result = build_result(process_response(response), response, context)
732
816
  context.output = result
817
+ rescue RubyLLM::Agents::CancelledError
818
+ context.output = Result.new(content: nil, cancelled: true)
733
819
  rescue RubyLLM::UnauthorizedError, RubyLLM::ForbiddenError => e
734
820
  raise_with_setup_hint(e, context)
735
821
  rescue RubyLLM::ModelNotFoundError => e
@@ -815,7 +901,11 @@ module RubyLLM
815
901
 
816
902
  response = client.complete do |chunk|
817
903
  first_chunk_at ||= Time.current
818
- context.stream_block.call(chunk)
904
+ if context.stream_events?
905
+ context.stream_block.call(StreamEvent.new(:chunk, {content: chunk.content}))
906
+ else
907
+ context.stream_block.call(chunk)
908
+ end
819
909
  end
820
910
 
821
911
  if first_chunk_at
@@ -841,7 +931,11 @@ module RubyLLM
841
931
 
842
932
  response = client.ask(user_prompt, **ask_opts) do |chunk|
843
933
  first_chunk_at ||= Time.current
844
- context.stream_block.call(chunk)
934
+ if context.stream_events?
935
+ context.stream_block.call(StreamEvent.new(:chunk, {content: chunk.content}))
936
+ else
937
+ context.stream_block.call(chunk)
938
+ end
845
939
  end
846
940
 
847
941
  if first_chunk_at
@@ -991,8 +1085,80 @@ module RubyLLM
991
1085
  # @return [RubyLLM::Chat] Client with tracking callbacks
992
1086
  def setup_tool_tracking(client)
993
1087
  client
994
- .on_tool_call { |tool_call| start_tracking_tool_call(tool_call) }
995
- .on_tool_result { |result| complete_tool_call_tracking(result) }
1088
+ .on_tool_call do |tool_call|
1089
+ 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))
1093
+ end
1094
+ .on_tool_result do |result|
1095
+ name = @pending_tool_call&.dig(:name)
1096
+ event_type = agent_delegate_name?(name) ? :agent_end : :tool_end
1097
+ end_data = tool_call_end_data(result)
1098
+ complete_tool_call_tracking(result)
1099
+ emit_stream_event(event_type, end_data)
1100
+ end
1101
+ end
1102
+
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
+ # Emits a StreamEvent to the caller's stream block when stream_events is enabled
1117
+ #
1118
+ # @param type [Symbol] Event type (:chunk, :tool_start, :tool_end, :agent_start, :agent_end, :error)
1119
+ # @param data [Hash] Event-specific data
1120
+ def emit_stream_event(type, data)
1121
+ return unless @context&.stream_block && @context.stream_events?
1122
+
1123
+ @context.stream_block.call(StreamEvent.new(type, data))
1124
+ end
1125
+
1126
+ # Builds data hash for a tool_start/agent_start event
1127
+ #
1128
+ # @param tool_call [Object] The tool call object from RubyLLM
1129
+ # @return [Hash] Event data
1130
+ def tool_call_start_data(tool_call)
1131
+ name = extract_tool_call_value(tool_call, :name)
1132
+ data = {
1133
+ tool_name: name,
1134
+ 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
1144
+ end
1145
+
1146
+ # Builds data hash for a tool_end/agent_end event from the pending tool call
1147
+ #
1148
+ # @param result [Object] The tool result
1149
+ # @return [Hash] Event data
1150
+ def tool_call_end_data(result)
1151
+ return {} unless @pending_tool_call
1152
+
1153
+ started_at = @pending_tool_call[:started_at]
1154
+ duration_ms = started_at ? ((Time.current - started_at) * 1000).to_i : nil
1155
+ result_data = extract_tool_result(result)
1156
+
1157
+ {
1158
+ tool_name: @pending_tool_call[:name],
1159
+ status: result_data[:status],
1160
+ duration_ms: duration_ms
1161
+ }.compact
996
1162
  end
997
1163
 
998
1164
  # Starts tracking a tool call
@@ -387,6 +387,7 @@ module RubyLLM
387
387
  :default_total_timeout,
388
388
  :default_streaming,
389
389
  :default_tools,
390
+ :default_tool_timeout,
390
391
  :default_thinking,
391
392
  :on_alert,
392
393
  :persist_prompts,
@@ -639,6 +640,7 @@ module RubyLLM
639
640
  # Streaming, tools, and thinking defaults
640
641
  @default_streaming = false
641
642
  @default_tools = []
643
+ @default_tool_timeout = nil
642
644
  @default_thinking = nil
643
645
 
644
646
  # Governance defaults
@@ -29,6 +29,9 @@ module RubyLLM
29
29
  # Raised when an execution cannot be replayed
30
30
  class ReplayError < Error; end
31
31
 
32
+ # Raised when an agent execution is cancelled via on_cancelled
33
+ class CancelledError < Error; end
34
+
32
35
  # Raised when the TTS API returns an error response
33
36
  class SpeechApiError < Error
34
37
  attr_reader :status, :response_body
@@ -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.9.0"
7
+ VERSION = "3.11.0"
8
8
  end
9
9
  end
@@ -0,0 +1,141 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ module DSL
6
+ # DSL module for declaring sub-agents on an agent class.
7
+ #
8
+ # Provides two forms — simple list for common cases, block for
9
+ # per-agent configuration:
10
+ #
11
+ # @example Simple form
12
+ # agents [ModelsAgent, ViewsAgent], forward: [:workspace_path]
13
+ #
14
+ # @example Block form
15
+ # agents do
16
+ # use ModelsAgent, timeout: 180, description: "Build models"
17
+ # use ViewsAgent
18
+ # forward :workspace_path, :project_id
19
+ # parallel true
20
+ # end
21
+ #
22
+ module Agents
23
+ # Declares sub-agents for this agent class.
24
+ #
25
+ # @param list [Array<Class>, nil] Agent classes (simple form)
26
+ # @param options [Hash] Global options (simple form)
27
+ # @yield Configuration block (block form)
28
+ # @return [Array<Hash>] Agent entries
29
+ def agents(list = nil, **options, &block)
30
+ if block
31
+ config = AgentsConfig.new
32
+ config.instance_eval(&block)
33
+ @agents_config = config
34
+ elsif list
35
+ config = AgentsConfig.new
36
+ Array(list).each { |a| config.use(a) }
37
+ options.each { |k, v| config.send(k, *Array(v)) }
38
+ @agents_config = config
39
+ end
40
+ @agents_config&.agent_entries || []
41
+ end
42
+
43
+ # Returns the agents configuration object.
44
+ #
45
+ # @return [AgentsConfig] Configuration (empty if no agents declared)
46
+ def agents_config
47
+ @agents_config ||
48
+ (superclass.respond_to?(:agents_config) ? superclass.agents_config : nil) ||
49
+ AgentsConfig.new
50
+ end
51
+ end
52
+ end
53
+
54
+ # Configuration object for the `agents` DSL.
55
+ #
56
+ # Holds the list of agent entries and global options like
57
+ # `parallel`, `forward`, `max_depth`, and `instructions`.
58
+ #
59
+ class AgentsConfig
60
+ attr_reader :agent_entries
61
+
62
+ def initialize
63
+ @agent_entries = []
64
+ @options = {
65
+ parallel: true,
66
+ timeout: nil,
67
+ max_depth: 5,
68
+ forward: [],
69
+ instructions: nil
70
+ }
71
+ end
72
+
73
+ # Registers an agent class with optional per-agent overrides.
74
+ #
75
+ # @param agent_class [Class] A BaseAgent subclass
76
+ # @param timeout [Integer, nil] Per-agent timeout override
77
+ # @param description [String, nil] Per-agent description override
78
+ def use(agent_class, timeout: nil, description: nil)
79
+ @agent_entries << {
80
+ agent_class: agent_class,
81
+ timeout: timeout,
82
+ description: description
83
+ }
84
+ end
85
+
86
+ # @!group Global Options
87
+
88
+ def parallel(value = true)
89
+ @options[:parallel] = value
90
+ end
91
+
92
+ def timeout(seconds)
93
+ @options[:timeout] = seconds
94
+ end
95
+
96
+ def max_depth(depth)
97
+ @options[:max_depth] = depth
98
+ end
99
+
100
+ def instructions(text)
101
+ @options[:instructions] = text
102
+ end
103
+
104
+ def forward(*params)
105
+ @options[:forward] = params.flatten
106
+ end
107
+
108
+ # @!endgroup
109
+
110
+ # @!group Query Methods
111
+
112
+ def parallel?
113
+ @options[:parallel]
114
+ end
115
+
116
+ def timeout_for(agent_class)
117
+ entry = @agent_entries.find { |e| e[:agent_class] == agent_class }
118
+ entry&.dig(:timeout) || @options[:timeout]
119
+ end
120
+
121
+ def forwarded_params
122
+ @options[:forward]
123
+ end
124
+
125
+ def max_depth_value
126
+ @options[:max_depth]
127
+ end
128
+
129
+ def instructions_text
130
+ @options[:instructions]
131
+ end
132
+
133
+ def description_for(agent_class)
134
+ entry = @agent_entries.find { |e| e[:agent_class] == agent_class }
135
+ entry&.dig(:description)
136
+ end
137
+
138
+ # @!endgroup
139
+ end
140
+ end
141
+ 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/agents"
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
@@ -124,7 +124,7 @@ module RubyLLM
124
124
  tenant.record_execution!(
125
125
  cost: context.total_cost || 0,
126
126
  tokens: context.total_tokens || 0,
127
- error: context.error?
127
+ error: context.failed?
128
128
  )
129
129
  return
130
130
  end
@@ -146,8 +146,6 @@ module RubyLLM
146
146
  tenant_id: context.tenant_id
147
147
  )
148
148
  end
149
- rescue => e
150
- error("Failed to record spend: #{e.message}", context)
151
149
  end
152
150
  end
153
151
  end
@@ -112,6 +112,11 @@ module RubyLLM
112
112
  # @return [String, nil] The agent class that produced this result
113
113
  attr_reader :agent_class_name
114
114
 
115
+ # @!group Cancellation
116
+ # @!attribute [r] cancelled
117
+ # @return [Boolean] Whether the execution was cancelled
118
+ attr_reader :cancelled
119
+
115
120
  # @!group Debug
116
121
  # @!attribute [r] trace
117
122
  # @return [Array<Hash>, nil] Pipeline trace entries (when debug: true)
@@ -173,6 +178,9 @@ module RubyLLM
173
178
  # Tracking
174
179
  @agent_class_name = options[:agent_class_name]
175
180
 
181
+ # Cancellation
182
+ @cancelled = options[:cancelled] || false
183
+
176
184
  # Debug trace
177
185
  @trace = options[:trace]
178
186
 
@@ -222,6 +230,13 @@ module RubyLLM
222
230
  !success?
223
231
  end
224
232
 
233
+ # Returns whether the execution was cancelled
234
+ #
235
+ # @return [Boolean] true if cancelled
236
+ def cancelled?
237
+ cancelled == true
238
+ end
239
+
225
240
  # Returns whether a fallback model was used
226
241
  #
227
242
  # @return [Boolean] true if chosen_model_id differs from model_id
@@ -5,15 +5,23 @@ module RubyLLM
5
5
  module Routing
6
6
  # Wraps a standard Result with routing-specific accessors.
7
7
  #
8
- # Delegates all standard Result methods (tokens, cost, timing, etc.)
9
- # to the underlying result, adding only the route-specific interface.
8
+ # When the route has an `agent:` mapping, the router auto-delegates
9
+ # to that agent. The delegated result is available via `delegated_result`,
10
+ # and `content` returns the delegated agent's content.
10
11
  #
11
- # @example
12
+ # @example Classification only (no agent mapping)
12
13
  # result = SupportRouter.call(message: "I was charged twice")
13
14
  # result.route # => :billing
14
- # result.agent_class # => BillingAgent (if mapped)
15
- # result.success? # => true
16
- # result.total_cost # => 0.0001
15
+ # result.delegated? # => false
16
+ #
17
+ # @example Auto-delegation (with agent mapping)
18
+ # result = SupportRouter.call(message: "I was charged twice")
19
+ # result.route # => :billing
20
+ # result.delegated? # => true
21
+ # result.delegated_to # => BillingAgent
22
+ # result.content # => BillingAgent's response content
23
+ # result.routing_cost # => cost of classification step
24
+ # result.total_cost # => classification + delegation
17
25
  #
18
26
  class RoutingResult < Result
19
27
  # @return [Symbol] The classified route name
@@ -25,6 +33,9 @@ module RubyLLM
25
33
  # @return [String] The raw text response from the LLM
26
34
  attr_reader :raw_response
27
35
 
36
+ # @return [Result, nil] The result from the delegated agent (if auto-delegated)
37
+ attr_reader :delegated_result
38
+
28
39
  # Creates a new RoutingResult by wrapping a base Result with route data.
29
40
  #
30
41
  # @param base_result [Result] The standard Result from BaseAgent execution
@@ -32,14 +43,32 @@ module RubyLLM
32
43
  # @option route_data [Symbol] :route The classified route name
33
44
  # @option route_data [Class, nil] :agent_class Mapped agent class
34
45
  # @option route_data [String] :raw_response Raw LLM text
46
+ # @option route_data [Result, nil] :delegated_result Result from auto-delegation
35
47
  def initialize(base_result:, route_data:)
48
+ @delegated_result = route_data[:delegated_result]
49
+ @routing_cost = base_result.total_cost
50
+
51
+ # When delegated, merge costs from both classification and delegation
52
+ total = if @delegated_result
53
+ (base_result.total_cost || 0) + (@delegated_result.respond_to?(:total_cost) ? @delegated_result.total_cost || 0 : 0)
54
+ else
55
+ base_result.total_cost
56
+ end
57
+
58
+ # Use delegated content when available
59
+ effective_content = if @delegated_result
60
+ @delegated_result.respond_to?(:content) ? @delegated_result.content : route_data
61
+ else
62
+ route_data
63
+ end
64
+
36
65
  super(
37
- content: route_data,
66
+ content: effective_content,
38
67
  input_tokens: base_result.input_tokens,
39
68
  output_tokens: base_result.output_tokens,
40
69
  input_cost: base_result.input_cost,
41
70
  output_cost: base_result.output_cost,
42
- total_cost: base_result.total_cost,
71
+ total_cost: total,
43
72
  model_id: base_result.model_id,
44
73
  chosen_model_id: base_result.chosen_model_id,
45
74
  temperature: base_result.temperature,
@@ -58,6 +87,27 @@ module RubyLLM
58
87
  @raw_response = route_data[:raw_response]
59
88
  end
60
89
 
90
+ # Whether the router auto-delegated to a mapped agent
91
+ #
92
+ # @return [Boolean]
93
+ def delegated?
94
+ !@delegated_result.nil?
95
+ end
96
+
97
+ # The agent class that was auto-invoked (alias for agent_class)
98
+ #
99
+ # @return [Class, nil]
100
+ def delegated_to
101
+ @agent_class if delegated?
102
+ end
103
+
104
+ # Cost of the classification step only (excluding delegation)
105
+ #
106
+ # @return [Float]
107
+ def routing_cost
108
+ @routing_cost || 0
109
+ end
110
+
61
111
  # Converts the result to a hash including routing fields.
62
112
  #
63
113
  # @return [Hash] All result data plus route, agent_class, raw_response
@@ -65,7 +115,8 @@ module RubyLLM
65
115
  super.merge(
66
116
  route: route,
67
117
  agent_class: agent_class&.name,
68
- raw_response: raw_response
118
+ raw_response: raw_response,
119
+ delegated: delegated?
69
120
  )
70
121
  end
71
122
  end
@@ -131,10 +131,29 @@ module RubyLLM
131
131
  end
132
132
 
133
133
  # Override build_result to return a RoutingResult.
134
+ # Auto-delegates to the mapped agent when the route has an `agent:` mapping.
134
135
  def build_result(content, response, context)
135
136
  base = super
137
+
138
+ # Auto-delegate to the mapped agent
139
+ agent_class = content[:agent_class]
140
+ if agent_class
141
+ content[:delegated_result] = agent_class.call(**delegation_params)
142
+ end
143
+
136
144
  RoutingResult.new(base_result: base, route_data: content)
137
145
  end
146
+
147
+ # Builds params to forward to the delegated agent.
148
+ # Forwards original message and custom params, excludes routing internals.
149
+ #
150
+ # @return [Hash] Params for the delegated agent
151
+ def delegation_params
152
+ forward = @options.except(:dry_run, :skip_cache, :debug, :stream_events)
153
+ forward[:_parent_execution_id] = @parent_execution_id if @parent_execution_id
154
+ forward[:_root_execution_id] = @root_execution_id if @root_execution_id
155
+ forward
156
+ end
138
157
  end
139
158
  end
140
159
  end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Typed event emitted during streaming execution.
6
+ #
7
+ # When `stream_events: true` is passed to an agent call, the stream
8
+ # block receives StreamEvent objects instead of raw RubyLLM chunks.
9
+ # This provides visibility into the full execution lifecycle —
10
+ # text chunks, tool invocations, agent delegations, and errors.
11
+ #
12
+ # @example Basic usage
13
+ # MyAgent.call(query: "test", stream_events: true) do |event|
14
+ # case event.type
15
+ # when :chunk then print event.data[:content]
16
+ # when :tool_start then puts "Running #{event.data[:tool_name]}..."
17
+ # when :tool_end then puts "Done (#{event.data[:duration_ms]}ms)"
18
+ # when :agent_start then puts "Delegated to #{event.data[:agent_name]}"
19
+ # when :agent_end then puts "Agent done (#{event.data[:duration_ms]}ms)"
20
+ # when :error then puts "Error: #{event.data[:message]}"
21
+ # end
22
+ # end
23
+ #
24
+ class StreamEvent
25
+ # @return [Symbol] Event type (:chunk, :tool_start, :tool_end,
26
+ # :agent_start, :agent_end, :error)
27
+ attr_reader :type
28
+
29
+ # @return [Hash] Event-specific data
30
+ attr_reader :data
31
+
32
+ # Creates a new StreamEvent
33
+ #
34
+ # @param type [Symbol] The event type
35
+ # @param data [Hash] Event-specific data
36
+ def initialize(type, data = {})
37
+ @type = type
38
+ @data = data
39
+ end
40
+
41
+ # @return [Boolean] Whether this is a text chunk event
42
+ def chunk?
43
+ @type == :chunk
44
+ end
45
+
46
+ # @return [Boolean] Whether this is a tool lifecycle event
47
+ def tool_event?
48
+ @type == :tool_start || @type == :tool_end
49
+ end
50
+
51
+ # @return [Boolean] Whether this is an agent lifecycle event
52
+ def agent_event?
53
+ @type == :agent_start || @type == :agent_end
54
+ end
55
+
56
+ # @return [Boolean] Whether this is an error event
57
+ def error?
58
+ @type == :error
59
+ end
60
+
61
+ def to_h
62
+ {type: @type, data: @data}
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module RubyLLM
6
+ module Agents
7
+ # Base class for tools that need access to the agent's execution context.
8
+ #
9
+ # Inherits from RubyLLM::Tool and adds:
10
+ # - `context` accessor: read agent params, tenant, execution ID
11
+ # - `timeout` DSL: per-tool timeout in seconds
12
+ # - Error handling: exceptions become error strings for the LLM
13
+ # - Tool execution tracking: records each tool call in the database
14
+ #
15
+ # Users implement `execute()` — the standard RubyLLM convention.
16
+ # This class overrides `call()` to wrap execution with its features.
17
+ #
18
+ # @example Defining a tool
19
+ # class BashTool < RubyLLM::Agents::Tool
20
+ # description "Run a shell command"
21
+ # timeout 30
22
+ #
23
+ # param :command, desc: "The command to run", required: true
24
+ #
25
+ # def execute(command:)
26
+ # context.container_id # reads agent param
27
+ # # ... run command ...
28
+ # end
29
+ # end
30
+ #
31
+ # @example Using with an agent
32
+ # class CodingAgent < ApplicationAgent
33
+ # param :container_id, required: true
34
+ # tools [BashTool]
35
+ # end
36
+ #
37
+ # CodingAgent.call(query: "list files", container_id: "abc123")
38
+ #
39
+ class Tool < RubyLLM::Tool
40
+ # The execution context, set before each call.
41
+ # Provides access to agent params, tenant, execution ID.
42
+ #
43
+ # @return [ToolContext, nil]
44
+ attr_reader :context
45
+
46
+ class << self
47
+ # Sets or gets the per-tool timeout in seconds.
48
+ #
49
+ # @param value [Integer, nil] Timeout in seconds (setter)
50
+ # @return [Integer, nil] The configured timeout (getter)
51
+ def timeout(value = nil)
52
+ if value
53
+ @timeout = value
54
+ else
55
+ @timeout
56
+ end
57
+ end
58
+ end
59
+
60
+ # Wraps RubyLLM's call() with context, timeout, tracking, and error handling.
61
+ #
62
+ # RubyLLM's Chat calls tool.call(args) during the tool loop.
63
+ # We set up context, create a tracking record, apply timeout,
64
+ # then delegate to super (which validates args and calls execute).
65
+ #
66
+ # @param args [Hash] Tool arguments from the LLM
67
+ # @return [String, Tool::Halt] The tool result or a Halt signal
68
+ def call(args)
69
+ pipeline_context = Thread.current[:ruby_llm_agents_caller_context]
70
+ @context = pipeline_context ? ToolContext.new(pipeline_context) : nil
71
+
72
+ record = start_tool_tracking(pipeline_context, args)
73
+
74
+ check_cancelled!(pipeline_context)
75
+
76
+ timeout_seconds = self.class.timeout
77
+ timeout_seconds ||= RubyLLM::Agents.configuration.default_tool_timeout
78
+
79
+ result = if timeout_seconds
80
+ Timeout.timeout(timeout_seconds) { super }
81
+ else
82
+ super
83
+ end
84
+
85
+ complete_tool_tracking(record, result, status: "success")
86
+ result
87
+ rescue Timeout::Error
88
+ complete_tool_tracking(record, nil, status: "timed_out", error: "Timed out after #{timeout_seconds}s")
89
+ "TIMEOUT: Tool did not complete within #{timeout_seconds}s."
90
+ rescue RubyLLM::Agents::CancelledError
91
+ complete_tool_tracking(record, nil, status: "cancelled")
92
+ raise # Let cancellation propagate to BaseAgent
93
+ rescue => e
94
+ complete_tool_tracking(record, nil, status: "error", error: e.message)
95
+ "ERROR (#{e.class}): #{e.message}"
96
+ end
97
+
98
+ private
99
+
100
+ # Creates a "running" ToolExecution record before the tool runs.
101
+ # Silently skips if no execution_id or ToolExecution is not available.
102
+ #
103
+ # @param pipeline_context [Pipeline::Context, nil]
104
+ # @param args [Hash] The tool arguments
105
+ # @return [ToolExecution, nil]
106
+ def start_tool_tracking(pipeline_context, args)
107
+ return nil unless pipeline_context&.execution_id
108
+ return nil unless defined?(ToolExecution)
109
+
110
+ @tool_iteration = (@tool_iteration || 0) + 1
111
+
112
+ ToolExecution.create!(
113
+ execution_id: pipeline_context.execution_id,
114
+ tool_name: name,
115
+ iteration: @tool_iteration,
116
+ status: "running",
117
+ input: normalize_input(args),
118
+ started_at: Time.current
119
+ )
120
+ rescue => e
121
+ # Don't let tracking failures break tool execution
122
+ Rails.logger.debug("[RubyLLM::Agents::Tool] Tracking failed: #{e.message}") if defined?(Rails) && Rails.logger
123
+ nil
124
+ end
125
+
126
+ # Updates the ToolExecution record after the tool completes.
127
+ #
128
+ # @param record [ToolExecution, nil]
129
+ # @param result [Object, nil] The tool result
130
+ # @param status [String] Final status
131
+ # @param error [String, nil] Error message
132
+ def complete_tool_tracking(record, result, status:, error: nil)
133
+ return unless record
134
+
135
+ completed_at = Time.current
136
+ duration_ms = record.started_at ? ((completed_at - record.started_at) * 1000).to_i : nil
137
+ output_str = result.is_a?(RubyLLM::Tool::Halt) ? result.content.to_s : result.to_s
138
+
139
+ record.update!(
140
+ status: status,
141
+ output: truncate_output(output_str),
142
+ output_bytes: output_str.bytesize,
143
+ error_message: error,
144
+ completed_at: completed_at,
145
+ duration_ms: duration_ms
146
+ )
147
+ rescue => e
148
+ Rails.logger.debug("[RubyLLM::Agents::Tool] Tracking update failed: #{e.message}") if defined?(Rails) && Rails.logger
149
+ end
150
+
151
+ def check_cancelled!(pipeline_context)
152
+ return unless pipeline_context
153
+ on_cancelled = pipeline_context[:on_cancelled]
154
+ return unless on_cancelled.respond_to?(:call)
155
+ raise CancelledError, "Execution cancelled" if on_cancelled.call
156
+ end
157
+
158
+ def normalize_input(args)
159
+ return {} if args.nil?
160
+ args.respond_to?(:to_h) ? args.to_h : {}
161
+ end
162
+
163
+ def truncate_output(str)
164
+ max = RubyLLM::Agents.configuration.try(:tool_result_max_length) || 10_000
165
+ (str.length > max) ? str[0, max] + "... (truncated)" : str
166
+ end
167
+ end
168
+ end
169
+ end
@@ -0,0 +1,71 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyLLM
4
+ module Agents
5
+ # Read-only wrapper around Pipeline::Context for tool authors.
6
+ #
7
+ # Exposes agent params and execution metadata to tools without
8
+ # leaking pipeline internals. Supports both method-style and
9
+ # hash-style access to agent params.
10
+ #
11
+ # @example Method-style access
12
+ # context.container_id # reads agent param
13
+ # context.tenant_id # fixed attribute
14
+ #
15
+ # @example Hash-style access
16
+ # context[:container_id]
17
+ #
18
+ class ToolContext
19
+ # Execution record ID — links tool calls to the agent execution
20
+ #
21
+ # @return [Integer, nil]
22
+ def id
23
+ @ctx.execution_id
24
+ end
25
+
26
+ # Tenant ID from the pipeline context
27
+ #
28
+ # @return [String, nil]
29
+ def tenant_id
30
+ @ctx.tenant_id
31
+ end
32
+
33
+ # Agent class name
34
+ #
35
+ # @return [String, nil]
36
+ def agent_type
37
+ @ctx.agent_class&.name
38
+ end
39
+
40
+ # Hash-style access to agent params
41
+ #
42
+ # @param key [Symbol, String] The param key
43
+ # @return [Object, nil]
44
+ def [](key)
45
+ @agent_options[key.to_sym] || @agent_options[key.to_s]
46
+ end
47
+
48
+ def initialize(pipeline_context)
49
+ @ctx = pipeline_context
50
+ @agent_options = @ctx.agent_instance&.send(:options) || {}
51
+ end
52
+
53
+ private
54
+
55
+ # Method-style access to agent params
56
+ def method_missing(method_name, *args)
57
+ key = method_name.to_sym
58
+ if @agent_options.key?(key) || @agent_options.key?(key.to_s)
59
+ self[key]
60
+ else
61
+ super
62
+ end
63
+ end
64
+
65
+ def respond_to_missing?(method_name, include_private = false)
66
+ key = method_name.to_sym
67
+ @agent_options.key?(key) || @agent_options.key?(key.to_s) || super
68
+ end
69
+ end
70
+ end
71
+ end
@@ -26,6 +26,13 @@ require_relative "agents/base_agent"
26
26
  # Agent-as-Tool adapter
27
27
  require_relative "agents/agent_tool"
28
28
 
29
+ # Streaming events
30
+ require_relative "agents/stream_event"
31
+
32
+ # Tool base class and context for coding agents
33
+ require_relative "agents/tool_context"
34
+ require_relative "agents/tool"
35
+
29
36
  # Infrastructure - Budget & Utilities
30
37
  require_relative "agents/infrastructure/circuit_breaker"
31
38
  require_relative "agents/infrastructure/budget_tracker"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ruby_llm-agents
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.9.0
4
+ version: 3.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - adham90
@@ -98,6 +98,7 @@ files:
98
98
  - app/models/ruby_llm/agents/tenant/resettable.rb
99
99
  - app/models/ruby_llm/agents/tenant/trackable.rb
100
100
  - app/models/ruby_llm/agents/tenant_budget.rb
101
+ - app/models/ruby_llm/agents/tool_execution.rb
101
102
  - app/services/ruby_llm/agents/agent_registry.rb
102
103
  - app/views/layouts/ruby_llm/agents/application.html.erb
103
104
  - app/views/ruby_llm/agents/agents/_config_agent.html.erb
@@ -239,6 +240,7 @@ files:
239
240
  - lib/ruby_llm/agents/core/llm_tenant.rb
240
241
  - lib/ruby_llm/agents/core/version.rb
241
242
  - lib/ruby_llm/agents/dsl.rb
243
+ - lib/ruby_llm/agents/dsl/agents.rb
242
244
  - lib/ruby_llm/agents/dsl/base.rb
243
245
  - lib/ruby_llm/agents/dsl/caching.rb
244
246
  - lib/ruby_llm/agents/dsl/queryable.rb
@@ -325,7 +327,10 @@ files:
325
327
  - lib/ruby_llm/agents/routing.rb
326
328
  - lib/ruby_llm/agents/routing/class_methods.rb
327
329
  - lib/ruby_llm/agents/routing/result.rb
330
+ - lib/ruby_llm/agents/stream_event.rb
328
331
  - lib/ruby_llm/agents/text/embedder.rb
332
+ - lib/ruby_llm/agents/tool.rb
333
+ - lib/ruby_llm/agents/tool_context.rb
329
334
  - lib/ruby_llm/agents/track_report.rb
330
335
  - lib/ruby_llm/agents/tracker.rb
331
336
  - lib/tasks/ruby_llm_agents.rake