ruby_llm-agents 3.10.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: 00477ed5ab4c97d2b28bb0b697288882744f1c377613224361233e0b3a145273
4
- data.tar.gz: bac4d24f3599fbf7f3045e5ac41b1e85d047d74491b5dcc1dfb1c47cc85a5f60
3
+ metadata.gz: 2db1aa75b826b63dae9aec7ee8bee4e3597ce9dab4509e49dcd5cc6bdd8b3aa8
4
+ data.tar.gz: aa5b53d9558b0724ba89a39575b2ac2c3ba2eda550c87d3388f3f66cbda6d0e3
5
5
  SHA512:
6
- metadata.gz: 29ac67e031dc46d3b0d19f91843e6b2e034f0246445a5ad1d6ce453a2782f41948a92947a3a6e36d177d381756858185f87aa9b07d723123e1af3914f66ab136
7
- data.tar.gz: d479f5076373eea1f30d8c797cd885d0788c7a081860a72fd69ee5dc3f77631860577eab91c14c09dd7f9fda709da41de90fb74b8a626b9094145f071e19259b
6
+ metadata.gz: c5537dd90aaceadb9b0948687d3a1ed6635e5c93d8b82a2eae23f805dcb3fc14c32c37aa3fa7bf56a581b48ea72e0d8b0c730f750fc866a6fc4461d784a3ac49
7
+ data.tar.gz: ebbaa8c6c7cf7d27163fbd33c1436cd3332ebbc7069eef6a6b24b50d12e75fd7b962dfaba21faf94d52e8a7ba67a37073c33dc82c91058beb32d486ef3565fc8
@@ -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
@@ -817,7 +901,11 @@ module RubyLLM
817
901
 
818
902
  response = client.complete do |chunk|
819
903
  first_chunk_at ||= Time.current
820
- 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
821
909
  end
822
910
 
823
911
  if first_chunk_at
@@ -843,7 +931,11 @@ module RubyLLM
843
931
 
844
932
  response = client.ask(user_prompt, **ask_opts) do |chunk|
845
933
  first_chunk_at ||= Time.current
846
- 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
847
939
  end
848
940
 
849
941
  if first_chunk_at
@@ -993,8 +1085,80 @@ module RubyLLM
993
1085
  # @return [RubyLLM::Chat] Client with tracking callbacks
994
1086
  def setup_tool_tracking(client)
995
1087
  client
996
- .on_tool_call { |tool_call| start_tracking_tool_call(tool_call) }
997
- .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
998
1162
  end
999
1163
 
1000
1164
  # Starts tracking a tool call
@@ -4,6 +4,6 @@ module RubyLLM
4
4
  module Agents
5
5
  # Current version of the RubyLLM::Agents gem
6
6
  # @return [String] Semantic version string
7
- VERSION = "3.10.0"
7
+ VERSION = "3.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
@@ -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
@@ -26,6 +26,9 @@ 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
+
29
32
  # Tool base class and context for coding agents
30
33
  require_relative "agents/tool_context"
31
34
  require_relative "agents/tool"
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.10.0
4
+ version: 3.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - adham90
@@ -240,6 +240,7 @@ files:
240
240
  - lib/ruby_llm/agents/core/llm_tenant.rb
241
241
  - lib/ruby_llm/agents/core/version.rb
242
242
  - lib/ruby_llm/agents/dsl.rb
243
+ - lib/ruby_llm/agents/dsl/agents.rb
243
244
  - lib/ruby_llm/agents/dsl/base.rb
244
245
  - lib/ruby_llm/agents/dsl/caching.rb
245
246
  - lib/ruby_llm/agents/dsl/queryable.rb
@@ -326,6 +327,7 @@ files:
326
327
  - lib/ruby_llm/agents/routing.rb
327
328
  - lib/ruby_llm/agents/routing/class_methods.rb
328
329
  - lib/ruby_llm/agents/routing/result.rb
330
+ - lib/ruby_llm/agents/stream_event.rb
329
331
  - lib/ruby_llm/agents/text/embedder.rb
330
332
  - lib/ruby_llm/agents/tool.rb
331
333
  - lib/ruby_llm/agents/tool_context.rb