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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/lib/generators/phronomy/install/templates/create_phronomy_messages.rb.tt +1 -1
  3. data/lib/phronomy/agent/base.rb +68 -35
  4. data/lib/phronomy/agent/handoff.rb +6 -2
  5. data/lib/phronomy/agent/react_agent.rb +57 -31
  6. data/lib/phronomy/agent/runner.rb +6 -4
  7. data/lib/phronomy/configuration.rb +6 -0
  8. data/lib/phronomy/context/assembler.rb +11 -3
  9. data/lib/phronomy/context/compaction_context.rb +1 -3
  10. data/lib/phronomy/context/context_version_cache.rb +22 -8
  11. data/lib/phronomy/context/token_estimator.rb +19 -2
  12. data/lib/phronomy/eval/eval_result.rb +15 -5
  13. data/lib/phronomy/eval/runner.rb +46 -11
  14. data/lib/phronomy/eval/scorer/llm_judge.rb +7 -2
  15. data/lib/phronomy/graph/compiled_graph.rb +9 -1
  16. data/lib/phronomy/graph/parallel_node.rb +53 -18
  17. data/lib/phronomy/graph/state_graph.rb +7 -1
  18. data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +47 -3
  19. data/lib/phronomy/guardrail/builtin/prompt_injection_detector.rb +15 -1
  20. data/lib/phronomy/memory/compression/summary.rb +4 -3
  21. data/lib/phronomy/memory/compression/tool_output_pruner.rb +11 -6
  22. data/lib/phronomy/memory/conversation_manager.rb +59 -14
  23. data/lib/phronomy/memory/retrieval/base.rb +4 -3
  24. data/lib/phronomy/memory/retrieval/composite.rb +5 -4
  25. data/lib/phronomy/memory/retrieval/recent.rb +4 -3
  26. data/lib/phronomy/memory/retrieval/semantic.rb +50 -17
  27. data/lib/phronomy/memory/storage/active_record.rb +18 -13
  28. data/lib/phronomy/memory/storage/in_memory.rb +25 -16
  29. data/lib/phronomy/rails/agent_job.rb +20 -3
  30. data/lib/phronomy/runnable.rb +4 -1
  31. data/lib/phronomy/state_store/active_record.rb +7 -3
  32. data/lib/phronomy/state_store/base.rb +16 -2
  33. data/lib/phronomy/state_store/in_memory.rb +5 -4
  34. data/lib/phronomy/tool/base.rb +19 -3
  35. data/lib/phronomy/tool/mcp_tool.rb +67 -9
  36. data/lib/phronomy/tracing/base.rb +0 -2
  37. data/lib/phronomy/tracing/langfuse_tracer.rb +24 -4
  38. data/lib/phronomy/tracing/null_tracer.rb +6 -3
  39. data/lib/phronomy/trust_pipeline.rb +32 -4
  40. data/lib/phronomy/vector_store/in_memory.rb +7 -5
  41. data/lib/phronomy/vector_store/redis_search.rb +30 -23
  42. data/lib/phronomy/version.rb +1 -1
  43. data/lib/phronomy.rb +39 -0
  44. 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<OpenStruct>]
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.to_s,
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
- messages.each_with_index do |msg, i|
101
- @raw_model_class.create!(
102
- thread_id: thread_id,
103
- seq: starting_seq + i,
104
- role: msg.role.to_s,
105
- content: msg.content.to_s,
106
- tool_calls_json: serialize_tool_calls(msg),
107
- model_id: (msg.model_id if msg.respond_to?(:model_id))
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
- OpenStruct.new(
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
- @store.delete(thread_id)
38
- clear_raw(thread_id: thread_id)
39
- clear_compactions(thread_id: thread_id)
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
- @raw_store[thread_id] ||= []
52
- messages.each_with_index do |msg, i|
53
- @raw_store[thread_id] << {seq: starting_seq + i, message: msg, recorded_at: now}
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
- @compaction_store[thread_id] ||= []
78
- @compaction_store[thread_id] << {start_seq: start_seq, end_seq: end_seq, summary_text: 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}
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
- return unless @raw_store[thread_id]
104
+ @mutex.synchronize do
105
+ next unless @raw_store[thread_id]
98
106
 
99
- @raw_store[thread_id].reject! { |entry| entry[:recorded_at] && entry[:recorded_at] < older_than }
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
- agent = Object.const_get(agent_class_name).new
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
- ActionCable.server.broadcast(stream, {type: "error", message: e.message})
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: event.payload[: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
@@ -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
- Phronomy.configuration.tracer.trace(name, input: input, **meta, &block)
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
- record = @model_class.find_or_initialize_by(thread_id: state.thread_id)
47
- record.state_json = payload
48
- record.save!
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
- # Raises ArgumentError if the name does not resolve to a class that
65
- # includes Phronomy::Graph::State, preventing unsafe deserialization.
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
@@ -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]["enum"] = values.map(&:to_s)
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
- next if value.nil? && !param.required
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
- # Spawns the server command as a child process and communicates line-by-line.
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
- def rpc_call(method, params)
125
- payload = JSON.generate(jsonrpc: "2.0", id: 1, method: method, params: params)
126
- stdout, _stderr, status = Open3.capture3(*@command, stdin_data: "#{payload}\n")
127
- raise Phronomy::ToolError, "MCP server exited with status #{status.exitstatus}" unless status.success?
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
- JSON.parse(stdout.lines.first.to_s)
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 [String] full URL of the MCP endpoint, e.g. "http://localhost:8080/mcp"
157
- def initialize(base_url)
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: 1, method: method, params: params)
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
@@ -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
  # Abstract tracer.
@@ -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
- http.request(req)
91
- rescue
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, **) = OpenStruct.new(name: 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 = build_graph.compile
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,