phronomy 0.1.2 → 0.1.3

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: 4bd9ee98ca8c05a22a5488a6996edb95dcffc26d14b1af92d6551bb24b66a530
4
- data.tar.gz: 14967148ee9764e8502ba8b45d28aa1640f9a086b80ac1a49d9bab3c228a3a70
3
+ metadata.gz: 04a7eceda662bfc638c3ec07ac161299b2bb08863e18e9ba03e7a3226165a921
4
+ data.tar.gz: 9938ace6e4a7250c08f733339af4de14c7d9ff62ff7c52a27eb954c0700d18f4
5
5
  SHA512:
6
- metadata.gz: b005dd5bac44045180bdbf9945c773155e27ee95d69c90b35e3864d01685d831fcd3617762b16871fc637efc86daca27224dda7040e9ef50120675fd7f18b986
7
- data.tar.gz: 2732036e9ed83a86eb75b2e3b55d0a2ccce53b65e354967c1220f1f98b1fb84d09ad42c82673d415dba263a698453827551fe27831e1278252d675e271a67369
6
+ metadata.gz: 1dc032c438a407a751b5c74fd76d172796e852a3add651c1ca6bb210991d20305a1cc609fe157e48026293084943714569219d359794c9bc7fde0dc396ed16d1
7
+ data.tar.gz: c1b52dcfa5196b92b72641f8d980856e5e827d61ff0e8afc61f5664f0556e0dbb0b047a7755d2f5f148525774c885ead4319510c32d7f2699fb4601c38d12ecd
@@ -453,9 +453,10 @@ module Phronomy
453
453
 
454
454
  chat = build_chat
455
455
  user_message = extract_message(input)
456
+ budget = build_token_budget
456
457
 
457
458
  # Assemble context via Assembler (same as invoke_once).
458
- assembler = Context::Assembler.new(budget: build_token_budget)
459
+ assembler = Context::Assembler.new(budget: budget)
459
460
  system_msg = build_instructions(input)
460
461
  assembler.add_instruction(system_msg) if system_msg
461
462
 
@@ -467,7 +468,33 @@ module Phronomy
467
468
 
468
469
  if memory && thread_id
469
470
  msgs = load_from_memory(memory, thread_id: thread_id, query: user_message)
470
- assembler.add_messages(msgs)
471
+ message_elements = build_message_elements(msgs)
472
+
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
479
+
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)
493
+ end
494
+ end
495
+ end
496
+
497
+ assembler.add_messages(message_elements.map { |e| e[:message] })
471
498
  end
472
499
 
473
500
  context = assembler.build
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module Phronomy
4
6
  module Agent
5
7
  # Represents a transfer edge from one agent to another.
@@ -25,6 +27,8 @@ module Phronomy
25
27
  klass_name = target_agent.class.name&.split("::")&.last || "Agent"
26
28
  @tool_name = "transfer_to_#{snake_case(klass_name)}"
27
29
  @description = description || "Transfer the conversation to #{klass_name}."
30
+ # Use a UUID so that two handoffs targeting the same class remain distinct.
31
+ @uuid = SecureRandom.uuid
28
32
  end
29
33
 
30
34
  # Builds an anonymous Phronomy::Tool::Base subclass for this handoff.
@@ -43,7 +47,7 @@ module Phronomy
43
47
  # The sentinel string embedded in the tool result.
44
48
  # @return [String]
45
49
  def sentinel
46
- "#{SENTINEL_PREFIX}:#{target_agent.class.name}"
50
+ "#{SENTINEL_PREFIX}:#{target_agent.class.name}:#{@uuid}"
47
51
  end
48
52
 
49
53
  private
@@ -28,13 +28,17 @@ module Phronomy
28
28
  messages = initial_messages.dup
29
29
  user_asked = false
30
30
  total_usage = Phronomy::TokenUsage.zero
31
+ iterations_exhausted = true
31
32
 
32
33
  max_iter.times do
33
34
  response = step(messages, input, user_asked: user_asked, config: config)
34
35
  user_asked = true
35
36
  messages = response[:messages]
36
37
  total_usage += response[:usage]
37
- break if response[:done]
38
+ if response[:done]
39
+ iterations_exhausted = false
40
+ break
41
+ end
38
42
  end
39
43
 
40
44
  save_to_memory(memory, thread_id: thread_id, messages: messages) if memory && thread_id
@@ -44,7 +48,7 @@ module Phronomy
44
48
  # Run output guardrails before returning to the caller.
45
49
  run_output_guardrails!(output)
46
50
 
47
- result = {output: output, messages: messages, usage: total_usage}
51
+ result = {output: output, messages: messages, usage: total_usage, iterations_exhausted: iterations_exhausted}
48
52
  [result, total_usage]
49
53
  end
50
54
  end
@@ -74,13 +78,17 @@ module Phronomy
74
78
  messages = initial_messages.dup
75
79
  user_asked = false
76
80
  total_usage = Phronomy::TokenUsage.zero
81
+ iterations_exhausted = true
77
82
 
78
83
  max_iter.times do
79
84
  response = stream_step(messages, input, user_asked: user_asked, config: config, &block)
80
85
  user_asked = true
81
86
  messages = response[:messages]
82
87
  total_usage += response[:usage]
83
- break if response[:done]
88
+ if response[:done]
89
+ iterations_exhausted = false
90
+ break
91
+ end
84
92
  end
85
93
 
86
94
  save_to_memory(memory, thread_id: thread_id, messages: messages) if memory && thread_id
@@ -88,7 +96,7 @@ module Phronomy
88
96
  output = messages.last&.content
89
97
  run_output_guardrails!(output)
90
98
 
91
- result = {output: output, messages: messages, usage: total_usage}
99
+ result = {output: output, messages: messages, usage: total_usage, iterations_exhausted: iterations_exhausted}
92
100
  block.call(StreamEvent.new(type: :done, payload: result))
93
101
  result
94
102
  rescue => e
@@ -52,14 +52,16 @@ module Phronomy
52
52
  handoffs_taken = 0
53
53
 
54
54
  loop do
55
- result = current.invoke(input, config: config)
56
- target = find_handoff_target(result[:messages])
57
- return result.merge(agent: current) unless target
58
-
55
+ # Check before invoking so we raise after exactly MAX_HANDOFFS handoffs,
56
+ # not after MAX_HANDOFFS + 1 LLM calls.
59
57
  if handoffs_taken >= MAX_HANDOFFS
60
58
  raise Phronomy::HandoffError, "Exceeded maximum handoffs (#{MAX_HANDOFFS})"
61
59
  end
62
60
 
61
+ result = current.invoke(input, config: config)
62
+ target = find_handoff_target(result[:messages])
63
+ return result.merge(agent: current) unless target
64
+
63
65
  current = target
64
66
  handoffs_taken += 1
65
67
  end
@@ -42,11 +42,17 @@ module Phronomy
42
42
  # Recursion limit for graph execution (default: 25)
43
43
  attr_accessor :recursion_limit
44
44
 
45
+ # When true (default), user input and LLM output are recorded in trace spans.
46
+ # Set to false in privacy-sensitive environments to prevent PII from reaching
47
+ # the tracing backend (OTel, Langfuse, etc.).
48
+ attr_accessor :trace_pii
49
+
45
50
  def initialize
46
51
  @recursion_limit = 25
47
52
  @tracer = Phronomy::Tracing::NullTracer.new
48
53
  @memory_async = false
49
54
  @memory_job_queue = :default
55
+ @trace_pii = true
50
56
  end
51
57
  end
52
58
  end
@@ -23,13 +23,29 @@ module Phronomy
23
23
  # Phronomy::Context::TokenEstimator.tokenizer = nil
24
24
  module TokenEstimator
25
25
  @tokenizer = nil
26
+ @tokenizer_mutex = Mutex.new
26
27
 
27
28
  class << self
28
29
  # Replace the built-in heuristic with a callable that takes a String
29
30
  # and returns an Integer token count. Set to nil to restore the default.
30
31
  #
32
+ # @note This is a process-wide setting. Set it once at application startup.
33
+ # In tests, call +TokenEstimator.reset_tokenizer!+ after each test to
34
+ # prevent cross-test contamination.
31
35
  # @param callable [#call, nil]
32
- attr_accessor :tokenizer
36
+ def tokenizer=(callable)
37
+ @tokenizer_mutex.synchronize { @tokenizer = callable }
38
+ end
39
+
40
+ # @return [#call, nil]
41
+ def tokenizer
42
+ @tokenizer_mutex.synchronize { @tokenizer }
43
+ end
44
+
45
+ # Resets the tokenizer to the built-in heuristic. Intended for test isolation.
46
+ def reset_tokenizer!
47
+ @tokenizer_mutex.synchronize { @tokenizer = nil }
48
+ end
33
49
 
34
50
  # Estimate the number of tokens for the given input.
35
51
  #
@@ -37,9 +53,10 @@ module Phronomy
37
53
  # or an Array of message-like objects (each must respond to #content).
38
54
  # @return [Integer] estimated token count (>= 0)
39
55
  def estimate(input)
56
+ tok = @tokenizer_mutex.synchronize { @tokenizer }
40
57
  case input
41
58
  when String
42
- @tokenizer ? @tokenizer.call(input) : (input.length / 4.0).ceil
59
+ tok ? tok.call(input) : (input.length / 4.0).ceil
43
60
  when Array
44
61
  input.sum { |m| estimate(m.content.to_s) }
45
62
  else
@@ -4,16 +4,26 @@ module Phronomy
4
4
  module Eval
5
5
  # An immutable record holding the outcome of evaluating one EvalCase.
6
6
  #
7
- # @!attribute eval_case [EvalCase] the original sample
8
- # @!attribute actual [String] the callable's output
9
- # @!attribute score [Float] scorer-assigned value in [0.0, 1.0]
10
- # @!attribute usage [Phronomy::TokenUsage, nil]
7
+ # @!attribute eval_case [EvalCase] the original sample
8
+ # @!attribute actual [String] the callable's output
9
+ # @!attribute score [Float] scorer-assigned value in [0.0, 1.0]
10
+ # @!attribute usage [Phronomy::TokenUsage, nil]
11
11
  # @!attribute latency_ms [Integer] wall-clock time of the callable in ms
12
- EvalResult = Data.define(:eval_case, :actual, :score, :usage, :latency_ms) do
12
+ # @!attribute error [Exception, nil] set when the scorer raised an exception
13
+ EvalResult = Data.define(:eval_case, :actual, :score, :usage, :latency_ms, :error) do
14
+ def initialize(eval_case:, actual:, score:, usage:, latency_ms:, error: nil)
15
+ super
16
+ end
17
+
13
18
  # Returns true when the scorer assigned a perfect score of 1.0.
14
19
  def pass?
15
20
  score >= 1.0
16
21
  end
22
+
23
+ # Returns true when the scorer raised an exception.
24
+ def scorer_error?
25
+ !error.nil?
26
+ end
17
27
  end
18
28
  end
19
29
  end
@@ -32,9 +32,9 @@ module Phronomy
32
32
  latency_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - t0
33
33
 
34
34
  actual, usage = extract(result)
35
- score = @scorer.score(actual: actual, expected: eval_case.expected, input: eval_case.input)
35
+ score, score_error = score_safely(@scorer, actual: actual, expected: eval_case.expected, input: eval_case.input)
36
36
 
37
- EvalResult.new(eval_case: eval_case, actual: actual, score: score, usage: usage, latency_ms: latency_ms)
37
+ EvalResult.new(eval_case: eval_case, actual: actual, score: score, usage: usage, latency_ms: latency_ms, error: score_error)
38
38
  end
39
39
  end
40
40
 
@@ -48,6 +48,13 @@ module Phronomy
48
48
  [result.to_s, nil]
49
49
  end
50
50
  end
51
+
52
+ # Calls the scorer and returns [score, error]. On failure, returns [0.0, exception].
53
+ def score_safely(scorer, **kwargs)
54
+ [scorer.score(**kwargs), nil]
55
+ rescue => e
56
+ [0.0, e]
57
+ end
51
58
  end
52
59
  end
53
60
  end
@@ -34,17 +34,22 @@ module Phronomy
34
34
 
35
35
  # @param model [String] RubyLLM model identifier
36
36
  # @param prompt_template [String] format string with %<input>s, %<expected>s, %<actual>s
37
- def initialize(model:, prompt_template: DEFAULT_PROMPT)
37
+ # @param raise_on_error [Boolean] when true, re-raises scoring exceptions instead of
38
+ # returning 0.0. Use this in batch eval pipelines where silent failures are unacceptable.
39
+ def initialize(model:, prompt_template: DEFAULT_PROMPT, raise_on_error: false)
38
40
  @model = model
39
41
  @prompt_template = prompt_template
42
+ @raise_on_error = raise_on_error
40
43
  end
41
44
 
42
- # @return [Float] score in [0.0, 1.0]; 0.0 on any error
45
+ # @return [Float] score in [0.0, 1.0]; 0.0 on error when raise_on_error is false
43
46
  def score(actual:, expected:, input: nil)
44
47
  prompt = format(@prompt_template, input: input.to_s, expected: expected.to_s, actual: actual.to_s)
45
48
  response = RubyLLM.chat(model: @model).ask(prompt)
46
49
  response.content.to_s.strip.scan(/-?\d+\.?\d*/).first.to_f.clamp(0.0, 1.0)
47
50
  rescue => e
51
+ raise if @raise_on_error
52
+
48
53
  warn "[LlmJudge] Scoring failed: #{e.message}"
49
54
  0.0
50
55
  end
@@ -64,36 +64,47 @@ module Phronomy
64
64
  def call(state)
65
65
  threads = @branches.map { |branch| Thread.new { branch.call(state) } }
66
66
  deadline = @timeout ? (Process.clock_gettime(Process::CLOCK_MONOTONIC) + @timeout) : nil
67
+ state_class = state.class
67
68
 
68
69
  if @on_error == :best_effort
69
- gather_best_effort(threads, deadline)
70
+ gather_best_effort(threads, deadline, state_class)
70
71
  else
71
- gather_raise(threads, deadline)
72
+ gather_raise(threads, deadline, state_class)
72
73
  end
73
74
  end
74
75
 
75
76
  private
76
77
 
77
78
  # Joins all threads, enforcing the deadline. Re-raises branch exceptions.
78
- def gather_raise(threads, deadline)
79
+ def gather_raise(threads, deadline, state_class)
79
80
  if deadline
80
81
  threads.each do |t|
81
82
  remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
82
83
  next if t.join([remaining, 0].max)
83
84
 
84
85
  # Thread did not finish within the time limit.
85
- threads.each(&:kill)
86
+ # Use Thread#raise instead of Thread#kill so that ensure blocks in
87
+ # branches (DB connection return, Mutex release, etc.) are executed.
88
+ timeout_error = Phronomy::Graph::TimeoutError.new(
89
+ "parallel branch timed out after #{@timeout}s"
90
+ )
91
+ threads.each { |thr| thr.raise(timeout_error) unless thr.stop? }
92
+ threads.each do |thr|
93
+ thr.join(0.1)
94
+ rescue
95
+ nil
96
+ end
86
97
  raise Phronomy::Graph::TimeoutError,
87
98
  "parallel branch timed out after #{@timeout}s"
88
99
  end
89
100
  end
90
101
 
91
102
  # All threads are done. Thread#value re-raises any stored exception.
92
- merge_results(threads.map(&:value))
103
+ merge_results(threads.map(&:value), state_class)
93
104
  end
94
105
 
95
106
  # Joins all threads, collecting errors instead of re-raising them.
96
- def gather_best_effort(threads, deadline)
107
+ def gather_best_effort(threads, deadline, state_class)
97
108
  errors = []
98
109
  results = threads.map do |t|
99
110
  if deadline
@@ -108,7 +119,15 @@ module Phronomy
108
119
  next nil
109
120
  end
110
121
  if joined.nil?
111
- t.kill
122
+ timeout_error = Phronomy::Graph::TimeoutError.new(
123
+ "branch timed out after #{@timeout}s"
124
+ )
125
+ t.raise(timeout_error) unless t.stop?
126
+ begin
127
+ t.join(0.1)
128
+ rescue
129
+ nil
130
+ end
112
131
  errors << Phronomy::Graph::TimeoutError.new(
113
132
  "branch timed out after #{@timeout}s"
114
133
  )
@@ -124,33 +143,49 @@ module Phronomy
124
143
  end
125
144
  end
126
145
 
127
- merged = merge_results(results) || {}
146
+ merged = merge_results(results, state_class) || {}
128
147
  merged[:parallel_errors] = errors unless errors.empty?
129
148
  merged.empty? ? nil : merged
130
149
  end
131
150
 
132
151
  # Merges an Array of per-branch result Hashes (nils are skipped).
133
- def merge_results(results)
152
+ # Field merge policy is determined from the State class field declarations:
153
+ # :replace fields — last-write-wins (rightmost branch wins)
154
+ # :append fields — all Arrays are concatenated
155
+ # :merge fields — all Hashes are deep-merged (rightmost wins on conflict)
156
+ # Unknown / undeclared fields fall back to type-based heuristics.
157
+ def merge_results(results, state_class = nil)
134
158
  merged = results.compact.each_with_object({}) do |result, acc|
135
159
  next unless result.is_a?(Hash)
136
160
 
137
161
  result.each do |key, val|
138
- acc[key] = acc.key?(key) ? merge_values(acc[key], val) : val
162
+ acc[key] = acc.key?(key) ? merge_values(acc[key], val, state_class&.fields&.dig(key, :type)) : val
139
163
  end
140
164
  end
141
165
 
142
166
  merged.empty? ? nil : merged
143
167
  end
144
168
 
145
- # Merges two values that share the same state field key across branches.
146
- # Arrays are concatenated; Hashes are deep-merged; scalars use last-write-wins.
147
- def merge_values(old_val, new_val)
148
- if old_val.is_a?(Array) && new_val.is_a?(Array)
149
- old_val + new_val
150
- elsif old_val.is_a?(Hash) && new_val.is_a?(Hash)
151
- old_val.merge(new_val)
152
- else
169
+ # Merges two values for the same state field key across branches.
170
+ # Uses the declared field policy when available, otherwise falls back to
171
+ # type-based heuristics (Array → concat, Hash → deep-merge, scalar → last-write-wins).
172
+ def merge_values(old_val, new_val, policy = nil)
173
+ case policy
174
+ when :append
175
+ (old_val.is_a?(Array) && new_val.is_a?(Array)) ? old_val + new_val : new_val
176
+ when :merge
177
+ (old_val.is_a?(Hash) && new_val.is_a?(Hash)) ? old_val.merge(new_val) : new_val
178
+ when :replace
153
179
  new_val
180
+ else
181
+ # Unknown field or no State class: fall back to type-based heuristic.
182
+ if old_val.is_a?(Array) && new_val.is_a?(Array)
183
+ old_val + new_val
184
+ elsif old_val.is_a?(Hash) && new_val.is_a?(Hash)
185
+ old_val.merge(new_val)
186
+ else
187
+ new_val
188
+ end
154
189
  end
155
190
  end
156
191
  end
@@ -125,6 +125,11 @@ module Phronomy
125
125
  # to use for this compiled graph, overriding the global default.
126
126
  # @return [CompiledGraph]
127
127
  def compile(state_store: nil)
128
+ if @entry_point.nil? && @nodes.size > 1
129
+ raise ArgumentError,
130
+ "set_entry_point was not called; call set_entry_point(:node_name) " \
131
+ "before compile when the graph has multiple nodes"
132
+ end
128
133
  CompiledGraph.new(
129
134
  state_class: @state_class,
130
135
  nodes: @nodes,
@@ -9,6 +9,11 @@ module Phronomy
9
9
  # {Phronomy::GuardrailError} when any pattern is found in the input string.
10
10
  # Additional patterns can be supplied via the +additional_patterns:+ argument.
11
11
  #
12
+ # **Limitations**: the built-in patterns cover well-known English and Japanese
13
+ # phrasings. Obfuscated, Base64-encoded, or novel injection phrasing may not
14
+ # be detected. For higher-assurance use cases, combine this guardrail with an
15
+ # LLM-based classifier.
16
+ #
12
17
  # @example
13
18
  # agent.add_input_guardrail(
14
19
  # Phronomy::Guardrail::Builtin::PromptInjectionDetector.new
@@ -21,6 +26,7 @@ module Phronomy
21
26
  class PromptInjectionDetector < InputGuardrail
22
27
  # Default patterns that signal a prompt injection attempt.
23
28
  DEFAULT_PATTERNS = [
29
+ # --- English patterns ---
24
30
  /ignore\s+(all\s+)?(previous|prior|above)\s+(instructions?|rules?|prompts?)/i,
25
31
  /disregard\s+(all\s+)?(previous|prior|above)\s+(instructions?|rules?|prompts?)/i,
26
32
  /forget\s+(all\s+)?(previous|prior|above)\s+(instructions?|rules?|prompts?)/i,
@@ -30,7 +36,15 @@ module Phronomy
30
36
  /\bpretend\s+(?:you\s+are|to\s+be)\b/i,
31
37
  /\bjailbreak\b/i,
32
38
  /\bdan\s*mode\b/i,
33
- /\bdev(?:eloper)?\s*mode\b/i
39
+ /\bdev(?:eloper)?\s*mode\b/i,
40
+ # --- Japanese patterns ---
41
+ /以前の(指示|ルール|プロンプト)を無視/,
42
+ /指示を無視して/,
43
+ /ルールを無視して/,
44
+ /あなたは今(から)?(?!助けて)/,
45
+ /システムプロンプト/,
46
+ /制約(を|から)無視/,
47
+ /制限(を|から)解除/
34
48
  ].freeze
35
49
 
36
50
  # @param additional_patterns [Array<Regexp>] extra patterns to check in addition
@@ -48,6 +48,7 @@ module Phronomy
48
48
  @retrieval = retrieval
49
49
  @compression = compression
50
50
  @ttl = ttl
51
+ @append_mutex = Mutex.new
51
52
  end
52
53
 
53
54
  # Load conversation messages for a thread, applying retrieval selection.
@@ -66,7 +67,7 @@ module Phronomy
66
67
  def load(thread_id:, query: nil)
67
68
  @storage.purge_older_than(thread_id: thread_id, older_than: Time.now - @ttl) if @ttl
68
69
  messages = reconstruct(thread_id)
69
- @retrieval.select(messages, query: query)
70
+ @retrieval.select(messages, query: query, thread_id: thread_id)
70
71
  end
71
72
 
72
73
  # Persist new messages for a thread and optionally apply compression.
@@ -126,10 +127,14 @@ module Phronomy
126
127
  # Append messages that are new since the last save to the raw history.
127
128
  # Messages are append-only; existing raw entries are never modified.
128
129
  def append_new_messages(thread_id:, messages:)
129
- raw = @storage.load_raw(thread_id: thread_id)
130
- starting_seq = raw.length
131
- new_messages = messages[starting_seq..]
132
- @storage.append_raw(thread_id: thread_id, messages: new_messages, starting_seq: starting_seq) if new_messages&.any?
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?
137
+ end
133
138
  end
134
139
 
135
140
  # Apply the configured compression strategy and persist the result.
@@ -183,14 +188,16 @@ module Phronomy
183
188
  summary_msgs + uncompacted
184
189
  end
185
190
 
191
+ # Immutable value object used as a summary placeholder in reconstructed context.
192
+ SummaryMessage = Data.define(:role, :content)
193
+
186
194
  def summary_message(text)
187
- require "ostruct"
188
195
  content = <<~CONTEXT.chomp
189
196
  <context type="summary" source="memory" trusted="false">
190
197
  #{text}
191
198
  </context>
192
199
  CONTEXT
193
- OpenStruct.new(role: :system, content: content)
200
+ SummaryMessage.new(role: :system, content: content)
194
201
  end
195
202
  end
196
203
  end
@@ -9,10 +9,11 @@ module Phronomy
9
9
  class Base
10
10
  # Select messages to inject into the context from a full chronological history.
11
11
  #
12
- # @param messages [Array] full history in chronological order
13
- # @param query [String, nil] current user input for query-aware retrieval
12
+ # @param messages [Array] full history in chronological order
13
+ # @param query [String, nil] current user input for query-aware retrieval
14
+ # @param thread_id [String, nil] active thread identifier for scoped retrieval
14
15
  # @return [Array] subset of messages in chronological order
15
- def select(messages, query: nil)
16
+ def select(messages, query: nil, thread_id: nil)
16
17
  raise NotImplementedError, "#{self.class}#select is not implemented"
17
18
  end
18
19
  end
@@ -29,15 +29,16 @@ module Phronomy
29
29
  # Merge results from all child retrievals, deduplicating by role+content.
30
30
  # System messages are sorted to the front; others preserve insertion order.
31
31
  #
32
- # @param messages [Array] full chronological history
33
- # @param query [String, nil] forwarded to each child retrieval
32
+ # @param messages [Array] full chronological history
33
+ # @param query [String, nil] forwarded to each child retrieval
34
+ # @param thread_id [String, nil] forwarded to each child retrieval
34
35
  # @return [Array]
35
- def select(messages, query: nil)
36
+ def select(messages, query: nil, thread_id: nil)
36
37
  all_messages = []
37
38
  seen = {}
38
39
 
39
40
  @sources.each do |source|
40
- source[:retrieval].select(messages, query: query).each do |msg|
41
+ source[:retrieval].select(messages, query: query, thread_id: thread_id).each do |msg|
41
42
  key = "#{msg.role}:#{msg.content}"
42
43
  next if seen[key]
43
44
 
@@ -22,10 +22,11 @@ module Phronomy
22
22
 
23
23
  # Returns the last k*2 messages from the history.
24
24
  #
25
- # @param messages [Array] full chronological history
26
- # @param query [String, nil] unused for recency-based retrieval
25
+ # @param messages [Array] full chronological history
26
+ # @param query [String, nil] unused for recency-based retrieval
27
+ # @param thread_id [String, nil] unused for recency-based retrieval
27
28
  # @return [Array]
28
- def select(messages, query: nil)
29
+ def select(messages, query: nil, thread_id: nil)
29
30
  messages.last(@k * 2)
30
31
  end
31
32
  end
@@ -18,12 +18,17 @@ module Phronomy
18
18
  # @param store [Phronomy::VectorStore::Base] vector store (default InMemory)
19
19
  # @param embeddings [Phronomy::Embeddings::Base] embeddings adapter
20
20
  # @param k [Integer] number of messages to retrieve
21
- def initialize(embeddings:, store: nil, k: 10)
21
+ # @param max_index_size [Integer, nil] maximum number of entries kept in the
22
+ # local index. When nil, the index grows unboundedly. When exceeded, the
23
+ # oldest entries (by insertion order) are evicted.
24
+ def initialize(embeddings:, store: nil, k: 10, max_index_size: nil)
22
25
  @store = store || Phronomy::VectorStore::InMemory.new
23
26
  @embeddings = embeddings
24
27
  @k = k
25
- @index = {} # id => message
28
+ @index = {} # id => message (insertion-ordered via Ruby Hash)
26
29
  @counter = 0
30
+ @max_index_size = max_index_size
31
+ @mutex = Mutex.new
27
32
  end
28
33
 
29
34
  # Index a new batch of messages so they are searchable on future #select calls.
@@ -33,11 +38,14 @@ module Phronomy
33
38
  # @param messages [Array]
34
39
  def index(thread_id:, messages:)
35
40
  messages.each do |msg|
36
- id = "#{thread_id}:#{@counter}"
37
- @counter += 1
38
41
  embedding = @embeddings.embed(msg.content.to_s)
39
- @store.add(id: id, embedding: embedding, metadata: {thread_id: thread_id, message: msg})
40
- @index[id] = msg
42
+ @mutex.synchronize do
43
+ id = "#{thread_id}:#{@counter}"
44
+ @counter += 1
45
+ @store.add(id: id, embedding: embedding, metadata: {thread_id: thread_id, message: msg})
46
+ @index[id] = msg
47
+ evict_oldest! if @max_index_size && @index.size > @max_index_size
48
+ end
41
49
  end
42
50
  end
43
51
 
@@ -45,24 +53,27 @@ module Phronomy
45
53
  #
46
54
  # @param thread_id [String]
47
55
  def clear_index(thread_id:)
48
- ids = @index.select { |id, _| id.start_with?("#{thread_id}:") }.keys
49
- ids.each do |id|
50
- @index.delete(id)
51
- @store.remove(id: id)
56
+ @mutex.synchronize do
57
+ ids = @index.keys.select { |id| id.start_with?("#{thread_id}:") }
58
+ ids.each do |id|
59
+ @index.delete(id)
60
+ @store.remove(id: id)
61
+ end
52
62
  end
53
63
  end
54
64
 
55
65
  # Return semantically relevant messages, or recent messages when query is nil.
56
66
  #
57
- # @param messages [Array] full history (used as fallback when query is nil)
58
- # @param query [String, nil] current user input for semantic search
67
+ # @param messages [Array] full history (used as fallback when query is nil)
68
+ # @param query [String, nil] current user input for semantic search
69
+ # @param thread_id [String, nil] when provided, results are filtered to this thread
59
70
  # @return [Array]
60
- def select(messages, query: nil)
71
+ def select(messages, query: nil, thread_id: nil)
61
72
  if query && !query.strip.empty?
62
73
  query_embedding = @embeddings.embed(query)
63
74
  results = @store.search(query_embedding: query_embedding, k: @k * 3)
64
75
  results
65
- .select { |r| r[:metadata][:thread_id] == extract_thread_from_results(r, messages) }
76
+ .select { |r| thread_id.nil? || r[:metadata][:thread_id] == thread_id }
66
77
  .first(@k)
67
78
  .map { |r| r[:metadata][:message] }
68
79
  else
@@ -72,8 +83,14 @@ module Phronomy
72
83
 
73
84
  private
74
85
 
75
- def extract_thread_from_results(result, _messages)
76
- result[:metadata][:thread_id]
86
+ # Evicts the oldest index entry to enforce max_index_size.
87
+ # Must be called inside @mutex.synchronize.
88
+ def evict_oldest!
89
+ oldest_id = @index.keys.first
90
+ return unless oldest_id
91
+
92
+ @index.delete(oldest_id)
93
+ @store.remove(id: oldest_id)
77
94
  end
78
95
  end
79
96
  end
@@ -1,7 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "json"
4
- require "ostruct"
5
4
 
6
5
  module Phronomy
7
6
  module Memory
@@ -38,6 +37,10 @@ module Phronomy
38
37
  # compaction_model_class: PhronomyCompaction
39
38
  # )
40
39
  # manager = Phronomy::Memory::ConversationManager.new(storage: storage, ...)
40
+ # Internal value object representing a loaded message record.
41
+ MessageStruct = Data.define(:role, :content, :tool_calls, :model_id)
42
+ private_constant :MessageStruct
43
+
41
44
  class ActiveRecord < Base
42
45
  # @param model_class [Class] AR model for the legacy load/save interface
43
46
  # @param raw_model_class [Class, nil] AR model for raw message storage
@@ -55,7 +58,7 @@ module Phronomy
55
58
  # Load all messages for a thread, ordered by creation time.
56
59
  #
57
60
  # @param thread_id [String]
58
- # @return [Array<OpenStruct>]
61
+ # @return [Array<MessageStruct>]
59
62
  def load(thread_id:)
60
63
  records = @model_class.where(thread_id: thread_id).order(:created_at).to_a
61
64
  records.map { |r| to_message_struct(r) }
@@ -201,7 +204,7 @@ module Phronomy
201
204
  parsed
202
205
  end
203
206
  end
204
- OpenStruct.new(
207
+ MessageStruct.new(
205
208
  role: record.role.to_sym,
206
209
  content: record.content,
207
210
  tool_calls: tool_calls,
@@ -11,6 +11,7 @@ module Phronomy
11
11
  # manager = Phronomy::Memory::ConversationManager.new(storage: storage, ...)
12
12
  class InMemory < Base
13
13
  def initialize
14
+ @mutex = Mutex.new
14
15
  @store = {}
15
16
  @raw_store = {} # thread_id => [{seq:, message:}, ...]
16
17
  @compaction_store = {} # thread_id => [{start_seq:, end_seq:, summary_text:}, ...]
@@ -23,20 +24,22 @@ module Phronomy
23
24
  # @param thread_id [String]
24
25
  # @return [Array]
25
26
  def load(thread_id:)
26
- (@store[thread_id] || []).dup
27
+ @mutex.synchronize { (@store[thread_id] || []).dup }
27
28
  end
28
29
 
29
30
  # @param thread_id [String]
30
31
  # @param messages [Array]
31
32
  def save(thread_id:, messages:)
32
- @store[thread_id] = messages.dup
33
+ @mutex.synchronize { @store[thread_id] = messages.dup }
33
34
  end
34
35
 
35
36
  # @param thread_id [String]
36
37
  def clear(thread_id:)
37
- @store.delete(thread_id)
38
- clear_raw(thread_id: thread_id)
39
- clear_compactions(thread_id: thread_id)
38
+ @mutex.synchronize do
39
+ @store.delete(thread_id)
40
+ @raw_store.delete(thread_id)
41
+ @compaction_store.delete(thread_id)
42
+ end
40
43
  end
41
44
 
42
45
  # -----------------------------------------------------------------------
@@ -48,21 +51,23 @@ module Phronomy
48
51
  # @param starting_seq [Integer]
49
52
  def append_raw(thread_id:, messages:, starting_seq:)
50
53
  now = Time.now
51
- @raw_store[thread_id] ||= []
52
- messages.each_with_index do |msg, i|
53
- @raw_store[thread_id] << {seq: starting_seq + i, message: msg, recorded_at: now}
54
+ @mutex.synchronize do
55
+ @raw_store[thread_id] ||= []
56
+ messages.each_with_index do |msg, i|
57
+ @raw_store[thread_id] << {seq: starting_seq + i, message: msg, recorded_at: now}
58
+ end
54
59
  end
55
60
  end
56
61
 
57
62
  # @param thread_id [String]
58
63
  # @return [Array<Hash>]
59
64
  def load_raw(thread_id:)
60
- (@raw_store[thread_id] || []).dup
65
+ @mutex.synchronize { (@raw_store[thread_id] || []).dup }
61
66
  end
62
67
 
63
68
  # @param thread_id [String]
64
69
  def clear_raw(thread_id:)
65
- @raw_store.delete(thread_id)
70
+ @mutex.synchronize { @raw_store.delete(thread_id) }
66
71
  end
67
72
 
68
73
  # -----------------------------------------------------------------------
@@ -74,19 +79,21 @@ module Phronomy
74
79
  # @param end_seq [Integer]
75
80
  # @param summary_text [String]
76
81
  def save_compaction(thread_id:, start_seq:, end_seq:, summary_text:)
77
- @compaction_store[thread_id] ||= []
78
- @compaction_store[thread_id] << {start_seq: start_seq, end_seq: end_seq, summary_text: summary_text}
82
+ @mutex.synchronize do
83
+ @compaction_store[thread_id] ||= []
84
+ @compaction_store[thread_id] << {start_seq: start_seq, end_seq: end_seq, summary_text: summary_text}
85
+ end
79
86
  end
80
87
 
81
88
  # @param thread_id [String]
82
89
  # @return [Array<Hash>]
83
90
  def load_compactions(thread_id:)
84
- (@compaction_store[thread_id] || []).dup
91
+ @mutex.synchronize { (@compaction_store[thread_id] || []).dup }
85
92
  end
86
93
 
87
94
  # @param thread_id [String]
88
95
  def clear_compactions(thread_id:)
89
- @compaction_store.delete(thread_id)
96
+ @mutex.synchronize { @compaction_store.delete(thread_id) }
90
97
  end
91
98
 
92
99
  # Remove raw messages recorded before +older_than+ for this thread.
@@ -94,9 +101,11 @@ module Phronomy
94
101
  # @param thread_id [String]
95
102
  # @param older_than [Time]
96
103
  def purge_older_than(thread_id:, older_than:)
97
- return unless @raw_store[thread_id]
104
+ @mutex.synchronize do
105
+ next unless @raw_store[thread_id]
98
106
 
99
- @raw_store[thread_id].reject! { |entry| entry[:recorded_at] && entry[:recorded_at] < older_than }
107
+ @raw_store[thread_id].reject! { |entry| entry[:recorded_at] && entry[:recorded_at] < older_than }
108
+ end
100
109
  end
101
110
  end
102
111
  end
@@ -25,6 +25,8 @@ module Phronomy
25
25
  class AgentJob < ::ActiveJob::Base
26
26
  # @param agent_class_name [String]
27
27
  # The constantize-able class name of the agent to run (e.g. "MyAgent").
28
+ # **Security**: only classes that are subclasses of +Phronomy::Agent::Base+
29
+ # are accepted. Never pass a value derived from user-controlled input.
28
30
  # @param input [String, Hash]
29
31
  # User input forwarded unchanged to the agent's +#stream+ method.
30
32
  # @param channel [String]
@@ -35,21 +37,36 @@ module Phronomy
35
37
  # Configuration forwarded to the agent's +#stream+ call. Both symbol and
36
38
  # string keys are accepted; all keys are converted to symbols before use.
37
39
  def perform(agent_class_name, input, channel:, stream:, config: {})
38
- agent = Object.const_get(agent_class_name).new
40
+ klass = resolve_agent_class!(agent_class_name)
41
+ agent = klass.new
39
42
  agent.stream(input, config: config.transform_keys(&:to_sym)) do |event|
40
43
  ActionCable.server.broadcast(stream, build_payload(event))
41
44
  end
42
45
  rescue => e
43
- ActionCable.server.broadcast(stream, {type: "error", message: e.message})
46
+ ::Rails.logger.error("[Phronomy::Rails::AgentJob] agent error (#{e.class}): #{e.message}")
47
+ ActionCable.server.broadcast(stream, {type: "error", message: "An error occurred while processing your request."})
44
48
  end
45
49
 
46
50
  private
47
51
 
52
+ # Resolves and validates the agent class name.
53
+ # Raises ArgumentError when the name does not resolve to a subclass of
54
+ # Phronomy::Agent::Base, preventing arbitrary class instantiation.
55
+ def resolve_agent_class!(class_name)
56
+ klass = Object.const_get(class_name.to_s)
57
+ unless klass.is_a?(Class) && klass < Phronomy::Agent::Base
58
+ raise ArgumentError, "#{class_name.inspect} is not a Phronomy::Agent::Base subclass"
59
+ end
60
+ klass
61
+ rescue NameError
62
+ raise ArgumentError, "Unknown agent class: #{class_name.inspect}"
63
+ end
64
+
48
65
  def build_payload(event)
49
66
  case event.type
50
67
  when :token then {type: "token", content: event.payload[:content]}
51
68
  when :done then {type: "done", output: event.payload[:output]}
52
- when :error then {type: "error", message: event.payload[:error]&.message}
69
+ when :error then {type: "error", message: "An error occurred while processing your request."}
53
70
  else {type: event.type.to_s}
54
71
  end
55
72
  end
@@ -28,7 +28,10 @@ module Phronomy
28
28
  # @example
29
29
  # trace("my_chain", input: input) { [invoke(input), nil] }
30
30
  def trace(name, input: nil, **meta, &block)
31
- Phronomy.configuration.tracer.trace(name, input: input, **meta, &block)
31
+ # Redact user input from spans when trace_pii is disabled to prevent
32
+ # accidental PII transmission to external tracing backends.
33
+ traced_input = Phronomy.configuration.trace_pii ? input : "[REDACTED]"
34
+ Phronomy.configuration.tracer.trace(name, input: traced_input, **meta, &block)
32
35
  end
33
36
  end
34
37
  end
@@ -43,9 +43,13 @@ module Phronomy
43
43
  def save(state)
44
44
  json = serialize_state(state)
45
45
  payload = @encryptor ? @encryptor.encrypt(json) : json
46
- record = @model_class.find_or_initialize_by(thread_id: state.thread_id)
47
- record.state_json = payload
48
- record.save!
46
+ # Use upsert to avoid a race condition where two concurrent saves for the
47
+ # same thread_id would both see "no record" and collide on the unique index.
48
+ @model_class.upsert(
49
+ {thread_id: state.thread_id, state_json: payload},
50
+ unique_by: :thread_id,
51
+ update_only: [:state_json]
52
+ )
49
53
  self
50
54
  end
51
55
 
@@ -61,9 +61,23 @@ module Phronomy
61
61
  end
62
62
 
63
63
  # Resolves and validates a state class name.
64
- # Raises ArgumentError if the name does not resolve to a class that
65
- # includes Phronomy::Graph::State, preventing unsafe deserialization.
64
+ # When a registry has been configured via +Phronomy::Graph.register_state_class+,
65
+ # only registered classes are accepted — this prevents unintended autoloading
66
+ # of arbitrary files from an untrusted class name stored in Redis/DB.
67
+ # When no registry is configured, falls back to Object.const_get with a check
68
+ # that the resolved class includes Phronomy::Graph::State.
66
69
  def safe_state_class(class_name)
70
+ registry = Phronomy::Graph.state_class_registry
71
+ if registry
72
+ klass = registry[class_name.to_s]
73
+ unless klass
74
+ raise ArgumentError,
75
+ "Unregistered state class: #{class_name.inspect}. " \
76
+ "Call Phronomy::Graph.register_state_class(#{class_name}) at startup."
77
+ end
78
+ return klass
79
+ end
80
+
67
81
  klass = Object.const_get(class_name.to_s)
68
82
  unless klass.is_a?(Class) && klass.include?(Phronomy::Graph::State)
69
83
  raise ArgumentError, "Invalid state class: #{class_name.inspect}"
@@ -193,6 +193,11 @@ module Phronomy
193
193
  end
194
194
  end
195
195
 
196
+ # Instance method accessor — delegates to the class-level flag.
197
+ def requires_approval
198
+ self.class.requires_approval
199
+ end
200
+
196
201
  # Instance method for requires_approval? (convenience accessor).
197
202
  def requires_approval?
198
203
  self.class.requires_approval
@@ -276,11 +281,15 @@ module Phronomy
276
281
 
277
282
  self.class.parameters.each do |name, param|
278
283
  value = normalized[name]
279
- next if value.nil? && !param.required
284
+ if value.nil?
285
+ # Return a descriptive error for missing required params so the LLM
286
+ # can self-correct on the next turn.
287
+ return [nil, "required parameter '#{name}' is missing"] if param.required
288
+ next
289
+ end
280
290
 
281
291
  if coerce_mode
282
292
  coerced, error = coerce_value(value, param.type)
283
- return [nil, error] if error && !coerce_mode
284
293
  return [nil, error] if error
285
294
  value = coerced
286
295
  else
@@ -79,12 +79,27 @@ module Phronomy
79
79
  # -----------------------------------------------------------------------
80
80
 
81
81
  # Minimal stdio transport implementing a subset of the MCP JSON-RPC protocol.
82
- # Spawns the server command as a child process and communicates line-by-line.
82
+ # Keeps the child process alive for the lifetime of this transport instance
83
+ # so that session state (registered resources, tool context, etc.) is preserved
84
+ # across multiple calls.
83
85
  class StdioTransport
84
86
  def initialize(command)
85
87
  # Split the command string into an argv array so that Open3 executes
86
88
  # it directly without going through the shell, preventing injection.
87
89
  @command = Shellwords.split(command)
90
+ @mutex = Mutex.new
91
+ @stdin = nil
92
+ @stdout = nil
93
+ end
94
+
95
+ # Shut down the child process and close its IO streams.
96
+ def close
97
+ @mutex.synchronize do
98
+ @stdin&.close
99
+ @stdout&.close
100
+ @stdin = nil
101
+ @stdout = nil
102
+ end
88
103
  end
89
104
 
90
105
  # Retrieve the tool definition from the server using the MCP `tools/list` method.
@@ -108,6 +123,10 @@ module Phronomy
108
123
  # @return [Object] the tool result
109
124
  def call_tool(tool_name, args)
110
125
  response = rpc_call("tools/call", {name: tool_name, arguments: args})
126
+ if response["error"]
127
+ err_msg = response.dig("error", "message") || response["error"].to_s
128
+ raise Phronomy::ToolError, "MCP server returned error: #{err_msg}"
129
+ end
111
130
  content = response.dig("result", "content")
112
131
 
113
132
  # MCP content is an array of content blocks; extract text blocks.
@@ -121,12 +140,22 @@ module Phronomy
121
140
 
122
141
  private
123
142
 
124
- def rpc_call(method, params)
125
- payload = JSON.generate(jsonrpc: "2.0", id: 1, method: method, params: params)
126
- stdout, _stderr, status = Open3.capture3(*@command, stdin_data: "#{payload}\n")
127
- raise Phronomy::ToolError, "MCP server exited with status #{status.exitstatus}" unless status.success?
143
+ # Ensure the child process is running, spawning it if necessary.
144
+ def ensure_started!
145
+ return if @stdin && !@stdin.closed?
128
146
 
129
- JSON.parse(stdout.lines.first.to_s)
147
+ @stdin, @stdout, _stderr, _wait_thr = Open3.popen3(*@command)
148
+ end
149
+
150
+ def rpc_call(method, params)
151
+ @mutex.synchronize do
152
+ ensure_started!
153
+ payload = JSON.generate(jsonrpc: "2.0", id: SecureRandom.uuid, method: method, params: params)
154
+ @stdin.puts(payload)
155
+ raw = @stdout.gets
156
+ raise Phronomy::ToolError, "MCP server closed the connection unexpectedly" if raw.nil?
157
+ JSON.parse(raw)
158
+ end
130
159
  end
131
160
 
132
161
  def parse_schema_params(properties)
@@ -153,9 +182,13 @@ module Phronomy
153
182
  # tool_name: "weather_lookup"
154
183
  # )
155
184
  class HttpTransport
156
- # @param base_url [String] full URL of the MCP endpoint, e.g. "http://localhost:8080/mcp"
157
- def initialize(base_url)
185
+ # @param base_url [String] full URL of the MCP endpoint, e.g. "http://localhost:8080/mcp"
186
+ # @param open_timeout [Integer] TCP connection timeout in seconds (default: 5)
187
+ # @param read_timeout [Integer] HTTP read timeout in seconds (default: 30)
188
+ def initialize(base_url, open_timeout: 5, read_timeout: 30)
158
189
  @uri = URI.parse(base_url)
190
+ @open_timeout = open_timeout
191
+ @read_timeout = read_timeout
159
192
  end
160
193
 
161
194
  # Retrieve the tool definition from the server using MCP `tools/list`.
@@ -192,10 +225,12 @@ module Phronomy
192
225
  private
193
226
 
194
227
  def rpc_call(method, params)
195
- payload = JSON.generate(jsonrpc: "2.0", id: 1, method: method, params: params)
228
+ payload = JSON.generate(jsonrpc: "2.0", id: SecureRandom.uuid, method: method, params: params)
196
229
 
197
230
  http = Net::HTTP.new(@uri.host, @uri.port)
198
231
  http.use_ssl = (@uri.scheme == "https")
232
+ http.open_timeout = @open_timeout
233
+ http.read_timeout = @read_timeout
199
234
 
200
235
  path = @uri.path.empty? ? "/" : @uri.path
201
236
  path = "#{path}?#{@uri.query}" if @uri.query
@@ -83,6 +83,8 @@ module Phronomy
83
83
  uri = URI.parse("#{@host}/api/public/ingestion")
84
84
  http = Net::HTTP.new(uri.host, uri.port)
85
85
  http.use_ssl = (uri.scheme == "https")
86
+ http.open_timeout = 3
87
+ http.read_timeout = 5
86
88
  req = Net::HTTP::Post.new(uri.request_uri)
87
89
  req["Content-Type"] = "application/json"
88
90
  req["Authorization"] = "Basic #{Base64.strict_encode64("#{@public_key}:#{@secret_key}")}"
@@ -82,6 +82,8 @@ module Phronomy
82
82
  @review_agent_class = review_agent
83
83
  @threshold = confidence_threshold.to_f
84
84
  @max_iterations = max_iterations.to_i
85
+ @graph_mutex = Mutex.new
86
+ @compiled_graph = nil
85
87
  end
86
88
 
87
89
  # Run the pipeline.
@@ -90,7 +92,7 @@ module Phronomy
90
92
  # @param config [Hash] forwarded to the underlying agents (e.g. thread_id)
91
93
  # @return [Result]
92
94
  def invoke(input, config: {})
93
- app = build_graph.compile
95
+ app = compiled_graph
94
96
  state = app.invoke({input: input}, config: config)
95
97
  confidence = combined_confidence(state)
96
98
  Result.new(
@@ -109,6 +111,16 @@ module Phronomy
109
111
  [(state.self_score || 0.0).to_f, (state.review_score || 0.0).to_f].min
110
112
  end
111
113
 
114
+ # Returns the compiled graph, building and caching it on first call.
115
+ # Thread-safe via double-checked locking.
116
+ def compiled_graph
117
+ return @compiled_graph if @compiled_graph
118
+
119
+ @graph_mutex.synchronize do
120
+ @compiled_graph ||= build_graph.compile
121
+ end
122
+ end
123
+
112
124
  def build_graph
113
125
  draft_agent = @draft_agent_class.new
114
126
  review_agent = @review_agent_class.new
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.1.2"
4
+ VERSION = "0.1.3"
5
5
  end
data/lib/phronomy.rb CHANGED
@@ -35,6 +35,45 @@ module Phronomy
35
35
  end
36
36
  end
37
37
 
38
+ # Namespace for graph-related classes (StateGraph, State, ParallelNode, …).
39
+ # Also serves as the registry for State classes that may be serialized to
40
+ # external stores (Redis, DB). Call +register_state_class+ at application
41
+ # startup so that only known classes can be deserialized.
42
+ module Graph
43
+ @state_class_registry = nil
44
+ @registry_mutex = Mutex.new
45
+
46
+ class << self
47
+ # Register one or more State classes that are allowed to be deserialized
48
+ # by StateStore backends. When at least one class is registered, only
49
+ # registered classes will be accepted by +StateStore::Base#safe_state_class+.
50
+ #
51
+ # Call this once at application startup (e.g. in a Rails initializer).
52
+ #
53
+ # @param classes [Array<Class>] classes including Phronomy::Graph::State
54
+ # @example
55
+ # Phronomy::Graph.register_state_class(MyWorkflowState, OtherState)
56
+ def register_state_class(*classes)
57
+ @registry_mutex.synchronize do
58
+ @state_class_registry ||= {}
59
+ classes.each do |klass|
60
+ raise ArgumentError, "#{klass.inspect} is not a Class" unless klass.is_a?(Class)
61
+ @state_class_registry[klass.name] = klass
62
+ end
63
+ end
64
+ end
65
+
66
+ # Returns the current registry Hash, or nil when no class has been registered.
67
+ # @return [Hash{String => Class}, nil]
68
+ attr_reader :state_class_registry
69
+
70
+ # Clears the registry. Primarily used in tests.
71
+ def reset_state_class_registry!
72
+ @registry_mutex.synchronize { @state_class_registry = nil }
73
+ end
74
+ end
75
+ end
76
+
38
77
  class << self
39
78
  def configuration
40
79
  @configuration ||= Configuration.new
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phronomy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.3
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raizo T.C.S