phronomy 0.1.2 → 0.1.4
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/lib/generators/phronomy/install/templates/create_phronomy_messages.rb.tt +1 -1
- data/lib/phronomy/agent/base.rb +68 -35
- data/lib/phronomy/agent/handoff.rb +6 -2
- data/lib/phronomy/agent/react_agent.rb +57 -31
- data/lib/phronomy/agent/runner.rb +6 -4
- data/lib/phronomy/configuration.rb +6 -0
- 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 +22 -8
- data/lib/phronomy/context/token_estimator.rb +19 -2
- data/lib/phronomy/eval/eval_result.rb +15 -5
- data/lib/phronomy/eval/runner.rb +46 -11
- data/lib/phronomy/eval/scorer/llm_judge.rb +7 -2
- data/lib/phronomy/graph/compiled_graph.rb +9 -1
- data/lib/phronomy/graph/parallel_node.rb +53 -18
- data/lib/phronomy/graph/state_graph.rb +7 -1
- data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +47 -3
- data/lib/phronomy/guardrail/builtin/prompt_injection_detector.rb +15 -1
- 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 +59 -14
- data/lib/phronomy/memory/retrieval/base.rb +4 -3
- data/lib/phronomy/memory/retrieval/composite.rb +5 -4
- data/lib/phronomy/memory/retrieval/recent.rb +4 -3
- data/lib/phronomy/memory/retrieval/semantic.rb +50 -17
- data/lib/phronomy/memory/storage/active_record.rb +18 -13
- data/lib/phronomy/memory/storage/in_memory.rb +25 -16
- data/lib/phronomy/rails/agent_job.rb +20 -3
- data/lib/phronomy/runnable.rb +4 -1
- data/lib/phronomy/state_store/active_record.rb +7 -3
- data/lib/phronomy/state_store/base.rb +16 -2
- data/lib/phronomy/state_store/in_memory.rb +5 -4
- data/lib/phronomy/tool/base.rb +19 -3
- data/lib/phronomy/tool/mcp_tool.rb +67 -9
- data/lib/phronomy/tracing/base.rb +0 -2
- data/lib/phronomy/tracing/langfuse_tracer.rb +24 -4
- data/lib/phronomy/tracing/null_tracer.rb +6 -3
- data/lib/phronomy/trust_pipeline.rb +32 -4
- data/lib/phronomy/vector_store/in_memory.rb +7 -5
- data/lib/phronomy/vector_store/redis_search.rb +30 -23
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +39 -0
- metadata +2 -2
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "json"
|
|
4
|
-
require "ostruct"
|
|
5
4
|
|
|
6
5
|
module Phronomy
|
|
7
6
|
module Memory
|
|
@@ -38,6 +37,10 @@ module Phronomy
|
|
|
38
37
|
# compaction_model_class: PhronomyCompaction
|
|
39
38
|
# )
|
|
40
39
|
# manager = Phronomy::Memory::ConversationManager.new(storage: storage, ...)
|
|
40
|
+
# Internal value object representing a loaded message record.
|
|
41
|
+
MessageStruct = Data.define(:role, :content, :tool_calls, :model_id)
|
|
42
|
+
private_constant :MessageStruct
|
|
43
|
+
|
|
41
44
|
class ActiveRecord < Base
|
|
42
45
|
# @param model_class [Class] AR model for the legacy load/save interface
|
|
43
46
|
# @param raw_model_class [Class, nil] AR model for raw message storage
|
|
@@ -55,7 +58,7 @@ module Phronomy
|
|
|
55
58
|
# Load all messages for a thread, ordered by creation time.
|
|
56
59
|
#
|
|
57
60
|
# @param thread_id [String]
|
|
58
|
-
# @return [Array<
|
|
61
|
+
# @return [Array<MessageStruct>]
|
|
59
62
|
def load(thread_id:)
|
|
60
63
|
records = @model_class.where(thread_id: thread_id).order(:created_at).to_a
|
|
61
64
|
records.map { |r| to_message_struct(r) }
|
|
@@ -72,7 +75,7 @@ module Phronomy
|
|
|
72
75
|
@model_class.create!(
|
|
73
76
|
thread_id: thread_id,
|
|
74
77
|
role: msg.role.to_s,
|
|
75
|
-
content: msg.content
|
|
78
|
+
content: msg.content,
|
|
76
79
|
tool_calls_json: serialize_tool_calls(msg),
|
|
77
80
|
model_id: (msg.model_id if msg.respond_to?(:model_id))
|
|
78
81
|
)
|
|
@@ -97,15 +100,17 @@ module Phronomy
|
|
|
97
100
|
def append_raw(thread_id:, messages:, starting_seq:)
|
|
98
101
|
return unless @raw_model_class
|
|
99
102
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
103
|
+
@raw_model_class.transaction do
|
|
104
|
+
messages.each_with_index do |msg, i|
|
|
105
|
+
@raw_model_class.create!(
|
|
106
|
+
thread_id: thread_id,
|
|
107
|
+
seq: starting_seq + i,
|
|
108
|
+
role: msg.role.to_s,
|
|
109
|
+
content: msg.content,
|
|
110
|
+
tool_calls_json: serialize_tool_calls(msg),
|
|
111
|
+
model_id: (msg.model_id if msg.respond_to?(:model_id))
|
|
112
|
+
)
|
|
113
|
+
end
|
|
109
114
|
end
|
|
110
115
|
end
|
|
111
116
|
|
|
@@ -201,7 +206,7 @@ module Phronomy
|
|
|
201
206
|
parsed
|
|
202
207
|
end
|
|
203
208
|
end
|
|
204
|
-
|
|
209
|
+
MessageStruct.new(
|
|
205
210
|
role: record.role.to_sym,
|
|
206
211
|
content: record.content,
|
|
207
212
|
tool_calls: tool_calls,
|
|
@@ -11,6 +11,7 @@ module Phronomy
|
|
|
11
11
|
# manager = Phronomy::Memory::ConversationManager.new(storage: storage, ...)
|
|
12
12
|
class InMemory < Base
|
|
13
13
|
def initialize
|
|
14
|
+
@mutex = Mutex.new
|
|
14
15
|
@store = {}
|
|
15
16
|
@raw_store = {} # thread_id => [{seq:, message:}, ...]
|
|
16
17
|
@compaction_store = {} # thread_id => [{start_seq:, end_seq:, summary_text:}, ...]
|
|
@@ -23,20 +24,22 @@ module Phronomy
|
|
|
23
24
|
# @param thread_id [String]
|
|
24
25
|
# @return [Array]
|
|
25
26
|
def load(thread_id:)
|
|
26
|
-
(@store[thread_id] || []).dup
|
|
27
|
+
@mutex.synchronize { (@store[thread_id] || []).dup }
|
|
27
28
|
end
|
|
28
29
|
|
|
29
30
|
# @param thread_id [String]
|
|
30
31
|
# @param messages [Array]
|
|
31
32
|
def save(thread_id:, messages:)
|
|
32
|
-
@store[thread_id] = messages.dup
|
|
33
|
+
@mutex.synchronize { @store[thread_id] = messages.dup }
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
# @param thread_id [String]
|
|
36
37
|
def clear(thread_id:)
|
|
37
|
-
@
|
|
38
|
-
|
|
39
|
-
|
|
38
|
+
@mutex.synchronize do
|
|
39
|
+
@store.delete(thread_id)
|
|
40
|
+
@raw_store.delete(thread_id)
|
|
41
|
+
@compaction_store.delete(thread_id)
|
|
42
|
+
end
|
|
40
43
|
end
|
|
41
44
|
|
|
42
45
|
# -----------------------------------------------------------------------
|
|
@@ -48,21 +51,23 @@ module Phronomy
|
|
|
48
51
|
# @param starting_seq [Integer]
|
|
49
52
|
def append_raw(thread_id:, messages:, starting_seq:)
|
|
50
53
|
now = Time.now
|
|
51
|
-
@
|
|
52
|
-
|
|
53
|
-
|
|
54
|
+
@mutex.synchronize do
|
|
55
|
+
@raw_store[thread_id] ||= []
|
|
56
|
+
messages.each_with_index do |msg, i|
|
|
57
|
+
@raw_store[thread_id] << {seq: starting_seq + i, message: msg, recorded_at: now}
|
|
58
|
+
end
|
|
54
59
|
end
|
|
55
60
|
end
|
|
56
61
|
|
|
57
62
|
# @param thread_id [String]
|
|
58
63
|
# @return [Array<Hash>]
|
|
59
64
|
def load_raw(thread_id:)
|
|
60
|
-
(@raw_store[thread_id] || []).dup
|
|
65
|
+
@mutex.synchronize { (@raw_store[thread_id] || []).dup }
|
|
61
66
|
end
|
|
62
67
|
|
|
63
68
|
# @param thread_id [String]
|
|
64
69
|
def clear_raw(thread_id:)
|
|
65
|
-
@raw_store.delete(thread_id)
|
|
70
|
+
@mutex.synchronize { @raw_store.delete(thread_id) }
|
|
66
71
|
end
|
|
67
72
|
|
|
68
73
|
# -----------------------------------------------------------------------
|
|
@@ -74,19 +79,21 @@ module Phronomy
|
|
|
74
79
|
# @param end_seq [Integer]
|
|
75
80
|
# @param summary_text [String]
|
|
76
81
|
def save_compaction(thread_id:, start_seq:, end_seq:, summary_text:)
|
|
77
|
-
@
|
|
78
|
-
|
|
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}
|
|
85
|
+
end
|
|
79
86
|
end
|
|
80
87
|
|
|
81
88
|
# @param thread_id [String]
|
|
82
89
|
# @return [Array<Hash>]
|
|
83
90
|
def load_compactions(thread_id:)
|
|
84
|
-
(@compaction_store[thread_id] || []).dup
|
|
91
|
+
@mutex.synchronize { (@compaction_store[thread_id] || []).dup }
|
|
85
92
|
end
|
|
86
93
|
|
|
87
94
|
# @param thread_id [String]
|
|
88
95
|
def clear_compactions(thread_id:)
|
|
89
|
-
@compaction_store.delete(thread_id)
|
|
96
|
+
@mutex.synchronize { @compaction_store.delete(thread_id) }
|
|
90
97
|
end
|
|
91
98
|
|
|
92
99
|
# Remove raw messages recorded before +older_than+ for this thread.
|
|
@@ -94,9 +101,11 @@ module Phronomy
|
|
|
94
101
|
# @param thread_id [String]
|
|
95
102
|
# @param older_than [Time]
|
|
96
103
|
def purge_older_than(thread_id:, older_than:)
|
|
97
|
-
|
|
104
|
+
@mutex.synchronize do
|
|
105
|
+
next unless @raw_store[thread_id]
|
|
98
106
|
|
|
99
|
-
|
|
107
|
+
@raw_store[thread_id].reject! { |entry| entry[:recorded_at] && entry[:recorded_at] < older_than }
|
|
108
|
+
end
|
|
100
109
|
end
|
|
101
110
|
end
|
|
102
111
|
end
|
|
@@ -25,6 +25,8 @@ module Phronomy
|
|
|
25
25
|
class AgentJob < ::ActiveJob::Base
|
|
26
26
|
# @param agent_class_name [String]
|
|
27
27
|
# The constantize-able class name of the agent to run (e.g. "MyAgent").
|
|
28
|
+
# **Security**: only classes that are subclasses of +Phronomy::Agent::Base+
|
|
29
|
+
# are accepted. Never pass a value derived from user-controlled input.
|
|
28
30
|
# @param input [String, Hash]
|
|
29
31
|
# User input forwarded unchanged to the agent's +#stream+ method.
|
|
30
32
|
# @param channel [String]
|
|
@@ -35,21 +37,36 @@ module Phronomy
|
|
|
35
37
|
# Configuration forwarded to the agent's +#stream+ call. Both symbol and
|
|
36
38
|
# string keys are accepted; all keys are converted to symbols before use.
|
|
37
39
|
def perform(agent_class_name, input, channel:, stream:, config: {})
|
|
38
|
-
|
|
40
|
+
klass = resolve_agent_class!(agent_class_name)
|
|
41
|
+
agent = klass.new
|
|
39
42
|
agent.stream(input, config: config.transform_keys(&:to_sym)) do |event|
|
|
40
43
|
ActionCable.server.broadcast(stream, build_payload(event))
|
|
41
44
|
end
|
|
42
45
|
rescue => e
|
|
43
|
-
|
|
46
|
+
::Rails.logger.error("[Phronomy::Rails::AgentJob] agent error (#{e.class}): #{e.message}")
|
|
47
|
+
ActionCable.server.broadcast(stream, {type: "error", message: "An error occurred while processing your request."})
|
|
44
48
|
end
|
|
45
49
|
|
|
46
50
|
private
|
|
47
51
|
|
|
52
|
+
# Resolves and validates the agent class name.
|
|
53
|
+
# Raises ArgumentError when the name does not resolve to a subclass of
|
|
54
|
+
# Phronomy::Agent::Base, preventing arbitrary class instantiation.
|
|
55
|
+
def resolve_agent_class!(class_name)
|
|
56
|
+
klass = Object.const_get(class_name.to_s)
|
|
57
|
+
unless klass.is_a?(Class) && klass < Phronomy::Agent::Base
|
|
58
|
+
raise ArgumentError, "#{class_name.inspect} is not a Phronomy::Agent::Base subclass"
|
|
59
|
+
end
|
|
60
|
+
klass
|
|
61
|
+
rescue NameError
|
|
62
|
+
raise ArgumentError, "Unknown agent class: #{class_name.inspect}"
|
|
63
|
+
end
|
|
64
|
+
|
|
48
65
|
def build_payload(event)
|
|
49
66
|
case event.type
|
|
50
67
|
when :token then {type: "token", content: event.payload[:content]}
|
|
51
68
|
when :done then {type: "done", output: event.payload[:output]}
|
|
52
|
-
when :error then {type: "error", message:
|
|
69
|
+
when :error then {type: "error", message: "An error occurred while processing your request."}
|
|
53
70
|
else {type: event.type.to_s}
|
|
54
71
|
end
|
|
55
72
|
end
|
data/lib/phronomy/runnable.rb
CHANGED
|
@@ -28,7 +28,10 @@ module Phronomy
|
|
|
28
28
|
# @example
|
|
29
29
|
# trace("my_chain", input: input) { [invoke(input), nil] }
|
|
30
30
|
def trace(name, input: nil, **meta, &block)
|
|
31
|
-
|
|
31
|
+
# Redact user input from spans when trace_pii is disabled to prevent
|
|
32
|
+
# accidental PII transmission to external tracing backends.
|
|
33
|
+
traced_input = Phronomy.configuration.trace_pii ? input : "[REDACTED]"
|
|
34
|
+
Phronomy.configuration.tracer.trace(name, input: traced_input, **meta, &block)
|
|
32
35
|
end
|
|
33
36
|
end
|
|
34
37
|
end
|
|
@@ -43,9 +43,13 @@ module Phronomy
|
|
|
43
43
|
def save(state)
|
|
44
44
|
json = serialize_state(state)
|
|
45
45
|
payload = @encryptor ? @encryptor.encrypt(json) : json
|
|
46
|
-
|
|
47
|
-
record
|
|
48
|
-
|
|
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
|
+
)
|
|
49
53
|
self
|
|
50
54
|
end
|
|
51
55
|
|
|
@@ -61,9 +61,23 @@ module Phronomy
|
|
|
61
61
|
end
|
|
62
62
|
|
|
63
63
|
# Resolves and validates a state class name.
|
|
64
|
-
#
|
|
65
|
-
#
|
|
64
|
+
# When a registry has been configured via +Phronomy::Graph.register_state_class+,
|
|
65
|
+
# only registered classes are accepted — this prevents unintended autoloading
|
|
66
|
+
# of arbitrary files from an untrusted class name stored in Redis/DB.
|
|
67
|
+
# When no registry is configured, falls back to Object.const_get with a check
|
|
68
|
+
# that the resolved class includes Phronomy::Graph::State.
|
|
66
69
|
def safe_state_class(class_name)
|
|
70
|
+
registry = Phronomy::Graph.state_class_registry
|
|
71
|
+
if registry
|
|
72
|
+
klass = registry[class_name.to_s]
|
|
73
|
+
unless klass
|
|
74
|
+
raise ArgumentError,
|
|
75
|
+
"Unregistered state class: #{class_name.inspect}. " \
|
|
76
|
+
"Call Phronomy::Graph.register_state_class(#{class_name}) at startup."
|
|
77
|
+
end
|
|
78
|
+
return klass
|
|
79
|
+
end
|
|
80
|
+
|
|
67
81
|
klass = Object.const_get(class_name.to_s)
|
|
68
82
|
unless klass.is_a?(Class) && klass.include?(Phronomy::Graph::State)
|
|
69
83
|
raise ArgumentError, "Invalid state class: #{class_name.inspect}"
|
|
@@ -8,30 +8,31 @@ module Phronomy
|
|
|
8
8
|
class InMemory < Base
|
|
9
9
|
def initialize
|
|
10
10
|
@store = {}
|
|
11
|
+
@mutex = Mutex.new
|
|
11
12
|
end
|
|
12
13
|
|
|
13
14
|
# @param state [Object] includes Phronomy::Graph::State; must have a non-nil thread_id
|
|
14
15
|
# @return [self]
|
|
15
16
|
def save(state)
|
|
16
|
-
@store[state.thread_id] = state
|
|
17
|
+
@mutex.synchronize { @store[state.thread_id] = state }
|
|
17
18
|
self
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
# @param thread_id [String]
|
|
21
22
|
# @return [Object, nil] state object or nil
|
|
22
23
|
def load(thread_id)
|
|
23
|
-
@store[thread_id]
|
|
24
|
+
@mutex.synchronize { @store[thread_id] }
|
|
24
25
|
end
|
|
25
26
|
|
|
26
27
|
# @param thread_id [String]
|
|
27
28
|
# @return [self]
|
|
28
29
|
def clear(thread_id)
|
|
29
|
-
@store.delete(thread_id)
|
|
30
|
+
@mutex.synchronize { @store.delete(thread_id) }
|
|
30
31
|
self
|
|
31
32
|
end
|
|
32
33
|
|
|
33
34
|
def clear_all
|
|
34
|
-
@store.clear
|
|
35
|
+
@mutex.synchronize { @store.clear }
|
|
35
36
|
self
|
|
36
37
|
end
|
|
37
38
|
end
|
data/lib/phronomy/tool/base.rb
CHANGED
|
@@ -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
|
|
@@ -193,6 +200,11 @@ module Phronomy
|
|
|
193
200
|
end
|
|
194
201
|
end
|
|
195
202
|
|
|
203
|
+
# Instance method accessor — delegates to the class-level flag.
|
|
204
|
+
def requires_approval
|
|
205
|
+
self.class.requires_approval
|
|
206
|
+
end
|
|
207
|
+
|
|
196
208
|
# Instance method for requires_approval? (convenience accessor).
|
|
197
209
|
def requires_approval?
|
|
198
210
|
self.class.requires_approval
|
|
@@ -276,11 +288,15 @@ module Phronomy
|
|
|
276
288
|
|
|
277
289
|
self.class.parameters.each do |name, param|
|
|
278
290
|
value = normalized[name]
|
|
279
|
-
|
|
291
|
+
if value.nil?
|
|
292
|
+
# Return a descriptive error for missing required params so the LLM
|
|
293
|
+
# can self-correct on the next turn.
|
|
294
|
+
return [nil, "required parameter '#{name}' is missing"] if param.required
|
|
295
|
+
next
|
|
296
|
+
end
|
|
280
297
|
|
|
281
298
|
if coerce_mode
|
|
282
299
|
coerced, error = coerce_value(value, param.type)
|
|
283
|
-
return [nil, error] if error && !coerce_mode
|
|
284
300
|
return [nil, error] if error
|
|
285
301
|
value = coerced
|
|
286
302
|
else
|
|
@@ -79,12 +79,38 @@ module Phronomy
|
|
|
79
79
|
# -----------------------------------------------------------------------
|
|
80
80
|
|
|
81
81
|
# Minimal stdio transport implementing a subset of the MCP JSON-RPC protocol.
|
|
82
|
-
#
|
|
82
|
+
# Keeps the child process alive for the lifetime of this transport instance
|
|
83
|
+
# so that session state (registered resources, tool context, etc.) is preserved
|
|
84
|
+
# across multiple calls.
|
|
83
85
|
class StdioTransport
|
|
84
86
|
def initialize(command)
|
|
85
87
|
# Split the command string into an argv array so that Open3 executes
|
|
86
88
|
# it directly without going through the shell, preventing injection.
|
|
87
89
|
@command = Shellwords.split(command)
|
|
90
|
+
@mutex = Mutex.new
|
|
91
|
+
@stdin = nil
|
|
92
|
+
@stdout = nil
|
|
93
|
+
@stderr = nil
|
|
94
|
+
@wait_thr = nil
|
|
95
|
+
@stderr_thread = nil
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Shut down the child process and close its IO streams.
|
|
99
|
+
def close
|
|
100
|
+
@mutex.synchronize do
|
|
101
|
+
@stdin&.close
|
|
102
|
+
@stdout&.close
|
|
103
|
+
@stderr&.close
|
|
104
|
+
@stdin = nil
|
|
105
|
+
@stdout = nil
|
|
106
|
+
@stderr = nil
|
|
107
|
+
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
|
|
88
114
|
end
|
|
89
115
|
|
|
90
116
|
# Retrieve the tool definition from the server using the MCP `tools/list` method.
|
|
@@ -108,6 +134,10 @@ module Phronomy
|
|
|
108
134
|
# @return [Object] the tool result
|
|
109
135
|
def call_tool(tool_name, args)
|
|
110
136
|
response = rpc_call("tools/call", {name: tool_name, arguments: args})
|
|
137
|
+
if response["error"]
|
|
138
|
+
err_msg = response.dig("error", "message") || response["error"].to_s
|
|
139
|
+
raise Phronomy::ToolError, "MCP server returned error: #{err_msg}"
|
|
140
|
+
end
|
|
111
141
|
content = response.dig("result", "content")
|
|
112
142
|
|
|
113
143
|
# MCP content is an array of content blocks; extract text blocks.
|
|
@@ -121,12 +151,30 @@ module Phronomy
|
|
|
121
151
|
|
|
122
152
|
private
|
|
123
153
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
154
|
+
# Ensure the child process is running, spawning it if necessary.
|
|
155
|
+
def ensure_started!
|
|
156
|
+
return if @stdin && !@stdin.closed?
|
|
157
|
+
|
|
158
|
+
@stdin, @stdout, @stderr, @wait_thr = Open3.popen3(*@command)
|
|
159
|
+
# Drain stderr asynchronously to prevent the pipe buffer from filling
|
|
160
|
+
# and deadlocking the child process. Errors inside the drain thread are
|
|
161
|
+
# silently ignored since stderr content is diagnostics-only.
|
|
162
|
+
@stderr_thread = Thread.new do
|
|
163
|
+
@stderr.read
|
|
164
|
+
rescue
|
|
165
|
+
nil
|
|
166
|
+
end
|
|
167
|
+
end
|
|
128
168
|
|
|
129
|
-
|
|
169
|
+
def rpc_call(method, params)
|
|
170
|
+
@mutex.synchronize do
|
|
171
|
+
ensure_started!
|
|
172
|
+
payload = JSON.generate(jsonrpc: "2.0", id: SecureRandom.uuid, method: method, params: params)
|
|
173
|
+
@stdin.puts(payload)
|
|
174
|
+
raw = @stdout.gets
|
|
175
|
+
raise Phronomy::ToolError, "MCP server closed the connection unexpectedly" if raw.nil?
|
|
176
|
+
JSON.parse(raw)
|
|
177
|
+
end
|
|
130
178
|
end
|
|
131
179
|
|
|
132
180
|
def parse_schema_params(properties)
|
|
@@ -153,9 +201,13 @@ module Phronomy
|
|
|
153
201
|
# tool_name: "weather_lookup"
|
|
154
202
|
# )
|
|
155
203
|
class HttpTransport
|
|
156
|
-
# @param base_url
|
|
157
|
-
|
|
204
|
+
# @param base_url [String] full URL of the MCP endpoint, e.g. "http://localhost:8080/mcp"
|
|
205
|
+
# @param open_timeout [Integer] TCP connection timeout in seconds (default: 5)
|
|
206
|
+
# @param read_timeout [Integer] HTTP read timeout in seconds (default: 30)
|
|
207
|
+
def initialize(base_url, open_timeout: 5, read_timeout: 30)
|
|
158
208
|
@uri = URI.parse(base_url)
|
|
209
|
+
@open_timeout = open_timeout
|
|
210
|
+
@read_timeout = read_timeout
|
|
159
211
|
end
|
|
160
212
|
|
|
161
213
|
# Retrieve the tool definition from the server using MCP `tools/list`.
|
|
@@ -179,6 +231,10 @@ module Phronomy
|
|
|
179
231
|
# @return [Object] the tool result
|
|
180
232
|
def call_tool(tool_name, args)
|
|
181
233
|
response = rpc_call("tools/call", {name: tool_name, arguments: args})
|
|
234
|
+
if response["error"]
|
|
235
|
+
err_msg = response.dig("error", "message") || response["error"].to_s
|
|
236
|
+
raise Phronomy::ToolError, "MCP HTTP server returned error: #{err_msg}"
|
|
237
|
+
end
|
|
182
238
|
content = response.dig("result", "content")
|
|
183
239
|
|
|
184
240
|
if content.is_a?(Array)
|
|
@@ -192,10 +248,12 @@ module Phronomy
|
|
|
192
248
|
private
|
|
193
249
|
|
|
194
250
|
def rpc_call(method, params)
|
|
195
|
-
payload = JSON.generate(jsonrpc: "2.0", id:
|
|
251
|
+
payload = JSON.generate(jsonrpc: "2.0", id: SecureRandom.uuid, method: method, params: params)
|
|
196
252
|
|
|
197
253
|
http = Net::HTTP.new(@uri.host, @uri.port)
|
|
198
254
|
http.use_ssl = (@uri.scheme == "https")
|
|
255
|
+
http.open_timeout = @open_timeout
|
|
256
|
+
http.read_timeout = @read_timeout
|
|
199
257
|
|
|
200
258
|
path = @uri.path.empty? ? "/" : @uri.path
|
|
201
259
|
path = "#{path}?#{@uri.query}" if @uri.query
|
|
@@ -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
|
+
@http_mutex = Mutex.new
|
|
38
40
|
end
|
|
39
41
|
|
|
40
42
|
# Returns a plain Hash that records the span start state.
|
|
@@ -78,19 +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
88
|
req = Net::HTTP::Post.new(uri.request_uri)
|
|
87
89
|
req["Content-Type"] = "application/json"
|
|
88
90
|
req["Authorization"] = "Basic #{Base64.strict_encode64("#{@public_key}:#{@secret_key}")}"
|
|
89
91
|
req.body = JSON.generate({batch: events})
|
|
90
|
-
|
|
91
|
-
|
|
92
|
+
|
|
93
|
+
@http_mutex.synchronize 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
|
+
@http_mutex.synchronize { @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}"
|
|
92
104
|
nil
|
|
93
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
|
|
94
114
|
end
|
|
95
115
|
end
|
|
96
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
|
|
@@ -75,13 +75,22 @@ module Phronomy
|
|
|
75
75
|
# @param review_agent [Class] subclass of Phronomy::Agent::Base
|
|
76
76
|
# @param confidence_threshold [Float] answers below this are retried (default: 0.7)
|
|
77
77
|
# @param max_iterations [Integer] maximum draft-review cycles (default: 3)
|
|
78
|
+
# @param input_delimiter [Array<String>, nil] optional two-element array
|
|
79
|
+
# [start_tag, end_tag] used to wrap user input in prompts, e.g.
|
|
80
|
+
# ["<user_input>", "</user_input>"] or
|
|
81
|
+
# ["=== user input start ===", "=== user input end ==="].
|
|
82
|
+
# When nil (default), input is embedded as-is for backward compatibility.
|
|
78
83
|
def initialize(draft_agent:, review_agent:,
|
|
79
84
|
confidence_threshold: DEFAULT_CONFIDENCE_THRESHOLD,
|
|
80
|
-
max_iterations: DEFAULT_MAX_ITERATIONS
|
|
85
|
+
max_iterations: DEFAULT_MAX_ITERATIONS,
|
|
86
|
+
input_delimiter: nil)
|
|
81
87
|
@draft_agent_class = draft_agent
|
|
82
88
|
@review_agent_class = review_agent
|
|
83
89
|
@threshold = confidence_threshold.to_f
|
|
84
90
|
@max_iterations = max_iterations.to_i
|
|
91
|
+
@input_delimiter = input_delimiter
|
|
92
|
+
@graph_mutex = Mutex.new
|
|
93
|
+
@compiled_graph = nil
|
|
85
94
|
end
|
|
86
95
|
|
|
87
96
|
# Run the pipeline.
|
|
@@ -90,7 +99,7 @@ module Phronomy
|
|
|
90
99
|
# @param config [Hash] forwarded to the underlying agents (e.g. thread_id)
|
|
91
100
|
# @return [Result]
|
|
92
101
|
def invoke(input, config: {})
|
|
93
|
-
app =
|
|
102
|
+
app = compiled_graph
|
|
94
103
|
state = app.invoke({input: input}, config: config)
|
|
95
104
|
confidence = combined_confidence(state)
|
|
96
105
|
Result.new(
|
|
@@ -109,6 +118,16 @@ module Phronomy
|
|
|
109
118
|
[(state.self_score || 0.0).to_f, (state.review_score || 0.0).to_f].min
|
|
110
119
|
end
|
|
111
120
|
|
|
121
|
+
# Returns the compiled graph, building and caching it on first call.
|
|
122
|
+
# Thread-safe via double-checked locking.
|
|
123
|
+
def compiled_graph
|
|
124
|
+
return @compiled_graph if @compiled_graph
|
|
125
|
+
|
|
126
|
+
@graph_mutex.synchronize do
|
|
127
|
+
@compiled_graph ||= build_graph.compile
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
|
|
112
131
|
def build_graph
|
|
113
132
|
draft_agent = @draft_agent_class.new
|
|
114
133
|
review_agent = @review_agent_class.new
|
|
@@ -161,6 +180,15 @@ module Phronomy
|
|
|
161
180
|
graph
|
|
162
181
|
end
|
|
163
182
|
|
|
183
|
+
# Wraps +input+ with the configured delimiter pair when +input_delimiter+ is set.
|
|
184
|
+
# When no delimiter is configured the input is returned unchanged.
|
|
185
|
+
def wrap_input(input)
|
|
186
|
+
return input unless @input_delimiter
|
|
187
|
+
|
|
188
|
+
start_tag, end_tag = @input_delimiter
|
|
189
|
+
"#{start_tag}\n#{input}\n#{end_tag}"
|
|
190
|
+
end
|
|
191
|
+
|
|
164
192
|
# Builds the prompt sent to the DraftAgent for each iteration.
|
|
165
193
|
def draft_prompt(input, feedback)
|
|
166
194
|
lines = [
|
|
@@ -174,7 +202,7 @@ module Phronomy
|
|
|
174
202
|
end
|
|
175
203
|
lines += [
|
|
176
204
|
"",
|
|
177
|
-
"Question: #{input}",
|
|
205
|
+
"Question: #{wrap_input(input)}",
|
|
178
206
|
"",
|
|
179
207
|
"RESPOND ONLY WITH VALID JSON (no text outside the JSON block):",
|
|
180
208
|
'{"answer":"<full answer>","confidence":<0.0-1.0>,' \
|
|
@@ -193,7 +221,7 @@ module Phronomy
|
|
|
193
221
|
[
|
|
194
222
|
"You are a rigorous quality reviewer. Evaluate the draft answer below.",
|
|
195
223
|
"",
|
|
196
|
-
"Question: #{input}",
|
|
224
|
+
"Question: #{wrap_input(input)}",
|
|
197
225
|
"",
|
|
198
226
|
"Draft answer:",
|
|
199
227
|
draft.to_s,
|