omni_agent 0.1.7 → 0.1.9

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: 53319cab79a557426f4a7a109a1627c425cffe49d56dc6f2d0baba58ab74b24d
4
- data.tar.gz: 541ff86413a12fbf65f95c12945e45a74d9baac18f9f7d446f5dce3281ae93ce
3
+ metadata.gz: 66c390e526b0bd6176f8755538304d0e0e99d9cc36627be1ee9b63119b53ce34
4
+ data.tar.gz: 4918452fbba6d30772abbf9d072d0c16cf363d5220a099183e9caa04d7481220
5
5
  SHA512:
6
- metadata.gz: 996ba6f8c9679fea087dccc4fc202de1b4fb2e8debece3fa63438719447819590294b0e965228ff4fb2e93eb25556283cce000dde2f04bba8fb2ee14a3b3ccfd
7
- data.tar.gz: 8620ac25e792aa0213f00abbf717b814c4b3cca8a09af45a8659f9e557d9df5c0e33363ff109615f139403595630832cf8ff0c370d88a1a26bdfc74bb97ccd6e
6
+ metadata.gz: 522e4be90a757a9056e95cf45f5aafb1f18d7b6914e1ca667e32b3774ab70422f552e333ac2e340c4d1dab1c628350e04d494a9726b908467eb53723ae300dd6
7
+ data.tar.gz: c2c7939747237e554df0ac2569b84e36b164c1235f75fa876f04518cb644ad02579e18f4ee168c53ff55f32b75c8a58284ff921b217eda4b9148f67a8d5e4de7
data/CHANGELOG.md CHANGED
@@ -2,6 +2,32 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [0.1.9](https://github.com/ACR1209/omni_agent/compare/omni_agent/v0.1.8...omni_agent/v0.1.9) (2026-07-12)
6
+
7
+
8
+ ### Features
9
+
10
+ * **agent:** support streaming responses via stream sink ([7cf1fba](https://github.com/ACR1209/omni_agent/commit/7cf1fba4fa378c5cee7f464354f760506a0ff513))
11
+ * **docs:** add enum type for temperature unit in input schema ([d992658](https://github.com/ACR1209/omni_agent/commit/d99265836307ce8f863b060eff73c7acdbc8d2ff))
12
+ * **providers:** add Ollama provider via OpenAI-compat endpoint ([4ae92ad](https://github.com/ACR1209/omni_agent/commit/4ae92ad0f549119b43c2e8a7381f4f7245aa2f68))
13
+ * **tool:** add optional constraint validations and custom validate procs ([db8371a](https://github.com/ACR1209/omni_agent/commit/db8371a189f710e812c3c3ce15f200df1ba94902))
14
+ * **tool:** add polymorphic reference datatype ([1686bbb](https://github.com/ACR1209/omni_agent/commit/1686bbbf41932622a5537c335da8c41c68a73f73))
15
+ * **tool:** infer and validate enum value types in schema builder ([ceb3e78](https://github.com/ACR1209/omni_agent/commit/ceb3e780498caa1158e9fc8af3eb6e9b95c33ddb))
16
+ * **tool:** validate required and enum arguments on invoke ([543c6e0](https://github.com/ACR1209/omni_agent/commit/543c6e08f9c394b84c73bc5957aff7058e0fab19))
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * **lint:** add space inside empty block braces ([9116b3f](https://github.com/ACR1209/omni_agent/commit/9116b3ff82e9fab9824347515b4c1cd40a40785e))
22
+ * **spec:** redact OpenAI API key from recorded VCR cassettes ([53cecd5](https://github.com/ACR1209/omni_agent/commit/53cecd540b6dd52ddc5bf3da0550836fbfcfab88))
23
+
24
+ ## [0.1.8](https://github.com/ACR1209/omni_agent/compare/omni_agent/v0.1.7...omni_agent/v0.1.8) (2026-07-07)
25
+
26
+
27
+ ### Features
28
+
29
+ * implement multi-agent delegation with context forwarding and depth control ([e9b5497](https://github.com/ACR1209/omni_agent/commit/e9b5497b0af89172701bf4c5d93b8d5dccc77a26))
30
+
5
31
  ## [0.1.7](https://github.com/ACR1209/omni_agent/compare/omni_agent-v0.1.6...omni_agent/v0.1.7) (2026-06-22)
6
32
 
7
33
 
data/README.md CHANGED
@@ -11,7 +11,7 @@ tool schemas, and generation lifecycle callbacks.
11
11
  - Prompt composition from ERB files in `app/agents/<agent_name>/`
12
12
  - Agent callbacks (`before_generation`, `after_generation`)
13
13
  - Agent and tool tags to support filtering strategies
14
- - OpenAI provider integration out of the box
14
+ - OpenAI provider integration out of the box, plus an Ollama provider for local models
15
15
  - Rake tasks and Rails generators for scaffolding
16
16
 
17
17
  ## Installation
@@ -27,6 +27,8 @@ Add the provider you're using to the Gemfile as well:
27
27
  gem "openai"
28
28
  ```
29
29
 
30
+ The `ollama` provider also depends on the `openai` gem — it talks to Ollama's OpenAI-compatible endpoint, so no separate gem is needed.
31
+
30
32
  Then run:
31
33
 
32
34
  ```bash
@@ -53,6 +55,14 @@ bundle exec rails generate omni_agent:agent ResearchAgent --model gpt-4.1-mini -
53
55
  OPENAI_ACCESS_TOKEN=your_api_key_here
54
56
  ```
55
57
 
58
+ To use Ollama instead, set `config.default_provider = :ollama` (see below) and, if needed, override its endpoint/model:
59
+
60
+ ```dotenv
61
+ OLLAMA_HOST=http://localhost:11434 # defaults to this
62
+ OLLAMA_MODEL=llama3.1 # defaults to this; pick a tool-calling-capable model
63
+ OLLAMA_API_KEY=ollama # unused by Ollama, but required by the openai client
64
+ ```
65
+
56
66
  4. Implement your agent prompt and optional tools under:
57
67
 
58
68
  ```text
@@ -97,6 +107,55 @@ module ResearchAgent::Tools
97
107
  end
98
108
  ```
99
109
 
110
+ ## Multi-Agent Delegation
111
+
112
+ `delegate_to` turns an agent into a supervisor: it wraps another agent class as a tool, so the supervisor's LLM can decide when to hand off to it. The delegated agent is a normal, independently defined agent (e.g. `app/agents/research_agent.rb`) — no manual tool file needed.
113
+
114
+ ```ruby
115
+ class SupervisorAgent < OmniAgent::Agent
116
+ use_model "gpt-4o"
117
+
118
+ delegate_to ResearchAgent, as: :research, description: "Look up factual info"
119
+ delegate_to MathAgent, as: :calculate, description: "Do arithmetic"
120
+ end
121
+ ```
122
+
123
+ Each delegated agent runs in isolation (its own fresh instance, no shared context) and returns its final answer as the tool result. Delegation depth is capped by `OmniAgent.configuration.max_delegation_depth` (default `5`) to guard against runaway recursive delegation; exceeding it raises `OmniAgent::MaxDelegationDepthError`.
124
+
125
+ Pass `run_alias:` to call a `run_aliases` method (or any zero-arg run entrypoint) on the delegated agent instead of its default `#run` — useful when the sub-agent should render a different prompt file for delegated calls:
126
+
127
+ ```ruby
128
+ class SupervisorAgent < OmniAgent::Agent
129
+ delegate_to SupportAgent, as: :triage_ticket, run_alias: :triage
130
+ end
131
+ ```
132
+
133
+ Pass `forward:` to share part (or all) of the supervisor's context with the delegated agent — an array of context keys, or `true` to forward everything. Omitted keys, and the default (`forward: []`), keep the delegated agent fully isolated:
134
+
135
+ ```ruby
136
+ class SupervisorAgent < OmniAgent::Agent
137
+ delegate_to ResearchAgent, as: :research, forward: [ :user, :locale ]
138
+ delegate_to MathAgent, as: :calculate, forward: true
139
+ end
140
+ ```
141
+
142
+ ## Streaming
143
+
144
+ Prefix any run entrypoint with `.stream` and pass a block to receive the response as it's generated instead of waiting for the full result:
145
+
146
+ ```ruby
147
+ ResearchAgent.with(user_id: 42).stream.run("What's new?") do |event|
148
+ case event.type
149
+ when :text then print event.text
150
+ when :tool_call then puts "\n[using #{event.tool_name}...]"
151
+ when :tool_result then puts "[#{event.error? ? "failed" : "done"}]"
152
+ when :done then puts "\n---"
153
+ end
154
+ end
155
+ ```
156
+
157
+ `.stream` must come before the call (`.stream.run(...)`, not `.run(...).stream`) — without it, or without a block, behavior is unchanged and the same `Response` is returned either way. Only the `openai`, `ollama`, and `mock` providers stream today. See [Streaming Responses](omniagent-docs/docs/agent/streaming.mdx) for the full event reference.
158
+
100
159
  ## Evals
101
160
 
102
161
  `OmniAgent::Eval` lets you test agent quality: deterministic assertions (tool calls, output matching) and pluggable LLM-as-judge scoring.
@@ -20,7 +20,7 @@ module OmniAgent
20
20
  @_omni_agent_wrapping_method = true
21
21
  alias_method alias_name, method_name
22
22
 
23
- define_method(method_name) do |input = nil, context: {}, **context_keywords|
23
+ define_method(method_name) do |input = nil, context: {}, **context_keywords, &block|
24
24
  if input.nil? && context == {} && context_keywords.empty?
25
25
  run_alias_entrypoint_logic(alias_name)
26
26
  else
@@ -29,7 +29,7 @@ module OmniAgent
29
29
 
30
30
  run_input = run_alias_entrypoint_logic(alias_name, fallback_input: input)
31
31
 
32
- run(run_input, context: merged_context, prompt_method: method_name)
32
+ run(run_input, context: merged_context, prompt_method: method_name, &block)
33
33
  end
34
34
  end
35
35
  ensure
@@ -78,12 +78,27 @@ module OmniAgent
78
78
  aliases = normalize_callbacks(:run_aliases, method_names)
79
79
 
80
80
  aliases.each do |method_name|
81
- define_method(method_name) do |input, context: {}|
82
- run(input, context: context, prompt_method: method_name)
81
+ define_method(method_name) do |input, context: {}, &block|
82
+ run(input, context: context, prompt_method: method_name, &block)
83
83
  end
84
84
  end
85
85
  end
86
86
 
87
+ def delegate_to(agent_class, as:, description: nil, run_alias: nil, forward: [])
88
+ unless agent_class.is_a?(Class) && agent_class <= OmniAgent::Agent
89
+ raise ArgumentError, "delegate_to requires an OmniAgent::Agent subclass"
90
+ end
91
+
92
+ tool_class = build_delegated_tool_class(agent_class, description: description, run_alias: run_alias, forward: forward)
93
+ delegated_tools_module.const_set(delegated_tool_const_name(as), tool_class)
94
+
95
+ @delegated_tool_classes = configured_delegated_tool_classes + [ tool_class ]
96
+ end
97
+
98
+ def configured_delegated_tool_classes
99
+ @delegated_tool_classes || []
100
+ end
101
+
87
102
  def with(context = nil, provider_override: nil, model_override: nil, options_override: {}, **context_keywords)
88
103
  merged_context = {}
89
104
  merged_context.merge!(context) if context.is_a?(Hash)
@@ -112,6 +127,58 @@ module OmniAgent
112
127
 
113
128
  private
114
129
 
130
+ def delegated_tools_module
131
+ @delegated_tools_module ||= const_set(:DelegatedTools, Module.new)
132
+ end
133
+
134
+ def delegated_tool_const_name(as)
135
+ as.to_s.split(/[_\s]+/).reject(&:empty?).map { |part| part[0].upcase + part[1..] }.join
136
+ end
137
+
138
+ def build_delegated_tool_class(agent_class, description:, run_alias:, forward:)
139
+ tool_description = description || "Delegate to #{agent_class.name}."
140
+
141
+ Class.new(OmniAgent::Tool) do
142
+ description tool_description
143
+
144
+ input do
145
+ string :input, description: "Input/question to send to the delegated agent."
146
+ end
147
+
148
+ define_method(:execute) do |input:|
149
+ forwarded_context = OmniAgent::Agent.__send__(:filter_forwarded_context, context, forward)
150
+ OmniAgent::Agent.__send__(:run_delegated_agent, agent_class, input, run_alias, forwarded_context)
151
+ end
152
+ end
153
+ end
154
+
155
+ def filter_forwarded_context(context, forward)
156
+ return {} unless context.is_a?(Hash)
157
+ return context.dup if forward == true
158
+ return {} if forward.nil? || forward == false
159
+
160
+ keys = Array(forward).map(&:to_sym)
161
+ context.select { |key, _| keys.include?(key.to_sym) }
162
+ end
163
+
164
+ def run_delegated_agent(agent_class, input, run_alias, forwarded_context)
165
+ depth = (Thread.current[:omni_agent_delegation_depth] ||= 0)
166
+ max_depth = OmniAgent.configuration.max_delegation_depth
167
+
168
+ if depth >= max_depth
169
+ raise OmniAgent::MaxDelegationDepthError,
170
+ "Exceeded max_delegation_depth (#{max_depth}) while delegating to #{agent_class.name}."
171
+ end
172
+
173
+ Thread.current[:omni_agent_delegation_depth] = depth + 1
174
+ begin
175
+ entrypoint = run_alias || :run
176
+ agent_class.new.public_send(entrypoint, input, context: forwarded_context).answer
177
+ ensure
178
+ Thread.current[:omni_agent_delegation_depth] = depth
179
+ end
180
+ end
181
+
115
182
  def normalize_callbacks(callback_type, callbacks)
116
183
  raise ArgumentError, "#{callback_type} requires at least one method name" if callbacks.empty?
117
184
 
@@ -145,7 +212,11 @@ module OmniAgent
145
212
  @default_context = context_override || {}
146
213
  end
147
214
 
148
- def run(input, context: {}, prompt_method: nil)
215
+ def stream
216
+ OmniAgent::Streaming::Proxy.new(self)
217
+ end
218
+
219
+ def run(input, context: {}, prompt_method: nil, &stream)
149
220
  context = @default_context.merge(context || {})
150
221
  bind_context_instance_variables(context)
151
222
 
@@ -170,13 +241,14 @@ module OmniAgent
170
241
  "Increase OmniAgent.configuration.max_tool_iterations if more tool calls are expected."
171
242
  end
172
243
 
173
- response = provider.chat(messages: messages, tools: filtered_tools, **@chat_options)
244
+ response = provider.chat(messages: messages, tools: filtered_tools, stream: stream, **@chat_options)
174
245
 
175
246
  if response.content && !response.tool_calls?
176
247
  messages << { role: "assistant", content: response.content }
177
248
  set_after_generation_state(response: response, messages: messages, initial_messages_count: initial_messages_count)
178
249
  run_after_generation_callbacks(input: input, context: context, messages: messages, response: response)
179
250
  sync_context_from_instance_variables(context)
251
+ stream&.call(OmniAgent::Streaming::Event.done(response))
180
252
  return response
181
253
  end
182
254
 
@@ -196,6 +268,9 @@ module OmniAgent
196
268
 
197
269
  if tool_class
198
270
  tool_instance = tool_class.new
271
+ tool_instance.context = context if tool_instance.respond_to?(:context=)
272
+
273
+ stream&.call(OmniAgent::Streaming::Event.tool_call(name: tool_name, arguments: tool_args, id: tool_id))
199
274
 
200
275
  begin
201
276
  result = tool_instance.invoke(tool_args)
@@ -206,13 +281,16 @@ module OmniAgent
206
281
  name: tool_name,
207
282
  content: result.to_s
208
283
  }
284
+ stream&.call(OmniAgent::Streaming::Event.tool_result(name: tool_name, id: tool_id, content: result.to_s))
209
285
  rescue => e
286
+ error_content = "Error executing tool: #{e.message}"
210
287
  messages << {
211
288
  role: "tool",
212
289
  tool_call_id: tool_id,
213
290
  name: tool_name,
214
- content: "Error executing tool: #{e.message}"
291
+ content: error_content
215
292
  }
293
+ stream&.call(OmniAgent::Streaming::Event.tool_result(name: tool_name, id: tool_id, content: error_content, error: true))
216
294
  end
217
295
 
218
296
  should_stop_generation ||= tool_class.respond_to?(:stops_generation?) && tool_class.stops_generation?
@@ -231,6 +309,7 @@ module OmniAgent
231
309
  set_after_generation_state(response: response, messages: messages, initial_messages_count: initial_messages_count)
232
310
  run_after_generation_callbacks(input: input, context: context, messages: messages, response: response)
233
311
  sync_context_from_instance_variables(context)
312
+ stream&.call(OmniAgent::Streaming::Event.done(response))
234
313
  return response
235
314
  end
236
315
  end
@@ -238,12 +317,17 @@ module OmniAgent
238
317
 
239
318
  def available_tools
240
319
  tool_namespace = "#{self.class.name}::Tools".safe_constantize
241
- return [] unless tool_namespace
242
320
 
243
- tool_namespace.constants.filter_map do |const_name|
244
- const = tool_namespace.const_get(const_name)
245
- const if const.is_a?(Class) && const < OmniAgent::Tool
321
+ namespace_tools = if tool_namespace
322
+ tool_namespace.constants.filter_map do |const_name|
323
+ const = tool_namespace.const_get(const_name)
324
+ const if const.is_a?(Class) && const < OmniAgent::Tool
325
+ end
326
+ else
327
+ []
246
328
  end
329
+
330
+ namespace_tools + self.class.configured_delegated_tool_classes
247
331
  end
248
332
 
249
333
  private
@@ -1,7 +1,7 @@
1
1
  module OmniAgent
2
2
  class Configuration
3
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
+ :max_delegation_depth, :eval_judge_provider, :eval_judge_model, :eval_cache_enabled, :eval_cache_path
5
5
 
6
6
  def initialize
7
7
  @default_provider = :openai
@@ -9,6 +9,7 @@ module OmniAgent
9
9
  @max_retries = 3
10
10
  @retry_base_delay = 0.5
11
11
  @max_tool_iterations = 10
12
+ @max_delegation_depth = 5
12
13
  @eval_judge_provider = nil
13
14
  @eval_judge_model = nil
14
15
  @eval_cache_enabled = true
@@ -4,6 +4,7 @@ module OmniAgent
4
4
  class MissingDependencyError < Error; end
5
5
  class UnknownProviderError < Error; end
6
6
  class MaxToolIterationsError < Error; end
7
+ class MaxDelegationDepthError < Error; end
7
8
  class EvalAssertionError < Error; end
8
9
  end
9
10
 
@@ -11,5 +12,6 @@ module OmniAgent
11
12
  MissingDependencyError = Errors::MissingDependencyError
12
13
  UnknownProviderError = Errors::UnknownProviderError
13
14
  MaxToolIterationsError = Errors::MaxToolIterationsError
15
+ MaxDelegationDepthError = Errors::MaxDelegationDepthError
14
16
  EvalAssertionError = Errors::EvalAssertionError
15
17
  end
@@ -9,7 +9,7 @@ module OmniAgent
9
9
  @model = model || default_model
10
10
  end
11
11
 
12
- def chat(messages:, tools: [], **_options)
12
+ def chat(messages:, tools: [], stream: nil, **_options)
13
13
  raise NotImplementedError, "Providers must implement #chat"
14
14
  end
15
15
 
@@ -3,9 +3,16 @@ module OmniAgent
3
3
  class Mock < Base
4
4
  LOREM_IPSUM = "Lorem ipsum dolor sit amet, consectetur adipiscing elit."
5
5
 
6
- def chat(messages:, tools: [], **_options)
6
+ def chat(messages:, tools: [], stream: nil, **_options)
7
7
  validate_messages!(messages, allowed_roles: %i[system user assistant tool])
8
8
 
9
+ if stream
10
+ LOREM_IPSUM.split(" ").each_with_index do |word, index|
11
+ chunk = index.zero? ? word : " #{word}"
12
+ stream.call(OmniAgent::Streaming::Event.text(chunk))
13
+ end
14
+ end
15
+
9
16
  OmniAgent::Providers::Response.new(
10
17
  content: LOREM_IPSUM,
11
18
  raw_request: {
@@ -0,0 +1,30 @@
1
+ # lib/omni_agent/providers/ollama.rb
2
+ module OmniAgent
3
+ module Providers
4
+ class Ollama < OpenAI
5
+ protected
6
+
7
+ def provider_label
8
+ "Ollama"
9
+ end
10
+
11
+ def client
12
+ @client ||= ::OpenAI::Client.new(api_key: @api_key, base_url: base_url)
13
+ end
14
+
15
+ def default_api_key
16
+ ENV.fetch("OLLAMA_API_KEY", "ollama")
17
+ end
18
+
19
+ def default_model
20
+ ENV.fetch("OLLAMA_MODEL", "llama3.1")
21
+ end
22
+
23
+ private
24
+
25
+ def base_url
26
+ "#{ENV.fetch('OLLAMA_HOST', 'http://localhost:11434')}/v1"
27
+ end
28
+ end
29
+ end
30
+ end
@@ -12,7 +12,7 @@ module OmniAgent
12
12
  "Please add `gem 'openai'` to your Gemfile."
13
13
  end
14
14
 
15
- def chat(messages:, tools: [], **options)
15
+ def chat(messages:, tools: [], stream: nil, **options)
16
16
  validate_messages!(messages, allowed_roles: %i[system user assistant tool])
17
17
 
18
18
  messages = messages.map do |msg|
@@ -44,15 +44,23 @@ module OmniAgent
44
44
  payload[:tools] = openai_tools if openai_tools.any?
45
45
  payload.merge!(options) if options.any?
46
46
 
47
- response = with_retries { client.chat.completions.create(**payload) }
47
+ response = if stream
48
+ stream_chat(payload: payload, stream: stream)
49
+ else
50
+ with_retries { client.chat.completions.create(**payload) }
51
+ end
48
52
 
49
53
  parse_response(response, payload)
50
54
  rescue => e
51
- raise OmniAgent::Error, "Error during OpenAI chat: #{e.message}"
55
+ raise OmniAgent::Error, "Error during #{provider_label} chat: #{e.message}"
52
56
  end
53
57
 
54
58
  protected
55
59
 
60
+ def provider_label
61
+ "OpenAI"
62
+ end
63
+
56
64
  def retryable_error?(error)
57
65
  defined?(::OpenAI::Errors) &&
58
66
  (error.is_a?(::OpenAI::Errors::RateLimitError) ||
@@ -74,6 +82,18 @@ module OmniAgent
74
82
 
75
83
  private
76
84
 
85
+ def stream_chat(payload:, stream:)
86
+ chat_stream = with_retries { client.chat.completions.stream(**payload) }
87
+
88
+ chat_stream.each do |event|
89
+ next unless event.is_a?(::OpenAI::Helpers::Streaming::ChatContentDeltaEvent)
90
+
91
+ stream.call(OmniAgent::Streaming::Event.text(event.delta))
92
+ end
93
+
94
+ chat_stream.get_final_completion
95
+ end
96
+
77
97
  def format_tool(tool_class)
78
98
  schema = tool_class.json_schema.dup
79
99
 
@@ -4,6 +4,7 @@ module OmniAgent
4
4
  def self.registry
5
5
  {
6
6
  openai: OmniAgent::Providers::OpenAI,
7
+ ollama: OmniAgent::Providers::Ollama,
7
8
  mock: OmniAgent::Providers::Mock,
8
9
  mock_judge: OmniAgent::Providers::MockJudge
9
10
  }
@@ -0,0 +1,54 @@
1
+ module OmniAgent
2
+ module Streaming
3
+ class Event
4
+ attr_reader :type, :text, :tool_name, :tool_arguments, :tool_id, :content, :error, :response
5
+
6
+ def self.text(delta)
7
+ new(type: :text, text: delta)
8
+ end
9
+
10
+ def self.tool_call(name:, arguments:, id:)
11
+ new(type: :tool_call, tool_name: name, tool_arguments: arguments, tool_id: id)
12
+ end
13
+
14
+ def self.tool_result(name:, id:, content:, error: false)
15
+ new(type: :tool_result, tool_name: name, tool_id: id, content: content, error: error)
16
+ end
17
+
18
+ def self.done(response)
19
+ new(type: :done, response: response)
20
+ end
21
+
22
+ def initialize(type:, text: nil, tool_name: nil, tool_arguments: nil, tool_id: nil, content: nil, error: false, response: nil)
23
+ @type = type
24
+ @text = text
25
+ @tool_name = tool_name
26
+ @tool_arguments = tool_arguments
27
+ @tool_id = tool_id
28
+ @content = content
29
+ @error = error
30
+ @response = response
31
+ end
32
+
33
+ def text?
34
+ type == :text
35
+ end
36
+
37
+ def tool_call?
38
+ type == :tool_call
39
+ end
40
+
41
+ def tool_result?
42
+ type == :tool_result
43
+ end
44
+
45
+ def done?
46
+ type == :done
47
+ end
48
+
49
+ def error?
50
+ @error == true
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,17 @@
1
+ module OmniAgent
2
+ module Streaming
3
+ class Proxy
4
+ def initialize(agent)
5
+ @agent = agent
6
+ end
7
+
8
+ def method_missing(name, *args, **kwargs, &block)
9
+ @agent.public_send(name, *args, **kwargs, &block)
10
+ end
11
+
12
+ def respond_to_missing?(name, include_private = false)
13
+ @agent.respond_to?(name, include_private) || super
14
+ end
15
+ end
16
+ end
17
+ end
@@ -1,26 +1,46 @@
1
1
  module OmniAgent
2
2
  class Tool
3
3
  class SchemaBuilder
4
- attr_reader :properties, :required_fields
4
+ attr_reader :properties, :required_fields, :validators, :polymorphics
5
5
 
6
6
  def initialize
7
7
  @properties = {}
8
8
  @required_fields = []
9
+ @validators = {}
10
+ @polymorphics = {}
9
11
  end
10
12
 
11
- def string(name, description: nil, required: true)
12
- add_property(name, type: "string", description: description, required: required)
13
+ def string(name, description: nil, required: true, min_length: nil, max_length: nil, pattern: nil, format: nil, validate: nil)
14
+ constraints = {}
15
+ constraints[:minLength] = min_length if min_length
16
+ constraints[:maxLength] = max_length if max_length
17
+ constraints[:pattern] = pattern.is_a?(Regexp) ? pattern.source : pattern if pattern
18
+ constraints[:format] = format if format
19
+
20
+ add_property(name, type: "string", description: description, required: required, constraints: constraints, validate: validate)
21
+ end
22
+
23
+ def integer(name, description: nil, required: true, min: nil, max: nil, validate: nil)
24
+ constraints = {}
25
+ constraints[:minimum] = min if min
26
+ constraints[:maximum] = max if max
27
+
28
+ add_property(name, type: "integer", description: description, required: required, constraints: constraints, validate: validate)
13
29
  end
14
30
 
15
- def integer(name, description: nil, required: true)
16
- add_property(name, type: "integer", description: description, required: required)
31
+ def number(name, description: nil, required: true, min: nil, max: nil, validate: nil)
32
+ constraints = {}
33
+ constraints[:minimum] = min if min
34
+ constraints[:maximum] = max if max
35
+
36
+ add_property(name, type: "number", description: description, required: required, constraints: constraints, validate: validate)
17
37
  end
18
38
 
19
- def boolean(name, description: nil, required: true)
20
- add_property(name, type: "boolean", description: description, required: required)
39
+ def boolean(name, description: nil, required: true, validate: nil)
40
+ add_property(name, type: "boolean", description: description, required: required, validate: validate)
21
41
  end
22
42
 
23
- def array(name, items_type: nil, description: nil, required: true, &block)
43
+ def array(name, items_type: nil, description: nil, required: true, min_items: nil, max_items: nil, validate: nil, &block)
24
44
  property = { type: "array" }
25
45
  property[:description] = description if description
26
46
 
@@ -38,11 +58,15 @@ module OmniAgent
38
58
  property[:items] = { type: items_type || "string" }
39
59
  end
40
60
 
61
+ property[:minItems] = min_items if min_items
62
+ property[:maxItems] = max_items if max_items
63
+
41
64
  @properties[name] = property
42
65
  @required_fields << name.to_s if required
66
+ @validators[name] = validate if validate
43
67
  end
44
68
 
45
- def hash(name, description: nil, required: true, &block)
69
+ def hash(name, description: nil, required: true, validate: nil, &block)
46
70
  property = { type: "object" }
47
71
  property[:description] = description if description
48
72
 
@@ -59,16 +83,112 @@ module OmniAgent
59
83
 
60
84
  @properties[name] = property
61
85
  @required_fields << name.to_s if required
86
+ @validators[name] = validate if validate
87
+ end
88
+
89
+ def enum(name, values:, description: nil, required: true, validate: nil)
90
+ raise ArgumentError, "enum requires at least one value" if values.empty?
91
+
92
+ normalized_values = values.map { |v| v.is_a?(Symbol) ? v.to_s : v }
93
+ value_data_type = enum_data_type(normalized_values)
94
+
95
+ property = { type: value_data_type, enum: normalized_values }
96
+ property[:description] = description if description
97
+ @properties[name] = property
98
+ @required_fields << name.to_s if required
99
+ @validators[name] = validate if validate
100
+ end
101
+
102
+ def polymorphic(name, types: nil, description: nil, id_type: "string", required: true, resolve: false, &block)
103
+ if resolve && (types.nil? || types.empty?)
104
+ raise ArgumentError, "polymorphic resolve: true requires types:"
105
+ end
106
+
107
+ type_values = types
108
+ type_description = description ? "#{description} (type)" : nil
109
+ id_type_value = id_type
110
+ id_description = description ? "#{description} (id)" : nil
111
+
112
+ if block_given?
113
+ field_builder = PolymorphicFieldBuilder.new
114
+ field_builder.instance_eval(&block)
115
+
116
+ type_values = field_builder.type_values || type_values
117
+ type_description = field_builder.type_description || type_description
118
+ id_type_value = field_builder.id_type || id_type_value
119
+ id_description = field_builder.id_description || id_description
120
+ end
121
+
122
+ type_field = :"#{name}_type"
123
+ id_field = :"#{name}_id"
124
+ id_json_type = id_type_value.to_s == "integer" ? "integer" : "string"
125
+
126
+ if type_values && !type_values.empty?
127
+ enum(type_field, values: type_values, description: type_description, required: required)
128
+ else
129
+ string(type_field, description: type_description, required: required)
130
+ end
131
+
132
+ if id_json_type == "integer"
133
+ integer(id_field, description: id_description, required: required)
134
+ else
135
+ string(id_field, description: id_description, required: required)
136
+ end
137
+
138
+ @polymorphics[name] = {
139
+ type_field: type_field,
140
+ id_field: id_field,
141
+ types: type_values,
142
+ id_type: id_json_type,
143
+ resolve: resolve
144
+ }
62
145
  end
63
146
 
64
147
  private
65
148
 
66
- def add_property(name, type:, description:, required:)
149
+ def enum_data_type(values)
150
+ types = values.map { |v| json_type_for(v) }.uniq
151
+
152
+ if types.size > 1
153
+ raise ArgumentError, "enum values must all be the same type, got: #{types.join(', ')}"
154
+ end
155
+
156
+ types.first
157
+ end
158
+
159
+ def json_type_for(value)
160
+ case value
161
+ when Integer then "integer"
162
+ when Float then "number"
163
+ when String then "string"
164
+ when true, false then "boolean"
165
+ else
166
+ raise ArgumentError, "unsupported enum value type: #{value.class}"
167
+ end
168
+ end
169
+
170
+ def add_property(name, type:, description:, required:, constraints: {}, validate: nil)
67
171
  property = { type: type }
68
172
  property[:description] = description if description
173
+ property.merge!(constraints)
69
174
 
70
175
  @properties[name] = property
71
176
  @required_fields << name.to_s if required
177
+ @validators[name] = validate if validate
178
+ end
179
+
180
+ class PolymorphicFieldBuilder
181
+ attr_reader :type_values, :type_description, :id_type, :id_description
182
+
183
+ def type(values: nil, description: nil)
184
+ @type_values = values
185
+ @type_description = description
186
+ end
187
+
188
+ def id(type: "string", description: nil)
189
+ @id_type = type
190
+ @id_description = description
191
+ end
72
192
  end
73
193
  end
74
194
  end
@@ -28,6 +28,8 @@ module OmniAgent
28
28
 
29
29
  @properties = builder.properties
30
30
  @required = builder.required_fields
31
+ @validators = builder.validators
32
+ @polymorphics = builder.polymorphics
31
33
  end
32
34
  end
33
35
 
@@ -43,7 +45,15 @@ module OmniAgent
43
45
  def parse_arguments(arguments_hash)
44
46
  kwargs = arguments_hash.transform_keys(&:to_sym)
45
47
  valid_keys = (@properties || {}).keys.map(&:to_sym)
46
- kwargs.slice(*valid_keys)
48
+ filtered = kwargs.slice(*valid_keys)
49
+
50
+ validate_required!(filtered)
51
+ validate_enums!(filtered)
52
+ validate_constraints!(filtered)
53
+ run_custom_validators!(filtered)
54
+ resolve_polymorphics!(filtered)
55
+
56
+ filtered
47
57
  end
48
58
 
49
59
  def stops_generation(value = true)
@@ -56,6 +66,87 @@ module OmniAgent
56
66
 
57
67
  private
58
68
 
69
+ def validate_required!(kwargs)
70
+ missing = (@required || []).map(&:to_sym) - kwargs.keys
71
+ return if missing.empty?
72
+
73
+ raise ArgumentError, "missing required argument(s): #{missing.join(', ')}"
74
+ end
75
+
76
+ def validate_enums!(kwargs)
77
+ (@properties || {}).each do |name, property|
78
+ allowed_values = property[:enum]
79
+ next unless allowed_values
80
+
81
+ key = name.to_sym
82
+ next unless kwargs.key?(key)
83
+
84
+ unless allowed_values.include?(kwargs[key])
85
+ raise ArgumentError, "invalid value for #{name}: #{kwargs[key].inspect} (must be one of: #{allowed_values.join(', ')})"
86
+ end
87
+ end
88
+ end
89
+
90
+ def validate_constraints!(kwargs)
91
+ (@properties || {}).each do |name, property|
92
+ key = name.to_sym
93
+ next unless kwargs.key?(key)
94
+
95
+ check_constraints!(name, property, kwargs[key])
96
+ end
97
+ end
98
+
99
+ def check_constraints!(name, property, value)
100
+ if property[:minLength] && value.is_a?(String) && value.length < property[:minLength]
101
+ raise ArgumentError, "#{name} must be at least #{property[:minLength]} characters"
102
+ end
103
+ if property[:maxLength] && value.is_a?(String) && value.length > property[:maxLength]
104
+ raise ArgumentError, "#{name} must be at most #{property[:maxLength]} characters"
105
+ end
106
+ if property[:pattern] && value.is_a?(String) && !value.match?(Regexp.new(property[:pattern]))
107
+ raise ArgumentError, "#{name} does not match required pattern #{property[:pattern]}"
108
+ end
109
+ if property[:minimum] && value.is_a?(Numeric) && value < property[:minimum]
110
+ raise ArgumentError, "#{name} must be >= #{property[:minimum]}"
111
+ end
112
+ if property[:maximum] && value.is_a?(Numeric) && value > property[:maximum]
113
+ raise ArgumentError, "#{name} must be <= #{property[:maximum]}"
114
+ end
115
+ if property[:minItems] && value.is_a?(Array) && value.length < property[:minItems]
116
+ raise ArgumentError, "#{name} must have at least #{property[:minItems]} items"
117
+ end
118
+ if property[:maxItems] && value.is_a?(Array) && value.length > property[:maxItems]
119
+ raise ArgumentError, "#{name} must have at most #{property[:maxItems]} items"
120
+ end
121
+ end
122
+
123
+ def run_custom_validators!(kwargs)
124
+ (@validators || {}).each do |name, validator|
125
+ key = name.to_sym
126
+ next unless kwargs.key?(key)
127
+
128
+ result = validator.call(kwargs[key])
129
+ raise ArgumentError, "invalid value for #{name}" if result == false
130
+ end
131
+ end
132
+
133
+ def resolve_polymorphics!(kwargs)
134
+ (@polymorphics || {}).each do |name, group|
135
+ next unless group[:resolve]
136
+
137
+ type_field = group[:type_field]
138
+ id_field = group[:id_field]
139
+ next unless kwargs.key?(type_field) && kwargs.key?(id_field)
140
+
141
+ type_value = kwargs.delete(type_field)
142
+ id_value = kwargs.delete(id_field)
143
+
144
+ klass = Object.const_get(type_value.to_s)
145
+ record = klass.find(id_value)
146
+ kwargs[name.to_sym] = record
147
+ end
148
+ end
149
+
59
150
  def normalize_tags(tag_names)
60
151
  raise ArgumentError, "tags requires at least one tag" if tag_names.empty?
61
152
 
@@ -69,6 +160,12 @@ module OmniAgent
69
160
  end
70
161
  end
71
162
 
163
+ attr_accessor :context
164
+
165
+ def initialize
166
+ @context = {}
167
+ end
168
+
72
169
  def invoke(arguments_hash)
73
170
  filtered_kwargs = self.class.parse_arguments(arguments_hash)
74
171
  execute(**filtered_kwargs)
@@ -1,3 +1,3 @@
1
1
  module OmniAgent
2
- VERSION = "0.1.7"
2
+ VERSION = "0.1.9"
3
3
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omni_agent
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.7
4
+ version: 0.1.9
5
5
  platform: ruby
6
6
  authors:
7
7
  - ACR1209
@@ -85,9 +85,12 @@ files:
85
85
  - lib/omni_agent/providers/base.rb
86
86
  - lib/omni_agent/providers/mock.rb
87
87
  - lib/omni_agent/providers/mock_judge.rb
88
+ - lib/omni_agent/providers/ollama.rb
88
89
  - lib/omni_agent/providers/openai.rb
89
90
  - lib/omni_agent/providers/response.rb
90
91
  - lib/omni_agent/railtie.rb
92
+ - lib/omni_agent/streaming/event.rb
93
+ - lib/omni_agent/streaming/proxy.rb
91
94
  - lib/omni_agent/tasks/omni_agent_tasks.rake
92
95
  - lib/omni_agent/tool.rb
93
96
  - lib/omni_agent/tool/schema_builder.rb