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.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/LICENSE +21 -0
  4. data/README.md +209 -18
  5. data/Rakefile +22 -2
  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 +2 -2
  31. data/lib/generators/solid_agent/agent/templates/agent.rb.erb +3 -3
  32. data/lib/generators/solid_agent/context/templates/context_model.rb.erb +50 -16
  33. data/lib/generators/solid_agent/context/templates/create_generations.rb.erb +8 -0
  34. data/lib/generators/solid_agent/context/templates/create_messages.rb.erb +4 -0
  35. data/lib/generators/solid_agent/context/templates/generation_model.rb.erb +11 -0
  36. data/lib/generators/solid_agent/install/install_generator.rb +9 -0
  37. data/lib/generators/solid_agent/install/templates/agent_context.rb.erb +60 -17
  38. data/lib/generators/solid_agent/install/templates/agent_generation.rb.erb +23 -6
  39. data/lib/generators/solid_agent/install/templates/agent_memory.rb.erb +51 -0
  40. data/lib/generators/solid_agent/install/templates/agent_memory_entry.rb.erb +12 -0
  41. data/lib/generators/solid_agent/install/templates/agent_run.rb.erb +122 -0
  42. data/lib/generators/solid_agent/install/templates/create_agent_generations.rb.erb +13 -0
  43. data/lib/generators/solid_agent/install/templates/create_agent_memories.rb.erb +35 -0
  44. data/lib/generators/solid_agent/install/templates/create_agent_messages.rb.erb +5 -0
  45. data/lib/generators/solid_agent/install/templates/create_agent_runs.rb.erb +46 -0
  46. data/lib/generators/solid_agent/manifest/manifest_generator.rb +209 -0
  47. data/lib/generators/solid_agent/manifest/templates/agent.md.erb +39 -0
  48. data/lib/generators/solid_agent/manifest/templates/prompt.erb +13 -0
  49. data/lib/generators/solid_agent/reasons/reasons_generator.rb +83 -0
  50. data/lib/generators/solid_agent/reasons/templates/add_reasoning_columns.rb.erb +12 -0
  51. data/lib/solid_agent/agent_manifest/agent_builder.rb +323 -0
  52. data/lib/solid_agent/agent_manifest/errors.rb +26 -0
  53. data/lib/solid_agent/agent_manifest/exporter_registry.rb +117 -0
  54. data/lib/solid_agent/agent_manifest/exporters/agent_md_exporter.rb +115 -0
  55. data/lib/solid_agent/agent_manifest/exporters/base_exporter.rb +152 -0
  56. data/lib/solid_agent/agent_manifest/exporters/crewai_exporter.rb +125 -0
  57. data/lib/solid_agent/agent_manifest/exporters/dotprompt_exporter.rb +92 -0
  58. data/lib/solid_agent/agent_manifest/input_schema.rb +154 -0
  59. data/lib/solid_agent/agent_manifest/manifest.rb +306 -0
  60. data/lib/solid_agent/agent_manifest/parser_registry.rb +185 -0
  61. data/lib/solid_agent/agent_manifest/parsers/agent_md_parser.rb +87 -0
  62. data/lib/solid_agent/agent_manifest/parsers/base_parser.rb +223 -0
  63. data/lib/solid_agent/agent_manifest/parsers/crewai_parser.rb +201 -0
  64. data/lib/solid_agent/agent_manifest/parsers/dotprompt_parser.rb +122 -0
  65. data/lib/solid_agent/agent_manifest/parsers/github_prompt_parser.rb +143 -0
  66. data/lib/solid_agent/agent_manifest/picoschema.rb +254 -0
  67. data/lib/solid_agent/agent_manifest/registry/auth.rb +103 -0
  68. data/lib/solid_agent/agent_manifest/registry/client.rb +384 -0
  69. data/lib/solid_agent/agent_manifest/resource.rb +103 -0
  70. data/lib/solid_agent/agent_manifest/tool.rb +160 -0
  71. data/lib/solid_agent/agent_manifest/validator.rb +368 -0
  72. data/lib/solid_agent/agent_manifest.rb +381 -0
  73. data/lib/solid_agent/has_context.rb +251 -30
  74. data/lib/solid_agent/has_memory.rb +136 -0
  75. data/lib/solid_agent/has_reasons.rb +230 -0
  76. data/lib/solid_agent/model_naming.rb +42 -0
  77. data/lib/solid_agent/model_pricing.rb +93 -0
  78. data/lib/solid_agent/reasonable/reason.rb +205 -0
  79. data/lib/solid_agent/reasonable.rb +181 -0
  80. data/lib/solid_agent/records/agent.rb +520 -0
  81. data/lib/solid_agent/records/agent_run.rb +520 -0
  82. data/lib/solid_agent/records/agent_template.rb +142 -0
  83. data/lib/solid_agent/records/agent_version.rb +141 -0
  84. data/lib/solid_agent/records/ownable.rb +130 -0
  85. data/lib/solid_agent/records.rb +152 -0
  86. data/lib/solid_agent/run_fingerprint.rb +51 -0
  87. data/lib/solid_agent/tool_cache.rb +91 -0
  88. data/lib/solid_agent/version.rb +1 -1
  89. data/lib/solid_agent.rb +70 -3
  90. 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