omni_agent 0.1.4 → 0.1.6

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 68e52e8d8eb8a4f59c708d32e7464b7526c92f8e5342d6a85fe3c1244bf643e7
4
- data.tar.gz: 36d892f5d7593582f31cc15d957deab720c80c8416a48ba32df4253f24d10ac6
3
+ metadata.gz: d48868f19885179d74185a391e4b6687f3dc2d4320962eac162ac6f12d755892
4
+ data.tar.gz: 6b93ca1c7aa336226e64936e13839c862c4a05a301a42b9802042012fe2f157c
5
5
  SHA512:
6
- metadata.gz: afaf69e6f85c7db759d6796477601945577550a80f86e53a694f52d07910ffe081e79aa18db0688a8aaf3b4cb5dae5e141beaefc43e4083c59e46af2f6496df3
7
- data.tar.gz: efc7e291b645b15c0fe2d528737597948aaa28b9cc5a2f1bffd153313f071c0bd659d3d5b885932a607cf21f55dd217f907f85e82a667cbc797c8d8b85b9674d
6
+ metadata.gz: d0b1c7d3a1e9e0166f934d722b51c6f08c2771368b336c59efa46b67b2ba71ac173189b9d97b3431649b6171fe8eb309db6c49de5401b1fbd230cdb800008053
7
+ data.tar.gz: e337186410b4b79da024276338208520d95c48fe7ca2b9fa1731d37c2ad4836f469aa69d57230805f0e250c591727de9d6db0aef49a7502da1b065a403557d44
data/CHANGELOG.md CHANGED
@@ -4,25 +4,41 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
- ## [0.1.0] - 2026-06-09
7
+ ## [0.1.6] - 2026-06-21
8
8
 
9
9
  ### Added
10
- - Initial release of OmniAgent as a Rails engine for building application-native AI agents.
11
- - Agent runtime with provider abstraction, tool-calling loop, callbacks, and prompt rendering.
12
- - OpenAI provider support via the `openai` gem.
13
- - Tool DSL with JSON schema generation.
14
- - Rails generators and tasks for agent scaffolding.
15
- - RSpec coverage for agent runtime, providers, tools, and integration flows.
10
+ - Added `OmniAgent::Eval` DSL for testing agent quality: `agent`, `eval_case`, `expect_tool_call`, `expect_output`, and LLM-as-judge `judge` assertions.
11
+ - Added `golden_set` support for generating eval cases from a YAML/JSON dataset file.
12
+ - Added `omni_agent:eval` rake task and `omni_agent:eval` generator for scaffolding and running evals.
13
+ - Added `mock_judge` provider for deterministic judge-assertion testing.
14
+ - Added `eval_judge_provider`/`eval_judge_model` configuration keys.
15
+ - Added `run_alias` to `eval_case`, so evals can target an agent's `run_aliases` method (different prompt file) instead of plain `#run`.
16
+ - Added `with:` to `input` for forwarding context into the agent run.
17
+ - Added eval result caching (keyed on agent class, run_alias, input, context) to avoid re-spending tokens on unchanged cases, configurable via `eval_cache_enabled`/`eval_cache_path`.
18
+ - Added `FRESH=1` env var and `fresh` task arg (`rake "omni_agent:eval[pattern,fresh]"`) to clear the eval cache before running, without manually deleting the cache file.
19
+ - Added `omni_agent` executable (`bundle exec omni_agent eval [pattern] [--fresh]`), an rspec-like CLI alternative to the `omni_agent:eval` rake task.
16
20
 
17
- ## [0.1.1] - 2026-06-09
21
+ ## [0.1.5] - 2026-06-18
18
22
 
19
- ### Changed
20
- - Lowered Rails version needed.
23
+ ### Added
24
+ - Added max tool iterations limit on the agent loop, with corresponding error raised when exceeded.
25
+ - Added retry logic to the OpenAI provider, configurable via `Configuration`.
21
26
 
22
- ## [0.1.2] - 2026-06-11
27
+ ### Fixed
28
+ - Improved provider resolution error handling for unknown providers.
29
+ - Forced eager load of error aliases to avoid Zeitwerk autoloading issues.
30
+
31
+ # [0.1.4] - 2026-06-16
23
32
 
24
33
  ### Fixed
25
- - Fixed Zeitwerk issue with `OmniAgent::Errors`
34
+ - Change invoke method to an instance method instead of a class method, to actually catch invocations of the `stop_generation!` method.
35
+
36
+ ### Changed
37
+ - Remove agent file from directory to prevent needing to change Zeitwerk initializer as Railtie wasn't allowing to change behaviour without it.
38
+
39
+ ### Added
40
+ - Add Railtie to project to handle concerns.
41
+ - Added docusaurus, the actual docs will be done later.
26
42
 
27
43
  ## [0.1.3] - 2026-06-12
28
44
 
@@ -36,14 +52,22 @@ All notable changes to this project will be documented in this file.
36
52
  - Added `history` as a context variable which are the messages to be sent to the provider.
37
53
  - Added support for nested datatypes in the `array` schema for tools
38
54
 
39
- # [0.1.4] - 2026-06-16
55
+ ## [0.1.2] - 2026-06-11
40
56
 
41
57
  ### Fixed
42
- - Change invoke method to an instance method instead of a class method, to actually catch invocations of the `stop_generation!` method.
58
+ - Fixed Zeitwerk issue with `OmniAgent::Errors`
59
+
60
+ ## [0.1.1] - 2026-06-09
43
61
 
44
62
  ### Changed
45
- - Remove agent file from directory to prevent needing to change Zeitwerk initializer as Railtie wasn't allowing to change behaviour without it.
63
+ - Lowered Rails version needed.
64
+
65
+ ## [0.1.0] - 2026-06-09
46
66
 
47
67
  ### Added
48
- - Add Railtie to project to handle concerns.
49
- - Added docusaurus, the actual docs will be done later.
68
+ - Initial release of OmniAgent as a Rails engine for building application-native AI agents.
69
+ - Agent runtime with provider abstraction, tool-calling loop, callbacks, and prompt rendering.
70
+ - OpenAI provider support via the `openai` gem.
71
+ - Tool DSL with JSON schema generation.
72
+ - Rails generators and tasks for agent scaffolding.
73
+ - RSpec coverage for agent runtime, providers, tools, and integration flows.
data/README.md CHANGED
@@ -97,6 +97,90 @@ module ResearchAgent::Tools
97
97
  end
98
98
  ```
99
99
 
100
+ ## Evals
101
+
102
+ `OmniAgent::Eval` lets you test agent quality: deterministic assertions (tool calls, output matching) and pluggable LLM-as-judge scoring.
103
+
104
+ ```ruby
105
+ class ResearchAgentEval < OmniAgent::Eval
106
+ agent ResearchAgent
107
+
108
+ eval_case "answers weather question" do
109
+ input "What's the weather in Paris?"
110
+ expect_tool_call :get_weather, with: { city: "Paris" }
111
+ expect_output to_include: "Paris"
112
+ end
113
+
114
+ eval_case "is polite" do
115
+ input "Tell me a joke"
116
+ judge "Is the response friendly and on-topic?", threshold: 0.7
117
+ end
118
+
119
+ eval_case "summarizes via the :summarize run alias" do
120
+ run_alias :summarize
121
+ input "Some long article text...", with: { tone: "casual" }
122
+ expect_output to_include: "summary"
123
+ end
124
+ end
125
+ ```
126
+
127
+ * **`input text, with: {}`**: `with:` is forwarded as the agent's `context:`, bound to matching instance variables during the run (e.g. `with: { tone: "casual" }` sets `@tone`).
128
+ * **`run_alias`**: Targets a method defined via `run_aliases` (or any zero-arg run entrypoint) instead of plain `#run` — useful when that alias renders a different prompt file (`<method_name>.md.erb`).
129
+
130
+ Judge provider resolution order: explicit `provider:` kwarg on `judge` → `OmniAgent.configuration.eval_judge_provider`/`eval_judge_model` → the agent's own provider (zero-config default).
131
+
132
+ ### Caching
133
+
134
+ Eval runs are cached by default, keyed on `(agent class, run_alias, input, context)`. Re-running the same case (e.g. iterating on assertions) replays the cached output instead of calling the provider again, saving tokens. Configure or disable it:
135
+
136
+ ```ruby
137
+ OmniAgent.configure do |config|
138
+ config.eval_cache_enabled = true # default
139
+ config.eval_cache_path = "tmp/omni_agent_eval_cache.json" # default
140
+ end
141
+ ```
142
+
143
+ Bypass the cache for a run (clears it before running, no manual file deletion needed):
144
+
145
+ ```bash
146
+ bundle exec omni_agent eval evals/research_agent_eval.rb --fresh
147
+ ```
148
+
149
+ Sample output:
150
+
151
+ ```text
152
+ [PASS] mentions lorem
153
+ [FAIL] mentions something the mock never says
154
+ - output "Lorem ipsum dolor sit amet, consectetur adipiscing elit." does not include "this never appears"
155
+
156
+ 1/2 cases passed
157
+ ```
158
+
159
+ Each case prints `[PASS]`/`[FAIL]` plus its name; failing cases list every unmet assertion's message. Exits non-zero if any case failed.
160
+
161
+ For many input/expected-output pairs, load a YAML/JSON dataset instead of writing a `case` per row:
162
+
163
+ ```ruby
164
+ golden_set "evals/golden/research_agent.yml" do |row|
165
+ expect_output to_include: row[:expected_output]
166
+ end
167
+ ```
168
+
169
+ Scaffold an eval and run it:
170
+
171
+ ```bash
172
+ bundle exec rails generate omni_agent:eval ResearchAgent
173
+
174
+ # run from your Rails app root, like running rspec
175
+ bundle exec omni_agent eval
176
+ bundle exec omni_agent eval evals/research_agent_eval.rb
177
+ bundle exec omni_agent eval evals/research_agent_eval.rb --fresh
178
+ ```
179
+
180
+ There's also an equivalent `rake omni_agent:eval` task (`rake "omni_agent:eval[pattern,fresh]"`) if you'd rather not use the binstub.
181
+
182
+ Calls real LLM providers (cost, non-determinism) — deliberately **not** part of `bundle exec rspec` or CI.
183
+
100
184
  ## Configuration
101
185
 
102
186
  Global defaults can be configured through `OmniAgent.configure`:
data/exe/omni_agent ADDED
@@ -0,0 +1,42 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "optparse"
5
+
6
+ options = { fresh: false }
7
+ parser = OptionParser.new do |opts|
8
+ opts.banner = "Usage: omni_agent eval [pattern] [options]"
9
+ opts.on("--fresh", "Clear the eval cache before running") { options[:fresh] = true }
10
+ end
11
+
12
+ command = ARGV.shift
13
+
14
+ case command
15
+ when "eval"
16
+ parser.parse!(ARGV)
17
+ pattern = ARGV.shift
18
+
19
+ environment_file = File.expand_path("config/environment", Dir.pwd)
20
+ unless File.exist?("#{environment_file}.rb")
21
+ abort "Could not find config/environment.rb -- run `omni_agent eval` from your Rails app root."
22
+ end
23
+ require environment_file
24
+
25
+ status = OmniAgent::Eval::CLI.run(pattern: pattern, fresh: options[:fresh])
26
+
27
+ case status
28
+ when :no_files
29
+ abort "No eval files found matching #{(pattern || OmniAgent::Eval::CLI::DEFAULT_PATTERN).inspect}"
30
+ when :no_evals
31
+ abort "No OmniAgent::Eval subclasses found"
32
+ when :failed
33
+ exit 1
34
+ end
35
+ when nil
36
+ warn parser.banner
37
+ exit 1
38
+ else
39
+ warn "Unknown command: #{command.inspect}"
40
+ warn parser.banner
41
+ exit 1
42
+ end
@@ -46,7 +46,7 @@ module OmniAgent
46
46
  end
47
47
 
48
48
  def agent_file_path
49
- File.join(destination_root, "#{file_name}.rb")
49
+ File.join(destination_root, "app", "agents", "#{file_name}.rb")
50
50
  end
51
51
 
52
52
  def prompt_file_path
@@ -0,0 +1,37 @@
1
+ require "rails/generators/named_base"
2
+ require "fileutils"
3
+
4
+ module OmniAgent
5
+ module Generators
6
+ class EvalGenerator < Rails::Generators::NamedBase
7
+ def create_eval_file
8
+ FileUtils.mkdir_p(evals_directory)
9
+
10
+ create_file(eval_file_path, <<~RUBY)
11
+ class #{eval_class_name} < OmniAgent::Eval
12
+ agent #{class_name}
13
+
14
+ eval_case "describe what #{class_name} should do" do
15
+ input "Say hello"
16
+ expect_output to_include: "hello"
17
+ end
18
+ end
19
+ RUBY
20
+ end
21
+
22
+ private
23
+
24
+ def evals_directory
25
+ File.join(destination_root, "evals")
26
+ end
27
+
28
+ def eval_file_path
29
+ File.join(evals_directory, "#{file_name}_eval.rb")
30
+ end
31
+
32
+ def eval_class_name
33
+ "#{class_name}Eval"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -159,8 +159,17 @@ module OmniAgent
159
159
  initial_messages_count = messages.length - 1
160
160
 
161
161
  filtered_tools = tool_filter(tools: available_tools, agent_tags: self.class.tags)
162
+ max_iterations = OmniAgent.configuration.max_tool_iterations
163
+ iterations = 0
162
164
 
163
165
  loop do
166
+ iterations += 1
167
+ if iterations > max_iterations
168
+ raise OmniAgent::MaxToolIterationsError,
169
+ "Exceeded max_tool_iterations (#{max_iterations}) without a final response. " \
170
+ "Increase OmniAgent.configuration.max_tool_iterations if more tool calls are expected."
171
+ end
172
+
164
173
  response = provider.chat(messages: messages, tools: filtered_tools, **@chat_options)
165
174
 
166
175
  if response.content && !response.tool_calls?
@@ -309,7 +318,15 @@ module OmniAgent
309
318
  end
310
319
 
311
320
  def resolve_provider(name, model)
312
- OmniAgent::Providers.registry[name.to_sym].new(model: model)
321
+ provider_class = OmniAgent::Providers.registry[name.to_sym]
322
+
323
+ unless provider_class
324
+ known = OmniAgent::Providers.registry.keys.join(", ")
325
+ raise OmniAgent::UnknownProviderError,
326
+ "Unknown provider #{name.inspect}. Known providers: #{known}"
327
+ end
328
+
329
+ provider_class.new(model: model)
313
330
  end
314
331
 
315
332
  def run_before_generation_callbacks(input:, context:, messages:)
@@ -1,10 +1,18 @@
1
1
  module OmniAgent
2
2
  class Configuration
3
- attr_accessor :default_provider, :default_model
3
+ attr_accessor :default_provider, :default_model, :max_retries, :retry_base_delay, :max_tool_iterations,
4
+ :eval_judge_provider, :eval_judge_model, :eval_cache_enabled, :eval_cache_path
4
5
 
5
6
  def initialize
6
7
  @default_provider = :openai
7
8
  @default_model = "gpt-4o-mini"
9
+ @max_retries = 3
10
+ @retry_base_delay = 0.5
11
+ @max_tool_iterations = 10
12
+ @eval_judge_provider = nil
13
+ @eval_judge_model = nil
14
+ @eval_cache_enabled = true
15
+ @eval_cache_path = "tmp/omni_agent_eval_cache.json"
8
16
  end
9
17
  end
10
- end
18
+ end
@@ -2,8 +2,14 @@ module OmniAgent
2
2
  module Errors
3
3
  class Error < StandardError; end
4
4
  class MissingDependencyError < Error; end
5
+ class UnknownProviderError < Error; end
6
+ class MaxToolIterationsError < Error; end
7
+ class EvalAssertionError < Error; end
5
8
  end
6
9
 
7
10
  Error = Errors::Error
8
11
  MissingDependencyError = Errors::MissingDependencyError
12
+ UnknownProviderError = Errors::UnknownProviderError
13
+ MaxToolIterationsError = Errors::MaxToolIterationsError
14
+ EvalAssertionError = Errors::EvalAssertionError
9
15
  end
@@ -0,0 +1,51 @@
1
+ require "digest"
2
+ require "fileutils"
3
+ require "json"
4
+
5
+ module OmniAgent
6
+ class Eval
7
+ module Cache
8
+ def self.fetch(key)
9
+ return yield unless OmniAgent.configuration.eval_cache_enabled
10
+
11
+ store = read_store
12
+ return store[key] if store.key?(key)
13
+
14
+ result = yield
15
+ store[key] = result
16
+ write_store(store)
17
+ result
18
+ end
19
+
20
+ def self.key_for(agent_class:, run_alias:, input:, context:)
21
+ Digest::SHA256.hexdigest(JSON.generate(
22
+ agent: agent_class.name,
23
+ run_alias: run_alias,
24
+ input: input,
25
+ context: context
26
+ ))
27
+ end
28
+
29
+ def self.clear!
30
+ File.delete(path) if File.exist?(path)
31
+ end
32
+
33
+ def self.path
34
+ OmniAgent.configuration.eval_cache_path
35
+ end
36
+
37
+ def self.read_store
38
+ return {} unless File.exist?(path)
39
+
40
+ JSON.parse(File.read(path))
41
+ rescue JSON::ParserError
42
+ {}
43
+ end
44
+
45
+ def self.write_store(store)
46
+ FileUtils.mkdir_p(File.dirname(path))
47
+ File.write(path, JSON.generate(store))
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,56 @@
1
+ module OmniAgent
2
+ class Eval
3
+ class Case
4
+ attr_reader :name
5
+
6
+ def initialize(name, input: nil, context: {}, run_alias: nil, row: nil, &block)
7
+ @name = name
8
+ @input = input
9
+ @context = context || {}
10
+ @run_alias = run_alias
11
+ @assertions = []
12
+
13
+ if block
14
+ row.nil? ? instance_eval(&block) : instance_exec(row, &block)
15
+ end
16
+ end
17
+
18
+ def input(text, with: {})
19
+ @input = text
20
+ @context = with || {}
21
+ end
22
+
23
+ def run_alias(name)
24
+ @run_alias = name
25
+ end
26
+
27
+ def expect_tool_call(tool_name, with: nil)
28
+ @assertions << ToolCallAssertion.new(tool_name, with: with)
29
+ end
30
+
31
+ def expect_output(to_include: nil, to_match: nil, &block)
32
+ @assertions << OutputAssertion.new(to_include: to_include, to_match: to_match, &block)
33
+ end
34
+
35
+ def judge(criteria, threshold: 0.7, provider: nil, model: nil)
36
+ @assertions << JudgeAssertion.new(criteria, threshold: threshold, provider: provider, model: model)
37
+ end
38
+
39
+ def configured_input
40
+ @input
41
+ end
42
+
43
+ def configured_context
44
+ @context
45
+ end
46
+
47
+ def configured_run_alias
48
+ @run_alias
49
+ end
50
+
51
+ def configured_assertions
52
+ @assertions
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,16 @@
1
+ module OmniAgent
2
+ class Eval
3
+ class CaseResult
4
+ attr_reader :case_name, :outcomes
5
+
6
+ def initialize(case_name:, outcomes:)
7
+ @case_name = case_name
8
+ @outcomes = outcomes
9
+ end
10
+
11
+ def passed?
12
+ outcomes.all?(&:passed?)
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,24 @@
1
+ module OmniAgent
2
+ class Eval
3
+ module CLI
4
+ DEFAULT_PATTERN = "evals/**/*_eval.rb"
5
+
6
+ def self.run(pattern: nil, fresh: false)
7
+ OmniAgent::Eval::Cache.clear! if fresh
8
+
9
+ files = Dir.glob(Rails.root.join(pattern || DEFAULT_PATTERN))
10
+ return :no_files if files.empty?
11
+
12
+ files.each { |file| require file }
13
+
14
+ eval_classes = ObjectSpace.each_object(Class).select { |klass| klass < OmniAgent::Eval }
15
+ return :no_evals if eval_classes.empty?
16
+
17
+ reports = eval_classes.map(&:run_all)
18
+ reports.each(&:print)
19
+
20
+ reports.all?(&:passed?) ? :passed : :failed
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,24 @@
1
+ require "yaml"
2
+ require "json"
3
+
4
+ module OmniAgent
5
+ class Eval
6
+ module GoldenSet
7
+ def self.load(path)
8
+ rows = path.to_s.end_with?(".json") ? JSON.parse(File.read(path)) : YAML.safe_load_file(path)
9
+ Array(rows).map { |row| deep_symbolize(row) }
10
+ end
11
+
12
+ def self.deep_symbolize(value)
13
+ case value
14
+ when Hash
15
+ value.each_with_object({}) { |(key, val), memo| memo[key.to_sym] = deep_symbolize(val) }
16
+ when Array
17
+ value.map { |item| deep_symbolize(item) }
18
+ else
19
+ value
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,53 @@
1
+ require "json"
2
+
3
+ module OmniAgent
4
+ class Eval
5
+ class Judge
6
+ GRADING_PROMPT = <<~PROMPT
7
+ You are grading an AI agent's output against a single criterion.
8
+
9
+ Output: %<output>s
10
+
11
+ Criterion: %<criteria>s
12
+
13
+ Respond with strict JSON only, no other text: {"score": <float between 0 and 1>, "reason": "<short explanation>"}
14
+ PROMPT
15
+
16
+ def initialize(provider: nil, model: nil)
17
+ @provider = provider
18
+ @model = model
19
+ end
20
+
21
+ def call(criteria:, output:, fallback_provider: nil)
22
+ judge_provider = resolve_provider(fallback_provider)
23
+ prompt = format(GRADING_PROMPT, output: output, criteria: criteria)
24
+
25
+ response = judge_provider.chat(messages: [ { role: "user", content: prompt } ])
26
+ parse_score(response.content)
27
+ end
28
+
29
+ private
30
+
31
+ def resolve_provider(fallback_provider)
32
+ return @provider if @provider.is_a?(OmniAgent::Providers::Base)
33
+
34
+ provider_name = @provider || OmniAgent.configuration.eval_judge_provider
35
+ return fallback_provider if provider_name.nil?
36
+
37
+ provider_class = OmniAgent::Providers.registry[provider_name.to_sym]
38
+ unless provider_class
39
+ raise OmniAgent::UnknownProviderError, "Unknown judge provider #{provider_name.inspect}"
40
+ end
41
+
42
+ provider_class.new(model: @model || OmniAgent.configuration.eval_judge_model)
43
+ end
44
+
45
+ def parse_score(content)
46
+ data = JSON.parse(content.to_s)
47
+ { score: data["score"].to_f, reason: data["reason"] }
48
+ rescue JSON::ParserError
49
+ { score: 0.0, reason: "judge response was not valid JSON: #{content.inspect}" }
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,22 @@
1
+ module OmniAgent
2
+ class Eval
3
+ class JudgeAssertion
4
+ def initialize(criteria, threshold: 0.7, provider: nil, model: nil)
5
+ @criteria = criteria
6
+ @threshold = threshold
7
+ @judge = Judge.new(provider: provider, model: model)
8
+ end
9
+
10
+ def call(run)
11
+ verdict = @judge.call(criteria: @criteria, output: run.output, fallback_provider: run.agent.provider)
12
+ passed = verdict[:score] >= @threshold
13
+
14
+ Outcome.new(
15
+ passed: passed,
16
+ message: "judge score #{verdict[:score]} (threshold #{@threshold}) - #{verdict[:reason]}",
17
+ score: verdict[:score]
18
+ )
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,17 @@
1
+ module OmniAgent
2
+ class Eval
3
+ class Outcome
4
+ attr_reader :message, :score
5
+
6
+ def initialize(passed:, message:, score: nil)
7
+ @passed = passed
8
+ @message = message
9
+ @score = score
10
+ end
11
+
12
+ def passed?
13
+ @passed == true
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,41 @@
1
+ module OmniAgent
2
+ class Eval
3
+ class OutputAssertion
4
+ def initialize(to_include: nil, to_match: nil, &block)
5
+ @to_include = to_include
6
+ @to_match = to_match
7
+ @block = block
8
+ end
9
+
10
+ def call(run)
11
+ output = run.output
12
+
13
+ return call_block(output) if @block
14
+ return call_to_include(output) if @to_include
15
+ return call_to_match(output) if @to_match
16
+
17
+ raise ArgumentError, "expect_output requires to_include:, to_match:, or a block"
18
+ end
19
+
20
+ private
21
+
22
+ def call_block(output)
23
+ passed = !!@block.call(output)
24
+ message = passed ? "output matched custom block" : "output #{output.inspect} failed custom block"
25
+ Outcome.new(passed: passed, message: message)
26
+ end
27
+
28
+ def call_to_include(output)
29
+ passed = output.to_s.include?(@to_include.to_s)
30
+ message = passed ? "output includes #{@to_include.inspect}" : "output #{output.inspect} does not include #{@to_include.inspect}"
31
+ Outcome.new(passed: passed, message: message)
32
+ end
33
+
34
+ def call_to_match(output)
35
+ passed = !!(output.to_s =~ @to_match)
36
+ message = passed ? "output matches #{@to_match.inspect}" : "output #{output.inspect} does not match #{@to_match.inspect}"
37
+ Outcome.new(passed: passed, message: message)
38
+ end
39
+ end
40
+ end
41
+ end
@@ -0,0 +1,37 @@
1
+ module OmniAgent
2
+ class Eval
3
+ class Report
4
+ attr_reader :case_results
5
+
6
+ def initialize(case_results)
7
+ @case_results = case_results
8
+ end
9
+
10
+ def passed?
11
+ case_results.all?(&:passed?)
12
+ end
13
+
14
+ def raise_on_failure!
15
+ return if passed?
16
+
17
+ failures = case_results.reject(&:passed?).map(&:case_name)
18
+ raise OmniAgent::EvalAssertionError, "Eval cases failed: #{failures.join(', ')}"
19
+ end
20
+
21
+ def print(io: $stdout)
22
+ case_results.each do |case_result|
23
+ status = case_result.passed? ? "PASS" : "FAIL"
24
+ io.puts "[#{status}] #{case_result.case_name}"
25
+
26
+ case_result.outcomes.each do |outcome|
27
+ next if outcome.passed?
28
+ io.puts " - #{outcome.message}"
29
+ end
30
+ end
31
+
32
+ passed_count = case_results.count(&:passed?)
33
+ io.puts "\n#{passed_count}/#{case_results.size} cases passed"
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,13 @@
1
+ module OmniAgent
2
+ class Eval
3
+ class Run
4
+ attr_reader :output, :tool_calls, :agent
5
+
6
+ def initialize(output:, tool_calls:, agent:)
7
+ @output = output
8
+ @tool_calls = tool_calls
9
+ @agent = agent
10
+ end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,66 @@
1
+ require "json"
2
+
3
+ module OmniAgent
4
+ class Eval
5
+ module Runner
6
+ def self.run(eval_case, agent_class)
7
+ agent = agent_class.new
8
+
9
+ cache_key = Cache.key_for(
10
+ agent_class: agent_class,
11
+ run_alias: eval_case.configured_run_alias,
12
+ input: eval_case.configured_input,
13
+ context: eval_case.configured_context
14
+ )
15
+
16
+ cached = Cache.fetch(cache_key) { invoke_agent(agent, eval_case) }
17
+ run = Run.new(output: cached["output"], tool_calls: normalize_tool_calls(cached["tool_calls"]), agent: agent)
18
+
19
+ outcomes = eval_case.configured_assertions.map { |assertion| assertion.call(run) }
20
+ CaseResult.new(case_name: eval_case.name, outcomes: outcomes)
21
+ end
22
+
23
+ def self.invoke_agent(agent, eval_case)
24
+ response = if eval_case.configured_run_alias
25
+ agent.public_send(eval_case.configured_run_alias, eval_case.configured_input, context: eval_case.configured_context)
26
+ else
27
+ agent.run(eval_case.configured_input, context: eval_case.configured_context)
28
+ end
29
+
30
+ { "output" => response.answer.to_s, "tool_calls" => extract_tool_calls(response) }
31
+ end
32
+
33
+ def self.extract_tool_calls(response)
34
+ response.generated_messages
35
+ .select { |message| (message[:role] || message["role"]) == "assistant" }
36
+ .flat_map { |message| message[:tool_calls] || message["tool_calls"] || [] }
37
+ .map { |tool_call| extract_tool_call(tool_call) }
38
+ end
39
+
40
+ def self.extract_tool_call(tool_call)
41
+ function = tool_call[:function] || tool_call["function"] || {}
42
+ name = function[:name] || function["name"]
43
+ raw_arguments = function[:arguments] || function["arguments"]
44
+
45
+ { "name" => name, "arguments" => parse_arguments(raw_arguments) }
46
+ end
47
+
48
+ def self.parse_arguments(raw_arguments)
49
+ return {} if raw_arguments.nil?
50
+
51
+ parsed = raw_arguments.is_a?(String) ? JSON.parse(raw_arguments) : raw_arguments
52
+ parsed.transform_keys(&:to_s)
53
+ rescue JSON::ParserError
54
+ {}
55
+ end
56
+
57
+ def self.normalize_tool_calls(tool_calls)
58
+ Array(tool_calls).map do |tool_call|
59
+ { name: tool_call["name"], arguments: (tool_call["arguments"] || {}).transform_keys(&:to_sym) }
60
+ end
61
+ end
62
+
63
+ private_class_method :invoke_agent, :extract_tool_calls, :extract_tool_call, :parse_arguments, :normalize_tool_calls
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,28 @@
1
+ module OmniAgent
2
+ class Eval
3
+ class ToolCallAssertion
4
+ def initialize(tool_name, with: nil)
5
+ @tool_name = tool_name.to_s
6
+ @expected_args = with
7
+ end
8
+
9
+ def call(run)
10
+ matches = run.tool_calls.select { |tool_call| tool_call[:name] == @tool_name }
11
+
12
+ if matches.empty?
13
+ return Outcome.new(passed: false, message: "expected tool `#{@tool_name}` to be called, but it wasn't")
14
+ end
15
+
16
+ return Outcome.new(passed: true, message: "tool `#{@tool_name}` was called") if @expected_args.nil?
17
+
18
+ expected = @expected_args.transform_keys(&:to_sym)
19
+ if matches.any? { |tool_call| expected.all? { |key, value| tool_call[:arguments][key] == value } }
20
+ Outcome.new(passed: true, message: "tool `#{@tool_name}` was called with #{expected}")
21
+ else
22
+ got = matches.map { |tool_call| tool_call[:arguments] }
23
+ Outcome.new(passed: false, message: "tool `#{@tool_name}` was called, but not with #{expected} (got #{got})")
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,40 @@
1
+ module OmniAgent
2
+ class Eval
3
+ class << self
4
+ def agent(klass = nil)
5
+ @agent_class = klass if klass
6
+ @agent_class
7
+ end
8
+
9
+ def eval_case(name, &block)
10
+ @configured_cases = configured_cases + [ Case.new(name, &block) ]
11
+ end
12
+
13
+ def golden_set(path, &block)
14
+ new_cases = GoldenSet.load(path).each_with_index.map do |row, index|
15
+ case_name = row[:name] || "row #{index}"
16
+ Case.new(case_name, input: row[:input], context: row[:context] || {}, run_alias: row[:run_alias], row: row, &block)
17
+ end
18
+
19
+ @configured_cases = configured_cases + new_cases
20
+ end
21
+
22
+ def configured_agent
23
+ @agent_class
24
+ end
25
+
26
+ def configured_cases
27
+ @configured_cases || []
28
+ end
29
+
30
+ def run_all
31
+ unless configured_agent
32
+ raise OmniAgent::Error, "#{name} must declare `agent SomeAgentClass` before running evals"
33
+ end
34
+
35
+ case_results = configured_cases.map { |eval_case| Runner.run(eval_case, configured_agent) }
36
+ Report.new(case_results)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -49,6 +49,29 @@ module OmniAgent
49
49
  raise NotImplementedError, "Providers must define a default model"
50
50
  end
51
51
 
52
+ # Override in subclasses to flag exceptions worth retrying (rate limits, timeouts, 5xx).
53
+ def retryable_error?(_error)
54
+ false
55
+ end
56
+
57
+ def with_retries
58
+ max_retries = OmniAgent.configuration.max_retries
59
+ base_delay = OmniAgent.configuration.retry_base_delay
60
+ attempt = 0
61
+
62
+ begin
63
+ yield
64
+ rescue => e
65
+ raise unless retryable_error?(e)
66
+
67
+ attempt += 1
68
+ raise if attempt > max_retries
69
+
70
+ sleep(base_delay * (2**(attempt - 1)))
71
+ retry
72
+ end
73
+ end
74
+
52
75
  def validate_message_payload!(message, index, role_name)
53
76
  has_content = message.key?(:content) || message.key?("content")
54
77
  content_value = message[:content] || message["content"]
@@ -0,0 +1,31 @@
1
+ module OmniAgent
2
+ module Providers
3
+ class MockJudge < Base
4
+ CANNED_SCORE = 1.0
5
+ CANNED_REASON = "mock judge always approves"
6
+
7
+ def chat(messages:, tools: [], **_options)
8
+ validate_messages!(messages, allowed_roles: %i[system user assistant tool])
9
+
10
+ content = { score: CANNED_SCORE, reason: CANNED_REASON }.to_json
11
+
12
+ OmniAgent::Providers::Response.new(
13
+ content: content,
14
+ raw_request: { model: model, messages: messages, tools: tools },
15
+ raw_response: { "choices" => [ { "message" => { "content" => content } } ] },
16
+ tool_calls: []
17
+ )
18
+ end
19
+
20
+ protected
21
+
22
+ def default_api_key
23
+ nil
24
+ end
25
+
26
+ def default_model
27
+ "mock-judge"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -44,7 +44,7 @@ module OmniAgent
44
44
  payload[:tools] = openai_tools if openai_tools.any?
45
45
  payload.merge!(options) if options.any?
46
46
 
47
- response = client.chat.completions.create(**payload)
47
+ response = with_retries { client.chat.completions.create(**payload) }
48
48
 
49
49
  parse_response(response, payload)
50
50
  rescue => e
@@ -53,6 +53,13 @@ module OmniAgent
53
53
 
54
54
  protected
55
55
 
56
+ def retryable_error?(error)
57
+ defined?(::OpenAI::Errors) &&
58
+ (error.is_a?(::OpenAI::Errors::RateLimitError) ||
59
+ error.is_a?(::OpenAI::Errors::InternalServerError) ||
60
+ error.is_a?(::OpenAI::Errors::APIConnectionError))
61
+ end
62
+
56
63
  def client
57
64
  @client ||= ::OpenAI::Client.new(api_key: @api_key)
58
65
  end
@@ -4,7 +4,8 @@ module OmniAgent
4
4
  def self.registry
5
5
  {
6
6
  openai: OmniAgent::Providers::OpenAI,
7
- mock: OmniAgent::Providers::Mock
7
+ mock: OmniAgent::Providers::Mock,
8
+ mock_judge: OmniAgent::Providers::MockJudge
8
9
  }
9
10
  end
10
11
  end
@@ -1,56 +1,56 @@
1
1
  require "fileutils"
2
2
 
3
3
  namespace :omni_agent do
4
- desc "Set up OmniAgent directories in your Rails app"
5
- task install: :environment do
6
- agents_root = Rails.root.join("app", "agents")
7
- FileUtils.mkdir_p(agents_root)
4
+ desc "Set up OmniAgent directories in your Rails app"
5
+ task install: :environment do
6
+ agents_root = Rails.root.join("app", "agents")
7
+ FileUtils.mkdir_p(agents_root)
8
8
 
9
- puts "Created #{agents_root}"
10
- puts "OmniAgent install complete."
11
- puts "Next: bundle exec rake \"omni_agent:create_agent[ResearchAgent]\""
12
- end
9
+ puts "Created #{agents_root}"
10
+ puts "OmniAgent install complete."
11
+ puts "Next: bundle exec rake \"omni_agent:create_agent[ResearchAgent]\""
12
+ end
13
13
 
14
- desc "Create an agent scaffold. Usage: rake \"omni_agent:create_agent[ResearchAgent]\" [-- --with-tools]"
15
- task :create_agent, [:name] => :environment do |_task, args|
16
- agent_name = args[:name].to_s.strip
14
+ desc "Create an agent scaffold. Usage: rake \"omni_agent:create_agent[ResearchAgent]\" [-- --with-tools]"
15
+ task :create_agent, [ :name ] => :environment do |_task, args|
16
+ agent_name = args[:name].to_s.strip
17
17
 
18
- if agent_name.empty?
19
- abort "Please provide an agent name. Example: bundle exec rake \"omni_agent:create_agent[ResearchAgent]\""
20
- end
18
+ if agent_name.empty?
19
+ abort "Please provide an agent name. Example: bundle exec rake \"omni_agent:create_agent[ResearchAgent]\""
20
+ end
21
21
 
22
- with_tools = ARGV.include?("--with-tools")
22
+ with_tools = ARGV.include?("--with-tools")
23
23
 
24
- agent_file_name = "#{agent_name.underscore}.rb"
25
- agent_dir_name = agent_name.underscore
26
- agent_dir = Rails.root.join("app", "agents", agent_dir_name)
27
- tools_dir = agent_dir.join("tools")
28
- prompt_file = agent_dir.join("prompt.md.erb")
29
- agent_rb = agent_dir.join(agent_file_name)
24
+ agent_file_name = "#{agent_name.underscore}.rb"
25
+ agent_dir_name = agent_name.underscore
26
+ agent_dir = Rails.root.join("app", "agents", agent_dir_name)
27
+ tools_dir = agent_dir.join("tools")
28
+ prompt_file = agent_dir.join("prompt.md.erb")
29
+ agent_rb = agent_dir.join(agent_file_name)
30
30
 
31
- FileUtils.mkdir_p(agent_dir)
31
+ FileUtils.mkdir_p(agent_dir)
32
32
 
33
- if File.exist?(agent_rb)
34
- abort "Agent file already exists: #{agent_rb}"
35
- end
33
+ if File.exist?(agent_rb)
34
+ abort "Agent file already exists: #{agent_rb}"
35
+ end
36
36
 
37
- File.write(agent_rb, <<~RUBY)
37
+ File.write(agent_rb, <<~RUBY)
38
38
  class #{agent_name} < OmniAgent::Agent
39
39
  end
40
- RUBY
40
+ RUBY
41
41
 
42
- unless File.exist?(prompt_file)
43
- File.write(prompt_file, <<~PROMPT)
42
+ unless File.exist?(prompt_file)
43
+ File.write(prompt_file, <<~PROMPT)
44
44
  You are #{agent_name}, a helpful assistant with access to local tools.
45
- PROMPT
46
- end
45
+ PROMPT
46
+ end
47
47
 
48
- if with_tools
49
- FileUtils.mkdir_p(tools_dir)
48
+ if with_tools
49
+ FileUtils.mkdir_p(tools_dir)
50
50
 
51
- example_tool = tools_dir.join("example_tool.rb")
52
- unless File.exist?(example_tool)
53
- File.write(example_tool, <<~RUBY)
51
+ example_tool = tools_dir.join("example_tool.rb")
52
+ unless File.exist?(example_tool)
53
+ File.write(example_tool, <<~RUBY)
54
54
  module #{agent_name}::Tools
55
55
  class ExampleTool < OmniAgent::Tool
56
56
  description "Example tool generated by omni_agent:create_agent"
@@ -66,13 +66,13 @@ namespace :omni_agent do
66
66
  end
67
67
  end
68
68
  end
69
- RUBY
70
- end
71
- end
69
+ RUBY
70
+ end
71
+ end
72
72
 
73
- puts "Created #{agent_rb}"
74
- puts "Created #{prompt_file}" if File.exist?(prompt_file)
75
- puts "Created #{tools_dir}" if with_tools
76
- puts "Created #{tools_dir.join("example_tool.rb")}" if with_tools && File.exist?(tools_dir.join("example_tool.rb"))
77
- end
73
+ puts "Created #{agent_rb}"
74
+ puts "Created #{prompt_file}" if File.exist?(prompt_file)
75
+ puts "Created #{tools_dir}" if with_tools
76
+ puts "Created #{tools_dir.join("example_tool.rb")}" if with_tools && File.exist?(tools_dir.join("example_tool.rb"))
77
+ end
78
78
  end
@@ -1,3 +1,3 @@
1
1
  module OmniAgent
2
- VERSION = "0.1.4"
2
+ VERSION = "0.1.6"
3
3
  end
data/lib/omni_agent.rb CHANGED
@@ -3,10 +3,16 @@ require "omni_agent/engine" if defined?(Rails)
3
3
  require "zeitwerk"
4
4
 
5
5
  loader = Zeitwerk::Loader.for_gem
6
- loader.inflector.inflect("openai" => "OpenAI")
6
+ loader.inflector.inflect("openai" => "OpenAI", "cli" => "CLI")
7
7
  loader.ignore(File.expand_path("generators", __dir__))
8
8
  loader.setup
9
9
 
10
+ # errors.rb defines top-level aliases (OmniAgent::MissingDependencyError, etc.)
11
+ # that Zeitwerk cannot autoload, since they don't map to a file path of their
12
+ # own. Force it to load eagerly so the aliases exist regardless of which
13
+ # constant is referenced first.
14
+ require "omni_agent/errors"
15
+
10
16
 
11
17
  module OmniAgent
12
18
  class << self
@@ -0,0 +1,19 @@
1
+ namespace :omni_agent do
2
+ desc 'Run evals. Usage: rake omni_agent:eval or rake "omni_agent:eval[evals/research_agent_eval.rb,fresh]". Pass `fresh` as the 2nd arg, or set FRESH=1, to bypass the eval cache.'
3
+ task :eval, [ :pattern, :fresh ] => :environment do |_task, args|
4
+ fresh = ENV["FRESH"] == "1" || args[:fresh] == "fresh"
5
+ pattern = args[:pattern].to_s.strip
6
+ pattern = nil if pattern.empty?
7
+
8
+ status = OmniAgent::Eval::CLI.run(pattern: pattern, fresh: fresh)
9
+
10
+ case status
11
+ when :no_files
12
+ abort "No eval files found matching #{(pattern || OmniAgent::Eval::CLI::DEFAULT_PATTERN).inspect}"
13
+ when :no_evals
14
+ abort "No OmniAgent::Eval subclasses found"
15
+ when :failed
16
+ exit 1
17
+ end
18
+ end
19
+ end
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omni_agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.4
4
+ version: 0.1.6
5
5
  platform: ruby
6
6
  authors:
7
7
  - ACR1209
8
- autorequire:
9
- bindir: bin
8
+ bindir: exe
10
9
  cert_chain: []
11
- date: 2026-06-17 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: rails
@@ -42,7 +41,8 @@ description: OmniAgent provides a Rails-native framework for defining AI agents,
42
41
  schemas, prompt templates, callbacks, and provider-backed generation workflows.
43
42
  email:
44
43
  - andrescoronel1209@gmail.com
45
- executables: []
44
+ executables:
45
+ - omni_agent
46
46
  extensions: []
47
47
  extra_rdoc_files: []
48
48
  files:
@@ -58,16 +58,33 @@ files:
58
58
  - app/models/omni_agent/application_record.rb
59
59
  - app/views/layouts/omni_agent/application.html.erb
60
60
  - config/routes.rb
61
+ - exe/omni_agent
61
62
  - lib/generators/omni_agent/agent/agent_generator.rb
63
+ - lib/generators/omni_agent/eval/eval_generator.rb
62
64
  - lib/generators/omni_agent/install/install_generator.rb
63
65
  - lib/omni_agent.rb
64
66
  - lib/omni_agent/agent.rb
65
67
  - lib/omni_agent/configuration.rb
66
68
  - lib/omni_agent/engine.rb
67
69
  - lib/omni_agent/errors.rb
70
+ - lib/omni_agent/eval.rb
71
+ - lib/omni_agent/eval/cache.rb
72
+ - lib/omni_agent/eval/case.rb
73
+ - lib/omni_agent/eval/case_result.rb
74
+ - lib/omni_agent/eval/cli.rb
75
+ - lib/omni_agent/eval/golden_set.rb
76
+ - lib/omni_agent/eval/judge.rb
77
+ - lib/omni_agent/eval/judge_assertion.rb
78
+ - lib/omni_agent/eval/outcome.rb
79
+ - lib/omni_agent/eval/output_assertion.rb
80
+ - lib/omni_agent/eval/report.rb
81
+ - lib/omni_agent/eval/run.rb
82
+ - lib/omni_agent/eval/runner.rb
83
+ - lib/omni_agent/eval/tool_call_assertion.rb
68
84
  - lib/omni_agent/providers.rb
69
85
  - lib/omni_agent/providers/base.rb
70
86
  - lib/omni_agent/providers/mock.rb
87
+ - lib/omni_agent/providers/mock_judge.rb
71
88
  - lib/omni_agent/providers/openai.rb
72
89
  - lib/omni_agent/providers/response.rb
73
90
  - lib/omni_agent/railtie.rb
@@ -75,6 +92,7 @@ files:
75
92
  - lib/omni_agent/tool.rb
76
93
  - lib/omni_agent/tool/schema_builder.rb
77
94
  - lib/omni_agent/version.rb
95
+ - lib/tasks/omni_agent_eval_tasks.rake
78
96
  homepage: https://github.com/ACR1209/omni_agent
79
97
  licenses:
80
98
  - MIT
@@ -82,7 +100,6 @@ metadata:
82
100
  homepage_uri: https://github.com/ACR1209/omni_agent
83
101
  source_code_uri: https://github.com/ACR1209/omni_agent
84
102
  changelog_uri: https://github.com/ACR1209/omni_agent/blob/main/CHANGELOG.md
85
- post_install_message:
86
103
  rdoc_options: []
87
104
  require_paths:
88
105
  - lib
@@ -97,8 +114,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
97
114
  - !ruby/object:Gem::Version
98
115
  version: '0'
99
116
  requirements: []
100
- rubygems_version: 3.5.11
101
- signing_key:
117
+ rubygems_version: 3.6.9
102
118
  specification_version: 4
103
119
  summary: Rails engine for building AI agents with tools.
104
120
  test_files: []