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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +132 -1263
  3. data/app/controllers/concerns/ruby_llm/agents/filterable.rb +5 -1
  4. data/app/controllers/concerns/ruby_llm/agents/paginatable.rb +2 -1
  5. data/app/controllers/ruby_llm/agents/agents_controller.rb +21 -2
  6. data/app/controllers/ruby_llm/agents/dashboard_controller.rb +137 -9
  7. data/app/controllers/ruby_llm/agents/executions_controller.rb +83 -5
  8. data/app/models/ruby_llm/agents/execution/analytics.rb +103 -12
  9. data/app/models/ruby_llm/agents/execution/scopes.rb +25 -0
  10. data/app/models/ruby_llm/agents/execution/workflow.rb +299 -0
  11. data/app/models/ruby_llm/agents/execution.rb +28 -59
  12. data/app/models/ruby_llm/agents/tenant_budget.rb +165 -0
  13. data/app/services/ruby_llm/agents/agent_registry.rb +118 -7
  14. data/app/views/layouts/ruby_llm/agents/application.html.erb +430 -0
  15. data/app/views/ruby_llm/agents/agents/_empty_state.html.erb +23 -0
  16. data/app/views/ruby_llm/agents/agents/_workflow.html.erb +125 -0
  17. data/app/views/ruby_llm/agents/agents/index.html.erb +93 -0
  18. data/app/views/ruby_llm/agents/agents/show.html.erb +775 -0
  19. data/app/views/ruby_llm/agents/dashboard/_agent_comparison.html.erb +112 -0
  20. data/app/views/ruby_llm/agents/dashboard/_budgets_bar.html.erb +75 -0
  21. data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_execution_item.html.erb +7 -4
  22. data/app/views/ruby_llm/agents/dashboard/_now_strip.html.erb +84 -0
  23. data/app/views/ruby_llm/agents/dashboard/_tenant_budget.html.erb +115 -0
  24. data/app/views/ruby_llm/agents/dashboard/_top_errors.html.erb +49 -0
  25. data/app/views/ruby_llm/agents/dashboard/index.html.erb +155 -0
  26. data/app/views/{rubyllm → ruby_llm}/agents/executions/_execution.html.erb +1 -1
  27. data/app/views/{rubyllm → ruby_llm}/agents/executions/_filters.html.erb +39 -11
  28. data/app/views/{rubyllm → ruby_llm}/agents/executions/_list.html.erb +19 -9
  29. data/app/views/ruby_llm/agents/executions/_workflow_summary.html.erb +101 -0
  30. data/app/views/ruby_llm/agents/executions/index.html.erb +88 -0
  31. data/app/views/{rubyllm → ruby_llm}/agents/executions/show.html.erb +260 -200
  32. data/app/views/{rubyllm → ruby_llm}/agents/settings/show.html.erb +1 -1
  33. data/app/views/ruby_llm/agents/shared/_breadcrumbs.html.erb +48 -0
  34. data/app/views/ruby_llm/agents/shared/_executions_table.html.erb +251 -0
  35. data/app/views/{rubyllm → ruby_llm}/agents/shared/_filter_dropdown.html.erb +1 -1
  36. data/app/views/ruby_llm/agents/shared/_nav_link.html.erb +27 -0
  37. data/app/views/{rubyllm → ruby_llm}/agents/shared/_select_dropdown.html.erb +1 -1
  38. data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_badge.html.erb +1 -1
  39. data/app/views/{rubyllm → ruby_llm}/agents/shared/_status_dot.html.erb +1 -1
  40. data/app/views/ruby_llm/agents/shared/_tenant_filter.html.erb +26 -0
  41. data/app/views/ruby_llm/agents/shared/_workflow_type_badge.html.erb +61 -0
  42. data/config/routes.rb +2 -0
  43. data/lib/generators/ruby_llm_agents/multi_tenancy_generator.rb +97 -0
  44. data/lib/generators/ruby_llm_agents/templates/add_attempts_migration.rb.tt +3 -3
  45. data/lib/generators/ruby_llm_agents/templates/add_tenant_to_executions_migration.rb.tt +23 -0
  46. data/lib/generators/ruby_llm_agents/templates/add_tool_calls_migration.rb.tt +2 -2
  47. data/lib/generators/ruby_llm_agents/templates/add_workflow_migration.rb.tt +38 -0
  48. data/lib/generators/ruby_llm_agents/templates/create_tenant_budgets_migration.rb.tt +45 -0
  49. data/lib/generators/ruby_llm_agents/templates/migration.rb.tt +17 -5
  50. data/lib/generators/ruby_llm_agents/upgrade_generator.rb +13 -0
  51. data/lib/ruby_llm/agents/alert_manager.rb +20 -16
  52. data/lib/ruby_llm/agents/base/caching.rb +40 -0
  53. data/lib/ruby_llm/agents/base/cost_calculation.rb +105 -0
  54. data/lib/ruby_llm/agents/base/dsl.rb +261 -0
  55. data/lib/ruby_llm/agents/base/execution.rb +258 -0
  56. data/lib/ruby_llm/agents/base/reliability_execution.rb +136 -0
  57. data/lib/ruby_llm/agents/base/response_building.rb +86 -0
  58. data/lib/ruby_llm/agents/base/tool_tracking.rb +57 -0
  59. data/lib/ruby_llm/agents/base.rb +37 -801
  60. data/lib/ruby_llm/agents/budget_tracker.rb +250 -139
  61. data/lib/ruby_llm/agents/cache_helper.rb +98 -0
  62. data/lib/ruby_llm/agents/circuit_breaker.rb +48 -30
  63. data/lib/ruby_llm/agents/configuration.rb +40 -1
  64. data/lib/ruby_llm/agents/engine.rb +65 -1
  65. data/lib/ruby_llm/agents/inflections.rb +14 -0
  66. data/lib/ruby_llm/agents/instrumentation.rb +66 -0
  67. data/lib/ruby_llm/agents/reliability.rb +8 -2
  68. data/lib/ruby_llm/agents/version.rb +1 -1
  69. data/lib/ruby_llm/agents/workflow/instrumentation.rb +254 -0
  70. data/lib/ruby_llm/agents/workflow/parallel.rb +282 -0
  71. data/lib/ruby_llm/agents/workflow/pipeline.rb +306 -0
  72. data/lib/ruby_llm/agents/workflow/result.rb +390 -0
  73. data/lib/ruby_llm/agents/workflow/router.rb +429 -0
  74. data/lib/ruby_llm/agents/workflow.rb +232 -0
  75. data/lib/ruby_llm/agents.rb +1 -0
  76. metadata +57 -75
  77. data/app/channels/ruby_llm/agents/executions_channel.rb +0 -46
  78. data/app/javascript/ruby_llm/agents/controllers/filter_controller.js +0 -56
  79. data/app/javascript/ruby_llm/agents/controllers/index.js +0 -12
  80. data/app/javascript/ruby_llm/agents/controllers/refresh_controller.js +0 -83
  81. data/app/views/layouts/rubyllm/agents/application.html.erb +0 -626
  82. data/app/views/rubyllm/agents/agents/index.html.erb +0 -20
  83. data/app/views/rubyllm/agents/agents/show.html.erb +0 -772
  84. data/app/views/rubyllm/agents/dashboard/_budgets_bar.html.erb +0 -165
  85. data/app/views/rubyllm/agents/dashboard/_now_strip.html.erb +0 -10
  86. data/app/views/rubyllm/agents/dashboard/_now_strip_values.html.erb +0 -71
  87. data/app/views/rubyllm/agents/dashboard/index.html.erb +0 -197
  88. data/app/views/rubyllm/agents/executions/index.html.erb +0 -28
  89. data/app/views/rubyllm/agents/executions/index.turbo_stream.erb +0 -18
  90. data/app/views/rubyllm/agents/shared/_executions_table.html.erb +0 -193
  91. /data/app/views/{rubyllm → ruby_llm}/agents/agents/_agent.html.erb +0 -0
  92. /data/app/views/{rubyllm → ruby_llm}/agents/agents/_version_comparison.html.erb +0 -0
  93. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_action_center.html.erb +0 -0
  94. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_alerts_feed.html.erb +0 -0
  95. /data/app/views/{rubyllm → ruby_llm}/agents/dashboard/_breaker_strip.html.erb +0 -0
  96. /data/app/views/{rubyllm → ruby_llm}/agents/executions/dry_run.html.erb +0 -0
  97. /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