omni_agent 0.1.10 → 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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +55 -0
- data/README.md +100 -2
- data/Rakefile +4 -0
- data/app/jobs/omni_agent/embed_job.rb +10 -0
- data/app/jobs/omni_agent/ingest_job.rb +12 -0
- data/docs/agent/callbacks.md +60 -0
- data/docs/agent/delegation.md +56 -0
- data/docs/agent/provider.md +93 -0
- data/docs/agent/run-entrypoints.md +81 -0
- data/docs/agent/streaming.md +62 -0
- data/docs/agent/structured-output.md +80 -0
- data/docs/configuration.md +68 -0
- data/docs/evals/caching.md +21 -0
- data/docs/evals/generating-and-running.md +87 -0
- data/docs/evals/golden-sets.md +32 -0
- data/docs/evals/judge-provider.md +20 -0
- data/docs/generators.md +143 -0
- data/docs/index.md +98 -0
- data/docs/intro.md +93 -0
- data/docs/mcp/authentication.md +226 -0
- data/docs/mcp/defining-servers.md +196 -0
- data/docs/mcp/transports.md +166 -0
- data/docs/rag.md +372 -0
- data/docs/stock-agents/ocr.md +52 -0
- data/docs/stock-agents/overview.md +91 -0
- data/docs/supported-providers.md +47 -0
- data/docs/tools.md +171 -0
- data/exe/omni_agent +48 -10
- data/lib/generators/omni_agent/chunks/chunks_generator.rb +130 -0
- data/lib/generators/omni_agent/embeddings/embeddings_generator.rb +72 -0
- data/lib/generators/omni_agent/install/install_generator.rb +78 -1
- data/lib/generators/omni_agent/mcp_server/mcp_server_generator.rb +4 -0
- data/lib/generators/omni_agent/stock_agent/stock_agent_generator.rb +73 -0
- data/lib/generators/omni_agent/vector_store.rb +113 -0
- data/lib/omni_agent/agent.rb +168 -39
- data/lib/omni_agent/chunkable.rb +181 -0
- data/lib/omni_agent/chunkers/hierarchical.rb +20 -0
- data/lib/omni_agent/chunkers/markdown.rb +41 -0
- data/lib/omni_agent/chunkers/recursive.rb +53 -0
- data/lib/omni_agent/chunkers.rb +31 -0
- data/lib/omni_agent/configuration.rb +14 -1
- data/lib/omni_agent/docs.rb +32 -0
- data/lib/omni_agent/embeddable.rb +131 -0
- data/lib/omni_agent/engine.rb +11 -0
- data/lib/omni_agent/errors.rb +15 -0
- data/lib/omni_agent/loaders/html.rb +21 -0
- data/lib/omni_agent/loaders/markdown.rb +18 -0
- data/lib/omni_agent/loaders/pdf.rb +25 -0
- data/lib/omni_agent/loaders/text.rb +15 -0
- data/lib/omni_agent/loaders.rb +68 -0
- data/lib/omni_agent/mcp/authenticator.rb +5 -2
- data/lib/omni_agent/mcp/oauth/access_token.rb +60 -0
- data/lib/omni_agent/mcp/oauth/http_client.rb +52 -0
- data/lib/omni_agent/mcp/oauth/introspection_verifier.rb +43 -0
- data/lib/omni_agent/mcp/oauth/jwt_verifier.rb +122 -0
- data/lib/omni_agent/mcp/oauth/resource_server.rb +166 -0
- data/lib/omni_agent/mcp/oauth.rb +18 -0
- data/lib/omni_agent/mcp/rack_app.rb +31 -7
- data/lib/omni_agent/mcp/server.rb +22 -5
- data/lib/omni_agent/providers/base.rb +14 -0
- data/lib/omni_agent/providers/mock.rb +89 -4
- data/lib/omni_agent/providers/ollama.rb +4 -0
- data/lib/omni_agent/providers/openai.rb +33 -2
- data/lib/omni_agent/providers/response.rb +6 -1
- data/lib/omni_agent/providers.rb +7 -0
- data/lib/omni_agent/schema.rb +200 -0
- data/lib/omni_agent/semantic_search.rb +45 -0
- data/lib/omni_agent/stock_agent.rb +59 -0
- data/lib/omni_agent/stock_agents/ocr/prompt.md.erb +6 -0
- data/lib/omni_agent/stock_agents/ocr.rb +70 -0
- data/lib/omni_agent/stock_agents.rb +26 -0
- data/lib/omni_agent/tool.rb +7 -107
- data/lib/omni_agent/version.rb +1 -1
- data/lib/omni_agent.rb +15 -1
- data/lib/tasks/omni_agent_chunks_tasks.rake +16 -0
- data/lib/tasks/omni_agent_embeddings_tasks.rake +15 -0
- metadata +55 -8
- data/app/assets/stylesheets/omni_agent/application.css +0 -15
- data/app/controllers/omni_agent/application_controller.rb +0 -4
- data/app/helpers/omni_agent/application_helper.rb +0 -4
- data/app/jobs/omni_agent/application_job.rb +0 -4
- data/app/mailers/omni_agent/application_mailer.rb +0 -6
- data/app/models/omni_agent/application_record.rb +0 -5
- 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
|
data/lib/omni_agent/engine.rb
CHANGED
|
@@ -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
|
data/lib/omni_agent/errors.rb
CHANGED
|
@@ -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
|
|
@@ -3,7 +3,8 @@ require "active_support/security_utils"
|
|
|
3
3
|
module OmniAgent
|
|
4
4
|
module MCP
|
|
5
5
|
class Authenticator
|
|
6
|
-
|
|
6
|
+
# error and access_token are only set by `authenticate :oauth`.
|
|
7
|
+
Result = Struct.new(:authenticated, :principal, :error, :access_token) do
|
|
7
8
|
def authenticated?
|
|
8
9
|
authenticated == true
|
|
9
10
|
end
|
|
@@ -21,7 +22,7 @@ module OmniAgent
|
|
|
21
22
|
if authentication.nil?
|
|
22
23
|
raise OmniAgent::MCPError,
|
|
23
24
|
"#{@server_class.name} does not declare authentication. " \
|
|
24
|
-
"Add `authenticate :bearer, tokens: ...`, `authenticate { |request| ... }`, " \
|
|
25
|
+
"Add `authenticate :bearer, tokens: ...`, `authenticate :oauth, ...`, `authenticate { |request| ... }`, " \
|
|
25
26
|
"or explicitly opt out with `authenticate :none`."
|
|
26
27
|
end
|
|
27
28
|
|
|
@@ -30,6 +31,8 @@ module OmniAgent
|
|
|
30
31
|
success(nil)
|
|
31
32
|
when :bearer
|
|
32
33
|
authenticate_bearer(request, authentication[:tokens])
|
|
34
|
+
when :oauth
|
|
35
|
+
authentication[:resource_server].authenticate(request)
|
|
33
36
|
when :custom
|
|
34
37
|
principal = authentication[:block].call(request)
|
|
35
38
|
principal ? success(principal) : failure
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module OmniAgent
|
|
2
|
+
module MCP
|
|
3
|
+
module OAuth
|
|
4
|
+
# A validated access token and its claims (JWT payload or introspection
|
|
5
|
+
# response). It is the principal unless `authenticate :oauth` has a block.
|
|
6
|
+
class AccessToken
|
|
7
|
+
attr_reader :token, :claims
|
|
8
|
+
|
|
9
|
+
def initialize(token, claims)
|
|
10
|
+
@token = token
|
|
11
|
+
@claims = claims.transform_keys(&:to_s).freeze
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def [](key)
|
|
15
|
+
claims[key.to_s]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def subject
|
|
19
|
+
claims["sub"]
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def issuer
|
|
23
|
+
claims["iss"]
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def client_id
|
|
27
|
+
claims["client_id"] || claims["azp"]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def audience
|
|
31
|
+
Array(claims["aud"]).map(&:to_s)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# RFC 9068 / RFC 7662 use a space-separated "scope"; some providers
|
|
35
|
+
# (Okta, Entra ID) use "scp", as a string or an array.
|
|
36
|
+
def scopes
|
|
37
|
+
raw = claims.key?("scope") ? claims["scope"] : claims["scp"]
|
|
38
|
+
raw.is_a?(Array) ? raw.map(&:to_s) : raw.to_s.split
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def scope?(*names)
|
|
42
|
+
(names.flatten.map(&:to_s) - scopes).empty?
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def expires_at
|
|
46
|
+
Time.at(claims["exp"]) if claims["exp"].is_a?(Numeric)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def to_s
|
|
50
|
+
subject.to_s
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Never print the raw token.
|
|
54
|
+
def inspect
|
|
55
|
+
"#<#{self.class.name} sub=#{subject.inspect} client_id=#{client_id.inspect} scopes=#{scopes.inspect}>"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require "net/http"
|
|
3
|
+
require "openssl"
|
|
4
|
+
require "uri"
|
|
5
|
+
|
|
6
|
+
module OmniAgent
|
|
7
|
+
module MCP
|
|
8
|
+
module OAuth
|
|
9
|
+
# Minimal JSON client for the authorization server (JWKS, introspection).
|
|
10
|
+
module HTTPClient
|
|
11
|
+
OPEN_TIMEOUT = 5
|
|
12
|
+
READ_TIMEOUT = 5
|
|
13
|
+
|
|
14
|
+
NETWORK_ERRORS = [
|
|
15
|
+
IOError, SystemCallError, SocketError, Timeout::Error, OpenSSL::SSL::SSLError, URI::InvalidURIError
|
|
16
|
+
].freeze
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
def get_json(url)
|
|
21
|
+
uri = URI.parse(url.to_s)
|
|
22
|
+
request(uri, Net::HTTP::Get.new(uri))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def post_form_json(url, form, basic_auth: nil)
|
|
26
|
+
uri = URI.parse(url.to_s)
|
|
27
|
+
post = Net::HTTP::Post.new(uri)
|
|
28
|
+
post.set_form_data(form)
|
|
29
|
+
post.basic_auth(*basic_auth) if basic_auth
|
|
30
|
+
request(uri, post)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def request(uri, http_request)
|
|
34
|
+
http_request["accept"] = "application/json"
|
|
35
|
+
|
|
36
|
+
response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
37
|
+
open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) do |http|
|
|
38
|
+
http.request(http_request)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
unless response.is_a?(Net::HTTPSuccess)
|
|
42
|
+
raise VerificationError, "#{http_request.method} #{uri} returned HTTP #{response.code}"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
JSON.parse(response.body.to_s)
|
|
46
|
+
rescue JSON::ParserError, *NETWORK_ERRORS => e
|
|
47
|
+
raise VerificationError, "#{http_request.method} #{uri} failed: #{e.class}: #{e.message}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
|
|
3
|
+
module OmniAgent
|
|
4
|
+
module MCP
|
|
5
|
+
module OAuth
|
|
6
|
+
# Asks the authorization server whether a token is active (RFC 7662).
|
|
7
|
+
# Works with opaque tokens. Returns the introspection response, or nil
|
|
8
|
+
# when the token is not active.
|
|
9
|
+
class IntrospectionVerifier
|
|
10
|
+
def initialize(url:, client_id: nil, client_secret: nil)
|
|
11
|
+
raise ArgumentError, "introspection: requires url:" if url.nil?
|
|
12
|
+
|
|
13
|
+
@url = url
|
|
14
|
+
@client_id = client_id
|
|
15
|
+
@client_secret = client_secret
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def call(token, _request = nil)
|
|
19
|
+
response = HTTPClient.post_form_json(
|
|
20
|
+
OAuth.resolve(@url),
|
|
21
|
+
{ "token" => token, "token_type_hint" => "access_token" },
|
|
22
|
+
basic_auth: credentials
|
|
23
|
+
)
|
|
24
|
+
response.is_a?(Hash) && response["active"] == true ? response : nil
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def inspect
|
|
28
|
+
"#<#{self.class.name} url=#{@url.inspect}>"
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
# client_secret_basic: RFC 6749 section 2.3.1 form-encodes both values.
|
|
34
|
+
def credentials
|
|
35
|
+
client_id = OAuth.resolve(@client_id)
|
|
36
|
+
return if client_id.nil?
|
|
37
|
+
|
|
38
|
+
[ client_id, OAuth.resolve(@client_secret) ].map { |value| URI.encode_www_form_component(value.to_s) }
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|