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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/LICENSE +21 -0
  4. data/README.md +209 -18
  5. data/Rakefile +22 -2
  6. data/docs/agent-md-spec.md +803 -0
  7. data/docs/parser-design.md +1369 -0
  8. data/docs/registry-api.md +882 -0
  9. data/examples/README.md +60 -0
  10. data/examples/manifests/changelog_writer.agent.md +81 -0
  11. data/examples/manifests/usage.rb +96 -0
  12. data/examples/memory_handoff/app/agents/researcher_agent.rb +36 -0
  13. data/examples/memory_handoff/app/agents/writer_agent.rb +41 -0
  14. data/examples/memory_handoff/usage.rb +45 -0
  15. data/examples/persistent_conversation/app/agents/support_agent.rb +59 -0
  16. data/examples/persistent_conversation/app/controllers/support_conversations_controller.rb +24 -0
  17. data/examples/persistent_conversation/app/views/agents/support/instructions.md.erb +8 -0
  18. data/examples/persistent_conversation/usage.rb +51 -0
  19. data/examples/reasoning/app/agents/analysis_agent.rb +52 -0
  20. data/examples/reasoning/usage.rb +52 -0
  21. data/examples/run_tracking/app/agents/report_agent.rb +30 -0
  22. data/examples/run_tracking/app/controllers/agent_runs_controller.rb +43 -0
  23. data/examples/run_tracking/app/jobs/document_analysis_job.rb +17 -0
  24. data/examples/run_tracking/app/services/document_analysis_run.rb +68 -0
  25. data/examples/run_tracking/usage.rb +85 -0
  26. data/examples/tool_streaming/app/agents/browser_agent.rb +65 -0
  27. data/examples/tool_streaming/app/channels/tool_status_channel.rb +24 -0
  28. data/examples/tool_streaming/app/views/browser_agent/tools/fetch_url.json.erb +15 -0
  29. data/examples/tool_streaming/usage.rb +47 -0
  30. data/lib/generators/solid_agent/agent/agent_generator.rb +2 -2
  31. data/lib/generators/solid_agent/agent/templates/agent.rb.erb +3 -3
  32. data/lib/generators/solid_agent/context/templates/context_model.rb.erb +50 -16
  33. data/lib/generators/solid_agent/context/templates/create_generations.rb.erb +8 -0
  34. data/lib/generators/solid_agent/context/templates/create_messages.rb.erb +4 -0
  35. data/lib/generators/solid_agent/context/templates/generation_model.rb.erb +11 -0
  36. data/lib/generators/solid_agent/install/install_generator.rb +9 -0
  37. data/lib/generators/solid_agent/install/templates/agent_context.rb.erb +60 -17
  38. data/lib/generators/solid_agent/install/templates/agent_generation.rb.erb +23 -6
  39. data/lib/generators/solid_agent/install/templates/agent_memory.rb.erb +51 -0
  40. data/lib/generators/solid_agent/install/templates/agent_memory_entry.rb.erb +12 -0
  41. data/lib/generators/solid_agent/install/templates/agent_run.rb.erb +122 -0
  42. data/lib/generators/solid_agent/install/templates/create_agent_generations.rb.erb +13 -0
  43. data/lib/generators/solid_agent/install/templates/create_agent_memories.rb.erb +35 -0
  44. data/lib/generators/solid_agent/install/templates/create_agent_messages.rb.erb +5 -0
  45. data/lib/generators/solid_agent/install/templates/create_agent_runs.rb.erb +46 -0
  46. data/lib/generators/solid_agent/manifest/manifest_generator.rb +209 -0
  47. data/lib/generators/solid_agent/manifest/templates/agent.md.erb +39 -0
  48. data/lib/generators/solid_agent/manifest/templates/prompt.erb +13 -0
  49. data/lib/generators/solid_agent/reasons/reasons_generator.rb +83 -0
  50. data/lib/generators/solid_agent/reasons/templates/add_reasoning_columns.rb.erb +12 -0
  51. data/lib/solid_agent/agent_manifest/agent_builder.rb +323 -0
  52. data/lib/solid_agent/agent_manifest/errors.rb +26 -0
  53. data/lib/solid_agent/agent_manifest/exporter_registry.rb +117 -0
  54. data/lib/solid_agent/agent_manifest/exporters/agent_md_exporter.rb +115 -0
  55. data/lib/solid_agent/agent_manifest/exporters/base_exporter.rb +152 -0
  56. data/lib/solid_agent/agent_manifest/exporters/crewai_exporter.rb +125 -0
  57. data/lib/solid_agent/agent_manifest/exporters/dotprompt_exporter.rb +92 -0
  58. data/lib/solid_agent/agent_manifest/input_schema.rb +154 -0
  59. data/lib/solid_agent/agent_manifest/manifest.rb +306 -0
  60. data/lib/solid_agent/agent_manifest/parser_registry.rb +185 -0
  61. data/lib/solid_agent/agent_manifest/parsers/agent_md_parser.rb +87 -0
  62. data/lib/solid_agent/agent_manifest/parsers/base_parser.rb +223 -0
  63. data/lib/solid_agent/agent_manifest/parsers/crewai_parser.rb +201 -0
  64. data/lib/solid_agent/agent_manifest/parsers/dotprompt_parser.rb +122 -0
  65. data/lib/solid_agent/agent_manifest/parsers/github_prompt_parser.rb +143 -0
  66. data/lib/solid_agent/agent_manifest/picoschema.rb +254 -0
  67. data/lib/solid_agent/agent_manifest/registry/auth.rb +103 -0
  68. data/lib/solid_agent/agent_manifest/registry/client.rb +384 -0
  69. data/lib/solid_agent/agent_manifest/resource.rb +103 -0
  70. data/lib/solid_agent/agent_manifest/tool.rb +160 -0
  71. data/lib/solid_agent/agent_manifest/validator.rb +368 -0
  72. data/lib/solid_agent/agent_manifest.rb +381 -0
  73. data/lib/solid_agent/has_context.rb +251 -30
  74. data/lib/solid_agent/has_memory.rb +136 -0
  75. data/lib/solid_agent/has_reasons.rb +230 -0
  76. data/lib/solid_agent/model_naming.rb +42 -0
  77. data/lib/solid_agent/model_pricing.rb +93 -0
  78. data/lib/solid_agent/reasonable/reason.rb +205 -0
  79. data/lib/solid_agent/reasonable.rb +181 -0
  80. data/lib/solid_agent/records/agent.rb +520 -0
  81. data/lib/solid_agent/records/agent_run.rb +520 -0
  82. data/lib/solid_agent/records/agent_template.rb +142 -0
  83. data/lib/solid_agent/records/agent_version.rb +141 -0
  84. data/lib/solid_agent/records/ownable.rb +130 -0
  85. data/lib/solid_agent/records.rb +152 -0
  86. data/lib/solid_agent/run_fingerprint.rb +51 -0
  87. data/lib/solid_agent/tool_cache.rb +91 -0
  88. data/lib/solid_agent/version.rb +1 -1
  89. data/lib/solid_agent.rb +70 -3
  90. metadata +87 -1
@@ -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,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "json"
5
+
6
+ # Caches the results of tool / MCP / service interactions so repeated calls
7
+ # with the same arguments reuse a persisted result instead of re-running the
8
+ # side effect (HTTP fetch, search, remote MCP call, ...).
9
+ #
10
+ # Works out of the box in Rails apps (backed by Rails.cache); any object
11
+ # responding to read/write can be substituted, so tests and non-Rails
12
+ # runtimes can inject their own store.
13
+ #
14
+ # @example Caching a tool implementation
15
+ # def fetch_url(url:)
16
+ # SolidAgent::ToolCache.fetch(tool: "fetch_url", args: { url: url }) do
17
+ # Net::HTTP.get_response(URI(url)).body
18
+ # end
19
+ # end
20
+ #
21
+ # @example Disabling globally (e.g. in tests)
22
+ # SolidAgent::ToolCache.enabled = false
23
+ module SolidAgent
24
+ module ToolCache
25
+ DEFAULT_TTL = 300 # seconds
26
+
27
+ class << self
28
+ attr_writer :enabled, :default_ttl, :store
29
+
30
+ def enabled
31
+ defined?(@enabled) ? @enabled : true
32
+ end
33
+
34
+ def default_ttl
35
+ @default_ttl || DEFAULT_TTL
36
+ end
37
+
38
+ def store
39
+ @store || (defined?(Rails) && Rails.respond_to?(:cache) ? Rails.cache : nil)
40
+ end
41
+
42
+ # Returns the cached result for (tool, args) or yields, caching the
43
+ # fresh result. Results that look like errors ({ error: ... }) are
44
+ # never cached, so transient failures don't stick.
45
+ #
46
+ # The returned hash is tagged with cached: true on cache hits so
47
+ # callers (and the model) can tell a replayed result from a fresh one.
48
+ def fetch(tool:, args: {}, ttl: nil, cache: store)
49
+ return yield unless enabled && cache
50
+
51
+ key = cache_key(tool, args)
52
+ cached = cache.read(key)
53
+ return tag_cached(cached) unless cached.nil?
54
+
55
+ result = yield
56
+ cache.write(key, result, expires_in: ttl || default_ttl) if cacheable?(result)
57
+ result
58
+ end
59
+
60
+ def cache_key(tool, args)
61
+ digest = Digest::SHA256.hexdigest(normalize_args(args).to_json)
62
+ "solid_agent:tool_cache:#{tool}:#{digest}"
63
+ end
64
+
65
+ private
66
+
67
+ def cacheable?(result)
68
+ return false if result.nil?
69
+ return !(result.key?(:error) || result.key?("error")) if result.respond_to?(:key?)
70
+
71
+ true
72
+ end
73
+
74
+ def tag_cached(result)
75
+ result.respond_to?(:merge) ? result.merge(cached: true) : result
76
+ end
77
+
78
+ # Stable key material regardless of hash ordering or string/symbol keys.
79
+ def normalize_args(args)
80
+ case args
81
+ when Hash
82
+ args.map { |k, v| [ k.to_s, normalize_args(v) ] }.sort_by(&:first)
83
+ when Array
84
+ args.map { |v| normalize_args(v) }
85
+ else
86
+ args
87
+ end
88
+ end
89
+ end
90
+ end
91
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SolidAgent
4
- VERSION = "0.1.1"
4
+ VERSION = "0.2.0"
5
5
  end
data/lib/solid_agent.rb CHANGED
@@ -1,12 +1,33 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ # The gem is written for a Rails host and used to assume one had already loaded
4
+ # ActiveSupport and ActiveModel for it — `require "solid_agent"` on its own
5
+ # raised NameError on ActiveSupport::Concern. Requiring the pieces we actually
6
+ # use makes the gem loadable from a plain Ruby process (a rake task, an IRB
7
+ # session, a non-Rails consumer of AgentManifest) and costs a Rails host
8
+ # nothing, since these are already loaded there.
9
+ #
10
+ # The core extensions are named one by one rather than pulled in with
11
+ # `active_support/all`: `safe_constantize` and `presence` are what the records
12
+ # seam calls, and a gem should not decide for its host that every monkey patch
13
+ # ActiveSupport ships is loaded.
14
+ require "active_support"
15
+ require "active_support/concern"
16
+ require "active_support/notifications"
17
+ require "active_support/core_ext/object/blank"
18
+ require "active_support/core_ext/string/inflections"
19
+ require "active_model"
20
+
3
21
  require_relative "solid_agent/version"
4
- require_relative "solid_agent/has_context"
5
- require_relative "solid_agent/has_tools"
6
- require_relative "solid_agent/streams_tool_updates"
22
+ require_relative "solid_agent/model_naming"
23
+ require_relative "solid_agent/records"
24
+ require_relative "solid_agent/tool_cache"
25
+ require_relative "solid_agent/model_pricing"
26
+ require_relative "solid_agent/run_fingerprint"
7
27
 
8
28
  module SolidAgent
9
29
  class Error < StandardError; end
30
+ class LoadError < Error; end
10
31
 
11
32
  class << self
12
33
  attr_accessor :context_class, :message_class, :generation_class
@@ -14,6 +35,44 @@ module SolidAgent
14
35
  def configure
15
36
  yield self if block_given?
16
37
  end
38
+
39
+ # Unified agent loading from any source
40
+ #
41
+ # @param source [String, Hash, URI] File path, URL, JSON, YAML, or Hash
42
+ # @param format [Symbol] Force format (:agent_md, :json, :yaml, :dotprompt, :crewai)
43
+ # @param base_class [Class] Parent class for generated agent
44
+ # @param as [String] Register as constant with this name
45
+ # @return [Class] Generated agent class
46
+ #
47
+ # @example Load from file
48
+ # ResearchAgent = SolidAgent.agent("agents/research.agent.md")
49
+ #
50
+ # @example Load from URL
51
+ # WeatherAgent = SolidAgent.agent("https://registry.example.com/weather.agent.md")
52
+ #
53
+ # @example Load from hash
54
+ # QuickAgent = SolidAgent.agent({ name: "quick", model: "gpt-4o", instructions: "Be helpful" })
55
+ #
56
+ def agent(source, format: nil, base_class: nil, as: nil)
57
+ manifest = AgentManifest.load(source, format: format)
58
+ AgentManifest.build(manifest, base_class: base_class, class_name: as)
59
+ end
60
+
61
+ # Load multiple agents from a directory
62
+ #
63
+ # @param directory [String] Path to directory containing agent manifests
64
+ # @param pattern [String] Glob pattern for manifest files
65
+ # @param options [Hash] Options passed to agent()
66
+ # @return [Array<Class>] Array of generated agent classes
67
+ #
68
+ # @example Load all agents from config/agents
69
+ # SolidAgent.agents_from("config/agents")
70
+ #
71
+ def agents_from(directory, pattern: "**/*.agent.md", **options)
72
+ Dir.glob(File.join(directory, pattern)).map do |path|
73
+ agent(path, **options)
74
+ end
75
+ end
17
76
  end
18
77
 
19
78
  # Default configuration
@@ -22,6 +81,14 @@ module SolidAgent
22
81
  self.generation_class = "AgentGeneration"
23
82
  end
24
83
 
84
+ require_relative "solid_agent/has_context"
85
+ require_relative "solid_agent/has_memory"
86
+ require_relative "solid_agent/has_tools"
87
+ require_relative "solid_agent/streams_tool_updates"
88
+ require_relative "solid_agent/reasonable"
89
+ require_relative "solid_agent/has_reasons"
90
+ require_relative "solid_agent/agent_manifest"
91
+
25
92
  # Load Rails integration if Rails is present
26
93
  if defined?(Rails::Engine)
27
94
  require_relative "solid_agent/engine"