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,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ # AgentContext stores conversation context for AI agents.
4
+ #
5
+ # Each context represents a single conversation or task session,
6
+ # tracking messages, generations, and metadata.
7
+ #
8
+ # @example Creating a context for a user
9
+ # context = AgentContext.create!(
10
+ # contextable: current_user,
11
+ # agent_name: "WritingAssistantAgent",
12
+ # action_name: "improve"
13
+ # )
14
+ #
15
+ # @example Finding contexts for a record
16
+ # user.agent_contexts.recent.each do |context|
17
+ # puts "#{context.agent_name}##{context.action_name}: #{context.messages.count} messages"
18
+ # end
19
+ #
20
+ class AgentContext < ApplicationRecord
21
+ # Associations
22
+ belongs_to :contextable, polymorphic: true, optional: true
23
+ has_many :messages, class_name: "AgentMessage", dependent: :destroy
24
+ has_many :generations, class_name: "AgentGeneration", dependent: :destroy
25
+
26
+ # Validations
27
+ validates :agent_name, presence: true
28
+ validates :action_name, presence: true
29
+
30
+ # Scopes
31
+ scope :recent, -> { order(created_at: :desc) }
32
+ scope :for_agent, ->(name) { where(agent_name: name) }
33
+ scope :for_action, ->(name) { where(action_name: name) }
34
+ scope :with_trace, ->(trace_id) { where(trace_id: trace_id) }
35
+
36
+ # Convenience method to get input_params from options
37
+ def input_params
38
+ options&.dig("input_params") || options&.dig(:input_params) || {}
39
+ end
40
+
41
+ # Records a generation response and updates token counts
42
+ #
43
+ # Response attributes are read defensively: ActiveAgent 1.x response
44
+ # objects don't expose #provider or #duration, and provider messages may
45
+ # not respond to #tool_calls — a hard read would raise inside
46
+ # SolidAgent's rescued persistence callback and silently drop the
47
+ # generation.
48
+ #
49
+ # @param response [ActiveAgent::GenerationResponse] the generation response
50
+ # @param extra_attributes [Hash] additional column values (e.g. trace_id, provenance)
51
+ # @return [AgentGeneration] the created generation record
52
+ def record_generation!(response, extra_attributes = {})
53
+ usage = response.respond_to?(:usage) ? response.usage : nil
54
+
55
+ generation = generations.create!({
56
+ content: response.message&.content,
57
+ model: response_value(response, :model),
58
+ provider: response_value(response, :provider),
59
+ finish_reason: response_value(response, :finish_reason),
60
+ input_tokens: usage&.input_tokens || 0,
61
+ output_tokens: usage&.output_tokens || 0,
62
+ cached_tokens: response_value(usage, :cached_tokens) || 0,
63
+ reasoning_tokens: response_value(usage, :reasoning_tokens) || 0,
64
+ tool_calls: extract_tool_calls(response),
65
+ raw_response: response_value(response, :raw_response),
66
+ duration_seconds: extract_duration_seconds(response, usage)
67
+ }.merge(extra_attributes))
68
+
69
+ # Update cumulative token counts
70
+ increment!(:total_input_tokens, generation.input_tokens)
71
+ increment!(:total_output_tokens, generation.output_tokens)
72
+
73
+ # Also add assistant message to the conversation
74
+ add_assistant_message(response.message&.content, metadata: { "tool_calls" => generation.tool_calls })
75
+
76
+ generation
77
+ end
78
+
79
+ # Records a generation together with its provenance snapshot, correlating
80
+ # it with the distributed trace via provenance[:trace_id].
81
+ #
82
+ # Called automatically by SolidAgent::HasContext when this method exists.
83
+ #
84
+ # @param response [ActiveAgent::GenerationResponse] the generation response
85
+ # @param provenance [Hash] provenance hash from HasContext#current_provenance
86
+ # @return [AgentGeneration] the created generation record
87
+ def record_generation_with_provenance!(response, provenance)
88
+ provenance = (provenance || {}).deep_stringify_keys
89
+
90
+ record_generation!(
91
+ response,
92
+ trace_id: provenance["trace_id"],
93
+ provenance: provenance
94
+ )
95
+ end
96
+
97
+ # Adds a user message to the context
98
+ #
99
+ # @param content [String] the message content
100
+ # @param attributes [Hash] additional attributes
101
+ # @return [AgentMessage] the created message
102
+ def add_user_message(content, **attributes)
103
+ messages.create!(role: "user", content: content, **attributes)
104
+ end
105
+
106
+ # Adds an assistant message to the context
107
+ #
108
+ # @param content [String] the message content
109
+ # @param attributes [Hash] additional attributes
110
+ # @return [AgentMessage] the created message
111
+ def add_assistant_message(content, **attributes)
112
+ messages.create!(role: "assistant", content: content, **attributes)
113
+ end
114
+
115
+ # Adds a system message to the context
116
+ #
117
+ # @param content [String] the message content
118
+ # @return [AgentMessage] the created message
119
+ def add_system_message(content)
120
+ messages.create!(role: "system", content: content)
121
+ end
122
+
123
+ # Adds a tool result message to the context
124
+ #
125
+ # @param tool_call_id [String] the ID of the tool call
126
+ # @param tool_name [String] the name of the tool
127
+ # @param result [Hash, String] the tool result
128
+ # @return [AgentMessage] the created message
129
+ def add_tool_message(tool_call_id:, tool_name:, result:, arguments: nil, duration_ms: nil)
130
+ messages.create!(
131
+ role: "tool",
132
+ tool_call_id: tool_call_id,
133
+ tool_name: tool_name,
134
+ tool_result: result,
135
+ tool_arguments: arguments.presence || {},
136
+ metadata: duration_ms ? { "duration_ms" => duration_ms } : {},
137
+ content: result.is_a?(String) ? result : result.to_json
138
+ )
139
+ end
140
+
141
+ # Returns total token count
142
+ def total_tokens
143
+ total_input_tokens + total_output_tokens
144
+ end
145
+
146
+ private
147
+
148
+ def response_value(response, method)
149
+ response.respond_to?(method) ? response.public_send(method) : nil
150
+ end
151
+
152
+ def extract_duration_seconds(response, usage)
153
+ return response.duration if response.respond_to?(:duration) && response.duration
154
+
155
+ duration_ms = usage.respond_to?(:duration_ms) ? usage.duration_ms : nil
156
+ duration_ms ? duration_ms / 1000.0 : nil
157
+ end
158
+
159
+ def extract_tool_calls(response)
160
+ message = response.message
161
+ return [] unless message.respond_to?(:tool_calls) && message.tool_calls.present?
162
+
163
+ message.tool_calls.map do |tc|
164
+ {
165
+ id: tc.respond_to?(:id) ? tc.id : nil,
166
+ name: tc.respond_to?(:name) ? tc.name : nil,
167
+ arguments: tc.respond_to?(:arguments) ? tc.arguments : nil
168
+ }
169
+ end
170
+ end
171
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ # AgentGeneration stores the results of LLM generation calls.
4
+ #
5
+ # Each generation represents a single API call to the LLM provider,
6
+ # tracking the response, token usage, and any tool calls made.
7
+ #
8
+ # @example Accessing generation data
9
+ # generation = context.generations.last
10
+ # puts "Model: #{generation.model}"
11
+ # puts "Tokens: #{generation.input_tokens} in, #{generation.output_tokens} out"
12
+ # puts "Duration: #{generation.duration_seconds}s"
13
+ #
14
+ class AgentGeneration < ApplicationRecord
15
+ # Associations
16
+ belongs_to :agent_context
17
+
18
+ # Scopes
19
+ scope :recent, -> { order(created_at: :desc) }
20
+ scope :by_model, ->(model) { where(model: model) }
21
+ scope :with_tool_calls, -> { where.not(tool_calls: []) }
22
+ scope :with_trace, ->(trace_id) { where(trace_id: trace_id) }
23
+ scope :completed, -> { where(finish_reason: "stop") }
24
+
25
+ # Returns total token count for this generation
26
+ def total_tokens
27
+ input_tokens + output_tokens
28
+ end
29
+
30
+ # Provider prompt-cache hit on this generation?
31
+ def cache_hit?
32
+ cached_tokens.to_i.positive?
33
+ end
34
+
35
+ # Extended thinking captured?
36
+ def thinking?
37
+ reasoning_tokens.to_i.positive?
38
+ end
39
+
40
+ # Check if this generation included tool calls
41
+ def has_tool_calls?
42
+ tool_calls.present? && tool_calls.any?
43
+ end
44
+
45
+ # Check if generation completed normally
46
+ def completed?
47
+ finish_reason == "stop"
48
+ end
49
+
50
+ # Check if generation was truncated due to length
51
+ def truncated?
52
+ finish_reason == "length"
53
+ end
54
+
55
+ # Check if generation ended with tool calls
56
+ def ended_with_tool_calls?
57
+ finish_reason == "tool_calls"
58
+ end
59
+
60
+ # Returns the cost estimate based on token usage (override with your pricing)
61
+ #
62
+ # @param input_price_per_million [Float] price per million input tokens
63
+ # @param output_price_per_million [Float, nil] price per million output tokens
64
+ # @return [Float, nil] estimated cost in dollars, nil when nothing to price
65
+ def estimated_cost(input_price_per_million: nil, output_price_per_million: nil)
66
+ if input_price_per_million && output_price_per_million
67
+ input_cost = (input_tokens / 1_000_000.0) * input_price_per_million
68
+ output_cost = (output_tokens / 1_000_000.0) * output_price_per_million
69
+ input_cost + output_cost
70
+ else
71
+ # Per-model rates from SolidAgent::ModelPricing (RubyLLM registry
72
+ # when available, static table otherwise)
73
+ SolidAgent::ModelPricing.estimate(model: model, input_tokens: input_tokens, output_tokens: output_tokens)
74
+ end
75
+ end
76
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Agent-curated long-term memory for a subject record, written and read by
4
+ # agents through SolidAgent::HasMemory's save_memory/recall_memory tools.
5
+ #
6
+ # Memory is scoped to (memorable, scope) — not to an agent class — so any
7
+ # agent operating on the same subject shares it, making it a handoff
8
+ # channel between agents. Entry source_agent records who wrote each note.
9
+ class AgentMemory < ApplicationRecord
10
+ belongs_to :memorable, polymorphic: true, optional: true
11
+ has_many :entries, class_name: "AgentMemoryEntry", dependent: :destroy
12
+
13
+ validates :scope, presence: true
14
+
15
+ # Finds or creates the memory for a subject.
16
+ def self.for(memorable, scope: SolidAgent::HasMemory::DEFAULT_SCOPE)
17
+ find_or_create_by!(memorable: memorable, scope: scope.to_s)
18
+ end
19
+
20
+ # Appends a summary note.
21
+ def remember(content, source_agent: nil, category: nil)
22
+ entries.create!(content: content, source_agent: source_agent, category: category)
23
+ end
24
+
25
+ # Most recent notes first.
26
+ def recall(limit: 20, category: nil)
27
+ scope = entries.order(created_at: :desc)
28
+ scope = scope.where(category: category) if category.present?
29
+ scope.limit(limit || 20).to_a
30
+ end
31
+
32
+ def forget(entry_id)
33
+ entries.find(entry_id).destroy!
34
+ end
35
+
36
+ def summary_list
37
+ entries.order(:created_at).pluck(:content)
38
+ end
39
+
40
+ # Formatted block suitable for injecting into another agent's
41
+ # instructions when handing a subject off.
42
+ def to_prompt
43
+ notes = entries.order(:created_at).map do |entry|
44
+ source = entry.source_agent.present? ? " (#{entry.source_agent})" : ""
45
+ "- #{entry.content}#{source}"
46
+ end
47
+ return "" if notes.empty?
48
+
49
+ "Memory notes for this subject:\n#{notes.join("\n")}"
50
+ end
51
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # One agent-authored summary note in an AgentMemory.
4
+ class AgentMemoryEntry < ApplicationRecord
5
+ belongs_to :agent_memory
6
+
7
+ validates :content, presence: true
8
+
9
+ scope :chronological, -> { order(:created_at) }
10
+ scope :by_category, ->(category) { where(category: category) }
11
+ scope :from_agent, ->(agent_name) { where(source_agent: agent_name) }
12
+ end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ # AgentMessage stores individual messages within an agent context.
4
+ #
5
+ # Messages can be from users, assistants, system prompts, or tool results.
6
+ # This provides a full audit trail of the conversation.
7
+ #
8
+ # @example Adding messages to a context
9
+ # context.messages.create!(role: "user", content: "Hello!")
10
+ # context.messages.create!(role: "assistant", content: "Hi there!")
11
+ #
12
+ # @example Tool call message
13
+ # context.messages.create!(
14
+ # role: "assistant",
15
+ # content: nil,
16
+ # tool_calls: [{ id: "call_123", name: "search", arguments: { query: "Ruby" } }]
17
+ # )
18
+ #
19
+ # @example Tool result message
20
+ # context.messages.create!(
21
+ # role: "tool",
22
+ # tool_call_id: "call_123",
23
+ # tool_name: "search",
24
+ # tool_result: { results: [...] }
25
+ # )
26
+ #
27
+ class AgentMessage < ApplicationRecord
28
+ # Associations
29
+ belongs_to :agent_context
30
+
31
+ # Validations
32
+ validates :role, presence: true, inclusion: { in: %w[user assistant system tool] }
33
+
34
+ # Scopes
35
+ scope :by_role, ->(role) { where(role: role) }
36
+ scope :user_messages, -> { by_role("user") }
37
+ scope :assistant_messages, -> { by_role("assistant") }
38
+ scope :system_messages, -> { by_role("system") }
39
+ scope :tool_messages, -> { by_role("tool") }
40
+ scope :chronological, -> { order(created_at: :asc) }
41
+
42
+ # Converts the message to a hash format for ActiveAgent prompts
43
+ #
44
+ # @return [Hash] message in { role:, content: } format
45
+ def to_message_hash
46
+ hash = { role: role, content: content }
47
+
48
+ # Include tool call info for assistant messages with tool calls
49
+ if role == "assistant" && tool_calls_data.present?
50
+ hash[:tool_calls] = tool_calls_data
51
+ end
52
+
53
+ # Include tool result info for tool messages
54
+ if role == "tool"
55
+ hash[:tool_call_id] = tool_call_id
56
+ hash[:name] = tool_name
57
+ end
58
+
59
+ hash
60
+ end
61
+
62
+ # Returns parsed tool calls from metadata or dedicated columns
63
+ def tool_calls_data
64
+ metadata&.dig("tool_calls") || []
65
+ end
66
+
67
+ # Check if this is a tool call message
68
+ def tool_call?
69
+ role == "assistant" && tool_calls_data.present?
70
+ end
71
+
72
+ # Check if this is a tool result message
73
+ def tool_result?
74
+ role == "tool"
75
+ end
76
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ # A single agent execution: lifecycle status, inputs/outputs, usage, and
4
+ # an append-only stream of progress events. Correlates with AgentContext/
5
+ # AgentGeneration rows and telemetry traces via trace_id.
6
+ class AgentRun < ApplicationRecord
7
+ belongs_to :runnable, polymorphic: true, optional: true
8
+
9
+ STATUSES = %w[pending running complete failed cancelled].freeze
10
+
11
+ validates :status, inclusion: { in: STATUSES }
12
+
13
+ scope :recent, -> { order(created_at: :desc) }
14
+ scope :for_agent, ->(agent_name) { where(agent_name: agent_name) }
15
+ scope :for_action, ->(action_name) { where(action_name: action_name) }
16
+ scope :with_trace, ->(trace_id) { where(trace_id: trace_id) }
17
+ scope :for_status, ->(status) { where(status: status) }
18
+
19
+ STATUSES.each do |status_name|
20
+ define_method("#{status_name}?") { status == status_name }
21
+ end
22
+
23
+ def in_progress?
24
+ pending? || running?
25
+ end
26
+
27
+ def finished?
28
+ complete? || failed? || cancelled?
29
+ end
30
+
31
+ # === Lifecycle ===
32
+
33
+ def start!
34
+ update!(status: "running", started_at: Time.current)
35
+ end
36
+
37
+ def complete!(output: nil, metadata: {}, input_tokens: nil, output_tokens: nil)
38
+ update!(
39
+ status: "complete",
40
+ output: output,
41
+ output_metadata: (output_metadata || {}).merge(metadata),
42
+ input_tokens: input_tokens || self.input_tokens,
43
+ output_tokens: output_tokens || self.output_tokens,
44
+ completed_at: Time.current,
45
+ duration_ms: calculated_duration_ms(fallback_end: Time.current)
46
+ )
47
+ end
48
+
49
+ def fail!(error)
50
+ update!(
51
+ status: "failed",
52
+ error_message: error.respond_to?(:message) ? error.message : error.to_s,
53
+ completed_at: Time.current,
54
+ duration_ms: calculated_duration_ms(fallback_end: Time.current)
55
+ )
56
+ end
57
+
58
+ def cancel!
59
+ return false if finished?
60
+
61
+ update!(status: "cancelled", completed_at: Time.current)
62
+ true
63
+ end
64
+
65
+ # === Progress events ===
66
+
67
+ # Appends a progress event mid-run so pollers can stream what the agent
68
+ # is doing (pending llm/tool/agent calls). Events pair up by eid: a
69
+ # "started" event is pending until a "done"/"error" with the same eid
70
+ # lands. update_column: no validations/callbacks, so this is safe to call
71
+ # from the run's own execution thread.
72
+ #
73
+ # Read-modify-write on a JSON column drops entries when two writers race,
74
+ # so the re-read and the write are serialized by a row lock.
75
+ def append_event(kind:, label:, eid: nil, status: "done", detail: nil, duration_ms: nil)
76
+ event = {
77
+ "at" => Time.current.iso8601(3),
78
+ "eid" => eid,
79
+ "kind" => kind.to_s,
80
+ "label" => label.to_s,
81
+ "status" => status.to_s
82
+ }.compact
83
+ event["detail"] = detail.to_s.byteslice(0, 1200).to_s.scrub if detail
84
+ event["duration_ms"] = duration_ms if duration_ms
85
+
86
+ with_lock do
87
+ current = self.class.where(id: id).pick(:events) || []
88
+ update_column(:events, current + [ event ])
89
+ end
90
+
91
+ event
92
+ end
93
+
94
+ # === Cohort fingerprinting ===
95
+
96
+ # Records the instructions this run executed under as a stable digest —
97
+ # the grouping key (with model) for configuration cohorts.
98
+ def record_instructions(instructions)
99
+ self.instructions_digest = SolidAgent::RunFingerprint.digest(instructions)
100
+ end
101
+
102
+ # Deterministic memorable name for the digest ("calm-heron") — reads far
103
+ # better than hex when comparing cohorts.
104
+ def instructions_codename
105
+ SolidAgent::RunFingerprint.codename(instructions_digest)
106
+ end
107
+
108
+ # === Usage ===
109
+
110
+ def total_tokens
111
+ input_tokens.to_i + output_tokens.to_i
112
+ end
113
+
114
+ def calculated_duration_ms(fallback_end: nil)
115
+ return duration_ms if duration_ms.present?
116
+
117
+ finish = completed_at || fallback_end
118
+ return nil unless started_at && finish
119
+
120
+ ((finish - started_at) * 1000).to_i
121
+ end
122
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateAgentContexts < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ create_table :agent_contexts do |t|
6
+ # Polymorphic association to any model (User, Document, etc.)
7
+ t.references :contextable, polymorphic: true, index: true
8
+
9
+ # Agent identification
10
+ t.string :agent_name, null: false
11
+ t.string :action_name, null: false
12
+
13
+ # System instructions for the conversation
14
+ t.text :instructions
15
+
16
+ # Flexible options storage (input_params, model settings, etc.)
17
+ t.jsonb :options, default: {}
18
+
19
+ # Tracing/debugging
20
+ t.string :trace_id, index: true
21
+
22
+ # Token usage tracking
23
+ t.integer :total_input_tokens, default: 0
24
+ t.integer :total_output_tokens, default: 0
25
+
26
+ t.timestamps
27
+ end
28
+
29
+ add_index :agent_contexts, [:agent_name, :action_name]
30
+ add_index :agent_contexts, :created_at
31
+ end
32
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateAgentGenerations < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ create_table :agent_generations do |t|
6
+ t.references :agent_context, null: false, foreign_key: true, index: true
7
+
8
+ # The assistant's response content
9
+ t.text :content
10
+
11
+ # Model information
12
+ t.string :model
13
+ t.string :provider
14
+
15
+ # Finish reason: stop, tool_calls, length, etc.
16
+ t.string :finish_reason
17
+
18
+ # Token usage for this generation
19
+ t.integer :input_tokens, default: 0
20
+ t.integer :output_tokens, default: 0
21
+ # Provider prompt-cache hits and extended-thinking usage
22
+ t.integer :cached_tokens, default: 0
23
+ t.integer :reasoning_tokens, default: 0
24
+
25
+ # Tool calls made in this generation
26
+ t.jsonb :tool_calls, default: []
27
+
28
+ # Raw response from the provider (for debugging)
29
+ t.jsonb :raw_response
30
+
31
+ # Timing
32
+ t.float :duration_seconds
33
+
34
+ # Telemetry correlation: the distributed trace this generation belongs
35
+ # to (e.g. ActiveAgent::Telemetry trace_id), threaded through
36
+ # prompt_options[:trace_id]
37
+ t.string :trace_id
38
+
39
+ # Provenance snapshot (agent/prompt/context checksums) captured at
40
+ # generation time — see SolidAgent::HasContext#current_provenance
41
+ t.jsonb :provenance, default: {}
42
+
43
+ t.timestamps
44
+ end
45
+
46
+ add_index :agent_generations, :model
47
+ add_index :agent_generations, :finish_reason
48
+ add_index :agent_generations, :trace_id
49
+ add_index :agent_generations, [:agent_context_id, :created_at]
50
+ end
51
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateAgentMemories < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ create_table :agent_memories do |t|
6
+ # The subject the memory is about (an Agent record, User, Project...).
7
+ # Any agent operating on the same subject + scope shares the memory,
8
+ # which is what makes it a handoff channel between agents.
9
+ t.references :memorable, polymorphic: true, index: true
10
+
11
+ # Namespace so a subject can carry independent memory streams.
12
+ t.string :scope, null: false, default: "default"
13
+
14
+ t.timestamps
15
+ end
16
+ add_index :agent_memories, [ :memorable_type, :memorable_id, :scope ], unique: true
17
+
18
+ create_table :agent_memory_entries do |t|
19
+ t.references :agent_memory, null: false, foreign_key: true
20
+
21
+ # The summary note the agent chose to persist.
22
+ t.text :content, null: false
23
+
24
+ # Which agent class wrote it (handoff provenance).
25
+ t.string :source_agent
26
+
27
+ # Optional label: fact, task, handoff, ...
28
+ t.string :category
29
+
30
+ t.timestamps
31
+ end
32
+ add_index :agent_memory_entries, [ :agent_memory_id, :created_at ]
33
+ add_index :agent_memory_entries, :category
34
+ end
35
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ class CreateAgentMessages < ActiveRecord::Migration<%= migration_version %>
4
+ def change
5
+ create_table :agent_messages do |t|
6
+ t.references :agent_context, null: false, foreign_key: true, index: true
7
+
8
+ # Message role: user, assistant, system, tool
9
+ t.string :role, null: false
10
+
11
+ # Message content (text content for the message)
12
+ t.text :content
13
+
14
+ # For tool calls and results
15
+ t.string :tool_call_id
16
+ t.string :tool_name
17
+ t.jsonb :tool_arguments, default: {}
18
+ t.jsonb :tool_result
19
+
20
+ # For multimodal content (images, files)
21
+ t.jsonb :attachments, default: []
22
+
23
+ # Metadata
24
+ t.jsonb :metadata, default: {}
25
+
26
+ # Provenance snapshot + content checksum, populated automatically by
27
+ # SolidAgent::HasContext when present
28
+ t.jsonb :provenance, default: {}
29
+ t.string :content_checksum
30
+
31
+ t.timestamps
32
+ end
33
+
34
+ add_index :agent_messages, :role
35
+ add_index :agent_messages, :tool_call_id
36
+ add_index :agent_messages, [:agent_context_id, :created_at]
37
+ end
38
+ end