phronomy 0.1.3 → 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/generators/phronomy/install/templates/create_phronomy_messages.rb.tt +1 -1
- data/lib/phronomy/actor.rb +68 -0
- data/lib/phronomy/agent/base.rb +125 -91
- data/lib/phronomy/agent/handoff.rb +2 -2
- data/lib/phronomy/agent/react_agent.rb +51 -33
- data/lib/phronomy/context/assembler.rb +11 -3
- data/lib/phronomy/context/compaction_context.rb +1 -3
- data/lib/phronomy/context/context_version_cache.rb +7 -16
- data/lib/phronomy/eval/runner.rb +39 -11
- data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +47 -3
- data/lib/phronomy/memory/compression/summary.rb +4 -3
- data/lib/phronomy/memory/compression/tool_output_pruner.rb +11 -6
- data/lib/phronomy/memory/conversation_manager.rb +25 -16
- data/lib/phronomy/memory/retrieval/semantic.rb +21 -5
- data/lib/phronomy/memory/storage/active_record.rb +32 -10
- 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 -9
- data/lib/phronomy/state_store/redis.rb +1 -1
- data/lib/phronomy/thread_actor_registry.rb +52 -0
- data/lib/phronomy/tool/base.rb +9 -2
- data/lib/phronomy/tool/mcp_tool.rb +28 -4
- data/lib/phronomy/tracing/base.rb +0 -2
- data/lib/phronomy/tracing/langfuse_tracer.rb +24 -6
- data/lib/phronomy/tracing/null_tracer.rb +6 -3
- data/lib/phronomy/trust_pipeline.rb +60 -52
- data/lib/phronomy/vector_store/redis_search.rb +28 -23
- 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 -183
- 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 -148
- data/lib/phronomy/graph.rb +0 -13
|
@@ -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,36 +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
13
|
end
|
|
12
14
|
|
|
13
|
-
# @param state [Object] includes Phronomy::
|
|
15
|
+
# @param state [Object] includes Phronomy::WorkflowContext; must have a non-nil thread_id
|
|
14
16
|
# @return [self]
|
|
15
17
|
def save(state)
|
|
16
|
-
|
|
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
|
|
17
22
|
self
|
|
18
23
|
end
|
|
19
24
|
|
|
20
25
|
# @param thread_id [String]
|
|
21
26
|
# @return [Object, nil] state object or nil
|
|
22
27
|
def load(thread_id)
|
|
23
|
-
|
|
28
|
+
store_id = object_id
|
|
29
|
+
Phronomy::ThreadActorRegistry.for(thread_id).call do
|
|
30
|
+
(Thread.current[THREAD_DATA_KEY] ||= {})[store_id]
|
|
31
|
+
end
|
|
24
32
|
end
|
|
25
33
|
|
|
26
34
|
# @param thread_id [String]
|
|
27
35
|
# @return [self]
|
|
28
36
|
def clear(thread_id)
|
|
29
|
-
|
|
37
|
+
store_id = object_id
|
|
38
|
+
Phronomy::ThreadActorRegistry.for(thread_id).call do
|
|
39
|
+
(Thread.current[THREAD_DATA_KEY] ||= {}).delete(store_id)
|
|
40
|
+
end
|
|
30
41
|
self
|
|
31
42
|
end
|
|
32
43
|
|
|
33
44
|
def clear_all
|
|
34
|
-
|
|
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
|
|
35
49
|
self
|
|
36
50
|
end
|
|
37
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?
|
|
@@ -157,7 +157,14 @@ module Phronomy
|
|
|
157
157
|
key = properties.key?(param_name.to_s) ? param_name.to_s : param_name.to_sym
|
|
158
158
|
next unless properties[key]
|
|
159
159
|
|
|
160
|
-
properties[key]["
|
|
160
|
+
param_type = properties[key]["type"]
|
|
161
|
+
properties[key]["enum"] = values.map do |v|
|
|
162
|
+
case param_type
|
|
163
|
+
when "integer" then v.is_a?(Integer) ? v : Integer(v.to_s)
|
|
164
|
+
when "number" then v.is_a?(Numeric) ? v : Float(v.to_s)
|
|
165
|
+
else v.to_s
|
|
166
|
+
end
|
|
167
|
+
end
|
|
161
168
|
end
|
|
162
169
|
|
|
163
170
|
schema
|
|
@@ -87,19 +87,31 @@ 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
|
+
@stderr = nil
|
|
94
|
+
@wait_thr = nil
|
|
95
|
+
@stderr_thread = nil
|
|
93
96
|
end
|
|
94
97
|
|
|
95
98
|
# Shut down the child process and close its IO streams.
|
|
96
99
|
def close
|
|
97
|
-
@
|
|
100
|
+
stderr_thread, wait_thr = @actor.call do
|
|
98
101
|
@stdin&.close
|
|
99
102
|
@stdout&.close
|
|
103
|
+
@stderr&.close
|
|
100
104
|
@stdin = nil
|
|
101
105
|
@stdout = nil
|
|
106
|
+
@stderr = nil
|
|
107
|
+
t = [@stderr_thread, @wait_thr]
|
|
108
|
+
@stderr_thread = nil
|
|
109
|
+
@wait_thr = nil
|
|
110
|
+
t
|
|
102
111
|
end
|
|
112
|
+
# Join outside the Actor to avoid blocking the Actor thread on slow joins.
|
|
113
|
+
stderr_thread&.join(1)
|
|
114
|
+
wait_thr&.join(5)
|
|
103
115
|
end
|
|
104
116
|
|
|
105
117
|
# Retrieve the tool definition from the server using the MCP `tools/list` method.
|
|
@@ -144,11 +156,19 @@ module Phronomy
|
|
|
144
156
|
def ensure_started!
|
|
145
157
|
return if @stdin && !@stdin.closed?
|
|
146
158
|
|
|
147
|
-
@stdin, @stdout,
|
|
159
|
+
@stdin, @stdout, @stderr, @wait_thr = Open3.popen3(*@command)
|
|
160
|
+
# Drain stderr asynchronously to prevent the pipe buffer from filling
|
|
161
|
+
# and deadlocking the child process. Errors inside the drain thread are
|
|
162
|
+
# silently ignored since stderr content is diagnostics-only.
|
|
163
|
+
@stderr_thread = Thread.new do
|
|
164
|
+
@stderr.read
|
|
165
|
+
rescue
|
|
166
|
+
nil
|
|
167
|
+
end
|
|
148
168
|
end
|
|
149
169
|
|
|
150
170
|
def rpc_call(method, params)
|
|
151
|
-
@
|
|
171
|
+
@actor.call do
|
|
152
172
|
ensure_started!
|
|
153
173
|
payload = JSON.generate(jsonrpc: "2.0", id: SecureRandom.uuid, method: method, params: params)
|
|
154
174
|
@stdin.puts(payload)
|
|
@@ -212,6 +232,10 @@ module Phronomy
|
|
|
212
232
|
# @return [Object] the tool result
|
|
213
233
|
def call_tool(tool_name, args)
|
|
214
234
|
response = rpc_call("tools/call", {name: tool_name, arguments: args})
|
|
235
|
+
if response["error"]
|
|
236
|
+
err_msg = response.dig("error", "message") || response["error"].to_s
|
|
237
|
+
raise Phronomy::ToolError, "MCP HTTP server returned error: #{err_msg}"
|
|
238
|
+
end
|
|
215
239
|
content = response.dig("result", "content")
|
|
216
240
|
|
|
217
241
|
if content.is_a?(Array)
|
|
@@ -35,6 +35,8 @@ module Phronomy
|
|
|
35
35
|
@public_key = public_key
|
|
36
36
|
@secret_key = secret_key
|
|
37
37
|
@host = host.chomp("/")
|
|
38
|
+
@http = nil
|
|
39
|
+
@actor = Phronomy::Actor.new
|
|
38
40
|
end
|
|
39
41
|
|
|
40
42
|
# Returns a plain Hash that records the span start state.
|
|
@@ -78,21 +80,37 @@ module Phronomy
|
|
|
78
80
|
private
|
|
79
81
|
|
|
80
82
|
# Sends a batch of events to the Langfuse ingestion endpoint.
|
|
83
|
+
# The Net::HTTP connection is cached and reused across calls to avoid
|
|
84
|
+
# per-span TCP + TLS handshake overhead (Issue #61).
|
|
81
85
|
# Errors are rescued and ignored to keep the tracer non-disruptive.
|
|
82
86
|
def ingest(events)
|
|
83
87
|
uri = URI.parse("#{@host}/api/public/ingestion")
|
|
84
|
-
http = Net::HTTP.new(uri.host, uri.port)
|
|
85
|
-
http.use_ssl = (uri.scheme == "https")
|
|
86
|
-
http.open_timeout = 3
|
|
87
|
-
http.read_timeout = 5
|
|
88
88
|
req = Net::HTTP::Post.new(uri.request_uri)
|
|
89
89
|
req["Content-Type"] = "application/json"
|
|
90
90
|
req["Authorization"] = "Basic #{Base64.strict_encode64("#{@public_key}:#{@secret_key}")}"
|
|
91
91
|
req.body = JSON.generate({batch: events})
|
|
92
|
-
|
|
93
|
-
|
|
92
|
+
|
|
93
|
+
@actor.call do
|
|
94
|
+
@http ||= build_http(uri)
|
|
95
|
+
@http.request(req)
|
|
96
|
+
end
|
|
97
|
+
rescue IOError, Errno::ECONNRESET, Errno::EPIPE => e
|
|
98
|
+
# Connection was reset; drop the cached connection and warn.
|
|
99
|
+
@actor.call { @http = nil }
|
|
100
|
+
warn "[Phronomy::LangfuseTracer] Ingestion failed: #{e.class}: #{e.message}"
|
|
101
|
+
nil
|
|
102
|
+
rescue => e
|
|
103
|
+
warn "[Phronomy::LangfuseTracer] Ingestion failed: #{e.class}: #{e.message}"
|
|
94
104
|
nil
|
|
95
105
|
end
|
|
106
|
+
|
|
107
|
+
def build_http(uri)
|
|
108
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
109
|
+
http.use_ssl = (uri.scheme == "https")
|
|
110
|
+
http.open_timeout = 3
|
|
111
|
+
http.read_timeout = 5
|
|
112
|
+
http
|
|
113
|
+
end
|
|
96
114
|
end
|
|
97
115
|
end
|
|
98
116
|
end
|
|
@@ -1,7 +1,5 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "ostruct"
|
|
4
|
-
|
|
5
3
|
module Phronomy
|
|
6
4
|
module Tracing
|
|
7
5
|
# No-op tracer used as the default. All calls succeed silently.
|
|
@@ -10,8 +8,13 @@ module Phronomy
|
|
|
10
8
|
#
|
|
11
9
|
# Phronomy.configure { |c| c.tracer = MyRealTracer.new }
|
|
12
10
|
class NullTracer < Base
|
|
11
|
+
# Internal value object for span handles returned by #start_span.
|
|
12
|
+
# Uses Struct (not OpenStruct) so that unknown attribute access raises NoMethodError.
|
|
13
|
+
SpanStruct = Struct.new(:name)
|
|
14
|
+
private_constant :SpanStruct
|
|
15
|
+
|
|
13
16
|
# Returns a minimal span object with the given name.
|
|
14
|
-
def start_span(name, **) =
|
|
17
|
+
def start_span(name, **) = SpanStruct.new(name)
|
|
15
18
|
|
|
16
19
|
# Does nothing.
|
|
17
20
|
def finish_span(span, **) = nil
|