omni_agent 0.1.11 → 0.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 (75) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +43 -0
  3. data/README.md +96 -2
  4. data/Rakefile +4 -0
  5. data/app/jobs/omni_agent/embed_job.rb +10 -0
  6. data/app/jobs/omni_agent/ingest_job.rb +12 -0
  7. data/docs/agent/callbacks.md +60 -0
  8. data/docs/agent/delegation.md +56 -0
  9. data/docs/agent/provider.md +93 -0
  10. data/docs/agent/run-entrypoints.md +81 -0
  11. data/docs/agent/streaming.md +62 -0
  12. data/docs/agent/structured-output.md +80 -0
  13. data/docs/configuration.md +68 -0
  14. data/docs/evals/caching.md +21 -0
  15. data/docs/evals/generating-and-running.md +87 -0
  16. data/docs/evals/golden-sets.md +32 -0
  17. data/docs/evals/judge-provider.md +20 -0
  18. data/docs/generators.md +143 -0
  19. data/docs/index.md +98 -0
  20. data/docs/intro.md +93 -0
  21. data/docs/mcp/authentication.md +226 -0
  22. data/docs/mcp/defining-servers.md +196 -0
  23. data/docs/mcp/transports.md +166 -0
  24. data/docs/rag.md +372 -0
  25. data/docs/stock-agents/ocr.md +52 -0
  26. data/docs/stock-agents/overview.md +91 -0
  27. data/docs/supported-providers.md +47 -0
  28. data/docs/tools.md +171 -0
  29. data/exe/omni_agent +48 -10
  30. data/lib/generators/omni_agent/chunks/chunks_generator.rb +130 -0
  31. data/lib/generators/omni_agent/embeddings/embeddings_generator.rb +72 -0
  32. data/lib/generators/omni_agent/install/install_generator.rb +78 -1
  33. data/lib/generators/omni_agent/stock_agent/stock_agent_generator.rb +73 -0
  34. data/lib/generators/omni_agent/vector_store.rb +113 -0
  35. data/lib/omni_agent/agent.rb +168 -39
  36. data/lib/omni_agent/chunkable.rb +181 -0
  37. data/lib/omni_agent/chunkers/hierarchical.rb +20 -0
  38. data/lib/omni_agent/chunkers/markdown.rb +41 -0
  39. data/lib/omni_agent/chunkers/recursive.rb +53 -0
  40. data/lib/omni_agent/chunkers.rb +31 -0
  41. data/lib/omni_agent/configuration.rb +14 -1
  42. data/lib/omni_agent/docs.rb +32 -0
  43. data/lib/omni_agent/embeddable.rb +131 -0
  44. data/lib/omni_agent/engine.rb +11 -0
  45. data/lib/omni_agent/errors.rb +15 -0
  46. data/lib/omni_agent/loaders/html.rb +21 -0
  47. data/lib/omni_agent/loaders/markdown.rb +18 -0
  48. data/lib/omni_agent/loaders/pdf.rb +25 -0
  49. data/lib/omni_agent/loaders/text.rb +15 -0
  50. data/lib/omni_agent/loaders.rb +68 -0
  51. data/lib/omni_agent/providers/base.rb +14 -0
  52. data/lib/omni_agent/providers/mock.rb +89 -4
  53. data/lib/omni_agent/providers/ollama.rb +4 -0
  54. data/lib/omni_agent/providers/openai.rb +33 -2
  55. data/lib/omni_agent/providers/response.rb +6 -1
  56. data/lib/omni_agent/providers.rb +7 -0
  57. data/lib/omni_agent/schema.rb +200 -0
  58. data/lib/omni_agent/semantic_search.rb +45 -0
  59. data/lib/omni_agent/stock_agent.rb +59 -0
  60. data/lib/omni_agent/stock_agents/ocr/prompt.md.erb +6 -0
  61. data/lib/omni_agent/stock_agents/ocr.rb +70 -0
  62. data/lib/omni_agent/stock_agents.rb +26 -0
  63. data/lib/omni_agent/tool.rb +7 -107
  64. data/lib/omni_agent/version.rb +1 -1
  65. data/lib/omni_agent.rb +12 -1
  66. data/lib/tasks/omni_agent_chunks_tasks.rake +16 -0
  67. data/lib/tasks/omni_agent_embeddings_tasks.rake +15 -0
  68. metadata +49 -8
  69. data/app/assets/stylesheets/omni_agent/application.css +0 -15
  70. data/app/controllers/omni_agent/application_controller.rb +0 -4
  71. data/app/helpers/omni_agent/application_helper.rb +0 -4
  72. data/app/jobs/omni_agent/application_job.rb +0 -4
  73. data/app/mailers/omni_agent/application_mailer.rb +0 -6
  74. data/app/models/omni_agent/application_record.rb +0 -5
  75. data/app/views/layouts/omni_agent/application.html.erb +0 -17
@@ -12,13 +12,14 @@ module OmniAgent
12
12
  "Please add `gem 'openai'` to your Gemfile."
13
13
  end
14
14
 
15
- def chat(messages:, tools: [], stream: nil, **options)
15
+ def chat(messages:, tools: [], stream: nil, output_schema: nil, **options)
16
16
  validate_messages!(messages, allowed_roles: %i[system user assistant tool])
17
17
 
18
18
  messages = messages.map do |msg|
19
19
  clean_msg = msg.reject { |_, v| v.nil? }
20
20
 
21
- if !clean_msg.key?(:tool_calls) && !clean_msg[:content].is_a?(String)
21
+ # Arrays are OpenAI content parts (text, image_url, file) and pass through.
22
+ if !clean_msg.key?(:tool_calls) && !clean_msg[:content].is_a?(String) && !clean_msg[:content].is_a?(Array)
22
23
  clean_msg[:content] = ""
23
24
  end
24
25
 
@@ -42,6 +43,7 @@ module OmniAgent
42
43
  messages: messages
43
44
  }
44
45
  payload[:tools] = openai_tools if openai_tools.any?
46
+ payload[:response_format] = format_response_format(output_schema) if output_schema
45
47
  payload.merge!(options) if options.any?
46
48
 
47
49
  response = if stream
@@ -55,8 +57,26 @@ module OmniAgent
55
57
  raise OmniAgent::Error, "Error during #{provider_label} chat: #{e.message}"
56
58
  end
57
59
 
60
+ def embed(texts, model: nil)
61
+ response = with_retries do
62
+ client.embeddings.create(input: texts, model: model || default_embedding_model)
63
+ end
64
+
65
+ response.data.map(&:embedding)
66
+ rescue => e
67
+ raise OmniAgent::Error, "Error during #{provider_label} embed: #{e.message}"
68
+ end
69
+
70
+ def supports_structured_output?
71
+ true
72
+ end
73
+
58
74
  protected
59
75
 
76
+ def default_embedding_model
77
+ "text-embedding-3-small"
78
+ end
79
+
60
80
  def provider_label
61
81
  "OpenAI"
62
82
  end
@@ -109,6 +129,17 @@ module OmniAgent
109
129
  }
110
130
  end
111
131
 
132
+ def format_response_format(output_schema)
133
+ {
134
+ type: "json_schema",
135
+ json_schema: {
136
+ name: "structured_output",
137
+ schema: output_schema.json_schema,
138
+ strict: output_schema.strict_compatible?
139
+ }
140
+ }
141
+ end
142
+
112
143
  def parse_response(raw_response, raw_request)
113
144
  provider_raw_response = raw_response.respond_to?(:to_h) ? raw_response.to_h : raw_response
114
145
  choices = raw_response.respond_to?(:choices) ? raw_response.choices : provider_raw_response["choices"]
@@ -1,7 +1,7 @@
1
1
  module OmniAgent
2
2
  module Providers
3
3
  class Response
4
- attr_reader :content, :tool_calls, :raw_response, :raw_request, :generated_messages
4
+ attr_reader :content, :tool_calls, :raw_response, :raw_request, :generated_messages, :structured_output
5
5
 
6
6
  def initialize(content:, tool_calls: [], raw_response: nil, raw_request: nil, generated_messages: [])
7
7
  @content = content
@@ -23,6 +23,11 @@ module OmniAgent
23
23
  @generated_messages = Array(messages)
24
24
  self
25
25
  end
26
+
27
+ def with_structured_output(output)
28
+ @structured_output = output
29
+ self
30
+ end
26
31
  end
27
32
  end
28
33
  end
@@ -9,5 +9,12 @@ module OmniAgent
9
9
  mock_judge: OmniAgent::Providers::MockJudge
10
10
  }
11
11
  end
12
+
13
+ def self.fetch(name)
14
+ registry.fetch(name.to_sym) do
15
+ raise OmniAgent::UnknownProviderError,
16
+ "Unknown provider #{name.inspect}. Known providers: #{registry.keys.join(', ')}"
17
+ end
18
+ end
12
19
  end
13
20
  end
@@ -0,0 +1,200 @@
1
+ module OmniAgent
2
+ # A typed object schema declared with the Tool::SchemaBuilder DSL. Tools use
3
+ # it to describe and validate their input; agents use it to describe and
4
+ # validate their structured output.
5
+ class Schema
6
+ STRICT_UNSUPPORTED_KEYWORDS = %i[minLength maxLength].freeze
7
+
8
+ TYPE_CHECKS = {
9
+ "string" => ->(value) { value.is_a?(String) },
10
+ "integer" => ->(value) { value.is_a?(Integer) },
11
+ "number" => ->(value) { value.is_a?(Numeric) },
12
+ "boolean" => ->(value) { value == true || value == false },
13
+ "array" => ->(value) { value.is_a?(Array) },
14
+ "object" => ->(value) { value.is_a?(Hash) }
15
+ }.freeze
16
+
17
+ attr_reader :properties, :required, :validators, :polymorphics
18
+
19
+ def self.build(&block)
20
+ builder = Tool::SchemaBuilder.new
21
+ builder.instance_eval(&block)
22
+
23
+ new(
24
+ properties: builder.properties,
25
+ required: builder.required_fields,
26
+ validators: builder.validators,
27
+ polymorphics: builder.polymorphics
28
+ )
29
+ end
30
+
31
+ def initialize(properties: {}, required: [], validators: {}, polymorphics: {})
32
+ @properties = properties
33
+ @required = required
34
+ @validators = validators
35
+ @polymorphics = polymorphics
36
+ end
37
+
38
+ def json_schema
39
+ {
40
+ type: "object",
41
+ properties: properties,
42
+ required: required,
43
+ additionalProperties: false
44
+ }
45
+ end
46
+
47
+ # Whether the schema satisfies the constraints of strict structured-output
48
+ # modes (e.g. OpenAI's `strict: true`): every property required, no
49
+ # free-form objects, and no keywords those modes reject.
50
+ def strict_compatible?
51
+ strict_object?(json_schema)
52
+ end
53
+
54
+ # Filters unknown keys, validates the rest, and returns a symbol-keyed hash.
55
+ # Raises ArgumentError when validation fails.
56
+ def parse(values)
57
+ parse_values(values, check_types: false)
58
+ end
59
+
60
+ # Like #parse, but top-level values must also match their declared JSON
61
+ # type, and nulls for optional fields are treated as absent.
62
+ def parse_typed(values)
63
+ parse_values(values, check_types: true)
64
+ end
65
+
66
+ private
67
+
68
+ def parse_values(values, check_types:)
69
+ raise ArgumentError, "expected an object, got #{values.class}" unless values.is_a?(Hash)
70
+
71
+ kwargs = values.transform_keys(&:to_sym)
72
+ valid_keys = properties.keys.map(&:to_sym)
73
+ filtered = kwargs.slice(*valid_keys)
74
+
75
+ validate_required!(filtered)
76
+ validate_types!(filtered) if check_types
77
+ validate_enums!(filtered)
78
+ validate_constraints!(filtered)
79
+ run_custom_validators!(filtered)
80
+ resolve_polymorphics!(filtered)
81
+
82
+ filtered
83
+ end
84
+
85
+ def strict_object?(schema)
86
+ return false unless schema[:additionalProperties] == false
87
+
88
+ nested = schema[:properties] || {}
89
+ return false unless nested.keys.map(&:to_s).sort == Array(schema[:required]).map(&:to_s).sort
90
+
91
+ nested.values.all? { |property| strict_property?(property) }
92
+ end
93
+
94
+ def strict_property?(property)
95
+ return false if STRICT_UNSUPPORTED_KEYWORDS.any? { |keyword| property.key?(keyword) }
96
+
97
+ case property[:type]
98
+ when "object" then strict_object?(property)
99
+ when "array" then strict_property?(property[:items] || {})
100
+ else true
101
+ end
102
+ end
103
+
104
+ def validate_required!(kwargs)
105
+ missing = required.map(&:to_sym) - kwargs.keys
106
+ return if missing.empty?
107
+
108
+ raise ArgumentError, "missing required argument(s): #{missing.join(', ')}"
109
+ end
110
+
111
+ def validate_types!(kwargs)
112
+ required_keys = required.map(&:to_sym)
113
+ kwargs.reject! { |key, value| value.nil? && !required_keys.include?(key) }
114
+
115
+ properties.each do |name, property|
116
+ key = name.to_sym
117
+ next unless kwargs.key?(key)
118
+
119
+ type_check = TYPE_CHECKS[property[:type]]
120
+ next if type_check.nil? || type_check.call(kwargs[key])
121
+
122
+ raise ArgumentError, "#{name} must be of type #{property[:type]}, got #{kwargs[key].inspect}"
123
+ end
124
+ end
125
+
126
+ def validate_enums!(kwargs)
127
+ properties.each do |name, property|
128
+ allowed_values = property[:enum]
129
+ next unless allowed_values
130
+
131
+ key = name.to_sym
132
+ next unless kwargs.key?(key)
133
+
134
+ unless allowed_values.include?(kwargs[key])
135
+ raise ArgumentError, "invalid value for #{name}: #{kwargs[key].inspect} (must be one of: #{allowed_values.join(', ')})"
136
+ end
137
+ end
138
+ end
139
+
140
+ def validate_constraints!(kwargs)
141
+ properties.each do |name, property|
142
+ key = name.to_sym
143
+ next unless kwargs.key?(key)
144
+
145
+ check_constraints!(name, property, kwargs[key])
146
+ end
147
+ end
148
+
149
+ def check_constraints!(name, property, value)
150
+ if property[:minLength] && value.is_a?(String) && value.length < property[:minLength]
151
+ raise ArgumentError, "#{name} must be at least #{property[:minLength]} characters"
152
+ end
153
+ if property[:maxLength] && value.is_a?(String) && value.length > property[:maxLength]
154
+ raise ArgumentError, "#{name} must be at most #{property[:maxLength]} characters"
155
+ end
156
+ if property[:pattern] && value.is_a?(String) && !value.match?(Regexp.new(property[:pattern]))
157
+ raise ArgumentError, "#{name} does not match required pattern #{property[:pattern]}"
158
+ end
159
+ if property[:minimum] && value.is_a?(Numeric) && value < property[:minimum]
160
+ raise ArgumentError, "#{name} must be >= #{property[:minimum]}"
161
+ end
162
+ if property[:maximum] && value.is_a?(Numeric) && value > property[:maximum]
163
+ raise ArgumentError, "#{name} must be <= #{property[:maximum]}"
164
+ end
165
+ if property[:minItems] && value.is_a?(Array) && value.length < property[:minItems]
166
+ raise ArgumentError, "#{name} must have at least #{property[:minItems]} items"
167
+ end
168
+ if property[:maxItems] && value.is_a?(Array) && value.length > property[:maxItems]
169
+ raise ArgumentError, "#{name} must have at most #{property[:maxItems]} items"
170
+ end
171
+ end
172
+
173
+ def run_custom_validators!(kwargs)
174
+ validators.each do |name, validator|
175
+ key = name.to_sym
176
+ next unless kwargs.key?(key)
177
+
178
+ result = validator.call(kwargs[key])
179
+ raise ArgumentError, "invalid value for #{name}" if result == false
180
+ end
181
+ end
182
+
183
+ def resolve_polymorphics!(kwargs)
184
+ polymorphics.each do |name, group|
185
+ next unless group[:resolve]
186
+
187
+ type_field = group[:type_field]
188
+ id_field = group[:id_field]
189
+ next unless kwargs.key?(type_field) && kwargs.key?(id_field)
190
+
191
+ type_value = kwargs.delete(type_field)
192
+ id_value = kwargs.delete(id_field)
193
+
194
+ klass = Object.const_get(type_value.to_s)
195
+ record = klass.find(id_value)
196
+ kwargs[name.to_sym] = record
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,45 @@
1
+ module OmniAgent
2
+ # Runs a semantic search on anything `Agent.searches` accepts: a model or
3
+ # relation with `has_embedding` (or its own `semantic_search(query, limit:)`),
4
+ # or a meilisearch-rails model, searched with one of its index embedders.
5
+ module SemanticSearch
6
+ def self.searchable?(target)
7
+ target.respond_to?(:semantic_search) || meilisearch?(target)
8
+ end
9
+
10
+ def self.call(target, query, limit:, embedder: "default")
11
+ return target.semantic_search(query, limit: limit) if target.respond_to?(:semantic_search)
12
+ return meilisearch(target.all, query, limit: limit, embedder: embedder) if meilisearch?(target)
13
+
14
+ raise ArgumentError, "#{target.inspect} can't be searched"
15
+ end
16
+
17
+ def self.meilisearch?(target)
18
+ model = target.is_a?(Class) ? target : target.try(:klass)
19
+ defined?(::Meilisearch::Rails) && model.is_a?(Class) && model < ::Meilisearch::Rails
20
+ end
21
+
22
+ # Equality conditions of the relation become Meilisearch filters (those
23
+ # attributes must be filterable_attributes). Hits are then checked
24
+ # against the full relation, so no condition is ever silently skipped.
25
+ def self.meilisearch(relation, query, limit:, embedder:)
26
+ params = { limit: limit, hybrid: { embedder: embedder } }
27
+ filter = relation.where_values_hash.map { |attribute, value| meilisearch_condition(attribute, value) }
28
+ params[:filter] = filter if filter.any?
29
+
30
+ hits = relation.klass.ms_search(query, params).first(limit)
31
+ allowed = relation.where(relation.klass.primary_key => hits.map(&:id)).pluck(relation.klass.primary_key).to_set
32
+ hits.select { |hit| allowed.include?(hit.id) }
33
+ end
34
+
35
+ def self.meilisearch_condition(attribute, value)
36
+ case value
37
+ when nil then "#{attribute} IS NULL"
38
+ when Array then "#{attribute} IN [#{value.map(&:to_json).join(', ')}]"
39
+ else "#{attribute} = #{value.to_json}"
40
+ end
41
+ end
42
+
43
+ private_class_method :meilisearch, :meilisearch_condition
44
+ end
45
+ end
@@ -0,0 +1,59 @@
1
+ module OmniAgent
2
+ # Base class for stock agents: ready-made agents shipped inside the gem under
3
+ # OmniAgent::StockAgents. Host apps use them as-is or subclass them to declare
4
+ # their own `output` schema, extra prompt instructions, tools or model.
5
+ #
6
+ # module OmniAgent::StockAgents
7
+ # class Summarizer < OmniAgent::StockAgent
8
+ # description "Summarizes documents"
9
+ # use_model "gpt-4o-mini"
10
+ # end
11
+ # end
12
+ #
13
+ # Prompts are read from lib/omni_agent/stock_agents/<stock_name>/ (override
14
+ # with `prompts_path`) and rendered before any prompts the host app defines
15
+ # for its subclass in app/agents/<agent_name>/.
16
+ class StockAgent < Agent
17
+ class << self
18
+ def description(text = nil)
19
+ @description = text if text
20
+ @description || inherited_setting(:description) || "No description provided."
21
+ end
22
+
23
+ def stock_name(value = nil)
24
+ @stock_name = value.to_sym if value
25
+ @stock_name || (stock_definition? ? default_stock_name : inherited_setting(:stock_name))
26
+ end
27
+
28
+ def prompts_path(path = nil)
29
+ @prompts_path = path.to_s if path
30
+ @prompts_path || (stock_definition? ? File.join(OmniAgent::StockAgents.root, stock_name.to_s) : inherited_setting(:prompts_path))
31
+ end
32
+
33
+ private
34
+
35
+ # True for the agents defined by the gem itself, as opposed to host-app
36
+ # subclasses, which inherit the stock name and prompts of their parent.
37
+ def stock_definition?
38
+ name.to_s.start_with?("OmniAgent::StockAgents::")
39
+ end
40
+
41
+ def default_stock_name
42
+ name.split("::").last
43
+ .gsub(/([A-Z]+)([A-Z][a-z])/, "\\1_\\2")
44
+ .gsub(/([a-z\d])([A-Z])/, "\\1_\\2")
45
+ .downcase
46
+ .to_sym
47
+ end
48
+ end
49
+
50
+ private
51
+
52
+ def prompt_template_paths(prompt_method)
53
+ stock_directory = self.class.prompts_path
54
+ stock_templates = stock_directory ? prompt_templates_in(stock_directory, prompt_method) : []
55
+
56
+ stock_templates + super
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,6 @@
1
+ You read documents from images and PDFs and transcribe them exactly.
2
+
3
+ - When you receive several images or files, they are pages of the same document, in the order given.
4
+ - Transcribe only what is visible. Never guess or invent text, numbers or dates; leave the field empty when it is illegible or missing.
5
+ - Keep numbers, currency symbols, dates and identifiers exactly as written.
6
+ - Any text sent alongside the images is an instruction from the user about what to extract.
@@ -0,0 +1,70 @@
1
+ require "pathname"
2
+ require "uri"
3
+
4
+ module OmniAgent
5
+ module StockAgents
6
+ # Reads photos, scans and PDFs with a vision model. `run` takes one source
7
+ # or an array of them (pages of the same document, in order):
8
+ #
9
+ # OCR.new.run(Pathname("receipt.jpg"))
10
+ # OCR.new.run([ Pathname("page1.png"), params[:page2], record.scan ])
11
+ # OCR.new.run([ "Only the totals table matters", URI("https://example.com/scan.png") ])
12
+ #
13
+ # Sources: Pathname, IO-like objects (File, StringIO, uploaded files),
14
+ # ActiveStorage attachments/blobs, and http(s) URIs for images. Strings are
15
+ # always instructions, never file paths, so user text can't read server files.
16
+ class OCR < OmniAgent::StockAgent
17
+ description "Extracts text and structured data from photos, scans and PDFs"
18
+ use_model "gpt-4o"
19
+
20
+ SIGNATURES = {
21
+ "image/png" => "\x89PNG".b,
22
+ "image/jpeg" => "\xFF\xD8\xFF".b,
23
+ "image/gif" => "GIF8".b,
24
+ "application/pdf" => "%PDF".b
25
+ }.freeze
26
+
27
+ # Default structured output. Host apps override it by subclassing
28
+ # and declaring their own `output do ... end` block.
29
+ output do
30
+ string :text, description: "Full text of the document, in reading order"
31
+ end
32
+
33
+ private
34
+
35
+ def build_messages(input:, history:)
36
+ # [input].flatten, not Array(input): Array() splits an IO into its lines.
37
+ super(input: input.is_a?(String) ? input : [ input ].flatten.map { |source| content_part(source) }, history: history)
38
+ end
39
+
40
+ def content_part(source)
41
+ case source
42
+ when String then { type: "text", text: source }
43
+ when URI::HTTP then { type: "image_url", image_url: { url: source.to_s } }
44
+ when Pathname then file_part(source.binread)
45
+ else
46
+ if source.respond_to?(:download) then file_part(source.download)
47
+ elsif source.respond_to?(:read) then file_part(source.read.tap { source.rewind if source.respond_to?(:rewind) })
48
+ else raise ArgumentError, "#{self.class.name} can't read a #{source.class}: pass a Pathname, IO, ActiveStorage attachment or URI"
49
+ end
50
+ end
51
+ end
52
+
53
+ def file_part(bytes)
54
+ type = media_type(bytes)
55
+ url = "data:#{type};base64,#{[ bytes ].pack('m0')}"
56
+ return { type: "file", file: { filename: "document.pdf", file_data: url } } if type == "application/pdf"
57
+
58
+ { type: "image_url", image_url: { url: url } }
59
+ end
60
+
61
+ def media_type(bytes)
62
+ bytes = bytes.b
63
+ return "image/webp" if bytes.start_with?("RIFF") && bytes[8, 4] == "WEBP"
64
+
65
+ SIGNATURES.find { |_type, magic| bytes.start_with?(magic) }&.first ||
66
+ raise(OmniAgent::Error, "#{self.class.name} reads PNG, JPEG, GIF, WEBP and PDF files only")
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,26 @@
1
+ module OmniAgent
2
+ # Namespace for the stock (default) agents shipped with the gem. Each one is
3
+ # an OmniAgent::StockAgent subclass in lib/omni_agent/stock_agents/<name>.rb,
4
+ # with its prompt templates in lib/omni_agent/stock_agents/<name>/.
5
+ module StockAgents
6
+ def self.root
7
+ File.expand_path("stock_agents", __dir__)
8
+ end
9
+
10
+ def self.all
11
+ constants.sort.filter_map do |const_name|
12
+ const = const_get(const_name)
13
+ const if const.is_a?(Class) && const < OmniAgent::StockAgent
14
+ end
15
+ end
16
+
17
+ def self.find(name)
18
+ agent_class = all.find { |stock_agent| stock_agent.stock_name == name.to_sym }
19
+ return agent_class if agent_class
20
+
21
+ available = all.map(&:stock_name).join(", ")
22
+ raise OmniAgent::UnknownStockAgentError,
23
+ "Unknown stock agent #{name.inspect}. Available stock agents: #{available.empty? ? 'none' : available}"
24
+ end
25
+ end
26
+ end
@@ -47,38 +47,19 @@ module OmniAgent
47
47
  end
48
48
 
49
49
  def input(&block)
50
- if block_given?
51
- builder = SchemaBuilder.new
52
- builder.instance_eval(&block)
53
-
54
- @properties = builder.properties
55
- @required = builder.required_fields
56
- @validators = builder.validators
57
- @polymorphics = builder.polymorphics
58
- end
50
+ @input_schema = Schema.build(&block) if block_given?
51
+ end
52
+
53
+ def input_schema
54
+ @input_schema || Schema.new
59
55
  end
60
56
 
61
57
  def json_schema
62
- {
63
- type: "object",
64
- properties: @properties || {},
65
- required: @required || [],
66
- additionalProperties: false
67
- }
58
+ input_schema.json_schema
68
59
  end
69
60
 
70
61
  def parse_arguments(arguments_hash)
71
- kwargs = arguments_hash.transform_keys(&:to_sym)
72
- valid_keys = (@properties || {}).keys.map(&:to_sym)
73
- filtered = kwargs.slice(*valid_keys)
74
-
75
- validate_required!(filtered)
76
- validate_enums!(filtered)
77
- validate_constraints!(filtered)
78
- run_custom_validators!(filtered)
79
- resolve_polymorphics!(filtered)
80
-
81
- filtered
62
+ input_schema.parse(arguments_hash)
82
63
  end
83
64
 
84
65
  def stops_generation(value = true)
@@ -91,87 +72,6 @@ module OmniAgent
91
72
 
92
73
  private
93
74
 
94
- def validate_required!(kwargs)
95
- missing = (@required || []).map(&:to_sym) - kwargs.keys
96
- return if missing.empty?
97
-
98
- raise ArgumentError, "missing required argument(s): #{missing.join(', ')}"
99
- end
100
-
101
- def validate_enums!(kwargs)
102
- (@properties || {}).each do |name, property|
103
- allowed_values = property[:enum]
104
- next unless allowed_values
105
-
106
- key = name.to_sym
107
- next unless kwargs.key?(key)
108
-
109
- unless allowed_values.include?(kwargs[key])
110
- raise ArgumentError, "invalid value for #{name}: #{kwargs[key].inspect} (must be one of: #{allowed_values.join(', ')})"
111
- end
112
- end
113
- end
114
-
115
- def validate_constraints!(kwargs)
116
- (@properties || {}).each do |name, property|
117
- key = name.to_sym
118
- next unless kwargs.key?(key)
119
-
120
- check_constraints!(name, property, kwargs[key])
121
- end
122
- end
123
-
124
- def check_constraints!(name, property, value)
125
- if property[:minLength] && value.is_a?(String) && value.length < property[:minLength]
126
- raise ArgumentError, "#{name} must be at least #{property[:minLength]} characters"
127
- end
128
- if property[:maxLength] && value.is_a?(String) && value.length > property[:maxLength]
129
- raise ArgumentError, "#{name} must be at most #{property[:maxLength]} characters"
130
- end
131
- if property[:pattern] && value.is_a?(String) && !value.match?(Regexp.new(property[:pattern]))
132
- raise ArgumentError, "#{name} does not match required pattern #{property[:pattern]}"
133
- end
134
- if property[:minimum] && value.is_a?(Numeric) && value < property[:minimum]
135
- raise ArgumentError, "#{name} must be >= #{property[:minimum]}"
136
- end
137
- if property[:maximum] && value.is_a?(Numeric) && value > property[:maximum]
138
- raise ArgumentError, "#{name} must be <= #{property[:maximum]}"
139
- end
140
- if property[:minItems] && value.is_a?(Array) && value.length < property[:minItems]
141
- raise ArgumentError, "#{name} must have at least #{property[:minItems]} items"
142
- end
143
- if property[:maxItems] && value.is_a?(Array) && value.length > property[:maxItems]
144
- raise ArgumentError, "#{name} must have at most #{property[:maxItems]} items"
145
- end
146
- end
147
-
148
- def run_custom_validators!(kwargs)
149
- (@validators || {}).each do |name, validator|
150
- key = name.to_sym
151
- next unless kwargs.key?(key)
152
-
153
- result = validator.call(kwargs[key])
154
- raise ArgumentError, "invalid value for #{name}" if result == false
155
- end
156
- end
157
-
158
- def resolve_polymorphics!(kwargs)
159
- (@polymorphics || {}).each do |name, group|
160
- next unless group[:resolve]
161
-
162
- type_field = group[:type_field]
163
- id_field = group[:id_field]
164
- next unless kwargs.key?(type_field) && kwargs.key?(id_field)
165
-
166
- type_value = kwargs.delete(type_field)
167
- id_value = kwargs.delete(id_field)
168
-
169
- klass = Object.const_get(type_value.to_s)
170
- record = klass.find(id_value)
171
- kwargs[name.to_sym] = record
172
- end
173
- end
174
-
175
75
  def normalize_tags(tag_names)
176
76
  raise ArgumentError, "tags requires at least one tag" if tag_names.empty?
177
77
 
@@ -1,3 +1,3 @@
1
1
  module OmniAgent
2
- VERSION = "0.1.11"
2
+ VERSION = "0.2.0"
3
3
  end
data/lib/omni_agent.rb CHANGED
@@ -5,7 +5,8 @@ require "zeitwerk"
5
5
  loader = Zeitwerk::Loader.for_gem
6
6
  loader.inflector.inflect(
7
7
  "openai" => "OpenAI", "cli" => "CLI", "mcp" => "MCP",
8
- "oauth" => "OAuth", "jwt_verifier" => "JWTVerifier", "http_client" => "HTTPClient"
8
+ "oauth" => "OAuth", "jwt_verifier" => "JWTVerifier", "http_client" => "HTTPClient",
9
+ "pdf" => "PDF", "html" => "HTML", "ocr" => "OCR"
9
10
  )
10
11
  loader.ignore(File.expand_path("generators", __dir__))
11
12
  loader.setup
@@ -26,5 +27,15 @@ module OmniAgent
26
27
  def configure
27
28
  yield(configuration)
28
29
  end
30
+
31
+ # Embeds a String into one vector, or an Array of Strings into one vector
32
+ # per text. Index and query with the same provider and model, or the
33
+ # vectors are not comparable.
34
+ def embed(input, provider: nil, model: nil)
35
+ provider_name = provider || configuration.embedding_provider || configuration.default_provider
36
+ vectors = Providers.fetch(provider_name).new.embed(Array(input), model: model || configuration.embedding_model)
37
+
38
+ input.is_a?(Array) ? vectors : vectors.first
39
+ end
29
40
  end
30
41
  end