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 +4 -4
- data/README.md +92 -4
- data/lib/generators/omnibot/install/install_generator.rb +12 -0
- data/lib/generators/omnibot/install/templates/create_omnibot_workflow_runs.rb.tt +19 -0
- data/lib/generators/omnibot/workflow/templates/workflow.rb.tt +16 -0
- data/lib/generators/omnibot/workflow/templates/workflow_spec.rb.tt +12 -0
- data/lib/generators/omnibot/workflow/workflow_generator.rb +14 -0
- data/lib/omnibot/errors.rb +4 -1
- data/lib/omnibot/version.rb +1 -1
- data/lib/omnibot/workflow/runner.rb +196 -0
- data/lib/omnibot/workflow.rb +127 -0
- data/lib/omnibot/workflow_run.rb +25 -0
- data/lib/omnibot/workflow_timer_job.rb +31 -0
- data/lib/omnibot.rb +6 -0
- metadata +37 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 6a9e962ec1fc30233e103cc753f9db7db243db279f3bb51f6fa8a7bf5abdd940
|
|
4
|
+
data.tar.gz: 73f4531396f0d8a73a7e6302195a6ff42a9d70ab9b3436a0c9c877a5f234af85
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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** (
|
|
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
|
-
|
|
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
|
-
- **
|
|
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
|
data/lib/omnibot/errors.rb
CHANGED
|
@@ -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
|
|
6
|
+
class WorkflowError < Error
|
|
7
|
+
class InvalidTransition < WorkflowError; end
|
|
8
|
+
class StaleResume < WorkflowError; end
|
|
9
|
+
end
|
|
7
10
|
end
|
data/lib/omnibot/version.rb
CHANGED
|
@@ -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.
|
|
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
|