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,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