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