ruby_llm-agents 0.3.1 → 0.3.3

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.
@@ -19,56 +19,109 @@
19
19
  </div>
20
20
 
21
21
  <!-- Header -->
22
- <div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 mb-6">
23
- <div class="flex items-center justify-between">
24
- <div>
25
- <div class="flex items-center gap-3">
26
- <h2 class="text-xl font-bold text-gray-900 dark:text-gray-100">
27
- <%= @execution.agent_type.gsub(/Agent$/, '') %>
28
- </h2>
29
-
30
- <%= render "rubyllm/agents/shared/status_badge", status: @execution.status, size: :md %>
31
-
32
- <% if @execution.streaming? %>
33
- <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-cyan-100 dark:bg-cyan-900/50 text-cyan-800 dark:text-cyan-300">
34
- Streaming
35
- </span>
36
- <% end %>
37
-
38
- <% if @execution.cache_hit %>
39
- <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/50 text-purple-800 dark:text-purple-300">
40
- Cached
41
- </span>
42
- <% end %>
22
+ <div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-4 sm:p-5 mb-6">
23
+ <!-- Desktop: Row 1 - Agent name + badges + buttons + date -->
24
+ <div class="hidden sm:flex sm:items-center sm:justify-between gap-4">
25
+ <div class="flex items-center gap-3 min-w-0">
26
+ <h2 class="text-lg font-bold text-gray-900 dark:text-gray-100 truncate">
27
+ <%= @execution.agent_type.gsub(/Agent$/, '') %>
28
+ </h2>
29
+ <%= render "rubyllm/agents/shared/status_badge", status: @execution.status, size: :md %>
30
+ <% if @execution.streaming? %>
31
+ <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-cyan-100 dark:bg-cyan-900/50 text-cyan-800 dark:text-cyan-300">Stream</span>
32
+ <% end %>
33
+ <% if @execution.cache_hit %>
34
+ <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-purple-100 dark:bg-purple-900/50 text-purple-800 dark:text-purple-300">Cached</span>
35
+ <% end %>
36
+ <% if @execution.finish_reason.present? %>
37
+ <% finish_colors = {
38
+ 'stop' => 'bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-300',
39
+ 'length' => 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-300',
40
+ 'content_filter' => 'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300',
41
+ 'tool_calls' => 'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300'
42
+ } %>
43
+ <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium <%= finish_colors[@execution.finish_reason] || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' %>"><%= @execution.finish_reason %></span>
44
+ <% end %>
45
+ </div>
46
+ <div class="flex items-center gap-3 flex-shrink-0">
47
+ <%= button_to rerun_execution_path(@execution, dry_run: true),
48
+ method: :post,
49
+ data: { turbo: false },
50
+ class: "inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors",
51
+ title: "Preview what would be sent without making an API call" do %>
52
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
53
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
54
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
55
+ </svg>
56
+ Dry Run
57
+ <% end %>
58
+ <button
59
+ type="button"
60
+ onclick="confirmRerun()"
61
+ class="inline-flex items-center gap-1.5 px-2.5 py-1 text-xs font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md transition-colors"
62
+ title="Re-execute this agent with the same parameters"
63
+ >
64
+ <svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
65
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/>
66
+ </svg>
67
+ Rerun
68
+ </button>
69
+ <span class="text-xs text-gray-500 dark:text-gray-400 whitespace-nowrap"><%= @execution.created_at.strftime("%b %d, %H:%M") %></span>
70
+ </div>
71
+ </div>
43
72
 
44
- <% if @execution.finish_reason.present? %>
45
- <% finish_colors = {
46
- 'stop' => 'bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-300',
47
- 'length' => 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-300',
48
- 'content_filter' => 'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300',
49
- 'tool_calls' => 'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300'
50
- } %>
51
- <span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium <%= finish_colors[@execution.finish_reason] || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' %>">
52
- <%= @execution.finish_reason %>
53
- </span>
54
- <% end %>
55
- </div>
73
+ <!-- Desktop: Row 2 - Info line + relative time -->
74
+ <div class="hidden sm:flex sm:items-center sm:justify-between mt-1.5">
75
+ <p class="text-xs text-gray-500 dark:text-gray-400">
76
+ #<%= @execution.id %> · v<%= @execution.agent_version %>
77
+ <% if @execution.model_provider.present? %>
78
+ · <%= @execution.model_provider %>
79
+ <% end %>
80
+ </p>
81
+ <span class="text-xs text-gray-400 dark:text-gray-500"><%= time_ago_in_words(@execution.created_at) %> ago</span>
82
+ </div>
56
83
 
57
- <p class="text-sm text-gray-500 dark:text-gray-400 mt-1">
58
- Execution #<%= @execution.id %> · v<%= @execution.agent_version %>
59
- <% if @execution.model_provider.present? %>
60
- · <%= @execution.model_provider %>
61
- <% end %>
62
- </p>
84
+ <!-- Mobile: Stacked layout -->
85
+ <div class="sm:hidden">
86
+ <h2 class="text-lg font-bold text-gray-900 dark:text-gray-100 truncate">
87
+ <%= @execution.agent_type.gsub(/Agent$/, '') %>
88
+ </h2>
89
+ <div class="flex flex-wrap items-center gap-2 mt-2">
90
+ <%= render "rubyllm/agents/shared/status_badge", status: @execution.status, size: :md %>
91
+ <% if @execution.streaming? %>
92
+ <span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium bg-cyan-100 dark:bg-cyan-900/50 text-cyan-800 dark:text-cyan-300">Stream</span>
93
+ <% end %>
94
+ <% if @execution.cache_hit %>
95
+ <span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium bg-purple-100 dark:bg-purple-900/50 text-purple-800 dark:text-purple-300">Cached</span>
96
+ <% end %>
97
+ <% if @execution.finish_reason.present? %>
98
+ <% finish_colors = {
99
+ 'stop' => 'bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-300',
100
+ 'length' => 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-300',
101
+ 'content_filter' => 'bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300',
102
+ 'tool_calls' => 'bg-blue-100 text-blue-800 dark:bg-blue-900/50 dark:text-blue-300'
103
+ } %>
104
+ <span class="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-medium <%= finish_colors[@execution.finish_reason] || 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300' %>"><%= @execution.finish_reason %></span>
105
+ <% end %>
63
106
  </div>
107
+ <p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
108
+ #<%= @execution.id %> · v<%= @execution.agent_version %>
109
+ <% if @execution.model_provider.present? %>
110
+ · <%= @execution.model_provider %>
111
+ <% end %>
112
+ </p>
64
113
 
65
- <div class="flex items-center gap-4">
66
- <!-- Rerun Buttons -->
114
+ <!-- Mobile: Date + Buttons -->
115
+ <div class="mt-4 pt-4 border-t border-gray-100 dark:border-gray-700">
116
+ <p class="text-xs text-gray-500 dark:text-gray-400 mb-3">
117
+ <%= @execution.created_at.strftime("%b %d, %Y at %H:%M") %>
118
+ <span class="text-gray-400 dark:text-gray-500">· <%= time_ago_in_words(@execution.created_at) %> ago</span>
119
+ </p>
67
120
  <div class="flex items-center gap-2">
68
121
  <%= button_to rerun_execution_path(@execution, dry_run: true),
69
122
  method: :post,
70
123
  data: { turbo: false },
71
- class: "inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors",
124
+ class: "flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 text-sm font-medium text-gray-700 dark:text-gray-200 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-600 transition-colors",
72
125
  title: "Preview what would be sent without making an API call" do %>
73
126
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
74
127
  <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
@@ -76,11 +129,10 @@
76
129
  </svg>
77
130
  Dry Run
78
131
  <% end %>
79
-
80
132
  <button
81
133
  type="button"
82
134
  onclick="confirmRerun()"
83
- class="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
135
+ class="flex-1 inline-flex items-center justify-center gap-1.5 px-3 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors"
84
136
  title="Re-execute this agent with the same parameters"
85
137
  >
86
138
  <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -89,14 +141,6 @@
89
141
  Rerun
90
142
  </button>
91
143
  </div>
92
-
93
- <div class="text-right text-sm text-gray-500 dark:text-gray-400">
94
- <p><%= @execution.created_at.strftime("%b %d, %Y at %H:%M") %></p>
95
-
96
- <p class="text-xs text-gray-400 dark:text-gray-500">
97
- <%= time_ago_in_words(@execution.created_at) %> ago
98
- </p>
99
- </div>
100
144
  </div>
101
145
  </div>
102
146
  </div>
@@ -438,6 +482,76 @@
438
482
  </div>
439
483
  <% end %>
440
484
 
485
+ <!-- Tool Calls -->
486
+ <% if @execution.tool_calls.present? && @execution.tool_calls.any? %>
487
+ <div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 mb-6">
488
+ <div class="flex items-center justify-between mb-4">
489
+ <div class="flex items-center gap-2">
490
+ <svg class="w-5 h-5 text-blue-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
491
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z"/>
492
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
493
+ </svg>
494
+ <h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wide">Tool Calls</h3>
495
+ <span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-blue-100 dark:bg-blue-900/50 text-blue-800 dark:text-blue-300">
496
+ <%= @execution.tool_calls.size %>
497
+ </span>
498
+ </div>
499
+ <button
500
+ type="button"
501
+ data-copy-json="<%= Base64.strict_encode64(JSON.pretty_generate(@execution.tool_calls)) %>"
502
+ class="copy-json-btn inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-md transition-colors"
503
+ >
504
+ <svg class="w-4 h-4 copy-icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
505
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z"/>
506
+ </svg>
507
+ <svg class="w-4 h-4 check-icon hidden" fill="none" stroke="currentColor" viewBox="0 0 24 24">
508
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"/>
509
+ </svg>
510
+ <span>Copy</span>
511
+ </button>
512
+ </div>
513
+
514
+ <div class="space-y-4">
515
+ <% @execution.tool_calls.each_with_index do |tool_call, index| %>
516
+ <%
517
+ # Handle both symbol and string keys
518
+ tool_id = tool_call['id'] || tool_call[:id]
519
+ tool_name = tool_call['name'] || tool_call[:name]
520
+ tool_args = tool_call['arguments'] || tool_call[:arguments] || {}
521
+ %>
522
+ <div class="border border-gray-100 dark:border-gray-700 rounded-lg overflow-hidden">
523
+ <!-- Tool Call Header -->
524
+ <div class="bg-gray-50 dark:bg-gray-900/50 px-4 py-3 flex items-center justify-between">
525
+ <div class="flex items-center gap-3">
526
+ <span class="inline-flex items-center justify-center w-6 h-6 rounded-full bg-blue-100 dark:bg-blue-900/50 text-blue-700 dark:text-blue-300 text-xs font-medium">
527
+ <%= index + 1 %>
528
+ </span>
529
+ <code class="text-sm font-semibold text-gray-900 dark:text-gray-100"><%= tool_name %></code>
530
+ </div>
531
+ <% if tool_id.present? %>
532
+ <span class="text-xs text-gray-400 dark:text-gray-500 font-mono truncate max-w-xs" title="<%= tool_id %>">
533
+ <%= tool_id.to_s.truncate(24) %>
534
+ </span>
535
+ <% end %>
536
+ </div>
537
+
538
+ <!-- Tool Call Arguments -->
539
+ <% if tool_args.present? && tool_args.any? %>
540
+ <div class="px-4 py-3">
541
+ <p class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide mb-2">Arguments</p>
542
+ <pre class="bg-gray-50 dark:bg-gray-900 text-gray-900 dark:text-gray-100 rounded-lg p-3 text-sm overflow-x-auto font-mono"><%= highlight_json(tool_args) %></pre>
543
+ </div>
544
+ <% else %>
545
+ <div class="px-4 py-3">
546
+ <p class="text-xs text-gray-400 dark:text-gray-500 italic">No arguments</p>
547
+ </div>
548
+ <% end %>
549
+ </div>
550
+ <% end %>
551
+ </div>
552
+ </div>
553
+ <% end %>
554
+
441
555
  <!-- Metadata -->
442
556
  <% if @execution.metadata.present? && @execution.metadata.any? %>
443
557
  <div class="bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-xl p-6 mb-6">
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Migration to add dedicated tool_calls column to executions
4
+ #
5
+ # This migration adds a dedicated column for storing tool call data,
6
+ # enabling easier querying and display of tool/function calls made
7
+ # during agent execution.
8
+ #
9
+ # Tool call structure:
10
+ # [
11
+ # { "id": "call_abc123", "name": "search", "arguments": { "query": "..." } },
12
+ # { "id": "call_def456", "name": "calculate", "arguments": { ... } }
13
+ # ]
14
+ #
15
+ # Run with: rails db:migrate
16
+ class AddToolCallsToRubyLLMAgentsExecutions < ActiveRecord::Migration<%= migration_version %>
17
+ def change
18
+ # Add tool_calls JSONB array for storing tool call details
19
+ # Each tool call contains: id, name, arguments
20
+ add_column :ruby_llm_agents_executions, :tool_calls, :jsonb, null: false, default: []
21
+
22
+ # Add counter for quick access to tool call count
23
+ add_column :ruby_llm_agents_executions, :tool_calls_count, :integer, null: false, default: 0
24
+
25
+ # Add index for querying executions that have tool calls
26
+ add_index :ruby_llm_agents_executions, :tool_calls_count
27
+ end
28
+ end
@@ -67,6 +67,10 @@ class CreateRubyLLMAgentsExecutions < ActiveRecord::Migration<%= migration_versi
67
67
  t.text :system_prompt
68
68
  t.text :user_prompt
69
69
 
70
+ # Tool calls tracking
71
+ t.jsonb :tool_calls, null: false, default: []
72
+ t.integer :tool_calls_count, null: false, default: 0
73
+
70
74
  t.timestamps
71
75
  end
72
76
 
@@ -89,6 +93,9 @@ class CreateRubyLLMAgentsExecutions < ActiveRecord::Migration<%= migration_versi
89
93
  # Caching index
90
94
  add_index :ruby_llm_agents_executions, :response_cache_key
91
95
 
96
+ # Tool calls index
97
+ add_index :ruby_llm_agents_executions, :tool_calls_count
98
+
92
99
  # Foreign keys for execution hierarchy
93
100
  add_foreign_key :ruby_llm_agents_executions, :ruby_llm_agents_executions,
94
101
  column: :parent_execution_id, on_delete: :nullify
@@ -107,6 +107,19 @@ module RubyLlmAgents
107
107
  )
108
108
  end
109
109
 
110
+ def create_add_tool_calls_migration
111
+ # Check if columns already exist
112
+ if column_exists?(:ruby_llm_agents_executions, :tool_calls)
113
+ say_status :skip, "tool_calls column already exists", :yellow
114
+ return
115
+ end
116
+
117
+ migration_template(
118
+ "add_tool_calls_migration.rb.tt",
119
+ File.join(db_migrate_path, "add_tool_calls_to_ruby_llm_agents_executions.rb")
120
+ )
121
+ end
122
+
110
123
  def show_post_upgrade_message
111
124
  say ""
112
125
  say "RubyLLM::Agents upgrade migration created!", :green
@@ -325,7 +325,9 @@ module RubyLLM
325
325
  # @return [RubyLLM::Chat] The configured RubyLLM client
326
326
  # @!attribute [r] time_to_first_token_ms
327
327
  # @return [Integer, nil] Time to first token in milliseconds (streaming only)
328
- attr_reader :model, :temperature, :client, :time_to_first_token_ms
328
+ # @!attribute [r] accumulated_tool_calls
329
+ # @return [Array<Hash>] Tool calls accumulated during execution
330
+ attr_reader :model, :temperature, :client, :time_to_first_token_ms, :accumulated_tool_calls
329
331
 
330
332
  # Creates a new agent instance
331
333
  #
@@ -337,6 +339,7 @@ module RubyLLM
337
339
  @model = model
338
340
  @temperature = temperature
339
341
  @options = options
342
+ @accumulated_tool_calls = []
340
343
  validate_required_params!
341
344
  @client = build_client
342
345
  end
@@ -424,21 +427,26 @@ module RubyLLM
424
427
 
425
428
  # Returns prompt info without making an API call (debug mode)
426
429
  #
427
- # @return [Hash] Agent configuration and prompt info
430
+ # @return [Result] A Result with dry run configuration info
428
431
  def dry_run_response
429
- {
430
- dry_run: true,
431
- agent: self.class.name,
432
- model: model,
432
+ Result.new(
433
+ content: {
434
+ dry_run: true,
435
+ agent: self.class.name,
436
+ model: model,
437
+ temperature: temperature,
438
+ timeout: self.class.timeout,
439
+ system_prompt: system_prompt,
440
+ user_prompt: user_prompt,
441
+ attachments: @options[:with],
442
+ schema: schema&.class&.name,
443
+ streaming: self.class.streaming,
444
+ tools: self.class.tools.map { |t| t.respond_to?(:name) ? t.name : t.to_s }
445
+ },
446
+ model_id: model,
433
447
  temperature: temperature,
434
- timeout: self.class.timeout,
435
- system_prompt: system_prompt,
436
- user_prompt: user_prompt,
437
- attachments: @options[:with],
438
- schema: schema&.class&.name,
439
- streaming: self.class.streaming,
440
- tools: self.class.tools.map { |t| t.respond_to?(:name) ? t.name : t.to_s }
441
- }
448
+ streaming: self.class.streaming
449
+ )
442
450
  end
443
451
 
444
452
  private
@@ -462,17 +470,20 @@ module RubyLLM
462
470
  #
463
471
  # @param model_override [String, nil] Optional model to use instead of default
464
472
  # @yield [chunk] Yields chunks when streaming is enabled
465
- # @return [Object] The processed response
473
+ # @return [Result] A Result object with processed content and metadata
466
474
  def execute_single_attempt(model_override: nil, &block)
467
475
  current_client = model_override ? build_client_with_model(model_override) : client
468
476
  @execution_started_at ||= Time.current
477
+ reset_accumulated_tool_calls!
469
478
 
470
479
  Timeout.timeout(self.class.timeout) do
471
480
  if self.class.streaming && block_given?
472
481
  execute_with_streaming(current_client, &block)
473
482
  else
474
483
  response = current_client.ask(user_prompt, **ask_options)
475
- process_response(capture_response(response))
484
+ extract_tool_calls_from_client(current_client)
485
+ capture_response(response)
486
+ build_result(process_response(response), response)
476
487
  end
477
488
  end
478
489
  end
@@ -485,7 +496,7 @@ module RubyLLM
485
496
  # @param current_client [RubyLLM::Chat] The configured client
486
497
  # @yield [chunk] Yields each chunk as it arrives
487
498
  # @yieldparam chunk [RubyLLM::Chunk] A streaming chunk
488
- # @return [Object] The processed response
499
+ # @return [Result] A Result object with processed content and metadata
489
500
  def execute_with_streaming(current_client, &block)
490
501
  first_chunk_at = nil
491
502
 
@@ -498,7 +509,9 @@ module RubyLLM
498
509
  @time_to_first_token_ms = ((first_chunk_at - @execution_started_at) * 1000).to_i
499
510
  end
500
511
 
501
- process_response(capture_response(response))
512
+ extract_tool_calls_from_client(current_client)
513
+ capture_response(response)
514
+ build_result(process_response(response), response)
502
515
  end
503
516
 
504
517
  # Executes the agent with retry/fallback/circuit breaker support
@@ -751,6 +764,183 @@ module RubyLLM
751
764
  client.with_message(message[:role], message[:content])
752
765
  end
753
766
  end
767
+
768
+ # @!group Result Building
769
+
770
+ # Builds a Result object from processed content and response metadata
771
+ #
772
+ # @param content [Hash, String] The processed response content
773
+ # @param response [RubyLLM::Message] The raw LLM response
774
+ # @return [Result] A Result object with full execution metadata
775
+ def build_result(content, response)
776
+ completed_at = Time.current
777
+ input_tokens = result_response_value(response, :input_tokens)
778
+ output_tokens = result_response_value(response, :output_tokens)
779
+ response_model_id = result_response_value(response, :model_id)
780
+
781
+ Result.new(
782
+ content: content,
783
+ input_tokens: input_tokens,
784
+ output_tokens: output_tokens,
785
+ cached_tokens: result_response_value(response, :cached_tokens, 0),
786
+ cache_creation_tokens: result_response_value(response, :cache_creation_tokens, 0),
787
+ model_id: model,
788
+ chosen_model_id: response_model_id || model,
789
+ temperature: temperature,
790
+ started_at: @execution_started_at,
791
+ completed_at: completed_at,
792
+ duration_ms: result_duration_ms(completed_at),
793
+ time_to_first_token_ms: @time_to_first_token_ms,
794
+ finish_reason: result_finish_reason(response),
795
+ streaming: self.class.streaming,
796
+ input_cost: result_input_cost(input_tokens, response_model_id),
797
+ output_cost: result_output_cost(output_tokens, response_model_id),
798
+ total_cost: result_total_cost(input_tokens, output_tokens, response_model_id),
799
+ tool_calls: @accumulated_tool_calls,
800
+ tool_calls_count: @accumulated_tool_calls.size
801
+ )
802
+ end
803
+
804
+ # Safely extracts a value from the response object
805
+ #
806
+ # @param response [Object] The response object
807
+ # @param method [Symbol] The method to call
808
+ # @param default [Object] Default value if method doesn't exist
809
+ # @return [Object] The extracted value or default
810
+ def result_response_value(response, method, default = nil)
811
+ return default unless response.respond_to?(method)
812
+ response.send(method) || default
813
+ end
814
+
815
+ # Calculates execution duration in milliseconds
816
+ #
817
+ # @param completed_at [Time] When execution completed
818
+ # @return [Integer, nil] Duration in ms or nil
819
+ def result_duration_ms(completed_at)
820
+ return nil unless @execution_started_at
821
+ ((completed_at - @execution_started_at) * 1000).to_i
822
+ end
823
+
824
+ # Extracts finish reason from response
825
+ #
826
+ # @param response [Object] The response object
827
+ # @return [String, nil] Normalized finish reason
828
+ def result_finish_reason(response)
829
+ reason = result_response_value(response, :finish_reason) ||
830
+ result_response_value(response, :stop_reason)
831
+ return nil unless reason
832
+
833
+ # Normalize to standard values
834
+ case reason.to_s.downcase
835
+ when "stop", "end_turn" then "stop"
836
+ when "length", "max_tokens" then "length"
837
+ when "content_filter", "safety" then "content_filter"
838
+ when "tool_calls", "tool_use" then "tool_calls"
839
+ else "other"
840
+ end
841
+ end
842
+
843
+ # Calculates input cost from tokens
844
+ #
845
+ # @param input_tokens [Integer, nil] Number of input tokens
846
+ # @param response_model_id [String, nil] Model that responded
847
+ # @return [Float, nil] Input cost in USD
848
+ def result_input_cost(input_tokens, response_model_id)
849
+ return nil unless input_tokens
850
+ model_info = result_model_info(response_model_id)
851
+ return nil unless model_info&.pricing
852
+ price = model_info.pricing.text_tokens&.input || 0
853
+ (input_tokens / 1_000_000.0 * price).round(6)
854
+ end
855
+
856
+ # Calculates output cost from tokens
857
+ #
858
+ # @param output_tokens [Integer, nil] Number of output tokens
859
+ # @param response_model_id [String, nil] Model that responded
860
+ # @return [Float, nil] Output cost in USD
861
+ def result_output_cost(output_tokens, response_model_id)
862
+ return nil unless output_tokens
863
+ model_info = result_model_info(response_model_id)
864
+ return nil unless model_info&.pricing
865
+ price = model_info.pricing.text_tokens&.output || 0
866
+ (output_tokens / 1_000_000.0 * price).round(6)
867
+ end
868
+
869
+ # Calculates total cost from tokens
870
+ #
871
+ # @param input_tokens [Integer, nil] Number of input tokens
872
+ # @param output_tokens [Integer, nil] Number of output tokens
873
+ # @param response_model_id [String, nil] Model that responded
874
+ # @return [Float, nil] Total cost in USD
875
+ def result_total_cost(input_tokens, output_tokens, response_model_id)
876
+ input_cost = result_input_cost(input_tokens, response_model_id)
877
+ output_cost = result_output_cost(output_tokens, response_model_id)
878
+ return nil unless input_cost || output_cost
879
+ ((input_cost || 0) + (output_cost || 0)).round(6)
880
+ end
881
+
882
+ # Resolves model info for cost calculation
883
+ #
884
+ # @param response_model_id [String, nil] Model ID from response
885
+ # @return [Object, nil] Model info or nil
886
+ def result_model_info(response_model_id)
887
+ lookup_id = response_model_id || model
888
+ return nil unless lookup_id
889
+ model_obj, _provider = RubyLLM::Models.resolve(lookup_id)
890
+ model_obj
891
+ rescue StandardError
892
+ nil
893
+ end
894
+
895
+ # @!endgroup
896
+
897
+ # @!group Tool Call Tracking
898
+
899
+ # Resets accumulated tool calls for a new execution
900
+ #
901
+ # @return [void]
902
+ def reset_accumulated_tool_calls!
903
+ @accumulated_tool_calls = []
904
+ end
905
+
906
+ # Extracts tool calls from all assistant messages in the conversation
907
+ #
908
+ # RubyLLM handles tool call loops internally. After ask() completes,
909
+ # the conversation history contains all intermediate assistant messages
910
+ # that had tool_calls. This method extracts those tool calls.
911
+ #
912
+ # @param client [RubyLLM::Chat] The chat client with conversation history
913
+ # @return [void]
914
+ def extract_tool_calls_from_client(client)
915
+ return unless client.respond_to?(:messages)
916
+
917
+ client.messages.each do |message|
918
+ next unless message.role == :assistant
919
+ next unless message.respond_to?(:tool_calls) && message.tool_calls.present?
920
+
921
+ message.tool_calls.each_value do |tool_call|
922
+ @accumulated_tool_calls << serialize_tool_call(tool_call)
923
+ end
924
+ end
925
+ end
926
+
927
+ # Serializes a single tool call to a hash
928
+ #
929
+ # @param tool_call [Object] The tool call object
930
+ # @return [Hash] Serialized tool call
931
+ def serialize_tool_call(tool_call)
932
+ if tool_call.respond_to?(:to_h)
933
+ tool_call.to_h.transform_keys(&:to_s)
934
+ else
935
+ {
936
+ "id" => tool_call.id,
937
+ "name" => tool_call.name,
938
+ "arguments" => tool_call.arguments
939
+ }
940
+ end
941
+ end
942
+
943
+ # @!endgroup
754
944
  end
755
945
  end
756
946
  end