phronomy 0.1.4 → 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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -0
  3. data/README.md +49 -38
  4. data/docs/trustworthy_ai_enhancements.md +4 -4
  5. data/lib/phronomy/actor.rb +68 -0
  6. data/lib/phronomy/agent/base.rb +80 -52
  7. data/lib/phronomy/context/context_version_cache.rb +10 -33
  8. data/lib/phronomy/memory/conversation_manager.rb +9 -38
  9. data/lib/phronomy/memory/retrieval/semantic.rb +7 -7
  10. data/lib/phronomy/memory/storage/active_record.rb +20 -0
  11. data/lib/phronomy/memory/storage/base.rb +22 -0
  12. data/lib/phronomy/memory/storage/in_memory.rb +65 -26
  13. data/lib/phronomy/state_store/active_record.rb +1 -1
  14. data/lib/phronomy/state_store/base.rb +14 -16
  15. data/lib/phronomy/state_store/file.rb +85 -0
  16. data/lib/phronomy/state_store/in_memory.rb +23 -10
  17. data/lib/phronomy/state_store/redis.rb +1 -1
  18. data/lib/phronomy/thread_actor_registry.rb +52 -0
  19. data/lib/phronomy/tool/base.rb +1 -1
  20. data/lib/phronomy/tool/mcp_tool.rb +10 -9
  21. data/lib/phronomy/tracing/langfuse_tracer.rb +3 -3
  22. data/lib/phronomy/trust_pipeline.rb +41 -49
  23. data/lib/phronomy/vector_store/in_memory.rb +5 -7
  24. data/lib/phronomy/vector_store/redis_search.rb +4 -6
  25. data/lib/phronomy/version.rb +1 -1
  26. data/lib/phronomy/workflow.rb +221 -0
  27. data/lib/phronomy/workflow_context.rb +119 -0
  28. data/lib/phronomy/workflow_runner.rb +285 -0
  29. data/lib/phronomy.rb +30 -34
  30. metadata +26 -10
  31. data/lib/phronomy/graph/compiled_graph.rb +0 -191
  32. data/lib/phronomy/graph/parallel_node.rb +0 -193
  33. data/lib/phronomy/graph/state.rb +0 -105
  34. data/lib/phronomy/graph/state_graph.rb +0 -149
  35. data/lib/phronomy/graph.rb +0 -13
@@ -28,7 +28,7 @@ module Phronomy
28
28
  @index = {} # id => message (insertion-ordered via Ruby Hash)
29
29
  @counter = 0
30
30
  @max_index_size = max_index_size
31
- @mutex = Mutex.new
31
+ @actor = Phronomy::Actor.new
32
32
  @indexed_object_ids = {} # thread_id => { object_id => true }
33
33
  end
34
34
 
@@ -43,14 +43,14 @@ module Phronomy
43
43
  def index(thread_id:, messages:)
44
44
  messages.each do |msg|
45
45
  # Fast path: skip already-indexed messages without calling embed.
46
- already_indexed = @mutex.synchronize do
46
+ already_indexed = @actor.call do
47
47
  (@indexed_object_ids[thread_id] ||= {})[msg.object_id]
48
48
  end
49
49
  next if already_indexed
50
50
 
51
51
  embedding = @embeddings.embed(msg.content.to_s)
52
- @mutex.synchronize do
53
- # Re-check inside lock to handle concurrent callers for the same thread.
52
+ @actor.call do
53
+ # Re-check inside Actor to handle concurrent callers for the same thread.
54
54
  indexed = (@indexed_object_ids[thread_id] ||= {})
55
55
  next if indexed[msg.object_id]
56
56
 
@@ -68,7 +68,7 @@ module Phronomy
68
68
  #
69
69
  # @param thread_id [String]
70
70
  def clear_index(thread_id:)
71
- @mutex.synchronize do
71
+ @actor.call do
72
72
  ids = @index.keys.select { |id| id.start_with?("#{thread_id}:") }
73
73
  ids.each do |id|
74
74
  @index.delete(id)
@@ -87,7 +87,7 @@ module Phronomy
87
87
  def select(messages, query: nil, thread_id: nil)
88
88
  if query && !query.strip.empty?
89
89
  query_embedding = @embeddings.embed(query)
90
- results = @mutex.synchronize { @store.search(query_embedding: query_embedding, k: @k * 3) }
90
+ results = @actor.call { @store.search(query_embedding: query_embedding, k: @k * 3) }
91
91
  results
92
92
  .select { |r| thread_id.nil? || r[:metadata][:thread_id] == thread_id }
93
93
  .first(@k)
@@ -100,7 +100,7 @@ module Phronomy
100
100
  private
101
101
 
102
102
  # Evicts the oldest index entry to enforce max_index_size.
103
- # Must be called inside @mutex.synchronize.
103
+ # Must be called inside the Actor.
104
104
  def evict_oldest!
105
105
  oldest_id = @index.keys.first
106
106
  return unless oldest_id
@@ -170,6 +170,26 @@ module Phronomy
170
170
  @model_class.where(thread_id: thread_id).where("created_at < ?", older_than).delete_all
171
171
  end
172
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
+
173
193
  private
174
194
 
175
195
  def ensure_raw_model!
@@ -127,6 +127,28 @@ module Phronomy
127
127
  def purge_older_than(thread_id:, older_than:)
128
128
  # no-op by default
129
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
130
152
  end
131
153
  end
132
154
  end
@@ -3,18 +3,19 @@
3
3
  module Phronomy
4
4
  module Memory
5
5
  module Storage
6
- # In-process Hash-backed storage for conversation messages.
6
+ # In-process storage for conversation messages backed by per-thread-id
7
+ # {Phronomy::Actor} instances from {Phronomy::ThreadActorRegistry}.
7
8
  # Messages are lost when the process exits.
8
9
  #
9
10
  # @example
10
11
  # storage = Phronomy::Memory::Storage::InMemory.new
11
12
  # manager = Phronomy::Memory::ConversationManager.new(storage: storage, ...)
12
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
+
13
18
  def initialize
14
- @mutex = Mutex.new
15
- @store = {}
16
- @raw_store = {} # thread_id => [{seq:, message:}, ...]
17
- @compaction_store = {} # thread_id => [{start_seq:, end_seq:, summary_text:}, ...]
18
19
  end
19
20
 
20
21
  # -----------------------------------------------------------------------
@@ -24,21 +25,20 @@ module Phronomy
24
25
  # @param thread_id [String]
25
26
  # @return [Array]
26
27
  def load(thread_id:)
27
- @mutex.synchronize { (@store[thread_id] || []).dup }
28
+ Phronomy::ThreadActorRegistry.for(thread_id).call { (thread_data.store || []).dup }
28
29
  end
29
30
 
30
31
  # @param thread_id [String]
31
32
  # @param messages [Array]
32
33
  def save(thread_id:, messages:)
33
- @mutex.synchronize { @store[thread_id] = messages.dup }
34
+ Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.store = messages.dup }
34
35
  end
35
36
 
36
37
  # @param thread_id [String]
37
38
  def clear(thread_id:)
38
- @mutex.synchronize do
39
- @store.delete(thread_id)
40
- @raw_store.delete(thread_id)
41
- @compaction_store.delete(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
42
  end
43
43
  end
44
44
 
@@ -49,25 +49,45 @@ module Phronomy
49
49
  # @param thread_id [String]
50
50
  # @param messages [Array]
51
51
  # @param starting_seq [Integer]
52
- def append_raw(thread_id:, messages:, starting_seq:)
53
- now = Time.now
54
- @mutex.synchronize do
55
- @raw_store[thread_id] ||= []
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
56
57
  messages.each_with_index do |msg, i|
57
- @raw_store[thread_id] << {seq: starting_seq + i, message: msg, recorded_at: now}
58
+ seq = starting_seq + i
59
+ data.raw_messages << {seq: seq, message: msg, recorded_at: now}
60
+ data.hwm = [data.hwm, seq].max
58
61
  end
59
62
  end
60
63
  end
61
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
+
62
82
  # @param thread_id [String]
63
83
  # @return [Array<Hash>]
64
84
  def load_raw(thread_id:)
65
- @mutex.synchronize { (@raw_store[thread_id] || []).dup }
85
+ Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.raw_messages.dup }
66
86
  end
67
87
 
68
88
  # @param thread_id [String]
69
89
  def clear_raw(thread_id:)
70
- @mutex.synchronize { @raw_store.delete(thread_id) }
90
+ Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.raw_messages.clear }
71
91
  end
72
92
 
73
93
  # -----------------------------------------------------------------------
@@ -79,21 +99,20 @@ module Phronomy
79
99
  # @param end_seq [Integer]
80
100
  # @param summary_text [String]
81
101
  def save_compaction(thread_id:, start_seq:, end_seq:, summary_text:)
82
- @mutex.synchronize do
83
- @compaction_store[thread_id] ||= []
84
- @compaction_store[thread_id] << {start_seq: start_seq, end_seq: end_seq, summary_text: 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}
85
104
  end
86
105
  end
87
106
 
88
107
  # @param thread_id [String]
89
108
  # @return [Array<Hash>]
90
109
  def load_compactions(thread_id:)
91
- @mutex.synchronize { (@compaction_store[thread_id] || []).dup }
110
+ Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.compactions.dup }
92
111
  end
93
112
 
94
113
  # @param thread_id [String]
95
114
  def clear_compactions(thread_id:)
96
- @mutex.synchronize { @compaction_store.delete(thread_id) }
115
+ Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.compactions.clear }
97
116
  end
98
117
 
99
118
  # Remove raw messages recorded before +older_than+ for this thread.
@@ -101,10 +120,30 @@ module Phronomy
101
120
  # @param thread_id [String]
102
121
  # @param older_than [Time]
103
122
  def purge_older_than(thread_id:, older_than:)
104
- @mutex.synchronize do
105
- next unless @raw_store[thread_id]
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
106
141
 
107
- @raw_store[thread_id].reject! { |entry| entry[:recorded_at] && entry[:recorded_at] < older_than }
142
+ def initialize
143
+ @store = nil
144
+ @raw_messages = []
145
+ @compactions = []
146
+ @hwm = -1
108
147
  end
109
148
  end
110
149
  end
@@ -38,7 +38,7 @@ module Phronomy
38
38
  end
39
39
 
40
40
  # Serializes and upserts the state for the given thread_id.
41
- # @param state [Object] includes Phronomy::Graph::State
41
+ # @param state [Object] includes Phronomy::WorkflowContext
42
42
  # @return [self]
43
43
  def save(state)
44
44
  json = serialize_state(state)
@@ -7,11 +7,11 @@ module Phronomy
7
7
  # Abstract base class for state persistence backends.
8
8
  # Subclasses must implement save, load, and clear.
9
9
  #
10
- # The state object passed to save must include Phronomy::Graph::State
11
- # and have a non-nil thread_id (set automatically by CompiledGraph#invoke).
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
12
  class Base
13
13
  # Persists the state. The thread_id is read from state.thread_id.
14
- # @param state [Object] object including Phronomy::Graph::State
14
+ # @param state [Object] object including Phronomy::WorkflowContext
15
15
  # @return [self]
16
16
  def save(state)
17
17
  raise NotImplementedError, "#{self.class}#save is not implemented"
@@ -40,8 +40,7 @@ module Phronomy
40
40
  state_class: state.class.name,
41
41
  state_data: json_safe(state.to_h),
42
42
  thread_id: state.thread_id,
43
- current_nodes: state.current_nodes&.map(&:to_s),
44
- halted_before: state.halted_before
43
+ phase: state.phase&.to_s
45
44
  )
46
45
  end
47
46
 
@@ -54,37 +53,36 @@ module Phronomy
54
53
  state = state_class.new(**state_data)
55
54
  state.set_graph_metadata(
56
55
  thread_id: data[:thread_id],
57
- current_nodes: data[:current_nodes]&.map(&:to_sym),
58
- halted_before: data[:halted_before]
56
+ phase: data[:phase]&.to_sym
59
57
  )
60
58
  state
61
59
  end
62
60
 
63
- # Resolves and validates a state class name.
64
- # When a registry has been configured via +Phronomy::Graph.register_state_class+,
61
+ # Resolves and validates a context class name.
62
+ # When a registry has been configured via +Phronomy.register_workflow_context+,
65
63
  # only registered classes are accepted — this prevents unintended autoloading
66
64
  # of arbitrary files from an untrusted class name stored in Redis/DB.
67
65
  # When no registry is configured, falls back to Object.const_get with a check
68
- # that the resolved class includes Phronomy::Graph::State.
66
+ # that the resolved class includes Phronomy::WorkflowContext.
69
67
  def safe_state_class(class_name)
70
- registry = Phronomy::Graph.state_class_registry
68
+ registry = Phronomy.workflow_context_registry
71
69
  if registry
72
70
  klass = registry[class_name.to_s]
73
71
  unless klass
74
72
  raise ArgumentError,
75
- "Unregistered state class: #{class_name.inspect}. " \
76
- "Call Phronomy::Graph.register_state_class(#{class_name}) at startup."
73
+ "Unregistered context class: #{class_name.inspect}. " \
74
+ "Call Phronomy.register_workflow_context(#{class_name}) at startup."
77
75
  end
78
76
  return klass
79
77
  end
80
78
 
81
79
  klass = Object.const_get(class_name.to_s)
82
- unless klass.is_a?(Class) && klass.include?(Phronomy::Graph::State)
83
- raise ArgumentError, "Invalid state class: #{class_name.inspect}"
80
+ unless klass.is_a?(Class) && klass.include?(Phronomy::WorkflowContext)
81
+ raise ArgumentError, "Invalid context class: #{class_name.inspect}"
84
82
  end
85
83
  klass
86
84
  rescue NameError
87
- raise ArgumentError, "Unknown state class: #{class_name.inspect}"
85
+ raise ArgumentError, "Unknown context class: #{class_name.inspect}"
88
86
  end
89
87
 
90
88
  # Recursively converts objects to JSON-safe primitives.
@@ -0,0 +1,85 @@
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
@@ -2,37 +2,50 @@
2
2
 
3
3
  module Phronomy
4
4
  module StateStore
5
- # In-memory state store. Stores state objects keyed by thread_id.
6
- # State objects are stored directly (no serialization), so this
7
- # backend is suitable for single-process use only.
5
+ # In-memory state store backed by per-thread-id {Phronomy::Actor} instances
6
+ # from {Phronomy::ThreadActorRegistry}. Suitable for single-process use only.
8
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
+
9
12
  def initialize
10
- @store = {}
11
- @mutex = Mutex.new
12
13
  end
13
14
 
14
- # @param state [Object] includes Phronomy::Graph::State; must have a non-nil thread_id
15
+ # @param state [Object] includes Phronomy::WorkflowContext; must have a non-nil thread_id
15
16
  # @return [self]
16
17
  def save(state)
17
- @mutex.synchronize { @store[state.thread_id] = 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
18
22
  self
19
23
  end
20
24
 
21
25
  # @param thread_id [String]
22
26
  # @return [Object, nil] state object or nil
23
27
  def load(thread_id)
24
- @mutex.synchronize { @store[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
25
32
  end
26
33
 
27
34
  # @param thread_id [String]
28
35
  # @return [self]
29
36
  def clear(thread_id)
30
- @mutex.synchronize { @store.delete(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
31
41
  self
32
42
  end
33
43
 
34
44
  def clear_all
35
- @mutex.synchronize { @store.clear }
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
36
49
  self
37
50
  end
38
51
  end
@@ -33,7 +33,7 @@ module Phronomy
33
33
  @ttl = ttl
34
34
  end
35
35
 
36
- # @param state [Object] includes Phronomy::Graph::State
36
+ # @param state [Object] includes Phronomy::WorkflowContext
37
37
  # @return [self]
38
38
  def save(state)
39
39
  serialized = serialize_state(state)
@@ -0,0 +1,52 @@
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
+ # @param thread_id [String]
23
+ # @return [Phronomy::Actor]
24
+ def for(thread_id)
25
+ @registry_actor.call { @actors[thread_id] ||= Actor.new }
26
+ end
27
+
28
+ # Gracefully stops the Actor for +thread_id+ and removes it from the
29
+ # registry. The next call to {.for} with the same id creates a fresh Actor.
30
+ #
31
+ # @param thread_id [String]
32
+ def stop(thread_id)
33
+ @registry_actor.call { @actors.delete(thread_id) }&.stop
34
+ end
35
+
36
+ # Stops and removes every registered Actor.
37
+ # Intended for test teardown and process shutdown.
38
+ def clear_all
39
+ actors = @registry_actor.call { @actors.values.tap { @actors.clear } }
40
+ actors.each(&:stop)
41
+ end
42
+
43
+ # Yields each currently registered Actor.
44
+ # A snapshot is taken so the registry cannot change while callers iterate.
45
+ #
46
+ # @yield [Phronomy::Actor]
47
+ def each_actor(&block)
48
+ @registry_actor.call { @actors.values.dup }.each(&block)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -58,7 +58,7 @@ module Phronomy
58
58
  end
59
59
 
60
60
  # Sets the access scope for this tool (metadata; enforcement is the responsibility of
61
- # the Graph/Guardrail layer).
61
+ # the Workflow/Guardrail layer).
62
62
  # @param value [Symbol] e.g. :read_only, :write, :admin
63
63
  def scope(value = nil)
64
64
  return @scope if value.nil?
@@ -87,7 +87,7 @@ module Phronomy
87
87
  # Split the command string into an argv array so that Open3 executes
88
88
  # it directly without going through the shell, preventing injection.
89
89
  @command = Shellwords.split(command)
90
- @mutex = Mutex.new
90
+ @actor = Phronomy::Actor.new
91
91
  @stdin = nil
92
92
  @stdout = nil
93
93
  @stderr = nil
@@ -97,20 +97,21 @@ module Phronomy
97
97
 
98
98
  # Shut down the child process and close its IO streams.
99
99
  def close
100
- @mutex.synchronize do
100
+ stderr_thread, wait_thr = @actor.call do
101
101
  @stdin&.close
102
102
  @stdout&.close
103
103
  @stderr&.close
104
104
  @stdin = nil
105
105
  @stdout = nil
106
106
  @stderr = nil
107
+ t = [@stderr_thread, @wait_thr]
108
+ @stderr_thread = nil
109
+ @wait_thr = nil
110
+ t
107
111
  end
108
- # Join the stderr drain thread and the child process outside the mutex
109
- # to avoid holding the lock during potentially slow joins.
110
- @stderr_thread&.join(1)
111
- @wait_thr&.join(5)
112
- @stderr_thread = nil
113
- @wait_thr = nil
112
+ # Join outside the Actor to avoid blocking the Actor thread on slow joins.
113
+ stderr_thread&.join(1)
114
+ wait_thr&.join(5)
114
115
  end
115
116
 
116
117
  # Retrieve the tool definition from the server using the MCP `tools/list` method.
@@ -167,7 +168,7 @@ module Phronomy
167
168
  end
168
169
 
169
170
  def rpc_call(method, params)
170
- @mutex.synchronize do
171
+ @actor.call do
171
172
  ensure_started!
172
173
  payload = JSON.generate(jsonrpc: "2.0", id: SecureRandom.uuid, method: method, params: params)
173
174
  @stdin.puts(payload)
@@ -36,7 +36,7 @@ module Phronomy
36
36
  @secret_key = secret_key
37
37
  @host = host.chomp("/")
38
38
  @http = nil
39
- @http_mutex = Mutex.new
39
+ @actor = Phronomy::Actor.new
40
40
  end
41
41
 
42
42
  # Returns a plain Hash that records the span start state.
@@ -90,13 +90,13 @@ module Phronomy
90
90
  req["Authorization"] = "Basic #{Base64.strict_encode64("#{@public_key}:#{@secret_key}")}"
91
91
  req.body = JSON.generate({batch: events})
92
92
 
93
- @http_mutex.synchronize do
93
+ @actor.call do
94
94
  @http ||= build_http(uri)
95
95
  @http.request(req)
96
96
  end
97
97
  rescue IOError, Errno::ECONNRESET, Errno::EPIPE => e
98
98
  # Connection was reset; drop the cached connection and warn.
99
- @http_mutex.synchronize { @http = nil }
99
+ @actor.call { @http = nil }
100
100
  warn "[Phronomy::LangfuseTracer] Ingestion failed: #{e.class}: #{e.message}"
101
101
  nil
102
102
  rescue => e