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,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ # HasMemory gives an agent a persistent, agent-curated summary list — the
4
+ # model decides when to read and write it while interacting with tools,
5
+ # other agents, and users.
6
+ #
7
+ # Memory is scoped to a subject record (any ActiveRecord model) plus a
8
+ # scope name, NOT to the agent class — so a memory written by one agent can
9
+ # be recalled by another operating on the same subject. That makes it a
10
+ # handoff channel: agent A records what it learned/did, agent B picks the
11
+ # subject up and recalls the summary before continuing.
12
+ #
13
+ # The concern is duck-typed against a memory model exposing:
14
+ # Model.for(memorable, scope:) -> memory record
15
+ # memory.remember(content, source_agent:, category:) -> entry
16
+ # memory.recall(limit:, category:) -> entries (responding to #content)
17
+ # The install generator's AgentMemory implements this contract.
18
+ #
19
+ # @example Give an agent memory tools the model can call
20
+ # class SupportAgent < ApplicationAgent
21
+ # include SolidAgent::HasMemory
22
+ # has_memory
23
+ #
24
+ # def handle
25
+ # prompt(message: params[:message], tools: memory_tool_definitions)
26
+ # end
27
+ # end
28
+ #
29
+ # @example Handoff between agents sharing a subject
30
+ # ResearchAgent.with(memorable: project).research.generate_now
31
+ # # later, a different agent class:
32
+ # WriterAgent.with(memorable: project).draft.generate_now
33
+ # # WriterAgent's recall_memory returns ResearchAgent's entries too.
34
+ module SolidAgent
35
+ module HasMemory
36
+ extend ActiveSupport::Concern
37
+
38
+ DEFAULT_SCOPE = "default"
39
+
40
+ # Function-calling schemas (common format) for the two memory tools.
41
+ # Exposed as a module method so non-agent callers (platform executors,
42
+ # MCP servers) can reuse the exact same contract.
43
+ def self.tool_definitions
44
+ [
45
+ {
46
+ name: "save_memory",
47
+ description: "Persist a short summary note to long-term memory. Use for facts, decisions, task outcomes, or anything a future agent or session should know. Keep each note self-contained.",
48
+ parameters: {
49
+ type: "object",
50
+ properties: {
51
+ content: { type: "string", description: "The summary note to remember" },
52
+ category: { type: "string", description: "Optional label, e.g. fact, task, handoff" }
53
+ },
54
+ required: [ "content" ]
55
+ }
56
+ },
57
+ {
58
+ name: "recall_memory",
59
+ description: "Read back previously saved memory notes for the current subject, most recent first. Use before starting work to pick up prior context or another agent's handoff.",
60
+ parameters: {
61
+ type: "object",
62
+ properties: {
63
+ category: { type: "string", description: "Only return notes with this label" },
64
+ limit: { type: "integer", description: "Maximum notes to return (default 20)" }
65
+ },
66
+ required: []
67
+ }
68
+ }
69
+ ]
70
+ end
71
+
72
+ included do
73
+ class_attribute :_memory_config, default: nil
74
+ end
75
+
76
+ class_methods do
77
+ # Configures memory for this agent.
78
+ #
79
+ # @param scope [String, Symbol] memory namespace (default "default")
80
+ # @param class_name [String] memory model (default "AgentMemory")
81
+ def has_memory(scope: DEFAULT_SCOPE, class_name: "AgentMemory")
82
+ self._memory_config = { scope: scope.to_s, class_name: class_name }
83
+ end
84
+ end
85
+
86
+ # The memory record for the current subject (or nil without a subject).
87
+ def memory
88
+ config = self.class._memory_config || { scope: DEFAULT_SCOPE, class_name: "AgentMemory" }
89
+ subject = memory_subject
90
+ return nil unless subject
91
+
92
+ @memory ||= config[:class_name].constantize.for(subject, scope: config[:scope])
93
+ end
94
+
95
+ # The record memory is attached to. Defaults to params[:memorable],
96
+ # falling back to the HasContext contextable when present. Override for
97
+ # custom subjects.
98
+ def memory_subject
99
+ return params[:memorable] if respond_to?(:params) && params.is_a?(Hash) && params[:memorable]
100
+
101
+ context.contextable if respond_to?(:context) && context.respond_to?(:contextable)
102
+ rescue StandardError
103
+ nil
104
+ end
105
+
106
+ def memory_tool_definitions
107
+ SolidAgent::HasMemory.tool_definitions
108
+ end
109
+
110
+ # Tool implementations — routed here by the provider's tool calls.
111
+
112
+ def save_memory(content:, category: nil)
113
+ return { error: "No memory subject available" } unless memory
114
+
115
+ entry = memory.remember(content, source_agent: self.class.name, category: category)
116
+ { saved: true, id: entry.respond_to?(:id) ? entry.id : nil, content: content }
117
+ end
118
+
119
+ def recall_memory(category: nil, limit: 20)
120
+ return { error: "No memory subject available" } unless memory
121
+
122
+ entries = memory.recall(limit: limit, category: category)
123
+ {
124
+ count: entries.size,
125
+ entries: entries.map do |entry|
126
+ {
127
+ content: entry.content,
128
+ category: (entry.category if entry.respond_to?(:category)),
129
+ source_agent: (entry.source_agent if entry.respond_to?(:source_agent)),
130
+ created_at: (entry.created_at.iso8601 if entry.respond_to?(:created_at) && entry.created_at)
131
+ }.compact
132
+ end
133
+ }
134
+ end
135
+ end
136
+ end
@@ -0,0 +1,230 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidAgent
4
+ # HasReasons provides reasoning/thinking trace collection for agents.
5
+ #
6
+ # This concern enables agents to capture and track extended thinking
7
+ # from LLMs that support it (Claude's extended thinking, OpenAI o1, etc.).
8
+ #
9
+ # Reasoning traces are captured separately from the main response content,
10
+ # allowing for:
11
+ # - Transparent AI decision-making
12
+ # - Debugging and analysis of AI behavior
13
+ # - Audit trails for compliance
14
+ # - Cost tracking (reasoning tokens)
15
+ #
16
+ # @example Basic usage
17
+ # class ResearchAgent < ApplicationAgent
18
+ # include SolidAgent::HasReasons
19
+ #
20
+ # def analyze
21
+ # result = prompt(
22
+ # messages: research_messages,
23
+ # extended_thinking: true # Enable extended thinking
24
+ # )
25
+ #
26
+ # # Reasoning is automatically captured
27
+ # last_reasoning.content #=> "Let me analyze this systematically..."
28
+ # total_reasoning_tokens #=> 450
29
+ # end
30
+ # end
31
+ #
32
+ # @example Configuring reasoning capture
33
+ # class AnalysisAgent < ApplicationAgent
34
+ # include SolidAgent::HasReasons
35
+ #
36
+ # has_reasons(
37
+ # auto_capture: true, # Auto-capture from all generations
38
+ # persist: true, # Persist to database
39
+ # budget_tokens: 10000 # Default reasoning token budget
40
+ # )
41
+ # end
42
+ #
43
+ # @example Accessing reasoning history
44
+ # agent.reasons # All captured reasons
45
+ # agent.reasoning_chain # Formatted reasoning chain
46
+ # agent.total_reasoning_tokens # Sum of all reasoning tokens
47
+ #
48
+ module HasReasons
49
+ extend ActiveSupport::Concern
50
+
51
+ included do
52
+ class_attribute :_reasons_config, default: {
53
+ auto_capture: true,
54
+ persist: false,
55
+ budget_tokens: nil,
56
+ redact_on_persist: false
57
+ }
58
+
59
+ # Storage for captured reasons
60
+ attr_accessor :_captured_reasons
61
+ end
62
+
63
+ class_methods do
64
+ # Configure reasoning behavior for this agent
65
+ #
66
+ # @param auto_capture [Boolean] Automatically capture reasoning from responses
67
+ # @param persist [Boolean] Persist reasoning to context (requires HasContext)
68
+ # @param budget_tokens [Integer, nil] Default reasoning token budget
69
+ # @param redact_on_persist [Boolean] Redact reasoning content when persisting
70
+ def has_reasons(auto_capture: true, persist: false, budget_tokens: nil, redact_on_persist: false)
71
+ self._reasons_config = {
72
+ auto_capture: auto_capture,
73
+ persist: persist,
74
+ budget_tokens: budget_tokens,
75
+ redact_on_persist: redact_on_persist
76
+ }
77
+ end
78
+ end
79
+
80
+ # Get all captured reasons for this agent instance
81
+ #
82
+ # @return [Array<Reasonable::Reason>]
83
+ def reasons
84
+ @_captured_reasons ||= []
85
+ end
86
+
87
+ # Get the last captured reason
88
+ #
89
+ # @return [Reasonable::Reason, nil]
90
+ def last_reasoning
91
+ reasons.last
92
+ end
93
+
94
+ # Get the total reasoning tokens used
95
+ #
96
+ # @return [Integer]
97
+ def total_reasoning_tokens
98
+ reasons.sum(&:tokens)
99
+ end
100
+
101
+ # Check if any reasoning has been captured
102
+ #
103
+ # @return [Boolean]
104
+ def has_reasoning?
105
+ reasons.any?(&:extended_thinking?)
106
+ end
107
+
108
+ # Get a formatted reasoning chain (all reasoning in sequence)
109
+ #
110
+ # @param separator [String] Separator between reasons
111
+ # @return [String]
112
+ def reasoning_chain(separator: "\n\n---\n\n")
113
+ reasons
114
+ .select(&:extended_thinking?)
115
+ .reject(&:redacted?)
116
+ .map(&:content)
117
+ .join(separator)
118
+ end
119
+
120
+ # Get reasoning statistics
121
+ #
122
+ # @return [Hash]
123
+ def reasoning_stats
124
+ {
125
+ count: reasons.count,
126
+ total_tokens: total_reasoning_tokens,
127
+ total_thinking_time_ms: reasons.sum { |r| r.thinking_time_ms || 0 },
128
+ redacted_count: reasons.count(&:redacted?),
129
+ models: reasons.map(&:model).compact.uniq
130
+ }
131
+ end
132
+
133
+ # Capture reasoning from an LLM response
134
+ #
135
+ # @param response [Object] LLM response object
136
+ # @return [Reasonable::Reason, nil] The captured reason
137
+ def capture_reasoning(response)
138
+ reason = Reasonable::Reason.from_response(response)
139
+ return nil unless reason&.extended_thinking?
140
+
141
+ @_captured_reasons ||= []
142
+ @_captured_reasons << reason
143
+
144
+ # Persist if configured and HasContext is available
145
+ persist_reasoning(reason) if _reasons_config[:persist]
146
+
147
+ reason
148
+ end
149
+
150
+ # Manually add a reason
151
+ #
152
+ # @param content [String] Reasoning content
153
+ # @param tokens [Integer] Token count
154
+ # @param metadata [Hash] Additional metadata
155
+ # @return [Reasonable::Reason]
156
+ def add_reason(content:, tokens: 0, **metadata)
157
+ reason = Reasonable::Reason.new(
158
+ content: content,
159
+ tokens: tokens,
160
+ model: current_model,
161
+ **metadata
162
+ )
163
+
164
+ @_captured_reasons ||= []
165
+ @_captured_reasons << reason
166
+
167
+ persist_reasoning(reason) if _reasons_config[:persist]
168
+
169
+ reason
170
+ end
171
+
172
+ # Clear all captured reasons
173
+ def clear_reasons!
174
+ @_captured_reasons = []
175
+ end
176
+
177
+ # Get default prompt options with reasoning configuration
178
+ #
179
+ # @return [Hash]
180
+ def reasoning_prompt_options
181
+ options = {}
182
+
183
+ if _reasons_config[:budget_tokens]
184
+ options[:reasoning_budget_tokens] = _reasons_config[:budget_tokens]
185
+ end
186
+
187
+ options[:extended_thinking] = true if _reasons_config[:auto_capture]
188
+
189
+ options
190
+ end
191
+
192
+ private
193
+
194
+ def persist_reasoning(reason)
195
+ return unless respond_to?(:context) && context.respond_to?(:generations)
196
+
197
+ # Find the most recent generation and store reasoning
198
+ generation = context.generations.order(created_at: :desc).first
199
+ return unless generation
200
+
201
+ if generation.respond_to?(:store_reason!)
202
+ # If generation includes Reasonable
203
+ persisted_reason = if _reasons_config[:redact_on_persist]
204
+ Reasonable::Reason.new(
205
+ content: "[Redacted]",
206
+ tokens: reason.tokens,
207
+ model: reason.model,
208
+ thinking_time_ms: reason.thinking_time_ms,
209
+ redacted: true,
210
+ metadata: reason.metadata
211
+ )
212
+ else
213
+ reason
214
+ end
215
+
216
+ generation.store_reason!(persisted_reason)
217
+ end
218
+ rescue StandardError => e
219
+ # Log but don't fail if persistence fails
220
+ Rails.logger.warn "[SolidAgent::HasReasons] Failed to persist reasoning: #{e.message}" if defined?(Rails)
221
+ end
222
+
223
+ def current_model
224
+ return @model if defined?(@model)
225
+ return prompt_options[:model] if respond_to?(:prompt_options) && prompt_options[:model]
226
+
227
+ nil
228
+ end
229
+ end
230
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ # HasTools provides a DSL for defining and loading tool schemas in ActiveAgent agents.
4
+ #
5
+ # This concern enables declarative tool registration with automatic schema loading
6
+ # from JSON view templates or inline definitions.
7
+ #
8
+ # @example Auto-discover tools from views
9
+ # class MyAgent < ApplicationAgent
10
+ # include SolidAgent::HasTools
11
+ # has_tools # Discovers all tools from app/views/my_agent/tools/*.json.erb
12
+ # end
13
+ #
14
+ # @example Explicit tool list
15
+ # class MyAgent < ApplicationAgent
16
+ # include SolidAgent::HasTools
17
+ # has_tools :search, :fetch, :analyze
18
+ # end
19
+ #
20
+ # @example Inline tool definition
21
+ # class MyAgent < ApplicationAgent
22
+ # include SolidAgent::HasTools
23
+ # tool :get_weather do
24
+ # description "Get current weather for a location"
25
+ # parameter :location, type: :string, required: true, description: "City name"
26
+ # parameter :units, type: :string, enum: %w[celsius fahrenheit], default: "celsius"
27
+ # end
28
+ # end
29
+ #
30
+ # @example Mixed approach
31
+ # class MyAgent < ApplicationAgent
32
+ # include SolidAgent::HasTools
33
+ # has_tools :navigate, :click # Load from templates
34
+ # tool :custom_action do # Define inline
35
+ # description "A custom action"
36
+ # parameter :input, type: :string, required: true
37
+ # end
38
+ # end
39
+ module SolidAgent
40
+ module HasTools
41
+ extend ActiveSupport::Concern
42
+
43
+ included do
44
+ class_attribute :_tool_names, default: []
45
+ class_attribute :_inline_tools, default: {}
46
+ class_attribute :_tools_auto_discover, default: false
47
+ end
48
+
49
+ class_methods do
50
+ # Declares which tools this agent uses.
51
+ #
52
+ # Without arguments, enables auto-discovery of tools from view templates.
53
+ # With arguments, explicitly lists tools to load from templates.
54
+ #
55
+ # @param tool_names [Array<Symbol, String>] explicit list of tools to load
56
+ # @return [void]
57
+ #
58
+ # @example Auto-discover
59
+ # has_tools
60
+ #
61
+ # @example Explicit list
62
+ # has_tools :navigate, :click, :fill_form
63
+ def has_tools(*tool_names)
64
+ if tool_names.empty?
65
+ self._tools_auto_discover = true
66
+ else
67
+ self._tool_names = tool_names.map(&:to_sym)
68
+ end
69
+ end
70
+
71
+ # Defines a tool inline using a DSL block.
72
+ #
73
+ # The tool name should match a method in the agent class that will be
74
+ # called when the LLM invokes this tool.
75
+ #
76
+ # @param name [Symbol, String] tool name (must match an instance method)
77
+ # @yield block for defining tool schema using ToolBuilder DSL
78
+ # @return [void]
79
+ #
80
+ # @example
81
+ # tool :search do
82
+ # description "Search for documents"
83
+ # parameter :query, type: :string, required: true
84
+ # parameter :limit, type: :integer, default: 10
85
+ # end
86
+ def tool(name, &block)
87
+ builder = ToolBuilder.new(name)
88
+ builder.instance_eval(&block) if block_given?
89
+ self._inline_tools = _inline_tools.merge(name.to_sym => builder.to_schema)
90
+ end
91
+ end
92
+
93
+ # Returns all tool schemas for this agent.
94
+ #
95
+ # Combines tools from:
96
+ # 1. Auto-discovered JSON templates (if has_tools called without args)
97
+ # 2. Explicitly listed tools (if has_tools called with args)
98
+ # 3. Inline tool definitions (from tool blocks)
99
+ #
100
+ # @return [Array<Hash>] array of tool schemas in OpenAI format
101
+ def tools
102
+ @_tools_cache ||= begin
103
+ schemas = []
104
+
105
+ # Load from templates
106
+ if _tools_auto_discover
107
+ schemas.concat(discover_tool_templates)
108
+ elsif _tool_names.any?
109
+ schemas.concat(_tool_names.map { |name| load_tool_schema(name) })
110
+ end
111
+
112
+ # Add inline tools
113
+ schemas.concat(_inline_tools.values)
114
+
115
+ schemas
116
+ end
117
+ end
118
+
119
+ # Reloads tools, clearing any cached schemas.
120
+ #
121
+ # Useful when tool templates may have changed during development.
122
+ #
123
+ # @return [Array<Hash>] freshly loaded tool schemas
124
+ def reload_tools!
125
+ @_tools_cache = nil
126
+ tools
127
+ end
128
+
129
+ private
130
+
131
+ # Discovers tool templates from the agent's views directory.
132
+ #
133
+ # Looks for JSON templates in:
134
+ # - app/views/{agent_name}/tools/*.json.erb
135
+ # - app/views/agents/{agent_without_suffix}/tools/*.json.erb
136
+ #
137
+ # @return [Array<Hash>] discovered tool schemas
138
+ def discover_tool_templates
139
+ tool_schemas = []
140
+ tools_path = Rails.root.join("app", "views", agent_name, "tools")
141
+
142
+ if tools_path.exist?
143
+ tools_path.glob("*.json.erb").each do |template_path|
144
+ tool_name = template_path.basename(".json.erb").to_s
145
+ tool_schemas << load_tool_schema(tool_name)
146
+ end
147
+ end
148
+
149
+ # Also check nested structure: app/views/agents/{name}/tools/
150
+ nested_path = Rails.root.join("app", "views", "agents", agent_name.delete_suffix("_agent"), "tools")
151
+ if nested_path.exist?
152
+ nested_path.glob("*.json.erb").each do |template_path|
153
+ tool_name = template_path.basename(".json.erb").to_s
154
+ # Avoid duplicates
155
+ next if tool_schemas.any? { |t| t[:name] == tool_name }
156
+ tool_schemas << load_tool_schema(tool_name)
157
+ end
158
+ end
159
+
160
+ tool_schemas
161
+ end
162
+
163
+ # Loads a single tool schema from its JSON view template.
164
+ #
165
+ # @param tool_name [Symbol, String] name of the tool
166
+ # @return [Hash] tool schema with symbolized keys
167
+ # @raise [ActionView::MissingTemplate] if template not found
168
+ # @raise [JSON::ParserError] if template produces invalid JSON
169
+ def load_tool_schema(tool_name)
170
+ template_path = "tools/#{tool_name}"
171
+
172
+ json_content = render_to_string(
173
+ template: "#{agent_name}/#{template_path}",
174
+ formats: [:json],
175
+ layout: false
176
+ )
177
+
178
+ JSON.parse(json_content, symbolize_names: true)
179
+ rescue JSON::ParserError => e
180
+ Rails.logger.error "[#{self.class.name}] Invalid JSON in tool template: #{template_path}"
181
+ raise e
182
+ end
183
+
184
+ # DSL builder for inline tool definitions.
185
+ #
186
+ # Provides a clean API for defining tool schemas programmatically:
187
+ #
188
+ # tool :my_tool do
189
+ # description "Does something useful"
190
+ # parameter :input, type: :string, required: true
191
+ # end
192
+ class ToolBuilder
193
+ def initialize(name)
194
+ @name = name.to_s
195
+ @description = ""
196
+ @parameters = {}
197
+ @required = []
198
+ end
199
+
200
+ # Sets the tool description.
201
+ #
202
+ # @param text [String] human-readable description of what the tool does
203
+ # @return [void]
204
+ def description(text)
205
+ @description = text
206
+ end
207
+
208
+ # Defines a parameter for the tool.
209
+ #
210
+ # @param name [Symbol, String] parameter name
211
+ # @param type [Symbol, String] JSON Schema type (:string, :integer, :boolean, :array, :object)
212
+ # @param required [Boolean] whether this parameter is required (default: false)
213
+ # @param description [String] parameter description
214
+ # @param enum [Array] allowed values (for string type)
215
+ # @param items [Hash] item schema (for array type)
216
+ # @param properties [Hash] nested properties (for object type)
217
+ # @param default [Object] default value
218
+ # @return [void]
219
+ #
220
+ # @example Simple string parameter
221
+ # parameter :query, type: :string, required: true
222
+ #
223
+ # @example Enum parameter
224
+ # parameter :format, type: :string, enum: %w[json xml csv]
225
+ #
226
+ # @example Array parameter
227
+ # parameter :tags, type: :array, items: { type: :string }
228
+ def parameter(name, type:, required: false, description: nil, enum: nil, items: nil, properties: nil, default: nil)
229
+ param_schema = { type: type.to_s }
230
+ param_schema[:description] = description if description
231
+ param_schema[:enum] = enum if enum
232
+ param_schema[:items] = items if items
233
+ param_schema[:properties] = properties if properties
234
+ param_schema[:default] = default if default
235
+
236
+ @parameters[name.to_s] = param_schema
237
+ @required << name.to_s if required
238
+ end
239
+
240
+ # Converts the builder state to an OpenAI-compatible tool schema.
241
+ #
242
+ # @return [Hash] tool schema
243
+ def to_schema
244
+ {
245
+ type: "function",
246
+ name: @name,
247
+ description: @description,
248
+ parameters: {
249
+ type: "object",
250
+ properties: @parameters,
251
+ required: @required
252
+ }.compact
253
+ }
254
+ end
255
+ end
256
+ end
257
+ end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module SolidAgent
4
+ # The one place that knows how a context class name maps to its message and
5
+ # generation class names.
6
+ #
7
+ # Two callers derive these names — HasContext#infer_class_names at runtime and
8
+ # the context generator when writing files — and they used to implement the
9
+ # rule independently, which is how they drifted.
10
+ module ModelNaming
11
+ # Suffixes a context class may end with. Only the first match is stripped:
12
+ # chaining +delete_suffix+ reduces "SessionContext" to "" and produces a
13
+ # bare "Message"/"Generation" pair that collides across every context.
14
+ CONTEXT_SUFFIXES = %w[Context Session].freeze
15
+
16
+ class << self
17
+ # The stem a context class name contributes to its siblings.
18
+ #
19
+ # @param class_name [String, Symbol, Class]
20
+ # @return [String]
21
+ #
22
+ # @example
23
+ # base_for("ChatSession") #=> "Chat"
24
+ # base_for("SessionContext") #=> "Session"
25
+ # base_for("Conversation") #=> "Conversation"
26
+ def base_for(class_name)
27
+ name = class_name.to_s
28
+ suffix = CONTEXT_SUFFIXES.find { |candidate| name.end_with?(candidate) && name != candidate }
29
+
30
+ suffix ? name.delete_suffix(suffix) : name
31
+ end
32
+
33
+ # @param class_name [String, Symbol, Class]
34
+ # @return [String]
35
+ def message_class_for(class_name) = "#{base_for(class_name)}Message"
36
+
37
+ # @param class_name [String, Symbol, Class]
38
+ # @return [String]
39
+ def generation_class_for(class_name) = "#{base_for(class_name)}Generation"
40
+ end
41
+ end
42
+ end