raif 1.0.0 → 1.1.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 (86) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +200 -41
  3. data/app/assets/stylesheets/raif/admin/stats.scss +12 -0
  4. data/app/controllers/raif/admin/application_controller.rb +14 -0
  5. data/app/controllers/raif/admin/stats/tasks_controller.rb +25 -0
  6. data/app/controllers/raif/admin/stats_controller.rb +19 -0
  7. data/app/controllers/raif/admin/tasks_controller.rb +18 -2
  8. data/app/controllers/raif/conversations_controller.rb +5 -1
  9. data/app/models/raif/agent.rb +11 -9
  10. data/app/models/raif/agents/native_tool_calling_agent.rb +11 -1
  11. data/app/models/raif/agents/re_act_agent.rb +6 -0
  12. data/app/models/raif/concerns/has_available_model_tools.rb +1 -1
  13. data/app/models/raif/concerns/json_schema_definition.rb +28 -0
  14. data/app/models/raif/concerns/llm_response_parsing.rb +23 -1
  15. data/app/models/raif/concerns/llm_temperature.rb +17 -0
  16. data/app/models/raif/concerns/llms/anthropic/message_formatting.rb +51 -0
  17. data/app/models/raif/concerns/llms/bedrock_claude/message_formatting.rb +70 -0
  18. data/app/models/raif/concerns/llms/message_formatting.rb +41 -0
  19. data/app/models/raif/concerns/llms/open_ai/message_formatting.rb +41 -0
  20. data/app/models/raif/conversation.rb +11 -3
  21. data/app/models/raif/conversation_entry.rb +22 -6
  22. data/app/models/raif/embedding_model.rb +22 -0
  23. data/app/models/raif/embedding_models/bedrock_titan.rb +34 -0
  24. data/app/models/raif/embedding_models/open_ai.rb +40 -0
  25. data/app/models/raif/llm.rb +39 -6
  26. data/app/models/raif/llms/anthropic.rb +23 -28
  27. data/app/models/raif/llms/bedrock_claude.rb +33 -19
  28. data/app/models/raif/llms/open_ai.rb +14 -17
  29. data/app/models/raif/llms/open_router.rb +93 -0
  30. data/app/models/raif/model_completion.rb +21 -2
  31. data/app/models/raif/model_file_input.rb +113 -0
  32. data/app/models/raif/model_image_input.rb +4 -0
  33. data/app/models/raif/model_tool.rb +77 -51
  34. data/app/models/raif/model_tool_invocation.rb +8 -6
  35. data/app/models/raif/model_tools/agent_final_answer.rb +18 -27
  36. data/app/models/raif/model_tools/fetch_url.rb +27 -36
  37. data/app/models/raif/model_tools/wikipedia_search.rb +46 -55
  38. data/app/models/raif/task.rb +71 -16
  39. data/app/views/layouts/raif/admin.html.erb +10 -0
  40. data/app/views/raif/admin/agents/show.html.erb +3 -1
  41. data/app/views/raif/admin/conversations/_conversation.html.erb +1 -1
  42. data/app/views/raif/admin/conversations/show.html.erb +3 -1
  43. data/app/views/raif/admin/model_completions/_model_completion.html.erb +1 -0
  44. data/app/views/raif/admin/model_completions/index.html.erb +1 -0
  45. data/app/views/raif/admin/model_completions/show.html.erb +30 -3
  46. data/app/views/raif/admin/stats/index.html.erb +128 -0
  47. data/app/views/raif/admin/stats/tasks/index.html.erb +45 -0
  48. data/app/views/raif/admin/tasks/_task.html.erb +5 -4
  49. data/app/views/raif/admin/tasks/index.html.erb +20 -2
  50. data/app/views/raif/admin/tasks/show.html.erb +3 -1
  51. data/app/views/raif/conversation_entries/_conversation_entry.html.erb +18 -14
  52. data/app/views/raif/conversation_entries/_form.html.erb +1 -1
  53. data/app/views/raif/conversation_entries/_form_with_available_tools.html.erb +4 -4
  54. data/app/views/raif/conversation_entries/_message.html.erb +10 -3
  55. data/config/locales/admin.en.yml +14 -0
  56. data/config/locales/en.yml +25 -3
  57. data/config/routes.rb +6 -0
  58. data/db/migrate/20250421202149_add_response_format_to_raif_conversations.rb +7 -0
  59. data/db/migrate/20250424200755_add_cost_columns_to_raif_model_completions.rb +14 -0
  60. data/db/migrate/20250424232946_add_created_at_indexes.rb +11 -0
  61. data/db/migrate/20250502155330_add_status_indexes_to_raif_tasks.rb +14 -0
  62. data/db/migrate/20250507155314_add_retry_count_to_raif_model_completions.rb +7 -0
  63. data/lib/generators/raif/agent/agent_generator.rb +22 -12
  64. data/lib/generators/raif/agent/templates/agent.rb.tt +3 -3
  65. data/lib/generators/raif/agent/templates/application_agent.rb.tt +7 -0
  66. data/lib/generators/raif/conversation/conversation_generator.rb +10 -0
  67. data/lib/generators/raif/conversation/templates/application_conversation.rb.tt +7 -0
  68. data/lib/generators/raif/conversation/templates/conversation.rb.tt +13 -11
  69. data/lib/generators/raif/install/templates/initializer.rb +50 -6
  70. data/lib/generators/raif/model_tool/model_tool_generator.rb +0 -5
  71. data/lib/generators/raif/model_tool/templates/model_tool.rb.tt +69 -56
  72. data/lib/generators/raif/task/templates/task.rb.tt +34 -23
  73. data/lib/raif/configuration.rb +40 -3
  74. data/lib/raif/embedding_model_registry.rb +83 -0
  75. data/lib/raif/engine.rb +34 -1
  76. data/lib/raif/errors/{open_ai/api_error.rb → invalid_model_file_input_error.rb} +1 -3
  77. data/lib/raif/errors/{anthropic/api_error.rb → invalid_model_image_input_error.rb} +1 -3
  78. data/lib/raif/errors/unsupported_feature_error.rb +8 -0
  79. data/lib/raif/errors.rb +3 -2
  80. data/lib/raif/json_schema_builder.rb +104 -0
  81. data/lib/raif/llm_registry.rb +205 -0
  82. data/lib/raif/version.rb +1 -1
  83. data/lib/raif.rb +5 -32
  84. data/lib/tasks/raif_tasks.rake +9 -4
  85. metadata +32 -19
  86. data/lib/raif/default_llms.rb +0 -37
@@ -1,13 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Raif::Llms::BedrockClaude < Raif::Llm
4
+ include Raif::Concerns::Llms::BedrockClaude::MessageFormatting
4
5
 
5
6
  def perform_model_completion!(model_completion)
6
7
  if Raif.config.aws_bedrock_model_name_prefix.present?
7
8
  model_completion.model_api_name = "#{Raif.config.aws_bedrock_model_name_prefix}.#{model_completion.model_api_name}"
8
9
  end
9
10
 
10
- params = build_api_parameters(model_completion)
11
+ params = build_request_parameters(model_completion)
11
12
  resp = bedrock_client.converse(params)
12
13
 
13
14
  model_completion.raw_response = if model_completion.response_format_json?
@@ -30,25 +31,41 @@ protected
30
31
  @bedrock_client ||= Aws::BedrockRuntime::Client.new(region: Raif.config.aws_bedrock_region)
31
32
  end
32
33
 
33
- def format_messages(messages)
34
- messages.map(&:symbolize_keys).map do |message|
35
- {
36
- role: message[:role],
37
- content: [{ text: message[:content] }]
38
- }
39
- end
40
- end
34
+ def build_request_parameters(model_completion)
35
+ # The AWS Bedrock SDK requires symbols for keys
36
+ messages_param = model_completion.messages.map(&:deep_symbolize_keys)
37
+ replace_tmp_base64_data_with_bytes(messages_param)
41
38
 
42
- def build_api_parameters(model_completion)
43
39
  params = {
44
40
  model_id: model_completion.model_api_name,
45
41
  inference_config: { max_tokens: model_completion.max_completion_tokens || 8192 },
46
- messages: format_messages(model_completion.messages)
42
+ messages: messages_param
47
43
  }
48
44
 
49
45
  params[:system] = [{ text: model_completion.system_prompt }] if model_completion.system_prompt.present?
50
46
 
51
- # Prepare tools configuration if needed
47
+ tool_config = build_tool_parameters(model_completion)
48
+ params[:tool_config] = tool_config if tool_config.present?
49
+
50
+ params
51
+ end
52
+
53
+ def replace_tmp_base64_data_with_bytes(messages)
54
+ # The AWS Bedrock SDK requires data sent as bytes (and doesn't support base64 like everyone else)
55
+ # The ModelCompletion stores the messages as JSON though, so it can't be raw bytes.
56
+ # We store the image data as base64, so we need to convert that to bytes before sending to AWS.
57
+ messages.each do |message|
58
+ message[:content].each do |content|
59
+ next unless content[:image] || content[:document]
60
+
61
+ type_key = content[:image] ? :image : :document
62
+ base64_data = content[type_key][:source].delete(:tmp_base64_data)
63
+ content[type_key][:source][:bytes] = Base64.strict_decode64(base64_data)
64
+ end
65
+ end
66
+ end
67
+
68
+ def build_tool_parameters(model_completion)
52
69
  tools = []
53
70
 
54
71
  # If we're looking for a JSON response, add a tool to the request that the model can use to provide a JSON response
@@ -71,14 +88,11 @@ protected
71
88
  end
72
89
  end
73
90
 
74
- # Add tool configuration if any tools are available
75
- if tools.any?
76
- params[:tool_config] = {
77
- tools: tools.map { |tool| { tool_spec: tool } }
78
- }
79
- end
91
+ return if tools.blank?
80
92
 
81
- params
93
+ {
94
+ tools: tools.map{|tool| { tool_spec: tool } }
95
+ }
82
96
  end
83
97
 
84
98
  def extract_text_response(resp)
@@ -1,29 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Raif::Llms::OpenAi < Raif::Llm
4
+ include Raif::Concerns::Llms::OpenAi::MessageFormatting
4
5
 
5
6
  def perform_model_completion!(model_completion)
6
7
  model_completion.temperature ||= default_temperature
7
- parameters = build_chat_parameters(model_completion)
8
+ parameters = build_request_parameters(model_completion)
8
9
 
9
10
  response = connection.post("chat/completions") do |req|
10
- req.body = parameters.to_json
11
+ req.body = parameters
11
12
  end
12
13
 
13
- resp = JSON.parse(response.body)
14
-
15
- # Handle API errors
16
- unless response.success?
17
- error_message = resp["error"]&.dig("message") || "OpenAI API error: #{response.status}"
18
- raise Raif::Errors::OpenAi::ApiError, error_message
19
- end
14
+ response_json = response.body
20
15
 
21
16
  model_completion.update!(
22
- response_tool_calls: extract_response_tool_calls(resp),
23
- raw_response: resp.dig("choices", 0, "message", "content"),
24
- completion_tokens: resp["usage"]["completion_tokens"],
25
- prompt_tokens: resp["usage"]["prompt_tokens"],
26
- total_tokens: resp["usage"]["total_tokens"],
17
+ response_tool_calls: extract_response_tool_calls(response_json),
18
+ raw_response: response_json.dig("choices", 0, "message", "content"),
19
+ completion_tokens: response_json.dig("usage", "completion_tokens"),
20
+ prompt_tokens: response_json.dig("usage", "prompt_tokens"),
21
+ total_tokens: response_json.dig("usage", "total_tokens"),
27
22
  response_format_parameter: parameters.dig(:response_format, :type)
28
23
  )
29
24
 
@@ -32,8 +27,10 @@ class Raif::Llms::OpenAi < Raif::Llm
32
27
 
33
28
  def connection
34
29
  @connection ||= Faraday.new(url: "https://api.openai.com/v1") do |f|
35
- f.headers["Content-Type"] = "application/json"
36
30
  f.headers["Authorization"] = "Bearer #{Raif.config.open_ai_api_key}"
31
+ f.request :json
32
+ f.response :json
33
+ f.response :raise_error
37
34
  end
38
35
  end
39
36
 
@@ -88,7 +85,7 @@ private
88
85
  end
89
86
  end
90
87
 
91
- def build_chat_parameters(model_completion)
88
+ def build_request_parameters(model_completion)
92
89
  formatted_system_prompt = model_completion.system_prompt.to_s.strip
93
90
 
94
91
  # If the response format is JSON, we need to include "as json" in the system prompt.
@@ -162,7 +159,7 @@ private
162
159
  def supports_structured_outputs?
163
160
  # Not all OpenAI models support structured outputs:
164
161
  # https://platform.openai.com/docs/guides/structured-outputs?api-mode=chat#supported-models
165
- provider_settings[:supports_structured_outputs]
162
+ provider_settings.key?(:supports_structured_outputs) ? provider_settings[:supports_structured_outputs] : true
166
163
  end
167
164
 
168
165
  def validate_object_properties(schema, errors)
@@ -0,0 +1,93 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Raif::Llms::OpenRouter < Raif::Llm
4
+ include Raif::Concerns::Llms::OpenAi::MessageFormatting
5
+
6
+ def perform_model_completion!(model_completion)
7
+ model_completion.temperature ||= default_temperature
8
+ parameters = build_request_parameters(model_completion)
9
+ response = connection.post("chat/completions") do |req|
10
+ req.body = parameters
11
+ end
12
+
13
+ response_json = response.body
14
+
15
+ model_completion.update!(
16
+ response_tool_calls: extract_response_tool_calls(response_json),
17
+ raw_response: response_json.dig("choices", 0, "message", "content"),
18
+ completion_tokens: response_json.dig("usage", "completion_tokens"),
19
+ prompt_tokens: response_json.dig("usage", "prompt_tokens"),
20
+ total_tokens: response_json.dig("usage", "total_tokens")
21
+ )
22
+
23
+ model_completion
24
+ end
25
+
26
+ def connection
27
+ @connection ||= Faraday.new(url: "https://openrouter.ai/api/v1") do |f|
28
+ f.headers["Authorization"] = "Bearer #{Raif.config.open_router_api_key}"
29
+ f.headers["HTTP-Referer"] = Raif.config.open_router_site_url if Raif.config.open_router_site_url.present?
30
+ f.headers["X-Title"] = Raif.config.open_router_app_name if Raif.config.open_router_app_name.present?
31
+ f.request :json
32
+ f.response :json
33
+ f.response :raise_error
34
+ end
35
+ end
36
+
37
+ protected
38
+
39
+ def build_request_parameters(model_completion)
40
+ params = {
41
+ model: model_completion.model_api_name,
42
+ messages: model_completion.messages,
43
+ temperature: model_completion.temperature.to_f,
44
+ max_tokens: model_completion.max_completion_tokens || default_max_completion_tokens,
45
+ stream: false
46
+ }
47
+
48
+ # Add system message to the messages array if present
49
+ if model_completion.system_prompt.present?
50
+ params[:messages].unshift({ "role" => "system", "content" => model_completion.system_prompt })
51
+ end
52
+
53
+ if model_completion.available_model_tools.any?
54
+ tools = []
55
+
56
+ model_completion.available_model_tools_map.each do |_tool_name, tool|
57
+ tools << {
58
+ type: "function",
59
+ function: {
60
+ name: tool.tool_name,
61
+ description: tool.tool_description,
62
+ parameters: tool.tool_arguments_schema
63
+ }
64
+ }
65
+ end
66
+
67
+ params[:tools] = tools
68
+ end
69
+
70
+ params
71
+ end
72
+
73
+ def extract_response_tool_calls(response_json)
74
+ tool_calls = response_json.dig("choices", 0, "message", "tool_calls")
75
+ return [] unless tool_calls.is_a?(Array)
76
+
77
+ tool_calls.map do |tool_call|
78
+ next unless tool_call["type"] == "function"
79
+
80
+ function = tool_call["function"]
81
+ next unless function.is_a?(Hash)
82
+
83
+ {
84
+ "id" => tool_call["id"],
85
+ "type" => "function",
86
+ "function" => {
87
+ "name" => function["name"],
88
+ "arguments" => function["arguments"]
89
+ }
90
+ }
91
+ end.compact
92
+ end
93
+ end
@@ -12,6 +12,7 @@ class Raif::ModelCompletion < Raif::ApplicationRecord
12
12
  delegate :json_response_schema, to: :source, allow_nil: true
13
13
 
14
14
  before_save :set_total_tokens
15
+ before_save :calculate_costs
15
16
 
16
17
  after_initialize -> { self.messages ||= [] }
17
18
  after_initialize -> { self.available_model_tools ||= [] }
@@ -20,9 +21,27 @@ class Raif::ModelCompletion < Raif::ApplicationRecord
20
21
  source.json_response_schema if source&.respond_to?(:json_response_schema)
21
22
  end
22
23
 
23
- protected
24
-
25
24
  def set_total_tokens
26
25
  self.total_tokens ||= completion_tokens.present? && prompt_tokens.present? ? completion_tokens + prompt_tokens : nil
27
26
  end
27
+
28
+ def calculate_costs
29
+ if prompt_tokens.present? && llm_config[:input_token_cost].present?
30
+ self.prompt_token_cost = llm_config[:input_token_cost] * prompt_tokens
31
+ end
32
+
33
+ if completion_tokens.present? && llm_config[:output_token_cost].present?
34
+ self.output_token_cost = llm_config[:output_token_cost] * completion_tokens
35
+ end
36
+
37
+ if prompt_token_cost.present? || output_token_cost.present?
38
+ self.total_cost = (prompt_token_cost || 0) + (output_token_cost || 0)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def llm_config
45
+ @llm_config ||= Raif.llm_config(llm_model_key.to_sym)
46
+ end
28
47
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Raif::ModelFileInput
4
+ include ActiveModel::Model
5
+
6
+ attr_accessor :input, :url, :base64_data, :filename, :content_type, :source_type
7
+
8
+ validates :url, format: { with: URI::DEFAULT_PARSER.make_regexp(%w[http https]), message: "is not a valid URL" }, allow_nil: true
9
+ validates :base64_data, presence: { message: "could not be read from input" }, if: -> { input.present? }
10
+ validates :content_type, presence: { message: "could not be determined" }, if: -> { input.present? }
11
+
12
+ def initialize(input: nil, url: nil)
13
+ raise ArgumentError, "You must provide either an input or a URL" if input.blank? && url.blank?
14
+ raise ArgumentError, "Provide either input or URL, not both" if input.present? && url.present?
15
+
16
+ super(input: input, url: url)
17
+
18
+ if url.present?
19
+ @source_type = :url
20
+ elsif input.present?
21
+ @source_type = :file_content
22
+ process_input!
23
+ end
24
+
25
+ unless valid?
26
+ raise Raif::Errors::InvalidModelFileInputError, errors.full_messages.join(", ")
27
+ end
28
+ end
29
+
30
+ def inspect
31
+ "#<#{self.class.name} input=#{input.inspect} url=#{url.inspect} base64_data=#{base64_data&.truncate(20)} filename=#{filename.inspect} content_type=#{content_type.inspect} source_type=#{source_type.inspect}>" # rubocop:disable Layout/LineLength
32
+ end
33
+
34
+ def file_bytes
35
+ Base64.strict_decode64(base64_data)
36
+ end
37
+
38
+ private
39
+
40
+ def process_input!
41
+ if input_is_active_storage_blob?
42
+ process_active_storage_blob(input)
43
+ return
44
+ elsif input_has_active_storage_blob?
45
+ process_active_storage_blob(input.blob)
46
+ return
47
+ end
48
+
49
+ case input
50
+ when String
51
+ process_string_input
52
+ when Pathname
53
+ process_from_path
54
+ when File, Tempfile, IO, StringIO
55
+ read_from_io
56
+ else
57
+ raise Raif::Errors::InvalidModelFileInputError, "Invalid input type for #{self.class.name} (got #{input.class})"
58
+ end
59
+ end
60
+
61
+ def process_string_input
62
+ if File.exist?(input)
63
+ @input = Pathname.new(input)
64
+ process_from_path
65
+ else
66
+ raise Raif::Errors::InvalidModelFileInputError, "File does not exist: #{input}"
67
+ end
68
+ end
69
+
70
+ def process_active_storage_blob(blob)
71
+ @filename = blob.filename.to_s
72
+ @content_type = blob.content_type
73
+ @base64_data = Base64.strict_encode64(blob.download)
74
+ end
75
+
76
+ def process_from_path
77
+ @filename = input.basename.to_s
78
+ @content_type = Marcel::MimeType.for(input)
79
+ @base64_data = Base64.strict_encode64(input.binread)
80
+ end
81
+
82
+ def read_from_io
83
+ @filename = input.respond_to?(:path) ? File.basename(input.path) : nil
84
+ @content_type = Marcel::MimeType.for(input)
85
+ try_rewind
86
+ @base64_data = Base64.strict_encode64(input.read)
87
+ end
88
+
89
+ def try_rewind
90
+ input.rewind if input.respond_to?(:rewind)
91
+ rescue IOError => e
92
+ logger.error "Failed to rewind IO: #{e.message}"
93
+ logger.error e.backtrace.join("\n")
94
+ end
95
+
96
+ def input_looks_like_base64?
97
+ input.match?(%r{\A[a-zA-Z0-9+/\n\r]+={0,2}\z})
98
+ end
99
+
100
+ def input_has_active_storage_blob?
101
+ return false unless defined?(ActiveStorage)
102
+ return false unless input.respond_to?(:blob)
103
+
104
+ input.blob.is_a?(ActiveStorage::Blob)
105
+ end
106
+
107
+ def input_is_active_storage_blob?
108
+ return false unless defined?(ActiveStorage)
109
+
110
+ input.is_a?(ActiveStorage::Blob)
111
+ end
112
+
113
+ end
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Raif::ModelImageInput < Raif::ModelFileInput
4
+ end
@@ -1,69 +1,95 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Raif::ModelTool
4
+ include Raif::Concerns::JsonSchemaDefinition
4
5
 
5
- delegate :tool_name, :tool_description, :tool_arguments_schema, to: :class
6
+ delegate :tool_name, :tool_description, :tool_arguments_schema, :example_model_invocation, to: :class
6
7
 
7
- # The description of the tool that will be provided to the model
8
- # when giving it a list of available tools.
9
- def self.description_for_llm
10
- <<~DESCRIPTION
11
- Name: #{tool_name}
12
- Description: #{tool_description}
13
- Arguments Schema:
14
- #{JSON.pretty_generate(tool_arguments_schema)}
15
- Example Usage:
16
- #{JSON.pretty_generate(example_model_invocation)}
17
- DESCRIPTION
18
- end
19
-
20
- # The name of the tool as it will be provided to the model & used in the model invocation.
21
- # Default for something like Raif::ModelTools::WikipediaSearch would be "wikipedia_search"
22
- def self.tool_name
23
- name.split("::").last.underscore
24
- end
8
+ class << self
9
+ # The description of the tool that will be provided to the model
10
+ # when giving it a list of available tools.
11
+ def description_for_llm
12
+ <<~DESCRIPTION
13
+ Name: #{tool_name}
14
+ Description: #{tool_description}
15
+ Arguments Schema:
16
+ #{JSON.pretty_generate(tool_arguments_schema)}
17
+ Example Usage:
18
+ #{JSON.pretty_generate(example_model_invocation)}
19
+ DESCRIPTION
20
+ end
25
21
 
26
- def self.tool_description
27
- raise NotImplementedError, "#{self.class.name}#tool_description is not implemented"
28
- end
22
+ # The name of the tool as it will be provided to the model & used in the model invocation.
23
+ # Default for something like Raif::ModelTools::WikipediaSearch would be "wikipedia_search"
24
+ def tool_name
25
+ name.split("::").last.underscore
26
+ end
29
27
 
30
- def self.example_model_invocation
31
- raise NotImplementedError, "#{self.class.name}#example_model_invocation is not implemented"
32
- end
28
+ def tool_description(&block)
29
+ if block_given?
30
+ @tool_description = block.call
31
+ elsif @tool_description.present?
32
+ @tool_description
33
+ else
34
+ raise NotImplementedError, "#{name}#tool_description is not implemented"
35
+ end
36
+ end
33
37
 
34
- def self.process_invocation(invocation)
35
- raise NotImplementedError, "#{self.class.name}#process_invocation is not implemented"
36
- end
38
+ def example_model_invocation(&block)
39
+ if block_given?
40
+ @example_model_invocation = block.call
41
+ elsif @example_model_invocation.present?
42
+ @example_model_invocation
43
+ else
44
+ raise NotImplementedError, "#{name}#example_model_invocation is not implemented"
45
+ end
46
+ end
37
47
 
38
- def self.invocation_partial_name
39
- name.gsub("Raif::ModelTools::", "").underscore
40
- end
48
+ def process_invocation(invocation)
49
+ raise NotImplementedError, "#{name}#process_invocation is not implemented"
50
+ end
41
51
 
42
- def self.tool_arguments_schema
43
- raise NotImplementedError, "#{self.class.name}#tool_arguments_schema is not implemented"
44
- end
52
+ def invocation_partial_name
53
+ name.gsub("Raif::ModelTools::", "").underscore
54
+ end
45
55
 
46
- def self.renderable?
47
- true
48
- end
56
+ def tool_arguments_schema(&block)
57
+ if block_given?
58
+ json_schema_definition(:tool_arguments, &block)
59
+ elsif schema_defined?(:tool_arguments)
60
+ schema_for(:tool_arguments)
61
+ else
62
+ raise NotImplementedError,
63
+ "#{name} must define tool arguments schema via tool_arguments_schema or override #{name}.tool_arguments_schema"
64
+ end
65
+ end
49
66
 
50
- def self.invoke_tool(tool_arguments:, source:)
51
- tool_invocation = Raif::ModelToolInvocation.new(
52
- source: source,
53
- tool_type: name,
54
- tool_arguments: tool_arguments
55
- )
67
+ def renderable?
68
+ true
69
+ end
56
70
 
57
- ActiveRecord::Base.transaction do
58
- tool_invocation.save!
59
- process_invocation(tool_invocation)
60
- tool_invocation.completed!
71
+ def triggers_observation_to_model?
72
+ false
61
73
  end
62
74
 
63
- tool_invocation
64
- rescue StandardError => e
65
- tool_invocation.failed!
66
- raise e
75
+ def invoke_tool(tool_arguments:, source:)
76
+ tool_invocation = Raif::ModelToolInvocation.new(
77
+ source: source,
78
+ tool_type: name,
79
+ tool_arguments: tool_arguments
80
+ )
81
+
82
+ ActiveRecord::Base.transaction do
83
+ tool_invocation.save!
84
+ process_invocation(tool_invocation)
85
+ tool_invocation.completed!
86
+ end
87
+
88
+ tool_invocation
89
+ rescue StandardError => e
90
+ tool_invocation.failed!
91
+ raise e
92
+ end
67
93
  end
68
94
 
69
95
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json-schema"
4
-
5
3
  class Raif::ModelToolInvocation < Raif::ApplicationRecord
6
4
  belongs_to :source, polymorphic: true
7
5
 
@@ -11,7 +9,11 @@ class Raif::ModelToolInvocation < Raif::ApplicationRecord
11
9
  validates :tool_type, presence: true
12
10
  validate :ensure_valid_tool_argument_schema, if: -> { tool_type.present? && tool_arguments_schema.present? }
13
11
 
14
- delegate :tool_arguments_schema, :renderable?, :tool_name, to: :tool
12
+ delegate :tool_arguments_schema,
13
+ :renderable?,
14
+ :tool_name,
15
+ :triggers_observation_to_model?,
16
+ to: :tool
15
17
 
16
18
  boolean_timestamp :completed_at
17
19
  boolean_timestamp :failed_at
@@ -25,9 +27,9 @@ class Raif::ModelToolInvocation < Raif::ApplicationRecord
25
27
  end
26
28
 
27
29
  def result_llm_message
28
- if result.present?
29
- "Result from #{tool_name}: #{result.to_json}"
30
- end
30
+ return unless tool.respond_to?(:observation_for_invocation)
31
+
32
+ tool.observation_for_invocation(self)
31
33
  end
32
34
 
33
35
  def to_partial_path
@@ -1,46 +1,37 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Raif::ModelTools::AgentFinalAnswer < Raif::ModelTool
4
+ tool_arguments_schema do
5
+ string "final_answer", description: "Your complete and final answer to the user's question or task"
6
+ end
4
7
 
5
- def self.example_model_invocation
8
+ example_model_invocation do
6
9
  {
7
10
  "name" => tool_name,
8
11
  "arguments" => { "final_answer": "The answer to the user's question or task" }
9
12
  }
10
13
  end
11
14
 
12
- def self.tool_arguments_schema
13
- {
14
- type: "object",
15
- additionalProperties: false,
16
- required: ["final_answer"],
17
- properties: {
18
- final_answer: {
19
- type: "string",
20
- description: "Your complete and final answer to the user's question or task"
21
- }
22
- }
23
- }
24
- end
25
-
26
- def self.tool_description
15
+ tool_description do
27
16
  "Provide your final answer to the user's question or task"
28
17
  end
29
18
 
30
- def self.observation_for_invocation(tool_invocation)
31
- return "No answer provided" unless tool_invocation.result.present?
19
+ class << self
20
+ def observation_for_invocation(tool_invocation)
21
+ return "No answer provided" unless tool_invocation.result.present?
32
22
 
33
- tool_invocation.result["final_answer"]
34
- end
23
+ tool_invocation.result["final_answer"]
24
+ end
35
25
 
36
- def self.process_invocation(tool_invocation)
37
- tool_invocation.update!(
38
- result: {
39
- final_answer: tool_invocation.tool_arguments["final_answer"]
40
- }
41
- )
26
+ def process_invocation(tool_invocation)
27
+ tool_invocation.update!(
28
+ result: {
29
+ final_answer: tool_invocation.tool_arguments["final_answer"]
30
+ }
31
+ )
42
32
 
43
- tool_invocation.result
33
+ tool_invocation.result
34
+ end
44
35
  end
45
36
 
46
37
  end