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 +4 -4
- data/app/models/ruby_llm/agents/execution.rb +4 -0
- data/app/models/ruby_llm/agents/tool_execution.rb +25 -0
- data/lib/ruby_llm/agents/agent_tool.rb +21 -3
- data/lib/ruby_llm/agents/base_agent.rb +174 -8
- data/lib/ruby_llm/agents/core/configuration.rb +2 -0
- data/lib/ruby_llm/agents/core/errors.rb +3 -0
- data/lib/ruby_llm/agents/core/version.rb +1 -1
- data/lib/ruby_llm/agents/dsl/agents.rb +141 -0
- data/lib/ruby_llm/agents/dsl.rb +1 -0
- data/lib/ruby_llm/agents/pipeline/context.rb +11 -2
- data/lib/ruby_llm/agents/pipeline/middleware/budget.rb +1 -3
- data/lib/ruby_llm/agents/results/base.rb +15 -0
- data/lib/ruby_llm/agents/routing/result.rb +60 -9
- data/lib/ruby_llm/agents/routing.rb +19 -0
- data/lib/ruby_llm/agents/stream_event.rb +66 -0
- data/lib/ruby_llm/agents/tool.rb +169 -0
- data/lib/ruby_llm/agents/tool_context.rb +71 -0
- data/lib/ruby_llm/agents.rb +7 -0
- metadata +6 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2db1aa75b826b63dae9aec7ee8bee4e3597ce9dab4509e49dcd5cc6bdd8b3aa8
|
|
4
|
+
data.tar.gz: aa5b53d9558b0724ba89a39575b2ac2c3ba2eda550c87d3388f3f66cbda6d0e3
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
#
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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.
|
|
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.
|
|
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
|
|
995
|
-
|
|
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
|
|
@@ -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
|
data/lib/ruby_llm/agents/dsl.rb
CHANGED
|
@@ -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.
|
|
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
|
-
#
|
|
9
|
-
# to
|
|
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.
|
|
15
|
-
#
|
|
16
|
-
#
|
|
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:
|
|
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:
|
|
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
|
data/lib/ruby_llm/agents.rb
CHANGED
|
@@ -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.
|
|
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
|