Module: SolidAgent::Records::Agent

Extended by:
ActiveSupport::Concern
Includes:
Ownable
Defined in:
lib/solid_agent/records/agent.rb

Overview

Behavior for the Agent record: a persisted agent configuration — provider, model, instructions, action prompts, tools — that versions itself as it is edited and can be executed.

This is the largest of the record concerns, and the one hosts extend most, which is why the model class stays in app/models and only the behavior ships here. Everything the concern reaches for outside its own row goes through SolidAgent seams resolved at call time: the version model, the run model, the execution job, the run executor. The gem never names Agent, AgentVersion or AgentRun as constants.

What deliberately did NOT come across from the platform model:

  • Code generation. to_agent_class_code emits Ruby source for an ActiveAgent class. That is compilation, not persistence, and it belongs with the thing that knows the current agent DSL.
  • Closed vocabularies. PRESET_TYPES, INSTRUCTION_SETS, AVAILABLE_TOOLS and PROVIDERS enumerate what one React component can render. They are product copy on a release cadence the gem does not control — PROVIDERS has already drifted between the two copies of this model — so hosts own them.

The columns those vocabularies describe (+preset_type+, appearance) do stay, because production agent_versions rows already carry them inside configuration_snapshot; dropping the columns would make every existing version row lossy on restore.

Examples:

Editing an agent writes a version

agent = Agent.create!(name: "Reviewer", provider: "openai", model: "gpt-4o")
agent.version_count           #=> 1
agent.update!(instructions: "Be terse.")
agent.latest_version.change_summary #=> "Updated: instructions"

Rolling back

agent.restore_from_version!(agent.agent_versions.find_by(version_number: 1))

Constant Summary collapse

DEFAULT_ACTION =

The action every agent has without declaring one: it runs under the agent's base instructions alone.

"ask"
VERSIONED_FIELDS =

The attributes whose change is worth a new version — the agent's behavior, not its identity. Renaming an agent or moving it to another provider is a fact about the record, not a revision to roll back to.

In the platform model this list is written out twice, once to decide whether to version and once to summarize what changed; the two are the same list by definition and drift the moment a column is added.

%w[
  instructions action_prompts preset_type appearance instruction_sets
  tools mcp_servers model_config response_format
].freeze
DESCRIPTIVE_FIELDS =

Identity attributes recorded in a snapshot but never restored from one. They give a version enough context to be read on its own ("this is what the agent was called then") without letting a rollback rename the record out from under its owner.

%w[name description provider model].freeze
SNAPSHOT_FIELDS =

Everything #configuration_snapshot captures.

(DESCRIPTIVE_FIELDS + VERSIONED_FIELDS).freeze

Instance Method Summary collapse

Methods included from Ownable

#owner, #owner=

Instance Method Details

#action_prompt_for(action_name) ⇒ Hash?

Returns the stored action prompt definition.

Parameters:

  • action_name (String, Symbol)

Returns:

  • (Hash, nil)

    the stored action prompt definition



176
177
178
# File 'lib/solid_agent/records/agent.rb', line 176

def action_prompt_for(action_name)
  action_prompt_list.find { |prompt| prompt["name"] == action_name.to_s }
end

#available_actionsArray<String>

Every invokable action: the built-in default plus each named prompt.

Returns:

  • (Array<String>)


170
171
172
# File 'lib/solid_agent/records/agent.rb', line 170

def available_actions
  [ DEFAULT_ACTION ] + action_prompt_list.filter_map { |prompt| prompt["name"].presence }
end

#composed_instructions_for(action_name) ⇒ String?

The system instructions an action executes under.

Named actions stack their prompt below the agent's base instructions rather than replacing them, so an action inherits the agent's persona and adds a job to it. The default action is the base instructions alone.

Parameters:

  • action_name (String, Symbol, nil)

Returns:

  • (String, nil)

    nil when neither part has content



188
189
190
191
# File 'lib/solid_agent/records/agent.rb', line 188

def composed_instructions_for(action_name)
  action = action_prompt_for(action_name)
  [ instructions, action&.dig("prompt") ].map(&:presence).compact.join("\n\n").presence
end

#configuration_snapshotHash{Symbol => Object}

The configuration as it stands right now, for writing into a version.

Only attributes the model actually has are captured, so a host that trimmed columns it does not use still snapshots cleanly.

Returns:

  • (Hash{Symbol => Object})


199
200
201
202
203
# File 'lib/solid_agent/records/agent.rb', line 199

def configuration_snapshot
  SNAPSHOT_FIELDS.each_with_object({}) do |field, snapshot|
    snapshot[field.to_sym] = self[field] if has_attribute?(field)
  end
end

#execute(input_prompt, action: nil, **params) ⇒ ActiveRecord::Base

Enqueues an asynchronous run of this agent.

Examples:

run = agent.execute("Summarize this", action: "summarize", document_id: 7)
run.status #=> "pending"

Parameters:

  • input_prompt (String)
  • action (String, Symbol, nil) (defaults to: nil)

    a named action; unknown names fall back to the default action

  • params (Hash)

    arbitrary input parameters recorded on the run

Returns:

  • (ActiveRecord::Base)

    the pending run

Raises:



303
304
305
306
307
308
309
310
311
312
313
314
# File 'lib/solid_agent/records/agent.rb', line 303

def execute(input_prompt, action: nil, **params)
  job = SolidAgent.execution_job
  unless job
    raise SolidAgent::Error,
          "#{SolidAgent.execution_job_class} is not defined, so #{self.class} cannot enqueue a run. " \
          "Set SolidAgent.execution_job_class to the job you use, or call #test_execute to run inline."
  end

  run = create_run(input_prompt, action: action, params: params, status: :pending)
  job.perform_later(run.id)
  run
end

#instructions_digest_versionsHash{String => String}

Maps each historical instructions digest to the first version that introduced it, so run cohorts can be labelled with real agent versions ("v3") instead of raw hashes.

Returns:

  • (Hash{String => String})

    digest to version label



268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
# File 'lib/solid_agent/records/agent.rb', line 268

def instructions_digest_versions
  return {} unless version_model

  agent_versions.order(:version_number).each_with_object({}) do |version, map|
    snapshot = (version.configuration_snapshot || {}).to_h.stringify_keys
    base = snapshot["instructions"]
    label = "v#{version.version_number}"

    digest = SolidAgent::RunFingerprint.digest(base)
    map[digest] ||= label if digest

    # Named actions run under composed instructions, so their runs carry
    # a different digest per action for the same version.
    Array(snapshot["action_prompts"]).each do |action|
      next unless action.is_a?(Hash)

      composed = [ base, action["prompt"] ].map(&:presence).compact.join("\n\n")
      composed_digest = SolidAgent::RunFingerprint.digest(composed)
      map[composed_digest] ||= label if composed_digest
    end
  end
end

#latest_versionActiveRecord::Base?

Returns the highest-numbered version.

Returns:

  • (ActiveRecord::Base, nil)

    the highest-numbered version



232
233
234
235
236
# File 'lib/solid_agent/records/agent.rb', line 232

def latest_version
  return nil unless version_model

  agent_versions.order(version_number: :desc).first
end

#restore_from_version!(version) ⇒ Boolean

Rolls the agent's behavior back to a stored version.

Only VERSIONED_FIELDS are written — a rollback restores how the agent behaves, not what it is called. Because those are exactly the fields the versioning callback watches, a successful restore writes a new version of its own: history moves forward, it does not rewind.

Parameters:

Returns:

  • (Boolean)

    true

Raises:

  • (ActiveRecord::RecordInvalid)

    when the restored configuration is invalid



215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
# File 'lib/solid_agent/records/agent.rb', line 215

def restore_from_version!(version)
  snapshot = (version.configuration_snapshot || {}).to_h.stringify_keys

  attributes = VERSIONED_FIELDS.each_with_object({}) do |field, restored|
    next unless has_attribute?(field)

    # A snapshot taken before a column existed restores that column to its
    # default rather than to NULL: v1 predates action_prompts, and rolling
    # back to v1 has to clear them — but into the empty collection the
    # column defaults to, which readers can still iterate, not `nil`.
    restored[field] = snapshot.key?(field) ? snapshot[field] : self.class.column_defaults[field]
  end

  update!(attributes)
end

#telemetry_agent_classString

The ActiveAgent class name this agent's runs are recorded under — the correlation key between an agent row, telemetry traces and solid_agent contexts, all of which key on a class name string rather than an id.

Returns:

  • (String)


161
162
163
164
165
# File 'lib/solid_agent/records/agent.rb', line 161

def telemetry_agent_class
  configured = self[:agent_class_name] if has_attribute?(:agent_class_name)
  base = configured.presence || name.to_s.parameterize(separator: "_").camelize
  base.end_with?("Agent") ? base : "#{base}Agent"
end

#test_execute(input_prompt, action: nil, **params) ⇒ ActiveRecord::Base

Runs this agent inline and records the result.

Execution itself is the host's: building a runnable agent from stored provider/model/instructions is activeagent's job, not a persistence gem's, so the work goes through SolidAgent.run_executor. Everything here is bookkeeping around it.

Failures are recorded on the run rather than raised — including a missing executor. The run row is the audit trail, and "this run could not be executed" is a fact about the run worth persisting; the directive message from the unconfigured seam lands in error_message.

Examples:

Wiring the executor once, in an initializer

SolidAgent.run_executor = ->(agent, run) { AgentExecutionService.call(agent, run) }

Parameters:

  • input_prompt (String)
  • action (String, Symbol, nil) (defaults to: nil)
  • params (Hash)

Returns:

  • (ActiveRecord::Base)

    the completed or failed run



335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
# File 'lib/solid_agent/records/agent.rb', line 335

def test_execute(input_prompt, action: nil, **params)
  run = create_run(input_prompt, action: action, params: params,
                   status: :running, started_at: Time.current)

  begin
    result = SolidAgent.run_executor.call(self, run)
    # The executor is host code and may hand back string keys (a JSON
    # round trip, an HTTP client). Normalizing here keeps that from
    # silently recording a run with no output.
    record_completion(run, result.to_h.deep_symbolize_keys)
  rescue ::StandardError => error
    record_failure(run, error)
  end

  run
end

#version_countInteger

Returns how many versions exist.

Returns:

  • (Integer)

    how many versions exist



239
240
241
242
243
# File 'lib/solid_agent/records/agent.rb', line 239

def version_count
  return 0 unless version_model

  agent_versions.count
end

#versioned?Boolean

Whether edits to this agent are recorded as versions.

False for observed agents. Their configuration is not authored here — the telemetry registrar rewrites it from every ingest batch — so versioning them would fill the history with revisions nobody made, and there is nothing to roll back to anyway: the source of truth is the other application's code.

Also false when the host generated the agent model but not the version model, which is a supported install that simply keeps no history.

Returns:

  • (Boolean)


257
258
259
260
261
# File 'lib/solid_agent/records/agent.rb', line 257

def versioned?
  return false if respond_to?(:observed?) && observed?

  !version_model.nil?
end