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