phronomy 0.6.0 → 0.7.1
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 +22 -0
- data/CHANGELOG.md +488 -0
- data/CONTRIBUTING.md +102 -0
- data/README.md +374 -36
- data/RELEASE_CHECKLIST.md +86 -0
- data/Rakefile +33 -0
- data/SECURITY.md +80 -0
- data/benchmark/baseline.json +9 -0
- data/benchmark/bench_agent_invoke.rb +105 -0
- data/benchmark/bench_context_assembler.rb +46 -0
- data/benchmark/bench_regression.rb +172 -0
- data/benchmark/bench_token_estimator.rb +44 -0
- data/benchmark/bench_tool_schema.rb +69 -0
- data/benchmark/bench_vector_store.rb +39 -0
- data/benchmark/bench_workflow.rb +55 -0
- data/benchmark/run_all.rb +118 -0
- data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
- data/docs/decisions/002-workflow-context-immutability.md +42 -0
- data/docs/decisions/003-event-loop-singleton.md +48 -0
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +75 -0
- data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
- data/docs/decisions/006-no-built-in-guardrails.md +66 -0
- data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
- data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
- data/docs/decisions/009-state-store-abstraction.md +141 -0
- data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +416 -49
- data/lib/phronomy/agent/before_completion_context.rb +1 -0
- data/lib/phronomy/agent/checkpoint.rb +1 -0
- data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
- data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
- data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
- data/lib/phronomy/agent/concerns/retryable.rb +12 -1
- data/lib/phronomy/agent/concerns/suspendable.rb +19 -0
- data/lib/phronomy/agent/fsm.rb +44 -52
- data/lib/phronomy/agent/handoff.rb +3 -0
- data/lib/phronomy/agent/orchestrator.rb +191 -54
- data/lib/phronomy/agent/parallel_tool_chat.rb +87 -13
- data/lib/phronomy/agent/react_agent.rb +16 -6
- data/lib/phronomy/agent/runner.rb +2 -0
- data/lib/phronomy/agent/shared_state.rb +11 -0
- data/lib/phronomy/agent/suspend_signal.rb +2 -0
- data/lib/phronomy/agent/team_coordinator.rb +17 -5
- data/lib/phronomy/async_queue.rb +155 -0
- data/lib/phronomy/blocking_adapter_pool.rb +435 -0
- data/lib/phronomy/cancellation_scope.rb +123 -0
- data/lib/phronomy/cancellation_token.rb +133 -0
- data/lib/phronomy/concurrency_gate.rb +155 -0
- data/lib/phronomy/configuration.rb +168 -2
- data/lib/phronomy/context/assembler.rb +6 -0
- data/lib/phronomy/context/compaction_context.rb +2 -0
- data/lib/phronomy/context/context_version_cache.rb +2 -0
- data/lib/phronomy/context/token_budget.rb +3 -0
- data/lib/phronomy/context/token_estimator.rb +9 -2
- data/lib/phronomy/context/trigger_context.rb +1 -0
- data/lib/phronomy/context/trim_context.rb +4 -0
- data/lib/phronomy/deadline.rb +63 -0
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings/base.rb +22 -2
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
- data/lib/phronomy/eval/comparison.rb +2 -0
- data/lib/phronomy/eval/dataset.rb +4 -0
- data/lib/phronomy/eval/metrics.rb +6 -0
- data/lib/phronomy/eval/runner.rb +11 -9
- data/lib/phronomy/eval/scorer/base.rb +1 -0
- data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
- data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
- data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
- data/lib/phronomy/event_loop.rb +275 -30
- data/lib/phronomy/fsm_session.rb +57 -4
- data/lib/phronomy/generator_verifier.rb +2 -0
- data/lib/phronomy/guardrail/base.rb +3 -0
- data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
- data/lib/phronomy/invocation_context.rb +152 -0
- data/lib/phronomy/knowledge_source/base.rb +24 -2
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
- data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
- data/lib/phronomy/llm_adapter/base.rb +104 -0
- data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
- data/lib/phronomy/llm_adapter.rb +20 -0
- data/lib/phronomy/loader/base.rb +1 -0
- data/lib/phronomy/loader/csv_loader.rb +2 -0
- data/lib/phronomy/loader/markdown_loader.rb +2 -0
- data/lib/phronomy/loader/plain_text_loader.rb +1 -0
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/output_parser/base.rb +1 -0
- data/lib/phronomy/output_parser/json_parser.rb +22 -3
- data/lib/phronomy/output_parser/structured_parser.rb +2 -0
- data/lib/phronomy/prompt_template.rb +5 -0
- data/lib/phronomy/runnable.rb +20 -3
- data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
- data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
- data/lib/phronomy/runtime/gate_registry.rb +52 -0
- data/lib/phronomy/runtime/pool_registry.rb +57 -0
- data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
- data/lib/phronomy/runtime/scheduler.rb +98 -0
- data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
- data/lib/phronomy/runtime/task_registry.rb +48 -0
- data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
- data/lib/phronomy/runtime/timer_queue.rb +106 -0
- data/lib/phronomy/runtime/timer_service.rb +42 -0
- data/lib/phronomy/runtime.rb +374 -0
- data/lib/phronomy/splitter/base.rb +2 -0
- data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
- data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
- data/lib/phronomy/state_store/base.rb +48 -0
- data/lib/phronomy/state_store/in_memory.rb +62 -0
- data/lib/phronomy/task/backend.rb +80 -0
- data/lib/phronomy/task/fiber_backend.rb +157 -0
- data/lib/phronomy/task/immediate_backend.rb +89 -0
- data/lib/phronomy/task/thread_backend.rb +84 -0
- data/lib/phronomy/task.rb +275 -0
- data/lib/phronomy/task_group.rb +265 -0
- data/lib/phronomy/testing/fake_clock.rb +109 -0
- data/lib/phronomy/testing/fake_scheduler.rb +104 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
- data/lib/phronomy/testing.rb +12 -0
- data/lib/phronomy/tool/agent_tool.rb +1 -0
- data/lib/phronomy/tool/base.rb +298 -28
- data/lib/phronomy/tool/mcp_tool.rb +103 -17
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tool_executor.rb +106 -0
- data/lib/phronomy/tracing/base.rb +3 -0
- data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +36 -0
- data/lib/phronomy/vector_store/async_backend.rb +110 -0
- data/lib/phronomy/vector_store/base.rb +40 -7
- data/lib/phronomy/vector_store/in_memory.rb +16 -7
- data/lib/phronomy/vector_store/pgvector.rb +40 -9
- data/lib/phronomy/vector_store/redis_search.rb +29 -8
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +147 -11
- data/lib/phronomy/workflow_context.rb +83 -6
- data/lib/phronomy/workflow_runner.rb +106 -7
- data/lib/phronomy.rb +112 -1
- data/scripts/api_snapshot.rb +91 -0
- data/scripts/check_api_annotations.rb +68 -0
- data/scripts/check_private_enforcement.rb +93 -0
- data/scripts/check_readme_runnable.rb +98 -0
- data/scripts/run_mutation.sh +46 -0
- metadata +83 -2
data/lib/phronomy/tool/base.rb
CHANGED
|
@@ -33,44 +33,121 @@ module Phronomy
|
|
|
33
33
|
# When omitted, RubyLLM's default conversion applies (e.g. WeatherTool → "weather").
|
|
34
34
|
#
|
|
35
35
|
# @param value [String, nil] the exact function name the LLM will see
|
|
36
|
+
# @api public
|
|
36
37
|
def tool_name(value = nil)
|
|
37
38
|
return @tool_name if value.nil?
|
|
38
39
|
|
|
39
40
|
@tool_name = value.to_s
|
|
40
41
|
end
|
|
41
42
|
|
|
42
|
-
# Extends RubyLLM::Tool.param with
|
|
43
|
-
#
|
|
44
|
-
#
|
|
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).
|
|
45
49
|
#
|
|
46
50
|
# @param name [Symbol] parameter name
|
|
47
|
-
# @param enum [Array, nil] allowed values
|
|
51
|
+
# @param enum [Array, nil] allowed values
|
|
52
|
+
# @param properties [Hash, nil] nested schema for :object params
|
|
48
53
|
# @param options [Hash] forwarded to RubyLLM::Tool.param
|
|
49
|
-
|
|
54
|
+
# @api public
|
|
55
|
+
def param(name, enum: nil, properties: nil, **options)
|
|
50
56
|
super(name, **options)
|
|
51
57
|
param_enums[name] = enum if enum
|
|
58
|
+
param_schemas[name] = normalize_nested_schema(properties) if properties
|
|
52
59
|
end
|
|
53
60
|
|
|
54
61
|
# Returns the enum constraints registered via .param.
|
|
55
62
|
# @return [Hash{Symbol => Array}]
|
|
63
|
+
# @api public
|
|
56
64
|
def param_enums
|
|
57
65
|
@param_enums ||= {}
|
|
58
66
|
end
|
|
59
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
|
+
|
|
60
90
|
# Sets the access scope for this tool (metadata; enforcement is the responsibility of
|
|
61
91
|
# the Workflow/Guardrail layer).
|
|
62
92
|
# @param value [Symbol] e.g. :read_only, :write, :admin
|
|
93
|
+
# @api public
|
|
63
94
|
def scope(value = nil)
|
|
64
95
|
return @scope if value.nil?
|
|
65
96
|
|
|
66
97
|
@scope = value
|
|
67
98
|
end
|
|
68
99
|
|
|
69
|
-
#
|
|
70
|
-
#
|
|
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
|
|
71
140
|
def on_error(behavior = nil)
|
|
72
141
|
return @on_error || :raise if behavior.nil?
|
|
73
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
|
|
74
151
|
@on_error = behavior
|
|
75
152
|
end
|
|
76
153
|
|
|
@@ -83,6 +160,7 @@ module Phronomy
|
|
|
83
160
|
# :raise — raise Phronomy::ToolError, stopping the agent loop.
|
|
84
161
|
# :coerce — attempt type coercion (e.g. "42" → 42 for :integer);
|
|
85
162
|
# falls back to :return_error when coercion is not possible.
|
|
163
|
+
# @api public
|
|
86
164
|
def on_schema_error(behavior = nil)
|
|
87
165
|
return @on_schema_error || :return_error if behavior.nil?
|
|
88
166
|
|
|
@@ -91,12 +169,40 @@ module Phronomy
|
|
|
91
169
|
|
|
92
170
|
# Configures whether human approval is required before executing this tool.
|
|
93
171
|
# @param value [Boolean]
|
|
172
|
+
# @api public
|
|
94
173
|
def requires_approval(value = nil)
|
|
95
174
|
return @requires_approval || false if value.nil?
|
|
96
175
|
|
|
97
176
|
@requires_approval = value
|
|
98
177
|
end
|
|
99
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
|
+
|
|
100
206
|
# Registers a retry policy for one or more exception classes.
|
|
101
207
|
#
|
|
102
208
|
# When the tool raises one of the listed exception classes, it will be
|
|
@@ -113,6 +219,7 @@ module Phronomy
|
|
|
113
219
|
# @example
|
|
114
220
|
# retry_on Phronomy::ToolError, times: 3, wait: :exponential, base: 1.0
|
|
115
221
|
# retry_on Net::ReadTimeout, times: 2, wait: 0.5
|
|
222
|
+
# @api public
|
|
116
223
|
def retry_on(*exception_classes, times: 1, wait: 0, base: 1.0)
|
|
117
224
|
@retry_policies ||= []
|
|
118
225
|
@retry_policies << {exceptions: exception_classes, times: times, wait: wait, base: base}
|
|
@@ -120,6 +227,7 @@ module Phronomy
|
|
|
120
227
|
|
|
121
228
|
# Returns all retry policies registered on this tool class.
|
|
122
229
|
# @return [Array<Hash>]
|
|
230
|
+
# @api public
|
|
123
231
|
def retry_policies
|
|
124
232
|
@retry_policies || []
|
|
125
233
|
end
|
|
@@ -127,6 +235,7 @@ module Phronomy
|
|
|
127
235
|
# Injectable sleep callable for testing.
|
|
128
236
|
# Defaults to Kernel#sleep.
|
|
129
237
|
# @return [#call]
|
|
238
|
+
# @api private
|
|
130
239
|
def _sleep_proc
|
|
131
240
|
@_sleep_proc || method(:sleep)
|
|
132
241
|
end
|
|
@@ -147,24 +256,37 @@ module Phronomy
|
|
|
147
256
|
# Injects "enum" entries for any param declared with enum: [...].
|
|
148
257
|
def params_schema
|
|
149
258
|
schema = super
|
|
150
|
-
return schema if schema.nil?
|
|
259
|
+
return schema if schema.nil?
|
|
151
260
|
|
|
152
|
-
enums = self.class.param_enums
|
|
153
261
|
properties = schema.dig("properties") || schema.dig(:properties)
|
|
154
262
|
return schema unless properties
|
|
155
263
|
|
|
156
|
-
|
|
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|
|
|
157
286
|
key = properties.key?(param_name.to_s) ? param_name.to_s : param_name.to_sym
|
|
158
287
|
next unless properties[key]
|
|
159
288
|
|
|
160
|
-
|
|
161
|
-
properties[key]["enum"] = values.map do |v|
|
|
162
|
-
case param_type
|
|
163
|
-
when "integer" then v.is_a?(Integer) ? v : Integer(v.to_s)
|
|
164
|
-
when "number" then v.is_a?(Numeric) ? v : Float(v.to_s)
|
|
165
|
-
else v.to_s
|
|
166
|
-
end
|
|
167
|
-
end
|
|
289
|
+
properties[key]["properties"] = nested_schema_to_json_schema(nested)
|
|
168
290
|
end
|
|
169
291
|
|
|
170
292
|
schema
|
|
@@ -174,10 +296,18 @@ module Phronomy
|
|
|
174
296
|
# the on_error policy, and wrap errors as ToolError.
|
|
175
297
|
#
|
|
176
298
|
# Execution order:
|
|
177
|
-
# 1.
|
|
178
|
-
# 2.
|
|
179
|
-
# 3.
|
|
180
|
-
|
|
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!
|
|
181
311
|
validated_args, schema_error = validate_and_coerce(args)
|
|
182
312
|
if schema_error
|
|
183
313
|
case self.class.on_schema_error
|
|
@@ -188,18 +318,46 @@ module Phronomy
|
|
|
188
318
|
return "Schema validation failed: #{schema_error}"
|
|
189
319
|
end
|
|
190
320
|
end
|
|
191
|
-
|
|
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)
|
|
192
324
|
rescue Phronomy::ToolError
|
|
193
325
|
raise
|
|
326
|
+
rescue Phronomy::CancellationError
|
|
327
|
+
raise
|
|
194
328
|
rescue => e
|
|
195
329
|
case self.class.on_error
|
|
196
|
-
when :return_empty
|
|
197
|
-
[]
|
|
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}"
|
|
198
338
|
else
|
|
199
339
|
raise Phronomy::ToolError, "#{self.class.name} execution failed: #{e.message}"
|
|
200
340
|
end
|
|
201
341
|
end
|
|
202
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
|
+
|
|
203
361
|
# Instance method accessor — delegates to the class-level flag.
|
|
204
362
|
def requires_approval
|
|
205
363
|
self.class.requires_approval
|
|
@@ -226,12 +384,52 @@ module Phronomy
|
|
|
226
384
|
# WeatherService.fetch(location).to_s
|
|
227
385
|
# end
|
|
228
386
|
# end
|
|
387
|
+
# @api public
|
|
229
388
|
def execute(**_args)
|
|
230
389
|
raise NotImplementedError, "#{self.class}#execute is not implemented"
|
|
231
390
|
end
|
|
232
391
|
|
|
233
392
|
private
|
|
234
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
|
+
|
|
235
433
|
# Executes the given block inside a retry loop driven by the class-level
|
|
236
434
|
# retry_policies. Each policy matches by exception class; the first matching
|
|
237
435
|
# policy governs the wait and retry count. Raises immediately when no policy
|
|
@@ -261,6 +459,7 @@ module Phronomy
|
|
|
261
459
|
# @param base [Float] base wait time in seconds
|
|
262
460
|
# @param attempt [Integer] zero-based attempt index
|
|
263
461
|
# @return [Float]
|
|
462
|
+
# @api public
|
|
264
463
|
def compute_retry_wait(strategy, base, attempt)
|
|
265
464
|
case strategy
|
|
266
465
|
when :exponential
|
|
@@ -279,6 +478,7 @@ module Phronomy
|
|
|
279
478
|
#
|
|
280
479
|
# @param args [Hash] raw args passed to #call (string or symbol keys)
|
|
281
480
|
# @return [Array(Hash, String|nil)] [possibly_coerced_args, error_message_or_nil]
|
|
481
|
+
# @api public
|
|
282
482
|
def validate_and_coerce(args)
|
|
283
483
|
return [args, nil] if self.class.parameters.empty?
|
|
284
484
|
|
|
@@ -304,6 +504,15 @@ module Phronomy
|
|
|
304
504
|
return [nil, error] if error
|
|
305
505
|
end
|
|
306
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
|
+
|
|
307
516
|
enum_vals = self.class.param_enums[name]
|
|
308
517
|
if enum_vals && !enum_vals.map(&:to_s).include?(value.to_s)
|
|
309
518
|
return [nil, "parameter '#{name}' must be one of: #{enum_vals.join(", ")} (got: #{value.inspect})"]
|
|
@@ -312,9 +521,69 @@ module Phronomy
|
|
|
312
521
|
result[name] = value
|
|
313
522
|
end
|
|
314
523
|
|
|
315
|
-
#
|
|
316
|
-
|
|
317
|
-
|
|
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
|
|
318
587
|
end
|
|
319
588
|
|
|
320
589
|
# Returns a type-error message string if +value+ does not match +declared_type+,
|
|
@@ -322,6 +591,7 @@ module Phronomy
|
|
|
322
591
|
#
|
|
323
592
|
# @param value [Object]
|
|
324
593
|
# @param declared_type [Symbol, String] e.g. :string, :integer, :number, :boolean, :array, :object
|
|
594
|
+
# @api public
|
|
325
595
|
def type_error(value, declared_type)
|
|
326
596
|
return nil if value.nil?
|
|
327
597
|
|