fosm-rails 0.1.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 +7 -0
- data/AGENTS.md +384 -0
- data/LICENSE +115 -0
- data/README.md +322 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/fosm/rails/application.css +15 -0
- data/app/controllers/fosm/admin/agents_controller.rb +242 -0
- data/app/controllers/fosm/admin/apps_controller.rb +34 -0
- data/app/controllers/fosm/admin/base_controller.rb +15 -0
- data/app/controllers/fosm/admin/dashboard_controller.rb +25 -0
- data/app/controllers/fosm/admin/settings_controller.rb +44 -0
- data/app/controllers/fosm/admin/transitions_controller.rb +22 -0
- data/app/controllers/fosm/admin/webhooks_controller.rb +37 -0
- data/app/controllers/fosm/application_controller.rb +23 -0
- data/app/controllers/fosm/rails/application_controller.rb +6 -0
- data/app/helpers/fosm/application_helper.rb +19 -0
- data/app/helpers/fosm/rails/application_helper.rb +11 -0
- data/app/jobs/fosm/application_job.rb +6 -0
- data/app/jobs/fosm/rails/application_job.rb +6 -0
- data/app/jobs/fosm/webhook_delivery_job.rb +52 -0
- data/app/models/fosm/application_record.rb +6 -0
- data/app/models/fosm/rails/application_record.rb +7 -0
- data/app/models/fosm/transition_log.rb +27 -0
- data/app/models/fosm/webhook_subscription.rb +15 -0
- data/app/views/fosm/admin/agents/chat.html.erb +242 -0
- data/app/views/fosm/admin/agents/show.html.erb +166 -0
- data/app/views/fosm/admin/apps/show.html.erb +114 -0
- data/app/views/fosm/admin/dashboard/index.html.erb +82 -0
- data/app/views/fosm/admin/settings/show.html.erb +63 -0
- data/app/views/fosm/admin/transitions/index.html.erb +65 -0
- data/app/views/fosm/admin/webhooks/index.html.erb +51 -0
- data/app/views/fosm/admin/webhooks/new.html.erb +45 -0
- data/app/views/layouts/fosm/application.html.erb +41 -0
- data/app/views/layouts/fosm/rails/application.html.erb +17 -0
- data/config/routes.rb +17 -0
- data/db/migrate/20240101000001_create_fosm_transition_logs.rb +23 -0
- data/db/migrate/20240101000002_create_fosm_webhook_subscriptions.rb +16 -0
- data/lib/fosm/agent.rb +232 -0
- data/lib/fosm/configuration.rb +50 -0
- data/lib/fosm/engine.rb +133 -0
- data/lib/fosm/errors.rb +31 -0
- data/lib/fosm/lifecycle/definition.rb +103 -0
- data/lib/fosm/lifecycle/event_definition.rb +27 -0
- data/lib/fosm/lifecycle/guard_definition.rb +16 -0
- data/lib/fosm/lifecycle/side_effect_definition.rb +16 -0
- data/lib/fosm/lifecycle/state_definition.rb +18 -0
- data/lib/fosm/lifecycle.rb +173 -0
- data/lib/fosm/rails/engine.rb +9 -0
- data/lib/fosm/rails/version.rb +9 -0
- data/lib/fosm/rails.rb +9 -0
- data/lib/fosm/registry.rb +29 -0
- data/lib/fosm/version.rb +3 -0
- data/lib/fosm-rails.rb +40 -0
- data/lib/generators/fosm/app/app_generator.rb +106 -0
- data/lib/generators/fosm/app/templates/agent.rb.tt +26 -0
- data/lib/generators/fosm/app/templates/controller.rb.tt +56 -0
- data/lib/generators/fosm/app/templates/migration.rb.tt +14 -0
- data/lib/generators/fosm/app/templates/model.rb.tt +31 -0
- data/lib/generators/fosm/app/templates/views/_form.html.erb.tt +24 -0
- data/lib/generators/fosm/app/templates/views/index.html.erb.tt +37 -0
- data/lib/generators/fosm/app/templates/views/new.html.erb.tt +4 -0
- data/lib/generators/fosm/app/templates/views/show.html.erb.tt +57 -0
- data/lib/tasks/fosm/rails_tasks.rake +4 -0
- metadata +139 -0
data/config/routes.rb
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Fosm::Engine.routes.draw do
|
|
2
|
+
namespace :admin do
|
|
3
|
+
root to: "dashboard#index"
|
|
4
|
+
resources :apps, only: [:index, :show], param: :slug do
|
|
5
|
+
member do
|
|
6
|
+
get :agent, to: "agents#show"
|
|
7
|
+
post :agent_invoke, to: "agents#agent_invoke"
|
|
8
|
+
get "agent/chat", to: "agents#chat", as: :agent_chat
|
|
9
|
+
post "agent/chat", to: "agents#chat_send", as: :agent_chat_send
|
|
10
|
+
delete "agent/chat", to: "agents#chat_reset", as: :agent_chat_reset
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
resources :transitions, only: [:index]
|
|
14
|
+
resources :webhooks, only: [:index, :new, :create, :destroy]
|
|
15
|
+
resource :settings, only: [:show]
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
class CreateFosmTransitionLogs < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :fosm_transition_logs do |t|
|
|
4
|
+
t.string :record_type, null: false
|
|
5
|
+
t.string :record_id, null: false
|
|
6
|
+
t.string :event_name, null: false
|
|
7
|
+
t.string :from_state, null: false
|
|
8
|
+
t.string :to_state, null: false
|
|
9
|
+
t.string :actor_type
|
|
10
|
+
t.string :actor_id
|
|
11
|
+
t.string :actor_label
|
|
12
|
+
t.column :metadata, :json, default: {} # use json (works for SQLite + PostgreSQL; jsonb on PG only)
|
|
13
|
+
|
|
14
|
+
# Intentionally no updated_at — this is an immutable log
|
|
15
|
+
t.datetime :created_at, null: false, default: -> { "CURRENT_TIMESTAMP" }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
add_index :fosm_transition_logs, [:record_type, :record_id], name: "idx_fosm_tl_record"
|
|
19
|
+
add_index :fosm_transition_logs, :event_name, name: "idx_fosm_tl_event"
|
|
20
|
+
add_index :fosm_transition_logs, :created_at, name: "idx_fosm_tl_created_at"
|
|
21
|
+
add_index :fosm_transition_logs, :actor_label, name: "idx_fosm_tl_actor"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
class CreateFosmWebhookSubscriptions < ActiveRecord::Migration[8.1]
|
|
2
|
+
def change
|
|
3
|
+
create_table :fosm_webhook_subscriptions do |t|
|
|
4
|
+
t.string :model_class_name, null: false
|
|
5
|
+
t.string :event_name, null: false
|
|
6
|
+
t.string :url, null: false
|
|
7
|
+
t.boolean :active, default: true, null: false
|
|
8
|
+
t.string :secret_token
|
|
9
|
+
|
|
10
|
+
t.timestamps
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
add_index :fosm_webhook_subscriptions, [:model_class_name, :event_name], name: "idx_fosm_webhooks_model_event"
|
|
14
|
+
add_index :fosm_webhook_subscriptions, :active, name: "idx_fosm_webhooks_active"
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/fosm/agent.rb
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
module Fosm
|
|
2
|
+
# Base class for FOSM AI agents powered by Gemlings.
|
|
3
|
+
#
|
|
4
|
+
# Each generated FOSM app gets an agent class that inherits from this.
|
|
5
|
+
# ::Gemlings::Tool instances are auto-generated from the lifecycle definition,
|
|
6
|
+
# giving the AI agent a bounded, machine-enforced set of actions.
|
|
7
|
+
#
|
|
8
|
+
# The AI agent can ONLY fire lifecycle events. It cannot directly update state.
|
|
9
|
+
# This is the "bounded autonomy" guarantee — the state machine is the guardrail.
|
|
10
|
+
# If a transition isn't valid, the tool returns { success: false } — the agent
|
|
11
|
+
# cannot bypass the machine.
|
|
12
|
+
#
|
|
13
|
+
# Gemlings is a required dependency of fosm-rails (declared in gemspec).
|
|
14
|
+
# See: https://github.com/khasinski/gemlings
|
|
15
|
+
#
|
|
16
|
+
# Usage:
|
|
17
|
+
#
|
|
18
|
+
# class Fosm::InvoiceAgent < Fosm::Agent
|
|
19
|
+
# model_class Fosm::Invoice
|
|
20
|
+
# default_model "anthropic/claude-sonnet-4-20250514"
|
|
21
|
+
#
|
|
22
|
+
# # Optional: add custom tools using Gemlings inline API
|
|
23
|
+
# # fosm_tool :find_overdue,
|
|
24
|
+
# # description: "Find sent invoices past their due date",
|
|
25
|
+
# # inputs: {} do
|
|
26
|
+
# # Fosm::Invoice.where(state: "sent").where("due_date < ?", Date.today)
|
|
27
|
+
# # .map { |inv| { id: inv.id, due_date: inv.due_date } }
|
|
28
|
+
# # end
|
|
29
|
+
# end
|
|
30
|
+
#
|
|
31
|
+
# # Build and run a Gemlings agent
|
|
32
|
+
# agent = Fosm::InvoiceAgent.build_agent
|
|
33
|
+
# agent.run("Mark all sent invoices older than 30 days as overdue")
|
|
34
|
+
#
|
|
35
|
+
class Agent
|
|
36
|
+
class << self
|
|
37
|
+
# Declares the model class this agent operates on.
|
|
38
|
+
# Resets the cached tool list so tools are regenerated on next call to .tools
|
|
39
|
+
def model_class(klass = nil)
|
|
40
|
+
if klass
|
|
41
|
+
@model_class = klass
|
|
42
|
+
@tools = nil
|
|
43
|
+
end
|
|
44
|
+
@model_class
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Sets/gets the default Gemlings model string.
|
|
48
|
+
# Format: "provider/model_name" — see Gemlings docs for supported providers.
|
|
49
|
+
def default_model(model = nil)
|
|
50
|
+
@default_model = model if model
|
|
51
|
+
@default_model || "anthropic/claude-sonnet-4-20250514"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Declare a custom Gemlings tool using the inline ::Gemlings.tool API.
|
|
55
|
+
#
|
|
56
|
+
# @param name [Symbol] snake_case tool name
|
|
57
|
+
# @param description [String] what this tool does (shown to the LLM)
|
|
58
|
+
# @param inputs [Hash] { param_name: "description" } for each parameter
|
|
59
|
+
# @param block [Proc] the tool implementation
|
|
60
|
+
#
|
|
61
|
+
# Example:
|
|
62
|
+
# fosm_tool :find_overdue_invoices,
|
|
63
|
+
# description: "Find all invoices that are past their due date",
|
|
64
|
+
# inputs: {} do
|
|
65
|
+
# Fosm::Invoice.where(state: "sent")
|
|
66
|
+
# .where("due_date < ?", Date.today)
|
|
67
|
+
# .map { |inv| { id: inv.id, due_date: inv.due_date.to_s } }
|
|
68
|
+
# end
|
|
69
|
+
def fosm_tool(name, description:, inputs: {}, &block)
|
|
70
|
+
@custom_tool_definitions ||= []
|
|
71
|
+
@custom_tool_definitions << { name: name, description: description, inputs: inputs, block: block }
|
|
72
|
+
@tools = nil # Reset cached tools
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Returns all Gemlings tool instances for this agent.
|
|
76
|
+
# Lazily built and cached — standard tools from lifecycle + custom tools.
|
|
77
|
+
def tools
|
|
78
|
+
@tools ||= build_all_tools
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Builds and returns a configured ::Gemlings::CodeAgent (default) or
|
|
82
|
+
# ::Gemlings::ToolCallingAgent ready to run tasks within FOSM constraints.
|
|
83
|
+
#
|
|
84
|
+
# @param model [String] override the default model, e.g. "openai/gpt-4o"
|
|
85
|
+
# @param agent_type [Symbol] :code (default) or :tool_calling
|
|
86
|
+
# @param instructions [String] extra instructions appended to system prompt
|
|
87
|
+
# @param kwargs [Hash] additional ::Gemlings::CodeAgent options
|
|
88
|
+
# (max_steps:, planning_interval:, callbacks:, output_type:, etc.)
|
|
89
|
+
def build_agent(model: nil, agent_type: :tool_calling, instructions: nil, **kwargs)
|
|
90
|
+
agent_class = agent_type == :tool_calling ? ::Gemlings::ToolCallingAgent : ::Gemlings::CodeAgent
|
|
91
|
+
|
|
92
|
+
agent_class.new(
|
|
93
|
+
model: model || default_model,
|
|
94
|
+
tools: tools,
|
|
95
|
+
instructions: build_system_instructions(instructions),
|
|
96
|
+
**kwargs
|
|
97
|
+
)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
private
|
|
101
|
+
|
|
102
|
+
def build_all_tools
|
|
103
|
+
raise ArgumentError, "#{name}.model_class is not set" unless @model_class
|
|
104
|
+
|
|
105
|
+
lifecycle = @model_class.fosm_lifecycle
|
|
106
|
+
raise ArgumentError, "#{@model_class.name} has no lifecycle defined" unless lifecycle
|
|
107
|
+
|
|
108
|
+
standard = build_standard_tools(@model_class, lifecycle)
|
|
109
|
+
custom = build_custom_tools
|
|
110
|
+
|
|
111
|
+
standard + custom
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
# Generates Gemlings tools from the lifecycle definition using ::Gemlings.tool inline API.
|
|
115
|
+
# Creates: list, get, available_events, transition_history, + one per event.
|
|
116
|
+
def build_standard_tools(klass, lifecycle)
|
|
117
|
+
mn = klass.name.demodulize.underscore # e.g. "invoice"
|
|
118
|
+
tools = []
|
|
119
|
+
|
|
120
|
+
# list_invoices — list all records, optionally filtered by state
|
|
121
|
+
tools << ::Gemlings.tool(
|
|
122
|
+
:"list_#{mn.pluralize}",
|
|
123
|
+
"List #{mn.pluralize} with their current state. Pass state: 'draft' to filter.",
|
|
124
|
+
state: "Optional state filter (e.g. 'draft', 'sent')"
|
|
125
|
+
) do |state: nil|
|
|
126
|
+
records = state.present? ? klass.where(state: state) : klass.all
|
|
127
|
+
records.map { |r|
|
|
128
|
+
{ id: r.id, state: r.state }
|
|
129
|
+
.merge(r.attributes.except("id", "state", "created_by_id", "created_at", "updated_at"))
|
|
130
|
+
}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
# get_invoice — fetch a single record by ID
|
|
134
|
+
tools << ::Gemlings.tool(
|
|
135
|
+
:"get_#{mn}",
|
|
136
|
+
"Get a #{mn} by ID with its current state and available lifecycle events.",
|
|
137
|
+
id: "The #{mn} ID (integer)"
|
|
138
|
+
) do |id:|
|
|
139
|
+
record = klass.find_by(id: id)
|
|
140
|
+
next({ error: "#{mn.humanize} ##{id} not found" }) unless record
|
|
141
|
+
|
|
142
|
+
{ id: record.id, state: record.state, available_events: record.available_events }
|
|
143
|
+
.merge(record.attributes.except("id", "state", "created_by_id", "created_at", "updated_at"))
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# available_events_for_invoice
|
|
147
|
+
tools << ::Gemlings.tool(
|
|
148
|
+
:"available_events_for_#{mn}",
|
|
149
|
+
"Returns which lifecycle events can fire on a #{mn} from its current state. Always check this before firing.",
|
|
150
|
+
id: "The #{mn} ID (integer)"
|
|
151
|
+
) do |id:|
|
|
152
|
+
record = klass.find_by(id: id)
|
|
153
|
+
next({ error: "#{mn.humanize} ##{id} not found" }) unless record
|
|
154
|
+
{ id: record.id, current_state: record.state, available_events: record.available_events }
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
# transition_history_for_invoice
|
|
158
|
+
tools << ::Gemlings.tool(
|
|
159
|
+
:"transition_history_for_#{mn}",
|
|
160
|
+
"Returns the full audit trail of every state transition for a #{mn}.",
|
|
161
|
+
id: "The #{mn} ID (integer)"
|
|
162
|
+
) do |id:|
|
|
163
|
+
Fosm::TransitionLog
|
|
164
|
+
.where(record_type: klass.name, record_id: id.to_s)
|
|
165
|
+
.order(created_at: :asc)
|
|
166
|
+
.map { |l|
|
|
167
|
+
{ event: l.event_name, from: l.from_state, to: l.to_state,
|
|
168
|
+
actor: l.actor_label || l.actor_type, at: l.created_at.iso8601 }
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# One tool per lifecycle event — the bounded autonomy guarantee.
|
|
173
|
+
# The agent can only fire declared events. Invalid transitions return { success: false }.
|
|
174
|
+
lifecycle.events.each do |event_def|
|
|
175
|
+
from_desc = event_def.from_states.join(" or ")
|
|
176
|
+
guard_note = event_def.guards.any? ? " Requires guards: #{event_def.guards.map(&:name).join(', ')}." : ""
|
|
177
|
+
|
|
178
|
+
tools << ::Gemlings.tool(
|
|
179
|
+
:"#{event_def.name}_#{mn}",
|
|
180
|
+
"Fire the '#{event_def.name}' event on a #{mn}. " \
|
|
181
|
+
"Valid from state [#{from_desc}] → #{event_def.to_state}.#{guard_note} " \
|
|
182
|
+
"Returns { success: false } if the machine rejects the transition.",
|
|
183
|
+
id: "The #{mn} ID (integer)"
|
|
184
|
+
) do |id:|
|
|
185
|
+
record = klass.find_by(id: id)
|
|
186
|
+
next({ success: false, error: "#{mn.humanize} ##{id} not found" }) unless record
|
|
187
|
+
|
|
188
|
+
record.fire!(event_def.name, actor: :agent)
|
|
189
|
+
{ success: true, id: record.id, previous_state: event_def.from_states.first,
|
|
190
|
+
new_state: record.reload.state }
|
|
191
|
+
rescue Fosm::Error => e
|
|
192
|
+
{ success: false, error: e.message, current_state: record&.state }
|
|
193
|
+
end
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
tools
|
|
197
|
+
end
|
|
198
|
+
|
|
199
|
+
def build_custom_tools
|
|
200
|
+
(@custom_tool_definitions || []).map do |defn|
|
|
201
|
+
::Gemlings.tool(defn[:name], defn[:description], **defn[:inputs], &defn[:block])
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Builds a system prompt that communicates FOSM constraints to the LLM.
|
|
206
|
+
def build_system_instructions(extra = nil)
|
|
207
|
+
klass = @model_class
|
|
208
|
+
lifecycle = klass.fosm_lifecycle
|
|
209
|
+
state_names = lifecycle.state_names.join(", ")
|
|
210
|
+
terminal_states = lifecycle.states.select(&:terminal?).map(&:name).join(", ")
|
|
211
|
+
event_names = lifecycle.event_names.join(", ")
|
|
212
|
+
mn = klass.name.demodulize.underscore
|
|
213
|
+
|
|
214
|
+
base = <<~INSTRUCTIONS
|
|
215
|
+
You are a FOSM AI agent managing #{klass.name.demodulize.pluralize.humanize}.
|
|
216
|
+
|
|
217
|
+
ARCHITECTURE CONSTRAINTS — you MUST follow these at all times:
|
|
218
|
+
1. State changes happen ONLY via lifecycle event tools. Never use direct updates.
|
|
219
|
+
2. Valid states: #{state_names}
|
|
220
|
+
3. Terminal states (no further transitions allowed): #{terminal_states.presence || "none"}
|
|
221
|
+
4. Available lifecycle events: #{event_names}
|
|
222
|
+
5. ALWAYS call available_events_for_#{mn}(id:) before firing any event.
|
|
223
|
+
6. If an event tool returns { success: false }, DO NOT retry — report the error.
|
|
224
|
+
7. Records in a terminal state cannot transition further — accept this.
|
|
225
|
+
8. Think step by step. State your reasoning before any action that changes state.
|
|
226
|
+
INSTRUCTIONS
|
|
227
|
+
|
|
228
|
+
extra.present? ? "#{base.strip}\n\n#{extra}" : base.strip
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
232
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
module Fosm
|
|
2
|
+
class Configuration
|
|
3
|
+
# The base controller class the engine's controllers will inherit from.
|
|
4
|
+
# Set this to match your app's ApplicationController.
|
|
5
|
+
attr_accessor :base_controller
|
|
6
|
+
|
|
7
|
+
# A callable that authorizes access to the /fosm/admin area.
|
|
8
|
+
# Called via instance_exec in the controller before_action.
|
|
9
|
+
# Example: -> { redirect_to root_path unless current_user&.superadmin? }
|
|
10
|
+
attr_accessor :admin_authorize
|
|
11
|
+
|
|
12
|
+
# A callable that authorizes access to individual FOSM apps.
|
|
13
|
+
# Receives the access_level declared in the app definition.
|
|
14
|
+
# Example: ->(level) { authenticate_user! }
|
|
15
|
+
attr_accessor :app_authorize
|
|
16
|
+
|
|
17
|
+
# A callable that returns the current user from the controller context.
|
|
18
|
+
# Used for transition log actor tracking.
|
|
19
|
+
attr_accessor :current_user_method
|
|
20
|
+
|
|
21
|
+
# Layout used for the admin section
|
|
22
|
+
attr_accessor :admin_layout
|
|
23
|
+
|
|
24
|
+
# Default layout used for generated FOSM app views
|
|
25
|
+
attr_accessor :app_layout
|
|
26
|
+
|
|
27
|
+
def initialize
|
|
28
|
+
@base_controller = "ApplicationController"
|
|
29
|
+
@admin_authorize = -> { true } # Override in initializer!
|
|
30
|
+
@app_authorize = ->(_level) { true } # Override in initializer!
|
|
31
|
+
@current_user_method = -> { defined?(current_user) ? current_user : nil }
|
|
32
|
+
@admin_layout = "fosm/application"
|
|
33
|
+
@app_layout = "application"
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
class << self
|
|
38
|
+
def configuration
|
|
39
|
+
@configuration ||= Configuration.new
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def configure
|
|
43
|
+
yield configuration
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def config
|
|
47
|
+
configuration
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
data/lib/fosm/engine.rb
ADDED
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
require "rails/engine"
|
|
2
|
+
|
|
3
|
+
module Fosm
|
|
4
|
+
class Engine < ::Rails::Engine
|
|
5
|
+
isolate_namespace Fosm
|
|
6
|
+
|
|
7
|
+
config.generators do |g|
|
|
8
|
+
g.test_framework :minitest
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Expose configuration
|
|
12
|
+
initializer "fosm.configuration" do
|
|
13
|
+
# Host app can configure via config/initializers/fosm.rb
|
|
14
|
+
# Run: rails fosm:install:migrations && rails db:migrate
|
|
15
|
+
|
|
16
|
+
# ── Gemlings compatibility patches ────────────────────────────────────────
|
|
17
|
+
# These patches fix Anthropic API incompatibilities in Gemlings' ToolCallingAgent.
|
|
18
|
+
# Each patch is guarded: it reads the upstream source file and checks whether the
|
|
19
|
+
# fix is already present. If Gemlings merges the fix, the patch becomes a no-op.
|
|
20
|
+
#
|
|
21
|
+
# Upstream PR: https://github.com/khasinski/gemlings (submitted from fork)
|
|
22
|
+
#
|
|
23
|
+
# TODO: remove these patches once the upstream PR is merged and a fixed version
|
|
24
|
+
# of gemlings is released. Steps to clean up:
|
|
25
|
+
# 1. Check if gemlings >= X.Y (the version that includes the fix) is the minimum
|
|
26
|
+
# required version in fosm-rails.gemspec.
|
|
27
|
+
# 2. Delete the two `unless` blocks below (lines ~37-104).
|
|
28
|
+
# 3. Remove the `_gemlings_dir`, `_memory_text`, `_adapter_text` variables.
|
|
29
|
+
# 4. Update AGENTS.md "Compatibility note" section to remove the patch description.
|
|
30
|
+
# 5. Bump fosm-rails version with a note in CHANGELOG.
|
|
31
|
+
# Track: https://github.com/khasinski/gemlings/pull/[PR_NUMBER]
|
|
32
|
+
|
|
33
|
+
# Read upstream source files directly from the gem installation path.
|
|
34
|
+
# We avoid using source_location on the methods themselves because prepend
|
|
35
|
+
# would redirect source_location to this engine file after patching.
|
|
36
|
+
_gemlings_dir = Gem.loaded_specs["gemlings"]&.gem_dir || ""
|
|
37
|
+
_memory_text = File.read(File.join(_gemlings_dir, "lib/gemlings/memory.rb")) rescue ""
|
|
38
|
+
_adapter_text = File.read(File.join(_gemlings_dir, "lib/gemlings/models/ruby_llm_adapter.rb")) rescue ""
|
|
39
|
+
|
|
40
|
+
# Patch 1 — Memory#to_messages (needed if upstream hasn't added tool_result + rstrip)
|
|
41
|
+
#
|
|
42
|
+
# Fixes:
|
|
43
|
+
# a) Trailing whitespace — Anthropic rejects assistant content ending with whitespace.
|
|
44
|
+
# b) Tool result format — Observation: plain text must become a tool_result block.
|
|
45
|
+
#
|
|
46
|
+
# Detection: upstream fix will contain both "tool_result" and "rstrip" in to_messages.
|
|
47
|
+
unless _memory_text.include?("tool_result") && _memory_text.include?("rstrip")
|
|
48
|
+
::Gemlings::Memory.prepend(Module.new do
|
|
49
|
+
def to_messages
|
|
50
|
+
messages = super
|
|
51
|
+
|
|
52
|
+
# Strip trailing whitespace — some LLM APIs (e.g. Anthropic) reject messages
|
|
53
|
+
# whose string content ends with whitespace.
|
|
54
|
+
messages = messages.map do |msg|
|
|
55
|
+
msg[:content].is_a?(String) ? msg.merge(content: msg[:content].rstrip) : msg
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# Rewrite "Observation: ..." user messages that follow a tool_calls step into
|
|
59
|
+
# structured tool_result blocks. Anthropic requires that every tool_use in an
|
|
60
|
+
# assistant message is immediately followed by a tool_result block.
|
|
61
|
+
result = []
|
|
62
|
+
messages.each do |msg|
|
|
63
|
+
prev = result.last
|
|
64
|
+
if prev && prev[:role] == "assistant" && prev[:tool_calls].present? &&
|
|
65
|
+
msg[:role] == "user" && msg[:content].is_a?(String) && msg[:content].start_with?("Observation:")
|
|
66
|
+
observation = msg[:content].sub(/\AObservation:\s*/, "").strip
|
|
67
|
+
tool_results = prev[:tool_calls].map { |tc| { type: "tool_result", tool_use_id: tc.id, content: observation } }
|
|
68
|
+
result << msg.merge(content: tool_results)
|
|
69
|
+
else
|
|
70
|
+
result << msg
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
result
|
|
75
|
+
end
|
|
76
|
+
end)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Patch 2 — RubyLLMAdapter#load_messages (needed if upstream hasn't added tool_result handling)
|
|
80
|
+
#
|
|
81
|
+
# Fixes: tool_result content arrays must be passed to ruby_llm as role: :tool messages
|
|
82
|
+
# with a tool_call_id, not as plain user text. Without this, ruby_llm sends a malformed
|
|
83
|
+
# payload that Anthropic rejects with "tool_use without tool_result" errors.
|
|
84
|
+
#
|
|
85
|
+
# Detection: upstream fix will contain "tool_result" in load_messages.
|
|
86
|
+
unless _adapter_text.include?("tool_result")
|
|
87
|
+
::Gemlings::Models::RubyLLMAdapter.prepend(Module.new do
|
|
88
|
+
private
|
|
89
|
+
|
|
90
|
+
def load_messages(chat, messages)
|
|
91
|
+
messages.each do |msg|
|
|
92
|
+
role = msg[:role]
|
|
93
|
+
content = msg[:content]
|
|
94
|
+
|
|
95
|
+
case role
|
|
96
|
+
when "system"
|
|
97
|
+
chat.with_instructions(content)
|
|
98
|
+
when "assistant"
|
|
99
|
+
attrs = { role: :assistant, content: content }
|
|
100
|
+
attrs[:tool_calls] = send(:convert_tool_calls_to_ruby_llm, msg[:tool_calls]) if msg[:tool_calls]
|
|
101
|
+
chat.add_message(attrs)
|
|
102
|
+
else
|
|
103
|
+
if content.is_a?(Array) && content.all? { |c| c.is_a?(Hash) && (c[:type] == "tool_result" || c["type"] == "tool_result") }
|
|
104
|
+
content.each do |tr|
|
|
105
|
+
chat.add_message(role: :tool, content: (tr[:content] || tr["content"]).to_s, tool_call_id: tr[:tool_use_id] || tr["tool_use_id"])
|
|
106
|
+
end
|
|
107
|
+
else
|
|
108
|
+
chat.add_message(role: role.to_sym, content: content || "")
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end)
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Auto-register all Fosm models with the registry after app loads.
|
|
118
|
+
# Use ::Rails to avoid ambiguity with Fosm::Rails module.
|
|
119
|
+
config.after_initialize do
|
|
120
|
+
::Rails.application.eager_load! if ::Rails.env.development? && !::Rails.application.config.eager_load
|
|
121
|
+
|
|
122
|
+
ObjectSpace.each_object(Class).select { |klass|
|
|
123
|
+
klass < ActiveRecord::Base &&
|
|
124
|
+
klass.name&.start_with?("Fosm::") &&
|
|
125
|
+
klass.respond_to?(:fosm_lifecycle) &&
|
|
126
|
+
klass.fosm_lifecycle.present?
|
|
127
|
+
}.each do |klass|
|
|
128
|
+
slug = klass.name.demodulize.underscore.dasherize
|
|
129
|
+
Fosm::Registry.register(klass, slug: slug)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
end
|
data/lib/fosm/errors.rb
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Fosm
|
|
2
|
+
class Error < StandardError; end
|
|
3
|
+
|
|
4
|
+
# Raised when fire! is called with an unknown event name
|
|
5
|
+
class UnknownEvent < Error
|
|
6
|
+
def initialize(event_name, model_class)
|
|
7
|
+
super("Unknown event '#{event_name}' on #{model_class.name}. Available events: #{model_class.fosm_lifecycle.events.map(&:name).join(', ')}")
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
# Raised when fire! is called but current state doesn't allow the event
|
|
12
|
+
class InvalidTransition < Error
|
|
13
|
+
def initialize(event_name, current_state, record_class)
|
|
14
|
+
super("Cannot fire '#{event_name}' from state '#{current_state}' on #{record_class.name}")
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# Raised when a guard blocks a transition
|
|
19
|
+
class GuardFailed < Error
|
|
20
|
+
def initialize(guard_name, event_name)
|
|
21
|
+
super("Guard '#{guard_name}' prevented transition for event '#{event_name}'")
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# Raised when trying to transition a record in a terminal state
|
|
26
|
+
class TerminalState < Error
|
|
27
|
+
def initialize(state_name, record_class)
|
|
28
|
+
super("#{record_class.name} is in terminal state '#{state_name}' and cannot transition further")
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
require_relative "state_definition"
|
|
2
|
+
require_relative "event_definition"
|
|
3
|
+
require_relative "guard_definition"
|
|
4
|
+
require_relative "side_effect_definition"
|
|
5
|
+
|
|
6
|
+
module Fosm
|
|
7
|
+
module Lifecycle
|
|
8
|
+
# Holds the entire lifecycle definition for a FOSM model.
|
|
9
|
+
# Instantiated once per model class at class load time.
|
|
10
|
+
class Definition
|
|
11
|
+
attr_reader :states, :events
|
|
12
|
+
|
|
13
|
+
def initialize
|
|
14
|
+
@states = []
|
|
15
|
+
@events = []
|
|
16
|
+
@pending_guards = {} # event_name => [GuardDefinition, ...]
|
|
17
|
+
@pending_side_effects = {} # event_name => [SideEffectDefinition, ...]
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# DSL: declare a state
|
|
21
|
+
def state(name, initial: false, terminal: false)
|
|
22
|
+
if initial && @states.any?(&:initial?)
|
|
23
|
+
raise ArgumentError, "Only one initial state is allowed"
|
|
24
|
+
end
|
|
25
|
+
@states << StateDefinition.new(name: name, initial: initial, terminal: terminal)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# DSL: declare an event
|
|
29
|
+
def event(name, from:, to:)
|
|
30
|
+
event_def = EventDefinition.new(name: name, from: from, to: to)
|
|
31
|
+
|
|
32
|
+
# Apply any guards/side_effects declared before this event (unusual but handle it)
|
|
33
|
+
(@pending_guards[name.to_sym] || []).each { |g| event_def.add_guard(g) }
|
|
34
|
+
(@pending_side_effects[name.to_sym] || []).each { |se| event_def.add_side_effect(se) }
|
|
35
|
+
|
|
36
|
+
@events << event_def
|
|
37
|
+
event_def
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# DSL: declare a guard on an event
|
|
41
|
+
def guard(name, on:, &block)
|
|
42
|
+
guard_def = GuardDefinition.new(name: name, &block)
|
|
43
|
+
event_def = find_event(on)
|
|
44
|
+
if event_def
|
|
45
|
+
event_def.add_guard(guard_def)
|
|
46
|
+
else
|
|
47
|
+
# Event may be declared after guard — store for later
|
|
48
|
+
@pending_guards[on.to_sym] ||= []
|
|
49
|
+
@pending_guards[on.to_sym] << guard_def
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# DSL: declare a side effect on an event
|
|
54
|
+
def side_effect(name, on:, &block)
|
|
55
|
+
side_effect_def = SideEffectDefinition.new(name: name, &block)
|
|
56
|
+
event_def = find_event(on)
|
|
57
|
+
if event_def
|
|
58
|
+
event_def.add_side_effect(side_effect_def)
|
|
59
|
+
else
|
|
60
|
+
@pending_side_effects[on.to_sym] ||= []
|
|
61
|
+
@pending_side_effects[on.to_sym] << side_effect_def
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def initial_state
|
|
66
|
+
@states.find(&:initial?)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def find_event(name)
|
|
70
|
+
@events.find { |e| e.name == name.to_sym }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def find_state(name)
|
|
74
|
+
@states.find { |s| s.name == name.to_sym }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def state_names
|
|
78
|
+
@states.map(&:name).map(&:to_s)
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def event_names
|
|
82
|
+
@events.map(&:name)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Returns events valid from the given state
|
|
86
|
+
def available_events_from(state)
|
|
87
|
+
@events.select { |e| e.valid_from?(state) }
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Returns a hash suitable for rendering a state diagram
|
|
91
|
+
def to_diagram_data
|
|
92
|
+
{
|
|
93
|
+
states: @states.map { |s| { name: s.name, initial: s.initial?, terminal: s.terminal? } },
|
|
94
|
+
transitions: @events.map { |e|
|
|
95
|
+
e.from_states.map { |from|
|
|
96
|
+
{ event: e.name, from: from, to: e.to_state, guards: e.guards.map(&:name) }
|
|
97
|
+
}
|
|
98
|
+
}.flatten
|
|
99
|
+
}
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Fosm
|
|
2
|
+
module Lifecycle
|
|
3
|
+
class EventDefinition
|
|
4
|
+
attr_reader :name, :from_states, :to_state, :guards, :side_effects
|
|
5
|
+
|
|
6
|
+
def initialize(name:, from:, to:)
|
|
7
|
+
@name = name.to_sym
|
|
8
|
+
@from_states = Array(from).map(&:to_sym)
|
|
9
|
+
@to_state = to.to_sym
|
|
10
|
+
@guards = []
|
|
11
|
+
@side_effects = []
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def add_guard(guard_def)
|
|
15
|
+
@guards << guard_def
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def add_side_effect(side_effect_def)
|
|
19
|
+
@side_effects << side_effect_def
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def valid_from?(state)
|
|
23
|
+
@from_states.include?(state.to_sym)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
module Fosm
|
|
2
|
+
module Lifecycle
|
|
3
|
+
class SideEffectDefinition
|
|
4
|
+
attr_reader :name
|
|
5
|
+
|
|
6
|
+
def initialize(name:, &block)
|
|
7
|
+
@name = name
|
|
8
|
+
@block = block
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def call(record, transition)
|
|
12
|
+
@block.call(record, transition)
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|