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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -0
- data/LICENSE +21 -0
- data/README.md +321 -0
- data/Rakefile +32 -0
- data/docs/agent-md-spec.md +803 -0
- data/docs/parser-design.md +1369 -0
- data/docs/registry-api.md +882 -0
- data/examples/README.md +60 -0
- data/examples/manifests/changelog_writer.agent.md +81 -0
- data/examples/manifests/usage.rb +96 -0
- data/examples/memory_handoff/app/agents/researcher_agent.rb +36 -0
- data/examples/memory_handoff/app/agents/writer_agent.rb +41 -0
- data/examples/memory_handoff/usage.rb +45 -0
- data/examples/persistent_conversation/app/agents/support_agent.rb +59 -0
- data/examples/persistent_conversation/app/controllers/support_conversations_controller.rb +24 -0
- data/examples/persistent_conversation/app/views/agents/support/instructions.md.erb +8 -0
- data/examples/persistent_conversation/usage.rb +51 -0
- data/examples/reasoning/app/agents/analysis_agent.rb +52 -0
- data/examples/reasoning/usage.rb +52 -0
- data/examples/run_tracking/app/agents/report_agent.rb +30 -0
- data/examples/run_tracking/app/controllers/agent_runs_controller.rb +43 -0
- data/examples/run_tracking/app/jobs/document_analysis_job.rb +17 -0
- data/examples/run_tracking/app/services/document_analysis_run.rb +68 -0
- data/examples/run_tracking/usage.rb +85 -0
- data/examples/tool_streaming/app/agents/browser_agent.rb +65 -0
- data/examples/tool_streaming/app/channels/tool_status_channel.rb +24 -0
- data/examples/tool_streaming/app/views/browser_agent/tools/fetch_url.json.erb +15 -0
- data/examples/tool_streaming/usage.rb +47 -0
- data/lib/generators/solid_agent/agent/agent_generator.rb +95 -0
- data/lib/generators/solid_agent/agent/templates/action.text.erb +10 -0
- data/lib/generators/solid_agent/agent/templates/agent.rb.erb +93 -0
- data/lib/generators/solid_agent/context/context_generator.rb +124 -0
- data/lib/generators/solid_agent/context/templates/context_model.rb.erb +134 -0
- data/lib/generators/solid_agent/context/templates/create_context.rb.erb +32 -0
- data/lib/generators/solid_agent/context/templates/create_generations.rb.erb +46 -0
- data/lib/generators/solid_agent/context/templates/create_messages.rb.erb +37 -0
- data/lib/generators/solid_agent/context/templates/generation_model.rb.erb +51 -0
- data/lib/generators/solid_agent/context/templates/message_model.rb.erb +47 -0
- data/lib/generators/solid_agent/install/install_generator.rb +92 -0
- data/lib/generators/solid_agent/install/templates/agent_context.rb.erb +171 -0
- data/lib/generators/solid_agent/install/templates/agent_generation.rb.erb +76 -0
- data/lib/generators/solid_agent/install/templates/agent_memory.rb.erb +51 -0
- data/lib/generators/solid_agent/install/templates/agent_memory_entry.rb.erb +12 -0
- data/lib/generators/solid_agent/install/templates/agent_message.rb.erb +76 -0
- data/lib/generators/solid_agent/install/templates/agent_run.rb.erb +122 -0
- data/lib/generators/solid_agent/install/templates/create_agent_contexts.rb.erb +32 -0
- data/lib/generators/solid_agent/install/templates/create_agent_generations.rb.erb +51 -0
- data/lib/generators/solid_agent/install/templates/create_agent_memories.rb.erb +35 -0
- data/lib/generators/solid_agent/install/templates/create_agent_messages.rb.erb +38 -0
- data/lib/generators/solid_agent/install/templates/create_agent_runs.rb.erb +46 -0
- data/lib/generators/solid_agent/install/templates/initializer.rb.erb +51 -0
- data/lib/generators/solid_agent/manifest/manifest_generator.rb +209 -0
- data/lib/generators/solid_agent/manifest/templates/agent.md.erb +39 -0
- data/lib/generators/solid_agent/manifest/templates/prompt.erb +13 -0
- data/lib/generators/solid_agent/reasons/reasons_generator.rb +83 -0
- data/lib/generators/solid_agent/reasons/templates/add_reasoning_columns.rb.erb +12 -0
- data/lib/generators/solid_agent/tool/templates/tool.json.erb +19 -0
- data/lib/generators/solid_agent/tool/tool_generator.rb +117 -0
- data/lib/solid_agent/agent_manifest/agent_builder.rb +323 -0
- data/lib/solid_agent/agent_manifest/errors.rb +26 -0
- data/lib/solid_agent/agent_manifest/exporter_registry.rb +117 -0
- data/lib/solid_agent/agent_manifest/exporters/agent_md_exporter.rb +115 -0
- data/lib/solid_agent/agent_manifest/exporters/base_exporter.rb +152 -0
- data/lib/solid_agent/agent_manifest/exporters/crewai_exporter.rb +125 -0
- data/lib/solid_agent/agent_manifest/exporters/dotprompt_exporter.rb +92 -0
- data/lib/solid_agent/agent_manifest/input_schema.rb +154 -0
- data/lib/solid_agent/agent_manifest/manifest.rb +306 -0
- data/lib/solid_agent/agent_manifest/parser_registry.rb +185 -0
- data/lib/solid_agent/agent_manifest/parsers/agent_md_parser.rb +87 -0
- data/lib/solid_agent/agent_manifest/parsers/base_parser.rb +223 -0
- data/lib/solid_agent/agent_manifest/parsers/crewai_parser.rb +201 -0
- data/lib/solid_agent/agent_manifest/parsers/dotprompt_parser.rb +122 -0
- data/lib/solid_agent/agent_manifest/parsers/github_prompt_parser.rb +143 -0
- data/lib/solid_agent/agent_manifest/picoschema.rb +254 -0
- data/lib/solid_agent/agent_manifest/registry/auth.rb +103 -0
- data/lib/solid_agent/agent_manifest/registry/client.rb +384 -0
- data/lib/solid_agent/agent_manifest/resource.rb +103 -0
- data/lib/solid_agent/agent_manifest/tool.rb +160 -0
- data/lib/solid_agent/agent_manifest/validator.rb +368 -0
- data/lib/solid_agent/agent_manifest.rb +381 -0
- data/lib/solid_agent/engine.rb +16 -0
- data/lib/solid_agent/has_context.rb +670 -0
- data/lib/solid_agent/has_memory.rb +136 -0
- data/lib/solid_agent/has_reasons.rb +230 -0
- data/lib/solid_agent/has_tools.rb +257 -0
- data/lib/solid_agent/model_naming.rb +42 -0
- data/lib/solid_agent/model_pricing.rb +93 -0
- data/lib/solid_agent/reasonable/reason.rb +205 -0
- data/lib/solid_agent/reasonable.rb +181 -0
- data/lib/solid_agent/records/agent.rb +520 -0
- data/lib/solid_agent/records/agent_run.rb +520 -0
- data/lib/solid_agent/records/agent_template.rb +142 -0
- data/lib/solid_agent/records/agent_version.rb +141 -0
- data/lib/solid_agent/records/ownable.rb +130 -0
- data/lib/solid_agent/records.rb +152 -0
- data/lib/solid_agent/run_fingerprint.rb +51 -0
- data/lib/solid_agent/streams_tool_updates.rb +178 -0
- data/lib/solid_agent/tool_cache.rb +91 -0
- data/lib/solid_agent/version.rb +5 -0
- data/lib/solid_agent.rb +95 -0
- data/sig/solid_agent.rbs +4 -0
- 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
|