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,93 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidAgent
|
|
4
|
+
# Estimates LLM spend from token counts (USD). Generation records store
|
|
5
|
+
# tokens only; pricing is layered on top for cost reporting, so figures
|
|
6
|
+
# are always estimates.
|
|
7
|
+
#
|
|
8
|
+
# Rates come from RubyLLM's model registry (USD per million tokens,
|
|
9
|
+
# maintained upstream per model) when that gem is available and knows
|
|
10
|
+
# the model; the static pattern table below is the fallback for aliases
|
|
11
|
+
# and self-hosted models, and a conservative blended rate covers
|
|
12
|
+
# everything else so totals stay meaningful.
|
|
13
|
+
#
|
|
14
|
+
# @example Estimate a generation's cost
|
|
15
|
+
# SolidAgent::ModelPricing.estimate(
|
|
16
|
+
# model: "claude-sonnet-5", input_tokens: 12_000, output_tokens: 800
|
|
17
|
+
# ) # => 0.048
|
|
18
|
+
#
|
|
19
|
+
# @example Look up a model's rates ($/1M input, $/1M output)
|
|
20
|
+
# SolidAgent::ModelPricing.rate_for("gpt-4o-mini") # => [0.15, 0.60]
|
|
21
|
+
class ModelPricing
|
|
22
|
+
PRICES = [
|
|
23
|
+
# [pattern, input $/1M, output $/1M] — first match wins, so mock
|
|
24
|
+
# models ("mock-gpt-4o-mini") price free before real-model patterns.
|
|
25
|
+
[ /mock/i, 0.0, 0.0 ],
|
|
26
|
+
[ /gpt-4o-mini/i, 0.15, 0.60 ],
|
|
27
|
+
[ /gpt-4o/i, 2.50, 10.00 ],
|
|
28
|
+
[ /gpt-4\.1-nano/i, 0.10, 0.40 ],
|
|
29
|
+
[ /gpt-4\.1-mini/i, 0.40, 1.60 ],
|
|
30
|
+
[ /gpt-4\.1/i, 2.00, 8.00 ],
|
|
31
|
+
[ /o3-mini|o4-mini/i, 1.10, 4.40 ],
|
|
32
|
+
[ /claude.*(fable|mythos)/i, 10.00, 50.00 ],
|
|
33
|
+
[ /claude.*haiku-?4/i, 1.00, 5.00 ],
|
|
34
|
+
[ /claude.*(haiku)/i, 0.80, 4.00 ],
|
|
35
|
+
[ /claude.*(sonnet)/i, 3.00, 15.00 ],
|
|
36
|
+
[ /claude.*opus-(5|4-[5-9])/i, 5.00, 25.00 ],
|
|
37
|
+
[ /claude.*(opus)/i, 15.00, 75.00 ],
|
|
38
|
+
[ /gemini.*flash/i, 0.10, 0.40 ],
|
|
39
|
+
[ /gemini.*pro/i, 1.25, 10.00 ],
|
|
40
|
+
[ /llama|mistral|mixtral|qwen|deepseek/i, 0.20, 0.60 ]
|
|
41
|
+
].freeze
|
|
42
|
+
|
|
43
|
+
# Fallback blended rate for unknown models ($/1M input, $/1M output)
|
|
44
|
+
DEFAULT_RATE = [ 1.00, 4.00 ].freeze
|
|
45
|
+
|
|
46
|
+
class << self
|
|
47
|
+
# @return [Float, nil] estimated USD cost, nil when there is nothing
|
|
48
|
+
# to price
|
|
49
|
+
def estimate(model:, input_tokens:, output_tokens:)
|
|
50
|
+
input = input_tokens.to_i
|
|
51
|
+
output = output_tokens.to_i
|
|
52
|
+
return nil if input.zero? && output.zero?
|
|
53
|
+
|
|
54
|
+
input_rate, output_rate = rate_for(model)
|
|
55
|
+
((input * input_rate) + (output * output_rate)) / 1_000_000.0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
# @return [Array(Float, Float)] $/1M input and output token rates
|
|
59
|
+
def rate_for(model)
|
|
60
|
+
return DEFAULT_RATE if model.blank?
|
|
61
|
+
|
|
62
|
+
registry_rate(model) || static_rate(model)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# Exact per-model rates from RubyLLM's registry when that gem is
|
|
66
|
+
# loaded. Lookups are memoized — the registry scan is not free and
|
|
67
|
+
# cost reporting calls this per row.
|
|
68
|
+
def registry_rate(model)
|
|
69
|
+
return nil unless defined?(::RubyLLM)
|
|
70
|
+
|
|
71
|
+
@registry_rates ||= {}
|
|
72
|
+
return @registry_rates[model] if @registry_rates.key?(model)
|
|
73
|
+
|
|
74
|
+
@registry_rates[model] = begin
|
|
75
|
+
info = ::RubyLLM.models.find(model.to_s)
|
|
76
|
+
tokens = info&.pricing&.text_tokens
|
|
77
|
+
if tokens&.input && tokens&.output
|
|
78
|
+
[ tokens.input, tokens.output ]
|
|
79
|
+
end
|
|
80
|
+
rescue ::StandardError
|
|
81
|
+
nil
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def static_rate(model)
|
|
86
|
+
PRICES.each do |pattern, input_rate, output_rate|
|
|
87
|
+
return [ input_rate, output_rate ] if model.to_s.match?(pattern)
|
|
88
|
+
end
|
|
89
|
+
DEFAULT_RATE
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module SolidAgent
|
|
4
|
+
module Reasonable
|
|
5
|
+
# Reason represents a single reasoning trace from an LLM's extended thinking.
|
|
6
|
+
#
|
|
7
|
+
# LLMs like Claude (with extended thinking) and OpenAI's o1 models produce
|
|
8
|
+
# reasoning traces that explain their thought process before generating output.
|
|
9
|
+
# This class captures and structures that reasoning for persistence and analysis.
|
|
10
|
+
#
|
|
11
|
+
# @example Creating a reason from an LLM response
|
|
12
|
+
# reason = Reason.new(
|
|
13
|
+
# content: "Let me think about this step by step...",
|
|
14
|
+
# tokens: 150,
|
|
15
|
+
# model: "claude-sonnet-4-20250514",
|
|
16
|
+
# thinking_time_ms: 2500
|
|
17
|
+
# )
|
|
18
|
+
#
|
|
19
|
+
# @example Checking if reasoning was used
|
|
20
|
+
# reason.extended_thinking? #=> true
|
|
21
|
+
# reason.summary(100) #=> "Let me think about this..."
|
|
22
|
+
#
|
|
23
|
+
class Reason
|
|
24
|
+
attr_reader :content, :tokens, :model, :thinking_time_ms,
|
|
25
|
+
:created_at, :metadata, :redacted
|
|
26
|
+
|
|
27
|
+
# Initialize a new Reason
|
|
28
|
+
#
|
|
29
|
+
# @param content [String] The reasoning content/trace
|
|
30
|
+
# @param tokens [Integer] Number of reasoning tokens used
|
|
31
|
+
# @param model [String] The model that generated the reasoning
|
|
32
|
+
# @param thinking_time_ms [Integer, nil] Time spent on reasoning
|
|
33
|
+
# @param redacted [Boolean] Whether content was redacted by provider
|
|
34
|
+
# @param metadata [Hash] Additional provider-specific metadata
|
|
35
|
+
def initialize(content:, tokens: 0, model: nil, thinking_time_ms: nil,
|
|
36
|
+
redacted: false, metadata: {}, created_at: nil)
|
|
37
|
+
@content = content
|
|
38
|
+
@tokens = tokens.to_i
|
|
39
|
+
@model = model
|
|
40
|
+
@thinking_time_ms = thinking_time_ms
|
|
41
|
+
@redacted = redacted
|
|
42
|
+
@metadata = metadata || {}
|
|
43
|
+
@created_at = created_at || Time.current
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Check if this represents extended thinking (vs. standard generation)
|
|
47
|
+
#
|
|
48
|
+
# @return [Boolean]
|
|
49
|
+
def extended_thinking?
|
|
50
|
+
tokens.positive? || content.present?
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Check if the reasoning content was redacted by the provider
|
|
54
|
+
#
|
|
55
|
+
# @return [Boolean]
|
|
56
|
+
def redacted?
|
|
57
|
+
@redacted == true
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# Get a summary of the reasoning content
|
|
61
|
+
#
|
|
62
|
+
# @param length [Integer] Maximum length of summary
|
|
63
|
+
# @return [String]
|
|
64
|
+
def summary(length: 200)
|
|
65
|
+
return "[Redacted]" if redacted?
|
|
66
|
+
return "" if content.blank?
|
|
67
|
+
|
|
68
|
+
str = content.to_s
|
|
69
|
+
return str if str.length <= length
|
|
70
|
+
|
|
71
|
+
"#{str[0, length - 3]}..."
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# Convert to a hash for serialization
|
|
75
|
+
#
|
|
76
|
+
# @return [Hash]
|
|
77
|
+
def to_h
|
|
78
|
+
{
|
|
79
|
+
content: content,
|
|
80
|
+
tokens: tokens,
|
|
81
|
+
model: model,
|
|
82
|
+
thinking_time_ms: thinking_time_ms,
|
|
83
|
+
redacted: redacted,
|
|
84
|
+
metadata: metadata,
|
|
85
|
+
created_at: created_at&.iso8601
|
|
86
|
+
}.compact
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
# Create from a hash (deserialization)
|
|
90
|
+
#
|
|
91
|
+
# @param hash [Hash] Hash representation
|
|
92
|
+
# @return [Reason]
|
|
93
|
+
def self.from_h(hash)
|
|
94
|
+
return nil unless hash.is_a?(Hash)
|
|
95
|
+
|
|
96
|
+
new(
|
|
97
|
+
content: hash[:content] || hash["content"],
|
|
98
|
+
tokens: hash[:tokens] || hash["tokens"] || 0,
|
|
99
|
+
model: hash[:model] || hash["model"],
|
|
100
|
+
thinking_time_ms: hash[:thinking_time_ms] || hash["thinking_time_ms"],
|
|
101
|
+
redacted: hash[:redacted] || hash["redacted"] || false,
|
|
102
|
+
metadata: hash[:metadata] || hash["metadata"] || {},
|
|
103
|
+
created_at: parse_time(hash[:created_at] || hash["created_at"])
|
|
104
|
+
)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Create from an ActiveAgent/LLM provider response
|
|
108
|
+
#
|
|
109
|
+
# @param response [Object] Provider response object
|
|
110
|
+
# @return [Reason, nil]
|
|
111
|
+
def self.from_response(response)
|
|
112
|
+
return nil unless response
|
|
113
|
+
|
|
114
|
+
# Handle different response formats
|
|
115
|
+
reasoning_content = extract_reasoning_content(response)
|
|
116
|
+
reasoning_tokens = extract_reasoning_tokens(response)
|
|
117
|
+
|
|
118
|
+
return nil if reasoning_content.blank? && reasoning_tokens.zero?
|
|
119
|
+
|
|
120
|
+
new(
|
|
121
|
+
content: reasoning_content,
|
|
122
|
+
tokens: reasoning_tokens,
|
|
123
|
+
model: response.respond_to?(:model) ? response.model : nil,
|
|
124
|
+
thinking_time_ms: extract_thinking_time(response),
|
|
125
|
+
redacted: reasoning_redacted?(response),
|
|
126
|
+
metadata: extract_reasoning_metadata(response)
|
|
127
|
+
)
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
class << self
|
|
131
|
+
private
|
|
132
|
+
|
|
133
|
+
def parse_time(value)
|
|
134
|
+
return nil unless value
|
|
135
|
+
return value if value.is_a?(Time)
|
|
136
|
+
|
|
137
|
+
Time.parse(value.to_s)
|
|
138
|
+
rescue ArgumentError
|
|
139
|
+
nil
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
def extract_reasoning_content(response)
|
|
143
|
+
# Claude format
|
|
144
|
+
return response.reasoning_content if response.respond_to?(:reasoning_content)
|
|
145
|
+
|
|
146
|
+
# OpenAI o1 format (reasoning is often in a separate field)
|
|
147
|
+
if response.respond_to?(:choices) && response.choices&.first
|
|
148
|
+
choice = response.choices.first
|
|
149
|
+
return choice.reasoning if choice.respond_to?(:reasoning)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Check usage for reasoning summary
|
|
153
|
+
if response.respond_to?(:usage) && response.usage
|
|
154
|
+
usage = response.usage
|
|
155
|
+
return usage.reasoning_content if usage.respond_to?(:reasoning_content)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
nil
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
def extract_reasoning_tokens(response)
|
|
162
|
+
return 0 unless response.respond_to?(:usage) && response.usage
|
|
163
|
+
|
|
164
|
+
usage = response.usage
|
|
165
|
+
if usage.respond_to?(:reasoning_tokens)
|
|
166
|
+
usage.reasoning_tokens || 0
|
|
167
|
+
else
|
|
168
|
+
0
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def extract_thinking_time(response)
|
|
173
|
+
return nil unless response.respond_to?(:usage) && response.usage
|
|
174
|
+
|
|
175
|
+
usage = response.usage
|
|
176
|
+
# Some providers track thinking time separately
|
|
177
|
+
if usage.respond_to?(:thinking_time_ms)
|
|
178
|
+
usage.thinking_time_ms
|
|
179
|
+
elsif usage.respond_to?(:reasoning_time_ms)
|
|
180
|
+
usage.reasoning_time_ms
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def reasoning_redacted?(response)
|
|
185
|
+
return false unless response.respond_to?(:usage) && response.usage
|
|
186
|
+
|
|
187
|
+
usage = response.usage
|
|
188
|
+
usage.respond_to?(:reasoning_redacted) && usage.reasoning_redacted == true
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def extract_reasoning_metadata(response)
|
|
192
|
+
metadata = {}
|
|
193
|
+
|
|
194
|
+
if response.respond_to?(:usage) && response.usage
|
|
195
|
+
usage = response.usage
|
|
196
|
+
metadata[:budget_tokens] = usage.reasoning_budget_tokens if usage.respond_to?(:reasoning_budget_tokens)
|
|
197
|
+
metadata[:effort] = usage.reasoning_effort if usage.respond_to?(:reasoning_effort)
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
metadata.compact
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "reasonable/reason"
|
|
4
|
+
|
|
5
|
+
module SolidAgent
|
|
6
|
+
# Reasonable provides AI reasoning/thinking trace support for models.
|
|
7
|
+
#
|
|
8
|
+
# This concern enables models to track and store reasoning traces from
|
|
9
|
+
# LLMs that support extended thinking (like Claude's extended thinking
|
|
10
|
+
# or OpenAI's o1 reasoning models).
|
|
11
|
+
#
|
|
12
|
+
# Reasoning traces help with:
|
|
13
|
+
# - Transparency: Understanding why an AI made certain decisions
|
|
14
|
+
# - Debugging: Identifying issues in AI logic
|
|
15
|
+
# - Auditing: Maintaining records of AI decision-making processes
|
|
16
|
+
# - Learning: Improving prompts based on reasoning patterns
|
|
17
|
+
#
|
|
18
|
+
# @example Basic usage in a model
|
|
19
|
+
# class AgentGeneration < ApplicationRecord
|
|
20
|
+
# include SolidAgent::Reasonable
|
|
21
|
+
# end
|
|
22
|
+
#
|
|
23
|
+
# generation.store_reasoning!(response)
|
|
24
|
+
# generation.reasoning_content #=> "Let me think step by step..."
|
|
25
|
+
# generation.reasoning_tokens #=> 150
|
|
26
|
+
# generation.has_reasoning? #=> true
|
|
27
|
+
#
|
|
28
|
+
# @example Configuring reasoning storage
|
|
29
|
+
# class MyGeneration < ApplicationRecord
|
|
30
|
+
# include SolidAgent::Reasonable
|
|
31
|
+
#
|
|
32
|
+
# reasonable_config(
|
|
33
|
+
# column: :thinking_trace, # Custom column name
|
|
34
|
+
# tokens_column: :think_tokens,
|
|
35
|
+
# auto_extract: true # Auto-extract from responses
|
|
36
|
+
# )
|
|
37
|
+
# end
|
|
38
|
+
#
|
|
39
|
+
module Reasonable
|
|
40
|
+
extend ActiveSupport::Concern
|
|
41
|
+
|
|
42
|
+
included do
|
|
43
|
+
# Default configuration
|
|
44
|
+
class_attribute :_reasonable_config, default: {
|
|
45
|
+
column: :reasoning_content,
|
|
46
|
+
tokens_column: :reasoning_tokens,
|
|
47
|
+
metadata_column: :reasoning_metadata,
|
|
48
|
+
auto_extract: false
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
class_methods do
|
|
53
|
+
# Configure reasoning storage options
|
|
54
|
+
#
|
|
55
|
+
# @param column [Symbol] Column to store reasoning content
|
|
56
|
+
# @param tokens_column [Symbol] Column to store token count
|
|
57
|
+
# @param metadata_column [Symbol] Column to store metadata (JSON)
|
|
58
|
+
# @param auto_extract [Boolean] Auto-extract reasoning from responses
|
|
59
|
+
def reasonable_config(column: nil, tokens_column: nil, metadata_column: nil, auto_extract: nil)
|
|
60
|
+
config = _reasonable_config.dup
|
|
61
|
+
config[:column] = column if column
|
|
62
|
+
config[:tokens_column] = tokens_column if tokens_column
|
|
63
|
+
config[:metadata_column] = metadata_column if metadata_column
|
|
64
|
+
config[:auto_extract] = auto_extract unless auto_extract.nil?
|
|
65
|
+
self._reasonable_config = config
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Store reasoning from an LLM response
|
|
70
|
+
#
|
|
71
|
+
# @param response [Object] LLM response with reasoning data
|
|
72
|
+
# @return [Reason, nil] The extracted reason or nil
|
|
73
|
+
def store_reasoning!(response)
|
|
74
|
+
reason = Reason.from_response(response)
|
|
75
|
+
return nil unless reason&.extended_thinking?
|
|
76
|
+
|
|
77
|
+
update_reasoning_columns(reason)
|
|
78
|
+
reason
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Store reasoning from a Reason object
|
|
82
|
+
#
|
|
83
|
+
# @param reason [Reason] The reason to store
|
|
84
|
+
# @return [Reason] The stored reason
|
|
85
|
+
def store_reason!(reason)
|
|
86
|
+
return nil unless reason.is_a?(Reason)
|
|
87
|
+
|
|
88
|
+
update_reasoning_columns(reason)
|
|
89
|
+
reason
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Get the stored reasoning content
|
|
93
|
+
#
|
|
94
|
+
# @return [String, nil]
|
|
95
|
+
def reasoning_content
|
|
96
|
+
column = _reasonable_config[:column]
|
|
97
|
+
respond_to?(column) ? send(column) : nil
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Get the stored reasoning token count
|
|
101
|
+
#
|
|
102
|
+
# @return [Integer]
|
|
103
|
+
def reasoning_tokens
|
|
104
|
+
column = _reasonable_config[:tokens_column]
|
|
105
|
+
(respond_to?(column) ? send(column) : 0).to_i
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
# Get the stored reasoning metadata
|
|
109
|
+
#
|
|
110
|
+
# @return [Hash]
|
|
111
|
+
def reasoning_metadata
|
|
112
|
+
column = _reasonable_config[:metadata_column]
|
|
113
|
+
(respond_to?(column) ? send(column) : {}) || {}
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
# Check if this record has reasoning stored
|
|
117
|
+
#
|
|
118
|
+
# @return [Boolean]
|
|
119
|
+
def has_reasoning?
|
|
120
|
+
reasoning_content.present? || reasoning_tokens.positive?
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# Check if reasoning was redacted
|
|
124
|
+
#
|
|
125
|
+
# @return [Boolean]
|
|
126
|
+
def reasoning_redacted?
|
|
127
|
+
reasoning_metadata["redacted"] == true
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
# Get a Reason object from stored data
|
|
131
|
+
#
|
|
132
|
+
# @return [Reason, nil]
|
|
133
|
+
def to_reason
|
|
134
|
+
return nil unless has_reasoning?
|
|
135
|
+
|
|
136
|
+
Reason.new(
|
|
137
|
+
content: reasoning_content,
|
|
138
|
+
tokens: reasoning_tokens,
|
|
139
|
+
model: respond_to?(:model) ? model : nil,
|
|
140
|
+
thinking_time_ms: reasoning_metadata["thinking_time_ms"],
|
|
141
|
+
redacted: reasoning_redacted?,
|
|
142
|
+
metadata: reasoning_metadata,
|
|
143
|
+
created_at: respond_to?(:created_at) ? created_at : nil
|
|
144
|
+
)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Get a summary of the reasoning
|
|
148
|
+
#
|
|
149
|
+
# @param length [Integer] Maximum length
|
|
150
|
+
# @return [String]
|
|
151
|
+
def reasoning_summary(length: 200)
|
|
152
|
+
return "[Redacted]" if reasoning_redacted?
|
|
153
|
+
return "" if reasoning_content.blank?
|
|
154
|
+
|
|
155
|
+
reasoning_content.truncate(length)
|
|
156
|
+
end
|
|
157
|
+
|
|
158
|
+
private
|
|
159
|
+
|
|
160
|
+
def update_reasoning_columns(reason)
|
|
161
|
+
updates = {}
|
|
162
|
+
|
|
163
|
+
content_col = _reasonable_config[:column]
|
|
164
|
+
tokens_col = _reasonable_config[:tokens_column]
|
|
165
|
+
metadata_col = _reasonable_config[:metadata_column]
|
|
166
|
+
|
|
167
|
+
updates[content_col] = reason.content if respond_to?("#{content_col}=")
|
|
168
|
+
updates[tokens_col] = reason.tokens if respond_to?("#{tokens_col}=")
|
|
169
|
+
|
|
170
|
+
if respond_to?("#{metadata_col}=")
|
|
171
|
+
updates[metadata_col] = {
|
|
172
|
+
thinking_time_ms: reason.thinking_time_ms,
|
|
173
|
+
redacted: reason.redacted,
|
|
174
|
+
model: reason.model
|
|
175
|
+
}.merge(reason.metadata).compact
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
update!(updates) if updates.any? && respond_to?(:update!)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|