ruby_llm-agents 0.3.3 → 0.3.5
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/README.md +132 -1263
- data/app/controllers/concerns/ruby_llm/agents/filterable.rb +5 -1
- data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +2 -1
- data/app/controllers/ruby_llm/agents/agents_controller.rb +21 -2
- data/app/controllers/ruby_llm/agents/dashboard_controller.rb +137 -9
- data/app/controllers/ruby_llm/agents/executions_controller.rb +83 -5
- data/app/models/ruby_llm/agents/execution/analytics.rb +103 -12
- data/app/models/ruby_llm/agents/execution/scopes.rb +25 -0
- data/app/models/ruby_llm/agents/execution/workflow.rb +299 -0
- data/app/models/ruby_llm/agents/execution.rb +28 -59
- data/app/models/ruby_llm/agents/tenant_budget.rb +165 -0
- data/app/services/ruby_llm/agents/agent_registry.rb +118 -7
- data/app/views/layouts/ruby_llm/agents/application.html.erb +430 -0
- data/app/views/ruby_llm/agents/agents/_empty_state.html.erb +23 -0
- data/app/views/ruby_llm/agents/agents/_workflow.html.erb +125 -0
- data/app/views/ruby_llm/agents/agents/index.html.erb +93 -0
- data/app/views/ruby_llm/agents/agents/show.html.erb +775 -0
- data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +112 -0
- data/app/views/ruby_llm/agents/dashboard/_budgets_bar.html.erb +75 -0
- data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_execution_item.html.erb +7 -4
- data/app/views/ruby_llm/agents/dashboard/_now_strip.html.erb +84 -0
- data/app/views/ruby_llm/agents/dashboard/_tenant_budget.html.erb +115 -0
- data/app/views/ruby_llm/agents/dashboard/_top_errors.html.erb +49 -0
- data/app/views/ruby_llm/agents/dashboard/index.html.erb +155 -0
- data/app/views/{rubyllm → ruby_llm}/agents/executions/_execution.html.erb +1 -1
- data/app/views/{rubyllm → ruby_llm}/agents/executions/_filters.html.erb +39 -11
- data/app/views/{rubyllm → ruby_llm}/agents/executions/_list.html.erb +19 -9
- data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +101 -0
- data/app/views/ruby_llm/agents/executions/index.html.erb +88 -0
- data/app/views/{rubyllm → ruby_llm}/agents/executions/show.html.erb +260 -200
- data/app/views/{rubyllm → ruby_llm}/agents/settings/show.html.erb +1 -1
- data/app/views/ruby_llm/agents/shared/_breadcrumbs.html.erb +48 -0
- data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +251 -0
- data/app/views/{rubyllm → ruby_llm}/agents/shared/_filter_dropdown.html.erb +1 -1
- data/app/views/ruby_llm/agents/shared/_nav_link.html.erb +27 -0
- data/app/views/{rubyllm → ruby_llm}/agents/shared/_select_dropdown.html.erb +1 -1
- data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_badge.html.erb +1 -1
- data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_dot.html.erb +1 -1
- data/app/views/ruby_llm/agents/shared/_tenant_filter.html.erb +26 -0
- data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +61 -0
- data/config/routes.rb +2 -0
- data/lib/generators/ruby_llm_agents/multi_tenancy_generator.rb +97 -0
- data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +3 -3
- data/lib/generators/ruby_llm_agents/templates/add_tenant_to_executions_migration.rb.tt +23 -0
- data/lib/generators/ruby_llm_agents/templates/add_tool_calls_migration.rb.tt +2 -2
- data/lib/generators/ruby_llm_agents/templates/add_workflow_migration.rb.tt +38 -0
- data/lib/generators/ruby_llm_agents/templates/create_tenant_budgets_migration.rb.tt +45 -0
- data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +17 -5
- data/lib/generators/ruby_llm_agents/upgrade_generator.rb +13 -0
- data/lib/ruby_llm/agents/alert_manager.rb +20 -16
- data/lib/ruby_llm/agents/base/caching.rb +40 -0
- data/lib/ruby_llm/agents/base/cost_calculation.rb +105 -0
- data/lib/ruby_llm/agents/base/dsl.rb +261 -0
- data/lib/ruby_llm/agents/base/execution.rb +258 -0
- data/lib/ruby_llm/agents/base/reliability_execution.rb +136 -0
- data/lib/ruby_llm/agents/base/response_building.rb +86 -0
- data/lib/ruby_llm/agents/base/tool_tracking.rb +57 -0
- data/lib/ruby_llm/agents/base.rb +37 -801
- data/lib/ruby_llm/agents/budget_tracker.rb +250 -139
- data/lib/ruby_llm/agents/cache_helper.rb +98 -0
- data/lib/ruby_llm/agents/circuit_breaker.rb +48 -30
- data/lib/ruby_llm/agents/configuration.rb +40 -1
- data/lib/ruby_llm/agents/engine.rb +65 -1
- data/lib/ruby_llm/agents/inflections.rb +14 -0
- data/lib/ruby_llm/agents/instrumentation.rb +66 -0
- data/lib/ruby_llm/agents/reliability.rb +8 -2
- data/lib/ruby_llm/agents/version.rb +1 -1
- data/lib/ruby_llm/agents/workflow/instrumentation.rb +254 -0
- data/lib/ruby_llm/agents/workflow/parallel.rb +282 -0
- data/lib/ruby_llm/agents/workflow/pipeline.rb +306 -0
- data/lib/ruby_llm/agents/workflow/result.rb +390 -0
- data/lib/ruby_llm/agents/workflow/router.rb +429 -0
- data/lib/ruby_llm/agents/workflow.rb +232 -0
- data/lib/ruby_llm/agents.rb +1 -0
- metadata +57 -75
- data/app/channels/ruby_llm/agents/executions_channel.rb +0 -46
- data/app/javascript/ruby_llm/agents/controllers/filter_controller.js +0 -56
- data/app/javascript/ruby_llm/agents/controllers/index.js +0 -12
- data/app/javascript/ruby_llm/agents/controllers/refresh_controller.js +0 -83
- data/app/views/layouts/rubyllm/agents/application.html.erb +0 -626
- data/app/views/rubyllm/agents/agents/index.html.erb +0 -20
- data/app/views/rubyllm/agents/agents/show.html.erb +0 -772
- data/app/views/rubyllm/agents/dashboard/_budgets_bar.html.erb +0 -165
- data/app/views/rubyllm/agents/dashboard/_now_strip.html.erb +0 -10
- data/app/views/rubyllm/agents/dashboard/_now_strip_values.html.erb +0 -71
- data/app/views/rubyllm/agents/dashboard/index.html.erb +0 -197
- data/app/views/rubyllm/agents/executions/index.html.erb +0 -28
- data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +0 -18
- data/app/views/rubyllm/agents/shared/_executions_table.html.erb +0 -193
- /data/app/views/{rubyllm → ruby_llm}/agents/agents/_agent.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/agents/_version_comparison.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_action_center.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_alerts_feed.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_breaker_strip.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/executions/dry_run.html.erb +0 -0
- /data/app/views/{rubyllm → ruby_llm}/agents/shared/_stat_card.html.erb +0 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
class Base
|
|
6
|
+
# Main execution flow for agents
|
|
7
|
+
#
|
|
8
|
+
# Handles the core execution logic including caching, streaming,
|
|
9
|
+
# client building, and parameter validation.
|
|
10
|
+
module Execution
|
|
11
|
+
# Executes the agent and returns the processed response
|
|
12
|
+
#
|
|
13
|
+
# Handles caching, dry-run mode, and delegates to uncached_call
|
|
14
|
+
# for actual LLM execution.
|
|
15
|
+
#
|
|
16
|
+
# @yield [chunk] Yields chunks when streaming is enabled
|
|
17
|
+
# @yieldparam chunk [RubyLLM::Chunk] A streaming chunk with content
|
|
18
|
+
# @return [Object] The processed LLM response
|
|
19
|
+
def call(&block)
|
|
20
|
+
return dry_run_response if @options[:dry_run]
|
|
21
|
+
return uncached_call(&block) if @options[:skip_cache] || !self.class.cache_enabled?
|
|
22
|
+
|
|
23
|
+
cache_key = agent_cache_key
|
|
24
|
+
|
|
25
|
+
# Check for cache hit BEFORE fetch to record it
|
|
26
|
+
if cache_store.exist?(cache_key)
|
|
27
|
+
started_at = Time.current
|
|
28
|
+
cached_result = cache_store.read(cache_key)
|
|
29
|
+
record_cache_hit_execution(cache_key, cached_result, started_at) if cached_result
|
|
30
|
+
return cached_result
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# Cache miss - execute and store
|
|
34
|
+
cache_store.fetch(cache_key, expires_in: self.class.cache_ttl) do
|
|
35
|
+
uncached_call(&block)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Executes the agent without caching
|
|
40
|
+
#
|
|
41
|
+
# Routes to reliability-enabled execution if configured, otherwise
|
|
42
|
+
# uses simple single-attempt execution.
|
|
43
|
+
#
|
|
44
|
+
# @yield [chunk] Yields chunks when streaming is enabled
|
|
45
|
+
# @return [Object] The processed response
|
|
46
|
+
def uncached_call(&block)
|
|
47
|
+
if reliability_enabled?
|
|
48
|
+
execute_with_reliability(&block)
|
|
49
|
+
else
|
|
50
|
+
instrument_execution { execute_single_attempt(&block) }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Executes a single LLM attempt with timeout
|
|
55
|
+
#
|
|
56
|
+
# @param model_override [String, nil] Optional model to use instead of default
|
|
57
|
+
# @yield [chunk] Yields chunks when streaming is enabled
|
|
58
|
+
# @return [Result] A Result object with processed content and metadata
|
|
59
|
+
def execute_single_attempt(model_override: nil, &block)
|
|
60
|
+
current_client = model_override ? build_client_with_model(model_override) : client
|
|
61
|
+
@execution_started_at ||= Time.current
|
|
62
|
+
reset_accumulated_tool_calls!
|
|
63
|
+
|
|
64
|
+
Timeout.timeout(self.class.timeout) do
|
|
65
|
+
if self.class.streaming && block_given?
|
|
66
|
+
execute_with_streaming(current_client, &block)
|
|
67
|
+
else
|
|
68
|
+
response = current_client.ask(user_prompt, **ask_options)
|
|
69
|
+
extract_tool_calls_from_client(current_client)
|
|
70
|
+
capture_response(response)
|
|
71
|
+
build_result(process_response(response), response)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Executes an LLM request with streaming enabled
|
|
77
|
+
#
|
|
78
|
+
# Yields chunks to the provided block as they arrive and tracks
|
|
79
|
+
# time to first token for latency analysis.
|
|
80
|
+
#
|
|
81
|
+
# @param current_client [RubyLLM::Chat] The configured client
|
|
82
|
+
# @yield [chunk] Yields each chunk as it arrives
|
|
83
|
+
# @yieldparam chunk [RubyLLM::Chunk] A streaming chunk
|
|
84
|
+
# @return [Result] A Result object with processed content and metadata
|
|
85
|
+
def execute_with_streaming(current_client, &block)
|
|
86
|
+
first_chunk_at = nil
|
|
87
|
+
|
|
88
|
+
response = current_client.ask(user_prompt, **ask_options) do |chunk|
|
|
89
|
+
first_chunk_at ||= Time.current
|
|
90
|
+
yield chunk if block_given?
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
if first_chunk_at && @execution_started_at
|
|
94
|
+
@time_to_first_token_ms = ((first_chunk_at - @execution_started_at) * 1000).to_i
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
extract_tool_calls_from_client(current_client)
|
|
98
|
+
capture_response(response)
|
|
99
|
+
build_result(process_response(response), response)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Returns prompt info without making an API call (debug mode)
|
|
103
|
+
#
|
|
104
|
+
# @return [Result] A Result with dry run configuration info
|
|
105
|
+
def dry_run_response
|
|
106
|
+
Result.new(
|
|
107
|
+
content: {
|
|
108
|
+
dry_run: true,
|
|
109
|
+
agent: self.class.name,
|
|
110
|
+
model: model,
|
|
111
|
+
temperature: temperature,
|
|
112
|
+
timeout: self.class.timeout,
|
|
113
|
+
system_prompt: system_prompt,
|
|
114
|
+
user_prompt: user_prompt,
|
|
115
|
+
attachments: @options[:with],
|
|
116
|
+
schema: schema&.class&.name,
|
|
117
|
+
streaming: self.class.streaming,
|
|
118
|
+
tools: resolved_tools.map { |t| t.respond_to?(:name) ? t.name : t.to_s }
|
|
119
|
+
},
|
|
120
|
+
model_id: model,
|
|
121
|
+
temperature: temperature,
|
|
122
|
+
streaming: self.class.streaming
|
|
123
|
+
)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
# Resolves tools for this execution
|
|
127
|
+
#
|
|
128
|
+
# Checks for instance method override first (for dynamic tools),
|
|
129
|
+
# then falls back to class-level DSL configuration. This allows
|
|
130
|
+
# agents to define tools dynamically based on runtime context.
|
|
131
|
+
#
|
|
132
|
+
# @return [Array<Class>] Tool classes to use
|
|
133
|
+
def resolved_tools
|
|
134
|
+
# Check if instance defines tools method (not inherited from class singleton)
|
|
135
|
+
if self.class.instance_methods(false).include?(:tools)
|
|
136
|
+
tools
|
|
137
|
+
else
|
|
138
|
+
self.class.tools
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# Resolves messages for this execution
|
|
143
|
+
#
|
|
144
|
+
# Priority order:
|
|
145
|
+
# 1. @override_messages (set via with_messages)
|
|
146
|
+
# 2. :messages option passed at call time
|
|
147
|
+
# 3. messages template method defined in subclass
|
|
148
|
+
#
|
|
149
|
+
# @return [Array<Hash>] Messages to apply to conversation
|
|
150
|
+
def resolved_messages
|
|
151
|
+
return @override_messages if @override_messages&.any?
|
|
152
|
+
return @options[:messages] if @options[:messages]&.any?
|
|
153
|
+
|
|
154
|
+
messages
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# Returns the consolidated reliability configuration for this agent instance
|
|
158
|
+
#
|
|
159
|
+
# @return [Hash] Reliability config with :retries, :fallback_models, :total_timeout, :circuit_breaker
|
|
160
|
+
def reliability_config
|
|
161
|
+
default_retries = RubyLLM::Agents.configuration.default_retries
|
|
162
|
+
{
|
|
163
|
+
retries: self.class.retries || default_retries,
|
|
164
|
+
fallback_models: self.class.fallback_models,
|
|
165
|
+
total_timeout: self.class.total_timeout,
|
|
166
|
+
circuit_breaker: self.class.circuit_breaker_config
|
|
167
|
+
}
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
# Returns whether any reliability features are enabled for this agent
|
|
171
|
+
#
|
|
172
|
+
# @return [Boolean] true if retries, fallbacks, or circuit breaker is configured
|
|
173
|
+
def reliability_enabled?
|
|
174
|
+
config = reliability_config
|
|
175
|
+
(config[:retries]&.dig(:max) || 0) > 0 ||
|
|
176
|
+
config[:fallback_models]&.any? ||
|
|
177
|
+
config[:circuit_breaker].present?
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Returns options to pass to the ask method
|
|
181
|
+
#
|
|
182
|
+
# Currently supports :with for attachments (images, PDFs, etc.)
|
|
183
|
+
#
|
|
184
|
+
# @return [Hash] Options for the ask call
|
|
185
|
+
def ask_options
|
|
186
|
+
opts = {}
|
|
187
|
+
opts[:with] = @options[:with] if @options[:with]
|
|
188
|
+
opts
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
# Validates that all required parameters are present
|
|
192
|
+
#
|
|
193
|
+
# @raise [ArgumentError] If required parameters are missing
|
|
194
|
+
# @return [void]
|
|
195
|
+
def validate_required_params!
|
|
196
|
+
required = self.class.params.select { |_, v| v[:required] }.keys
|
|
197
|
+
missing = required.reject { |p| @options.key?(p) || @options.key?(p.to_s) }
|
|
198
|
+
raise ArgumentError, "#{self.class} missing required params: #{missing.join(', ')}" if missing.any?
|
|
199
|
+
end
|
|
200
|
+
|
|
201
|
+
# Builds and configures the RubyLLM client
|
|
202
|
+
#
|
|
203
|
+
# @return [RubyLLM::Chat] Configured chat client
|
|
204
|
+
def build_client
|
|
205
|
+
client = RubyLLM.chat
|
|
206
|
+
.with_model(model)
|
|
207
|
+
.with_temperature(temperature)
|
|
208
|
+
client = client.with_instructions(system_prompt) if system_prompt
|
|
209
|
+
client = client.with_schema(schema) if schema
|
|
210
|
+
client = client.with_tools(*resolved_tools) if resolved_tools.any?
|
|
211
|
+
client = apply_messages(client, resolved_messages) if resolved_messages.any?
|
|
212
|
+
client
|
|
213
|
+
end
|
|
214
|
+
|
|
215
|
+
# Builds a client with a specific model
|
|
216
|
+
#
|
|
217
|
+
# @param model_id [String] The model identifier
|
|
218
|
+
# @return [RubyLLM::Chat] Configured chat client
|
|
219
|
+
def build_client_with_model(model_id)
|
|
220
|
+
client = RubyLLM.chat
|
|
221
|
+
.with_model(model_id)
|
|
222
|
+
.with_temperature(temperature)
|
|
223
|
+
client = client.with_instructions(system_prompt) if system_prompt
|
|
224
|
+
client = client.with_schema(schema) if schema
|
|
225
|
+
client = client.with_tools(*resolved_tools) if resolved_tools.any?
|
|
226
|
+
client = apply_messages(client, resolved_messages) if resolved_messages.any?
|
|
227
|
+
client
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Applies conversation history to the client
|
|
231
|
+
#
|
|
232
|
+
# @param client [RubyLLM::Chat] The chat client
|
|
233
|
+
# @param msgs [Array<Hash>] Messages with :role and :content keys
|
|
234
|
+
# @return [RubyLLM::Chat] Client with messages applied
|
|
235
|
+
def apply_messages(client, msgs)
|
|
236
|
+
msgs.reduce(client) do |c, message|
|
|
237
|
+
c.with_message(message[:role].to_s, message[:content])
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
# Builds a client with pre-populated conversation history
|
|
242
|
+
#
|
|
243
|
+
# @deprecated Use resolved_messages and apply_messages instead.
|
|
244
|
+
# Override the messages template method or pass messages: option to call.
|
|
245
|
+
# @param messages [Array<Hash>] Messages with :role and :content keys
|
|
246
|
+
# @return [RubyLLM::Chat] Client with messages added
|
|
247
|
+
# @example
|
|
248
|
+
# build_client_with_messages([
|
|
249
|
+
# { role: "user", content: "Hello" },
|
|
250
|
+
# { role: "assistant", content: "Hi there!" }
|
|
251
|
+
# ])
|
|
252
|
+
def build_client_with_messages(messages)
|
|
253
|
+
apply_messages(build_client, messages)
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
class Base
|
|
6
|
+
# Reliability execution with retry/fallback/circuit breaker support
|
|
7
|
+
#
|
|
8
|
+
# Handles executing agents with automatic retries, model fallbacks,
|
|
9
|
+
# circuit breaker protection, and budget enforcement.
|
|
10
|
+
module ReliabilityExecution
|
|
11
|
+
# Executes the agent with retry/fallback/circuit breaker support
|
|
12
|
+
#
|
|
13
|
+
# @yield [chunk] Yields chunks when streaming is enabled
|
|
14
|
+
# @return [Object] The processed response
|
|
15
|
+
# @raise [Reliability::AllModelsExhaustedError] If all models fail
|
|
16
|
+
# @raise [Reliability::BudgetExceededError] If budget limits exceeded
|
|
17
|
+
# @raise [Reliability::TotalTimeoutError] If total timeout exceeded
|
|
18
|
+
def execute_with_reliability(&block)
|
|
19
|
+
config = reliability_config
|
|
20
|
+
models_to_try = [model, *config[:fallback_models]].uniq
|
|
21
|
+
total_deadline = config[:total_timeout] ? Time.current + config[:total_timeout] : nil
|
|
22
|
+
started_at = Time.current
|
|
23
|
+
|
|
24
|
+
# Get current tenant_id for multi-tenancy support
|
|
25
|
+
global_config = RubyLLM::Agents.configuration
|
|
26
|
+
tenant_id = global_config.multi_tenancy_enabled? ? global_config.current_tenant_id : nil
|
|
27
|
+
|
|
28
|
+
# Pre-check budget (tenant_id is resolved automatically if not passed)
|
|
29
|
+
BudgetTracker.check_budget!(self.class.name, tenant_id: tenant_id) if global_config.budgets_enabled?
|
|
30
|
+
|
|
31
|
+
instrument_execution_with_attempts(models_to_try: models_to_try) do |attempt_tracker|
|
|
32
|
+
last_error = nil
|
|
33
|
+
|
|
34
|
+
models_to_try.each do |current_model|
|
|
35
|
+
# Check circuit breaker (with tenant isolation if enabled)
|
|
36
|
+
breaker = get_circuit_breaker(current_model, tenant_id: tenant_id)
|
|
37
|
+
if breaker&.open?
|
|
38
|
+
attempt_tracker.record_short_circuit(current_model)
|
|
39
|
+
next
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
retries_remaining = config[:retries]&.dig(:max) || 0
|
|
43
|
+
attempt_index = 0
|
|
44
|
+
|
|
45
|
+
loop do
|
|
46
|
+
# Check total timeout
|
|
47
|
+
if total_deadline && Time.current > total_deadline
|
|
48
|
+
elapsed = Time.current - started_at
|
|
49
|
+
raise Reliability::TotalTimeoutError.new(config[:total_timeout], elapsed)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
attempt = attempt_tracker.start_attempt(current_model)
|
|
53
|
+
|
|
54
|
+
begin
|
|
55
|
+
result = execute_single_attempt(model_override: current_model, &block)
|
|
56
|
+
attempt_tracker.complete_attempt(attempt, success: true, response: @last_response)
|
|
57
|
+
|
|
58
|
+
# Record success in circuit breaker
|
|
59
|
+
breaker&.record_success!
|
|
60
|
+
|
|
61
|
+
# Record budget spend (with tenant isolation if enabled)
|
|
62
|
+
if @last_response && global_config.budgets_enabled?
|
|
63
|
+
record_attempt_cost(attempt_tracker, tenant_id: tenant_id)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# Use throw instead of return to allow instrument_execution_with_attempts
|
|
67
|
+
# to properly complete the execution record before returning
|
|
68
|
+
throw :execution_success, result
|
|
69
|
+
|
|
70
|
+
rescue *retryable_errors(config) => e
|
|
71
|
+
last_error = e
|
|
72
|
+
attempt_tracker.complete_attempt(attempt, success: false, error: e)
|
|
73
|
+
breaker&.record_failure!
|
|
74
|
+
|
|
75
|
+
if retries_remaining > 0 && !past_deadline?(total_deadline)
|
|
76
|
+
retries_remaining -= 1
|
|
77
|
+
attempt_index += 1
|
|
78
|
+
retries_config = config[:retries] || {}
|
|
79
|
+
delay = Reliability.calculate_backoff(
|
|
80
|
+
strategy: retries_config[:backoff] || :exponential,
|
|
81
|
+
base: retries_config[:base] || 0.4,
|
|
82
|
+
max_delay: retries_config[:max_delay] || 3.0,
|
|
83
|
+
attempt: attempt_index
|
|
84
|
+
)
|
|
85
|
+
sleep(delay)
|
|
86
|
+
else
|
|
87
|
+
break # Move to next model
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
# Non-retryable error - record and move to next model
|
|
92
|
+
last_error = e
|
|
93
|
+
attempt_tracker.complete_attempt(attempt, success: false, error: e)
|
|
94
|
+
breaker&.record_failure!
|
|
95
|
+
break
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# All models exhausted
|
|
101
|
+
raise Reliability::AllModelsExhaustedError.new(models_to_try, last_error)
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Returns the list of retryable error classes
|
|
106
|
+
#
|
|
107
|
+
# @param config [Hash] Reliability configuration
|
|
108
|
+
# @return [Array<Class>] Error classes to retry on
|
|
109
|
+
def retryable_errors(config)
|
|
110
|
+
custom_errors = config[:retries]&.dig(:on) || []
|
|
111
|
+
Reliability.default_retryable_errors + custom_errors
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Checks if the total deadline has passed
|
|
115
|
+
#
|
|
116
|
+
# @param deadline [Time, nil] The deadline
|
|
117
|
+
# @return [Boolean] true if past deadline
|
|
118
|
+
def past_deadline?(deadline)
|
|
119
|
+
deadline && Time.current > deadline
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Gets or creates a circuit breaker for a model
|
|
123
|
+
#
|
|
124
|
+
# @param model_id [String] The model identifier
|
|
125
|
+
# @param tenant_id [String, nil] Optional tenant identifier for multi-tenant isolation
|
|
126
|
+
# @return [CircuitBreaker, nil] The circuit breaker or nil if not configured
|
|
127
|
+
def get_circuit_breaker(model_id, tenant_id: nil)
|
|
128
|
+
config = reliability_config[:circuit_breaker]
|
|
129
|
+
return nil unless config
|
|
130
|
+
|
|
131
|
+
CircuitBreaker.from_config(self.class.name, model_id, config, tenant_id: tenant_id)
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
end
|
|
136
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
class Base
|
|
6
|
+
# Result object construction from LLM responses
|
|
7
|
+
#
|
|
8
|
+
# Handles building Result objects with full execution metadata
|
|
9
|
+
# including tokens, costs, timing, and tool calls.
|
|
10
|
+
module ResponseBuilding
|
|
11
|
+
# Builds a Result object from processed content and response metadata
|
|
12
|
+
#
|
|
13
|
+
# @param content [Hash, String] The processed response content
|
|
14
|
+
# @param response [RubyLLM::Message] The raw LLM response
|
|
15
|
+
# @return [Result] A Result object with full execution metadata
|
|
16
|
+
def build_result(content, response)
|
|
17
|
+
completed_at = Time.current
|
|
18
|
+
input_tokens = result_response_value(response, :input_tokens)
|
|
19
|
+
output_tokens = result_response_value(response, :output_tokens)
|
|
20
|
+
response_model_id = result_response_value(response, :model_id)
|
|
21
|
+
|
|
22
|
+
Result.new(
|
|
23
|
+
content: content,
|
|
24
|
+
input_tokens: input_tokens,
|
|
25
|
+
output_tokens: output_tokens,
|
|
26
|
+
cached_tokens: result_response_value(response, :cached_tokens, 0),
|
|
27
|
+
cache_creation_tokens: result_response_value(response, :cache_creation_tokens, 0),
|
|
28
|
+
model_id: model,
|
|
29
|
+
chosen_model_id: response_model_id || model,
|
|
30
|
+
temperature: temperature,
|
|
31
|
+
started_at: @execution_started_at,
|
|
32
|
+
completed_at: completed_at,
|
|
33
|
+
duration_ms: result_duration_ms(completed_at),
|
|
34
|
+
time_to_first_token_ms: @time_to_first_token_ms,
|
|
35
|
+
finish_reason: result_finish_reason(response),
|
|
36
|
+
streaming: self.class.streaming,
|
|
37
|
+
input_cost: result_input_cost(input_tokens, response_model_id),
|
|
38
|
+
output_cost: result_output_cost(output_tokens, response_model_id),
|
|
39
|
+
total_cost: result_total_cost(input_tokens, output_tokens, response_model_id),
|
|
40
|
+
tool_calls: @accumulated_tool_calls,
|
|
41
|
+
tool_calls_count: @accumulated_tool_calls.size
|
|
42
|
+
)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Safely extracts a value from the response object
|
|
46
|
+
#
|
|
47
|
+
# @param response [Object] The response object
|
|
48
|
+
# @param method [Symbol] The method to call
|
|
49
|
+
# @param default [Object] Default value if method doesn't exist
|
|
50
|
+
# @return [Object] The extracted value or default
|
|
51
|
+
def result_response_value(response, method, default = nil)
|
|
52
|
+
return default unless response.respond_to?(method)
|
|
53
|
+
response.send(method) || default
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Calculates execution duration in milliseconds
|
|
57
|
+
#
|
|
58
|
+
# @param completed_at [Time] When execution completed
|
|
59
|
+
# @return [Integer, nil] Duration in ms or nil
|
|
60
|
+
def result_duration_ms(completed_at)
|
|
61
|
+
return nil unless @execution_started_at
|
|
62
|
+
((completed_at - @execution_started_at) * 1000).to_i
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Extracts finish reason from response
|
|
66
|
+
#
|
|
67
|
+
# @param response [Object] The response object
|
|
68
|
+
# @return [String, nil] Normalized finish reason
|
|
69
|
+
def result_finish_reason(response)
|
|
70
|
+
reason = result_response_value(response, :finish_reason) ||
|
|
71
|
+
result_response_value(response, :stop_reason)
|
|
72
|
+
return nil unless reason
|
|
73
|
+
|
|
74
|
+
# Normalize to standard values
|
|
75
|
+
case reason.to_s.downcase
|
|
76
|
+
when "stop", "end_turn" then "stop"
|
|
77
|
+
when "length", "max_tokens" then "length"
|
|
78
|
+
when "content_filter", "safety" then "content_filter"
|
|
79
|
+
when "tool_calls", "tool_use" then "tool_calls"
|
|
80
|
+
else "other"
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyLLM
|
|
4
|
+
module Agents
|
|
5
|
+
class Base
|
|
6
|
+
# Tool call tracking for agent executions
|
|
7
|
+
#
|
|
8
|
+
# Handles accumulating and serializing tool calls made during
|
|
9
|
+
# an agent's execution cycle.
|
|
10
|
+
module ToolTracking
|
|
11
|
+
# Resets accumulated tool calls for a new execution
|
|
12
|
+
#
|
|
13
|
+
# @return [void]
|
|
14
|
+
def reset_accumulated_tool_calls!
|
|
15
|
+
@accumulated_tool_calls = []
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Extracts tool calls from all assistant messages in the conversation
|
|
19
|
+
#
|
|
20
|
+
# RubyLLM handles tool call loops internally. After ask() completes,
|
|
21
|
+
# the conversation history contains all intermediate assistant messages
|
|
22
|
+
# that had tool_calls. This method extracts those tool calls.
|
|
23
|
+
#
|
|
24
|
+
# @param client [RubyLLM::Chat] The chat client with conversation history
|
|
25
|
+
# @return [void]
|
|
26
|
+
def extract_tool_calls_from_client(client)
|
|
27
|
+
return unless client.respond_to?(:messages)
|
|
28
|
+
|
|
29
|
+
client.messages.each do |message|
|
|
30
|
+
next unless message.role == :assistant
|
|
31
|
+
next unless message.respond_to?(:tool_calls) && message.tool_calls.present?
|
|
32
|
+
|
|
33
|
+
message.tool_calls.each_value do |tool_call|
|
|
34
|
+
@accumulated_tool_calls << serialize_tool_call(tool_call)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# Serializes a single tool call to a hash
|
|
40
|
+
#
|
|
41
|
+
# @param tool_call [Object] The tool call object
|
|
42
|
+
# @return [Hash] Serialized tool call
|
|
43
|
+
def serialize_tool_call(tool_call)
|
|
44
|
+
if tool_call.respond_to?(:to_h)
|
|
45
|
+
tool_call.to_h.transform_keys(&:to_s)
|
|
46
|
+
else
|
|
47
|
+
{
|
|
48
|
+
"id" => tool_call.id,
|
|
49
|
+
"name" => tool_call.name,
|
|
50
|
+
"arguments" => tool_call.arguments
|
|
51
|
+
}
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|