phronomy 0.8.0 → 0.9.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +31 -41
  3. data/benchmark/baseline.json +1 -1
  4. data/benchmark/bench_agent_invoke.rb +1 -1
  5. data/benchmark/bench_context_assembler.rb +9 -1
  6. data/benchmark/bench_regression.rb +8 -8
  7. data/benchmark/bench_tool_schema.rb +2 -2
  8. data/benchmark/bench_vector_store.rb +1 -1
  9. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +224 -0
  10. data/lib/phronomy/agent/base.rb +253 -351
  11. data/lib/phronomy/agent/concerns/suspendable.rb +6 -6
  12. data/lib/phronomy/agent/context/capability/base.rb +689 -0
  13. data/lib/phronomy/agent/context/capability/scope_policy.rb +54 -0
  14. data/lib/phronomy/agent/context/knowledge/base.rb +58 -0
  15. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +102 -0
  16. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +58 -0
  17. data/lib/phronomy/agent/invocation_pipeline.rb +10 -1
  18. data/lib/phronomy/agent/react_agent.rb +24 -23
  19. data/lib/phronomy/agent/shared_state.rb +2 -2
  20. data/lib/phronomy/agent/tool_executor.rb +1 -1
  21. data/lib/phronomy/concurrency/gate_registry.rb +0 -1
  22. data/lib/phronomy/configuration.rb +0 -6
  23. data/lib/phronomy/llm_context_window/assembler.rb +77 -44
  24. data/lib/phronomy/multi_agent/handoff.rb +4 -4
  25. data/lib/phronomy/multi_agent/orchestrator.rb +1 -1
  26. data/lib/phronomy/multi_agent/team_coordinator.rb +2 -2
  27. data/lib/phronomy/runtime/runtime_metrics.rb +0 -1
  28. data/lib/phronomy/runtime.rb +1 -2
  29. data/lib/phronomy/tool.rb +3 -4
  30. data/lib/phronomy/{tool/agent_tool.rb → tools/agent.rb} +6 -6
  31. data/lib/phronomy/{tool/mcp_tool.rb → tools/mcp.rb} +9 -9
  32. data/lib/phronomy/tools/vector_search.rb +70 -0
  33. data/lib/phronomy/vector_store/async_backend.rb +110 -0
  34. data/lib/phronomy/vector_store/base.rb +89 -0
  35. data/lib/phronomy/vector_store/embeddings/base.rb +41 -0
  36. data/lib/phronomy/vector_store/embeddings/ruby_llm_embeddings.rb +47 -0
  37. data/lib/phronomy/vector_store/in_memory.rb +103 -0
  38. data/lib/phronomy/vector_store/loader/base.rb +27 -0
  39. data/lib/phronomy/vector_store/loader/csv_loader.rb +58 -0
  40. data/lib/phronomy/vector_store/loader/markdown_loader.rb +78 -0
  41. data/lib/phronomy/vector_store/loader/plain_text_loader.rb +24 -0
  42. data/lib/phronomy/vector_store/pgvector.rb +127 -0
  43. data/lib/phronomy/vector_store/redis_search.rb +192 -0
  44. data/lib/phronomy/vector_store/splitter/base.rb +49 -0
  45. data/lib/phronomy/vector_store/splitter/fixed_size_splitter.rb +53 -0
  46. data/lib/phronomy/vector_store/splitter/recursive_splitter.rb +107 -0
  47. data/lib/phronomy/vector_store.rb +16 -4
  48. data/lib/phronomy/version.rb +1 -1
  49. data/lib/phronomy.rb +2 -1
  50. data/scripts/api_snapshot.rb +11 -9
  51. metadata +28 -32
  52. data/lib/phronomy/agent/context/conversation/compaction_context.rb +0 -117
  53. data/lib/phronomy/agent/context/conversation/trigger_context.rb +0 -43
  54. data/lib/phronomy/agent/context/conversation/trim_context.rb +0 -82
  55. data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +0 -45
  56. data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +0 -51
  57. data/lib/phronomy/agent/context/knowledge/loader/base.rb +0 -31
  58. data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +0 -62
  59. data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +0 -82
  60. data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +0 -28
  61. data/lib/phronomy/agent/context/knowledge/source/base.rb +0 -60
  62. data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +0 -102
  63. data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +0 -63
  64. data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +0 -58
  65. data/lib/phronomy/agent/context/knowledge/splitter/base.rb +0 -53
  66. data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +0 -57
  67. data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +0 -111
  68. data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +0 -116
  69. data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +0 -95
  70. data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +0 -109
  71. data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +0 -133
  72. data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +0 -198
  73. data/lib/phronomy/embeddings.rb +0 -11
  74. data/lib/phronomy/loader.rb +0 -13
  75. data/lib/phronomy/splitter.rb +0 -12
  76. data/lib/phronomy/tool/base.rb +0 -685
  77. data/lib/phronomy/tool/scope_policy.rb +0 -50
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- # Text splitter implementations for chunking documents before embedding.
5
- #
6
- # Sub-classes are auto-loaded by Zeitwerk:
7
- # Phronomy::Agent::Context::Knowledge::Splitter::Base
8
- # Phronomy::Agent::Context::Knowledge::Splitter::FixedSizeSplitter
9
- # Phronomy::Agent::Context::Knowledge::Splitter::RecursiveSplitter
10
- module Splitter
11
- end
12
- end
@@ -1,685 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Tool
5
- # Base class extending RubyLLM::Tool with Phronomy-specific DSL.
6
- #
7
- # Additional DSL over RubyLLM::Tool:
8
- # - tool_name : explicit function name exposed to the LLM (overrides auto-conversion)
9
- # - scope : access-scope metadata (:read_only, :write, etc.)
10
- # - on_error : error-handling policy (:raise or :return_empty)
11
- # - on_schema_error : behavior when LLM passes schema-violating arguments
12
- # :return_error (default), :raise, or :coerce
13
- # - requires_approval : require human approval before execution
14
- # - param :name, enum: [...] : restrict allowed values in the JSON Schema
15
- #
16
- # @example
17
- # class SearchKnowledgeBase < Phronomy::Tool::Base
18
- # tool_name "search_kb" # explicit name shown to the LLM
19
- # description "Search the internal knowledge base"
20
- # param :query, type: :string, desc: "Search query"
21
- # param :lang, type: :string, desc: "Language", required: false, enum: %w[en ja fr]
22
- # scope :read_only
23
- # on_error :return_empty
24
- #
25
- # def execute(query:, lang: "en")
26
- # KnowledgeBase.search(query, lang: lang)
27
- # end
28
- # end
29
- class Base < RubyLLM::Tool
30
- class << self
31
- # Sets an explicit function name to expose to the LLM, bypassing RubyLLM's
32
- # automatic CamelCase-to-snake_case conversion.
33
- # When omitted, RubyLLM's default conversion applies (e.g. WeatherTool → "weather").
34
- #
35
- # @param value [String, nil] the exact function name the LLM will see
36
- # @api public
37
- def tool_name(value = nil)
38
- return @tool_name if value.nil?
39
-
40
- @tool_name = value.to_s
41
- end
42
-
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).
49
- #
50
- # @param name [Symbol] parameter name
51
- # @param enum [Array, nil] allowed values
52
- # @param properties [Hash, nil] nested schema for :object params
53
- # @param options [Hash] forwarded to RubyLLM::Tool.param
54
- # @api public
55
- def param(name, enum: nil, properties: nil, **options)
56
- super(name, **options)
57
- param_enums[name] = enum if enum
58
- param_schemas[name] = normalize_nested_schema(properties) if properties
59
- end
60
-
61
- # Returns the enum constraints registered via .param.
62
- # @return [Hash{Symbol => Array}]
63
- # @api public
64
- def param_enums
65
- @param_enums ||= {}
66
- end
67
-
68
- # Returns nested schema definitions registered via .param(properties: ...).
69
- # @return [Hash{Symbol => Hash}]
70
- # @api public
71
- # mutant:disable - neutral failure: unparser round-trip produces different source
72
- def param_schemas
73
- @param_schemas ||= {}
74
- end
75
-
76
- private
77
-
78
- # Recursively normalises a properties hash so all keys are Symbols and
79
- # each spec has a :type key.
80
- # mutant:disable
81
- def normalize_nested_schema(props)
82
- props.transform_keys(&:to_sym).transform_values do |spec|
83
- s = spec.transform_keys(&:to_sym)
84
- s[:type] ||= :string
85
- s[:properties] = normalize_nested_schema(s[:properties]) if s[:properties]
86
- s
87
- end
88
- end
89
-
90
- public
91
-
92
- # Sets the access scope for this tool (metadata; enforcement is the responsibility of
93
- # the Workflow/Guardrail layer).
94
- # @param value [Symbol] e.g. :read_only, :write, :admin
95
- # @api public
96
- # mutant:disable - neutral failure: unparser round-trip produces different source
97
- def scope(value = nil)
98
- return @scope if value.nil?
99
-
100
- @scope = value
101
- end
102
-
103
- # Sets or reads the execution mode for this tool.
104
- #
105
- # Execution mode is the concurrency contract declaration for the tool.
106
- # In Phronomy's non-preemptive, cooperative concurrency model it controls
107
- # which runtime resource is used to dispatch the tool:
108
- #
109
- # | Mode | Dispatcher | Constraint |
110
- # |------|-----------|------------|
111
- # | +:cooperative+ | +Runtime.instance.spawn+ (scheduler task) | *Must not* block the scheduler thread; use only for in-memory computation |
112
- # | +:blocking_io+ | {Phronomy::Concurrency::BlockingAdapterPool} (bounded thread pool) | **Default**. Safe for all blocking I/O (HTTP, DB, file) |
113
- # | +:cpu_bound+ | Falls back to +:blocking_io+ + emits a warning | No dedicated process pool yet; use +:blocking_io+ explicitly to suppress the warning |
114
- # | +:external_process+ | Falls back to +:blocking_io+ | No process manager yet |
115
- #
116
- # Tools that perform network calls, file I/O, or database queries should use
117
- # +:blocking_io+ (the default). Tools that only perform in-memory computation
118
- # may declare +:cooperative+ for lower overhead.
119
- #
120
- # @param value [Symbol, nil] when nil, returns the current value
121
- # @return [Symbol] the current execution mode (default :blocking_io)
122
- # @api public
123
- # mutant:disable
124
- def execution_mode(value = nil)
125
- return @execution_mode || :blocking_io if value.nil?
126
-
127
- valid = %i[cooperative blocking_io cpu_bound external_process]
128
- unless valid.include?(value)
129
- raise ArgumentError, "execution_mode must be one of #{valid.inspect}, got #{value.inspect}"
130
- end
131
-
132
- @execution_mode = value
133
- end
134
-
135
- # Configures error-handling behavior when +execute+ raises an unexpected error.
136
- #
137
- # @param behavior [Symbol]
138
- # :raise (default) — re-raise as Phronomy::ToolError, stopping the agent.
139
- # :suppress — suppress the error and return a descriptive string so
140
- # the LLM can recover on the next turn.
141
- # :return_empty — *deprecated* alias for +:suppress+; will be removed in a
142
- # future major release.
143
- # @api public
144
- def on_error(behavior = nil)
145
- return @on_error || :raise if behavior.nil?
146
-
147
- if behavior == :return_empty
148
- msg = "[Phronomy] on_error :return_empty is deprecated; use :suppress instead"
149
- if Phronomy.configuration.logger
150
- Phronomy.configuration.logger.warn(msg)
151
- else
152
- warn msg
153
- end
154
- end
155
- @on_error = behavior
156
- end
157
-
158
- # Configures how this tool responds when the LLM passes arguments that violate
159
- # the declared parameter types or enum constraints.
160
- #
161
- # @param behavior [Symbol]
162
- # :return_error (default) — return a descriptive error string as the tool result
163
- # so the LLM can self-correct on the next turn.
164
- # :raise — raise Phronomy::ToolError, stopping the agent loop.
165
- # :coerce — attempt type coercion (e.g. "42" → 42 for :integer);
166
- # falls back to :return_error when coercion is not possible.
167
- # @api public
168
- # mutant:disable - neutral failure: unparser round-trip produces different source
169
- def on_schema_error(behavior = nil)
170
- return @on_schema_error || :return_error if behavior.nil?
171
-
172
- @on_schema_error = behavior
173
- end
174
-
175
- # Configures whether human approval is required before executing this tool.
176
- # @param value [Boolean]
177
- # @api public
178
- # mutant:disable - neutral failure: unparser round-trip produces different source
179
- def requires_approval(value = nil)
180
- return @requires_approval || false if value.nil?
181
-
182
- @requires_approval = value
183
- end
184
-
185
- # Marks one or more parameter names as sensitive so their values are
186
- # replaced with +"[REDACTED]"+ in log and trace output.
187
- #
188
- # @param names [Array<Symbol>] parameter names to redact
189
- # @return [Array<Symbol>] the full list of redacted param names
190
- # @api public
191
- # mutant:disable
192
- def redact_params(*names)
193
- if names.empty?
194
- parent = superclass.respond_to?(:redact_params) ? superclass.redact_params : []
195
- ((@redacted_params || []) + parent).uniq
196
- else
197
- @redacted_params = ((@redacted_params || []) + names.map(&:to_sym)).uniq
198
- end
199
- end
200
-
201
- # Sets a per-tool maximum result size (in characters).
202
- # Overrides the global +Phronomy.configuration.tool_result_max_size+ when set.
203
- # Set to +nil+ to inherit the global limit.
204
- #
205
- # @param value [Integer, nil]
206
- # @api public
207
- def max_result_size(value = :__unset__)
208
- return @max_result_size if value == :__unset__
209
-
210
- @max_result_size = value
211
- end
212
-
213
- # Registers a retry policy for one or more exception classes.
214
- #
215
- # When the tool raises one of the listed exception classes, it will be
216
- # retried up to +times+ times with the specified wait strategy.
217
- # Multiple policies can be registered and are evaluated in order.
218
- #
219
- # GuardrailError is never retried regardless of this configuration.
220
- #
221
- # @param exception_classes [Array<Class>] exception classes to retry on
222
- # @param times [Integer] maximum retry attempts (default: 1)
223
- # @param wait [Symbol, Numeric] :exponential, :linear, or a fixed Float
224
- # @param base [Float] base wait time in seconds (default: 1.0)
225
- #
226
- # @example
227
- # retry_on Phronomy::ToolError, times: 3, wait: :exponential, base: 1.0
228
- # retry_on Net::ReadTimeout, times: 2, wait: 0.5
229
- # @api public
230
- def retry_on(*exception_classes, times: 1, wait: 0, base: 1.0)
231
- @retry_policies ||= []
232
- @retry_policies << {exceptions: exception_classes, times: times, wait: wait, base: base}
233
- end
234
-
235
- # Returns all retry policies registered on this tool class.
236
- # @return [Array<Hash>]
237
- # @api public
238
- # mutant:disable - neutral failure: unparser round-trip produces different source
239
- def retry_policies
240
- @retry_policies || []
241
- end
242
-
243
- # Injectable sleep callable for testing.
244
- # Defaults to Kernel#sleep.
245
- # @return [#call]
246
- # @api private
247
- # mutant:disable - neutral failure: unparser round-trip produces different source
248
- def _sleep_proc
249
- @_sleep_proc || method(:sleep)
250
- end
251
-
252
- # Overrides the sleep callable used between retries.
253
- # @param proc [#call]
254
- attr_writer :_sleep_proc
255
- end
256
-
257
- # Returns the function name exposed to the LLM.
258
- # Uses the class-level tool_name if set; otherwise falls back to RubyLLM's
259
- # automatic conversion (CamelCase → snake_case, strips trailing "_tool").
260
- # mutant:disable - neutral failure: unparser round-trip produces different source
261
- def name
262
- self.class.tool_name || super
263
- end
264
-
265
- # Returns the JSON Schema for this tool's parameters.
266
- # Injects "enum" entries for any param declared with enum: [...].
267
- # mutant:disable - genuine equivalent mutations:
268
- # 1. `|| schema.dig(:properties)`: dead code because RubyLLM::Tool always returns a
269
- # string-keyed hash; schema.dig(:properties) is always nil in practice.
270
- # 2. `return schema unless properties` guard: dead code when schema is non-nil because
271
- # RubyLLM::Tool always includes a "properties" key when parameters are declared.
272
- def params_schema
273
- schema = super
274
- return schema if schema.nil?
275
-
276
- properties = schema.dig("properties") || schema.dig(:properties)
277
- return schema unless properties
278
-
279
- # Inject enum values for params declared with enum: [...].
280
- unless self.class.param_enums.empty?
281
- enums = self.class.param_enums
282
- enums.each do |param_name, values|
283
- key = properties.key?(param_name.to_s) ? param_name.to_s : param_name.to_sym
284
- next unless properties[key]
285
-
286
- param_type = properties[key]["type"]
287
- properties[key]["enum"] = values.map do |v|
288
- case param_type
289
- when "integer" then v.is_a?(Integer) ? v : Integer(v.to_s)
290
- when "number" then v.is_a?(Numeric) ? v : Float(v.to_s)
291
- else v.to_s
292
- end
293
- end
294
- end
295
- end
296
-
297
- # Inject nested properties for :object params (issue #162).
298
- # Without this the LLM sees only { "type": "object" } with no field
299
- # definitions, making it unable to populate nested object params.
300
- self.class.param_schemas.each do |param_name, nested|
301
- key = properties.key?(param_name.to_s) ? param_name.to_s : param_name.to_sym
302
- next unless properties[key]
303
-
304
- properties[key]["properties"] = nested_schema_to_json_schema(nested)
305
- end
306
-
307
- schema
308
- end
309
-
310
- # Overrides RubyLLM::Tool#call to apply schema validation, the retry policy,
311
- # the on_error policy, and wrap errors as ToolError.
312
- #
313
- # Execution order:
314
- # 1. Early cancellation check (kwarg token takes precedence over thread-local).
315
- # 2. Schema validation (type + enum checks).
316
- # 3. Inject +cancellation_token:+ into args when +execute+ opts in.
317
- # 4. Call super(validated_args) inside a retry loop.
318
- # 5. On persistent failure, apply on_error policy.
319
- #
320
- # @param args [Hash]
321
- # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional; takes precedence over the thread-local token
322
- # @api public
323
- # mutant:disable
324
- def call(args, cancellation_token: nil)
325
- ct = cancellation_token
326
- ct&.raise_if_cancelled!
327
- validated_args, schema_error = validate_and_coerce(args)
328
- if schema_error
329
- case self.class.on_schema_error
330
- when :raise
331
- raise Phronomy::ToolError, "#{self.class.name} schema error: #{schema_error}"
332
- else
333
- # :return_error (default) and coerce fallback
334
- return "Schema validation failed: #{schema_error}"
335
- end
336
- end
337
- validated_args = validated_args.merge(cancellation_token: ct) if ct && execute_accepts_cancellation_token?
338
- result = with_tool_retry { super(validated_args) }
339
- truncate_result_if_needed(result)
340
- rescue Phronomy::ToolError
341
- raise
342
- rescue Phronomy::CancellationError
343
- raise
344
- rescue => e
345
- case self.class.on_error
346
- when :return_empty, :suppress
347
- msg = "[Phronomy] Tool #{self.class.name} suppressed error: #{e.class}: #{e.message}"
348
- if Phronomy.configuration.logger
349
- Phronomy.configuration.logger.warn(msg)
350
- else
351
- warn msg
352
- end
353
- "Tool error suppressed: #{e.message}"
354
- else
355
- raise Phronomy::ToolError, "#{self.class.name} execution failed: #{e.message}"
356
- end
357
- end
358
-
359
- # Invokes this tool asynchronously and returns a {Phronomy::Task}.
360
- #
361
- # Routing is governed by the class-level {.execution_mode} setting.
362
- # Delegates to {Phronomy::Agent::ToolExecutor.call_async} which is the single
363
- # place in the framework that applies the execution-mode routing rules.
364
- #
365
- # @param args [Hash]
366
- # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
367
- # @return [#await]
368
- # @api public
369
- # mutant:disable
370
- def call_async(args, cancellation_token: nil)
371
- Phronomy::Agent::ToolExecutor.call_async(
372
- tool: self,
373
- args: args,
374
- cancellation_token: cancellation_token
375
- )
376
- end
377
-
378
- # Instance method accessor — delegates to the class-level flag.
379
- def requires_approval
380
- self.class.requires_approval
381
- end
382
-
383
- # Instance method for requires_approval? (convenience accessor).
384
- # mutant:disable - genuine equivalent: self.requires_approval delegates to
385
- # self.class.requires_approval via the instance method defined above, so
386
- # both expressions produce the same value.
387
- def requires_approval?
388
- self.class.requires_approval
389
- end
390
-
391
- # Override this method to implement the tool's logic.
392
- #
393
- # The method receives the declared {.param} fields as keyword arguments.
394
- # The return value is passed back to the LLM as the tool result.
395
- #
396
- # @abstract Subclasses must implement this method.
397
- # @return [String] result string returned to the LLM
398
- # @example
399
- # class WeatherTool < Phronomy::Tool::Base
400
- # description "Get current weather"
401
- # param :location, type: :string, desc: "City name"
402
- #
403
- # def execute(location:)
404
- # WeatherService.fetch(location).to_s
405
- # end
406
- # end
407
- # @api public
408
- def execute(**_args)
409
- raise NotImplementedError, "#{self.class}#execute is not implemented"
410
- end
411
-
412
- private
413
-
414
- # Returns true when the #execute method declares a +cancellation_token:+
415
- # keyword parameter, indicating it opts into cooperative cancellation.
416
- # mutant:disable
417
- def execute_accepts_cancellation_token?
418
- method(:execute).parameters.any? do |type, name| # mutant:disable
419
- name == :cancellation_token && %i[key keyreq].include?(type)
420
- end
421
- end
422
-
423
- # Truncates the result string when it exceeds the configured maximum size.
424
- # Uses the per-tool limit first, then the global configuration limit.
425
- # Returns the original result when no limit is configured.
426
- def truncate_result_if_needed(result)
427
- max = self.class.max_result_size || Phronomy.configuration.tool_result_max_size
428
- return result unless max && result.respond_to?(:length) && result.length > max
429
-
430
- msg = "[Phronomy] Tool #{self.class.name} result truncated " \
431
- "(#{result.length} chars > #{max} limit)"
432
- if Phronomy.configuration.logger
433
- Phronomy.configuration.logger.warn(msg)
434
- else
435
- warn msg
436
- end
437
- "#{result[0, max]}...[truncated]"
438
- end
439
-
440
- # Returns a copy of +args+ with redacted parameter values replaced by
441
- # +"[REDACTED]"+. Used for logging and tracing.
442
- # @param args [Hash]
443
- # @return [Hash]
444
- # @api private
445
- def redacted_args(args)
446
- redacted = self.class.redact_params
447
- return args if redacted.empty?
448
-
449
- args.each_with_object({}) do |(k, v), h|
450
- h[k] = redacted.include?(k.to_sym) ? "[REDACTED]" : v
451
- end
452
- end
453
-
454
- # Executes the given block inside a retry loop driven by the class-level
455
- # retry_policies. Each policy matches by exception class; the first matching
456
- # policy governs the wait and retry count. Raises immediately when no policy
457
- # covers the exception or when all retries are exhausted.
458
- # mutant:disable - genuine equivalent mutations:
459
- # 1. `if policies.empty?; return yield; end` early-return variants (nil, false, block
460
- # removal): behavior is identical because when policies is empty, yield is still
461
- # called inside begin/rescue, any exception is re-raised (policy=nil, condition
462
- # false), and successful returns propagate the same value either way.
463
- # 2. `p[:exceptions].any?` vs `p.fetch(:exceptions).any?`: :exceptions key is always
464
- # present (set unconditionally by .retry_on), so fetch/[] are equivalent.
465
- # 3. `policy[:times]`, `policy[:wait]`, `policy[:base]` vs `.fetch(...)`: same reason
466
- # as #2 — all keys are always set by .retry_on.
467
- def with_tool_retry
468
- policies = self.class.retry_policies
469
- return yield if policies.empty?
470
-
471
- attempt = 0
472
- begin
473
- yield
474
- rescue => e
475
- policy = policies.find { |p| p[:exceptions].any? { |ex| e.is_a?(ex) } }
476
- if policy && attempt < policy[:times]
477
- wait = compute_retry_wait(policy[:wait], policy[:base], attempt)
478
- self.class._sleep_proc.call(wait) if wait > 0
479
- attempt += 1
480
- retry
481
- end
482
- raise
483
- end
484
- end
485
-
486
- # Computes the wait duration for a given strategy, base, and attempt index.
487
- #
488
- # @param strategy [Symbol, Numeric] :exponential, :linear, or a fixed Numeric
489
- # @param base [Float] base wait time in seconds
490
- # @param attempt [Integer] zero-based attempt index
491
- # @return [Float]
492
- # @api public
493
- def compute_retry_wait(strategy, base, attempt)
494
- case strategy
495
- when :exponential
496
- (2**attempt) * base
497
- when :linear
498
- (attempt + 1) * base
499
- when Numeric
500
- strategy.to_f
501
- else
502
- base.to_f
503
- end
504
- end
505
-
506
- # Validates args against declared parameter types and enum constraints.
507
- # When on_schema_error is :coerce, attempts type coercion first.
508
- #
509
- # @param args [Hash] raw args passed to #call (string or symbol keys)
510
- # @return [Array(Hash, String|nil)] [possibly_coerced_args, error_message_or_nil]
511
- # @api public
512
- # mutant:disable
513
- def validate_and_coerce(args)
514
- # mutant:disable - genuine equivalents:
515
- # 1. `return [args, nil]` vs `return [args]`: Ruby multiple assignment
516
- # fills nil for missing elements, so both are identical to callers.
517
- # 2. `self.class.parameters` vs `self.parameters`: RubyLLM::Tool exposes
518
- # `parameters` as both a class method and an instance method that
519
- # delegates to the class method, so both return the same value.
520
- return [args, nil] if self.class.parameters.empty?
521
-
522
- normalized = (args || {}).transform_keys(&:to_sym)
523
- coerce_mode = self.class.on_schema_error == :coerce
524
- result = {}
525
-
526
- self.class.parameters.each do |name, param| # mutant:disable
527
- value = normalized[name]
528
- if value.nil?
529
- # Return a descriptive error for missing required params so the LLM
530
- # can self-correct on the next turn.
531
- return [nil, "required parameter '#{name}' is missing"] if param.required
532
- next
533
- end
534
-
535
- if coerce_mode
536
- coerced, error = coerce_value(value, param.type)
537
- return [nil, error] if error
538
- value = coerced
539
- else
540
- error = type_error(value, param.type)
541
- return [nil, error] if error
542
- end
543
-
544
- # Recursively validate nested object properties when declared.
545
- if param.type.to_sym == :object
546
- nested_schema = self.class.param_schemas[name]
547
- if nested_schema
548
- error = validate_nested_object(value, nested_schema, name.to_s)
549
- return [nil, error] if error
550
- end
551
- end
552
-
553
- enum_vals = self.class.param_enums[name]
554
- if enum_vals && !enum_vals.map(&:to_s).include?(value.to_s)
555
- return [nil, "parameter '#{name}' must be one of: #{enum_vals.join(", ")} (got: #{value.inspect})"]
556
- end
557
-
558
- result[name] = value
559
- end
560
-
561
- # Reject any keys not covered by declared parameters to prevent silent
562
- # parameter injection (e.g. via prompt injection).
563
- extra = normalized.keys - self.class.parameters.keys # mutant:disable
564
- unless extra.empty?
565
- return [nil, "unknown parameter(s): #{extra.inspect}"]
566
- end
567
-
568
- [result, nil] # mutant:disable
569
- end
570
-
571
- # Converts the internal normalized nested schema (from param_schemas) to
572
- # a JSON Schema +properties+ hash suitable for inclusion in the LLM tool
573
- # definition (issue #162).
574
- #
575
- # @param nested [Hash{Symbol=>Hash}] normalized schema from param_schemas
576
- # @return [Hash{String=>Hash}] JSON Schema properties
577
- # @api public
578
- # mutant:disable
579
- def nested_schema_to_json_schema(nested)
580
- nested.each_with_object({}) do |(prop_name, spec), acc|
581
- entry = {"type" => spec[:type].to_s}
582
- entry["description"] = spec[:desc] if spec[:desc]
583
- entry["enum"] = spec[:enum] if spec[:enum]
584
- entry["properties"] = nested_schema_to_json_schema(spec[:properties]) if spec[:properties]
585
- acc[prop_name.to_s] = entry
586
- end
587
- end
588
-
589
- # Recursively validates +value+ (a Hash) against a +properties+ schema.
590
- # Returns an error message string or nil.
591
- #
592
- # @param value [Hash] the object value to validate
593
- # @param properties [Hash{Symbol=>Hash}] nested schema from param_schemas
594
- # @param path [String] dot-separated field path for error messages
595
- # @api public
596
- # mutant:disable
597
- def validate_nested_object(value, properties, path)
598
- return "field '#{path}' must be an object (Hash)" unless value.is_a?(Hash)
599
-
600
- normalized = value.transform_keys(&:to_sym)
601
-
602
- # Reject extra keys not declared in the schema (issue #166).
603
- extra = normalized.keys - properties.keys
604
- unless extra.empty?
605
- return "nested field '#{path}' contains undeclared key(s): #{extra.inspect}"
606
- end
607
-
608
- properties.each do |fname, spec|
609
- field_path = "#{path}.#{fname}"
610
- field_value = normalized[fname]
611
-
612
- if field_value.nil?
613
- return "nested required field '#{field_path}' is missing" if spec[:required]
614
- next
615
- end
616
-
617
- error = type_error(field_value, spec[:type])
618
- return "nested field '#{field_path}': #{error}" if error
619
-
620
- next unless spec[:type].to_sym == :object && spec[:properties]
621
-
622
- error = validate_nested_object(field_value, spec[:properties], field_path)
623
- return error if error
624
- end
625
- nil
626
- end
627
-
628
- # Returns a type-error message string if +value+ does not match +declared_type+,
629
- # or nil if the value is acceptable.
630
- #
631
- # @param value [Object]
632
- # @param declared_type [Symbol, String] e.g. :string, :integer, :number, :boolean, :array, :object
633
- # @api public
634
- # mutant:disable
635
- def type_error(value, declared_type)
636
- return nil if value.nil?
637
-
638
- ok = case declared_type.to_sym
639
- when :string then value.is_a?(String)
640
- when :integer then value.is_a?(Integer)
641
- when :number, :float then value.is_a?(Numeric)
642
- when :boolean then [true, false].include?(value)
643
- when :array then value.is_a?(Array)
644
- when :object then value.is_a?(Hash)
645
- else true # unknown types pass through
646
- end
647
-
648
- if ok
649
- nil
650
- else
651
- "parameter '#{value.respond_to?(:keys) ? "(object)" : value.inspect}' expected type #{declared_type}"
652
- end
653
- end
654
-
655
- # Attempts to coerce +value+ to +declared_type+.
656
- # Returns [coerced_value, nil] on success, [nil, error_message] on failure.
657
- # mutant:disable
658
- def coerce_value(value, declared_type)
659
- return [value, nil] if value.nil?
660
-
661
- case declared_type.to_sym
662
- when :string
663
- [value.to_s, nil]
664
- when :integer
665
- coerced = Integer(value)
666
- [coerced, nil]
667
- when :number, :float
668
- coerced = Float(value)
669
- [coerced, nil]
670
- when :boolean
671
- case value.to_s.downcase
672
- when "true" then [true, nil]
673
- when "false" then [false, nil]
674
- else [nil, "parameter cannot be coerced to boolean: #{value.inspect}"]
675
- end
676
- else
677
- # Arrays, objects, unknown types: pass through as-is
678
- [value, nil]
679
- end
680
- rescue ArgumentError, TypeError
681
- [nil, "parameter cannot be coerced to #{declared_type}: #{value.inspect}"]
682
- end
683
- end
684
- end
685
- end