ask-rails 0.6.0 → 0.8.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.
Files changed (29) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +69 -0
  3. data/README.md +161 -17
  4. data/lib/ask/actions/backend.rb +66 -0
  5. data/lib/ask/actions/context.rb +26 -0
  6. data/lib/ask/actions/result.rb +43 -0
  7. data/lib/ask/actions.rb +44 -0
  8. data/lib/ask/rails/railtie.rb +0 -3
  9. data/lib/ask/rails/state.rb +91 -0
  10. data/lib/ask/rails/version.rb +1 -1
  11. data/lib/ask/rails.rb +2 -0
  12. data/lib/generators/ask/action/templates/action.rb +24 -0
  13. data/lib/generators/ask/action_generator.rb +59 -0
  14. data/lib/generators/ask/agent/templates/agent.rb +18 -0
  15. data/lib/generators/ask/agent_generator.rb +17 -0
  16. data/lib/generators/ask/install/templates/application_action.rb +33 -0
  17. data/lib/generators/ask/install/templates/application_workflow.rb +23 -0
  18. data/lib/generators/ask/install/templates/audit_log_migration.rb +4 -1
  19. data/lib/generators/ask/install/templates/initializer.rb +28 -4
  20. data/lib/generators/ask/install/templates/state_migration.rb +6 -2
  21. data/lib/generators/ask/install_generator.rb +39 -11
  22. data/lib/generators/ask/workflow/templates/workflow.rb +28 -0
  23. data/lib/generators/ask/workflow_generator.rb +35 -0
  24. metadata +29 -6
  25. data/lib/ask/rails/generators/install/install_generator.rb +0 -45
  26. data/lib/ask/rails/generators/install/templates/audit_log_migration.rb +0 -19
  27. data/lib/ask/rails/generators/install/templates/initializer.rb +0 -27
  28. data/lib/ask/rails/generators/install/templates/state_migration.rb +0 -18
  29. /data/lib/{ask/rails/generators → generators/ask}/install/templates/application_agent.rb +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 87296c9e940bcd34d48e640c3a13c3e3d634b57abf253c434d0919b1dc3388bc
4
- data.tar.gz: b8edd3def1dfa96b41d6af0dd842bebb724ac9909c8728d5c8a193a3956e83be
3
+ metadata.gz: 2abe315f91db647a828734bbb14a557ba8feac60a35e99185f83545704e721cc
4
+ data.tar.gz: be8595aaa795abec9f74d8bee89e44cdfbb0da792b11e944eb7294529e7bede1
5
5
  SHA512:
6
- metadata.gz: 4533e9675014317d48c973bcfa23dd30a996d24f9f5e734060c37250735cdf0569df6b38ed2bb60c3a6c6d0dfdb8cf814869d5b628a9f0e37a8f5f91616f8347
7
- data.tar.gz: 13fc4f80420ad209ab5e531d254883858acb6e8ac28d7806bac514afcc58c278df79a3c7026dfa69a49f20fbd70fa7ef0ac5ecada3255afedb3d23c338dcff2d
6
+ metadata.gz: ccc55bc26de71c3382af0dc38ab825e1886ddfcfaf030715bf4dc3ff5c0016fe50dcaa3eb0c0f7dd630cbfeb3f35880cc0d4a28c9f1291ab4822872aa9501336
7
+ data.tar.gz: 0bab3d0dab2a293485984deb1acf767c14e63cc9cc8995fab0f2a0e70e276d6e847a2573d41cd535632033f1d151dfbe6cb3118a9c0763ac5c08eacf0593bef7
data/CHANGELOG.md CHANGED
@@ -1,3 +1,72 @@
1
+ ## [0.8.0] — 2026-07-31
2
+
3
+ ### Added
4
+
5
+ - **`Ask::Actions` — action convention** — user-facing operations callable from any channel (web, Slack, voice) by name:
6
+
7
+ ```ruby
8
+ Ask::Actions.dispatch(action: "chats.create", context: context, params: {})
9
+ ```
10
+
11
+ - `Ask::Actions::Result` — uniform response shape (`ok`/`error`, `message`, `data`, `code`)
12
+ - `Ask::Actions::Context` — per-request context bag; attributes become accessors
13
+ - `Ask::Actions::Backend` — dispatcher with explicit registration (`Ask::Actions.register`) and convention resolution (`"chats.create"` → `Chats::Create` via Zeitwerk). Unknown actions raise `Ask::Actions::Backend::UnknownAction` with guidance.
14
+
15
+ - **`ask:action NAME [NAMESPACE]` generator** — scaffolds actions:
16
+
17
+ ```bash
18
+ rails generate ask:action create_workspace # app/actions/create_workspace.rb
19
+ rails generate ask:action chats create # app/actions/chats/create.rb → "chats.create"
20
+ ```
21
+
22
+ - **Actions in install generator** — `ask:install` now creates `app/actions/` and `app/actions/application_action.rb` (base class with `call(context:, params:)` → `#call`). The initializer documents the actions block with a registration example.
23
+
24
+ ### Tested
25
+
26
+ - 25 new tests (Result, Context, Backend, generators) — 48 runs, 141 assertions, 0 failures.
27
+ - End-to-end smoke test in a fresh Rails 8.1 app: `ask:install` + `ask:action` (namespaced and top-level) run; `Ask::Actions.dispatch` resolves both via convention; unknown actions raise with a helpful message.
28
+
29
+ ## [0.7.0] — 2026-07-31
30
+
31
+ ### Added
32
+
33
+ - **`ask:agent NAME` generator** — scaffolds an individual agent under `app/agents/`:
34
+
35
+ ```bash
36
+ rails generate ask:agent support_bot
37
+ ```
38
+
39
+ - **`ask:workflow NAME` generator** — scaffolds a workflow module under `app/workflows/<name>/` with `workflow.rb` and `steps/` directory. Requires ask-graph; aborts with a helpful message if it's not installed:
40
+
41
+ ```bash
42
+ rails generate ask:workflow notify_customer
43
+ ```
44
+
45
+ - **`Ask::Rails::State`** — ActiveRecord-backed state adapter for the `ask_state` table. Used by ask-graph for workflow checkpoints. Works with any database adapter (PostgreSQL, MySQL, SQLite). Implements key-value storage plus ordered lists (for agent session indexes).
46
+
47
+ - **ask-graph support in install generator** — `ask:install` now creates `app/workflows/application_workflow.rb` and the workflows directory when ask-graph is installed. Pass `--skip-graph` to skip. The initializer's graph block is only generated when ask-graph is present:
48
+
49
+ ```ruby
50
+ if defined?(Ask::Graph)
51
+ Ask::Graph.storage = Ask::Rails::State.new
52
+ end
53
+ ```
54
+
55
+ ### Changed
56
+
57
+ - **Generators consolidated under `lib/generators/ask/`** — all three generators (`ask:install`, `ask:agent`, `ask:workflow`) now live at the standard Rails discovery path. The old railtie-registered `ask:rails:*` names and the duplicate `lib/generators/ask/install_generator.rb` were removed. No duplicated work between generators: `ask:install` owns one-time setup, `ask:agent`/`ask:workflow` own per-component scaffolding.
58
+
59
+ - **Initializer fixed to use real APIs** — removed references to non-existent `Ask::State::PostgreSQL` and `config.state`. The agent block now only configures what ask-agent supports (audit log, default model).
60
+
61
+ - **State migration uses `t.json` instead of `t.jsonb`** — works on PostgreSQL, MySQL, and SQLite.
62
+
63
+ - **`migration_version` fix** — generated migrations now produce `ActiveRecord::Migration[8.1]` instead of the broken `Migration[[8.1]]`.
64
+
65
+ ### Tested
66
+
67
+ - 24 tests, 67 assertions, 0 failures — including 13 tests for `Ask::Rails::State` against an in-memory SQLite database.
68
+ - End-to-end smoke test in a fresh Rails app: `ask:install`, `ask:agent`, `ask:workflow` all run; migrations execute; a workflow checkpoint survives a second run via `Ask::Rails::State`.
69
+
1
70
  ## [0.6.0] — 2026-07-29
2
71
 
3
72
  ### Added
data/README.md CHANGED
@@ -1,36 +1,121 @@
1
1
  # ask-rails
2
2
 
3
- Rails integration for the [ask-rb](https://github.com/ask-rb) ecosystem. Provides generators, file conventions, and railtie for using AI agents in your Rails app.
3
+ Rails integration for the [ask-rb](https://github.com/ask-rb) ecosystem. Provides generators, file conventions, and a railtie for building agents and workflows in your Rails app.
4
4
 
5
5
  ## Installation
6
6
 
7
+ Add to your Gemfile:
8
+
9
+ ```ruby
10
+ gem "ask-rails"
11
+ gem "ask-graph" # optional — add only if you use workflows
12
+ ```
13
+
14
+ Run the installer:
15
+
7
16
  ```bash
8
- bundle add ask-rails
17
+ bundle install
9
18
  rails generate ask:install
10
19
  ```
11
20
 
12
21
  This creates:
13
- - `config/initializers/ask.rb` — agent configuration
14
- - `app/agents/application_agent.rb` — base class for your agents
15
- - `app/agents/` — directory for agent definitions
16
22
 
17
- ## Usage
23
+ | File | Purpose |
24
+ |---|---|
25
+ | `config/initializers/ask.rb` | Agent + workflow configuration (graph block only generated when ask-graph is installed) |
26
+ | `app/agents/application_agent.rb` | Base class for your agents |
27
+ | `app/actions/application_action.rb` | Base class for your actions |
28
+ | `app/workflows/application_workflow.rb` | Base class for your workflows (only with ask-graph) |
29
+ | `db/migrate/*_create_ask_state.rb` | Shared key-value state table — workflow checkpoints and agent session indexes |
30
+ | `db/migrate/*_create_ask_audit_logs.rb` | Agent session audit log |
31
+
32
+ The `ask_state` table is backed by `Ask::Rails::State` — an ActiveRecord adapter that works with any database (PostgreSQL, MySQL, SQLite). Workflow checkpoints and agent session persistence share the same table, keyed by convention.
33
+
34
+ ## Generators
35
+
36
+ ### `ask:agent NAME`
18
37
 
19
- Define an agent:
38
+ Scaffolds a new agent:
39
+
40
+ ```bash
41
+ rails generate ask:agent support_bot
42
+ ```
43
+
44
+ Creates `app/agents/support_bot.rb`:
20
45
 
21
46
  ```ruby
22
- # app/agents/support_bot.rb
23
- class Agents::SupportBot < ApplicationAgent
24
- model "gpt-4o"
25
- system_prompt "You help users with support questions."
47
+ module Agents
48
+ class SupportBot < ApplicationAgent
49
+ system_prompt "You are a helpful assistant."
50
+ end
51
+ end
52
+ ```
53
+
54
+ ### `ask:workflow NAME`
55
+
56
+ Scaffolds a new workflow (requires ask-graph):
26
57
 
27
- tool :bash
28
- tool :read
29
- tool :grep
58
+ ```bash
59
+ rails generate ask:workflow notify_customer
60
+ ```
61
+
62
+ Creates `app/workflows/notify_customer/workflow.rb` and `app/workflows/notify_customer/steps/`:
63
+
64
+ ```ruby
65
+ module NotifyCustomer
66
+ class Workflow < ApplicationWorkflow
67
+ # step SomeStep
68
+ end
30
69
  end
31
70
  ```
32
71
 
33
- Run it:
72
+ ### `ask:action NAME [NAMESPACE]`
73
+
74
+ Scaffolds a new action — a user-facing operation callable from any channel (web, Slack, voice) by name:
75
+
76
+ ```bash
77
+ rails generate ask:action create_workspace # app/actions/create_workspace.rb
78
+ rails generate ask:action chats create # app/actions/chats/create.rb
79
+ ```
80
+
81
+ Creates `app/actions/chats/create.rb`:
82
+
83
+ ```ruby
84
+ module Chats
85
+ class Create < ApplicationAction
86
+ def call
87
+ Ask::Actions::Result.ok(message: "Chat created", data: { id: record.id })
88
+ end
89
+ end
90
+ end
91
+ ```
92
+
93
+ ### Skipping workflow scaffolding
94
+
95
+ If you don't use ask-graph, install with:
96
+
97
+ ```bash
98
+ rails generate ask:install --skip-graph
99
+ ```
100
+
101
+ The initializer still works — the graph block is omitted when ask-graph isn't installed.
102
+
103
+ ## Usage
104
+
105
+ ### Agents
106
+
107
+ ```ruby
108
+ # app/agents/support_bot.rb
109
+ module Agents
110
+ class SupportBot < ApplicationAgent
111
+ model "gpt-4o"
112
+ system_prompt "You help users with support questions."
113
+
114
+ tool :bash
115
+ tool :read
116
+ end
117
+ end
118
+ ```
34
119
 
35
120
  ```ruby
36
121
  agent = Ask::Agent.new("support_bot")
@@ -38,12 +123,71 @@ response = agent.run("Find all open issues in the codebase")
38
123
  puts response
39
124
  ```
40
125
 
41
- Or use `Ask.chat` for one-off conversations:
126
+ ### Workflows
127
+
128
+ ```ruby
129
+ # app/workflows/order_fulfillment/workflow.rb
130
+ module OrderFulfillment
131
+ class Workflow < ApplicationWorkflow
132
+ step ValidatePayment
133
+ step NotifyCustomer
134
+ step ShipOrder
135
+ end
136
+ end
137
+ ```
138
+
139
+ ```ruby
140
+ # app/workflows/order_fulfillment/steps/validate_payment.rb
141
+ module OrderFulfillment
142
+ class ValidatePayment
143
+ def call(context)
144
+ context.payment = PaymentService.charge(context.input[:order])
145
+ end
146
+ end
147
+ end
148
+ ```
149
+
150
+ ```ruby
151
+ result = OrderFulfillment::Workflow.call(order: order)
152
+ result.payment
153
+ ```
154
+
155
+ Checkpoints are saved to the shared `ask_state` table, so workflows resume after crashes.
156
+
157
+ ### Actions
158
+
159
+ Actions are the operations your users trigger — booking an appointment, creating a chat, upgrading a plan. They live in `app/actions/` and are dispatched **by name**, so any channel (web controller, Slack handler, voice agent) calls the same operation:
42
160
 
43
161
  ```ruby
44
- Ask.chat("Summarize this article: #{text}")
162
+ # app/actions/chats/create.rb
163
+ module Chats
164
+ class Create < ApplicationAction
165
+ def call
166
+ chat = context.channel.start_new_chat!
167
+ Ask::Actions::Result.ok(message: "Chat created", data: { chat: chat })
168
+ end
169
+ end
170
+ end
45
171
  ```
46
172
 
173
+ ```ruby
174
+ # From any channel:
175
+ context = Ask::Actions::Context.new(user: current_user, session: session)
176
+ result = Ask::Actions.dispatch(action: "chats.create", context: context, params: { name: "general" })
177
+ result.ok? # => true
178
+ result.message # => "Chat created"
179
+ result.data # => { chat: ... }
180
+ ```
181
+
182
+ By convention, `"chats.create"` resolves to `Chats::Create` — no registration needed (Zeitwerk autoloads `app/actions/`). Register explicitly to override the convention or to list actions in `Ask::Actions.available`:
183
+
184
+ ```ruby
185
+ # config/initializers/ask.rb
186
+ Ask::Actions.register "chats.create", Chats::Create
187
+ ```
188
+
189
+ Unknown action names raise `Ask::Actions::Backend::UnknownAction` with a helpful message.
190
+
47
191
  ## Configuration
48
192
 
49
193
  API keys are resolved automatically by `Ask::Auth`:
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Actions
5
+ # Dispatches named actions to their classes.
6
+ #
7
+ # Resolution order:
8
+ # 1. Explicitly registered actions (Ask::Actions.register)
9
+ # 2. Convention: "chats.create" resolves to Chats::Create
10
+ #
11
+ # Convention resolution works with Zeitwerk — app/actions/chats/create.rb
12
+ # is autoloaded as Chats::Create with no configuration.
13
+ class Backend
14
+ class UnknownAction < StandardError
15
+ def initialize(action)
16
+ super(<<~MSG.strip)
17
+ Unknown action #{action.inspect}. Define it at app/actions/ \
18
+ (e.g. app/actions/chats/create.rb for "chats.create") or register \
19
+ it with Ask::Actions.register.
20
+ MSG
21
+ end
22
+ end
23
+
24
+ CONVENTION = /\A[a-z0-9_.]+\z/
25
+
26
+ class << self
27
+ def register(action, klass)
28
+ registered[action.to_s] = klass
29
+ end
30
+
31
+ def registered
32
+ @registered ||= {}
33
+ end
34
+
35
+ def reset!
36
+ @registered = {}
37
+ end
38
+
39
+ def available
40
+ registered.keys
41
+ end
42
+
43
+ def dispatch(action:, context:, params: {})
44
+ resolve(action).call(context: context, params: params)
45
+ end
46
+
47
+ def resolve(action)
48
+ name = action.to_s
49
+ registered[name] || resolve_by_convention(name) || unknown_action!(name)
50
+ end
51
+
52
+ private
53
+
54
+ def resolve_by_convention(name)
55
+ return nil unless name.match?(CONVENTION)
56
+
57
+ name.split(".").map { |part| part.split("_").map(&:capitalize).join }.join("::").safe_constantize
58
+ end
59
+
60
+ def unknown_action!(name)
61
+ raise UnknownAction, name
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Actions
5
+ # A per-request bag of context. Channel adapters construct it with
6
+ # whatever the app needs — user, session, workspace, channel, voice call —
7
+ # and actions read from it. Attributes become accessors on the instance.
8
+ #
9
+ # @example
10
+ # context = Ask::Actions::Context.new(user: user, session: session, workspace: workspace)
11
+ # context.user # => user
12
+ # context.workspace # => workspace
13
+ class Context
14
+ def initialize(**attributes)
15
+ attributes.each { |name, value| define_attribute(name, value) }
16
+ end
17
+
18
+ private
19
+
20
+ def define_attribute(name, value)
21
+ singleton_class.attr_accessor(name)
22
+ public_send("#{name}=", value)
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Ask
4
+ module Actions
5
+ # The uniform response shape every action returns. Channel adapters
6
+ # (web controllers, Slack handlers, voice agents) consume the same
7
+ # contract regardless of which surface a request came from.
8
+ #
9
+ # @example
10
+ # Ask::Actions::Result.ok(message: "Booking confirmed", data: { booking: booking })
11
+ # Ask::Actions::Result.error(message: "Slot unavailable", code: :slot_taken)
12
+ class Result
13
+ attr_reader :ok, :message, :data, :code
14
+
15
+ def initialize(ok:, message:, data: {}, code: nil)
16
+ @ok = ok
17
+ @message = message
18
+ @data = data
19
+ @code = code
20
+ end
21
+
22
+ def ok?
23
+ ok
24
+ end
25
+
26
+ def error?
27
+ !ok
28
+ end
29
+
30
+ def to_h
31
+ { ok: ok, message: message, data: data, code: code }
32
+ end
33
+
34
+ def self.ok(message: "", data: {}, code: nil)
35
+ new(ok: true, message: message, data: data, code: code)
36
+ end
37
+
38
+ def self.error(message: "", data: {}, code: nil)
39
+ new(ok: false, message: message, data: data, code: code)
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "actions/result"
4
+ require_relative "actions/context"
5
+ require_relative "actions/backend"
6
+
7
+ module Ask
8
+ # Actions — a convention for user-facing operations callable from any channel.
9
+ #
10
+ # Every action lives in app/actions/, responds to .call(context:, params:),
11
+ # and returns an Ask::Actions::Result. Dispatch them by name so any channel —
12
+ # web, Slack, voice — can invoke the same operation:
13
+ #
14
+ # Ask::Actions.dispatch(action: "chats.create", context: context, params: {})
15
+ #
16
+ # By convention, "chats.create" resolves to Chats::Create (app/actions/chats/create.rb).
17
+ # Register explicitly to override the convention or to list actions in
18
+ # Ask::Actions.available:
19
+ #
20
+ # Ask::Actions.register "chats.create", Chats::Create
21
+ module Actions
22
+ class << self
23
+ def register(action, klass)
24
+ Backend.register(action, klass)
25
+ end
26
+
27
+ def dispatch(action:, context:, params: {})
28
+ Backend.dispatch(action: action, context: context, params: params)
29
+ end
30
+
31
+ def resolve(action)
32
+ Backend.resolve(action)
33
+ end
34
+
35
+ def available
36
+ Backend.available
37
+ end
38
+
39
+ def reset!
40
+ Backend.reset!
41
+ end
42
+ end
43
+ end
44
+ end
@@ -3,9 +3,6 @@
3
3
  module Ask
4
4
  module Rails
5
5
  class Railtie < ::Rails::Railtie
6
- generators do
7
- require_relative "generators/install/install_generator"
8
- end
9
6
  end
10
7
  end
11
8
  end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_record"
4
+
5
+ module Ask
6
+ module Rails
7
+ # ActiveRecord-backed state adapter for the ask_state table.
8
+ #
9
+ # Used by ask-graph (workflow checkpoints) and any other ask component
10
+ # that needs durable key-value storage in the Rails database. Works with
11
+ # any Rails database adapter — PostgreSQL, MySQL, SQLite — because the
12
+ # +value+ column is a portable JSON type.
13
+ #
14
+ # @example
15
+ # store = Ask::Rails::State.new
16
+ # store.set("checkpoint:1", { completed: true })
17
+ # store.get("checkpoint:1") # => { "completed" => true }
18
+ class State < ::Ask::State::Adapter
19
+ class Record < ::ActiveRecord::Base
20
+ self.table_name = "ask_state"
21
+ end
22
+
23
+ def get(key)
24
+ record = Record.find_by(key: key)
25
+ return nil unless record
26
+ return nil if record.expires_at && record.expires_at <= Time.current
27
+
28
+ record.value
29
+ end
30
+
31
+ def set(key, value, ttl: nil)
32
+ record = Record.find_or_initialize_by(key: key)
33
+ record.value = value
34
+ record.expires_at = ttl ? Time.current + ttl : nil
35
+ record.save!
36
+ value
37
+ end
38
+
39
+ def delete(key)
40
+ Record.where(key: key).delete_all
41
+ end
42
+
43
+ def clear
44
+ Record.delete_all
45
+ end
46
+
47
+ def keys(pattern: nil)
48
+ scope = Record.all
49
+ if pattern
50
+ like = self.class.glob_to_like(pattern)
51
+ scope = scope.where("key LIKE ?", like)
52
+ end
53
+ scope.pluck(:key)
54
+ end
55
+
56
+ def set_if_not_exists(key, value, ttl: nil)
57
+ return false if exists?(key)
58
+
59
+ set(key, value, ttl: ttl)
60
+ true
61
+ end
62
+
63
+ # Ordered lists — stored as an array in the value column.
64
+ # Required by Ask::Agent::Persistence::Base for session indexes.
65
+
66
+ def list_append(key, value, max_length: nil)
67
+ entries = list_range(key)
68
+ entries << value
69
+ entries = entries.last(max_length) if max_length
70
+ set(key, entries)
71
+ end
72
+
73
+ def list_range(key, start = 0, stop = -1)
74
+ entries = get(key)
75
+ return [] unless entries.is_a?(Array)
76
+
77
+ entries[start..stop] || []
78
+ end
79
+
80
+ def list_remove(key, value)
81
+ entries = get(key)
82
+ return 0 unless entries.is_a?(Array)
83
+
84
+ before = entries.size
85
+ entries.delete(value)
86
+ set(key, entries)
87
+ before - entries.size
88
+ end
89
+ end
90
+ end
91
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Ask
4
4
  module Rails
5
- VERSION = "0.6.0"
5
+ VERSION = "0.8.0"
6
6
  end
7
7
  end
data/lib/ask/rails.rb CHANGED
@@ -8,4 +8,6 @@ module Ask
8
8
  end
9
9
 
10
10
  require_relative "rails/version"
11
+ require_relative "rails/state"
12
+ require_relative "actions"
11
13
  require_relative "rails/railtie" if defined?(::Rails::Railtie)
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ # <%= dispatch_name %> action.
4
+ #
5
+ # Dispatch it from any channel — web, Slack, voice:
6
+ # Ask::Actions.dispatch(action: "<%= dispatch_name %>", context: context, params: {})
7
+ #
8
+ # Return an Ask::Actions::Result:
9
+ # Ask::Actions::Result.ok(message: "Done", data: { id: record.id })
10
+ <% if namespaced? -%>
11
+ module <%= namespace_class_name %>
12
+ class <%= action_class_name %> < ApplicationAction
13
+ # def call
14
+ # Ask::Actions::Result.ok(message: "Done")
15
+ # end
16
+ end
17
+ end
18
+ <% else -%>
19
+ class <%= action_class_name %> < ApplicationAction
20
+ # def call
21
+ # Ask::Actions::Result.ok(message: "Done")
22
+ # end
23
+ end
24
+ <% end -%>
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Ask
6
+ module Generators
7
+ # Creates an action under app/actions. Actions are operations callable
8
+ # from any channel by name — dispatch them with Ask::Actions.dispatch.
9
+ #
10
+ # rails generate ask:action create_workspace
11
+ # # => app/actions/create_workspace.rb (dispatch name: "create_workspace")
12
+ #
13
+ # rails generate ask:action chats create
14
+ # # => app/actions/chats/create.rb (dispatch name: "chats.create")
15
+ class ActionGenerator < ::Rails::Generators::NamedBase
16
+ source_root File.expand_path("action/templates", __dir__)
17
+
18
+ desc "Creates an action under app/actions — a user-facing operation callable from any channel"
19
+
20
+ def create_action
21
+ template "action.rb", action_path
22
+ end
23
+
24
+ private
25
+
26
+ def action_path
27
+ if namespaced?
28
+ "app/actions/#{namespace_name}/#{action_name}.rb"
29
+ else
30
+ "app/actions/#{action_name}.rb"
31
+ end
32
+ end
33
+
34
+ def namespaced?
35
+ args.any?
36
+ end
37
+
38
+ def namespace_name
39
+ file_name
40
+ end
41
+
42
+ def action_name
43
+ namespaced? ? args.first.underscore : file_name
44
+ end
45
+
46
+ def namespace_class_name
47
+ namespace_name.camelize
48
+ end
49
+
50
+ def action_class_name
51
+ action_name.camelize
52
+ end
53
+
54
+ def dispatch_name
55
+ namespaced? ? "#{namespace_name}.#{action_name}" : action_name
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ # <%= class_name %> agent.
4
+ #
5
+ # Run it:
6
+ # agent = Ask::Agent.new("<%= file_name %>")
7
+ # response = agent.run("Hello")
8
+ #
9
+ module Agents
10
+ class <%= class_name %> < ApplicationAgent
11
+ # model "gpt-4o"
12
+ system_prompt "You are a helpful assistant."
13
+
14
+ # tool :bash
15
+ # tool :read
16
+ # tool :grep
17
+ end
18
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Ask
6
+ module Generators
7
+ class AgentGenerator < ::Rails::Generators::NamedBase
8
+ source_root File.expand_path("agent/templates", __dir__)
9
+
10
+ desc "Creates an agent under app/agents — subclass ApplicationAgent with a model and system prompt"
11
+
12
+ def create_agent
13
+ template "agent.rb", "app/agents/#{file_name}.rb"
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Base class for your application's actions.
4
+ #
5
+ # Subclass this to define actions — operations callable from any channel
6
+ # (web, Slack, voice) by name:
7
+ #
8
+ # module Chats
9
+ # class Create < ApplicationAction
10
+ # def call
11
+ # chat = context.channel.start_new_chat!
12
+ # Ask::Actions::Result.ok(message: "Chat created", data: { chat: chat })
13
+ # end
14
+ # end
15
+ # end
16
+ #
17
+ # Then dispatch from anywhere:
18
+ # Ask::Actions.dispatch(action: "chats.create", context: context, params: {})
19
+ #
20
+ class ApplicationAction
21
+ def self.call(context:, params: {})
22
+ new(context: context, params: params).call
23
+ end
24
+
25
+ def initialize(context:, params: {})
26
+ @context = context
27
+ @params = params
28
+ end
29
+
30
+ private
31
+
32
+ attr_reader :context, :params
33
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Base class for your application's workflows.
4
+ #
5
+ # Subclass this to define workflows:
6
+ #
7
+ # module OrderFulfillment
8
+ # class Workflow < ApplicationWorkflow
9
+ # step ValidatePayment
10
+ # step NotifyCustomer
11
+ # step ShipOrder
12
+ # end
13
+ # end
14
+ #
15
+ # Then run:
16
+ # result = OrderFulfillment::Workflow.call({ order: order })
17
+ #
18
+ class ApplicationWorkflow < Ask::Graph
19
+ # Shared defaults — uncomment as needed:
20
+ # step_timeout 30
21
+ # workflow_timeout 60
22
+ # storage PostgresStore.new
23
+ end
@@ -1,6 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class CreateAskAuditLogs < ActiveRecord::Migration[8.1]
3
+ # Migration for ask-agent's audit log table.
4
+ # This table records session events (tool calls, errors, token usage, etc.)
5
+ # from every agent that has audit_log configured.
6
+ class CreateAskAuditLogs < ActiveRecord::Migration[<%= migration_version %>]
4
7
  def change
5
8
  create_table :ask_audit_logs, if_not_exists: true do |t|
6
9
  t.string :session_id, null: false
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # Ask Agent Configuration
4
- # ========================
3
+ # Ask Configuration
4
+ # =================
5
5
  #
6
6
  # API keys are resolved automatically by Ask::Auth:
7
7
  # - Environment variables: OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.
@@ -10,10 +10,34 @@
10
10
  #
11
11
  # See https://github.com/ask-rb/ask-auth for details.
12
12
 
13
+ # Agents
13
14
  Ask::Agent.configure do |config|
14
- # Enable audit logging to the ask_audit_logs table
15
+ # Audit log for session events (ask_audit_logs table)
15
16
  # config.audit_log = { adapter: :active_record }
16
17
 
17
- # Optional: set a default model
18
18
  # config.default_model = "gpt-4o"
19
19
  end
20
+
21
+ # Workflows (ask-graph)
22
+ #
23
+ # The graph block is only generated when ask-graph is installed.
24
+ # Run `bundle add ask-graph` then `rails generate ask:install` to add it.
25
+ if defined?(Ask::Graph)
26
+ # Checkpoint storage for workflow crash recovery — backed by the
27
+ # ask_state table (created by the migration) in your Rails database.
28
+ # Works with any database adapter: PostgreSQL, MySQL, SQLite.
29
+ Ask::Graph.storage = Ask::Rails::State.new
30
+
31
+ # Ask::Graph.default_step_timeout 30
32
+ # Ask::Graph.default_workflow_timeout 60
33
+ end
34
+
35
+ # Actions
36
+ #
37
+ # Actions live in app/actions/ and are dispatched by name from any channel:
38
+ # Ask::Actions.dispatch(action: "chats.create", context: context, params: {})
39
+ #
40
+ # By convention, "chats.create" resolves to Chats::Create
41
+ # (app/actions/chats/create.rb). Register explicitly to override the
42
+ # convention or to list the action in Ask::Actions.available:
43
+ # Ask::Actions.register "chats.create", Chats::Create
@@ -1,10 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class CreateAskState < ActiveRecord::Migration[8.1]
3
+ # Migration for ask-rb's shared key-value state table.
4
+ # Used by ask-agent (session persistence) and ask-graph (workflow checkpoints).
5
+ # This single table serves both components — keys are namespaced by convention.
6
+ # The json column type works on PostgreSQL, MySQL, and SQLite.
7
+ class CreateAskState < ActiveRecord::Migration[<%= migration_version %>]
4
8
  def change
5
9
  create_table :ask_state do |t|
6
10
  t.string :key, null: false
7
- t.jsonb :value, null: false, default: {}
11
+ t.json :value, null: false, default: {}
8
12
  t.datetime :expires_at
9
13
  t.timestamps
10
14
 
@@ -6,34 +6,62 @@ require "rails/generators/active_record"
6
6
  module Ask
7
7
  module Generators
8
8
  class InstallGenerator < ::Rails::Generators::Base
9
- include ::Rails::Generators::Migration
9
+ include ::ActiveRecord::Generators::Migration
10
10
 
11
11
  source_root File.expand_path("install/templates", __dir__)
12
12
 
13
- desc "Sets up ask-rb for Rails — creates initializer, state and audit log migrations"
13
+ desc "Sets up ask-rb for Rails — creates initializer, agents and workflows directories, and shared state migrations"
14
+
15
+ class_option :skip_graph, type: :boolean, default: false,
16
+ desc: "Skip workflow scaffolding even if ask-graph is installed"
14
17
 
15
18
  def create_initializer
16
19
  template "initializer.rb", "config/initializers/ask.rb"
17
20
  end
18
21
 
22
+ def create_agents_directory
23
+ empty_directory "app/agents"
24
+ end
25
+
26
+ def create_application_agent
27
+ template "application_agent.rb", "app/agents/application_agent.rb"
28
+ end
29
+
30
+ def create_actions_directory
31
+ empty_directory "app/actions"
32
+ end
33
+
34
+ def create_application_action
35
+ template "application_action.rb", "app/actions/application_action.rb"
36
+ end
37
+
38
+ def create_workflows_directory
39
+ return if skip_graph?
40
+ empty_directory "app/workflows"
41
+ end
42
+
43
+ def create_application_workflow
44
+ return if skip_graph?
45
+ template "application_workflow.rb", "app/workflows/application_workflow.rb"
46
+ end
47
+
19
48
  def create_state_migration
20
- template "state_migration.rb", "db/migrate/#{next_migration_number}_create_ask_state.rb"
49
+ migration_template "state_migration.rb", "db/migrate/create_ask_state.rb"
21
50
  end
22
51
 
23
52
  def create_audit_log_migration
24
- template "audit_log_migration.rb", "db/migrate/#{next_migration_number}_create_ask_audit_logs.rb"
53
+ migration_template "audit_log_migration.rb", "db/migrate/create_ask_audit_logs.rb"
25
54
  end
26
55
 
27
56
  private
28
57
 
29
- def next_migration_number
30
- # Rails requires exactly YYYYMMDDHHMMSS format
31
- @migration_suffix ||= 0
32
- now = Time.now.utc.strftime("%Y%m%d%H%M")
33
- "#{now}#{format('%02d', @migration_suffix)}"
34
- ensure
35
- @migration_suffix = (@migration_suffix || 0) + 1
58
+ def skip_graph?
59
+ options[:skip_graph] || !defined?(Ask::Graph)
36
60
  end
61
+
62
+ def migration_version
63
+ ActiveRecord::Migration.current_version
64
+ end
37
65
  end
38
66
  end
39
67
  end
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ # <%= class_name %> workflow.
4
+ #
5
+ # Define steps as plain Ruby classes in steps/ — each responds to call(context).
6
+ # Compose other workflows via Workflow.call(context) inside a step.
7
+ #
8
+ # module <%= class_name %>
9
+ # class Workflow < ApplicationWorkflow
10
+ # step SomeStep
11
+ # step OtherStep, if: :condition?
12
+ #
13
+ # private
14
+ #
15
+ # def condition?
16
+ # context.some_flag
17
+ # end
18
+ # end
19
+ # end
20
+ #
21
+ # Run it:
22
+ # result = <%= class_name %>::Workflow.call({ input: value })
23
+ #
24
+ module <%= class_name %>
25
+ class Workflow < ApplicationWorkflow
26
+ # step SomeStep
27
+ end
28
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+
5
+ module Ask
6
+ module Generators
7
+ class WorkflowGenerator < ::Rails::Generators::NamedBase
8
+ source_root File.expand_path("workflow/templates", __dir__)
9
+
10
+ desc "Creates a workflow under app/workflows — module directory, Workflow class, and steps directory"
11
+
12
+ def verify_ask_graph
13
+ return if defined?(Ask::Graph)
14
+
15
+ raise ::Thor::Error, <<~MSG.strip
16
+ ask-graph is not installed. Add it to your Gemfile and run `bundle install` first:
17
+
18
+ gem "ask-graph"
19
+ MSG
20
+ end
21
+
22
+ def create_workflow_directory
23
+ empty_directory "app/workflows/#{file_name}"
24
+ end
25
+
26
+ def create_workflow
27
+ template "workflow.rb", "app/workflows/#{file_name}/workflow.rb"
28
+ end
29
+
30
+ def create_steps_directory
31
+ empty_directory "app/workflows/#{file_name}/steps"
32
+ end
33
+ end
34
+ end
35
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: ask-rails
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Kaka Ruto
@@ -79,6 +79,20 @@ dependencies:
79
79
  - - "~>"
80
80
  - !ruby/object:Gem::Version
81
81
  version: '13.0'
82
+ - !ruby/object:Gem::Dependency
83
+ name: sqlite3
84
+ requirement: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - ">="
87
+ - !ruby/object:Gem::Version
88
+ version: '1.4'
89
+ type: :development
90
+ prerelease: false
91
+ version_requirements: !ruby/object:Gem::Requirement
92
+ requirements:
93
+ - - ">="
94
+ - !ruby/object:Gem::Version
95
+ version: '1.4'
82
96
  description: Rails generators, file conventions, and railtie for using ask-agent in
83
97
  Rails apps.
84
98
  email:
@@ -91,18 +105,27 @@ files:
91
105
  - LICENSE
92
106
  - README.md
93
107
  - lib/ask-rails.rb
108
+ - lib/ask/actions.rb
109
+ - lib/ask/actions/backend.rb
110
+ - lib/ask/actions/context.rb
111
+ - lib/ask/actions/result.rb
94
112
  - lib/ask/rails.rb
95
- - lib/ask/rails/generators/install/install_generator.rb
96
- - lib/ask/rails/generators/install/templates/application_agent.rb
97
- - lib/ask/rails/generators/install/templates/audit_log_migration.rb
98
- - lib/ask/rails/generators/install/templates/initializer.rb
99
- - lib/ask/rails/generators/install/templates/state_migration.rb
100
113
  - lib/ask/rails/railtie.rb
114
+ - lib/ask/rails/state.rb
101
115
  - lib/ask/rails/version.rb
116
+ - lib/generators/ask/action/templates/action.rb
117
+ - lib/generators/ask/action_generator.rb
118
+ - lib/generators/ask/agent/templates/agent.rb
119
+ - lib/generators/ask/agent_generator.rb
120
+ - lib/generators/ask/install/templates/application_action.rb
121
+ - lib/generators/ask/install/templates/application_agent.rb
122
+ - lib/generators/ask/install/templates/application_workflow.rb
102
123
  - lib/generators/ask/install/templates/audit_log_migration.rb
103
124
  - lib/generators/ask/install/templates/initializer.rb
104
125
  - lib/generators/ask/install/templates/state_migration.rb
105
126
  - lib/generators/ask/install_generator.rb
127
+ - lib/generators/ask/workflow/templates/workflow.rb
128
+ - lib/generators/ask/workflow_generator.rb
106
129
  homepage: https://github.com/ask-rb/ask-rails
107
130
  licenses:
108
131
  - MIT
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "rails/generators"
4
- require "rails/generators/active_record"
5
-
6
- module Ask
7
- module Rails
8
- module Generators
9
- class InstallGenerator < ::Rails::Generators::Base
10
- include ::Rails::Generators::Migration
11
-
12
- source_root File.expand_path("templates", __dir__)
13
-
14
- desc "Sets up ask-rb for Rails — creates initializer, agents directory, state and audit log migrations"
15
-
16
- def create_initializer
17
- template "initializer.rb", "config/initializers/ask.rb"
18
- end
19
-
20
- def create_application_agent
21
- template "application_agent.rb", "app/agents/application_agent.rb"
22
- end
23
-
24
- def create_agents_directory
25
- empty_directory "app/agents"
26
- create_file "app/agents/.keep", "" unless options[:skip_keep]
27
- end
28
-
29
- def create_state_migration
30
- migration_template "state_migration.rb", "db/migrate/create_ask_state.rb"
31
- end
32
-
33
- def create_audit_log_migration
34
- migration_template "audit_log_migration.rb", "db/migrate/create_ask_audit_logs.rb"
35
- end
36
-
37
- private
38
-
39
- def migration_version
40
- "[#{ActiveRecord::Migration.current_version}]"
41
- end
42
- end
43
- end
44
- end
45
- end
@@ -1,19 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Migration for ask-agent's audit log table.
4
- # This table records session events (tool calls, errors, token usage, etc.)
5
- # from every agent that has audit_log configured.
6
- class CreateAskAuditLogs < ActiveRecord::Migration[<%= migration_version %>]
7
- def change
8
- create_table :ask_audit_logs, if_not_exists: true do |t|
9
- t.string :session_id, null: false
10
- t.string :event_type, null: false
11
- t.jsonb :data, default: {}
12
- t.datetime :timestamp, null: false
13
- t.timestamps
14
-
15
- t.index [:session_id, :event_type]
16
- t.index :timestamp
17
- end
18
- end
19
- end
@@ -1,27 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Ask Agent Configuration
4
- # ========================
5
- #
6
- # API keys are resolved automatically by Ask::Auth:
7
- # - Environment variables: OPENAI_API_KEY, ANTHROPIC_API_KEY, etc.
8
- # - Rails credentials: rails credentials:edit → ask.openai, ask.anthropic
9
- # - ~/.ask/credentials.yml
10
- #
11
- # See https://github.com/ask-rb/ask-auth for details.
12
-
13
- # Shared key-value state store for agent sessions and graph checkpoints.
14
- # Uses the ask_state table created by the migration.
15
- ASK_STATE = Ask::State::PostgreSQL.new(
16
- table_name: :ask_state
17
- )
18
-
19
- Ask::Agent.configure do |config|
20
- # State persistence for agent sessions
21
- config.state = ASK_STATE
22
-
23
- # Audit log for session events
24
- config.audit_log = { adapter: :active_record }
25
-
26
- # config.default_model = "gpt-4o"
27
- end
@@ -1,18 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Migration for ask-rb's shared key-value state table.
4
- # Used by ask-agent (session persistence) and ask-graph (workflow checkpoints).
5
- # This single table serves both components — keys are namespaced by convention.
6
- class CreateAskState < ActiveRecord::Migration[<%= migration_version %>]
7
- def change
8
- create_table :ask_state do |t|
9
- t.string :key, null: false
10
- t.jsonb :value, null: false, default: {}
11
- t.datetime :expires_at
12
- t.timestamps
13
-
14
- t.index :key, unique: true
15
- t.index :expires_at
16
- end
17
- end
18
- end