omni_agent 0.1.5 → 0.1.7

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: 1513c46e356438c23dfd193f25c390df240f2d1d812813114e2e582222de2965
4
- data.tar.gz: a9b05b4445128aea795b3f7f5ea80cb6bd668bf2b674c15651011ce6780e0c2e
3
+ metadata.gz: 53319cab79a557426f4a7a109a1627c425cffe49d56dc6f2d0baba58ab74b24d
4
+ data.tar.gz: 541ff86413a12fbf65f95c12945e45a74d9baac18f9f7d446f5dce3281ae93ce
5
5
  SHA512:
6
- metadata.gz: 4ee226683fb316a82a18457b50bbe783712fe4ad9115befa4ab7c9421b31747acf2b3f609360c126a599ac8dded16a9cc4dda670e115f3bed3711147776956a3
7
- data.tar.gz: 6ab849bcae377ccc919d6daf13b0556d9d6ed07b1cd16d645fe66fb026df4dd941d4f9a06b396ea044bb4ad0db886457c25ce1ca4a4c4d4de6306dc31edf47f2
6
+ metadata.gz: 996ba6f8c9679fea087dccc4fc202de1b4fb2e8debece3fa63438719447819590294b0e965228ff4fb2e93eb25556283cce000dde2f04bba8fb2ee14a3b3ccfd
7
+ data.tar.gz: 8620ac25e792aa0213f00abbf717b814c4b3cca8a09af45a8659f9e557d9df5c0e33363ff109615f139403595630832cf8ff0c370d88a1a26bdfc74bb97ccd6e
data/CHANGELOG.md CHANGED
@@ -2,8 +2,29 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.1.7](https://github.com/ACR1209/omni_agent/compare/omni_agent-v0.1.6...omni_agent/v0.1.7) (2026-06-22)
6
+
7
+
8
+ ### Bug Fixes
9
+
10
+ * **docs:** use release-please block marker for version badge ([b83ca8d](https://github.com/ACR1209/omni_agent/commit/b83ca8dcad4acba976125b2a844e9c67d5a4df73))
11
+
5
12
  ## [Unreleased]
6
13
 
14
+ ## [0.1.6] - 2026-06-21
15
+
16
+ ### Added
17
+ - Added `OmniAgent::Eval` DSL for testing agent quality: `agent`, `eval_case`, `expect_tool_call`, `expect_output`, and LLM-as-judge `judge` assertions.
18
+ - Added `golden_set` support for generating eval cases from a YAML/JSON dataset file.
19
+ - Added `omni_agent:eval` rake task and `omni_agent:eval` generator for scaffolding and running evals.
20
+ - Added `mock_judge` provider for deterministic judge-assertion testing.
21
+ - Added `eval_judge_provider`/`eval_judge_model` configuration keys.
22
+ - Added `run_alias` to `eval_case`, so evals can target an agent's `run_aliases` method (different prompt file) instead of plain `#run`.
23
+ - Added `with:` to `input` for forwarding context into the agent run.
24
+ - 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`.
25
+ - 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.
26
+ - Added `omni_agent` executable (`bundle exec omni_agent eval [pattern] [--fresh]`), an rspec-like CLI alternative to the `omni_agent:eval` rake task.
27
+
7
28
  ## [0.1.5] - 2026-06-18
8
29
 
9
30
  ### Added
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/Rakefile CHANGED
@@ -6,6 +6,13 @@ load "rails/tasks/engine.rake"
6
6
 
7
7
  require "bundler/gem_tasks"
8
8
 
9
+ # release-please already tags and pushes releases, and CI checks out a
10
+ # detached HEAD, so the default `release` task's git tag/push step
11
+ # (release:guard_clean, release:source_control_push) always fails there.
12
+ # Redefine it to just build + push the gem.
13
+ Rake::Task["release"].clear
14
+ task release: [ "build", "release:rubygem_push" ]
15
+
9
16
  RSpec::Core::RakeTask.new(:spec)
10
17
 
11
18
  task default: :spec
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
@@ -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
@@ -1,6 +1,7 @@
1
1
  module OmniAgent
2
2
  class Configuration
3
- attr_accessor :default_provider, :default_model, :max_retries, :retry_base_delay, :max_tool_iterations
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
@@ -8,6 +9,10 @@ module OmniAgent
8
9
  @max_retries = 3
9
10
  @retry_base_delay = 0.5
10
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"
11
16
  end
12
17
  end
13
- end
18
+ end
@@ -4,10 +4,12 @@ module OmniAgent
4
4
  class MissingDependencyError < Error; end
5
5
  class UnknownProviderError < Error; end
6
6
  class MaxToolIterationsError < Error; end
7
+ class EvalAssertionError < Error; end
7
8
  end
8
9
 
9
10
  Error = Errors::Error
10
11
  MissingDependencyError = Errors::MissingDependencyError
11
12
  UnknownProviderError = Errors::UnknownProviderError
12
13
  MaxToolIterationsError = Errors::MaxToolIterationsError
14
+ EvalAssertionError = Errors::EvalAssertionError
13
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
@@ -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
@@ -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.5"
2
+ VERSION = "0.1.7"
3
3
  end
data/lib/omni_agent.rb CHANGED
@@ -3,7 +3,7 @@ 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
 
@@ -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,11 +1,11 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omni_agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.5
4
+ version: 0.1.7
5
5
  platform: ruby
6
6
  authors:
7
7
  - ACR1209
8
- bindir: bin
8
+ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies:
@@ -41,7 +41,8 @@ description: OmniAgent provides a Rails-native framework for defining AI agents,
41
41
  schemas, prompt templates, callbacks, and provider-backed generation workflows.
42
42
  email:
43
43
  - andrescoronel1209@gmail.com
44
- executables: []
44
+ executables:
45
+ - omni_agent
45
46
  extensions: []
46
47
  extra_rdoc_files: []
47
48
  files:
@@ -57,16 +58,33 @@ files:
57
58
  - app/models/omni_agent/application_record.rb
58
59
  - app/views/layouts/omni_agent/application.html.erb
59
60
  - config/routes.rb
61
+ - exe/omni_agent
60
62
  - lib/generators/omni_agent/agent/agent_generator.rb
63
+ - lib/generators/omni_agent/eval/eval_generator.rb
61
64
  - lib/generators/omni_agent/install/install_generator.rb
62
65
  - lib/omni_agent.rb
63
66
  - lib/omni_agent/agent.rb
64
67
  - lib/omni_agent/configuration.rb
65
68
  - lib/omni_agent/engine.rb
66
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
67
84
  - lib/omni_agent/providers.rb
68
85
  - lib/omni_agent/providers/base.rb
69
86
  - lib/omni_agent/providers/mock.rb
87
+ - lib/omni_agent/providers/mock_judge.rb
70
88
  - lib/omni_agent/providers/openai.rb
71
89
  - lib/omni_agent/providers/response.rb
72
90
  - lib/omni_agent/railtie.rb
@@ -74,6 +92,7 @@ files:
74
92
  - lib/omni_agent/tool.rb
75
93
  - lib/omni_agent/tool/schema_builder.rb
76
94
  - lib/omni_agent/version.rb
95
+ - lib/tasks/omni_agent_eval_tasks.rake
77
96
  homepage: https://github.com/ACR1209/omni_agent
78
97
  licenses:
79
98
  - MIT