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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +99 -0
- data/LICENSE +21 -0
- data/README.md +231 -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 +299 -32
- 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
- data/solid_agent.gemspec +41 -0
- 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 (
|
|
12
|
+
# @example Basic usage with auto-context (contextual inferred from params)
|
|
10
13
|
# class WritingAssistantAgent < ApplicationAgent
|
|
11
14
|
# include SolidAgent::HasContext
|
|
12
|
-
# has_context
|
|
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,
|
|
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 (
|
|
33
|
+
# @example Manual context management (contextual: false)
|
|
31
34
|
# class ResearchAgent < ApplicationAgent
|
|
32
35
|
# include SolidAgent::HasContext
|
|
33
|
-
# has_context :research_session,
|
|
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
|
|
44
|
+
# @example Multiple contexts with different contextual params
|
|
42
45
|
# class MultiModalAgent < ApplicationAgent
|
|
43
46
|
# include SolidAgent::HasContext
|
|
44
|
-
# has_context :conversation,
|
|
45
|
-
# has_context :analysis,
|
|
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
|
|
83
|
-
# - Symbol: Auto-load/create context using params[
|
|
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,
|
|
91
|
+
# has_context :conversation, contextual: :user
|
|
89
92
|
#
|
|
90
93
|
# @example Manual context management
|
|
91
|
-
# has_context :session,
|
|
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
|
-
#
|
|
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,
|
|
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
|
-
|
|
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
|
|
133
|
-
if
|
|
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,
|
|
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:
|
|
154
|
-
message:
|
|
155
|
-
generation:
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
194
|
+
contextual_param = config[:contextual]
|
|
187
195
|
|
|
188
|
-
if
|
|
196
|
+
if contextual_param.is_a?(Symbol)
|
|
189
197
|
# Load or create with contextable from params
|
|
190
|
-
contextable_value = params[
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
439
|
-
|
|
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
|