phronomy 0.1.3 → 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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 04a7eceda662bfc638c3ec07ac161299b2bb08863e18e9ba03e7a3226165a921
4
- data.tar.gz: 9938ace6e4a7250c08f733339af4de14c7d9ff62ff7c52a27eb954c0700d18f4
3
+ metadata.gz: d95954b46d12542673b5a319b338c7733d579b72105499a55dc4251628bc807f
4
+ data.tar.gz: 174341a0e329d861066d475b062260c3d78fac86da3d024ebc1594d7a37ec348
5
5
  SHA512:
6
- metadata.gz: 1dc032c438a407a751b5c74fd76d172796e852a3add651c1ca6bb210991d20305a1cc609fe157e48026293084943714569219d359794c9bc7fde0dc396ed16d1
7
- data.tar.gz: c1b52dcfa5196b92b72641f8d980856e5e827d61ff0e8afc61f5664f0556e0dbb0b047a7755d2f5f148525774c885ead4319510c32d7f2699fb4601c38d12ecd
6
+ metadata.gz: ee299b8d67fec8cb268683ffe672daab04a5c5b4794728dbeea3877e6c5216cefe91f292acac977d7293b837cd0b159445d58a87f925781a26f24438faecd010
7
+ data.tar.gz: 6da71943dc65b3671f5bd34ff18509a8ee2e0b12bfb5fcb206df77ba2c7345c9fd1b59dec90088dc3e8588e9327cb162046a803a9ac7cd405f4d30fc6712ebc3
@@ -3,7 +3,7 @@ class CreatePhronomyMessages < ActiveRecord::Migration[<%= ActiveRecord::Migrati
3
3
  create_table :phronomy_messages do |t|
4
4
  t.string :thread_id, null: false
5
5
  t.string :role, null: false
6
- t.text :content, null: false
6
+ t.text :content
7
7
  t.text :tool_calls_json
8
8
  t.string :model_id
9
9
  t.timestamps
@@ -446,82 +446,88 @@ module Phronomy
446
446
  def stream(input, config: {}, &block)
447
447
  return invoke(input, config: config) unless block
448
448
 
449
- run_input_guardrails!(input)
449
+ caller_meta = {}
450
+ caller_meta[:user_id] = config[:user_id] if config[:user_id]
451
+ caller_meta[:session_id] = config[:session_id] if config[:session_id]
450
452
 
451
- memory = config[:memory]
452
- thread_id = config[:thread_id]
453
+ trace("agent.invoke", input: input, **caller_meta) do |_span|
454
+ run_input_guardrails!(input)
455
+
456
+ memory = config[:memory]
457
+ thread_id = config[:thread_id]
453
458
 
454
- chat = build_chat
455
- user_message = extract_message(input)
456
- budget = build_token_budget
459
+ chat = build_chat
460
+ user_message = extract_message(input)
461
+ budget = build_token_budget
457
462
 
458
- # Assemble context via Assembler (same as invoke_once).
459
- assembler = Context::Assembler.new(budget: budget)
460
- system_msg = build_instructions(input)
461
- assembler.add_instruction(system_msg) if system_msg
463
+ # Assemble context via Assembler (same as invoke_once).
464
+ assembler = Context::Assembler.new(budget: budget)
465
+ system_msg = build_instructions(input)
466
+ assembler.add_instruction(system_msg) if system_msg
462
467
 
463
- Array(config[:knowledge_sources]).each do |ks|
464
- ks.fetch(query: user_message).each do |chunk|
465
- assembler.add_knowledge(chunk[:content], type: chunk[:type], source: chunk[:source])
468
+ Array(config[:knowledge_sources]).each do |ks|
469
+ ks.fetch(query: user_message).each do |chunk|
470
+ assembler.add_knowledge(chunk[:content], type: chunk[:type], source: chunk[:source])
471
+ end
466
472
  end
467
- end
468
473
 
469
- if memory && thread_id
470
- msgs = load_from_memory(memory, thread_id: thread_id, query: user_message)
471
- message_elements = build_message_elements(msgs)
474
+ if memory && thread_id
475
+ msgs = load_from_memory(memory, thread_id: thread_id, query: user_message)
476
+ message_elements = build_message_elements(msgs)
472
477
 
473
- # Run on_trim: app may call ctx.remove(seqs) to drop messages this turn.
474
- if (trim_cb = self.class._on_trim_callback)
475
- trim_ctx = Context::TrimContext.new(message_elements: message_elements, budget: budget)
476
- trim_cb.call(trim_ctx)
477
- message_elements = trim_ctx.message_elements
478
- end
478
+ # Run on_trim: app may call ctx.remove(seqs) to drop messages this turn.
479
+ if (trim_cb = self.class._on_trim_callback)
480
+ trim_ctx = Context::TrimContext.new(message_elements: message_elements, budget: budget)
481
+ trim_cb.call(trim_ctx)
482
+ message_elements = trim_ctx.message_elements
483
+ end
479
484
 
480
- # Run on_compaction_trigger → on_compact pipeline before calling the LLM.
481
- if (trigger_cb = self.class._on_compaction_trigger_callback)
482
- trigger_ctx = Context::TriggerContext.new(message_elements: message_elements, budget: budget)
483
- if trigger_cb.call(trigger_ctx)
484
- if (compact_cb = self.class._on_compact_callback)
485
- compact_ctx = Context::CompactionContext.new(
486
- message_elements: message_elements,
487
- budget: budget,
488
- thread_id: thread_id,
489
- memory: memory
490
- )
491
- compact_cb.call(compact_ctx)
492
- message_elements = build_message_elements(compact_ctx.result_messages)
485
+ # Run on_compaction_trigger → on_compact pipeline before calling the LLM.
486
+ if (trigger_cb = self.class._on_compaction_trigger_callback)
487
+ trigger_ctx = Context::TriggerContext.new(message_elements: message_elements, budget: budget)
488
+ if trigger_cb.call(trigger_ctx)
489
+ if (compact_cb = self.class._on_compact_callback)
490
+ compact_ctx = Context::CompactionContext.new(
491
+ message_elements: message_elements,
492
+ budget: budget,
493
+ thread_id: thread_id,
494
+ memory: memory
495
+ )
496
+ compact_cb.call(compact_ctx)
497
+ message_elements = build_message_elements(compact_ctx.result_messages)
498
+ end
493
499
  end
494
500
  end
495
- end
496
501
 
497
- assembler.add_messages(message_elements.map { |e| e[:message] })
498
- end
502
+ assembler.add_messages(message_elements.map { |e| e[:message] })
503
+ end
499
504
 
500
- context = assembler.build
501
- apply_instructions(chat, context[:system]) if context[:system]
502
- context[:messages].each { |msg| chat.messages << msg }
505
+ context = assembler.build
506
+ apply_instructions(chat, context[:system]) if context[:system]
507
+ context[:messages].each { |msg| chat.messages << msg }
503
508
 
504
- # Wire per-event callbacks to yield StreamEvents.
505
- chat.on_tool_call { |tool_call| block.call(StreamEvent.new(type: :tool_call, payload: {tool_call: tool_call})) }
506
- chat.on_tool_result { |tool_result| block.call(StreamEvent.new(type: :tool_result, payload: {tool_result: tool_result})) }
509
+ # Wire per-event callbacks to yield StreamEvents.
510
+ chat.before_tool_call { |tool_call| block.call(StreamEvent.new(type: :tool_call, payload: {tool_call: tool_call})) }
511
+ chat.after_tool_result { |tool_result| block.call(StreamEvent.new(type: :tool_result, payload: {tool_result: tool_result})) }
507
512
 
508
- # Run before_completion hooks (global → class → instance) before the LLM call.
509
- run_before_completion_hooks!(chat, config)
513
+ # Run before_completion hooks (global → class → instance) before the LLM call.
514
+ run_before_completion_hooks!(chat, config)
510
515
 
511
- response = chat.ask(user_message) do |chunk|
512
- block.call(StreamEvent.new(type: :token, payload: {content: chunk.content}))
513
- end
516
+ response = chat.ask(user_message) do |chunk|
517
+ block.call(StreamEvent.new(type: :token, payload: {content: chunk.content}))
518
+ end
514
519
 
515
- save_to_memory(memory, thread_id: thread_id, messages: chat.messages) if memory && thread_id
520
+ save_to_memory(memory, thread_id: thread_id, messages: chat.messages) if memory && thread_id
516
521
 
517
- output = response.content
518
- usage = Phronomy::TokenUsage.from_tokens(response.tokens)
522
+ output = response.content
523
+ usage = Phronomy::TokenUsage.from_tokens(response.tokens)
519
524
 
520
- run_output_guardrails!(output)
525
+ run_output_guardrails!(output)
521
526
 
522
- result = {output: output, messages: chat.messages, usage: usage}
523
- block.call(StreamEvent.new(type: :done, payload: result))
524
- result
527
+ result = {output: output, messages: chat.messages, usage: usage}
528
+ block.call(StreamEvent.new(type: :done, payload: result))
529
+ [result, usage]
530
+ end
525
531
  rescue => e
526
532
  block&.call(StreamEvent.new(type: :error, payload: {error: e}))
527
533
  raise
@@ -25,10 +25,10 @@ module Phronomy
25
25
  def initialize(target_agent:, description: nil)
26
26
  @target_agent = target_agent
27
27
  klass_name = target_agent.class.name&.split("::")&.last || "Agent"
28
- @tool_name = "transfer_to_#{snake_case(klass_name)}"
29
- @description = description || "Transfer the conversation to #{klass_name}."
30
28
  # Use a UUID so that two handoffs targeting the same class remain distinct.
31
29
  @uuid = SecureRandom.uuid
30
+ @tool_name = "transfer_to_#{snake_case(klass_name)}_#{@uuid.delete("-")[0, 8]}"
31
+ @description = description || "Transfer the conversation to #{klass_name}."
32
32
  end
33
33
 
34
34
  # Builds an anonymous Phronomy::Tool::Base subclass for this handoff.
@@ -5,7 +5,11 @@ module Phronomy
5
5
  # ReAct pattern (Reasoning + Acting) agent.
6
6
  # Repeats the LLM <-> Tool loop until no more tool calls are made.
7
7
  class ReactAgent < Base
8
- def invoke(input, config: {})
8
+ private
9
+
10
+ # Performs a single (non-retried) ReAct invocation.
11
+ # Overrides Base#invoke_once so that Base#invoke's retry loop is inherited.
12
+ def invoke_once(input, config: {})
9
13
  caller_meta = {}
10
14
  caller_meta[:user_id] = config[:user_id] if config[:user_id]
11
15
  caller_meta[:session_id] = config[:session_id] if config[:session_id]
@@ -43,7 +47,11 @@ module Phronomy
43
47
 
44
48
  save_to_memory(memory, thread_id: thread_id, messages: messages) if memory && thread_id
45
49
 
46
- output = messages.last&.content
50
+ # Fall back to the last message that carries non-nil content. This
51
+ # guards against the case where the final message is a tool-call or
52
+ # tool-result message (content == nil) when max_iterations is
53
+ # exhausted before the model produces a text reply.
54
+ output = messages.reverse.find { |m| m.content && !m.content.empty? }&.content
47
55
 
48
56
  # Run output guardrails before returning to the caller.
49
57
  run_output_guardrails!(output)
@@ -53,6 +61,8 @@ module Phronomy
53
61
  end
54
62
  end
55
63
 
64
+ public
65
+
56
66
  # Streaming version of #invoke for the ReAct loop.
57
67
  # Yields {Phronomy::Agent::StreamEvent} events while the LLM-tool loop runs.
58
68
  #
@@ -63,42 +73,50 @@ module Phronomy
63
73
  def stream(input, config: {}, &block)
64
74
  return invoke(input, config: config) unless block
65
75
 
66
- run_input_guardrails!(input)
76
+ caller_meta = {}
77
+ caller_meta[:user_id] = config[:user_id] if config[:user_id]
78
+ caller_meta[:session_id] = config[:session_id] if config[:session_id]
67
79
 
68
- memory = config[:memory]
69
- thread_id = config[:thread_id]
70
- max_iter = self.class.max_iterations
80
+ trace("agent.invoke", input: input, **caller_meta) do |_span|
81
+ run_input_guardrails!(input)
71
82
 
72
- initial_messages = if memory && thread_id
73
- load_from_memory(memory, thread_id: thread_id, query: extract_message(input))
74
- else
75
- []
76
- end
83
+ memory = config[:memory]
84
+ thread_id = config[:thread_id]
85
+ max_iter = self.class.max_iterations
77
86
 
78
- messages = initial_messages.dup
79
- user_asked = false
80
- total_usage = Phronomy::TokenUsage.zero
81
- iterations_exhausted = true
82
-
83
- max_iter.times do
84
- response = stream_step(messages, input, user_asked: user_asked, config: config, &block)
85
- user_asked = true
86
- messages = response[:messages]
87
- total_usage += response[:usage]
88
- if response[:done]
89
- iterations_exhausted = false
90
- break
87
+ initial_messages = if memory && thread_id
88
+ load_from_memory(memory, thread_id: thread_id, query: extract_message(input))
89
+ else
90
+ []
91
91
  end
92
- end
93
92
 
94
- save_to_memory(memory, thread_id: thread_id, messages: messages) if memory && thread_id
93
+ messages = initial_messages.dup
94
+ user_asked = false
95
+ total_usage = Phronomy::TokenUsage.zero
96
+ iterations_exhausted = true
97
+
98
+ max_iter.times do
99
+ response = stream_step(messages, input, user_asked: user_asked, config: config, &block)
100
+ user_asked = true
101
+ messages = response[:messages]
102
+ total_usage += response[:usage]
103
+ if response[:done]
104
+ iterations_exhausted = false
105
+ break
106
+ end
107
+ end
95
108
 
96
- output = messages.last&.content
97
- run_output_guardrails!(output)
109
+ save_to_memory(memory, thread_id: thread_id, messages: messages) if memory && thread_id
98
110
 
99
- result = {output: output, messages: messages, usage: total_usage, iterations_exhausted: iterations_exhausted}
100
- block.call(StreamEvent.new(type: :done, payload: result))
101
- result
111
+ # Fall back to the last message that carries non-nil content (same as
112
+ # the non-streaming path above).
113
+ output = messages.reverse.find { |m| m.content && !m.content.empty? }&.content
114
+ run_output_guardrails!(output)
115
+
116
+ result = {output: output, messages: messages, usage: total_usage, iterations_exhausted: iterations_exhausted}
117
+ block.call(StreamEvent.new(type: :done, payload: result))
118
+ [result, total_usage]
119
+ end
102
120
  rescue => e
103
121
  block&.call(StreamEvent.new(type: :error, payload: {error: e}))
104
122
  raise
@@ -136,8 +154,8 @@ module Phronomy
136
154
  chat = build_chat
137
155
  messages.each { |m| chat.add_message(m) }
138
156
 
139
- chat.on_tool_call { |tc| block.call(StreamEvent.new(type: :tool_call, payload: {tool_call: tc})) }
140
- chat.on_tool_result { |tr| block.call(StreamEvent.new(type: :tool_result, payload: {tool_result: tr})) }
157
+ chat.before_tool_call { |tc| block.call(StreamEvent.new(type: :tool_call, payload: {tool_call: tc})) }
158
+ chat.after_tool_result { |tr| block.call(StreamEvent.new(type: :tool_result, payload: {tool_result: tr})) }
141
159
 
142
160
  # Run before_completion hooks before each LLM call in the streaming loop.
143
161
  run_before_completion_hooks!(chat, config)
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "cgi"
4
+
3
5
  module Phronomy
4
6
  module Context
5
7
  # Assembler collects all four context regions and produces the final
@@ -34,7 +36,7 @@ module Phronomy
34
36
  # @param trusted [Boolean]
35
37
  # @return [String]
36
38
  def self.xml_tag(text, type:, trusted: false)
37
- "<context type=\"#{type}\" trusted=\"#{trusted}\">\n#{text}\n</context>"
39
+ "<context type=\"#{CGI.escapeHTML(type.to_s)}\" trusted=\"#{trusted}\">\n#{CGI.escapeHTML(text.to_s)}\n</context>"
38
40
  end
39
41
 
40
42
  # @param budget [Phronomy::Context::TokenBudget, nil]
@@ -104,8 +106,8 @@ module Phronomy
104
106
  private
105
107
 
106
108
  def xml_context_tag(chunk)
107
- src_attr = chunk[:source] ? " source=\"#{chunk[:source]}\"" : ""
108
- "<context type=\"#{chunk[:type]}\"#{src_attr} trusted=\"#{chunk[:trusted]}\">\n#{chunk[:text]}\n</context>"
109
+ src_attr = chunk[:source] ? " source=\"#{CGI.escapeHTML(chunk[:source].to_s)}\"" : ""
110
+ "<context type=\"#{CGI.escapeHTML(chunk[:type].to_s)}\"#{src_attr} trusted=\"#{chunk[:trusted]}\">\n#{CGI.escapeHTML(chunk[:text].to_s)}\n</context>"
109
111
  end
110
112
 
111
113
  def trim_messages_to_budget(messages, system_text)
@@ -122,6 +124,12 @@ module Phronomy
122
124
  accumulated += tokens
123
125
  result.push(msg)
124
126
  end
127
+
128
+ if result.empty? && messages.any?
129
+ warn "[Phronomy::Assembler] All #{messages.length} conversation message(s) dropped: " \
130
+ "token budget exhausted by system context (budget=#{@budget.context_window}, used_by_system=#{used})"
131
+ end
132
+
125
133
  result.reverse
126
134
  end
127
135
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ostruct"
4
-
5
3
  module Phronomy
6
4
  module Context
7
5
  # Context object passed to the +on_compact+ callback registered on an agent.
@@ -103,7 +101,7 @@ module Phronomy
103
101
  end
104
102
 
105
103
  remaining = (@message_elements[(last_idx + 1)..] || []).map { |e| e[:message] }
106
- summary_msg = OpenStruct.new(role: :system, content: summary_text)
104
+ summary_msg = RubyLLM::Message.new(role: :system, content: summary_text)
107
105
  @result_messages = [summary_msg] + remaining
108
106
  end
109
107
  end
@@ -27,32 +27,46 @@ module Phronomy
27
27
  attr_reader :system_tokens
28
28
 
29
29
  def initialize
30
- reset
30
+ @mutex = Mutex.new
31
+ @fingerprint = nil
32
+ @system_text = nil
33
+ @system_tokens = 0
31
34
  end
32
35
 
33
36
  # Returns true when the given fingerprint matches the stored one.
37
+ # The check is performed under a mutex so that a concurrent #update cannot
38
+ # expose a partially-written state where fingerprint is new but system_text
39
+ # is still nil (Issue #55).
34
40
  #
35
41
  # @param fingerprint [String] SHA-256 hex digest to compare
36
42
  # @return [Boolean]
37
43
  def valid?(fingerprint)
38
- !@fingerprint.nil? && @fingerprint == fingerprint
44
+ @mutex.synchronize do
45
+ !@fingerprint.nil? && !@system_text.nil? && @fingerprint == fingerprint
46
+ end
39
47
  end
40
48
 
41
49
  # Update the cache with a new fingerprint and system text.
50
+ # All three assignments are performed atomically under a mutex so that
51
+ # concurrent readers never observe a partial state (Issue #55).
42
52
  #
43
53
  # @param fingerprint [String] new SHA-256 hex digest
44
54
  # @param system_text [String] fully assembled system prompt text
45
55
  def update(fingerprint:, system_text:)
46
- @fingerprint = fingerprint
47
- @system_text = system_text.to_s
48
- @system_tokens = TokenEstimator.estimate(@system_text)
56
+ @mutex.synchronize do
57
+ @fingerprint = fingerprint
58
+ @system_text = system_text.to_s
59
+ @system_tokens = TokenEstimator.estimate(@system_text)
60
+ end
49
61
  end
50
62
 
51
63
  # Clear all cached values (used for testing and forced invalidation).
52
64
  def reset
53
- @fingerprint = nil
54
- @system_text = nil
55
- @system_tokens = 0
65
+ @mutex.synchronize do
66
+ @fingerprint = nil
67
+ @system_text = nil
68
+ @system_tokens = 0
69
+ end
56
70
  end
57
71
  end
58
72
  end
@@ -22,24 +22,52 @@ module Phronomy
22
22
  @scorer = scorer
23
23
  end
24
24
 
25
- # @param dataset [Dataset] collection of EvalCase objects
26
- # @param callable [#call] accepts a single String argument
25
+ # @param dataset [Dataset] collection of EvalCase objects
26
+ # @param callable [#call] accepts a single String argument
27
+ # @param concurrency [Integer] number of parallel threads (default: 1, sequential)
27
28
  # @return [Array<EvalResult>]
28
- def run(dataset, callable)
29
- dataset.map do |eval_case|
30
- t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
31
- result = callable.call(eval_case.input)
32
- latency_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - t0
29
+ def run(dataset, callable, concurrency: 1)
30
+ cases = dataset.to_a
31
+ return cases.map { |eval_case| run_one(eval_case, callable) } if concurrency <= 1
33
32
 
34
- actual, usage = extract(result)
35
- score, score_error = score_safely(@scorer, actual: actual, expected: eval_case.expected, input: eval_case.input)
36
-
37
- EvalResult.new(eval_case: eval_case, actual: actual, score: score, usage: usage, latency_ms: latency_ms, error: score_error)
33
+ # Run cases in slices of +concurrency+ threads. Each slice is joined
34
+ # before the next starts, bounding peak thread count to +concurrency+.
35
+ # Writing to pre-allocated slots (one per thread) is safe because each
36
+ # thread writes to a unique index and all threads in a slice are joined
37
+ # before the next slice begins.
38
+ # Exceptions in worker threads are collected and re-raised after all
39
+ # threads in the slice are joined, preventing orphaned threads.
40
+ results = Array.new(cases.length)
41
+ cases.each_with_index.each_slice(concurrency) do |batch|
42
+ errors = []
43
+ errors_mu = Mutex.new
44
+ threads = batch.map do |eval_case, i|
45
+ Thread.new do
46
+ results[i] = run_one(eval_case, callable)
47
+ rescue => e
48
+ errors_mu.synchronize { errors << e }
49
+ end
50
+ end
51
+ threads.each(&:join)
52
+ raise errors.first if errors.any?
38
53
  end
54
+ results
39
55
  end
40
56
 
41
57
  private
42
58
 
59
+ # Evaluate a single EvalCase with the given callable and return an EvalResult.
60
+ def run_one(eval_case, callable)
61
+ t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
62
+ result = callable.call(eval_case.input)
63
+ latency_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - t0
64
+
65
+ actual, usage = extract(result)
66
+ score, score_error = score_safely(@scorer, actual: actual, expected: eval_case.expected, input: eval_case.input)
67
+
68
+ EvalResult.new(eval_case: eval_case, actual: actual, score: score, usage: usage, latency_ms: latency_ms, error: score_error)
69
+ end
70
+
43
71
  # Normalises the callable's return value into [actual_string, usage_or_nil].
44
72
  def extract(result)
45
73
  if result.is_a?(Hash)
@@ -169,7 +169,15 @@ module Phronomy
169
169
  def next_node(current, state)
170
170
  if (cond = @conditional_edges[current])
171
171
  result = cond[:condition].call(state)
172
- return cond[:mapping] ? cond[:mapping][result] : result
172
+ if cond[:mapping]
173
+ unless cond[:mapping].key?(result)
174
+ raise ArgumentError,
175
+ "Conditional edge from #{current.inspect} returned #{result.inspect}, " \
176
+ "which is not present in the mapping (#{cond[:mapping].keys.inspect})"
177
+ end
178
+ return cond[:mapping][result]
179
+ end
180
+ return result
173
181
  end
174
182
 
175
183
  edges = @edges[current]
@@ -113,7 +113,8 @@ module Phronomy
113
113
  def add_subgraph(name, subgraph, input_mapper: nil, output_mapper: nil)
114
114
  add_node(name) do |state|
115
115
  input = input_mapper ? input_mapper.call(state) : state.to_h
116
- sub_state = subgraph.invoke(input, config: {thread_id: state.thread_id})
116
+ sub_thread_id = "#{state.thread_id}/#{name}"
117
+ sub_state = subgraph.invoke(input, config: {thread_id: sub_thread_id})
117
118
  output_mapper ? output_mapper.call(sub_state) : sub_state.to_h
118
119
  end
119
120
  end
@@ -25,14 +25,20 @@ module Phronomy
25
25
  # Recognised PII categories and their detection patterns.
26
26
  PATTERNS = {
27
27
  # Japanese My Number: 12 consecutive or grouped digits (4-4-4).
28
+ # Matched candidates are additionally validated with the official check-digit
29
+ # algorithm (JIS X 0076) to eliminate false positives from arbitrary 12-digit strings.
28
30
  my_number: {
29
31
  pattern: /(?<!\d)(?<!\d[- ])\d{4}[- ]?\d{4}[- ]?\d{4}(?![- ]?\d)/,
30
- label: "My Number"
32
+ label: "My Number",
33
+ validate_my_number: true
31
34
  },
32
35
  # Credit / debit card: 16 digits, optionally separated by spaces or hyphens.
36
+ # Matched candidates are additionally validated with the Luhn algorithm
37
+ # to eliminate false positives from arbitrary 16-digit sequences.
33
38
  credit_card: {
34
39
  pattern: /\b(?:\d{4}[- ]?){3}\d{4}\b/,
35
- label: "credit card number"
40
+ label: "credit card number",
41
+ validate_luhn: true
36
42
  },
37
43
  # Email address (simplified RFC 5322).
38
44
  email: {
@@ -64,9 +70,47 @@ module Phronomy
64
70
  def check(value)
65
71
  text = value.to_s
66
72
  @active_patterns.each do |entry|
67
- fail!("PII detected in input: #{entry[:label]}") if text.match?(entry[:pattern])
73
+ detected = if entry[:validate_luhn]
74
+ # Scan for all candidates then filter by Luhn check-digit validation.
75
+ # This avoids false positives on arbitrary 16-digit strings (e.g. internal IDs).
76
+ text.scan(entry[:pattern]).any? { |m| luhn_valid?(m.gsub(/[- ]/, "")) }
77
+ elsif entry[:validate_my_number]
78
+ # Scan for all candidates then apply the JIS X 0076 check-digit algorithm.
79
+ # This avoids false positives on arbitrary 12-digit strings.
80
+ text.scan(entry[:pattern]).any? { |m| my_number_valid?(m.gsub(/[- ]/, "")) }
81
+ else
82
+ text.match?(entry[:pattern])
83
+ end
84
+ fail!("PII detected in input: #{entry[:label]}") if detected
68
85
  end
69
86
  end
87
+
88
+ private
89
+
90
+ # Returns true when +digits+ (a 12-character string of decimal digits) satisfies
91
+ # the Japanese My Number check-digit algorithm defined in JIS X 0076.
92
+ # The check digit is the 12th digit.
93
+ def my_number_valid?(digits)
94
+ weights = [6, 5, 4, 3, 2, 7, 6, 5, 4, 3, 2]
95
+ total = weights.each_with_index.sum { |w, i| w * digits[i].to_i }
96
+ remainder = total % 11
97
+ check = (remainder <= 1) ? 0 : 11 - remainder
98
+ check == digits[11].to_i
99
+ end
100
+
101
+ # Returns true when +digits+ (a string of decimal digits) satisfies the
102
+ # Luhn check-digit algorithm used by payment card networks.
103
+ def luhn_valid?(digits)
104
+ digits.chars.reverse.each_with_index.sum do |d, i|
105
+ n = d.to_i
106
+ if i.odd?
107
+ doubled = n * 2
108
+ (doubled > 9) ? (doubled - 9) : doubled
109
+ else
110
+ n
111
+ end
112
+ end % 10 == 0
113
+ end
70
114
  end
71
115
  end
72
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 Memory
7
5
  module Compression
@@ -64,6 +62,9 @@ module Phronomy
64
62
  else
65
63
  {messages: messages, compaction: nil}
66
64
  end
65
+ rescue => e
66
+ warn "[Phronomy] Compression failed (#{e.class}: #{e.message}); saving without compaction."
67
+ {messages: messages, compaction: nil}
67
68
  end
68
69
 
69
70
  private
@@ -98,7 +99,7 @@ module Phronomy
98
99
  #{text}
99
100
  </context>
100
101
  CONTEXT
101
- OpenStruct.new(role: :system, content: content)
102
+ RubyLLM::Message.new(role: :system, content: content)
102
103
  end
103
104
  end
104
105
  end
@@ -1,7 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "ostruct"
4
-
5
3
  module Phronomy
6
4
  module Memory
7
5
  module Compression
@@ -25,6 +23,11 @@ module Phronomy
25
23
  class ToolOutputPruner < Base
26
24
  TRUNCATION_NOTE = "\n[... output truncated ...]"
27
25
 
26
+ # Internal value object for cloned messages.
27
+ # Uses Struct (not OpenStruct) so that unknown attribute access raises NoMethodError.
28
+ ClonedMessage = Struct.new(:role, :content, :tool_calls, :model_id, keyword_init: true)
29
+ private_constant :ClonedMessage
30
+
28
31
  # @param max_chars [Integer] maximum character length for tool-result content
29
32
  def initialize(max_chars: 4000)
30
33
  @max_chars = max_chars
@@ -51,10 +54,12 @@ module Phronomy
51
54
  private
52
55
 
53
56
  def clone_message(original, new_content)
54
- attrs = {role: original.role, content: new_content}
55
- attrs[:tool_calls] = original.tool_calls if original.respond_to?(:tool_calls)
56
- attrs[:model_id] = original.model_id if original.respond_to?(:model_id)
57
- OpenStruct.new(attrs)
57
+ ClonedMessage.new(
58
+ role: original.role,
59
+ content: new_content,
60
+ tool_calls: (original.tool_calls if original.respond_to?(:tool_calls)),
61
+ model_id: (original.model_id if original.respond_to?(:model_id))
62
+ )
58
63
  end
59
64
  end
60
65
  end
@@ -48,7 +48,16 @@ module Phronomy
48
48
  @retrieval = retrieval
49
49
  @compression = compression
50
50
  @ttl = ttl
51
- @append_mutex = Mutex.new
51
+ # Per-thread mutexes allow concurrent saves for different thread_ids while
52
+ # preventing races (duplicate compaction records) within the same thread_id.
53
+ @thread_mutexes = {}
54
+ @thread_mutexes_mutex = Mutex.new
55
+ # Tracks the monotonically increasing next-seq per thread so that TTL
56
+ # purges (which reduce raw.length) do not reset the sequence counter.
57
+ # Protected by a dedicated mutex so concurrent saves for distinct
58
+ # thread_ids do not race on the shared Hash (Issue #60).
59
+ @raw_seq_hwm = {}
60
+ @raw_seq_hwm_mutex = Mutex.new
52
61
  end
53
62
 
54
63
  # Load conversation messages for a thread, applying retrieval selection.
@@ -83,8 +92,10 @@ module Phronomy
83
92
  # @param thread_id [String]
84
93
  # @param messages [Array] full conversation history up to this point
85
94
  def save(thread_id:, messages:)
86
- append_new_messages(thread_id: thread_id, messages: messages)
87
- compress_and_save(thread_id: thread_id, messages: messages)
95
+ thread_mutex(thread_id).synchronize do
96
+ append_new_messages_unlocked(thread_id: thread_id, messages: messages)
97
+ compress_and_save(thread_id: thread_id, messages: messages)
98
+ end
88
99
  @retrieval.index(thread_id: thread_id, messages: messages) if @retrieval.respond_to?(:index)
89
100
  end
90
101
 
@@ -124,22 +135,44 @@ module Phronomy
124
135
 
125
136
  private
126
137
 
138
+ # Returns (or lazily creates) the per-thread mutex for +thread_id+.
139
+ # The outer @thread_mutexes_mutex protects the hash from concurrent creation.
140
+ def thread_mutex(thread_id)
141
+ @thread_mutexes_mutex.synchronize do
142
+ @thread_mutexes[thread_id] ||= Mutex.new
143
+ end
144
+ end
145
+
127
146
  # Append messages that are new since the last save to the raw history.
147
+ # Must be called while holding the per-thread mutex (via thread_mutex).
128
148
  # Messages are append-only; existing raw entries are never modified.
129
- def append_new_messages(thread_id:, messages:)
130
- # Synchronize load + append to prevent seq number collisions when two
131
- # threads save the same thread_id concurrently.
132
- @append_mutex.synchronize do
133
- raw = @storage.load_raw(thread_id: thread_id)
134
- starting_seq = raw.length
135
- new_messages = messages[starting_seq..]
136
- @storage.append_raw(thread_id: thread_id, messages: new_messages, starting_seq: starting_seq) if new_messages&.any?
149
+ #
150
+ # Uses a per-thread high-water-mark (HWM) to determine the next seq number.
151
+ # The HWM is the maximum of:
152
+ # - The highest seq stored in the raw store (correct after normal appends)
153
+ # - The in-memory HWM (correct after TTL purge empties the raw store)
154
+ # This prevents seq number collisions when TTL purge reduces raw.length.
155
+ def append_new_messages_unlocked(thread_id:, messages:)
156
+ raw = @storage.load_raw(thread_id: thread_id)
157
+ # Derive the next seq from the raw store's high-water-mark seq when
158
+ # entries are present. Fall back to the in-memory HWM when the raw
159
+ # store has been partially or fully purged by TTL expiry.
160
+ stored_next_seq = raw.any? ? raw.map { |e| e[:seq] }.max + 1 : nil
161
+ hwm = @raw_seq_hwm_mutex.synchronize { @raw_seq_hwm[thread_id] }
162
+ next_seq = [stored_next_seq, hwm].compact.max || 0
163
+ new_messages = messages[next_seq..]
164
+ if new_messages&.any?
165
+ @storage.append_raw(thread_id: thread_id, messages: new_messages, starting_seq: next_seq)
166
+ @raw_seq_hwm_mutex.synchronize { @raw_seq_hwm[thread_id] = next_seq + new_messages.length }
137
167
  end
138
168
  end
139
169
 
140
170
  # Apply the configured compression strategy and persist the result.
141
171
  # When no strategy is configured, saves messages directly to the legacy store.
142
172
  # When compression fires, also persists the compaction record.
173
+ # If the compression strategy raises (e.g. LLM timeout), we fall back to
174
+ # saving the messages without compaction so the conversation is never lost
175
+ # due to a transient summarization failure (Issue #58).
143
176
  def compress_and_save(thread_id:, messages:)
144
177
  unless @compression
145
178
  @storage.save(thread_id: thread_id, messages: messages)
@@ -151,11 +184,16 @@ module Phronomy
151
184
  all_raw = @storage.load_raw(thread_id: thread_id)
152
185
  uncompacted = all_raw.select { |r| r[:seq] >= uncompacted_start_seq }.map { |r| r[:message] }
153
186
 
154
- result = @compression.compress(
155
- thread_id: thread_id,
156
- messages: uncompacted,
157
- seq_offset: uncompacted_start_seq
158
- )
187
+ result = begin
188
+ @compression.compress(
189
+ thread_id: thread_id,
190
+ messages: uncompacted,
191
+ seq_offset: uncompacted_start_seq
192
+ )
193
+ rescue => e
194
+ warn "[Phronomy] Compression failed (#{e.class}: #{e.message}); saving without compaction."
195
+ {messages: messages, compaction: nil}
196
+ end
159
197
 
160
198
  if result[:compaction]
161
199
  @storage.save_compaction(
@@ -29,21 +29,36 @@ module Phronomy
29
29
  @counter = 0
30
30
  @max_index_size = max_index_size
31
31
  @mutex = Mutex.new
32
+ @indexed_object_ids = {} # thread_id => { object_id => true }
32
33
  end
33
34
 
34
35
  # Index a new batch of messages so they are searchable on future #select calls.
35
36
  # Called by ConversationManager#save.
36
37
  #
38
+ # Messages are deduplicated by object identity: if a message object has already
39
+ # been indexed for the given thread_id, it is skipped (no duplicate embed call).
40
+ #
37
41
  # @param thread_id [String]
38
42
  # @param messages [Array]
39
43
  def index(thread_id:, messages:)
40
44
  messages.each do |msg|
45
+ # Fast path: skip already-indexed messages without calling embed.
46
+ already_indexed = @mutex.synchronize do
47
+ (@indexed_object_ids[thread_id] ||= {})[msg.object_id]
48
+ end
49
+ next if already_indexed
50
+
41
51
  embedding = @embeddings.embed(msg.content.to_s)
42
52
  @mutex.synchronize do
53
+ # Re-check inside lock to handle concurrent callers for the same thread.
54
+ indexed = (@indexed_object_ids[thread_id] ||= {})
55
+ next if indexed[msg.object_id]
56
+
43
57
  id = "#{thread_id}:#{@counter}"
44
58
  @counter += 1
45
59
  @store.add(id: id, embedding: embedding, metadata: {thread_id: thread_id, message: msg})
46
60
  @index[id] = msg
61
+ indexed[msg.object_id] = true
47
62
  evict_oldest! if @max_index_size && @index.size > @max_index_size
48
63
  end
49
64
  end
@@ -59,6 +74,7 @@ module Phronomy
59
74
  @index.delete(id)
60
75
  @store.remove(id: id)
61
76
  end
77
+ @indexed_object_ids.delete(thread_id)
62
78
  end
63
79
  end
64
80
 
@@ -71,7 +87,7 @@ module Phronomy
71
87
  def select(messages, query: nil, thread_id: nil)
72
88
  if query && !query.strip.empty?
73
89
  query_embedding = @embeddings.embed(query)
74
- results = @store.search(query_embedding: query_embedding, k: @k * 3)
90
+ results = @mutex.synchronize { @store.search(query_embedding: query_embedding, k: @k * 3) }
75
91
  results
76
92
  .select { |r| thread_id.nil? || r[:metadata][:thread_id] == thread_id }
77
93
  .first(@k)
@@ -75,7 +75,7 @@ module Phronomy
75
75
  @model_class.create!(
76
76
  thread_id: thread_id,
77
77
  role: msg.role.to_s,
78
- content: msg.content.to_s,
78
+ content: msg.content,
79
79
  tool_calls_json: serialize_tool_calls(msg),
80
80
  model_id: (msg.model_id if msg.respond_to?(:model_id))
81
81
  )
@@ -100,15 +100,17 @@ module Phronomy
100
100
  def append_raw(thread_id:, messages:, starting_seq:)
101
101
  return unless @raw_model_class
102
102
 
103
- messages.each_with_index do |msg, i|
104
- @raw_model_class.create!(
105
- thread_id: thread_id,
106
- seq: starting_seq + i,
107
- role: msg.role.to_s,
108
- content: msg.content.to_s,
109
- tool_calls_json: serialize_tool_calls(msg),
110
- model_id: (msg.model_id if msg.respond_to?(:model_id))
111
- )
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
112
114
  end
113
115
  end
114
116
 
@@ -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
@@ -90,6 +90,9 @@ module Phronomy
90
90
  @mutex = Mutex.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.
@@ -97,9 +100,17 @@ module Phronomy
97
100
  @mutex.synchronize do
98
101
  @stdin&.close
99
102
  @stdout&.close
103
+ @stderr&.close
100
104
  @stdin = nil
101
105
  @stdout = nil
106
+ @stderr = nil
102
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
103
114
  end
104
115
 
105
116
  # Retrieve the tool definition from the server using the MCP `tools/list` method.
@@ -144,7 +155,15 @@ module Phronomy
144
155
  def ensure_started!
145
156
  return if @stdin && !@stdin.closed?
146
157
 
147
- @stdin, @stdout, _stderr, _wait_thr = Open3.popen3(*@command)
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
148
167
  end
149
168
 
150
169
  def rpc_call(method, params)
@@ -212,6 +231,10 @@ module Phronomy
212
231
  # @return [Object] the tool result
213
232
  def call_tool(tool_name, args)
214
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
215
238
  content = response.dig("result", "content")
216
239
 
217
240
  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
+ @http_mutex = Mutex.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
+ @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}"
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
@@ -75,13 +75,20 @@ 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
85
92
  @graph_mutex = Mutex.new
86
93
  @compiled_graph = nil
87
94
  end
@@ -173,6 +180,15 @@ module Phronomy
173
180
  graph
174
181
  end
175
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
+
176
192
  # Builds the prompt sent to the DraftAgent for each iteration.
177
193
  def draft_prompt(input, feedback)
178
194
  lines = [
@@ -186,7 +202,7 @@ module Phronomy
186
202
  end
187
203
  lines += [
188
204
  "",
189
- "Question: #{input}",
205
+ "Question: #{wrap_input(input)}",
190
206
  "",
191
207
  "RESPOND ONLY WITH VALID JSON (no text outside the JSON block):",
192
208
  '{"answer":"<full answer>","confidence":<0.0-1.0>,' \
@@ -205,7 +221,7 @@ module Phronomy
205
221
  [
206
222
  "You are a rigorous quality reviewer. Evaluate the draft answer below.",
207
223
  "",
208
- "Question: #{input}",
224
+ "Question: #{wrap_input(input)}",
209
225
  "",
210
226
  "Draft answer:",
211
227
  draft.to_s,
@@ -14,13 +14,14 @@ module Phronomy
14
14
  class InMemory < Base
15
15
  def initialize
16
16
  @documents = {}
17
+ @mutex = Mutex.new
17
18
  end
18
19
 
19
20
  # @param id [String]
20
21
  # @param embedding [Array<Float>]
21
22
  # @param metadata [Hash]
22
23
  def add(id:, embedding:, metadata: {})
23
- @documents[id] = {embedding: embedding, metadata: metadata}
24
+ @mutex.synchronize { @documents[id] = {embedding: embedding, metadata: metadata} }
24
25
  self
25
26
  end
26
27
 
@@ -28,7 +29,8 @@ module Phronomy
28
29
  # @param k [Integer]
29
30
  # @return [Array<Hash>] sorted by descending score
30
31
  def search(query_embedding:, k: 5)
31
- results = @documents.map do |id, doc|
32
+ snapshot = @mutex.synchronize { @documents.dup }
33
+ results = snapshot.map do |id, doc|
32
34
  score = cosine_similarity(query_embedding, doc[:embedding])
33
35
  {id: id, score: score, metadata: doc[:metadata]}
34
36
  end
@@ -36,18 +38,18 @@ module Phronomy
36
38
  end
37
39
 
38
40
  def remove(id:)
39
- @documents.delete(id)
41
+ @mutex.synchronize { @documents.delete(id) }
40
42
  self
41
43
  end
42
44
 
43
45
  def clear
44
- @documents.clear
46
+ @mutex.synchronize { @documents.clear }
45
47
  self
46
48
  end
47
49
 
48
50
  # @return [Integer] number of documents stored
49
51
  def size
50
- @documents.size
52
+ @mutex.synchronize { @documents.size }
51
53
  end
52
54
 
53
55
  private
@@ -38,6 +38,7 @@ module Phronomy
38
38
  @index_name = index_name
39
39
  @dimension = dimension
40
40
  @index_created = false
41
+ @mutex = Mutex.new
41
42
  end
42
43
 
43
44
  # @param id [String]
@@ -79,37 +80,43 @@ module Phronomy
79
80
  end
80
81
 
81
82
  def clear
82
- begin
83
- @redis.call("FT.DROPINDEX", @index_name, "DD")
84
- rescue => e
85
- raise unless e.message.to_s.include?("Unknown Index name")
83
+ @mutex.synchronize do
84
+ begin
85
+ @redis.call("FT.DROPINDEX", @index_name, "DD")
86
+ rescue => e
87
+ raise unless e.message.to_s.include?("Unknown Index name")
88
+ end
89
+ @index_created = false
86
90
  end
87
- @index_created = false
88
91
  self
89
92
  end
90
93
 
91
94
  private
92
95
 
93
96
  def ensure_index!(dim)
94
- return if @index_created
95
-
96
- @dimension ||= dim
97
- begin
98
- @redis.call(
99
- "FT.CREATE", @index_name,
100
- "ON", "HASH",
101
- "PREFIX", 1, DOC_PREFIX,
102
- "SCHEMA",
103
- "embedding", "VECTOR", "FLAT", 6,
104
- "TYPE", "FLOAT32",
105
- "DIM", @dimension,
106
- "DISTANCE_METRIC", "COSINE",
107
- "metadata", "TEXT"
108
- )
109
- rescue => e
110
- raise unless e.message.to_s.include?("Index already exists")
97
+ return if @index_created # fast path outside lock
98
+
99
+ @mutex.synchronize do
100
+ return if @index_created # re-check inside lock
101
+
102
+ @dimension ||= dim
103
+ begin
104
+ @redis.call(
105
+ "FT.CREATE", @index_name,
106
+ "ON", "HASH",
107
+ "PREFIX", 1, DOC_PREFIX,
108
+ "SCHEMA",
109
+ "embedding", "VECTOR", "FLAT", 6,
110
+ "TYPE", "FLOAT32",
111
+ "DIM", @dimension,
112
+ "DISTANCE_METRIC", "COSINE",
113
+ "metadata", "TEXT"
114
+ )
115
+ rescue => e
116
+ raise unless e.message.to_s.include?("Index already exists")
117
+ end
118
+ @index_created = true
111
119
  end
112
- @index_created = true
113
120
  end
114
121
 
115
122
  # Pack a Float array as a FLOAT32 binary string for RediSearch.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.1.3"
4
+ VERSION = "0.1.4"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phronomy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raizo T.C.S
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-05-10 00:00:00.000000000 Z
11
+ date: 2026-05-11 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm