solid_agent 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/LICENSE +21 -0
  4. data/README.md +209 -18
  5. data/Rakefile +22 -2
  6. data/docs/agent-md-spec.md +803 -0
  7. data/docs/parser-design.md +1369 -0
  8. data/docs/registry-api.md +882 -0
  9. data/examples/README.md +60 -0
  10. data/examples/manifests/changelog_writer.agent.md +81 -0
  11. data/examples/manifests/usage.rb +96 -0
  12. data/examples/memory_handoff/app/agents/researcher_agent.rb +36 -0
  13. data/examples/memory_handoff/app/agents/writer_agent.rb +41 -0
  14. data/examples/memory_handoff/usage.rb +45 -0
  15. data/examples/persistent_conversation/app/agents/support_agent.rb +59 -0
  16. data/examples/persistent_conversation/app/controllers/support_conversations_controller.rb +24 -0
  17. data/examples/persistent_conversation/app/views/agents/support/instructions.md.erb +8 -0
  18. data/examples/persistent_conversation/usage.rb +51 -0
  19. data/examples/reasoning/app/agents/analysis_agent.rb +52 -0
  20. data/examples/reasoning/usage.rb +52 -0
  21. data/examples/run_tracking/app/agents/report_agent.rb +30 -0
  22. data/examples/run_tracking/app/controllers/agent_runs_controller.rb +43 -0
  23. data/examples/run_tracking/app/jobs/document_analysis_job.rb +17 -0
  24. data/examples/run_tracking/app/services/document_analysis_run.rb +68 -0
  25. data/examples/run_tracking/usage.rb +85 -0
  26. data/examples/tool_streaming/app/agents/browser_agent.rb +65 -0
  27. data/examples/tool_streaming/app/channels/tool_status_channel.rb +24 -0
  28. data/examples/tool_streaming/app/views/browser_agent/tools/fetch_url.json.erb +15 -0
  29. data/examples/tool_streaming/usage.rb +47 -0
  30. data/lib/generators/solid_agent/agent/agent_generator.rb +2 -2
  31. data/lib/generators/solid_agent/agent/templates/agent.rb.erb +3 -3
  32. data/lib/generators/solid_agent/context/templates/context_model.rb.erb +50 -16
  33. data/lib/generators/solid_agent/context/templates/create_generations.rb.erb +8 -0
  34. data/lib/generators/solid_agent/context/templates/create_messages.rb.erb +4 -0
  35. data/lib/generators/solid_agent/context/templates/generation_model.rb.erb +11 -0
  36. data/lib/generators/solid_agent/install/install_generator.rb +9 -0
  37. data/lib/generators/solid_agent/install/templates/agent_context.rb.erb +60 -17
  38. data/lib/generators/solid_agent/install/templates/agent_generation.rb.erb +23 -6
  39. data/lib/generators/solid_agent/install/templates/agent_memory.rb.erb +51 -0
  40. data/lib/generators/solid_agent/install/templates/agent_memory_entry.rb.erb +12 -0
  41. data/lib/generators/solid_agent/install/templates/agent_run.rb.erb +122 -0
  42. data/lib/generators/solid_agent/install/templates/create_agent_generations.rb.erb +13 -0
  43. data/lib/generators/solid_agent/install/templates/create_agent_memories.rb.erb +35 -0
  44. data/lib/generators/solid_agent/install/templates/create_agent_messages.rb.erb +5 -0
  45. data/lib/generators/solid_agent/install/templates/create_agent_runs.rb.erb +46 -0
  46. data/lib/generators/solid_agent/manifest/manifest_generator.rb +209 -0
  47. data/lib/generators/solid_agent/manifest/templates/agent.md.erb +39 -0
  48. data/lib/generators/solid_agent/manifest/templates/prompt.erb +13 -0
  49. data/lib/generators/solid_agent/reasons/reasons_generator.rb +83 -0
  50. data/lib/generators/solid_agent/reasons/templates/add_reasoning_columns.rb.erb +12 -0
  51. data/lib/solid_agent/agent_manifest/agent_builder.rb +323 -0
  52. data/lib/solid_agent/agent_manifest/errors.rb +26 -0
  53. data/lib/solid_agent/agent_manifest/exporter_registry.rb +117 -0
  54. data/lib/solid_agent/agent_manifest/exporters/agent_md_exporter.rb +115 -0
  55. data/lib/solid_agent/agent_manifest/exporters/base_exporter.rb +152 -0
  56. data/lib/solid_agent/agent_manifest/exporters/crewai_exporter.rb +125 -0
  57. data/lib/solid_agent/agent_manifest/exporters/dotprompt_exporter.rb +92 -0
  58. data/lib/solid_agent/agent_manifest/input_schema.rb +154 -0
  59. data/lib/solid_agent/agent_manifest/manifest.rb +306 -0
  60. data/lib/solid_agent/agent_manifest/parser_registry.rb +185 -0
  61. data/lib/solid_agent/agent_manifest/parsers/agent_md_parser.rb +87 -0
  62. data/lib/solid_agent/agent_manifest/parsers/base_parser.rb +223 -0
  63. data/lib/solid_agent/agent_manifest/parsers/crewai_parser.rb +201 -0
  64. data/lib/solid_agent/agent_manifest/parsers/dotprompt_parser.rb +122 -0
  65. data/lib/solid_agent/agent_manifest/parsers/github_prompt_parser.rb +143 -0
  66. data/lib/solid_agent/agent_manifest/picoschema.rb +254 -0
  67. data/lib/solid_agent/agent_manifest/registry/auth.rb +103 -0
  68. data/lib/solid_agent/agent_manifest/registry/client.rb +384 -0
  69. data/lib/solid_agent/agent_manifest/resource.rb +103 -0
  70. data/lib/solid_agent/agent_manifest/tool.rb +160 -0
  71. data/lib/solid_agent/agent_manifest/validator.rb +368 -0
  72. data/lib/solid_agent/agent_manifest.rb +381 -0
  73. data/lib/solid_agent/has_context.rb +251 -30
  74. data/lib/solid_agent/has_memory.rb +136 -0
  75. data/lib/solid_agent/has_reasons.rb +230 -0
  76. data/lib/solid_agent/model_naming.rb +42 -0
  77. data/lib/solid_agent/model_pricing.rb +93 -0
  78. data/lib/solid_agent/reasonable/reason.rb +205 -0
  79. data/lib/solid_agent/reasonable.rb +181 -0
  80. data/lib/solid_agent/records/agent.rb +520 -0
  81. data/lib/solid_agent/records/agent_run.rb +520 -0
  82. data/lib/solid_agent/records/agent_template.rb +142 -0
  83. data/lib/solid_agent/records/agent_version.rb +141 -0
  84. data/lib/solid_agent/records/ownable.rb +130 -0
  85. data/lib/solid_agent/records.rb +152 -0
  86. data/lib/solid_agent/run_fingerprint.rb +51 -0
  87. data/lib/solid_agent/tool_cache.rb +91 -0
  88. data/lib/solid_agent/version.rb +1 -1
  89. data/lib/solid_agent.rb +70 -3
  90. metadata +87 -1
@@ -1,15 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  # HasContext provides database-backed prompt context management for agents.
4
6
  #
5
7
  # This concern adds the `has_context` class method which configures an agent
6
8
  # to persist its prompt context, messages, and generation results to the database.
7
9
  # It works similarly to ActiveRecord associations, allowing custom naming.
8
10
  #
9
- # @example Basic usage with auto-context (contextable inferred from params)
11
+ # @example Basic usage with auto-context (contextual inferred from params)
10
12
  # class WritingAssistantAgent < ApplicationAgent
11
13
  # include SolidAgent::HasContext
12
- # has_context contextable: :document # Auto-creates context from params[:document]
14
+ # has_context contextual: :document # Auto-creates context from params[:document]
13
15
  #
14
16
  # def improve
15
17
  # prompt # Context automatically created before prompt
@@ -19,7 +21,7 @@
19
21
  # @example Named context with auto-creation
20
22
  # class ChatAgent < ApplicationAgent
21
23
  # include SolidAgent::HasContext
22
- # has_context :conversation, contextable: :user # Auto-loads/creates from params[:user]
24
+ # has_context :conversation, contextual: :user # Auto-loads/creates from params[:user]
23
25
  #
24
26
  # def chat
25
27
  # add_conversation_user_message(params[:message])
@@ -27,10 +29,10 @@
27
29
  # end
28
30
  # end
29
31
  #
30
- # @example Manual context management (contextable: false)
32
+ # @example Manual context management (contextual: false)
31
33
  # class ResearchAgent < ApplicationAgent
32
34
  # include SolidAgent::HasContext
33
- # has_context :research_session, contextable: false
35
+ # has_context :research_session, contextual: false
34
36
  #
35
37
  # def research
36
38
  # create_research_session(contextable: params[:project]) # Manual creation
@@ -38,11 +40,11 @@
38
40
  # end
39
41
  # end
40
42
  #
41
- # @example Multiple contexts with different contextables
43
+ # @example Multiple contexts with different contextual params
42
44
  # class MultiModalAgent < ApplicationAgent
43
45
  # include SolidAgent::HasContext
44
- # has_context :conversation, contextable: :user # Auto from params[:user]
45
- # has_context :analysis, contextable: :document # Auto from params[:document]
46
+ # has_context :conversation, contextual: :user # Auto from params[:user]
47
+ # has_context :analysis, contextual: :document # Auto from params[:document]
46
48
  #
47
49
  # def analyze
48
50
  # prompt # Both contexts auto-created
@@ -79,26 +81,26 @@ module SolidAgent
79
81
  #
80
82
  # @param auto_save [Boolean] Automatically save generation results (default: true)
81
83
  #
82
- # @param contextable [Symbol, false, nil] Param key for auto-context creation
83
- # - Symbol: Auto-load/create context using params[contextable] (e.g., :user, :document)
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)
84
86
  # - false: Disable auto-context, require manual create_* or load_* calls
85
87
  # - nil: Auto-create context without a contextable (anonymous context)
86
88
  #
87
89
  # @example Auto-context from params
88
- # has_context :conversation, contextable: :user
90
+ # has_context :conversation, contextual: :user
89
91
  #
90
92
  # @example Manual context management
91
- # has_context :session, contextable: false
93
+ # has_context :session, contextual: false
92
94
  #
93
95
  # @example Fully customized
94
96
  # has_context :session,
95
97
  # class_name: "ChatSession",
96
98
  # message_class: "ChatMessage",
97
99
  # generation_class: "ChatGeneration",
98
- # contextable: :chat_user,
100
+ # contextual: :chat_user,
99
101
  # auto_save: false
100
102
  #
101
- def has_context(name = nil, class_name: nil, message_class: nil, generation_class: nil, auto_save: true, contextable: nil)
103
+ def has_context(name = nil, class_name: nil, message_class: nil, generation_class: nil, auto_save: true, contextual: nil)
102
104
  # Normalize name
103
105
  context_name = normalize_context_name(name)
104
106
 
@@ -111,7 +113,7 @@ module SolidAgent
111
113
  message_class: message_class || inferred_classes[:message],
112
114
  generation_class: generation_class || inferred_classes[:generation],
113
115
  auto_save: auto_save,
114
- contextable: contextable
116
+ contextual: contextual
115
117
  }
116
118
 
117
119
  # Store configuration
@@ -129,10 +131,10 @@ module SolidAgent
129
131
  around_generation :capture_and_persist_generation
130
132
  end
131
133
 
132
- # Add auto-context callback if contextable is not explicitly false
133
- if contextable != false
134
+ # Add auto-context callback if contextual is not explicitly false
135
+ if contextual != false
134
136
  after_prompt :"ensure_#{context_name}_exists"
135
- define_auto_context_method(context_name, contextable)
137
+ define_auto_context_method(context_name, contextual)
136
138
  end
137
139
  end
138
140
 
@@ -149,14 +151,19 @@ module SolidAgent
149
151
 
150
152
  def infer_class_names(context_name, explicit_class_name)
151
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.
152
157
  {
153
- context: "AgentContext",
154
- message: "AgentMessage",
155
- generation: "AgentGeneration"
158
+ context: SolidAgent.context_class,
159
+ message: SolidAgent.message_class,
160
+ generation: SolidAgent.generation_class
156
161
  }
157
162
  elsif explicit_class_name
158
- # If class_name is provided, infer message/generation from it
159
- base = explicit_class_name.to_s.delete_suffix("Context").delete_suffix("Session")
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)
160
167
  {
161
168
  context: explicit_class_name,
162
169
  message: "#{base}Message",
@@ -177,17 +184,17 @@ module SolidAgent
177
184
  attr_accessor context_name
178
185
  end
179
186
 
180
- def define_auto_context_method(context_name, contextable_key)
187
+ def define_auto_context_method(context_name, contextual_key)
181
188
  # Define ensure_{name}_exists method that auto-creates context if not present
182
189
  define_method("ensure_#{context_name}_exists") do
183
190
  return if send(context_name).present?
184
191
 
185
192
  config = self.class._context_configs[context_name]
186
- contextable_param = config[:contextable]
193
+ contextual_param = config[:contextual]
187
194
 
188
- if contextable_param.is_a?(Symbol)
195
+ if contextual_param.is_a?(Symbol)
189
196
  # Load or create with contextable from params
190
- contextable_value = params[contextable_param]
197
+ contextable_value = params[contextual_param]
191
198
  send("load_#{context_name}", contextable: contextable_value)
192
199
  else
193
200
  # Create anonymous context (no contextable)
@@ -282,7 +289,23 @@ module SolidAgent
282
289
  define_method("add_#{context_name}_message") do |role:, content:, **attributes|
283
290
  ctx = send(context_name)
284
291
  raise SolidAgent::Error, "No #{context_name} loaded. Call load_#{context_name} or create_#{context_name} first." unless ctx
285
- ctx.messages.create!(role: role, content: content, **attributes)
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)
286
309
  end
287
310
 
288
311
  # Define add_{name}_user_message method
@@ -409,8 +432,116 @@ module SolidAgent
409
432
  send("#{primary_context_name}_summary")
410
433
  end
411
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
+
412
536
  private
413
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
+
414
545
  # After prompt callback - persists the rendered prompt message to context
415
546
  def persist_prompt_to_context
416
547
  return unless context
@@ -433,10 +564,17 @@ module SolidAgent
433
564
  def persist_generation_to_context
434
565
  return unless context && generation_response
435
566
 
567
+ persist_tool_messages_to_context
568
+
436
569
  begin
437
570
  if generation_response.respond_to?(:message) && generation_response.message&.content.present?
438
- context.record_generation!(generation_response)
439
- Rails.logger.info "[SolidAgent] Persisted generation to context #{context.id}"
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]})"
440
578
  else
441
579
  Rails.logger.warn "[SolidAgent] Skipping persistence - no message content in response"
442
580
  end
@@ -445,5 +583,88 @@ module SolidAgent
445
583
  Rails.logger.error e.backtrace.first(5).join("\n")
446
584
  end
447
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
448
669
  end
449
670
  end
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ # HasMemory gives an agent a persistent, agent-curated summary list — the
4
+ # model decides when to read and write it while interacting with tools,
5
+ # other agents, and users.
6
+ #
7
+ # Memory is scoped to a subject record (any ActiveRecord model) plus a
8
+ # scope name, NOT to the agent class — so a memory written by one agent can
9
+ # be recalled by another operating on the same subject. That makes it a
10
+ # handoff channel: agent A records what it learned/did, agent B picks the
11
+ # subject up and recalls the summary before continuing.
12
+ #
13
+ # The concern is duck-typed against a memory model exposing:
14
+ # Model.for(memorable, scope:) -> memory record
15
+ # memory.remember(content, source_agent:, category:) -> entry
16
+ # memory.recall(limit:, category:) -> entries (responding to #content)
17
+ # The install generator's AgentMemory implements this contract.
18
+ #
19
+ # @example Give an agent memory tools the model can call
20
+ # class SupportAgent < ApplicationAgent
21
+ # include SolidAgent::HasMemory
22
+ # has_memory
23
+ #
24
+ # def handle
25
+ # prompt(message: params[:message], tools: memory_tool_definitions)
26
+ # end
27
+ # end
28
+ #
29
+ # @example Handoff between agents sharing a subject
30
+ # ResearchAgent.with(memorable: project).research.generate_now
31
+ # # later, a different agent class:
32
+ # WriterAgent.with(memorable: project).draft.generate_now
33
+ # # WriterAgent's recall_memory returns ResearchAgent's entries too.
34
+ module SolidAgent
35
+ module HasMemory
36
+ extend ActiveSupport::Concern
37
+
38
+ DEFAULT_SCOPE = "default"
39
+
40
+ # Function-calling schemas (common format) for the two memory tools.
41
+ # Exposed as a module method so non-agent callers (platform executors,
42
+ # MCP servers) can reuse the exact same contract.
43
+ def self.tool_definitions
44
+ [
45
+ {
46
+ name: "save_memory",
47
+ description: "Persist a short summary note to long-term memory. Use for facts, decisions, task outcomes, or anything a future agent or session should know. Keep each note self-contained.",
48
+ parameters: {
49
+ type: "object",
50
+ properties: {
51
+ content: { type: "string", description: "The summary note to remember" },
52
+ category: { type: "string", description: "Optional label, e.g. fact, task, handoff" }
53
+ },
54
+ required: [ "content" ]
55
+ }
56
+ },
57
+ {
58
+ name: "recall_memory",
59
+ description: "Read back previously saved memory notes for the current subject, most recent first. Use before starting work to pick up prior context or another agent's handoff.",
60
+ parameters: {
61
+ type: "object",
62
+ properties: {
63
+ category: { type: "string", description: "Only return notes with this label" },
64
+ limit: { type: "integer", description: "Maximum notes to return (default 20)" }
65
+ },
66
+ required: []
67
+ }
68
+ }
69
+ ]
70
+ end
71
+
72
+ included do
73
+ class_attribute :_memory_config, default: nil
74
+ end
75
+
76
+ class_methods do
77
+ # Configures memory for this agent.
78
+ #
79
+ # @param scope [String, Symbol] memory namespace (default "default")
80
+ # @param class_name [String] memory model (default "AgentMemory")
81
+ def has_memory(scope: DEFAULT_SCOPE, class_name: "AgentMemory")
82
+ self._memory_config = { scope: scope.to_s, class_name: class_name }
83
+ end
84
+ end
85
+
86
+ # The memory record for the current subject (or nil without a subject).
87
+ def memory
88
+ config = self.class._memory_config || { scope: DEFAULT_SCOPE, class_name: "AgentMemory" }
89
+ subject = memory_subject
90
+ return nil unless subject
91
+
92
+ @memory ||= config[:class_name].constantize.for(subject, scope: config[:scope])
93
+ end
94
+
95
+ # The record memory is attached to. Defaults to params[:memorable],
96
+ # falling back to the HasContext contextable when present. Override for
97
+ # custom subjects.
98
+ def memory_subject
99
+ return params[:memorable] if respond_to?(:params) && params.is_a?(Hash) && params[:memorable]
100
+
101
+ context.contextable if respond_to?(:context) && context.respond_to?(:contextable)
102
+ rescue StandardError
103
+ nil
104
+ end
105
+
106
+ def memory_tool_definitions
107
+ SolidAgent::HasMemory.tool_definitions
108
+ end
109
+
110
+ # Tool implementations — routed here by the provider's tool calls.
111
+
112
+ def save_memory(content:, category: nil)
113
+ return { error: "No memory subject available" } unless memory
114
+
115
+ entry = memory.remember(content, source_agent: self.class.name, category: category)
116
+ { saved: true, id: entry.respond_to?(:id) ? entry.id : nil, content: content }
117
+ end
118
+
119
+ def recall_memory(category: nil, limit: 20)
120
+ return { error: "No memory subject available" } unless memory
121
+
122
+ entries = memory.recall(limit: limit, category: category)
123
+ {
124
+ count: entries.size,
125
+ entries: entries.map do |entry|
126
+ {
127
+ content: entry.content,
128
+ category: (entry.category if entry.respond_to?(:category)),
129
+ source_agent: (entry.source_agent if entry.respond_to?(:source_agent)),
130
+ created_at: (entry.created_at.iso8601 if entry.respond_to?(:created_at) && entry.created_at)
131
+ }.compact
132
+ end
133
+ }
134
+ end
135
+ end
136
+ end