phronomy 0.2.2 → 0.3.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 +88 -30
- data/README.md +26 -110
- data/lib/phronomy/agent/base.rb +127 -54
- data/lib/phronomy/agent/checkpoint.rb +53 -0
- data/lib/phronomy/agent/react_agent.rb +18 -28
- data/lib/phronomy/agent/suspend_signal.rb +35 -0
- data/lib/phronomy/agent.rb +2 -1
- data/lib/phronomy/configuration.rb +0 -24
- 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/trust_pipeline.rb +1 -2
- 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 +1 -8
- data/lib/phronomy.rb +1 -0
- data/scripts/check_readme_ruby.rb +38 -0
- metadata +5 -33
- 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
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
# Encapsulates the suspended state of an agent invocation.
|
|
6
|
+
#
|
|
7
|
+
# A Checkpoint is returned as the +:checkpoint+ key of the result hash when
|
|
8
|
+
# an approval-required tool is encountered and no synchronous
|
|
9
|
+
# on_approval_required handler has been registered.
|
|
10
|
+
#
|
|
11
|
+
# Pass the checkpoint to Agent::Base#resume to continue execution after
|
|
12
|
+
# obtaining an approval decision from the user or an external system.
|
|
13
|
+
#
|
|
14
|
+
# @example Suspend and resume
|
|
15
|
+
# result = agent.invoke("Do task X")
|
|
16
|
+
# if result[:suspended]
|
|
17
|
+
# approved = prompt_user(result[:checkpoint].pending_tool_name)
|
|
18
|
+
# result = agent.resume(result[:checkpoint], approved: approved)
|
|
19
|
+
# end
|
|
20
|
+
# puts result[:output]
|
|
21
|
+
class Checkpoint
|
|
22
|
+
# @return [String, nil] the thread_id from the invocation config
|
|
23
|
+
attr_reader :thread_id
|
|
24
|
+
|
|
25
|
+
# @return [Array<RubyLLM::Message>] conversation messages up to and including
|
|
26
|
+
# the assistant message that requested the pending tool call
|
|
27
|
+
attr_reader :messages
|
|
28
|
+
|
|
29
|
+
# @return [String] the name of the tool awaiting approval
|
|
30
|
+
attr_reader :pending_tool_name
|
|
31
|
+
|
|
32
|
+
# @return [Hash] the arguments the LLM passed to the pending tool
|
|
33
|
+
attr_reader :pending_tool_args
|
|
34
|
+
|
|
35
|
+
# @return [String] the tool_call_id from the LLM response (required to
|
|
36
|
+
# inject the tool result message on resume)
|
|
37
|
+
attr_reader :pending_tool_call_id
|
|
38
|
+
|
|
39
|
+
# @param thread_id [String, nil]
|
|
40
|
+
# @param messages [Array<RubyLLM::Message>]
|
|
41
|
+
# @param pending_tool_name [String]
|
|
42
|
+
# @param pending_tool_args [Hash]
|
|
43
|
+
# @param pending_tool_call_id [String]
|
|
44
|
+
def initialize(thread_id:, messages:, pending_tool_name:, pending_tool_args:, pending_tool_call_id:)
|
|
45
|
+
@thread_id = thread_id
|
|
46
|
+
@messages = messages.dup.freeze
|
|
47
|
+
@pending_tool_name = pending_tool_name
|
|
48
|
+
@pending_tool_args = pending_tool_args
|
|
49
|
+
@pending_tool_call_id = pending_tool_call_id
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -18,18 +18,11 @@ module Phronomy
|
|
|
18
18
|
# Run input guardrails before any LLM interaction.
|
|
19
19
|
run_input_guardrails!(input)
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
thread_id = config[:thread_id]
|
|
21
|
+
config[:thread_id]
|
|
23
22
|
max_iter = self.class.max_iterations
|
|
24
23
|
|
|
25
|
-
# Seed with
|
|
26
|
-
|
|
27
|
-
load_from_memory(memory, thread_id: thread_id, query: extract_message(input))
|
|
28
|
-
else
|
|
29
|
-
[]
|
|
30
|
-
end
|
|
31
|
-
|
|
32
|
-
messages = initial_messages.dup
|
|
24
|
+
# Seed with app-managed conversation history when provided.
|
|
25
|
+
messages = Array(config[:messages]).dup
|
|
33
26
|
user_asked = false
|
|
34
27
|
total_usage = Phronomy::TokenUsage.zero
|
|
35
28
|
iterations_exhausted = true
|
|
@@ -45,12 +38,8 @@ module Phronomy
|
|
|
45
38
|
end
|
|
46
39
|
end
|
|
47
40
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
# Fall back to the last message that carries non-nil content. This
|
|
41
|
+
# Fall back to the last message
|
|
51
42
|
# 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
43
|
output = messages.reverse.find { |m| m.content && !m.content.empty? }&.content
|
|
55
44
|
|
|
56
45
|
# Run output guardrails before returning to the caller.
|
|
@@ -80,17 +69,10 @@ module Phronomy
|
|
|
80
69
|
trace("agent.invoke", input: input, **caller_meta) do |_span|
|
|
81
70
|
run_input_guardrails!(input)
|
|
82
71
|
|
|
83
|
-
|
|
84
|
-
thread_id = config[:thread_id]
|
|
72
|
+
config[:thread_id]
|
|
85
73
|
max_iter = self.class.max_iterations
|
|
86
74
|
|
|
87
|
-
|
|
88
|
-
load_from_memory(memory, thread_id: thread_id, query: extract_message(input))
|
|
89
|
-
else
|
|
90
|
-
[]
|
|
91
|
-
end
|
|
92
|
-
|
|
93
|
-
messages = initial_messages.dup
|
|
75
|
+
messages = Array(config[:messages]).dup
|
|
94
76
|
user_asked = false
|
|
95
77
|
total_usage = Phronomy::TokenUsage.zero
|
|
96
78
|
iterations_exhausted = true
|
|
@@ -106,8 +88,6 @@ module Phronomy
|
|
|
106
88
|
end
|
|
107
89
|
end
|
|
108
90
|
|
|
109
|
-
save_to_memory(memory, thread_id: thread_id, messages: messages) if memory && thread_id
|
|
110
|
-
|
|
111
91
|
# Fall back to the last message that carries non-nil content (same as
|
|
112
92
|
# the non-streaming path above).
|
|
113
93
|
output = messages.reverse.find { |m| m.content && !m.content.empty? }&.content
|
|
@@ -154,8 +134,18 @@ module Phronomy
|
|
|
154
134
|
chat = build_chat
|
|
155
135
|
messages.each { |m| chat.add_message(m) }
|
|
156
136
|
|
|
157
|
-
|
|
158
|
-
chat.
|
|
137
|
+
current_tool_call = nil
|
|
138
|
+
chat.on_tool_call do |tc|
|
|
139
|
+
current_tool_call = tc
|
|
140
|
+
block.call(StreamEvent.new(type: :tool_call, payload: {tool_call: tc}))
|
|
141
|
+
end
|
|
142
|
+
chat.on_tool_result do |tr|
|
|
143
|
+
block.call(StreamEvent.new(type: :tool_result, payload: {
|
|
144
|
+
tool_call_id: current_tool_call&.id,
|
|
145
|
+
tool_name: current_tool_call&.name,
|
|
146
|
+
tool_result: tr
|
|
147
|
+
}))
|
|
148
|
+
end
|
|
159
149
|
|
|
160
150
|
# Run before_completion hooks before each LLM call in the streaming loop.
|
|
161
151
|
run_before_completion_hooks!(chat, config)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
# Raised internally inside the on_tool_call hook when an approval-required
|
|
6
|
+
# tool is encountered and no synchronous on_approval_required handler has
|
|
7
|
+
# been registered. Caught by Agent::Base#invoke_once to produce a
|
|
8
|
+
# suspended result hash containing a Checkpoint.
|
|
9
|
+
#
|
|
10
|
+
# This class is intentionally NOT part of the public API. Callers should
|
|
11
|
+
# inspect the +:suspended+ key in the result hash returned by #invoke.
|
|
12
|
+
#
|
|
13
|
+
# @api private
|
|
14
|
+
class SuspendSignal < StandardError
|
|
15
|
+
# @return [String] the name of the tool that triggered the suspension
|
|
16
|
+
attr_reader :tool_name
|
|
17
|
+
|
|
18
|
+
# @return [Hash] the arguments the LLM passed to the tool
|
|
19
|
+
attr_reader :args
|
|
20
|
+
|
|
21
|
+
# @return [String] the tool_call_id from the LLM response
|
|
22
|
+
attr_reader :tool_call_id
|
|
23
|
+
|
|
24
|
+
# @param tool_name [String]
|
|
25
|
+
# @param args [Hash]
|
|
26
|
+
# @param tool_call_id [String]
|
|
27
|
+
def initialize(tool_name:, args:, tool_call_id:)
|
|
28
|
+
super("Agent suspended waiting for approval of tool: #{tool_name}")
|
|
29
|
+
@tool_name = tool_name
|
|
30
|
+
@args = args
|
|
31
|
+
@tool_call_id = tool_call_id
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
data/lib/phronomy/agent.rb
CHANGED
|
@@ -7,7 +7,8 @@ module Phronomy
|
|
|
7
7
|
# type values:
|
|
8
8
|
# :token — a content delta from the LLM (payload: { content: String })
|
|
9
9
|
# :tool_call — the LLM requested a tool call (payload: { tool_call: Object })
|
|
10
|
-
# :tool_result — a tool finished executing (payload: {
|
|
10
|
+
# :tool_result — a tool finished executing (payload: { tool_call_id: String, tool_name: String,
|
|
11
|
+
# tool_result: Object })
|
|
11
12
|
# :done — the agent finished (payload: { output: String, messages: Array,
|
|
12
13
|
# usage: TokenUsage })
|
|
13
14
|
# :error — an unrecoverable error occurred (payload: { error: Exception })
|
|
@@ -16,20 +16,6 @@ module Phronomy
|
|
|
16
16
|
# Default embedding model name
|
|
17
17
|
attr_accessor :default_embedding_model
|
|
18
18
|
|
|
19
|
-
# Default StateStore instance (nil = no persistence)
|
|
20
|
-
attr_accessor :default_state_store
|
|
21
|
-
|
|
22
|
-
# Default Memory instance
|
|
23
|
-
attr_accessor :default_memory
|
|
24
|
-
|
|
25
|
-
# When true, all memory backends write asynchronously via ActiveJob by default.
|
|
26
|
-
# Individual instances can still override with their own async: option.
|
|
27
|
-
# Requires ActiveJob to be available.
|
|
28
|
-
attr_accessor :memory_async
|
|
29
|
-
|
|
30
|
-
# ActiveJob queue name used for async memory writes (default: :default)
|
|
31
|
-
attr_accessor :memory_job_queue
|
|
32
|
-
|
|
33
19
|
# Tracer instance
|
|
34
20
|
attr_accessor :tracer
|
|
35
21
|
|
|
@@ -47,20 +33,10 @@ module Phronomy
|
|
|
47
33
|
# the tracing backend (OTel, Langfuse, etc.).
|
|
48
34
|
attr_accessor :trace_pii
|
|
49
35
|
|
|
50
|
-
# Maximum number of Actors that {ThreadActorRegistry} may hold simultaneously.
|
|
51
|
-
# When the registry is full, the least-recently-used Actor is stopped and
|
|
52
|
-
# evicted before a new one is created.
|
|
53
|
-
# Defaults to +nil+ (no limit). Set to a positive integer for long-running
|
|
54
|
-
# server processes that handle many distinct conversation threads.
|
|
55
|
-
attr_accessor :max_actors
|
|
56
|
-
|
|
57
36
|
def initialize
|
|
58
37
|
@recursion_limit = 25
|
|
59
38
|
@tracer = Phronomy::Tracing::NullTracer.new
|
|
60
|
-
@memory_async = false
|
|
61
|
-
@memory_job_queue = :default
|
|
62
39
|
@trace_pii = true
|
|
63
|
-
@max_actors = nil
|
|
64
40
|
end
|
|
65
41
|
end
|
|
66
42
|
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
|
|
@@ -94,7 +94,6 @@ module Phronomy
|
|
|
94
94
|
@threshold = confidence_threshold.to_f
|
|
95
95
|
@max_iterations = max_iterations.to_i
|
|
96
96
|
@input_delimiter = input_delimiter
|
|
97
|
-
@actor = Phronomy::Actor.new
|
|
98
97
|
@compiled_graph = nil
|
|
99
98
|
end
|
|
100
99
|
|
|
@@ -125,7 +124,7 @@ module Phronomy
|
|
|
125
124
|
|
|
126
125
|
# Returns the compiled workflow, building and caching it on first call.
|
|
127
126
|
def compiled_graph
|
|
128
|
-
@
|
|
127
|
+
@compiled_graph ||= build_workflow
|
|
129
128
|
end
|
|
130
129
|
|
|
131
130
|
def build_workflow
|
|
@@ -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)
|
|
@@ -38,7 +38,7 @@ module Phronomy
|
|
|
38
38
|
|
|
39
39
|
def initialize(state_class:, nodes:, after_transitions:, route_transitions:,
|
|
40
40
|
external_events:, entry_point:, wait_state_names: [],
|
|
41
|
-
before_callbacks: {}, after_callbacks: {}
|
|
41
|
+
before_callbacks: {}, after_callbacks: {})
|
|
42
42
|
@state_class = state_class
|
|
43
43
|
@nodes = nodes
|
|
44
44
|
@after_transitions = after_transitions # { from => to }
|
|
@@ -48,7 +48,6 @@ module Phronomy
|
|
|
48
48
|
@wait_state_names = wait_state_names
|
|
49
49
|
@before_callbacks = before_callbacks.dup
|
|
50
50
|
@after_callbacks = after_callbacks.dup
|
|
51
|
-
@state_store_override = state_store
|
|
52
51
|
@phase_machine_class = build_phase_machine_class
|
|
53
52
|
end
|
|
54
53
|
|
|
@@ -134,10 +133,6 @@ module Phronomy
|
|
|
134
133
|
|
|
135
134
|
private
|
|
136
135
|
|
|
137
|
-
def state_store
|
|
138
|
-
@state_store_override || Phronomy.configuration.default_state_store
|
|
139
|
-
end
|
|
140
|
-
|
|
141
136
|
def run_graph(state, from_node: nil, recursion_limit: 25, &event_block)
|
|
142
137
|
current_node = from_node || @entry_point
|
|
143
138
|
tracker = new_phase_machine(current_node)
|
|
@@ -153,7 +148,6 @@ module Phronomy
|
|
|
153
148
|
# Auto-halt at wait states: save context and return to caller.
|
|
154
149
|
if @wait_state_names.include?(current_node)
|
|
155
150
|
state.set_graph_metadata(thread_id: state.thread_id, phase: current_node)
|
|
156
|
-
state_store&.save(state)
|
|
157
151
|
return state
|
|
158
152
|
end
|
|
159
153
|
|
|
@@ -195,7 +189,6 @@ module Phronomy
|
|
|
195
189
|
end
|
|
196
190
|
|
|
197
191
|
state.set_graph_metadata(thread_id: state.thread_id, phase: :__end__)
|
|
198
|
-
state_store&.save(state)
|
|
199
192
|
state
|
|
200
193
|
end
|
|
201
194
|
|
data/lib/phronomy.rb
CHANGED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Extracts every ```ruby ... ``` block from README.md and runs `ruby -c` on each.
|
|
4
|
+
# Exits non-zero if any block has a syntax error.
|
|
5
|
+
|
|
6
|
+
require "tempfile"
|
|
7
|
+
require "open3"
|
|
8
|
+
|
|
9
|
+
readme_path = File.expand_path("../README.md", __dir__)
|
|
10
|
+
readme = File.read(readme_path)
|
|
11
|
+
blocks = readme.scan(/^```ruby\n(.*?)^```/m).map.with_index(1) { |(code), i| [i, code] }
|
|
12
|
+
|
|
13
|
+
puts "Checking #{blocks.size} Ruby code blocks in README.md..."
|
|
14
|
+
|
|
15
|
+
failures = []
|
|
16
|
+
|
|
17
|
+
blocks.each do |index, code|
|
|
18
|
+
Tempfile.create(["readme_block_#{index}", ".rb"]) do |f|
|
|
19
|
+
f.write(code)
|
|
20
|
+
f.flush
|
|
21
|
+
stdout, status = Open3.capture2e("ruby", "-c", f.path)
|
|
22
|
+
if status.success?
|
|
23
|
+
puts " OK block ##{index}"
|
|
24
|
+
else
|
|
25
|
+
failures << index
|
|
26
|
+
puts " FAIL block ##{index}"
|
|
27
|
+
puts stdout.gsub(f.path, "block ##{index}")
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
if failures.empty?
|
|
33
|
+
puts "All #{blocks.size} Ruby code blocks passed syntax check."
|
|
34
|
+
exit 0
|
|
35
|
+
else
|
|
36
|
+
puts "\n#{failures.size} block(s) failed syntax check: #{failures.join(", ")}"
|
|
37
|
+
exit 1
|
|
38
|
+
end
|