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.
Files changed (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/LICENSE +21 -0
  4. data/README.md +321 -0
  5. data/Rakefile +32 -0
  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 +95 -0
  31. data/lib/generators/solid_agent/agent/templates/action.text.erb +10 -0
  32. data/lib/generators/solid_agent/agent/templates/agent.rb.erb +93 -0
  33. data/lib/generators/solid_agent/context/context_generator.rb +124 -0
  34. data/lib/generators/solid_agent/context/templates/context_model.rb.erb +134 -0
  35. data/lib/generators/solid_agent/context/templates/create_context.rb.erb +32 -0
  36. data/lib/generators/solid_agent/context/templates/create_generations.rb.erb +46 -0
  37. data/lib/generators/solid_agent/context/templates/create_messages.rb.erb +37 -0
  38. data/lib/generators/solid_agent/context/templates/generation_model.rb.erb +51 -0
  39. data/lib/generators/solid_agent/context/templates/message_model.rb.erb +47 -0
  40. data/lib/generators/solid_agent/install/install_generator.rb +92 -0
  41. data/lib/generators/solid_agent/install/templates/agent_context.rb.erb +171 -0
  42. data/lib/generators/solid_agent/install/templates/agent_generation.rb.erb +76 -0
  43. data/lib/generators/solid_agent/install/templates/agent_memory.rb.erb +51 -0
  44. data/lib/generators/solid_agent/install/templates/agent_memory_entry.rb.erb +12 -0
  45. data/lib/generators/solid_agent/install/templates/agent_message.rb.erb +76 -0
  46. data/lib/generators/solid_agent/install/templates/agent_run.rb.erb +122 -0
  47. data/lib/generators/solid_agent/install/templates/create_agent_contexts.rb.erb +32 -0
  48. data/lib/generators/solid_agent/install/templates/create_agent_generations.rb.erb +51 -0
  49. data/lib/generators/solid_agent/install/templates/create_agent_memories.rb.erb +35 -0
  50. data/lib/generators/solid_agent/install/templates/create_agent_messages.rb.erb +38 -0
  51. data/lib/generators/solid_agent/install/templates/create_agent_runs.rb.erb +46 -0
  52. data/lib/generators/solid_agent/install/templates/initializer.rb.erb +51 -0
  53. data/lib/generators/solid_agent/manifest/manifest_generator.rb +209 -0
  54. data/lib/generators/solid_agent/manifest/templates/agent.md.erb +39 -0
  55. data/lib/generators/solid_agent/manifest/templates/prompt.erb +13 -0
  56. data/lib/generators/solid_agent/reasons/reasons_generator.rb +83 -0
  57. data/lib/generators/solid_agent/reasons/templates/add_reasoning_columns.rb.erb +12 -0
  58. data/lib/generators/solid_agent/tool/templates/tool.json.erb +19 -0
  59. data/lib/generators/solid_agent/tool/tool_generator.rb +117 -0
  60. data/lib/solid_agent/agent_manifest/agent_builder.rb +323 -0
  61. data/lib/solid_agent/agent_manifest/errors.rb +26 -0
  62. data/lib/solid_agent/agent_manifest/exporter_registry.rb +117 -0
  63. data/lib/solid_agent/agent_manifest/exporters/agent_md_exporter.rb +115 -0
  64. data/lib/solid_agent/agent_manifest/exporters/base_exporter.rb +152 -0
  65. data/lib/solid_agent/agent_manifest/exporters/crewai_exporter.rb +125 -0
  66. data/lib/solid_agent/agent_manifest/exporters/dotprompt_exporter.rb +92 -0
  67. data/lib/solid_agent/agent_manifest/input_schema.rb +154 -0
  68. data/lib/solid_agent/agent_manifest/manifest.rb +306 -0
  69. data/lib/solid_agent/agent_manifest/parser_registry.rb +185 -0
  70. data/lib/solid_agent/agent_manifest/parsers/agent_md_parser.rb +87 -0
  71. data/lib/solid_agent/agent_manifest/parsers/base_parser.rb +223 -0
  72. data/lib/solid_agent/agent_manifest/parsers/crewai_parser.rb +201 -0
  73. data/lib/solid_agent/agent_manifest/parsers/dotprompt_parser.rb +122 -0
  74. data/lib/solid_agent/agent_manifest/parsers/github_prompt_parser.rb +143 -0
  75. data/lib/solid_agent/agent_manifest/picoschema.rb +254 -0
  76. data/lib/solid_agent/agent_manifest/registry/auth.rb +103 -0
  77. data/lib/solid_agent/agent_manifest/registry/client.rb +384 -0
  78. data/lib/solid_agent/agent_manifest/resource.rb +103 -0
  79. data/lib/solid_agent/agent_manifest/tool.rb +160 -0
  80. data/lib/solid_agent/agent_manifest/validator.rb +368 -0
  81. data/lib/solid_agent/agent_manifest.rb +381 -0
  82. data/lib/solid_agent/engine.rb +16 -0
  83. data/lib/solid_agent/has_context.rb +670 -0
  84. data/lib/solid_agent/has_memory.rb +136 -0
  85. data/lib/solid_agent/has_reasons.rb +230 -0
  86. data/lib/solid_agent/has_tools.rb +257 -0
  87. data/lib/solid_agent/model_naming.rb +42 -0
  88. data/lib/solid_agent/model_pricing.rb +93 -0
  89. data/lib/solid_agent/reasonable/reason.rb +205 -0
  90. data/lib/solid_agent/reasonable.rb +181 -0
  91. data/lib/solid_agent/records/agent.rb +520 -0
  92. data/lib/solid_agent/records/agent_run.rb +520 -0
  93. data/lib/solid_agent/records/agent_template.rb +142 -0
  94. data/lib/solid_agent/records/agent_version.rb +141 -0
  95. data/lib/solid_agent/records/ownable.rb +130 -0
  96. data/lib/solid_agent/records.rb +152 -0
  97. data/lib/solid_agent/run_fingerprint.rb +51 -0
  98. data/lib/solid_agent/streams_tool_updates.rb +178 -0
  99. data/lib/solid_agent/tool_cache.rb +91 -0
  100. data/lib/solid_agent/version.rb +5 -0
  101. data/lib/solid_agent.rb +95 -0
  102. data/sig/solid_agent.rbs +4 -0
  103. 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