solid_agent 0.1.1 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -0
- data/LICENSE +21 -0
- data/README.md +209 -18
- data/Rakefile +22 -2
- 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 +2 -2
- data/lib/generators/solid_agent/agent/templates/agent.rb.erb +3 -3
- data/lib/generators/solid_agent/context/templates/context_model.rb.erb +50 -16
- data/lib/generators/solid_agent/context/templates/create_generations.rb.erb +8 -0
- data/lib/generators/solid_agent/context/templates/create_messages.rb.erb +4 -0
- data/lib/generators/solid_agent/context/templates/generation_model.rb.erb +11 -0
- data/lib/generators/solid_agent/install/install_generator.rb +9 -0
- data/lib/generators/solid_agent/install/templates/agent_context.rb.erb +60 -17
- data/lib/generators/solid_agent/install/templates/agent_generation.rb.erb +23 -6
- 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_run.rb.erb +122 -0
- data/lib/generators/solid_agent/install/templates/create_agent_generations.rb.erb +13 -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 +5 -0
- data/lib/generators/solid_agent/install/templates/create_agent_runs.rb.erb +46 -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/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/has_context.rb +251 -30
- data/lib/solid_agent/has_memory.rb +136 -0
- data/lib/solid_agent/has_reasons.rb +230 -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/tool_cache.rb +91 -0
- data/lib/solid_agent/version.rb +1 -1
- data/lib/solid_agent.rb +70 -3
- metadata +87 -1
|
@@ -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,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
|
|
@@ -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
|