phronomy 0.16.0 → 0.18.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 (123) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +151 -1134
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +69 -1249
  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 +26 -6
  10. data/benchmark/bench_tool_schema.rb +2 -35
  11. data/docs/changelog/0.14-and-earlier.md +1137 -0
  12. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  13. data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
  14. data/docs/decisions/010-cooperative-first-concurrency.md +155 -228
  15. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +2 -2
  16. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  17. data/docs/features.md +87 -0
  18. data/docs/getting-started.md +351 -0
  19. data/docs/migrations/0.15.md +35 -0
  20. data/docs/migrations/0.16.md +43 -0
  21. data/docs/runtime-and-concurrency.md +258 -0
  22. data/examples/workflows/generic_task_event_mapping.rb +14 -6
  23. data/lib/phronomy/agent/agent_invocation.rb +2 -36
  24. data/lib/phronomy/agent/agent_invocation_session_builder.rb +157 -94
  25. data/lib/phronomy/agent/agent_root.rb +1 -2
  26. data/lib/phronomy/agent/async_event_api.rb +3 -3
  27. data/lib/phronomy/agent/base.rb +170 -333
  28. data/lib/phronomy/agent/context/capability/base.rb +177 -298
  29. data/lib/phronomy/agent/context_assembler.rb +65 -29
  30. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
  31. data/lib/phronomy/agent/context_plan_validator.rb +0 -33
  32. data/lib/phronomy/agent/execution_coordinator.rb +6 -7
  33. data/lib/phronomy/agent/journal_projection.rb +28 -2
  34. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
  35. data/lib/phronomy/agent/shared_state.rb +48 -138
  36. data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
  37. data/lib/phronomy/agent/tool_executor.rb +29 -71
  38. data/lib/phronomy/agent/tool_invocation.rb +180 -336
  39. data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
  40. data/lib/phronomy/agent.rb +6 -10
  41. data/lib/phronomy/configuration.rb +4 -171
  42. data/lib/phronomy/diagnostics.rb +12 -41
  43. data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
  44. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
  45. data/lib/phronomy/engine/concurrency/cancellation_token.rb +51 -79
  46. data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
  47. data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
  48. data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
  49. data/lib/phronomy/engine/event_loop.rb +89 -190
  50. data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
  51. data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
  52. data/lib/phronomy/engine/runtime.rb +49 -377
  53. data/lib/phronomy/engine/task.rb +136 -277
  54. data/lib/phronomy/llm_adapter/base.rb +14 -14
  55. data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
  56. data/lib/phronomy/llm_adapter.rb +2 -2
  57. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  58. data/lib/phronomy/metrics.rb +15 -30
  59. data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
  60. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
  61. data/lib/phronomy/multi_agent/handoff.rb +1 -0
  62. data/lib/phronomy/multi_agent/orchestrator.rb +252 -256
  63. data/lib/phronomy/multi_agent/team_coordinator.rb +44 -133
  64. data/lib/phronomy/testing/eval/comparison.rb +23 -0
  65. data/lib/phronomy/testing/eval/dataset.rb +27 -0
  66. data/lib/phronomy/testing/eval/eval_case.rb +13 -0
  67. data/lib/phronomy/testing/eval/eval_result.rb +16 -0
  68. data/lib/phronomy/testing/eval/metrics.rb +43 -0
  69. data/lib/phronomy/testing/eval/runner.rb +52 -0
  70. data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
  71. data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
  72. data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
  73. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
  74. data/lib/phronomy/testing/eval/scorer.rb +10 -0
  75. data/lib/phronomy/testing/eval.rb +9 -0
  76. data/lib/phronomy/testing/fake_clock.rb +6 -53
  77. data/lib/phronomy/testing.rb +2 -6
  78. data/lib/phronomy/tools/agent.rb +141 -6
  79. data/lib/phronomy/vector_store/async_backend.rb +21 -17
  80. data/lib/phronomy/vector_store/base.rb +2 -2
  81. data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
  82. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  83. data/lib/phronomy/version.rb +1 -1
  84. data/lib/phronomy/workflow_runner.rb +2 -4
  85. data/lib/phronomy.rb +7 -121
  86. data/scripts/api_snapshot.rb +4 -15
  87. metadata +24 -38
  88. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  89. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  90. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  91. data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
  92. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
  93. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
  94. data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
  95. data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
  96. data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
  97. data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
  98. data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
  99. data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
  100. data/lib/phronomy/engine/task/backend.rb +0 -80
  101. data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
  102. data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
  103. data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
  104. data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
  105. data/lib/phronomy/engine/task/thread_backend.rb +0 -84
  106. data/lib/phronomy/engine/task_group.rb +0 -265
  107. data/lib/phronomy/eval/comparison.rb +0 -47
  108. data/lib/phronomy/eval/dataset.rb +0 -45
  109. data/lib/phronomy/eval/eval_case.rb +0 -17
  110. data/lib/phronomy/eval/eval_result.rb +0 -29
  111. data/lib/phronomy/eval/metrics.rb +0 -66
  112. data/lib/phronomy/eval/runner.rb +0 -94
  113. data/lib/phronomy/eval/scorer/base.rb +0 -22
  114. data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
  115. data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
  116. data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
  117. data/lib/phronomy/eval/scorer.rb +0 -9
  118. data/lib/phronomy/eval.rb +0 -7
  119. data/lib/phronomy/knowledge_source.rb +0 -12
  120. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  121. data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
  122. data/lib/phronomy/testing/fake_scheduler.rb +0 -104
  123. data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
@@ -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,159 @@ 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.
116
+ # Declares whether Tool work is safe to run cooperatively on the EventLoop
117
+ # or must be offloaded to a bounded worker pool.
94
118
  #
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)
119
+ # Phronomy does not classify the reason for offloading. Blocking I/O,
120
+ # CPU-bound synchronous work, and other long synchronous calls all use
121
+ # +:offloaded+. The application owns that workload classification.
112
122
  # @api public
113
- # mutant:disable
114
123
  def execution_mode(value = nil)
115
- return @execution_mode || :blocking_io if value.nil?
124
+ if value.nil?
125
+ return @execution_mode if instance_variable_defined?(:@execution_mode)
126
+ return superclass.execution_mode if superclass.respond_to?(:execution_mode)
116
127
 
117
- valid = %i[cooperative blocking_io cpu_bound external_process]
118
- unless valid.include?(value)
119
- raise ArgumentError, "execution_mode must be one of #{valid.inspect}, got #{value.inspect}"
128
+ return :offloaded
120
129
  end
121
130
 
131
+ valid = %i[cooperative offloaded]
132
+ unless valid.include?(value)
133
+ raise ArgumentError,
134
+ "execution_mode must be one of #{valid.inspect}, got #{value.inspect}"
135
+ end
122
136
  @execution_mode = value
123
137
  end
124
138
 
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.
139
+ # Configures execution-error handling. Supported values are :raise
140
+ # and :suppress only.
133
141
  # @api public
134
142
  def on_error(behavior = nil)
135
- return @on_error || :raise if behavior.nil?
143
+ if behavior.nil?
144
+ return @on_error if instance_variable_defined?(:@on_error)
145
+ return superclass.on_error if superclass.respond_to?(:on_error)
136
146
 
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
147
+ return :raise
148
+ end
149
+
150
+ valid = %i[raise suppress]
151
+ unless valid.include?(behavior)
152
+ raise ArgumentError,
153
+ "on_error must be one of #{valid.inspect}, got #{behavior.inspect}"
144
154
  end
145
155
  @on_error = behavior
146
156
  end
147
157
 
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
158
  # @api public
158
- # mutant:disable - neutral failure: unparser round-trip produces different source
159
159
  def on_schema_error(behavior = nil)
160
- return @on_schema_error || :return_error if behavior.nil?
160
+ if behavior.nil?
161
+ return @on_schema_error if instance_variable_defined?(:@on_schema_error)
162
+ return superclass.on_schema_error if superclass.respond_to?(:on_schema_error)
163
+
164
+ return :return_error
165
+ end
161
166
 
162
167
  @on_schema_error = behavior
163
168
  end
164
169
 
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
170
  # @api public
170
171
  def requires_approval(value = :__unset__, &block)
171
172
  if block
@@ -186,8 +187,6 @@ module Phronomy
186
187
  end
187
188
  end
188
189
 
189
- # Registers a Tool-specific semantic fact extractor for authorization.
190
- # The block receives validated immutable arguments and read-only context.
191
190
  # @api public
192
191
  def approval_facts(&block)
193
192
  if block
@@ -199,13 +198,7 @@ module Phronomy
199
198
  end
200
199
  end
201
200
 
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
201
  # @api public
208
- # mutant:disable
209
202
  def redact_params(*names)
210
203
  if names.empty?
211
204
  parent = superclass.respond_to?(:redact_params) ? superclass.redact_params : []
@@ -215,34 +208,23 @@ module Phronomy
215
208
  end
216
209
  end
217
210
 
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
211
  # @api public
224
212
  def max_result_size(value = :__unset__)
225
- return @max_result_size if value == :__unset__
213
+ if value == :__unset__
214
+ return @max_result_size if instance_variable_defined?(:@max_result_size)
215
+ return superclass.max_result_size if superclass.respond_to?(:max_result_size)
216
+
217
+ return nil
218
+ end
226
219
 
227
220
  @max_result_size = value
228
221
  end
229
222
  end
230
223
 
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
224
  def name
236
225
  self.class.tool_name || super
237
226
  end
238
227
 
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
228
  def params_schema
247
229
  schema = super
248
230
  return schema if schema.nil?
@@ -250,104 +232,81 @@ module Phronomy
250
232
  properties = schema.dig("properties") || schema.dig(:properties)
251
233
  return schema unless properties
252
234
 
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
235
+ self.class.param_enums.each do |param_name, values|
236
+ key = properties.key?(param_name.to_s) ? param_name.to_s : param_name.to_sym
237
+ next unless properties[key]
238
+
239
+ param_type = properties[key]["type"]
240
+ properties[key]["enum"] = values.map do |value|
241
+ case param_type
242
+ when "integer"
243
+ value.is_a?(Integer) ? value : Integer(value.to_s)
244
+ when "number"
245
+ value.is_a?(Numeric) ? value : Float(value.to_s)
246
+ when "boolean"
247
+ unless value == true || value == false
248
+ raise ArgumentError,
249
+ "boolean enum values must be true or false (got: #{value.inspect})"
272
250
  end
251
+ value
252
+ else
253
+ value.to_s
273
254
  end
274
255
  end
275
256
  end
276
257
 
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
258
  self.class.param_schemas.each do |param_name, nested|
281
259
  key = properties.key?(param_name.to_s) ? param_name.to_s : param_name.to_sym
282
260
  next unless properties[key]
283
-
284
261
  properties[key]["properties"] = nested_schema_to_json_schema(nested)
285
262
  end
286
263
 
287
264
  schema
288
265
  end
289
266
 
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
267
  # @api public
303
- # mutant:disable
304
268
  def call(args, cancellation_token: nil)
305
- ct = cancellation_token
306
- ct&.raise_if_cancelled!
269
+ cancellation_token&.raise_if_cancelled!
307
270
  validated_args, schema_error = validate_and_coerce(args)
308
271
  if schema_error
309
272
  case self.class.on_schema_error
310
273
  when :raise
311
- raise Phronomy::ToolError, "#{self.class.name} schema error: #{schema_error}"
274
+ raise Phronomy::ToolError,
275
+ "#{self.class.name} schema error: #{schema_error}"
312
276
  else
313
- # :return_error (default) and coerce fallback
314
277
  return "Schema validation failed: #{schema_error}"
315
278
  end
316
279
  end
317
- validated_args = validated_args.merge(cancellation_token: ct) if ct && execute_accepts_cancellation_token?
280
+
281
+ if cancellation_token && execute_accepts_cancellation_token?
282
+ validated_args = validated_args.merge(cancellation_token: cancellation_token)
283
+ end
318
284
  result = super(validated_args)
319
285
  truncate_result_if_needed(result)
320
- rescue Phronomy::ToolError
321
- raise
322
- rescue Phronomy::CancellationError
286
+ rescue Phronomy::ToolError, Phronomy::CancellationError
323
287
  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}"
288
+ rescue => error
289
+ if self.class.on_error == :suppress
290
+ msg = "[Phronomy] Tool #{self.class.name} suppressed error: " \
291
+ "#{error.class}: #{error.message}"
328
292
  if Phronomy.configuration.logger
329
293
  Phronomy.configuration.logger.warn(msg)
330
294
  else
331
295
  warn msg
332
296
  end
333
- "Tool error suppressed: #{e.message}"
297
+ "Tool error suppressed: #{error.message}"
334
298
  else
335
- raise Phronomy::ToolError, "#{self.class.name} execution failed: #{e.message}"
299
+ raise Phronomy::ToolError,
300
+ "#{self.class.name} execution failed: #{error.message}"
336
301
  end
337
302
  end
338
303
 
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
304
  # @api public
349
- # mutant:disable
350
- def call_async(args, cancellation_token: nil, config: {})
305
+ def call_async(
306
+ args,
307
+ cancellation_token: nil,
308
+ config: {}
309
+ )
351
310
  Phronomy::Agent::ToolExecutor.call_async(
352
311
  tool: self,
353
312
  args: args,
@@ -356,47 +315,24 @@ module Phronomy
356
315
  )
357
316
  end
358
317
 
359
- # Instance method accessor — delegates to the class-level flag.
360
318
  def requires_approval
361
319
  self.class.requires_approval
362
320
  end
363
321
 
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
322
  def requires_approval?
369
323
  self.class.requires_approval
370
324
  end
371
325
 
372
- # Origin metadata consumed by ToolInvocation authorization policy.
373
326
  # @api public
374
327
  def tool_origin
375
328
  :local
376
329
  end
377
330
 
378
- # Display-safe transport/origin metadata for approval requests.
379
331
  # @api public
380
332
  def approval_metadata
381
333
  {}
382
334
  end
383
335
 
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
336
  # @api public
401
337
  def execute(**_args)
402
338
  raise NotImplementedError, "#{self.class}#execute is not implemented"
@@ -404,24 +340,18 @@ module Phronomy
404
340
 
405
341
  private
406
342
 
407
- # Returns true when the #execute method declares a +cancellation_token:+
408
- # keyword parameter, indicating it opts into cooperative cancellation.
409
- # mutant:disable
410
343
  def execute_accepts_cancellation_token?
411
- method(:execute).parameters.any? do |type, name| # mutant:disable
344
+ method(:execute).parameters.any? do |type, name|
412
345
  name == :cancellation_token && %i[key keyreq].include?(type)
413
346
  end
414
347
  end
415
348
 
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
349
  def truncate_result_if_needed(result)
420
350
  max = self.class.max_result_size || Phronomy.configuration.tool_result_max_size
421
351
  return result unless max && result.respond_to?(:length) && result.length > max
422
352
 
423
353
  msg = "[Phronomy] Tool #{self.class.name} result truncated " \
424
- "(#{result.length} chars > #{max} limit)"
354
+ "(#{result.length} chars > #{max} limit)"
425
355
  if Phronomy.configuration.logger
426
356
  Phronomy.configuration.logger.warn(msg)
427
357
  else
@@ -430,45 +360,25 @@ module Phronomy
430
360
  "#{result[0, max]}...[truncated]"
431
361
  end
432
362
 
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
363
  def redacted_args(args)
439
364
  redacted = self.class.redact_params
440
365
  return args if redacted.empty?
441
366
 
442
- args.each_with_object({}) do |(k, v), h|
443
- h[k] = redacted.include?(k.to_sym) ? "[REDACTED]" : v
367
+ args.each_with_object({}) do |(key, value), result|
368
+ result[key] = redacted.include?(key.to_sym) ? "[REDACTED]" : value
444
369
  end
445
370
  end
446
371
 
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
372
  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
373
  return [args, nil] if self.class.parameters.empty?
462
374
 
463
375
  normalized = (args || {}).transform_keys(&:to_sym)
464
376
  coerce_mode = self.class.on_schema_error == :coerce
465
377
  result = {}
466
378
 
467
- self.class.parameters.each do |name, param| # mutant:disable
379
+ self.class.parameters.each do |name, param|
468
380
  value = normalized[name]
469
381
  if value.nil?
470
- # Return a descriptive error for missing required params so the LLM
471
- # can self-correct on the next turn.
472
382
  return [nil, "required parameter '#{name}' is missing"] if param.required
473
383
  next
474
384
  end
@@ -482,7 +392,6 @@ module Phronomy
482
392
  return [nil, error] if error
483
393
  end
484
394
 
485
- # Recursively validate nested object properties when declared.
486
395
  if param.type.to_sym == :object
487
396
  nested_schema = self.class.param_schemas[name]
488
397
  if nested_schema
@@ -493,62 +402,46 @@ module Phronomy
493
402
 
494
403
  enum_vals = self.class.param_enums[name]
495
404
  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})"]
405
+ return [nil,
406
+ "parameter '#{name}' must be one of: #{enum_vals.join(", ")} " \
407
+ "(got: #{value.inspect})"]
497
408
  end
498
409
 
499
410
  result[name] = value
500
411
  end
501
412
 
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
413
+ extra = normalized.keys - self.class.parameters.keys
505
414
  unless extra.empty?
506
415
  return [nil, "unknown parameter(s): #{extra.inspect}"]
507
416
  end
508
417
 
509
- [result, nil] # mutant:disable
418
+ [result, nil]
510
419
  end
511
420
 
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
421
  def nested_schema_to_json_schema(nested)
521
- nested.each_with_object({}) do |(prop_name, spec), acc|
422
+ nested.each_with_object({}) do |(prop_name, spec), result|
522
423
  entry = {"type" => spec[:type].to_s}
523
424
  entry["description"] = spec[:desc] if spec[:desc]
524
425
  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
426
+ if spec[:properties]
427
+ entry["properties"] = nested_schema_to_json_schema(spec[:properties])
428
+ end
429
+ result[prop_name.to_s] = entry
527
430
  end
528
431
  end
529
432
 
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
433
  def validate_nested_object(value, properties, path)
539
434
  return "field '#{path}' must be an object (Hash)" unless value.is_a?(Hash)
540
435
 
541
436
  normalized = value.transform_keys(&:to_sym)
542
-
543
- # Reject extra keys not declared in the schema (issue #166).
544
437
  extra = normalized.keys - properties.keys
545
438
  unless extra.empty?
546
439
  return "nested field '#{path}' contains undeclared key(s): #{extra.inspect}"
547
440
  end
548
441
 
549
- properties.each do |fname, spec|
550
- field_path = "#{path}.#{fname}"
551
- field_value = normalized[fname]
442
+ properties.each do |name, spec|
443
+ field_path = "#{path}.#{name}"
444
+ field_value = normalized[name]
552
445
 
553
446
  if field_value.nil?
554
447
  return "nested required field '#{field_path}' is missing" if spec[:required]
@@ -566,13 +459,6 @@ module Phronomy
566
459
  nil
567
460
  end
568
461
 
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
462
  def type_error(value, declared_type)
577
463
  return nil if value.nil?
578
464
 
@@ -583,19 +469,15 @@ module Phronomy
583
469
  when :boolean then [true, false].include?(value)
584
470
  when :array then value.is_a?(Array)
585
471
  when :object then value.is_a?(Hash)
586
- else true # unknown types pass through
472
+ else true
587
473
  end
588
474
 
589
- if ok
590
- nil
591
- else
592
- "parameter '#{value.respond_to?(:keys) ? "(object)" : value.inspect}' expected type #{declared_type}"
593
- end
475
+ return nil if ok
476
+
477
+ shown = value.respond_to?(:keys) ? "(object)" : value.inspect
478
+ "parameter '#{shown}' expected type #{declared_type}"
594
479
  end
595
480
 
596
- # Attempts to coerce +value+ to +declared_type+.
597
- # Returns [coerced_value, nil] on success, [nil, error_message] on failure.
598
- # mutant:disable
599
481
  def coerce_value(value, declared_type)
600
482
  return [value, nil] if value.nil?
601
483
 
@@ -603,11 +485,9 @@ module Phronomy
603
485
  when :string
604
486
  [value.to_s, nil]
605
487
  when :integer
606
- coerced = Integer(value)
607
- [coerced, nil]
488
+ [Integer(value), nil]
608
489
  when :number, :float
609
- coerced = Float(value)
610
- [coerced, nil]
490
+ [Float(value), nil]
611
491
  when :boolean
612
492
  case value.to_s.downcase
613
493
  when "true" then [true, nil]
@@ -615,7 +495,6 @@ module Phronomy
615
495
  else [nil, "parameter cannot be coerced to boolean: #{value.inspect}"]
616
496
  end
617
497
  else
618
- # Arrays, objects, unknown types: pass through as-is
619
498
  [value, nil]
620
499
  end
621
500
  rescue ArgumentError, TypeError