phronomy 0.5.4 → 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.
Files changed (111) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +21 -0
  3. data/CHANGELOG.md +379 -0
  4. data/CONTRIBUTING.md +102 -0
  5. data/README.md +262 -48
  6. data/RELEASE_CHECKLIST.md +86 -0
  7. data/SECURITY.md +80 -0
  8. data/benchmark/baseline.json +9 -0
  9. data/benchmark/bench_agent_invoke.rb +105 -0
  10. data/benchmark/bench_context_assembler.rb +46 -0
  11. data/benchmark/bench_regression.rb +171 -0
  12. data/benchmark/bench_token_estimator.rb +44 -0
  13. data/benchmark/bench_tool_schema.rb +69 -0
  14. data/benchmark/bench_vector_store.rb +39 -0
  15. data/benchmark/bench_workflow.rb +55 -0
  16. data/benchmark/run_all.rb +118 -0
  17. data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
  18. data/docs/decisions/002-workflow-context-immutability.md +42 -0
  19. data/docs/decisions/003-event-loop-singleton.md +48 -0
  20. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +51 -0
  21. data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
  22. data/docs/decisions/006-no-built-in-guardrails.md +48 -0
  23. data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
  24. data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
  25. data/docs/decisions/009-state-store-abstraction.md +141 -0
  26. data/lib/phronomy/agent/base.rb +281 -13
  27. data/lib/phronomy/agent/before_completion_context.rb +1 -0
  28. data/lib/phronomy/agent/checkpoint.rb +1 -0
  29. data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
  30. data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
  31. data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
  32. data/lib/phronomy/agent/concerns/retryable.rb +12 -1
  33. data/lib/phronomy/agent/concerns/suspendable.rb +4 -0
  34. data/lib/phronomy/agent/fsm.rb +180 -0
  35. data/lib/phronomy/agent/handoff.rb +3 -0
  36. data/lib/phronomy/agent/orchestrator.rb +123 -11
  37. data/lib/phronomy/agent/parallel_tool_chat.rb +92 -0
  38. data/lib/phronomy/agent/react_agent.rb +8 -6
  39. data/lib/phronomy/agent/runner.rb +2 -0
  40. data/lib/phronomy/agent/shared_state.rb +11 -0
  41. data/lib/phronomy/agent/suspend_signal.rb +2 -0
  42. data/lib/phronomy/agent/team_coordinator.rb +17 -5
  43. data/lib/phronomy/cancellation_token.rb +92 -0
  44. data/lib/phronomy/configuration.rb +32 -2
  45. data/lib/phronomy/context/assembler.rb +6 -0
  46. data/lib/phronomy/context/compaction_context.rb +2 -0
  47. data/lib/phronomy/context/context_version_cache.rb +2 -0
  48. data/lib/phronomy/context/token_budget.rb +3 -0
  49. data/lib/phronomy/context/token_estimator.rb +9 -2
  50. data/lib/phronomy/context/trigger_context.rb +1 -0
  51. data/lib/phronomy/context/trim_context.rb +4 -0
  52. data/lib/phronomy/context.rb +0 -1
  53. data/lib/phronomy/embeddings/base.rb +5 -2
  54. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
  55. data/lib/phronomy/eval/comparison.rb +2 -0
  56. data/lib/phronomy/eval/dataset.rb +4 -0
  57. data/lib/phronomy/eval/metrics.rb +6 -0
  58. data/lib/phronomy/eval/runner.rb +2 -0
  59. data/lib/phronomy/eval/scorer/base.rb +1 -0
  60. data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
  61. data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
  62. data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
  63. data/lib/phronomy/event.rb +14 -0
  64. data/lib/phronomy/event_loop.rb +254 -0
  65. data/lib/phronomy/fsm_session.rb +201 -0
  66. data/lib/phronomy/generator_verifier.rb +24 -22
  67. data/lib/phronomy/guardrail/base.rb +3 -0
  68. data/lib/phronomy/guardrail.rb +0 -1
  69. data/lib/phronomy/knowledge_source/base.rb +6 -2
  70. data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
  71. data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
  72. data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
  73. data/lib/phronomy/loader/base.rb +1 -0
  74. data/lib/phronomy/loader/csv_loader.rb +2 -0
  75. data/lib/phronomy/loader/markdown_loader.rb +2 -0
  76. data/lib/phronomy/loader/plain_text_loader.rb +1 -0
  77. data/lib/phronomy/output_parser/base.rb +1 -0
  78. data/lib/phronomy/output_parser/json_parser.rb +22 -3
  79. data/lib/phronomy/output_parser/structured_parser.rb +2 -0
  80. data/lib/phronomy/prompt_template.rb +5 -0
  81. data/lib/phronomy/runnable.rb +20 -3
  82. data/lib/phronomy/splitter/base.rb +2 -0
  83. data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
  84. data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
  85. data/lib/phronomy/state_store/base.rb +48 -0
  86. data/lib/phronomy/state_store/in_memory.rb +62 -0
  87. data/lib/phronomy/tool/agent_tool.rb +1 -0
  88. data/lib/phronomy/tool/base.rb +189 -27
  89. data/lib/phronomy/tool/mcp_tool.rb +68 -13
  90. data/lib/phronomy/tracing/base.rb +3 -0
  91. data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
  92. data/lib/phronomy/tracing/open_telemetry_tracer.rb +2 -0
  93. data/lib/phronomy/vector_store/base.rb +33 -7
  94. data/lib/phronomy/vector_store/in_memory.rb +16 -7
  95. data/lib/phronomy/vector_store/pgvector.rb +40 -9
  96. data/lib/phronomy/vector_store/redis_search.rb +29 -8
  97. data/lib/phronomy/version.rb +1 -1
  98. data/lib/phronomy/workflow.rb +175 -74
  99. data/lib/phronomy/workflow_context.rb +55 -5
  100. data/lib/phronomy/workflow_runner.rb +197 -114
  101. data/lib/phronomy.rb +74 -1
  102. data/scripts/api_snapshot.rb +91 -0
  103. data/scripts/check_api_annotations.rb +68 -0
  104. data/scripts/check_private_enforcement.rb +93 -0
  105. data/scripts/check_readme_runnable.rb +98 -0
  106. data/scripts/run_mutation.sh +46 -0
  107. metadata +50 -6
  108. data/lib/phronomy/context/builder.rb +0 -92
  109. data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +0 -100
  110. data/lib/phronomy/guardrail/builtin/prompt_injection_detector.rb +0 -67
  111. data/lib/phronomy/guardrail/builtin.rb +0 -16
@@ -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 an optional +enum:+ keyword.
43
- # The enum values are stored separately and injected into the JSON Schema
44
- # produced by #params_schema.
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; when given, added as "enum" in JSON Schema
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
- def param(name, enum: nil, **options)
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
- # @param behavior [Symbol] :raise (default) or :return_empty
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? || self.class.param_enums.empty?
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
- enums.each do |param_name, values|
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
- 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
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. Schema validation (type + enum checks).
178
- # 2. Call super(validated_args) inside a retry loop.
179
- # 3. On persistent failure, apply on_error policy.
180
- def call(args)
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
- # Merge in any extra keys not covered by declared parameters (pass-through).
316
- extra = normalized.reject { |k, _| self.class.parameters.key?(k) }
317
- [result.merge(extra), nil]
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
- klass.param(p[:name].to_sym, type: p[:type]&.to_sym || :string, desc: p[:description].to_s)
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
- def initialize(command)
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
- @stdin, @stdout, @stderr, @wait_thr = Open3.popen3(*@command)
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
- def initialize(base_url, open_timeout: 5, read_timeout: 30)
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,
@@ -17,6 +17,7 @@ module Phronomy
17
17
  # end
18
18
  class OpenTelemetryTracer < Base
19
19
  # @param tracer_name [String] name passed to the OTel TracerProvider
20
+ # @api public
20
21
  def initialize(tracer_name: "phronomy")
21
22
  require "opentelemetry"
22
23
  @otel_tracer = OpenTelemetry.tracer_provider.tracer(tracer_name, Phronomy::VERSION)
@@ -27,6 +28,7 @@ module Phronomy
27
28
  # +phronomy.+.
28
29
  #
29
30
  # @return [OpenTelemetry::Trace::Span]
31
+ # @api public
30
32
  def start_span(name, input: nil, **attributes)
31
33
  attrs = {}
32
34
  attrs["phronomy.input"] = input.to_s if input