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