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 +4 -4
- data/CHANGELOG.md +41 -17
- data/README.md +84 -0
- data/exe/omni_agent +42 -0
- data/lib/generators/omni_agent/agent/agent_generator.rb +1 -1
- data/lib/generators/omni_agent/eval/eval_generator.rb +37 -0
- data/lib/omni_agent/agent.rb +18 -1
- data/lib/omni_agent/configuration.rb +10 -2
- data/lib/omni_agent/errors.rb +6 -0
- data/lib/omni_agent/eval/cache.rb +51 -0
- data/lib/omni_agent/eval/case.rb +56 -0
- data/lib/omni_agent/eval/case_result.rb +16 -0
- data/lib/omni_agent/eval/cli.rb +24 -0
- data/lib/omni_agent/eval/golden_set.rb +24 -0
- data/lib/omni_agent/eval/judge.rb +53 -0
- data/lib/omni_agent/eval/judge_assertion.rb +22 -0
- data/lib/omni_agent/eval/outcome.rb +17 -0
- data/lib/omni_agent/eval/output_assertion.rb +41 -0
- data/lib/omni_agent/eval/report.rb +37 -0
- data/lib/omni_agent/eval/run.rb +13 -0
- data/lib/omni_agent/eval/runner.rb +66 -0
- data/lib/omni_agent/eval/tool_call_assertion.rb +28 -0
- data/lib/omni_agent/eval.rb +40 -0
- data/lib/omni_agent/providers/base.rb +23 -0
- data/lib/omni_agent/providers/mock_judge.rb +31 -0
- data/lib/omni_agent/providers/openai.rb +8 -1
- data/lib/omni_agent/providers.rb +2 -1
- data/lib/omni_agent/tasks/omni_agent_tasks.rake +44 -44
- data/lib/omni_agent/version.rb +1 -1
- data/lib/omni_agent.rb +7 -1
- data/lib/tasks/omni_agent_eval_tasks.rake +19 -0
- metadata +24 -8
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: d48868f19885179d74185a391e4b6687f3dc2d4320962eac162ac6f12d755892
|
|
4
|
+
data.tar.gz: 6b93ca1c7aa336226e64936e13839c862c4a05a301a42b9802042012fe2f157c
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
7
|
+
## [0.1.6] - 2026-06-21
|
|
8
8
|
|
|
9
9
|
### Added
|
|
10
|
-
-
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
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.
|
|
21
|
+
## [0.1.5] - 2026-06-18
|
|
18
22
|
|
|
19
|
-
###
|
|
20
|
-
-
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
-
|
|
55
|
+
## [0.1.2] - 2026-06-11
|
|
40
56
|
|
|
41
57
|
### Fixed
|
|
42
|
-
-
|
|
58
|
+
- Fixed Zeitwerk issue with `OmniAgent::Errors`
|
|
59
|
+
|
|
60
|
+
## [0.1.1] - 2026-06-09
|
|
43
61
|
|
|
44
62
|
### Changed
|
|
45
|
-
-
|
|
63
|
+
- Lowered Rails version needed.
|
|
64
|
+
|
|
65
|
+
## [0.1.0] - 2026-06-09
|
|
46
66
|
|
|
47
67
|
### Added
|
|
48
|
-
-
|
|
49
|
-
-
|
|
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
|
|
@@ -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
|
data/lib/omni_agent/agent.rb
CHANGED
|
@@ -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]
|
|
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
|
data/lib/omni_agent/errors.rb
CHANGED
|
@@ -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,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
|
data/lib/omni_agent/providers.rb
CHANGED
|
@@ -1,56 +1,56 @@
|
|
|
1
1
|
require "fileutils"
|
|
2
2
|
|
|
3
3
|
namespace :omni_agent do
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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
|
-
|
|
22
|
+
with_tools = ARGV.include?("--with-tools")
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
31
|
+
FileUtils.mkdir_p(agent_dir)
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
if File.exist?(agent_rb)
|
|
34
|
+
abort "Agent file already exists: #{agent_rb}"
|
|
35
|
+
end
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
File.write(agent_rb, <<~RUBY)
|
|
38
38
|
class #{agent_name} < OmniAgent::Agent
|
|
39
39
|
end
|
|
40
|
-
|
|
40
|
+
RUBY
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
|
|
46
|
-
|
|
45
|
+
PROMPT
|
|
46
|
+
end
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
if with_tools
|
|
49
|
+
FileUtils.mkdir_p(tools_dir)
|
|
50
50
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
69
|
+
RUBY
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
72
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
data/lib/omni_agent/version.rb
CHANGED
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
|
+
version: 0.1.6
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- ACR1209
|
|
8
|
-
|
|
9
|
-
bindir: bin
|
|
8
|
+
bindir: exe
|
|
10
9
|
cert_chain: []
|
|
11
|
-
date:
|
|
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.
|
|
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: []
|