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