phronomy 0.1.4 → 0.2.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 +56 -0
- data/README.md +49 -38
- data/docs/trustworthy_ai_enhancements.md +4 -4
- data/lib/phronomy/actor.rb +68 -0
- data/lib/phronomy/agent/base.rb +80 -52
- data/lib/phronomy/context/context_version_cache.rb +10 -33
- data/lib/phronomy/memory/conversation_manager.rb +9 -38
- data/lib/phronomy/memory/retrieval/semantic.rb +7 -7
- data/lib/phronomy/memory/storage/active_record.rb +20 -0
- data/lib/phronomy/memory/storage/base.rb +22 -0
- data/lib/phronomy/memory/storage/in_memory.rb +65 -26
- data/lib/phronomy/state_store/active_record.rb +1 -1
- data/lib/phronomy/state_store/base.rb +14 -16
- data/lib/phronomy/state_store/in_memory.rb +23 -10
- data/lib/phronomy/state_store/redis.rb +1 -1
- data/lib/phronomy/thread_actor_registry.rb +52 -0
- data/lib/phronomy/tool/base.rb +1 -1
- data/lib/phronomy/tool/mcp_tool.rb +10 -9
- data/lib/phronomy/tracing/langfuse_tracer.rb +3 -3
- data/lib/phronomy/trust_pipeline.rb +41 -49
- data/lib/phronomy/vector_store/in_memory.rb +5 -7
- data/lib/phronomy/vector_store/redis_search.rb +4 -6
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +281 -0
- data/lib/phronomy/workflow_context.rb +119 -0
- data/lib/phronomy/workflow_runner.rb +262 -0
- data/lib/phronomy.rb +30 -34
- metadata +25 -10
- data/lib/phronomy/graph/compiled_graph.rb +0 -191
- data/lib/phronomy/graph/parallel_node.rb +0 -193
- data/lib/phronomy/graph/state.rb +0 -105
- data/lib/phronomy/graph/state_graph.rb +0 -149
- data/lib/phronomy/graph.rb +0 -13
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
-
|
|
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
|
-
|
|
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::
|
|
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::
|
|
11
|
-
# and have a non-nil thread_id (set automatically by
|
|
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::
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
64
|
-
# When a registry has been configured via +Phronomy
|
|
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::
|
|
66
|
+
# that the resolved class includes Phronomy::WorkflowContext.
|
|
69
67
|
def safe_state_class(class_name)
|
|
70
|
-
registry = Phronomy
|
|
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
|
|
76
|
-
"Call Phronomy
|
|
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::
|
|
83
|
-
raise ArgumentError, "Invalid
|
|
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
|
|
85
|
+
raise ArgumentError, "Unknown context class: #{class_name.inspect}"
|
|
88
86
|
end
|
|
89
87
|
|
|
90
88
|
# Recursively converts objects to JSON-safe primitives.
|
|
@@ -2,37 +2,50 @@
|
|
|
2
2
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
module StateStore
|
|
5
|
-
# In-memory state store
|
|
6
|
-
#
|
|
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::
|
|
15
|
+
# @param state [Object] includes Phronomy::WorkflowContext; must have a non-nil thread_id
|
|
15
16
|
# @return [self]
|
|
16
17
|
def save(state)
|
|
17
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
data/lib/phronomy/tool/base.rb
CHANGED
|
@@ -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
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
99
|
+
@actor.call { @http = nil }
|
|
100
100
|
warn "[Phronomy::LangfuseTracer] Ingestion failed: #{e.class}: #{e.message}"
|
|
101
101
|
nil
|
|
102
102
|
rescue => e
|
|
@@ -56,7 +56,7 @@ module Phronomy
|
|
|
56
56
|
# Internal graph state — not part of the public API.
|
|
57
57
|
# @private
|
|
58
58
|
class PipelineState
|
|
59
|
-
include Phronomy::
|
|
59
|
+
include Phronomy::WorkflowContext
|
|
60
60
|
|
|
61
61
|
field :input, type: :replace, default: -> { "" }
|
|
62
62
|
field :draft, type: :replace, default: -> {}
|
|
@@ -89,7 +89,7 @@ module Phronomy
|
|
|
89
89
|
@threshold = confidence_threshold.to_f
|
|
90
90
|
@max_iterations = max_iterations.to_i
|
|
91
91
|
@input_delimiter = input_delimiter
|
|
92
|
-
@
|
|
92
|
+
@actor = Phronomy::Actor.new
|
|
93
93
|
@compiled_graph = nil
|
|
94
94
|
end
|
|
95
95
|
|
|
@@ -118,66 +118,58 @@ module Phronomy
|
|
|
118
118
|
[(state.self_score || 0.0).to_f, (state.review_score || 0.0).to_f].min
|
|
119
119
|
end
|
|
120
120
|
|
|
121
|
-
# Returns the compiled
|
|
122
|
-
# Thread-safe via double-checked locking.
|
|
121
|
+
# Returns the compiled workflow, building and caching it on first call.
|
|
123
122
|
def compiled_graph
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
@graph_mutex.synchronize do
|
|
127
|
-
@compiled_graph ||= build_graph.compile
|
|
128
|
-
end
|
|
123
|
+
@actor.call { @compiled_graph ||= build_workflow }
|
|
129
124
|
end
|
|
130
125
|
|
|
131
|
-
def
|
|
126
|
+
def build_workflow
|
|
132
127
|
draft_agent = @draft_agent_class.new
|
|
133
128
|
review_agent = @review_agent_class.new
|
|
134
129
|
threshold = @threshold
|
|
135
130
|
max_iter = @max_iterations
|
|
131
|
+
pipeline = self
|
|
136
132
|
|
|
137
|
-
|
|
133
|
+
Phronomy::Workflow.define(PipelineState) do
|
|
134
|
+
initial :draft
|
|
138
135
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
136
|
+
state :draft, action: ->(state) {
|
|
137
|
+
feedback = state.review_notes.last
|
|
138
|
+
prompt = pipeline.__send__(:draft_prompt, state.input, feedback)
|
|
139
|
+
result = draft_agent.invoke(prompt)
|
|
140
|
+
parsed = pipeline.__send__(:safe_parse_draft, result[:output])
|
|
141
|
+
state.merge(
|
|
142
|
+
draft: parsed[:answer].to_s,
|
|
143
|
+
self_score: pipeline.__send__(:clamp, parsed[:confidence]),
|
|
144
|
+
citations: pipeline.__send__(:normalize_citations, parsed[:citations]),
|
|
145
|
+
iteration: state.iteration + 1
|
|
146
|
+
)
|
|
147
|
+
}
|
|
151
148
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
149
|
+
state :review, action: ->(state) {
|
|
150
|
+
prompt = pipeline.__send__(:review_prompt, state.input, state.draft, state.citations)
|
|
151
|
+
result = review_agent.invoke(prompt)
|
|
152
|
+
parsed = pipeline.__send__(:safe_parse_review, result[:output])
|
|
153
|
+
state.merge(
|
|
154
|
+
review_score: pipeline.__send__(:clamp, parsed[:score]),
|
|
155
|
+
approved: parsed[:approved] == true,
|
|
156
|
+
review_notes: parsed[:feedback].to_s
|
|
157
|
+
)
|
|
158
|
+
}
|
|
162
159
|
|
|
163
|
-
|
|
164
|
-
state.merge(output: state.draft)
|
|
165
|
-
end
|
|
160
|
+
state :finalize, action: ->(state) { state.merge(output: state.draft) }
|
|
166
161
|
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
graph.add_conditional_edges(
|
|
170
|
-
:review,
|
|
171
|
-
->(state) {
|
|
172
|
-
confidence = [state.self_score || 0.0, state.review_score || 0.0].min
|
|
173
|
-
passed = confidence >= threshold && state.approved
|
|
174
|
-
exhausted = state.iteration >= max_iter
|
|
175
|
-
(passed || exhausted) ? :finalize : :draft
|
|
176
|
-
}
|
|
177
|
-
)
|
|
178
|
-
graph.add_edge(:finalize, Phronomy::Graph::StateGraph::FINISH)
|
|
162
|
+
after :draft, to: :review
|
|
163
|
+
after :finalize, to: :__finish__
|
|
179
164
|
|
|
180
|
-
|
|
165
|
+
event :route_review, from: :review,
|
|
166
|
+
guard: ->(state) {
|
|
167
|
+
confidence = [state.self_score || 0.0, state.review_score || 0.0].min
|
|
168
|
+
(confidence >= threshold && state.approved) || state.iteration >= max_iter
|
|
169
|
+
},
|
|
170
|
+
to: :finalize
|
|
171
|
+
event :route_review, from: :review, to: :draft
|
|
172
|
+
end
|
|
181
173
|
end
|
|
182
174
|
|
|
183
175
|
# Wraps +input+ with the configured delimiter pair when +input_delimiter+ is set.
|