phronomy 0.2.2 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +127 -30
- data/README.md +106 -122
- data/lib/phronomy/agent/base.rb +135 -57
- data/lib/phronomy/agent/checkpoint.rb +53 -0
- data/lib/phronomy/agent/orchestrator.rb +119 -0
- data/lib/phronomy/agent/react_agent.rb +18 -28
- data/lib/phronomy/agent/shared_state.rb +303 -0
- data/lib/phronomy/agent/suspend_signal.rb +35 -0
- data/lib/phronomy/agent/team_coordinator.rb +285 -0
- data/lib/phronomy/agent.rb +2 -1
- data/lib/phronomy/configuration.rb +0 -24
- data/lib/phronomy/generator_verifier.rb +250 -0
- data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +10 -27
- data/lib/phronomy/railtie.rb +0 -6
- data/lib/phronomy/ruby_llm_patches.rb +20 -0
- data/lib/phronomy/tool/mcp_tool.rb +23 -26
- data/lib/phronomy/tracing/langfuse_tracer.rb +3 -6
- data/lib/phronomy/vector_store/redis_search.rb +4 -4
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +4 -7
- data/lib/phronomy/workflow_runner.rb +42 -30
- data/lib/phronomy.rb +18 -0
- data/scripts/check_readme_ruby.rb +38 -0
- metadata +12 -38
- data/docs/trustworthy_ai_enhancements.md +0 -332
- data/lib/phronomy/active_record/acts_as.rb +0 -48
- data/lib/phronomy/active_record/checkpoint.rb +0 -20
- data/lib/phronomy/active_record/extensions.rb +0 -14
- data/lib/phronomy/active_record/message.rb +0 -20
- data/lib/phronomy/actor.rb +0 -68
- data/lib/phronomy/memory/compression/base.rb +0 -37
- data/lib/phronomy/memory/compression/summary.rb +0 -107
- data/lib/phronomy/memory/compression/tool_output_pruner.rb +0 -67
- data/lib/phronomy/memory/compression.rb +0 -11
- data/lib/phronomy/memory/conversation_manager.rb +0 -213
- data/lib/phronomy/memory/retrieval/base.rb +0 -22
- data/lib/phronomy/memory/retrieval/composite.rb +0 -76
- data/lib/phronomy/memory/retrieval/recent.rb +0 -35
- data/lib/phronomy/memory/retrieval/semantic.rb +0 -114
- data/lib/phronomy/memory/retrieval.rb +0 -12
- data/lib/phronomy/memory/storage/active_record.rb +0 -248
- data/lib/phronomy/memory/storage/base.rb +0 -155
- data/lib/phronomy/memory/storage/in_memory.rb +0 -152
- data/lib/phronomy/memory/storage.rb +0 -11
- data/lib/phronomy/memory.rb +0 -21
- data/lib/phronomy/rails/agent_job.rb +0 -75
- data/lib/phronomy/state_store/active_record.rb +0 -76
- data/lib/phronomy/state_store/base.rb +0 -112
- data/lib/phronomy/state_store/encryptor/active_support.rb +0 -49
- data/lib/phronomy/state_store/encryptor/base.rb +0 -34
- data/lib/phronomy/state_store/encryptor.rb +0 -16
- data/lib/phronomy/state_store/file.rb +0 -85
- data/lib/phronomy/state_store/in_memory.rb +0 -53
- data/lib/phronomy/state_store/redis.rb +0 -70
- data/lib/phronomy/state_store.rb +0 -9
- data/lib/phronomy/thread_actor_registry.rb +0 -85
- data/lib/phronomy/trust_pipeline.rb +0 -264
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# Implements the Generator-Verifier multi-agent coordination pattern
|
|
5
|
+
# (Anthropic blog, Pattern 1): a generator agent produces an
|
|
6
|
+
# answer while a verifier agent evaluates its quality.
|
|
7
|
+
#
|
|
8
|
+
# @see https://claude.com/blog/multi-agent-coordination-patterns
|
|
9
|
+
#
|
|
10
|
+
# All prompt construction and result parsing are provided by the caller,
|
|
11
|
+
# giving full control over the LLM dialogue.
|
|
12
|
+
# The generator and verifier agents are configurable, and the pipeline
|
|
13
|
+
# retries until confidence passes the threshold or max iterations are reached.
|
|
14
|
+
#
|
|
15
|
+
# @example Basic usage with custom prompt builders
|
|
16
|
+
# pipeline = Phronomy::GeneratorVerifier.new(
|
|
17
|
+
# draft_agent: MyDraftAgent,
|
|
18
|
+
# review_agent: MyReviewAgent,
|
|
19
|
+
# draft_prompt_builder: ->(input, feedback) { "Question: #{input}" },
|
|
20
|
+
# review_prompt_builder: ->(input, draft, citations) { "Review: #{draft}" }
|
|
21
|
+
# )
|
|
22
|
+
# result = pipeline.invoke("What is the refund policy?")
|
|
23
|
+
# puts result.output # the final answer string
|
|
24
|
+
# puts result.trusted? # true when confidence >= threshold
|
|
25
|
+
#
|
|
26
|
+
# @example Custom result parsers
|
|
27
|
+
# pipeline = Phronomy::GeneratorVerifier.new(
|
|
28
|
+
# ...,
|
|
29
|
+
# draft_result_parser: ->(text) { my_parse_draft(text) },
|
|
30
|
+
# review_result_parser: ->(text) { my_parse_review(text) }
|
|
31
|
+
# )
|
|
32
|
+
#
|
|
33
|
+
# @example Raising on low confidence
|
|
34
|
+
# pipeline = Phronomy::GeneratorVerifier.new(
|
|
35
|
+
# ...,
|
|
36
|
+
# raise_if_untrusted: true
|
|
37
|
+
# )
|
|
38
|
+
# begin
|
|
39
|
+
# result = pipeline.invoke("question")
|
|
40
|
+
# rescue Phronomy::LowConfidenceError => e
|
|
41
|
+
# puts "Untrusted: #{e.result.confidence}"
|
|
42
|
+
# end
|
|
43
|
+
class GeneratorVerifier
|
|
44
|
+
# Default confidence threshold for trusting an answer.
|
|
45
|
+
DEFAULT_CONFIDENCE_THRESHOLD = 0.7
|
|
46
|
+
|
|
47
|
+
# Default maximum draft-review cycles before returning best effort.
|
|
48
|
+
DEFAULT_MAX_ITERATIONS = 3
|
|
49
|
+
|
|
50
|
+
# Immutable value object returned by {GeneratorVerifier#invoke}.
|
|
51
|
+
#
|
|
52
|
+
# @!attribute [r] output
|
|
53
|
+
# @return [String] the final answer text
|
|
54
|
+
# @!attribute [r] confidence
|
|
55
|
+
# @return [Float] combined confidence score (0.0–1.0)
|
|
56
|
+
# @!attribute [r] citations
|
|
57
|
+
# @return [Array<Hash>] [{source:, excerpt:}, ...]
|
|
58
|
+
#
|
|
59
|
+
# **WARNING**: These citations are extracted from the LLM's own response
|
|
60
|
+
# and are **not** verified against any external knowledge base or URL.
|
|
61
|
+
# Do not treat them as authoritative without independent verification.
|
|
62
|
+
# @!attribute [r] iterations
|
|
63
|
+
# @return [Integer] number of draft-review cycles executed
|
|
64
|
+
# @!attribute [r] review_notes
|
|
65
|
+
# @return [Array<String>] reviewer feedback for each cycle
|
|
66
|
+
# @!attribute [r] trusted
|
|
67
|
+
# @return [Boolean] true when confidence >= threshold
|
|
68
|
+
Result = Struct.new(
|
|
69
|
+
:output, :confidence, :citations, :iterations, :review_notes, :trusted,
|
|
70
|
+
keyword_init: true
|
|
71
|
+
) do
|
|
72
|
+
# @return [Boolean] true when confidence >= threshold
|
|
73
|
+
alias_method :trusted?, :trusted
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Internal graph state — not part of the public API.
|
|
77
|
+
# @private
|
|
78
|
+
class PipelineState
|
|
79
|
+
include Phronomy::WorkflowContext
|
|
80
|
+
|
|
81
|
+
field :input, type: :replace, default: -> { "" }
|
|
82
|
+
field :draft, type: :replace, default: -> {}
|
|
83
|
+
field :self_score, type: :replace, default: -> { 0.0 }
|
|
84
|
+
field :review_score, type: :replace, default: -> { 0.0 }
|
|
85
|
+
field :citations, type: :replace, default: -> { [] }
|
|
86
|
+
field :review_notes, type: :append, default: -> { [] }
|
|
87
|
+
field :iteration, type: :replace, default: -> { 0 }
|
|
88
|
+
field :approved, type: :replace, default: -> { false }
|
|
89
|
+
field :output, type: :replace, default: -> {}
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
private_constant :PipelineState
|
|
93
|
+
|
|
94
|
+
# @param draft_agent [Class] subclass of Phronomy::Agent::Base
|
|
95
|
+
# used to generate answer drafts
|
|
96
|
+
# @param review_agent [Class] subclass of Phronomy::Agent::Base
|
|
97
|
+
# used to evaluate each draft
|
|
98
|
+
# @param draft_prompt_builder [#call] +call(input, feedback)+ → String
|
|
99
|
+
# prompt for the generator. +feedback+ is nil on the first iteration and
|
|
100
|
+
# contains the reviewer's feedback string on subsequent iterations.
|
|
101
|
+
# @param review_prompt_builder [#call] +call(input, draft, citations)+ → String
|
|
102
|
+
# prompt for the verifier. +citations+ is an Array of Hashes.
|
|
103
|
+
# @param draft_result_parser [#call, nil] +call(text)+ → Hash with
|
|
104
|
+
# +:answer+, +:confidence+, and +:citations+ keys. Defaults to JSON parsing
|
|
105
|
+
# with a safe fallback when the response cannot be parsed.
|
|
106
|
+
# @param review_result_parser [#call, nil] +call(text)+ → Hash with
|
|
107
|
+
# +:approved+, +:score+, and +:feedback+ keys. Defaults to JSON parsing
|
|
108
|
+
# with a safe fallback.
|
|
109
|
+
# @param confidence_threshold [Float] minimum combined confidence to
|
|
110
|
+
# trust an answer (default: 0.7)
|
|
111
|
+
# @param max_iterations [Integer] maximum draft-review cycles
|
|
112
|
+
# before returning the best-effort answer (default: 3)
|
|
113
|
+
# @param raise_if_untrusted [Boolean] when +true+, raises
|
|
114
|
+
# {Phronomy::LowConfidenceError} if the final result does not meet the
|
|
115
|
+
# confidence threshold (default: false)
|
|
116
|
+
def initialize(
|
|
117
|
+
draft_agent:,
|
|
118
|
+
review_agent:,
|
|
119
|
+
draft_prompt_builder:,
|
|
120
|
+
review_prompt_builder:,
|
|
121
|
+
draft_result_parser: nil,
|
|
122
|
+
review_result_parser: nil,
|
|
123
|
+
confidence_threshold: DEFAULT_CONFIDENCE_THRESHOLD,
|
|
124
|
+
max_iterations: DEFAULT_MAX_ITERATIONS,
|
|
125
|
+
raise_if_untrusted: false
|
|
126
|
+
)
|
|
127
|
+
@draft_agent_class = draft_agent
|
|
128
|
+
@review_agent_class = review_agent
|
|
129
|
+
@draft_prompt_builder = draft_prompt_builder
|
|
130
|
+
@review_prompt_builder = review_prompt_builder
|
|
131
|
+
@draft_result_parser = draft_result_parser || method(:default_parse_draft)
|
|
132
|
+
@review_result_parser = review_result_parser || method(:default_parse_review)
|
|
133
|
+
@threshold = confidence_threshold.to_f
|
|
134
|
+
@max_iterations = max_iterations.to_i
|
|
135
|
+
@raise_if_untrusted = raise_if_untrusted
|
|
136
|
+
@compiled_graph = nil
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
# Run the generator-verifier pipeline.
|
|
140
|
+
#
|
|
141
|
+
# @param input [String] the user question or task description
|
|
142
|
+
# @param config [Hash] forwarded to the underlying agents (e.g. thread_id)
|
|
143
|
+
# @return [Result]
|
|
144
|
+
# @raise [Phronomy::LowConfidenceError] when +raise_if_untrusted:+ is +true+
|
|
145
|
+
# and the result does not meet the confidence threshold
|
|
146
|
+
def invoke(input, config: {})
|
|
147
|
+
app = compiled_graph
|
|
148
|
+
state = app.invoke({input: input}, config: config)
|
|
149
|
+
confidence = combined_confidence(state)
|
|
150
|
+
trusted = confidence >= @threshold
|
|
151
|
+
result = Result.new(
|
|
152
|
+
output: state.output || state.draft.to_s,
|
|
153
|
+
confidence: confidence,
|
|
154
|
+
citations: state.citations,
|
|
155
|
+
iterations: state.iteration,
|
|
156
|
+
review_notes: state.review_notes,
|
|
157
|
+
trusted: trusted
|
|
158
|
+
)
|
|
159
|
+
raise LowConfidenceError.new(result) if @raise_if_untrusted && !trusted
|
|
160
|
+
result
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
private
|
|
164
|
+
|
|
165
|
+
def combined_confidence(state)
|
|
166
|
+
[(state.self_score || 0.0).to_f, (state.review_score || 0.0).to_f].min
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def compiled_graph
|
|
170
|
+
@compiled_graph ||= build_workflow
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def build_workflow
|
|
174
|
+
draft_agent = @draft_agent_class.new
|
|
175
|
+
review_agent = @review_agent_class.new
|
|
176
|
+
threshold = @threshold
|
|
177
|
+
max_iter = @max_iterations
|
|
178
|
+
dpb = @draft_prompt_builder
|
|
179
|
+
rpb = @review_prompt_builder
|
|
180
|
+
drp = @draft_result_parser
|
|
181
|
+
rrp = @review_result_parser
|
|
182
|
+
pipeline = self
|
|
183
|
+
|
|
184
|
+
Phronomy::Workflow.define(PipelineState) do
|
|
185
|
+
initial :draft
|
|
186
|
+
|
|
187
|
+
state :draft, action: ->(state) {
|
|
188
|
+
feedback = state.review_notes.last
|
|
189
|
+
prompt = dpb.call(state.input, feedback)
|
|
190
|
+
result = draft_agent.invoke(prompt)
|
|
191
|
+
parsed = drp.call(result[:output])
|
|
192
|
+
state.merge(
|
|
193
|
+
draft: parsed[:answer].to_s,
|
|
194
|
+
self_score: pipeline.__send__(:clamp, parsed[:confidence]),
|
|
195
|
+
citations: pipeline.__send__(:normalize_citations, parsed[:citations]),
|
|
196
|
+
iteration: state.iteration + 1
|
|
197
|
+
)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
state :review, action: ->(state) {
|
|
201
|
+
prompt = rpb.call(state.input, state.draft, state.citations)
|
|
202
|
+
result = review_agent.invoke(prompt)
|
|
203
|
+
parsed = rrp.call(result[:output])
|
|
204
|
+
state.merge(
|
|
205
|
+
review_score: pipeline.__send__(:clamp, parsed[:score]),
|
|
206
|
+
approved: parsed[:approved] == true,
|
|
207
|
+
review_notes: parsed[:feedback].to_s
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
state :finalize, action: ->(state) { state.merge(output: state.draft) }
|
|
212
|
+
|
|
213
|
+
after :draft, to: :review
|
|
214
|
+
after :finalize, to: :__finish__
|
|
215
|
+
|
|
216
|
+
event :route_review, from: :review,
|
|
217
|
+
guard: ->(state) {
|
|
218
|
+
confidence = [state.self_score || 0.0, state.review_score || 0.0].min
|
|
219
|
+
(confidence >= threshold && state.approved) || state.iteration >= max_iter
|
|
220
|
+
},
|
|
221
|
+
to: :finalize
|
|
222
|
+
event :route_review, from: :review, to: :draft
|
|
223
|
+
end
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def default_parse_draft(text)
|
|
227
|
+
json_parser.parse(text)
|
|
228
|
+
rescue Phronomy::ParseError
|
|
229
|
+
{answer: text.to_s, confidence: 0.0, citations: []}
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def default_parse_review(text)
|
|
233
|
+
json_parser.parse(text)
|
|
234
|
+
rescue Phronomy::ParseError
|
|
235
|
+
{approved: false, score: 0.0, feedback: "Review output could not be parsed: #{text}"}
|
|
236
|
+
end
|
|
237
|
+
|
|
238
|
+
def json_parser
|
|
239
|
+
@json_parser ||= Phronomy::OutputParser::JsonParser.new
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def clamp(val)
|
|
243
|
+
val.to_f.clamp(0.0, 1.0)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def normalize_citations(raw)
|
|
247
|
+
Array(raw).filter_map { |c| c.is_a?(Hash) ? c.transform_keys(&:to_sym) : nil }
|
|
248
|
+
end
|
|
249
|
+
end
|
|
250
|
+
end
|
|
@@ -7,10 +7,10 @@ module Phronomy
|
|
|
7
7
|
#
|
|
8
8
|
# Four categories are supported and each can be individually toggled:
|
|
9
9
|
#
|
|
10
|
-
# - +:
|
|
10
|
+
# - +:ssn+ — US Social Security Numbers (###-##-####)
|
|
11
11
|
# - +:credit_card+ — Credit / debit card numbers
|
|
12
12
|
# - +:email+ — E-mail addresses
|
|
13
|
-
# - +:phone+ —
|
|
13
|
+
# - +:phone+ — Phone numbers
|
|
14
14
|
#
|
|
15
15
|
# All four categories are active by default.
|
|
16
16
|
#
|
|
@@ -24,13 +24,10 @@ module Phronomy
|
|
|
24
24
|
class PIIPatternDetector < InputGuardrail
|
|
25
25
|
# Recognised PII categories and their detection patterns.
|
|
26
26
|
PATTERNS = {
|
|
27
|
-
#
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
pattern: /(?<!\d)(?<!\d[- ])\d{4}[- ]?\d{4}[- ]?\d{4}(?![- ]?\d)/,
|
|
32
|
-
label: "My Number",
|
|
33
|
-
validate_my_number: true
|
|
27
|
+
# US Social Security Number: ###-##-#### (hyphens required).
|
|
28
|
+
ssn: {
|
|
29
|
+
pattern: /\b\d{3}-\d{2}-\d{4}\b/,
|
|
30
|
+
label: "SSN"
|
|
34
31
|
},
|
|
35
32
|
# Credit / debit card: 16 digits, optionally separated by spaces or hyphens.
|
|
36
33
|
# Matched candidates are additionally validated with the Luhn algorithm
|
|
@@ -45,9 +42,10 @@ module Phronomy
|
|
|
45
42
|
pattern: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/,
|
|
46
43
|
label: "email address"
|
|
47
44
|
},
|
|
48
|
-
#
|
|
45
|
+
# Phone number: 3-digit area code, 3-4-digit exchange, 4-digit subscriber;
|
|
46
|
+
# optional E.164 country-code prefix (e.g. +1, +44).
|
|
49
47
|
phone: {
|
|
50
|
-
pattern:
|
|
48
|
+
pattern: /(?:\+\d{1,3}[.\- ]?)?\(?\d{3}\)?[.\- ]?\d{3,4}[.\- ]?\d{4}\b/,
|
|
51
49
|
label: "phone number"
|
|
52
50
|
}
|
|
53
51
|
}.freeze
|
|
@@ -55,7 +53,7 @@ module Phronomy
|
|
|
55
53
|
ALL_CATEGORIES = PATTERNS.keys.freeze
|
|
56
54
|
|
|
57
55
|
# @param detect [Array<Symbol>] categories to detect.
|
|
58
|
-
# Defaults to all four: +:
|
|
56
|
+
# Defaults to all four: +:ssn+, +:credit_card+, +:email+, +:phone+.
|
|
59
57
|
# @raise [ArgumentError] when an unknown category symbol is provided.
|
|
60
58
|
def initialize(detect: ALL_CATEGORIES)
|
|
61
59
|
unknown = Array(detect) - ALL_CATEGORIES
|
|
@@ -74,10 +72,6 @@ module Phronomy
|
|
|
74
72
|
# Scan for all candidates then filter by Luhn check-digit validation.
|
|
75
73
|
# This avoids false positives on arbitrary 16-digit strings (e.g. internal IDs).
|
|
76
74
|
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
75
|
else
|
|
82
76
|
text.match?(entry[:pattern])
|
|
83
77
|
end
|
|
@@ -87,17 +81,6 @@ module Phronomy
|
|
|
87
81
|
|
|
88
82
|
private
|
|
89
83
|
|
|
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
84
|
# Returns true when +digits+ (a string of decimal digits) satisfies the
|
|
102
85
|
# Luhn check-digit algorithm used by payment card networks.
|
|
103
86
|
def luhn_valid?(digits)
|
data/lib/phronomy/railtie.rb
CHANGED
|
@@ -30,16 +30,10 @@ module Phronomy
|
|
|
30
30
|
|
|
31
31
|
# Loads Phronomy::Rails::AgentJob when both ActionCable and ActiveJob are present.
|
|
32
32
|
initializer "phronomy.agent_job" do
|
|
33
|
-
if defined?(::ActionCable) && defined?(::ActiveJob)
|
|
34
|
-
require "phronomy/rails/agent_job"
|
|
35
|
-
end
|
|
36
33
|
end
|
|
37
34
|
|
|
38
35
|
# Loads Phronomy ActiveRecord extensions when ActiveRecord is available.
|
|
39
36
|
initializer "phronomy.active_record", after: "active_record.initialize_database" do
|
|
40
|
-
ActiveSupport.on_load(:active_record) do
|
|
41
|
-
require "phronomy/active_record/extensions" if defined?(::ActiveRecord)
|
|
42
|
-
end
|
|
43
37
|
end
|
|
44
38
|
end
|
|
45
39
|
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Patches for upstream ruby_llm bugs that have not yet been released.
|
|
4
|
+
# Remove each patch once the fix is available in a published gem version.
|
|
5
|
+
|
|
6
|
+
module RubyLLM
|
|
7
|
+
module Streaming
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
# Upstream ruby_llm <= 1.15.0 assumes the SSE error chunk always has two
|
|
11
|
+
# lines ("event: error\ndata: {...}") and uses a fixed index [1], which
|
|
12
|
+
# raises NoMethodError when some providers (e.g. Qwen) return a single-line
|
|
13
|
+
# chunk ("data: {...}"). This patch finds the data line by content instead.
|
|
14
|
+
def handle_error_chunk(chunk, env)
|
|
15
|
+
data_line = chunk.split("\n").find { |l| l.start_with?("data: ") } || chunk.split("\n")[0]
|
|
16
|
+
error_data = data_line.delete_prefix("data: ")
|
|
17
|
+
parse_error_from_json(error_data, env, "Failed to parse error chunk")
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -11,10 +11,11 @@ module Phronomy
|
|
|
11
11
|
# A Phronomy::Tool::Base subclass that wraps a tool exposed by an external
|
|
12
12
|
# MCP (Model Context Protocol) server.
|
|
13
13
|
#
|
|
14
|
-
#
|
|
15
|
-
#
|
|
16
|
-
#
|
|
17
|
-
#
|
|
14
|
+
# Supports two transport schemes:
|
|
15
|
+
# - <b>"stdio://\<command\>"</b> — spawns a child process that communicates via
|
|
16
|
+
# newline-delimited JSON-RPC on stdin/stdout.
|
|
17
|
+
# - <b>"http://\<url\>"</b> / <b>"https://\<url\>"</b> — connects to a running
|
|
18
|
+
# HTTP/SSE MCP server using +net/http+.
|
|
18
19
|
#
|
|
19
20
|
# @example
|
|
20
21
|
# web_search = Phronomy::Tool::McpTool.from_server(
|
|
@@ -31,6 +32,7 @@ module Phronomy
|
|
|
31
32
|
# @param server_uri [String] URI of the MCP server.
|
|
32
33
|
# Supported schemes:
|
|
33
34
|
# - "stdio://<command>" — spawn a child process
|
|
35
|
+
# - "http://<url>" / "https://<url>" — connect to an HTTP/SSE server
|
|
34
36
|
# @param tool_name [String] the tool name as registered in the MCP server
|
|
35
37
|
# @return [McpTool] a configured subclass instance ready for use with an Agent
|
|
36
38
|
def from_server(server_uri, tool_name:)
|
|
@@ -87,7 +89,6 @@ module Phronomy
|
|
|
87
89
|
# Split the command string into an argv array so that Open3 executes
|
|
88
90
|
# it directly without going through the shell, preventing injection.
|
|
89
91
|
@command = Shellwords.split(command)
|
|
90
|
-
@actor = Phronomy::Actor.new
|
|
91
92
|
@stdin = nil
|
|
92
93
|
@stdout = nil
|
|
93
94
|
@stderr = nil
|
|
@@ -97,19 +98,17 @@ module Phronomy
|
|
|
97
98
|
|
|
98
99
|
# Shut down the child process and close its IO streams.
|
|
99
100
|
def close
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
end
|
|
112
|
-
# Join outside the Actor to avoid blocking the Actor thread on slow joins.
|
|
101
|
+
@stdin&.close
|
|
102
|
+
@stdout&.close
|
|
103
|
+
@stderr&.close
|
|
104
|
+
@stdin = nil
|
|
105
|
+
@stdout = nil
|
|
106
|
+
@stderr = nil
|
|
107
|
+
stderr_thread = @stderr_thread
|
|
108
|
+
wait_thr = @wait_thr
|
|
109
|
+
@stderr_thread = nil
|
|
110
|
+
@wait_thr = nil
|
|
111
|
+
# Join outside the lock to avoid blocking on slow joins.
|
|
113
112
|
stderr_thread&.join(1)
|
|
114
113
|
wait_thr&.join(5)
|
|
115
114
|
end
|
|
@@ -168,14 +167,12 @@ module Phronomy
|
|
|
168
167
|
end
|
|
169
168
|
|
|
170
169
|
def rpc_call(method, params)
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
JSON.parse(raw)
|
|
178
|
-
end
|
|
170
|
+
ensure_started!
|
|
171
|
+
payload = JSON.generate(jsonrpc: "2.0", id: SecureRandom.uuid, method: method, params: params)
|
|
172
|
+
@stdin.puts(payload)
|
|
173
|
+
raw = @stdout.gets
|
|
174
|
+
raise Phronomy::ToolError, "MCP server closed the connection unexpectedly" if raw.nil?
|
|
175
|
+
JSON.parse(raw)
|
|
179
176
|
end
|
|
180
177
|
|
|
181
178
|
def parse_schema_params(properties)
|
|
@@ -36,7 +36,6 @@ module Phronomy
|
|
|
36
36
|
@secret_key = secret_key
|
|
37
37
|
@host = host.chomp("/")
|
|
38
38
|
@http = nil
|
|
39
|
-
@actor = Phronomy::Actor.new
|
|
40
39
|
end
|
|
41
40
|
|
|
42
41
|
# Returns a plain Hash that records the span start state.
|
|
@@ -90,13 +89,11 @@ module Phronomy
|
|
|
90
89
|
req["Authorization"] = "Basic #{Base64.strict_encode64("#{@public_key}:#{@secret_key}")}"
|
|
91
90
|
req.body = JSON.generate({batch: events})
|
|
92
91
|
|
|
93
|
-
@
|
|
94
|
-
|
|
95
|
-
@http.request(req)
|
|
96
|
-
end
|
|
92
|
+
@http ||= build_http(uri)
|
|
93
|
+
@http.request(req)
|
|
97
94
|
rescue IOError, Errno::ECONNRESET, Errno::EPIPE => e
|
|
98
95
|
# Connection was reset; drop the cached connection and warn.
|
|
99
|
-
@
|
|
96
|
+
@http = nil
|
|
100
97
|
warn "[Phronomy::LangfuseTracer] Ingestion failed: #{e.class}: #{e.message}"
|
|
101
98
|
nil
|
|
102
99
|
rescue => e
|
|
@@ -38,7 +38,7 @@ module Phronomy
|
|
|
38
38
|
@index_name = index_name
|
|
39
39
|
@dimension = dimension
|
|
40
40
|
@index_created = false
|
|
41
|
-
@
|
|
41
|
+
@mutex = Mutex.new
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
# @param id [String]
|
|
@@ -80,7 +80,7 @@ module Phronomy
|
|
|
80
80
|
end
|
|
81
81
|
|
|
82
82
|
def clear
|
|
83
|
-
@
|
|
83
|
+
@mutex.synchronize do
|
|
84
84
|
begin
|
|
85
85
|
@redis.call("FT.DROPINDEX", @index_name, "DD")
|
|
86
86
|
rescue => e
|
|
@@ -94,8 +94,8 @@ module Phronomy
|
|
|
94
94
|
private
|
|
95
95
|
|
|
96
96
|
def ensure_index!(dim)
|
|
97
|
-
@
|
|
98
|
-
|
|
97
|
+
@mutex.synchronize do
|
|
98
|
+
return if @index_created
|
|
99
99
|
|
|
100
100
|
@dimension ||= dim
|
|
101
101
|
begin
|
data/lib/phronomy/version.rb
CHANGED
data/lib/phronomy/workflow.rb
CHANGED
|
@@ -53,11 +53,10 @@ module Phronomy
|
|
|
53
53
|
|
|
54
54
|
# Defines a new Workflow.
|
|
55
55
|
# @param context_class [Class] class that includes Phronomy::WorkflowContext
|
|
56
|
-
# @param state_store [Object, nil] optional state store override (passed to WorkflowRunner)
|
|
57
56
|
# @yield block evaluated in DSL context
|
|
58
57
|
# @return [Phronomy::Workflow] compiled and ready-to-run workflow instance
|
|
59
|
-
def self.define(context_class,
|
|
60
|
-
builder = Builder.new(context_class
|
|
58
|
+
def self.define(context_class, &block)
|
|
59
|
+
builder = Builder.new(context_class)
|
|
61
60
|
builder.instance_eval(&block)
|
|
62
61
|
builder.build
|
|
63
62
|
end
|
|
@@ -110,9 +109,8 @@ module Phronomy
|
|
|
110
109
|
class Builder
|
|
111
110
|
FINISH = Phronomy::WorkflowRunner::FINISH
|
|
112
111
|
|
|
113
|
-
def initialize(context_class
|
|
112
|
+
def initialize(context_class)
|
|
114
113
|
@context_class = context_class
|
|
115
|
-
@state_store = state_store
|
|
116
114
|
@initial = nil
|
|
117
115
|
# { node_name => callable }
|
|
118
116
|
@states = {}
|
|
@@ -210,8 +208,7 @@ module Phronomy
|
|
|
210
208
|
route_transitions: route_transitions,
|
|
211
209
|
external_events: external_events,
|
|
212
210
|
entry_point: @initial || nodes.keys.first,
|
|
213
|
-
wait_state_names: @wait_state_names
|
|
214
|
-
state_store: @state_store
|
|
211
|
+
wait_state_names: @wait_state_names
|
|
215
212
|
)
|
|
216
213
|
|
|
217
214
|
Workflow.new(runner)
|