solid_agent 0.1.1 → 0.2.1

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 (91) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +99 -0
  3. data/LICENSE +21 -0
  4. data/README.md +231 -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 +299 -32
  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. data/solid_agent.gemspec +41 -0
  91. metadata +93 -3
@@ -1,15 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+ require "set"
5
+
3
6
  # HasContext provides database-backed prompt context management for agents.
4
7
  #
5
8
  # This concern adds the `has_context` class method which configures an agent
6
9
  # to persist its prompt context, messages, and generation results to the database.
7
10
  # It works similarly to ActiveRecord associations, allowing custom naming.
8
11
  #
9
- # @example Basic usage with auto-context (contextable inferred from params)
12
+ # @example Basic usage with auto-context (contextual inferred from params)
10
13
  # class WritingAssistantAgent < ApplicationAgent
11
14
  # include SolidAgent::HasContext
12
- # has_context contextable: :document # Auto-creates context from params[:document]
15
+ # has_context contextual: :document # Auto-creates context from params[:document]
13
16
  #
14
17
  # def improve
15
18
  # prompt # Context automatically created before prompt
@@ -19,7 +22,7 @@
19
22
  # @example Named context with auto-creation
20
23
  # class ChatAgent < ApplicationAgent
21
24
  # include SolidAgent::HasContext
22
- # has_context :conversation, contextable: :user # Auto-loads/creates from params[:user]
25
+ # has_context :conversation, contextual: :user # Auto-loads/creates from params[:user]
23
26
  #
24
27
  # def chat
25
28
  # add_conversation_user_message(params[:message])
@@ -27,10 +30,10 @@
27
30
  # end
28
31
  # end
29
32
  #
30
- # @example Manual context management (contextable: false)
33
+ # @example Manual context management (contextual: false)
31
34
  # class ResearchAgent < ApplicationAgent
32
35
  # include SolidAgent::HasContext
33
- # has_context :research_session, contextable: false
36
+ # has_context :research_session, contextual: false
34
37
  #
35
38
  # def research
36
39
  # create_research_session(contextable: params[:project]) # Manual creation
@@ -38,11 +41,11 @@
38
41
  # end
39
42
  # end
40
43
  #
41
- # @example Multiple contexts with different contextables
44
+ # @example Multiple contexts with different contextual params
42
45
  # class MultiModalAgent < ApplicationAgent
43
46
  # include SolidAgent::HasContext
44
- # has_context :conversation, contextable: :user # Auto from params[:user]
45
- # has_context :analysis, contextable: :document # Auto from params[:document]
47
+ # has_context :conversation, contextual: :user # Auto from params[:user]
48
+ # has_context :analysis, contextual: :document # Auto from params[:document]
46
49
  #
47
50
  # def analyze
48
51
  # prompt # Both contexts auto-created
@@ -79,26 +82,26 @@ module SolidAgent
79
82
  #
80
83
  # @param auto_save [Boolean] Automatically save generation results (default: true)
81
84
  #
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)
85
+ # @param contextual [Symbol, false, nil] Param key for auto-context creation
86
+ # - Symbol: Auto-load/create context using params[contextual] (e.g., :user, :document)
84
87
  # - false: Disable auto-context, require manual create_* or load_* calls
85
88
  # - nil: Auto-create context without a contextable (anonymous context)
86
89
  #
87
90
  # @example Auto-context from params
88
- # has_context :conversation, contextable: :user
91
+ # has_context :conversation, contextual: :user
89
92
  #
90
93
  # @example Manual context management
91
- # has_context :session, contextable: false
94
+ # has_context :session, contextual: false
92
95
  #
93
96
  # @example Fully customized
94
97
  # has_context :session,
95
98
  # class_name: "ChatSession",
96
99
  # message_class: "ChatMessage",
97
100
  # generation_class: "ChatGeneration",
98
- # contextable: :chat_user,
101
+ # contextual: :chat_user,
99
102
  # auto_save: false
100
103
  #
101
- def has_context(name = nil, class_name: nil, message_class: nil, generation_class: nil, auto_save: true, contextable: nil)
104
+ def has_context(name = nil, class_name: nil, message_class: nil, generation_class: nil, auto_save: true, contextual: nil)
102
105
  # Normalize name
103
106
  context_name = normalize_context_name(name)
104
107
 
@@ -111,7 +114,7 @@ module SolidAgent
111
114
  message_class: message_class || inferred_classes[:message],
112
115
  generation_class: generation_class || inferred_classes[:generation],
113
116
  auto_save: auto_save,
114
- contextable: contextable
117
+ contextual: contextual
115
118
  }
116
119
 
117
120
  # Store configuration
@@ -129,10 +132,10 @@ module SolidAgent
129
132
  around_generation :capture_and_persist_generation
130
133
  end
131
134
 
132
- # Add auto-context callback if contextable is not explicitly false
133
- if contextable != false
135
+ # Add auto-context callback if contextual is not explicitly false
136
+ if contextual != false
134
137
  after_prompt :"ensure_#{context_name}_exists"
135
- define_auto_context_method(context_name, contextable)
138
+ define_auto_context_method(context_name, contextual)
136
139
  end
137
140
  end
138
141
 
@@ -149,14 +152,19 @@ module SolidAgent
149
152
 
150
153
  def infer_class_names(context_name, explicit_class_name)
151
154
  if context_name == :context
155
+ # The default trio is configurable — SolidAgent.context_class and
156
+ # friends are what the shipped initializer tells hosts to set, so
157
+ # they have to be read here rather than hardcoded.
152
158
  {
153
- context: "AgentContext",
154
- message: "AgentMessage",
155
- generation: "AgentGeneration"
159
+ context: SolidAgent.context_class,
160
+ message: SolidAgent.message_class,
161
+ generation: SolidAgent.generation_class
156
162
  }
157
163
  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")
164
+ # If class_name is provided, infer message/generation from it.
165
+ # Strip at most one suffix: chaining delete_suffix would reduce
166
+ # "SessionContext" to "" and yield a bare "Message"/"Generation".
167
+ base = SolidAgent::ModelNaming.base_for(explicit_class_name)
160
168
  {
161
169
  context: explicit_class_name,
162
170
  message: "#{base}Message",
@@ -177,17 +185,17 @@ module SolidAgent
177
185
  attr_accessor context_name
178
186
  end
179
187
 
180
- def define_auto_context_method(context_name, contextable_key)
188
+ def define_auto_context_method(context_name, contextual_key)
181
189
  # Define ensure_{name}_exists method that auto-creates context if not present
182
190
  define_method("ensure_#{context_name}_exists") do
183
191
  return if send(context_name).present?
184
192
 
185
193
  config = self.class._context_configs[context_name]
186
- contextable_param = config[:contextable]
194
+ contextual_param = config[:contextual]
187
195
 
188
- if contextable_param.is_a?(Symbol)
196
+ if contextual_param.is_a?(Symbol)
189
197
  # Load or create with contextable from params
190
- contextable_value = params[contextable_param]
198
+ contextable_value = params[contextual_param]
191
199
  send("load_#{context_name}", contextable: contextable_value)
192
200
  else
193
201
  # Create anonymous context (no contextable)
@@ -282,7 +290,23 @@ module SolidAgent
282
290
  define_method("add_#{context_name}_message") do |role:, content:, **attributes|
283
291
  ctx = send(context_name)
284
292
  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)
293
+
294
+ # Build message attributes
295
+ message_attrs = { role: role, content: content, **attributes }
296
+
297
+ # Add provenance data if the message model supports it
298
+ # Check via the context's message association if available
299
+ begin
300
+ if ctx.messages.respond_to?(:build)
301
+ sample = ctx.messages.build
302
+ message_attrs[:provenance] = current_provenance if sample.respond_to?(:provenance=)
303
+ message_attrs[:content_checksum] = Digest::MD5.hexdigest(content.to_s) if sample.respond_to?(:content_checksum=)
304
+ end
305
+ rescue StandardError
306
+ # Ignore if we can't check - just create without extra fields
307
+ end
308
+
309
+ ctx.messages.create!(**message_attrs)
286
310
  end
287
311
 
288
312
  # Define add_{name}_user_message method
@@ -409,11 +433,144 @@ module SolidAgent
409
433
  send("#{primary_context_name}_summary")
410
434
  end
411
435
 
436
+ # ============================================
437
+ # Provenance & Checksums
438
+ # ============================================
439
+
440
+ # Generate checksum for current prompt configuration
441
+ #
442
+ # @return [String] MD5 hex digest
443
+ def prompt_checksum
444
+ data = {
445
+ instructions: prompt_options[:instructions],
446
+ model: prompt_options[:model],
447
+ temperature: prompt_options[:temperature],
448
+ tools: prompt_tool_roster.map { |tool| tool[:name] }.presence
449
+ }.compact
450
+ Digest::MD5.hexdigest(data.to_json)
451
+ end
452
+
453
+ # The tool schemas this generation actually offered the provider, as a
454
+ # compact roster.
455
+ #
456
+ # The full schemas are too heavy to persist on every generation, and
457
+ # the checksum above only proves the roster *changed* — it can't say
458
+ # what the agent could do. Recording names, descriptions and parameter
459
+ # keys makes the tool surface auditable straight from the generation
460
+ # records, without requiring telemetry to be switched on.
461
+ #
462
+ # Shape matches ActiveAgent's `prompt.input.tools` span attribute so a
463
+ # dashboard parses one format from both sources.
464
+ #
465
+ # @return [Array<Hash>] entries with :name, :description, :parameters
466
+ def prompt_tool_roster
467
+ Array(prompt_options[:tools]).filter_map do |tool|
468
+ next unless tool.respond_to?(:[])
469
+
470
+ name = tool[:name] || tool["name"]
471
+ next if name.blank?
472
+
473
+ parameters = tool[:parameters] || tool["parameters"] || tool[:input_schema] || tool["input_schema"]
474
+ properties = parameters.is_a?(Hash) ? (parameters[:properties] || parameters["properties"]) : nil
475
+
476
+ {
477
+ name: name.to_s,
478
+ description: (tool[:description] || tool["description"]).to_s.presence,
479
+ parameters: properties.is_a?(Hash) ? properties.keys.map(&:to_s) : []
480
+ }.compact
481
+ end
482
+ end
483
+
484
+ # Generate checksum for current context state
485
+ #
486
+ # @return [String, nil] MD5 hex digest or nil if no context
487
+ def context_checksum
488
+ return nil unless context
489
+ Digest::MD5.hexdigest({
490
+ context_id: context.id,
491
+ message_count: context.messages.size,
492
+ last_message_id: context.messages.last&.id
493
+ }.to_json)
494
+ end
495
+
496
+ # Generate provenance record for current agent state
497
+ #
498
+ # @return [Hash] Full provenance data for tracing
499
+ def current_provenance
500
+ {
501
+ agent_class: self.class.name,
502
+ agent_checksum: agent_checksum,
503
+ prompt_checksum: prompt_checksum,
504
+ context_checksum: context_checksum,
505
+ context_id: context&.id,
506
+ action_name: action_name,
507
+ trace_id: prompt_options[:trace_id],
508
+ timestamp: Time.now.iso8601,
509
+ manifest_fingerprint: manifest_fingerprint,
510
+ tools: prompt_tool_roster.presence
511
+ }.compact
512
+ end
513
+
514
+ # Generate checksum for the agent class configuration
515
+ #
516
+ # Class-level options are read defensively so provenance never raises
517
+ # inside the (rescued) persistence path and silently drops generations.
518
+ #
519
+ # @return [String] MD5 hex digest
520
+ def agent_checksum
521
+ data = {
522
+ class: self.class.name,
523
+ prompt_options: class_options(:prompt_options),
524
+ embed_options: class_options(:embed_options)
525
+ }.compact
526
+ Digest::MD5.hexdigest(data.to_json)
527
+ end
528
+
529
+ # Get manifest fingerprint if agent was built from manifest
530
+ #
531
+ # @return [String, nil] Fingerprint or nil
532
+ def manifest_fingerprint
533
+ return nil unless self.class.respond_to?(:_manifest) && self.class._manifest
534
+ self.class._manifest.fingerprint
535
+ end
536
+
412
537
  private
413
538
 
414
- # After prompt callback - persists the rendered prompt message to context
539
+ # Class-level option hash for checksums, or nil when unavailable
540
+ def class_options(reader)
541
+ return nil unless self.class.respond_to?(reader)
542
+
543
+ self.class.public_send(reader)&.except(:access_token, :api_key)
544
+ end
545
+
546
+ # Marks this generation as resuming one that paused for user input, for a
547
+ # host that replays a stored conversation itself. Private, like
548
+ # #resuming_generation?, because every public method on an agent is one
549
+ # of its actions.
550
+ #
551
+ # @example
552
+ # before_generation { self.resuming_generation = params[:checkpoint].present? }
553
+ attr_writer :resuming_generation
554
+
555
+ # Returns whether this generation resumes one that paused for user input:
556
+ # true when `self.resuming_generation = true` was set, or when the agent
557
+ # defines a public `resuming?` (the framework's resume flag) that returns
558
+ # true. A resuming generation does not persist its prompt.
559
+ #
560
+ # @return [Boolean]
561
+ def resuming_generation?
562
+ return true if @resuming_generation
563
+ return false unless respond_to?(:resuming?)
564
+
565
+ resuming? ? true : false
566
+ end
567
+
568
+ # After prompt callback - persists the rendered prompt message to context.
569
+ # Skipped for a resumed generation, because the generation that paused
570
+ # already persisted the user turn and the resumed prompt replays it.
415
571
  def persist_prompt_to_context
416
572
  return unless context
573
+ return if resuming_generation?
417
574
 
418
575
  if prompt_options[:messages].present?
419
576
  rendered_message = prompt_options[:messages].last
@@ -429,14 +586,29 @@ module SolidAgent
429
586
  generation_response
430
587
  end
431
588
 
432
- # Persists the generation response to context
589
+ # Persists the generation response to context. A response paused for user
590
+ # input is skipped, because its last message is an unfinished turn. The
591
+ # response of the generation that resumes it repeats the restored tool
592
+ # results, so they are persisted with the final answer.
433
593
  def persist_generation_to_context
434
594
  return unless context && generation_response
435
595
 
596
+ if generation_paused?
597
+ Rails.logger.info "[SolidAgent] Skipping persistence - generation is awaiting user input"
598
+ return
599
+ end
600
+
601
+ persist_tool_messages_to_context
602
+
436
603
  begin
437
604
  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}"
605
+ # Include provenance if the context supports it
606
+ if context.respond_to?(:record_generation_with_provenance!)
607
+ context.record_generation_with_provenance!(generation_response, current_provenance)
608
+ else
609
+ context.record_generation!(generation_response)
610
+ end
611
+ Rails.logger.info "[SolidAgent] Persisted generation to context #{context.id} (#{prompt_checksum[0..7]})"
440
612
  else
441
613
  Rails.logger.warn "[SolidAgent] Skipping persistence - no message content in response"
442
614
  end
@@ -445,5 +617,100 @@ module SolidAgent
445
617
  Rails.logger.error e.backtrace.first(5).join("\n")
446
618
  end
447
619
  end
620
+
621
+ def generation_paused?
622
+ return false unless generation_response.respond_to?(:awaiting_input?)
623
+
624
+ generation_response.awaiting_input? ? true : false
625
+ end
626
+
627
+ # Overridable enrichment hook for tool persistence. Executors that run
628
+ # tools server-side (a platform's execution service, a job) can
629
+ # override this to return their own invocation records — an array of
630
+ # hashes with symbol keys :tool_call_id, :name, :arguments and
631
+ # :duration_ms (all optional) — so persisted tool messages carry the
632
+ # call's arguments and timing, which provider response messages don't
633
+ # include. Records are matched to response tool messages by
634
+ # tool_call_id when both sides have one, otherwise by position.
635
+ def tool_invocations
636
+ []
637
+ end
638
+
639
+ # Persists the tool/MCP interaction stream (tool result messages from
640
+ # the response's message stack) to the context, so conversations show
641
+ # the full agent <-> tool exchange, not just the final assistant text.
642
+ #
643
+ # Requires the context model to expose add_tool_message (the install
644
+ # generator's AgentContext does); contexts without it are skipped.
645
+ # Messages are deduped by tool_call_id. A response's stack repeats
646
+ # earlier turns (a multi-turn conversation's history, a resumed
647
+ # generation's restored conversation), so a call already on the context
648
+ # or earlier in the same stack is skipped.
649
+ def persist_tool_messages_to_context
650
+ return unless context.respond_to?(:add_tool_message)
651
+ return unless generation_response.respond_to?(:messages)
652
+
653
+ tool_index = -1
654
+ seen_tool_call_ids = Set.new
655
+ Array(generation_response.messages).each do |message|
656
+ next unless message.respond_to?(:role) && message.role.to_s == "tool"
657
+
658
+ tool_index += 1
659
+ tool_call_id = message.respond_to?(:tool_call_id) ? message.tool_call_id : nil
660
+ if tool_call_id.present?
661
+ next unless seen_tool_call_ids.add?(tool_call_id.to_s)
662
+ next if tool_message_persisted?(tool_call_id)
663
+ end
664
+
665
+ invocation = tool_invocation_for(tool_call_id, tool_index)
666
+ # Provider tool messages often carry no name (Ollama's don't); the
667
+ # executor's invocation record is the fallback.
668
+ name = (message.name if message.respond_to?(:name))
669
+ name = invocation[:name] if !name.present? && invocation
670
+
671
+ attributes = {
672
+ tool_call_id: tool_call_id,
673
+ tool_name: name,
674
+ result: (message.content if message.respond_to?(:content))
675
+ }
676
+ if invocation && tool_message_details_supported?
677
+ attributes[:arguments] = invocation[:arguments]
678
+ attributes[:duration_ms] = invocation[:duration_ms]
679
+ end
680
+ context.add_tool_message(**attributes)
681
+ end
682
+ rescue => e
683
+ Rails.logger.error "[SolidAgent] Failed to persist tool messages: #{e.message}"
684
+ end
685
+
686
+ def tool_message_persisted?(tool_call_id)
687
+ return false unless context.respond_to?(:messages)
688
+
689
+ scope = context.messages
690
+ scope.respond_to?(:exists?) && scope.exists?(role: "tool", tool_call_id: tool_call_id)
691
+ end
692
+
693
+ # Finds the executor invocation record for a response tool message —
694
+ # by tool_call_id when the record carries one, else by position among
695
+ # the response's tool messages.
696
+ def tool_invocation_for(tool_call_id, index)
697
+ invocations = Array(tool_invocations)
698
+ return nil if invocations.empty?
699
+
700
+ if tool_call_id.present?
701
+ match = invocations.find { |inv| inv[:tool_call_id] && inv[:tool_call_id].to_s == tool_call_id.to_s }
702
+ return match if match
703
+ end
704
+ invocations[index]
705
+ end
706
+
707
+ # Whether the context's add_tool_message accepts the arguments:/
708
+ # duration_ms: enrichment keywords (older generated models don't).
709
+ def tool_message_details_supported?
710
+ parameters = context.method(:add_tool_message).parameters
711
+ parameters.any? { |type, param_name| type == :keyrest || ([ :key, :keyreq ].include?(type) && param_name == :arguments) }
712
+ rescue ::NameError
713
+ false
714
+ end
448
715
  end
449
716
  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