solid_agent 0.0.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.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/LICENSE +21 -0
  4. data/README.md +321 -0
  5. data/Rakefile +32 -0
  6. data/docs/agent-md-spec.md +803 -0
  7. data/docs/parser-design.md +1369 -0
  8. data/docs/registry-api.md +882 -0
  9. data/examples/README.md +60 -0
  10. data/examples/manifests/changelog_writer.agent.md +81 -0
  11. data/examples/manifests/usage.rb +96 -0
  12. data/examples/memory_handoff/app/agents/researcher_agent.rb +36 -0
  13. data/examples/memory_handoff/app/agents/writer_agent.rb +41 -0
  14. data/examples/memory_handoff/usage.rb +45 -0
  15. data/examples/persistent_conversation/app/agents/support_agent.rb +59 -0
  16. data/examples/persistent_conversation/app/controllers/support_conversations_controller.rb +24 -0
  17. data/examples/persistent_conversation/app/views/agents/support/instructions.md.erb +8 -0
  18. data/examples/persistent_conversation/usage.rb +51 -0
  19. data/examples/reasoning/app/agents/analysis_agent.rb +52 -0
  20. data/examples/reasoning/usage.rb +52 -0
  21. data/examples/run_tracking/app/agents/report_agent.rb +30 -0
  22. data/examples/run_tracking/app/controllers/agent_runs_controller.rb +43 -0
  23. data/examples/run_tracking/app/jobs/document_analysis_job.rb +17 -0
  24. data/examples/run_tracking/app/services/document_analysis_run.rb +68 -0
  25. data/examples/run_tracking/usage.rb +85 -0
  26. data/examples/tool_streaming/app/agents/browser_agent.rb +65 -0
  27. data/examples/tool_streaming/app/channels/tool_status_channel.rb +24 -0
  28. data/examples/tool_streaming/app/views/browser_agent/tools/fetch_url.json.erb +15 -0
  29. data/examples/tool_streaming/usage.rb +47 -0
  30. data/lib/generators/solid_agent/agent/agent_generator.rb +95 -0
  31. data/lib/generators/solid_agent/agent/templates/action.text.erb +10 -0
  32. data/lib/generators/solid_agent/agent/templates/agent.rb.erb +93 -0
  33. data/lib/generators/solid_agent/context/context_generator.rb +124 -0
  34. data/lib/generators/solid_agent/context/templates/context_model.rb.erb +134 -0
  35. data/lib/generators/solid_agent/context/templates/create_context.rb.erb +32 -0
  36. data/lib/generators/solid_agent/context/templates/create_generations.rb.erb +46 -0
  37. data/lib/generators/solid_agent/context/templates/create_messages.rb.erb +37 -0
  38. data/lib/generators/solid_agent/context/templates/generation_model.rb.erb +51 -0
  39. data/lib/generators/solid_agent/context/templates/message_model.rb.erb +47 -0
  40. data/lib/generators/solid_agent/install/install_generator.rb +92 -0
  41. data/lib/generators/solid_agent/install/templates/agent_context.rb.erb +171 -0
  42. data/lib/generators/solid_agent/install/templates/agent_generation.rb.erb +76 -0
  43. data/lib/generators/solid_agent/install/templates/agent_memory.rb.erb +51 -0
  44. data/lib/generators/solid_agent/install/templates/agent_memory_entry.rb.erb +12 -0
  45. data/lib/generators/solid_agent/install/templates/agent_message.rb.erb +76 -0
  46. data/lib/generators/solid_agent/install/templates/agent_run.rb.erb +122 -0
  47. data/lib/generators/solid_agent/install/templates/create_agent_contexts.rb.erb +32 -0
  48. data/lib/generators/solid_agent/install/templates/create_agent_generations.rb.erb +51 -0
  49. data/lib/generators/solid_agent/install/templates/create_agent_memories.rb.erb +35 -0
  50. data/lib/generators/solid_agent/install/templates/create_agent_messages.rb.erb +38 -0
  51. data/lib/generators/solid_agent/install/templates/create_agent_runs.rb.erb +46 -0
  52. data/lib/generators/solid_agent/install/templates/initializer.rb.erb +51 -0
  53. data/lib/generators/solid_agent/manifest/manifest_generator.rb +209 -0
  54. data/lib/generators/solid_agent/manifest/templates/agent.md.erb +39 -0
  55. data/lib/generators/solid_agent/manifest/templates/prompt.erb +13 -0
  56. data/lib/generators/solid_agent/reasons/reasons_generator.rb +83 -0
  57. data/lib/generators/solid_agent/reasons/templates/add_reasoning_columns.rb.erb +12 -0
  58. data/lib/generators/solid_agent/tool/templates/tool.json.erb +19 -0
  59. data/lib/generators/solid_agent/tool/tool_generator.rb +117 -0
  60. data/lib/solid_agent/agent_manifest/agent_builder.rb +323 -0
  61. data/lib/solid_agent/agent_manifest/errors.rb +26 -0
  62. data/lib/solid_agent/agent_manifest/exporter_registry.rb +117 -0
  63. data/lib/solid_agent/agent_manifest/exporters/agent_md_exporter.rb +115 -0
  64. data/lib/solid_agent/agent_manifest/exporters/base_exporter.rb +152 -0
  65. data/lib/solid_agent/agent_manifest/exporters/crewai_exporter.rb +125 -0
  66. data/lib/solid_agent/agent_manifest/exporters/dotprompt_exporter.rb +92 -0
  67. data/lib/solid_agent/agent_manifest/input_schema.rb +154 -0
  68. data/lib/solid_agent/agent_manifest/manifest.rb +306 -0
  69. data/lib/solid_agent/agent_manifest/parser_registry.rb +185 -0
  70. data/lib/solid_agent/agent_manifest/parsers/agent_md_parser.rb +87 -0
  71. data/lib/solid_agent/agent_manifest/parsers/base_parser.rb +223 -0
  72. data/lib/solid_agent/agent_manifest/parsers/crewai_parser.rb +201 -0
  73. data/lib/solid_agent/agent_manifest/parsers/dotprompt_parser.rb +122 -0
  74. data/lib/solid_agent/agent_manifest/parsers/github_prompt_parser.rb +143 -0
  75. data/lib/solid_agent/agent_manifest/picoschema.rb +254 -0
  76. data/lib/solid_agent/agent_manifest/registry/auth.rb +103 -0
  77. data/lib/solid_agent/agent_manifest/registry/client.rb +384 -0
  78. data/lib/solid_agent/agent_manifest/resource.rb +103 -0
  79. data/lib/solid_agent/agent_manifest/tool.rb +160 -0
  80. data/lib/solid_agent/agent_manifest/validator.rb +368 -0
  81. data/lib/solid_agent/agent_manifest.rb +381 -0
  82. data/lib/solid_agent/engine.rb +16 -0
  83. data/lib/solid_agent/has_context.rb +670 -0
  84. data/lib/solid_agent/has_memory.rb +136 -0
  85. data/lib/solid_agent/has_reasons.rb +230 -0
  86. data/lib/solid_agent/has_tools.rb +257 -0
  87. data/lib/solid_agent/model_naming.rb +42 -0
  88. data/lib/solid_agent/model_pricing.rb +93 -0
  89. data/lib/solid_agent/reasonable/reason.rb +205 -0
  90. data/lib/solid_agent/reasonable.rb +181 -0
  91. data/lib/solid_agent/records/agent.rb +520 -0
  92. data/lib/solid_agent/records/agent_run.rb +520 -0
  93. data/lib/solid_agent/records/agent_template.rb +142 -0
  94. data/lib/solid_agent/records/agent_version.rb +141 -0
  95. data/lib/solid_agent/records/ownable.rb +130 -0
  96. data/lib/solid_agent/records.rb +152 -0
  97. data/lib/solid_agent/run_fingerprint.rb +51 -0
  98. data/lib/solid_agent/streams_tool_updates.rb +178 -0
  99. data/lib/solid_agent/tool_cache.rb +91 -0
  100. data/lib/solid_agent/version.rb +5 -0
  101. data/lib/solid_agent.rb +95 -0
  102. data/sig/solid_agent.rbs +4 -0
  103. metadata +174 -14
@@ -0,0 +1,520 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ require_relative "ownable"
6
+ require_relative "../run_fingerprint"
7
+
8
+ module SolidAgent
9
+ module Records
10
+ # Behavior for the +Agent+ record: a persisted agent configuration —
11
+ # provider, model, instructions, action prompts, tools — that versions
12
+ # itself as it is edited and can be executed.
13
+ #
14
+ # This is the largest of the record concerns, and the one hosts extend most,
15
+ # which is why the model class stays in +app/models+ and only the behavior
16
+ # ships here. Everything the concern reaches for outside its own row goes
17
+ # through {SolidAgent} seams resolved at call time: the version model, the
18
+ # run model, the execution job, the run executor. The gem never names
19
+ # +Agent+, +AgentVersion+ or +AgentRun+ as constants.
20
+ #
21
+ # What deliberately did NOT come across from the platform model:
22
+ #
23
+ # * *Code generation.* +to_agent_class_code+ emits Ruby source for an
24
+ # ActiveAgent class. That is compilation, not persistence, and it belongs
25
+ # with the thing that knows the current agent DSL.
26
+ # * *Closed vocabularies.* PRESET_TYPES, INSTRUCTION_SETS, AVAILABLE_TOOLS
27
+ # and PROVIDERS enumerate what one React component can render. They are
28
+ # product copy on a release cadence the gem does not control — PROVIDERS
29
+ # has already drifted between the two copies of this model — so hosts
30
+ # own them.
31
+ #
32
+ # The columns those vocabularies describe (+preset_type+, +appearance+) do
33
+ # stay, because production +agent_versions+ rows already carry them inside
34
+ # +configuration_snapshot+; dropping the columns would make every existing
35
+ # version row lossy on restore.
36
+ #
37
+ # @example Editing an agent writes a version
38
+ # agent = Agent.create!(name: "Reviewer", provider: "openai", model: "gpt-4o")
39
+ # agent.version_count #=> 1
40
+ # agent.update!(instructions: "Be terse.")
41
+ # agent.latest_version.change_summary #=> "Updated: instructions"
42
+ #
43
+ # @example Rolling back
44
+ # agent.restore_from_version!(agent.agent_versions.find_by(version_number: 1))
45
+ module Agent
46
+ extend ActiveSupport::Concern
47
+ include Ownable
48
+
49
+ # The action every agent has without declaring one: it runs under the
50
+ # agent's base instructions alone.
51
+ DEFAULT_ACTION = "ask"
52
+
53
+ # The attributes whose change is worth a new version — the agent's
54
+ # behavior, not its identity. Renaming an agent or moving it to another
55
+ # provider is a fact about the record, not a revision to roll back to.
56
+ #
57
+ # In the platform model this list is written out twice, once to decide
58
+ # whether to version and once to summarize what changed; the two are the
59
+ # same list by definition and drift the moment a column is added.
60
+ VERSIONED_FIELDS = %w[
61
+ instructions action_prompts preset_type appearance instruction_sets
62
+ tools mcp_servers model_config response_format
63
+ ].freeze
64
+
65
+ # Identity attributes recorded in a snapshot but never restored from one.
66
+ # They give a version enough context to be read on its own ("this is what
67
+ # the agent was called then") without letting a rollback rename the
68
+ # record out from under its owner.
69
+ DESCRIPTIVE_FIELDS = %w[name description provider model].freeze
70
+
71
+ # Everything {#configuration_snapshot} captures.
72
+ SNAPSHOT_FIELDS = (DESCRIPTIVE_FIELDS + VERSIONED_FIELDS).freeze
73
+
74
+ included do
75
+ # Both associations pin foreign_key: the host names this model, and
76
+ # Rails would otherwise derive `platform_agent_id` from a class called
77
+ # PlatformAgent while the child concerns declare `belongs_to :agent`
78
+ # against a real `agent_id` column.
79
+ has_many :agent_versions,
80
+ class_name: SolidAgent.agent_version_class.to_s,
81
+ foreign_key: :agent_id,
82
+ dependent: :destroy
83
+ has_many :agent_runs,
84
+ class_name: SolidAgent.agent_run_class.to_s,
85
+ foreign_key: :agent_id,
86
+ dependent: :destroy
87
+
88
+ validates :name, presence: true, length: { minimum: 2, maximum: 100 }
89
+ validates :slug, presence: true, format: { with: /\A[a-z0-9\-_]+\z/ }
90
+ validates :provider, presence: true
91
+ validates :model, presence: true
92
+ # Slug uniqueness is hand-rolled rather than declared, because the
93
+ # scope is the ownership column and Ownable lets the host choose it.
94
+ # A `uniqueness: { scope: :user_id }` option would freeze that choice
95
+ # at include time.
96
+ validate :slug_must_be_unique_for_owner
97
+ validate :action_prompts_must_be_well_formed
98
+
99
+ # `observed` agents were discovered from reported telemetry rather than
100
+ # authored here. The platform cannot execute them — you cannot push
101
+ # instructions into someone else's app — so they are read-only records
102
+ # of something running elsewhere until a fork copies them.
103
+ enum :status, { draft: 0, active: 1, archived: 2, observed: 3 }
104
+
105
+ before_validation :generate_slug, on: :create
106
+ after_create :create_initial_version, if: :versioned?
107
+ after_update :create_version_on_config_change, if: :configuration_changed?
108
+
109
+ scope :active_agents, -> { where(status: :active) }
110
+ scope :observed_agents, -> { where(status: :observed) }
111
+ scope :authored, -> { where.not(status: :observed) }
112
+ scope :by_provider, ->(provider) { where(provider: provider) }
113
+
114
+ # Finds agents whose tools array contains +tool+.
115
+ #
116
+ # This is the one query in the records layer that is not portable, and
117
+ # it is asymmetric on purpose:
118
+ #
119
+ # * On Postgres it uses jsonb containment (`@>`), which is indexable
120
+ # with a GIN index and is what production runs. The column must be
121
+ # `jsonb` — `json` has no containment operator and raises.
122
+ # * Everywhere else it falls back to a quoted LIKE against the
123
+ # serialized array. That matches `["search"]` without matching
124
+ # `["research"]`, but it is a substring test on text: it cannot use
125
+ # an index, and it would also match a tool name appearing as a key
126
+ # if a host stored objects rather than strings in `tools`. The
127
+ # explicit ESCAPE clause is not decoration — sqlite has no default
128
+ # escape character, so without it a tool named `c_de` would match
129
+ # `code`.
130
+ #
131
+ # The fallback exists so sqlite and MySQL hosts get an answer instead
132
+ # of a StatementInvalid; Postgres remains the target.
133
+ scope :with_tool, ->(tool) {
134
+ if klass.jsonb_containment?
135
+ where("tools @> ?", [ tool.to_s ].to_json)
136
+ else
137
+ where("tools LIKE ? ESCAPE '\\'", "%\"#{klass.sanitize_sql_like(tool.to_s)}\"%")
138
+ end
139
+ }
140
+ end
141
+
142
+ class_methods do
143
+ # Whether this model's connection supports the jsonb containment
144
+ # operator.
145
+ #
146
+ # Read from the configured adapter rather than by leasing a connection:
147
+ # {with_tool} asks on every call, and building a relation should not
148
+ # check out a connection to do it.
149
+ #
150
+ # @return [Boolean]
151
+ def jsonb_containment?
152
+ connection_db_config.adapter.to_s.match?(/postgres|postgis/i)
153
+ end
154
+ end
155
+
156
+ # The ActiveAgent class name this agent's runs are recorded under — the
157
+ # correlation key between an agent row, telemetry traces and solid_agent
158
+ # contexts, all of which key on a class name string rather than an id.
159
+ #
160
+ # @return [String]
161
+ def telemetry_agent_class
162
+ configured = self[:agent_class_name] if has_attribute?(:agent_class_name)
163
+ base = configured.presence || name.to_s.parameterize(separator: "_").camelize
164
+ base.end_with?("Agent") ? base : "#{base}Agent"
165
+ end
166
+
167
+ # Every invokable action: the built-in default plus each named prompt.
168
+ #
169
+ # @return [Array<String>]
170
+ def available_actions
171
+ [ DEFAULT_ACTION ] + action_prompt_list.filter_map { |prompt| prompt["name"].presence }
172
+ end
173
+
174
+ # @param action_name [String, Symbol]
175
+ # @return [Hash, nil] the stored action prompt definition
176
+ def action_prompt_for(action_name)
177
+ action_prompt_list.find { |prompt| prompt["name"] == action_name.to_s }
178
+ end
179
+
180
+ # The system instructions an action executes under.
181
+ #
182
+ # Named actions stack their prompt below the agent's base instructions
183
+ # rather than replacing them, so an action inherits the agent's persona
184
+ # and adds a job to it. The default action is the base instructions alone.
185
+ #
186
+ # @param action_name [String, Symbol, nil]
187
+ # @return [String, nil] nil when neither part has content
188
+ def composed_instructions_for(action_name)
189
+ action = action_prompt_for(action_name)
190
+ [ instructions, action&.dig("prompt") ].map(&:presence).compact.join("\n\n").presence
191
+ end
192
+
193
+ # The configuration as it stands right now, for writing into a version.
194
+ #
195
+ # Only attributes the model actually has are captured, so a host that
196
+ # trimmed columns it does not use still snapshots cleanly.
197
+ #
198
+ # @return [Hash{Symbol => Object}]
199
+ def configuration_snapshot
200
+ SNAPSHOT_FIELDS.each_with_object({}) do |field, snapshot|
201
+ snapshot[field.to_sym] = self[field] if has_attribute?(field)
202
+ end
203
+ end
204
+
205
+ # Rolls the agent's behavior back to a stored version.
206
+ #
207
+ # Only {VERSIONED_FIELDS} are written — a rollback restores how the agent
208
+ # behaves, not what it is called. Because those are exactly the fields the
209
+ # versioning callback watches, a successful restore writes a new version
210
+ # of its own: history moves forward, it does not rewind.
211
+ #
212
+ # @param version [#configuration_snapshot]
213
+ # @return [Boolean] true
214
+ # @raise [ActiveRecord::RecordInvalid] when the restored configuration is invalid
215
+ def restore_from_version!(version)
216
+ snapshot = (version.configuration_snapshot || {}).to_h.stringify_keys
217
+
218
+ attributes = VERSIONED_FIELDS.each_with_object({}) do |field, restored|
219
+ next unless has_attribute?(field)
220
+
221
+ # A snapshot taken before a column existed restores that column to its
222
+ # default rather than to NULL: v1 predates action_prompts, and rolling
223
+ # back to v1 has to clear them — but into the empty collection the
224
+ # column defaults to, which readers can still iterate, not `nil`.
225
+ restored[field] = snapshot.key?(field) ? snapshot[field] : self.class.column_defaults[field]
226
+ end
227
+
228
+ update!(attributes)
229
+ end
230
+
231
+ # @return [ActiveRecord::Base, nil] the highest-numbered version
232
+ def latest_version
233
+ return nil unless version_model
234
+
235
+ agent_versions.order(version_number: :desc).first
236
+ end
237
+
238
+ # @return [Integer] how many versions exist
239
+ def version_count
240
+ return 0 unless version_model
241
+
242
+ agent_versions.count
243
+ end
244
+
245
+ # Whether edits to this agent are recorded as versions.
246
+ #
247
+ # False for observed agents. Their configuration is not authored here —
248
+ # the telemetry registrar rewrites it from every ingest batch — so
249
+ # versioning them would fill the history with revisions nobody made, and
250
+ # there is nothing to roll back to anyway: the source of truth is the
251
+ # other application's code.
252
+ #
253
+ # Also false when the host generated the agent model but not the version
254
+ # model, which is a supported install that simply keeps no history.
255
+ #
256
+ # @return [Boolean]
257
+ def versioned?
258
+ return false if respond_to?(:observed?) && observed?
259
+
260
+ !version_model.nil?
261
+ end
262
+
263
+ # Maps each historical instructions digest to the first version that
264
+ # introduced it, so run cohorts can be labelled with real agent versions
265
+ # ("v3") instead of raw hashes.
266
+ #
267
+ # @return [Hash{String => String}] digest to version label
268
+ def instructions_digest_versions
269
+ return {} unless version_model
270
+
271
+ agent_versions.order(:version_number).each_with_object({}) do |version, map|
272
+ snapshot = (version.configuration_snapshot || {}).to_h.stringify_keys
273
+ base = snapshot["instructions"]
274
+ label = "v#{version.version_number}"
275
+
276
+ digest = SolidAgent::RunFingerprint.digest(base)
277
+ map[digest] ||= label if digest
278
+
279
+ # Named actions run under composed instructions, so their runs carry
280
+ # a different digest per action for the same version.
281
+ Array(snapshot["action_prompts"]).each do |action|
282
+ next unless action.is_a?(Hash)
283
+
284
+ composed = [ base, action["prompt"] ].map(&:presence).compact.join("\n\n")
285
+ composed_digest = SolidAgent::RunFingerprint.digest(composed)
286
+ map[composed_digest] ||= label if composed_digest
287
+ end
288
+ end
289
+ end
290
+
291
+ # Enqueues an asynchronous run of this agent.
292
+ #
293
+ # @param input_prompt [String]
294
+ # @param action [String, Symbol, nil] a named action; unknown names fall
295
+ # back to the default action
296
+ # @param params [Hash] arbitrary input parameters recorded on the run
297
+ # @return [ActiveRecord::Base] the pending run
298
+ # @raise [SolidAgent::Error] when no execution job is configured
299
+ #
300
+ # @example
301
+ # run = agent.execute("Summarize this", action: "summarize", document_id: 7)
302
+ # run.status #=> "pending"
303
+ def execute(input_prompt, action: nil, **params)
304
+ job = SolidAgent.execution_job
305
+ unless job
306
+ raise SolidAgent::Error,
307
+ "#{SolidAgent.execution_job_class} is not defined, so #{self.class} cannot enqueue a run. " \
308
+ "Set SolidAgent.execution_job_class to the job you use, or call #test_execute to run inline."
309
+ end
310
+
311
+ run = create_run(input_prompt, action: action, params: params, status: :pending)
312
+ job.perform_later(run.id)
313
+ run
314
+ end
315
+
316
+ # Runs this agent inline and records the result.
317
+ #
318
+ # Execution itself is the host's: building a runnable agent from stored
319
+ # provider/model/instructions is activeagent's job, not a persistence
320
+ # gem's, so the work goes through {SolidAgent.run_executor}. Everything
321
+ # here is bookkeeping around it.
322
+ #
323
+ # Failures are recorded on the run rather than raised — including a
324
+ # missing executor. The run row is the audit trail, and "this run could
325
+ # not be executed" is a fact about the run worth persisting; the
326
+ # directive message from the unconfigured seam lands in +error_message+.
327
+ #
328
+ # @param input_prompt [String]
329
+ # @param action [String, Symbol, nil]
330
+ # @param params [Hash]
331
+ # @return [ActiveRecord::Base] the completed or failed run
332
+ #
333
+ # @example Wiring the executor once, in an initializer
334
+ # SolidAgent.run_executor = ->(agent, run) { AgentExecutionService.call(agent, run) }
335
+ def test_execute(input_prompt, action: nil, **params)
336
+ run = create_run(input_prompt, action: action, params: params,
337
+ status: :running, started_at: Time.current)
338
+
339
+ begin
340
+ result = SolidAgent.run_executor.call(self, run)
341
+ # The executor is host code and may hand back string keys (a JSON
342
+ # round trip, an HTTP client). Normalizing here keeps that from
343
+ # silently recording a run with no output.
344
+ record_completion(run, result.to_h.deep_symbolize_keys)
345
+ rescue ::StandardError => error
346
+ record_failure(run, error)
347
+ end
348
+
349
+ run
350
+ end
351
+
352
+ private
353
+
354
+ def version_model
355
+ SolidAgent.agent_version_model
356
+ end
357
+
358
+ # Closing out a run is the run record's own business — Records::AgentRun
359
+ # merges metadata rather than replacing it, tolerates partial usage
360
+ # numbers and instruments the status change — so this defers to the
361
+ # lifecycle when the host's run model implements it. Writing the columns
362
+ # here as well would give one application two subtly different endings
363
+ # for the same run. The direct write is the floor for a plain run model
364
+ # that carries the columns but none of the behavior.
365
+ def record_completion(run, result)
366
+ usage = result[:usage] || {}
367
+
368
+ if run.respond_to?(:finish!)
369
+ return run.finish!(
370
+ output: result[:output],
371
+ metadata: result[:metadata] || {},
372
+ input_tokens: usage[:input_tokens],
373
+ output_tokens: usage[:output_tokens],
374
+ total_tokens: usage[:total_tokens]
375
+ )
376
+ end
377
+
378
+ run.update!(
379
+ output: result[:output],
380
+ output_metadata: result[:metadata],
381
+ status: :complete,
382
+ completed_at: Time.current,
383
+ duration_ms: elapsed_ms(run.started_at),
384
+ input_tokens: usage[:input_tokens],
385
+ output_tokens: usage[:output_tokens],
386
+ total_tokens: usage[:total_tokens]
387
+ )
388
+ end
389
+
390
+ def record_failure(run, error)
391
+ return run.fail!(error) if run.respond_to?(:fail!)
392
+
393
+ run.update!(
394
+ status: :failed,
395
+ completed_at: Time.current,
396
+ error_message: error.message,
397
+ error_backtrace: error.backtrace&.first(10)&.join("\n")
398
+ )
399
+ end
400
+
401
+ # Runs are created through the configured model's own columns: the run
402
+ # schema and the agent schema drift independently once a host starts
403
+ # editing generated migrations, and a run is not worth failing over an
404
+ # attribute the host chose not to keep.
405
+ def create_run(input_prompt, action:, params:, status:, started_at: nil)
406
+ attributes = {
407
+ "input_prompt" => input_prompt,
408
+ "action_name" => normalized_action(action),
409
+ "input_params" => params,
410
+ "status" => status,
411
+ "trace_id" => SecureRandom.uuid,
412
+ "started_at" => started_at
413
+ }.compact
414
+
415
+ agent_runs.create!(attributes.slice(*agent_runs.klass.column_names))
416
+ end
417
+
418
+ def elapsed_ms(started_at)
419
+ return nil unless started_at
420
+
421
+ ((Time.current - started_at) * 1000).to_i
422
+ end
423
+
424
+ # Unknown action names fall back to the default rather than failing the
425
+ # run: an action can be renamed between enqueue and execution.
426
+ def normalized_action(action)
427
+ action = action.to_s.presence
428
+ action if action && available_actions.include?(action)
429
+ end
430
+
431
+ def action_prompt_list
432
+ list = has_attribute?(:action_prompts) ? action_prompts : nil
433
+ list.is_a?(Array) ? list.select { |prompt| prompt.is_a?(Hash) } : []
434
+ end
435
+
436
+ def generate_slug
437
+ return if slug.present?
438
+
439
+ base_slug = name.to_s.parameterize
440
+ self.slug = base_slug
441
+
442
+ # Probing the same scope the uniqueness validation uses — the platform
443
+ # model probes globally, which hands the second tenant to register
444
+ # "Support Bot" a `support-bot-1` even though the DB index
445
+ # (owner column + slug) had `support-bot` free for them.
446
+ counter = 1
447
+ while slug_taken?
448
+ self.slug = "#{base_slug}-#{counter}"
449
+ counter += 1
450
+ end
451
+ end
452
+
453
+ def slug_must_be_unique_for_owner
454
+ return if slug.blank?
455
+
456
+ errors.add(:slug, :taken) if slug_taken?
457
+ end
458
+
459
+ # Uniqueness is per owner, matching the (owner column, slug) unique index.
460
+ # `for_owner` is what makes that portable: it narrows to this record's
461
+ # owner where the host stores one, and no-ops into global uniqueness
462
+ # where it does not.
463
+ def slug_taken?
464
+ scope = self.class.where(slug: slug)
465
+ scope = scope.where.not(id: id) if persisted?
466
+ scope = scope.for_owner(self[self.class.owner_foreign_key]) if self.class.respond_to?(:owner_foreign_key)
467
+ scope.exists?
468
+ end
469
+
470
+ def create_initial_version
471
+ agent_versions.create!(
472
+ version_number: 1,
473
+ change_summary: "Initial creation",
474
+ configuration_snapshot: configuration_snapshot
475
+ )
476
+ end
477
+
478
+ def configuration_changed?
479
+ return false unless versioned?
480
+
481
+ changed_versioned_fields.any?
482
+ end
483
+
484
+ def create_version_on_config_change
485
+ agent_versions.create!(
486
+ version_number: (latest_version&.version_number || 0) + 1,
487
+ change_summary: "Updated: #{changed_versioned_fields.join(', ')}",
488
+ configuration_snapshot: configuration_snapshot
489
+ )
490
+ end
491
+
492
+ def changed_versioned_fields
493
+ saved_changes.keys & VERSIONED_FIELDS
494
+ end
495
+
496
+ def action_prompts_must_be_well_formed
497
+ return unless has_attribute?(:action_prompts)
498
+ return if action_prompts.blank?
499
+
500
+ unless action_prompts.is_a?(Array) && action_prompts.all? { |prompt| prompt.is_a?(Hash) }
501
+ errors.add(:action_prompts, "must be a list of action definitions")
502
+ return
503
+ end
504
+
505
+ names = action_prompts.map { |prompt| prompt["name"].to_s }
506
+ names.each do |action_name|
507
+ unless action_name.match?(/\A[a-z][a-z0-9_]*\z/)
508
+ errors.add(:action_prompts, "action name '#{action_name}' must be snake_case")
509
+ end
510
+
511
+ if action_name == DEFAULT_ACTION
512
+ errors.add(:action_prompts, "'#{DEFAULT_ACTION}' is the built-in default action")
513
+ end
514
+ end
515
+
516
+ errors.add(:action_prompts, "action names must be unique") if names.uniq.size != names.size
517
+ end
518
+ end
519
+ end
520
+ end