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,76 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
|
|
5
|
-
module Phronomy
|
|
6
|
-
module StateStore
|
|
7
|
-
# ActiveRecord-backed state store.
|
|
8
|
-
# Persists graph state to a relational database using an AR model.
|
|
9
|
-
#
|
|
10
|
-
# The model_class must respond to:
|
|
11
|
-
# .find_by(thread_id:)
|
|
12
|
-
# .find_or_initialize_by(thread_id:)
|
|
13
|
-
# #state_json=, #save!
|
|
14
|
-
# .where(thread_id:).delete_all
|
|
15
|
-
#
|
|
16
|
-
# Minimal migration:
|
|
17
|
-
# create_table :phronomy_states do |t|
|
|
18
|
-
# t.string :thread_id, null: false, index: { unique: true }
|
|
19
|
-
# t.text :state_json, null: false
|
|
20
|
-
# t.timestamps
|
|
21
|
-
# end
|
|
22
|
-
#
|
|
23
|
-
# @example
|
|
24
|
-
# Phronomy.configure do |c|
|
|
25
|
-
# c.default_state_store = Phronomy::StateStore::ActiveRecord.new(
|
|
26
|
-
# model_class: PhronomyState
|
|
27
|
-
# )
|
|
28
|
-
# end
|
|
29
|
-
class ActiveRecord < Base
|
|
30
|
-
# @param model_class [Class] ActiveRecord model with the schema above.
|
|
31
|
-
# @param encryptor [Phronomy::StateStore::Encryptor::Base, nil]
|
|
32
|
-
# Optional encryption adapter. When supplied, the JSON payload is
|
|
33
|
-
# encrypted before writing and decrypted after reading.
|
|
34
|
-
# When nil (default), data is stored as plain JSON.
|
|
35
|
-
def initialize(model_class:, encryptor: nil)
|
|
36
|
-
@model_class = model_class
|
|
37
|
-
@encryptor = encryptor
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# Serializes and upserts the state for the given thread_id.
|
|
41
|
-
# @param state [Object] includes Phronomy::WorkflowContext
|
|
42
|
-
# @return [self]
|
|
43
|
-
def save(state)
|
|
44
|
-
json = serialize_state(state)
|
|
45
|
-
payload = @encryptor ? @encryptor.encrypt(json) : json
|
|
46
|
-
# Use upsert to avoid a race condition where two concurrent saves for the
|
|
47
|
-
# same thread_id would both see "no record" and collide on the unique index.
|
|
48
|
-
@model_class.upsert(
|
|
49
|
-
{thread_id: state.thread_id, state_json: payload},
|
|
50
|
-
unique_by: :thread_id,
|
|
51
|
-
update_only: [:state_json]
|
|
52
|
-
)
|
|
53
|
-
self
|
|
54
|
-
end
|
|
55
|
-
|
|
56
|
-
# Loads and deserializes the state for the given thread_id.
|
|
57
|
-
# @param thread_id [String]
|
|
58
|
-
# @return [Object, nil] state instance or nil
|
|
59
|
-
def load(thread_id)
|
|
60
|
-
record = @model_class.find_by(thread_id: thread_id)
|
|
61
|
-
return nil unless record
|
|
62
|
-
|
|
63
|
-
payload = record.state_json
|
|
64
|
-
json = @encryptor ? @encryptor.decrypt(payload) : payload
|
|
65
|
-
deserialize_state(json)
|
|
66
|
-
end
|
|
67
|
-
|
|
68
|
-
# Deletes the state for the given thread_id.
|
|
69
|
-
# @return [self]
|
|
70
|
-
def clear(thread_id)
|
|
71
|
-
@model_class.where(thread_id: thread_id).delete_all
|
|
72
|
-
self
|
|
73
|
-
end
|
|
74
|
-
end
|
|
75
|
-
end
|
|
76
|
-
end
|
|
@@ -1,112 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
|
|
5
|
-
module Phronomy
|
|
6
|
-
module StateStore
|
|
7
|
-
# Abstract base class for state persistence backends.
|
|
8
|
-
# Subclasses must implement save, load, and clear.
|
|
9
|
-
#
|
|
10
|
-
# The state object passed to save must include Phronomy::WorkflowContext
|
|
11
|
-
# and have a non-nil thread_id (set automatically by WorkflowRunner#invoke).
|
|
12
|
-
class Base
|
|
13
|
-
# Persists the state. The thread_id is read from state.thread_id.
|
|
14
|
-
# @param state [Object] object including Phronomy::WorkflowContext
|
|
15
|
-
# @return [self]
|
|
16
|
-
def save(state)
|
|
17
|
-
raise NotImplementedError, "#{self.class}#save is not implemented"
|
|
18
|
-
end
|
|
19
|
-
|
|
20
|
-
# Loads the state for the given thread_id.
|
|
21
|
-
# @param thread_id [String]
|
|
22
|
-
# @return [Object, nil] state object or nil if not found
|
|
23
|
-
def load(thread_id)
|
|
24
|
-
raise NotImplementedError, "#{self.class}#load is not implemented"
|
|
25
|
-
end
|
|
26
|
-
|
|
27
|
-
# Removes the saved state for the given thread_id.
|
|
28
|
-
# @param thread_id [String]
|
|
29
|
-
# @return [self]
|
|
30
|
-
def clear(thread_id)
|
|
31
|
-
raise NotImplementedError, "#{self.class}#clear is not implemented"
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
private
|
|
35
|
-
|
|
36
|
-
# Serializes a state object to a JSON string.
|
|
37
|
-
# Includes user-defined fields and internal graph metadata.
|
|
38
|
-
def serialize_state(state)
|
|
39
|
-
JSON.generate(
|
|
40
|
-
state_class: state.class.name,
|
|
41
|
-
state_data: json_safe(state.to_h),
|
|
42
|
-
thread_id: state.thread_id,
|
|
43
|
-
phase: state.phase&.to_s
|
|
44
|
-
)
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# Deserializes a JSON string back into a state object.
|
|
48
|
-
# @return [Object] state instance with graph metadata restored
|
|
49
|
-
def deserialize_state(json_str)
|
|
50
|
-
data = JSON.parse(json_str, symbolize_names: true)
|
|
51
|
-
state_class = safe_state_class(data[:state_class])
|
|
52
|
-
state_data = symbolize_keys(data[:state_data])
|
|
53
|
-
state = state_class.new(**state_data)
|
|
54
|
-
state.set_graph_metadata(
|
|
55
|
-
thread_id: data[:thread_id],
|
|
56
|
-
phase: data[:phase]&.to_sym
|
|
57
|
-
)
|
|
58
|
-
state
|
|
59
|
-
end
|
|
60
|
-
|
|
61
|
-
# Resolves and validates a context class name.
|
|
62
|
-
# When a registry has been configured via +Phronomy.register_workflow_context+,
|
|
63
|
-
# only registered classes are accepted — this prevents unintended autoloading
|
|
64
|
-
# of arbitrary files from an untrusted class name stored in Redis/DB.
|
|
65
|
-
# When no registry is configured, falls back to Object.const_get with a check
|
|
66
|
-
# that the resolved class includes Phronomy::WorkflowContext.
|
|
67
|
-
def safe_state_class(class_name)
|
|
68
|
-
registry = Phronomy.workflow_context_registry
|
|
69
|
-
if registry
|
|
70
|
-
klass = registry[class_name.to_s]
|
|
71
|
-
unless klass
|
|
72
|
-
raise ArgumentError,
|
|
73
|
-
"Unregistered context class: #{class_name.inspect}. " \
|
|
74
|
-
"Call Phronomy.register_workflow_context(#{class_name}) at startup."
|
|
75
|
-
end
|
|
76
|
-
return klass
|
|
77
|
-
end
|
|
78
|
-
|
|
79
|
-
klass = Object.const_get(class_name.to_s)
|
|
80
|
-
unless klass.is_a?(Class) && klass.include?(Phronomy::WorkflowContext)
|
|
81
|
-
raise ArgumentError, "Invalid context class: #{class_name.inspect}"
|
|
82
|
-
end
|
|
83
|
-
klass
|
|
84
|
-
rescue NameError
|
|
85
|
-
raise ArgumentError, "Unknown context class: #{class_name.inspect}"
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
# Recursively converts objects to JSON-safe primitives.
|
|
89
|
-
def json_safe(obj)
|
|
90
|
-
case obj
|
|
91
|
-
when Hash
|
|
92
|
-
obj.transform_keys(&:to_s).transform_values { |v| json_safe(v) }
|
|
93
|
-
when Array
|
|
94
|
-
obj.map { |v| json_safe(v) }
|
|
95
|
-
when String, Numeric, TrueClass, FalseClass, NilClass
|
|
96
|
-
obj
|
|
97
|
-
else
|
|
98
|
-
obj.respond_to?(:to_h) ? json_safe(obj.to_h) : obj.to_s
|
|
99
|
-
end
|
|
100
|
-
end
|
|
101
|
-
|
|
102
|
-
# Recursively symbolizes hash keys.
|
|
103
|
-
def symbolize_keys(obj)
|
|
104
|
-
case obj
|
|
105
|
-
when Hash then obj.transform_keys(&:to_sym).transform_values { |v| symbolize_keys(v) }
|
|
106
|
-
when Array then obj.map { |v| symbolize_keys(v) }
|
|
107
|
-
else obj
|
|
108
|
-
end
|
|
109
|
-
end
|
|
110
|
-
end
|
|
111
|
-
end
|
|
112
|
-
end
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module StateStore
|
|
5
|
-
module Encryptor
|
|
6
|
-
# Encryptor backed by ActiveSupport::MessageEncryptor.
|
|
7
|
-
#
|
|
8
|
-
# Requires the +activesupport+ gem to be available in the host application.
|
|
9
|
-
# Does NOT require rails — any Ruby project that depends on activesupport can
|
|
10
|
-
# use this adapter.
|
|
11
|
-
#
|
|
12
|
-
# @example
|
|
13
|
-
# encryptor = Phronomy::StateStore::Encryptor::ActiveSupport.new(
|
|
14
|
-
# secret_key_base: ENV.fetch("SECRET_KEY_BASE")
|
|
15
|
-
# )
|
|
16
|
-
# store = Phronomy::StateStore::ActiveRecord.new(
|
|
17
|
-
# model_class: PhronomyState,
|
|
18
|
-
# encryptor: encryptor
|
|
19
|
-
# )
|
|
20
|
-
class ActiveSupport < Base
|
|
21
|
-
# @param secret_key_base [String] secret used to derive the encryption key.
|
|
22
|
-
# Must be at least 30 random bytes (use +SecureRandom.hex(64)+ to generate).
|
|
23
|
-
# @param cipher [String] OpenSSL cipher name (default: "aes-256-gcm").
|
|
24
|
-
# @raise [LoadError] when activesupport is not available.
|
|
25
|
-
def initialize(secret_key_base:, cipher: "aes-256-gcm")
|
|
26
|
-
require "active_support/message_encryptor"
|
|
27
|
-
key = ::ActiveSupport::KeyGenerator.new(secret_key_base)
|
|
28
|
-
.generate_key("phronomy state store", 32)
|
|
29
|
-
@encryptor = ::ActiveSupport::MessageEncryptor.new(key, cipher: cipher)
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
# Encrypts the plaintext using AES-256-GCM.
|
|
33
|
-
# @param plaintext [String]
|
|
34
|
-
# @return [String] Base64-encoded authenticated ciphertext
|
|
35
|
-
def encrypt(plaintext)
|
|
36
|
-
@encryptor.encrypt_and_sign(plaintext)
|
|
37
|
-
end
|
|
38
|
-
|
|
39
|
-
# Decrypts and verifies the ciphertext.
|
|
40
|
-
# @param ciphertext [String]
|
|
41
|
-
# @return [String] the original plaintext
|
|
42
|
-
# @raise [ActiveSupport::MessageEncryptor::InvalidMessage] on tampered data
|
|
43
|
-
def decrypt(ciphertext)
|
|
44
|
-
@encryptor.decrypt_and_verify(ciphertext)
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
end
|
|
48
|
-
end
|
|
49
|
-
end
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module StateStore
|
|
5
|
-
module Encryptor
|
|
6
|
-
# Abstract base class for state encryption adapters.
|
|
7
|
-
#
|
|
8
|
-
# Subclass and implement {#encrypt} and {#decrypt} to integrate any
|
|
9
|
-
# symmetric encryption scheme. Pass an instance to
|
|
10
|
-
# {Phronomy::StateStore::ActiveRecord} via the +encryptor:+ argument.
|
|
11
|
-
#
|
|
12
|
-
# @example
|
|
13
|
-
# class MyEncryptor < Phronomy::StateStore::Encryptor::Base
|
|
14
|
-
# def encrypt(plaintext) = Base64.strict_encode64(plaintext.reverse)
|
|
15
|
-
# def decrypt(ciphertext) = Base64.strict_decode64(ciphertext).reverse
|
|
16
|
-
# end
|
|
17
|
-
class Base
|
|
18
|
-
# Encrypts a plaintext string.
|
|
19
|
-
# @param plaintext [String] the JSON string produced by the state store
|
|
20
|
-
# @return [String] the encrypted ciphertext
|
|
21
|
-
def encrypt(plaintext)
|
|
22
|
-
raise NotImplementedError, "#{self.class}#encrypt is not implemented"
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
# Decrypts a ciphertext string.
|
|
26
|
-
# @param ciphertext [String] previously produced by {#encrypt}
|
|
27
|
-
# @return [String] the original plaintext
|
|
28
|
-
def decrypt(ciphertext)
|
|
29
|
-
raise NotImplementedError, "#{self.class}#decrypt is not implemented"
|
|
30
|
-
end
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
end
|
|
34
|
-
end
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require_relative "encryptor/base"
|
|
4
|
-
require_relative "encryptor/active_support"
|
|
5
|
-
|
|
6
|
-
module Phronomy
|
|
7
|
-
module StateStore
|
|
8
|
-
# Namespace for state encryption adapters.
|
|
9
|
-
#
|
|
10
|
-
# Available adapters:
|
|
11
|
-
# - {Phronomy::StateStore::Encryptor::Base} — abstract interface
|
|
12
|
-
# - {Phronomy::StateStore::Encryptor::ActiveSupport} — AES-256-GCM via ActiveSupport
|
|
13
|
-
module Encryptor
|
|
14
|
-
end
|
|
15
|
-
end
|
|
16
|
-
end
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "fileutils"
|
|
4
|
-
require "json"
|
|
5
|
-
|
|
6
|
-
module Phronomy
|
|
7
|
-
module StateStore
|
|
8
|
-
# File-system-backed state store.
|
|
9
|
-
# Persists graph state as a JSON file under a configurable directory.
|
|
10
|
-
# No additional server or database migration is required — it works with
|
|
11
|
-
# the local file system out of the box.
|
|
12
|
-
#
|
|
13
|
-
# Each thread_id is stored as a separate file named "<thread_id>.json".
|
|
14
|
-
# The thread_id is sanitised before use as a filename to prevent path
|
|
15
|
-
# traversal: only alphanumeric characters, hyphens, underscores, and dots
|
|
16
|
-
# are allowed; all other characters are replaced with underscores.
|
|
17
|
-
#
|
|
18
|
-
# @note This store is suitable for single-process use (development, CLI
|
|
19
|
-
# tools, tests). It is not safe for concurrent access across multiple
|
|
20
|
-
# processes without external locking.
|
|
21
|
-
#
|
|
22
|
-
# @example
|
|
23
|
-
# store = Phronomy::StateStore::File.new(dir: "tmp/workflow_states")
|
|
24
|
-
# Phronomy::Workflow.define(MyContext, state_store: store) do
|
|
25
|
-
# # ...
|
|
26
|
-
# end
|
|
27
|
-
class File < Base
|
|
28
|
-
# @param dir [String] directory where state files are stored.
|
|
29
|
-
# Created automatically if it does not exist.
|
|
30
|
-
def initialize(dir: ::File.join(::Dir.tmpdir, "phronomy_states"))
|
|
31
|
-
@dir = ::File.expand_path(dir)
|
|
32
|
-
::FileUtils.mkdir_p(@dir)
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
# @param state [Object] includes Phronomy::WorkflowContext; must have a non-nil thread_id
|
|
36
|
-
# @return [self]
|
|
37
|
-
def save(state)
|
|
38
|
-
::File.write(path(state.thread_id), serialize_state(state))
|
|
39
|
-
self
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
# @param thread_id [String]
|
|
43
|
-
# @return [Object, nil] state instance or nil if not found
|
|
44
|
-
def load(thread_id)
|
|
45
|
-
file = path(thread_id)
|
|
46
|
-
return nil unless ::File.exist?(file)
|
|
47
|
-
|
|
48
|
-
deserialize_state(::File.read(file))
|
|
49
|
-
end
|
|
50
|
-
|
|
51
|
-
# Removes the saved state file for the given thread_id.
|
|
52
|
-
# @param thread_id [String]
|
|
53
|
-
# @return [self]
|
|
54
|
-
def clear(thread_id)
|
|
55
|
-
file = path(thread_id)
|
|
56
|
-
::File.delete(file) if ::File.exist?(file)
|
|
57
|
-
self
|
|
58
|
-
end
|
|
59
|
-
|
|
60
|
-
# Removes all state files managed by this store instance.
|
|
61
|
-
# @return [self]
|
|
62
|
-
def clear_all
|
|
63
|
-
::Dir.glob(::File.join(@dir, "*.json")).each { |f| ::File.delete(f) }
|
|
64
|
-
self
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
# @return [String] the directory used by this store
|
|
68
|
-
def directory
|
|
69
|
-
@dir
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
private
|
|
73
|
-
|
|
74
|
-
# Converts a thread_id into a safe filename component.
|
|
75
|
-
# Characters outside [A-Za-z0-9._-] are replaced with underscores.
|
|
76
|
-
def sanitize(thread_id)
|
|
77
|
-
thread_id.to_s.gsub(/[^A-Za-z0-9._-]/, "_")
|
|
78
|
-
end
|
|
79
|
-
|
|
80
|
-
def path(thread_id)
|
|
81
|
-
::File.join(@dir, "#{sanitize(thread_id)}.json")
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
end
|
|
@@ -1,53 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module StateStore
|
|
5
|
-
# In-memory state store backed by per-thread-id {Phronomy::Actor} instances
|
|
6
|
-
# from {Phronomy::ThreadActorRegistry}. Suitable for single-process use only.
|
|
7
|
-
class InMemory < Base
|
|
8
|
-
# Thread-local key for per-thread-id state data (namespaced by store
|
|
9
|
-
# instance object_id to support multiple independent InMemory stores).
|
|
10
|
-
THREAD_DATA_KEY = :phronomy_state_store_in_memory_data
|
|
11
|
-
|
|
12
|
-
def initialize
|
|
13
|
-
end
|
|
14
|
-
|
|
15
|
-
# @param state [Object] includes Phronomy::WorkflowContext; must have a non-nil thread_id
|
|
16
|
-
# @return [self]
|
|
17
|
-
def save(state)
|
|
18
|
-
store_id = object_id
|
|
19
|
-
Phronomy::ThreadActorRegistry.for(state.thread_id).call do
|
|
20
|
-
(Thread.current[THREAD_DATA_KEY] ||= {})[store_id] = state
|
|
21
|
-
end
|
|
22
|
-
self
|
|
23
|
-
end
|
|
24
|
-
|
|
25
|
-
# @param thread_id [String]
|
|
26
|
-
# @return [Object, nil] state object or nil
|
|
27
|
-
def load(thread_id)
|
|
28
|
-
store_id = object_id
|
|
29
|
-
Phronomy::ThreadActorRegistry.for(thread_id).call do
|
|
30
|
-
(Thread.current[THREAD_DATA_KEY] ||= {})[store_id]
|
|
31
|
-
end
|
|
32
|
-
end
|
|
33
|
-
|
|
34
|
-
# @param thread_id [String]
|
|
35
|
-
# @return [self]
|
|
36
|
-
def clear(thread_id)
|
|
37
|
-
store_id = object_id
|
|
38
|
-
Phronomy::ThreadActorRegistry.for(thread_id).call do
|
|
39
|
-
(Thread.current[THREAD_DATA_KEY] ||= {}).delete(store_id)
|
|
40
|
-
end
|
|
41
|
-
self
|
|
42
|
-
end
|
|
43
|
-
|
|
44
|
-
def clear_all
|
|
45
|
-
store_id = object_id
|
|
46
|
-
Phronomy::ThreadActorRegistry.each_actor do |actor|
|
|
47
|
-
actor.call { (Thread.current[THREAD_DATA_KEY] ||= {}).delete(store_id) }
|
|
48
|
-
end
|
|
49
|
-
self
|
|
50
|
-
end
|
|
51
|
-
end
|
|
52
|
-
end
|
|
53
|
-
end
|
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
require "json"
|
|
4
|
-
|
|
5
|
-
module Phronomy
|
|
6
|
-
module StateStore
|
|
7
|
-
# Redis-backed state store.
|
|
8
|
-
# Persists graph state as a JSON string under the key
|
|
9
|
-
# "phronomy:state:<thread_id>" in Redis.
|
|
10
|
-
#
|
|
11
|
-
# The Redis client must be compatible with the redis-rb gem interface:
|
|
12
|
-
# client.set(key, value)
|
|
13
|
-
# client.get(key)
|
|
14
|
-
# client.del(key)
|
|
15
|
-
#
|
|
16
|
-
# @example
|
|
17
|
-
# require "redis"
|
|
18
|
-
# redis = Redis.new(url: ENV["REDIS_URL"])
|
|
19
|
-
# Phronomy.configure do |c|
|
|
20
|
-
# c.default_state_store = Phronomy::StateStore::Redis.new(client: redis)
|
|
21
|
-
# end
|
|
22
|
-
#
|
|
23
|
-
# @example with TTL
|
|
24
|
-
# Phronomy::StateStore::Redis.new(client: redis, ttl: 3600)
|
|
25
|
-
class Redis < Base
|
|
26
|
-
KEY_PREFIX = "phronomy:state:"
|
|
27
|
-
private_constant :KEY_PREFIX
|
|
28
|
-
|
|
29
|
-
# @param client [#set, #get, #del] Redis-compatible client
|
|
30
|
-
# @param ttl [Integer, nil] optional key expiry in seconds
|
|
31
|
-
def initialize(client:, ttl: nil)
|
|
32
|
-
@client = client
|
|
33
|
-
@ttl = ttl
|
|
34
|
-
end
|
|
35
|
-
|
|
36
|
-
# @param state [Object] includes Phronomy::WorkflowContext
|
|
37
|
-
# @return [self]
|
|
38
|
-
def save(state)
|
|
39
|
-
serialized = serialize_state(state)
|
|
40
|
-
if @ttl
|
|
41
|
-
@client.set(key(state.thread_id), serialized, ex: @ttl)
|
|
42
|
-
else
|
|
43
|
-
@client.set(key(state.thread_id), serialized)
|
|
44
|
-
end
|
|
45
|
-
self
|
|
46
|
-
end
|
|
47
|
-
|
|
48
|
-
# @param thread_id [String]
|
|
49
|
-
# @return [Object, nil] state instance or nil
|
|
50
|
-
def load(thread_id)
|
|
51
|
-
raw = @client.get(key(thread_id))
|
|
52
|
-
return nil unless raw
|
|
53
|
-
|
|
54
|
-
deserialize_state(raw)
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
# @return [self]
|
|
58
|
-
def clear(thread_id)
|
|
59
|
-
@client.del(key(thread_id))
|
|
60
|
-
self
|
|
61
|
-
end
|
|
62
|
-
|
|
63
|
-
private
|
|
64
|
-
|
|
65
|
-
def key(thread_id)
|
|
66
|
-
"#{KEY_PREFIX}#{thread_id}"
|
|
67
|
-
end
|
|
68
|
-
end
|
|
69
|
-
end
|
|
70
|
-
end
|
data/lib/phronomy/state_store.rb
DELETED
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
# Namespace for state persistence backends.
|
|
5
|
-
# A StateStore saves and loads graph State objects keyed by thread_id.
|
|
6
|
-
# The thread_id is embedded in the State itself (state.thread_id).
|
|
7
|
-
module StateStore
|
|
8
|
-
end
|
|
9
|
-
end
|
|
@@ -1,85 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
# Global per-thread-id {Actor} registry.
|
|
5
|
-
#
|
|
6
|
-
# Maps the +:thread_id+ key from the +config:+ argument passed to
|
|
7
|
-
# {Phronomy::Agent::Base#invoke} to a {Phronomy::Actor} instance.
|
|
8
|
-
# Each thread_id gets exactly one Actor so that all operations for the same
|
|
9
|
-
# conversation are serialised automatically.
|
|
10
|
-
#
|
|
11
|
-
# @example
|
|
12
|
-
# Phronomy::ThreadActorRegistry.for("user-42").call do
|
|
13
|
-
# # runs sequentially on the Actor's thread
|
|
14
|
-
# end
|
|
15
|
-
module ThreadActorRegistry
|
|
16
|
-
@actors = {}
|
|
17
|
-
@registry_actor = Actor.new
|
|
18
|
-
|
|
19
|
-
class << self
|
|
20
|
-
# Returns (or lazily creates) the {Actor} for +thread_id+.
|
|
21
|
-
#
|
|
22
|
-
# When +Phronomy.configuration.max_actors+ is set, the registry evicts the
|
|
23
|
-
# least-recently-used Actor (by stopping it) before inserting a new one.
|
|
24
|
-
# Accessing an existing Actor moves it to the most-recently-used position.
|
|
25
|
-
#
|
|
26
|
-
# @param thread_id [String]
|
|
27
|
-
# @return [Phronomy::Actor]
|
|
28
|
-
def for(thread_id)
|
|
29
|
-
@registry_actor.call do
|
|
30
|
-
if @actors.key?(thread_id)
|
|
31
|
-
# LRU touch: move to end (most-recently used)
|
|
32
|
-
actor = @actors.delete(thread_id)
|
|
33
|
-
@actors[thread_id] = actor
|
|
34
|
-
else
|
|
35
|
-
evict_lru_if_needed!
|
|
36
|
-
@actors[thread_id] = Actor.new
|
|
37
|
-
end
|
|
38
|
-
@actors[thread_id]
|
|
39
|
-
end
|
|
40
|
-
end
|
|
41
|
-
|
|
42
|
-
# Returns the current number of registered Actors.
|
|
43
|
-
#
|
|
44
|
-
# @return [Integer]
|
|
45
|
-
def actor_count
|
|
46
|
-
@registry_actor.call { @actors.size }
|
|
47
|
-
end
|
|
48
|
-
|
|
49
|
-
# Gracefully stops the Actor for +thread_id+ and removes it from the
|
|
50
|
-
# registry. The next call to {.for} with the same id creates a fresh Actor.
|
|
51
|
-
#
|
|
52
|
-
# @param thread_id [String]
|
|
53
|
-
def stop(thread_id)
|
|
54
|
-
@registry_actor.call { @actors.delete(thread_id) }&.stop
|
|
55
|
-
end
|
|
56
|
-
|
|
57
|
-
# Stops and removes every registered Actor.
|
|
58
|
-
# Intended for test teardown and process shutdown.
|
|
59
|
-
def clear_all
|
|
60
|
-
actors = @registry_actor.call { @actors.values.tap { @actors.clear } }
|
|
61
|
-
actors.each(&:stop)
|
|
62
|
-
end
|
|
63
|
-
|
|
64
|
-
# Yields each currently registered Actor.
|
|
65
|
-
# A snapshot is taken so the registry cannot change while callers iterate.
|
|
66
|
-
#
|
|
67
|
-
# @yield [Phronomy::Actor]
|
|
68
|
-
def each_actor(&block)
|
|
69
|
-
@registry_actor.call { @actors.values.dup }.each(&block)
|
|
70
|
-
end
|
|
71
|
-
|
|
72
|
-
private
|
|
73
|
-
|
|
74
|
-
# Evicts the least-recently-used Actor when the registry is at capacity.
|
|
75
|
-
# Must be called from within @registry_actor.call { } to be thread-safe.
|
|
76
|
-
def evict_lru_if_needed!
|
|
77
|
-
max = Phronomy.configuration.max_actors
|
|
78
|
-
return unless max && @actors.size >= max
|
|
79
|
-
|
|
80
|
-
_lru_id, lru_actor = @actors.shift
|
|
81
|
-
lru_actor.stop
|
|
82
|
-
end
|
|
83
|
-
end
|
|
84
|
-
end
|
|
85
|
-
end
|