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_relative "../run_fingerprint"
4
+
5
+ module SolidAgent
6
+ module Records
7
+ # Behavior for the +AgentRun+ record: one execution of an agent, from
8
+ # enqueue through terminal state, with its inputs, outputs, token usage and
9
+ # an append-only stream of progress events.
10
+ #
11
+ # == Two schemas, one concern
12
+ #
13
+ # This record exists in the wild in two shapes, and the concern has to fit
14
+ # both without asking anyone to migrate a live table:
15
+ #
16
+ # * The platform shape — +agent_id+, an *integer* +status+ enum, +logs+,
17
+ # +total_tokens+, +error_backtrace+.
18
+ # * The gem's earlier generator shape — a polymorphic +runnable+, a *string*
19
+ # +status+, +events+, +instructions_digest+, and neither +total_tokens+
20
+ # nor +error_backtrace+.
21
+ #
22
+ # The integer enum and +agent_id+ win: they are what is deployed and
23
+ # queried in production. The polymorphic +runnable+ and +instructions_digest+
24
+ # are kept as additions, so a run can be about a persisted agent record *or*
25
+ # about an arbitrary host object (a workflow, a job, a document). {#subject}
26
+ # is the one reader that does not care which.
27
+ #
28
+ # The log column is the only irreconcilable name, so it is not reconciled —
29
+ # {events_column} names it, defaulting to +:events+. A host on the platform
30
+ # schema sets it to +:logs+ and keeps its table.
31
+ #
32
+ # Every column only one shape has — +total_tokens+, +error_backtrace+,
33
+ # +instructions_digest+, +agent_id+, +runnable_type+ — is guarded, so
34
+ # including this concern on either table works and writes simply skip what
35
+ # is not there.
36
+ #
37
+ # == What is deliberately absent
38
+ #
39
+ # The platform broadcasts status changes over ActionCable. That couples a
40
+ # persistence concern to a delivery mechanism the host may not run, so this
41
+ # emits {STATUS_CHANGED_EVENT} instead and lets a dashboard subscribe and
42
+ # broadcast however it likes.
43
+ #
44
+ # @example Configure a host that kept the platform's +logs+ column
45
+ # class AgentRun < ApplicationRecord
46
+ # include SolidAgent::Records::AgentRun
47
+ # self.events_column = :logs
48
+ # end
49
+ #
50
+ # @example Drive a run through its lifecycle
51
+ # run = AgentRun.create!(agent: agent, input_prompt: "summarize this")
52
+ # run.start!
53
+ # run.append_event(kind: "llm", label: "gpt-4o", eid: "1", status: "started")
54
+ # run.finish!(output: "…", input_tokens: 120, output_tokens: 40)
55
+ # run.total_tokens #=> 160
56
+ #
57
+ # @example Re-broadcast status changes from the host
58
+ # ActiveSupport::Notifications.subscribe("run.status_changed.solid_agent") do |*, payload|
59
+ # ActionCable.server.broadcast("agent_run_#{payload[:run].id}", payload[:run].summary)
60
+ # end
61
+ module AgentRun
62
+ extend ActiveSupport::Concern
63
+
64
+ # Integer-backed lifecycle. The values are the ones already persisted in
65
+ # production rows and must not be renumbered.
66
+ STATUSES = { pending: 0, running: 1, complete: 2, failed: 3, cancelled: 4 }.freeze
67
+
68
+ # Emitted after a committed status change. Payload: +:run+, +:from+,
69
+ # +:to+, +:trace_id+.
70
+ STATUS_CHANGED_EVENT = "run.status_changed.solid_agent"
71
+
72
+ # Event +detail+ is operator-facing context, not a payload to preserve —
73
+ # a runaway tool response must not turn the events column into the
74
+ # largest row in the table.
75
+ DETAIL_LIMIT = 1200
76
+
77
+ # Enough backtrace to name the failing frame and its callers; the full
78
+ # trace belongs in the exception reporter, not in every run row.
79
+ BACKTRACE_LINES = 10
80
+
81
+ INPUT_PREVIEW_LIMIT = 100
82
+ OUTPUT_PREVIEW_LIMIT = 200
83
+ INSTRUCTIONS_PREVIEW_LIMIT = 120
84
+
85
+ # What {#summary} reports when neither the column nor the metadata names
86
+ # an action — every agent has a default entry point, and callers group by
87
+ # this field.
88
+ DEFAULT_ACTION_NAME = "ask"
89
+
90
+ included do
91
+ # Named with the configured class *string* so a host can point the
92
+ # records layer at +Ai::Assistant+ without the gem ever constantizing
93
+ # an autoloadable constant at load time.
94
+ #
95
+ # optional: true, unlike the platform's required +belongs_to :agent+ —
96
+ # a run may be about a +runnable+ instead, or about nothing persisted
97
+ # at all when a bare executor just wants the audit row. Hosts that want
98
+ # the stricter rule add `validates :agent, presence: true`.
99
+ belongs_to :agent, class_name: SolidAgent.agent_class.to_s, optional: true
100
+ belongs_to :runnable, polymorphic: true, optional: true
101
+
102
+ # The log column's name, not its contents. Making it configurable is
103
+ # what lets the platform keep +logs+ and the generator keep +events+
104
+ # without either side renaming a column on a live table.
105
+ class_attribute :events_column, default: :events
106
+
107
+ # No prefix or suffix: `run.complete?`, `run.failed?` and
108
+ # `run.cancelled?` are called by name across the platform's jobs and
109
+ # controllers, and prefixing would rename all of them.
110
+ #
111
+ # The cost is that the enum also generates bare `complete!`, `failed!`
112
+ # and `cancelled!` setters into a module that sits *above* this concern
113
+ # in the ancestor chain. They silently win over any same-named method
114
+ # defined here — with no warning from Rails — which is why the
115
+ # lifecycle transitions below are named {#finish!} and {#fail!}. Those
116
+ # bare setters remain callable and move `status` without touching
117
+ # `completed_at` or `duration_ms`; prefer the lifecycle methods.
118
+ enum :status, STATUSES
119
+
120
+ validates :trace_id, presence: true
121
+
122
+ scope :recent, -> { order(created_at: :desc) }
123
+ scope :successful, -> { where(status: :complete) }
124
+ # `failed` is already taken by the enum's own scope, which is why this
125
+ # one carries the suffix.
126
+ scope :failed_runs, -> { where(status: :failed) }
127
+ scope :today, -> { where(created_at: Time.current.beginning_of_day..) }
128
+ scope :for_agent, ->(agent_name) { where(agent_name: agent_name) }
129
+ scope :for_action, ->(action_name) { where(action_name: action_name) }
130
+ scope :with_trace, ->(trace_id) { where(trace_id: trace_id) }
131
+ scope :for_status, ->(status) { where(status: status) }
132
+
133
+ # Not `on: :create`: rows written before trace_id was mandatory would
134
+ # otherwise be unsavable forever, failing a presence validation on a
135
+ # column nothing is filling in. Healing them on the next write is
136
+ # cheaper than a backfill migration.
137
+ before_validation :assign_trace_id
138
+
139
+ after_update_commit :notify_status_change, if: :saved_change_to_status?
140
+ end
141
+
142
+ class_methods do
143
+ # Sums token usage across the relation, falling back per row to
144
+ # input + output where the provider reported no total.
145
+ #
146
+ # This exists because +sum(:total_tokens)+ silently undercounts: the
147
+ # fallback in {#total_tokens} lives in Ruby, so a row whose provider
148
+ # reported only input and output counts as zero in SQL. That is a known
149
+ # wart of keeping a denormalized total column at all, not a bug to
150
+ # paper over — the column is authoritative when present because some
151
+ # providers bill for tokens neither prompt nor completion accounts for
152
+ # (cached reads, reasoning), so it cannot simply be dropped.
153
+ #
154
+ # @return [Integer]
155
+ #
156
+ # @example
157
+ # AgentRun.today.total_tokens_sum #=> 41_233
158
+ # AgentRun.today.sum(:total_tokens) #=> 12_004, missing every fallback row
159
+ def total_tokens_sum
160
+ fallback = "COALESCE(input_tokens, 0) + COALESCE(output_tokens, 0)"
161
+ expression =
162
+ if column_names.include?("total_tokens")
163
+ "COALESCE(total_tokens, #{fallback})"
164
+ else
165
+ fallback
166
+ end
167
+
168
+ all.sum(Arel.sql(expression)).to_i
169
+ end
170
+ end
171
+
172
+ # The thing this run was about: the polymorphic +runnable+ when the host
173
+ # attached one, otherwise the agent record.
174
+ #
175
+ # @return [ActiveRecord::Base, nil]
176
+ def subject
177
+ association_if_present(:runnable, :runnable_id) || association_if_present(:agent, :agent_id)
178
+ end
179
+
180
+ # @return [Boolean] whether the run has not reached a terminal state
181
+ def in_progress?
182
+ pending? || running?
183
+ end
184
+
185
+ # @return [Boolean] whether the run reached any terminal state, successful or not
186
+ def finished?
187
+ complete? || failed? || cancelled?
188
+ end
189
+
190
+ # === Lifecycle ===
191
+
192
+ # Marks the run as running and stamps +started_at+.
193
+ #
194
+ # @return [Boolean]
195
+ def start!
196
+ update!(persistable(status: :running, started_at: Time.current))
197
+ end
198
+
199
+ # Completes the run: output, merged metadata, usage and duration.
200
+ #
201
+ # Named +finish!+ rather than +complete!+ because the enum owns that
202
+ # name — see the note on {STATUSES} in the +included+ block.
203
+ #
204
+ # Usage arguments are nil-tolerant and fall back to whatever is already
205
+ # on the record, so an executor that recorded tokens incrementally
206
+ # mid-run does not have to repeat them here.
207
+ #
208
+ # @param output [String, nil]
209
+ # @param metadata [Hash] merged into +output_metadata+, not replacing it
210
+ # @param input_tokens [Integer, nil]
211
+ # @param output_tokens [Integer, nil]
212
+ # @param total_tokens [Integer, nil] the provider's own total, when it reported one
213
+ # @return [Boolean]
214
+ def finish!(output: nil, metadata: {}, input_tokens: nil, output_tokens: nil, total_tokens: nil)
215
+ finished_at = Time.current
216
+
217
+ update!(persistable(
218
+ status: :complete,
219
+ output: output,
220
+ output_metadata: (output_metadata || {}).merge(metadata || {}),
221
+ input_tokens: input_tokens || self.input_tokens,
222
+ output_tokens: output_tokens || self.output_tokens,
223
+ total_tokens: total_tokens || self[:total_tokens],
224
+ completed_at: finished_at,
225
+ duration_ms: calculated_duration_ms(fallback_end: finished_at)
226
+ ))
227
+ end
228
+
229
+ # Records a failure. Accepts an exception or a plain message.
230
+ #
231
+ # @param error [Exception, String]
232
+ # @return [Boolean]
233
+ def fail!(error)
234
+ finished_at = Time.current
235
+
236
+ update!(persistable(
237
+ status: :failed,
238
+ error_message: error.respond_to?(:message) ? error.message : error.to_s,
239
+ error_backtrace: backtrace_for(error),
240
+ completed_at: finished_at,
241
+ duration_ms: calculated_duration_ms(fallback_end: finished_at)
242
+ ))
243
+ end
244
+
245
+ # Cancels a run that has not finished.
246
+ #
247
+ # A finished run is left exactly as it was — cancelling a run that
248
+ # already succeeded would rewrite history — and the caller is told so by
249
+ # the return value rather than by an exception, because "the run beat me
250
+ # to it" is a race, not a fault.
251
+ #
252
+ # @param reason [String] recorded as +error_message+ when none is set
253
+ # @return [Boolean] whether this call performed the cancellation
254
+ def cancel!(reason: "Cancelled by user")
255
+ return false unless in_progress?
256
+
257
+ finished_at = Time.current
258
+
259
+ update!(persistable(
260
+ status: :cancelled,
261
+ completed_at: finished_at,
262
+ duration_ms: calculated_duration_ms(fallback_end: finished_at),
263
+ error_message: error_message.presence || reason
264
+ ))
265
+ true
266
+ end
267
+
268
+ # === Progress events ===
269
+
270
+ # @return [Array<Hash>] the event stream, oldest first
271
+ def events_log
272
+ self[events_attribute] || []
273
+ end
274
+
275
+ # Appends a progress event so pollers can stream what the agent is doing.
276
+ #
277
+ # Events pair up by +eid+: a "started" event is pending until a "done" or
278
+ # "error" event with the same +eid+ lands, which is how a UI shows an
279
+ # in-flight LLM or tool call.
280
+ #
281
+ # Writes with +update_column+ — no validations, no callbacks, no
282
+ # +updated_at+ churn — so it is safe to call from the run's own execution
283
+ # thread mid-transaction. The re-read and the write happen under a row
284
+ # lock, so an append racing another writer cannot drop either entry.
285
+ #
286
+ # @param kind [String, Symbol] "llm", "tool", "agent", …
287
+ # @param label [String, Symbol] human-readable name of the thing happening
288
+ # @param eid [String, nil] correlation id pairing a start with its end
289
+ # @param status [String] "started", "done" or "error"
290
+ # @param detail [String, nil] truncated to {DETAIL_LIMIT} bytes
291
+ # @param duration_ms [Integer, nil]
292
+ # @return [Hash] the event as written
293
+ def append_event(kind:, label:, eid: nil, status: "done", detail: nil, duration_ms: nil)
294
+ event = {
295
+ "at" => Time.current.iso8601(3),
296
+ "eid" => eid,
297
+ "kind" => kind.to_s,
298
+ "label" => label.to_s,
299
+ "status" => status.to_s
300
+ }.compact
301
+ event["detail"] = truncated_detail(detail) if detail
302
+ event["duration_ms"] = duration_ms if duration_ms
303
+
304
+ append_to_events_log(event)
305
+ event
306
+ end
307
+
308
+ # Appends a human-readable log line to the same stream.
309
+ #
310
+ # Log entries carry +timestamp+/+level+/+message+ while events carry
311
+ # +at+/+kind+/+label+; both shapes are already persisted and both are
312
+ # read by existing dashboards, so they coexist in one column rather than
313
+ # one being rewritten into the other.
314
+ #
315
+ # Unlike {#append_event} this saves normally, so validations and
316
+ # callbacks run — a log line is usually written from the caller's own
317
+ # thread, where a full save is what is wanted.
318
+ #
319
+ # @param message [String]
320
+ # @param level [String, Symbol]
321
+ # @return [Hash] the entry as written
322
+ def add_log(message, level: :info)
323
+ entry = {
324
+ "timestamp" => Time.current.iso8601,
325
+ "level" => level.to_s,
326
+ "message" => message.to_s
327
+ }
328
+
329
+ append_to_events_log(entry, save: true)
330
+ end
331
+
332
+ # === Cohort fingerprinting ===
333
+
334
+ # Records the instructions this run executed under as a stable digest —
335
+ # the grouping key (with model) for configuration cohorts.
336
+ #
337
+ # A no-op on a schema without the column, where the digest is derived
338
+ # from +output_metadata+ instead. Assigns without saving, so an executor
339
+ # can set it alongside everything else it is about to persist.
340
+ #
341
+ # @param instructions [String, nil]
342
+ # @return [String, nil] the digest assigned
343
+ def record_instructions(instructions)
344
+ return nil unless self.class.column_names.include?("instructions_digest")
345
+
346
+ self[:instructions_digest] = SolidAgent::RunFingerprint.digest(instructions)
347
+ end
348
+
349
+ # Stable 8-character fingerprint of the instructions this run executed
350
+ # under.
351
+ #
352
+ # Prefers the stored column and falls back to hashing
353
+ # +output_metadata["instructions"]+, because the platform never had the
354
+ # column and computed this on read. Both paths use the same digest
355
+ # function, so cohorts computed either way group together.
356
+ #
357
+ # @return [String, nil]
358
+ def instructions_digest
359
+ stored = self[:instructions_digest] if self.class.column_names.include?("instructions_digest")
360
+
361
+ stored.presence || SolidAgent::RunFingerprint.digest(metadata_value("instructions"))
362
+ end
363
+
364
+ # @return [String, nil] deterministic "calm-heron" name for the digest
365
+ def instructions_codename
366
+ SolidAgent::RunFingerprint.codename(instructions_digest)
367
+ end
368
+
369
+ # === Usage and timing ===
370
+
371
+ # Total tokens the run consumed.
372
+ #
373
+ # The stored column wins when the provider reported one — it can exceed
374
+ # input + output, since cached and reasoning tokens are billed but not
375
+ # counted in either. Otherwise the two are summed, which is why a bare
376
+ # +SUM(total_tokens)+ in SQL undercounts; use {.total_tokens_sum}.
377
+ #
378
+ # @return [Integer]
379
+ def total_tokens
380
+ reported = self[:total_tokens] if self.class.column_names.include?("total_tokens")
381
+ return reported unless reported.nil?
382
+
383
+ input_tokens.to_i + output_tokens.to_i
384
+ end
385
+
386
+ # Duration in milliseconds: the stored value when set, otherwise derived
387
+ # from the timestamps.
388
+ #
389
+ # @param fallback_end [Time, nil] stands in for +completed_at+ while the
390
+ # run is being finished and the column is not written yet
391
+ # @return [Integer, nil] nil when the run never started
392
+ def calculated_duration_ms(fallback_end: nil)
393
+ return duration_ms if duration_ms.present?
394
+
395
+ finish = completed_at || fallback_end
396
+ return nil unless started_at && finish
397
+
398
+ ((finish - started_at) * 1000).to_i
399
+ end
400
+
401
+ # A display-sized digest of the run, as consumed by run lists and APIs.
402
+ #
403
+ # @return [Hash]
404
+ def summary
405
+ {
406
+ id: id,
407
+ status: status,
408
+ input_preview: input_prompt&.truncate(INPUT_PREVIEW_LIMIT),
409
+ output_preview: output&.truncate(OUTPUT_PREVIEW_LIMIT),
410
+ duration_ms: calculated_duration_ms,
411
+ tokens: total_tokens,
412
+ provider: metadata_value("provider"),
413
+ model: metadata_value("model"),
414
+ action_name: action_name || metadata_value("action") || DEFAULT_ACTION_NAME,
415
+ instructions_digest: instructions_digest,
416
+ instructions_codename: instructions_codename,
417
+ instructions_preview: metadata_value("instructions")&.truncate(INSTRUCTIONS_PREVIEW_LIMIT),
418
+ created_at: created_at,
419
+ error: error_message
420
+ }
421
+ end
422
+
423
+ private
424
+
425
+ def assign_trace_id
426
+ self.trace_id ||= SecureRandom.uuid
427
+ end
428
+
429
+ def notify_status_change
430
+ from, to = saved_change_to_status
431
+
432
+ ActiveSupport::Notifications.instrument(
433
+ STATUS_CHANGED_EVENT,
434
+ run: self, from: from, to: to, trace_id: trace_id
435
+ )
436
+ end
437
+
438
+ # Drops attributes the host's table does not have, so one lifecycle
439
+ # method serves both schemas instead of each transition growing a
440
+ # column check.
441
+ def persistable(attributes)
442
+ columns = self.class.column_names
443
+ attributes.select { |name, _| columns.include?(name.to_s) }
444
+ end
445
+
446
+ # nil rather than a raise when the foreign key is missing: each original
447
+ # schema lacks the other's, and asking for the association the table does
448
+ # not model is a legitimate question with the answer "none".
449
+ def association_if_present(name, foreign_key)
450
+ return nil unless self.class.column_names.include?(foreign_key.to_s)
451
+
452
+ public_send(name)
453
+ end
454
+
455
+ def metadata_value(key)
456
+ return nil unless self.class.column_names.include?("output_metadata")
457
+
458
+ output_metadata&.dig(key)
459
+ end
460
+
461
+ # The configured log column, checked. A host that renamed the column but
462
+ # not the setting would otherwise get a confusing nil from a json column
463
+ # that does not exist.
464
+ def events_attribute
465
+ return events_column if self.class.column_names.include?(events_column.to_s)
466
+
467
+ raise SolidAgent::Error,
468
+ "#{self.class.name} has no #{events_column} column. Set " \
469
+ "#{self.class.name}.events_column to the column holding run events."
470
+ end
471
+
472
+ # Reads through the database so concurrent appends from the executor and
473
+ # the caller interleave instead of overwriting one another. An unsaved
474
+ # record has nothing to re-read, so it keeps what is in memory.
475
+ def current_events
476
+ return events_log unless persisted?
477
+
478
+ self.class.where(id: id).pick(events_attribute) || []
479
+ end
480
+
481
+ # Read-modify-write on a JSON column loses entries when two writers
482
+ # race — a tool loop appending progress while the run's own thread
483
+ # logs, say: both read the same array and the second write wins. The
484
+ # read and the write are serialized by a row lock so the append is
485
+ # always against the latest persisted value.
486
+ #
487
+ # An unsaved record has no row to lock; it keeps the in-memory
488
+ # behaviour current_events already falls back to.
489
+ #
490
+ # @param entry [Hash] event or log entry to append
491
+ # @param save [Boolean] true runs validations and callbacks (add_log),
492
+ # false writes the column directly (append_event, called from the
493
+ # run's own execution thread)
494
+ def append_to_events_log(entry, save: false)
495
+ unless persisted?
496
+ write_attribute(events_attribute, current_events + [ entry ])
497
+ return entry
498
+ end
499
+
500
+ with_lock do
501
+ appended = current_events + [ entry ]
502
+
503
+ save ? update!(events_attribute => appended) : update_column(events_attribute, appended)
504
+ end
505
+
506
+ entry
507
+ end
508
+
509
+ def truncated_detail(detail)
510
+ detail.to_s.byteslice(0, DETAIL_LIMIT).to_s.scrub
511
+ end
512
+
513
+ def backtrace_for(error)
514
+ return nil unless error.respond_to?(:backtrace)
515
+
516
+ error.backtrace&.first(BACKTRACE_LINES)&.join("\n")
517
+ end
518
+ end
519
+ end
520
+ end
@@ -0,0 +1,142 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidAgent
4
+ module Records
5
+ # Behavior for the +AgentTemplate+ record: a named, reusable agent
6
+ # configuration that new agents are stamped out of.
7
+ #
8
+ # A template holds the same configuration columns an agent holds —
9
+ # provider, model, instructions, tools, and so on — and knows how to copy
10
+ # them onto a fresh agent record for an owner. It is a prototype, not a
11
+ # parent: the agent gets a snapshot, and later edits to the template never
12
+ # reach agents already created from it.
13
+ #
14
+ # What this concern deliberately leaves to the host:
15
+ #
16
+ # * *Catalog copy.* Seeded template libraries pin provider model IDs
17
+ # ("gpt-4o", "claude-sonnet-4-20250514"). Shipping those from a gem would
18
+ # hand its release cadence to vendor deprecation schedules, so seeds stay
19
+ # in the application that curates them.
20
+ # * *Merchandising.* Category, featured, popularity, public/free-tier — and
21
+ # the usage counter that ranks them — describe how one product sells
22
+ # templates. A persistence gem whose schema asserts a pricing model is
23
+ # wrong. Every reference here to such a column is guarded with
24
+ # +has_attribute?+ so hosts that add them still work.
25
+ #
26
+ # In place of a usage counter, {#create_agent_for} instruments
27
+ # {USED_EVENT}. Counting is a subscriber's job, which lets a dashboard
28
+ # increment a column, a metrics backend emit a gauge, and a plain host app
29
+ # do nothing at all — from the same code path.
30
+ #
31
+ # @example Stamping an agent out of a template
32
+ # template = AgentTemplate.find_by!(slug: "code-assistant")
33
+ # agent = template.create_agent_for(current_user, name: "My Reviewer")
34
+ # agent.persisted? #=> true
35
+ #
36
+ # @example Counting template usage from the host
37
+ # ActiveSupport::Notifications.subscribe("template.used.solid_agent") do |*, payload|
38
+ # payload[:template].increment!(:usage_count)
39
+ # end
40
+ module AgentTemplate
41
+ extend ActiveSupport::Concern
42
+
43
+ # Emitted after an agent is successfully created from a template.
44
+ # Payload: +:template+, +:agent+, +:owner+, and +:category+ when the
45
+ # host has added that column.
46
+ USED_EVENT = "template.used.solid_agent"
47
+
48
+ # Columns copied verbatim onto the new agent. Identity (name, slug),
49
+ # ownership and lifecycle are handled separately — those are the
50
+ # agent's own, not the template's.
51
+ CONFIGURATION_ATTRIBUTES = %w[
52
+ description
53
+ provider
54
+ model
55
+ instructions
56
+ preset_type
57
+ appearance
58
+ instruction_sets
59
+ tools
60
+ mcp_servers
61
+ model_config
62
+ ].freeze
63
+
64
+ included do
65
+ validates :name, presence: true
66
+ # Slugs are the stable handle hosts seed and link templates by
67
+ # (`find_by(slug: "code-assistant")`), so uniqueness is global rather
68
+ # than scoped the way an agent's slug is scoped to its owner.
69
+ validates :slug, presence: true, uniqueness: true
70
+ end
71
+
72
+ # The configuration this template stamps onto an agent.
73
+ #
74
+ # Only attributes the template actually has are included, so a host that
75
+ # trims columns it does not use — or adds them later — needs no change
76
+ # here.
77
+ #
78
+ # @return [Hash{String => Object}]
79
+ def template_configuration
80
+ CONFIGURATION_ATTRIBUTES.each_with_object({}) do |attribute, config|
81
+ config[attribute] = self[attribute] if has_attribute?(attribute)
82
+ end
83
+ end
84
+
85
+ # Builds and saves an agent for +owner+ from this template.
86
+ #
87
+ # Returns the agent whether or not it saved — unsaved with errors
88
+ # populated, the way +create+ does — because callers render those errors.
89
+ # {USED_EVENT} fires only on a successful save.
90
+ #
91
+ # @param owner [Object] the record the agent belongs to (user, account, …)
92
+ # @param name [String, nil] overrides the template's name
93
+ # @return [Object] an instance of {SolidAgent.agent_model}
94
+ # @raise [SolidAgent::Error] when no agent model is configured or generated
95
+ #
96
+ # @example Overriding just the name
97
+ # template.create_agent_for(account, name: "Release Notes Writer")
98
+ def create_agent_for(owner, name: nil)
99
+ model = SolidAgent.agent_model!
100
+
101
+ # Intersect with the agent's columns rather than assuming the two
102
+ # schemas match: template and agent drift independently once a host
103
+ # starts editing generated migrations.
104
+ attributes = template_configuration.slice(*model.column_names)
105
+ attributes["name"] = name.presence || self.name
106
+ attributes["status"] = :draft if draft_status?(model)
107
+
108
+ agent = model.new(attributes)
109
+ # Ownable maps `owner` onto whichever column the host actually has.
110
+ # A single-tenant install may have none, and creating an unowned agent
111
+ # is a legitimate outcome there, not an error worth raising.
112
+ agent.owner = owner if agent.respond_to?(:owner=)
113
+
114
+ if agent.save
115
+ ActiveSupport::Notifications.instrument(USED_EVENT, used_event_payload(agent, owner))
116
+ end
117
+
118
+ agent
119
+ end
120
+
121
+ private
122
+
123
+ # A template stamps out drafts, not live agents — but only where the host
124
+ # modeled a draft state. On a bare integer column with no enum the symbol
125
+ # casts to nil and trips the NOT NULL constraint, so there the database
126
+ # default is the better answer than a guess.
127
+ def draft_status?(model)
128
+ column = model.columns_hash["status"]
129
+ return false unless column
130
+ return true unless column.type == :integer
131
+
132
+ model.defined_enums["status"]&.key?("draft") || false
133
+ end
134
+
135
+ def used_event_payload(agent, owner)
136
+ payload = { template: self, agent: agent, owner: owner }
137
+ payload[:category] = self[:category] if has_attribute?(:category)
138
+ payload
139
+ end
140
+ end
141
+ end
142
+ end