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.
Files changed (85) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +55 -0
  3. data/README.md +100 -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/mcp_server/mcp_server_generator.rb +4 -0
  34. data/lib/generators/omni_agent/stock_agent/stock_agent_generator.rb +73 -0
  35. data/lib/generators/omni_agent/vector_store.rb +113 -0
  36. data/lib/omni_agent/agent.rb +168 -39
  37. data/lib/omni_agent/chunkable.rb +181 -0
  38. data/lib/omni_agent/chunkers/hierarchical.rb +20 -0
  39. data/lib/omni_agent/chunkers/markdown.rb +41 -0
  40. data/lib/omni_agent/chunkers/recursive.rb +53 -0
  41. data/lib/omni_agent/chunkers.rb +31 -0
  42. data/lib/omni_agent/configuration.rb +14 -1
  43. data/lib/omni_agent/docs.rb +32 -0
  44. data/lib/omni_agent/embeddable.rb +131 -0
  45. data/lib/omni_agent/engine.rb +11 -0
  46. data/lib/omni_agent/errors.rb +15 -0
  47. data/lib/omni_agent/loaders/html.rb +21 -0
  48. data/lib/omni_agent/loaders/markdown.rb +18 -0
  49. data/lib/omni_agent/loaders/pdf.rb +25 -0
  50. data/lib/omni_agent/loaders/text.rb +15 -0
  51. data/lib/omni_agent/loaders.rb +68 -0
  52. data/lib/omni_agent/mcp/authenticator.rb +5 -2
  53. data/lib/omni_agent/mcp/oauth/access_token.rb +60 -0
  54. data/lib/omni_agent/mcp/oauth/http_client.rb +52 -0
  55. data/lib/omni_agent/mcp/oauth/introspection_verifier.rb +43 -0
  56. data/lib/omni_agent/mcp/oauth/jwt_verifier.rb +122 -0
  57. data/lib/omni_agent/mcp/oauth/resource_server.rb +166 -0
  58. data/lib/omni_agent/mcp/oauth.rb +18 -0
  59. data/lib/omni_agent/mcp/rack_app.rb +31 -7
  60. data/lib/omni_agent/mcp/server.rb +22 -5
  61. data/lib/omni_agent/providers/base.rb +14 -0
  62. data/lib/omni_agent/providers/mock.rb +89 -4
  63. data/lib/omni_agent/providers/ollama.rb +4 -0
  64. data/lib/omni_agent/providers/openai.rb +33 -2
  65. data/lib/omni_agent/providers/response.rb +6 -1
  66. data/lib/omni_agent/providers.rb +7 -0
  67. data/lib/omni_agent/schema.rb +200 -0
  68. data/lib/omni_agent/semantic_search.rb +45 -0
  69. data/lib/omni_agent/stock_agent.rb +59 -0
  70. data/lib/omni_agent/stock_agents/ocr/prompt.md.erb +6 -0
  71. data/lib/omni_agent/stock_agents/ocr.rb +70 -0
  72. data/lib/omni_agent/stock_agents.rb +26 -0
  73. data/lib/omni_agent/tool.rb +7 -107
  74. data/lib/omni_agent/version.rb +1 -1
  75. data/lib/omni_agent.rb +15 -1
  76. data/lib/tasks/omni_agent_chunks_tasks.rake +16 -0
  77. data/lib/tasks/omni_agent_embeddings_tasks.rake +15 -0
  78. metadata +55 -8
  79. data/app/assets/stylesheets/omni_agent/application.css +0 -15
  80. data/app/controllers/omni_agent/application_controller.rb +0 -4
  81. data/app/helpers/omni_agent/application_helper.rb +0 -4
  82. data/app/jobs/omni_agent/application_job.rb +0 -4
  83. data/app/mailers/omni_agent/application_mailer.rb +0 -6
  84. data/app/models/omni_agent/application_record.rb +0 -5
  85. data/app/views/layouts/omni_agent/application.html.erb +0 -17
data/docs/rag.md ADDED
@@ -0,0 +1,372 @@
1
+ <!-- Generated from omniagent-docs/docs/rag.mdx by `bundle exec rake docs:sync`. Do not edit by hand. -->
2
+ <!-- Online version: https://omniagent.activetaskers.com/docs/rag -->
3
+
4
+ # Retrieval-Augmented Generation (RAG)
5
+
6
+ RAG lets an agent answer from your own data: it searches your records for the passages relevant to a question and gives them to the model. Omni Agent keeps the vectors in your own database (or Meilisearch) and turns any searchable model into an agent tool. Records are searched whole with [`has_embedding`](#has_embedding), and files or long text in chunks with [`has_chunks`](#documents).
7
+
8
+ ## Quick Start
9
+
10
+ ```bash
11
+ rails generate omni_agent:embeddings Document title content
12
+ bin/rails db:migrate
13
+ bin/rails "omni_agent:embeddings:backfill[Document]"
14
+ ```
15
+
16
+ ```ruby
17
+ class SupportAgent < OmniAgent::Agent
18
+ searches Document, scope: ->(context) { where(account_id: context[:account_id]) }
19
+ end
20
+
21
+ SupportAgent.with(account_id: current_account.id).run("How do refunds work?")
22
+ ```
23
+
24
+ The agent now has a `SearchDocuments` tool. The model calls it when it needs information, and gets back the closest documents with their ids.
25
+
26
+ ## Supported Stores
27
+
28
+ | Store | How | Requirements |
29
+ | --- | --- | --- |
30
+ | Postgres | `has_embedding`, via [neighbor](https://github.com/ankane/neighbor) + pgvector | `gem "neighbor"`, the pgvector extension on the server |
31
+ | SQLite | `has_embedding`, via neighbor + sqlite-vec | `gem "neighbor"`, `gem "sqlite-vec"` |
32
+ | MariaDB 11.8+ | `has_embedding`, via neighbor | `gem "neighbor"` |
33
+ | Meilisearch | `include Meilisearch::Rails`, used as-is | [meilisearch-rails](https://github.com/meilisearch/meilisearch-rails) and an embedder on the index |
34
+ | Anything else | Your own `semantic_search(query, limit:)` | — |
35
+
36
+ MySQL can only search vectors on HeatWave. Use one of the stores above, or see the [Ruby fallback](#ruby-fallback-small-tables-only).
37
+
38
+ ## has_embedding
39
+
40
+ ```ruby
41
+ class Document < ApplicationRecord
42
+ has_embedding :title, :content
43
+ end
44
+ ```
45
+
46
+ * The listed attributes are joined into `embedding_text`, embedded with [`OmniAgent.embed`](#embeddings) and stored in the `embedding` column.
47
+ * Saving re-embeds only when one of those attributes changed. Blank text stores `nil` without calling the provider.
48
+ * `Document.semantic_search("refund policy", limit: 5)` returns the closest records, by cosine distance. It respects the current scope: `Document.where(account_id: 1).semantic_search(...)`.
49
+ * `Document.embed_all` embeds records with no vector yet. `Document.embed_all(force: true)` re-embeds every record.
50
+
51
+ Options:
52
+
53
+ * **`column:`**: The vector column. Defaults to `:embedding`.
54
+ * **`dimensions:`**: Vector size. Required on SQLite, where the column is `binary`. Postgres and MariaDB read it from the column.
55
+ * **`later:`**: `true` embeds in a [background job](#background-embedding) after commit, instead of during `save`. A Hash also sets that model's job options (`later: { queue: :critical }`).
56
+ * **`using: :ruby`**: See [Ruby fallback](#ruby-fallback-small-tables-only).
57
+
58
+ > **Tip**
59
+ >
60
+ > In tests, set `config.embedding_provider = :mock` so saving a record never calls a real API. The mock returns deterministic 8-dimension vectors.
61
+
62
+ ## Background Embedding
63
+
64
+ With `later:`, saving a record enqueues `OmniAgent::EmbedJob` after the transaction commits, so a worker never picks up a record that isn't in the database yet. The vector is `nil` until the job runs.
65
+
66
+ The job is a regular ActiveJob job, so it runs on your app's queue adapter (Sidekiq, Solid Queue, GoodJob, ...). Set the adapter app-wide:
67
+
68
+ ```ruby
69
+ # config/application.rb
70
+ config.active_job.queue_adapter = :sidekiq
71
+ ```
72
+
73
+ > **Warning**
74
+ >
75
+ > If you use Sidekiq only through `Sidekiq::Job` and never set `queue_adapter`, ActiveJob falls back to `:async`: jobs run in threads inside your web process and are lost on restart.
76
+
77
+ Set the queue and other options for every embed job in the initializer, then override them per model. They also apply to the jobs that [ingest documents](#documents):
78
+
79
+ ```ruby
80
+ # config/initializers/omni_agent.rb
81
+ OmniAgent.configure do |config|
82
+ config.embedding_job_options = { queue: :embeddings, priority: 10 }
83
+ end
84
+ ```
85
+
86
+ ```ruby
87
+ class Document < ApplicationRecord
88
+ has_embedding :title, :content, later: true # queue: :embeddings, priority: 10
89
+ end
90
+
91
+ class Ticket < ApplicationRecord
92
+ has_embedding :subject, :body, later: { queue: :critical } # queue: :critical, priority: 10
93
+ end
94
+ ```
95
+
96
+ Both take ActiveJob's `set` options: `queue`, `priority`, `wait` and `wait_until`. Unknown keys raise an `ArgumentError`. `priority` only matters on backends that support it (Solid Queue, GoodJob, Delayed Job). Sidekiq ignores it and orders work by queue, so use a separate queue there instead. Make sure your workers process the queue you pick (for Sidekiq, `sidekiq -q embeddings -q default`).
97
+
98
+ * **Failures**: errors from the embedding API reach your job backend, which retries them (Sidekiq retries by default). Rate limits and timeouts are first retried inside the provider (`max_retries`).
99
+ * **Deleted records**: a job whose record was deleted before it ran is discarded, not retried.
100
+
101
+ ## The Generator
102
+
103
+ ```bash
104
+ rails generate omni_agent:embeddings Document title content
105
+ ```
106
+
107
+ * Detects the store from your database adapter (override with `--backend=pgvector|sqlite|mariadb|ruby`).
108
+ * Detects the vector size by embedding one string with your configured model (override with `--dimensions=N`, e.g. when offline).
109
+ * Writes the migration: a `vector` column with a cosine HNSW index on Postgres (pgvector can't index above 2,000 dimensions, so larger vectors are left unindexed), a `binary` column plus `config/initializers/neighbor.rb` on SQLite, a `vector` column on MariaDB.
110
+ * Adds `has_embedding :title, :content` to the model.
111
+
112
+ Other options: `--column=NAME` and `--later`.
113
+
114
+ ## searches
115
+
116
+ ```ruby
117
+ class SupportAgent < OmniAgent::Agent
118
+ searches Document
119
+ searches Article, as: :search_help_center, limit: 3
120
+ end
121
+ ```
122
+
123
+ Each call adds a read-only tool named `Search<Models>`. Its result lists each match as `[Document#12] <text>`, so the model can cite ids. The text is the record's `embedding_text`; for other models it's their string attributes. Define `embedding_text` on a model to control what the model sees.
124
+
125
+ Options:
126
+
127
+ * **`scope:`**: A lambda run on the model with the run's [context](agent/run-entrypoints.md). Use it for tenant isolation. Without it, the tool searches every record.
128
+ * **`limit:`**: Results per search. Defaults to `5`.
129
+ * **`as:`** / **`description:`**: Rename the tool or replace its description.
130
+ * **`embedder:`**: The Meilisearch embedder to search with. Defaults to `"default"`.
131
+
132
+ > **Warning**
133
+ >
134
+ > Always pass `scope:` in a multi-tenant app. Context comes from your code (`Agent.with(account_id: ...)`), never from the model, so the model can't widen it.
135
+
136
+ Because these are normal tools, they also work over [MCP](index.md#mcp-servers) and with [delegation](agent/delegation.md). Combine them with [structured output](agent/structured-output.md) to get citations back as data:
137
+
138
+ ```ruby
139
+ output do
140
+ string :answer
141
+ array :source_ids, items_type: "integer"
142
+ end
143
+ ```
144
+
145
+ ## Documents
146
+
147
+ To answer from files (PDFs, Markdown, HTML) or long text, split them into chunks and search the chunks:
148
+
149
+ ```bash
150
+ rails generate omni_agent:chunks Document file
151
+ bin/rails active_storage:install # if you haven't
152
+ bin/rails db:migrate
153
+ ```
154
+
155
+ ```ruby
156
+ class Document < ApplicationRecord
157
+ has_one_attached :file
158
+ has_chunks :file
159
+ end
160
+
161
+ class SupportAgent < OmniAgent::Agent
162
+ searches Document, scope: ->(context) { where(account_id: context[:account_id]) }
163
+ end
164
+
165
+ Document.create!(account_id: 1, file: params[:file]) # read, split and embedded in a background job
166
+ ```
167
+
168
+ When the source changes, `OmniAgent::IngestJob` reads it into text, splits it, embeds the chunks in batches and replaces the document's chunks. They're stored in a `DocumentChunk` model that `belongs_to :document` and uses `has_embedding :content`, so every [store](#supported-stores) `has_embedding` supports works.
169
+
170
+ The source can be:
171
+
172
+ * An ActiveStorage attachment (`has_one_attached`)
173
+ * A text column, such as Markdown kept in the database
174
+ * Rich text (`has_rich_text`), read as plain text
175
+ * Any method that returns a String or an IO
176
+
177
+ ### Loaders
178
+
179
+ A loader turns a document into text. By default (`as: :auto`), the format is detected from the file's content type, then its extension, then its first bytes:
180
+
181
+ | Loader | Reads | Needs |
182
+ | --- | --- | --- |
183
+ | `:pdf` | `.pdf` files with a text layer | `gem "pdf-reader"` |
184
+ | `:html` | `.html`, `.htm`: the visible text, without scripts or styles | — |
185
+ | `:markdown` | `.md`, `.markdown`, `.mdx`, as they are | — |
186
+ | `:text` | `.txt`, `.csv`, any `text/*` type, and any file that doesn't look binary | — |
187
+
188
+ Files no loader can read (a zip, an image) are skipped with a warning in the log, and never fail the save. To accept only one format, enforce its loader. Other files are then rejected by validation when the record is saved:
189
+
190
+ ```ruby
191
+ has_chunks :file, as: :pdf # "File must be a PDF file"
192
+ ```
193
+
194
+ For other formats, pass any object that responds to `call(io, filename:, content_type:)` and returns a String:
195
+
196
+ ```ruby
197
+ has_chunks :file, as: ->(io, **) { Docx::Document.open(io).text }
198
+ ```
199
+
200
+ To have auto-detection use it too, register it in the initializer with a `handles?` method:
201
+
202
+ ```ruby
203
+ class DocxLoader
204
+ def self.handles?(filename:, content_type:)
205
+ File.extname(filename.to_s) == ".docx"
206
+ end
207
+
208
+ def self.call(io, **)
209
+ Docx::Document.open(io).text
210
+ end
211
+ end
212
+
213
+ OmniAgent.configure do |config|
214
+ config.document_loaders = { docx: DocxLoader } # tried before the built-in loaders
215
+ end
216
+ ```
217
+
218
+ > **Note**
219
+ >
220
+ > Scanned PDFs are images, with no text to read, so they produce no chunks. Run them through OCR in a custom loader, e.g. one that calls the [OCR stock agent](stock-agents/ocr.md).
221
+
222
+ ### Chunkers
223
+
224
+ | Chunker | Splits |
225
+ | --- | --- |
226
+ | `:recursive` (default) | On the coarsest boundary that fits: headings, then paragraphs, lines, sentences, words. Each chunk starts with the end of the previous one (`overlap`), so a sentence cut at a boundary is still found. |
227
+ | `:markdown` | One chunk per Markdown section, tagged with its heading path (`Billing › Refunds`). Long sections are split further. |
228
+ | `:hierarchical` | Small chunks for precise matching, each carrying its whole section. The search matches the small chunk, and the agent reads the section around it. |
229
+
230
+ ```ruby
231
+ has_chunks :file, chunker: :hierarchical, size: 500, overlap: 100
232
+ ```
233
+
234
+ `size` and `overlap` are in characters (defaults: `1000` and `200`, about 250 and 50 tokens). For `:hierarchical`, `size` is the small chunk and sections go up to four times that.
235
+
236
+ A custom chunker is any object that responds to `call(text, size:, overlap:)` and returns Strings, or Hashes with `content:` and optional `context:` (what the agent reads instead of `content`) and `metadata:`:
237
+
238
+ ```ruby
239
+ has_chunks :body, chunker: ->(text, **) { text.split("\n---\n") }
240
+ ```
241
+
242
+ ### Options
243
+
244
+ * **`as:`**: The [loader](#loaders). Defaults to `:auto`.
245
+ * **`chunker:`**: The [chunker](#chunkers). Defaults to `:recursive`.
246
+ * **`size:`** / **`overlap:`**: Chunk size and overlap, in characters.
247
+ * **`later:`**: Ingests in `OmniAgent::IngestJob` by default, with the [`embedding_job_options`](#background-embedding). A Hash overrides them for this model (`later: { queue: :documents }`), and `false` ingests right after commit, in the request.
248
+ * **`class_name:`**: The chunk model. Defaults to `"#{model}Chunk"`.
249
+
250
+ ### Updating and Searching
251
+
252
+ * Changing the source replaces the chunks. Chunks whose text didn't change keep their vector, so editing a page of a long document only embeds that page.
253
+ * Deleting the document deletes its chunks.
254
+ * `bin/rails "omni_agent:chunks:ingest[Document]"` ingests documents that have no chunks yet, for example ones created before you added `has_chunks`. Add `,force` to re-ingest every document, for example after changing the chunker. Files no loader can read are listed and skipped.
255
+ * `Document.where(account_id: 1).semantic_search("refunds")` returns the closest `DocumentChunk` records of those documents.
256
+ * `searches Document` searches the chunks of the documents in `scope:`, and lists each match as `[Document#12 › Billing › Refunds] <text>`.
257
+
258
+ ## Meilisearch
259
+
260
+ meilisearch-rails models work with `searches` as they are:
261
+
262
+ ```ruby
263
+ class Article < ApplicationRecord
264
+ include Meilisearch::Rails
265
+
266
+ meilisearch do
267
+ attribute :title, :body, :account_id
268
+ filterable_attributes [ :account_id ]
269
+ end
270
+ end
271
+
272
+ class SupportAgent < OmniAgent::Agent
273
+ searches Article, scope: ->(context) { where(account_id: context[:account_id]) }
274
+ end
275
+ ```
276
+
277
+ The tool runs a hybrid (keyword + semantic) search with the index's `default` embedder (pick another with `embedder:`). Meilisearch embeds documents and queries itself, so configure an embedder on the index. meilisearch-rails has no setting for it, so do it once, for example from a console or a migration:
278
+
279
+ ```ruby
280
+ Article.index.update_embedders(
281
+ default: { source: "openAi", apiKey: ENV["OPENAI_ACCESS_TOKEN"], model: "text-embedding-3-small", documentTemplate: "{{doc.title}} {{doc.body}}" }
282
+ )
283
+ ```
284
+
285
+ Equality conditions in `scope:` (`where(account_id: 1)`) are sent to Meilisearch as filters, so those attributes must be both indexed (listed in `attribute`) and `filterable_attributes`. If they're missing, the search matches nothing and the tool returns "No results.". Every hit is then checked against the full scope in your database, so a condition Meilisearch can't express is still enforced.
286
+
287
+ ## Any Other Store
288
+
289
+ `searches` works with anything that responds to `semantic_search(query, limit:)` and returns records (or strings):
290
+
291
+ ```ruby
292
+ class HelpCenter
293
+ def self.semantic_search(query, limit:)
294
+ QDRANT.search( # your vector store clientvector: OmniAgent.embed(query), limit: limit).map { |hit| hit["payload"]["text"] }
295
+ end
296
+ end
297
+
298
+ searches HelpCenter
299
+ ```
300
+
301
+ ## Always Retrieve First
302
+
303
+ To retrieve before every generation instead of letting the model decide, use a [`before_generation` callback](agent/callbacks.md). Instance variables it sets are available in the prompt:
304
+
305
+ ```ruby
306
+ class SupportAgent < OmniAgent::Agent
307
+ before_generation :retrieve
308
+
309
+ def retrieve(payload)
310
+ @passages = Document.semantic_search(payload[:input], limit: 5)
311
+ end
312
+ end
313
+ ```
314
+
315
+ ```erb
316
+ <%# app/agents/support_agent/prompt.md.erb %>
317
+ Answer using only the sources below and cite their ids.
318
+
319
+ <% @passages.each do |passage| %>
320
+ [<%= passage.id %>] <%= passage.embedding_text %>
321
+ <% end %>
322
+ ```
323
+
324
+ This saves a round trip but always pays for retrieval, even for questions that don't need it. `searches` is usually the better default.
325
+
326
+ ## Embeddings
327
+
328
+ `OmniAgent.embed` turns text into vectors with the configured provider. `has_embedding` uses it, and you can call it directly:
329
+
330
+ ```ruby
331
+ OmniAgent.embed("How do refunds work?") # => [0.0123, -0.0456, ...]
332
+ OmniAgent.embed([ "first chunk", "second chunk" ]) # => [[...], [...]]
333
+ ```
334
+
335
+ | Provider | Default embedding model |
336
+ | --- | --- |
337
+ | `:openai` | `text-embedding-3-small` |
338
+ | `:ollama` | `nomic-embed-text` (or `OLLAMA_EMBEDDING_MODEL`) |
339
+ | `:mock` | Deterministic 8-dimension vectors, no network calls |
340
+
341
+ Embedding settings are separate from the chat model:
342
+
343
+ ```ruby
344
+ OmniAgent.configure do |config|
345
+ config.embedding_provider = :openai # defaults to default_provider
346
+ config.embedding_model = "text-embedding-3-large" # defaults to the provider's model
347
+ end
348
+ ```
349
+
350
+ Override per call with `OmniAgent.embed(text, provider: :ollama, model: "mxbai-embed-large")`.
351
+
352
+ > **Warning**
353
+ >
354
+ > Index and query with the same provider and model. Vectors from different models are not comparable, and search returns garbage without raising. After changing models, update the column's dimensions in a migration and run `bin/rails "omni_agent:embeddings:backfill[Document,force]"`.
355
+
356
+ ## Ruby Fallback (Small Tables Only)
357
+
358
+ > **Danger**
359
+ >
360
+ > **Avoid this in production.** The Ruby fallback loads **every row of the table** and computes similarity in Ruby on **each search**. It gets slower and uses more memory with every record you add, and it has no index. Use it only for prototypes or tables under ~10k rows, when your database can't search vectors (plain MySQL). Move to Postgres, SQLite or MariaDB with neighbor as soon as you can.
361
+
362
+ ```bash
363
+ rails generate omni_agent:embeddings Document title content --backend=ruby
364
+ ```
365
+
366
+ ```ruby
367
+ class Document < ApplicationRecord
368
+ has_embedding :title, :content, using: :ruby # stored in a json column
369
+ end
370
+ ```
371
+
372
+ Everything else (`semantic_search`, `searches`, `embed_all`, the backfill task) works the same, so switching to neighbor later only means changing the column and removing `using: :ruby`.
@@ -0,0 +1,52 @@
1
+ <!-- Generated from omniagent-docs/docs/stock-agents/ocr.mdx by `bundle exec rake docs:sync`. Do not edit by hand. -->
2
+ <!-- Online version: https://omniagent.activetaskers.com/docs/stock-agents/ocr -->
3
+
4
+ # OCR
5
+
6
+ `OmniAgent::StockAgents::OCR` reads photos, scans and PDFs with a vision model (`gpt-4o` by default). Its default output is `{ text: "..." }`, the full text in reading order; declare your own `output` to extract fields instead.
7
+
8
+ ```bash
9
+ rails generate omni_agent:stock_agent InvoiceOcr --from=ocr --fields=invoice_number:string total:number paid:boolean
10
+ ```
11
+
12
+ ```ruby
13
+ # app/agents/invoice_ocr.rb
14
+ class InvoiceOcr < OmniAgent::StockAgents::OCR
15
+ output do
16
+ string :invoice_number
17
+ number :total
18
+ boolean :paid
19
+ end
20
+ end
21
+
22
+ InvoiceOcr.new.run(Pathname("invoice.jpg")).structured_output
23
+ # => { invoice_number: "A-1042", total: 120.5, paid: true }
24
+ ```
25
+
26
+ ## Input
27
+
28
+ `run` takes one source or an array of them. Several sources are treated as pages of the same document, in order:
29
+
30
+ ```ruby
31
+ InvoiceOcr.new.run(Pathname("invoice.pdf"))
32
+ InvoiceOcr.new.run([ params[:front], params[:back] ]) # uploaded files
33
+ InvoiceOcr.new.run(record.scans.to_a) # ActiveStorage attachments
34
+ InvoiceOcr.new.run([ "Only the line items", Pathname("page1.png"), URI("https://example.com/page2.png") ])
35
+ ```
36
+
37
+ | Source | Sent as |
38
+ | --- | --- |
39
+ | `Pathname`, `File`, `StringIO`, uploaded files (anything with `read`) | Base64 data URL |
40
+ | ActiveStorage attachments and blobs (anything with `download`) | Base64 data URL |
41
+ | `URI("https://...")` | Image URL the provider fetches |
42
+ | `String` | Text instructions sent with the images |
43
+
44
+ Files must be PNG, JPEG, GIF, WEBP or PDF; the type is read from the file's bytes, not its name. A plain string is never treated as a file path, so text from your users can't make the agent read files from your server. Pass a `Pathname` to read a file.
45
+
46
+ ## Providers
47
+
48
+ PDFs are sent as file parts, which OpenAI supports. Ollama only accepts base64 images, so with Ollama use a vision model (e.g. `llama3.2-vision`) and pass image files, not PDFs or URIs.
49
+
50
+ ## Prompt
51
+
52
+ The stock prompt tells the model to transcribe only what is visible, keep numbers and dates exactly as written, and leave illegible fields empty instead of guessing. Add document-specific instructions in `app/agents/invoice_ocr/prompt.md.erb`; they are appended after it (see [Prompts](overview.md#prompts)).
@@ -0,0 +1,91 @@
1
+ <!-- Generated from omniagent-docs/docs/stock-agents/overview.mdx by `bundle exec rake docs:sync`. Do not edit by hand. -->
2
+ <!-- Online version: https://omniagent.activetaskers.com/docs/stock-agents/overview -->
3
+
4
+ # Overview
5
+
6
+ Stock agents are ready-made agents shipped with the gem under `OmniAgent::StockAgents`. They bring their own prompt, model settings and tools; your project subclasses one and declares the [structured output](../agent/structured-output.md) it needs.
7
+
8
+ ## Using a Stock Agent
9
+
10
+ ```bash
11
+ rails generate omni_agent:stock_agent InvoiceOcr --from=ocr --fields=invoice_number:string total:number paid:boolean
12
+ ```
13
+
14
+ Generates:
15
+
16
+ ```ruby
17
+ # app/agents/invoice_ocr.rb
18
+ class InvoiceOcr < OmniAgent::StockAgents::OCR
19
+ output do
20
+ string :invoice_number
21
+ number :total
22
+ boolean :paid
23
+ end
24
+ end
25
+ ```
26
+
27
+ and an optional `app/agents/invoice_ocr/prompt.md.erb`. Edit the `output` block with the full DSL (descriptions, `required: false`, enums, nested arrays and hashes), then run it like any agent:
28
+
29
+ ```ruby
30
+ InvoiceOcr.new.run(Pathname("invoice.jpg")).structured_output
31
+ # => { invoice_number: "A-1042", total: 120.5, paid: true }
32
+ ```
33
+
34
+ What `run` accepts depends on the stock agent; see its page (e.g. [OCR](ocr.md)).
35
+
36
+ A subclass inherits everything from the stock agent — provider, model, `options`, tags, callbacks, tools and prompts — and can override any of it with the usual DSL (`use_model`, `options`, `output`, ...). To replace one of the stock agent's tools, define a tool with the same name under your subclass's `Tools` namespace (e.g. `InvoiceOcr::Tools::Extract`). See [Inheritance](../agent/provider.md#inheritance) for how `use_model` interacts with an inherited provider.
37
+
38
+ ### Prompts
39
+
40
+ The stock agent's prompt (shipped inside the gem) is rendered first. If your subclass has `app/agents/<agent_name>/prompt.md.erb`, it is appended after it, so you can add project-specific instructions without copying the stock prompt. The same applies to [run alias](../agent/run-entrypoints.md) prompt files.
41
+
42
+ ## Listing Stock Agents
43
+
44
+ ```ruby
45
+ OmniAgent::StockAgents.all # => [OmniAgent::StockAgents::OCR, ...]
46
+ OmniAgent::StockAgents.find(:ocr) # => OmniAgent::StockAgents::OCR
47
+ OmniAgent::StockAgents.find(:nope) # raises OmniAgent::UnknownStockAgentError
48
+ ```
49
+
50
+ ## Adding a Stock Agent to the Gem
51
+
52
+ From the gem repository:
53
+
54
+ ```bash
55
+ bin/rake "stock_agent:new[summarizer]"
56
+ ```
57
+
58
+ Pass the class name as a second argument (`"stock_agent:new[ocr,OCR]"`) only when the class name isn't the camelized file name; the task then prints the Zeitwerk inflection to add in `lib/omni_agent.rb`. It creates:
59
+
60
+ ```text
61
+ lib/omni_agent/stock_agents/
62
+ ├── summarizer.rb # class OmniAgent::StockAgents::Summarizer < OmniAgent::StockAgent
63
+ └── summarizer/
64
+ ├── prompt.md.erb # default prompt
65
+ └── tools/ # optional: OmniAgent::StockAgents::Summarizer::Tools::*
66
+ spec/lib/stock_agents/summarizer_spec.rb
67
+ ```
68
+
69
+ A stock agent is a regular agent with a few extra class methods:
70
+
71
+ ```ruby
72
+ module OmniAgent
73
+ module StockAgents
74
+ class Summarizer < OmniAgent::StockAgent
75
+ description "Summarizes documents"
76
+ use_model "gpt-4o-mini"
77
+
78
+ # Default output, used when the host app doesn't declare its own.
79
+ output do
80
+ string :summary, description: "Short summary of the document"
81
+ end
82
+ end
83
+ end
84
+ end
85
+ ```
86
+
87
+ * **`description`**: Human-readable summary of what the stock agent does.
88
+ * **`stock_name`**: Name used by `OmniAgent::StockAgents.find` and the generator's `--from`. Defaults to the underscored class name (`Summarizer` → `:summarizer`).
89
+ * **`prompts_path`**: Directory with the stock prompt templates. Defaults to `lib/omni_agent/stock_agents/<stock_name>/`.
90
+
91
+ Give each new stock agent its own page in `omniagent-docs/docs/stock-agents/`.
@@ -0,0 +1,47 @@
1
+ <!-- Generated from omniagent-docs/docs/supported-providers.mdx by `bundle exec rake docs:sync`. Do not edit by hand. -->
2
+ <!-- Online version: https://omniagent.activetaskers.com/docs/supported-providers -->
3
+
4
+ # Supported providers
5
+
6
+ This is a list of our currently supported providers.
7
+
8
+ - OpenAI
9
+ - Ollama
10
+ - Mock
11
+
12
+ If you need another integration you can open a PR for it!
13
+
14
+ ## OpenAI
15
+
16
+ Requires the `openai` gem and an API key:
17
+
18
+ ```dotenv
19
+ OPENAI_ACCESS_TOKEN=your_api_key_here
20
+ ```
21
+
22
+ ```ruby
23
+ provider :openai, model: "gpt-4o-mini"
24
+ ```
25
+
26
+ ## Ollama
27
+
28
+ Talks to a local (or remote) [Ollama](https://ollama.com) server via its OpenAI-compatible `/v1` endpoint. Depends on the `openai` gem too — no separate client library needed. Pick a tool-calling-capable model (e.g. `llama3.1`, `qwen2.5`) if your agent uses tools.
29
+
30
+ ```ruby
31
+ provider :ollama, model: "llama3.1"
32
+ ```
33
+
34
+ Configuration is via environment variables, all optional:
35
+
36
+ | Variable | Default | Purpose |
37
+ | --- | --- | --- |
38
+ | `OLLAMA_HOST` | `http://localhost:11434` | Base URL of the Ollama server (the provider appends `/v1`) |
39
+ | `OLLAMA_MODEL` | `llama3.1` | Default model when none is passed to `provider`/`use_model` |
40
+ | `OLLAMA_EMBEDDING_MODEL` | `nomic-embed-text` | Default model for [`OmniAgent.embed`](rag.md#embeddings) |
41
+ | `OLLAMA_API_KEY` | `ollama` | Sent as the bearer token; Ollama ignores it, but the `openai` client requires a non-nil value |
42
+
43
+ Streaming, tool-calling and embeddings all work the same as the OpenAI provider (Ollama subclasses it and only overrides client construction and defaults).
44
+
45
+ ## Mock
46
+
47
+ Returns a canned response without making network calls. Useful for tests. `embed` returns deterministic vectors derived from the text, so the same text always gets the same vector.