phronomy 0.2.2 → 0.4.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 +127 -30
- data/README.md +106 -122
- data/lib/phronomy/agent/base.rb +135 -57
- data/lib/phronomy/agent/checkpoint.rb +53 -0
- data/lib/phronomy/agent/orchestrator.rb +119 -0
- data/lib/phronomy/agent/react_agent.rb +18 -28
- data/lib/phronomy/agent/shared_state.rb +303 -0
- data/lib/phronomy/agent/suspend_signal.rb +35 -0
- data/lib/phronomy/agent/team_coordinator.rb +285 -0
- data/lib/phronomy/agent.rb +2 -1
- data/lib/phronomy/configuration.rb +0 -24
- data/lib/phronomy/generator_verifier.rb +250 -0
- data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +10 -27
- data/lib/phronomy/railtie.rb +0 -6
- data/lib/phronomy/ruby_llm_patches.rb +20 -0
- data/lib/phronomy/tool/mcp_tool.rb +23 -26
- data/lib/phronomy/tracing/langfuse_tracer.rb +3 -6
- data/lib/phronomy/vector_store/redis_search.rb +4 -4
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +4 -7
- data/lib/phronomy/workflow_runner.rb +42 -30
- data/lib/phronomy.rb +18 -0
- data/scripts/check_readme_ruby.rb +38 -0
- metadata +12 -38
- data/docs/trustworthy_ai_enhancements.md +0 -332
- data/lib/phronomy/active_record/acts_as.rb +0 -48
- data/lib/phronomy/active_record/checkpoint.rb +0 -20
- data/lib/phronomy/active_record/extensions.rb +0 -14
- data/lib/phronomy/active_record/message.rb +0 -20
- data/lib/phronomy/actor.rb +0 -68
- data/lib/phronomy/memory/compression/base.rb +0 -37
- data/lib/phronomy/memory/compression/summary.rb +0 -107
- data/lib/phronomy/memory/compression/tool_output_pruner.rb +0 -67
- data/lib/phronomy/memory/compression.rb +0 -11
- data/lib/phronomy/memory/conversation_manager.rb +0 -213
- data/lib/phronomy/memory/retrieval/base.rb +0 -22
- data/lib/phronomy/memory/retrieval/composite.rb +0 -76
- data/lib/phronomy/memory/retrieval/recent.rb +0 -35
- data/lib/phronomy/memory/retrieval/semantic.rb +0 -114
- data/lib/phronomy/memory/retrieval.rb +0 -12
- data/lib/phronomy/memory/storage/active_record.rb +0 -248
- data/lib/phronomy/memory/storage/base.rb +0 -155
- data/lib/phronomy/memory/storage/in_memory.rb +0 -152
- data/lib/phronomy/memory/storage.rb +0 -11
- data/lib/phronomy/memory.rb +0 -21
- data/lib/phronomy/rails/agent_job.rb +0 -75
- data/lib/phronomy/state_store/active_record.rb +0 -76
- data/lib/phronomy/state_store/base.rb +0 -112
- data/lib/phronomy/state_store/encryptor/active_support.rb +0 -49
- data/lib/phronomy/state_store/encryptor/base.rb +0 -34
- data/lib/phronomy/state_store/encryptor.rb +0 -16
- data/lib/phronomy/state_store/file.rb +0 -85
- data/lib/phronomy/state_store/in_memory.rb +0 -53
- data/lib/phronomy/state_store/redis.rb +0 -70
- data/lib/phronomy/state_store.rb +0 -9
- data/lib/phronomy/thread_actor_registry.rb +0 -85
- data/lib/phronomy/trust_pipeline.rb +0 -264
|
@@ -1,76 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Memory
|
|
5
|
-
module Retrieval
|
|
6
|
-
# Retrieval strategy that merges results from multiple child retrieval strategies.
|
|
7
|
-
#
|
|
8
|
-
# Each child is given a weight that controls what fraction of a token budget
|
|
9
|
-
# it should consume. Results are deduplicated (by role + content) and
|
|
10
|
-
# system messages are sorted to the front.
|
|
11
|
-
#
|
|
12
|
-
# @example
|
|
13
|
-
# composite = Phronomy::Memory::Retrieval::Composite.new(
|
|
14
|
-
# sources: [
|
|
15
|
-
# { retrieval: Phronomy::Memory::Retrieval::Recent.new(k: 5), weight: 0.4 },
|
|
16
|
-
# { retrieval: Phronomy::Memory::Retrieval::Semantic.new(...), weight: 0.6 }
|
|
17
|
-
# ]
|
|
18
|
-
# )
|
|
19
|
-
# manager = Phronomy::Memory::ConversationManager.new(
|
|
20
|
-
# storage: Phronomy::Memory::Storage::InMemory.new,
|
|
21
|
-
# retrieval: composite
|
|
22
|
-
# )
|
|
23
|
-
class Composite < Base
|
|
24
|
-
# @param sources [Array<Hash>] each entry: { retrieval:, weight: } (weight default 1.0)
|
|
25
|
-
def initialize(sources:)
|
|
26
|
-
@sources = sources.map { |s| {retrieval: s[:retrieval], weight: (s[:weight] || 1.0).to_f} }
|
|
27
|
-
end
|
|
28
|
-
|
|
29
|
-
# Merge results from all child retrievals, deduplicating by role+content.
|
|
30
|
-
# System messages are sorted to the front; others preserve insertion order.
|
|
31
|
-
#
|
|
32
|
-
# @param messages [Array] full chronological history
|
|
33
|
-
# @param query [String, nil] forwarded to each child retrieval
|
|
34
|
-
# @param thread_id [String, nil] forwarded to each child retrieval
|
|
35
|
-
# @return [Array]
|
|
36
|
-
def select(messages, query: nil, thread_id: nil)
|
|
37
|
-
all_messages = []
|
|
38
|
-
seen = {}
|
|
39
|
-
|
|
40
|
-
@sources.each do |source|
|
|
41
|
-
source[:retrieval].select(messages, query: query, thread_id: thread_id).each do |msg|
|
|
42
|
-
key = "#{msg.role}:#{msg.content}"
|
|
43
|
-
next if seen[key]
|
|
44
|
-
|
|
45
|
-
seen[key] = true
|
|
46
|
-
all_messages << msg
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
systems = all_messages.select { |m| m.role.to_sym == :system }
|
|
51
|
-
others = all_messages.reject { |m| m.role.to_sym == :system }
|
|
52
|
-
systems + others
|
|
53
|
-
end
|
|
54
|
-
|
|
55
|
-
# Forward index calls to all child retrievals that support it.
|
|
56
|
-
#
|
|
57
|
-
# @param thread_id [String]
|
|
58
|
-
# @param messages [Array]
|
|
59
|
-
def index(thread_id:, messages:)
|
|
60
|
-
@sources.each do |source|
|
|
61
|
-
source[:retrieval].index(thread_id: thread_id, messages: messages) if source[:retrieval].respond_to?(:index)
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# Forward clear_index to all child retrievals that support it.
|
|
66
|
-
#
|
|
67
|
-
# @param thread_id [String]
|
|
68
|
-
def clear_index(thread_id:)
|
|
69
|
-
@sources.each do |source|
|
|
70
|
-
source[:retrieval].clear_index(thread_id: thread_id) if source[:retrieval].respond_to?(:clear_index)
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
end
|
|
@@ -1,35 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Memory
|
|
5
|
-
module Retrieval
|
|
6
|
-
# Retrieval strategy that returns the most recent k turns (k*2 messages).
|
|
7
|
-
#
|
|
8
|
-
# This is the simplest and most predictable strategy: older messages are
|
|
9
|
-
# discarded without compression.
|
|
10
|
-
#
|
|
11
|
-
# @example
|
|
12
|
-
# retrieval = Phronomy::Memory::Retrieval::Recent.new(k: 10)
|
|
13
|
-
# manager = Phronomy::Memory::ConversationManager.new(
|
|
14
|
-
# storage: storage,
|
|
15
|
-
# retrieval: retrieval
|
|
16
|
-
# )
|
|
17
|
-
class Recent < Base
|
|
18
|
-
# @param k [Integer] number of turns to retain (each turn = 1 user + 1 assistant message)
|
|
19
|
-
def initialize(k: 10)
|
|
20
|
-
@k = k
|
|
21
|
-
end
|
|
22
|
-
|
|
23
|
-
# Returns the last k*2 messages from the history.
|
|
24
|
-
#
|
|
25
|
-
# @param messages [Array] full chronological history
|
|
26
|
-
# @param query [String, nil] unused for recency-based retrieval
|
|
27
|
-
# @param thread_id [String, nil] unused for recency-based retrieval
|
|
28
|
-
# @return [Array]
|
|
29
|
-
def select(messages, query: nil, thread_id: nil)
|
|
30
|
-
messages.last(@k * 2)
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
|
35
|
-
end
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Memory
|
|
5
|
-
module Retrieval
|
|
6
|
-
# Retrieval strategy that returns the k semantically closest messages to the query.
|
|
7
|
-
#
|
|
8
|
-
# Messages are indexed in a VectorStore on save. On retrieval, the query is
|
|
9
|
-
# embedded and the k nearest messages are returned. Falls back to the k most
|
|
10
|
-
# recent messages when no query is provided.
|
|
11
|
-
#
|
|
12
|
-
# @example
|
|
13
|
-
# retrieval = Phronomy::Memory::Retrieval::Semantic.new(
|
|
14
|
-
# embeddings: Phronomy::Embeddings::RubyLLMEmbeddings.new(model: "text-embedding-3-small"),
|
|
15
|
-
# k: 10
|
|
16
|
-
# )
|
|
17
|
-
class Semantic < Base
|
|
18
|
-
# @param store [Phronomy::VectorStore::Base] vector store (default InMemory)
|
|
19
|
-
# @param embeddings [Phronomy::Embeddings::Base] embeddings adapter
|
|
20
|
-
# @param k [Integer] number of messages to retrieve
|
|
21
|
-
# @param max_index_size [Integer, nil] maximum number of entries kept in the
|
|
22
|
-
# local index. When nil, the index grows unboundedly. When exceeded, the
|
|
23
|
-
# oldest entries (by insertion order) are evicted.
|
|
24
|
-
def initialize(embeddings:, store: nil, k: 10, max_index_size: nil)
|
|
25
|
-
@store = store || Phronomy::VectorStore::InMemory.new
|
|
26
|
-
@embeddings = embeddings
|
|
27
|
-
@k = k
|
|
28
|
-
@index = {} # id => message (insertion-ordered via Ruby Hash)
|
|
29
|
-
@counter = 0
|
|
30
|
-
@max_index_size = max_index_size
|
|
31
|
-
@actor = Phronomy::Actor.new
|
|
32
|
-
@indexed_object_ids = {} # thread_id => { object_id => true }
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
# Index a new batch of messages so they are searchable on future #select calls.
|
|
36
|
-
# Called by ConversationManager#save.
|
|
37
|
-
#
|
|
38
|
-
# Messages are deduplicated by object identity: if a message object has already
|
|
39
|
-
# been indexed for the given thread_id, it is skipped (no duplicate embed call).
|
|
40
|
-
#
|
|
41
|
-
# @param thread_id [String]
|
|
42
|
-
# @param messages [Array]
|
|
43
|
-
def index(thread_id:, messages:)
|
|
44
|
-
messages.each do |msg|
|
|
45
|
-
# Fast path: skip already-indexed messages without calling embed.
|
|
46
|
-
already_indexed = @actor.call do
|
|
47
|
-
(@indexed_object_ids[thread_id] ||= {})[msg.object_id]
|
|
48
|
-
end
|
|
49
|
-
next if already_indexed
|
|
50
|
-
|
|
51
|
-
embedding = @embeddings.embed(msg.content.to_s)
|
|
52
|
-
@actor.call do
|
|
53
|
-
# Re-check inside Actor to handle concurrent callers for the same thread.
|
|
54
|
-
indexed = (@indexed_object_ids[thread_id] ||= {})
|
|
55
|
-
next if indexed[msg.object_id]
|
|
56
|
-
|
|
57
|
-
id = "#{thread_id}:#{@counter}"
|
|
58
|
-
@counter += 1
|
|
59
|
-
@store.add(id: id, embedding: embedding, metadata: {thread_id: thread_id, message: msg})
|
|
60
|
-
@index[id] = msg
|
|
61
|
-
indexed[msg.object_id] = true
|
|
62
|
-
evict_oldest! if @max_index_size && @index.size > @max_index_size
|
|
63
|
-
end
|
|
64
|
-
end
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
# Clear indexed messages for a thread.
|
|
68
|
-
#
|
|
69
|
-
# @param thread_id [String]
|
|
70
|
-
def clear_index(thread_id:)
|
|
71
|
-
@actor.call do
|
|
72
|
-
ids = @index.keys.select { |id| id.start_with?("#{thread_id}:") }
|
|
73
|
-
ids.each do |id|
|
|
74
|
-
@index.delete(id)
|
|
75
|
-
@store.remove(id: id)
|
|
76
|
-
end
|
|
77
|
-
@indexed_object_ids.delete(thread_id)
|
|
78
|
-
end
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
# Return semantically relevant messages, or recent messages when query is nil.
|
|
82
|
-
#
|
|
83
|
-
# @param messages [Array] full history (used as fallback when query is nil)
|
|
84
|
-
# @param query [String, nil] current user input for semantic search
|
|
85
|
-
# @param thread_id [String, nil] when provided, results are filtered to this thread
|
|
86
|
-
# @return [Array]
|
|
87
|
-
def select(messages, query: nil, thread_id: nil)
|
|
88
|
-
if query && !query.strip.empty?
|
|
89
|
-
query_embedding = @embeddings.embed(query)
|
|
90
|
-
results = @actor.call { @store.search(query_embedding: query_embedding, k: @k * 3) }
|
|
91
|
-
results
|
|
92
|
-
.select { |r| thread_id.nil? || r[:metadata][:thread_id] == thread_id }
|
|
93
|
-
.first(@k)
|
|
94
|
-
.map { |r| r[:metadata][:message] }
|
|
95
|
-
else
|
|
96
|
-
messages.last(@k)
|
|
97
|
-
end
|
|
98
|
-
end
|
|
99
|
-
|
|
100
|
-
private
|
|
101
|
-
|
|
102
|
-
# Evicts the oldest index entry to enforce max_index_size.
|
|
103
|
-
# Must be called inside the Actor.
|
|
104
|
-
def evict_oldest!
|
|
105
|
-
oldest_id = @index.keys.first
|
|
106
|
-
return unless oldest_id
|
|
107
|
-
|
|
108
|
-
@index.delete(oldest_id)
|
|
109
|
-
@store.remove(id: oldest_id)
|
|
110
|
-
end
|
|
111
|
-
end
|
|
112
|
-
end
|
|
113
|
-
end
|
|
114
|
-
end
|
|
@@ -1,12 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Memory
|
|
5
|
-
# Retrieval is the selection axis of conversation management.
|
|
6
|
-
# Implementations decide which messages from a full history to return
|
|
7
|
-
# given a query and a maximum message count or token limit.
|
|
8
|
-
# Token budgeting is NOT their responsibility — that belongs to Context::Assembler.
|
|
9
|
-
module Retrieval
|
|
10
|
-
end
|
|
11
|
-
end
|
|
12
|
-
end
|
|
@@ -1,248 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
|
|
5
|
-
module Phronomy
|
|
6
|
-
module Memory
|
|
7
|
-
module Storage
|
|
8
|
-
# ActiveRecord-backed storage for conversation messages.
|
|
9
|
-
# Persists messages to a relational database via user-supplied AR model classes.
|
|
10
|
-
#
|
|
11
|
-
# The message model_class must respond to:
|
|
12
|
-
# .where(thread_id:).order(:created_at) — returns a collection of records
|
|
13
|
-
# .where(thread_id:).delete_all
|
|
14
|
-
# .create!(thread_id:, role:, content:, tool_calls_json:, model_id:)
|
|
15
|
-
# Each record must expose: #role, #content, #tool_calls_json, #model_id
|
|
16
|
-
#
|
|
17
|
-
# The raw_model_class (optional) must respond to:
|
|
18
|
-
# .where(thread_id:).order(:seq) — returns records in seq order
|
|
19
|
-
# .where(thread_id:).delete_all
|
|
20
|
-
# .create!(thread_id:, seq:, role:, content:, tool_calls_json:, model_id:)
|
|
21
|
-
# Each record must expose: #seq, #role, #content, #tool_calls_json, #model_id
|
|
22
|
-
#
|
|
23
|
-
# The compaction_model_class (optional) must respond to:
|
|
24
|
-
# .where(thread_id:).order(:start_seq)
|
|
25
|
-
# .where(thread_id:).delete_all
|
|
26
|
-
# .create!(thread_id:, start_seq:, end_seq:, summary_text:)
|
|
27
|
-
# Each record must expose: #start_seq, #end_seq, #summary_text
|
|
28
|
-
#
|
|
29
|
-
# When raw_model_class or compaction_model_class are nil, the corresponding
|
|
30
|
-
# operations raise NotImplementedError — use InMemory storage if you do not
|
|
31
|
-
# need full raw/compaction persistence.
|
|
32
|
-
#
|
|
33
|
-
# @example
|
|
34
|
-
# storage = Phronomy::Memory::Storage::ActiveRecord.new(
|
|
35
|
-
# model_class: PhronomyMessage,
|
|
36
|
-
# raw_model_class: PhronomyRawMessage,
|
|
37
|
-
# compaction_model_class: PhronomyCompaction
|
|
38
|
-
# )
|
|
39
|
-
# manager = Phronomy::Memory::ConversationManager.new(storage: storage, ...)
|
|
40
|
-
# Internal value object representing a loaded message record.
|
|
41
|
-
MessageStruct = Data.define(:role, :content, :tool_calls, :model_id)
|
|
42
|
-
private_constant :MessageStruct
|
|
43
|
-
|
|
44
|
-
class ActiveRecord < Base
|
|
45
|
-
# @param model_class [Class] AR model for the legacy load/save interface
|
|
46
|
-
# @param raw_model_class [Class, nil] AR model for raw message storage
|
|
47
|
-
# @param compaction_model_class [Class, nil] AR model for compaction records
|
|
48
|
-
def initialize(model_class:, raw_model_class: nil, compaction_model_class: nil)
|
|
49
|
-
@model_class = model_class
|
|
50
|
-
@raw_model_class = raw_model_class
|
|
51
|
-
@compaction_model_class = compaction_model_class
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
# -----------------------------------------------------------------------
|
|
55
|
-
# Legacy interface
|
|
56
|
-
# -----------------------------------------------------------------------
|
|
57
|
-
|
|
58
|
-
# Load all messages for a thread, ordered by creation time.
|
|
59
|
-
#
|
|
60
|
-
# @param thread_id [String]
|
|
61
|
-
# @return [Array<MessageStruct>]
|
|
62
|
-
def load(thread_id:)
|
|
63
|
-
records = @model_class.where(thread_id: thread_id).order(:created_at).to_a
|
|
64
|
-
records.map { |r| to_message_struct(r) }
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
# Replace all stored messages for a thread.
|
|
68
|
-
#
|
|
69
|
-
# @param thread_id [String]
|
|
70
|
-
# @param messages [Array]
|
|
71
|
-
def save(thread_id:, messages:)
|
|
72
|
-
@model_class.transaction do
|
|
73
|
-
@model_class.where(thread_id: thread_id).delete_all
|
|
74
|
-
messages.each do |msg|
|
|
75
|
-
@model_class.create!(
|
|
76
|
-
thread_id: thread_id,
|
|
77
|
-
role: msg.role.to_s,
|
|
78
|
-
content: msg.content,
|
|
79
|
-
tool_calls_json: serialize_tool_calls(msg),
|
|
80
|
-
model_id: (msg.model_id if msg.respond_to?(:model_id))
|
|
81
|
-
)
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
|
|
86
|
-
# @param thread_id [String]
|
|
87
|
-
def clear(thread_id:)
|
|
88
|
-
@model_class.where(thread_id: thread_id).delete_all
|
|
89
|
-
clear_raw(thread_id: thread_id)
|
|
90
|
-
clear_compactions(thread_id: thread_id)
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
# -----------------------------------------------------------------------
|
|
94
|
-
# Raw message interface
|
|
95
|
-
# -----------------------------------------------------------------------
|
|
96
|
-
|
|
97
|
-
# @param thread_id [String]
|
|
98
|
-
# @param messages [Array]
|
|
99
|
-
# @param starting_seq [Integer]
|
|
100
|
-
def append_raw(thread_id:, messages:, starting_seq:)
|
|
101
|
-
return unless @raw_model_class
|
|
102
|
-
|
|
103
|
-
@raw_model_class.transaction do
|
|
104
|
-
messages.each_with_index do |msg, i|
|
|
105
|
-
@raw_model_class.create!(
|
|
106
|
-
thread_id: thread_id,
|
|
107
|
-
seq: starting_seq + i,
|
|
108
|
-
role: msg.role.to_s,
|
|
109
|
-
content: msg.content,
|
|
110
|
-
tool_calls_json: serialize_tool_calls(msg),
|
|
111
|
-
model_id: (msg.model_id if msg.respond_to?(:model_id))
|
|
112
|
-
)
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
end
|
|
116
|
-
|
|
117
|
-
# @param thread_id [String]
|
|
118
|
-
# @return [Array<Hash>]
|
|
119
|
-
def load_raw(thread_id:)
|
|
120
|
-
return [] unless @raw_model_class
|
|
121
|
-
|
|
122
|
-
records = @raw_model_class.where(thread_id: thread_id).order(:seq).to_a
|
|
123
|
-
records.map { |r| {seq: r.seq, message: to_message_struct(r)} }
|
|
124
|
-
end
|
|
125
|
-
|
|
126
|
-
# @param thread_id [String]
|
|
127
|
-
def clear_raw(thread_id:)
|
|
128
|
-
@raw_model_class&.where(thread_id: thread_id)&.delete_all
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
# -----------------------------------------------------------------------
|
|
132
|
-
# Compaction record interface
|
|
133
|
-
# -----------------------------------------------------------------------
|
|
134
|
-
|
|
135
|
-
# @param thread_id [String]
|
|
136
|
-
# @param start_seq [Integer]
|
|
137
|
-
# @param end_seq [Integer]
|
|
138
|
-
# @param summary_text [String]
|
|
139
|
-
def save_compaction(thread_id:, start_seq:, end_seq:, summary_text:)
|
|
140
|
-
ensure_compaction_model!
|
|
141
|
-
@compaction_model_class.create!(
|
|
142
|
-
thread_id: thread_id,
|
|
143
|
-
start_seq: start_seq,
|
|
144
|
-
end_seq: end_seq,
|
|
145
|
-
summary_text: summary_text
|
|
146
|
-
)
|
|
147
|
-
end
|
|
148
|
-
|
|
149
|
-
# @param thread_id [String]
|
|
150
|
-
# @return [Array<Hash>]
|
|
151
|
-
def load_compactions(thread_id:)
|
|
152
|
-
return [] unless @compaction_model_class
|
|
153
|
-
|
|
154
|
-
records = @compaction_model_class.where(thread_id: thread_id).order(:start_seq).to_a
|
|
155
|
-
records.map { |r| {start_seq: r.start_seq, end_seq: r.end_seq, summary_text: r.summary_text} }
|
|
156
|
-
end
|
|
157
|
-
|
|
158
|
-
# @param thread_id [String]
|
|
159
|
-
def clear_compactions(thread_id:)
|
|
160
|
-
@compaction_model_class&.where(thread_id: thread_id)&.delete_all
|
|
161
|
-
end
|
|
162
|
-
|
|
163
|
-
# Remove messages for a thread that were created before +older_than+.
|
|
164
|
-
# Only the legacy message store is filtered; raw and compaction records
|
|
165
|
-
# are left untouched because they use seq-based addressing.
|
|
166
|
-
#
|
|
167
|
-
# @param thread_id [String]
|
|
168
|
-
# @param older_than [Time]
|
|
169
|
-
def purge_older_than(thread_id:, older_than:)
|
|
170
|
-
@model_class.where(thread_id: thread_id).where("created_at < ?", older_than).delete_all
|
|
171
|
-
end
|
|
172
|
-
|
|
173
|
-
# Returns the next seq number to use for new raw messages for +thread_id+.
|
|
174
|
-
# Derived from MAX(seq) in the database; since purge_older_than does not
|
|
175
|
-
# touch raw records, this value is always correct.
|
|
176
|
-
#
|
|
177
|
-
# @param thread_id [String]
|
|
178
|
-
# @return [Integer]
|
|
179
|
-
def next_seq(thread_id:)
|
|
180
|
-
return 0 unless @raw_model_class
|
|
181
|
-
|
|
182
|
-
((@raw_model_class.where(thread_id: thread_id).maximum(:seq) || -1) + 1)
|
|
183
|
-
end
|
|
184
|
-
|
|
185
|
-
# Delegates to the block directly; serialisation of concurrent saves
|
|
186
|
-
# for the same thread_id is the caller's responsibility (e.g. DB-level
|
|
187
|
-
# transaction isolation or application-layer queuing).
|
|
188
|
-
# @param thread_id [String]
|
|
189
|
-
def with_thread_lock(thread_id:)
|
|
190
|
-
yield
|
|
191
|
-
end
|
|
192
|
-
|
|
193
|
-
private
|
|
194
|
-
|
|
195
|
-
def ensure_raw_model!
|
|
196
|
-
raise NotImplementedError, "raw_model_class is required for raw message storage" unless @raw_model_class
|
|
197
|
-
end
|
|
198
|
-
|
|
199
|
-
def ensure_compaction_model!
|
|
200
|
-
raise NotImplementedError, "compaction_model_class is required for compaction record storage" unless @compaction_model_class
|
|
201
|
-
end
|
|
202
|
-
|
|
203
|
-
def serialize_tool_calls(msg)
|
|
204
|
-
return unless msg.respond_to?(:tool_calls) && msg.tool_calls
|
|
205
|
-
|
|
206
|
-
serializable = case msg.tool_calls
|
|
207
|
-
when Hash
|
|
208
|
-
msg.tool_calls.transform_values { |tc| tc.respond_to?(:to_h) ? tc.to_h : tc }
|
|
209
|
-
when Array
|
|
210
|
-
msg.tool_calls.map { |tc| tc.respond_to?(:to_h) ? tc.to_h : tc }
|
|
211
|
-
else
|
|
212
|
-
msg.tool_calls
|
|
213
|
-
end
|
|
214
|
-
JSON.generate(serializable)
|
|
215
|
-
end
|
|
216
|
-
|
|
217
|
-
def to_message_struct(record)
|
|
218
|
-
tool_calls = if record.tool_calls_json
|
|
219
|
-
parsed = JSON.parse(record.tool_calls_json)
|
|
220
|
-
case parsed
|
|
221
|
-
when Hash
|
|
222
|
-
parsed.transform_values { |tc| restore_tool_call(tc) }
|
|
223
|
-
when Array
|
|
224
|
-
parsed.map { |tc| restore_tool_call(tc) }
|
|
225
|
-
else
|
|
226
|
-
parsed
|
|
227
|
-
end
|
|
228
|
-
end
|
|
229
|
-
MessageStruct.new(
|
|
230
|
-
role: record.role.to_sym,
|
|
231
|
-
content: record.content,
|
|
232
|
-
tool_calls: tool_calls,
|
|
233
|
-
model_id: record.respond_to?(:model_id) ? record.model_id : nil
|
|
234
|
-
)
|
|
235
|
-
end
|
|
236
|
-
|
|
237
|
-
def restore_tool_call(tc)
|
|
238
|
-
return tc unless tc.is_a?(Hash) && tc["id"] && tc["name"]
|
|
239
|
-
RubyLLM::ToolCall.new(
|
|
240
|
-
id: tc["id"],
|
|
241
|
-
name: tc["name"],
|
|
242
|
-
arguments: tc["arguments"] || {}
|
|
243
|
-
)
|
|
244
|
-
end
|
|
245
|
-
end
|
|
246
|
-
end
|
|
247
|
-
end
|
|
248
|
-
end
|
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Memory
|
|
5
|
-
module Storage
|
|
6
|
-
# Abstract base class for conversation storage backends.
|
|
7
|
-
#
|
|
8
|
-
# Each backend manages two independent datasets per thread:
|
|
9
|
-
#
|
|
10
|
-
# 1. Raw messages — the original, unmodified conversation history.
|
|
11
|
-
# Every message is stored with a monotonically increasing seq number
|
|
12
|
-
# (0-based, scoped to thread_id). Raw messages are never modified or
|
|
13
|
-
# deleted; they are the authoritative record.
|
|
14
|
-
#
|
|
15
|
-
# 2. Compaction records — the output of LLM-based compaction (summarization).
|
|
16
|
-
# Each record covers a contiguous range [start_seq..end_seq] of raw
|
|
17
|
-
# messages and stores the summary text produced for that range.
|
|
18
|
-
# Multiple non-overlapping compaction records may exist per thread.
|
|
19
|
-
#
|
|
20
|
-
# The conventional load/save/clear interface is kept for use by
|
|
21
|
-
# ConversationManager (compression path) and direct storage access.
|
|
22
|
-
#
|
|
23
|
-
# @abstract Subclass and implement all abstract methods.
|
|
24
|
-
class Base
|
|
25
|
-
# -----------------------------------------------------------------------
|
|
26
|
-
# Conventional load/save/clear interface
|
|
27
|
-
# -----------------------------------------------------------------------
|
|
28
|
-
|
|
29
|
-
# Load all messages for a thread in chronological order.
|
|
30
|
-
#
|
|
31
|
-
# @param thread_id [String]
|
|
32
|
-
# @return [Array]
|
|
33
|
-
def load(thread_id:)
|
|
34
|
-
raise NotImplementedError, "#{self.class}#load is not implemented"
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# Persist messages for a thread (replaces existing messages).
|
|
38
|
-
#
|
|
39
|
-
# @param thread_id [String]
|
|
40
|
-
# @param messages [Array]
|
|
41
|
-
def save(thread_id:, messages:)
|
|
42
|
-
raise NotImplementedError, "#{self.class}#save is not implemented"
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# Delete all messages for a thread.
|
|
46
|
-
#
|
|
47
|
-
# @param thread_id [String]
|
|
48
|
-
def clear(thread_id:)
|
|
49
|
-
raise NotImplementedError, "#{self.class}#clear is not implemented"
|
|
50
|
-
end
|
|
51
|
-
|
|
52
|
-
# -----------------------------------------------------------------------
|
|
53
|
-
# Raw message interface
|
|
54
|
-
# -----------------------------------------------------------------------
|
|
55
|
-
|
|
56
|
-
# Append new messages to the raw history for a thread.
|
|
57
|
-
# Each message is stored together with its seq number.
|
|
58
|
-
#
|
|
59
|
-
# @param thread_id [String]
|
|
60
|
-
# @param messages [Array] message objects to append
|
|
61
|
-
# @param starting_seq [Integer] seq number to assign to messages[0]
|
|
62
|
-
def append_raw(thread_id:, messages:, starting_seq:)
|
|
63
|
-
raise NotImplementedError, "#{self.class}#append_raw is not implemented"
|
|
64
|
-
end
|
|
65
|
-
|
|
66
|
-
# Return all raw messages for a thread as an array of hashes.
|
|
67
|
-
#
|
|
68
|
-
# @param thread_id [String]
|
|
69
|
-
# @return [Array<Hash>] each entry: { seq: Integer, message: Object }
|
|
70
|
-
def load_raw(thread_id:)
|
|
71
|
-
raise NotImplementedError, "#{self.class}#load_raw is not implemented"
|
|
72
|
-
end
|
|
73
|
-
|
|
74
|
-
# Delete raw messages for a thread (used in #clear).
|
|
75
|
-
#
|
|
76
|
-
# @param thread_id [String]
|
|
77
|
-
def clear_raw(thread_id:)
|
|
78
|
-
raise NotImplementedError, "#{self.class}#clear_raw is not implemented"
|
|
79
|
-
end
|
|
80
|
-
|
|
81
|
-
# -----------------------------------------------------------------------
|
|
82
|
-
# Compaction record interface
|
|
83
|
-
# -----------------------------------------------------------------------
|
|
84
|
-
|
|
85
|
-
# Persist a compaction record for a thread.
|
|
86
|
-
# A compaction record stores the LLM-generated summary that covers raw
|
|
87
|
-
# messages from start_seq to end_seq (inclusive).
|
|
88
|
-
#
|
|
89
|
-
# @param thread_id [String]
|
|
90
|
-
# @param start_seq [Integer]
|
|
91
|
-
# @param end_seq [Integer]
|
|
92
|
-
# @param summary_text [String]
|
|
93
|
-
def save_compaction(thread_id:, start_seq:, end_seq:, summary_text:)
|
|
94
|
-
raise NotImplementedError, "#{self.class}#save_compaction is not implemented"
|
|
95
|
-
end
|
|
96
|
-
|
|
97
|
-
# Return all compaction records for a thread in ascending start_seq order.
|
|
98
|
-
#
|
|
99
|
-
# @param thread_id [String]
|
|
100
|
-
# @return [Array<Hash>] each entry: { start_seq:, end_seq:, summary_text: }
|
|
101
|
-
def load_compactions(thread_id:)
|
|
102
|
-
raise NotImplementedError, "#{self.class}#load_compactions is not implemented"
|
|
103
|
-
end
|
|
104
|
-
|
|
105
|
-
# Delete all compaction records for a thread.
|
|
106
|
-
#
|
|
107
|
-
# @param thread_id [String]
|
|
108
|
-
def clear_compactions(thread_id:)
|
|
109
|
-
raise NotImplementedError, "#{self.class}#clear_compactions is not implemented"
|
|
110
|
-
end
|
|
111
|
-
|
|
112
|
-
# Remove all stored data (raw messages, compaction records, legacy store)
|
|
113
|
-
# for a thread. Equivalent to {#clear}, provided as a named alias to make
|
|
114
|
-
# the "right to erasure" intent explicit.
|
|
115
|
-
#
|
|
116
|
-
# @param thread_id [String]
|
|
117
|
-
def purge(thread_id:)
|
|
118
|
-
clear(thread_id: thread_id)
|
|
119
|
-
end
|
|
120
|
-
|
|
121
|
-
# Remove raw messages recorded before +older_than+ for a thread.
|
|
122
|
-
# The default implementation is a no-op; backends that support
|
|
123
|
-
# timestamp-based deletion should override this method.
|
|
124
|
-
#
|
|
125
|
-
# @param thread_id [String]
|
|
126
|
-
# @param older_than [Time]
|
|
127
|
-
def purge_older_than(thread_id:, older_than:)
|
|
128
|
-
# no-op by default
|
|
129
|
-
end
|
|
130
|
-
|
|
131
|
-
# Returns the next seq number to assign when appending new raw messages
|
|
132
|
-
# for +thread_id+. Must be monotonically increasing and must survive
|
|
133
|
-
# purge_older_than (i.e. the counter must not reset when old raw records
|
|
134
|
-
# are deleted by a TTL purge).
|
|
135
|
-
#
|
|
136
|
-
# @param thread_id [String]
|
|
137
|
-
# @return [Integer]
|
|
138
|
-
def next_seq(thread_id:)
|
|
139
|
-
raise NotImplementedError, "#{self.class}#next_seq is not implemented"
|
|
140
|
-
end
|
|
141
|
-
|
|
142
|
-
# Executes the block while holding a per-thread-id lock for +thread_id+.
|
|
143
|
-
# Used by ConversationManager to prevent concurrent compaction for the
|
|
144
|
-
# same thread. The default implementation yields without locking; backends
|
|
145
|
-
# that require serialisation should override this method.
|
|
146
|
-
#
|
|
147
|
-
# @param thread_id [String]
|
|
148
|
-
# @yield
|
|
149
|
-
def with_thread_lock(thread_id:)
|
|
150
|
-
yield
|
|
151
|
-
end
|
|
152
|
-
end
|
|
153
|
-
end
|
|
154
|
-
end
|
|
155
|
-
end
|