solid_agent 0.0.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -0
- data/LICENSE +21 -0
- data/README.md +321 -0
- data/Rakefile +32 -0
- data/docs/agent-md-spec.md +803 -0
- data/docs/parser-design.md +1369 -0
- data/docs/registry-api.md +882 -0
- data/examples/README.md +60 -0
- data/examples/manifests/changelog_writer.agent.md +81 -0
- data/examples/manifests/usage.rb +96 -0
- data/examples/memory_handoff/app/agents/researcher_agent.rb +36 -0
- data/examples/memory_handoff/app/agents/writer_agent.rb +41 -0
- data/examples/memory_handoff/usage.rb +45 -0
- data/examples/persistent_conversation/app/agents/support_agent.rb +59 -0
- data/examples/persistent_conversation/app/controllers/support_conversations_controller.rb +24 -0
- data/examples/persistent_conversation/app/views/agents/support/instructions.md.erb +8 -0
- data/examples/persistent_conversation/usage.rb +51 -0
- data/examples/reasoning/app/agents/analysis_agent.rb +52 -0
- data/examples/reasoning/usage.rb +52 -0
- data/examples/run_tracking/app/agents/report_agent.rb +30 -0
- data/examples/run_tracking/app/controllers/agent_runs_controller.rb +43 -0
- data/examples/run_tracking/app/jobs/document_analysis_job.rb +17 -0
- data/examples/run_tracking/app/services/document_analysis_run.rb +68 -0
- data/examples/run_tracking/usage.rb +85 -0
- data/examples/tool_streaming/app/agents/browser_agent.rb +65 -0
- data/examples/tool_streaming/app/channels/tool_status_channel.rb +24 -0
- data/examples/tool_streaming/app/views/browser_agent/tools/fetch_url.json.erb +15 -0
- data/examples/tool_streaming/usage.rb +47 -0
- data/lib/generators/solid_agent/agent/agent_generator.rb +95 -0
- data/lib/generators/solid_agent/agent/templates/action.text.erb +10 -0
- data/lib/generators/solid_agent/agent/templates/agent.rb.erb +93 -0
- data/lib/generators/solid_agent/context/context_generator.rb +124 -0
- data/lib/generators/solid_agent/context/templates/context_model.rb.erb +134 -0
- data/lib/generators/solid_agent/context/templates/create_context.rb.erb +32 -0
- data/lib/generators/solid_agent/context/templates/create_generations.rb.erb +46 -0
- data/lib/generators/solid_agent/context/templates/create_messages.rb.erb +37 -0
- data/lib/generators/solid_agent/context/templates/generation_model.rb.erb +51 -0
- data/lib/generators/solid_agent/context/templates/message_model.rb.erb +47 -0
- data/lib/generators/solid_agent/install/install_generator.rb +92 -0
- data/lib/generators/solid_agent/install/templates/agent_context.rb.erb +171 -0
- data/lib/generators/solid_agent/install/templates/agent_generation.rb.erb +76 -0
- data/lib/generators/solid_agent/install/templates/agent_memory.rb.erb +51 -0
- data/lib/generators/solid_agent/install/templates/agent_memory_entry.rb.erb +12 -0
- data/lib/generators/solid_agent/install/templates/agent_message.rb.erb +76 -0
- data/lib/generators/solid_agent/install/templates/agent_run.rb.erb +122 -0
- data/lib/generators/solid_agent/install/templates/create_agent_contexts.rb.erb +32 -0
- data/lib/generators/solid_agent/install/templates/create_agent_generations.rb.erb +51 -0
- data/lib/generators/solid_agent/install/templates/create_agent_memories.rb.erb +35 -0
- data/lib/generators/solid_agent/install/templates/create_agent_messages.rb.erb +38 -0
- data/lib/generators/solid_agent/install/templates/create_agent_runs.rb.erb +46 -0
- data/lib/generators/solid_agent/install/templates/initializer.rb.erb +51 -0
- data/lib/generators/solid_agent/manifest/manifest_generator.rb +209 -0
- data/lib/generators/solid_agent/manifest/templates/agent.md.erb +39 -0
- data/lib/generators/solid_agent/manifest/templates/prompt.erb +13 -0
- data/lib/generators/solid_agent/reasons/reasons_generator.rb +83 -0
- data/lib/generators/solid_agent/reasons/templates/add_reasoning_columns.rb.erb +12 -0
- data/lib/generators/solid_agent/tool/templates/tool.json.erb +19 -0
- data/lib/generators/solid_agent/tool/tool_generator.rb +117 -0
- data/lib/solid_agent/agent_manifest/agent_builder.rb +323 -0
- data/lib/solid_agent/agent_manifest/errors.rb +26 -0
- data/lib/solid_agent/agent_manifest/exporter_registry.rb +117 -0
- data/lib/solid_agent/agent_manifest/exporters/agent_md_exporter.rb +115 -0
- data/lib/solid_agent/agent_manifest/exporters/base_exporter.rb +152 -0
- data/lib/solid_agent/agent_manifest/exporters/crewai_exporter.rb +125 -0
- data/lib/solid_agent/agent_manifest/exporters/dotprompt_exporter.rb +92 -0
- data/lib/solid_agent/agent_manifest/input_schema.rb +154 -0
- data/lib/solid_agent/agent_manifest/manifest.rb +306 -0
- data/lib/solid_agent/agent_manifest/parser_registry.rb +185 -0
- data/lib/solid_agent/agent_manifest/parsers/agent_md_parser.rb +87 -0
- data/lib/solid_agent/agent_manifest/parsers/base_parser.rb +223 -0
- data/lib/solid_agent/agent_manifest/parsers/crewai_parser.rb +201 -0
- data/lib/solid_agent/agent_manifest/parsers/dotprompt_parser.rb +122 -0
- data/lib/solid_agent/agent_manifest/parsers/github_prompt_parser.rb +143 -0
- data/lib/solid_agent/agent_manifest/picoschema.rb +254 -0
- data/lib/solid_agent/agent_manifest/registry/auth.rb +103 -0
- data/lib/solid_agent/agent_manifest/registry/client.rb +384 -0
- data/lib/solid_agent/agent_manifest/resource.rb +103 -0
- data/lib/solid_agent/agent_manifest/tool.rb +160 -0
- data/lib/solid_agent/agent_manifest/validator.rb +368 -0
- data/lib/solid_agent/agent_manifest.rb +381 -0
- data/lib/solid_agent/engine.rb +16 -0
- data/lib/solid_agent/has_context.rb +670 -0
- data/lib/solid_agent/has_memory.rb +136 -0
- data/lib/solid_agent/has_reasons.rb +230 -0
- data/lib/solid_agent/has_tools.rb +257 -0
- data/lib/solid_agent/model_naming.rb +42 -0
- data/lib/solid_agent/model_pricing.rb +93 -0
- data/lib/solid_agent/reasonable/reason.rb +205 -0
- data/lib/solid_agent/reasonable.rb +181 -0
- data/lib/solid_agent/records/agent.rb +520 -0
- data/lib/solid_agent/records/agent_run.rb +520 -0
- data/lib/solid_agent/records/agent_template.rb +142 -0
- data/lib/solid_agent/records/agent_version.rb +141 -0
- data/lib/solid_agent/records/ownable.rb +130 -0
- data/lib/solid_agent/records.rb +152 -0
- data/lib/solid_agent/run_fingerprint.rb +51 -0
- data/lib/solid_agent/streams_tool_updates.rb +178 -0
- data/lib/solid_agent/tool_cache.rb +91 -0
- data/lib/solid_agent/version.rb +5 -0
- data/lib/solid_agent.rb +95 -0
- data/sig/solid_agent.rbs +4 -0
- metadata +174 -14
|
@@ -0,0 +1,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
|