solid_agent 0.1.1 → 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 +209 -18
- data/Rakefile +22 -2
- 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 +2 -2
- data/lib/generators/solid_agent/agent/templates/agent.rb.erb +3 -3
- data/lib/generators/solid_agent/context/templates/context_model.rb.erb +50 -16
- data/lib/generators/solid_agent/context/templates/create_generations.rb.erb +8 -0
- data/lib/generators/solid_agent/context/templates/create_messages.rb.erb +4 -0
- data/lib/generators/solid_agent/context/templates/generation_model.rb.erb +11 -0
- data/lib/generators/solid_agent/install/install_generator.rb +9 -0
- data/lib/generators/solid_agent/install/templates/agent_context.rb.erb +60 -17
- data/lib/generators/solid_agent/install/templates/agent_generation.rb.erb +23 -6
- 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_run.rb.erb +122 -0
- data/lib/generators/solid_agent/install/templates/create_agent_generations.rb.erb +13 -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 +5 -0
- data/lib/generators/solid_agent/install/templates/create_agent_runs.rb.erb +46 -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/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/has_context.rb +251 -30
- data/lib/solid_agent/has_memory.rb +136 -0
- data/lib/solid_agent/has_reasons.rb +230 -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/tool_cache.rb +91 -0
- data/lib/solid_agent/version.rb +1 -1
- data/lib/solid_agent.rb +70 -3
- metadata +87 -1
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "reasonable/reason"
|
|
4
|
+
|
|
5
|
+
module SolidAgent
|
|
6
|
+
# Reasonable provides AI reasoning/thinking trace support for models.
|
|
7
|
+
#
|
|
8
|
+
# This concern enables models to track and store reasoning traces from
|
|
9
|
+
# LLMs that support extended thinking (like Claude's extended thinking
|
|
10
|
+
# or OpenAI's o1 reasoning models).
|
|
11
|
+
#
|
|
12
|
+
# Reasoning traces help with:
|
|
13
|
+
# - Transparency: Understanding why an AI made certain decisions
|
|
14
|
+
# - Debugging: Identifying issues in AI logic
|
|
15
|
+
# - Auditing: Maintaining records of AI decision-making processes
|
|
16
|
+
# - Learning: Improving prompts based on reasoning patterns
|
|
17
|
+
#
|
|
18
|
+
# @example Basic usage in a model
|
|
19
|
+
# class AgentGeneration < ApplicationRecord
|
|
20
|
+
# include SolidAgent::Reasonable
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# generation.store_reasoning!(response)
|
|
24
|
+
# generation.reasoning_content #=> "Let me think step by step..."
|
|
25
|
+
# generation.reasoning_tokens #=> 150
|
|
26
|
+
# generation.has_reasoning? #=> true
|
|
27
|
+
#
|
|
28
|
+
# @example Configuring reasoning storage
|
|
29
|
+
# class MyGeneration < ApplicationRecord
|
|
30
|
+
# include SolidAgent::Reasonable
|
|
31
|
+
#
|
|
32
|
+
# reasonable_config(
|
|
33
|
+
# column: :thinking_trace, # Custom column name
|
|
34
|
+
# tokens_column: :think_tokens,
|
|
35
|
+
# auto_extract: true # Auto-extract from responses
|
|
36
|
+
# )
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
module Reasonable
|
|
40
|
+
extend ActiveSupport::Concern
|
|
41
|
+
|
|
42
|
+
included do
|
|
43
|
+
# Default configuration
|
|
44
|
+
class_attribute :_reasonable_config, default: {
|
|
45
|
+
column: :reasoning_content,
|
|
46
|
+
tokens_column: :reasoning_tokens,
|
|
47
|
+
metadata_column: :reasoning_metadata,
|
|
48
|
+
auto_extract: false
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class_methods do
|
|
53
|
+
# Configure reasoning storage options
|
|
54
|
+
#
|
|
55
|
+
# @param column [Symbol] Column to store reasoning content
|
|
56
|
+
# @param tokens_column [Symbol] Column to store token count
|
|
57
|
+
# @param metadata_column [Symbol] Column to store metadata (JSON)
|
|
58
|
+
# @param auto_extract [Boolean] Auto-extract reasoning from responses
|
|
59
|
+
def reasonable_config(column: nil, tokens_column: nil, metadata_column: nil, auto_extract: nil)
|
|
60
|
+
config = _reasonable_config.dup
|
|
61
|
+
config[:column] = column if column
|
|
62
|
+
config[:tokens_column] = tokens_column if tokens_column
|
|
63
|
+
config[:metadata_column] = metadata_column if metadata_column
|
|
64
|
+
config[:auto_extract] = auto_extract unless auto_extract.nil?
|
|
65
|
+
self._reasonable_config = config
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Store reasoning from an LLM response
|
|
70
|
+
#
|
|
71
|
+
# @param response [Object] LLM response with reasoning data
|
|
72
|
+
# @return [Reason, nil] The extracted reason or nil
|
|
73
|
+
def store_reasoning!(response)
|
|
74
|
+
reason = Reason.from_response(response)
|
|
75
|
+
return nil unless reason&.extended_thinking?
|
|
76
|
+
|
|
77
|
+
update_reasoning_columns(reason)
|
|
78
|
+
reason
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Store reasoning from a Reason object
|
|
82
|
+
#
|
|
83
|
+
# @param reason [Reason] The reason to store
|
|
84
|
+
# @return [Reason] The stored reason
|
|
85
|
+
def store_reason!(reason)
|
|
86
|
+
return nil unless reason.is_a?(Reason)
|
|
87
|
+
|
|
88
|
+
update_reasoning_columns(reason)
|
|
89
|
+
reason
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Get the stored reasoning content
|
|
93
|
+
#
|
|
94
|
+
# @return [String, nil]
|
|
95
|
+
def reasoning_content
|
|
96
|
+
column = _reasonable_config[:column]
|
|
97
|
+
respond_to?(column) ? send(column) : nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Get the stored reasoning token count
|
|
101
|
+
#
|
|
102
|
+
# @return [Integer]
|
|
103
|
+
def reasoning_tokens
|
|
104
|
+
column = _reasonable_config[:tokens_column]
|
|
105
|
+
(respond_to?(column) ? send(column) : 0).to_i
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Get the stored reasoning metadata
|
|
109
|
+
#
|
|
110
|
+
# @return [Hash]
|
|
111
|
+
def reasoning_metadata
|
|
112
|
+
column = _reasonable_config[:metadata_column]
|
|
113
|
+
(respond_to?(column) ? send(column) : {}) || {}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Check if this record has reasoning stored
|
|
117
|
+
#
|
|
118
|
+
# @return [Boolean]
|
|
119
|
+
def has_reasoning?
|
|
120
|
+
reasoning_content.present? || reasoning_tokens.positive?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Check if reasoning was redacted
|
|
124
|
+
#
|
|
125
|
+
# @return [Boolean]
|
|
126
|
+
def reasoning_redacted?
|
|
127
|
+
reasoning_metadata["redacted"] == true
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Get a Reason object from stored data
|
|
131
|
+
#
|
|
132
|
+
# @return [Reason, nil]
|
|
133
|
+
def to_reason
|
|
134
|
+
return nil unless has_reasoning?
|
|
135
|
+
|
|
136
|
+
Reason.new(
|
|
137
|
+
content: reasoning_content,
|
|
138
|
+
tokens: reasoning_tokens,
|
|
139
|
+
model: respond_to?(:model) ? model : nil,
|
|
140
|
+
thinking_time_ms: reasoning_metadata["thinking_time_ms"],
|
|
141
|
+
redacted: reasoning_redacted?,
|
|
142
|
+
metadata: reasoning_metadata,
|
|
143
|
+
created_at: respond_to?(:created_at) ? created_at : nil
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Get a summary of the reasoning
|
|
148
|
+
#
|
|
149
|
+
# @param length [Integer] Maximum length
|
|
150
|
+
# @return [String]
|
|
151
|
+
def reasoning_summary(length: 200)
|
|
152
|
+
return "[Redacted]" if reasoning_redacted?
|
|
153
|
+
return "" if reasoning_content.blank?
|
|
154
|
+
|
|
155
|
+
reasoning_content.truncate(length)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def update_reasoning_columns(reason)
|
|
161
|
+
updates = {}
|
|
162
|
+
|
|
163
|
+
content_col = _reasonable_config[:column]
|
|
164
|
+
tokens_col = _reasonable_config[:tokens_column]
|
|
165
|
+
metadata_col = _reasonable_config[:metadata_column]
|
|
166
|
+
|
|
167
|
+
updates[content_col] = reason.content if respond_to?("#{content_col}=")
|
|
168
|
+
updates[tokens_col] = reason.tokens if respond_to?("#{tokens_col}=")
|
|
169
|
+
|
|
170
|
+
if respond_to?("#{metadata_col}=")
|
|
171
|
+
updates[metadata_col] = {
|
|
172
|
+
thinking_time_ms: reason.thinking_time_ms,
|
|
173
|
+
redacted: reason.redacted,
|
|
174
|
+
model: reason.model
|
|
175
|
+
}.merge(reason.metadata).compact
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
update!(updates) if updates.any? && respond_to?(:update!)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -0,0 +1,520 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
5
|
+
require_relative "ownable"
|
|
6
|
+
require_relative "../run_fingerprint"
|
|
7
|
+
|
|
8
|
+
module SolidAgent
|
|
9
|
+
module Records
|
|
10
|
+
# Behavior for the +Agent+ record: a persisted agent configuration —
|
|
11
|
+
# provider, model, instructions, action prompts, tools — that versions
|
|
12
|
+
# itself as it is edited and can be executed.
|
|
13
|
+
#
|
|
14
|
+
# This is the largest of the record concerns, and the one hosts extend most,
|
|
15
|
+
# which is why the model class stays in +app/models+ and only the behavior
|
|
16
|
+
# ships here. Everything the concern reaches for outside its own row goes
|
|
17
|
+
# through {SolidAgent} seams resolved at call time: the version model, the
|
|
18
|
+
# run model, the execution job, the run executor. The gem never names
|
|
19
|
+
# +Agent+, +AgentVersion+ or +AgentRun+ as constants.
|
|
20
|
+
#
|
|
21
|
+
# What deliberately did NOT come across from the platform model:
|
|
22
|
+
#
|
|
23
|
+
# * *Code generation.* +to_agent_class_code+ emits Ruby source for an
|
|
24
|
+
# ActiveAgent class. That is compilation, not persistence, and it belongs
|
|
25
|
+
# with the thing that knows the current agent DSL.
|
|
26
|
+
# * *Closed vocabularies.* PRESET_TYPES, INSTRUCTION_SETS, AVAILABLE_TOOLS
|
|
27
|
+
# and PROVIDERS enumerate what one React component can render. They are
|
|
28
|
+
# product copy on a release cadence the gem does not control — PROVIDERS
|
|
29
|
+
# has already drifted between the two copies of this model — so hosts
|
|
30
|
+
# own them.
|
|
31
|
+
#
|
|
32
|
+
# The columns those vocabularies describe (+preset_type+, +appearance+) do
|
|
33
|
+
# stay, because production +agent_versions+ rows already carry them inside
|
|
34
|
+
# +configuration_snapshot+; dropping the columns would make every existing
|
|
35
|
+
# version row lossy on restore.
|
|
36
|
+
#
|
|
37
|
+
# @example Editing an agent writes a version
|
|
38
|
+
# agent = Agent.create!(name: "Reviewer", provider: "openai", model: "gpt-4o")
|
|
39
|
+
# agent.version_count #=> 1
|
|
40
|
+
# agent.update!(instructions: "Be terse.")
|
|
41
|
+
# agent.latest_version.change_summary #=> "Updated: instructions"
|
|
42
|
+
#
|
|
43
|
+
# @example Rolling back
|
|
44
|
+
# agent.restore_from_version!(agent.agent_versions.find_by(version_number: 1))
|
|
45
|
+
module Agent
|
|
46
|
+
extend ActiveSupport::Concern
|
|
47
|
+
include Ownable
|
|
48
|
+
|
|
49
|
+
# The action every agent has without declaring one: it runs under the
|
|
50
|
+
# agent's base instructions alone.
|
|
51
|
+
DEFAULT_ACTION = "ask"
|
|
52
|
+
|
|
53
|
+
# The attributes whose change is worth a new version — the agent's
|
|
54
|
+
# behavior, not its identity. Renaming an agent or moving it to another
|
|
55
|
+
# provider is a fact about the record, not a revision to roll back to.
|
|
56
|
+
#
|
|
57
|
+
# In the platform model this list is written out twice, once to decide
|
|
58
|
+
# whether to version and once to summarize what changed; the two are the
|
|
59
|
+
# same list by definition and drift the moment a column is added.
|
|
60
|
+
VERSIONED_FIELDS = %w[
|
|
61
|
+
instructions action_prompts preset_type appearance instruction_sets
|
|
62
|
+
tools mcp_servers model_config response_format
|
|
63
|
+
].freeze
|
|
64
|
+
|
|
65
|
+
# Identity attributes recorded in a snapshot but never restored from one.
|
|
66
|
+
# They give a version enough context to be read on its own ("this is what
|
|
67
|
+
# the agent was called then") without letting a rollback rename the
|
|
68
|
+
# record out from under its owner.
|
|
69
|
+
DESCRIPTIVE_FIELDS = %w[name description provider model].freeze
|
|
70
|
+
|
|
71
|
+
# Everything {#configuration_snapshot} captures.
|
|
72
|
+
SNAPSHOT_FIELDS = (DESCRIPTIVE_FIELDS + VERSIONED_FIELDS).freeze
|
|
73
|
+
|
|
74
|
+
included do
|
|
75
|
+
# Both associations pin foreign_key: the host names this model, and
|
|
76
|
+
# Rails would otherwise derive `platform_agent_id` from a class called
|
|
77
|
+
# PlatformAgent while the child concerns declare `belongs_to :agent`
|
|
78
|
+
# against a real `agent_id` column.
|
|
79
|
+
has_many :agent_versions,
|
|
80
|
+
class_name: SolidAgent.agent_version_class.to_s,
|
|
81
|
+
foreign_key: :agent_id,
|
|
82
|
+
dependent: :destroy
|
|
83
|
+
has_many :agent_runs,
|
|
84
|
+
class_name: SolidAgent.agent_run_class.to_s,
|
|
85
|
+
foreign_key: :agent_id,
|
|
86
|
+
dependent: :destroy
|
|
87
|
+
|
|
88
|
+
validates :name, presence: true, length: { minimum: 2, maximum: 100 }
|
|
89
|
+
validates :slug, presence: true, format: { with: /\A[a-z0-9\-_]+\z/ }
|
|
90
|
+
validates :provider, presence: true
|
|
91
|
+
validates :model, presence: true
|
|
92
|
+
# Slug uniqueness is hand-rolled rather than declared, because the
|
|
93
|
+
# scope is the ownership column and Ownable lets the host choose it.
|
|
94
|
+
# A `uniqueness: { scope: :user_id }` option would freeze that choice
|
|
95
|
+
# at include time.
|
|
96
|
+
validate :slug_must_be_unique_for_owner
|
|
97
|
+
validate :action_prompts_must_be_well_formed
|
|
98
|
+
|
|
99
|
+
# `observed` agents were discovered from reported telemetry rather than
|
|
100
|
+
# authored here. The platform cannot execute them — you cannot push
|
|
101
|
+
# instructions into someone else's app — so they are read-only records
|
|
102
|
+
# of something running elsewhere until a fork copies them.
|
|
103
|
+
enum :status, { draft: 0, active: 1, archived: 2, observed: 3 }
|
|
104
|
+
|
|
105
|
+
before_validation :generate_slug, on: :create
|
|
106
|
+
after_create :create_initial_version, if: :versioned?
|
|
107
|
+
after_update :create_version_on_config_change, if: :configuration_changed?
|
|
108
|
+
|
|
109
|
+
scope :active_agents, -> { where(status: :active) }
|
|
110
|
+
scope :observed_agents, -> { where(status: :observed) }
|
|
111
|
+
scope :authored, -> { where.not(status: :observed) }
|
|
112
|
+
scope :by_provider, ->(provider) { where(provider: provider) }
|
|
113
|
+
|
|
114
|
+
# Finds agents whose tools array contains +tool+.
|
|
115
|
+
#
|
|
116
|
+
# This is the one query in the records layer that is not portable, and
|
|
117
|
+
# it is asymmetric on purpose:
|
|
118
|
+
#
|
|
119
|
+
# * On Postgres it uses jsonb containment (`@>`), which is indexable
|
|
120
|
+
# with a GIN index and is what production runs. The column must be
|
|
121
|
+
# `jsonb` — `json` has no containment operator and raises.
|
|
122
|
+
# * Everywhere else it falls back to a quoted LIKE against the
|
|
123
|
+
# serialized array. That matches `["search"]` without matching
|
|
124
|
+
# `["research"]`, but it is a substring test on text: it cannot use
|
|
125
|
+
# an index, and it would also match a tool name appearing as a key
|
|
126
|
+
# if a host stored objects rather than strings in `tools`. The
|
|
127
|
+
# explicit ESCAPE clause is not decoration — sqlite has no default
|
|
128
|
+
# escape character, so without it a tool named `c_de` would match
|
|
129
|
+
# `code`.
|
|
130
|
+
#
|
|
131
|
+
# The fallback exists so sqlite and MySQL hosts get an answer instead
|
|
132
|
+
# of a StatementInvalid; Postgres remains the target.
|
|
133
|
+
scope :with_tool, ->(tool) {
|
|
134
|
+
if klass.jsonb_containment?
|
|
135
|
+
where("tools @> ?", [ tool.to_s ].to_json)
|
|
136
|
+
else
|
|
137
|
+
where("tools LIKE ? ESCAPE '\\'", "%\"#{klass.sanitize_sql_like(tool.to_s)}\"%")
|
|
138
|
+
end
|
|
139
|
+
}
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
class_methods do
|
|
143
|
+
# Whether this model's connection supports the jsonb containment
|
|
144
|
+
# operator.
|
|
145
|
+
#
|
|
146
|
+
# Read from the configured adapter rather than by leasing a connection:
|
|
147
|
+
# {with_tool} asks on every call, and building a relation should not
|
|
148
|
+
# check out a connection to do it.
|
|
149
|
+
#
|
|
150
|
+
# @return [Boolean]
|
|
151
|
+
def jsonb_containment?
|
|
152
|
+
connection_db_config.adapter.to_s.match?(/postgres|postgis/i)
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# The ActiveAgent class name this agent's runs are recorded under — the
|
|
157
|
+
# correlation key between an agent row, telemetry traces and solid_agent
|
|
158
|
+
# contexts, all of which key on a class name string rather than an id.
|
|
159
|
+
#
|
|
160
|
+
# @return [String]
|
|
161
|
+
def telemetry_agent_class
|
|
162
|
+
configured = self[:agent_class_name] if has_attribute?(:agent_class_name)
|
|
163
|
+
base = configured.presence || name.to_s.parameterize(separator: "_").camelize
|
|
164
|
+
base.end_with?("Agent") ? base : "#{base}Agent"
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
# Every invokable action: the built-in default plus each named prompt.
|
|
168
|
+
#
|
|
169
|
+
# @return [Array<String>]
|
|
170
|
+
def available_actions
|
|
171
|
+
[ DEFAULT_ACTION ] + action_prompt_list.filter_map { |prompt| prompt["name"].presence }
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
# @param action_name [String, Symbol]
|
|
175
|
+
# @return [Hash, nil] the stored action prompt definition
|
|
176
|
+
def action_prompt_for(action_name)
|
|
177
|
+
action_prompt_list.find { |prompt| prompt["name"] == action_name.to_s }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# The system instructions an action executes under.
|
|
181
|
+
#
|
|
182
|
+
# Named actions stack their prompt below the agent's base instructions
|
|
183
|
+
# rather than replacing them, so an action inherits the agent's persona
|
|
184
|
+
# and adds a job to it. The default action is the base instructions alone.
|
|
185
|
+
#
|
|
186
|
+
# @param action_name [String, Symbol, nil]
|
|
187
|
+
# @return [String, nil] nil when neither part has content
|
|
188
|
+
def composed_instructions_for(action_name)
|
|
189
|
+
action = action_prompt_for(action_name)
|
|
190
|
+
[ instructions, action&.dig("prompt") ].map(&:presence).compact.join("\n\n").presence
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
# The configuration as it stands right now, for writing into a version.
|
|
194
|
+
#
|
|
195
|
+
# Only attributes the model actually has are captured, so a host that
|
|
196
|
+
# trimmed columns it does not use still snapshots cleanly.
|
|
197
|
+
#
|
|
198
|
+
# @return [Hash{Symbol => Object}]
|
|
199
|
+
def configuration_snapshot
|
|
200
|
+
SNAPSHOT_FIELDS.each_with_object({}) do |field, snapshot|
|
|
201
|
+
snapshot[field.to_sym] = self[field] if has_attribute?(field)
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Rolls the agent's behavior back to a stored version.
|
|
206
|
+
#
|
|
207
|
+
# Only {VERSIONED_FIELDS} are written — a rollback restores how the agent
|
|
208
|
+
# behaves, not what it is called. Because those are exactly the fields the
|
|
209
|
+
# versioning callback watches, a successful restore writes a new version
|
|
210
|
+
# of its own: history moves forward, it does not rewind.
|
|
211
|
+
#
|
|
212
|
+
# @param version [#configuration_snapshot]
|
|
213
|
+
# @return [Boolean] true
|
|
214
|
+
# @raise [ActiveRecord::RecordInvalid] when the restored configuration is invalid
|
|
215
|
+
def restore_from_version!(version)
|
|
216
|
+
snapshot = (version.configuration_snapshot || {}).to_h.stringify_keys
|
|
217
|
+
|
|
218
|
+
attributes = VERSIONED_FIELDS.each_with_object({}) do |field, restored|
|
|
219
|
+
next unless has_attribute?(field)
|
|
220
|
+
|
|
221
|
+
# A snapshot taken before a column existed restores that column to its
|
|
222
|
+
# default rather than to NULL: v1 predates action_prompts, and rolling
|
|
223
|
+
# back to v1 has to clear them — but into the empty collection the
|
|
224
|
+
# column defaults to, which readers can still iterate, not `nil`.
|
|
225
|
+
restored[field] = snapshot.key?(field) ? snapshot[field] : self.class.column_defaults[field]
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
update!(attributes)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# @return [ActiveRecord::Base, nil] the highest-numbered version
|
|
232
|
+
def latest_version
|
|
233
|
+
return nil unless version_model
|
|
234
|
+
|
|
235
|
+
agent_versions.order(version_number: :desc).first
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
# @return [Integer] how many versions exist
|
|
239
|
+
def version_count
|
|
240
|
+
return 0 unless version_model
|
|
241
|
+
|
|
242
|
+
agent_versions.count
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Whether edits to this agent are recorded as versions.
|
|
246
|
+
#
|
|
247
|
+
# False for observed agents. Their configuration is not authored here —
|
|
248
|
+
# the telemetry registrar rewrites it from every ingest batch — so
|
|
249
|
+
# versioning them would fill the history with revisions nobody made, and
|
|
250
|
+
# there is nothing to roll back to anyway: the source of truth is the
|
|
251
|
+
# other application's code.
|
|
252
|
+
#
|
|
253
|
+
# Also false when the host generated the agent model but not the version
|
|
254
|
+
# model, which is a supported install that simply keeps no history.
|
|
255
|
+
#
|
|
256
|
+
# @return [Boolean]
|
|
257
|
+
def versioned?
|
|
258
|
+
return false if respond_to?(:observed?) && observed?
|
|
259
|
+
|
|
260
|
+
!version_model.nil?
|
|
261
|
+
end
|
|
262
|
+
|
|
263
|
+
# Maps each historical instructions digest to the first version that
|
|
264
|
+
# introduced it, so run cohorts can be labelled with real agent versions
|
|
265
|
+
# ("v3") instead of raw hashes.
|
|
266
|
+
#
|
|
267
|
+
# @return [Hash{String => String}] digest to version label
|
|
268
|
+
def instructions_digest_versions
|
|
269
|
+
return {} unless version_model
|
|
270
|
+
|
|
271
|
+
agent_versions.order(:version_number).each_with_object({}) do |version, map|
|
|
272
|
+
snapshot = (version.configuration_snapshot || {}).to_h.stringify_keys
|
|
273
|
+
base = snapshot["instructions"]
|
|
274
|
+
label = "v#{version.version_number}"
|
|
275
|
+
|
|
276
|
+
digest = SolidAgent::RunFingerprint.digest(base)
|
|
277
|
+
map[digest] ||= label if digest
|
|
278
|
+
|
|
279
|
+
# Named actions run under composed instructions, so their runs carry
|
|
280
|
+
# a different digest per action for the same version.
|
|
281
|
+
Array(snapshot["action_prompts"]).each do |action|
|
|
282
|
+
next unless action.is_a?(Hash)
|
|
283
|
+
|
|
284
|
+
composed = [ base, action["prompt"] ].map(&:presence).compact.join("\n\n")
|
|
285
|
+
composed_digest = SolidAgent::RunFingerprint.digest(composed)
|
|
286
|
+
map[composed_digest] ||= label if composed_digest
|
|
287
|
+
end
|
|
288
|
+
end
|
|
289
|
+
end
|
|
290
|
+
|
|
291
|
+
# Enqueues an asynchronous run of this agent.
|
|
292
|
+
#
|
|
293
|
+
# @param input_prompt [String]
|
|
294
|
+
# @param action [String, Symbol, nil] a named action; unknown names fall
|
|
295
|
+
# back to the default action
|
|
296
|
+
# @param params [Hash] arbitrary input parameters recorded on the run
|
|
297
|
+
# @return [ActiveRecord::Base] the pending run
|
|
298
|
+
# @raise [SolidAgent::Error] when no execution job is configured
|
|
299
|
+
#
|
|
300
|
+
# @example
|
|
301
|
+
# run = agent.execute("Summarize this", action: "summarize", document_id: 7)
|
|
302
|
+
# run.status #=> "pending"
|
|
303
|
+
def execute(input_prompt, action: nil, **params)
|
|
304
|
+
job = SolidAgent.execution_job
|
|
305
|
+
unless job
|
|
306
|
+
raise SolidAgent::Error,
|
|
307
|
+
"#{SolidAgent.execution_job_class} is not defined, so #{self.class} cannot enqueue a run. " \
|
|
308
|
+
"Set SolidAgent.execution_job_class to the job you use, or call #test_execute to run inline."
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
run = create_run(input_prompt, action: action, params: params, status: :pending)
|
|
312
|
+
job.perform_later(run.id)
|
|
313
|
+
run
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Runs this agent inline and records the result.
|
|
317
|
+
#
|
|
318
|
+
# Execution itself is the host's: building a runnable agent from stored
|
|
319
|
+
# provider/model/instructions is activeagent's job, not a persistence
|
|
320
|
+
# gem's, so the work goes through {SolidAgent.run_executor}. Everything
|
|
321
|
+
# here is bookkeeping around it.
|
|
322
|
+
#
|
|
323
|
+
# Failures are recorded on the run rather than raised — including a
|
|
324
|
+
# missing executor. The run row is the audit trail, and "this run could
|
|
325
|
+
# not be executed" is a fact about the run worth persisting; the
|
|
326
|
+
# directive message from the unconfigured seam lands in +error_message+.
|
|
327
|
+
#
|
|
328
|
+
# @param input_prompt [String]
|
|
329
|
+
# @param action [String, Symbol, nil]
|
|
330
|
+
# @param params [Hash]
|
|
331
|
+
# @return [ActiveRecord::Base] the completed or failed run
|
|
332
|
+
#
|
|
333
|
+
# @example Wiring the executor once, in an initializer
|
|
334
|
+
# SolidAgent.run_executor = ->(agent, run) { AgentExecutionService.call(agent, run) }
|
|
335
|
+
def test_execute(input_prompt, action: nil, **params)
|
|
336
|
+
run = create_run(input_prompt, action: action, params: params,
|
|
337
|
+
status: :running, started_at: Time.current)
|
|
338
|
+
|
|
339
|
+
begin
|
|
340
|
+
result = SolidAgent.run_executor.call(self, run)
|
|
341
|
+
# The executor is host code and may hand back string keys (a JSON
|
|
342
|
+
# round trip, an HTTP client). Normalizing here keeps that from
|
|
343
|
+
# silently recording a run with no output.
|
|
344
|
+
record_completion(run, result.to_h.deep_symbolize_keys)
|
|
345
|
+
rescue ::StandardError => error
|
|
346
|
+
record_failure(run, error)
|
|
347
|
+
end
|
|
348
|
+
|
|
349
|
+
run
|
|
350
|
+
end
|
|
351
|
+
|
|
352
|
+
private
|
|
353
|
+
|
|
354
|
+
def version_model
|
|
355
|
+
SolidAgent.agent_version_model
|
|
356
|
+
end
|
|
357
|
+
|
|
358
|
+
# Closing out a run is the run record's own business — Records::AgentRun
|
|
359
|
+
# merges metadata rather than replacing it, tolerates partial usage
|
|
360
|
+
# numbers and instruments the status change — so this defers to the
|
|
361
|
+
# lifecycle when the host's run model implements it. Writing the columns
|
|
362
|
+
# here as well would give one application two subtly different endings
|
|
363
|
+
# for the same run. The direct write is the floor for a plain run model
|
|
364
|
+
# that carries the columns but none of the behavior.
|
|
365
|
+
def record_completion(run, result)
|
|
366
|
+
usage = result[:usage] || {}
|
|
367
|
+
|
|
368
|
+
if run.respond_to?(:finish!)
|
|
369
|
+
return run.finish!(
|
|
370
|
+
output: result[:output],
|
|
371
|
+
metadata: result[:metadata] || {},
|
|
372
|
+
input_tokens: usage[:input_tokens],
|
|
373
|
+
output_tokens: usage[:output_tokens],
|
|
374
|
+
total_tokens: usage[:total_tokens]
|
|
375
|
+
)
|
|
376
|
+
end
|
|
377
|
+
|
|
378
|
+
run.update!(
|
|
379
|
+
output: result[:output],
|
|
380
|
+
output_metadata: result[:metadata],
|
|
381
|
+
status: :complete,
|
|
382
|
+
completed_at: Time.current,
|
|
383
|
+
duration_ms: elapsed_ms(run.started_at),
|
|
384
|
+
input_tokens: usage[:input_tokens],
|
|
385
|
+
output_tokens: usage[:output_tokens],
|
|
386
|
+
total_tokens: usage[:total_tokens]
|
|
387
|
+
)
|
|
388
|
+
end
|
|
389
|
+
|
|
390
|
+
def record_failure(run, error)
|
|
391
|
+
return run.fail!(error) if run.respond_to?(:fail!)
|
|
392
|
+
|
|
393
|
+
run.update!(
|
|
394
|
+
status: :failed,
|
|
395
|
+
completed_at: Time.current,
|
|
396
|
+
error_message: error.message,
|
|
397
|
+
error_backtrace: error.backtrace&.first(10)&.join("\n")
|
|
398
|
+
)
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
# Runs are created through the configured model's own columns: the run
|
|
402
|
+
# schema and the agent schema drift independently once a host starts
|
|
403
|
+
# editing generated migrations, and a run is not worth failing over an
|
|
404
|
+
# attribute the host chose not to keep.
|
|
405
|
+
def create_run(input_prompt, action:, params:, status:, started_at: nil)
|
|
406
|
+
attributes = {
|
|
407
|
+
"input_prompt" => input_prompt,
|
|
408
|
+
"action_name" => normalized_action(action),
|
|
409
|
+
"input_params" => params,
|
|
410
|
+
"status" => status,
|
|
411
|
+
"trace_id" => SecureRandom.uuid,
|
|
412
|
+
"started_at" => started_at
|
|
413
|
+
}.compact
|
|
414
|
+
|
|
415
|
+
agent_runs.create!(attributes.slice(*agent_runs.klass.column_names))
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
def elapsed_ms(started_at)
|
|
419
|
+
return nil unless started_at
|
|
420
|
+
|
|
421
|
+
((Time.current - started_at) * 1000).to_i
|
|
422
|
+
end
|
|
423
|
+
|
|
424
|
+
# Unknown action names fall back to the default rather than failing the
|
|
425
|
+
# run: an action can be renamed between enqueue and execution.
|
|
426
|
+
def normalized_action(action)
|
|
427
|
+
action = action.to_s.presence
|
|
428
|
+
action if action && available_actions.include?(action)
|
|
429
|
+
end
|
|
430
|
+
|
|
431
|
+
def action_prompt_list
|
|
432
|
+
list = has_attribute?(:action_prompts) ? action_prompts : nil
|
|
433
|
+
list.is_a?(Array) ? list.select { |prompt| prompt.is_a?(Hash) } : []
|
|
434
|
+
end
|
|
435
|
+
|
|
436
|
+
def generate_slug
|
|
437
|
+
return if slug.present?
|
|
438
|
+
|
|
439
|
+
base_slug = name.to_s.parameterize
|
|
440
|
+
self.slug = base_slug
|
|
441
|
+
|
|
442
|
+
# Probing the same scope the uniqueness validation uses — the platform
|
|
443
|
+
# model probes globally, which hands the second tenant to register
|
|
444
|
+
# "Support Bot" a `support-bot-1` even though the DB index
|
|
445
|
+
# (owner column + slug) had `support-bot` free for them.
|
|
446
|
+
counter = 1
|
|
447
|
+
while slug_taken?
|
|
448
|
+
self.slug = "#{base_slug}-#{counter}"
|
|
449
|
+
counter += 1
|
|
450
|
+
end
|
|
451
|
+
end
|
|
452
|
+
|
|
453
|
+
def slug_must_be_unique_for_owner
|
|
454
|
+
return if slug.blank?
|
|
455
|
+
|
|
456
|
+
errors.add(:slug, :taken) if slug_taken?
|
|
457
|
+
end
|
|
458
|
+
|
|
459
|
+
# Uniqueness is per owner, matching the (owner column, slug) unique index.
|
|
460
|
+
# `for_owner` is what makes that portable: it narrows to this record's
|
|
461
|
+
# owner where the host stores one, and no-ops into global uniqueness
|
|
462
|
+
# where it does not.
|
|
463
|
+
def slug_taken?
|
|
464
|
+
scope = self.class.where(slug: slug)
|
|
465
|
+
scope = scope.where.not(id: id) if persisted?
|
|
466
|
+
scope = scope.for_owner(self[self.class.owner_foreign_key]) if self.class.respond_to?(:owner_foreign_key)
|
|
467
|
+
scope.exists?
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
def create_initial_version
|
|
471
|
+
agent_versions.create!(
|
|
472
|
+
version_number: 1,
|
|
473
|
+
change_summary: "Initial creation",
|
|
474
|
+
configuration_snapshot: configuration_snapshot
|
|
475
|
+
)
|
|
476
|
+
end
|
|
477
|
+
|
|
478
|
+
def configuration_changed?
|
|
479
|
+
return false unless versioned?
|
|
480
|
+
|
|
481
|
+
changed_versioned_fields.any?
|
|
482
|
+
end
|
|
483
|
+
|
|
484
|
+
def create_version_on_config_change
|
|
485
|
+
agent_versions.create!(
|
|
486
|
+
version_number: (latest_version&.version_number || 0) + 1,
|
|
487
|
+
change_summary: "Updated: #{changed_versioned_fields.join(', ')}",
|
|
488
|
+
configuration_snapshot: configuration_snapshot
|
|
489
|
+
)
|
|
490
|
+
end
|
|
491
|
+
|
|
492
|
+
def changed_versioned_fields
|
|
493
|
+
saved_changes.keys & VERSIONED_FIELDS
|
|
494
|
+
end
|
|
495
|
+
|
|
496
|
+
def action_prompts_must_be_well_formed
|
|
497
|
+
return unless has_attribute?(:action_prompts)
|
|
498
|
+
return if action_prompts.blank?
|
|
499
|
+
|
|
500
|
+
unless action_prompts.is_a?(Array) && action_prompts.all? { |prompt| prompt.is_a?(Hash) }
|
|
501
|
+
errors.add(:action_prompts, "must be a list of action definitions")
|
|
502
|
+
return
|
|
503
|
+
end
|
|
504
|
+
|
|
505
|
+
names = action_prompts.map { |prompt| prompt["name"].to_s }
|
|
506
|
+
names.each do |action_name|
|
|
507
|
+
unless action_name.match?(/\A[a-z][a-z0-9_]*\z/)
|
|
508
|
+
errors.add(:action_prompts, "action name '#{action_name}' must be snake_case")
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
if action_name == DEFAULT_ACTION
|
|
512
|
+
errors.add(:action_prompts, "'#{DEFAULT_ACTION}' is the built-in default action")
|
|
513
|
+
end
|
|
514
|
+
end
|
|
515
|
+
|
|
516
|
+
errors.add(:action_prompts, "action names must be unique") if names.uniq.size != names.size
|
|
517
|
+
end
|
|
518
|
+
end
|
|
519
|
+
end
|
|
520
|
+
end
|