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
data/docs/tools.md
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
<!-- Generated from omniagent-docs/docs/tools.mdx by `bundle exec rake docs:sync`. Do not edit by hand. -->
|
|
2
|
+
<!-- Online version: https://omniagent.activetaskers.com/docs/tools -->
|
|
3
|
+
|
|
4
|
+
# Tools
|
|
5
|
+
|
|
6
|
+
Tools are how agents take action beyond generating text. Every tool is a small class that declares an input schema and implements `#execute`.
|
|
7
|
+
|
|
8
|
+
## Defining a Tool
|
|
9
|
+
|
|
10
|
+
Tools generated for an agent live under `app/agents/<agent_name>/tools/` and are namespaced under `<AgentClass>::Tools`:
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
module ResearchAgent::Tools
|
|
14
|
+
class GetWeather < OmniAgent::Tool
|
|
15
|
+
description "Get current weather for a city"
|
|
16
|
+
tags :weather
|
|
17
|
+
metadata category: :utility
|
|
18
|
+
|
|
19
|
+
input do
|
|
20
|
+
string :city, description: "City name"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def execute(city:)
|
|
24
|
+
"Sunny in #{city}"
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
* **`description`**: Sent to the provider so the model knows when to call the tool.
|
|
31
|
+
* **`tags`**: Symbols used for filtering tools available to an agent.
|
|
32
|
+
* **`metadata`**: Free-form hash for your own bookkeeping (not sent to the provider).
|
|
33
|
+
* **`input`**: Declares the JSON-schema-style parameters the model must supply.
|
|
34
|
+
* **`execute`**: Runs when the model calls the tool. Receives the parsed arguments as keyword args.
|
|
35
|
+
|
|
36
|
+
## MCP Title and Annotations
|
|
37
|
+
|
|
38
|
+
When a tool is exposed through an [MCP server](mcp/defining-servers.md), you can add a display title and behavior hints for MCP clients:
|
|
39
|
+
|
|
40
|
+
```ruby
|
|
41
|
+
class GetWeather < OmniAgent::Tool
|
|
42
|
+
title "Get weather"
|
|
43
|
+
annotations read_only: true, destructive: false, idempotent: true, open_world: true
|
|
44
|
+
end
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
These are sent only to MCP clients, not to LLM providers. See [Tool Annotations](mcp/defining-servers.md#tool-annotations).
|
|
48
|
+
|
|
49
|
+
## Input Schema DSL
|
|
50
|
+
|
|
51
|
+
Use `input do ... end` to declare typed parameters:
|
|
52
|
+
|
|
53
|
+
```ruby
|
|
54
|
+
input do
|
|
55
|
+
string :city, description: "City name"
|
|
56
|
+
integer :days, description: "Forecast length", required: false
|
|
57
|
+
number :confidence, min: 0.0, max: 1.0, required: false
|
|
58
|
+
boolean :metric, required: false
|
|
59
|
+
array :tags, items_type: "string"
|
|
60
|
+
hash :coordinates do
|
|
61
|
+
string :lat
|
|
62
|
+
string :lng
|
|
63
|
+
end
|
|
64
|
+
enum :unit, values: [ "celsius", "fahrenheit" ], description: "Temperature unit"
|
|
65
|
+
end
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Supported field types:
|
|
69
|
+
|
|
70
|
+
* **`string`**
|
|
71
|
+
* **`integer`**
|
|
72
|
+
* **`number`** — floating-point values
|
|
73
|
+
* **`boolean`**
|
|
74
|
+
* **`array`** — pass `items_type:` for primitive items, or a block for an array of objects
|
|
75
|
+
* **`hash`** — pass a block to declare nested properties, or omit it for a free-form object
|
|
76
|
+
* **`enum`** — pass `values:` as a non-empty array of same-typed values (all strings, all integers, all floats, or all booleans). Mixed-type or empty value sets raise `ArgumentError` at schema-definition time.
|
|
77
|
+
* **`polymorphic`** — see [Polymorphic References](#polymorphic-references) below.
|
|
78
|
+
|
|
79
|
+
All fields are `required: true` by default. Pass `required: false` to make a field optional.
|
|
80
|
+
|
|
81
|
+
`invoke` validates arguments against the schema before calling `#execute`: a missing required field, an enum value outside `values:`, a constraint violation, or a failing custom validator all raise `ArgumentError` rather than reaching your tool code.
|
|
82
|
+
|
|
83
|
+
## Validations
|
|
84
|
+
|
|
85
|
+
`string`, `integer`, `number`, and `array` accept optional constraint kwargs. These are emitted as standard JSON Schema keywords (so the model sees them too) and enforced again at runtime by `invoke`:
|
|
86
|
+
|
|
87
|
+
```ruby
|
|
88
|
+
input do
|
|
89
|
+
string :name, min_length: 3, max_length: 40, pattern: /\A[a-z]+\z/i, format: "email"
|
|
90
|
+
integer :level, min: 1, max: 5
|
|
91
|
+
number :ratio, min: 0.0, max: 1.0
|
|
92
|
+
array :tags, items_type: "string", min_items: 1, max_items: 10
|
|
93
|
+
end
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
| Kwarg | Applies to | JSON Schema keyword |
|
|
97
|
+
|---|---|---|
|
|
98
|
+
| `min_length:` / `max_length:` | `string` | `minLength` / `maxLength` |
|
|
99
|
+
| `pattern:` (`Regexp` or `String`) | `string` | `pattern` |
|
|
100
|
+
| `format:` | `string` | `format` |
|
|
101
|
+
| `min:` / `max:` | `integer`, `number` | `minimum` / `maximum` |
|
|
102
|
+
| `min_items:` / `max_items:` | `array` | `minItems` / `maxItems` |
|
|
103
|
+
|
|
104
|
+
Every field type also accepts `validate:`, a `Proc` for arbitrary runtime rules. It is never serialized into the schema sent to the provider — it only runs locally in `invoke`. Return `false` to raise a generic `ArgumentError`, or raise your own `ArgumentError` for a custom message:
|
|
105
|
+
|
|
106
|
+
```ruby
|
|
107
|
+
input do
|
|
108
|
+
string :slug, validate: ->(v) { v == v.downcase or raise ArgumentError, "slug must be lowercase" }
|
|
109
|
+
end
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
## Polymorphic References
|
|
113
|
+
|
|
114
|
+
`polymorphic :name` declares a Rails-style polymorphic reference. It expands into two ordinary schema fields, `name_type` and `name_id`:
|
|
115
|
+
|
|
116
|
+
```ruby
|
|
117
|
+
input do
|
|
118
|
+
polymorphic :actor,
|
|
119
|
+
types: [ "User", "Admin" ], # allowed actor_type values -> becomes an enum
|
|
120
|
+
description: "The actor performing the action",
|
|
121
|
+
id_type: :integer, # actor_id schema type, default "string"
|
|
122
|
+
resolve: true # fetch the actual record instead of raw type/id
|
|
123
|
+
end
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
Individual descriptions for each sub-field can be set with the block form instead of a shared `description:`:
|
|
127
|
+
|
|
128
|
+
```ruby
|
|
129
|
+
polymorphic :actor, types: [ "User", "Admin" ], resolve: true do
|
|
130
|
+
type values: [ "User", "Admin" ], description: "The actor's class name"
|
|
131
|
+
id type: :integer, description: "The actor's primary key"
|
|
132
|
+
end
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
Two runtime modes:
|
|
136
|
+
|
|
137
|
+
* **`resolve: false` (default)** — `#execute` receives `actor_type:` and `actor_id:` as separate keyword arguments. `actor_type` is validated against `types:` like any other enum.
|
|
138
|
+
* **`resolve: true`** — requires `types:`. At runtime, `invoke` looks up the class named by `actor_type` (restricted to the `types:` whitelist — never an arbitrary string) via `Object.const_get`, calls `.find(actor_id)` on it, and passes the result as a single `actor:` keyword argument instead. A record that isn't found raises `ArgumentError`, which the agent loop surfaces back to the model as a retryable tool error, just like an invalid enum value.
|
|
139
|
+
|
|
140
|
+
`resolve: true` without `types:` raises `ArgumentError` at schema-definition time.
|
|
141
|
+
|
|
142
|
+
## Stopping Generation Early
|
|
143
|
+
|
|
144
|
+
Sometimes a tool call should end the agent's run instead of looping back to the model — for example, a tool that hands off to a human or returns a final answer directly.
|
|
145
|
+
|
|
146
|
+
Declare it at the class level:
|
|
147
|
+
|
|
148
|
+
```ruby
|
|
149
|
+
class Escalate < OmniAgent::Tool
|
|
150
|
+
stops_generation
|
|
151
|
+
|
|
152
|
+
def execute(reason:)
|
|
153
|
+
"Escalated: #{reason}"
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
Or trigger it conditionally from inside `#execute`:
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
def execute(reason:)
|
|
162
|
+
stop_generation! if reason == "urgent"
|
|
163
|
+
"Logged: #{reason}"
|
|
164
|
+
end
|
|
165
|
+
```
|
|
166
|
+
|
|
167
|
+
## How Tools Are Discovered
|
|
168
|
+
|
|
169
|
+
`OmniAgent::Agent#available_tools` looks up the `<AgentClass>::Tools` namespace and collects every constant that is a subclass of `OmniAgent::Tool`. There's no manual registration step — defining a tool class in the right namespace is enough for the agent to pick it up.
|
|
170
|
+
|
|
171
|
+
The class method `AgentClass.tool_classes` returns the same list without creating an agent instance (and therefore without a provider). [`tools_from`](mcp/defining-servers.md) in MCP servers uses it.
|
data/exe/omni_agent
CHANGED
|
@@ -3,13 +3,24 @@
|
|
|
3
3
|
|
|
4
4
|
require "optparse"
|
|
5
5
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
6
|
+
BANNER = <<~BANNER
|
|
7
|
+
Usage: omni_agent eval [pattern] [--fresh]
|
|
8
|
+
omni_agent mcp <ServerClass>
|
|
9
|
+
omni_agent docs [page] [--path]
|
|
10
|
+
BANNER
|
|
11
|
+
|
|
12
|
+
# Each command gets its own parser, so a flag meant for another command is
|
|
13
|
+
# rejected instead of silently ignored.
|
|
14
|
+
def parse_options(options)
|
|
15
|
+
OptionParser.new do |opts|
|
|
16
|
+
opts.banner = BANNER
|
|
17
|
+
yield opts, options
|
|
18
|
+
end.parse!(ARGV)
|
|
19
|
+
options
|
|
20
|
+
rescue OptionParser::ParseError => e
|
|
21
|
+
warn e.message
|
|
22
|
+
warn BANNER
|
|
23
|
+
exit 1
|
|
13
24
|
end
|
|
14
25
|
|
|
15
26
|
def require_rails_environment(command)
|
|
@@ -24,7 +35,9 @@ command = ARGV.shift
|
|
|
24
35
|
|
|
25
36
|
case command
|
|
26
37
|
when "eval"
|
|
27
|
-
|
|
38
|
+
options = parse_options(fresh: false) do |opts, parsed|
|
|
39
|
+
opts.on("--fresh", "Clear the eval cache before running") { parsed[:fresh] = true }
|
|
40
|
+
end
|
|
28
41
|
pattern = ARGV.shift
|
|
29
42
|
|
|
30
43
|
require_rails_environment("eval")
|
|
@@ -48,11 +61,36 @@ when "mcp"
|
|
|
48
61
|
|
|
49
62
|
require_rails_environment("mcp")
|
|
50
63
|
OmniAgent::MCP::Stdio.start(server)
|
|
64
|
+
when "docs"
|
|
65
|
+
options = parse_options(path: false) do |opts, parsed|
|
|
66
|
+
opts.on("--path", "Print the path of the bundled docs instead of their contents") { parsed[:path] = true }
|
|
67
|
+
end
|
|
68
|
+
require_relative "../lib/omni_agent/docs"
|
|
69
|
+
|
|
70
|
+
# docs/ is generated when the gem is built, so a gem installed from git
|
|
71
|
+
# (`gem "omni_agent", github: ...`) doesn't have it.
|
|
72
|
+
unless File.directory?(OmniAgent::Docs.root)
|
|
73
|
+
abort "The bundled docs are missing (they're generated by `rake build`). Read them online at https://omniagent.activetaskers.com/docs/intro"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
page = ARGV.shift
|
|
77
|
+
file = OmniAgent::Docs.path(page || "index")
|
|
78
|
+
unless file
|
|
79
|
+
warn "Unknown docs page: #{page.inspect}. Available pages:"
|
|
80
|
+
warn OmniAgent::Docs.pages.map { |name| " #{name}" }
|
|
81
|
+
exit 1
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
if options[:path]
|
|
85
|
+
puts page ? file : OmniAgent::Docs.root
|
|
86
|
+
else
|
|
87
|
+
puts File.read(file)
|
|
88
|
+
end
|
|
51
89
|
when nil
|
|
52
|
-
warn
|
|
90
|
+
warn BANNER
|
|
53
91
|
exit 1
|
|
54
92
|
else
|
|
55
93
|
warn "Unknown command: #{command.inspect}"
|
|
56
|
-
warn
|
|
94
|
+
warn BANNER
|
|
57
95
|
exit 1
|
|
58
96
|
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
require "rails/generators/named_base"
|
|
2
|
+
require_relative "../vector_store"
|
|
3
|
+
|
|
4
|
+
module OmniAgent
|
|
5
|
+
module Generators
|
|
6
|
+
class ChunksGenerator < Rails::Generators::NamedBase
|
|
7
|
+
include VectorStore
|
|
8
|
+
|
|
9
|
+
desc "Creates a chunk model for a document model and declares has_chunks on it"
|
|
10
|
+
|
|
11
|
+
argument :source, type: :string, banner: "source", desc: "Attachment, text column or rich text to chunk"
|
|
12
|
+
|
|
13
|
+
class_option :as, type: :string, desc: "Loader to enforce (pdf, html, markdown, text). Default: detected per file"
|
|
14
|
+
class_option :chunker, type: :string, enum: OmniAgent::Chunkers.registry.keys.map(&:to_s), desc: "Default: recursive"
|
|
15
|
+
class_option :size, type: :numeric, desc: "Chunk size in characters. Default: 1000"
|
|
16
|
+
class_option :overlap, type: :numeric, desc: "Characters repeated between chunks. Default: 200"
|
|
17
|
+
class_option :inline, type: :boolean, default: false, desc: "Ingest during save instead of in a background job"
|
|
18
|
+
class_option :dimensions, type: :numeric, desc: "Vector size (default: detected by calling the embedding model once)"
|
|
19
|
+
class_option :backend, type: :string, enum: BACKENDS, desc: "Default: detected from the database adapter"
|
|
20
|
+
|
|
21
|
+
def check_backend_and_dimensions
|
|
22
|
+
raise Thor::Error, "#{model_path} not found. Generate the model first." unless File.exist?(File.join(destination_root, model_path))
|
|
23
|
+
|
|
24
|
+
backend
|
|
25
|
+
dimensions
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def create_migration_file
|
|
29
|
+
migration_file("Create#{chunk_table.camelize}", [
|
|
30
|
+
"create_table :#{chunk_table} do |t|",
|
|
31
|
+
" t.references :#{reference}, null: false, foreign_key: { to_table: :#{table_name} }",
|
|
32
|
+
" t.integer :position, null: false",
|
|
33
|
+
" t.text :content, null: false",
|
|
34
|
+
" t.text :context",
|
|
35
|
+
" t.json :metadata",
|
|
36
|
+
"end",
|
|
37
|
+
"add_index :#{chunk_table}, [ :#{reference}_id, :position ]",
|
|
38
|
+
*vector_column_lines(chunk_table, "embedding")
|
|
39
|
+
])
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def create_vector_store_initializer
|
|
43
|
+
create_sqlite_initializer
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def create_chunk_model
|
|
47
|
+
lines = [ "class #{chunk_class} < ApplicationRecord" ]
|
|
48
|
+
lines << " belongs_to :#{reference}#{", class_name: \"#{class_name}\"" if reference.camelize != class_name}"
|
|
49
|
+
lines << " # Ruby backend, small tables only: every search loads and ranks all rows in Ruby." if backend == "ruby"
|
|
50
|
+
lines << " has_embedding #{[ ':content', *vector_store_options ].join(', ')}"
|
|
51
|
+
lines << "end"
|
|
52
|
+
create_file File.join("app", "models", "#{file_path}_chunk.rb"), lines.join("\n") + "\n"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def add_has_chunks
|
|
56
|
+
lines = []
|
|
57
|
+
lines << " has_one_attached :#{source}\n" if attachment_needed?
|
|
58
|
+
lines << " has_chunks #{chunk_arguments.join(', ')}\n"
|
|
59
|
+
inject_into_class model_path, class_name, lines.join
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def show_next_steps
|
|
63
|
+
say ""
|
|
64
|
+
say_vector_store_setup
|
|
65
|
+
say "To read PDFs, also:"
|
|
66
|
+
say " bundle add pdf-reader"
|
|
67
|
+
say "Then migrate and ingest existing records:"
|
|
68
|
+
say " bin/rails active_storage:install # if #{source} is an upload and you haven't" if attachment?
|
|
69
|
+
say " bin/rails db:migrate"
|
|
70
|
+
say " bin/rails \"omni_agent:chunks:ingest[#{class_name}]\""
|
|
71
|
+
say ""
|
|
72
|
+
say "Give an agent a search tool with:"
|
|
73
|
+
say " searches #{class_name}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def model_path
|
|
79
|
+
File.join("app", "models", "#{file_path}.rb")
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def chunk_class
|
|
83
|
+
"#{class_name}Chunk"
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def chunk_table
|
|
87
|
+
"#{singular_table_name}_chunks"
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def reference
|
|
91
|
+
class_name.demodulize.underscore
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def chunk_arguments
|
|
95
|
+
arguments = [ ":#{source}" ]
|
|
96
|
+
arguments << "as: :#{options[:as]}" if options[:as]
|
|
97
|
+
arguments << "chunker: :#{options[:chunker]}" if options[:chunker]
|
|
98
|
+
arguments << "size: #{options[:size]}" if options[:size]
|
|
99
|
+
arguments << "overlap: #{options[:overlap]}" if options[:overlap]
|
|
100
|
+
arguments << "later: false" if options[:inline]
|
|
101
|
+
arguments
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def attachment?
|
|
105
|
+
attachment_needed? || model_source.match?(/has_(one|many)_attached\s+:#{source}\b/)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# A source the model doesn't declare and the table doesn't have is an upload.
|
|
109
|
+
def attachment_needed?
|
|
110
|
+
return @attachment_needed if defined?(@attachment_needed)
|
|
111
|
+
|
|
112
|
+
@attachment_needed = !model_source.match?(/(has_\w+|def|attribute|alias_attribute)\s+:?#{source}\b/) && !source_column?
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def model_source
|
|
116
|
+
File.read(File.join(destination_root, model_path))
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# In the database, or in a migration that may not have run yet.
|
|
120
|
+
def source_column?
|
|
121
|
+
migrations = Dir[File.join(destination_root, "db", "migrate", "*.rb")].map { |path| File.read(path) }
|
|
122
|
+
return true if migrations.any? { |migration| migration.include?(":#{table_name}") && migration.match?(/[:"]#{source}\b/) }
|
|
123
|
+
|
|
124
|
+
ActiveRecord::Base.with_connection { |connection| connection.table_exists?(table_name) && connection.column_exists?(table_name, source) }
|
|
125
|
+
rescue ActiveRecord::ActiveRecordError
|
|
126
|
+
false
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require "rails/generators/named_base"
|
|
2
|
+
require_relative "../vector_store"
|
|
3
|
+
|
|
4
|
+
module OmniAgent
|
|
5
|
+
module Generators
|
|
6
|
+
class EmbeddingsGenerator < Rails::Generators::NamedBase
|
|
7
|
+
include VectorStore
|
|
8
|
+
|
|
9
|
+
desc "Adds an embedding column to a model and declares has_embedding on it"
|
|
10
|
+
|
|
11
|
+
argument :sources, type: :array, banner: "attribute [attribute ...]", desc: "Attributes to embed"
|
|
12
|
+
|
|
13
|
+
class_option :column, type: :string, default: "embedding", desc: "Column that stores the vector"
|
|
14
|
+
class_option :dimensions, type: :numeric, desc: "Vector size (default: detected by calling the embedding model once)"
|
|
15
|
+
class_option :backend, type: :string, enum: BACKENDS, desc: "Default: detected from the database adapter"
|
|
16
|
+
class_option :later, type: :boolean, default: false, desc: "Embed in a background job after commit"
|
|
17
|
+
|
|
18
|
+
def check_backend_and_dimensions
|
|
19
|
+
backend
|
|
20
|
+
dimensions
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def create_migration_file
|
|
24
|
+
migration_file(migration_name, vector_column_lines(table_name, column))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def create_vector_store_initializer
|
|
28
|
+
create_sqlite_initializer
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def add_has_embedding
|
|
32
|
+
model_path = File.join("app", "models", "#{file_path}.rb")
|
|
33
|
+
raise Thor::Error, "#{model_path} not found. Generate the model first." unless File.exist?(File.join(destination_root, model_path))
|
|
34
|
+
|
|
35
|
+
inject_into_class model_path, class_name, model_lines
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def show_next_steps
|
|
39
|
+
say ""
|
|
40
|
+
say_vector_store_setup
|
|
41
|
+
say "Then migrate and embed existing records:"
|
|
42
|
+
say " bin/rails db:migrate"
|
|
43
|
+
say " bin/rails \"omni_agent:embeddings:backfill[#{class_name}]\""
|
|
44
|
+
say ""
|
|
45
|
+
say "Give an agent a search tool with:"
|
|
46
|
+
say " searches #{class_name}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def column
|
|
52
|
+
options[:column]
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def migration_name
|
|
56
|
+
"Add#{column.camelize}To#{table_name.camelize}"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def model_lines
|
|
60
|
+
arguments = sources.map { |source| ":#{source}" }
|
|
61
|
+
arguments << "column: :#{column}" unless column == "embedding"
|
|
62
|
+
arguments.concat(vector_store_options)
|
|
63
|
+
arguments << "later: true" if options[:later]
|
|
64
|
+
|
|
65
|
+
lines = []
|
|
66
|
+
lines << " # Ruby backend, small tables only: every search loads and ranks all rows in Ruby.\n" if backend == "ruby"
|
|
67
|
+
lines << " has_embedding #{arguments.join(', ')}\n"
|
|
68
|
+
lines.join
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -4,7 +4,55 @@ require "fileutils"
|
|
|
4
4
|
module OmniAgent
|
|
5
5
|
module Generators
|
|
6
6
|
class InstallGenerator < Rails::Generators::Base
|
|
7
|
-
desc "Creates OmniAgent base directories in app/agents"
|
|
7
|
+
desc "Creates OmniAgent base directories in app/agents and config/initializers/omni_agent.rb"
|
|
8
|
+
|
|
9
|
+
INITIALIZER = <<~RUBY
|
|
10
|
+
OmniAgent.configure do |config|
|
|
11
|
+
# Every setting below shows its default. Uncomment to change it.
|
|
12
|
+
|
|
13
|
+
# Agents
|
|
14
|
+
# config.default_provider = :openai # :openai, :ollama, :mock
|
|
15
|
+
# config.default_model = "gpt-4o-mini"
|
|
16
|
+
# config.max_tool_iterations = 10
|
|
17
|
+
# config.max_delegation_depth = 5
|
|
18
|
+
|
|
19
|
+
# Provider retries (rate limits, timeouts, 5xx), with exponential backoff
|
|
20
|
+
# config.max_retries = 3
|
|
21
|
+
# config.retry_base_delay = 0.5
|
|
22
|
+
|
|
23
|
+
# Embeddings and RAG (has_embedding, searches)
|
|
24
|
+
# config.embedding_provider = nil # nil uses default_provider; use :mock in tests
|
|
25
|
+
# config.embedding_model = nil # nil uses the provider's default embedding model
|
|
26
|
+
# config.embedding_job_options = {} # for has_embedding later: true, e.g. { queue: :embeddings, priority: 10 }
|
|
27
|
+
# config.document_loaders = {} # extra has_chunks loaders, e.g. { docx: DocxLoader }
|
|
28
|
+
|
|
29
|
+
# Evals
|
|
30
|
+
# config.eval_judge_provider = nil # nil uses the evaluated agent's provider
|
|
31
|
+
# config.eval_judge_model = nil
|
|
32
|
+
# config.eval_cache_enabled = true
|
|
33
|
+
# config.eval_cache_path = "tmp/omni_agent_eval_cache.json"
|
|
34
|
+
|
|
35
|
+
# MCP servers
|
|
36
|
+
# config.mcp_allowed_origins = []
|
|
37
|
+
# config.mcp_max_request_bytes = 1_048_576
|
|
38
|
+
end
|
|
39
|
+
RUBY
|
|
40
|
+
|
|
41
|
+
class_option :skip_agents_md, type: :boolean, default: false,
|
|
42
|
+
desc: "Don't point AGENTS.md (and CLAUDE.md, if present) at the bundled OmniAgent docs"
|
|
43
|
+
|
|
44
|
+
AGENTS_MD_MARKER = "<!-- omni_agent:docs -->".freeze
|
|
45
|
+
AGENTS_MD_SECTION = <<~MARKDOWN.freeze
|
|
46
|
+
#{AGENTS_MD_MARKER}
|
|
47
|
+
## OmniAgent
|
|
48
|
+
|
|
49
|
+
This app builds AI agents with the `omni_agent` gem. Its documentation ships
|
|
50
|
+
inside the gem: run `bundle exec omni_agent docs` for the index (conventions,
|
|
51
|
+
typical flows and every page), `bundle exec omni_agent docs <page>` to read a
|
|
52
|
+
page, or `bundle exec omni_agent docs --path` for the directory. Read it before
|
|
53
|
+
adding or changing agents (`app/agents/`), tools, MCP servers (`app/mcp_servers/`)
|
|
54
|
+
or evals (`evals/`), and prefer the `rails generate omni_agent:*` generators.
|
|
55
|
+
MARKDOWN
|
|
8
56
|
|
|
9
57
|
def create_agents_root
|
|
10
58
|
agents_root = File.join(destination_root, "app", "agents")
|
|
@@ -12,10 +60,39 @@ module OmniAgent
|
|
|
12
60
|
say_status :create, agents_root
|
|
13
61
|
end
|
|
14
62
|
|
|
63
|
+
def create_initializer
|
|
64
|
+
path = File.join("config", "initializers", "omni_agent.rb")
|
|
65
|
+
return say_status(:skip, path, :yellow) if File.exist?(File.join(destination_root, path))
|
|
66
|
+
|
|
67
|
+
create_file path, INITIALIZER
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def point_coding_agents_at_docs
|
|
71
|
+
return if options[:skip_agents_md]
|
|
72
|
+
|
|
73
|
+
add_docs_section("AGENTS.md", create: true)
|
|
74
|
+
add_docs_section("CLAUDE.md", create: false)
|
|
75
|
+
end
|
|
76
|
+
|
|
15
77
|
def show_next_steps
|
|
16
78
|
say "OmniAgent install complete."
|
|
17
79
|
say "Next: rails generate omni_agent:agent SupportAgent --with-tools Tool1 Tool2"
|
|
18
80
|
say "Expose tools to MCP clients: rails generate omni_agent:mcp_server Support --agents SupportAgent"
|
|
81
|
+
say "Read the bundled docs: bundle exec omni_agent docs"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
private
|
|
85
|
+
|
|
86
|
+
def add_docs_section(file, create:)
|
|
87
|
+
path = File.join(destination_root, file)
|
|
88
|
+
|
|
89
|
+
if File.exist?(path)
|
|
90
|
+
return say_status(:identical, file) if File.read(path).include?(AGENTS_MD_MARKER)
|
|
91
|
+
|
|
92
|
+
append_to_file file, "\n#{AGENTS_MD_SECTION}"
|
|
93
|
+
elsif create
|
|
94
|
+
create_file file, AGENTS_MD_SECTION
|
|
95
|
+
end
|
|
19
96
|
end
|
|
20
97
|
end
|
|
21
98
|
end
|
|
@@ -101,6 +101,10 @@ module OmniAgent
|
|
|
101
101
|
|
|
102
102
|
# Comma-separated list of accepted bearer tokens.
|
|
103
103
|
authenticate :bearer, tokens: -> { ENV.fetch("#{token_env_var}", "").split(",") }
|
|
104
|
+
# Or OAuth 2.1 with tokens from your authorization server (add `gem "jwt"`):
|
|
105
|
+
# authenticate :oauth, resource: "https://app.example.com/mcp/...",
|
|
106
|
+
# authorization_servers: "https://auth.example.com",
|
|
107
|
+
# jwks_uri: "https://auth.example.com/.well-known/jwks.json"
|
|
104
108
|
|
|
105
109
|
# Hide tools per principal:
|
|
106
110
|
# authorize_tool { |tool_class, principal| true }
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
require "rails/generators/named_base"
|
|
2
|
+
require "fileutils"
|
|
3
|
+
|
|
4
|
+
module OmniAgent
|
|
5
|
+
module Generators
|
|
6
|
+
class StockAgentGenerator < Rails::Generators::NamedBase
|
|
7
|
+
FIELD_TYPES = %w[string integer number boolean].freeze
|
|
8
|
+
|
|
9
|
+
class_option :from,
|
|
10
|
+
type: :string,
|
|
11
|
+
required: true,
|
|
12
|
+
desc: "Stock agent to build on (e.g. ocr)"
|
|
13
|
+
class_option :fields,
|
|
14
|
+
type: :array,
|
|
15
|
+
default: [],
|
|
16
|
+
banner: "name:type name:type ...",
|
|
17
|
+
desc: "Output fields to declare (types: #{FIELD_TYPES.join(', ')})"
|
|
18
|
+
|
|
19
|
+
def create_agent_structure
|
|
20
|
+
agent_source = [
|
|
21
|
+
"class #{class_name} < #{stock_agent_class.name}",
|
|
22
|
+
" output do",
|
|
23
|
+
*output_field_lines.map { |line| " #{line}" },
|
|
24
|
+
" end",
|
|
25
|
+
"end"
|
|
26
|
+
].join("\n")
|
|
27
|
+
|
|
28
|
+
FileUtils.mkdir_p(agent_directory)
|
|
29
|
+
create_file(agent_file_path, "#{agent_source}\n")
|
|
30
|
+
|
|
31
|
+
create_file(prompt_file_path, <<~ERB)
|
|
32
|
+
<%# Optional: extra instructions appended after the #{stock_agent_class.stock_name} stock agent prompt. %>
|
|
33
|
+
ERB
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def stock_agent_class
|
|
39
|
+
@stock_agent_class ||= OmniAgent::StockAgents.find(options[:from])
|
|
40
|
+
rescue OmniAgent::UnknownStockAgentError => e
|
|
41
|
+
raise Thor::Error, e.message
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def output_field_lines
|
|
45
|
+
fields = Array(options[:fields]).map(&:strip).reject(&:empty?)
|
|
46
|
+
return [ 'string :example_field, description: "Describe the value the agent must return"' ] if fields.empty?
|
|
47
|
+
|
|
48
|
+
fields.map do |field|
|
|
49
|
+
name, type = field.split(":", 2)
|
|
50
|
+
type = (type || "string").downcase
|
|
51
|
+
|
|
52
|
+
unless FIELD_TYPES.include?(type)
|
|
53
|
+
raise Thor::Error, "Unsupported field type #{type.inspect} for #{name}. Use one of: #{FIELD_TYPES.join(', ')}"
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
"#{type} :#{name.underscore}"
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def agent_directory
|
|
61
|
+
File.join(destination_root, "app", "agents", file_name)
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def agent_file_path
|
|
65
|
+
File.join(destination_root, "app", "agents", "#{file_name}.rb")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def prompt_file_path
|
|
69
|
+
File.join(agent_directory, "prompt.md.erb")
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|