phronomy 0.6.0 → 0.7.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +21 -0
  3. data/CHANGELOG.md +338 -0
  4. data/CONTRIBUTING.md +102 -0
  5. data/README.md +242 -27
  6. data/RELEASE_CHECKLIST.md +86 -0
  7. data/SECURITY.md +80 -0
  8. data/benchmark/baseline.json +9 -0
  9. data/benchmark/bench_agent_invoke.rb +105 -0
  10. data/benchmark/bench_context_assembler.rb +46 -0
  11. data/benchmark/bench_regression.rb +171 -0
  12. data/benchmark/bench_token_estimator.rb +44 -0
  13. data/benchmark/bench_tool_schema.rb +69 -0
  14. data/benchmark/bench_vector_store.rb +39 -0
  15. data/benchmark/bench_workflow.rb +55 -0
  16. data/benchmark/run_all.rb +118 -0
  17. data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
  18. data/docs/decisions/002-workflow-context-immutability.md +42 -0
  19. data/docs/decisions/003-event-loop-singleton.md +48 -0
  20. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +51 -0
  21. data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
  22. data/docs/decisions/006-no-built-in-guardrails.md +48 -0
  23. data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
  24. data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
  25. data/docs/decisions/009-state-store-abstraction.md +141 -0
  26. data/lib/phronomy/agent/base.rb +194 -12
  27. data/lib/phronomy/agent/before_completion_context.rb +1 -0
  28. data/lib/phronomy/agent/checkpoint.rb +1 -0
  29. data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
  30. data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
  31. data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
  32. data/lib/phronomy/agent/concerns/retryable.rb +12 -1
  33. data/lib/phronomy/agent/concerns/suspendable.rb +4 -0
  34. data/lib/phronomy/agent/fsm.rb +15 -0
  35. data/lib/phronomy/agent/handoff.rb +3 -0
  36. data/lib/phronomy/agent/orchestrator.rb +123 -11
  37. data/lib/phronomy/agent/parallel_tool_chat.rb +21 -4
  38. data/lib/phronomy/agent/react_agent.rb +8 -6
  39. data/lib/phronomy/agent/runner.rb +2 -0
  40. data/lib/phronomy/agent/shared_state.rb +11 -0
  41. data/lib/phronomy/agent/suspend_signal.rb +2 -0
  42. data/lib/phronomy/agent/team_coordinator.rb +17 -5
  43. data/lib/phronomy/cancellation_token.rb +92 -0
  44. data/lib/phronomy/configuration.rb +26 -2
  45. data/lib/phronomy/context/assembler.rb +6 -0
  46. data/lib/phronomy/context/compaction_context.rb +2 -0
  47. data/lib/phronomy/context/context_version_cache.rb +2 -0
  48. data/lib/phronomy/context/token_budget.rb +3 -0
  49. data/lib/phronomy/context/token_estimator.rb +9 -2
  50. data/lib/phronomy/context/trigger_context.rb +1 -0
  51. data/lib/phronomy/context/trim_context.rb +4 -0
  52. data/lib/phronomy/embeddings/base.rb +5 -2
  53. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
  54. data/lib/phronomy/eval/comparison.rb +2 -0
  55. data/lib/phronomy/eval/dataset.rb +4 -0
  56. data/lib/phronomy/eval/metrics.rb +6 -0
  57. data/lib/phronomy/eval/runner.rb +2 -0
  58. data/lib/phronomy/eval/scorer/base.rb +1 -0
  59. data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
  60. data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
  61. data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
  62. data/lib/phronomy/event_loop.rb +114 -7
  63. data/lib/phronomy/fsm_session.rb +8 -1
  64. data/lib/phronomy/generator_verifier.rb +2 -0
  65. data/lib/phronomy/guardrail/base.rb +3 -0
  66. data/lib/phronomy/knowledge_source/base.rb +6 -2
  67. data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
  68. data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
  69. data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
  70. data/lib/phronomy/loader/base.rb +1 -0
  71. data/lib/phronomy/loader/csv_loader.rb +2 -0
  72. data/lib/phronomy/loader/markdown_loader.rb +2 -0
  73. data/lib/phronomy/loader/plain_text_loader.rb +1 -0
  74. data/lib/phronomy/output_parser/base.rb +1 -0
  75. data/lib/phronomy/output_parser/json_parser.rb +22 -3
  76. data/lib/phronomy/output_parser/structured_parser.rb +2 -0
  77. data/lib/phronomy/prompt_template.rb +5 -0
  78. data/lib/phronomy/runnable.rb +20 -3
  79. data/lib/phronomy/splitter/base.rb +2 -0
  80. data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
  81. data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
  82. data/lib/phronomy/state_store/base.rb +48 -0
  83. data/lib/phronomy/state_store/in_memory.rb +62 -0
  84. data/lib/phronomy/tool/agent_tool.rb +1 -0
  85. data/lib/phronomy/tool/base.rb +189 -27
  86. data/lib/phronomy/tool/mcp_tool.rb +68 -13
  87. data/lib/phronomy/tracing/base.rb +3 -0
  88. data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
  89. data/lib/phronomy/tracing/open_telemetry_tracer.rb +2 -0
  90. data/lib/phronomy/vector_store/base.rb +33 -7
  91. data/lib/phronomy/vector_store/in_memory.rb +16 -7
  92. data/lib/phronomy/vector_store/pgvector.rb +40 -9
  93. data/lib/phronomy/vector_store/redis_search.rb +29 -8
  94. data/lib/phronomy/version.rb +1 -1
  95. data/lib/phronomy/workflow.rb +96 -7
  96. data/lib/phronomy/workflow_context.rb +54 -4
  97. data/lib/phronomy/workflow_runner.rb +35 -7
  98. data/lib/phronomy.rb +70 -1
  99. data/scripts/api_snapshot.rb +91 -0
  100. data/scripts/check_api_annotations.rb +68 -0
  101. data/scripts/check_private_enforcement.rb +93 -0
  102. data/scripts/check_readme_runnable.rb +98 -0
  103. data/scripts/run_mutation.sh +46 -0
  104. metadata +45 -2
@@ -37,8 +37,14 @@ module Phronomy
37
37
 
38
38
  def initialize
39
39
  @queue = Thread::Queue.new # global event queue (thread-safe; no Mutex needed)
40
- @fsms = {} # { id => FSMSession } — EventLoop thread only
41
- @waiting = {} # { id => completion_queue } — EventLoop thread only
40
+ @fsms = {} # { id => FSMSession } — EventLoop thread only
41
+ @waiting = {} # { id => completion_queue } — EventLoop thread only
42
+ # Mutex-backed FSM count for drain-mode shutdown.
43
+ @fsm_count_mutex = Mutex.new
44
+ @fsm_count_cond = ConditionVariable.new
45
+ @fsm_count = 0
46
+ # Token cancelled when shutdown is requested; new child sessions receive it.
47
+ @shutdown_token = Phronomy::CancellationToken.new
42
48
  end
43
49
 
44
50
  # Registers an FSMSession for execution and returns a completion queue.
@@ -53,6 +59,7 @@ module Phronomy
53
59
  #
54
60
  # @param fsm_session [Phronomy::FSMSession]
55
61
  # @return [Thread::Queue] resolves to final/halted context, or an Exception
62
+ # @api private
56
63
  def register(fsm_session)
57
64
  if Thread.current[:phronomy_event_loop_thread]
58
65
  raise Phronomy::Error,
@@ -78,6 +85,7 @@ module Phronomy
78
85
  #
79
86
  # @param agent_fsm [Phronomy::Agent::FSM]
80
87
  # @return [nil]
88
+ # @api private
81
89
  def enqueue_child(agent_fsm)
82
90
  @queue.push(Event.new(type: :start, target_id: agent_fsm.id,
83
91
  payload: {session: agent_fsm, completion: nil}))
@@ -87,13 +95,20 @@ module Phronomy
87
95
  # Posts an event to the loop. Safe to call from any thread (including IO threads).
88
96
  #
89
97
  # @param event [Phronomy::Event]
98
+ # @api private
90
99
  def post(event)
91
100
  @queue.push(event)
92
101
  end
93
102
 
94
103
  # Starts the background event loop thread.
95
104
  # @return [self]
105
+ # @api private
96
106
  def start
107
+ return self if @thread&.alive?
108
+
109
+ # Reset shutdown state so the loop can be restarted after a stop.
110
+ @shutdown_token = Phronomy::CancellationToken.new
111
+ @fsm_count_mutex.synchronize { @fsm_count = 0 }
97
112
  @running = true
98
113
  @thread = Thread.new do
99
114
  Thread.current[:phronomy_event_loop_thread] = true
@@ -104,11 +119,73 @@ module Phronomy
104
119
  end
105
120
 
106
121
  # Stops the background thread. Used in tests only.
122
+ #
123
+ # Sends a cooperative shutdown sentinel to the event queue so that the
124
+ # worker thread can finish any in-flight handler before exiting. Waits up
125
+ # to +timeout+ seconds for a clean shutdown; if the thread is still alive
126
+ # afterwards it is force-killed as a last resort.
127
+ #
128
+ # @param timeout [Numeric] seconds to wait for cooperative shutdown. Defaults
129
+ # to +Phronomy.configuration.event_loop_stop_grace_seconds+ (5 s).
130
+ # @param drain [Boolean] when +true+, wait for all active FSMSessions to
131
+ # complete before signalling the loop to stop. Bounded by +timeout+.
132
+ # Defaults to +false+.
133
+ # @param force_kill [Boolean] when +true+, the worker thread is killed with
134
+ # +Thread#kill+ if it does not stop within +timeout+. When +false+
135
+ # (default), the thread is never killed; the method returns +:timeout+
136
+ # instead. +false+ is safer for production because +Thread#kill+ can
137
+ # interrupt +ensure+ blocks.
138
+ # @return [Symbol] shutdown status:
139
+ # - +:clean+ — loop exited cooperatively with no active sessions discarded
140
+ # - +:drained_with_discards+ — drain mode requested but sessions remained;
141
+ # they were discarded and the loop was stopped
142
+ # - +:timeout+ — the worker thread did not stop in time and +force_kill:+ is +false+
143
+ # - +:force_killed+ — the worker thread did not stop in time and was killed
107
144
  # @api private
108
- def stop
145
+ def stop(timeout: Phronomy.configuration.event_loop_stop_grace_seconds, drain: false, force_kill: false)
146
+ @shutdown_token.cancel!
147
+ status = :clean
148
+
149
+ if drain
150
+ # Wait for active sessions to finish, bounded by timeout.
151
+ deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
152
+ @fsm_count_mutex.synchronize do
153
+ while @fsm_count > 0
154
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
155
+ break if remaining <= 0
156
+ @fsm_count_cond.wait(@fsm_count_mutex, remaining)
157
+ end
158
+ status = :drained_with_discards if @fsm_count > 0
159
+ end
160
+ end
161
+
109
162
  @running = false
110
- @thread&.kill
163
+ @queue.push(:__stop__) # unblock queue.pop so the worker can see @running = false
164
+ begin
165
+ @thread&.join(timeout)
166
+ rescue
167
+ # Thread may have terminated with an exception (e.g. simulated crash in
168
+ # tests). Suppress the re-raise so the cleanup below always runs.
169
+ nil
170
+ end
171
+ if @thread&.alive?
172
+ if force_kill
173
+ Phronomy.configuration.logger&.warn(
174
+ "[Phronomy] EventLoop thread did not stop within #{timeout}s; force-killing. " \
175
+ "This is a last resort — check for blocking operations in event handlers."
176
+ )
177
+ @thread.kill
178
+ status = :force_killed
179
+ else
180
+ Phronomy.configuration.logger&.warn(
181
+ "[Phronomy] EventLoop thread did not stop within #{timeout}s; abandoning " \
182
+ "(force_kill: false). Check for blocking operations in event handlers."
183
+ )
184
+ status = :timeout
185
+ end
186
+ end
111
187
  @thread = nil
188
+ status
112
189
  end
113
190
 
114
191
  private
@@ -116,6 +193,12 @@ module Phronomy
116
193
  def run_loop
117
194
  while @running
118
195
  event = @queue.pop
196
+ # :__stop__ is used purely as an unblock signal for @queue.pop; the
197
+ # actual stop condition is @running == false (set before the push).
198
+ # Treating it as `next` instead of `break` prevents a stale sentinel
199
+ # (left by a previous stop call that raced with thread start) from
200
+ # immediately terminating a freshly restarted EventLoop.
201
+ next if event == :__stop__
119
202
 
120
203
  case event.type
121
204
  when :finished, :halted, :error
@@ -124,18 +207,42 @@ module Phronomy
124
207
  @fsms.delete(event.target_id)
125
208
  cq = @waiting.delete(event.target_id)
126
209
  cq&.push(event.payload)
210
+ # Decrement active FSM count and signal drain waiters.
211
+ @fsm_count_mutex.synchronize do
212
+ @fsm_count -= 1
213
+ @fsm_count_cond.signal if @fsm_count <= 0
214
+ end
127
215
 
128
216
  when :start
129
217
  # session and completion_queue arrive together in the payload so that
130
218
  # this thread is the sole writer of @fsms and @waiting.
131
219
  # completion may be nil for fire-and-forget child sessions (AgentFSM).
132
- @fsms[event.target_id] = event.payload[:session]
220
+ session = event.payload[:session]
133
221
  cq = event.payload[:completion]
222
+
223
+ # When shutdown has been requested, reject new sessions with a
224
+ # CancellationError rather than starting new LLM calls that would
225
+ # be interrupted by force-kill.
226
+ if @shutdown_token.cancelled? && cq
227
+ cq.push(Phronomy::CancellationError.new("EventLoop is shutting down"))
228
+ next
229
+ end
230
+
231
+ @fsms[event.target_id] = session
134
232
  @waiting[event.target_id] = cq if cq
135
- event.payload[:session].start
233
+ @fsm_count_mutex.synchronize { @fsm_count += 1 }
234
+ session.start
136
235
 
137
236
  else
138
- @fsms[event.target_id]&.handle(event)
237
+ fsm = @fsms[event.target_id]
238
+ if fsm
239
+ fsm.handle(event)
240
+ else
241
+ # Warn when an event is dropped due to an unknown target_id so that
242
+ # mis-typed IDs and handler-deregistration races are visible.
243
+ warn "[Phronomy::EventLoop] Dropped event #{event.type.inspect} — " \
244
+ "no handler for target_id #{event.target_id.inspect}"
245
+ end
139
246
  end
140
247
  end
141
248
  rescue => e
@@ -51,6 +51,7 @@ module Phronomy
51
51
  # @param recursion_limit [Integer]
52
52
  # @param resume_event [Symbol, nil] external event to fire when resuming
53
53
  # @param resume_phase [Symbol, nil] wait state name to resume from
54
+ # @api private
54
55
  def initialize(id:, context:, entry_point:, entry_actions:, auto_state_set:,
55
56
  declared_states:, wait_state_names:, external_events:, phase_machine_class:,
56
57
  recursion_limit:, resume_event: nil, resume_phase: nil)
@@ -88,7 +89,11 @@ module Phronomy
88
89
  @current_state = @entry_point
89
90
  @tracker = build_tracker(@current_state)
90
91
  @tracker.context = @ctx
91
- (@entry_actions[@current_state] || []).each { |c| c.call(@ctx) }
92
+ (@entry_actions[@current_state] || []).each do |c|
93
+ result = c.call(@ctx)
94
+ @ctx = result if result.is_a?(Phronomy::WorkflowContext)
95
+ end
96
+ @tracker.context = @ctx
92
97
  advance_or_halt
93
98
  end
94
99
  rescue => e
@@ -99,6 +104,7 @@ module Phronomy
99
104
  # Called for :state_completed and all user-defined external events.
100
105
  #
101
106
  # @param event [Phronomy::Event]
107
+ # @api private
102
108
  def handle(event)
103
109
  return if @done
104
110
 
@@ -118,6 +124,7 @@ module Phronomy
118
124
  end
119
125
 
120
126
  fire_event!(@tracker, event_name, @current_state)
127
+ @ctx = @tracker.context
121
128
  next_phase = @tracker.phase.to_sym
122
129
  # When next_phase == @current_state, no transition matched → treat as terminal.
123
130
  @current_state = (next_phase == @current_state) ? FINISH : next_phase
@@ -113,6 +113,7 @@ module Phronomy
113
113
  # @param raise_if_untrusted [Boolean] when +true+, raises
114
114
  # {Phronomy::LowConfidenceError} if the final result does not meet the
115
115
  # confidence threshold (default: false)
116
+ # @api private
116
117
  def initialize(
117
118
  draft_agent:,
118
119
  review_agent:,
@@ -143,6 +144,7 @@ module Phronomy
143
144
  # @return [Result]
144
145
  # @raise [Phronomy::LowConfidenceError] when +raise_if_untrusted:+ is +true+
145
146
  # and the result does not meet the confidence threshold
147
+ # @api private
146
148
  def invoke(input, config: {})
147
149
  app = compiled_workflow
148
150
  state = app.invoke({input: input}, config: config)
@@ -17,6 +17,7 @@ module Phronomy
17
17
  # Validate the value. Subclasses must implement this method.
18
18
  # @param value [Object] the input or output being checked
19
19
  # @raise [Phronomy::GuardrailError] if the guardrail rejects the value
20
+ # @api public
20
21
  def check(value)
21
22
  raise NotImplementedError, "#{self.class}#check is not implemented"
22
23
  end
@@ -24,6 +25,7 @@ module Phronomy
24
25
  # Run the check, raising GuardrailError on failure.
25
26
  # @param value [Object]
26
27
  # @return [Object] the original value (unchanged) when the check passes
28
+ # @api public
27
29
  def run!(value)
28
30
  check(value)
29
31
  value
@@ -34,6 +36,7 @@ module Phronomy
34
36
  # Call inside #check to reject the value.
35
37
  # @param reason [String] human-readable rejection reason
36
38
  # @raise [Phronomy::GuardrailError]
39
+ # @api public
37
40
  def fail!(reason)
38
41
  raise Phronomy::GuardrailError.new(reason, guardrail: self)
39
42
  end
@@ -11,9 +11,12 @@ module Phronomy
11
11
  class Base
12
12
  # Retrieve knowledge chunks relevant to the given query.
13
13
  #
14
- # @param query [String, nil] the current user input used to select relevant chunks
14
+ # @param query [String, nil] the current user input used to select relevant chunks
15
+ # @param cancellation_token [Phronomy::CancellationToken, nil] optional token; raises CancellationError when cancelled
15
16
  # @return [Array<Hash>] array of { content: String, type: Symbol }
16
- def fetch(query: nil)
17
+ # @api public
18
+ def fetch(query: nil, cancellation_token: nil)
19
+ cancellation_token&.raise_if_cancelled!
17
20
  raise NotImplementedError, "#{self.class}#fetch is not implemented"
18
21
  end
19
22
 
@@ -24,6 +27,7 @@ module Phronomy
24
27
  # Override in subclasses that return fixed content.
25
28
  #
26
29
  # @return [Boolean]
30
+ # @api public
27
31
  def static?
28
32
  false
29
33
  end
@@ -43,6 +43,7 @@ module Phronomy
43
43
  # Call this after saving a new set of messages (e.g. from a ConversationManager save hook).
44
44
  #
45
45
  # @param messages [Array] message objects responding to #role and #content
46
+ # @api public
46
47
  def update(messages:)
47
48
  messages.each do |msg|
48
49
  next unless msg.role.to_sym == :user
@@ -54,9 +55,12 @@ module Phronomy
54
55
  # Returns a single chunk containing all known entity facts in XML context format.
55
56
  # Returns an empty array when no entities have been discovered.
56
57
  #
57
- # @param query [String, nil] unused — entity knowledge is always fully injected
58
+ # @param query [String, nil] unused — entity knowledge is always fully injected
59
+ # @param cancellation_token [Phronomy::CancellationToken, nil] optional; raises CancellationError when cancelled
58
60
  # @return [Array<Hash>]
59
- def fetch(query: nil)
61
+ # @api public
62
+ def fetch(query: nil, cancellation_token: nil)
63
+ cancellation_token&.raise_if_cancelled!
60
64
  return [] if @entities.empty?
61
65
 
62
66
  lines = @entities.map { |key, value| "- #{key}: #{value}" }.join("\n")
@@ -70,6 +74,7 @@ module Phronomy
70
74
  # Returns the current entity store (primarily for testing).
71
75
  #
72
76
  # @return [Hash]
77
+ # @api public
73
78
  def entities
74
79
  @entities.dup
75
80
  end
@@ -22,6 +22,7 @@ module Phronomy
22
22
  # @param type [Symbol] semantic tag (default :rag)
23
23
  # @param source [String, nil] default source label; falls back to
24
24
  # each document's :source metadata when nil
25
+ # @api public
25
26
  def initialize(store:, embeddings:, k: 5, type: :rag, source: nil)
26
27
  @store = store
27
28
  @embeddings = embeddings
@@ -34,13 +35,16 @@ module Phronomy
34
35
  #
35
36
  # Returns an empty array when query is nil or blank.
36
37
  #
37
- # @param query [String, nil]
38
+ # @param query [String, nil]
39
+ # @param cancellation_token [Phronomy::CancellationToken, nil] optional; raises CancellationError when cancelled
38
40
  # @return [Array<Hash>]
39
- def fetch(query: nil)
41
+ # @api public
42
+ def fetch(query: nil, cancellation_token: nil)
43
+ cancellation_token&.raise_if_cancelled!
40
44
  return [] if query.nil? || query.strip.empty?
41
45
 
42
- vector = @embeddings.embed(query)
43
- results = @store.search(query_embedding: vector, k: @k)
46
+ vector = @embeddings.embed(query, cancellation_token)
47
+ results = @store.search(query_embedding: vector, k: @k, cancellation_token: cancellation_token)
44
48
  results.map do |doc|
45
49
  chunk = {content: doc[:metadata][:content], type: @type}
46
50
  src = @source || doc[:metadata][:source]
@@ -19,6 +19,7 @@ module Phronomy
19
19
  # @param source [String, nil] label identifying where this knowledge came from
20
20
  # (e.g. a filename). Included in the context XML tag and exposed to the LLM
21
21
  # so that agents can produce grounded citations.
22
+ # @api public
22
23
  def initialize(text, type: :static, source: nil)
23
24
  @text = text.to_s
24
25
  @type = type
@@ -27,9 +28,12 @@ module Phronomy
27
28
 
28
29
  # Returns the fixed text as a single chunk, regardless of query.
29
30
  #
30
- # @param query [String, nil] ignored for static knowledge
31
+ # @param query [String, nil] ignored for static knowledge
32
+ # @param cancellation_token [Phronomy::CancellationToken, nil] optional; raises CancellationError when cancelled
31
33
  # @return [Array<Hash>]
32
- def fetch(query: nil)
34
+ # @api public
35
+ def fetch(query: nil, cancellation_token: nil)
36
+ cancellation_token&.raise_if_cancelled!
33
37
  return [] if @text.empty?
34
38
 
35
39
  chunk = {content: @text, type: @type}
@@ -39,6 +43,7 @@ module Phronomy
39
43
 
40
44
  # Static knowledge content never changes between invocations.
41
45
  # @return [true]
46
+ # @api public
42
47
  def static?
43
48
  true
44
49
  end
@@ -16,6 +16,7 @@ module Phronomy
16
16
  # @param source [String] file path, URL, or other source identifier
17
17
  # @return [Array<Hash>] array of <tt>{ text: String, metadata: Hash }</tt>
18
18
  # @raise [NotImplementedError] when not overridden by a subclass
19
+ # @api public
19
20
  def load(source)
20
21
  raise NotImplementedError, "#{self.class}#load is not implemented"
21
22
  end
@@ -20,6 +20,7 @@ module Phronomy
20
20
  class CsvLoader < Base
21
21
  # @param headers [Boolean] treat the first row as headers (default: true)
22
22
  # @param text_column [String, nil] if set, use only this column as the document text
23
+ # @api public
23
24
  def initialize(headers: true, text_column: nil)
24
25
  @headers = headers
25
26
  @text_column = text_column
@@ -28,6 +29,7 @@ module Phronomy
28
29
  # @param source [String] path to a CSV file
29
30
  # @return [Array<Hash>]
30
31
  # @raise [Errno::ENOENT] if the file does not exist
32
+ # @api public
31
33
  def load(source)
32
34
  rows = CSV.read(source, headers: @headers, encoding: "UTF-8")
33
35
 
@@ -24,6 +24,7 @@ module Phronomy
24
24
  HEADING_RE = /^(\#{1,6})\s+(.+)$/
25
25
 
26
26
  # @param split_on_headings [Boolean] split on H1–H6 boundaries (default: true)
27
+ # @api public
27
28
  def initialize(split_on_headings: true)
28
29
  @split_on_headings = split_on_headings
29
30
  end
@@ -31,6 +32,7 @@ module Phronomy
31
32
  # @param source [String] path to a Markdown file
32
33
  # @return [Array<Hash>]
33
34
  # @raise [Errno::ENOENT] if the file does not exist
35
+ # @api public
34
36
  def load(source)
35
37
  content = File.read(source, encoding: "UTF-8")
36
38
  return [{text: content, metadata: {source: source}}] unless @split_on_headings
@@ -12,6 +12,7 @@ module Phronomy
12
12
  # @param source [String] absolute or relative path to a text file
13
13
  # @return [Array<Hash>] single-element array with the file contents
14
14
  # @raise [Errno::ENOENT] if the file does not exist
15
+ # @api public
15
16
  def load(source)
16
17
  text = File.read(source, encoding: "UTF-8")
17
18
  [{text: text, metadata: {source: source}}]
@@ -9,6 +9,7 @@ module Phronomy
9
9
 
10
10
  # @param input [String, #to_s] text to parse
11
11
  # @return [Object] parsed result
12
+ # @api public
12
13
  def invoke(input, config: {})
13
14
  parse(input.is_a?(String) ? input : input.to_s)
14
15
  end
@@ -10,6 +10,7 @@ module Phronomy
10
10
  # @param text [String]
11
11
  # @return [Hash, Array] result parsed with symbolize_names: true
12
12
  # @raise [Phronomy::ParseError] raised when JSON parsing fails
13
+ # @api public
13
14
  def parse(text)
14
15
  json_str = extract_json(text)
15
16
  JSON.parse(json_str, symbolize_names: true)
@@ -19,10 +20,28 @@ module Phronomy
19
20
 
20
21
  private
21
22
 
22
- # Extracts the inner content of a Markdown code fence if present;
23
- # otherwise returns the text as-is.
23
+ # Extracts a JSON string from the LLM response text.
24
+ #
25
+ # Strategy (in order):
26
+ # 1. Try each ```json ... ``` or ``` ... ``` code fence in document order,
27
+ # returning the content of the first one that parses as valid JSON.
28
+ # 2. Try the raw text stripped of leading/trailing whitespace.
29
+ #
30
+ # This handles:
31
+ # - Single JSON code fence (common case)
32
+ # - Multiple code fences — the first parseable JSON block wins
33
+ # - No fence — LLM omitted the backticks but returned valid JSON
24
34
  def extract_json(text)
25
- text.match(/```(?:json)?\s*\n?(.*?)\n?```/m)&.captures&.first || text.strip
35
+ text.scan(/```(?:json)?\s*\n?(.*?)\n?```/m).each do |captures|
36
+ candidate = captures.first.strip
37
+ JSON.parse(candidate)
38
+ return candidate
39
+ rescue JSON::ParserError
40
+ next
41
+ end
42
+
43
+ # Fallback: no valid fence found — try the raw text
44
+ text.strip
26
45
  end
27
46
  end
28
47
  end
@@ -9,6 +9,7 @@ module Phronomy
9
9
  # parser.parse('{"name":"Alice","age":30}') #=> #<struct PersonSchema name="Alice", age=30>
10
10
  class StructuredParser < Base
11
11
  # @param schema_class [Class] Struct with keyword_init: true or equivalent
12
+ # @api public
12
13
  def initialize(schema_class)
13
14
  @schema_class = schema_class
14
15
  end
@@ -16,6 +17,7 @@ module Phronomy
16
17
  # @param text [String]
17
18
  # @return [Object] instance of schema_class
18
19
  # @raise [Phronomy::ParseError] raised when JSON parsing or schema instantiation fails
20
+ # @api public
19
21
  def parse(text)
20
22
  data = JsonParser.new.parse(text)
21
23
  @schema_class.new(**data)
@@ -27,6 +27,7 @@ module Phronomy
27
27
 
28
28
  # @param template [String] human message template with {{var}} placeholders
29
29
  # @param system_template [String, nil] optional system message template
30
+ # @api public
30
31
  def initialize(template:, system_template: nil)
31
32
  @template = template
32
33
  @system_template = system_template
@@ -36,6 +37,7 @@ module Phronomy
36
37
  #
37
38
  # @param variables [Hash{Symbol => String}]
38
39
  # @return [String]
40
+ # @api public
39
41
  def format(**variables)
40
42
  substitute(@template, variables)
41
43
  end
@@ -45,6 +47,7 @@ module Phronomy
45
47
  #
46
48
  # @param variables [Hash{Symbol => String}]
47
49
  # @return [String, nil]
50
+ # @api public
48
51
  def format_system(**variables)
49
52
  @system_template && substitute(@system_template, variables)
50
53
  end
@@ -54,6 +57,7 @@ module Phronomy
54
57
  #
55
58
  # @param input [Hash{Symbol => String}]
56
59
  # @return [Hash]
60
+ # @api public
57
61
  def invoke(input, config: {})
58
62
  vars = normalize_input(input)
59
63
  result = {prompt: format(**vars)}
@@ -65,6 +69,7 @@ module Phronomy
65
69
  # Returns the list of placeholder names found in both templates.
66
70
  #
67
71
  # @return [Array<Symbol>]
72
+ # @api public
68
73
  def variables
69
74
  names = @template.scan(PLACEHOLDER).flatten
70
75
  names += @system_template.scan(PLACEHOLDER).flatten if @system_template
@@ -25,13 +25,30 @@ module Phronomy
25
25
  # Yields a span; the block must return [result, usage] where usage is a
26
26
  # Phronomy::TokenUsage or nil. Returns only the result value.
27
27
  #
28
+ # When +trace_pii+ is disabled, both the input and the output (LLM response,
29
+ # tool result) are replaced with the literal string "[REDACTED]" before being
30
+ # forwarded to the tracing backend. The actual result is still returned to
31
+ # the caller — only the copy sent to the tracer is redacted.
32
+ #
28
33
  # @example
29
34
  # trace("my_chain", input: input) { [invoke(input), nil] }
35
+ # @api public
30
36
  def trace(name, input: nil, **meta, &block)
31
- # Redact user input from spans when trace_pii is disabled to prevent
32
- # accidental PII transmission to external tracing backends.
33
37
  traced_input = Phronomy.configuration.trace_pii ? input : "[REDACTED]"
34
- Phronomy.configuration.tracer.trace(name, input: traced_input, **meta, &block)
38
+
39
+ if Phronomy.configuration.trace_pii
40
+ # PII recording is allowed: pass through unchanged.
41
+ Phronomy.configuration.tracer.trace(name, input: traced_input, **meta, &block)
42
+ else
43
+ # Redact both input (above) and output before forwarding to the tracer.
44
+ # Capture the real result so callers receive the unredacted value.
45
+ real_result = nil
46
+ Phronomy.configuration.tracer.trace(name, input: traced_input, **meta) do |span|
47
+ real_result, usage = block.call(span)
48
+ ["[REDACTED]", usage]
49
+ end
50
+ real_result
51
+ end
35
52
  end
36
53
  end
37
54
  end
@@ -18,6 +18,7 @@ module Phronomy
18
18
  # returned by a Loader, or a plain String.
19
19
  # @return [Array<Hash>] array of <tt>{ text: String, metadata: Hash }</tt>
20
20
  # @raise [NotImplementedError] when not overridden by a subclass
21
+ # @api public
21
22
  def split(document)
22
23
  raise NotImplementedError, "#{self.class}#split is not implemented"
23
24
  end
@@ -26,6 +27,7 @@ module Phronomy
26
27
  #
27
28
  # @param documents [Array<Hash, String>]
28
29
  # @return [Array<Hash>]
30
+ # @api public
29
31
  def split_all(documents)
30
32
  documents.flat_map { |doc| split(doc) }
31
33
  end
@@ -15,6 +15,7 @@ module Phronomy
15
15
  # @param chunk_size [Integer] maximum characters per chunk (default: 1000)
16
16
  # @param chunk_overlap [Integer] characters to repeat at the start of each
17
17
  # subsequent chunk (default: 200); must be less than chunk_size
18
+ # @api public
18
19
  def initialize(chunk_size: 1000, chunk_overlap: 200)
19
20
  raise ArgumentError, "chunk_overlap must be less than chunk_size" if chunk_overlap >= chunk_size
20
21
 
@@ -24,6 +25,7 @@ module Phronomy
24
25
 
25
26
  # @param document [Hash, String]
26
27
  # @return [Array<Hash>]
28
+ # @api public
27
29
  def split(document)
28
30
  doc = normalise(document)
29
31
  text = doc[:text]
@@ -25,6 +25,7 @@ module Phronomy
25
25
  # @param chunk_size [Integer] maximum characters per chunk (default: 1000)
26
26
  # @param chunk_overlap [Integer] overlap characters (default: 200)
27
27
  # @param separators [Array<String>] separator list in priority order
28
+ # @api public
28
29
  def initialize(chunk_size: 1000, chunk_overlap: 200, separators: DEFAULT_SEPARATORS)
29
30
  raise ArgumentError, "chunk_overlap must be less than chunk_size" if chunk_overlap >= chunk_size
30
31
 
@@ -35,6 +36,7 @@ module Phronomy
35
36
 
36
37
  # @param document [Hash, String]
37
38
  # @return [Array<Hash>]
39
+ # @api public
38
40
  def split(document)
39
41
  doc = normalise(document)
40
42
  texts = recursive_split(doc[:text], @separators)
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module StateStore
5
+ # Abstract base class for workflow state persistence backends.
6
+ #
7
+ # Subclasses must implement {#load}, {#save}, and {#delete}.
8
+ # A snapshot is a plain +Hash+ with two keys:
9
+ # +:fields+ — output of +context.to_h+
10
+ # +:phase+ — +context.phase.to_s+
11
+ #
12
+ # @example Implementing a custom backend
13
+ # class MyStore < Phronomy::StateStore::Base
14
+ # def load(thread_id) = MyRecord.find_by(thread_id:)&.to_h
15
+ # def save(thread_id, snapshot) = MyRecord.upsert(thread_id:, data: snapshot)
16
+ # def delete(thread_id) = MyRecord.where(thread_id:).delete_all
17
+ # end
18
+ class Base
19
+ # Load the stored snapshot for +thread_id+.
20
+ #
21
+ # @param thread_id [String]
22
+ # @return [Hash, nil] stored snapshot hash, or +nil+ if absent
23
+ # @api public
24
+ def load(thread_id)
25
+ raise NotImplementedError, "#{self.class}#load is not implemented"
26
+ end
27
+
28
+ # Persist +snapshot+ for +thread_id+. Overwrites any existing snapshot.
29
+ #
30
+ # @param thread_id [String]
31
+ # @param snapshot [Hash] serialisable hash of workflow state
32
+ # @return [void]
33
+ # @api public
34
+ def save(thread_id, snapshot)
35
+ raise NotImplementedError, "#{self.class}#save is not implemented"
36
+ end
37
+
38
+ # Delete the stored snapshot for +thread_id+. No-op if absent.
39
+ #
40
+ # @param thread_id [String]
41
+ # @return [void]
42
+ # @api public
43
+ def delete(thread_id)
44
+ raise NotImplementedError, "#{self.class}#delete is not implemented"
45
+ end
46
+ end
47
+ end
48
+ end