Module: SolidAgent::Records::AgentRun
- Extended by:
- ActiveSupport::Concern
- Defined in:
- lib/solid_agent/records/agent_run.rb
Overview
Behavior for the AgentRun record: one execution of an agent, from
enqueue through terminal state, with its inputs, outputs, token usage and
an append-only stream of progress events.
Two schemas, one concern
This record exists in the wild in two shapes, and the concern has to fit both without asking anyone to migrate a live table:
- The platform shape —
agent_id, an integerstatusenum,logs,total_tokens,error_backtrace. - The gem's earlier generator shape — a polymorphic
runnable, a stringstatus,events,instructions_digest, and neithertotal_tokensnorerror_backtrace.
The integer enum and agent_id win: they are what is deployed and
queried in production. The polymorphic runnable and instructions_digest
are kept as additions, so a run can be about a persisted agent record or
about an arbitrary host object (a workflow, a job, a document). #subject
is the one reader that does not care which.
The log column is the only irreconcilable name, so it is not reconciled —
events_column names it, defaulting to :events. A host on the platform
schema sets it to :logs and keeps its table.
Every column only one shape has — total_tokens, error_backtrace,
instructions_digest, agent_id, runnable_type — is guarded, so
including this concern on either table works and writes simply skip what
is not there.
What is deliberately absent
The platform broadcasts status changes over ActionCable. That couples a persistence concern to a delivery mechanism the host may not run, so this emits STATUS_CHANGED_EVENT instead and lets a dashboard subscribe and broadcast however it likes.
Constant Summary collapse
- STATUSES =
Integer-backed lifecycle. The values are the ones already persisted in production rows and must not be renumbered.
{ pending: 0, running: 1, complete: 2, failed: 3, cancelled: 4 }.freeze
- STATUS_CHANGED_EVENT =
Emitted after a committed status change. Payload:
:run,:from,:to,:trace_id. "run.status_changed.solid_agent"- DETAIL_LIMIT =
Event
detailis operator-facing context, not a payload to preserve — a runaway tool response must not turn the events column into the largest row in the table. 1200- BACKTRACE_LINES =
Enough backtrace to name the failing frame and its callers; the full trace belongs in the exception reporter, not in every run row.
10- INPUT_PREVIEW_LIMIT =
100- OUTPUT_PREVIEW_LIMIT =
200- INSTRUCTIONS_PREVIEW_LIMIT =
120- DEFAULT_ACTION_NAME =
What #summary reports when neither the column nor the metadata names an action — every agent has a default entry point, and callers group by this field.
"ask"
Instance Method Summary collapse
-
#add_log(message, level: :info) ⇒ Hash
Appends a human-readable log line to the same stream.
-
#append_event(kind:, label:, eid: nil, status: "done", detail: nil, duration_ms: nil) ⇒ Hash
Appends a progress event so pollers can stream what the agent is doing.
-
#calculated_duration_ms(fallback_end: nil) ⇒ Integer?
Duration in milliseconds: the stored value when set, otherwise derived from the timestamps.
-
#cancel!(reason: "Cancelled by user") ⇒ Boolean
Cancels a run that has not finished.
-
#events_log ⇒ Array<Hash>
The event stream, oldest first.
-
#fail!(error) ⇒ Boolean
Records a failure.
-
#finish!(output: nil, metadata: {}, input_tokens: nil, output_tokens: nil, total_tokens: nil) ⇒ Boolean
Completes the run: output, merged metadata, usage and duration.
-
#finished? ⇒ Boolean
Whether the run reached any terminal state, successful or not.
-
#in_progress? ⇒ Boolean
Whether the run has not reached a terminal state.
-
#instructions_codename ⇒ String?
Deterministic "calm-heron" name for the digest.
-
#instructions_digest ⇒ String?
Stable 8-character fingerprint of the instructions this run executed under.
-
#record_instructions(instructions) ⇒ String?
Records the instructions this run executed under as a stable digest — the grouping key (with model) for configuration cohorts.
-
#start! ⇒ Boolean
Marks the run as running and stamps
started_at. -
#subject ⇒ ActiveRecord::Base?
The thing this run was about: the polymorphic
runnablewhen the host attached one, otherwise the agent record. -
#summary ⇒ Hash
A display-sized digest of the run, as consumed by run lists and APIs.
-
#total_tokens ⇒ Integer
Total tokens the run consumed.
Instance Method Details
#add_log(message, level: :info) ⇒ Hash
Appends a human-readable log line to the same stream.
Log entries carry +timestamp+/+level+/+message+ while events carry +at+/+kind+/+label+; both shapes are already persisted and both are read by existing dashboards, so they coexist in one column rather than one being rewritten into the other.
Unlike #append_event this saves normally, so validations and callbacks run — a log line is usually written from the caller's own thread, where a full save is what is wanted.
322 323 324 325 326 327 328 329 330 |
# File 'lib/solid_agent/records/agent_run.rb', line 322 def add_log(, level: :info) entry = { "timestamp" => Time.current.iso8601, "level" => level.to_s, "message" => .to_s } append_to_events_log(entry, save: true) end |
#append_event(kind:, label:, eid: nil, status: "done", detail: nil, duration_ms: nil) ⇒ Hash
Appends a progress event so pollers can stream what the agent is doing.
Events pair up by eid: a "started" event is pending until a "done" or
"error" event with the same eid lands, which is how a UI shows an
in-flight LLM or tool call.
Writes with update_column — no validations, no callbacks, no
updated_at churn — so it is safe to call from the run's own execution
thread mid-transaction. The re-read and the write happen under a row
lock, so an append racing another writer cannot drop either entry.
293 294 295 296 297 298 299 300 301 302 303 304 305 306 |
# File 'lib/solid_agent/records/agent_run.rb', line 293 def append_event(kind:, label:, eid: nil, status: "done", detail: nil, duration_ms: nil) event = { "at" => Time.current.iso8601(3), "eid" => eid, "kind" => kind.to_s, "label" => label.to_s, "status" => status.to_s }.compact event["detail"] = truncated_detail(detail) if detail event["duration_ms"] = duration_ms if duration_ms append_to_events_log(event) event end |
#calculated_duration_ms(fallback_end: nil) ⇒ Integer?
Duration in milliseconds: the stored value when set, otherwise derived from the timestamps.
392 393 394 395 396 397 398 399 |
# File 'lib/solid_agent/records/agent_run.rb', line 392 def calculated_duration_ms(fallback_end: nil) return duration_ms if duration_ms.present? finish = completed_at || fallback_end return nil unless started_at && finish ((finish - started_at) * 1000).to_i end |
#cancel!(reason: "Cancelled by user") ⇒ Boolean
Cancels a run that has not finished.
A finished run is left exactly as it was — cancelling a run that already succeeded would rewrite history — and the caller is told so by the return value rather than by an exception, because "the run beat me to it" is a race, not a fault.
254 255 256 257 258 259 260 261 262 263 264 265 266 |
# File 'lib/solid_agent/records/agent_run.rb', line 254 def cancel!(reason: "Cancelled by user") return false unless in_progress? finished_at = Time.current update!(persistable( status: :cancelled, completed_at: finished_at, duration_ms: calculated_duration_ms(fallback_end: finished_at), error_message: .presence || reason )) true end |
#events_log ⇒ Array<Hash>
Returns the event stream, oldest first.
271 272 273 |
# File 'lib/solid_agent/records/agent_run.rb', line 271 def events_log self[events_attribute] || [] end |
#fail!(error) ⇒ Boolean
Records a failure. Accepts an exception or a plain message.
233 234 235 236 237 238 239 240 241 242 243 |
# File 'lib/solid_agent/records/agent_run.rb', line 233 def fail!(error) finished_at = Time.current update!(persistable( status: :failed, error_message: error.respond_to?(:message) ? error. : error.to_s, error_backtrace: backtrace_for(error), completed_at: finished_at, duration_ms: calculated_duration_ms(fallback_end: finished_at) )) end |
#finish!(output: nil, metadata: {}, input_tokens: nil, output_tokens: nil, total_tokens: nil) ⇒ Boolean
Completes the run: output, merged metadata, usage and duration.
Named finish! rather than complete! because the enum owns that
name — see the note on STATUSES in the included block.
Usage arguments are nil-tolerant and fall back to whatever is already on the record, so an executor that recorded tokens incrementally mid-run does not have to repeat them here.
214 215 216 217 218 219 220 221 222 223 224 225 226 227 |
# File 'lib/solid_agent/records/agent_run.rb', line 214 def finish!(output: nil, metadata: {}, input_tokens: nil, output_tokens: nil, total_tokens: nil) finished_at = Time.current update!(persistable( status: :complete, output: output, output_metadata: ( || {}).merge( || {}), input_tokens: input_tokens || self.input_tokens, output_tokens: output_tokens || self.output_tokens, total_tokens: total_tokens || self[:total_tokens], completed_at: finished_at, duration_ms: calculated_duration_ms(fallback_end: finished_at) )) end |
#finished? ⇒ Boolean
Returns whether the run reached any terminal state, successful or not.
186 187 188 |
# File 'lib/solid_agent/records/agent_run.rb', line 186 def finished? complete? || failed? || cancelled? end |
#in_progress? ⇒ Boolean
Returns whether the run has not reached a terminal state.
181 182 183 |
# File 'lib/solid_agent/records/agent_run.rb', line 181 def in_progress? pending? || running? end |
#instructions_codename ⇒ String?
Returns deterministic "calm-heron" name for the digest.
365 366 367 |
# File 'lib/solid_agent/records/agent_run.rb', line 365 def instructions_codename SolidAgent::RunFingerprint.codename(instructions_digest) end |
#instructions_digest ⇒ String?
Stable 8-character fingerprint of the instructions this run executed under.
Prefers the stored column and falls back to hashing
output_metadata["instructions"], because the platform never had the
column and computed this on read. Both paths use the same digest
function, so cohorts computed either way group together.
358 359 360 361 362 |
# File 'lib/solid_agent/records/agent_run.rb', line 358 def instructions_digest stored = self[:instructions_digest] if self.class.column_names.include?("instructions_digest") stored.presence || SolidAgent::RunFingerprint.digest(("instructions")) end |
#record_instructions(instructions) ⇒ String?
Records the instructions this run executed under as a stable digest — the grouping key (with model) for configuration cohorts.
A no-op on a schema without the column, where the digest is derived
from output_metadata instead. Assigns without saving, so an executor
can set it alongside everything else it is about to persist.
343 344 345 346 347 |
# File 'lib/solid_agent/records/agent_run.rb', line 343 def record_instructions(instructions) return nil unless self.class.column_names.include?("instructions_digest") self[:instructions_digest] = SolidAgent::RunFingerprint.digest(instructions) end |
#start! ⇒ Boolean
Marks the run as running and stamps started_at.
195 196 197 |
# File 'lib/solid_agent/records/agent_run.rb', line 195 def start! update!(persistable(status: :running, started_at: Time.current)) end |
#subject ⇒ ActiveRecord::Base?
The thing this run was about: the polymorphic runnable when the host
attached one, otherwise the agent record.
176 177 178 |
# File 'lib/solid_agent/records/agent_run.rb', line 176 def subject association_if_present(:runnable, :runnable_id) || association_if_present(:agent, :agent_id) end |
#summary ⇒ Hash
A display-sized digest of the run, as consumed by run lists and APIs.
404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 |
# File 'lib/solid_agent/records/agent_run.rb', line 404 def summary { id: id, status: status, input_preview: input_prompt&.truncate(INPUT_PREVIEW_LIMIT), output_preview: output&.truncate(OUTPUT_PREVIEW_LIMIT), duration_ms: calculated_duration_ms, tokens: total_tokens, provider: ("provider"), model: ("model"), action_name: action_name || ("action") || DEFAULT_ACTION_NAME, instructions_digest: instructions_digest, instructions_codename: instructions_codename, instructions_preview: ("instructions")&.truncate(INSTRUCTIONS_PREVIEW_LIMIT), created_at: created_at, error: } end |
#total_tokens ⇒ Integer
Total tokens the run consumed.
The stored column wins when the provider reported one — it can exceed
input + output, since cached and reasoning tokens are billed but not
counted in either. Otherwise the two are summed, which is why a bare
SUM(total_tokens) in SQL undercounts; use total_tokens_sum.
379 380 381 382 383 384 |
# File 'lib/solid_agent/records/agent_run.rb', line 379 def total_tokens reported = self[:total_tokens] if self.class.column_names.include?("total_tokens") return reported unless reported.nil? input_tokens.to_i + output_tokens.to_i end |