phronomy 0.2.2 → 0.3.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 +88 -30
- data/README.md +26 -110
- data/lib/phronomy/agent/base.rb +127 -54
- data/lib/phronomy/agent/checkpoint.rb +53 -0
- data/lib/phronomy/agent/react_agent.rb +18 -28
- data/lib/phronomy/agent/suspend_signal.rb +35 -0
- data/lib/phronomy/agent.rb +2 -1
- data/lib/phronomy/configuration.rb +0 -24
- 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/trust_pipeline.rb +1 -2
- 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 +1 -8
- data/lib/phronomy.rb +1 -0
- data/scripts/check_readme_ruby.rb +38 -0
- metadata +5 -33
- 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
|
@@ -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
|
|
@@ -1,152 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Memory
|
|
5
|
-
module Storage
|
|
6
|
-
# In-process storage for conversation messages backed by per-thread-id
|
|
7
|
-
# {Phronomy::Actor} instances from {Phronomy::ThreadActorRegistry}.
|
|
8
|
-
# Messages are lost when the process exits.
|
|
9
|
-
#
|
|
10
|
-
# @example
|
|
11
|
-
# storage = Phronomy::Memory::Storage::InMemory.new
|
|
12
|
-
# manager = Phronomy::Memory::ConversationManager.new(storage: storage, ...)
|
|
13
|
-
class InMemory < Base
|
|
14
|
-
# Thread-local key for per-thread-id storage data (namespaced by store
|
|
15
|
-
# instance object_id to support multiple independent InMemory stores).
|
|
16
|
-
THREAD_DATA_KEY = :phronomy_storage_in_memory_data
|
|
17
|
-
|
|
18
|
-
def initialize
|
|
19
|
-
end
|
|
20
|
-
|
|
21
|
-
# -----------------------------------------------------------------------
|
|
22
|
-
# Legacy interface
|
|
23
|
-
# -----------------------------------------------------------------------
|
|
24
|
-
|
|
25
|
-
# @param thread_id [String]
|
|
26
|
-
# @return [Array]
|
|
27
|
-
def load(thread_id:)
|
|
28
|
-
Phronomy::ThreadActorRegistry.for(thread_id).call { (thread_data.store || []).dup }
|
|
29
|
-
end
|
|
30
|
-
|
|
31
|
-
# @param thread_id [String]
|
|
32
|
-
# @param messages [Array]
|
|
33
|
-
def save(thread_id:, messages:)
|
|
34
|
-
Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.store = messages.dup }
|
|
35
|
-
end
|
|
36
|
-
|
|
37
|
-
# @param thread_id [String]
|
|
38
|
-
def clear(thread_id:)
|
|
39
|
-
store_id = object_id
|
|
40
|
-
Phronomy::ThreadActorRegistry.for(thread_id).call do
|
|
41
|
-
(Thread.current[THREAD_DATA_KEY] ||= {}).delete(store_id)
|
|
42
|
-
end
|
|
43
|
-
end
|
|
44
|
-
|
|
45
|
-
# -----------------------------------------------------------------------
|
|
46
|
-
# Raw message interface
|
|
47
|
-
# -----------------------------------------------------------------------
|
|
48
|
-
|
|
49
|
-
# @param thread_id [String]
|
|
50
|
-
# @param messages [Array]
|
|
51
|
-
# @param starting_seq [Integer]
|
|
52
|
-
# @param recorded_at [Time, nil] timestamp for test overrides; defaults to +Time.now+
|
|
53
|
-
def append_raw(thread_id:, messages:, starting_seq:, recorded_at: nil)
|
|
54
|
-
now = recorded_at || Time.now
|
|
55
|
-
Phronomy::ThreadActorRegistry.for(thread_id).call do
|
|
56
|
-
data = thread_data
|
|
57
|
-
messages.each_with_index do |msg, i|
|
|
58
|
-
seq = starting_seq + i
|
|
59
|
-
data.raw_messages << {seq: seq, message: msg, recorded_at: now}
|
|
60
|
-
data.hwm = [data.hwm, seq].max
|
|
61
|
-
end
|
|
62
|
-
end
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
# @param thread_id [String]
|
|
66
|
-
# @return [Integer]
|
|
67
|
-
def next_seq(thread_id:)
|
|
68
|
-
Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.hwm + 1 }
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
# Routes +block+ through the per-thread-id {Phronomy::Actor}, serialising
|
|
72
|
-
# all operations for the same thread. Reentrant calls (the block itself
|
|
73
|
-
# calling storage methods that also route through the Actor) are safe
|
|
74
|
-
# because {Phronomy::Actor#call} detects the same-thread case and executes
|
|
75
|
-
# inline.
|
|
76
|
-
#
|
|
77
|
-
# @param thread_id [String]
|
|
78
|
-
def with_thread_lock(thread_id:, &block)
|
|
79
|
-
Phronomy::ThreadActorRegistry.for(thread_id).call(&block)
|
|
80
|
-
end
|
|
81
|
-
|
|
82
|
-
# @param thread_id [String]
|
|
83
|
-
# @return [Array<Hash>]
|
|
84
|
-
def load_raw(thread_id:)
|
|
85
|
-
Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.raw_messages.dup }
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# @param thread_id [String]
|
|
89
|
-
def clear_raw(thread_id:)
|
|
90
|
-
Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.raw_messages.clear }
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
# -----------------------------------------------------------------------
|
|
94
|
-
# Compaction record interface
|
|
95
|
-
# -----------------------------------------------------------------------
|
|
96
|
-
|
|
97
|
-
# @param thread_id [String]
|
|
98
|
-
# @param start_seq [Integer]
|
|
99
|
-
# @param end_seq [Integer]
|
|
100
|
-
# @param summary_text [String]
|
|
101
|
-
def save_compaction(thread_id:, start_seq:, end_seq:, summary_text:)
|
|
102
|
-
Phronomy::ThreadActorRegistry.for(thread_id).call do
|
|
103
|
-
thread_data.compactions << {start_seq: start_seq, end_seq: end_seq, summary_text: summary_text}
|
|
104
|
-
end
|
|
105
|
-
end
|
|
106
|
-
|
|
107
|
-
# @param thread_id [String]
|
|
108
|
-
# @return [Array<Hash>]
|
|
109
|
-
def load_compactions(thread_id:)
|
|
110
|
-
Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.compactions.dup }
|
|
111
|
-
end
|
|
112
|
-
|
|
113
|
-
# @param thread_id [String]
|
|
114
|
-
def clear_compactions(thread_id:)
|
|
115
|
-
Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.compactions.clear }
|
|
116
|
-
end
|
|
117
|
-
|
|
118
|
-
# Remove raw messages recorded before +older_than+ for this thread.
|
|
119
|
-
#
|
|
120
|
-
# @param thread_id [String]
|
|
121
|
-
# @param older_than [Time]
|
|
122
|
-
def purge_older_than(thread_id:, older_than:)
|
|
123
|
-
Phronomy::ThreadActorRegistry.for(thread_id).call do
|
|
124
|
-
thread_data.raw_messages.reject! { |entry| entry[:recorded_at] && entry[:recorded_at] < older_than }
|
|
125
|
-
end
|
|
126
|
-
end
|
|
127
|
-
|
|
128
|
-
private
|
|
129
|
-
|
|
130
|
-
# Returns (or lazily initialises) the {ThreadData} for the current Actor
|
|
131
|
-
# thread and this storage instance. Must only be called from within a
|
|
132
|
-
# {Phronomy::ThreadActorRegistry.for} block so that +Thread.current+ is
|
|
133
|
-
# the correct Actor thread.
|
|
134
|
-
def thread_data
|
|
135
|
-
(Thread.current[THREAD_DATA_KEY] ||= {})[object_id] ||= ThreadData.new
|
|
136
|
-
end
|
|
137
|
-
|
|
138
|
-
# Value object holding all per-thread-id storage state.
|
|
139
|
-
class ThreadData
|
|
140
|
-
attr_accessor :store, :raw_messages, :compactions, :hwm
|
|
141
|
-
|
|
142
|
-
def initialize
|
|
143
|
-
@store = nil
|
|
144
|
-
@raw_messages = []
|
|
145
|
-
@compactions = []
|
|
146
|
-
@hwm = -1
|
|
147
|
-
end
|
|
148
|
-
end
|
|
149
|
-
end
|
|
150
|
-
end
|
|
151
|
-
end
|
|
152
|
-
end
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Memory
|
|
5
|
-
# Storage is the persistence axis of conversation management.
|
|
6
|
-
# Implementations are responsible only for saving and loading raw message arrays.
|
|
7
|
-
# Token budgeting, retrieval selection, and compression are handled by other axes.
|
|
8
|
-
module Storage
|
|
9
|
-
end
|
|
10
|
-
end
|
|
11
|
-
end
|
data/lib/phronomy/memory.rb
DELETED
|
@@ -1,21 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Memory
|
|
5
|
-
end
|
|
6
|
-
end
|
|
7
|
-
|
|
8
|
-
require_relative "memory/storage"
|
|
9
|
-
require_relative "memory/storage/base"
|
|
10
|
-
require_relative "memory/storage/in_memory"
|
|
11
|
-
require_relative "memory/storage/active_record"
|
|
12
|
-
require_relative "memory/retrieval"
|
|
13
|
-
require_relative "memory/retrieval/base"
|
|
14
|
-
require_relative "memory/retrieval/recent"
|
|
15
|
-
require_relative "memory/retrieval/semantic"
|
|
16
|
-
require_relative "memory/retrieval/composite"
|
|
17
|
-
require_relative "memory/compression"
|
|
18
|
-
require_relative "memory/compression/base"
|
|
19
|
-
require_relative "memory/compression/summary"
|
|
20
|
-
require_relative "memory/compression/tool_output_pruner"
|
|
21
|
-
require_relative "memory/conversation_manager"
|
|
@@ -1,75 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Rails
|
|
5
|
-
# ActiveJob-based job that runs a Phronomy agent in streaming mode and
|
|
6
|
-
# broadcasts each event to an ActionCable stream.
|
|
7
|
-
#
|
|
8
|
-
# Enqueue with +perform_later+ to run the agent asynchronously in a background
|
|
9
|
-
# worker. Every streaming event is forwarded to ActionCable subscribers in
|
|
10
|
-
# real time.
|
|
11
|
-
#
|
|
12
|
-
# @example Enqueueing a streaming agent job
|
|
13
|
-
# Phronomy::Rails::AgentJob.perform_later(
|
|
14
|
-
# "MyAgent",
|
|
15
|
-
# "What is the weather today?",
|
|
16
|
-
# channel: "AgentChannel",
|
|
17
|
-
# stream: "agent_#{current_user.id}"
|
|
18
|
-
# )
|
|
19
|
-
#
|
|
20
|
-
# Events broadcast to the ActionCable stream:
|
|
21
|
-
# { type: "token", content: "..." } — each content delta from the LLM
|
|
22
|
-
# { type: "done", output: "..." } — final complete output
|
|
23
|
-
# { type: "error", message: "..." } — when the agent or job raises
|
|
24
|
-
#
|
|
25
|
-
class AgentJob < ::ActiveJob::Base
|
|
26
|
-
# @param agent_class_name [String]
|
|
27
|
-
# The constantize-able class name of the agent to run (e.g. "MyAgent").
|
|
28
|
-
# **Security**: only classes that are subclasses of +Phronomy::Agent::Base+
|
|
29
|
-
# are accepted. Never pass a value derived from user-controlled input.
|
|
30
|
-
# @param input [String, Hash]
|
|
31
|
-
# User input forwarded unchanged to the agent's +#stream+ method.
|
|
32
|
-
# @param channel [String]
|
|
33
|
-
# ActionCable channel name. Retained for documentation / future routing.
|
|
34
|
-
# @param stream [String]
|
|
35
|
-
# ActionCable stream identifier passed to +ActionCable.server.broadcast+.
|
|
36
|
-
# @param config [Hash]
|
|
37
|
-
# Configuration forwarded to the agent's +#stream+ call. Both symbol and
|
|
38
|
-
# string keys are accepted; all keys are converted to symbols before use.
|
|
39
|
-
def perform(agent_class_name, input, channel:, stream:, config: {})
|
|
40
|
-
klass = resolve_agent_class!(agent_class_name)
|
|
41
|
-
agent = klass.new
|
|
42
|
-
agent.stream(input, config: config.transform_keys(&:to_sym)) do |event|
|
|
43
|
-
ActionCable.server.broadcast(stream, build_payload(event))
|
|
44
|
-
end
|
|
45
|
-
rescue => e
|
|
46
|
-
::Rails.logger.error("[Phronomy::Rails::AgentJob] agent error (#{e.class}): #{e.message}")
|
|
47
|
-
ActionCable.server.broadcast(stream, {type: "error", message: "An error occurred while processing your request."})
|
|
48
|
-
end
|
|
49
|
-
|
|
50
|
-
private
|
|
51
|
-
|
|
52
|
-
# Resolves and validates the agent class name.
|
|
53
|
-
# Raises ArgumentError when the name does not resolve to a subclass of
|
|
54
|
-
# Phronomy::Agent::Base, preventing arbitrary class instantiation.
|
|
55
|
-
def resolve_agent_class!(class_name)
|
|
56
|
-
klass = Object.const_get(class_name.to_s)
|
|
57
|
-
unless klass.is_a?(Class) && klass < Phronomy::Agent::Base
|
|
58
|
-
raise ArgumentError, "#{class_name.inspect} is not a Phronomy::Agent::Base subclass"
|
|
59
|
-
end
|
|
60
|
-
klass
|
|
61
|
-
rescue NameError
|
|
62
|
-
raise ArgumentError, "Unknown agent class: #{class_name.inspect}"
|
|
63
|
-
end
|
|
64
|
-
|
|
65
|
-
def build_payload(event)
|
|
66
|
-
case event.type
|
|
67
|
-
when :token then {type: "token", content: event.payload[:content]}
|
|
68
|
-
when :done then {type: "done", output: event.payload[:output]}
|
|
69
|
-
when :error then {type: "error", message: "An error occurred while processing your request."}
|
|
70
|
-
else {type: event.type.to_s}
|
|
71
|
-
end
|
|
72
|
-
end
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
end
|