omnibot-ruby 0.1.0 → 0.2.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: 075266e9395d95dedce797206f30b73cdfd972aa59a97813f87e1b6b7f6ce75b
4
- data.tar.gz: 4cda42ac155f91a4422000ebc2773c328062694cef3e9867292c47d8d5e7e393
3
+ metadata.gz: 6a9e962ec1fc30233e103cc753f9db7db243db279f3bb51f6fa8a7bf5abdd940
4
+ data.tar.gz: 73f4531396f0d8a73a7e6302195a6ff42a9d70ab9b3436a0c9c877a5f234af85
5
5
  SHA512:
6
- metadata.gz: 2bbf2240ffcae6a4225abf426d0e0799e075cff632b10cb3357bd03383263564af5706f71bc205a7c8d7adb6111bd863c857087a0efadf8008288907c7be5b36
7
- data.tar.gz: ff9256f23f891d0664f2e476c797b02462bbd3dd2c2f9ff41c1a70ce4f509c56c1769d8ac171e146fe957b244ace503dfa3dbacf137b57c964d6b40065489b59
6
+ metadata.gz: d9373a4af7299c9a2ef450b93242c387cefd23ec8efd21b9eeec30dc51e1f7c003848b861e1ae3e52d6698800725e419ad1b09db0256f7bb57a5445ee7e25bdf
7
+ data.tar.gz: 9b24f2b865bee9764f474e7613e91d14d09c9afc9144175caf48d2a996294588ca501c47bfb5e48ed554a42189f5cfff7fcba76497e963c6b8fd6f6dde69c776
data/README.md CHANGED
@@ -4,7 +4,7 @@ Rails-first LLM agents for Ruby.
4
4
 
5
5
  ## Why
6
6
 
7
- `omnibot-ruby` is a Rails-idiomatic agent framework built on [`ruby_llm`](https://github.com/crmne/ruby_llm) — a class DSL for bounded tool-calling agents that lives in your Rails app, not a Python sidecar you have to deploy, proxy, and keep in sync. It ships two primitives: **Agent** (v0.1, available now — a bounded tool-calling loop with fast paths, structured extraction, and streaming) and **Workflow** (coming in v0.2 — a durable, ActiveRecord-checkpointed graph engine for multi-step conversations with human handover, so long-running flows survive restarts without a Redis checkpointer). Everything runs in-process, which means token streaming to ActionCable/Turbo is a callback away instead of blocked behind an HTTP hop.
7
+ `omnibot-ruby` is a Rails-idiomatic agent framework built on [`ruby_llm`](https://github.com/crmne/ruby_llm) — a class DSL for bounded tool-calling agents that lives in your Rails app, not a Python sidecar you have to deploy, proxy, and keep in sync. It ships two primitives: **Agent** (a bounded tool-calling loop with fast paths, structured extraction, and streaming) and **Workflow** (v0.2 — a durable, ActiveRecord-checkpointed graph engine for multi-step conversations with human handover, so long-running flows survive restarts without a Redis checkpointer). Everything runs in-process, which means token streaming to ActionCable/Turbo is a callback away instead of blocked behind an HTTP hop.
8
8
 
9
9
  ## Install
10
10
 
@@ -174,6 +174,83 @@ end
174
174
 
175
175
  The fake replays your script in order: `to_call_tool` asserts a tool is called and actually executes it (so bugs in your tool body still surface), `then_reply` ends the turn with a text reply, and `then_extract` ends it with a Hash for `Agent.extract` specs. If the script runs out, unscripted calls get a default `"(fake) <message>"` reply so runaway loops fail loudly instead of hanging. Class-form tools are plain Ruby objects — unit-test them directly, no fake required.
176
176
 
177
+ ## Durable workflows
178
+
179
+ `Omnibot::Workflow` is a checkpoint-per-step graph engine for multi-step conversations that must survive a restart between messages: steps are nodes, transitions are edges, state persists as jsonb on a gem-owned `omnibot_workflow_runs` table. No Redis checkpointer, no graph compilation, no sidecar — it's ActiveRecord and ActiveJob, which your Rails app already has.
180
+
181
+ ```bash
182
+ rails g omnibot:install # also creates the omnibot_workflow_runs migration (skipped if it exists)
183
+ rails g omnibot:workflow DepositCheck
184
+ ```
185
+
186
+ ```ruby
187
+ class DepositCheckWorkflow < Omnibot::Workflow
188
+ state :amount, :bank, :paid
189
+
190
+ step :ask_for_proof do
191
+ reply "Please upload your transfer receipt 🙏"
192
+ wait_for_input # checkpoint: pause and persist; resumes on the next message
193
+ end
194
+
195
+ step :extract_proof do
196
+ proof = ProofAgent.extract(input, schema: Class.new) # input = what resume() received
197
+ state.amount = proof[:amount]
198
+ state.bank = proof[:bank]
199
+ reply "Got it — Rp#{state.amount} via #{state.bank}. Checking with the gateway…"
200
+ end
201
+
202
+ step :watch_gateway, poll: { every: 5, max_attempts: 5 } do
203
+ status = gateway_check
204
+ if status == :pending
205
+ reply "Still checking with the gateway… (attempt #{attempts})"
206
+ poll_again # schedule the next tick, stop this one
207
+ end
208
+ state.paid = (status == :paid)
209
+ end
210
+
211
+ transition from: :ask_for_proof, to: :extract_proof
212
+ transition from: :extract_proof, to: :watch_gateway
213
+ transition from: :watch_gateway, to: :done, if: -> { state.paid }
214
+ transition from: :watch_gateway, to: :failed
215
+ end
216
+
217
+ run = DepositCheckWorkflow.start
218
+ run.status # => "waiting_for_input"
219
+ run.replies # => ["Please upload your transfer receipt 🙏"]
220
+
221
+ run = DepositCheckWorkflow.resume(run, input: "transfer 50rb via BCA, receipt attached")
222
+ run.status # => "running" — now polling the gateway on an ActiveJob timer
223
+ run.current_step # => "watch_gateway"
224
+ run.state["amount"] # => 50_000
225
+ ```
226
+
227
+ This is the same flow as `examples/deposit_check.rb` (runnable: `bundle exec ruby examples/deposit_check.rb`) and `spec/omnibot/workflow_integration_spec.rb` — copy-paste it and swap in your own agent/gateway calls.
228
+
229
+ A step body runs at most once per entry: `wait_for_input` throws out of the step immediately (statements after it never run), so bodies need no idempotence gymnastics. After a step returns normally, transitions are evaluated in declaration order — first matching `if:` wins (unconditional always matches) — and the run walks into the next step in the same activation, stopping only at `wait_for_input`, `handover!`, a poll schedule, a terminal step (`:done`, `:expired`, `:failed`, `:cancelled`, or any user step with no outgoing transitions), or an exception (→ `failed`, with `run.error` set).
230
+
231
+ **Replies have two delivery paths.** Foreground activations (`start`/`resume`) return the activation's replies on `run.replies`. Background activations — a poll tick or a timeout firing via `Omnibot::WorkflowTimerJob` — have no caller to return to, so `reply` *always* also emits `omnibot.workflow.reply`; that's the one seam for both. Bridge it to your message provider with a one-line subscriber:
232
+
233
+ ```ruby
234
+ ActiveSupport::Notifications.subscribe("omnibot.workflow.reply") do |event|
235
+ YourMessenger.send(event.payload[:run_id], event.payload[:text])
236
+ end
237
+ ```
238
+
239
+ **Human handover and control operations:**
240
+
241
+ ```ruby
242
+ run.status # => "waiting_for_human" after a step calls handover!(reason: "...")
243
+ run.resume_from_human # re-enters the current step as a fresh attempt
244
+ run.retry! # re-enters a *failed* run's current step (attempts increments)
245
+ run.cancel! # any active run -> "cancelled"; raises Omnibot::WorkflowError::StaleResume if already terminal
246
+ ```
247
+
248
+ `resume` raises `Omnibot::WorkflowError::StaleResume` when the run is terminal (`done`/`failed`/`expired`/`cancelled`) or `waiting_for_human` (its own message points you at `resume_from_human` instead). `while_running :ignore` (the default) makes `resume` on a still-`running` run a no-op that returns it unchanged — this is the only mode v0.2 implements; `while_running :interrupt` (abort the in-flight step and apply the new input immediately) is documented but raises `NotImplementedError` and ships in v0.3. Resuming a run sitting at a `wait_for_input` checkpoint evaluates its transitions and re-enters; if that lands on another `wait_for_input` checkpoint, it simply resumes again next time.
249
+
250
+ Timers (`timeout :step, after:, to:`, and poll's `every:`) are ActiveJob, scheduled with `set(wait:)`, and stale-checked against a per-entry `timer_token` before they act — a fired timer from a step the run has since left is a safe no-op. **Running the example:** the demo and integration spec use ActiveJob's `:test` adapter with `perform_enqueued_jobs`, not `:inline` — `InlineAdapter#enqueue_at` isn't implemented in any ActiveJob version we've checked, so `:inline` can't run a `poll`/`timeout` step at all. See "Production queue adapters" below for real deployments.
251
+
252
+ **Production queue adapters:** schedule jobs fire through `Omnibot::WorkflowTimerJob` via ActiveJob, so `wait:` timing depends on your adapter's `enqueue_at` support (Sidekiq, Sidekiq Cron, GoodJob, etc. all handle it — `:inline` and `:async` don't). Set `Omnibot::WorkflowTimerJob.enqueue_after_transaction_commit` so a timer scheduled inside a still-open transaction doesn't fire before the transaction commits — on Rails 7.2 that setting takes a symbol (`= :always`); on Rails 8.0+ it also accepts a boolean (`= true`). On Rails 7.1 the setting doesn't exist at all — rely on the token guard instead: a rolled-back activation may still enqueue a harmless no-op job, but the job re-checks `status`/`current_step`/`timer_token` under `with_lock` before doing anything, so the stale timer just finds nothing to do (the `timer_token` guard makes it self-healing).
253
+
177
254
  ## Instrumentation
178
255
 
179
256
  Everything is instrumented via `ActiveSupport::Notifications`, so you can wire usage logging, cost caps, or a dashboard without touching the gem:
@@ -186,7 +263,17 @@ Everything is instrumented via `ActiveSupport::Notifications`, so you can wire u
186
263
 
187
264
  `omnibot.llm.call`'s `usage:` is per-call (that one provider round trip's final response). `omnibot.agent.run`'s `usage:` is the run total, summed across every provider round trip in the run — a single `Agent.run` can be several `omnibot.llm.call`s deep when the tool-calling loop goes more than one turn.
188
265
 
189
- Timing is available on the event object itself (`event.duration`) for any subscribed event — it is not a payload key. Workflow events (`omnibot.workflow.*`) arrive with Workflow in v0.2.
266
+ Workflow (v0.2) emits its own events on the same seam:
267
+
268
+ | Event | Payload keys |
269
+ |---|---|
270
+ | `omnibot.workflow.step` | `workflow:` (Workflow class), `run_id:`, `step:` (Symbol), `attempts:` (Integer), `status:` (String, the run's status after the step body ran), `error:` (String, present only when the step raised) |
271
+ | `omnibot.workflow.transition` | `workflow:`, `run_id:`, `from:` (Symbol), `to:` (Symbol) |
272
+ | `omnibot.workflow.reply` | `workflow:`, `run_id:`, `step:` (String — `run.current_step`), `text:` (String) |
273
+ | `omnibot.workflow.handover` | `workflow:`, `run_id:`, `step:` (String), `reason:` (whatever was passed to `handover!`) |
274
+ | `omnibot.workflow.timeout` | `workflow:`, `run_id:`, `step:` (Symbol) |
275
+
276
+ Timing is available on the event object itself (`event.duration`) for any subscribed event — it is not a payload key.
190
277
 
191
278
  A minimal usage-log subscriber — the whole recipe is 4 lines:
192
279
 
@@ -201,8 +288,9 @@ Nothing in the gem phones home, ever — instrumentation is a local seam you sub
201
288
 
202
289
  ## Roadmap
203
290
 
204
- - **v0.2 — Workflow.** A durable, ActiveRecord-checkpointed graph engine for multi-step conversations: steps as nodes, transitions as edges, `wait_for_input` to checkpoint and pause for the next inbound message, `handover!` to page a human. State persists as jsonb on a gem-owned `omnibot_workflow_runs` table, so a workflow survives a deploy or a restart mid-conversation without a Redis checkpointer.
205
- - **Later — hosted observability.** A subscriber gem plus a hosted dashboard, built entirely on the instrumentation events above. Paid, optional, and no core changes required to adopt it.
291
+ - **v0.2 — Workflow. Shipped.** A durable, ActiveRecord-checkpointed graph engine for multi-step conversations: steps as nodes, transitions as edges, `wait_for_input` to checkpoint and pause for the next inbound message, `handover!` to page a human. State persists as jsonb on a gem-owned `omnibot_workflow_runs` table, so a workflow survives a deploy or a restart mid-conversation without a Redis checkpointer. See [Durable workflows](#durable-workflows) above.
292
+ - **Next — hosted observability.** A subscriber gem plus a hosted dashboard, built entirely on the instrumentation events above (Agent and Workflow both). Paid, optional, and no core changes required to adopt it.
293
+ - **Next — `while_running :interrupt`.** Full semantics for aborting an in-flight step when a new message arrives mid-activation, if demand shows up; `:ignore` is the only implemented mode today.
206
294
 
207
295
  ## License
208
296
 
@@ -1,13 +1,25 @@
1
1
  require "rails/generators"
2
+ require "rails/generators/migration"
2
3
 
3
4
  module Omnibot
4
5
  module Generators
5
6
  class InstallGenerator < Rails::Generators::Base
7
+ include Rails::Generators::Migration
6
8
  source_root File.expand_path("templates", __dir__)
7
9
 
10
+ def self.next_migration_number(_dir)
11
+ Time.now.utc.strftime("%Y%m%d%H%M%S")
12
+ end
13
+
8
14
  def create_initializer
9
15
  template "initializer.rb.tt", "config/initializers/omnibot.rb"
10
16
  end
17
+
18
+ def create_workflow_runs_migration
19
+ return if Dir[File.join(destination_root, "db/migrate/*_create_omnibot_workflow_runs.rb")].any?
20
+ migration_template "create_omnibot_workflow_runs.rb.tt",
21
+ "db/migrate/create_omnibot_workflow_runs.rb"
22
+ end
11
23
  end
12
24
  end
13
25
  end
@@ -0,0 +1,19 @@
1
+ class CreateOmnibotWorkflowRuns < ActiveRecord::Migration[7.1]
2
+ def change
3
+ create_table :omnibot_workflow_runs do |t|
4
+ t.string :type, null: false
5
+ t.string :status, null: false
6
+ t.string :current_step
7
+ # On PostgreSQL you may change this to t.jsonb
8
+ t.json :state, default: {}
9
+ t.integer :attempts, default: 0, null: false
10
+ t.datetime :step_entered_at
11
+ t.integer :timer_token, default: 0, null: false
12
+ t.references :ref, polymorphic: true, index: true
13
+ t.text :error
14
+ t.timestamps
15
+ end
16
+ add_index :omnibot_workflow_runs, :status
17
+ add_index :omnibot_workflow_runs, [:type, :status]
18
+ end
19
+ end
@@ -0,0 +1,16 @@
1
+ class <%= class_name %>Workflow < Omnibot::Workflow
2
+ state :example_field
3
+
4
+ step :ask do
5
+ reply "What can I help you with?"
6
+ wait_for_input # checkpoint: resumes on the next message
7
+ end
8
+
9
+ step :handle do
10
+ state.example_field = input
11
+ end
12
+
13
+ transition from: :ask, to: :handle
14
+ transition from: :handle, to: :done
15
+ timeout :ask, after: 30.minutes, to: :expired
16
+ end
@@ -0,0 +1,12 @@
1
+ require "rails_helper"
2
+
3
+ RSpec.describe <%= class_name %>Workflow do
4
+ it "asks, waits, and completes on input" do
5
+ run = <%= class_name %>Workflow.start
6
+ expect(run.status).to eq("waiting_for_input")
7
+ expect(run.replies).to eq(["What can I help you with?"])
8
+
9
+ run = <%= class_name %>Workflow.resume(run, input: "hello")
10
+ expect(run.status).to eq("done")
11
+ end
12
+ end
@@ -0,0 +1,14 @@
1
+ require "rails/generators"
2
+
3
+ module Omnibot
4
+ module Generators
5
+ class WorkflowGenerator < Rails::Generators::NamedBase
6
+ source_root File.expand_path("templates", __dir__)
7
+
8
+ def create_workflow
9
+ template "workflow.rb.tt", "app/workflows/#{file_name}_workflow.rb"
10
+ template "workflow_spec.rb.tt", "spec/workflows/#{file_name}_workflow_spec.rb"
11
+ end
12
+ end
13
+ end
14
+ end
@@ -3,5 +3,8 @@ module Omnibot
3
3
  class LLMError < Error; end
4
4
  class ToolError < Error; end
5
5
  class ExtractionError < Error; end
6
- class WorkflowError < Error; end
6
+ class WorkflowError < Error
7
+ class InvalidTransition < WorkflowError; end
8
+ class StaleResume < WorkflowError; end
9
+ end
7
10
  end
@@ -1,3 +1,3 @@
1
1
  module Omnibot
2
- VERSION = "0.1.0"
2
+ VERSION = "0.2.0"
3
3
  end
@@ -0,0 +1,196 @@
1
+ module Omnibot
2
+ class Workflow
3
+ INTERRUPT = :__omnibot_workflow_interrupt
4
+
5
+ # Runs inside run.with_lock, always.
6
+ class Runner
7
+ MAX_STEPS_PER_ACTIVATION = 100
8
+
9
+ def initialize(run)
10
+ @run = run
11
+ @workflow = run.workflow_class
12
+ end
13
+
14
+ # Enter `step` (fresh entry or re-entry), then follow transitions
15
+ # until an interrupt, terminal step, or error stops the loop.
16
+ def enter(step, input: nil)
17
+ iterations = 0
18
+ loop do
19
+ iterations += 1
20
+ if iterations > MAX_STEPS_PER_ACTIVATION
21
+ return fail_run("activation exceeded #{MAX_STEPS_PER_ACTIVATION} step entries " \
22
+ "(transition loop at :#{step}?)")
23
+ end
24
+ record_entry(step)
25
+ ctx = ExecutionContext.new(@run, @workflow, input)
26
+ outcome = execute_body(step, ctx)
27
+ case outcome
28
+ in { interrupt: :wait_input } then return checkpoint("waiting_for_input")
29
+ in { interrupt: :handover } then return checkpoint("waiting_for_human")
30
+ in { interrupt: :poll_again } then return schedule_poll(step)
31
+ in { error: e } then return fail_run(e.message)
32
+ in { completed: true }
33
+ input = nil # input is consumed by the first step that runs after resume
34
+ nxt = next_step_from(step, ctx)
35
+ return if nxt.nil? # fail_run already called
36
+ return complete(nxt) if TERMINAL_STEPS.include?(nxt)
37
+ step = nxt
38
+ end
39
+ end
40
+ end
41
+
42
+ def poll_tick(step)
43
+ poll = @workflow.steps.fetch(step)[:poll]
44
+ if @run.attempts >= poll[:max_attempts]
45
+ target = @workflow.timeouts[step]&.fetch(:to) || :expired
46
+ if TERMINAL_STEPS.include?(target)
47
+ complete(target)
48
+ else
49
+ @run.update!(status: "running")
50
+ enter(target)
51
+ end
52
+ return
53
+ end
54
+ enter(step)
55
+ end
56
+
57
+ private
58
+
59
+ def record_entry(step)
60
+ # attempts.positive? distinguishes a genuine first entry (attempts=0, current_step
61
+ # pre-seeded by create!) from a re-entry into the same step.
62
+ same = @run.current_step == step.to_s && @run.attempts.positive?
63
+ @run.attempts = same ? @run.attempts + 1 : 1
64
+ @run.current_step = step.to_s
65
+ @run.timer_token += 1
66
+ @run.step_entered_at = Time.current
67
+ @run.save!
68
+
69
+ if (t = @workflow.timeouts[step])
70
+ WorkflowTimerJob.set(wait: t[:after])
71
+ .perform_later(@run.id, step.to_s, @run.timer_token, "timeout")
72
+ end
73
+ end
74
+
75
+ def execute_body(step, ctx)
76
+ body = @workflow.steps.fetch(step)[:body]
77
+ ActiveSupport::Notifications.instrument(
78
+ "omnibot.workflow.step",
79
+ workflow: @workflow, run_id: @run.id, step: step, attempts: @run.attempts
80
+ ) do |payload|
81
+ interrupted = catch(INTERRUPT) do
82
+ ctx.instance_exec(&body)
83
+ nil
84
+ end
85
+ payload[:status] = @run.status
86
+ interrupted ? { interrupt: interrupted } : { completed: true }
87
+ rescue StandardError => e
88
+ payload[:error] = e.message
89
+ payload[:status] = "failed"
90
+ { error: e }
91
+ end
92
+ end
93
+
94
+ def next_step_from(step, ctx)
95
+ rules = @workflow.transitions.select { |t| t[:from] == step }
96
+ return :done if rules.empty? # terminal-by-absence
97
+ rule = rules.find do |t|
98
+ next true if t[:if].nil?
99
+ begin
100
+ ctx.instance_exec(&t[:if])
101
+ rescue StandardError => e
102
+ fail_run("transition condition from :#{step} raised: #{e.message}")
103
+ return nil
104
+ end
105
+ end
106
+ if rule.nil?
107
+ fail_run("no transition matched from :#{step}")
108
+ return nil
109
+ end
110
+ ActiveSupport::Notifications.instrument(
111
+ "omnibot.workflow.transition",
112
+ workflow: @workflow, run_id: @run.id, from: step, to: rule[:to]
113
+ )
114
+ rule[:to]
115
+ end
116
+
117
+ def complete(terminal_step)
118
+ if terminal_step == :done && (hook = @workflow.on_complete_hook)
119
+ begin
120
+ ExecutionContext.new(@run, @workflow, nil).instance_exec(&hook)
121
+ rescue StandardError => e
122
+ # ponytail: a completed workflow whose hook failed lands in failed — retry! re-runs the
123
+ # FINAL step, so hooks should be idempotent
124
+ return fail_run("on_complete hook raised: #{e.message}")
125
+ end
126
+ end
127
+ @run.update!(status: terminal_step.to_s)
128
+ end
129
+
130
+ def fail_run(message)
131
+ @run.update!(status: "failed", error: message)
132
+ end
133
+
134
+ def checkpoint(status)
135
+ @run.update!(status: status)
136
+ end
137
+
138
+ def schedule_poll(step)
139
+ poll = @workflow.steps.fetch(step)[:poll]
140
+ return fail_run("poll_again called from non-poll step :#{step}") if poll.nil?
141
+ @run.update!(status: "running")
142
+ WorkflowTimerJob.set(wait: poll[:every])
143
+ .perform_later(@run.id, step.to_s, @run.timer_token, "poll")
144
+ end
145
+ end
146
+
147
+ class ExecutionContext
148
+ attr_reader :run, :input
149
+
150
+ def initialize(run, workflow, input)
151
+ @run = run
152
+ @workflow = workflow
153
+ @input = input
154
+ define_state_proxy
155
+ end
156
+
157
+ def state = @state_proxy
158
+ def attempts = @run.attempts
159
+
160
+ def reply(text)
161
+ @run.replies << text
162
+ ActiveSupport::Notifications.instrument(
163
+ "omnibot.workflow.reply",
164
+ workflow: @workflow, run_id: @run.id, step: @run.current_step, text: text
165
+ )
166
+ text
167
+ end
168
+
169
+ def wait_for_input = throw(INTERRUPT, :wait_input)
170
+
171
+ def handover!(reason: nil)
172
+ ActiveSupport::Notifications.instrument(
173
+ "omnibot.workflow.handover",
174
+ workflow: @workflow, run_id: @run.id, step: @run.current_step, reason: reason
175
+ )
176
+ throw(INTERRUPT, :handover)
177
+ end
178
+
179
+ def poll_again = throw(INTERRUPT, :poll_again)
180
+
181
+ private
182
+
183
+ def define_state_proxy
184
+ run = @run
185
+ keys = @workflow.state_keys
186
+ @state_proxy = Object.new
187
+ keys.each do |key|
188
+ @state_proxy.define_singleton_method(key) { run.state[key.to_s] }
189
+ @state_proxy.define_singleton_method("#{key}=") do |v|
190
+ run.state = run.state.merge(key.to_s => v)
191
+ end
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,127 @@
1
+ module Omnibot
2
+ class Workflow
3
+ TERMINAL_STEPS = %i[done expired failed cancelled].freeze
4
+ WHILE_RUNNING_MODES = %i[ignore interrupt].freeze
5
+
6
+ class << self
7
+ def state(*keys) = keys.any? ? state_keys.concat(keys) : state_keys
8
+ def state_keys = @state_keys ||= []
9
+
10
+ def step(name, poll: nil, &body)
11
+ steps[name.to_sym] = { body: body, poll: poll }
12
+ end
13
+
14
+ def steps = @steps ||= {}
15
+
16
+ def transition(from:, to:, if: nil)
17
+ cond = binding.local_variable_get(:if)
18
+ transitions << { from: from.to_sym, to: to.to_sym, if: cond }
19
+ end
20
+
21
+ def transitions = @transitions ||= []
22
+
23
+ def timeout(step, after:, to:)
24
+ timeouts[step.to_sym] = { after: after, to: to.to_sym }
25
+ end
26
+
27
+ def timeouts = @timeouts ||= {}
28
+
29
+ def on_complete(&blk) = blk ? @on_complete_hook = blk : nil
30
+ def on_complete_hook = @on_complete_hook
31
+
32
+ def while_running(mode = nil)
33
+ return @while_running || :ignore if mode.nil?
34
+ unless WHILE_RUNNING_MODES.include?(mode)
35
+ raise ArgumentError, "while_running must be :ignore or :interrupt"
36
+ end
37
+ @while_running = mode
38
+ end
39
+
40
+ def start(ref: nil, state: {})
41
+ first = steps.keys.first or raise WorkflowError, "#{name} declares no steps"
42
+ run = WorkflowRun.create!(
43
+ type: name, status: "running", current_step: first.to_s,
44
+ state: state.transform_keys(&:to_s), attempts: 0, timer_token: 0,
45
+ step_entered_at: Time.current, ref: ref
46
+ )
47
+ run.replies = []
48
+ run.with_lock { Runner.new(run).enter(first) }
49
+ run
50
+ end
51
+
52
+ def resume(run, input: nil)
53
+ run.reload
54
+ run.replies = []
55
+ run.with_lock do
56
+ case run.status
57
+ when "waiting_for_input"
58
+ step = run.current_step.to_sym
59
+ ctx = ExecutionContext.new(run, self, input)
60
+ runner = Runner.new(run)
61
+ nxt = runner.send(:next_step_from, step, ctx)
62
+ break if nxt.nil?
63
+ if TERMINAL_STEPS.include?(nxt)
64
+ runner.send(:complete, nxt)
65
+ else
66
+ runner.enter(nxt, input: input)
67
+ end
68
+ when "running"
69
+ if while_running == :interrupt
70
+ raise NotImplementedError, "while_running :interrupt ships in v0.3"
71
+ end
72
+ # :ignore — return unchanged
73
+ when "waiting_for_human"
74
+ raise WorkflowError::StaleResume, "use resume_from_human for waiting_for_human runs"
75
+ else
76
+ raise WorkflowError::StaleResume, "cannot resume a #{run.status} run"
77
+ end
78
+ end
79
+ run
80
+ end
81
+
82
+ def resume_from_human(run, input: nil)
83
+ control(run, allowed: %w[waiting_for_human]) do
84
+ run.update!(status: "running")
85
+ Runner.new(run).enter(run.current_step.to_sym, input: input)
86
+ end
87
+ end
88
+
89
+ def retry!(run)
90
+ control(run, allowed: %w[failed]) do
91
+ run.update!(status: "running", error: nil)
92
+ Runner.new(run).enter(run.current_step.to_sym)
93
+ end
94
+ end
95
+
96
+ def cancel!(run)
97
+ control(run, allowed: WorkflowRun::ACTIVE_STATUSES) do
98
+ run.update!(status: "cancelled")
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ def control(run, allowed:)
105
+ run.reload
106
+ run.replies = []
107
+ run.with_lock do
108
+ unless allowed.include?(run.status)
109
+ raise WorkflowError::StaleResume, "cannot perform this on a #{run.status} run"
110
+ end
111
+ yield
112
+ end
113
+ run
114
+ end
115
+
116
+ def inherited(subclass)
117
+ super
118
+ subclass.instance_variable_set(:@state_keys, state_keys.dup)
119
+ subclass.instance_variable_set(:@steps, steps.dup)
120
+ subclass.instance_variable_set(:@transitions, transitions.map(&:dup))
121
+ subclass.instance_variable_set(:@timeouts, timeouts.dup)
122
+ subclass.instance_variable_set(:@on_complete_hook, @on_complete_hook)
123
+ subclass.instance_variable_set(:@while_running, @while_running)
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,25 @@
1
+ require "active_record"
2
+
3
+ module Omnibot
4
+ class WorkflowRun < ActiveRecord::Base
5
+ self.table_name = "omnibot_workflow_runs"
6
+ self.inheritance_column = nil # `type` stores the workflow class name, not STI
7
+
8
+ ACTIVE_STATUSES = %w[running waiting_for_input waiting_for_human].freeze
9
+ TERMINAL_STATUSES = %w[done failed expired cancelled].freeze
10
+
11
+ belongs_to :ref, polymorphic: true, optional: true
12
+
13
+ attr_accessor :replies
14
+
15
+ after_initialize { @replies ||= [] }
16
+
17
+ def active? = ACTIVE_STATUSES.include?(status)
18
+ def terminal? = TERMINAL_STATUSES.include?(status)
19
+ def workflow_class = type.constantize
20
+
21
+ def retry! = workflow_class.retry!(self)
22
+ def cancel! = workflow_class.cancel!(self)
23
+ def resume_from_human(input: nil) = workflow_class.resume_from_human(self, input: input)
24
+ end
25
+ end
@@ -0,0 +1,31 @@
1
+ module Omnibot
2
+ class WorkflowTimerJob < ActiveJob::Base
3
+ def perform(run_id, step, token, kind)
4
+ run = WorkflowRun.find_by(id: run_id) or return
5
+ run.replies = []
6
+ run.with_lock do
7
+ return unless run.active?
8
+ return unless run.current_step == step && run.timer_token == token
9
+
10
+ workflow = run.workflow_class
11
+ case kind
12
+ when "timeout"
13
+ ActiveSupport::Notifications.instrument(
14
+ "omnibot.workflow.timeout",
15
+ workflow: workflow, run_id: run.id, step: step.to_sym
16
+ )
17
+ target = workflow.timeouts.fetch(step.to_sym)[:to]
18
+ runner = Workflow::Runner.new(run)
19
+ if Workflow::TERMINAL_STEPS.include?(target)
20
+ runner.send(:complete, target)
21
+ else
22
+ run.update!(status: "running")
23
+ runner.enter(target)
24
+ end
25
+ when "poll"
26
+ Workflow::Runner.new(run).poll_tick(step.to_sym)
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
data/lib/omnibot.rb CHANGED
@@ -3,6 +3,8 @@ require "active_support"
3
3
  require "active_support/core_ext"
4
4
  require "active_support/notifications"
5
5
  require "ruby_llm"
6
+ require "active_record"
7
+ require "active_job"
6
8
 
7
9
  require_relative "omnibot/version"
8
10
  require_relative "omnibot/errors"
@@ -11,3 +13,7 @@ require_relative "omnibot/tool"
11
13
  require_relative "omnibot/result"
12
14
  require_relative "omnibot/agent"
13
15
  require_relative "omnibot/testing"
16
+ require_relative "omnibot/workflow_run"
17
+ require_relative "omnibot/workflow"
18
+ require_relative "omnibot/workflow/runner"
19
+ require_relative "omnibot/workflow_timer_job"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: omnibot-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.2.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Johannes Dwicahyo
@@ -37,6 +37,34 @@ dependencies:
37
37
  - - ">="
38
38
  - !ruby/object:Gem::Version
39
39
  version: '7.1'
40
+ - !ruby/object:Gem::Dependency
41
+ name: activerecord
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: '7.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - ">="
52
+ - !ruby/object:Gem::Version
53
+ version: '7.1'
54
+ - !ruby/object:Gem::Dependency
55
+ name: activejob
56
+ requirement: !ruby/object:Gem::Requirement
57
+ requirements:
58
+ - - ">="
59
+ - !ruby/object:Gem::Version
60
+ version: '7.1'
61
+ type: :runtime
62
+ prerelease: false
63
+ version_requirements: !ruby/object:Gem::Requirement
64
+ requirements:
65
+ - - ">="
66
+ - !ruby/object:Gem::Version
67
+ version: '7.1'
40
68
  description: 'Agent framework for Rails: bounded tool-calling loops, fast paths, structured
41
69
  extraction, and (v0.2) durable workflows on ActiveRecord. Built on ruby_llm.'
42
70
  email:
@@ -51,7 +79,11 @@ files:
51
79
  - lib/generators/omnibot/agent/templates/agent.rb.tt
52
80
  - lib/generators/omnibot/agent/templates/agent_spec.rb.tt
53
81
  - lib/generators/omnibot/install/install_generator.rb
82
+ - lib/generators/omnibot/install/templates/create_omnibot_workflow_runs.rb.tt
54
83
  - lib/generators/omnibot/install/templates/initializer.rb.tt
84
+ - lib/generators/omnibot/workflow/templates/workflow.rb.tt
85
+ - lib/generators/omnibot/workflow/templates/workflow_spec.rb.tt
86
+ - lib/generators/omnibot/workflow/workflow_generator.rb
55
87
  - lib/omnibot.rb
56
88
  - lib/omnibot/agent.rb
57
89
  - lib/omnibot/config.rb
@@ -60,6 +92,10 @@ files:
60
92
  - lib/omnibot/testing.rb
61
93
  - lib/omnibot/tool.rb
62
94
  - lib/omnibot/version.rb
95
+ - lib/omnibot/workflow.rb
96
+ - lib/omnibot/workflow/runner.rb
97
+ - lib/omnibot/workflow_run.rb
98
+ - lib/omnibot/workflow_timer_job.rb
63
99
  homepage: https://github.com/johannesdwicahyo/omnibot-ruby
64
100
  licenses:
65
101
  - MIT