phronomy 0.16.0 → 0.17.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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +54 -0
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +124 -92
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +4 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +1 -1
  10. data/benchmark/bench_tool_schema.rb +2 -35
  11. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  12. data/docs/decisions/010-cooperative-first-concurrency.md +7 -0
  13. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
  14. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  15. data/lib/phronomy/agent/agent_invocation.rb +2 -36
  16. data/lib/phronomy/agent/agent_invocation_session_builder.rb +156 -93
  17. data/lib/phronomy/agent/agent_root.rb +1 -2
  18. data/lib/phronomy/agent/base.rb +135 -314
  19. data/lib/phronomy/agent/context/capability/base.rb +166 -297
  20. data/lib/phronomy/agent/context_assembler.rb +65 -29
  21. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
  22. data/lib/phronomy/agent/context_plan_validator.rb +0 -33
  23. data/lib/phronomy/agent/execution_coordinator.rb +0 -1
  24. data/lib/phronomy/agent/journal_projection.rb +28 -2
  25. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
  26. data/lib/phronomy/agent/shared_state.rb +46 -138
  27. data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
  28. data/lib/phronomy/agent/tool_invocation.rb +108 -314
  29. data/lib/phronomy/agent.rb +6 -10
  30. data/lib/phronomy/configuration.rb +15 -158
  31. data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
  32. data/lib/phronomy/engine/runtime.rb +15 -230
  33. data/lib/phronomy/engine/task_group.rb +30 -102
  34. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  35. data/lib/phronomy/multi_agent/orchestrator.rb +152 -204
  36. data/lib/phronomy/multi_agent/team_coordinator.rb +42 -133
  37. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  38. data/lib/phronomy/version.rb +1 -1
  39. data/lib/phronomy.rb +3 -120
  40. data/scripts/api_snapshot.rb +1 -12
  41. metadata +3 -9
  42. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  43. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  44. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  45. data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
  46. data/lib/phronomy/knowledge_source.rb +0 -12
  47. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  48. data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
@@ -5,35 +5,8 @@ module Phronomy
5
5
  module Context
6
6
  module Capability
7
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
- # - on_error : error-handling policy (:raise or :return_empty)
12
- # - on_schema_error : behavior when LLM passes schema-violating arguments
13
- # :return_error (default), :raise, or :coerce
14
- # - requires_approval : Boolean/callable Tool-side approval default
15
- # - approval_facts : callable exposing semantic facts to Agent policy
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
- # on_error :return_empty
25
- #
26
- # def execute(query:, lang: "en")
27
- # KnowledgeBase.search(query, lang: lang)
28
- # end
29
- # end
30
8
  class Base < RubyLLM::Tool
31
9
  class << self
32
- # Sets an explicit function name to expose to the LLM, bypassing RubyLLM's
33
- # automatic CamelCase-to-snake_case conversion.
34
- # When omitted, RubyLLM's default conversion applies (e.g. WeatherTool → "weather").
35
- #
36
- # @param value [String, nil] the exact function name the LLM will see
37
10
  # @api public
38
11
  def tool_name(value = nil)
39
12
  return @tool_name if value.nil?
@@ -41,131 +14,153 @@ module Phronomy
41
14
  @tool_name = value.to_s
42
15
  end
43
16
 
44
- # Extends RubyLLM::Tool.param with optional +enum:+ and +properties:+ keywords.
45
- # - +enum:+ restricts allowed values; injected into the JSON Schema.
46
- # - +properties:+ declares nested fields for :object type params. Each
47
- # entry is a Hash mapping field name (Symbol) to a spec Hash with keys:
48
- # :type (Symbol, default :string), :required (Boolean, default false),
49
- # and optionally :properties (for further nesting).
50
- #
51
- # @param name [Symbol] parameter name
52
- # @param enum [Array, nil] allowed values
53
- # @param properties [Hash, nil] nested schema for :object params
54
- # @param options [Hash] forwarded to RubyLLM::Tool.param
17
+ # RubyLLM stores Tool descriptions in a class-instance variable.
18
+ # Preserve normal class inheritance semantics so Phronomy's anonymous
19
+ # decorator subclasses do not lose their parent's description.
20
+ # @api public
21
+ def description(text = nil)
22
+ unless text
23
+ return @description if instance_variable_defined?(:@description)
24
+ return superclass.description if superclass.respond_to?(:description)
25
+
26
+ return nil
27
+ end
28
+
29
+ @description = text
30
+ end
31
+ alias_method :desc, :description
32
+
33
+ # RubyLLM stores declared parameters in a class-instance variable.
34
+ # Copy the parent's registry on first access so child classes inherit
35
+ # existing parameters while remaining free to add their own.
36
+ # @api public
37
+ def parameters
38
+ return @parameters if instance_variable_defined?(:@parameters)
39
+
40
+ parent = superclass.respond_to?(:parameters) ? superclass.parameters : {}
41
+ @parameters = parent.dup
42
+ end
43
+
44
+ # RubyLLM stores an explicit .params schema definition in a
45
+ # class-instance variable. Readers must fall back to the parent.
46
+ # @api public
47
+ def params_schema_definition
48
+ return @params_schema_definition if instance_variable_defined?(:@params_schema_definition)
49
+ return superclass.params_schema_definition if superclass.respond_to?(:params_schema_definition)
50
+
51
+ nil
52
+ end
53
+
54
+ # RubyLLM provider params are also class-instance state. Copy them on
55
+ # first access to preserve inheritance without sharing the top-level
56
+ # mutable Hash between parent and child.
57
+ # @api public
58
+ def provider_params
59
+ return @provider_params if instance_variable_defined?(:@provider_params)
60
+
61
+ parent = superclass.respond_to?(:provider_params) ? superclass.provider_params : {}
62
+ @provider_params = duplicate_configuration(parent)
63
+ end
64
+
55
65
  # @api public
56
66
  def param(name, enum: nil, properties: nil, **options)
57
67
  super(name, **options)
58
- param_enums[name] = enum if enum
68
+ param_enums[name] = duplicate_configuration(enum) if enum
59
69
  param_schemas[name] = normalize_nested_schema(properties) if properties
60
70
  end
61
71
 
62
- # Returns the enum constraints registered via .param.
63
- # @return [Hash{Symbol => Array}]
64
72
  # @api public
65
73
  def param_enums
66
- @param_enums ||= {}
74
+ return @param_enums if instance_variable_defined?(:@param_enums)
75
+
76
+ parent = superclass.respond_to?(:param_enums) ? superclass.param_enums : {}
77
+ @param_enums = duplicate_configuration(parent)
67
78
  end
68
79
 
69
- # Returns nested schema definitions registered via .param(properties: ...).
70
- # @return [Hash{Symbol => Hash}]
71
80
  # @api public
72
- # mutant:disable - neutral failure: unparser round-trip produces different source
73
81
  def param_schemas
74
- @param_schemas ||= {}
82
+ return @param_schemas if instance_variable_defined?(:@param_schemas)
83
+
84
+ parent = superclass.respond_to?(:param_schemas) ? superclass.param_schemas : {}
85
+ @param_schemas = duplicate_configuration(parent)
75
86
  end
76
87
 
77
88
  private
78
89
 
79
- # Recursively normalises a properties hash so all keys are Symbols and
80
- # each spec has a :type key.
81
- # mutant:disable
90
+ def duplicate_configuration(value)
91
+ case value
92
+ when Hash
93
+ value.to_h do |key, child|
94
+ [key, duplicate_configuration(child)]
95
+ end
96
+ when Array
97
+ value.map { |child| duplicate_configuration(child) }
98
+ else
99
+ value
100
+ end
101
+ end
102
+
82
103
  def normalize_nested_schema(props)
83
104
  props.transform_keys(&:to_sym).transform_values do |spec|
84
- s = spec.transform_keys(&:to_sym)
85
- s[:type] ||= :string
86
- s[:properties] = normalize_nested_schema(s[:properties]) if s[:properties]
87
- s
105
+ normalized = spec.transform_keys(&:to_sym)
106
+ normalized[:type] ||= :string
107
+ if normalized[:properties]
108
+ normalized[:properties] = normalize_nested_schema(normalized[:properties])
109
+ end
110
+ normalized
88
111
  end
89
112
  end
90
113
 
91
114
  public
92
115
 
93
- # Sets or reads the execution mode for this tool.
94
- #
95
- # Execution mode is the concurrency contract declaration for the tool.
96
- # In Phronomy's non-preemptive, cooperative concurrency model it controls
97
- # which runtime resource is used to dispatch the tool:
98
- #
99
- # | Mode | Dispatcher | Constraint |
100
- # |------|-----------|------------|
101
- # | +:cooperative+ | +Runtime.instance.spawn+ (scheduler task) | *Must not* block the scheduler thread; use only for in-memory computation |
102
- # | +:blocking_io+ | {Phronomy::Concurrency::BlockingAdapterPool} (bounded thread pool) | **Default**. Safe for all blocking I/O (HTTP, DB, file) |
103
- # | +:cpu_bound+ | Falls back to +:blocking_io+ + emits a warning | No dedicated process pool yet; use +:blocking_io+ explicitly to suppress the warning |
104
- # | +:external_process+ | Falls back to +:blocking_io+ | No process manager yet |
105
- #
106
- # Tools that perform network calls, file I/O, or database queries should use
107
- # +:blocking_io+ (the default). Tools that only perform in-memory computation
108
- # may declare +:cooperative+ for lower overhead.
109
- #
110
- # @param value [Symbol, nil] when nil, returns the current value
111
- # @return [Symbol] the current execution mode (default :blocking_io)
112
116
  # @api public
113
- # mutant:disable
114
117
  def execution_mode(value = nil)
115
- return @execution_mode || :blocking_io if value.nil?
118
+ if value.nil?
119
+ return @execution_mode if instance_variable_defined?(:@execution_mode)
120
+ return superclass.execution_mode if superclass.respond_to?(:execution_mode)
121
+
122
+ return :blocking_io
123
+ end
116
124
 
117
125
  valid = %i[cooperative blocking_io cpu_bound external_process]
118
126
  unless valid.include?(value)
119
- raise ArgumentError, "execution_mode must be one of #{valid.inspect}, got #{value.inspect}"
127
+ raise ArgumentError,
128
+ "execution_mode must be one of #{valid.inspect}, got #{value.inspect}"
120
129
  end
121
-
122
130
  @execution_mode = value
123
131
  end
124
132
 
125
- # Configures error-handling behavior when +execute+ raises an unexpected error.
126
- #
127
- # @param behavior [Symbol]
128
- # :raise (default) — re-raise as Phronomy::ToolError, stopping the agent.
129
- # :suppress — suppress the error and return a descriptive string so
130
- # the LLM can recover on the next turn.
131
- # :return_empty — *deprecated* alias for +:suppress+; will be removed in a
132
- # future major release.
133
+ # Configures execution-error handling. Supported values are :raise
134
+ # and :suppress only.
133
135
  # @api public
134
136
  def on_error(behavior = nil)
135
- return @on_error || :raise if behavior.nil?
137
+ if behavior.nil?
138
+ return @on_error if instance_variable_defined?(:@on_error)
139
+ return superclass.on_error if superclass.respond_to?(:on_error)
136
140
 
137
- if behavior == :return_empty
138
- msg = "[Phronomy] on_error :return_empty is deprecated; use :suppress instead"
139
- if Phronomy.configuration.logger
140
- Phronomy.configuration.logger.warn(msg)
141
- else
142
- warn msg
143
- end
141
+ return :raise
142
+ end
143
+
144
+ valid = %i[raise suppress]
145
+ unless valid.include?(behavior)
146
+ raise ArgumentError,
147
+ "on_error must be one of #{valid.inspect}, got #{behavior.inspect}"
144
148
  end
145
149
  @on_error = behavior
146
150
  end
147
151
 
148
- # Configures how this tool responds when the LLM passes arguments that violate
149
- # the declared parameter types or enum constraints.
150
- #
151
- # @param behavior [Symbol]
152
- # :return_error (default) — return a descriptive error string as the tool result
153
- # so the LLM can self-correct on the next turn.
154
- # :raise — raise Phronomy::ToolError, stopping the agent loop.
155
- # :coerce — attempt type coercion (e.g. "42" → 42 for :integer);
156
- # falls back to :return_error when coercion is not possible.
157
152
  # @api public
158
- # mutant:disable - neutral failure: unparser round-trip produces different source
159
153
  def on_schema_error(behavior = nil)
160
- return @on_schema_error || :return_error if behavior.nil?
154
+ if behavior.nil?
155
+ return @on_schema_error if instance_variable_defined?(:@on_schema_error)
156
+ return superclass.on_schema_error if superclass.respond_to?(:on_schema_error)
157
+
158
+ return :return_error
159
+ end
161
160
 
162
161
  @on_schema_error = behavior
163
162
  end
164
163
 
165
- # Configures the Tool-side default for approval. A callable receives
166
- # ApprovalEvaluationRequest and must return true or false. It is
167
- # evaluated on the Runtime authorization pool, not in Tool#call.
168
- # @param value [Boolean, #call]
169
164
  # @api public
170
165
  def requires_approval(value = :__unset__, &block)
171
166
  if block
@@ -186,8 +181,6 @@ module Phronomy
186
181
  end
187
182
  end
188
183
 
189
- # Registers a Tool-specific semantic fact extractor for authorization.
190
- # The block receives validated immutable arguments and read-only context.
191
184
  # @api public
192
185
  def approval_facts(&block)
193
186
  if block
@@ -199,13 +192,7 @@ module Phronomy
199
192
  end
200
193
  end
201
194
 
202
- # Marks one or more parameter names as sensitive so their values are
203
- # replaced with +"[REDACTED]"+ in log and trace output.
204
- #
205
- # @param names [Array<Symbol>] parameter names to redact
206
- # @return [Array<Symbol>] the full list of redacted param names
207
195
  # @api public
208
- # mutant:disable
209
196
  def redact_params(*names)
210
197
  if names.empty?
211
198
  parent = superclass.respond_to?(:redact_params) ? superclass.redact_params : []
@@ -215,34 +202,23 @@ module Phronomy
215
202
  end
216
203
  end
217
204
 
218
- # Sets a per-tool maximum result size (in characters).
219
- # Overrides the global +Phronomy.configuration.tool_result_max_size+ when set.
220
- # Set to +nil+ to inherit the global limit.
221
- #
222
- # @param value [Integer, nil]
223
205
  # @api public
224
206
  def max_result_size(value = :__unset__)
225
- return @max_result_size if value == :__unset__
207
+ if value == :__unset__
208
+ return @max_result_size if instance_variable_defined?(:@max_result_size)
209
+ return superclass.max_result_size if superclass.respond_to?(:max_result_size)
210
+
211
+ return nil
212
+ end
226
213
 
227
214
  @max_result_size = value
228
215
  end
229
216
  end
230
217
 
231
- # Returns the function name exposed to the LLM.
232
- # Uses the class-level tool_name if set; otherwise falls back to RubyLLM's
233
- # automatic conversion (CamelCase → snake_case, strips trailing "_tool").
234
- # mutant:disable - neutral failure: unparser round-trip produces different source
235
218
  def name
236
219
  self.class.tool_name || super
237
220
  end
238
221
 
239
- # Returns the JSON Schema for this tool's parameters.
240
- # Injects "enum" entries for any param declared with enum: [...].
241
- # mutant:disable - genuine equivalent mutations:
242
- # 1. `|| schema.dig(:properties)`: dead code because RubyLLM::Tool always returns a
243
- # string-keyed hash; schema.dig(:properties) is always nil in practice.
244
- # 2. `return schema unless properties` guard: dead code when schema is non-nil because
245
- # RubyLLM::Tool always includes a "properties" key when parameters are declared.
246
222
  def params_schema
247
223
  schema = super
248
224
  return schema if schema.nil?
@@ -250,103 +226,76 @@ module Phronomy
250
226
  properties = schema.dig("properties") || schema.dig(:properties)
251
227
  return schema unless properties
252
228
 
253
- # Inject enum values for params declared with enum: [...].
254
- unless self.class.param_enums.empty?
255
- enums = self.class.param_enums
256
- enums.each do |param_name, values|
257
- key = properties.key?(param_name.to_s) ? param_name.to_s : param_name.to_sym
258
- next unless properties[key]
259
-
260
- param_type = properties[key]["type"]
261
- properties[key]["enum"] = values.map do |v|
262
- case param_type
263
- when "integer" then v.is_a?(Integer) ? v : Integer(v.to_s)
264
- when "number" then v.is_a?(Numeric) ? v : Float(v.to_s)
265
- when "boolean"
266
- unless v == true || v == false
267
- raise ArgumentError,
268
- "boolean enum values must be true or false (got: #{v.inspect})"
269
- end
270
- v
271
- else v.to_s
229
+ self.class.param_enums.each do |param_name, values|
230
+ key = properties.key?(param_name.to_s) ? param_name.to_s : param_name.to_sym
231
+ next unless properties[key]
232
+
233
+ param_type = properties[key]["type"]
234
+ properties[key]["enum"] = values.map do |value|
235
+ case param_type
236
+ when "integer"
237
+ value.is_a?(Integer) ? value : Integer(value.to_s)
238
+ when "number"
239
+ value.is_a?(Numeric) ? value : Float(value.to_s)
240
+ when "boolean"
241
+ unless value == true || value == false
242
+ raise ArgumentError,
243
+ "boolean enum values must be true or false (got: #{value.inspect})"
272
244
  end
245
+ value
246
+ else
247
+ value.to_s
273
248
  end
274
249
  end
275
250
  end
276
251
 
277
- # Inject nested properties for :object params (issue #162).
278
- # Without this the LLM sees only { "type": "object" } with no field
279
- # definitions, making it unable to populate nested object params.
280
252
  self.class.param_schemas.each do |param_name, nested|
281
253
  key = properties.key?(param_name.to_s) ? param_name.to_s : param_name.to_sym
282
254
  next unless properties[key]
283
-
284
255
  properties[key]["properties"] = nested_schema_to_json_schema(nested)
285
256
  end
286
257
 
287
258
  schema
288
259
  end
289
260
 
290
- # Overrides RubyLLM::Tool#call to apply schema validation,
291
- # the on_error policy, and wrap errors as ToolError.
292
- #
293
- # Execution order:
294
- # 1. Early cancellation check (kwarg token takes precedence over thread-local).
295
- # 2. Schema validation (type + enum checks).
296
- # 3. Inject +cancellation_token:+ into args when +execute+ opts in.
297
- # 4. Call super(validated_args) exactly once.
298
- # 5. On failure, apply on_error policy.
299
- #
300
- # @param args [Hash]
301
- # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil] optional; takes precedence over the thread-local token
302
261
  # @api public
303
- # mutant:disable
304
262
  def call(args, cancellation_token: nil)
305
- ct = cancellation_token
306
- ct&.raise_if_cancelled!
263
+ cancellation_token&.raise_if_cancelled!
307
264
  validated_args, schema_error = validate_and_coerce(args)
308
265
  if schema_error
309
266
  case self.class.on_schema_error
310
267
  when :raise
311
- raise Phronomy::ToolError, "#{self.class.name} schema error: #{schema_error}"
268
+ raise Phronomy::ToolError,
269
+ "#{self.class.name} schema error: #{schema_error}"
312
270
  else
313
- # :return_error (default) and coerce fallback
314
271
  return "Schema validation failed: #{schema_error}"
315
272
  end
316
273
  end
317
- validated_args = validated_args.merge(cancellation_token: ct) if ct && execute_accepts_cancellation_token?
274
+
275
+ if cancellation_token && execute_accepts_cancellation_token?
276
+ validated_args = validated_args.merge(cancellation_token: cancellation_token)
277
+ end
318
278
  result = super(validated_args)
319
279
  truncate_result_if_needed(result)
320
- rescue Phronomy::ToolError
321
- raise
322
- rescue Phronomy::CancellationError
280
+ rescue Phronomy::ToolError, Phronomy::CancellationError
323
281
  raise
324
- rescue => e
325
- case self.class.on_error
326
- when :return_empty, :suppress
327
- msg = "[Phronomy] Tool #{self.class.name} suppressed error: #{e.class}: #{e.message}"
282
+ rescue => error
283
+ if self.class.on_error == :suppress
284
+ msg = "[Phronomy] Tool #{self.class.name} suppressed error: " \
285
+ "#{error.class}: #{error.message}"
328
286
  if Phronomy.configuration.logger
329
287
  Phronomy.configuration.logger.warn(msg)
330
288
  else
331
289
  warn msg
332
290
  end
333
- "Tool error suppressed: #{e.message}"
291
+ "Tool error suppressed: #{error.message}"
334
292
  else
335
- raise Phronomy::ToolError, "#{self.class.name} execution failed: #{e.message}"
293
+ raise Phronomy::ToolError,
294
+ "#{self.class.name} execution failed: #{error.message}"
336
295
  end
337
296
  end
338
297
 
339
- # Invokes this tool asynchronously and returns a {Phronomy::Task}.
340
- #
341
- # Routing is governed by the class-level {.execution_mode} setting.
342
- # Delegates to {Phronomy::Agent::ToolExecutor.call_async} which is the single
343
- # place in the framework that applies the execution-mode routing rules.
344
- #
345
- # @param args [Hash]
346
- # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
347
- # @return [#await]
348
298
  # @api public
349
- # mutant:disable
350
299
  def call_async(args, cancellation_token: nil, config: {})
351
300
  Phronomy::Agent::ToolExecutor.call_async(
352
301
  tool: self,
@@ -356,47 +305,24 @@ module Phronomy
356
305
  )
357
306
  end
358
307
 
359
- # Instance method accessor — delegates to the class-level flag.
360
308
  def requires_approval
361
309
  self.class.requires_approval
362
310
  end
363
311
 
364
- # Instance method for requires_approval? (convenience accessor).
365
- # mutant:disable - genuine equivalent: self.requires_approval delegates to
366
- # self.class.requires_approval via the instance method defined above, so
367
- # both expressions produce the same value.
368
312
  def requires_approval?
369
313
  self.class.requires_approval
370
314
  end
371
315
 
372
- # Origin metadata consumed by ToolInvocation authorization policy.
373
316
  # @api public
374
317
  def tool_origin
375
318
  :local
376
319
  end
377
320
 
378
- # Display-safe transport/origin metadata for approval requests.
379
321
  # @api public
380
322
  def approval_metadata
381
323
  {}
382
324
  end
383
325
 
384
- # Override this method to implement the tool's logic.
385
- #
386
- # The method receives the declared {.param} fields as keyword arguments.
387
- # The return value is passed back to the LLM as the tool result.
388
- #
389
- # @abstract Subclasses must implement this method.
390
- # @return [String] result string returned to the LLM
391
- # @example
392
- # class WeatherTool < Phronomy::Agent::Context::Capability::Base
393
- # description "Get current weather"
394
- # param :location, type: :string, desc: "City name"
395
- #
396
- # def execute(location:)
397
- # WeatherService.fetch(location).to_s
398
- # end
399
- # end
400
326
  # @api public
401
327
  def execute(**_args)
402
328
  raise NotImplementedError, "#{self.class}#execute is not implemented"
@@ -404,24 +330,18 @@ module Phronomy
404
330
 
405
331
  private
406
332
 
407
- # Returns true when the #execute method declares a +cancellation_token:+
408
- # keyword parameter, indicating it opts into cooperative cancellation.
409
- # mutant:disable
410
333
  def execute_accepts_cancellation_token?
411
- method(:execute).parameters.any? do |type, name| # mutant:disable
334
+ method(:execute).parameters.any? do |type, name|
412
335
  name == :cancellation_token && %i[key keyreq].include?(type)
413
336
  end
414
337
  end
415
338
 
416
- # Truncates the result string when it exceeds the configured maximum size.
417
- # Uses the per-tool limit first, then the global configuration limit.
418
- # Returns the original result when no limit is configured.
419
339
  def truncate_result_if_needed(result)
420
340
  max = self.class.max_result_size || Phronomy.configuration.tool_result_max_size
421
341
  return result unless max && result.respond_to?(:length) && result.length > max
422
342
 
423
343
  msg = "[Phronomy] Tool #{self.class.name} result truncated " \
424
- "(#{result.length} chars > #{max} limit)"
344
+ "(#{result.length} chars > #{max} limit)"
425
345
  if Phronomy.configuration.logger
426
346
  Phronomy.configuration.logger.warn(msg)
427
347
  else
@@ -430,45 +350,25 @@ module Phronomy
430
350
  "#{result[0, max]}...[truncated]"
431
351
  end
432
352
 
433
- # Returns a copy of +args+ with redacted parameter values replaced by
434
- # +"[REDACTED]"+. Used for logging and tracing.
435
- # @param args [Hash]
436
- # @return [Hash]
437
- # @api private
438
353
  def redacted_args(args)
439
354
  redacted = self.class.redact_params
440
355
  return args if redacted.empty?
441
356
 
442
- args.each_with_object({}) do |(k, v), h|
443
- h[k] = redacted.include?(k.to_sym) ? "[REDACTED]" : v
357
+ args.each_with_object({}) do |(key, value), result|
358
+ result[key] = redacted.include?(key.to_sym) ? "[REDACTED]" : value
444
359
  end
445
360
  end
446
361
 
447
- # Validates args against declared parameter types and enum constraints.
448
- # When on_schema_error is :coerce, attempts type coercion first.
449
- #
450
- # @param args [Hash] raw args passed to #call (string or symbol keys)
451
- # @return [Array(Hash, String|nil)] [possibly_coerced_args, error_message_or_nil]
452
- # @api public
453
- # mutant:disable
454
362
  def validate_and_coerce(args)
455
- # mutant:disable - genuine equivalents:
456
- # 1. `return [args, nil]` vs `return [args]`: Ruby multiple assignment
457
- # fills nil for missing elements, so both are identical to callers.
458
- # 2. `self.class.parameters` vs `self.parameters`: RubyLLM::Tool exposes
459
- # `parameters` as both a class method and an instance method that
460
- # delegates to the class method, so both return the same value.
461
363
  return [args, nil] if self.class.parameters.empty?
462
364
 
463
365
  normalized = (args || {}).transform_keys(&:to_sym)
464
366
  coerce_mode = self.class.on_schema_error == :coerce
465
367
  result = {}
466
368
 
467
- self.class.parameters.each do |name, param| # mutant:disable
369
+ self.class.parameters.each do |name, param|
468
370
  value = normalized[name]
469
371
  if value.nil?
470
- # Return a descriptive error for missing required params so the LLM
471
- # can self-correct on the next turn.
472
372
  return [nil, "required parameter '#{name}' is missing"] if param.required
473
373
  next
474
374
  end
@@ -482,7 +382,6 @@ module Phronomy
482
382
  return [nil, error] if error
483
383
  end
484
384
 
485
- # Recursively validate nested object properties when declared.
486
385
  if param.type.to_sym == :object
487
386
  nested_schema = self.class.param_schemas[name]
488
387
  if nested_schema
@@ -493,62 +392,46 @@ module Phronomy
493
392
 
494
393
  enum_vals = self.class.param_enums[name]
495
394
  if enum_vals && !enum_vals.map(&:to_s).include?(value.to_s)
496
- return [nil, "parameter '#{name}' must be one of: #{enum_vals.join(", ")} (got: #{value.inspect})"]
395
+ return [nil,
396
+ "parameter '#{name}' must be one of: #{enum_vals.join(", ")} " \
397
+ "(got: #{value.inspect})"]
497
398
  end
498
399
 
499
400
  result[name] = value
500
401
  end
501
402
 
502
- # Reject any keys not covered by declared parameters to prevent silent
503
- # parameter injection (e.g. via prompt injection).
504
- extra = normalized.keys - self.class.parameters.keys # mutant:disable
403
+ extra = normalized.keys - self.class.parameters.keys
505
404
  unless extra.empty?
506
405
  return [nil, "unknown parameter(s): #{extra.inspect}"]
507
406
  end
508
407
 
509
- [result, nil] # mutant:disable
408
+ [result, nil]
510
409
  end
511
410
 
512
- # Converts the internal normalized nested schema (from param_schemas) to
513
- # a JSON Schema +properties+ hash suitable for inclusion in the LLM tool
514
- # definition (issue #162).
515
- #
516
- # @param nested [Hash{Symbol=>Hash}] normalized schema from param_schemas
517
- # @return [Hash{String=>Hash}] JSON Schema properties
518
- # @api public
519
- # mutant:disable
520
411
  def nested_schema_to_json_schema(nested)
521
- nested.each_with_object({}) do |(prop_name, spec), acc|
412
+ nested.each_with_object({}) do |(prop_name, spec), result|
522
413
  entry = {"type" => spec[:type].to_s}
523
414
  entry["description"] = spec[:desc] if spec[:desc]
524
415
  entry["enum"] = spec[:enum] if spec[:enum]
525
- entry["properties"] = nested_schema_to_json_schema(spec[:properties]) if spec[:properties]
526
- acc[prop_name.to_s] = entry
416
+ if spec[:properties]
417
+ entry["properties"] = nested_schema_to_json_schema(spec[:properties])
418
+ end
419
+ result[prop_name.to_s] = entry
527
420
  end
528
421
  end
529
422
 
530
- # Recursively validates +value+ (a Hash) against a +properties+ schema.
531
- # Returns an error message string or nil.
532
- #
533
- # @param value [Hash] the object value to validate
534
- # @param properties [Hash{Symbol=>Hash}] nested schema from param_schemas
535
- # @param path [String] dot-separated field path for error messages
536
- # @api public
537
- # mutant:disable
538
423
  def validate_nested_object(value, properties, path)
539
424
  return "field '#{path}' must be an object (Hash)" unless value.is_a?(Hash)
540
425
 
541
426
  normalized = value.transform_keys(&:to_sym)
542
-
543
- # Reject extra keys not declared in the schema (issue #166).
544
427
  extra = normalized.keys - properties.keys
545
428
  unless extra.empty?
546
429
  return "nested field '#{path}' contains undeclared key(s): #{extra.inspect}"
547
430
  end
548
431
 
549
- properties.each do |fname, spec|
550
- field_path = "#{path}.#{fname}"
551
- field_value = normalized[fname]
432
+ properties.each do |name, spec|
433
+ field_path = "#{path}.#{name}"
434
+ field_value = normalized[name]
552
435
 
553
436
  if field_value.nil?
554
437
  return "nested required field '#{field_path}' is missing" if spec[:required]
@@ -566,13 +449,6 @@ module Phronomy
566
449
  nil
567
450
  end
568
451
 
569
- # Returns a type-error message string if +value+ does not match +declared_type+,
570
- # or nil if the value is acceptable.
571
- #
572
- # @param value [Object]
573
- # @param declared_type [Symbol, String] e.g. :string, :integer, :number, :boolean, :array, :object
574
- # @api public
575
- # mutant:disable
576
452
  def type_error(value, declared_type)
577
453
  return nil if value.nil?
578
454
 
@@ -583,19 +459,15 @@ module Phronomy
583
459
  when :boolean then [true, false].include?(value)
584
460
  when :array then value.is_a?(Array)
585
461
  when :object then value.is_a?(Hash)
586
- else true # unknown types pass through
462
+ else true
587
463
  end
588
464
 
589
- if ok
590
- nil
591
- else
592
- "parameter '#{value.respond_to?(:keys) ? "(object)" : value.inspect}' expected type #{declared_type}"
593
- end
465
+ return nil if ok
466
+
467
+ shown = value.respond_to?(:keys) ? "(object)" : value.inspect
468
+ "parameter '#{shown}' expected type #{declared_type}"
594
469
  end
595
470
 
596
- # Attempts to coerce +value+ to +declared_type+.
597
- # Returns [coerced_value, nil] on success, [nil, error_message] on failure.
598
- # mutant:disable
599
471
  def coerce_value(value, declared_type)
600
472
  return [value, nil] if value.nil?
601
473
 
@@ -603,11 +475,9 @@ module Phronomy
603
475
  when :string
604
476
  [value.to_s, nil]
605
477
  when :integer
606
- coerced = Integer(value)
607
- [coerced, nil]
478
+ [Integer(value), nil]
608
479
  when :number, :float
609
- coerced = Float(value)
610
- [coerced, nil]
480
+ [Float(value), nil]
611
481
  when :boolean
612
482
  case value.to_s.downcase
613
483
  when "true" then [true, nil]
@@ -615,7 +485,6 @@ module Phronomy
615
485
  else [nil, "parameter cannot be coerced to boolean: #{value.inspect}"]
616
486
  end
617
487
  else
618
- # Arrays, objects, unknown types: pass through as-is
619
488
  [value, nil]
620
489
  end
621
490
  rescue ArgumentError, TypeError