raif 1.0.0 → 1.2.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 (116) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +346 -43
  3. data/app/assets/builds/raif.css +26 -1
  4. data/app/assets/stylesheets/raif/admin/stats.scss +12 -0
  5. data/app/assets/stylesheets/raif/loader.scss +27 -1
  6. data/app/controllers/raif/admin/application_controller.rb +14 -0
  7. data/app/controllers/raif/admin/stats/tasks_controller.rb +25 -0
  8. data/app/controllers/raif/admin/stats_controller.rb +19 -0
  9. data/app/controllers/raif/admin/tasks_controller.rb +18 -2
  10. data/app/controllers/raif/conversations_controller.rb +5 -1
  11. data/app/models/raif/agent.rb +11 -9
  12. data/app/models/raif/agents/native_tool_calling_agent.rb +11 -1
  13. data/app/models/raif/agents/re_act_agent.rb +6 -0
  14. data/app/models/raif/concerns/has_available_model_tools.rb +1 -1
  15. data/app/models/raif/concerns/json_schema_definition.rb +28 -0
  16. data/app/models/raif/concerns/llm_response_parsing.rb +42 -14
  17. data/app/models/raif/concerns/llm_temperature.rb +17 -0
  18. data/app/models/raif/concerns/llms/anthropic/message_formatting.rb +51 -0
  19. data/app/models/raif/concerns/llms/anthropic/tool_formatting.rb +56 -0
  20. data/app/models/raif/concerns/llms/bedrock/message_formatting.rb +70 -0
  21. data/app/models/raif/concerns/llms/bedrock/tool_formatting.rb +37 -0
  22. data/app/models/raif/concerns/llms/message_formatting.rb +42 -0
  23. data/app/models/raif/concerns/llms/open_ai/json_schema_validation.rb +138 -0
  24. data/app/models/raif/concerns/llms/open_ai_completions/message_formatting.rb +41 -0
  25. data/app/models/raif/concerns/llms/open_ai_completions/tool_formatting.rb +26 -0
  26. data/app/models/raif/concerns/llms/open_ai_responses/message_formatting.rb +43 -0
  27. data/app/models/raif/concerns/llms/open_ai_responses/tool_formatting.rb +42 -0
  28. data/app/models/raif/conversation.rb +28 -7
  29. data/app/models/raif/conversation_entry.rb +40 -8
  30. data/app/models/raif/embedding_model.rb +22 -0
  31. data/app/models/raif/embedding_models/bedrock.rb +34 -0
  32. data/app/models/raif/embedding_models/open_ai.rb +40 -0
  33. data/app/models/raif/llm.rb +108 -9
  34. data/app/models/raif/llms/anthropic.rb +72 -57
  35. data/app/models/raif/llms/bedrock.rb +165 -0
  36. data/app/models/raif/llms/open_ai_base.rb +66 -0
  37. data/app/models/raif/llms/open_ai_completions.rb +100 -0
  38. data/app/models/raif/llms/open_ai_responses.rb +144 -0
  39. data/app/models/raif/llms/open_router.rb +88 -0
  40. data/app/models/raif/model_completion.rb +23 -2
  41. data/app/models/raif/model_file_input.rb +113 -0
  42. data/app/models/raif/model_image_input.rb +4 -0
  43. data/app/models/raif/model_tool.rb +82 -52
  44. data/app/models/raif/model_tool_invocation.rb +8 -6
  45. data/app/models/raif/model_tools/agent_final_answer.rb +18 -27
  46. data/app/models/raif/model_tools/fetch_url.rb +27 -36
  47. data/app/models/raif/model_tools/provider_managed/base.rb +9 -0
  48. data/app/models/raif/model_tools/provider_managed/code_execution.rb +5 -0
  49. data/app/models/raif/model_tools/provider_managed/image_generation.rb +5 -0
  50. data/app/models/raif/model_tools/provider_managed/web_search.rb +5 -0
  51. data/app/models/raif/model_tools/wikipedia_search.rb +46 -55
  52. data/app/models/raif/streaming_responses/anthropic.rb +63 -0
  53. data/app/models/raif/streaming_responses/bedrock.rb +89 -0
  54. data/app/models/raif/streaming_responses/open_ai_completions.rb +76 -0
  55. data/app/models/raif/streaming_responses/open_ai_responses.rb +54 -0
  56. data/app/models/raif/task.rb +71 -16
  57. data/app/views/layouts/raif/admin.html.erb +10 -0
  58. data/app/views/raif/admin/agents/show.html.erb +3 -1
  59. data/app/views/raif/admin/conversations/_conversation.html.erb +1 -1
  60. data/app/views/raif/admin/conversations/_conversation_entry.html.erb +48 -0
  61. data/app/views/raif/admin/conversations/show.html.erb +4 -2
  62. data/app/views/raif/admin/model_completions/_model_completion.html.erb +8 -0
  63. data/app/views/raif/admin/model_completions/index.html.erb +2 -0
  64. data/app/views/raif/admin/model_completions/show.html.erb +58 -3
  65. data/app/views/raif/admin/stats/index.html.erb +128 -0
  66. data/app/views/raif/admin/stats/tasks/index.html.erb +45 -0
  67. data/app/views/raif/admin/tasks/_task.html.erb +5 -4
  68. data/app/views/raif/admin/tasks/index.html.erb +20 -2
  69. data/app/views/raif/admin/tasks/show.html.erb +3 -1
  70. data/app/views/raif/conversation_entries/_citations.html.erb +9 -0
  71. data/app/views/raif/conversation_entries/_conversation_entry.html.erb +22 -14
  72. data/app/views/raif/conversation_entries/_form.html.erb +1 -1
  73. data/app/views/raif/conversation_entries/_form_with_available_tools.html.erb +4 -4
  74. data/app/views/raif/conversation_entries/_message.html.erb +14 -3
  75. data/config/locales/admin.en.yml +16 -0
  76. data/config/locales/en.yml +47 -3
  77. data/config/routes.rb +6 -0
  78. data/db/migrate/20250224234252_create_raif_tables.rb +1 -1
  79. data/db/migrate/20250421202149_add_response_format_to_raif_conversations.rb +7 -0
  80. data/db/migrate/20250424200755_add_cost_columns_to_raif_model_completions.rb +14 -0
  81. data/db/migrate/20250424232946_add_created_at_indexes.rb +11 -0
  82. data/db/migrate/20250502155330_add_status_indexes_to_raif_tasks.rb +14 -0
  83. data/db/migrate/20250507155314_add_retry_count_to_raif_model_completions.rb +7 -0
  84. data/db/migrate/20250527213016_add_response_id_and_response_array_to_model_completions.rb +14 -0
  85. data/db/migrate/20250603140622_add_citations_to_raif_model_completions.rb +13 -0
  86. data/db/migrate/20250603202013_add_stream_response_to_raif_model_completions.rb +7 -0
  87. data/lib/generators/raif/agent/agent_generator.rb +22 -12
  88. data/lib/generators/raif/agent/templates/agent.rb.tt +3 -3
  89. data/lib/generators/raif/agent/templates/application_agent.rb.tt +7 -0
  90. data/lib/generators/raif/conversation/conversation_generator.rb +10 -0
  91. data/lib/generators/raif/conversation/templates/application_conversation.rb.tt +7 -0
  92. data/lib/generators/raif/conversation/templates/conversation.rb.tt +16 -14
  93. data/lib/generators/raif/install/templates/initializer.rb +62 -6
  94. data/lib/generators/raif/model_tool/model_tool_generator.rb +0 -5
  95. data/lib/generators/raif/model_tool/templates/model_tool.rb.tt +69 -56
  96. data/lib/generators/raif/task/templates/task.rb.tt +34 -23
  97. data/lib/raif/configuration.rb +63 -4
  98. data/lib/raif/embedding_model_registry.rb +83 -0
  99. data/lib/raif/engine.rb +56 -7
  100. data/lib/raif/errors/{open_ai/api_error.rb → invalid_model_file_input_error.rb} +1 -3
  101. data/lib/raif/errors/{anthropic/api_error.rb → invalid_model_image_input_error.rb} +1 -3
  102. data/lib/raif/errors/streaming_error.rb +18 -0
  103. data/lib/raif/errors/unsupported_feature_error.rb +8 -0
  104. data/lib/raif/errors.rb +4 -2
  105. data/lib/raif/json_schema_builder.rb +104 -0
  106. data/lib/raif/llm_registry.rb +315 -0
  107. data/lib/raif/migration_checker.rb +74 -0
  108. data/lib/raif/utils/html_fragment_processor.rb +169 -0
  109. data/lib/raif/utils.rb +1 -0
  110. data/lib/raif/version.rb +1 -1
  111. data/lib/raif.rb +7 -32
  112. data/lib/tasks/raif_tasks.rake +9 -4
  113. metadata +62 -12
  114. data/app/models/raif/llms/bedrock_claude.rb +0 -134
  115. data/app/models/raif/llms/open_ai.rb +0 -259
  116. data/lib/raif/default_llms.rb +0 -37
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Raif::Llms::OpenAiResponses < Raif::Llms::OpenAiBase
4
+ include Raif::Concerns::Llms::OpenAiResponses::MessageFormatting
5
+ include Raif::Concerns::Llms::OpenAiResponses::ToolFormatting
6
+
7
+ private
8
+
9
+ def api_path
10
+ "responses"
11
+ end
12
+
13
+ def streaming_response_type
14
+ Raif::StreamingResponses::OpenAiResponses
15
+ end
16
+
17
+ def update_model_completion(model_completion, response_json)
18
+ model_completion.update!(
19
+ response_id: response_json["id"],
20
+ response_tool_calls: extract_response_tool_calls(response_json),
21
+ raw_response: extract_raw_response(response_json),
22
+ response_array: response_json["output"],
23
+ citations: extract_citations(response_json),
24
+ completion_tokens: response_json.dig("usage", "output_tokens"),
25
+ prompt_tokens: response_json.dig("usage", "input_tokens"),
26
+ total_tokens: response_json.dig("usage", "total_tokens")
27
+ )
28
+ end
29
+
30
+ def extract_response_tool_calls(resp)
31
+ return if resp["output"].blank?
32
+
33
+ tool_calls = []
34
+ resp["output"].each do |output_item|
35
+ next unless output_item["type"] == "function_call"
36
+
37
+ tool_calls << {
38
+ "name" => output_item["name"],
39
+ "arguments" => JSON.parse(output_item["arguments"])
40
+ }
41
+ end
42
+
43
+ tool_calls.any? ? tool_calls : nil
44
+ end
45
+
46
+ def extract_raw_response(resp)
47
+ text_outputs = []
48
+
49
+ output_messages = resp["output"]&.select{ |output_item| output_item["type"] == "message" }
50
+ output_messages&.each do |output_message|
51
+ output_message["content"].each do |content_item|
52
+ text_outputs << content_item["text"] if content_item["type"] == "output_text"
53
+ end
54
+ end
55
+
56
+ text_outputs.join("\n").presence
57
+ end
58
+
59
+ def extract_citations(resp)
60
+ return [] if resp["output"].blank?
61
+
62
+ citations = []
63
+
64
+ # Look through output messages for citations in annotations
65
+ output_messages = resp["output"].select{|output_item| output_item["type"] == "message" }
66
+ output_messages.each do |output_message|
67
+ next unless output_message["content"].present?
68
+
69
+ output_message["content"].each do |content_item|
70
+ next unless content_item["type"] == "output_text" && content_item["annotations"].present?
71
+
72
+ content_item["annotations"].each do |annotation|
73
+ next unless annotation["type"] == "url_citation"
74
+
75
+ citations << {
76
+ "url" => Raif::Utils::HtmlFragmentProcessor.strip_tracking_parameters(annotation["url"]),
77
+ "title" => annotation["title"]
78
+ }
79
+ end
80
+ end
81
+ end
82
+
83
+ citations.uniq{|citation| citation["url"] }
84
+ end
85
+
86
+ def build_request_parameters(model_completion)
87
+ parameters = {
88
+ model: api_name,
89
+ input: model_completion.messages,
90
+ }
91
+
92
+ if supports_temperature?
93
+ parameters[:temperature] = model_completion.temperature.to_f
94
+ end
95
+
96
+ parameters[:stream] = true if model_completion.stream_response?
97
+
98
+ # Add instructions (system prompt) if present
99
+ formatted_system_prompt = format_system_prompt(model_completion)
100
+ if formatted_system_prompt.present?
101
+ parameters[:instructions] = formatted_system_prompt
102
+ end
103
+
104
+ # Add max_output_tokens if specified
105
+ if model_completion.max_completion_tokens.present?
106
+ parameters[:max_output_tokens] = model_completion.max_completion_tokens
107
+ end
108
+
109
+ # If the LLM supports native tool use and there are available tools, add them to the parameters
110
+ if supports_native_tool_use?
111
+ tools = build_tools_parameter(model_completion)
112
+ parameters[:tools] = tools unless tools.blank?
113
+ end
114
+
115
+ # Add response format if needed. Default will be { "type": "text" }
116
+ response_format = determine_response_format(model_completion)
117
+ if response_format.present?
118
+ parameters[:text] = { format: response_format }
119
+ model_completion.response_format_parameter = response_format[:type]
120
+ end
121
+
122
+ parameters
123
+ end
124
+
125
+ def determine_response_format(model_completion)
126
+ # Only configure response format for JSON outputs
127
+ return unless model_completion.response_format_json?
128
+
129
+ if model_completion.json_response_schema.present? && supports_structured_outputs?
130
+ validate_json_schema!(model_completion.json_response_schema)
131
+
132
+ {
133
+ type: "json_schema",
134
+ name: "json_response_schema",
135
+ strict: true,
136
+ schema: model_completion.json_response_schema
137
+ }
138
+ else
139
+ # Default JSON mode for OpenAI models that don't support structured outputs or no schema is provided
140
+ { type: "json_object" }
141
+ end
142
+ end
143
+
144
+ end
@@ -0,0 +1,88 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Raif::Llms::OpenRouter < Raif::Llm
4
+ include Raif::Concerns::Llms::OpenAiCompletions::MessageFormatting
5
+ include Raif::Concerns::Llms::OpenAiCompletions::ToolFormatting
6
+ include Raif::Concerns::Llms::OpenAi::JsonSchemaValidation
7
+
8
+ def perform_model_completion!(model_completion, &block)
9
+ model_completion.temperature ||= default_temperature
10
+ parameters = build_request_parameters(model_completion)
11
+ response = connection.post("chat/completions") do |req|
12
+ req.body = parameters
13
+ req.options.on_data = streaming_chunk_handler(model_completion, &block) if model_completion.stream_response?
14
+ end
15
+
16
+ unless model_completion.stream_response?
17
+ update_model_completion(model_completion, response.body)
18
+ end
19
+
20
+ model_completion
21
+ end
22
+
23
+ private
24
+
25
+ def connection
26
+ @connection ||= Faraday.new(url: "https://openrouter.ai/api/v1") do |f|
27
+ f.headers["Authorization"] = "Bearer #{Raif.config.open_router_api_key}"
28
+ f.headers["HTTP-Referer"] = Raif.config.open_router_site_url if Raif.config.open_router_site_url.present?
29
+ f.headers["X-Title"] = Raif.config.open_router_app_name if Raif.config.open_router_app_name.present?
30
+ f.request :json
31
+ f.response :json
32
+ f.response :raise_error
33
+ end
34
+ end
35
+
36
+ def streaming_response_type
37
+ Raif::StreamingResponses::OpenAiCompletions
38
+ end
39
+
40
+ def update_model_completion(model_completion, response_json)
41
+ model_completion.update!(
42
+ response_tool_calls: extract_response_tool_calls(response_json),
43
+ raw_response: response_json.dig("choices", 0, "message", "content"),
44
+ response_array: response_json["choices"],
45
+ completion_tokens: response_json.dig("usage", "completion_tokens"),
46
+ prompt_tokens: response_json.dig("usage", "prompt_tokens"),
47
+ total_tokens: response_json.dig("usage", "total_tokens")
48
+ )
49
+ end
50
+
51
+ def build_request_parameters(model_completion)
52
+ params = {
53
+ model: model_completion.model_api_name,
54
+ messages: model_completion.messages,
55
+ temperature: model_completion.temperature.to_f,
56
+ max_tokens: model_completion.max_completion_tokens || default_max_completion_tokens,
57
+ }
58
+
59
+ # Add system message to the messages array if present
60
+ if model_completion.system_prompt.present?
61
+ params[:messages].unshift({ "role" => "system", "content" => model_completion.system_prompt })
62
+ end
63
+
64
+ if supports_native_tool_use?
65
+ tools = build_tools_parameter(model_completion)
66
+ params[:tools] = tools unless tools.blank?
67
+ end
68
+
69
+ if model_completion.stream_response?
70
+ # Ask for usage stats in the last chunk
71
+ params[:stream] = true
72
+ params[:stream_options] = { include_usage: true }
73
+ end
74
+
75
+ params
76
+ end
77
+
78
+ def extract_response_tool_calls(resp)
79
+ return if resp.dig("choices", 0, "message", "tool_calls").blank?
80
+
81
+ resp.dig("choices", 0, "message", "tool_calls").map do |tool_call|
82
+ {
83
+ "name" => tool_call["function"]["name"],
84
+ "arguments" => JSON.parse(tool_call["function"]["arguments"])
85
+ }
86
+ end
87
+ end
88
+ end
@@ -12,17 +12,38 @@ 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 ||= [] }
19
+ after_initialize -> { self.response_array ||= [] }
20
+ after_initialize -> { self.citations ||= [] }
18
21
 
19
22
  def json_response_schema
20
23
  source.json_response_schema if source&.respond_to?(:json_response_schema)
21
24
  end
22
25
 
23
- protected
24
-
25
26
  def set_total_tokens
26
27
  self.total_tokens ||= completion_tokens.present? && prompt_tokens.present? ? completion_tokens + prompt_tokens : nil
27
28
  end
29
+
30
+ def calculate_costs
31
+ if prompt_tokens.present? && llm_config[:input_token_cost].present?
32
+ self.prompt_token_cost = llm_config[:input_token_cost] * prompt_tokens
33
+ end
34
+
35
+ if completion_tokens.present? && llm_config[:output_token_cost].present?
36
+ self.output_token_cost = llm_config[:output_token_cost] * completion_tokens
37
+ end
38
+
39
+ if prompt_token_cost.present? || output_token_cost.present?
40
+ self.total_cost = (prompt_token_cost || 0) + (output_token_cost || 0)
41
+ end
42
+ end
43
+
44
+ private
45
+
46
+ def llm_config
47
+ @llm_config ||= Raif.llm_config(llm_model_key.to_sym)
48
+ end
28
49
  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,99 @@
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
-
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
6
+ delegate :tool_name, :tool_description, :tool_arguments_schema, :example_model_invocation, to: :class
19
7
 
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
66
+
67
+ def provider_managed?
68
+ false
69
+ end
49
70
 
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
- )
56
-
57
- ActiveRecord::Base.transaction do
58
- tool_invocation.save!
59
- process_invocation(tool_invocation)
60
- tool_invocation.completed!
71
+ def renderable?
72
+ true
61
73
  end
62
74
 
63
- tool_invocation
64
- rescue StandardError => e
65
- tool_invocation.failed!
66
- raise e
75
+ def triggers_observation_to_model?
76
+ false
77
+ end
78
+
79
+ def invoke_tool(tool_arguments:, source:)
80
+ tool_invocation = Raif::ModelToolInvocation.new(
81
+ source: source,
82
+ tool_type: name,
83
+ tool_arguments: tool_arguments
84
+ )
85
+
86
+ ActiveRecord::Base.transaction do
87
+ tool_invocation.save!
88
+ process_invocation(tool_invocation)
89
+ tool_invocation.completed!
90
+ end
91
+
92
+ tool_invocation
93
+ rescue StandardError => e
94
+ tool_invocation.failed!
95
+ raise e
96
+ end
67
97
  end
68
98
 
69
99
  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