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
|
@@ -4,95 +4,34 @@ require "securerandom"
|
|
|
4
4
|
|
|
5
5
|
module Phronomy
|
|
6
6
|
module MultiAgent
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
# @see https://claude.com/blog/multi-agent-coordination-patterns
|
|
10
|
-
#
|
|
11
|
-
# A coordinator LLM agent decomposes work into tasks and enqueues them
|
|
12
|
-
# dynamically via built-in tools. A fixed set of worker agents processes tasks
|
|
13
|
-
# sequentially — one task per worker per turn — carrying forward their
|
|
14
|
-
# conversation history across assignments to accumulate domain context over time.
|
|
15
|
-
#
|
|
16
|
-
# Workers are selected in sequence (the worker with the fewest accumulated
|
|
17
|
-
# messages is chosen by default). Task dispatch is synchronous; there is no
|
|
18
|
-
# concurrent or parallel execution.
|
|
19
|
-
#
|
|
20
|
-
# The coordinator is an {Agent::Base} subclass that has two built-in tools:
|
|
21
|
-
# - +enqueue_task+ — adds a task description to the queue
|
|
22
|
-
# - +finalize+ — signals that all tasks have been enqueued
|
|
23
|
-
#
|
|
24
|
-
# Worker persistence is implemented by passing each worker's accumulated
|
|
25
|
-
# +messages+ array back as a top-level +messages:+ argument on every subsequent
|
|
26
|
-
# +invoke+ call, so the LLM retains context across multiple task assignments.
|
|
27
|
-
#
|
|
28
|
-
# @example Basic usage
|
|
29
|
-
# class MigrationTeam < Phronomy::MultiAgent::TeamCoordinator
|
|
30
|
-
# coordinator_model "claude-3-5-sonnet-20241022"
|
|
31
|
-
# coordinator_instructions <<~INST
|
|
32
|
-
# Analyze the request and enqueue one migration task per service.
|
|
33
|
-
# Call enqueue_task for each service, then call finalize.
|
|
34
|
-
# INST
|
|
35
|
-
#
|
|
36
|
-
# pool size: 3, agent: MigrationAgent
|
|
37
|
-
#
|
|
38
|
-
# aggregate do |assignments|
|
|
39
|
-
# { reports: assignments.map { |a| { task: a[:task][:description], result: a[:result] } } }
|
|
40
|
-
# end
|
|
41
|
-
# end
|
|
42
|
-
#
|
|
43
|
-
# result = MigrationTeam.new.invoke("Migrate all services to Rails 8")
|
|
7
|
+
# Coordinator/worker multi-agent pattern with persistent worker Agent state.
|
|
44
8
|
class TeamCoordinator
|
|
45
|
-
# Holds per-worker context between task invocations.
|
|
46
|
-
# Worker persistence is implemented by carrying +messages+ forward on each
|
|
47
|
-
# successive +agent#invoke+ call as the top-level +messages:+ argument..
|
|
48
9
|
WorkerState = Struct.new(
|
|
49
|
-
:index,
|
|
50
|
-
:agent,
|
|
51
|
-
:
|
|
52
|
-
:status
|
|
10
|
+
:index,
|
|
11
|
+
:agent,
|
|
12
|
+
:transcript_size,
|
|
13
|
+
:status
|
|
53
14
|
) do
|
|
54
|
-
|
|
55
|
-
def available? = [:idle, :available].include?(status)
|
|
15
|
+
def available? = %i[idle available].include?(status)
|
|
56
16
|
end
|
|
57
17
|
private_constant :WorkerState
|
|
58
18
|
|
|
59
19
|
class << self
|
|
60
|
-
# Sets the LLM model for the coordinator agent.
|
|
61
|
-
# Falls back to +Phronomy.configuration.default_model+ when not set.
|
|
62
|
-
#
|
|
63
|
-
# @param value [String, nil]
|
|
64
20
|
# @api public
|
|
65
21
|
def coordinator_model(value = nil)
|
|
66
22
|
value ? @coordinator_model = value : @coordinator_model
|
|
67
23
|
end
|
|
68
24
|
|
|
69
|
-
# Sets the system instructions for the coordinator agent.
|
|
70
|
-
# The prompt should direct the LLM to call +enqueue_task+ for each task
|
|
71
|
-
# and then call +finalize+ when all tasks are enqueued.
|
|
72
|
-
#
|
|
73
|
-
# @param value [String, nil]
|
|
74
25
|
# @api public
|
|
75
26
|
def coordinator_instructions(value = nil)
|
|
76
27
|
value ? @coordinator_instructions = value : @coordinator_instructions
|
|
77
28
|
end
|
|
78
29
|
|
|
79
|
-
# Sets the LLM provider for the coordinator agent.
|
|
80
|
-
# Required when using a custom +BASE_URL+ (e.g. LM Studio, Ollama, vLLM)
|
|
81
|
-
# so that RubyLLM does not attempt to resolve an unknown model name.
|
|
82
|
-
# Pass the same value as +LLMConfig::PROVIDER+ in your examples.
|
|
83
|
-
#
|
|
84
|
-
# @param value [Symbol, nil]
|
|
85
30
|
# @api public
|
|
86
31
|
def coordinator_provider(value = nil)
|
|
87
32
|
value ? @coordinator_provider = value : @coordinator_provider
|
|
88
33
|
end
|
|
89
34
|
|
|
90
|
-
# Configures the set of workers.
|
|
91
|
-
#
|
|
92
|
-
# @param size [Integer] number of persistent worker instances (tasks are assigned sequentially)
|
|
93
|
-
# @param agent [Class] Agent::Base subclass used for all workers
|
|
94
|
-
# @param on_error [Symbol] +:raise+ (default) propagates worker exceptions;
|
|
95
|
-
# +:skip+ records the failure and continues with remaining tasks
|
|
96
35
|
# @api public
|
|
97
36
|
def pool(size:, agent:, on_error: :raise)
|
|
98
37
|
@pool_size = Integer(size)
|
|
@@ -100,57 +39,31 @@ module Phronomy
|
|
|
100
39
|
@on_error = on_error
|
|
101
40
|
end
|
|
102
41
|
|
|
103
|
-
# Customises the worker selection algorithm.
|
|
104
|
-
# The block receives an Array of available WorkerState objects and must
|
|
105
|
-
# return the one to assign the next task to.
|
|
106
|
-
# Default: worker with the fewest accumulated messages (round-robin-like).
|
|
107
|
-
#
|
|
108
|
-
# @yield [Array<WorkerState>] available workers
|
|
109
|
-
# @yieldreturn [WorkerState] the chosen worker
|
|
110
42
|
# @api public
|
|
111
43
|
def schedule(&block)
|
|
112
44
|
@scheduler = block
|
|
113
45
|
end
|
|
114
46
|
|
|
115
|
-
# Defines how task assignments are merged into the final return value.
|
|
116
|
-
# The block receives an Array of assignment Hashes:
|
|
117
|
-
# { task: Hash, result: String|nil, worker: Integer, error: Exception|nil }
|
|
118
|
-
# When omitted, the raw assignments array is returned.
|
|
119
|
-
#
|
|
120
|
-
# @yield [Array<Hash>] all completed (and skipped) task assignments
|
|
121
47
|
# @api public
|
|
122
48
|
def aggregate(&block)
|
|
123
49
|
@aggregator = block
|
|
124
50
|
end
|
|
125
51
|
|
|
126
|
-
# @!visibility private
|
|
127
52
|
def _coordinator_model = @coordinator_model
|
|
128
|
-
# @!visibility private
|
|
129
53
|
def _coordinator_instructions = @coordinator_instructions
|
|
130
|
-
# @!visibility private
|
|
131
54
|
def _coordinator_provider = @coordinator_provider
|
|
132
|
-
# @!visibility private
|
|
133
55
|
def _pool_size = @pool_size || 1
|
|
134
|
-
# @!visibility private
|
|
135
56
|
def _worker_agent = @worker_agent
|
|
136
|
-
# @!visibility private
|
|
137
57
|
def _on_error = @on_error || :raise
|
|
138
|
-
# @!visibility private
|
|
139
58
|
def _scheduler = @scheduler
|
|
140
|
-
# @!visibility private
|
|
141
59
|
def _aggregator = @aggregator
|
|
142
60
|
end
|
|
143
61
|
|
|
144
|
-
# Runs the full team coordination: coordinator generates tasks, workers
|
|
145
|
-
# process them sequentially, and the aggregate block merges the results.
|
|
146
|
-
#
|
|
147
|
-
# @param team_input [String, Hash] the high-level objective given to the coordinator
|
|
148
|
-
# @param config [Hash] reserved for future use
|
|
149
|
-
# @return [Object] the return value of the aggregate block, or the raw assignments Array
|
|
150
|
-
# @raise [ArgumentError] when +pool :agent+ has not been configured
|
|
151
62
|
# @api public
|
|
152
63
|
def invoke(team_input, config: {})
|
|
153
|
-
|
|
64
|
+
unless self.class._worker_agent
|
|
65
|
+
raise ArgumentError, "pool :agent must be configured before invoking"
|
|
66
|
+
end
|
|
154
67
|
|
|
155
68
|
task_queue = []
|
|
156
69
|
run_coordinator(team_input, task_queue)
|
|
@@ -158,26 +71,13 @@ module Phronomy
|
|
|
158
71
|
finalize_result(assignments)
|
|
159
72
|
end
|
|
160
73
|
|
|
161
|
-
# Streaming version of +invoke+. Yields a Hash event for each completed or
|
|
162
|
-
# failed task assignment.
|
|
163
|
-
#
|
|
164
|
-
# Yielded Hash keys:
|
|
165
|
-
# :type — +:task_completed+ or +:task_failed+
|
|
166
|
-
# :worker — worker index (Integer)
|
|
167
|
-
# :task — the task Hash from the queue ({ id:, description:, metadata:, enqueued_at: })
|
|
168
|
-
# :result — output string, or +nil+ on failure
|
|
169
|
-
# :error — Exception, or +nil+ on success
|
|
170
|
-
#
|
|
171
|
-
# @param team_input [String, Hash]
|
|
172
|
-
# @param config [Hash]
|
|
173
|
-
# @yield [Hash] one event per completed/failed task
|
|
174
|
-
# @return [Object] same as +invoke+
|
|
175
|
-
# @raise [ArgumentError] when +pool :agent+ has not been configured
|
|
176
74
|
# @api public
|
|
177
75
|
def stream(team_input, config: {}, &block)
|
|
178
76
|
return invoke(team_input, config: config) unless block
|
|
179
77
|
|
|
180
|
-
|
|
78
|
+
unless self.class._worker_agent
|
|
79
|
+
raise ArgumentError, "pool :agent must be configured before invoking"
|
|
80
|
+
end
|
|
181
81
|
|
|
182
82
|
task_queue = []
|
|
183
83
|
run_coordinator(team_input, task_queue)
|
|
@@ -187,23 +87,25 @@ module Phronomy
|
|
|
187
87
|
|
|
188
88
|
private
|
|
189
89
|
|
|
190
|
-
# Phase 1: Run the coordinator LLM agent to populate task_queue.
|
|
191
90
|
def run_coordinator(team_input, task_queue)
|
|
192
91
|
coordinator = build_coordinator_agent(task_queue)
|
|
193
92
|
input = team_input.is_a?(String) ? team_input : team_input.to_s
|
|
194
93
|
coordinator.invoke(input)
|
|
195
94
|
end
|
|
196
95
|
|
|
197
|
-
# Phase 2: Process tasks from the queue using the worker pool.
|
|
198
|
-
# Workers accumulate message history across assignments.
|
|
199
96
|
def run_workers(task_queue, &event_block)
|
|
200
97
|
pool_size = self.class._pool_size
|
|
201
98
|
agent_class = self.class._worker_agent
|
|
202
99
|
on_error = self.class._on_error
|
|
203
100
|
scheduler = self.class._scheduler
|
|
204
101
|
|
|
205
|
-
workers = Array.new(pool_size) do |
|
|
206
|
-
WorkerState.new(
|
|
102
|
+
workers = Array.new(pool_size) do |index|
|
|
103
|
+
WorkerState.new(
|
|
104
|
+
index: index,
|
|
105
|
+
agent: agent_class.new,
|
|
106
|
+
transcript_size: 0,
|
|
107
|
+
status: :idle
|
|
108
|
+
)
|
|
207
109
|
end
|
|
208
110
|
|
|
209
111
|
assignments = []
|
|
@@ -214,40 +116,45 @@ module Phronomy
|
|
|
214
116
|
worker = scheduler ? scheduler.call(available) : default_scheduler(available)
|
|
215
117
|
|
|
216
118
|
begin
|
|
217
|
-
# Worker agent retains conversation history in its Journal.
|
|
218
119
|
result = worker.agent.invoke(task[:description])
|
|
219
|
-
worker.
|
|
120
|
+
worker.transcript_size = worker.agent.transcript.length
|
|
220
121
|
worker.status = :available
|
|
221
|
-
entry = {
|
|
122
|
+
entry = {
|
|
123
|
+
task: task,
|
|
124
|
+
result: result[:output],
|
|
125
|
+
worker: worker.index,
|
|
126
|
+
error: nil
|
|
127
|
+
}
|
|
222
128
|
assignments << entry
|
|
223
129
|
event_block&.call(entry.merge(type: :task_completed))
|
|
224
|
-
rescue =>
|
|
130
|
+
rescue => error
|
|
225
131
|
worker.status = :available
|
|
226
132
|
raise unless on_error == :skip
|
|
227
133
|
|
|
228
|
-
entry = {
|
|
134
|
+
entry = {
|
|
135
|
+
task: task,
|
|
136
|
+
result: nil,
|
|
137
|
+
worker: worker.index,
|
|
138
|
+
error: error
|
|
139
|
+
}
|
|
229
140
|
assignments << entry
|
|
230
141
|
event_block&.call(entry.merge(type: :task_failed))
|
|
231
142
|
end
|
|
232
143
|
end
|
|
233
144
|
|
|
234
|
-
workers.each { |
|
|
145
|
+
workers.each { |worker| worker.status = :done }
|
|
235
146
|
assignments
|
|
236
147
|
end
|
|
237
148
|
|
|
238
|
-
# Phase 3: Apply the aggregate block (or return raw assignments).
|
|
239
149
|
def finalize_result(assignments)
|
|
240
150
|
aggregator = self.class._aggregator
|
|
241
151
|
aggregator ? aggregator.call(assignments) : assignments
|
|
242
152
|
end
|
|
243
153
|
|
|
244
|
-
# Default scheduler: assign to the worker with the fewest accumulated
|
|
245
|
-
# messages (promotes round-robin-like distribution across the pool).
|
|
246
154
|
def default_scheduler(available_workers)
|
|
247
|
-
available_workers.min_by
|
|
155
|
+
available_workers.min_by(&:transcript_size)
|
|
248
156
|
end
|
|
249
157
|
|
|
250
|
-
# Build an anonymous coordinator Agent::Base with the two built-in tools.
|
|
251
158
|
def build_coordinator_agent(task_queue)
|
|
252
159
|
coordinator_model_val = self.class._coordinator_model
|
|
253
160
|
coordinator_instructions_val = self.class._coordinator_instructions
|
|
@@ -260,34 +167,38 @@ module Phronomy
|
|
|
260
167
|
model coordinator_model_val
|
|
261
168
|
provider coordinator_provider_val if coordinator_provider_val
|
|
262
169
|
instructions coordinator_instructions_val
|
|
263
|
-
tools
|
|
170
|
+
tools(enqueue_tool => nil, finalize_tool => nil)
|
|
264
171
|
end
|
|
265
172
|
|
|
266
173
|
coordinator_class.new
|
|
267
174
|
end
|
|
268
175
|
|
|
269
|
-
# Builds the +enqueue_task+ tool. Each call appends a task Hash to task_queue.
|
|
270
176
|
def build_enqueue_tool(task_queue)
|
|
271
177
|
Class.new(Phronomy::Agent::Context::Capability::Base) do
|
|
272
178
|
tool_name "enqueue_task"
|
|
273
179
|
description "Add a task to the worker queue."
|
|
180
|
+
execution_mode :cooperative
|
|
274
181
|
param :description, type: :string, desc: "What the worker agent should do"
|
|
275
182
|
param :metadata, type: :string, desc: "Optional metadata", required: false
|
|
276
183
|
|
|
277
184
|
define_method(:execute) do |description:, metadata: nil|
|
|
278
|
-
task = {
|
|
185
|
+
task = {
|
|
186
|
+
id: task_queue.size + 1,
|
|
187
|
+
description: description,
|
|
188
|
+
metadata: metadata,
|
|
189
|
+
enqueued_at: Time.now
|
|
190
|
+
}
|
|
279
191
|
task_queue << task
|
|
280
192
|
"Task ##{task[:id]} enqueued: #{description}"
|
|
281
193
|
end
|
|
282
194
|
end
|
|
283
195
|
end
|
|
284
196
|
|
|
285
|
-
# Builds the +finalize+ tool. Signals to the coordinator LLM that all tasks
|
|
286
|
-
# have been enqueued; returns a confirmation string.
|
|
287
197
|
def build_finalize_tool(task_queue)
|
|
288
198
|
Class.new(Phronomy::Agent::Context::Capability::Base) do
|
|
289
199
|
tool_name "finalize"
|
|
290
200
|
description "Signal that task generation is complete. Call this after all tasks have been enqueued."
|
|
201
|
+
execution_mode :cooperative
|
|
291
202
|
param :summary, type: :string, desc: "Brief summary of what was enqueued", required: false
|
|
292
203
|
|
|
293
204
|
define_method(:execute) do |summary: ""|
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Testing
|
|
5
|
+
module Eval
|
|
6
|
+
class Comparison
|
|
7
|
+
ComparisonPair = Data.define(:eval_case, :result_a, :result_b)
|
|
8
|
+
|
|
9
|
+
def initialize(scorer: Scorer::ExactMatch.new)
|
|
10
|
+
@scorer = scorer
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def compare(dataset, callable_a, callable_b)
|
|
14
|
+
results_a = Runner.new(scorer: @scorer).run(dataset, callable_a)
|
|
15
|
+
results_b = Runner.new(scorer: @scorer).run(dataset, callable_b)
|
|
16
|
+
results_a.zip(results_b).map do |a, b|
|
|
17
|
+
ComparisonPair.new(eval_case: a.eval_case, result_a: a, result_b: b)
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Testing
|
|
5
|
+
module Eval
|
|
6
|
+
class Dataset
|
|
7
|
+
include Enumerable
|
|
8
|
+
|
|
9
|
+
def initialize(cases = [])
|
|
10
|
+
@cases = cases.freeze
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.from_array(pairs)
|
|
14
|
+
new(pairs.map { |item| EvalCase.new(**item) })
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def each(&block)
|
|
18
|
+
@cases.each(&block)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def size
|
|
22
|
+
@cases.size
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Testing
|
|
5
|
+
module Eval
|
|
6
|
+
EvalResult = Data.define(:eval_case, :actual, :score, :usage, :latency_ms, :error) do
|
|
7
|
+
def initialize(eval_case:, actual:, score:, usage:, latency_ms:, error: nil)
|
|
8
|
+
super
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def pass? = score >= 1.0
|
|
12
|
+
def scorer_error? = !error.nil?
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Testing
|
|
5
|
+
module Eval
|
|
6
|
+
class Metrics
|
|
7
|
+
def initialize(results)
|
|
8
|
+
@results = results
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def pass_rate
|
|
12
|
+
return 0.0 if @results.empty?
|
|
13
|
+
@results.count(&:pass?).to_f / @results.size
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def average_score
|
|
17
|
+
return 0.0 if @results.empty?
|
|
18
|
+
@results.sum(&:score) / @results.size
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def total_usage
|
|
22
|
+
@results.map(&:usage).compact.reduce(Phronomy::TokenUsage.zero, :+)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def average_latency_ms
|
|
26
|
+
return 0.0 if @results.empty?
|
|
27
|
+
@results.sum(&:latency_ms).to_f / @results.size
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def to_h
|
|
31
|
+
{
|
|
32
|
+
total: @results.size,
|
|
33
|
+
pass_count: @results.count(&:pass?),
|
|
34
|
+
pass_rate: pass_rate,
|
|
35
|
+
average_score: average_score,
|
|
36
|
+
total_usage: total_usage.to_h,
|
|
37
|
+
average_latency_ms: average_latency_ms
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Testing
|
|
5
|
+
module Eval
|
|
6
|
+
# Sequential test-only evaluator. Concurrency belongs to the subsystem
|
|
7
|
+
# under test, not to the evaluation helper itself.
|
|
8
|
+
class Runner
|
|
9
|
+
def initialize(scorer: Scorer::ExactMatch.new)
|
|
10
|
+
@scorer = scorer
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def run(dataset, callable)
|
|
14
|
+
dataset.to_a.map { |eval_case| run_one(eval_case, callable) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def run_one(eval_case, callable)
|
|
20
|
+
started_at = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
21
|
+
result = callable.call(eval_case.input)
|
|
22
|
+
latency_ms = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - started_at
|
|
23
|
+
actual, usage = extract(result)
|
|
24
|
+
score, score_error = score_safely(
|
|
25
|
+
@scorer,
|
|
26
|
+
actual: actual,
|
|
27
|
+
expected: eval_case.expected,
|
|
28
|
+
input: eval_case.input
|
|
29
|
+
)
|
|
30
|
+
EvalResult.new(
|
|
31
|
+
eval_case: eval_case,
|
|
32
|
+
actual: actual,
|
|
33
|
+
score: score,
|
|
34
|
+
usage: usage,
|
|
35
|
+
latency_ms: latency_ms,
|
|
36
|
+
error: score_error
|
|
37
|
+
)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def extract(result)
|
|
41
|
+
result.is_a?(Hash) ? [result[:output].to_s, result[:usage]] : [result.to_s, nil]
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def score_safely(scorer, **kwargs)
|
|
45
|
+
[scorer.score(**kwargs), nil]
|
|
46
|
+
rescue => error
|
|
47
|
+
[0.0, error]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Testing
|
|
5
|
+
module Eval
|
|
6
|
+
module Scorer
|
|
7
|
+
class Base
|
|
8
|
+
def score(actual:, expected:, input: nil)
|
|
9
|
+
raise NotImplementedError, "#{self.class}#score is not implemented"
|
|
10
|
+
end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Testing
|
|
5
|
+
module Eval
|
|
6
|
+
module Scorer
|
|
7
|
+
class ExactMatch < Base
|
|
8
|
+
def initialize(case_sensitive: true)
|
|
9
|
+
@case_sensitive = case_sensitive
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def score(actual:, expected:, input: nil)
|
|
13
|
+
actual_value = actual.to_s.strip
|
|
14
|
+
expected_value = expected.to_s.strip
|
|
15
|
+
unless @case_sensitive
|
|
16
|
+
actual_value = actual_value.downcase
|
|
17
|
+
expected_value = expected_value.downcase
|
|
18
|
+
end
|
|
19
|
+
(actual_value == expected_value) ? 1.0 : 0.0
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Testing
|
|
5
|
+
module Eval
|
|
6
|
+
module Scorer
|
|
7
|
+
class IncludesScorer < Base
|
|
8
|
+
def initialize(case_sensitive: false)
|
|
9
|
+
@case_sensitive = case_sensitive
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def score(actual:, expected:, input: nil)
|
|
13
|
+
actual_value = actual.to_s
|
|
14
|
+
expected_value = expected.to_s
|
|
15
|
+
unless @case_sensitive
|
|
16
|
+
actual_value = actual_value.downcase
|
|
17
|
+
expected_value = expected_value.downcase
|
|
18
|
+
end
|
|
19
|
+
actual_value.include?(expected_value) ? 1.0 : 0.0
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Testing
|
|
5
|
+
module Eval
|
|
6
|
+
module Scorer
|
|
7
|
+
class LlmJudge < Base
|
|
8
|
+
DEFAULT_PROMPT = <<~PROMPT
|
|
9
|
+
You are an impartial judge evaluating the quality of an AI assistant response.
|
|
10
|
+
Rate the response on a scale from 0.0 (completely wrong or unhelpful) to 1.0 (perfect).
|
|
11
|
+
Respond with ONLY a single decimal number between 0.0 and 1.0 — no other text.
|
|
12
|
+
|
|
13
|
+
Question: %<input>s
|
|
14
|
+
Expected answer: %<expected>s
|
|
15
|
+
Actual response: %<actual>s
|
|
16
|
+
|
|
17
|
+
Score:
|
|
18
|
+
PROMPT
|
|
19
|
+
|
|
20
|
+
def initialize(model:, prompt_template: DEFAULT_PROMPT, raise_on_error: false)
|
|
21
|
+
@model = model
|
|
22
|
+
@prompt_template = prompt_template
|
|
23
|
+
@raise_on_error = raise_on_error
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def score(actual:, expected:, input: nil)
|
|
27
|
+
prompt = format(
|
|
28
|
+
@prompt_template,
|
|
29
|
+
input: input.to_s,
|
|
30
|
+
expected: expected.to_s,
|
|
31
|
+
actual: actual.to_s
|
|
32
|
+
)
|
|
33
|
+
response = Phronomy::Runtime.instance.offload.submit do
|
|
34
|
+
RubyLLM.chat(model: @model).ask(prompt)
|
|
35
|
+
end.blocking_wait
|
|
36
|
+
response.content.to_s.strip.scan(/-?\d+\.?\d*/).first.to_f.clamp(0.0, 1.0)
|
|
37
|
+
rescue => error
|
|
38
|
+
raise if @raise_on_error
|
|
39
|
+
warn "[LlmJudge] Scoring failed: #{error.message}"
|
|
40
|
+
0.0
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|