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