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
@@ -0,0 +1,53 @@
1
+ module OmniAgent
2
+ module Chunkers
3
+ # Splits on the coarsest boundary that fits: headings, then paragraphs,
4
+ # lines, sentences and words, cutting mid-word only as a last resort.
5
+ # Pieces are merged back up to `size` characters, and each chunk starts
6
+ # with up to `overlap` characters of whole pieces from the one before.
7
+ module Recursive
8
+ # Zero-width, so every separator stays attached to its text.
9
+ SEPARATORS = [
10
+ /(?=^\#{1,6}\s)/, # Markdown headings
11
+ /(?<=\n\n)/, # paragraphs
12
+ /(?<=\n)/, # lines
13
+ /(?<=[.!?]\s)/, # sentences
14
+ /(?<=\s)/ # words
15
+ ].freeze
16
+
17
+ def self.call(text, size: 1000, overlap: 200)
18
+ merge(split(text.to_s, size, SEPARATORS), size, overlap)
19
+ end
20
+
21
+ def self.split(text, size, separators)
22
+ return [ text ] if text.length <= size
23
+
24
+ separator, *rest = separators
25
+ return text.scan(/.{1,#{size}}/m) if separator.nil?
26
+
27
+ text.split(separator).flat_map { |piece| split(piece, size, rest) }
28
+ end
29
+
30
+ def self.merge(pieces, size, overlap)
31
+ chunks = []
32
+ current = []
33
+
34
+ pieces.each do |piece|
35
+ if current.any? && length(current) + piece.length > size
36
+ chunks << current.join
37
+ current.shift while current.any? && (length(current) > overlap || length(current) + piece.length > size)
38
+ end
39
+ current << piece
40
+ end
41
+ chunks << current.join if current.any?
42
+
43
+ chunks.map(&:strip).reject(&:empty?)
44
+ end
45
+
46
+ def self.length(pieces)
47
+ pieces.sum(&:length)
48
+ end
49
+
50
+ private_class_method :split, :merge, :length
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,31 @@
1
+ module OmniAgent
2
+ # Strategies that split text into chunks for `has_chunks`. A chunker is
3
+ # anything that responds to `call(text, size:, overlap:)` and returns
4
+ # Strings, or Hashes with :content and optional :context and :metadata.
5
+ module Chunkers
6
+ def self.registry
7
+ {
8
+ recursive: Recursive,
9
+ markdown: Markdown,
10
+ hierarchical: Hierarchical
11
+ }
12
+ end
13
+
14
+ def self.fetch(chunker)
15
+ return chunker if chunker.respond_to?(:call)
16
+
17
+ registry.fetch(chunker.to_sym) do
18
+ raise ArgumentError, "Unknown chunker #{chunker.inspect}. Use #{registry.keys.join(', ')}, or an object that responds to call."
19
+ end
20
+ end
21
+
22
+ # Runs a chunker and normalizes its output to Hashes, dropping blank chunks.
23
+ def self.chunk(text, chunker:, size:, overlap:)
24
+ fetch(chunker).call(text, size: size, overlap: overlap).filter_map do |chunk|
25
+ chunk = chunk.is_a?(Hash) ? chunk.to_h.transform_keys(&:to_sym) : { content: chunk.to_s }
26
+ content = chunk[:content].to_s.strip
27
+ { content: content, context: chunk[:context].presence, metadata: chunk[:metadata] || {} } unless content.empty?
28
+ end
29
+ end
30
+ end
31
+ end
@@ -2,7 +2,8 @@ module OmniAgent
2
2
  class Configuration
3
3
  attr_accessor :default_provider, :default_model, :max_retries, :retry_base_delay, :max_tool_iterations,
4
4
  :max_delegation_depth, :eval_judge_provider, :eval_judge_model, :eval_cache_enabled, :eval_cache_path,
5
- :mcp_allowed_origins, :mcp_max_request_bytes
5
+ :mcp_allowed_origins, :mcp_max_request_bytes,
6
+ :embedding_provider, :embedding_model, :document_loaders
6
7
 
7
8
  def initialize
8
9
  @default_provider = :openai
@@ -17,6 +18,18 @@ module OmniAgent
17
18
  @eval_cache_path = "tmp/omni_agent_eval_cache.json"
18
19
  @mcp_allowed_origins = []
19
20
  @mcp_max_request_bytes = 1_048_576
21
+ @embedding_provider = nil
22
+ @embedding_model = nil
23
+ @embedding_job_options = {}
24
+ @document_loaders = {}
25
+ end
26
+
27
+ attr_reader :embedding_job_options
28
+
29
+ # ActiveJob `set` options for OmniAgent::EmbedJob (has_embedding later: true),
30
+ # e.g. { queue: :embeddings, priority: 10 }.
31
+ def embedding_job_options=(options)
32
+ @embedding_job_options = OmniAgent::Embeddable.validate_job_options!(options, "embedding_job_options")
20
33
  end
21
34
  end
22
35
  end
@@ -0,0 +1,32 @@
1
+ module OmniAgent
2
+ # The plain-Markdown documentation bundled in the gem's docs/ directory, so
3
+ # coding agents working in a host app can read it offline. Generated from
4
+ # omniagent-docs/ with `bundle exec rake docs:sync`.
5
+ module Docs
6
+ ROOT = File.expand_path("../../docs", __dir__)
7
+
8
+ class << self
9
+ def root
10
+ ROOT
11
+ end
12
+
13
+ # Page names relative to the docs root, without the .md extension
14
+ # (e.g. "tools", "mcp/authentication").
15
+ def pages
16
+ Dir.glob("**/*.md", base: root).map { |path| path.delete_suffix(".md") }.sort
17
+ end
18
+
19
+ def path(page = "index")
20
+ name = page.to_s.strip.delete_prefix("/").delete_suffix(".md")
21
+ return unless pages.include?(name)
22
+
23
+ File.join(root, "#{name}.md")
24
+ end
25
+
26
+ def read(page = "index")
27
+ file = path(page)
28
+ File.read(file) if file
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,131 @@
1
+ module OmniAgent
2
+ # ActiveRecord macro that keeps an embedding of some attributes up to date
3
+ # and adds `semantic_search`. Extended into every model by the engine.
4
+ #
5
+ # class Document < ApplicationRecord
6
+ # has_embedding :title, :content
7
+ # end
8
+ #
9
+ # Document.where(account_id: 1).semantic_search("refund policy", limit: 5)
10
+ #
11
+ # The default backend is the neighbor gem (pgvector, sqlite-vec, MariaDB).
12
+ # `later: true` embeds in OmniAgent::EmbedJob after commit instead; pass a
13
+ # Hash (`later: { queue: :embeddings }`) to override the global
14
+ # `embedding_job_options` for this model.
15
+ #
16
+ # `using: :ruby` stores vectors in a JSON column and ranks them in Ruby;
17
+ # it loads every row per search, so it only suits small tables.
18
+ module Embeddable
19
+ BACKENDS = %i[neighbor ruby].freeze
20
+ # The options ActiveJob's `set` takes, for `later:` and embedding_job_options.
21
+ JOB_OPTIONS = %i[queue priority wait wait_until].freeze
22
+
23
+ def self.validate_job_options!(options, setting)
24
+ unknown = options.keys.map(&:to_sym) - JOB_OPTIONS
25
+ return options.to_h.transform_keys(&:to_sym) if unknown.empty?
26
+
27
+ raise ArgumentError, "Unknown #{setting} option #{unknown.first.inspect}. Use #{JOB_OPTIONS.join(', ')}."
28
+ end
29
+
30
+ def has_embedding(*sources, column: :embedding, dimensions: nil, using: :neighbor, later: false)
31
+ raise ArgumentError, "has_embedding requires at least one attribute" if sources.empty?
32
+ raise ArgumentError, "has_embedding using: must be :neighbor or :ruby" unless BACKENDS.include?(using)
33
+ raise ArgumentError, "has_embedding later: must be true, false or a Hash of job options" unless [ true, false ].include?(later) || later.is_a?(Hash)
34
+ raise ArgumentError, "#{name} already uses has_chunks. A model uses has_embedding or has_chunks, not both." if respond_to?(:chunk_options)
35
+
36
+ class_attribute :embedding_options, instance_writer: false
37
+ self.embedding_options = {
38
+ sources: sources.map(&:to_sym),
39
+ column: column.to_sym,
40
+ using: using,
41
+ job_options: later.is_a?(Hash) ? Embeddable.validate_job_options!(later, "later:") : {}
42
+ }
43
+
44
+ if using == :neighbor
45
+ unless respond_to?(:has_neighbors)
46
+ raise OmniAgent::MissingDependencyError,
47
+ "has_embedding needs the neighbor gem: add `gem \"neighbor\"` to your Gemfile. " \
48
+ "Without a vector database, `using: :ruby` works for small tables only."
49
+ end
50
+
51
+ has_neighbors column, dimensions: dimensions
52
+ end
53
+
54
+ extend ClassMethods
55
+ include InstanceMethods
56
+
57
+ if later
58
+ after_commit :embed_later, on: %i[create update], if: -> { embedding_sources.any? { |source| saved_change_to_attribute?(source) } }
59
+ else
60
+ before_save :assign_embedding, if: -> { embedding_sources.any? { |source| will_save_change_to_attribute?(source) } }
61
+ end
62
+ end
63
+
64
+ module ClassMethods
65
+ def semantic_search(query, limit: 5)
66
+ vector = OmniAgent.embed(query)
67
+ column = embedding_options[:column]
68
+
69
+ if embedding_options[:using] == :ruby
70
+ # ponytail: loads every embedded row and ranks in Ruby, O(n) per search; use neighbor past ~10k rows.
71
+ all.where.not(column => nil).to_a.max_by(limit) { |record| cosine_similarity(record[column], vector) }
72
+ else
73
+ all.nearest_neighbors(column, vector, distance: "cosine").first(limit)
74
+ end
75
+ end
76
+
77
+ # Embeds records that have no vector yet, or every record with
78
+ # force: true (e.g. after switching embedding models).
79
+ def embed_all(force: false, batch_size: 100)
80
+ column = embedding_options[:column]
81
+ records = force ? all : all.where(column => nil)
82
+
83
+ records.find_in_batches(batch_size: batch_size) do |batch|
84
+ batch = batch.reject { |record| record.embedding_text.blank? }
85
+ next if batch.empty?
86
+
87
+ OmniAgent.embed(batch.map(&:embedding_text)).zip(batch) do |vector, record|
88
+ record.update_column(column, vector)
89
+ end
90
+ end
91
+ end
92
+
93
+ private
94
+
95
+ def cosine_similarity(a, b)
96
+ norms = Math.sqrt(a.sum { |x| x * x }) * Math.sqrt(b.sum { |x| x * x })
97
+ norms.zero? ? 0.0 : a.zip(b).sum { |x, y| x * y } / norms
98
+ end
99
+ end
100
+
101
+ module InstanceMethods
102
+ # The text that gets embedded, and what a `searches` tool shows the model.
103
+ def embedding_text
104
+ embedding_sources.filter_map { |source| public_send(source).presence&.to_s }.join("\n\n")
105
+ end
106
+
107
+ def embed!
108
+ update_column(embedding_options[:column], embedding_for(embedding_text))
109
+ end
110
+
111
+ private
112
+
113
+ def embedding_sources
114
+ embedding_options[:sources]
115
+ end
116
+
117
+ def embedding_for(text)
118
+ text.blank? ? nil : OmniAgent.embed(text)
119
+ end
120
+
121
+ def assign_embedding
122
+ self[embedding_options[:column]] = embedding_for(embedding_text)
123
+ end
124
+
125
+ def embed_later
126
+ job_options = OmniAgent.configuration.embedding_job_options.merge(embedding_options[:job_options])
127
+ OmniAgent::EmbedJob.set(**job_options).perform_later(self)
128
+ end
129
+ end
130
+ end
131
+ end
@@ -1,3 +1,7 @@
1
+ # EmbedJob (has_embedding later:) needs ActiveJob. Loading its railtie here,
2
+ # during Bundler.require, also works in apps that don't require it themselves.
3
+ require "active_job/railtie"
4
+
1
5
  module OmniAgent
2
6
  class Engine < ::Rails::Engine
3
7
  isolate_namespace OmniAgent
@@ -5,5 +9,12 @@ module OmniAgent
5
9
  initializer "omni_agent.mcp_routing" do
6
10
  ActionDispatch::Routing::Mapper.include(OmniAgent::MCP::Routing)
7
11
  end
12
+
13
+ initializer "omni_agent.active_record" do
14
+ ActiveSupport.on_load(:active_record) do
15
+ extend OmniAgent::Embeddable
16
+ extend OmniAgent::Chunkable
17
+ end
18
+ end
8
19
  end
9
20
  end
@@ -7,6 +7,18 @@ module OmniAgent
7
7
  class MaxDelegationDepthError < Error; end
8
8
  class EvalAssertionError < Error; end
9
9
  class MCPError < Error; end
10
+ # Carries the raw model output so callers can log, retry or repair it.
11
+ class StructuredOutputError < Error
12
+ attr_reader :content, :response
13
+
14
+ def initialize(message = nil, content: nil, response: nil)
15
+ super(message)
16
+ @content = content
17
+ @response = response
18
+ end
19
+ end
20
+ class UnknownStockAgentError < Error; end
21
+ class UnsupportedDocumentError < Error; end
10
22
  end
11
23
 
12
24
  Error = Errors::Error
@@ -16,4 +28,7 @@ module OmniAgent
16
28
  MaxDelegationDepthError = Errors::MaxDelegationDepthError
17
29
  EvalAssertionError = Errors::EvalAssertionError
18
30
  MCPError = Errors::MCPError
31
+ StructuredOutputError = Errors::StructuredOutputError
32
+ UnknownStockAgentError = Errors::UnknownStockAgentError
33
+ UnsupportedDocumentError = Errors::UnsupportedDocumentError
19
34
  end
@@ -0,0 +1,21 @@
1
+ module OmniAgent
2
+ module Loaders
3
+ # Visible text only, with block elements on their own paragraphs. Uses
4
+ # Loofah, which Rails already depends on.
5
+ module HTML
6
+ EXTENSIONS = %w[.html .htm .xhtml].freeze
7
+ CONTENT_TYPES = %w[text/html application/xhtml+xml].freeze
8
+
9
+ def self.handles?(filename:, content_type:)
10
+ Loaders.matches?(filename, content_type, EXTENSIONS, CONTENT_TYPES)
11
+ end
12
+
13
+ def self.call(io, **)
14
+ require "loofah"
15
+ document = Loofah.document(io.read)
16
+ document.css("script, style, noscript, template").remove
17
+ document.to_text
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,18 @@
1
+ module OmniAgent
2
+ module Loaders
3
+ # Reads the file as-is, so the Markdown and hierarchical chunkers can
4
+ # split on its headings.
5
+ module Markdown
6
+ EXTENSIONS = %w[.md .markdown .mdx].freeze
7
+ CONTENT_TYPES = %w[text/markdown text/x-markdown text/x-web-markdown].freeze
8
+
9
+ def self.handles?(filename:, content_type:)
10
+ Loaders.matches?(filename, content_type, EXTENSIONS, CONTENT_TYPES)
11
+ end
12
+
13
+ def self.call(io, **)
14
+ io.read
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,25 @@
1
+ module OmniAgent
2
+ module Loaders
3
+ # Text-based PDFs, via the optional pdf-reader gem. Scanned PDFs have no
4
+ # text layer and come out empty; they need OCR, in a custom loader.
5
+ module PDF
6
+ EXTENSIONS = %w[.pdf].freeze
7
+ CONTENT_TYPES = %w[application/pdf].freeze
8
+
9
+ def self.handles?(filename:, content_type:)
10
+ Loaders.matches?(filename, content_type, EXTENSIONS, CONTENT_TYPES)
11
+ end
12
+
13
+ def self.call(io, **)
14
+ begin
15
+ require "pdf-reader"
16
+ rescue LoadError
17
+ raise OmniAgent::MissingDependencyError, "Reading PDFs needs the pdf-reader gem: add `gem \"pdf-reader\"` to your Gemfile."
18
+ end
19
+
20
+ # pdf-reader pads text with spaces to keep the page layout.
21
+ ::PDF::Reader.new(io).pages.map { |page| page.text.gsub(/[ \t]{2,}/, " ") }.join("\n\n")
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,15 @@
1
+ module OmniAgent
2
+ module Loaders
3
+ module Text
4
+ EXTENSIONS = %w[.txt .text .csv .log].freeze
5
+
6
+ def self.handles?(filename:, content_type:)
7
+ content_type.to_s.start_with?("text/") || Loaders.matches?(filename, content_type, EXTENSIONS, [])
8
+ end
9
+
10
+ def self.call(io, **)
11
+ io.read
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,68 @@
1
+ module OmniAgent
2
+ # Strategies that turn a document into text for `has_chunks`. A loader is
3
+ # anything that responds to `call(io, filename:, content_type:)` and returns
4
+ # a String. Loaders that also answer `handles?(filename:, content_type:)`
5
+ # take part in auto-detection; register your own in
6
+ # `config.document_loaders` (they're tried before the built-in ones).
7
+ module Loaders
8
+ # Content types that say nothing about the format, so detection skips them.
9
+ GENERIC_CONTENT_TYPES = [ "application/octet-stream", "binary/octet-stream" ].freeze
10
+ SNIFF_BYTES = 8192
11
+
12
+ def self.registry
13
+ built_in = { pdf: PDF, html: HTML, markdown: Markdown, text: Text }
14
+ OmniAgent.configuration.document_loaders.transform_keys(&:to_sym).merge(built_in) { |_name, custom, _built_in| custom }
15
+ end
16
+
17
+ def self.fetch(loader)
18
+ return loader if loader.respond_to?(:call)
19
+
20
+ registry.fetch(loader.to_sym) do
21
+ raise ArgumentError, "Unknown loader #{loader.inspect}. Use :auto, #{registry.keys.join(', ')}, or an object that responds to call."
22
+ end
23
+ end
24
+
25
+ # Reads a document into clean text, with the given loader or, for
26
+ # as: :auto, the first one that handles its filename or content type.
27
+ # Without either, it looks at the first bytes: PDF and HTML are
28
+ # recognized, anything else without NUL bytes is read as text.
29
+ def self.load(io, as: :auto, filename: nil, content_type: nil)
30
+ content_type = nil if GENERIC_CONTENT_TYPES.include?(content_type)
31
+ loader = as.to_s == "auto" ? detect(io, filename: filename, content_type: content_type) : fetch(as)
32
+
33
+ clean(loader.call(io, filename: filename, content_type: content_type))
34
+ end
35
+
36
+ def self.detect(io, filename:, content_type:)
37
+ loader = registry.values.find { |candidate| candidate.respond_to?(:handles?) && candidate.handles?(filename: filename, content_type: content_type) }
38
+ loader || sniff(io) || raise(
39
+ OmniAgent::UnsupportedDocumentError,
40
+ "Can't read #{filename || 'this document'} (#{content_type || 'unknown type'}). " \
41
+ "Pass `as:` a loader, or register one in config.document_loaders."
42
+ )
43
+ end
44
+
45
+ def self.sniff(io)
46
+ sample = io.read(SNIFF_BYTES).to_s
47
+ io.rewind
48
+
49
+ if sample.start_with?("%PDF-") then PDF
50
+ elsif sample.match?(/\A\s*<(!doctype html|html)/i) then HTML
51
+ elsif !sample.include?("\0") then Text
52
+ end
53
+ end
54
+
55
+ # Fixes encodings and strips what databases and chunkers trip on:
56
+ # invalid bytes, NULs (Postgres rejects them), CRLFs and runs of blank lines.
57
+ def self.clean(text)
58
+ text.to_s.dup.force_encoding(Encoding::UTF_8).scrub("").delete("\u0000")
59
+ .gsub(/\r\n?/, "\n").gsub(/[ \t]+$/, "").gsub(/\n{3,}/, "\n\n").strip
60
+ end
61
+
62
+ def self.matches?(filename, content_type, extensions, content_types)
63
+ content_types.include?(content_type.to_s.split(";").first) || extensions.include?(File.extname(filename.to_s).downcase)
64
+ end
65
+
66
+ private_class_method :detect, :sniff, :clean
67
+ end
68
+ end
@@ -13,6 +13,20 @@ module OmniAgent
13
13
  raise NotImplementedError, "Providers must implement #chat"
14
14
  end
15
15
 
16
+ # Returns one embedding vector (Array of Floats) per text in `texts`.
17
+ # `model` is the embedding model; it defaults per provider, never to the
18
+ # chat model.
19
+ def embed(texts, model: nil)
20
+ raise NotImplementedError, "#{self.class.name.split('::').last} does not support embeddings"
21
+ end
22
+
23
+ # Providers that return true receive an agent's output schema as the
24
+ # `output_schema:` keyword of #chat. Otherwise the schema is only
25
+ # described in the system prompt.
26
+ def supports_structured_output?
27
+ false
28
+ end
29
+
16
30
  def validate_messages!(messages, allowed_roles:)
17
31
  unless messages.is_a?(Array)
18
32
  raise OmniAgent::Error, "messages must be an array"
@@ -1,20 +1,25 @@
1
+ require "json"
2
+ require "digest"
3
+
1
4
  module OmniAgent
2
5
  module Providers
3
6
  class Mock < Base
4
7
  LOREM_IPSUM = "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
5
8
 
6
- def chat(messages:, tools: [], stream: nil, **_options)
9
+ def chat(messages:, tools: [], stream: nil, output_schema: nil, **_options)
7
10
  validate_messages!(messages, allowed_roles: %i[system user assistant tool])
8
11
 
12
+ content = output_schema ? JSON.generate(sample_output(output_schema)) : LOREM_IPSUM
13
+
9
14
  if stream
10
- LOREM_IPSUM.split(" ").each_with_index do |word, index|
15
+ content.split(" ").each_with_index do |word, index|
11
16
  chunk = index.zero? ? word : " #{word}"
12
17
  stream.call(OmniAgent::Streaming::Event.text(chunk))
13
18
  end
14
19
  end
15
20
 
16
21
  OmniAgent::Providers::Response.new(
17
- content: LOREM_IPSUM,
22
+ content: content,
18
23
  raw_request: {
19
24
  model: model,
20
25
  messages: messages,
@@ -24,7 +29,7 @@ module OmniAgent
24
29
  "choices" => [
25
30
  {
26
31
  "message" => {
27
- "content" => LOREM_IPSUM
32
+ "content" => content
28
33
  }
29
34
  }
30
35
  ]
@@ -33,6 +38,16 @@ module OmniAgent
33
38
  )
34
39
  end
35
40
 
41
+ # Deterministic placeholder vectors derived from the text, so specs can
42
+ # index and search without calling a real embedding model.
43
+ def embed(texts, model: nil)
44
+ texts.map { |text| Digest::SHA256.digest(text.to_s).bytes.first(8).map { |byte| byte / 255.0 } }
45
+ end
46
+
47
+ def supports_structured_output?
48
+ true
49
+ end
50
+
36
51
  protected
37
52
 
38
53
  def default_api_key
@@ -42,6 +57,76 @@ module OmniAgent
42
57
  def default_model
43
58
  "mock"
44
59
  end
60
+
61
+ private
62
+
63
+ # Builds a placeholder value for every property so agents with an output
64
+ # schema get a parseable response without calling a real model. Raises
65
+ # when a field needs a value the mock can't invent (pattern, custom
66
+ # validate:, polymorphic resolve: true).
67
+ def sample_output(output_schema)
68
+ unresolvable = output_schema.polymorphics.find { |_name, group| group[:resolve] }
69
+ unsatisfiable!(unresolvable.first, "it is polymorphic with resolve: true") if unresolvable
70
+
71
+ sample = sample_object(output_schema.json_schema)
72
+
73
+ output_schema.validators.each do |name, validator|
74
+ passed = begin
75
+ validator.call(sample[name]) != false
76
+ rescue StandardError
77
+ false
78
+ end
79
+ unsatisfiable!(name, "its validate: proc rejects the placeholder #{sample[name].inspect}") unless passed
80
+ end
81
+
82
+ sample
83
+ end
84
+
85
+ def sample_object(schema, path = nil)
86
+ (schema[:properties] || {}).to_h do |name, property|
87
+ [ name, sample_value(property, [ path, name ].compact.join(".")) ]
88
+ end
89
+ end
90
+
91
+ def sample_value(property, path)
92
+ return property[:enum].first if property[:enum]
93
+
94
+ case property[:type]
95
+ when "string" then sample_string(property, path)
96
+ when "integer" then sample_number(property, integer: true)
97
+ when "number" then sample_number(property).to_f
98
+ when "boolean" then true
99
+ when "array" then Array.new([ property[:minItems].to_i, 1 ].max) { sample_value(property[:items] || {}, "#{path}[]") }
100
+ when "object" then sample_object(property, path)
101
+ end
102
+ end
103
+
104
+ def sample_number(property, integer: false)
105
+ minimum = property[:minimum]
106
+ maximum = property[:maximum]
107
+ return integer ? minimum.ceil : minimum if minimum
108
+ return integer ? maximum.floor : maximum if maximum && maximum < 0
109
+
110
+ 0
111
+ end
112
+
113
+ def sample_string(property, path)
114
+ text = LOREM_IPSUM
115
+ text = text.ljust(property[:minLength], "x") if property[:minLength]
116
+ text = text[0, property[:maxLength]] if property[:maxLength]
117
+
118
+ if property[:pattern] && !text.match?(Regexp.new(property[:pattern]))
119
+ unsatisfiable!(path, "its value must match the pattern #{property[:pattern]}")
120
+ end
121
+
122
+ text
123
+ end
124
+
125
+ def unsatisfiable!(field, reason)
126
+ raise OmniAgent::Error,
127
+ "The mock provider can't generate structured output for `#{field}` because #{reason}. " \
128
+ "Stub the provider's #chat response in this test instead."
129
+ end
45
130
  end
46
131
  end
47
132
  end
@@ -20,6 +20,10 @@ module OmniAgent
20
20
  ENV.fetch("OLLAMA_MODEL", "llama3.1")
21
21
  end
22
22
 
23
+ def default_embedding_model
24
+ ENV.fetch("OLLAMA_EMBEDDING_MODEL", "nomic-embed-text")
25
+ end
26
+
23
27
  private
24
28
 
25
29
  def base_url