solid_agent 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -0
- data/LICENSE +21 -0
- data/README.md +321 -0
- data/Rakefile +32 -0
- data/docs/agent-md-spec.md +803 -0
- data/docs/parser-design.md +1369 -0
- data/docs/registry-api.md +882 -0
- data/examples/README.md +60 -0
- data/examples/manifests/changelog_writer.agent.md +81 -0
- data/examples/manifests/usage.rb +96 -0
- data/examples/memory_handoff/app/agents/researcher_agent.rb +36 -0
- data/examples/memory_handoff/app/agents/writer_agent.rb +41 -0
- data/examples/memory_handoff/usage.rb +45 -0
- data/examples/persistent_conversation/app/agents/support_agent.rb +59 -0
- data/examples/persistent_conversation/app/controllers/support_conversations_controller.rb +24 -0
- data/examples/persistent_conversation/app/views/agents/support/instructions.md.erb +8 -0
- data/examples/persistent_conversation/usage.rb +51 -0
- data/examples/reasoning/app/agents/analysis_agent.rb +52 -0
- data/examples/reasoning/usage.rb +52 -0
- data/examples/run_tracking/app/agents/report_agent.rb +30 -0
- data/examples/run_tracking/app/controllers/agent_runs_controller.rb +43 -0
- data/examples/run_tracking/app/jobs/document_analysis_job.rb +17 -0
- data/examples/run_tracking/app/services/document_analysis_run.rb +68 -0
- data/examples/run_tracking/usage.rb +85 -0
- data/examples/tool_streaming/app/agents/browser_agent.rb +65 -0
- data/examples/tool_streaming/app/channels/tool_status_channel.rb +24 -0
- data/examples/tool_streaming/app/views/browser_agent/tools/fetch_url.json.erb +15 -0
- data/examples/tool_streaming/usage.rb +47 -0
- data/lib/generators/solid_agent/agent/agent_generator.rb +95 -0
- data/lib/generators/solid_agent/agent/templates/action.text.erb +10 -0
- data/lib/generators/solid_agent/agent/templates/agent.rb.erb +93 -0
- data/lib/generators/solid_agent/context/context_generator.rb +124 -0
- data/lib/generators/solid_agent/context/templates/context_model.rb.erb +134 -0
- data/lib/generators/solid_agent/context/templates/create_context.rb.erb +32 -0
- data/lib/generators/solid_agent/context/templates/create_generations.rb.erb +46 -0
- data/lib/generators/solid_agent/context/templates/create_messages.rb.erb +37 -0
- data/lib/generators/solid_agent/context/templates/generation_model.rb.erb +51 -0
- data/lib/generators/solid_agent/context/templates/message_model.rb.erb +47 -0
- data/lib/generators/solid_agent/install/install_generator.rb +92 -0
- data/lib/generators/solid_agent/install/templates/agent_context.rb.erb +171 -0
- data/lib/generators/solid_agent/install/templates/agent_generation.rb.erb +76 -0
- data/lib/generators/solid_agent/install/templates/agent_memory.rb.erb +51 -0
- data/lib/generators/solid_agent/install/templates/agent_memory_entry.rb.erb +12 -0
- data/lib/generators/solid_agent/install/templates/agent_message.rb.erb +76 -0
- data/lib/generators/solid_agent/install/templates/agent_run.rb.erb +122 -0
- data/lib/generators/solid_agent/install/templates/create_agent_contexts.rb.erb +32 -0
- data/lib/generators/solid_agent/install/templates/create_agent_generations.rb.erb +51 -0
- data/lib/generators/solid_agent/install/templates/create_agent_memories.rb.erb +35 -0
- data/lib/generators/solid_agent/install/templates/create_agent_messages.rb.erb +38 -0
- data/lib/generators/solid_agent/install/templates/create_agent_runs.rb.erb +46 -0
- data/lib/generators/solid_agent/install/templates/initializer.rb.erb +51 -0
- data/lib/generators/solid_agent/manifest/manifest_generator.rb +209 -0
- data/lib/generators/solid_agent/manifest/templates/agent.md.erb +39 -0
- data/lib/generators/solid_agent/manifest/templates/prompt.erb +13 -0
- data/lib/generators/solid_agent/reasons/reasons_generator.rb +83 -0
- data/lib/generators/solid_agent/reasons/templates/add_reasoning_columns.rb.erb +12 -0
- data/lib/generators/solid_agent/tool/templates/tool.json.erb +19 -0
- data/lib/generators/solid_agent/tool/tool_generator.rb +117 -0
- data/lib/solid_agent/agent_manifest/agent_builder.rb +323 -0
- data/lib/solid_agent/agent_manifest/errors.rb +26 -0
- data/lib/solid_agent/agent_manifest/exporter_registry.rb +117 -0
- data/lib/solid_agent/agent_manifest/exporters/agent_md_exporter.rb +115 -0
- data/lib/solid_agent/agent_manifest/exporters/base_exporter.rb +152 -0
- data/lib/solid_agent/agent_manifest/exporters/crewai_exporter.rb +125 -0
- data/lib/solid_agent/agent_manifest/exporters/dotprompt_exporter.rb +92 -0
- data/lib/solid_agent/agent_manifest/input_schema.rb +154 -0
- data/lib/solid_agent/agent_manifest/manifest.rb +306 -0
- data/lib/solid_agent/agent_manifest/parser_registry.rb +185 -0
- data/lib/solid_agent/agent_manifest/parsers/agent_md_parser.rb +87 -0
- data/lib/solid_agent/agent_manifest/parsers/base_parser.rb +223 -0
- data/lib/solid_agent/agent_manifest/parsers/crewai_parser.rb +201 -0
- data/lib/solid_agent/agent_manifest/parsers/dotprompt_parser.rb +122 -0
- data/lib/solid_agent/agent_manifest/parsers/github_prompt_parser.rb +143 -0
- data/lib/solid_agent/agent_manifest/picoschema.rb +254 -0
- data/lib/solid_agent/agent_manifest/registry/auth.rb +103 -0
- data/lib/solid_agent/agent_manifest/registry/client.rb +384 -0
- data/lib/solid_agent/agent_manifest/resource.rb +103 -0
- data/lib/solid_agent/agent_manifest/tool.rb +160 -0
- data/lib/solid_agent/agent_manifest/validator.rb +368 -0
- data/lib/solid_agent/agent_manifest.rb +381 -0
- data/lib/solid_agent/engine.rb +16 -0
- data/lib/solid_agent/has_context.rb +670 -0
- data/lib/solid_agent/has_memory.rb +136 -0
- data/lib/solid_agent/has_reasons.rb +230 -0
- data/lib/solid_agent/has_tools.rb +257 -0
- data/lib/solid_agent/model_naming.rb +42 -0
- data/lib/solid_agent/model_pricing.rb +93 -0
- data/lib/solid_agent/reasonable/reason.rb +205 -0
- data/lib/solid_agent/reasonable.rb +181 -0
- data/lib/solid_agent/records/agent.rb +520 -0
- data/lib/solid_agent/records/agent_run.rb +520 -0
- data/lib/solid_agent/records/agent_template.rb +142 -0
- data/lib/solid_agent/records/agent_version.rb +141 -0
- data/lib/solid_agent/records/ownable.rb +130 -0
- data/lib/solid_agent/records.rb +152 -0
- data/lib/solid_agent/run_fingerprint.rb +51 -0
- data/lib/solid_agent/streams_tool_updates.rb +178 -0
- data/lib/solid_agent/tool_cache.rb +91 -0
- data/lib/solid_agent/version.rb +5 -0
- data/lib/solid_agent.rb +95 -0
- data/sig/solid_agent.rbs +4 -0
- metadata +174 -14
|
@@ -0,0 +1,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
|