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 +4 -4
- data/lib/phronomy/agent/base.rb +29 -2
- data/lib/phronomy/agent/handoff.rb +5 -1
- data/lib/phronomy/agent/react_agent.rb +12 -4
- data/lib/phronomy/agent/runner.rb +6 -4
- data/lib/phronomy/configuration.rb +6 -0
- data/lib/phronomy/context/token_estimator.rb +19 -2
- data/lib/phronomy/eval/eval_result.rb +15 -5
- data/lib/phronomy/eval/runner.rb +9 -2
- data/lib/phronomy/eval/scorer/llm_judge.rb +7 -2
- data/lib/phronomy/graph/parallel_node.rb +53 -18
- data/lib/phronomy/graph/state_graph.rb +5 -0
- data/lib/phronomy/guardrail/builtin/prompt_injection_detector.rb +15 -1
- data/lib/phronomy/memory/conversation_manager.rb +14 -7
- data/lib/phronomy/memory/retrieval/base.rb +4 -3
- data/lib/phronomy/memory/retrieval/composite.rb +5 -4
- data/lib/phronomy/memory/retrieval/recent.rb +4 -3
- data/lib/phronomy/memory/retrieval/semantic.rb +33 -16
- data/lib/phronomy/memory/storage/active_record.rb +6 -3
- data/lib/phronomy/memory/storage/in_memory.rb +25 -16
- data/lib/phronomy/rails/agent_job.rb +20 -3
- data/lib/phronomy/runnable.rb +4 -1
- data/lib/phronomy/state_store/active_record.rb +7 -3
- data/lib/phronomy/state_store/base.rb +16 -2
- data/lib/phronomy/tool/base.rb +11 -2
- data/lib/phronomy/tool/mcp_tool.rb +44 -9
- data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
- data/lib/phronomy/trust_pipeline.rb +13 -1
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +39 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 04a7eceda662bfc638c3ec07ac161299b2bb08863e18e9ba03e7a3226165a921
|
|
4
|
+
data.tar.gz: 9938ace6e4a7250c08f733339af4de14c7d9ff62ff7c52a27eb954c0700d18f4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 1dc032c438a407a751b5c74fd76d172796e852a3add651c1ca6bb210991d20305a1cc609fe157e48026293084943714569219d359794c9bc7fde0dc396ed16d1
|
|
7
|
+
data.tar.gz: c1b52dcfa5196b92b72641f8d980856e5e827d61ff0e8afc61f5664f0556e0dbb0b047a7755d2f5f148525774c885ead4319510c32d7f2699fb4601c38d12ecd
|
data/lib/phronomy/agent/base.rb
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
8
|
-
# @!attribute actual
|
|
9
|
-
# @!attribute score
|
|
10
|
-
# @!attribute usage
|
|
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
|
-
|
|
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
|
data/lib/phronomy/eval/runner.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
146
|
-
#
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
old_val.
|
|
152
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
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
|
-
|
|
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
|
|
13
|
-
# @param query
|
|
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
|
|
33
|
-
# @param query
|
|
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
|
|
26
|
-
# @param query
|
|
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
|
-
|
|
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
|
-
@
|
|
40
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
58
|
-
# @param query
|
|
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] ==
|
|
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
|
-
|
|
76
|
-
|
|
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<
|
|
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
|
-
|
|
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
|
-
@
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
@
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
@
|
|
78
|
-
|
|
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
|
-
|
|
104
|
+
@mutex.synchronize do
|
|
105
|
+
next unless @raw_store[thread_id]
|
|
98
106
|
|
|
99
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
data/lib/phronomy/runnable.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
47
|
-
record
|
|
48
|
-
|
|
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
|
-
#
|
|
65
|
-
#
|
|
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}"
|
data/lib/phronomy/tool/base.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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
|
-
|
|
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
|
|
157
|
-
|
|
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:
|
|
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 =
|
|
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
|
data/lib/phronomy/version.rb
CHANGED
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
|