phronomy 0.6.0 → 0.7.1

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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +22 -0
  3. data/CHANGELOG.md +488 -0
  4. data/CONTRIBUTING.md +102 -0
  5. data/README.md +374 -36
  6. data/RELEASE_CHECKLIST.md +86 -0
  7. data/Rakefile +33 -0
  8. data/SECURITY.md +80 -0
  9. data/benchmark/baseline.json +9 -0
  10. data/benchmark/bench_agent_invoke.rb +105 -0
  11. data/benchmark/bench_context_assembler.rb +46 -0
  12. data/benchmark/bench_regression.rb +172 -0
  13. data/benchmark/bench_token_estimator.rb +44 -0
  14. data/benchmark/bench_tool_schema.rb +69 -0
  15. data/benchmark/bench_vector_store.rb +39 -0
  16. data/benchmark/bench_workflow.rb +55 -0
  17. data/benchmark/run_all.rb +118 -0
  18. data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
  19. data/docs/decisions/002-workflow-context-immutability.md +42 -0
  20. data/docs/decisions/003-event-loop-singleton.md +48 -0
  21. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +75 -0
  22. data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
  23. data/docs/decisions/006-no-built-in-guardrails.md +66 -0
  24. data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
  25. data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
  26. data/docs/decisions/009-state-store-abstraction.md +141 -0
  27. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  28. data/lib/phronomy/agent/base.rb +416 -49
  29. data/lib/phronomy/agent/before_completion_context.rb +1 -0
  30. data/lib/phronomy/agent/checkpoint.rb +1 -0
  31. data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
  32. data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
  33. data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
  34. data/lib/phronomy/agent/concerns/retryable.rb +12 -1
  35. data/lib/phronomy/agent/concerns/suspendable.rb +19 -0
  36. data/lib/phronomy/agent/fsm.rb +44 -52
  37. data/lib/phronomy/agent/handoff.rb +3 -0
  38. data/lib/phronomy/agent/orchestrator.rb +191 -54
  39. data/lib/phronomy/agent/parallel_tool_chat.rb +87 -13
  40. data/lib/phronomy/agent/react_agent.rb +16 -6
  41. data/lib/phronomy/agent/runner.rb +2 -0
  42. data/lib/phronomy/agent/shared_state.rb +11 -0
  43. data/lib/phronomy/agent/suspend_signal.rb +2 -0
  44. data/lib/phronomy/agent/team_coordinator.rb +17 -5
  45. data/lib/phronomy/async_queue.rb +155 -0
  46. data/lib/phronomy/blocking_adapter_pool.rb +435 -0
  47. data/lib/phronomy/cancellation_scope.rb +123 -0
  48. data/lib/phronomy/cancellation_token.rb +133 -0
  49. data/lib/phronomy/concurrency_gate.rb +155 -0
  50. data/lib/phronomy/configuration.rb +168 -2
  51. data/lib/phronomy/context/assembler.rb +6 -0
  52. data/lib/phronomy/context/compaction_context.rb +2 -0
  53. data/lib/phronomy/context/context_version_cache.rb +2 -0
  54. data/lib/phronomy/context/token_budget.rb +3 -0
  55. data/lib/phronomy/context/token_estimator.rb +9 -2
  56. data/lib/phronomy/context/trigger_context.rb +1 -0
  57. data/lib/phronomy/context/trim_context.rb +4 -0
  58. data/lib/phronomy/deadline.rb +63 -0
  59. data/lib/phronomy/diagnostics.rb +62 -0
  60. data/lib/phronomy/embeddings/base.rb +22 -2
  61. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
  62. data/lib/phronomy/eval/comparison.rb +2 -0
  63. data/lib/phronomy/eval/dataset.rb +4 -0
  64. data/lib/phronomy/eval/metrics.rb +6 -0
  65. data/lib/phronomy/eval/runner.rb +11 -9
  66. data/lib/phronomy/eval/scorer/base.rb +1 -0
  67. data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
  68. data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
  69. data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
  70. data/lib/phronomy/event_loop.rb +275 -30
  71. data/lib/phronomy/fsm_session.rb +57 -4
  72. data/lib/phronomy/generator_verifier.rb +2 -0
  73. data/lib/phronomy/guardrail/base.rb +3 -0
  74. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  75. data/lib/phronomy/invocation_context.rb +152 -0
  76. data/lib/phronomy/knowledge_source/base.rb +24 -2
  77. data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
  78. data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
  79. data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
  80. data/lib/phronomy/llm_adapter/base.rb +104 -0
  81. data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
  82. data/lib/phronomy/llm_adapter.rb +20 -0
  83. data/lib/phronomy/loader/base.rb +1 -0
  84. data/lib/phronomy/loader/csv_loader.rb +2 -0
  85. data/lib/phronomy/loader/markdown_loader.rb +2 -0
  86. data/lib/phronomy/loader/plain_text_loader.rb +1 -0
  87. data/lib/phronomy/metrics.rb +38 -0
  88. data/lib/phronomy/output_parser/base.rb +1 -0
  89. data/lib/phronomy/output_parser/json_parser.rb +22 -3
  90. data/lib/phronomy/output_parser/structured_parser.rb +2 -0
  91. data/lib/phronomy/prompt_template.rb +5 -0
  92. data/lib/phronomy/runnable.rb +20 -3
  93. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  94. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  95. data/lib/phronomy/runtime/gate_registry.rb +52 -0
  96. data/lib/phronomy/runtime/pool_registry.rb +57 -0
  97. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  98. data/lib/phronomy/runtime/scheduler.rb +98 -0
  99. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  100. data/lib/phronomy/runtime/task_registry.rb +48 -0
  101. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  102. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  103. data/lib/phronomy/runtime/timer_service.rb +42 -0
  104. data/lib/phronomy/runtime.rb +374 -0
  105. data/lib/phronomy/splitter/base.rb +2 -0
  106. data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
  107. data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
  108. data/lib/phronomy/state_store/base.rb +48 -0
  109. data/lib/phronomy/state_store/in_memory.rb +62 -0
  110. data/lib/phronomy/task/backend.rb +80 -0
  111. data/lib/phronomy/task/fiber_backend.rb +157 -0
  112. data/lib/phronomy/task/immediate_backend.rb +89 -0
  113. data/lib/phronomy/task/thread_backend.rb +84 -0
  114. data/lib/phronomy/task.rb +275 -0
  115. data/lib/phronomy/task_group.rb +265 -0
  116. data/lib/phronomy/testing/fake_clock.rb +109 -0
  117. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  118. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  119. data/lib/phronomy/testing.rb +12 -0
  120. data/lib/phronomy/tool/agent_tool.rb +1 -0
  121. data/lib/phronomy/tool/base.rb +298 -28
  122. data/lib/phronomy/tool/mcp_tool.rb +103 -17
  123. data/lib/phronomy/tool/scope_policy.rb +50 -0
  124. data/lib/phronomy/tool_executor.rb +106 -0
  125. data/lib/phronomy/tracing/base.rb +3 -0
  126. data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
  127. data/lib/phronomy/tracing/open_telemetry_tracer.rb +36 -0
  128. data/lib/phronomy/vector_store/async_backend.rb +110 -0
  129. data/lib/phronomy/vector_store/base.rb +40 -7
  130. data/lib/phronomy/vector_store/in_memory.rb +16 -7
  131. data/lib/phronomy/vector_store/pgvector.rb +40 -9
  132. data/lib/phronomy/vector_store/redis_search.rb +29 -8
  133. data/lib/phronomy/version.rb +1 -1
  134. data/lib/phronomy/workflow.rb +147 -11
  135. data/lib/phronomy/workflow_context.rb +83 -6
  136. data/lib/phronomy/workflow_runner.rb +106 -7
  137. data/lib/phronomy.rb +112 -1
  138. data/scripts/api_snapshot.rb +91 -0
  139. data/scripts/check_api_annotations.rb +68 -0
  140. data/scripts/check_private_enforcement.rb +93 -0
  141. data/scripts/check_readme_runnable.rb +98 -0
  142. data/scripts/run_mutation.sh +46 -0
  143. metadata +83 -2
@@ -33,44 +33,121 @@ 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
+ # Sets or reads the execution mode for this tool.
101
+ #
102
+ # Execution mode is the concurrency contract declaration for the tool.
103
+ # In Phronomy's non-preemptive, cooperative concurrency model it controls
104
+ # which runtime resource is used to dispatch the tool:
105
+ #
106
+ # | Mode | Dispatcher | Constraint |
107
+ # |------|-----------|------------|
108
+ # | +:cooperative+ | +Runtime.instance.spawn+ (scheduler task) | *Must not* block the scheduler thread; use only for in-memory computation |
109
+ # | +:blocking_io+ | {Phronomy::BlockingAdapterPool} (bounded thread pool) | **Default**. Safe for all blocking I/O (HTTP, DB, file) |
110
+ # | +:cpu_bound+ | Falls back to +:blocking_io+ + emits a warning | No dedicated process pool yet; use +:blocking_io+ explicitly to suppress the warning |
111
+ # | +:external_process+ | Falls back to +:blocking_io+ | No process manager yet |
112
+ #
113
+ # Tools that perform network calls, file I/O, or database queries should use
114
+ # +:blocking_io+ (the default). Tools that only perform in-memory computation
115
+ # may declare +:cooperative+ for lower overhead.
116
+ #
117
+ # @param value [Symbol, nil] when nil, returns the current value
118
+ # @return [Symbol] the current execution mode (default :blocking_io)
119
+ # @api public
120
+ def execution_mode(value = nil)
121
+ return @execution_mode || :blocking_io if value.nil?
122
+
123
+ valid = %i[cooperative blocking_io cpu_bound external_process]
124
+ unless valid.include?(value)
125
+ raise ArgumentError, "execution_mode must be one of #{valid.inspect}, got #{value.inspect}"
126
+ end
127
+
128
+ @execution_mode = value
129
+ end
130
+
131
+ # Configures error-handling behavior when +execute+ raises an unexpected error.
132
+ #
133
+ # @param behavior [Symbol]
134
+ # :raise (default) — re-raise as Phronomy::ToolError, stopping the agent.
135
+ # :suppress — suppress the error and return a descriptive string so
136
+ # the LLM can recover on the next turn.
137
+ # :return_empty — *deprecated* alias for +:suppress+; will be removed in a
138
+ # future major release.
139
+ # @api public
71
140
  def on_error(behavior = nil)
72
141
  return @on_error || :raise if behavior.nil?
73
142
 
143
+ if behavior == :return_empty
144
+ msg = "[Phronomy] on_error :return_empty is deprecated; use :suppress instead"
145
+ if Phronomy.configuration.logger
146
+ Phronomy.configuration.logger.warn(msg)
147
+ else
148
+ warn msg
149
+ end
150
+ end
74
151
  @on_error = behavior
75
152
  end
76
153
 
@@ -83,6 +160,7 @@ module Phronomy
83
160
  # :raise — raise Phronomy::ToolError, stopping the agent loop.
84
161
  # :coerce — attempt type coercion (e.g. "42" → 42 for :integer);
85
162
  # falls back to :return_error when coercion is not possible.
163
+ # @api public
86
164
  def on_schema_error(behavior = nil)
87
165
  return @on_schema_error || :return_error if behavior.nil?
88
166
 
@@ -91,12 +169,40 @@ module Phronomy
91
169
 
92
170
  # Configures whether human approval is required before executing this tool.
93
171
  # @param value [Boolean]
172
+ # @api public
94
173
  def requires_approval(value = nil)
95
174
  return @requires_approval || false if value.nil?
96
175
 
97
176
  @requires_approval = value
98
177
  end
99
178
 
179
+ # Marks one or more parameter names as sensitive so their values are
180
+ # replaced with +"[REDACTED]"+ in log and trace output.
181
+ #
182
+ # @param names [Array<Symbol>] parameter names to redact
183
+ # @return [Array<Symbol>] the full list of redacted param names
184
+ # @api public
185
+ def redact_params(*names)
186
+ if names.empty?
187
+ parent = superclass.respond_to?(:redact_params) ? superclass.redact_params : []
188
+ ((@redacted_params || []) + parent).uniq
189
+ else
190
+ @redacted_params = ((@redacted_params || []) + names.map(&:to_sym)).uniq
191
+ end
192
+ end
193
+
194
+ # Sets a per-tool maximum result size (in characters).
195
+ # Overrides the global +Phronomy.configuration.tool_result_max_size+ when set.
196
+ # Set to +nil+ to inherit the global limit.
197
+ #
198
+ # @param value [Integer, nil]
199
+ # @api public
200
+ def max_result_size(value = :__unset__)
201
+ return @max_result_size if value == :__unset__
202
+
203
+ @max_result_size = value
204
+ end
205
+
100
206
  # Registers a retry policy for one or more exception classes.
101
207
  #
102
208
  # When the tool raises one of the listed exception classes, it will be
@@ -113,6 +219,7 @@ module Phronomy
113
219
  # @example
114
220
  # retry_on Phronomy::ToolError, times: 3, wait: :exponential, base: 1.0
115
221
  # retry_on Net::ReadTimeout, times: 2, wait: 0.5
222
+ # @api public
116
223
  def retry_on(*exception_classes, times: 1, wait: 0, base: 1.0)
117
224
  @retry_policies ||= []
118
225
  @retry_policies << {exceptions: exception_classes, times: times, wait: wait, base: base}
@@ -120,6 +227,7 @@ module Phronomy
120
227
 
121
228
  # Returns all retry policies registered on this tool class.
122
229
  # @return [Array<Hash>]
230
+ # @api public
123
231
  def retry_policies
124
232
  @retry_policies || []
125
233
  end
@@ -127,6 +235,7 @@ module Phronomy
127
235
  # Injectable sleep callable for testing.
128
236
  # Defaults to Kernel#sleep.
129
237
  # @return [#call]
238
+ # @api private
130
239
  def _sleep_proc
131
240
  @_sleep_proc || method(:sleep)
132
241
  end
@@ -147,24 +256,37 @@ module Phronomy
147
256
  # Injects "enum" entries for any param declared with enum: [...].
148
257
  def params_schema
149
258
  schema = super
150
- return schema if schema.nil? || self.class.param_enums.empty?
259
+ return schema if schema.nil?
151
260
 
152
- enums = self.class.param_enums
153
261
  properties = schema.dig("properties") || schema.dig(:properties)
154
262
  return schema unless properties
155
263
 
156
- enums.each do |param_name, values|
264
+ # Inject enum values for params declared with enum: [...].
265
+ unless self.class.param_enums.empty?
266
+ enums = self.class.param_enums
267
+ enums.each do |param_name, values|
268
+ key = properties.key?(param_name.to_s) ? param_name.to_s : param_name.to_sym
269
+ next unless properties[key]
270
+
271
+ param_type = properties[key]["type"]
272
+ properties[key]["enum"] = values.map do |v|
273
+ case param_type
274
+ when "integer" then v.is_a?(Integer) ? v : Integer(v.to_s)
275
+ when "number" then v.is_a?(Numeric) ? v : Float(v.to_s)
276
+ else v.to_s
277
+ end
278
+ end
279
+ end
280
+ end
281
+
282
+ # Inject nested properties for :object params (issue #162).
283
+ # Without this the LLM sees only { "type": "object" } with no field
284
+ # definitions, making it unable to populate nested object params.
285
+ self.class.param_schemas.each do |param_name, nested|
157
286
  key = properties.key?(param_name.to_s) ? param_name.to_s : param_name.to_sym
158
287
  next unless properties[key]
159
288
 
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
289
+ properties[key]["properties"] = nested_schema_to_json_schema(nested)
168
290
  end
169
291
 
170
292
  schema
@@ -174,10 +296,18 @@ module Phronomy
174
296
  # the on_error policy, and wrap errors as ToolError.
175
297
  #
176
298
  # 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)
299
+ # 1. Early cancellation check (kwarg token takes precedence over thread-local).
300
+ # 2. Schema validation (type + enum checks).
301
+ # 3. Inject +cancellation_token:+ into args when +execute+ opts in.
302
+ # 4. Call super(validated_args) inside a retry loop.
303
+ # 5. On persistent failure, apply on_error policy.
304
+ #
305
+ # @param args [Hash]
306
+ # @param cancellation_token [Phronomy::CancellationToken, nil] optional; takes precedence over the thread-local token
307
+ # @api public
308
+ def call(args, cancellation_token: nil)
309
+ ct = cancellation_token
310
+ ct&.raise_if_cancelled!
181
311
  validated_args, schema_error = validate_and_coerce(args)
182
312
  if schema_error
183
313
  case self.class.on_schema_error
@@ -188,18 +318,46 @@ module Phronomy
188
318
  return "Schema validation failed: #{schema_error}"
189
319
  end
190
320
  end
191
- with_tool_retry { super(validated_args) }
321
+ validated_args = validated_args.merge(cancellation_token: ct) if ct && execute_accepts_cancellation_token?
322
+ result = with_tool_retry { super(validated_args) }
323
+ truncate_result_if_needed(result)
192
324
  rescue Phronomy::ToolError
193
325
  raise
326
+ rescue Phronomy::CancellationError
327
+ raise
194
328
  rescue => e
195
329
  case self.class.on_error
196
- when :return_empty
197
- []
330
+ when :return_empty, :suppress
331
+ msg = "[Phronomy] Tool #{self.class.name} suppressed error: #{e.class}: #{e.message}"
332
+ if Phronomy.configuration.logger
333
+ Phronomy.configuration.logger.warn(msg)
334
+ else
335
+ warn msg
336
+ end
337
+ "Tool error suppressed: #{e.message}"
198
338
  else
199
339
  raise Phronomy::ToolError, "#{self.class.name} execution failed: #{e.message}"
200
340
  end
201
341
  end
202
342
 
343
+ # Invokes this tool asynchronously and returns a {Phronomy::Task}.
344
+ #
345
+ # Routing is governed by the class-level {.execution_mode} setting.
346
+ # Delegates to {Phronomy::ToolExecutor.call_async} which is the single
347
+ # place in the framework that applies the execution-mode routing rules.
348
+ #
349
+ # @param args [Hash]
350
+ # @param cancellation_token [Phronomy::CancellationToken, nil]
351
+ # @return [#await]
352
+ # @api public
353
+ def call_async(args, cancellation_token: nil)
354
+ Phronomy::ToolExecutor.call_async(
355
+ tool: self,
356
+ args: args,
357
+ cancellation_token: cancellation_token
358
+ )
359
+ end
360
+
203
361
  # Instance method accessor — delegates to the class-level flag.
204
362
  def requires_approval
205
363
  self.class.requires_approval
@@ -226,12 +384,52 @@ module Phronomy
226
384
  # WeatherService.fetch(location).to_s
227
385
  # end
228
386
  # end
387
+ # @api public
229
388
  def execute(**_args)
230
389
  raise NotImplementedError, "#{self.class}#execute is not implemented"
231
390
  end
232
391
 
233
392
  private
234
393
 
394
+ # Returns true when the #execute method declares a +cancellation_token:+
395
+ # keyword parameter, indicating it opts into cooperative cancellation.
396
+ def execute_accepts_cancellation_token?
397
+ method(:execute).parameters.any? do |type, name|
398
+ name == :cancellation_token && %i[key keyreq].include?(type)
399
+ end
400
+ end
401
+
402
+ # Truncates the result string when it exceeds the configured maximum size.
403
+ # Uses the per-tool limit first, then the global configuration limit.
404
+ # Returns the original result when no limit is configured.
405
+ def truncate_result_if_needed(result)
406
+ max = self.class.max_result_size || Phronomy.configuration.tool_result_max_size
407
+ return result unless max && result.respond_to?(:length) && result.length > max
408
+
409
+ msg = "[Phronomy] Tool #{self.class.name} result truncated " \
410
+ "(#{result.length} chars > #{max} limit)"
411
+ if Phronomy.configuration.logger
412
+ Phronomy.configuration.logger.warn(msg)
413
+ else
414
+ warn msg
415
+ end
416
+ "#{result[0, max]}...[truncated]"
417
+ end
418
+
419
+ # Returns a copy of +args+ with redacted parameter values replaced by
420
+ # +"[REDACTED]"+. Used for logging and tracing.
421
+ # @param args [Hash]
422
+ # @return [Hash]
423
+ # @api private
424
+ def redacted_args(args)
425
+ redacted = self.class.redact_params
426
+ return args if redacted.empty?
427
+
428
+ args.each_with_object({}) do |(k, v), h|
429
+ h[k] = redacted.include?(k.to_sym) ? "[REDACTED]" : v
430
+ end
431
+ end
432
+
235
433
  # Executes the given block inside a retry loop driven by the class-level
236
434
  # retry_policies. Each policy matches by exception class; the first matching
237
435
  # policy governs the wait and retry count. Raises immediately when no policy
@@ -261,6 +459,7 @@ module Phronomy
261
459
  # @param base [Float] base wait time in seconds
262
460
  # @param attempt [Integer] zero-based attempt index
263
461
  # @return [Float]
462
+ # @api public
264
463
  def compute_retry_wait(strategy, base, attempt)
265
464
  case strategy
266
465
  when :exponential
@@ -279,6 +478,7 @@ module Phronomy
279
478
  #
280
479
  # @param args [Hash] raw args passed to #call (string or symbol keys)
281
480
  # @return [Array(Hash, String|nil)] [possibly_coerced_args, error_message_or_nil]
481
+ # @api public
282
482
  def validate_and_coerce(args)
283
483
  return [args, nil] if self.class.parameters.empty?
284
484
 
@@ -304,6 +504,15 @@ module Phronomy
304
504
  return [nil, error] if error
305
505
  end
306
506
 
507
+ # Recursively validate nested object properties when declared.
508
+ if param.type.to_sym == :object
509
+ nested_schema = self.class.param_schemas[name]
510
+ if nested_schema
511
+ error = validate_nested_object(value, nested_schema, name.to_s)
512
+ return [nil, error] if error
513
+ end
514
+ end
515
+
307
516
  enum_vals = self.class.param_enums[name]
308
517
  if enum_vals && !enum_vals.map(&:to_s).include?(value.to_s)
309
518
  return [nil, "parameter '#{name}' must be one of: #{enum_vals.join(", ")} (got: #{value.inspect})"]
@@ -312,9 +521,69 @@ module Phronomy
312
521
  result[name] = value
313
522
  end
314
523
 
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]
524
+ # Reject any keys not covered by declared parameters to prevent silent
525
+ # parameter injection (e.g. via prompt injection).
526
+ extra = normalized.keys - self.class.parameters.keys
527
+ unless extra.empty?
528
+ return [nil, "unknown parameter(s): #{extra.inspect}"]
529
+ end
530
+
531
+ [result, nil]
532
+ end
533
+
534
+ # Converts the internal normalized nested schema (from param_schemas) to
535
+ # a JSON Schema +properties+ hash suitable for inclusion in the LLM tool
536
+ # definition (issue #162).
537
+ #
538
+ # @param nested [Hash{Symbol=>Hash}] normalized schema from param_schemas
539
+ # @return [Hash{String=>Hash}] JSON Schema properties
540
+ # @api public
541
+ def nested_schema_to_json_schema(nested)
542
+ nested.each_with_object({}) do |(prop_name, spec), acc|
543
+ entry = {"type" => spec[:type].to_s}
544
+ entry["description"] = spec[:desc] if spec[:desc]
545
+ entry["enum"] = spec[:enum] if spec[:enum]
546
+ entry["properties"] = nested_schema_to_json_schema(spec[:properties]) if spec[:properties]
547
+ acc[prop_name.to_s] = entry
548
+ end
549
+ end
550
+
551
+ # Recursively validates +value+ (a Hash) against a +properties+ schema.
552
+ # Returns an error message string or nil.
553
+ #
554
+ # @param value [Hash] the object value to validate
555
+ # @param properties [Hash{Symbol=>Hash}] nested schema from param_schemas
556
+ # @param path [String] dot-separated field path for error messages
557
+ # @api public
558
+ def validate_nested_object(value, properties, path)
559
+ return "field '#{path}' must be an object (Hash)" unless value.is_a?(Hash)
560
+
561
+ normalized = value.transform_keys(&:to_sym)
562
+
563
+ # Reject extra keys not declared in the schema (issue #166).
564
+ extra = normalized.keys - properties.keys
565
+ unless extra.empty?
566
+ return "nested field '#{path}' contains undeclared key(s): #{extra.inspect}"
567
+ end
568
+
569
+ properties.each do |fname, spec|
570
+ field_path = "#{path}.#{fname}"
571
+ field_value = normalized[fname]
572
+
573
+ if field_value.nil?
574
+ return "nested required field '#{field_path}' is missing" if spec[:required]
575
+ next
576
+ end
577
+
578
+ error = type_error(field_value, spec[:type])
579
+ return "nested field '#{field_path}': #{error}" if error
580
+
581
+ next unless spec[:type].to_sym == :object && spec[:properties]
582
+
583
+ error = validate_nested_object(field_value, spec[:properties], field_path)
584
+ return error if error
585
+ end
586
+ nil
318
587
  end
319
588
 
320
589
  # Returns a type-error message string if +value+ does not match +declared_type+,
@@ -322,6 +591,7 @@ module Phronomy
322
591
  #
323
592
  # @param value [Object]
324
593
  # @param declared_type [Symbol, String] e.g. :string, :integer, :number, :boolean, :array, :object
594
+ # @api public
325
595
  def type_error(value, declared_type)
326
596
  return nil if value.nil?
327
597