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,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidAgent
|
|
4
|
+
module Records
|
|
5
|
+
# Version history for an agent's configuration.
|
|
6
|
+
#
|
|
7
|
+
# Every time an agent's versioned attributes change, the host writes a new
|
|
8
|
+
# row holding a full snapshot of the configuration at that moment. Snapshots
|
|
9
|
+
# are whole, not deltas: a version has to be restorable on its own, long
|
|
10
|
+
# after the rows around it were pruned, and diffing is cheap enough to do in
|
|
11
|
+
# Ruby.
|
|
12
|
+
#
|
|
13
|
+
# The +agent+ association is named with the configured class *string*
|
|
14
|
+
# (SolidAgent.agent_class) rather than a constant, so the host can point the
|
|
15
|
+
# records layer at +Ai::Assistant+ without the gem ever touching an
|
|
16
|
+
# autoloadable constant during load.
|
|
17
|
+
#
|
|
18
|
+
# Nothing here uses jsonb operators, so the concern works the same on
|
|
19
|
+
# Postgres, MySQL and sqlite; comparison happens in Ruby.
|
|
20
|
+
#
|
|
21
|
+
# @example Diffing two versions
|
|
22
|
+
# v2.diff(v1)
|
|
23
|
+
# #=> { "model" => { from: "gpt-4o-mini", to: "gpt-4o" },
|
|
24
|
+
# # "tools" => { from: nil, to: ["search"] } }
|
|
25
|
+
#
|
|
26
|
+
# @example Walking the history
|
|
27
|
+
# version.previous #=> the next-lower version, or nil at v1
|
|
28
|
+
# version.next_version #=> the next-higher version, or nil at the tip
|
|
29
|
+
module AgentVersion
|
|
30
|
+
extend ActiveSupport::Concern
|
|
31
|
+
|
|
32
|
+
included do
|
|
33
|
+
# class_name is read as a String at include time — the host configures
|
|
34
|
+
# SolidAgent in an initializer, which always runs before app/models is
|
|
35
|
+
# autoloaded, and Rails resolves the string to a class only on first
|
|
36
|
+
# use. Constantizing here instead would pin a class that a code reload
|
|
37
|
+
# then replaces.
|
|
38
|
+
# optional: false is spelled out rather than inherited from the host's
|
|
39
|
+
# belongs_to_required_by_default: agent_id is NOT NULL, so an app that
|
|
40
|
+
# loads older Rails defaults would otherwise trade a validation error
|
|
41
|
+
# for a NotNullViolation.
|
|
42
|
+
belongs_to :agent, class_name: SolidAgent.agent_class.to_s, optional: false
|
|
43
|
+
|
|
44
|
+
# Mirrors the unique index on [agent_id, version_number]. The index is
|
|
45
|
+
# the real guarantee; the validation exists to fail with a readable
|
|
46
|
+
# error instead of a RecordNotUnique from the adapter.
|
|
47
|
+
validates :version_number, presence: true, uniqueness: { scope: :agent_id }
|
|
48
|
+
validates :configuration_snapshot, presence: true
|
|
49
|
+
|
|
50
|
+
scope :recent, -> { order(version_number: :desc) }
|
|
51
|
+
scope :by_version, ->(number) { where(version_number: number) }
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Compares this version's snapshot against another's.
|
|
55
|
+
#
|
|
56
|
+
# The result is keyed by configuration key and reads from the *other*
|
|
57
|
+
# version to this one, so `newer.diff(older)` describes what the newer
|
|
58
|
+
# version changed.
|
|
59
|
+
#
|
|
60
|
+
# Both key sets are unioned, which means a key dropped in this version is
|
|
61
|
+
# reported as `{ from: <old value>, to: nil }` rather than silently
|
|
62
|
+
# skipped. Keys are compared as strings, because a snapshot built in
|
|
63
|
+
# memory carries symbol keys while one loaded from a json column carries
|
|
64
|
+
# strings, and the two must not read as a wholesale rewrite.
|
|
65
|
+
#
|
|
66
|
+
# @param other_version [#configuration_snapshot, nil]
|
|
67
|
+
# @return [Hash{String => Hash}] changed keys to +{ from:, to: }+
|
|
68
|
+
#
|
|
69
|
+
# @example A removed key
|
|
70
|
+
# v1.update!(configuration_snapshot: { "model" => "gpt-4o", "tools" => ["search"] })
|
|
71
|
+
# v2.update!(configuration_snapshot: { "model" => "gpt-4o" })
|
|
72
|
+
# v2.diff(v1) #=> { "tools" => { from: ["search"], to: nil } }
|
|
73
|
+
def diff(other_version)
|
|
74
|
+
return {} unless other_version
|
|
75
|
+
|
|
76
|
+
mine = normalized_snapshot(configuration_snapshot)
|
|
77
|
+
theirs = normalized_snapshot(other_version.configuration_snapshot)
|
|
78
|
+
|
|
79
|
+
(mine.keys | theirs.keys).each_with_object({}) do |key, changes|
|
|
80
|
+
before = theirs[key]
|
|
81
|
+
after = mine[key]
|
|
82
|
+
changes[key] = { from: before, to: after } unless before == after
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# The nearest version below this one, or nil when this is the first.
|
|
87
|
+
#
|
|
88
|
+
# @return [ActiveRecord::Base, nil]
|
|
89
|
+
def previous
|
|
90
|
+
sibling_versions.where("version_number < ?", version_number).order(version_number: :desc).first
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# The nearest version above this one, or nil when this is the tip.
|
|
94
|
+
#
|
|
95
|
+
# @return [ActiveRecord::Base, nil]
|
|
96
|
+
def next_version
|
|
97
|
+
sibling_versions.where("version_number > ?", version_number).order(version_number: :asc).first
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Whether no higher-numbered version exists for the same agent.
|
|
101
|
+
#
|
|
102
|
+
# Answered from the version table alone rather than by asking the agent
|
|
103
|
+
# for its latest version: the gem owns no part of the host's Agent model
|
|
104
|
+
# and must not require it to expose a +latest_version+ reader.
|
|
105
|
+
#
|
|
106
|
+
# @return [Boolean]
|
|
107
|
+
def latest?
|
|
108
|
+
return false if version_number.nil?
|
|
109
|
+
|
|
110
|
+
!sibling_versions.where("version_number > ?", version_number).exists?
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
# Whether this is version 1.
|
|
114
|
+
#
|
|
115
|
+
# Deliberately a property of the numbering, not of the surviving rows —
|
|
116
|
+
# after old versions are pruned the oldest remaining row is not the
|
|
117
|
+
# initial configuration and should not claim to be.
|
|
118
|
+
#
|
|
119
|
+
# @return [Boolean]
|
|
120
|
+
def initial?
|
|
121
|
+
version_number == 1
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
# Versions of the same agent, queried through the concrete host class.
|
|
127
|
+
# Going through the class rather than +agent.agent_versions+ keeps the
|
|
128
|
+
# gem from assuming what the host named the inverse association, and
|
|
129
|
+
# avoids loading the agent row just to walk sibling versions.
|
|
130
|
+
def sibling_versions
|
|
131
|
+
self.class.where(agent_id: agent_id)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
def normalized_snapshot(snapshot)
|
|
135
|
+
return {} if snapshot.blank?
|
|
136
|
+
|
|
137
|
+
snapshot.to_h.transform_keys(&:to_s)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidAgent
|
|
4
|
+
module Records
|
|
5
|
+
# Configurable ownership for the agent records.
|
|
6
|
+
#
|
|
7
|
+
# Tenancy is the one thing every application has already decided before it
|
|
8
|
+
# installs this gem. The platform scopes agents to a plain +user_id+; a
|
|
9
|
+
# multi-tenant host scopes them to an account, a workspace, an organization.
|
|
10
|
+
# Hardcoding +belongs_to :user+ would force one of those to migrate, and
|
|
11
|
+
# hardcoding nothing would leave every consumer to reinvent the same scope.
|
|
12
|
+
#
|
|
13
|
+
# So the concern declares the association from two class attributes, and
|
|
14
|
+
# everything else in the gem talks to it through the +owner+ pair — which is
|
|
15
|
+
# why {AgentTemplate#create_agent_for} can assign an owner it knows nothing
|
|
16
|
+
# about.
|
|
17
|
+
#
|
|
18
|
+
# The +belongs_to+ is declared with a class *name*, never a class: host
|
|
19
|
+
# models are autoloaded, and constantizing +User+ while the gem's concern is
|
|
20
|
+
# being included either deadlocks the Rails loader or pins a class that the
|
|
21
|
+
# next code reload replaces. It is also +optional: true+, because a
|
|
22
|
+
# single-user install legitimately has agents that belong to nobody.
|
|
23
|
+
#
|
|
24
|
+
# @example The default: agents own a user_id column
|
|
25
|
+
# class Agent < ApplicationRecord
|
|
26
|
+
# include SolidAgent::Records::Ownable
|
|
27
|
+
# end
|
|
28
|
+
#
|
|
29
|
+
# agent.owner = current_user
|
|
30
|
+
# Agent.for_owner(current_user)
|
|
31
|
+
#
|
|
32
|
+
# @example A multi-tenant host
|
|
33
|
+
# class Agent < ApplicationRecord
|
|
34
|
+
# include SolidAgent::Records::Ownable
|
|
35
|
+
# owned_by :account, class_name: "Tenancy::Account"
|
|
36
|
+
# end
|
|
37
|
+
#
|
|
38
|
+
# Agent.owner_foreign_key #=> "account_id"
|
|
39
|
+
module Ownable
|
|
40
|
+
extend ActiveSupport::Concern
|
|
41
|
+
|
|
42
|
+
included do
|
|
43
|
+
# instance_writer is off deliberately: ownership mapping is a property
|
|
44
|
+
# of the model, and a record that could rewrite it would make
|
|
45
|
+
# `for_owner` and `owner` disagree for the length of a request.
|
|
46
|
+
class_attribute :owner_association, instance_writer: false, default: :user
|
|
47
|
+
class_attribute :owner_class_name, instance_writer: false, default: "User"
|
|
48
|
+
|
|
49
|
+
declare_owner_association
|
|
50
|
+
|
|
51
|
+
# Restricts to one owner — and to nothing at all when the host has no
|
|
52
|
+
# ownership column, where it returns every record instead of raising.
|
|
53
|
+
# A single-tenant install still calls `for_owner(current_user)` from
|
|
54
|
+
# shared code paths, and there the honest answer to "which of these are
|
|
55
|
+
# yours" is "all of them", not StatementInvalid.
|
|
56
|
+
scope :for_owner, ->(owner) {
|
|
57
|
+
klass.owner_column? ? where(klass.owner_foreign_key => owner) : all
|
|
58
|
+
}
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
class_methods do
|
|
62
|
+
# Points ownership at a different association.
|
|
63
|
+
#
|
|
64
|
+
# @param association [Symbol, String] association name, e.g. +:account+
|
|
65
|
+
# @param class_name [String, nil] owner model name; defaults to the
|
|
66
|
+
# association name camelized
|
|
67
|
+
# @param options [Hash] passed through to +belongs_to+ (+foreign_key+,
|
|
68
|
+
# +inverse_of+, +optional: false+ to require an owner, …)
|
|
69
|
+
# @return [void]
|
|
70
|
+
#
|
|
71
|
+
# @example Requiring an owner
|
|
72
|
+
# owned_by :account, class_name: "Account", optional: false
|
|
73
|
+
def owned_by(association, class_name: nil, **options)
|
|
74
|
+
self.owner_association = association.to_sym
|
|
75
|
+
self.owner_class_name = (class_name.presence || association.to_s.camelize).to_s
|
|
76
|
+
|
|
77
|
+
declare_owner_association(**options)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# The column ownership is stored in.
|
|
81
|
+
#
|
|
82
|
+
# Read from the reflection rather than assembled from the association
|
|
83
|
+
# name, so a host that passed a custom +foreign_key+ to {owned_by} gets
|
|
84
|
+
# the column it actually chose.
|
|
85
|
+
#
|
|
86
|
+
# @return [String]
|
|
87
|
+
def owner_foreign_key
|
|
88
|
+
reflect_on_association(owner_association)&.foreign_key&.to_s || "#{owner_association}_id"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Whether this model actually stores an owner.
|
|
92
|
+
#
|
|
93
|
+
# Consults the schema rather than the declaration: the association is
|
|
94
|
+
# always declared, and a host that generated the tables without an
|
|
95
|
+
# ownership column is a supported install, not a broken one.
|
|
96
|
+
#
|
|
97
|
+
# @return [Boolean]
|
|
98
|
+
def owner_column?
|
|
99
|
+
column_names.include?(owner_foreign_key)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def declare_owner_association(**options)
|
|
105
|
+
belongs_to owner_association, **{ class_name: owner_class_name, optional: true }.merge(options)
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# The record this one belongs to, or nil when the host stores no owner.
|
|
110
|
+
#
|
|
111
|
+
# Defined as a method rather than +alias_method+ because the underlying
|
|
112
|
+
# association is per-class configuration: an alias would bind to whatever
|
|
113
|
+
# {owned_by} had been called with at include time, and a subclass that
|
|
114
|
+
# re-owned itself would silently keep reading the parent's association.
|
|
115
|
+
#
|
|
116
|
+
# @return [Object, nil]
|
|
117
|
+
def owner
|
|
118
|
+
return nil unless self.class.owner_column?
|
|
119
|
+
|
|
120
|
+
public_send(self.class.owner_association)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# @param record [Object, nil] the owning record
|
|
124
|
+
# @return [Object, nil] the assigned record
|
|
125
|
+
def owner=(record)
|
|
126
|
+
public_send(:"#{self.class.owner_association}=", record)
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidAgent
|
|
4
|
+
# Behavior for the agent-configuration records: the agent itself, its version
|
|
5
|
+
# history, the templates it can be created from, and its runs.
|
|
6
|
+
#
|
|
7
|
+
# The gem ships behavior only. The model classes are host-owned — generated
|
|
8
|
+
# into +app/models+ by `rails generate solid_agent:agents` — and the gem
|
|
9
|
+
# never defines or requires the +Agent+, +AgentVersion+, +AgentTemplate+ or
|
|
10
|
+
# +AgentRun+ constants. That is deliberate, for three reasons:
|
|
11
|
+
#
|
|
12
|
+
# * ActiveAgent's dashboard cannot depend on solid_agent — solid_agent
|
|
13
|
+
# already depends on activeagent, so the reverse edge would be a cycle.
|
|
14
|
+
# Naming the models with configurable strings and resolving them at call
|
|
15
|
+
# time is what lets both the dashboard and a plain host app read the same
|
|
16
|
+
# tables without either gem requiring the other.
|
|
17
|
+
# * Engine-namespacing them as +SolidAgent::Agent+ would make
|
|
18
|
+
# +isolate_namespace+ resolve the table to +solid_agent_agents+, and would
|
|
19
|
+
# invalidate the +contextable_type: "Agent"+ strings already persisted in
|
|
20
|
+
# production +agent_contexts+ rows.
|
|
21
|
+
# * Agent configuration is the thing applications most want to extend. A
|
|
22
|
+
# host-owned model can be edited; a gem-owned one can only be monkey-patched.
|
|
23
|
+
#
|
|
24
|
+
# @example Resolving the configured model
|
|
25
|
+
# SolidAgent.agent_model #=> Agent
|
|
26
|
+
# SolidAgent.agent_model_name #=> "Agent"
|
|
27
|
+
# SolidAgent.records_installed? #=> true
|
|
28
|
+
#
|
|
29
|
+
# @example Pointing at differently-named models
|
|
30
|
+
# SolidAgent.configure do |config|
|
|
31
|
+
# config.agent_class = "Ai::Assistant"
|
|
32
|
+
# config.agent_run_class = "Ai::AssistantRun"
|
|
33
|
+
# end
|
|
34
|
+
module Records
|
|
35
|
+
# Model names the gem resolves lazily, and their defaults.
|
|
36
|
+
MODELS = {
|
|
37
|
+
agent_class: "Agent",
|
|
38
|
+
agent_version_class: "AgentVersion",
|
|
39
|
+
agent_template_class: "AgentTemplate",
|
|
40
|
+
agent_run_class: "AgentRun"
|
|
41
|
+
}.freeze
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
class << self
|
|
45
|
+
Records::MODELS.each_key { |name| attr_writer name }
|
|
46
|
+
|
|
47
|
+
Records::MODELS.each do |name, default|
|
|
48
|
+
# Configured class name, as a String. Never constantized at load time —
|
|
49
|
+
# host models are autoloaded, and touching them during gem load either
|
|
50
|
+
# deadlocks the Rails loader or pins a stale class across a reload.
|
|
51
|
+
define_method(name) { instance_variable_get(:"@#{name}") || default }
|
|
52
|
+
|
|
53
|
+
# The resolved class, or nil when the host has not generated it.
|
|
54
|
+
#
|
|
55
|
+
# @return [Class, nil]
|
|
56
|
+
reader = name.to_s.sub(/_class\z/, "_model")
|
|
57
|
+
define_method(reader) { public_send(name).to_s.safe_constantize }
|
|
58
|
+
|
|
59
|
+
# The resolved class, raising a directive error when absent.
|
|
60
|
+
#
|
|
61
|
+
# @raise [SolidAgent::Error]
|
|
62
|
+
define_method("#{reader}!") do
|
|
63
|
+
public_send(reader) ||
|
|
64
|
+
raise(Error, "#{public_send(name)} is not defined. Run `rails generate solid_agent:agents` " \
|
|
65
|
+
"to create it, or set SolidAgent.#{name} to the model you use instead.")
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Whether the agent-records models are present and backed by tables.
|
|
70
|
+
#
|
|
71
|
+
# Consumers that must degrade gracefully — ActiveAgent's dashboard being
|
|
72
|
+
# the motivating one — check this before touching the models. It answers
|
|
73
|
+
# false both when the constant is missing and when the migration has not
|
|
74
|
+
# run, because a defined model over a missing table fails later and less
|
|
75
|
+
# legibly.
|
|
76
|
+
#
|
|
77
|
+
# @return [Boolean]
|
|
78
|
+
def records_installed?
|
|
79
|
+
model = agent_model
|
|
80
|
+
return false unless model
|
|
81
|
+
|
|
82
|
+
model.respond_to?(:table_exists?) && model.table_exists?
|
|
83
|
+
rescue ::StandardError
|
|
84
|
+
# A connection that is not established yet is not an error worth raising
|
|
85
|
+
# from a predicate whose whole job is to be safe to call.
|
|
86
|
+
false
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Executes an agent record and returns its result.
|
|
90
|
+
#
|
|
91
|
+
# Running an agent from a persisted configuration means building a class
|
|
92
|
+
# from stored provider/model/instructions and driving it — execution
|
|
93
|
+
# concerns, which belong to activeagent and to the host, not to a
|
|
94
|
+
# persistence gem. So the gem defines the seam and the host fills it.
|
|
95
|
+
#
|
|
96
|
+
# The callable receives +(agent_record, run)+ and must return a Hash with
|
|
97
|
+
# +:output+ and optionally +:metadata+ and +:usage+.
|
|
98
|
+
#
|
|
99
|
+
# @example
|
|
100
|
+
# SolidAgent.run_executor = ->(agent_record, run) { AgentExecutionService.call(agent_record, run) }
|
|
101
|
+
#
|
|
102
|
+
# @return [#call]
|
|
103
|
+
def run_executor
|
|
104
|
+
@run_executor ||= lambda do |agent_record, _run|
|
|
105
|
+
raise Error, "No SolidAgent.run_executor is configured, so #{agent_record.class} cannot be executed. " \
|
|
106
|
+
"Set SolidAgent.run_executor to a callable taking (agent_record, run) and returning " \
|
|
107
|
+
"{ output:, metadata:, usage: }."
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
attr_writer :run_executor
|
|
112
|
+
|
|
113
|
+
# Job class enqueued by asynchronous execution, resolved at call time.
|
|
114
|
+
#
|
|
115
|
+
# @return [String]
|
|
116
|
+
def execution_job_class
|
|
117
|
+
@execution_job_class || "AgentExecutionJob"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
attr_writer :execution_job_class
|
|
121
|
+
|
|
122
|
+
# @return [Class, nil]
|
|
123
|
+
def execution_job = execution_job_class.to_s.safe_constantize
|
|
124
|
+
|
|
125
|
+
# Resets every records-layer configuration knob. Test support.
|
|
126
|
+
#
|
|
127
|
+
# @return [void]
|
|
128
|
+
def reset_records_configuration!
|
|
129
|
+
Records::MODELS.each_key { |name| instance_variable_set(:"@#{name}", nil) }
|
|
130
|
+
@run_executor = nil
|
|
131
|
+
@execution_job_class = nil
|
|
132
|
+
end
|
|
133
|
+
end
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# The concerns load after the seam above, so a `belongs_to` reading
|
|
137
|
+
# SolidAgent.agent_class from an `included do` block can never outrun the
|
|
138
|
+
# reader that answers it.
|
|
139
|
+
#
|
|
140
|
+
# They are required eagerly, and that is safe precisely because none of them
|
|
141
|
+
# touches an ActiveRecord API at load time: every `belongs_to`, `enum`,
|
|
142
|
+
# `validates` and `scope` lives inside an `included do` or `class_methods`
|
|
143
|
+
# block, which Ruby stores as a block and runs only when a host model includes
|
|
144
|
+
# the concern. So `require "solid_agent"` in a process with no ActiveRecord —
|
|
145
|
+
# a rake task, a manifest-only consumer — defines these modules and loads
|
|
146
|
+
# nothing else. If a concern ever needs an AR constant at load time, it belongs
|
|
147
|
+
# behind an `ActiveSupport.on_load(:active_record)` hook, not in this list.
|
|
148
|
+
require_relative "records/ownable"
|
|
149
|
+
require_relative "records/agent"
|
|
150
|
+
require_relative "records/agent_version"
|
|
151
|
+
require_relative "records/agent_template"
|
|
152
|
+
require_relative "records/agent_run"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module SolidAgent
|
|
6
|
+
# Stable fingerprints for the instructions a run executed under — the
|
|
7
|
+
# grouping key (with model) for configuration cohorts when comparing
|
|
8
|
+
# instruction/model changes across runs.
|
|
9
|
+
#
|
|
10
|
+
# @example Digest and codename
|
|
11
|
+
# digest = SolidAgent::RunFingerprint.digest("You are a helpful agent.")
|
|
12
|
+
# # => "a1b2c3d4"
|
|
13
|
+
# SolidAgent::RunFingerprint.codename(digest)
|
|
14
|
+
# # => "calm-heron"
|
|
15
|
+
module RunFingerprint
|
|
16
|
+
# Deterministic memorable names for digests — they read far better
|
|
17
|
+
# than hex when comparing cohorts, and are stable across runs and
|
|
18
|
+
# deployments because they derive from the digest alone.
|
|
19
|
+
ADJECTIVES = %w[
|
|
20
|
+
calm brisk quiet bold amber coral dusky fresh golden keen
|
|
21
|
+
lively mellow nimble pale rustic silver tidal vivid wry zesty
|
|
22
|
+
arid breezy crisp dapper eager foggy hazy icy jolly lunar
|
|
23
|
+
misty polar
|
|
24
|
+
].freeze
|
|
25
|
+
NOUNS = %w[
|
|
26
|
+
heron otter falcon cedar willow harbor mesa ridge grove delta
|
|
27
|
+
prairie summit canyon reef atoll fjord tundra oasis lagoon dune
|
|
28
|
+
glacier meadow bluff cove marsh basin knoll strait quarry vale
|
|
29
|
+
hollow crag
|
|
30
|
+
].freeze
|
|
31
|
+
|
|
32
|
+
class << self
|
|
33
|
+
# @param instructions [String, nil]
|
|
34
|
+
# @return [String, nil] 8-hex-char digest, nil for blank input
|
|
35
|
+
def digest(instructions)
|
|
36
|
+
return nil if instructions.nil? || instructions.to_s.strip.empty?
|
|
37
|
+
|
|
38
|
+
Digest::SHA256.hexdigest(instructions.to_s)[0, 8]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param digest [String, nil] an 8-hex-char instructions digest
|
|
42
|
+
# @return [String, nil] deterministic "adjective-noun" codename
|
|
43
|
+
def codename(digest)
|
|
44
|
+
return nil if digest.nil? || digest.to_s.empty?
|
|
45
|
+
|
|
46
|
+
value = digest.to_s.to_i(16)
|
|
47
|
+
"#{ADJECTIVES[value % 32]}-#{NOUNS[(value / 32) % 32]}"
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# StreamsToolUpdates provides real-time UI feedback during tool execution.
|
|
4
|
+
#
|
|
5
|
+
# When an agent is processing and calls tools (which may take time), this concern
|
|
6
|
+
# broadcasts status updates to the client so users see what's happening rather than
|
|
7
|
+
# a frozen UI. This is especially important for long-running tools like web browsing.
|
|
8
|
+
#
|
|
9
|
+
# @example Basic usage
|
|
10
|
+
# class MyAgent < ApplicationAgent
|
|
11
|
+
# include SolidAgent::HasTools
|
|
12
|
+
# include SolidAgent::StreamsToolUpdates
|
|
13
|
+
#
|
|
14
|
+
# has_tools :search, :navigate
|
|
15
|
+
#
|
|
16
|
+
# def research
|
|
17
|
+
# prompt(tools: tools)
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# @example With custom descriptions
|
|
22
|
+
# class MyAgent < ApplicationAgent
|
|
23
|
+
# include SolidAgent::HasTools
|
|
24
|
+
# include SolidAgent::StreamsToolUpdates
|
|
25
|
+
#
|
|
26
|
+
# tool_description :navigate, ->(args) { "Visiting #{args[:url]}..." }
|
|
27
|
+
# tool_description :search, ->(args) { "Searching for '#{args[:query]}'..." }
|
|
28
|
+
# tool_description :extract_text, "Reading page content..."
|
|
29
|
+
# end
|
|
30
|
+
module SolidAgent
|
|
31
|
+
module StreamsToolUpdates
|
|
32
|
+
extend ActiveSupport::Concern
|
|
33
|
+
|
|
34
|
+
included do
|
|
35
|
+
class_attribute :_tool_descriptions, default: {}
|
|
36
|
+
class_attribute :_wrapped_tools, default: Set.new
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
class_methods do
|
|
40
|
+
# Defines a human-readable description for a tool that will be shown in the UI.
|
|
41
|
+
#
|
|
42
|
+
# @param tool_name [Symbol, String] the tool method name
|
|
43
|
+
# @param description [String, Proc] static string or proc that receives args
|
|
44
|
+
#
|
|
45
|
+
# @example Static description
|
|
46
|
+
# tool_description :extract_text, "Reading page content..."
|
|
47
|
+
#
|
|
48
|
+
# @example Dynamic description with args
|
|
49
|
+
# tool_description :navigate, ->(args) { "Visiting #{args[:url]}..." }
|
|
50
|
+
def tool_description(tool_name, description)
|
|
51
|
+
self._tool_descriptions = _tool_descriptions.merge(tool_name.to_sym => description)
|
|
52
|
+
wrap_tool_method(tool_name)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Wraps a tool method to broadcast status before execution
|
|
56
|
+
#
|
|
57
|
+
# @param tool_name [Symbol, String] the tool method name
|
|
58
|
+
def wrap_tool_method(tool_name)
|
|
59
|
+
tool_sym = tool_name.to_sym
|
|
60
|
+
return if _wrapped_tools.include?(tool_sym)
|
|
61
|
+
|
|
62
|
+
self._wrapped_tools = _wrapped_tools.dup.add(tool_sym)
|
|
63
|
+
|
|
64
|
+
# Use prepend to wrap the method
|
|
65
|
+
wrapper_module = Module.new do
|
|
66
|
+
define_method(tool_sym) do |**kwargs|
|
|
67
|
+
broadcast_tool_status(tool_sym, kwargs) if should_broadcast_tools?
|
|
68
|
+
super(**kwargs)
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
prepend wrapper_module
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def should_broadcast_tools?
|
|
79
|
+
params[:stream_id].present?
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# Broadcasts a tool status update to the client
|
|
83
|
+
#
|
|
84
|
+
# @param tool_name [String, Symbol] the tool being executed
|
|
85
|
+
# @param args [Hash] the arguments passed to the tool
|
|
86
|
+
def broadcast_tool_status(tool_name, args = {})
|
|
87
|
+
return unless params[:stream_id]
|
|
88
|
+
|
|
89
|
+
description = tool_description_for(tool_name, args)
|
|
90
|
+
|
|
91
|
+
Rails.logger.info "[#{self.class.name}] Tool status: #{description}"
|
|
92
|
+
|
|
93
|
+
ActionCable.server.broadcast(
|
|
94
|
+
params[:stream_id],
|
|
95
|
+
{
|
|
96
|
+
tool_status: {
|
|
97
|
+
name: tool_name.to_s,
|
|
98
|
+
description: description,
|
|
99
|
+
timestamp: Time.current.iso8601
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Gets the human-readable description for a tool
|
|
106
|
+
#
|
|
107
|
+
# @param tool_name [String, Symbol] the tool name
|
|
108
|
+
# @param args [Hash] the arguments passed to the tool
|
|
109
|
+
# @return [String] description to show in UI
|
|
110
|
+
def tool_description_for(tool_name, args = {})
|
|
111
|
+
custom = _tool_descriptions[tool_name.to_sym]
|
|
112
|
+
|
|
113
|
+
if custom.is_a?(Proc)
|
|
114
|
+
custom.call(args)
|
|
115
|
+
elsif custom.is_a?(String)
|
|
116
|
+
custom
|
|
117
|
+
else
|
|
118
|
+
default_tool_description(tool_name, args)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Generates a default description for common tool types
|
|
123
|
+
#
|
|
124
|
+
# @param tool_name [String, Symbol] the tool name
|
|
125
|
+
# @param args [Hash] the arguments passed to the tool
|
|
126
|
+
# @return [String] default description
|
|
127
|
+
def default_tool_description(tool_name, args = {})
|
|
128
|
+
case tool_name.to_s
|
|
129
|
+
when "navigate"
|
|
130
|
+
args[:url] ? "Visiting #{truncate_url(args[:url])}..." : "Navigating to page..."
|
|
131
|
+
when "click"
|
|
132
|
+
args[:text] ? "Clicking '#{args[:text]}'..." : "Clicking element..."
|
|
133
|
+
when "fill_form"
|
|
134
|
+
args[:field] ? "Filling in #{args[:field]}..." : "Filling form..."
|
|
135
|
+
when "extract_text", "extract_main_content"
|
|
136
|
+
"Reading page content..."
|
|
137
|
+
when "extract_links"
|
|
138
|
+
"Extracting links..."
|
|
139
|
+
when "page_info"
|
|
140
|
+
"Getting page info..."
|
|
141
|
+
when "go_back"
|
|
142
|
+
"Going back..."
|
|
143
|
+
when "search", "web_search"
|
|
144
|
+
args[:query] ? "Searching for '#{args[:query]}'..." : "Searching..."
|
|
145
|
+
when "read", "read_file"
|
|
146
|
+
args[:path] ? "Reading #{File.basename(args[:path])}..." : "Reading file..."
|
|
147
|
+
when "write", "write_file"
|
|
148
|
+
args[:path] ? "Writing #{File.basename(args[:path])}..." : "Writing file..."
|
|
149
|
+
else
|
|
150
|
+
"Performing #{tool_name.to_s.tr('_', ' ')}..."
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Truncates a URL for display
|
|
155
|
+
#
|
|
156
|
+
# @param url [String] the URL to truncate
|
|
157
|
+
# @param max_length [Integer] maximum length
|
|
158
|
+
# @return [String] truncated URL
|
|
159
|
+
def truncate_url(url, max_length: 50)
|
|
160
|
+
return url if url.length <= max_length
|
|
161
|
+
|
|
162
|
+
uri = URI.parse(url)
|
|
163
|
+
host = uri.host || url[0..max_length]
|
|
164
|
+
path = uri.path || ""
|
|
165
|
+
|
|
166
|
+
if host.length > max_length
|
|
167
|
+
"#{host[0..max_length]}..."
|
|
168
|
+
elsif (host.length + path.length) > max_length
|
|
169
|
+
remaining = max_length - host.length - 3
|
|
170
|
+
"#{host}#{path[0..remaining]}..."
|
|
171
|
+
else
|
|
172
|
+
"#{host}#{path}"
|
|
173
|
+
end
|
|
174
|
+
rescue URI::InvalidURIError
|
|
175
|
+
url.length > max_length ? "#{url[0..max_length]}..." : url
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|