phronomy 0.7.1 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (110) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +35 -45
  3. data/benchmark/baseline.json +1 -1
  4. data/benchmark/bench_agent_invoke.rb +1 -1
  5. data/benchmark/bench_context_assembler.rb +11 -3
  6. data/benchmark/bench_regression.rb +11 -11
  7. data/benchmark/bench_token_estimator.rb +5 -5
  8. data/benchmark/bench_tool_schema.rb +2 -2
  9. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +224 -0
  10. data/lib/phronomy/agent/base.rb +268 -403
  11. data/lib/phronomy/agent/checkpoint.rb +118 -0
  12. data/lib/phronomy/agent/concerns/suspendable.rb +6 -6
  13. data/lib/phronomy/agent/context/capability/base.rb +689 -0
  14. data/lib/phronomy/agent/context/capability/scope_policy.rb +54 -0
  15. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  16. data/lib/phronomy/agent/context/knowledge/base.rb +58 -0
  17. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +102 -0
  18. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +58 -0
  19. data/lib/phronomy/agent/fsm.rb +1 -1
  20. data/lib/phronomy/agent/invocation_pipeline.rb +108 -0
  21. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  22. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  23. data/lib/phronomy/agent/react_agent.rb +43 -37
  24. data/lib/phronomy/agent/runner.rb +2 -2
  25. data/lib/phronomy/agent/shared_state.rb +2 -2
  26. data/lib/phronomy/agent/tool_executor.rb +108 -0
  27. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  28. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  29. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  30. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  31. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  32. data/lib/phronomy/concurrency/deadline.rb +65 -0
  33. data/lib/phronomy/{runtime → concurrency}/gate_registry.rb +1 -2
  34. data/lib/phronomy/{runtime → concurrency}/pool_registry.rb +1 -1
  35. data/lib/phronomy/configuration.rb +0 -6
  36. data/lib/phronomy/context.rb +2 -8
  37. data/lib/phronomy/eval/runner.rb +4 -0
  38. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  39. data/lib/phronomy/event_loop.rb +7 -7
  40. data/lib/phronomy/invocation_context.rb +3 -3
  41. data/lib/phronomy/knowledge_source.rb +0 -5
  42. data/lib/phronomy/llm_adapter/ruby_llm.rb +17 -11
  43. data/lib/phronomy/llm_context_window/assembler.rb +191 -0
  44. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  45. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  46. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  47. data/lib/phronomy/{agent → multi_agent}/handoff.rb +6 -6
  48. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +7 -7
  49. data/lib/phronomy/{agent → multi_agent}/parallel_tool_chat.rb +4 -4
  50. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +4 -4
  51. data/lib/phronomy/runtime/runtime_metrics.rb +0 -1
  52. data/lib/phronomy/runtime.rb +20 -6
  53. data/lib/phronomy/task_group.rb +1 -1
  54. data/lib/phronomy/tool.rb +3 -4
  55. data/lib/phronomy/{tool/agent_tool.rb → tools/agent.rb} +6 -6
  56. data/lib/phronomy/{tool/mcp_tool.rb → tools/mcp.rb} +9 -9
  57. data/lib/phronomy/tools/vector_search.rb +70 -0
  58. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  59. data/lib/phronomy/vector_store/async_backend.rb +4 -4
  60. data/lib/phronomy/vector_store/base.rb +2 -2
  61. data/lib/phronomy/vector_store/embeddings/base.rb +41 -0
  62. data/lib/phronomy/vector_store/embeddings/ruby_llm_embeddings.rb +47 -0
  63. data/lib/phronomy/vector_store/in_memory.rb +12 -2
  64. data/lib/phronomy/vector_store/loader/base.rb +27 -0
  65. data/lib/phronomy/vector_store/loader/csv_loader.rb +58 -0
  66. data/lib/phronomy/vector_store/loader/markdown_loader.rb +78 -0
  67. data/lib/phronomy/vector_store/loader/plain_text_loader.rb +24 -0
  68. data/lib/phronomy/vector_store/pgvector.rb +2 -2
  69. data/lib/phronomy/vector_store/redis_search.rb +2 -2
  70. data/lib/phronomy/vector_store/splitter/base.rb +49 -0
  71. data/lib/phronomy/vector_store/splitter/fixed_size_splitter.rb +53 -0
  72. data/lib/phronomy/vector_store/splitter/recursive_splitter.rb +107 -0
  73. data/lib/phronomy/vector_store.rb +14 -2
  74. data/lib/phronomy/version.rb +1 -1
  75. data/lib/phronomy/workflow_context.rb +8 -0
  76. data/lib/phronomy/workflow_runner.rb +11 -131
  77. data/lib/phronomy.rb +2 -0
  78. data/scripts/api_snapshot.rb +11 -9
  79. metadata +44 -46
  80. data/lib/phronomy/async_queue.rb +0 -155
  81. data/lib/phronomy/blocking_adapter_pool.rb +0 -435
  82. data/lib/phronomy/cancellation_scope.rb +0 -123
  83. data/lib/phronomy/cancellation_token.rb +0 -133
  84. data/lib/phronomy/concurrency_gate.rb +0 -155
  85. data/lib/phronomy/context/assembler.rb +0 -143
  86. data/lib/phronomy/context/compaction_context.rb +0 -111
  87. data/lib/phronomy/context/trigger_context.rb +0 -39
  88. data/lib/phronomy/context/trim_context.rb +0 -75
  89. data/lib/phronomy/deadline.rb +0 -63
  90. data/lib/phronomy/embeddings/base.rb +0 -39
  91. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  92. data/lib/phronomy/embeddings.rb +0 -11
  93. data/lib/phronomy/fsm_session.rb +0 -247
  94. data/lib/phronomy/knowledge_source/base.rb +0 -54
  95. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  96. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  97. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  98. data/lib/phronomy/loader/base.rb +0 -25
  99. data/lib/phronomy/loader/csv_loader.rb +0 -56
  100. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  101. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  102. data/lib/phronomy/loader.rb +0 -13
  103. data/lib/phronomy/prompt_template.rb +0 -96
  104. data/lib/phronomy/splitter/base.rb +0 -47
  105. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  106. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  107. data/lib/phronomy/splitter.rb +0 -12
  108. data/lib/phronomy/tool/base.rb +0 -644
  109. data/lib/phronomy/tool/scope_policy.rb +0 -50
  110. data/lib/phronomy/tool_executor.rb +0 -106
@@ -1,644 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Tool
5
- # Base class extending RubyLLM::Tool with Phronomy-specific DSL.
6
- #
7
- # Additional DSL over RubyLLM::Tool:
8
- # - tool_name : explicit function name exposed to the LLM (overrides auto-conversion)
9
- # - scope : access-scope metadata (:read_only, :write, etc.)
10
- # - on_error : error-handling policy (:raise or :return_empty)
11
- # - on_schema_error : behavior when LLM passes schema-violating arguments
12
- # :return_error (default), :raise, or :coerce
13
- # - requires_approval : require human approval before execution
14
- # - param :name, enum: [...] : restrict allowed values in the JSON Schema
15
- #
16
- # @example
17
- # class SearchKnowledgeBase < Phronomy::Tool::Base
18
- # tool_name "search_kb" # explicit name shown to the LLM
19
- # description "Search the internal knowledge base"
20
- # param :query, type: :string, desc: "Search query"
21
- # param :lang, type: :string, desc: "Language", required: false, enum: %w[en ja fr]
22
- # scope :read_only
23
- # on_error :return_empty
24
- #
25
- # def execute(query:, lang: "en")
26
- # KnowledgeBase.search(query, lang: lang)
27
- # end
28
- # end
29
- class Base < RubyLLM::Tool
30
- class << self
31
- # Sets an explicit function name to expose to the LLM, bypassing RubyLLM's
32
- # automatic CamelCase-to-snake_case conversion.
33
- # When omitted, RubyLLM's default conversion applies (e.g. WeatherTool → "weather").
34
- #
35
- # @param value [String, nil] the exact function name the LLM will see
36
- # @api public
37
- def tool_name(value = nil)
38
- return @tool_name if value.nil?
39
-
40
- @tool_name = value.to_s
41
- end
42
-
43
- # Extends RubyLLM::Tool.param with optional +enum:+ and +properties:+ keywords.
44
- # - +enum:+ restricts allowed values; injected into the JSON Schema.
45
- # - +properties:+ declares nested fields for :object type params. Each
46
- # entry is a Hash mapping field name (Symbol) to a spec Hash with keys:
47
- # :type (Symbol, default :string), :required (Boolean, default false),
48
- # and optionally :properties (for further nesting).
49
- #
50
- # @param name [Symbol] parameter name
51
- # @param enum [Array, nil] allowed values
52
- # @param properties [Hash, nil] nested schema for :object params
53
- # @param options [Hash] forwarded to RubyLLM::Tool.param
54
- # @api public
55
- def param(name, enum: nil, properties: nil, **options)
56
- super(name, **options)
57
- param_enums[name] = enum if enum
58
- param_schemas[name] = normalize_nested_schema(properties) if properties
59
- end
60
-
61
- # Returns the enum constraints registered via .param.
62
- # @return [Hash{Symbol => Array}]
63
- # @api public
64
- def param_enums
65
- @param_enums ||= {}
66
- end
67
-
68
- # Returns nested schema definitions registered via .param(properties: ...).
69
- # @return [Hash{Symbol => Hash}]
70
- # @api public
71
- 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
-
90
- # Sets the access scope for this tool (metadata; enforcement is the responsibility of
91
- # the Workflow/Guardrail layer).
92
- # @param value [Symbol] e.g. :read_only, :write, :admin
93
- # @api public
94
- def scope(value = nil)
95
- return @scope if value.nil?
96
-
97
- @scope = value
98
- end
99
-
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
140
- def on_error(behavior = nil)
141
- return @on_error || :raise if behavior.nil?
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
151
- @on_error = behavior
152
- end
153
-
154
- # Configures how this tool responds when the LLM passes arguments that violate
155
- # the declared parameter types or enum constraints.
156
- #
157
- # @param behavior [Symbol]
158
- # :return_error (default) — return a descriptive error string as the tool result
159
- # so the LLM can self-correct on the next turn.
160
- # :raise — raise Phronomy::ToolError, stopping the agent loop.
161
- # :coerce — attempt type coercion (e.g. "42" → 42 for :integer);
162
- # falls back to :return_error when coercion is not possible.
163
- # @api public
164
- def on_schema_error(behavior = nil)
165
- return @on_schema_error || :return_error if behavior.nil?
166
-
167
- @on_schema_error = behavior
168
- end
169
-
170
- # Configures whether human approval is required before executing this tool.
171
- # @param value [Boolean]
172
- # @api public
173
- def requires_approval(value = nil)
174
- return @requires_approval || false if value.nil?
175
-
176
- @requires_approval = value
177
- end
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
-
206
- # Registers a retry policy for one or more exception classes.
207
- #
208
- # When the tool raises one of the listed exception classes, it will be
209
- # retried up to +times+ times with the specified wait strategy.
210
- # Multiple policies can be registered and are evaluated in order.
211
- #
212
- # GuardrailError is never retried regardless of this configuration.
213
- #
214
- # @param exception_classes [Array<Class>] exception classes to retry on
215
- # @param times [Integer] maximum retry attempts (default: 1)
216
- # @param wait [Symbol, Numeric] :exponential, :linear, or a fixed Float
217
- # @param base [Float] base wait time in seconds (default: 1.0)
218
- #
219
- # @example
220
- # retry_on Phronomy::ToolError, times: 3, wait: :exponential, base: 1.0
221
- # retry_on Net::ReadTimeout, times: 2, wait: 0.5
222
- # @api public
223
- def retry_on(*exception_classes, times: 1, wait: 0, base: 1.0)
224
- @retry_policies ||= []
225
- @retry_policies << {exceptions: exception_classes, times: times, wait: wait, base: base}
226
- end
227
-
228
- # Returns all retry policies registered on this tool class.
229
- # @return [Array<Hash>]
230
- # @api public
231
- def retry_policies
232
- @retry_policies || []
233
- end
234
-
235
- # Injectable sleep callable for testing.
236
- # Defaults to Kernel#sleep.
237
- # @return [#call]
238
- # @api private
239
- def _sleep_proc
240
- @_sleep_proc || method(:sleep)
241
- end
242
-
243
- # Overrides the sleep callable used between retries.
244
- # @param proc [#call]
245
- attr_writer :_sleep_proc
246
- end
247
-
248
- # Returns the function name exposed to the LLM.
249
- # Uses the class-level tool_name if set; otherwise falls back to RubyLLM's
250
- # automatic conversion (CamelCase → snake_case, strips trailing "_tool").
251
- def name
252
- self.class.tool_name || super
253
- end
254
-
255
- # Returns the JSON Schema for this tool's parameters.
256
- # Injects "enum" entries for any param declared with enum: [...].
257
- def params_schema
258
- schema = super
259
- return schema if schema.nil?
260
-
261
- properties = schema.dig("properties") || schema.dig(:properties)
262
- return schema unless properties
263
-
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|
286
- key = properties.key?(param_name.to_s) ? param_name.to_s : param_name.to_sym
287
- next unless properties[key]
288
-
289
- properties[key]["properties"] = nested_schema_to_json_schema(nested)
290
- end
291
-
292
- schema
293
- end
294
-
295
- # Overrides RubyLLM::Tool#call to apply schema validation, the retry policy,
296
- # the on_error policy, and wrap errors as ToolError.
297
- #
298
- # Execution order:
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!
311
- validated_args, schema_error = validate_and_coerce(args)
312
- if schema_error
313
- case self.class.on_schema_error
314
- when :raise
315
- raise Phronomy::ToolError, "#{self.class.name} schema error: #{schema_error}"
316
- else
317
- # :return_error (default) and coerce fallback
318
- return "Schema validation failed: #{schema_error}"
319
- end
320
- end
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)
324
- rescue Phronomy::ToolError
325
- raise
326
- rescue Phronomy::CancellationError
327
- raise
328
- rescue => e
329
- case self.class.on_error
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}"
338
- else
339
- raise Phronomy::ToolError, "#{self.class.name} execution failed: #{e.message}"
340
- end
341
- end
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
-
361
- # Instance method accessor — delegates to the class-level flag.
362
- def requires_approval
363
- self.class.requires_approval
364
- end
365
-
366
- # Instance method for requires_approval? (convenience accessor).
367
- def requires_approval?
368
- self.class.requires_approval
369
- end
370
-
371
- # Override this method to implement the tool's logic.
372
- #
373
- # The method receives the declared {.param} fields as keyword arguments.
374
- # The return value is passed back to the LLM as the tool result.
375
- #
376
- # @abstract Subclasses must implement this method.
377
- # @return [String] result string returned to the LLM
378
- # @example
379
- # class WeatherTool < Phronomy::Tool::Base
380
- # description "Get current weather"
381
- # param :location, type: :string, desc: "City name"
382
- #
383
- # def execute(location:)
384
- # WeatherService.fetch(location).to_s
385
- # end
386
- # end
387
- # @api public
388
- def execute(**_args)
389
- raise NotImplementedError, "#{self.class}#execute is not implemented"
390
- end
391
-
392
- private
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
-
433
- # Executes the given block inside a retry loop driven by the class-level
434
- # retry_policies. Each policy matches by exception class; the first matching
435
- # policy governs the wait and retry count. Raises immediately when no policy
436
- # covers the exception or when all retries are exhausted.
437
- def with_tool_retry
438
- policies = self.class.retry_policies
439
- return yield if policies.empty?
440
-
441
- attempt = 0
442
- begin
443
- yield
444
- rescue => e
445
- policy = policies.find { |p| p[:exceptions].any? { |ex| e.is_a?(ex) } }
446
- if policy && attempt < policy[:times]
447
- wait = compute_retry_wait(policy[:wait], policy[:base], attempt)
448
- self.class._sleep_proc.call(wait) if wait > 0
449
- attempt += 1
450
- retry
451
- end
452
- raise
453
- end
454
- end
455
-
456
- # Computes the wait duration for a given strategy, base, and attempt index.
457
- #
458
- # @param strategy [Symbol, Numeric] :exponential, :linear, or a fixed Numeric
459
- # @param base [Float] base wait time in seconds
460
- # @param attempt [Integer] zero-based attempt index
461
- # @return [Float]
462
- # @api public
463
- def compute_retry_wait(strategy, base, attempt)
464
- case strategy
465
- when :exponential
466
- (2**attempt) * base
467
- when :linear
468
- (attempt + 1) * base
469
- when Numeric
470
- strategy.to_f
471
- else
472
- base.to_f
473
- end
474
- end
475
-
476
- # Validates args against declared parameter types and enum constraints.
477
- # When on_schema_error is :coerce, attempts type coercion first.
478
- #
479
- # @param args [Hash] raw args passed to #call (string or symbol keys)
480
- # @return [Array(Hash, String|nil)] [possibly_coerced_args, error_message_or_nil]
481
- # @api public
482
- def validate_and_coerce(args)
483
- return [args, nil] if self.class.parameters.empty?
484
-
485
- normalized = (args || {}).transform_keys(&:to_sym)
486
- coerce_mode = self.class.on_schema_error == :coerce
487
- result = {}
488
-
489
- self.class.parameters.each do |name, param|
490
- value = normalized[name]
491
- if value.nil?
492
- # Return a descriptive error for missing required params so the LLM
493
- # can self-correct on the next turn.
494
- return [nil, "required parameter '#{name}' is missing"] if param.required
495
- next
496
- end
497
-
498
- if coerce_mode
499
- coerced, error = coerce_value(value, param.type)
500
- return [nil, error] if error
501
- value = coerced
502
- else
503
- error = type_error(value, param.type)
504
- return [nil, error] if error
505
- end
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
-
516
- enum_vals = self.class.param_enums[name]
517
- if enum_vals && !enum_vals.map(&:to_s).include?(value.to_s)
518
- return [nil, "parameter '#{name}' must be one of: #{enum_vals.join(", ")} (got: #{value.inspect})"]
519
- end
520
-
521
- result[name] = value
522
- end
523
-
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
587
- end
588
-
589
- # Returns a type-error message string if +value+ does not match +declared_type+,
590
- # or nil if the value is acceptable.
591
- #
592
- # @param value [Object]
593
- # @param declared_type [Symbol, String] e.g. :string, :integer, :number, :boolean, :array, :object
594
- # @api public
595
- def type_error(value, declared_type)
596
- return nil if value.nil?
597
-
598
- ok = case declared_type.to_sym
599
- when :string then value.is_a?(String)
600
- when :integer then value.is_a?(Integer)
601
- when :number, :float then value.is_a?(Numeric)
602
- when :boolean then [true, false].include?(value)
603
- when :array then value.is_a?(Array)
604
- when :object then value.is_a?(Hash)
605
- else true # unknown types pass through
606
- end
607
-
608
- if ok
609
- nil
610
- else
611
- "parameter '#{value.respond_to?(:keys) ? "(object)" : value.inspect}' expected type #{declared_type}"
612
- end
613
- end
614
-
615
- # Attempts to coerce +value+ to +declared_type+.
616
- # Returns [coerced_value, nil] on success, [nil, error_message] on failure.
617
- def coerce_value(value, declared_type)
618
- return [value, nil] if value.nil?
619
-
620
- case declared_type.to_sym
621
- when :string
622
- [value.to_s, nil]
623
- when :integer
624
- coerced = Integer(value)
625
- [coerced, nil]
626
- when :number, :float
627
- coerced = Float(value)
628
- [coerced, nil]
629
- when :boolean
630
- case value.to_s.downcase
631
- when "true" then [true, nil]
632
- when "false" then [false, nil]
633
- else [nil, "parameter cannot be coerced to boolean: #{value.inspect}"]
634
- end
635
- else
636
- # Arrays, objects, unknown types: pass through as-is
637
- [value, nil]
638
- end
639
- rescue ArgumentError, TypeError
640
- [nil, "parameter cannot be coerced to #{declared_type}: #{value.inspect}"]
641
- end
642
- end
643
- end
644
- end
@@ -1,50 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Tool
5
- # Evaluates whether a tool with a given scope may execute.
6
- #
7
- # A ScopePolicy is a callable that receives +(tool_class, scope, agent)+ and
8
- # returns one of:
9
- # +:allow+ — proceed immediately without an approval gate.
10
- # +:reject+ — block execution; the tool returns a denial message.
11
- # +:approve+ — delegate to the agent's approval handler (if registered);
12
- # when no handler is registered the call is rejected.
13
- #
14
- # The {Default} instance is used automatically when no custom policy is
15
- # configured on an agent.
16
- #
17
- # @example Custom policy that allows everything
18
- # agent.scope_policy = ->(_tool_class, _scope, _agent) { :allow }
19
- #
20
- # @example Strict policy that rejects all write scopes
21
- # agent.scope_policy = ->(_tc, scope, _agent) {
22
- # scope == :write ? :reject : :allow
23
- # }
24
- class ScopePolicy
25
- # Scopes that must go through an approval gate before execution.
26
- APPROVAL_REQUIRED_SCOPES = %i[write admin external_network filesystem process external_process].freeze
27
-
28
- # Scopes that are always permitted without approval.
29
- ALWAYS_ALLOWED_SCOPES = %i[read_only].freeze
30
-
31
- # Returns +:allow+ for always-allowed scopes, +:approve+ for high-risk
32
- # scopes, and +:allow+ for anything else (including +nil+).
33
- #
34
- # @param _tool_class [Class]
35
- # @param scope [Symbol, nil]
36
- # @param _agent [Object]
37
- # @return [:allow, :approve, :reject]
38
- # @api private
39
- def call(_tool_class, scope, _agent)
40
- return :allow if scope.nil? || ALWAYS_ALLOWED_SCOPES.include?(scope)
41
- return :approve if APPROVAL_REQUIRED_SCOPES.include?(scope)
42
-
43
- :allow
44
- end
45
-
46
- # Shared singleton used when no custom policy is configured.
47
- DEFAULT = new.freeze
48
- end
49
- end
50
- end