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 integer status enum, logs, total_tokens, error_backtrace.
  • The gem's earlier generator shape — a polymorphic runnable, a string status, events, instructions_digest, and neither total_tokens nor error_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.

Examples:

Configure a host that kept the platform's logs column

class AgentRun < ApplicationRecord
  include SolidAgent::Records::AgentRun
  self.events_column = :logs
end

Drive a run through its lifecycle

run = AgentRun.create!(agent: agent, input_prompt: "summarize this")
run.start!
run.append_event(kind: "llm", label: "gpt-4o", eid: "1", status: "started")
run.finish!(output: "", input_tokens: 120, output_tokens: 40)
run.total_tokens #=> 160

Re-broadcast status changes from the host

ActiveSupport::Notifications.subscribe("run.status_changed.solid_agent") do |*, payload|
  ActionCable.server.broadcast("agent_run_#{payload[:run].id}", payload[:run].summary)
end

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 detail is 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

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.

Parameters:

  • message (String)
  • level (String, Symbol) (defaults to: :info)

Returns:

  • (Hash)

    the entry as written



322
323
324
325
326
327
328
329
330
# File 'lib/solid_agent/records/agent_run.rb', line 322

def add_log(message, level: :info)
  entry = {
    "timestamp" => Time.current.iso8601,
    "level" => level.to_s,
    "message" => 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.

Parameters:

  • kind (String, Symbol)

    "llm", "tool", "agent", …

  • label (String, Symbol)

    human-readable name of the thing happening

  • eid (String, nil) (defaults to: nil)

    correlation id pairing a start with its end

  • status (String) (defaults to: "done")

    "started", "done" or "error"

  • detail (String, nil) (defaults to: nil)

    truncated to DETAIL_LIMIT bytes

  • duration_ms (Integer, nil) (defaults to: nil)

Returns:

  • (Hash)

    the event as written



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.

Parameters:

  • fallback_end (Time, nil) (defaults to: nil)

    stands in for completed_at while the run is being finished and the column is not written yet

Returns:

  • (Integer, nil)

    nil when the run never started



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.

Parameters:

  • reason (String) (defaults to: "Cancelled by user")

    recorded as error_message when none is set

Returns:

  • (Boolean)

    whether this call performed the cancellation



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: error_message.presence || reason
  ))
  true
end

#events_logArray<Hash>

Returns the event stream, oldest first.

Returns:

  • (Array<Hash>)

    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.

Parameters:

  • error (Exception, String)

Returns:

  • (Boolean)


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.message : 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.

Parameters:

  • output (String, nil) (defaults to: nil)
  • metadata (Hash) (defaults to: {})

    merged into output_metadata, not replacing it

  • input_tokens (Integer, nil) (defaults to: nil)
  • output_tokens (Integer, nil) (defaults to: nil)
  • total_tokens (Integer, nil) (defaults to: nil)

    the provider's own total, when it reported one

Returns:

  • (Boolean)


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.

Returns:

  • (Boolean)

    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.

Returns:

  • (Boolean)

    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_codenameString?

Returns deterministic "calm-heron" name for the digest.

Returns:

  • (String, nil)

    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_digestString?

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.

Returns:

  • (String, nil)


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.

Parameters:

  • instructions (String, nil)

Returns:

  • (String, nil)

    the digest assigned



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.

Returns:

  • (Boolean)


195
196
197
# File 'lib/solid_agent/records/agent_run.rb', line 195

def start!
  update!(persistable(status: :running, started_at: Time.current))
end

#subjectActiveRecord::Base?

The thing this run was about: the polymorphic runnable when the host attached one, otherwise the agent record.

Returns:

  • (ActiveRecord::Base, nil)


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

#summaryHash

A display-sized digest of the run, as consumed by run lists and APIs.

Returns:

  • (Hash)


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: error_message
  }
end

#total_tokensInteger

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.

Returns:

  • (Integer)


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