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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -0
  3. data/README.md +49 -38
  4. data/docs/trustworthy_ai_enhancements.md +4 -4
  5. data/lib/generators/phronomy/install/templates/create_phronomy_messages.rb.tt +1 -1
  6. data/lib/phronomy/actor.rb +68 -0
  7. data/lib/phronomy/agent/base.rb +125 -91
  8. data/lib/phronomy/agent/handoff.rb +2 -2
  9. data/lib/phronomy/agent/react_agent.rb +51 -33
  10. data/lib/phronomy/context/assembler.rb +11 -3
  11. data/lib/phronomy/context/compaction_context.rb +1 -3
  12. data/lib/phronomy/context/context_version_cache.rb +7 -16
  13. data/lib/phronomy/eval/runner.rb +39 -11
  14. data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +47 -3
  15. data/lib/phronomy/memory/compression/summary.rb +4 -3
  16. data/lib/phronomy/memory/compression/tool_output_pruner.rb +11 -6
  17. data/lib/phronomy/memory/conversation_manager.rb +25 -16
  18. data/lib/phronomy/memory/retrieval/semantic.rb +21 -5
  19. data/lib/phronomy/memory/storage/active_record.rb +32 -10
  20. data/lib/phronomy/memory/storage/base.rb +22 -0
  21. data/lib/phronomy/memory/storage/in_memory.rb +65 -26
  22. data/lib/phronomy/state_store/active_record.rb +1 -1
  23. data/lib/phronomy/state_store/base.rb +14 -16
  24. data/lib/phronomy/state_store/in_memory.rb +23 -9
  25. data/lib/phronomy/state_store/redis.rb +1 -1
  26. data/lib/phronomy/thread_actor_registry.rb +52 -0
  27. data/lib/phronomy/tool/base.rb +9 -2
  28. data/lib/phronomy/tool/mcp_tool.rb +28 -4
  29. data/lib/phronomy/tracing/base.rb +0 -2
  30. data/lib/phronomy/tracing/langfuse_tracer.rb +24 -6
  31. data/lib/phronomy/tracing/null_tracer.rb +6 -3
  32. data/lib/phronomy/trust_pipeline.rb +60 -52
  33. data/lib/phronomy/vector_store/redis_search.rb +28 -23
  34. data/lib/phronomy/version.rb +1 -1
  35. data/lib/phronomy/workflow.rb +281 -0
  36. data/lib/phronomy/workflow_context.rb +119 -0
  37. data/lib/phronomy/workflow_runner.rb +262 -0
  38. data/lib/phronomy.rb +30 -34
  39. metadata +25 -10
  40. data/lib/phronomy/graph/compiled_graph.rb +0 -183
  41. data/lib/phronomy/graph/parallel_node.rb +0 -193
  42. data/lib/phronomy/graph/state.rb +0 -105
  43. data/lib/phronomy/graph/state_graph.rb +0 -148
  44. 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 Hash-backed storage for conversation messages.
6
+ # In-process storage for conversation messages backed by per-thread-id
7
+ # {Phronomy::Actor} instances from {Phronomy::ThreadActorRegistry}.
7
8
  # Messages are lost when the process exits.
8
9
  #
9
10
  # @example
10
11
  # storage = Phronomy::Memory::Storage::InMemory.new
11
12
  # manager = Phronomy::Memory::ConversationManager.new(storage: storage, ...)
12
13
  class InMemory < Base
14
+ # Thread-local key for per-thread-id storage data (namespaced by store
15
+ # instance object_id to support multiple independent InMemory stores).
16
+ THREAD_DATA_KEY = :phronomy_storage_in_memory_data
17
+
13
18
  def initialize
14
- @mutex = Mutex.new
15
- @store = {}
16
- @raw_store = {} # thread_id => [{seq:, message:}, ...]
17
- @compaction_store = {} # thread_id => [{start_seq:, end_seq:, summary_text:}, ...]
18
19
  end
19
20
 
20
21
  # -----------------------------------------------------------------------
@@ -24,21 +25,20 @@ module Phronomy
24
25
  # @param thread_id [String]
25
26
  # @return [Array]
26
27
  def load(thread_id:)
27
- @mutex.synchronize { (@store[thread_id] || []).dup }
28
+ Phronomy::ThreadActorRegistry.for(thread_id).call { (thread_data.store || []).dup }
28
29
  end
29
30
 
30
31
  # @param thread_id [String]
31
32
  # @param messages [Array]
32
33
  def save(thread_id:, messages:)
33
- @mutex.synchronize { @store[thread_id] = messages.dup }
34
+ Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.store = messages.dup }
34
35
  end
35
36
 
36
37
  # @param thread_id [String]
37
38
  def clear(thread_id:)
38
- @mutex.synchronize do
39
- @store.delete(thread_id)
40
- @raw_store.delete(thread_id)
41
- @compaction_store.delete(thread_id)
39
+ store_id = object_id
40
+ Phronomy::ThreadActorRegistry.for(thread_id).call do
41
+ (Thread.current[THREAD_DATA_KEY] ||= {}).delete(store_id)
42
42
  end
43
43
  end
44
44
 
@@ -49,25 +49,45 @@ module Phronomy
49
49
  # @param thread_id [String]
50
50
  # @param messages [Array]
51
51
  # @param starting_seq [Integer]
52
- def append_raw(thread_id:, messages:, starting_seq:)
53
- now = Time.now
54
- @mutex.synchronize do
55
- @raw_store[thread_id] ||= []
52
+ # @param recorded_at [Time, nil] timestamp for test overrides; defaults to +Time.now+
53
+ def append_raw(thread_id:, messages:, starting_seq:, recorded_at: nil)
54
+ now = recorded_at || Time.now
55
+ Phronomy::ThreadActorRegistry.for(thread_id).call do
56
+ data = thread_data
56
57
  messages.each_with_index do |msg, i|
57
- @raw_store[thread_id] << {seq: starting_seq + i, message: msg, recorded_at: now}
58
+ seq = starting_seq + i
59
+ data.raw_messages << {seq: seq, message: msg, recorded_at: now}
60
+ data.hwm = [data.hwm, seq].max
58
61
  end
59
62
  end
60
63
  end
61
64
 
65
+ # @param thread_id [String]
66
+ # @return [Integer]
67
+ def next_seq(thread_id:)
68
+ Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.hwm + 1 }
69
+ end
70
+
71
+ # Routes +block+ through the per-thread-id {Phronomy::Actor}, serialising
72
+ # all operations for the same thread. Reentrant calls (the block itself
73
+ # calling storage methods that also route through the Actor) are safe
74
+ # because {Phronomy::Actor#call} detects the same-thread case and executes
75
+ # inline.
76
+ #
77
+ # @param thread_id [String]
78
+ def with_thread_lock(thread_id:, &block)
79
+ Phronomy::ThreadActorRegistry.for(thread_id).call(&block)
80
+ end
81
+
62
82
  # @param thread_id [String]
63
83
  # @return [Array<Hash>]
64
84
  def load_raw(thread_id:)
65
- @mutex.synchronize { (@raw_store[thread_id] || []).dup }
85
+ Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.raw_messages.dup }
66
86
  end
67
87
 
68
88
  # @param thread_id [String]
69
89
  def clear_raw(thread_id:)
70
- @mutex.synchronize { @raw_store.delete(thread_id) }
90
+ Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.raw_messages.clear }
71
91
  end
72
92
 
73
93
  # -----------------------------------------------------------------------
@@ -79,21 +99,20 @@ module Phronomy
79
99
  # @param end_seq [Integer]
80
100
  # @param summary_text [String]
81
101
  def save_compaction(thread_id:, start_seq:, end_seq:, summary_text:)
82
- @mutex.synchronize do
83
- @compaction_store[thread_id] ||= []
84
- @compaction_store[thread_id] << {start_seq: start_seq, end_seq: end_seq, summary_text: summary_text}
102
+ Phronomy::ThreadActorRegistry.for(thread_id).call do
103
+ thread_data.compactions << {start_seq: start_seq, end_seq: end_seq, summary_text: summary_text}
85
104
  end
86
105
  end
87
106
 
88
107
  # @param thread_id [String]
89
108
  # @return [Array<Hash>]
90
109
  def load_compactions(thread_id:)
91
- @mutex.synchronize { (@compaction_store[thread_id] || []).dup }
110
+ Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.compactions.dup }
92
111
  end
93
112
 
94
113
  # @param thread_id [String]
95
114
  def clear_compactions(thread_id:)
96
- @mutex.synchronize { @compaction_store.delete(thread_id) }
115
+ Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.compactions.clear }
97
116
  end
98
117
 
99
118
  # Remove raw messages recorded before +older_than+ for this thread.
@@ -101,10 +120,30 @@ module Phronomy
101
120
  # @param thread_id [String]
102
121
  # @param older_than [Time]
103
122
  def purge_older_than(thread_id:, older_than:)
104
- @mutex.synchronize do
105
- next unless @raw_store[thread_id]
123
+ Phronomy::ThreadActorRegistry.for(thread_id).call do
124
+ thread_data.raw_messages.reject! { |entry| entry[:recorded_at] && entry[:recorded_at] < older_than }
125
+ end
126
+ end
127
+
128
+ private
129
+
130
+ # Returns (or lazily initialises) the {ThreadData} for the current Actor
131
+ # thread and this storage instance. Must only be called from within a
132
+ # {Phronomy::ThreadActorRegistry.for} block so that +Thread.current+ is
133
+ # the correct Actor thread.
134
+ def thread_data
135
+ (Thread.current[THREAD_DATA_KEY] ||= {})[object_id] ||= ThreadData.new
136
+ end
137
+
138
+ # Value object holding all per-thread-id storage state.
139
+ class ThreadData
140
+ attr_accessor :store, :raw_messages, :compactions, :hwm
106
141
 
107
- @raw_store[thread_id].reject! { |entry| entry[:recorded_at] && entry[:recorded_at] < older_than }
142
+ def initialize
143
+ @store = nil
144
+ @raw_messages = []
145
+ @compactions = []
146
+ @hwm = -1
108
147
  end
109
148
  end
110
149
  end
@@ -38,7 +38,7 @@ module Phronomy
38
38
  end
39
39
 
40
40
  # Serializes and upserts the state for the given thread_id.
41
- # @param state [Object] includes Phronomy::Graph::State
41
+ # @param state [Object] includes Phronomy::WorkflowContext
42
42
  # @return [self]
43
43
  def save(state)
44
44
  json = serialize_state(state)
@@ -7,11 +7,11 @@ module Phronomy
7
7
  # Abstract base class for state persistence backends.
8
8
  # Subclasses must implement save, load, and clear.
9
9
  #
10
- # The state object passed to save must include Phronomy::Graph::State
11
- # and have a non-nil thread_id (set automatically by CompiledGraph#invoke).
10
+ # The state object passed to save must include Phronomy::WorkflowContext
11
+ # and have a non-nil thread_id (set automatically by WorkflowRunner#invoke).
12
12
  class Base
13
13
  # Persists the state. The thread_id is read from state.thread_id.
14
- # @param state [Object] object including Phronomy::Graph::State
14
+ # @param state [Object] object including Phronomy::WorkflowContext
15
15
  # @return [self]
16
16
  def save(state)
17
17
  raise NotImplementedError, "#{self.class}#save is not implemented"
@@ -40,8 +40,7 @@ module Phronomy
40
40
  state_class: state.class.name,
41
41
  state_data: json_safe(state.to_h),
42
42
  thread_id: state.thread_id,
43
- current_nodes: state.current_nodes&.map(&:to_s),
44
- halted_before: state.halted_before
43
+ phase: state.phase&.to_s
45
44
  )
46
45
  end
47
46
 
@@ -54,37 +53,36 @@ module Phronomy
54
53
  state = state_class.new(**state_data)
55
54
  state.set_graph_metadata(
56
55
  thread_id: data[:thread_id],
57
- current_nodes: data[:current_nodes]&.map(&:to_sym),
58
- halted_before: data[:halted_before]
56
+ phase: data[:phase]&.to_sym
59
57
  )
60
58
  state
61
59
  end
62
60
 
63
- # Resolves and validates a state class name.
64
- # When a registry has been configured via +Phronomy::Graph.register_state_class+,
61
+ # Resolves and validates a context class name.
62
+ # When a registry has been configured via +Phronomy.register_workflow_context+,
65
63
  # only registered classes are accepted — this prevents unintended autoloading
66
64
  # of arbitrary files from an untrusted class name stored in Redis/DB.
67
65
  # When no registry is configured, falls back to Object.const_get with a check
68
- # that the resolved class includes Phronomy::Graph::State.
66
+ # that the resolved class includes Phronomy::WorkflowContext.
69
67
  def safe_state_class(class_name)
70
- registry = Phronomy::Graph.state_class_registry
68
+ registry = Phronomy.workflow_context_registry
71
69
  if registry
72
70
  klass = registry[class_name.to_s]
73
71
  unless klass
74
72
  raise ArgumentError,
75
- "Unregistered state class: #{class_name.inspect}. " \
76
- "Call Phronomy::Graph.register_state_class(#{class_name}) at startup."
73
+ "Unregistered context class: #{class_name.inspect}. " \
74
+ "Call Phronomy.register_workflow_context(#{class_name}) at startup."
77
75
  end
78
76
  return klass
79
77
  end
80
78
 
81
79
  klass = Object.const_get(class_name.to_s)
82
- unless klass.is_a?(Class) && klass.include?(Phronomy::Graph::State)
83
- raise ArgumentError, "Invalid state class: #{class_name.inspect}"
80
+ unless klass.is_a?(Class) && klass.include?(Phronomy::WorkflowContext)
81
+ raise ArgumentError, "Invalid context class: #{class_name.inspect}"
84
82
  end
85
83
  klass
86
84
  rescue NameError
87
- raise ArgumentError, "Unknown state class: #{class_name.inspect}"
85
+ raise ArgumentError, "Unknown context class: #{class_name.inspect}"
88
86
  end
89
87
 
90
88
  # Recursively converts objects to JSON-safe primitives.
@@ -2,36 +2,50 @@
2
2
 
3
3
  module Phronomy
4
4
  module StateStore
5
- # In-memory state store. Stores state objects keyed by thread_id.
6
- # State objects are stored directly (no serialization), so this
7
- # backend is suitable for single-process use only.
5
+ # In-memory state store backed by per-thread-id {Phronomy::Actor} instances
6
+ # from {Phronomy::ThreadActorRegistry}. Suitable for single-process use only.
8
7
  class InMemory < Base
8
+ # Thread-local key for per-thread-id state data (namespaced by store
9
+ # instance object_id to support multiple independent InMemory stores).
10
+ THREAD_DATA_KEY = :phronomy_state_store_in_memory_data
11
+
9
12
  def initialize
10
- @store = {}
11
13
  end
12
14
 
13
- # @param state [Object] includes Phronomy::Graph::State; must have a non-nil thread_id
15
+ # @param state [Object] includes Phronomy::WorkflowContext; must have a non-nil thread_id
14
16
  # @return [self]
15
17
  def save(state)
16
- @store[state.thread_id] = state
18
+ store_id = object_id
19
+ Phronomy::ThreadActorRegistry.for(state.thread_id).call do
20
+ (Thread.current[THREAD_DATA_KEY] ||= {})[store_id] = state
21
+ end
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
- @store[thread_id]
28
+ store_id = object_id
29
+ Phronomy::ThreadActorRegistry.for(thread_id).call do
30
+ (Thread.current[THREAD_DATA_KEY] ||= {})[store_id]
31
+ end
24
32
  end
25
33
 
26
34
  # @param thread_id [String]
27
35
  # @return [self]
28
36
  def clear(thread_id)
29
- @store.delete(thread_id)
37
+ store_id = object_id
38
+ Phronomy::ThreadActorRegistry.for(thread_id).call do
39
+ (Thread.current[THREAD_DATA_KEY] ||= {}).delete(store_id)
40
+ end
30
41
  self
31
42
  end
32
43
 
33
44
  def clear_all
34
- @store.clear
45
+ store_id = object_id
46
+ Phronomy::ThreadActorRegistry.each_actor do |actor|
47
+ actor.call { (Thread.current[THREAD_DATA_KEY] ||= {}).delete(store_id) }
48
+ end
35
49
  self
36
50
  end
37
51
  end
@@ -33,7 +33,7 @@ module Phronomy
33
33
  @ttl = ttl
34
34
  end
35
35
 
36
- # @param state [Object] includes Phronomy::Graph::State
36
+ # @param state [Object] includes Phronomy::WorkflowContext
37
37
  # @return [self]
38
38
  def save(state)
39
39
  serialized = serialize_state(state)
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Global per-thread-id {Actor} registry.
5
+ #
6
+ # Maps the +:thread_id+ key from the +config:+ argument passed to
7
+ # {Phronomy::Agent::Base#invoke} to a {Phronomy::Actor} instance.
8
+ # Each thread_id gets exactly one Actor so that all operations for the same
9
+ # conversation are serialised automatically.
10
+ #
11
+ # @example
12
+ # Phronomy::ThreadActorRegistry.for("user-42").call do
13
+ # # runs sequentially on the Actor's thread
14
+ # end
15
+ module ThreadActorRegistry
16
+ @actors = {}
17
+ @registry_actor = Actor.new
18
+
19
+ class << self
20
+ # Returns (or lazily creates) the {Actor} for +thread_id+.
21
+ #
22
+ # @param thread_id [String]
23
+ # @return [Phronomy::Actor]
24
+ def for(thread_id)
25
+ @registry_actor.call { @actors[thread_id] ||= Actor.new }
26
+ end
27
+
28
+ # Gracefully stops the Actor for +thread_id+ and removes it from the
29
+ # registry. The next call to {.for} with the same id creates a fresh Actor.
30
+ #
31
+ # @param thread_id [String]
32
+ def stop(thread_id)
33
+ @registry_actor.call { @actors.delete(thread_id) }&.stop
34
+ end
35
+
36
+ # Stops and removes every registered Actor.
37
+ # Intended for test teardown and process shutdown.
38
+ def clear_all
39
+ actors = @registry_actor.call { @actors.values.tap { @actors.clear } }
40
+ actors.each(&:stop)
41
+ end
42
+
43
+ # Yields each currently registered Actor.
44
+ # A snapshot is taken so the registry cannot change while callers iterate.
45
+ #
46
+ # @yield [Phronomy::Actor]
47
+ def each_actor(&block)
48
+ @registry_actor.call { @actors.values.dup }.each(&block)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -58,7 +58,7 @@ module Phronomy
58
58
  end
59
59
 
60
60
  # Sets the access scope for this tool (metadata; enforcement is the responsibility of
61
- # the Graph/Guardrail layer).
61
+ # the Workflow/Guardrail layer).
62
62
  # @param value [Symbol] e.g. :read_only, :write, :admin
63
63
  def scope(value = nil)
64
64
  return @scope if value.nil?
@@ -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
@@ -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
- @mutex = Mutex.new
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
- @mutex.synchronize do
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, _stderr, _wait_thr = Open3.popen3(*@command)
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
- @mutex.synchronize do
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)
@@ -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
+ @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
- http.request(req)
93
- rescue
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, **) = 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