action_ai 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 026dc5bbd0ff082b147fc05e6e9e2784d0f89da393249857c383e38da57c79ac
4
- data.tar.gz: ffeec65357f212fd305ad480a7a5f35c2b96ba9880acfcafbdaf437885f532b0
3
+ metadata.gz: 626b0d7eb4998d7c208dc99eb8e60932a3006988176bbd784935f1e00437b485
4
+ data.tar.gz: 97512e0c8fd015af6235d61b31ed36e7030702ddaf4b436b18b462b39013bd55
5
5
  SHA512:
6
- metadata.gz: 742b0b2a62dc5ab713b5fa3cb64aa255a197835b0ef027f6e79bc0903f4c3497f1b13836a2792dfd75852423d08dd7fd65dbc81108c92d143b6f812a64ac9172
7
- data.tar.gz: 9999ac05bfd4eca661dbe84156b2d06a8a6ab0006ae64d7bc2d2f0c334b125e62ea8ed4f03ba24c6248f90b0d305ca9848a10cfa19040c06815face10bdc251a
6
+ metadata.gz: 5721c21b4234b75cc9afce08eef6e5df89b4cb3364b75df8bbc3735c9ed3735d19fe6186d1893b678e5bfb9cddcbe15511909babd0ab7851f7663fd2c4fb1011
7
+ data.tar.gz: 025dec0b60c2b398450fe31174789df35689bef9a48a5c77627619c9df3c6fd2de62b153c968430a2135b0ca2cc9ee73e29049e502e5b34acbbd1217ba523fa4
data/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+
6
+ ## [0.3.0] - 2026-10-13
7
+
8
+ ### Changed
9
+
10
+ - Moved to RubyLLM 2.
11
+ - Dropped `echo` test model in favor of `echo` protocol.
12
+
13
+ ### Added
14
+
15
+ #### Structured output
16
+
17
+ - `.schema` class method on any `ActiveModel::API` model, returning a cached `Schematist::Schema` instance derived from the model’s attribute types and constraints.
18
+ - Include Active Record associations as nested schemas via objects/arrays.
19
+ Consider associations registered for _nested attributes_ only.
20
+ - Prefer custom schemas over inferred ones.
21
+ Define them as `<ModelName>Schema < ActionAI::Schema` classes under `app/schemas`.
22
+ - `#returns(ModelClass)` instance method in agent actions: configures structured output via `chat.with_schema` and decorates the resulting `RubyLLM::Message` with `#object` returning a model instance.
23
+ - Use Strong Parameters to decorate returned JSON for security reasons.
24
+ AI model’s output shouldn’t be considered a trusted input.
25
+ - `#returns([ModelClass])` array syntax: configures an array schema and `#object` returns an array of model instances.
26
+
27
+
28
+ ## [0.2.0] - 2026-06-30
29
+
30
+ ### Added
31
+
32
+ - Implicit `ask` behavior for AI actions without explicit prompts, matching Action Controller-style ergonomics.
33
+ - Action chaining, enabling users to construct complex AI workflows from discrete jobs.
34
+
35
+ ### Fixed
36
+
37
+ - `echo` test model to support multiple chat interactions.
38
+
39
+
5
40
  ## [0.1.0] - 2026-05-05
6
41
 
7
42
  Refactored from Action Mailer.
data/README.rdoc CHANGED
@@ -16,6 +16,8 @@ shape for AI interactions rather than building an unrelated API from scratch.
16
16
 
17
17
  The framework works by initializing any instance variables you want to be
18
18
  available in the prompt template.
19
+ Like +render+ in Action Controller, the implicit +ask+ will be triggered
20
+ automatically at the end of the action unless you have already called it.
19
21
 
20
22
  This can be as simple as:
21
23
 
@@ -25,10 +27,16 @@ This can be as simple as:
25
27
  def code(task, language)
26
28
  @task = task
27
29
  @language = language
28
- ask
29
30
  end
30
31
  end
31
32
 
33
+ If you need to customize the prompt execution (e.g., pass options or a custom prompt),
34
+ you can call +ask+ explicitly:
35
+
36
+ def run(spec_file)
37
+ ask "Run the attached spec", with: spec_file
38
+ end
39
+
32
40
  After the action method completes, the framework will automatically:
33
41
  1. Render the prompt from the corresponding template (e.g., +app/ai/prompts/generator/code.erb+)
34
42
  2. Send it to the configured AI model via +ask+
@@ -64,6 +72,22 @@ Or you can just chain the methods together like:
64
72
 
65
73
  Generator.code("Parse CSV and dedupe", :ruby).content # Returns AI's response for the prompt
66
74
 
75
+ You can also chain multiple agent actions to compose a small workflow before reading the final response:
76
+
77
+ class WorkflowAgent < ApplicationAI
78
+ def collect(topic)
79
+ @topic = topic
80
+ ask "Collect #{@topic}"
81
+ end
82
+
83
+ def refine(style:)
84
+ ask "Refine #{@topic} as #{style}"
85
+ end
86
+ end
87
+
88
+ WorkflowAgent.collect("release notes").refine(style: "bullet list").content
89
+ # => collected release notes refined as bullet list
90
+
67
91
  == Setting defaults
68
92
 
69
93
  It is possible to set default values that will be used in every method in your
@@ -93,6 +117,110 @@ The Agent class has the full list of configuration options. Here's an example:
93
117
  }
94
118
 
95
119
 
120
+ == Structured output (data schemas)
121
+
122
+ Action AI can instruct the model to return structured JSON that maps directly
123
+ to an ActiveModel model. Two features cooperate to make this work:
124
+
125
+ === +.schema+ on ActiveModel models
126
+
127
+ Any model that includes +ActiveModel::API+ automatically gains a +.schema+
128
+ class method that returns a +Schematist::Schema+ instance derived from the model's
129
+ attribute types.
130
+
131
+ class Person
132
+ include ActiveModel::Model
133
+ include ActiveModel::Attributes
134
+
135
+ attribute :name, :string
136
+ attribute :age, :integer
137
+ attribute :score, :float
138
+ end
139
+
140
+ Person.schema # => a Schematist::Schema instance
141
+ Person.schema.new.to_json_schema # => {name: "Person", schema: {...}}
142
+
143
+ Supported type mappings:
144
+
145
+ * +:string+, +:immutable_string+, +:text+ → JSON +string+
146
+ * +:integer+, +:big_integer+ → JSON +integer+
147
+ * +:float+, +:decimal+, +:big_decimal+ → JSON +number+
148
+ * +:boolean+ → JSON +boolean+
149
+ * +:date+, +:datetime+, +:time+ → JSON +string+ with +format+ set to
150
+ +"date"+, +"date-time"+, or +"time"+ respectively
151
+ * +:binary+ → JSON +string+ with +contentEncoding+ set to +"base64"+
152
+
153
+ Other types are not included in the schema.
154
+
155
+ Active Record associations are included as nested schemas via +object+/+array+.
156
+ To prevent unnescessary nesting or circular dependencies, the schema will include
157
+ associations registered for _nested attributes_ (via +accepts_nested_attributes_for+) only.
158
+
159
+ === +returns+ in agent actions
160
+
161
+ Call +returns ModelClass+ inside an action method to declare the expected
162
+ return type. Action AI will:
163
+
164
+ 1. Configure the chat to request output matching the model's JSON schema
165
+ (+chat.with_schema ModelClass.schema+).
166
+ 2. Decorate the resulting +RubyLLM::Message+ with Strong Parameters.
167
+ 3. Construct a model instance accessible via +#object+.
168
+
169
+ class Extractor < ApplicationAI
170
+ def person(text)
171
+ @text = text
172
+
173
+ returns Person do |params|
174
+ params.permit(:name, :age)
175
+ end
176
+ end
177
+ end
178
+
179
+ result = Extractor.person("Alice is 30 years old")
180
+ result.content # => '{"name":"Alice","age":30}'
181
+ result.parsed # => {"name" => "Alice", "age" => 30}
182
+ result.object # => #<Person name="Alice" age=30>
183
+
184
+ Pass the class wrapped in an array to declare an array return type:
185
+
186
+ class Extractor < ApplicationAI
187
+ def people(text)
188
+ @text = text
189
+
190
+ returns [Person] do |params|
191
+ params.permit(:name, :age)
192
+ end
193
+ end
194
+ end
195
+
196
+ result = Extractor.people("Alice is 30 and Bob is 25")
197
+ result.content # => '[{"name":"Alice","age":30},{"name":"Bob","age":25}]'
198
+ result.parsed # => [{"name" => "Alice", "age" => 30}, {"name" => "Bob", "age" => 25}]
199
+ result.object # => [#<Person name="Alice" age=30>, #<Person name="Bob" age=25>]
200
+
201
+ The schema is built from the model's declared attributes and cached.
202
+
203
+ === Custom schemas
204
+
205
+ If an inferred schema is not sufficient, define a +Schematist::Schema+
206
+ subclass under +app/schemas+ with the model's full class name followed by
207
+ +Schema+. Custom schemas are looked up first; models without a matching class
208
+ continue to use the inferred schema.
209
+
210
+ # app/schemas/person_schema.rb
211
+ class PersonSchema < ActionAI::Schema
212
+ string :name
213
+ integer :age, minimum: 0
214
+ end
215
+
216
+ For namespaced models, mirror the namespace in +app/schemas+:
217
+
218
+ # app/schemas/admin/person_schema.rb
219
+ class Admin::PersonSchema < ActionAI::Schema
220
+ string :name
221
+ end
222
+
223
+
96
224
  == Download and installation
97
225
 
98
226
  The latest version of Action AI can be installed with RubyGems:
@@ -278,6 +278,8 @@ module ActionAI
278
278
  # <tt>execution_job</tt>. Agents can set this to use a custom queue name.
279
279
  class Agent < AbstractController::Base
280
280
  include Callbacks
281
+ include ImplicitInteraction
282
+ prepend StructuredOutput
281
283
  include QueuedExecution
282
284
  include Rescuable
283
285
  include Parameterized
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAI
4
+ # # Action AI Implicit Interaction
5
+ #
6
+ # Handles implicit interactions for an agent action that does not explicitly
7
+ # respond with `ask`.
8
+ #
9
+ # If no explicit `ask` is performed by the action, the implicit response is
10
+ # to call `ask` with the default prompt rendering.
11
+ module ImplicitInteraction
12
+ def send_action(...)
13
+ super
14
+ .tap { default_interaction unless performed? }
15
+ end
16
+
17
+ def default_interaction = ask
18
+ end
19
+ end
@@ -77,6 +77,23 @@ module ActionAI
77
77
  # end
78
78
  def later(...) = enqueue_execution(...)
79
79
 
80
+ ruby2_keywords def method_missing(method, *args, &)
81
+ case method.name
82
+ when *@agent_class.action_methods
83
+ processed_agent.process(@action = method, *(@args = args), &)
84
+ self
85
+ else
86
+ super
87
+ end
88
+ end
89
+
90
+ def respond_to_missing?(method, include_private = false)
91
+ [
92
+ method.name.in?(@agent_class.action_methods),
93
+ super,
94
+ ].any?
95
+ end
96
+
80
97
  private
81
98
  # Returns the processed Agent instance. We keep this instance
82
99
  # on hand so we can run callbacks and delegate exception handling to it.
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "action_ai/refinements/regexp"
4
+
5
+ module ActionAI
6
+ module ModelSchema
7
+ # Extends schema inference with Active Model–specific behavior.
8
+ #
9
+ # Overrides the base inference methods to extract constraint metadata
10
+ # from Active Model validation declarations (presence, inclusion,
11
+ # numericality, format, length, comparison, and acceptance).
12
+ module Inference::ActiveModel
13
+ using Refinements::Regexp
14
+
15
+ private
16
+
17
+ # Determines whether an attribute is required by checking for
18
+ # a presence validator, in addition to any other sources.
19
+ def attribute_required? name
20
+ super { [
21
+ attribute_validators(name, :presence)
22
+ .any?,
23
+
24
+ *(yield if block_given?)
25
+ ] }
26
+ end
27
+
28
+ # Infers enum values from inclusion and numericality validators
29
+ # that declare an `:in` option with multiple values.
30
+ def attribute_enum name
31
+ super { [
32
+ *[
33
+ *attribute_validators(name, :numericality),
34
+ *attribute_validators(name, :inclusion),
35
+ ].filter_map { it.options[:in] },
36
+
37
+ *(yield if block_given?)
38
+ ] }
39
+ end
40
+
41
+ # Infers a constant value from validators that restrict the
42
+ # attribute to a single value: comparison/equality, single-item
43
+ # inclusion, or acceptance validators without `allow_nil`.
44
+ def attribute_const name
45
+ super { [
46
+ *[
47
+ *attribute_validators(name, :comparison),
48
+ *attribute_validators(name, :numericality),
49
+ ].filter_map { it.options[:equal_to] },
50
+
51
+ *[
52
+ *attribute_validators(name, :inclusion),
53
+ *attribute_validators(name, :numericality),
54
+ ].filter_map { it.options[:in] }
55
+ .select(&:one?)
56
+ .map(&:first),
57
+
58
+ *attribute_validators(name, :acceptance)
59
+ .reject { it.options[:allow_nil] }
60
+ .presence
61
+ &.any?,
62
+
63
+ *(yield if block_given?)
64
+ ] }
65
+ end
66
+
67
+ # Infers a regex pattern from format validators, combining
68
+ # multiple patterns into a single expression.
69
+ # Validators with `:without` are negated.
70
+ def attribute_format name
71
+ super { [
72
+ *attribute_validators(name, :format)
73
+ .map(&:options)
74
+ .filter_map { it[:with] or ~it[:without] rescue nil },
75
+
76
+ *(yield if block_given?)
77
+ ] }
78
+ end
79
+
80
+ # Infers minimum and maximum string length from length validators.
81
+ def attribute_length name
82
+ super { [
83
+ *attribute_validators(name, :length)
84
+ .map(&:options)
85
+ .map { {
86
+ minimum: it.values_at(:is, :minimum).find(&:itself),
87
+ maximum: it.values_at(:is, :maximum).find(&:itself),
88
+ } },
89
+
90
+ *(yield if block_given?)
91
+ ] }
92
+ end
93
+
94
+ # Infers numeric boundaries (minimum, maximum, greater_than,
95
+ # less_than, multiple_of) from comparison and numericality validators.
96
+ def attribute_boundaries name
97
+ super { [
98
+ *[
99
+ *attribute_validators(name, :comparison),
100
+ *attribute_validators(name, :numericality),
101
+ ].map(&:options)
102
+ .map { {
103
+ minimum: it[:greater_than_or_equal_to],
104
+ greater_than: it[:greater_than],
105
+ less_than: it[:less_than],
106
+ maximum: it[:less_than_or_equal_to],
107
+
108
+ multiple_of: it[:multiple_of],
109
+ } },
110
+
111
+ *(yield if block_given?)
112
+ ] }
113
+ end
114
+
115
+ # Returns the validators for an attribute, optionally filtered
116
+ # by validator type (e.g., `:presence`, `:inclusion`, `:format`).
117
+ def attribute_validators name, type = nil
118
+ validators_on(name)
119
+ .grep(ActiveModel::Validations.const_get "#{type.to_s.camelize}Validator")
120
+ end
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module ActionAI
4
+ module ModelSchema
5
+ # Extends schema inference with Active Record–specific behavior.
6
+ #
7
+ # Overrides the base inference methods to incorporate database column
8
+ # metadata (nullability, defaults, limits, decimal scale) and model-level
9
+ # features (defined enums, STI inheritance).
10
+ #
11
+ # Unlike the Active Model inference, which relies solely on validation
12
+ # declarations, this module reads the actual column schema to produce
13
+ # more precise constraints.
14
+ module Inference::ActiveRecord
15
+ include Memery
16
+
17
+ def foreign_keys = reflect_on_all_associations(:belongs_to)
18
+ .map(&:foreign_key)
19
+
20
+ def cache_columns = [
21
+ *(reflect_on_all_associations
22
+ .filter_map(&:counter_cache_column)
23
+ ),
24
+ ]
25
+
26
+ private
27
+
28
+ # Excludes DB-managed columns, since they are not typically part of the
29
+ # model’s input data.
30
+ def schema_attributes = super
31
+ .except(*[
32
+ *primary_key,
33
+ *foreign_keys.flatten,
34
+ :created_at, :updated_at,
35
+ locking_column,
36
+ *cache_columns,
37
+ ].map(&:to_sym))
38
+ .merge(schema_reflections)
39
+
40
+ memoize def schema_reflections = nested_attributes_options
41
+ .filter_map { |name, options|
42
+ reflection = reflect_on_association name
43
+
44
+ [ name, {
45
+ type: reflection.collection? ? :array : :object,
46
+ of: reflection.klass.schema,
47
+ description: try(:human_attribute_name, name),
48
+ required: reflection_required?(name),
49
+ max_items: options[:limit],
50
+ }.compact ]
51
+ }
52
+ .to_h
53
+
54
+ # Determines whether an attribute is required by checking the column’s
55
+ # null constraint, in addition to any Active Model checks.
56
+ def attribute_required? name
57
+ super { [
58
+ !column_for_attribute(name).null,
59
+
60
+ *(yield if block_given?)
61
+ ] }
62
+ end
63
+
64
+ # Returns the default value for an attribute from the column defaults.
65
+ def attribute_default name
66
+ column_defaults[name.to_s]
67
+ end
68
+
69
+ # Infers enum values from Active Record `enum` declarations and,
70
+ # for the inheritance column, includes valid STI class names (the base
71
+ # class and all descendants).
72
+ def attribute_enum name
73
+ super { [
74
+ defined_enums[name],
75
+
76
+ ([self, *descendants]
77
+ .map(&:name) if
78
+ name.to_s == inheritance_column),
79
+
80
+ *(yield if block_given?)
81
+ ] }
82
+ end
83
+
84
+ # Infers a constant value from acceptance validators that do not allow
85
+ # nil, but only for non-nullable columns.
86
+ def attribute_const name
87
+ super { [
88
+ *attribute_validators(name, :acceptance)
89
+ &.select { it.options[:allow_nil] }
90
+ .presence
91
+ &.any?,
92
+
93
+ *(yield if block_given?)
94
+ ] unless column_for_attribute(name).null }
95
+ end
96
+
97
+ # Infers the maximum string length from the column’s database limit.
98
+ def attribute_length name
99
+ super { [
100
+ {
101
+ maximum: column_for_attribute(name).limit
102
+ },
103
+
104
+ *(yield if block_given?)
105
+ ] }
106
+ end
107
+
108
+ # For decimal columns, sets `multiple_of` based on the column scale
109
+ # (e.g., scale 2 → multiple_of 0.01), ensuring the generated schema
110
+ # rejects values with more decimal places than the column supports.
111
+ def attribute_boundaries name
112
+ super { [
113
+ {
114
+ multiple_of: column_for_attribute(name)
115
+ .then.detect { it.type == :decimal }
116
+ &.then { 10 ** (-it.scale) },
117
+ },
118
+
119
+ *(yield if block_given?)
120
+ ] }
121
+ end
122
+
123
+ def reflection_required? name
124
+ reflect_on_association(name)&.then do
125
+ case it
126
+ when ActiveRecord::Reflection::BelongsToReflection
127
+ not it.options[:optional]
128
+ when ActiveRecord::Reflection::HasOneReflection
129
+ it.options[:required] || false
130
+ else
131
+ false
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end