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,670 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
# HasContext provides database-backed prompt context management for agents.
|
|
6
|
+
#
|
|
7
|
+
# This concern adds the `has_context` class method which configures an agent
|
|
8
|
+
# to persist its prompt context, messages, and generation results to the database.
|
|
9
|
+
# It works similarly to ActiveRecord associations, allowing custom naming.
|
|
10
|
+
#
|
|
11
|
+
# @example Basic usage with auto-context (contextual inferred from params)
|
|
12
|
+
# class WritingAssistantAgent < ApplicationAgent
|
|
13
|
+
# include SolidAgent::HasContext
|
|
14
|
+
# has_context contextual: :document # Auto-creates context from params[:document]
|
|
15
|
+
#
|
|
16
|
+
# def improve
|
|
17
|
+
# prompt # Context automatically created before prompt
|
|
18
|
+
# end
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# @example Named context with auto-creation
|
|
22
|
+
# class ChatAgent < ApplicationAgent
|
|
23
|
+
# include SolidAgent::HasContext
|
|
24
|
+
# has_context :conversation, contextual: :user # Auto-loads/creates from params[:user]
|
|
25
|
+
#
|
|
26
|
+
# def chat
|
|
27
|
+
# add_conversation_user_message(params[:message])
|
|
28
|
+
# prompt messages: conversation_messages
|
|
29
|
+
# end
|
|
30
|
+
# end
|
|
31
|
+
#
|
|
32
|
+
# @example Manual context management (contextual: false)
|
|
33
|
+
# class ResearchAgent < ApplicationAgent
|
|
34
|
+
# include SolidAgent::HasContext
|
|
35
|
+
# has_context :research_session, contextual: false
|
|
36
|
+
#
|
|
37
|
+
# def research
|
|
38
|
+
# create_research_session(contextable: params[:project]) # Manual creation
|
|
39
|
+
# prompt(tools: tools)
|
|
40
|
+
# end
|
|
41
|
+
# end
|
|
42
|
+
#
|
|
43
|
+
# @example Multiple contexts with different contextual params
|
|
44
|
+
# class MultiModalAgent < ApplicationAgent
|
|
45
|
+
# include SolidAgent::HasContext
|
|
46
|
+
# has_context :conversation, contextual: :user # Auto from params[:user]
|
|
47
|
+
# has_context :analysis, contextual: :document # Auto from params[:document]
|
|
48
|
+
#
|
|
49
|
+
# def analyze
|
|
50
|
+
# prompt # Both contexts auto-created
|
|
51
|
+
# end
|
|
52
|
+
# end
|
|
53
|
+
#
|
|
54
|
+
module SolidAgent
|
|
55
|
+
module HasContext
|
|
56
|
+
extend ActiveSupport::Concern
|
|
57
|
+
|
|
58
|
+
included do
|
|
59
|
+
# Store all context configurations (supports multiple has_context calls)
|
|
60
|
+
class_attribute :_context_configs, default: {}
|
|
61
|
+
|
|
62
|
+
# Default context accessor for backward compatibility
|
|
63
|
+
attr_accessor :generation_response
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
class_methods do
|
|
67
|
+
# Configures database-backed context persistence for this agent.
|
|
68
|
+
#
|
|
69
|
+
# @param name [Symbol, nil] Name for the context (e.g., :conversation, :research_session)
|
|
70
|
+
# - nil or :context uses default naming (context, load_context, create_context)
|
|
71
|
+
# - :conversation creates conversation, load_conversation, create_conversation
|
|
72
|
+
#
|
|
73
|
+
# @param class_name [String, Class] The model class for storing context
|
|
74
|
+
# - Defaults to "AgentContext" for unnamed, or "{Name}Context" for named
|
|
75
|
+
#
|
|
76
|
+
# @param message_class [String, Class] The model class for storing messages
|
|
77
|
+
# - Defaults based on context class name
|
|
78
|
+
#
|
|
79
|
+
# @param generation_class [String, Class] The model class for storing generations
|
|
80
|
+
# - Defaults based on context class name
|
|
81
|
+
#
|
|
82
|
+
# @param auto_save [Boolean] Automatically save generation results (default: true)
|
|
83
|
+
#
|
|
84
|
+
# @param contextual [Symbol, false, nil] Param key for auto-context creation
|
|
85
|
+
# - Symbol: Auto-load/create context using params[contextual] (e.g., :user, :document)
|
|
86
|
+
# - false: Disable auto-context, require manual create_* or load_* calls
|
|
87
|
+
# - nil: Auto-create context without a contextable (anonymous context)
|
|
88
|
+
#
|
|
89
|
+
# @example Auto-context from params
|
|
90
|
+
# has_context :conversation, contextual: :user
|
|
91
|
+
#
|
|
92
|
+
# @example Manual context management
|
|
93
|
+
# has_context :session, contextual: false
|
|
94
|
+
#
|
|
95
|
+
# @example Fully customized
|
|
96
|
+
# has_context :session,
|
|
97
|
+
# class_name: "ChatSession",
|
|
98
|
+
# message_class: "ChatMessage",
|
|
99
|
+
# generation_class: "ChatGeneration",
|
|
100
|
+
# contextual: :chat_user,
|
|
101
|
+
# auto_save: false
|
|
102
|
+
#
|
|
103
|
+
def has_context(name = nil, class_name: nil, message_class: nil, generation_class: nil, auto_save: true, contextual: nil)
|
|
104
|
+
# Normalize name
|
|
105
|
+
context_name = normalize_context_name(name)
|
|
106
|
+
|
|
107
|
+
# Infer class names based on context name
|
|
108
|
+
inferred_classes = infer_class_names(context_name, class_name)
|
|
109
|
+
|
|
110
|
+
config = {
|
|
111
|
+
name: context_name,
|
|
112
|
+
context_class: class_name || inferred_classes[:context],
|
|
113
|
+
message_class: message_class || inferred_classes[:message],
|
|
114
|
+
generation_class: generation_class || inferred_classes[:generation],
|
|
115
|
+
auto_save: auto_save,
|
|
116
|
+
contextual: contextual
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
# Store configuration
|
|
120
|
+
self._context_configs = _context_configs.merge(context_name => config)
|
|
121
|
+
|
|
122
|
+
# Define instance accessor for this context
|
|
123
|
+
define_context_accessor(context_name)
|
|
124
|
+
|
|
125
|
+
# Define helper methods
|
|
126
|
+
define_context_methods(context_name)
|
|
127
|
+
|
|
128
|
+
# Add callbacks for auto_save (only for primary/first context)
|
|
129
|
+
if auto_save && _context_configs.size == 1
|
|
130
|
+
after_prompt :persist_prompt_to_context
|
|
131
|
+
around_generation :capture_and_persist_generation
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Add auto-context callback if contextual is not explicitly false
|
|
135
|
+
if contextual != false
|
|
136
|
+
after_prompt :"ensure_#{context_name}_exists"
|
|
137
|
+
define_auto_context_method(context_name, contextual)
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
private
|
|
142
|
+
|
|
143
|
+
def normalize_context_name(name)
|
|
144
|
+
case name
|
|
145
|
+
when nil, :context, :contexts
|
|
146
|
+
:context
|
|
147
|
+
else
|
|
148
|
+
name.to_s.singularize.to_sym
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def infer_class_names(context_name, explicit_class_name)
|
|
153
|
+
if context_name == :context
|
|
154
|
+
# The default trio is configurable — SolidAgent.context_class and
|
|
155
|
+
# friends are what the shipped initializer tells hosts to set, so
|
|
156
|
+
# they have to be read here rather than hardcoded.
|
|
157
|
+
{
|
|
158
|
+
context: SolidAgent.context_class,
|
|
159
|
+
message: SolidAgent.message_class,
|
|
160
|
+
generation: SolidAgent.generation_class
|
|
161
|
+
}
|
|
162
|
+
elsif explicit_class_name
|
|
163
|
+
# If class_name is provided, infer message/generation from it.
|
|
164
|
+
# Strip at most one suffix: chaining delete_suffix would reduce
|
|
165
|
+
# "SessionContext" to "" and yield a bare "Message"/"Generation".
|
|
166
|
+
base = SolidAgent::ModelNaming.base_for(explicit_class_name)
|
|
167
|
+
{
|
|
168
|
+
context: explicit_class_name,
|
|
169
|
+
message: "#{base}Message",
|
|
170
|
+
generation: "#{base}Generation"
|
|
171
|
+
}
|
|
172
|
+
else
|
|
173
|
+
# Infer from context_name (e.g., :conversation -> Conversation, ConversationMessage)
|
|
174
|
+
base = context_name.to_s.camelize
|
|
175
|
+
{
|
|
176
|
+
context: base,
|
|
177
|
+
message: "#{base}Message",
|
|
178
|
+
generation: "#{base}Generation"
|
|
179
|
+
}
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def define_context_accessor(context_name)
|
|
184
|
+
attr_accessor context_name
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def define_auto_context_method(context_name, contextual_key)
|
|
188
|
+
# Define ensure_{name}_exists method that auto-creates context if not present
|
|
189
|
+
define_method("ensure_#{context_name}_exists") do
|
|
190
|
+
return if send(context_name).present?
|
|
191
|
+
|
|
192
|
+
config = self.class._context_configs[context_name]
|
|
193
|
+
contextual_param = config[:contextual]
|
|
194
|
+
|
|
195
|
+
if contextual_param.is_a?(Symbol)
|
|
196
|
+
# Load or create with contextable from params
|
|
197
|
+
contextable_value = params[contextual_param]
|
|
198
|
+
send("load_#{context_name}", contextable: contextable_value)
|
|
199
|
+
else
|
|
200
|
+
# Create anonymous context (no contextable)
|
|
201
|
+
send("create_#{context_name}")
|
|
202
|
+
end
|
|
203
|
+
end
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def define_context_methods(context_name)
|
|
207
|
+
config_key = context_name
|
|
208
|
+
|
|
209
|
+
# Define {name}_class method
|
|
210
|
+
define_method("#{context_name}_class") do
|
|
211
|
+
config = self.class._context_configs[config_key]
|
|
212
|
+
instance_variable_get("@_#{context_name}_class") ||
|
|
213
|
+
instance_variable_set("@_#{context_name}_class", config[:context_class].to_s.constantize)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
# Define {name}_message_class method
|
|
217
|
+
define_method("#{context_name}_message_class") do
|
|
218
|
+
config = self.class._context_configs[config_key]
|
|
219
|
+
instance_variable_get("@_#{context_name}_message_class") ||
|
|
220
|
+
instance_variable_set("@_#{context_name}_message_class", config[:message_class].to_s.constantize)
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Define {name}_generation_class method
|
|
224
|
+
define_method("#{context_name}_generation_class") do
|
|
225
|
+
config = self.class._context_configs[config_key]
|
|
226
|
+
instance_variable_get("@_#{context_name}_generation_class") ||
|
|
227
|
+
instance_variable_set("@_#{context_name}_generation_class", config[:generation_class].to_s.constantize)
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
# Define load_{name} method
|
|
231
|
+
define_method("load_#{context_name}") do |contextable: nil, context_id: nil, **options|
|
|
232
|
+
ctx_class = send("#{context_name}_class")
|
|
233
|
+
|
|
234
|
+
loaded = if context_id
|
|
235
|
+
ctx_class.find(context_id)
|
|
236
|
+
elsif contextable
|
|
237
|
+
ctx_class.find_or_create_by!(
|
|
238
|
+
contextable: contextable,
|
|
239
|
+
agent_name: self.class.name,
|
|
240
|
+
action_name: action_name
|
|
241
|
+
) do |ctx|
|
|
242
|
+
ctx.instructions = prompt_options[:instructions] if ctx.respond_to?(:instructions=)
|
|
243
|
+
ctx.options = options if ctx.respond_to?(:options=)
|
|
244
|
+
ctx.trace_id = prompt_options[:trace_id] if ctx.respond_to?(:trace_id=)
|
|
245
|
+
end
|
|
246
|
+
else
|
|
247
|
+
ctx_class.create!(
|
|
248
|
+
agent_name: self.class.name,
|
|
249
|
+
action_name: action_name,
|
|
250
|
+
instructions: prompt_options[:instructions],
|
|
251
|
+
options: options,
|
|
252
|
+
trace_id: prompt_options[:trace_id]
|
|
253
|
+
)
|
|
254
|
+
end
|
|
255
|
+
|
|
256
|
+
send("#{context_name}=", loaded)
|
|
257
|
+
end
|
|
258
|
+
|
|
259
|
+
# Define create_{name} method
|
|
260
|
+
define_method("create_#{context_name}") do |contextable: nil, **options|
|
|
261
|
+
ctx_class = send("#{context_name}_class")
|
|
262
|
+
|
|
263
|
+
created = ctx_class.create!(
|
|
264
|
+
contextable: contextable,
|
|
265
|
+
agent_name: self.class.name,
|
|
266
|
+
action_name: action_name,
|
|
267
|
+
instructions: prompt_options[:instructions],
|
|
268
|
+
options: options,
|
|
269
|
+
trace_id: prompt_options[:trace_id]
|
|
270
|
+
)
|
|
271
|
+
|
|
272
|
+
send("#{context_name}=", created)
|
|
273
|
+
end
|
|
274
|
+
|
|
275
|
+
# Define {name}_messages method
|
|
276
|
+
define_method("#{context_name}_messages") do
|
|
277
|
+
ctx = send(context_name)
|
|
278
|
+
return [] unless ctx
|
|
279
|
+
ctx.messages.map(&:to_message_hash)
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
# Define with_{name}_messages method
|
|
283
|
+
define_method("with_#{context_name}_messages") do
|
|
284
|
+
msgs = send("#{context_name}_messages")
|
|
285
|
+
prompt messages: msgs if msgs.any?
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Define add_{name}_message method
|
|
289
|
+
define_method("add_#{context_name}_message") do |role:, content:, **attributes|
|
|
290
|
+
ctx = send(context_name)
|
|
291
|
+
raise SolidAgent::Error, "No #{context_name} loaded. Call load_#{context_name} or create_#{context_name} first." unless ctx
|
|
292
|
+
|
|
293
|
+
# Build message attributes
|
|
294
|
+
message_attrs = { role: role, content: content, **attributes }
|
|
295
|
+
|
|
296
|
+
# Add provenance data if the message model supports it
|
|
297
|
+
# Check via the context's message association if available
|
|
298
|
+
begin
|
|
299
|
+
if ctx.messages.respond_to?(:build)
|
|
300
|
+
sample = ctx.messages.build
|
|
301
|
+
message_attrs[:provenance] = current_provenance if sample.respond_to?(:provenance=)
|
|
302
|
+
message_attrs[:content_checksum] = Digest::MD5.hexdigest(content.to_s) if sample.respond_to?(:content_checksum=)
|
|
303
|
+
end
|
|
304
|
+
rescue StandardError
|
|
305
|
+
# Ignore if we can't check - just create without extra fields
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
ctx.messages.create!(**message_attrs)
|
|
309
|
+
end
|
|
310
|
+
|
|
311
|
+
# Define add_{name}_user_message method
|
|
312
|
+
define_method("add_#{context_name}_user_message") do |content, **attributes|
|
|
313
|
+
send("add_#{context_name}_message", role: "user", content: content, **attributes)
|
|
314
|
+
end
|
|
315
|
+
|
|
316
|
+
# Define add_{name}_assistant_message method
|
|
317
|
+
define_method("add_#{context_name}_assistant_message") do |content, **attributes|
|
|
318
|
+
send("add_#{context_name}_message", role: "assistant", content: content, **attributes)
|
|
319
|
+
end
|
|
320
|
+
|
|
321
|
+
# Define {name}_result method - returns the last assistant message content
|
|
322
|
+
# Useful for extracting the final result to pass back to a caller's context
|
|
323
|
+
define_method("#{context_name}_result") do
|
|
324
|
+
ctx = send(context_name)
|
|
325
|
+
return nil unless ctx
|
|
326
|
+
ctx.messages.select { |m| m.role == "assistant" }.last&.content
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Define {name}_last_generation method - returns the last generation record
|
|
330
|
+
define_method("#{context_name}_last_generation") do
|
|
331
|
+
ctx = send(context_name)
|
|
332
|
+
return nil unless ctx
|
|
333
|
+
ctx.generations.last
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
# Define {name}_summary method - returns a hash with key context data
|
|
337
|
+
# Useful for passing structured results back to a parent context
|
|
338
|
+
define_method("#{context_name}_summary") do
|
|
339
|
+
ctx = send(context_name)
|
|
340
|
+
return nil unless ctx
|
|
341
|
+
{
|
|
342
|
+
id: ctx.id,
|
|
343
|
+
result: send("#{context_name}_result"),
|
|
344
|
+
message_count: ctx.messages.size,
|
|
345
|
+
total_tokens: ctx.respond_to?(:total_tokens) ? ctx.total_tokens : nil,
|
|
346
|
+
created_at: ctx.created_at,
|
|
347
|
+
agent_name: ctx.agent_name,
|
|
348
|
+
action_name: ctx.action_name
|
|
349
|
+
}.compact
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
end
|
|
353
|
+
|
|
354
|
+
# === Backward compatibility methods ===
|
|
355
|
+
# These delegate to the primary context (first or :context)
|
|
356
|
+
|
|
357
|
+
def context
|
|
358
|
+
primary_context_name = self.class._context_configs.keys.first || :context
|
|
359
|
+
send(primary_context_name)
|
|
360
|
+
end
|
|
361
|
+
|
|
362
|
+
def context=(value)
|
|
363
|
+
primary_context_name = self.class._context_configs.keys.first || :context
|
|
364
|
+
send("#{primary_context_name}=", value)
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
def context_class
|
|
368
|
+
primary_context_name = self.class._context_configs.keys.first || :context
|
|
369
|
+
send("#{primary_context_name}_class")
|
|
370
|
+
end
|
|
371
|
+
|
|
372
|
+
def message_class
|
|
373
|
+
primary_context_name = self.class._context_configs.keys.first || :context
|
|
374
|
+
send("#{primary_context_name}_message_class")
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def generation_class
|
|
378
|
+
primary_context_name = self.class._context_configs.keys.first || :context
|
|
379
|
+
send("#{primary_context_name}_generation_class")
|
|
380
|
+
end
|
|
381
|
+
|
|
382
|
+
def load_context(contextable: nil, context_id: nil, **options)
|
|
383
|
+
primary_context_name = self.class._context_configs.keys.first || :context
|
|
384
|
+
send("load_#{primary_context_name}", contextable: contextable, context_id: context_id, **options)
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
def create_context(contextable: nil, **options)
|
|
388
|
+
primary_context_name = self.class._context_configs.keys.first || :context
|
|
389
|
+
send("create_#{primary_context_name}", contextable: contextable, **options)
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def context_messages
|
|
393
|
+
primary_context_name = self.class._context_configs.keys.first || :context
|
|
394
|
+
send("#{primary_context_name}_messages")
|
|
395
|
+
end
|
|
396
|
+
|
|
397
|
+
def with_context_messages
|
|
398
|
+
primary_context_name = self.class._context_configs.keys.first || :context
|
|
399
|
+
send("with_#{primary_context_name}_messages")
|
|
400
|
+
end
|
|
401
|
+
|
|
402
|
+
def add_message(role:, content:, **attributes)
|
|
403
|
+
primary_context_name = self.class._context_configs.keys.first || :context
|
|
404
|
+
send("add_#{primary_context_name}_message", role: role, content: content, **attributes)
|
|
405
|
+
end
|
|
406
|
+
|
|
407
|
+
def add_user_message(content, **attributes)
|
|
408
|
+
add_message(role: "user", content: content, **attributes)
|
|
409
|
+
end
|
|
410
|
+
|
|
411
|
+
def add_assistant_message(content, **attributes)
|
|
412
|
+
add_message(role: "assistant", content: content, **attributes)
|
|
413
|
+
end
|
|
414
|
+
|
|
415
|
+
# Returns the last assistant message content from the primary context
|
|
416
|
+
# Useful for extracting the final result to pass back to a caller
|
|
417
|
+
def context_result
|
|
418
|
+
primary_context_name = self.class._context_configs.keys.first || :context
|
|
419
|
+
send("#{primary_context_name}_result")
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Returns the last generation record from the primary context
|
|
423
|
+
def last_generation
|
|
424
|
+
primary_context_name = self.class._context_configs.keys.first || :context
|
|
425
|
+
send("#{primary_context_name}_last_generation")
|
|
426
|
+
end
|
|
427
|
+
|
|
428
|
+
# Returns a summary hash of the primary context
|
|
429
|
+
# Useful for passing structured results back to a parent context
|
|
430
|
+
def context_summary
|
|
431
|
+
primary_context_name = self.class._context_configs.keys.first || :context
|
|
432
|
+
send("#{primary_context_name}_summary")
|
|
433
|
+
end
|
|
434
|
+
|
|
435
|
+
# ============================================
|
|
436
|
+
# Provenance & Checksums
|
|
437
|
+
# ============================================
|
|
438
|
+
|
|
439
|
+
# Generate checksum for current prompt configuration
|
|
440
|
+
#
|
|
441
|
+
# @return [String] MD5 hex digest
|
|
442
|
+
def prompt_checksum
|
|
443
|
+
data = {
|
|
444
|
+
instructions: prompt_options[:instructions],
|
|
445
|
+
model: prompt_options[:model],
|
|
446
|
+
temperature: prompt_options[:temperature],
|
|
447
|
+
tools: prompt_tool_roster.map { |tool| tool[:name] }.presence
|
|
448
|
+
}.compact
|
|
449
|
+
Digest::MD5.hexdigest(data.to_json)
|
|
450
|
+
end
|
|
451
|
+
|
|
452
|
+
# The tool schemas this generation actually offered the provider, as a
|
|
453
|
+
# compact roster.
|
|
454
|
+
#
|
|
455
|
+
# The full schemas are too heavy to persist on every generation, and
|
|
456
|
+
# the checksum above only proves the roster *changed* — it can't say
|
|
457
|
+
# what the agent could do. Recording names, descriptions and parameter
|
|
458
|
+
# keys makes the tool surface auditable straight from the generation
|
|
459
|
+
# records, without requiring telemetry to be switched on.
|
|
460
|
+
#
|
|
461
|
+
# Shape matches ActiveAgent's `prompt.input.tools` span attribute so a
|
|
462
|
+
# dashboard parses one format from both sources.
|
|
463
|
+
#
|
|
464
|
+
# @return [Array<Hash>] entries with :name, :description, :parameters
|
|
465
|
+
def prompt_tool_roster
|
|
466
|
+
Array(prompt_options[:tools]).filter_map do |tool|
|
|
467
|
+
next unless tool.respond_to?(:[])
|
|
468
|
+
|
|
469
|
+
name = tool[:name] || tool["name"]
|
|
470
|
+
next if name.blank?
|
|
471
|
+
|
|
472
|
+
parameters = tool[:parameters] || tool["parameters"] || tool[:input_schema] || tool["input_schema"]
|
|
473
|
+
properties = parameters.is_a?(Hash) ? (parameters[:properties] || parameters["properties"]) : nil
|
|
474
|
+
|
|
475
|
+
{
|
|
476
|
+
name: name.to_s,
|
|
477
|
+
description: (tool[:description] || tool["description"]).to_s.presence,
|
|
478
|
+
parameters: properties.is_a?(Hash) ? properties.keys.map(&:to_s) : []
|
|
479
|
+
}.compact
|
|
480
|
+
end
|
|
481
|
+
end
|
|
482
|
+
|
|
483
|
+
# Generate checksum for current context state
|
|
484
|
+
#
|
|
485
|
+
# @return [String, nil] MD5 hex digest or nil if no context
|
|
486
|
+
def context_checksum
|
|
487
|
+
return nil unless context
|
|
488
|
+
Digest::MD5.hexdigest({
|
|
489
|
+
context_id: context.id,
|
|
490
|
+
message_count: context.messages.size,
|
|
491
|
+
last_message_id: context.messages.last&.id
|
|
492
|
+
}.to_json)
|
|
493
|
+
end
|
|
494
|
+
|
|
495
|
+
# Generate provenance record for current agent state
|
|
496
|
+
#
|
|
497
|
+
# @return [Hash] Full provenance data for tracing
|
|
498
|
+
def current_provenance
|
|
499
|
+
{
|
|
500
|
+
agent_class: self.class.name,
|
|
501
|
+
agent_checksum: agent_checksum,
|
|
502
|
+
prompt_checksum: prompt_checksum,
|
|
503
|
+
context_checksum: context_checksum,
|
|
504
|
+
context_id: context&.id,
|
|
505
|
+
action_name: action_name,
|
|
506
|
+
trace_id: prompt_options[:trace_id],
|
|
507
|
+
timestamp: Time.now.iso8601,
|
|
508
|
+
manifest_fingerprint: manifest_fingerprint,
|
|
509
|
+
tools: prompt_tool_roster.presence
|
|
510
|
+
}.compact
|
|
511
|
+
end
|
|
512
|
+
|
|
513
|
+
# Generate checksum for the agent class configuration
|
|
514
|
+
#
|
|
515
|
+
# Class-level options are read defensively so provenance never raises
|
|
516
|
+
# inside the (rescued) persistence path and silently drops generations.
|
|
517
|
+
#
|
|
518
|
+
# @return [String] MD5 hex digest
|
|
519
|
+
def agent_checksum
|
|
520
|
+
data = {
|
|
521
|
+
class: self.class.name,
|
|
522
|
+
prompt_options: class_options(:prompt_options),
|
|
523
|
+
embed_options: class_options(:embed_options)
|
|
524
|
+
}.compact
|
|
525
|
+
Digest::MD5.hexdigest(data.to_json)
|
|
526
|
+
end
|
|
527
|
+
|
|
528
|
+
# Get manifest fingerprint if agent was built from manifest
|
|
529
|
+
#
|
|
530
|
+
# @return [String, nil] Fingerprint or nil
|
|
531
|
+
def manifest_fingerprint
|
|
532
|
+
return nil unless self.class.respond_to?(:_manifest) && self.class._manifest
|
|
533
|
+
self.class._manifest.fingerprint
|
|
534
|
+
end
|
|
535
|
+
|
|
536
|
+
private
|
|
537
|
+
|
|
538
|
+
# Class-level option hash for checksums, or nil when unavailable
|
|
539
|
+
def class_options(reader)
|
|
540
|
+
return nil unless self.class.respond_to?(reader)
|
|
541
|
+
|
|
542
|
+
self.class.public_send(reader)&.except(:access_token, :api_key)
|
|
543
|
+
end
|
|
544
|
+
|
|
545
|
+
# After prompt callback - persists the rendered prompt message to context
|
|
546
|
+
def persist_prompt_to_context
|
|
547
|
+
return unless context
|
|
548
|
+
|
|
549
|
+
if prompt_options[:messages].present?
|
|
550
|
+
rendered_message = prompt_options[:messages].last
|
|
551
|
+
content = rendered_message.is_a?(Hash) ? rendered_message[:content] : rendered_message.to_s
|
|
552
|
+
add_user_message(content) if content.present?
|
|
553
|
+
end
|
|
554
|
+
end
|
|
555
|
+
|
|
556
|
+
# Around callback to capture the response and persist to context
|
|
557
|
+
def capture_and_persist_generation
|
|
558
|
+
self.generation_response = yield
|
|
559
|
+
persist_generation_to_context
|
|
560
|
+
generation_response
|
|
561
|
+
end
|
|
562
|
+
|
|
563
|
+
# Persists the generation response to context
|
|
564
|
+
def persist_generation_to_context
|
|
565
|
+
return unless context && generation_response
|
|
566
|
+
|
|
567
|
+
persist_tool_messages_to_context
|
|
568
|
+
|
|
569
|
+
begin
|
|
570
|
+
if generation_response.respond_to?(:message) && generation_response.message&.content.present?
|
|
571
|
+
# Include provenance if the context supports it
|
|
572
|
+
if context.respond_to?(:record_generation_with_provenance!)
|
|
573
|
+
context.record_generation_with_provenance!(generation_response, current_provenance)
|
|
574
|
+
else
|
|
575
|
+
context.record_generation!(generation_response)
|
|
576
|
+
end
|
|
577
|
+
Rails.logger.info "[SolidAgent] Persisted generation to context #{context.id} (#{prompt_checksum[0..7]})"
|
|
578
|
+
else
|
|
579
|
+
Rails.logger.warn "[SolidAgent] Skipping persistence - no message content in response"
|
|
580
|
+
end
|
|
581
|
+
rescue => e
|
|
582
|
+
Rails.logger.error "[SolidAgent] Failed to persist generation: #{e.message}"
|
|
583
|
+
Rails.logger.error e.backtrace.first(5).join("\n")
|
|
584
|
+
end
|
|
585
|
+
end
|
|
586
|
+
|
|
587
|
+
# Overridable enrichment hook for tool persistence. Executors that run
|
|
588
|
+
# tools server-side (a platform's execution service, a job) can
|
|
589
|
+
# override this to return their own invocation records — an array of
|
|
590
|
+
# hashes with symbol keys :tool_call_id, :name, :arguments and
|
|
591
|
+
# :duration_ms (all optional) — so persisted tool messages carry the
|
|
592
|
+
# call's arguments and timing, which provider response messages don't
|
|
593
|
+
# include. Records are matched to response tool messages by
|
|
594
|
+
# tool_call_id when both sides have one, otherwise by position.
|
|
595
|
+
def tool_invocations
|
|
596
|
+
[]
|
|
597
|
+
end
|
|
598
|
+
|
|
599
|
+
# Persists the tool/MCP interaction stream (tool result messages from
|
|
600
|
+
# the response's message stack) to the context, so conversations show
|
|
601
|
+
# the full agent <-> tool exchange, not just the final assistant text.
|
|
602
|
+
#
|
|
603
|
+
# Requires the context model to expose add_tool_message (the install
|
|
604
|
+
# generator's AgentContext does); contexts without it are skipped.
|
|
605
|
+
# Messages are deduped by tool_call_id so re-persisting a shared
|
|
606
|
+
# message stack (multi-turn conversations) doesn't duplicate rows.
|
|
607
|
+
def persist_tool_messages_to_context
|
|
608
|
+
return unless context.respond_to?(:add_tool_message)
|
|
609
|
+
return unless generation_response.respond_to?(:messages)
|
|
610
|
+
|
|
611
|
+
tool_index = -1
|
|
612
|
+
Array(generation_response.messages).each do |message|
|
|
613
|
+
next unless message.respond_to?(:role) && message.role.to_s == "tool"
|
|
614
|
+
|
|
615
|
+
tool_index += 1
|
|
616
|
+
tool_call_id = message.respond_to?(:tool_call_id) ? message.tool_call_id : nil
|
|
617
|
+
next if tool_call_id.present? && tool_message_persisted?(tool_call_id)
|
|
618
|
+
|
|
619
|
+
invocation = tool_invocation_for(tool_call_id, tool_index)
|
|
620
|
+
# Provider tool messages often carry no name (Ollama's don't); the
|
|
621
|
+
# executor's invocation record is the fallback.
|
|
622
|
+
name = (message.name if message.respond_to?(:name))
|
|
623
|
+
name = invocation[:name] if !name.present? && invocation
|
|
624
|
+
|
|
625
|
+
attributes = {
|
|
626
|
+
tool_call_id: tool_call_id,
|
|
627
|
+
tool_name: name,
|
|
628
|
+
result: (message.content if message.respond_to?(:content))
|
|
629
|
+
}
|
|
630
|
+
if invocation && tool_message_details_supported?
|
|
631
|
+
attributes[:arguments] = invocation[:arguments]
|
|
632
|
+
attributes[:duration_ms] = invocation[:duration_ms]
|
|
633
|
+
end
|
|
634
|
+
context.add_tool_message(**attributes)
|
|
635
|
+
end
|
|
636
|
+
rescue => e
|
|
637
|
+
Rails.logger.error "[SolidAgent] Failed to persist tool messages: #{e.message}"
|
|
638
|
+
end
|
|
639
|
+
|
|
640
|
+
def tool_message_persisted?(tool_call_id)
|
|
641
|
+
return false unless context.respond_to?(:messages)
|
|
642
|
+
|
|
643
|
+
scope = context.messages
|
|
644
|
+
scope.respond_to?(:exists?) && scope.exists?(role: "tool", tool_call_id: tool_call_id)
|
|
645
|
+
end
|
|
646
|
+
|
|
647
|
+
# Finds the executor invocation record for a response tool message —
|
|
648
|
+
# by tool_call_id when the record carries one, else by position among
|
|
649
|
+
# the response's tool messages.
|
|
650
|
+
def tool_invocation_for(tool_call_id, index)
|
|
651
|
+
invocations = Array(tool_invocations)
|
|
652
|
+
return nil if invocations.empty?
|
|
653
|
+
|
|
654
|
+
if tool_call_id.present?
|
|
655
|
+
match = invocations.find { |inv| inv[:tool_call_id] && inv[:tool_call_id].to_s == tool_call_id.to_s }
|
|
656
|
+
return match if match
|
|
657
|
+
end
|
|
658
|
+
invocations[index]
|
|
659
|
+
end
|
|
660
|
+
|
|
661
|
+
# Whether the context's add_tool_message accepts the arguments:/
|
|
662
|
+
# duration_ms: enrichment keywords (older generated models don't).
|
|
663
|
+
def tool_message_details_supported?
|
|
664
|
+
parameters = context.method(:add_tool_message).parameters
|
|
665
|
+
parameters.any? { |type, param_name| type == :keyrest || ([ :key, :keyreq ].include?(type) && param_name == :arguments) }
|
|
666
|
+
rescue ::NameError
|
|
667
|
+
false
|
|
668
|
+
end
|
|
669
|
+
end
|
|
670
|
+
end
|