Module: SolidAgent::Records::Agent
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_codeemits 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.
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
-
#action_prompt_for(action_name) ⇒ Hash?
The stored action prompt definition.
-
#available_actions ⇒ Array<String>
Every invokable action: the built-in default plus each named prompt.
-
#composed_instructions_for(action_name) ⇒ String?
The system instructions an action executes under.
-
#configuration_snapshot ⇒ Hash{Symbol => Object}
The configuration as it stands right now, for writing into a version.
-
#execute(input_prompt, action: nil, **params) ⇒ ActiveRecord::Base
Enqueues an asynchronous run of this agent.
-
#instructions_digest_versions ⇒ Hash{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.
-
#latest_version ⇒ ActiveRecord::Base?
The highest-numbered version.
-
#restore_from_version!(version) ⇒ Boolean
Rolls the agent's behavior back to a stored version.
-
#telemetry_agent_class ⇒ String
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.
-
#test_execute(input_prompt, action: nil, **params) ⇒ ActiveRecord::Base
Runs this agent inline and records the result.
-
#version_count ⇒ Integer
How many versions exist.
-
#versioned? ⇒ Boolean
Whether edits to this agent are recorded as versions.
Methods included from Ownable
Instance Method Details
#action_prompt_for(action_name) ⇒ Hash?
Returns 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_actions ⇒ Array<String>
Every invokable action: the built-in default plus each named prompt.
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.
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_snapshot ⇒ Hash{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.
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.
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_versions ⇒ Hash{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.
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_version ⇒ ActiveRecord::Base?
Returns 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.
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_class ⇒ String
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.
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.
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_count ⇒ Integer
Returns 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.
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 |