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.
Files changed (64) hide show
  1. checksums.yaml +7 -0
  2. data/AGENTS.md +384 -0
  3. data/LICENSE +115 -0
  4. data/README.md +322 -0
  5. data/Rakefile +6 -0
  6. data/app/assets/stylesheets/fosm/rails/application.css +15 -0
  7. data/app/controllers/fosm/admin/agents_controller.rb +242 -0
  8. data/app/controllers/fosm/admin/apps_controller.rb +34 -0
  9. data/app/controllers/fosm/admin/base_controller.rb +15 -0
  10. data/app/controllers/fosm/admin/dashboard_controller.rb +25 -0
  11. data/app/controllers/fosm/admin/settings_controller.rb +44 -0
  12. data/app/controllers/fosm/admin/transitions_controller.rb +22 -0
  13. data/app/controllers/fosm/admin/webhooks_controller.rb +37 -0
  14. data/app/controllers/fosm/application_controller.rb +23 -0
  15. data/app/controllers/fosm/rails/application_controller.rb +6 -0
  16. data/app/helpers/fosm/application_helper.rb +19 -0
  17. data/app/helpers/fosm/rails/application_helper.rb +11 -0
  18. data/app/jobs/fosm/application_job.rb +6 -0
  19. data/app/jobs/fosm/rails/application_job.rb +6 -0
  20. data/app/jobs/fosm/webhook_delivery_job.rb +52 -0
  21. data/app/models/fosm/application_record.rb +6 -0
  22. data/app/models/fosm/rails/application_record.rb +7 -0
  23. data/app/models/fosm/transition_log.rb +27 -0
  24. data/app/models/fosm/webhook_subscription.rb +15 -0
  25. data/app/views/fosm/admin/agents/chat.html.erb +242 -0
  26. data/app/views/fosm/admin/agents/show.html.erb +166 -0
  27. data/app/views/fosm/admin/apps/show.html.erb +114 -0
  28. data/app/views/fosm/admin/dashboard/index.html.erb +82 -0
  29. data/app/views/fosm/admin/settings/show.html.erb +63 -0
  30. data/app/views/fosm/admin/transitions/index.html.erb +65 -0
  31. data/app/views/fosm/admin/webhooks/index.html.erb +51 -0
  32. data/app/views/fosm/admin/webhooks/new.html.erb +45 -0
  33. data/app/views/layouts/fosm/application.html.erb +41 -0
  34. data/app/views/layouts/fosm/rails/application.html.erb +17 -0
  35. data/config/routes.rb +17 -0
  36. data/db/migrate/20240101000001_create_fosm_transition_logs.rb +23 -0
  37. data/db/migrate/20240101000002_create_fosm_webhook_subscriptions.rb +16 -0
  38. data/lib/fosm/agent.rb +232 -0
  39. data/lib/fosm/configuration.rb +50 -0
  40. data/lib/fosm/engine.rb +133 -0
  41. data/lib/fosm/errors.rb +31 -0
  42. data/lib/fosm/lifecycle/definition.rb +103 -0
  43. data/lib/fosm/lifecycle/event_definition.rb +27 -0
  44. data/lib/fosm/lifecycle/guard_definition.rb +16 -0
  45. data/lib/fosm/lifecycle/side_effect_definition.rb +16 -0
  46. data/lib/fosm/lifecycle/state_definition.rb +18 -0
  47. data/lib/fosm/lifecycle.rb +173 -0
  48. data/lib/fosm/rails/engine.rb +9 -0
  49. data/lib/fosm/rails/version.rb +9 -0
  50. data/lib/fosm/rails.rb +9 -0
  51. data/lib/fosm/registry.rb +29 -0
  52. data/lib/fosm/version.rb +3 -0
  53. data/lib/fosm-rails.rb +40 -0
  54. data/lib/generators/fosm/app/app_generator.rb +106 -0
  55. data/lib/generators/fosm/app/templates/agent.rb.tt +26 -0
  56. data/lib/generators/fosm/app/templates/controller.rb.tt +56 -0
  57. data/lib/generators/fosm/app/templates/migration.rb.tt +14 -0
  58. data/lib/generators/fosm/app/templates/model.rb.tt +31 -0
  59. data/lib/generators/fosm/app/templates/views/_form.html.erb.tt +24 -0
  60. data/lib/generators/fosm/app/templates/views/index.html.erb.tt +37 -0
  61. data/lib/generators/fosm/app/templates/views/new.html.erb.tt +4 -0
  62. data/lib/generators/fosm/app/templates/views/show.html.erb.tt +57 -0
  63. data/lib/tasks/fosm/rails_tasks.rake +4 -0
  64. 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
@@ -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
@@ -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 GuardDefinition
4
+ attr_reader :name
5
+
6
+ def initialize(name:, &block)
7
+ @name = name
8
+ @block = block
9
+ end
10
+
11
+ def call(record)
12
+ @block.call(record)
13
+ end
14
+ end
15
+ end
16
+ 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