phronomy 0.6.0 → 0.7.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/.mutant.yml +21 -0
- data/CHANGELOG.md +338 -0
- data/CONTRIBUTING.md +102 -0
- data/README.md +242 -27
- data/RELEASE_CHECKLIST.md +86 -0
- data/SECURITY.md +80 -0
- data/benchmark/baseline.json +9 -0
- data/benchmark/bench_agent_invoke.rb +105 -0
- data/benchmark/bench_context_assembler.rb +46 -0
- data/benchmark/bench_regression.rb +171 -0
- data/benchmark/bench_token_estimator.rb +44 -0
- data/benchmark/bench_tool_schema.rb +69 -0
- data/benchmark/bench_vector_store.rb +39 -0
- data/benchmark/bench_workflow.rb +55 -0
- data/benchmark/run_all.rb +118 -0
- data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
- data/docs/decisions/002-workflow-context-immutability.md +42 -0
- data/docs/decisions/003-event-loop-singleton.md +48 -0
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +51 -0
- data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
- data/docs/decisions/006-no-built-in-guardrails.md +48 -0
- data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
- data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
- data/docs/decisions/009-state-store-abstraction.md +141 -0
- data/lib/phronomy/agent/base.rb +194 -12
- data/lib/phronomy/agent/before_completion_context.rb +1 -0
- data/lib/phronomy/agent/checkpoint.rb +1 -0
- data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
- data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
- data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
- data/lib/phronomy/agent/concerns/retryable.rb +12 -1
- data/lib/phronomy/agent/concerns/suspendable.rb +4 -0
- data/lib/phronomy/agent/fsm.rb +15 -0
- data/lib/phronomy/agent/handoff.rb +3 -0
- data/lib/phronomy/agent/orchestrator.rb +123 -11
- data/lib/phronomy/agent/parallel_tool_chat.rb +21 -4
- data/lib/phronomy/agent/react_agent.rb +8 -6
- data/lib/phronomy/agent/runner.rb +2 -0
- data/lib/phronomy/agent/shared_state.rb +11 -0
- data/lib/phronomy/agent/suspend_signal.rb +2 -0
- data/lib/phronomy/agent/team_coordinator.rb +17 -5
- data/lib/phronomy/cancellation_token.rb +92 -0
- data/lib/phronomy/configuration.rb +26 -2
- data/lib/phronomy/context/assembler.rb +6 -0
- data/lib/phronomy/context/compaction_context.rb +2 -0
- data/lib/phronomy/context/context_version_cache.rb +2 -0
- data/lib/phronomy/context/token_budget.rb +3 -0
- data/lib/phronomy/context/token_estimator.rb +9 -2
- data/lib/phronomy/context/trigger_context.rb +1 -0
- data/lib/phronomy/context/trim_context.rb +4 -0
- data/lib/phronomy/embeddings/base.rb +5 -2
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
- data/lib/phronomy/eval/comparison.rb +2 -0
- data/lib/phronomy/eval/dataset.rb +4 -0
- data/lib/phronomy/eval/metrics.rb +6 -0
- data/lib/phronomy/eval/runner.rb +2 -0
- data/lib/phronomy/eval/scorer/base.rb +1 -0
- data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
- data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
- data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
- data/lib/phronomy/event_loop.rb +114 -7
- data/lib/phronomy/fsm_session.rb +8 -1
- data/lib/phronomy/generator_verifier.rb +2 -0
- data/lib/phronomy/guardrail/base.rb +3 -0
- data/lib/phronomy/knowledge_source/base.rb +6 -2
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
- data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
- data/lib/phronomy/loader/base.rb +1 -0
- data/lib/phronomy/loader/csv_loader.rb +2 -0
- data/lib/phronomy/loader/markdown_loader.rb +2 -0
- data/lib/phronomy/loader/plain_text_loader.rb +1 -0
- data/lib/phronomy/output_parser/base.rb +1 -0
- data/lib/phronomy/output_parser/json_parser.rb +22 -3
- data/lib/phronomy/output_parser/structured_parser.rb +2 -0
- data/lib/phronomy/prompt_template.rb +5 -0
- data/lib/phronomy/runnable.rb +20 -3
- data/lib/phronomy/splitter/base.rb +2 -0
- data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
- data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
- data/lib/phronomy/state_store/base.rb +48 -0
- data/lib/phronomy/state_store/in_memory.rb +62 -0
- data/lib/phronomy/tool/agent_tool.rb +1 -0
- data/lib/phronomy/tool/base.rb +189 -27
- data/lib/phronomy/tool/mcp_tool.rb +68 -13
- data/lib/phronomy/tracing/base.rb +3 -0
- data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +2 -0
- data/lib/phronomy/vector_store/base.rb +33 -7
- data/lib/phronomy/vector_store/in_memory.rb +16 -7
- data/lib/phronomy/vector_store/pgvector.rb +40 -9
- data/lib/phronomy/vector_store/redis_search.rb +29 -8
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +96 -7
- data/lib/phronomy/workflow_context.rb +54 -4
- data/lib/phronomy/workflow_runner.rb +35 -7
- data/lib/phronomy.rb +70 -1
- data/scripts/api_snapshot.rb +91 -0
- data/scripts/check_api_annotations.rb +68 -0
- data/scripts/check_private_enforcement.rb +93 -0
- data/scripts/check_readme_runnable.rb +98 -0
- data/scripts/run_mutation.sh +46 -0
- metadata +45 -2
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module StateStore
|
|
5
|
+
# Thread-safe in-process state store backed by a plain Ruby Hash.
|
|
6
|
+
#
|
|
7
|
+
# Used as the recommended default for single-process applications and tests.
|
|
8
|
+
# State does not survive process restart.
|
|
9
|
+
#
|
|
10
|
+
# @example
|
|
11
|
+
# store = Phronomy::StateStore::InMemory.new
|
|
12
|
+
# store.save("t1", { fields: { count: 1 }, phase: "__end__" })
|
|
13
|
+
# store.load("t1") # => { fields: { count: 1 }, phase: "__end__" }
|
|
14
|
+
# store.delete("t1")
|
|
15
|
+
# store.load("t1") # => nil
|
|
16
|
+
class InMemory < Base
|
|
17
|
+
def initialize
|
|
18
|
+
@data = {}
|
|
19
|
+
@mutex = Mutex.new
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# @param thread_id [String]
|
|
23
|
+
# @return [Hash, nil]
|
|
24
|
+
# @api public
|
|
25
|
+
def load(thread_id)
|
|
26
|
+
@mutex.synchronize do
|
|
27
|
+
snap = @data[thread_id]
|
|
28
|
+
snap ? deep_dup(snap) : nil
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @param thread_id [String]
|
|
33
|
+
# @param snapshot [Hash]
|
|
34
|
+
# @return [void]
|
|
35
|
+
# @api public
|
|
36
|
+
def save(thread_id, snapshot)
|
|
37
|
+
@mutex.synchronize { @data[thread_id] = deep_dup(snapshot) }
|
|
38
|
+
nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# @param thread_id [String]
|
|
42
|
+
# @return [void]
|
|
43
|
+
# @api public
|
|
44
|
+
def delete(thread_id)
|
|
45
|
+
@mutex.synchronize { @data.delete(thread_id) }
|
|
46
|
+
nil
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
# Recursively deep-duplicates a plain-data value (Hash, Array, or scalar).
|
|
52
|
+
# Sufficient for snapshot data which consists of JSON-compatible types.
|
|
53
|
+
def deep_dup(val)
|
|
54
|
+
case val
|
|
55
|
+
when Hash then val.each_with_object({}) { |(k, v), h| h[k] = deep_dup(v) }
|
|
56
|
+
when Array then val.map { |v| deep_dup(v) }
|
|
57
|
+
else val.frozen? ? val : (val.dup rescue val) # rubocop:disable Style/RescueModifier
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
|
@@ -35,6 +35,7 @@ module Phronomy
|
|
|
35
35
|
# @param description [String, nil] description exposed to the LLM;
|
|
36
36
|
# defaults to "Delegates to <AgentClassName>"
|
|
37
37
|
# @return [Class] an anonymous Phronomy::Tool::AgentTool subclass
|
|
38
|
+
# @api public
|
|
38
39
|
def from_agent(agent_class, tool_name: nil, description: nil)
|
|
39
40
|
raise ArgumentError, "agent_class must be a Class" unless agent_class.is_a?(Class)
|
|
40
41
|
|
data/lib/phronomy/tool/base.rb
CHANGED
|
@@ -33,44 +33,90 @@ module Phronomy
|
|
|
33
33
|
# When omitted, RubyLLM's default conversion applies (e.g. WeatherTool → "weather").
|
|
34
34
|
#
|
|
35
35
|
# @param value [String, nil] the exact function name the LLM will see
|
|
36
|
+
# @api public
|
|
36
37
|
def tool_name(value = nil)
|
|
37
38
|
return @tool_name if value.nil?
|
|
38
39
|
|
|
39
40
|
@tool_name = value.to_s
|
|
40
41
|
end
|
|
41
42
|
|
|
42
|
-
# Extends RubyLLM::Tool.param with
|
|
43
|
-
#
|
|
44
|
-
#
|
|
43
|
+
# Extends RubyLLM::Tool.param with optional +enum:+ and +properties:+ keywords.
|
|
44
|
+
# - +enum:+ restricts allowed values; injected into the JSON Schema.
|
|
45
|
+
# - +properties:+ declares nested fields for :object type params. Each
|
|
46
|
+
# entry is a Hash mapping field name (Symbol) to a spec Hash with keys:
|
|
47
|
+
# :type (Symbol, default :string), :required (Boolean, default false),
|
|
48
|
+
# and optionally :properties (for further nesting).
|
|
45
49
|
#
|
|
46
50
|
# @param name [Symbol] parameter name
|
|
47
|
-
# @param enum [Array, nil] allowed values
|
|
51
|
+
# @param enum [Array, nil] allowed values
|
|
52
|
+
# @param properties [Hash, nil] nested schema for :object params
|
|
48
53
|
# @param options [Hash] forwarded to RubyLLM::Tool.param
|
|
49
|
-
|
|
54
|
+
# @api public
|
|
55
|
+
def param(name, enum: nil, properties: nil, **options)
|
|
50
56
|
super(name, **options)
|
|
51
57
|
param_enums[name] = enum if enum
|
|
58
|
+
param_schemas[name] = normalize_nested_schema(properties) if properties
|
|
52
59
|
end
|
|
53
60
|
|
|
54
61
|
# Returns the enum constraints registered via .param.
|
|
55
62
|
# @return [Hash{Symbol => Array}]
|
|
63
|
+
# @api public
|
|
56
64
|
def param_enums
|
|
57
65
|
@param_enums ||= {}
|
|
58
66
|
end
|
|
59
67
|
|
|
68
|
+
# Returns nested schema definitions registered via .param(properties: ...).
|
|
69
|
+
# @return [Hash{Symbol => Hash}]
|
|
70
|
+
# @api public
|
|
71
|
+
def param_schemas
|
|
72
|
+
@param_schemas ||= {}
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
private
|
|
76
|
+
|
|
77
|
+
# Recursively normalises a properties hash so all keys are Symbols and
|
|
78
|
+
# each spec has a :type key.
|
|
79
|
+
def normalize_nested_schema(props)
|
|
80
|
+
props.transform_keys(&:to_sym).transform_values do |spec|
|
|
81
|
+
s = spec.transform_keys(&:to_sym)
|
|
82
|
+
s[:type] ||= :string
|
|
83
|
+
s[:properties] = normalize_nested_schema(s[:properties]) if s[:properties]
|
|
84
|
+
s
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
public
|
|
89
|
+
|
|
60
90
|
# Sets the access scope for this tool (metadata; enforcement is the responsibility of
|
|
61
91
|
# the Workflow/Guardrail layer).
|
|
62
92
|
# @param value [Symbol] e.g. :read_only, :write, :admin
|
|
93
|
+
# @api public
|
|
63
94
|
def scope(value = nil)
|
|
64
95
|
return @scope if value.nil?
|
|
65
96
|
|
|
66
97
|
@scope = value
|
|
67
98
|
end
|
|
68
99
|
|
|
69
|
-
# Configures error-handling behavior.
|
|
70
|
-
#
|
|
100
|
+
# Configures error-handling behavior when +execute+ raises an unexpected error.
|
|
101
|
+
#
|
|
102
|
+
# @param behavior [Symbol]
|
|
103
|
+
# :raise (default) — re-raise as Phronomy::ToolError, stopping the agent.
|
|
104
|
+
# :suppress — suppress the error and return a descriptive string so
|
|
105
|
+
# the LLM can recover on the next turn.
|
|
106
|
+
# :return_empty — *deprecated* alias for +:suppress+; will be removed in a
|
|
107
|
+
# future major release.
|
|
108
|
+
# @api public
|
|
71
109
|
def on_error(behavior = nil)
|
|
72
110
|
return @on_error || :raise if behavior.nil?
|
|
73
111
|
|
|
112
|
+
if behavior == :return_empty
|
|
113
|
+
msg = "[Phronomy] on_error :return_empty is deprecated; use :suppress instead"
|
|
114
|
+
if Phronomy.configuration.logger
|
|
115
|
+
Phronomy.configuration.logger.warn(msg)
|
|
116
|
+
else
|
|
117
|
+
warn msg
|
|
118
|
+
end
|
|
119
|
+
end
|
|
74
120
|
@on_error = behavior
|
|
75
121
|
end
|
|
76
122
|
|
|
@@ -83,6 +129,7 @@ module Phronomy
|
|
|
83
129
|
# :raise — raise Phronomy::ToolError, stopping the agent loop.
|
|
84
130
|
# :coerce — attempt type coercion (e.g. "42" → 42 for :integer);
|
|
85
131
|
# falls back to :return_error when coercion is not possible.
|
|
132
|
+
# @api public
|
|
86
133
|
def on_schema_error(behavior = nil)
|
|
87
134
|
return @on_schema_error || :return_error if behavior.nil?
|
|
88
135
|
|
|
@@ -91,6 +138,7 @@ module Phronomy
|
|
|
91
138
|
|
|
92
139
|
# Configures whether human approval is required before executing this tool.
|
|
93
140
|
# @param value [Boolean]
|
|
141
|
+
# @api public
|
|
94
142
|
def requires_approval(value = nil)
|
|
95
143
|
return @requires_approval || false if value.nil?
|
|
96
144
|
|
|
@@ -113,6 +161,7 @@ module Phronomy
|
|
|
113
161
|
# @example
|
|
114
162
|
# retry_on Phronomy::ToolError, times: 3, wait: :exponential, base: 1.0
|
|
115
163
|
# retry_on Net::ReadTimeout, times: 2, wait: 0.5
|
|
164
|
+
# @api public
|
|
116
165
|
def retry_on(*exception_classes, times: 1, wait: 0, base: 1.0)
|
|
117
166
|
@retry_policies ||= []
|
|
118
167
|
@retry_policies << {exceptions: exception_classes, times: times, wait: wait, base: base}
|
|
@@ -120,6 +169,7 @@ module Phronomy
|
|
|
120
169
|
|
|
121
170
|
# Returns all retry policies registered on this tool class.
|
|
122
171
|
# @return [Array<Hash>]
|
|
172
|
+
# @api public
|
|
123
173
|
def retry_policies
|
|
124
174
|
@retry_policies || []
|
|
125
175
|
end
|
|
@@ -127,6 +177,7 @@ module Phronomy
|
|
|
127
177
|
# Injectable sleep callable for testing.
|
|
128
178
|
# Defaults to Kernel#sleep.
|
|
129
179
|
# @return [#call]
|
|
180
|
+
# @api private
|
|
130
181
|
def _sleep_proc
|
|
131
182
|
@_sleep_proc || method(:sleep)
|
|
132
183
|
end
|
|
@@ -147,24 +198,37 @@ module Phronomy
|
|
|
147
198
|
# Injects "enum" entries for any param declared with enum: [...].
|
|
148
199
|
def params_schema
|
|
149
200
|
schema = super
|
|
150
|
-
return schema if schema.nil?
|
|
201
|
+
return schema if schema.nil?
|
|
151
202
|
|
|
152
|
-
enums = self.class.param_enums
|
|
153
203
|
properties = schema.dig("properties") || schema.dig(:properties)
|
|
154
204
|
return schema unless properties
|
|
155
205
|
|
|
156
|
-
|
|
206
|
+
# Inject enum values for params declared with enum: [...].
|
|
207
|
+
unless self.class.param_enums.empty?
|
|
208
|
+
enums = self.class.param_enums
|
|
209
|
+
enums.each do |param_name, values|
|
|
210
|
+
key = properties.key?(param_name.to_s) ? param_name.to_s : param_name.to_sym
|
|
211
|
+
next unless properties[key]
|
|
212
|
+
|
|
213
|
+
param_type = properties[key]["type"]
|
|
214
|
+
properties[key]["enum"] = values.map do |v|
|
|
215
|
+
case param_type
|
|
216
|
+
when "integer" then v.is_a?(Integer) ? v : Integer(v.to_s)
|
|
217
|
+
when "number" then v.is_a?(Numeric) ? v : Float(v.to_s)
|
|
218
|
+
else v.to_s
|
|
219
|
+
end
|
|
220
|
+
end
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# Inject nested properties for :object params (issue #162).
|
|
225
|
+
# Without this the LLM sees only { "type": "object" } with no field
|
|
226
|
+
# definitions, making it unable to populate nested object params.
|
|
227
|
+
self.class.param_schemas.each do |param_name, nested|
|
|
157
228
|
key = properties.key?(param_name.to_s) ? param_name.to_s : param_name.to_sym
|
|
158
229
|
next unless properties[key]
|
|
159
230
|
|
|
160
|
-
|
|
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
|
|
231
|
+
properties[key]["properties"] = nested_schema_to_json_schema(nested)
|
|
168
232
|
end
|
|
169
233
|
|
|
170
234
|
schema
|
|
@@ -174,10 +238,18 @@ module Phronomy
|
|
|
174
238
|
# the on_error policy, and wrap errors as ToolError.
|
|
175
239
|
#
|
|
176
240
|
# Execution order:
|
|
177
|
-
# 1.
|
|
178
|
-
# 2.
|
|
179
|
-
# 3.
|
|
180
|
-
|
|
241
|
+
# 1. Early cancellation check (kwarg token takes precedence over thread-local).
|
|
242
|
+
# 2. Schema validation (type + enum checks).
|
|
243
|
+
# 3. Inject +cancellation_token:+ into args when +execute+ opts in.
|
|
244
|
+
# 4. Call super(validated_args) inside a retry loop.
|
|
245
|
+
# 5. On persistent failure, apply on_error policy.
|
|
246
|
+
#
|
|
247
|
+
# @param args [Hash]
|
|
248
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil] optional; takes precedence over the thread-local token
|
|
249
|
+
# @api public
|
|
250
|
+
def call(args, cancellation_token: nil)
|
|
251
|
+
ct = cancellation_token || Thread.current[:phronomy_cancellation_token]
|
|
252
|
+
ct&.raise_if_cancelled!
|
|
181
253
|
validated_args, schema_error = validate_and_coerce(args)
|
|
182
254
|
if schema_error
|
|
183
255
|
case self.class.on_schema_error
|
|
@@ -188,13 +260,22 @@ module Phronomy
|
|
|
188
260
|
return "Schema validation failed: #{schema_error}"
|
|
189
261
|
end
|
|
190
262
|
end
|
|
263
|
+
validated_args = validated_args.merge(cancellation_token: ct) if ct && execute_accepts_cancellation_token?
|
|
191
264
|
with_tool_retry { super(validated_args) }
|
|
192
265
|
rescue Phronomy::ToolError
|
|
193
266
|
raise
|
|
267
|
+
rescue Phronomy::CancellationError
|
|
268
|
+
raise
|
|
194
269
|
rescue => e
|
|
195
270
|
case self.class.on_error
|
|
196
|
-
when :return_empty
|
|
197
|
-
[]
|
|
271
|
+
when :return_empty, :suppress
|
|
272
|
+
msg = "[Phronomy] Tool #{self.class.name} suppressed error: #{e.class}: #{e.message}"
|
|
273
|
+
if Phronomy.configuration.logger
|
|
274
|
+
Phronomy.configuration.logger.warn(msg)
|
|
275
|
+
else
|
|
276
|
+
warn msg
|
|
277
|
+
end
|
|
278
|
+
"Tool error suppressed: #{e.message}"
|
|
198
279
|
else
|
|
199
280
|
raise Phronomy::ToolError, "#{self.class.name} execution failed: #{e.message}"
|
|
200
281
|
end
|
|
@@ -226,12 +307,21 @@ module Phronomy
|
|
|
226
307
|
# WeatherService.fetch(location).to_s
|
|
227
308
|
# end
|
|
228
309
|
# end
|
|
310
|
+
# @api public
|
|
229
311
|
def execute(**_args)
|
|
230
312
|
raise NotImplementedError, "#{self.class}#execute is not implemented"
|
|
231
313
|
end
|
|
232
314
|
|
|
233
315
|
private
|
|
234
316
|
|
|
317
|
+
# Returns true when the #execute method declares a +cancellation_token:+
|
|
318
|
+
# keyword parameter, indicating it opts into cooperative cancellation.
|
|
319
|
+
def execute_accepts_cancellation_token?
|
|
320
|
+
method(:execute).parameters.any? do |type, name|
|
|
321
|
+
name == :cancellation_token && %i[key keyreq].include?(type)
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
235
325
|
# Executes the given block inside a retry loop driven by the class-level
|
|
236
326
|
# retry_policies. Each policy matches by exception class; the first matching
|
|
237
327
|
# policy governs the wait and retry count. Raises immediately when no policy
|
|
@@ -261,6 +351,7 @@ module Phronomy
|
|
|
261
351
|
# @param base [Float] base wait time in seconds
|
|
262
352
|
# @param attempt [Integer] zero-based attempt index
|
|
263
353
|
# @return [Float]
|
|
354
|
+
# @api public
|
|
264
355
|
def compute_retry_wait(strategy, base, attempt)
|
|
265
356
|
case strategy
|
|
266
357
|
when :exponential
|
|
@@ -279,6 +370,7 @@ module Phronomy
|
|
|
279
370
|
#
|
|
280
371
|
# @param args [Hash] raw args passed to #call (string or symbol keys)
|
|
281
372
|
# @return [Array(Hash, String|nil)] [possibly_coerced_args, error_message_or_nil]
|
|
373
|
+
# @api public
|
|
282
374
|
def validate_and_coerce(args)
|
|
283
375
|
return [args, nil] if self.class.parameters.empty?
|
|
284
376
|
|
|
@@ -304,6 +396,15 @@ module Phronomy
|
|
|
304
396
|
return [nil, error] if error
|
|
305
397
|
end
|
|
306
398
|
|
|
399
|
+
# Recursively validate nested object properties when declared.
|
|
400
|
+
if param.type.to_sym == :object
|
|
401
|
+
nested_schema = self.class.param_schemas[name]
|
|
402
|
+
if nested_schema
|
|
403
|
+
error = validate_nested_object(value, nested_schema, name.to_s)
|
|
404
|
+
return [nil, error] if error
|
|
405
|
+
end
|
|
406
|
+
end
|
|
407
|
+
|
|
307
408
|
enum_vals = self.class.param_enums[name]
|
|
308
409
|
if enum_vals && !enum_vals.map(&:to_s).include?(value.to_s)
|
|
309
410
|
return [nil, "parameter '#{name}' must be one of: #{enum_vals.join(", ")} (got: #{value.inspect})"]
|
|
@@ -312,9 +413,69 @@ module Phronomy
|
|
|
312
413
|
result[name] = value
|
|
313
414
|
end
|
|
314
415
|
|
|
315
|
-
#
|
|
316
|
-
|
|
317
|
-
|
|
416
|
+
# Reject any keys not covered by declared parameters to prevent silent
|
|
417
|
+
# parameter injection (e.g. via prompt injection).
|
|
418
|
+
extra = normalized.keys - self.class.parameters.keys
|
|
419
|
+
unless extra.empty?
|
|
420
|
+
return [nil, "unknown parameter(s): #{extra.inspect}"]
|
|
421
|
+
end
|
|
422
|
+
|
|
423
|
+
[result, nil]
|
|
424
|
+
end
|
|
425
|
+
|
|
426
|
+
# Converts the internal normalized nested schema (from param_schemas) to
|
|
427
|
+
# a JSON Schema +properties+ hash suitable for inclusion in the LLM tool
|
|
428
|
+
# definition (issue #162).
|
|
429
|
+
#
|
|
430
|
+
# @param nested [Hash{Symbol=>Hash}] normalized schema from param_schemas
|
|
431
|
+
# @return [Hash{String=>Hash}] JSON Schema properties
|
|
432
|
+
# @api public
|
|
433
|
+
def nested_schema_to_json_schema(nested)
|
|
434
|
+
nested.each_with_object({}) do |(prop_name, spec), acc|
|
|
435
|
+
entry = {"type" => spec[:type].to_s}
|
|
436
|
+
entry["description"] = spec[:desc] if spec[:desc]
|
|
437
|
+
entry["enum"] = spec[:enum] if spec[:enum]
|
|
438
|
+
entry["properties"] = nested_schema_to_json_schema(spec[:properties]) if spec[:properties]
|
|
439
|
+
acc[prop_name.to_s] = entry
|
|
440
|
+
end
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Recursively validates +value+ (a Hash) against a +properties+ schema.
|
|
444
|
+
# Returns an error message string or nil.
|
|
445
|
+
#
|
|
446
|
+
# @param value [Hash] the object value to validate
|
|
447
|
+
# @param properties [Hash{Symbol=>Hash}] nested schema from param_schemas
|
|
448
|
+
# @param path [String] dot-separated field path for error messages
|
|
449
|
+
# @api public
|
|
450
|
+
def validate_nested_object(value, properties, path)
|
|
451
|
+
return "field '#{path}' must be an object (Hash)" unless value.is_a?(Hash)
|
|
452
|
+
|
|
453
|
+
normalized = value.transform_keys(&:to_sym)
|
|
454
|
+
|
|
455
|
+
# Reject extra keys not declared in the schema (issue #166).
|
|
456
|
+
extra = normalized.keys - properties.keys
|
|
457
|
+
unless extra.empty?
|
|
458
|
+
return "nested field '#{path}' contains undeclared key(s): #{extra.inspect}"
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
properties.each do |fname, spec|
|
|
462
|
+
field_path = "#{path}.#{fname}"
|
|
463
|
+
field_value = normalized[fname]
|
|
464
|
+
|
|
465
|
+
if field_value.nil?
|
|
466
|
+
return "nested required field '#{field_path}' is missing" if spec[:required]
|
|
467
|
+
next
|
|
468
|
+
end
|
|
469
|
+
|
|
470
|
+
error = type_error(field_value, spec[:type])
|
|
471
|
+
return "nested field '#{field_path}': #{error}" if error
|
|
472
|
+
|
|
473
|
+
next unless spec[:type].to_sym == :object && spec[:properties]
|
|
474
|
+
|
|
475
|
+
error = validate_nested_object(field_value, spec[:properties], field_path)
|
|
476
|
+
return error if error
|
|
477
|
+
end
|
|
478
|
+
nil
|
|
318
479
|
end
|
|
319
480
|
|
|
320
481
|
# Returns a type-error message string if +value+ does not match +declared_type+,
|
|
@@ -322,6 +483,7 @@ module Phronomy
|
|
|
322
483
|
#
|
|
323
484
|
# @param value [Object]
|
|
324
485
|
# @param declared_type [Symbol, String] e.g. :string, :integer, :number, :boolean, :array, :object
|
|
486
|
+
# @api public
|
|
325
487
|
def type_error(value, declared_type)
|
|
326
488
|
return nil if value.nil?
|
|
327
489
|
|
|
@@ -5,6 +5,7 @@ require "net/http"
|
|
|
5
5
|
require "open3"
|
|
6
6
|
require "securerandom"
|
|
7
7
|
require "shellwords"
|
|
8
|
+
require "timeout"
|
|
8
9
|
require "uri"
|
|
9
10
|
|
|
10
11
|
module Phronomy
|
|
@@ -36,6 +37,7 @@ module Phronomy
|
|
|
36
37
|
# - "http://<url>" / "https://<url>" — connect to an HTTP/SSE server
|
|
37
38
|
# @param tool_name [String] the tool name as registered in the MCP server
|
|
38
39
|
# @return [McpTool] a configured subclass instance ready for use with an Agent
|
|
40
|
+
# @api public
|
|
39
41
|
def from_server(server_uri, tool_name:)
|
|
40
42
|
# Use a short-lived transport only to query the tool definition,
|
|
41
43
|
# then close it. Each McpTool instance creates its own transport
|
|
@@ -71,7 +73,10 @@ module Phronomy
|
|
|
71
73
|
# Register description and params from the MCP tool definition.
|
|
72
74
|
klass.description(tool_def[:description] || tool_name)
|
|
73
75
|
(tool_def[:parameters] || []).each do |p|
|
|
74
|
-
|
|
76
|
+
opts = {type: p[:type]&.to_sym || :string, desc: p[:description].to_s}
|
|
77
|
+
opts[:required] = p[:required] if p.key?(:required)
|
|
78
|
+
opts[:enum] = p[:enum] if p.key?(:enum)
|
|
79
|
+
klass.param(p[:name].to_sym, **opts)
|
|
75
80
|
end
|
|
76
81
|
|
|
77
82
|
# Each instance creates its own transport so concurrent agent threads
|
|
@@ -107,10 +112,27 @@ module Phronomy
|
|
|
107
112
|
# so that session state (registered resources, tool context, etc.) is preserved
|
|
108
113
|
# across multiple calls.
|
|
109
114
|
class StdioTransport
|
|
110
|
-
|
|
115
|
+
# @param command [String] shell command to spawn the MCP server process
|
|
116
|
+
# @param read_timeout [Integer] seconds to wait for the server's JSON-RPC response
|
|
117
|
+
# before raising {Phronomy::ToolError}. Mirrors the +read_timeout+ option on
|
|
118
|
+
# {HttpTransport}. Defaults to 30 seconds.
|
|
119
|
+
# @param env [Hash, nil] environment variable overrides for the subprocess.
|
|
120
|
+
# When provided, only these variables are added/overridden; the parent environment
|
|
121
|
+
# is still inherited unless explicitly cleared via an empty string value.
|
|
122
|
+
# @param cwd [String, nil] working directory for the subprocess.
|
|
123
|
+
# Defaults to the current process's working directory.
|
|
124
|
+
# @param startup_timeout [Numeric, nil] seconds to wait for the server to
|
|
125
|
+
# emit its first line on stdout before raising {Phronomy::ToolError}.
|
|
126
|
+
# When nil (default), no startup check is performed.
|
|
127
|
+
# @api public
|
|
128
|
+
def initialize(command, read_timeout: 30, env: nil, cwd: nil, startup_timeout: nil)
|
|
111
129
|
# Split the command string into an argv array so that Open3 executes
|
|
112
130
|
# it directly without going through the shell, preventing injection.
|
|
113
131
|
@command = Shellwords.split(command)
|
|
132
|
+
@read_timeout = read_timeout
|
|
133
|
+
@env = env
|
|
134
|
+
@cwd = cwd
|
|
135
|
+
@startup_timeout = startup_timeout
|
|
114
136
|
@stdin = nil
|
|
115
137
|
@stdout = nil
|
|
116
138
|
@stderr = nil
|
|
@@ -137,15 +159,17 @@ module Phronomy
|
|
|
137
159
|
# Retrieve the tool definition from the server using the MCP `tools/list` method.
|
|
138
160
|
# @param tool_name [String]
|
|
139
161
|
# @return [Hash] { description:, parameters: }
|
|
162
|
+
# @api public
|
|
140
163
|
def fetch_tool(tool_name)
|
|
141
164
|
response = rpc_call("tools/list", {})
|
|
142
165
|
tools = response.dig("result", "tools") || []
|
|
143
166
|
defn = tools.find { |t| t["name"] == tool_name }
|
|
144
167
|
raise ArgumentError, "Tool #{tool_name.inspect} not found on MCP server #{@command.inspect}" unless defn
|
|
145
168
|
|
|
169
|
+
required_names = defn.dig("inputSchema", "required") || []
|
|
146
170
|
{
|
|
147
171
|
description: defn["description"],
|
|
148
|
-
parameters: parse_schema_params(defn.dig("inputSchema", "properties") || {})
|
|
172
|
+
parameters: parse_schema_params(defn.dig("inputSchema", "properties") || {}, required_names: required_names)
|
|
149
173
|
}
|
|
150
174
|
end
|
|
151
175
|
|
|
@@ -153,6 +177,7 @@ module Phronomy
|
|
|
153
177
|
# @param tool_name [String]
|
|
154
178
|
# @param args [Hash]
|
|
155
179
|
# @return [Object] the tool result
|
|
180
|
+
# @api public
|
|
156
181
|
def call_tool(tool_name, args)
|
|
157
182
|
response = rpc_call("tools/call", {name: tool_name, arguments: args})
|
|
158
183
|
if response["error"]
|
|
@@ -176,7 +201,11 @@ module Phronomy
|
|
|
176
201
|
def ensure_started!
|
|
177
202
|
return if @stdin && !@stdin.closed?
|
|
178
203
|
|
|
179
|
-
|
|
204
|
+
popen3_opts = {}
|
|
205
|
+
popen3_opts[:chdir] = @cwd if @cwd
|
|
206
|
+
|
|
207
|
+
argv = @env ? [@env, *@command] : @command
|
|
208
|
+
@stdin, @stdout, @stderr, @wait_thr = Open3.popen3(*argv, **popen3_opts)
|
|
180
209
|
# Drain stderr asynchronously to prevent the pipe buffer from filling
|
|
181
210
|
# and deadlocking the child process. Errors inside the drain thread are
|
|
182
211
|
# silently ignored since stderr content is diagnostics-only.
|
|
@@ -185,24 +214,38 @@ module Phronomy
|
|
|
185
214
|
rescue
|
|
186
215
|
nil
|
|
187
216
|
end
|
|
217
|
+
|
|
218
|
+
if @startup_timeout
|
|
219
|
+
Timeout.timeout(@startup_timeout) { @stdout.gets.tap { |line| @stdout.ungetbyte(line) if line } }
|
|
220
|
+
end
|
|
221
|
+
rescue Timeout::Error
|
|
222
|
+
close
|
|
223
|
+
raise Phronomy::ToolError,
|
|
224
|
+
"MCP stdio server did not start within #{@startup_timeout} seconds"
|
|
188
225
|
end
|
|
189
226
|
|
|
190
227
|
def rpc_call(method, params)
|
|
191
228
|
ensure_started!
|
|
192
229
|
payload = JSON.generate(jsonrpc: "2.0", id: SecureRandom.uuid, method: method, params: params)
|
|
193
230
|
@stdin.puts(payload)
|
|
194
|
-
raw = @stdout.gets
|
|
231
|
+
raw = Timeout.timeout(@read_timeout) { @stdout.gets }
|
|
195
232
|
raise Phronomy::ToolError, "MCP server closed the connection unexpectedly" if raw.nil?
|
|
196
233
|
JSON.parse(raw)
|
|
234
|
+
rescue Timeout::Error
|
|
235
|
+
raise Phronomy::ToolError,
|
|
236
|
+
"MCP stdio server did not respond within #{@read_timeout} seconds"
|
|
197
237
|
end
|
|
198
238
|
|
|
199
|
-
def parse_schema_params(properties)
|
|
239
|
+
def parse_schema_params(properties, required_names: [])
|
|
200
240
|
properties.map do |name, schema|
|
|
201
|
-
{
|
|
241
|
+
param = {
|
|
202
242
|
name: name.to_s,
|
|
203
243
|
type: schema["type"] || "string",
|
|
204
|
-
description: schema["description"].to_s
|
|
244
|
+
description: schema["description"].to_s,
|
|
245
|
+
required: required_names.include?(name.to_s)
|
|
205
246
|
}
|
|
247
|
+
param[:enum] = schema["enum"] if schema["enum"]
|
|
248
|
+
param
|
|
206
249
|
end
|
|
207
250
|
end
|
|
208
251
|
end
|
|
@@ -223,10 +266,15 @@ module Phronomy
|
|
|
223
266
|
# @param base_url [String] full URL of the MCP endpoint, e.g. "http://localhost:8080/mcp"
|
|
224
267
|
# @param open_timeout [Integer] TCP connection timeout in seconds (default: 5)
|
|
225
268
|
# @param read_timeout [Integer] HTTP read timeout in seconds (default: 30)
|
|
226
|
-
|
|
269
|
+
# @param headers [Hash] additional HTTP request headers (e.g. Authorization).
|
|
270
|
+
# Merged on top of the default Content-Type and Accept headers; caller-supplied
|
|
271
|
+
# values override defaults when keys collide.
|
|
272
|
+
# @api public
|
|
273
|
+
def initialize(base_url, open_timeout: 5, read_timeout: 30, headers: {})
|
|
227
274
|
@uri = URI.parse(base_url)
|
|
228
275
|
@open_timeout = open_timeout
|
|
229
276
|
@read_timeout = read_timeout
|
|
277
|
+
@extra_headers = headers
|
|
230
278
|
end
|
|
231
279
|
|
|
232
280
|
# HTTP connections are stateless; close is a no-op, defined so that
|
|
@@ -237,15 +285,17 @@ module Phronomy
|
|
|
237
285
|
# Retrieve the tool definition from the server using MCP `tools/list`.
|
|
238
286
|
# @param tool_name [String]
|
|
239
287
|
# @return [Hash] { description:, parameters: }
|
|
288
|
+
# @api public
|
|
240
289
|
def fetch_tool(tool_name)
|
|
241
290
|
response = rpc_call("tools/list", {})
|
|
242
291
|
tools = response.dig("result", "tools") || []
|
|
243
292
|
defn = tools.find { |t| t["name"] == tool_name }
|
|
244
293
|
raise ArgumentError, "Tool #{tool_name.inspect} not found on MCP server #{@uri}" unless defn
|
|
245
294
|
|
|
295
|
+
required_names = defn.dig("inputSchema", "required") || []
|
|
246
296
|
{
|
|
247
297
|
description: defn["description"],
|
|
248
|
-
parameters: parse_schema_params(defn.dig("inputSchema", "properties") || {})
|
|
298
|
+
parameters: parse_schema_params(defn.dig("inputSchema", "properties") || {}, required_names: required_names)
|
|
249
299
|
}
|
|
250
300
|
end
|
|
251
301
|
|
|
@@ -253,6 +303,7 @@ module Phronomy
|
|
|
253
303
|
# @param tool_name [String]
|
|
254
304
|
# @param args [Hash]
|
|
255
305
|
# @return [Object] the tool result
|
|
306
|
+
# @api public
|
|
256
307
|
def call_tool(tool_name, args)
|
|
257
308
|
response = rpc_call("tools/call", {name: tool_name, arguments: args})
|
|
258
309
|
if response["error"]
|
|
@@ -285,6 +336,7 @@ module Phronomy
|
|
|
285
336
|
request = Net::HTTP::Post.new(path)
|
|
286
337
|
request["Content-Type"] = "application/json"
|
|
287
338
|
request["Accept"] = "application/json, text/event-stream"
|
|
339
|
+
@extra_headers.each { |k, v| request[k.to_s] = v.to_s }
|
|
288
340
|
request.body = payload
|
|
289
341
|
|
|
290
342
|
http_response = http.request(request)
|
|
@@ -323,13 +375,16 @@ module Phronomy
|
|
|
323
375
|
result || raise(Phronomy::ToolError, "No valid JSON-RPC response found in SSE stream")
|
|
324
376
|
end
|
|
325
377
|
|
|
326
|
-
def parse_schema_params(properties)
|
|
378
|
+
def parse_schema_params(properties, required_names: [])
|
|
327
379
|
properties.map do |name, schema|
|
|
328
|
-
{
|
|
380
|
+
param = {
|
|
329
381
|
name: name.to_s,
|
|
330
382
|
type: schema["type"] || "string",
|
|
331
|
-
description: schema["description"].to_s
|
|
383
|
+
description: schema["description"].to_s,
|
|
384
|
+
required: required_names.include?(name.to_s)
|
|
332
385
|
}
|
|
386
|
+
param[:enum] = schema["enum"] if schema["enum"]
|
|
387
|
+
param
|
|
333
388
|
end
|
|
334
389
|
end
|
|
335
390
|
end
|
|
@@ -23,6 +23,7 @@ module Phronomy
|
|
|
23
23
|
# @param meta [Hash] additional metadata attached to the span
|
|
24
24
|
# @yield [span] the active span object
|
|
25
25
|
# @return [Object] the block's return value
|
|
26
|
+
# @api public
|
|
26
27
|
def trace(name, input: nil, **meta)
|
|
27
28
|
span = start_span(name, input: input, **meta)
|
|
28
29
|
result, usage = yield span
|
|
@@ -38,6 +39,7 @@ module Phronomy
|
|
|
38
39
|
# @param name [String]
|
|
39
40
|
# @param attributes [Hash]
|
|
40
41
|
# @return [Object] an opaque span handle
|
|
42
|
+
# @api public
|
|
41
43
|
def start_span(name, **attributes)
|
|
42
44
|
raise NotImplementedError, "#{self.class}#start_span is not implemented"
|
|
43
45
|
end
|
|
@@ -48,6 +50,7 @@ module Phronomy
|
|
|
48
50
|
# @param output [Object, nil] successful output value
|
|
49
51
|
# @param usage [Phronomy::TokenUsage, nil] token usage for this span
|
|
50
52
|
# @param error [Exception, nil] exception if the block raised
|
|
53
|
+
# @api public
|
|
51
54
|
def finish_span(span, output: nil, usage: nil, error: nil)
|
|
52
55
|
raise NotImplementedError, "#{self.class}#finish_span is not implemented"
|
|
53
56
|
end
|
|
@@ -31,6 +31,7 @@ module Phronomy
|
|
|
31
31
|
# @param public_key [String] Langfuse project public key
|
|
32
32
|
# @param secret_key [String] Langfuse project secret key
|
|
33
33
|
# @param host [String] Langfuse host URL (override for self-hosted instances)
|
|
34
|
+
# @api public
|
|
34
35
|
def initialize(public_key:, secret_key:, host: DEFAULT_HOST)
|
|
35
36
|
@public_key = public_key
|
|
36
37
|
@secret_key = secret_key
|
|
@@ -41,6 +42,7 @@ module Phronomy
|
|
|
41
42
|
# Returns a plain Hash that records the span start state.
|
|
42
43
|
#
|
|
43
44
|
# @return [Hash] an opaque span handle used by {#finish_span}
|
|
45
|
+
# @api public
|
|
44
46
|
def start_span(name, input: nil, **meta)
|
|
45
47
|
{
|
|
46
48
|
id: SecureRandom.uuid,
|