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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -0
- data/LICENSE +21 -0
- data/README.md +209 -18
- data/Rakefile +22 -2
- data/docs/agent-md-spec.md +803 -0
- data/docs/parser-design.md +1369 -0
- data/docs/registry-api.md +882 -0
- data/examples/README.md +60 -0
- data/examples/manifests/changelog_writer.agent.md +81 -0
- data/examples/manifests/usage.rb +96 -0
- data/examples/memory_handoff/app/agents/researcher_agent.rb +36 -0
- data/examples/memory_handoff/app/agents/writer_agent.rb +41 -0
- data/examples/memory_handoff/usage.rb +45 -0
- data/examples/persistent_conversation/app/agents/support_agent.rb +59 -0
- data/examples/persistent_conversation/app/controllers/support_conversations_controller.rb +24 -0
- data/examples/persistent_conversation/app/views/agents/support/instructions.md.erb +8 -0
- data/examples/persistent_conversation/usage.rb +51 -0
- data/examples/reasoning/app/agents/analysis_agent.rb +52 -0
- data/examples/reasoning/usage.rb +52 -0
- data/examples/run_tracking/app/agents/report_agent.rb +30 -0
- data/examples/run_tracking/app/controllers/agent_runs_controller.rb +43 -0
- data/examples/run_tracking/app/jobs/document_analysis_job.rb +17 -0
- data/examples/run_tracking/app/services/document_analysis_run.rb +68 -0
- data/examples/run_tracking/usage.rb +85 -0
- data/examples/tool_streaming/app/agents/browser_agent.rb +65 -0
- data/examples/tool_streaming/app/channels/tool_status_channel.rb +24 -0
- data/examples/tool_streaming/app/views/browser_agent/tools/fetch_url.json.erb +15 -0
- data/examples/tool_streaming/usage.rb +47 -0
- data/lib/generators/solid_agent/agent/agent_generator.rb +2 -2
- data/lib/generators/solid_agent/agent/templates/agent.rb.erb +3 -3
- data/lib/generators/solid_agent/context/templates/context_model.rb.erb +50 -16
- data/lib/generators/solid_agent/context/templates/create_generations.rb.erb +8 -0
- data/lib/generators/solid_agent/context/templates/create_messages.rb.erb +4 -0
- data/lib/generators/solid_agent/context/templates/generation_model.rb.erb +11 -0
- data/lib/generators/solid_agent/install/install_generator.rb +9 -0
- data/lib/generators/solid_agent/install/templates/agent_context.rb.erb +60 -17
- data/lib/generators/solid_agent/install/templates/agent_generation.rb.erb +23 -6
- data/lib/generators/solid_agent/install/templates/agent_memory.rb.erb +51 -0
- data/lib/generators/solid_agent/install/templates/agent_memory_entry.rb.erb +12 -0
- data/lib/generators/solid_agent/install/templates/agent_run.rb.erb +122 -0
- data/lib/generators/solid_agent/install/templates/create_agent_generations.rb.erb +13 -0
- data/lib/generators/solid_agent/install/templates/create_agent_memories.rb.erb +35 -0
- data/lib/generators/solid_agent/install/templates/create_agent_messages.rb.erb +5 -0
- data/lib/generators/solid_agent/install/templates/create_agent_runs.rb.erb +46 -0
- data/lib/generators/solid_agent/manifest/manifest_generator.rb +209 -0
- data/lib/generators/solid_agent/manifest/templates/agent.md.erb +39 -0
- data/lib/generators/solid_agent/manifest/templates/prompt.erb +13 -0
- data/lib/generators/solid_agent/reasons/reasons_generator.rb +83 -0
- data/lib/generators/solid_agent/reasons/templates/add_reasoning_columns.rb.erb +12 -0
- data/lib/solid_agent/agent_manifest/agent_builder.rb +323 -0
- data/lib/solid_agent/agent_manifest/errors.rb +26 -0
- data/lib/solid_agent/agent_manifest/exporter_registry.rb +117 -0
- data/lib/solid_agent/agent_manifest/exporters/agent_md_exporter.rb +115 -0
- data/lib/solid_agent/agent_manifest/exporters/base_exporter.rb +152 -0
- data/lib/solid_agent/agent_manifest/exporters/crewai_exporter.rb +125 -0
- data/lib/solid_agent/agent_manifest/exporters/dotprompt_exporter.rb +92 -0
- data/lib/solid_agent/agent_manifest/input_schema.rb +154 -0
- data/lib/solid_agent/agent_manifest/manifest.rb +306 -0
- data/lib/solid_agent/agent_manifest/parser_registry.rb +185 -0
- data/lib/solid_agent/agent_manifest/parsers/agent_md_parser.rb +87 -0
- data/lib/solid_agent/agent_manifest/parsers/base_parser.rb +223 -0
- data/lib/solid_agent/agent_manifest/parsers/crewai_parser.rb +201 -0
- data/lib/solid_agent/agent_manifest/parsers/dotprompt_parser.rb +122 -0
- data/lib/solid_agent/agent_manifest/parsers/github_prompt_parser.rb +143 -0
- data/lib/solid_agent/agent_manifest/picoschema.rb +254 -0
- data/lib/solid_agent/agent_manifest/registry/auth.rb +103 -0
- data/lib/solid_agent/agent_manifest/registry/client.rb +384 -0
- data/lib/solid_agent/agent_manifest/resource.rb +103 -0
- data/lib/solid_agent/agent_manifest/tool.rb +160 -0
- data/lib/solid_agent/agent_manifest/validator.rb +368 -0
- data/lib/solid_agent/agent_manifest.rb +381 -0
- data/lib/solid_agent/has_context.rb +251 -30
- data/lib/solid_agent/has_memory.rb +136 -0
- data/lib/solid_agent/has_reasons.rb +230 -0
- data/lib/solid_agent/model_naming.rb +42 -0
- data/lib/solid_agent/model_pricing.rb +93 -0
- data/lib/solid_agent/reasonable/reason.rb +205 -0
- data/lib/solid_agent/reasonable.rb +181 -0
- data/lib/solid_agent/records/agent.rb +520 -0
- data/lib/solid_agent/records/agent_run.rb +520 -0
- data/lib/solid_agent/records/agent_template.rb +142 -0
- data/lib/solid_agent/records/agent_version.rb +141 -0
- data/lib/solid_agent/records/ownable.rb +130 -0
- data/lib/solid_agent/records.rb +152 -0
- data/lib/solid_agent/run_fingerprint.rb +51 -0
- data/lib/solid_agent/tool_cache.rb +91 -0
- data/lib/solid_agent/version.rb +1 -1
- data/lib/solid_agent.rb +70 -3
- 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 (
|
|
11
|
+
# @example Basic usage with auto-context (contextual inferred from params)
|
|
10
12
|
# class WritingAssistantAgent < ApplicationAgent
|
|
11
13
|
# include SolidAgent::HasContext
|
|
12
|
-
# has_context
|
|
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,
|
|
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 (
|
|
32
|
+
# @example Manual context management (contextual: false)
|
|
31
33
|
# class ResearchAgent < ApplicationAgent
|
|
32
34
|
# include SolidAgent::HasContext
|
|
33
|
-
# has_context :research_session,
|
|
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
|
|
43
|
+
# @example Multiple contexts with different contextual params
|
|
42
44
|
# class MultiModalAgent < ApplicationAgent
|
|
43
45
|
# include SolidAgent::HasContext
|
|
44
|
-
# has_context :conversation,
|
|
45
|
-
# has_context :analysis,
|
|
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
|
|
83
|
-
# - Symbol: Auto-load/create context using params[
|
|
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,
|
|
90
|
+
# has_context :conversation, contextual: :user
|
|
89
91
|
#
|
|
90
92
|
# @example Manual context management
|
|
91
|
-
# has_context :session,
|
|
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
|
-
#
|
|
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,
|
|
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
|
-
|
|
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
|
|
133
|
-
if
|
|
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,
|
|
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:
|
|
154
|
-
message:
|
|
155
|
-
generation:
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
193
|
+
contextual_param = config[:contextual]
|
|
187
194
|
|
|
188
|
-
if
|
|
195
|
+
if contextual_param.is_a?(Symbol)
|
|
189
196
|
# Load or create with contextable from params
|
|
190
|
-
contextable_value = params[
|
|
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
|
-
|
|
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
|
|
439
|
-
|
|
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
|