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