raif 1.3.0 → 1.5.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.
Files changed (206) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +8 -7
  3. data/app/assets/builds/raif.css +4 -1
  4. data/app/assets/builds/raif_admin.css +52 -2
  5. data/app/assets/builds/raif_admin_sprockets.js +2709 -0
  6. data/app/assets/javascript/raif/admin/copy_to_clipboard_controller.js +132 -0
  7. data/app/assets/javascript/raif/admin/cost_estimate_controller.js +80 -0
  8. data/app/assets/javascript/raif/admin/judge_config_controller.js +23 -0
  9. data/app/assets/javascript/raif/admin/select_all_checkboxes_controller.js +33 -0
  10. data/app/assets/javascript/raif/admin/sortable_table_controller.js +51 -0
  11. data/app/assets/javascript/raif/admin/table_search_controller.js +15 -0
  12. data/app/assets/javascript/raif/admin/tom_select_controller.js +33 -0
  13. data/app/assets/javascript/raif/controllers/conversations_controller.js +1 -1
  14. data/app/assets/javascript/raif_admin.js +23 -0
  15. data/app/assets/javascript/raif_admin_sprockets.js +24 -0
  16. data/app/assets/stylesheets/raif/admin/conversation.scss +16 -0
  17. data/app/assets/stylesheets/raif/conversations.scss +3 -0
  18. data/app/assets/stylesheets/raif.scss +2 -1
  19. data/app/assets/stylesheets/raif_admin.scss +50 -1
  20. data/app/controllers/raif/admin/agents_controller.rb +27 -1
  21. data/app/controllers/raif/admin/application_controller.rb +16 -0
  22. data/app/controllers/raif/admin/configs_controller.rb +95 -0
  23. data/app/controllers/raif/admin/llms_controller.rb +27 -0
  24. data/app/controllers/raif/admin/model_completions_controller.rb +24 -1
  25. data/app/controllers/raif/admin/model_tool_invocations_controller.rb +7 -1
  26. data/app/controllers/raif/admin/prompt_studio/agents_controller.rb +25 -0
  27. data/app/controllers/raif/admin/prompt_studio/base_controller.rb +32 -0
  28. data/app/controllers/raif/admin/prompt_studio/batch_runs_controller.rb +102 -0
  29. data/app/controllers/raif/admin/prompt_studio/conversations_controller.rb +25 -0
  30. data/app/controllers/raif/admin/prompt_studio/tasks_controller.rb +64 -0
  31. data/app/controllers/raif/admin/stats/model_tool_invocations_controller.rb +21 -0
  32. data/app/controllers/raif/admin/stats/tasks_controller.rb +15 -6
  33. data/app/controllers/raif/admin/stats_controller.rb +32 -3
  34. data/app/controllers/raif/admin/tasks_controller.rb +5 -0
  35. data/app/controllers/raif/conversation_entries_controller.rb +1 -0
  36. data/app/controllers/raif/conversations_controller.rb +10 -2
  37. data/app/helpers/raif/application_helper.rb +40 -0
  38. data/app/jobs/raif/conversation_entry_job.rb +8 -6
  39. data/app/jobs/raif/prompt_studio_batch_run_item_job.rb +11 -0
  40. data/app/jobs/raif/prompt_studio_batch_run_job.rb +15 -0
  41. data/app/jobs/raif/prompt_studio_task_run_job.rb +36 -0
  42. data/app/models/raif/admin/task_stat.rb +7 -0
  43. data/app/models/raif/agent.rb +98 -6
  44. data/app/models/raif/agents/native_tool_calling_agent.rb +179 -52
  45. data/app/models/raif/application_record.rb +18 -0
  46. data/app/models/raif/concerns/agent_inference_stats.rb +35 -0
  47. data/app/models/raif/concerns/has_prompt_templates.rb +88 -0
  48. data/app/models/raif/concerns/has_runtime_duration.rb +41 -0
  49. data/app/models/raif/concerns/json_schema_definition.rb +54 -6
  50. data/app/models/raif/concerns/llm_prompt_caching.rb +20 -0
  51. data/app/models/raif/concerns/llms/anthropic/message_formatting.rb +34 -0
  52. data/app/models/raif/concerns/llms/anthropic/response_tool_calls.rb +24 -0
  53. data/app/models/raif/concerns/llms/anthropic/tool_formatting.rb +8 -0
  54. data/app/models/raif/concerns/llms/bedrock/message_formatting.rb +43 -0
  55. data/app/models/raif/concerns/llms/bedrock/response_tool_calls.rb +26 -0
  56. data/app/models/raif/concerns/llms/bedrock/tool_formatting.rb +8 -0
  57. data/app/models/raif/concerns/llms/google/message_formatting.rb +112 -0
  58. data/app/models/raif/concerns/llms/google/response_tool_calls.rb +32 -0
  59. data/app/models/raif/concerns/llms/google/tool_formatting.rb +76 -0
  60. data/app/models/raif/concerns/llms/message_formatting.rb +41 -5
  61. data/app/models/raif/concerns/llms/open_ai/json_schema_validation.rb +3 -3
  62. data/app/models/raif/concerns/llms/open_ai_completions/message_formatting.rb +22 -0
  63. data/app/models/raif/concerns/llms/open_ai_completions/response_tool_calls.rb +22 -0
  64. data/app/models/raif/concerns/llms/open_ai_completions/tool_formatting.rb +8 -0
  65. data/app/models/raif/concerns/llms/open_ai_responses/message_formatting.rb +17 -0
  66. data/app/models/raif/concerns/llms/open_ai_responses/response_tool_calls.rb +26 -0
  67. data/app/models/raif/concerns/llms/open_ai_responses/tool_formatting.rb +8 -0
  68. data/app/models/raif/concerns/provider_managed_tool_calls.rb +162 -0
  69. data/app/models/raif/concerns/run_with.rb +127 -0
  70. data/app/models/raif/conversation.rb +112 -8
  71. data/app/models/raif/conversation_entry.rb +38 -4
  72. data/app/models/raif/embedding_model.rb +2 -1
  73. data/app/models/raif/embedding_models/bedrock.rb +10 -1
  74. data/app/models/raif/embedding_models/google.rb +37 -0
  75. data/app/models/raif/embedding_models/open_ai.rb +1 -1
  76. data/app/models/raif/evals/llm_judge.rb +70 -0
  77. data/{lib → app/models}/raif/evals/llm_judges/binary.rb +41 -3
  78. data/{lib → app/models}/raif/evals/llm_judges/comparative.rb +41 -3
  79. data/{lib → app/models}/raif/evals/llm_judges/scored.rb +39 -1
  80. data/{lib → app/models}/raif/evals/llm_judges/summarization.rb +40 -2
  81. data/app/models/raif/llm.rb +104 -4
  82. data/app/models/raif/llms/anthropic.rb +32 -22
  83. data/app/models/raif/llms/bedrock.rb +64 -24
  84. data/app/models/raif/llms/google.rb +166 -0
  85. data/app/models/raif/llms/open_ai_base.rb +23 -5
  86. data/app/models/raif/llms/open_ai_completions.rb +14 -12
  87. data/app/models/raif/llms/open_ai_responses.rb +14 -17
  88. data/app/models/raif/llms/open_router.rb +16 -15
  89. data/app/models/raif/model_completion.rb +103 -1
  90. data/app/models/raif/model_tool.rb +55 -5
  91. data/app/models/raif/model_tool_invocation.rb +68 -6
  92. data/app/models/raif/model_tools/agent_final_answer.rb +2 -7
  93. data/app/models/raif/model_tools/provider_managed/code_execution.rb +4 -0
  94. data/app/models/raif/model_tools/provider_managed/image_generation.rb +4 -0
  95. data/app/models/raif/model_tools/provider_managed/web_search.rb +4 -0
  96. data/app/models/raif/prompt_studio_batch_run.rb +155 -0
  97. data/app/models/raif/prompt_studio_batch_run_item.rb +220 -0
  98. data/app/models/raif/streaming_responses/bedrock.rb +60 -1
  99. data/app/models/raif/streaming_responses/google.rb +71 -0
  100. data/app/models/raif/task.rb +85 -18
  101. data/app/models/raif/user_tool_invocation.rb +19 -0
  102. data/app/views/layouts/raif/admin.html.erb +43 -2
  103. data/app/views/raif/admin/agents/_agent.html.erb +9 -0
  104. data/app/views/raif/admin/agents/_conversation_message.html.erb +28 -6
  105. data/app/views/raif/admin/agents/index.html.erb +50 -0
  106. data/app/views/raif/admin/agents/show.html.erb +50 -1
  107. data/app/views/raif/admin/configs/show.html.erb +117 -0
  108. data/app/views/raif/admin/conversations/_conversation_entry.html.erb +29 -34
  109. data/app/views/raif/admin/conversations/show.html.erb +2 -0
  110. data/app/views/raif/admin/llms/index.html.erb +110 -0
  111. data/app/views/raif/admin/model_completions/_model_completion.html.erb +10 -5
  112. data/app/views/raif/admin/model_completions/index.html.erb +40 -1
  113. data/app/views/raif/admin/model_completions/show.html.erb +256 -84
  114. data/app/views/raif/admin/model_tool_invocations/index.html.erb +22 -1
  115. data/app/views/raif/admin/model_tool_invocations/show.html.erb +18 -0
  116. data/app/views/raif/admin/model_tools/_list.html.erb +16 -0
  117. data/app/views/raif/admin/model_tools/_model_tool.html.erb +36 -0
  118. data/app/views/raif/admin/prompt_studio/agents/index.html.erb +56 -0
  119. data/app/views/raif/admin/prompt_studio/agents/show.html.erb +57 -0
  120. data/app/views/raif/admin/prompt_studio/batch_runs/_batch_run_item.html.erb +54 -0
  121. data/app/views/raif/admin/prompt_studio/batch_runs/_judge_config_fields.html.erb +76 -0
  122. data/app/views/raif/admin/prompt_studio/batch_runs/_judge_detail_modal.html.erb +27 -0
  123. data/app/views/raif/admin/prompt_studio/batch_runs/_modal.html.erb +35 -0
  124. data/app/views/raif/admin/prompt_studio/batch_runs/_progress.html.erb +78 -0
  125. data/app/views/raif/admin/prompt_studio/batch_runs/show.html.erb +49 -0
  126. data/app/views/raif/admin/prompt_studio/conversations/index.html.erb +48 -0
  127. data/app/views/raif/admin/prompt_studio/conversations/show.html.erb +36 -0
  128. data/app/views/raif/admin/prompt_studio/shared/_nav_tabs.html.erb +17 -0
  129. data/app/views/raif/admin/prompt_studio/shared/_prompt_comparison.html.erb +87 -0
  130. data/app/views/raif/admin/prompt_studio/shared/_type_filter.html.erb +54 -0
  131. data/app/views/raif/admin/prompt_studio/tasks/_task_result.html.erb +145 -0
  132. data/app/views/raif/admin/prompt_studio/tasks/_task_row.html.erb +12 -0
  133. data/app/views/raif/admin/prompt_studio/tasks/_task_type_filter.html.erb +58 -0
  134. data/app/views/raif/admin/prompt_studio/tasks/_tasks_table.html.erb +22 -0
  135. data/app/views/raif/admin/prompt_studio/tasks/index.html.erb +35 -0
  136. data/app/views/raif/admin/prompt_studio/tasks/show.html.erb +19 -0
  137. data/app/views/raif/admin/stats/_stats_tile.html.erb +34 -0
  138. data/app/views/raif/admin/stats/index.html.erb +71 -88
  139. data/app/views/raif/admin/stats/model_tool_invocations/index.html.erb +43 -0
  140. data/app/views/raif/admin/stats/tasks/index.html.erb +20 -6
  141. data/app/views/raif/admin/tasks/_task.html.erb +1 -0
  142. data/app/views/raif/admin/tasks/index.html.erb +23 -6
  143. data/app/views/raif/admin/tasks/show.html.erb +56 -3
  144. data/app/views/raif/conversation_entries/_form.html.erb +3 -0
  145. data/app/views/raif/conversation_entries/_message.html.erb +10 -6
  146. data/app/views/raif/conversations/_conversation.html.erb +10 -0
  147. data/app/views/raif/conversations/_entry_processed.turbo_stream.erb +12 -0
  148. data/app/views/raif/conversations/index.html.erb +23 -0
  149. data/config/importmap.rb +8 -0
  150. data/config/locales/admin.en.yml +161 -1
  151. data/config/locales/en.yml +67 -4
  152. data/config/routes.rb +10 -0
  153. data/db/migrate/20250904194456_add_generating_entry_response_to_raif_conversations.rb +7 -0
  154. data/db/migrate/20250911125234_add_source_to_raif_tasks.rb +7 -0
  155. data/db/migrate/20251020005853_add_source_to_raif_agents.rb +7 -0
  156. data/db/migrate/20251020011346_rename_task_run_args_to_run_with.rb +7 -0
  157. data/db/migrate/20251020011405_add_run_with_to_raif_agents.rb +13 -0
  158. data/db/migrate/20251024160119_add_llm_messages_max_length_to_raif_conversations.rb +14 -0
  159. data/db/migrate/20251124185033_add_provider_tool_call_id_to_raif_model_tool_invocations.rb +7 -0
  160. data/db/migrate/20251128202941_add_tool_choice_to_raif_model_completions.rb +7 -0
  161. data/db/migrate/20260118144846_add_source_to_raif_conversations.rb +7 -0
  162. data/db/migrate/20260119000000_add_failure_tracking_to_raif_model_completions.rb +10 -0
  163. data/db/migrate/20260119000001_add_completed_at_to_raif_model_completions.rb +8 -0
  164. data/db/migrate/20260119000002_add_started_at_to_raif_model_completions.rb +8 -0
  165. data/db/migrate/20260307000000_add_prompt_studio_run_to_raif_tasks.rb +7 -0
  166. data/db/migrate/20260308000000_create_raif_prompt_studio_batch_runs.rb +27 -0
  167. data/db/migrate/20260308000001_create_raif_prompt_studio_batch_run_items.rb +24 -0
  168. data/db/migrate/20260407000000_add_cache_token_columns_to_raif_model_completions.rb +8 -0
  169. data/lib/generators/raif/agent/agent_generator.rb +18 -0
  170. data/lib/generators/raif/agent/templates/agent.rb.tt +7 -5
  171. data/lib/generators/raif/agent/templates/application_agent.rb.tt +1 -1
  172. data/lib/generators/raif/agent/templates/system_prompt.erb.tt +3 -0
  173. data/lib/generators/raif/conversation/conversation_generator.rb +19 -1
  174. data/lib/generators/raif/conversation/templates/conversation.rb.tt +6 -0
  175. data/lib/generators/raif/conversation/templates/system_prompt.erb.tt +4 -0
  176. data/lib/generators/raif/install/templates/initializer.rb +117 -8
  177. data/lib/generators/raif/task/task_generator.rb +18 -0
  178. data/lib/generators/raif/task/templates/prompt.erb.tt +4 -0
  179. data/lib/generators/raif/task/templates/task.rb.tt +10 -9
  180. data/lib/raif/configuration.rb +47 -2
  181. data/lib/raif/embedding_model_registry.rb +8 -0
  182. data/lib/raif/engine.rb +24 -1
  183. data/lib/raif/errors/blank_response_error.rb +8 -0
  184. data/lib/raif/errors/instance_dependent_schema_error.rb +8 -0
  185. data/lib/raif/errors/prompt_template_error.rb +15 -0
  186. data/lib/raif/errors/streaming_error.rb +6 -3
  187. data/lib/raif/errors.rb +3 -0
  188. data/lib/raif/evals/run.rb +1 -0
  189. data/lib/raif/evals.rb +0 -6
  190. data/lib/raif/json_schema_builder.rb +14 -0
  191. data/lib/raif/llm_registry.rb +433 -42
  192. data/lib/raif/messages.rb +180 -0
  193. data/lib/raif/prompt_studio_comparison_builder.rb +138 -0
  194. data/lib/raif/token_estimator.rb +28 -0
  195. data/lib/raif/version.rb +1 -1
  196. data/lib/raif.rb +11 -0
  197. data/lib/tasks/annotate_rb.rake +10 -0
  198. data/spec/support/rspec_helpers.rb +15 -9
  199. data/spec/support/test_task.rb +9 -0
  200. data/spec/support/test_template_task.rb +41 -0
  201. metadata +108 -15
  202. data/app/models/raif/agents/re_act_agent.rb +0 -127
  203. data/app/models/raif/agents/re_act_step.rb +0 -32
  204. data/app/models/raif/concerns/task_run_args.rb +0 -62
  205. data/lib/raif/evals/llm_judge.rb +0 -32
  206. /data/{lib → app/models}/raif/evals/scoring_rubric.rb +0 -0
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif
4
+ class PromptStudioBatchRunJob < ApplicationJob
5
+
6
+ def perform(batch_run:)
7
+ batch_run.update!(started_at: Time.current)
8
+
9
+ batch_run.items.where(status: "pending").find_each do |item|
10
+ Raif::PromptStudioBatchRunItemJob.perform_later(item: item)
11
+ end
12
+ end
13
+
14
+ end
15
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif
4
+ class PromptStudioTaskRunJob < ApplicationJob
5
+
6
+ def perform(task:)
7
+ task.run
8
+ broadcast_task_result(task)
9
+ rescue StandardError => e
10
+ logger.error "Error running prompt studio task: #{e.message}"
11
+ logger.error e.backtrace&.join("\n")
12
+
13
+ task.update(failed_at: Time.current) unless task.failed_at?
14
+ broadcast_task_result(task)
15
+ end
16
+
17
+ private
18
+
19
+ def broadcast_task_result(task)
20
+ comparison = Raif::PromptStudioComparisonBuilder.build(task)
21
+ original_task = task.prompt_studio_run? && task.source.is_a?(Raif::Task) ? task.source : nil
22
+
23
+ html = Raif::Admin::PromptStudio::TasksController.render(
24
+ partial: "raif/admin/prompt_studio/tasks/task_result",
25
+ locals: { task: task, comparison: comparison, original_task: original_task }
26
+ )
27
+
28
+ Turbo::StreamsChannel.broadcast_replace_to(
29
+ task,
30
+ target: ActionView::RecordIdentifier.dom_id(task, :result),
31
+ html: html
32
+ )
33
+ end
34
+
35
+ end
36
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif
4
+ module Admin
5
+ TaskStat = Data.define(:type, :llm_model_key, :count, :input_cost, :output_cost, :total_cost)
6
+ end
7
+ end
@@ -1,18 +1,59 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # == Schema Information
4
+ #
5
+ # Table name: raif_agents
6
+ #
7
+ # id :bigint not null, primary key
8
+ # available_model_tools :jsonb not null
9
+ # completed_at :datetime
10
+ # conversation_history :jsonb not null
11
+ # creator_type :string not null
12
+ # failed_at :datetime
13
+ # failure_reason :text
14
+ # final_answer :text
15
+ # iteration_count :integer default(0), not null
16
+ # llm_model_key :string not null
17
+ # max_iterations :integer default(10), not null
18
+ # requested_language_key :string
19
+ # run_with :jsonb
20
+ # source_type :string
21
+ # started_at :datetime
22
+ # system_prompt :text
23
+ # task :text
24
+ # type :string not null
25
+ # created_at :datetime not null
26
+ # updated_at :datetime not null
27
+ # creator_id :bigint not null
28
+ # source_id :bigint
29
+ #
30
+ # Indexes
31
+ #
32
+ # index_raif_agents_on_created_at (created_at)
33
+ # index_raif_agents_on_creator (creator_type,creator_id)
34
+ # index_raif_agents_on_source (source_type,source_id)
35
+ #
3
36
  module Raif
4
37
  class Agent < ApplicationRecord
38
+ prepend Raif::Concerns::HasPromptTemplates
39
+
5
40
  include Raif::Concerns::HasLlm
6
41
  include Raif::Concerns::HasRequestedLanguage
7
42
  include Raif::Concerns::HasAvailableModelTools
43
+ include Raif::Concerns::HasRuntimeDuration
8
44
  include Raif::Concerns::InvokesModelTools
45
+ include Raif::Concerns::AgentInferenceStats
46
+ include Raif::Concerns::LlmPromptCaching
47
+ include Raif::Concerns::RunWith
9
48
 
10
49
  belongs_to :creator, polymorphic: true
50
+ belongs_to :source, polymorphic: true, optional: true
11
51
 
12
52
  has_many :raif_model_completions, as: :source, dependent: :destroy, class_name: "Raif::ModelCompletion"
13
53
 
14
54
  after_initialize -> { self.available_model_tools ||= [] }
15
55
  after_initialize -> { self.conversation_history ||= [] }
56
+ after_initialize -> { self.run_with ||= {} }
16
57
 
17
58
  boolean_timestamp :started_at
18
59
  boolean_timestamp :completed_at
@@ -69,16 +110,25 @@ module Raif
69
110
  Task: #{task}
70
111
  DEBUG
71
112
 
72
- add_conversation_history_entry({ role: "user", content: task })
113
+ add_conversation_history_entry(Raif::Messages::UserMessage.new(content: task).to_h)
73
114
 
74
115
  while iteration_count < max_iterations
75
116
  update_columns(iteration_count: iteration_count + 1)
76
117
 
118
+ # Update the system prompt on each iteration in case it has changed since the last iteration
119
+ self.system_prompt = build_system_prompt
120
+
121
+ # Hook for subclasses to perform actions before the LLM chat (e.g., add warnings)
122
+ before_iteration_llm_chat
123
+
77
124
  model_completion = llm.chat(
78
125
  messages: conversation_history,
79
126
  source: self,
80
127
  system_prompt: system_prompt,
81
- available_model_tools: native_model_tools
128
+ available_model_tools: native_model_tools,
129
+ tool_choice: tool_choice_for_iteration,
130
+ anthropic_prompt_caching_enabled: self.class.anthropic_prompt_caching_enabled,
131
+ bedrock_prompt_caching_enabled: self.class.bedrock_prompt_caching_enabled
82
132
  )
83
133
 
84
134
  logger.debug <<~DEBUG
@@ -93,25 +143,40 @@ module Raif
93
143
  DEBUG
94
144
 
95
145
  process_iteration_model_completion(model_completion)
96
- break if final_answer.present?
146
+ break if final_answer.present? || failed?
97
147
  end
98
148
 
99
- completed!
149
+ finalize_run!
100
150
  final_answer
101
151
  rescue StandardError => e
102
- self.failed_at = Time.current
103
- self.failure_reason = e.message
152
+ self.failed_at ||= Time.current
153
+ self.failure_reason ||= e.message
104
154
  save!
105
155
 
106
156
  raise
107
157
  end
108
158
 
159
+ def final_iteration?
160
+ iteration_count == max_iterations
161
+ end
162
+
109
163
  private
110
164
 
111
165
  def populate_default_model_tools
112
166
  # no-op by default. Can be overridden by subclasses to add default model tools
113
167
  end
114
168
 
169
+ def finalize_run!
170
+ validate_successful_completion
171
+ return if failed?
172
+
173
+ completed!
174
+ end
175
+
176
+ def validate_successful_completion
177
+ # no-op by default. Can be overridden by subclasses to enforce success criteria.
178
+ end
179
+
115
180
  def process_iteration_model_completion(model_completion)
116
181
  raise NotImplementedError, "#{self.class.name} must implement process_iteration_model_completion"
117
182
  end
@@ -120,6 +185,27 @@ module Raif
120
185
  # no-op by default
121
186
  end
122
187
 
188
+ # Hook for subclasses to perform actions before the LLM chat on each iteration
189
+ # Override in subclasses to add warnings, context, etc.
190
+ def before_iteration_llm_chat
191
+ # no-op by default
192
+ end
193
+
194
+ # Hook for subclasses to specify tool_choice for the current iteration
195
+ # Override in subclasses to force specific tools (e.g., on final iteration)
196
+ # @return [Class, nil] A model tool class (e.g., Raif::ModelTools::AgentFinalAnswer), or nil for default behavior
197
+ def tool_choice_for_iteration
198
+ nil
199
+ end
200
+
201
+ # Hook for subclasses to require a specific tool on the current iteration.
202
+ # Override to align prompt warnings and provider-level tool_choice.
203
+ # Overrides should be deterministic and side-effect free for a given iteration.
204
+ # @return [Class, nil] A model tool class, or nil if no specific tool is required.
205
+ def required_tool_for_iteration
206
+ nil
207
+ end
208
+
123
209
  def add_conversation_history_entry(entry)
124
210
  entry_stringified = entry.stringify_keys
125
211
  conversation_history << entry_stringified
@@ -127,6 +213,12 @@ module Raif
127
213
  on_conversation_history_entry.call(entry_stringified) if on_conversation_history_entry.present?
128
214
  end
129
215
 
216
+ def fail_run!(reason)
217
+ self.failed_at ||= Time.current
218
+ self.failure_reason ||= reason
219
+ save!
220
+ end
221
+
130
222
  def build_system_prompt
131
223
  raise NotImplementedError, "Subclasses of Raif::Agent must implement build_system_prompt"
132
224
  end
@@ -1,5 +1,38 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # == Schema Information
4
+ #
5
+ # Table name: raif_agents
6
+ #
7
+ # id :bigint not null, primary key
8
+ # available_model_tools :jsonb not null
9
+ # completed_at :datetime
10
+ # conversation_history :jsonb not null
11
+ # creator_type :string not null
12
+ # failed_at :datetime
13
+ # failure_reason :text
14
+ # final_answer :text
15
+ # iteration_count :integer default(0), not null
16
+ # llm_model_key :string not null
17
+ # max_iterations :integer default(10), not null
18
+ # requested_language_key :string
19
+ # run_with :jsonb
20
+ # source_type :string
21
+ # started_at :datetime
22
+ # system_prompt :text
23
+ # task :text
24
+ # type :string not null
25
+ # created_at :datetime not null
26
+ # updated_at :datetime not null
27
+ # creator_id :bigint not null
28
+ # source_id :bigint
29
+ #
30
+ # Indexes
31
+ #
32
+ # index_raif_agents_on_created_at (created_at)
33
+ # index_raif_agents_on_creator (creator_type,creator_id)
34
+ # index_raif_agents_on_source (source_type,source_id)
35
+ #
3
36
  module Raif
4
37
  module Agents
5
38
  class NativeToolCallingAgent < Raif::Agent
@@ -12,7 +45,10 @@ module Raif
12
45
  }
13
46
 
14
47
  before_validation -> {
15
- available_model_tools << "Raif::ModelTools::AgentFinalAnswer" unless available_model_tools.include?("Raif::ModelTools::AgentFinalAnswer")
48
+ # If there is no final answer tool added, add it
49
+ unless available_model_tools_map.key?("agent_final_answer")
50
+ available_model_tools << "Raif::ModelTools::AgentFinalAnswer"
51
+ end
16
52
  }
17
53
 
18
54
  def build_system_prompt
@@ -28,7 +64,7 @@ module Raif
28
64
  6. Provide a final answer to the user's request.
29
65
 
30
66
  For your final answer:
31
- - Use the agent_final_answer tool/function with your complete answer as the "final_answer" parameter.
67
+ - You **MUST** use the agent_final_answer tool/function to provide your final answer.
32
68
  - Your answer should be comprehensive and directly address the user's request.
33
69
 
34
70
  Guidelines
@@ -48,82 +84,173 @@ module Raif
48
84
  available_model_tools
49
85
  end
50
86
 
87
+ def final_answer_tool
88
+ available_model_tools_map["agent_final_answer"]
89
+ end
90
+
91
+ def required_tool_for_iteration
92
+ return final_answer_tool if final_iteration?
93
+
94
+ nil
95
+ end
96
+
97
+ def before_iteration_llm_chat
98
+ required_tool = current_iteration_required_tool
99
+ return if required_tool.blank?
100
+
101
+ warning_message = Raif::Messages::UserMessage.new(
102
+ content: required_tool_warning_message(required_tool)
103
+ )
104
+ add_conversation_history_entry(warning_message.to_h)
105
+ end
106
+
107
+ def tool_choice_for_iteration
108
+ return current_iteration_required_tool if current_iteration_required_tool.present?
109
+ return :required if llm.supports_faithful_required_tool_choice?(native_model_tools)
110
+
111
+ log_required_tool_choice_fallback_once!
112
+ nil
113
+ end
114
+
51
115
  def process_iteration_model_completion(model_completion)
52
- if model_completion.parsed_response.present?
53
- add_conversation_history_entry({
54
- role: "assistant",
55
- content: model_completion.parsed_response
56
- })
57
- end
116
+ required_tool = current_iteration_required_tool
117
+ assistant_response_message = model_completion.parsed_response if model_completion.parsed_response.present?
58
118
 
119
+ # The model made no tool call in this completion. Tell it to make a tool call.
59
120
  if model_completion.response_tool_calls.blank?
60
- add_conversation_history_entry({
61
- role: "assistant",
62
- content: "<observation>Error: No tool call found. I need to make a tool call at each step. Available tools: #{available_model_tools_map.keys.join(", ")}</observation>" # rubocop:disable Layout/LineLength
63
- })
121
+ if assistant_response_message.present?
122
+ assistant_message = Raif::Messages::AssistantMessage.new(content: assistant_response_message)
123
+ add_conversation_history_entry(assistant_message.to_h)
124
+ end
125
+
126
+ error_content = if required_tool.present?
127
+ "Error: This iteration required the tool '#{required_tool.tool_name}', but the model response contained no tool call. Available tools: #{available_model_tools_map.keys.join(", ")}" # rubocop:disable Layout/LineLength
128
+ else
129
+ "Error: Previous message contained no tool call. Make a tool call at each step. Available tools: #{available_model_tools_map.keys.join(", ")}" # rubocop:disable Layout/LineLength
130
+ end
131
+ handle_iteration_error(error_content, required_tool:)
132
+
64
133
  return
65
134
  end
66
135
 
67
- tool_call = model_completion.response_tool_calls.first
136
+ # The model returned multiple tool calls. We only allow one per step.
137
+ if model_completion.response_tool_calls.length > 1
138
+ if assistant_response_message.present?
139
+ assistant_message = Raif::Messages::AssistantMessage.new(content: assistant_response_message)
140
+ add_conversation_history_entry(assistant_message.to_h)
141
+ end
142
+
143
+ error_content = "Error: Multiple tool calls received. Only one tool call is allowed per step. " \
144
+ "Please call exactly one tool at a time."
145
+ handle_iteration_error(error_content, required_tool:)
68
146
 
69
- unless tool_call["name"] && tool_call["arguments"]
70
- add_conversation_history_entry({
71
- role: "assistant",
72
- content: "<observation>Error: Invalid action specified. Please provide a valid action, formatted as a JSON object with 'tool' and 'arguments' keys.</observation>" # rubocop:disable Layout/LineLength
73
- })
74
147
  return
75
148
  end
76
149
 
150
+ tool_call = model_completion.response_tool_calls.first
151
+
77
152
  tool_name = tool_call["name"]
78
153
  tool_arguments = tool_call["arguments"]
154
+ tool_klass = available_model_tools_map[tool_name]
79
155
 
80
- # Add assistant's response to conversation history (without the actual tool calls)
81
- # add_conversation_history_entry({
82
- # role: "assistant",
83
- # content: "<thought>I need to use the #{tool_name} tool to help with this task.</thought>"
84
- # })
156
+ # Prepare tool arguments before recording to history so the history
157
+ # accurately reflects what was actually invoked
158
+ tool_arguments = tool_klass.prepare_tool_arguments(tool_arguments) if tool_klass.present?
85
159
 
86
- # Check if we have a final answer. If yes, we're done.
87
- if tool_name == "agent_final_answer"
88
- self.final_answer = tool_arguments["final_answer"]
89
- add_conversation_history_entry({ role: "assistant", content: "<answer>#{final_answer}</answer>" })
160
+ # Add the tool call to history (with prepared arguments if tool is known)
161
+ tool_call_message = Raif::Messages::ToolCall.new(
162
+ provider_tool_call_id: tool_call["provider_tool_call_id"],
163
+ name: tool_call["name"],
164
+ arguments: tool_arguments,
165
+ assistant_message: assistant_response_message,
166
+ provider_metadata: tool_call["provider_metadata"]
167
+ )
168
+ add_conversation_history_entry(tool_call_message.to_h)
169
+
170
+ if required_tool.present? && tool_name != required_tool.tool_name
171
+ error_content = "Error: This iteration required the tool '#{required_tool.tool_name}', but the model called '#{tool_name}' instead."
172
+ handle_iteration_error(error_content, required_tool:)
90
173
  return
91
174
  end
92
175
 
93
- # Add the tool call to conversation history
94
- add_conversation_history_entry({
95
- role: "assistant",
96
- content: "<action>#{JSON.pretty_generate(tool_call)}</action>"
97
- })
98
-
99
- # Find the tool class and process it
100
- tool_klass = available_model_tools_map[tool_name]
101
-
102
176
  # The model tried to use a tool that doesn't exist
103
- unless tool_klass
104
- add_conversation_history_entry({
105
- role: "assistant",
106
- content: "<observation>Error: Tool '#{tool_name}' not found. Available tools: #{available_model_tools_map.keys.join(", ")}</observation>"
107
- })
177
+ if tool_klass.blank?
178
+ error_content = "Error: Tool '#{tool_name}' is not a valid tool. " \
179
+ "Available tools: #{available_model_tools_map.keys.join(", ")}"
180
+ handle_iteration_error(error_content, required_tool:)
108
181
  return
109
182
  end
110
183
 
184
+ # Make sure the tool arguments match the tool's schema
111
185
  unless JSON::Validator.validate(tool_klass.tool_arguments_schema, tool_arguments)
112
- add_conversation_history_entry({
113
- role: "assistant",
114
- content: "<observation>Error: Invalid tool arguments. Please provide valid arguments for the tool '#{tool_name}'. Tool arguments schema: #{tool_klass.tool_arguments_schema.to_json}</observation>" # rubocop:disable Layout/LineLength
115
- })
186
+ error_content = "Error: Invalid tool arguments for the tool '#{tool_name}'. " \
187
+ "Tool arguments schema: #{tool_klass.tool_arguments_schema.to_json}"
188
+ handle_iteration_error(error_content, required_tool:)
116
189
  return
117
190
  end
118
191
 
119
- # Process the tool and add observation to history
120
- tool_invocation = tool_klass.invoke_tool(tool_arguments: tool_arguments, source: self)
121
- observation = tool_klass.observation_for_invocation(tool_invocation)
192
+ # Process the tool invocation and add observation/result to history
193
+ tool_invocation = tool_klass.invoke_tool(
194
+ provider_tool_call_id: tool_call["provider_tool_call_id"],
195
+ tool_arguments: tool_arguments,
196
+ source: self
197
+ )
198
+
199
+ if tool_name == "agent_final_answer"
200
+ self.final_answer = tool_invocation.result
201
+ else
202
+ add_conversation_history_entry(tool_invocation.as_tool_call_result_message)
203
+ end
204
+ end
205
+
206
+ def validate_successful_completion
207
+ return if failed? || final_answer.present?
208
+
209
+ fail_run!("Agent completed without calling agent_final_answer")
210
+ end
211
+
212
+ def required_tool_warning_message(required_tool)
213
+ if required_tool == final_answer_tool
214
+ if final_iteration?
215
+ I18n.t("raif.agents.native_tool_calling_agent.final_answer_warning")
216
+ else
217
+ "Warning: This iteration requires the agent_final_answer tool. If you do not use it now, the next iteration will be your final chance."
218
+ end
219
+ else
220
+ "Warning: This iteration requires the #{required_tool.tool_name} tool."
221
+ end
222
+ end
223
+
224
+ def current_iteration_required_tool
225
+ if @required_tool_iteration_count != iteration_count
226
+ @required_tool_iteration_count = iteration_count
227
+ @current_iteration_required_tool = required_tool_for_iteration
228
+ end
229
+
230
+ @current_iteration_required_tool
231
+ end
232
+
233
+ def handle_iteration_error(error_content, required_tool: nil)
234
+ error_message = Raif::Messages::UserMessage.new(content: error_content)
235
+ add_conversation_history_entry(error_message.to_h)
236
+
237
+ return if required_tool.blank? || retry_iteration_available?
238
+
239
+ fail_run!(error_content)
240
+ end
241
+
242
+ def retry_iteration_available?
243
+ iteration_count < max_iterations
244
+ end
245
+
246
+ def log_required_tool_choice_fallback_once!
247
+ return if @logged_required_tool_choice_fallback
122
248
 
123
- add_conversation_history_entry({
124
- role: "assistant",
125
- content: "<observation>#{observation}</observation>"
126
- })
249
+ @logged_required_tool_choice_fallback = true
250
+ Raif.logger.warn(
251
+ "NativeToolCallingAgent is falling back to runtime tool-call validation because #{llm.key} " \
252
+ "cannot faithfully enforce tool_choice: :required for tools: #{available_model_tools_map.keys.join(", ")}"
253
+ )
127
254
  end
128
255
 
129
256
  def ensure_llm_supports_native_tool_use
@@ -8,6 +8,24 @@ class Raif::ApplicationRecord < Raif.config.model_superclass.constantize
8
8
  scope :newest_first, -> { order(created_at: :desc) }
9
9
  scope :oldest_first, -> { order(created_at: :asc) }
10
10
 
11
+ # Returns a scope that checks if a JSON column is not blank (not null and not empty array)
12
+ # @param column_name [Symbol, String] the name of the JSON column
13
+ # @return [ActiveRecord::Relation]
14
+ def self.where_json_not_blank(column_name)
15
+ quoted_column = connection.quote_column_name(column_name.to_s)
16
+
17
+ case connection.adapter_name.downcase
18
+ when "postgresql"
19
+ where.not(column_name => nil)
20
+ .where("jsonb_array_length(#{quoted_column}) > 0")
21
+ when "mysql2", "trilogy"
22
+ where.not(column_name => nil)
23
+ .where("JSON_LENGTH(#{quoted_column}) > 0")
24
+ else
25
+ raise "Unsupported database: #{connection.adapter_name}"
26
+ end
27
+ end
28
+
11
29
  def self.table_name_prefix
12
30
  "raif_"
13
31
  end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif::Concerns::AgentInferenceStats
4
+ extend ActiveSupport::Concern
5
+
6
+ # Returns the total number of prompt tokens across all model completions
7
+ def total_prompt_tokens
8
+ @total_prompt_tokens ||= raif_model_completions.sum(:prompt_tokens)
9
+ end
10
+
11
+ # Returns the total number of completion tokens across all model completions
12
+ def total_completion_tokens
13
+ @total_completion_tokens ||= raif_model_completions.sum(:completion_tokens)
14
+ end
15
+
16
+ # Returns the total number of tokens across all model completions
17
+ def total_tokens_sum
18
+ @total_tokens_sum ||= raif_model_completions.sum(:total_tokens)
19
+ end
20
+
21
+ # Returns the total cost of prompt tokens across all model completions
22
+ def total_prompt_token_cost
23
+ @total_prompt_token_cost ||= raif_model_completions.sum(:prompt_token_cost)
24
+ end
25
+
26
+ # Returns the total cost of output tokens across all model completions
27
+ def total_output_token_cost
28
+ @total_output_token_cost ||= raif_model_completions.sum(:output_token_cost)
29
+ end
30
+
31
+ # Returns the total cost across all model completions
32
+ def total_cost
33
+ @total_cost ||= raif_model_completions.sum(:total_cost)
34
+ end
35
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Raif
4
+ module Concerns
5
+ module HasPromptTemplates
6
+ extend ActiveSupport::Concern
7
+
8
+ class TemplateContext < ActionView::Base.with_empty_template_cache
9
+ def initialize(lookup_context, instance)
10
+ super(lookup_context, {}, nil)
11
+ @_instance = instance
12
+ end
13
+
14
+ def method_missing(method_name, ...)
15
+ if @_instance.respond_to?(method_name)
16
+ @_instance.public_send(method_name, ...)
17
+ else
18
+ super
19
+ end
20
+ end
21
+
22
+ def respond_to_missing?(method_name, include_private = false)
23
+ @_instance.respond_to?(method_name, include_private) || super
24
+ end
25
+ end
26
+
27
+ class_methods do
28
+ # Returns the template prefix path derived from the class name.
29
+ # e.g. Raif::Tasks::SummarizeDocument -> "raif/tasks/summarize_document"
30
+ # e.g. Raif::Tasks::Docs::Summarize -> "raif/tasks/docs/summarize"
31
+ def prompt_template_prefix
32
+ name.underscore
33
+ end
34
+
35
+ def prompt_template_view_paths
36
+ ActionController::Base.view_paths
37
+ end
38
+ end
39
+
40
+ def build_prompt
41
+ if prompt_template_exists?(:prompt)
42
+ render_prompt_template(:prompt)
43
+ else
44
+ super
45
+ end
46
+ end
47
+
48
+ def build_system_prompt
49
+ if prompt_template_exists?(:system_prompt)
50
+ render_prompt_template(:system_prompt)
51
+ else
52
+ super
53
+ end
54
+ end
55
+
56
+ private
57
+
58
+ def prompt_template_name
59
+ self.class.prompt_template_prefix.split("/").last
60
+ end
61
+
62
+ def prompt_template_dir
63
+ File.dirname(self.class.prompt_template_prefix)
64
+ end
65
+
66
+ def prompt_template_exists?(template_type)
67
+ prompt_lookup_context_for(template_type).exists?(prompt_template_name, prompt_template_dir)
68
+ end
69
+
70
+ def prompt_lookup_context_for(template_type)
71
+ lookup = ActionView::LookupContext.new(self.class.prompt_template_view_paths)
72
+ lookup.formats = [template_type]
73
+ lookup
74
+ end
75
+
76
+ def render_prompt_template(template_type)
77
+ lookup = prompt_lookup_context_for(template_type)
78
+ context = TemplateContext.new(lookup, self)
79
+ context.render(template: "#{prompt_template_dir}/#{prompt_template_name}").strip
80
+ rescue ActionView::Template::Error, ActionView::MissingTemplate => e
81
+ raise Raif::Errors::PromptTemplateError.new(
82
+ template_path: "#{self.class.prompt_template_prefix}.#{template_type}.erb",
83
+ original_error: e
84
+ )
85
+ end
86
+ end
87
+ end
88
+ end