phronomy 0.15.1 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.mutant.yml +8 -9
- data/CHANGELOG.md +159 -28
- data/CONTRIBUTING.md +28 -16
- data/README.md +400 -143
- data/benchmark/baseline.json +2 -3
- data/benchmark/bench_agent_invoke.rb +7 -4
- data/benchmark/bench_context_assembler.rb +134 -34
- data/benchmark/bench_regression.rb +3 -19
- data/benchmark/bench_tool_schema.rb +2 -34
- data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
- data/docs/decisions/010-cooperative-first-concurrency.md +7 -0
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +40 -1
- data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
- data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
- data/lib/phronomy/agent/activation_registry.rb +28 -0
- data/lib/phronomy/agent/agent_execution.rb +97 -0
- data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
- data/lib/phronomy/agent/agent_invocation.rb +44 -46
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +206 -104
- data/lib/phronomy/agent/agent_root.rb +66 -0
- data/lib/phronomy/agent/async_event_api.rb +55 -475
- data/lib/phronomy/agent/base.rb +351 -514
- data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
- data/lib/phronomy/agent/context/capability/base.rb +166 -297
- data/lib/phronomy/agent/context_assembler.rb +357 -0
- data/lib/phronomy/agent/context_candidate.rb +47 -0
- data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
- data/lib/phronomy/agent/context_importer.rb +217 -0
- data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
- data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
- data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +118 -0
- data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
- data/lib/phronomy/agent/context_plan.rb +25 -0
- data/lib/phronomy/agent/context_plan_validator.rb +134 -0
- data/lib/phronomy/agent/context_policies/default.rb +53 -0
- data/lib/phronomy/agent/context_policy.rb +15 -0
- data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
- data/lib/phronomy/agent/context_policy_registry.rb +46 -0
- data/lib/phronomy/agent/context_request.rb +35 -0
- data/lib/phronomy/agent/context_selection_unit.rb +38 -0
- data/lib/phronomy/agent/derived_content_spec.rb +34 -0
- data/lib/phronomy/agent/execution_coordinator.rb +1122 -0
- data/lib/phronomy/agent/immutable.rb +31 -0
- data/lib/phronomy/agent/journal_projection.rb +60 -0
- data/lib/phronomy/agent/journal_record.rb +67 -0
- data/lib/phronomy/agent/llm_call_record.rb +51 -0
- data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
- data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
- data/lib/phronomy/agent/llm_input_patch.rb +21 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
- data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +189 -0
- data/lib/phronomy/agent/shared_state.rb +46 -138
- data/lib/phronomy/agent/token_budget_resolver.rb +70 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
- data/lib/phronomy/agent/tool_definition_set.rb +55 -0
- data/lib/phronomy/agent/tool_invocation.rb +108 -314
- data/lib/phronomy/agent.rb +10 -16
- data/lib/phronomy/agent_busy_error.rb +5 -0
- data/lib/phronomy/canonical_json.rb +136 -0
- data/lib/phronomy/configuration.rb +17 -155
- data/lib/phronomy/content_store/base.rb +51 -0
- data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
- data/lib/phronomy/engine/event_loop.rb +3 -0
- data/lib/phronomy/engine/runtime.rb +15 -230
- data/lib/phronomy/engine/task_group.rb +30 -102
- data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
- data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
- data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
- data/lib/phronomy/multi_agent/orchestrator.rb +153 -204
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
- data/lib/phronomy/multi_agent/team_coordinator.rb +46 -133
- data/lib/phronomy/persistence/in_memory.rb +247 -0
- data/lib/phronomy/persistence.rb +39 -0
- data/lib/phronomy/tools/agent.rb +14 -36
- data/lib/phronomy/vector_store/in_memory.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +9 -115
- data/scripts/add_to_h_to_token_doubles.rb +33 -0
- data/scripts/add_to_h_unnamed_doubles.rb +27 -0
- data/scripts/api_snapshot.rb +1 -12
- data/scripts/migrate_spec_agent_definition.rb +108 -0
- data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
- data/scripts/migrate_spec_inline_pass3.rb +24 -0
- metadata +54 -13
- data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
- data/lib/phronomy/agent/before_completion_context.rb +0 -47
- data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
- 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/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
|
@@ -1,96 +1,37 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
3
5
|
module Phronomy
|
|
4
6
|
module MultiAgent
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
# @see https://claude.com/blog/multi-agent-coordination-patterns
|
|
8
|
-
#
|
|
9
|
-
# A coordinator LLM agent decomposes work into tasks and enqueues them
|
|
10
|
-
# dynamically via built-in tools. A fixed set of worker agents processes tasks
|
|
11
|
-
# sequentially — one task per worker per turn — carrying forward their
|
|
12
|
-
# conversation history across assignments to accumulate domain context over time.
|
|
13
|
-
#
|
|
14
|
-
# Workers are selected in sequence (the worker with the fewest accumulated
|
|
15
|
-
# messages is chosen by default). Task dispatch is synchronous; there is no
|
|
16
|
-
# concurrent or parallel execution.
|
|
17
|
-
#
|
|
18
|
-
# The coordinator is an {Agent::Base} subclass that has two built-in tools:
|
|
19
|
-
# - +enqueue_task+ — adds a task description to the queue
|
|
20
|
-
# - +finalize+ — signals that all tasks have been enqueued
|
|
21
|
-
#
|
|
22
|
-
# Worker persistence is implemented by passing each worker's accumulated
|
|
23
|
-
# +messages+ array back as a top-level +messages:+ argument on every subsequent
|
|
24
|
-
# +invoke+ call, so the LLM retains context across multiple task assignments.
|
|
25
|
-
#
|
|
26
|
-
# @example Basic usage
|
|
27
|
-
# class MigrationTeam < Phronomy::MultiAgent::TeamCoordinator
|
|
28
|
-
# coordinator_model "claude-3-5-sonnet-20241022"
|
|
29
|
-
# coordinator_instructions <<~INST
|
|
30
|
-
# Analyze the request and enqueue one migration task per service.
|
|
31
|
-
# Call enqueue_task for each service, then call finalize.
|
|
32
|
-
# INST
|
|
33
|
-
#
|
|
34
|
-
# pool size: 3, agent: MigrationAgent
|
|
35
|
-
#
|
|
36
|
-
# aggregate do |assignments|
|
|
37
|
-
# { reports: assignments.map { |a| { task: a[:task][:description], result: a[:result] } } }
|
|
38
|
-
# end
|
|
39
|
-
# end
|
|
40
|
-
#
|
|
41
|
-
# result = MigrationTeam.new.invoke("Migrate all services to Rails 8")
|
|
7
|
+
# Coordinator/worker multi-agent pattern with persistent worker Agent state.
|
|
42
8
|
class TeamCoordinator
|
|
43
|
-
# Holds per-worker context between task invocations.
|
|
44
|
-
# Worker persistence is implemented by carrying +messages+ forward on each
|
|
45
|
-
# successive +agent#invoke+ call as the top-level +messages:+ argument..
|
|
46
9
|
WorkerState = Struct.new(
|
|
47
|
-
:index,
|
|
48
|
-
:agent,
|
|
49
|
-
:
|
|
50
|
-
:status
|
|
10
|
+
:index,
|
|
11
|
+
:agent,
|
|
12
|
+
:transcript_size,
|
|
13
|
+
:status
|
|
51
14
|
) do
|
|
52
|
-
|
|
53
|
-
def available? = [:idle, :available].include?(status)
|
|
15
|
+
def available? = %i[idle available].include?(status)
|
|
54
16
|
end
|
|
55
17
|
private_constant :WorkerState
|
|
56
18
|
|
|
57
19
|
class << self
|
|
58
|
-
# Sets the LLM model for the coordinator agent.
|
|
59
|
-
# Falls back to +Phronomy.configuration.default_model+ when not set.
|
|
60
|
-
#
|
|
61
|
-
# @param value [String, nil]
|
|
62
20
|
# @api public
|
|
63
21
|
def coordinator_model(value = nil)
|
|
64
22
|
value ? @coordinator_model = value : @coordinator_model
|
|
65
23
|
end
|
|
66
24
|
|
|
67
|
-
# Sets the system instructions for the coordinator agent.
|
|
68
|
-
# The prompt should direct the LLM to call +enqueue_task+ for each task
|
|
69
|
-
# and then call +finalize+ when all tasks are enqueued.
|
|
70
|
-
#
|
|
71
|
-
# @param value [String, nil]
|
|
72
25
|
# @api public
|
|
73
26
|
def coordinator_instructions(value = nil)
|
|
74
27
|
value ? @coordinator_instructions = value : @coordinator_instructions
|
|
75
28
|
end
|
|
76
29
|
|
|
77
|
-
# Sets the LLM provider for the coordinator agent.
|
|
78
|
-
# Required when using a custom +BASE_URL+ (e.g. LM Studio, Ollama, vLLM)
|
|
79
|
-
# so that RubyLLM does not attempt to resolve an unknown model name.
|
|
80
|
-
# Pass the same value as +LLMConfig::PROVIDER+ in your examples.
|
|
81
|
-
#
|
|
82
|
-
# @param value [Symbol, nil]
|
|
83
30
|
# @api public
|
|
84
31
|
def coordinator_provider(value = nil)
|
|
85
32
|
value ? @coordinator_provider = value : @coordinator_provider
|
|
86
33
|
end
|
|
87
34
|
|
|
88
|
-
# Configures the set of workers.
|
|
89
|
-
#
|
|
90
|
-
# @param size [Integer] number of persistent worker instances (tasks are assigned sequentially)
|
|
91
|
-
# @param agent [Class] Agent::Base subclass used for all workers
|
|
92
|
-
# @param on_error [Symbol] +:raise+ (default) propagates worker exceptions;
|
|
93
|
-
# +:skip+ records the failure and continues with remaining tasks
|
|
94
35
|
# @api public
|
|
95
36
|
def pool(size:, agent:, on_error: :raise)
|
|
96
37
|
@pool_size = Integer(size)
|
|
@@ -98,57 +39,31 @@ module Phronomy
|
|
|
98
39
|
@on_error = on_error
|
|
99
40
|
end
|
|
100
41
|
|
|
101
|
-
# Customises the worker selection algorithm.
|
|
102
|
-
# The block receives an Array of available WorkerState objects and must
|
|
103
|
-
# return the one to assign the next task to.
|
|
104
|
-
# Default: worker with the fewest accumulated messages (round-robin-like).
|
|
105
|
-
#
|
|
106
|
-
# @yield [Array<WorkerState>] available workers
|
|
107
|
-
# @yieldreturn [WorkerState] the chosen worker
|
|
108
42
|
# @api public
|
|
109
43
|
def schedule(&block)
|
|
110
44
|
@scheduler = block
|
|
111
45
|
end
|
|
112
46
|
|
|
113
|
-
# Defines how task assignments are merged into the final return value.
|
|
114
|
-
# The block receives an Array of assignment Hashes:
|
|
115
|
-
# { task: Hash, result: String|nil, worker: Integer, error: Exception|nil }
|
|
116
|
-
# When omitted, the raw assignments array is returned.
|
|
117
|
-
#
|
|
118
|
-
# @yield [Array<Hash>] all completed (and skipped) task assignments
|
|
119
47
|
# @api public
|
|
120
48
|
def aggregate(&block)
|
|
121
49
|
@aggregator = block
|
|
122
50
|
end
|
|
123
51
|
|
|
124
|
-
# @!visibility private
|
|
125
52
|
def _coordinator_model = @coordinator_model
|
|
126
|
-
# @!visibility private
|
|
127
53
|
def _coordinator_instructions = @coordinator_instructions
|
|
128
|
-
# @!visibility private
|
|
129
54
|
def _coordinator_provider = @coordinator_provider
|
|
130
|
-
# @!visibility private
|
|
131
55
|
def _pool_size = @pool_size || 1
|
|
132
|
-
# @!visibility private
|
|
133
56
|
def _worker_agent = @worker_agent
|
|
134
|
-
# @!visibility private
|
|
135
57
|
def _on_error = @on_error || :raise
|
|
136
|
-
# @!visibility private
|
|
137
58
|
def _scheduler = @scheduler
|
|
138
|
-
# @!visibility private
|
|
139
59
|
def _aggregator = @aggregator
|
|
140
60
|
end
|
|
141
61
|
|
|
142
|
-
# Runs the full team coordination: coordinator generates tasks, workers
|
|
143
|
-
# process them sequentially, and the aggregate block merges the results.
|
|
144
|
-
#
|
|
145
|
-
# @param team_input [String, Hash] the high-level objective given to the coordinator
|
|
146
|
-
# @param config [Hash] reserved for future use
|
|
147
|
-
# @return [Object] the return value of the aggregate block, or the raw assignments Array
|
|
148
|
-
# @raise [ArgumentError] when +pool :agent+ has not been configured
|
|
149
62
|
# @api public
|
|
150
63
|
def invoke(team_input, config: {})
|
|
151
|
-
|
|
64
|
+
unless self.class._worker_agent
|
|
65
|
+
raise ArgumentError, "pool :agent must be configured before invoking"
|
|
66
|
+
end
|
|
152
67
|
|
|
153
68
|
task_queue = []
|
|
154
69
|
run_coordinator(team_input, task_queue)
|
|
@@ -156,26 +71,13 @@ module Phronomy
|
|
|
156
71
|
finalize_result(assignments)
|
|
157
72
|
end
|
|
158
73
|
|
|
159
|
-
# Streaming version of +invoke+. Yields a Hash event for each completed or
|
|
160
|
-
# failed task assignment.
|
|
161
|
-
#
|
|
162
|
-
# Yielded Hash keys:
|
|
163
|
-
# :type — +:task_completed+ or +:task_failed+
|
|
164
|
-
# :worker — worker index (Integer)
|
|
165
|
-
# :task — the task Hash from the queue ({ id:, description:, metadata:, enqueued_at: })
|
|
166
|
-
# :result — output string, or +nil+ on failure
|
|
167
|
-
# :error — Exception, or +nil+ on success
|
|
168
|
-
#
|
|
169
|
-
# @param team_input [String, Hash]
|
|
170
|
-
# @param config [Hash]
|
|
171
|
-
# @yield [Hash] one event per completed/failed task
|
|
172
|
-
# @return [Object] same as +invoke+
|
|
173
|
-
# @raise [ArgumentError] when +pool :agent+ has not been configured
|
|
174
74
|
# @api public
|
|
175
75
|
def stream(team_input, config: {}, &block)
|
|
176
76
|
return invoke(team_input, config: config) unless block
|
|
177
77
|
|
|
178
|
-
|
|
78
|
+
unless self.class._worker_agent
|
|
79
|
+
raise ArgumentError, "pool :agent must be configured before invoking"
|
|
80
|
+
end
|
|
179
81
|
|
|
180
82
|
task_queue = []
|
|
181
83
|
run_coordinator(team_input, task_queue)
|
|
@@ -185,23 +87,25 @@ module Phronomy
|
|
|
185
87
|
|
|
186
88
|
private
|
|
187
89
|
|
|
188
|
-
# Phase 1: Run the coordinator LLM agent to populate task_queue.
|
|
189
90
|
def run_coordinator(team_input, task_queue)
|
|
190
91
|
coordinator = build_coordinator_agent(task_queue)
|
|
191
92
|
input = team_input.is_a?(String) ? team_input : team_input.to_s
|
|
192
93
|
coordinator.invoke(input)
|
|
193
94
|
end
|
|
194
95
|
|
|
195
|
-
# Phase 2: Process tasks from the queue using the worker pool.
|
|
196
|
-
# Workers accumulate message history across assignments.
|
|
197
96
|
def run_workers(task_queue, &event_block)
|
|
198
97
|
pool_size = self.class._pool_size
|
|
199
98
|
agent_class = self.class._worker_agent
|
|
200
99
|
on_error = self.class._on_error
|
|
201
100
|
scheduler = self.class._scheduler
|
|
202
101
|
|
|
203
|
-
workers = Array.new(pool_size) do |
|
|
204
|
-
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
|
+
)
|
|
205
109
|
end
|
|
206
110
|
|
|
207
111
|
assignments = []
|
|
@@ -212,39 +116,45 @@ module Phronomy
|
|
|
212
116
|
worker = scheduler ? scheduler.call(available) : default_scheduler(available)
|
|
213
117
|
|
|
214
118
|
begin
|
|
215
|
-
result = worker.agent.invoke(task[:description]
|
|
216
|
-
worker.
|
|
119
|
+
result = worker.agent.invoke(task[:description])
|
|
120
|
+
worker.transcript_size = worker.agent.transcript.length
|
|
217
121
|
worker.status = :available
|
|
218
|
-
entry = {
|
|
122
|
+
entry = {
|
|
123
|
+
task: task,
|
|
124
|
+
result: result[:output],
|
|
125
|
+
worker: worker.index,
|
|
126
|
+
error: nil
|
|
127
|
+
}
|
|
219
128
|
assignments << entry
|
|
220
129
|
event_block&.call(entry.merge(type: :task_completed))
|
|
221
|
-
rescue =>
|
|
130
|
+
rescue => error
|
|
222
131
|
worker.status = :available
|
|
223
132
|
raise unless on_error == :skip
|
|
224
133
|
|
|
225
|
-
entry = {
|
|
134
|
+
entry = {
|
|
135
|
+
task: task,
|
|
136
|
+
result: nil,
|
|
137
|
+
worker: worker.index,
|
|
138
|
+
error: error
|
|
139
|
+
}
|
|
226
140
|
assignments << entry
|
|
227
141
|
event_block&.call(entry.merge(type: :task_failed))
|
|
228
142
|
end
|
|
229
143
|
end
|
|
230
144
|
|
|
231
|
-
workers.each { |
|
|
145
|
+
workers.each { |worker| worker.status = :done }
|
|
232
146
|
assignments
|
|
233
147
|
end
|
|
234
148
|
|
|
235
|
-
# Phase 3: Apply the aggregate block (or return raw assignments).
|
|
236
149
|
def finalize_result(assignments)
|
|
237
150
|
aggregator = self.class._aggregator
|
|
238
151
|
aggregator ? aggregator.call(assignments) : assignments
|
|
239
152
|
end
|
|
240
153
|
|
|
241
|
-
# Default scheduler: assign to the worker with the fewest accumulated
|
|
242
|
-
# messages (promotes round-robin-like distribution across the pool).
|
|
243
154
|
def default_scheduler(available_workers)
|
|
244
|
-
available_workers.min_by
|
|
155
|
+
available_workers.min_by(&:transcript_size)
|
|
245
156
|
end
|
|
246
157
|
|
|
247
|
-
# Build an anonymous coordinator Agent::Base with the two built-in tools.
|
|
248
158
|
def build_coordinator_agent(task_queue)
|
|
249
159
|
coordinator_model_val = self.class._coordinator_model
|
|
250
160
|
coordinator_instructions_val = self.class._coordinator_instructions
|
|
@@ -253,16 +163,16 @@ module Phronomy
|
|
|
253
163
|
finalize_tool = build_finalize_tool(task_queue)
|
|
254
164
|
|
|
255
165
|
coordinator_class = Class.new(Phronomy::Agent::Base) do
|
|
166
|
+
agent_definition id: "team-coordinator-#{SecureRandom.hex(4)}", version: 1
|
|
256
167
|
model coordinator_model_val
|
|
257
168
|
provider coordinator_provider_val if coordinator_provider_val
|
|
258
169
|
instructions coordinator_instructions_val
|
|
259
|
-
tools
|
|
170
|
+
tools(enqueue_tool => nil, finalize_tool => nil)
|
|
260
171
|
end
|
|
261
172
|
|
|
262
173
|
coordinator_class.new
|
|
263
174
|
end
|
|
264
175
|
|
|
265
|
-
# Builds the +enqueue_task+ tool. Each call appends a task Hash to task_queue.
|
|
266
176
|
def build_enqueue_tool(task_queue)
|
|
267
177
|
Class.new(Phronomy::Agent::Context::Capability::Base) do
|
|
268
178
|
tool_name "enqueue_task"
|
|
@@ -271,15 +181,18 @@ module Phronomy
|
|
|
271
181
|
param :metadata, type: :string, desc: "Optional metadata", required: false
|
|
272
182
|
|
|
273
183
|
define_method(:execute) do |description:, metadata: nil|
|
|
274
|
-
task = {
|
|
184
|
+
task = {
|
|
185
|
+
id: task_queue.size + 1,
|
|
186
|
+
description: description,
|
|
187
|
+
metadata: metadata,
|
|
188
|
+
enqueued_at: Time.now
|
|
189
|
+
}
|
|
275
190
|
task_queue << task
|
|
276
191
|
"Task ##{task[:id]} enqueued: #{description}"
|
|
277
192
|
end
|
|
278
193
|
end
|
|
279
194
|
end
|
|
280
195
|
|
|
281
|
-
# Builds the +finalize+ tool. Signals to the coordinator LLM that all tasks
|
|
282
|
-
# have been enqueued; returns a confirmation string.
|
|
283
196
|
def build_finalize_tool(task_queue)
|
|
284
197
|
Class.new(Phronomy::Agent::Context::Capability::Base) do
|
|
285
198
|
tool_name "finalize"
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "monitor"
|
|
4
|
+
require "digest"
|
|
5
|
+
|
|
6
|
+
module Phronomy
|
|
7
|
+
class Persistence
|
|
8
|
+
class InMemory < Persistence
|
|
9
|
+
class Contents < Phronomy::ContentStore::Base
|
|
10
|
+
def initialize(owner)
|
|
11
|
+
@owner = owner
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def put(bytes, canonicalization_version:)
|
|
15
|
+
value = String(bytes).b.freeze
|
|
16
|
+
id = content_id_for(value)
|
|
17
|
+
@owner.synchronize do
|
|
18
|
+
current = @owner.state[:contents][id]
|
|
19
|
+
if current && current[:bytes] != value
|
|
20
|
+
raise Phronomy::ContentStore::IntegrityError, "digest collision for #{id}"
|
|
21
|
+
end
|
|
22
|
+
@owner.state[:contents][id] ||= {
|
|
23
|
+
bytes: value,
|
|
24
|
+
canonicalization_version: Integer(canonicalization_version)
|
|
25
|
+
}
|
|
26
|
+
end
|
|
27
|
+
id
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def fetch(content_id)
|
|
31
|
+
@owner.synchronize do
|
|
32
|
+
record = @owner.state[:contents].fetch(content_id.to_s) do
|
|
33
|
+
raise NotFoundError, "content not found: #{content_id}"
|
|
34
|
+
end
|
|
35
|
+
bytes = record[:bytes]
|
|
36
|
+
unless content_id_for(bytes) == content_id.to_s
|
|
37
|
+
raise Phronomy::ContentStore::IntegrityError, "content digest mismatch: #{content_id}"
|
|
38
|
+
end
|
|
39
|
+
bytes.dup
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def exist?(content_id)
|
|
44
|
+
@owner.synchronize { @owner.state[:contents].key?(content_id.to_s) }
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
class Agents
|
|
49
|
+
def initialize(owner) = @owner = owner
|
|
50
|
+
|
|
51
|
+
def create(root)
|
|
52
|
+
@owner.synchronize do
|
|
53
|
+
key = root.agent_id.to_s
|
|
54
|
+
raise ConflictError, "agent_id must not be empty" if key.empty?
|
|
55
|
+
raise ConflictError, "agent already exists: #{key}" if @owner.state[:agents].key?(key)
|
|
56
|
+
@owner.state[:agents][key] = root
|
|
57
|
+
end
|
|
58
|
+
root
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def load(agent_id)
|
|
62
|
+
@owner.synchronize do
|
|
63
|
+
@owner.state[:agents].fetch(agent_id.to_s) { raise NotFoundError, "agent not found: #{agent_id}" }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def save(agent_id, expected_revision:, root:)
|
|
68
|
+
@owner.synchronize do
|
|
69
|
+
current = load(agent_id)
|
|
70
|
+
unless current.agent_revision == expected_revision
|
|
71
|
+
raise ConflictError,
|
|
72
|
+
"agent revision conflict: expected #{expected_revision}, actual #{current.agent_revision}"
|
|
73
|
+
end
|
|
74
|
+
unless root.agent_id.to_s == agent_id.to_s
|
|
75
|
+
raise ConflictError, "Agent root identity mismatch: #{root.agent_id} != #{agent_id}"
|
|
76
|
+
end
|
|
77
|
+
unless root.agent_revision == expected_revision + 1
|
|
78
|
+
raise ConflictError,
|
|
79
|
+
"agent save must advance revision exactly once: " \
|
|
80
|
+
"expected #{expected_revision + 1}, got #{root.agent_revision}"
|
|
81
|
+
end
|
|
82
|
+
@owner.state[:agents][agent_id.to_s] = root
|
|
83
|
+
end
|
|
84
|
+
root
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def delete(agent_id)
|
|
88
|
+
@owner.synchronize { @owner.state[:agents].delete(agent_id.to_s) }
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
class Journals
|
|
93
|
+
def initialize(owner) = @owner = owner
|
|
94
|
+
|
|
95
|
+
def append(agent_id, expected_position:, records:)
|
|
96
|
+
@owner.synchronize do
|
|
97
|
+
target = (@owner.state[:journals][agent_id.to_s] ||= [])
|
|
98
|
+
unless target.length == expected_position
|
|
99
|
+
raise ConflictError,
|
|
100
|
+
"journal position conflict: expected #{expected_position}, actual #{target.length}"
|
|
101
|
+
end
|
|
102
|
+
existing_ids = target.to_h { |record| [record.record_id, true] }
|
|
103
|
+
incoming_ids = {}
|
|
104
|
+
appended = Array(records).each_with_index.map do |record, index|
|
|
105
|
+
unless record.agent_id.to_s == agent_id.to_s
|
|
106
|
+
raise ConflictError,
|
|
107
|
+
"Journal record Agent mismatch: #{record.agent_id} != #{agent_id}"
|
|
108
|
+
end
|
|
109
|
+
if existing_ids[record.record_id] || incoming_ids[record.record_id]
|
|
110
|
+
raise ConflictError, "duplicate Journal record_id: #{record.record_id}"
|
|
111
|
+
end
|
|
112
|
+
incoming_ids[record.record_id] = true
|
|
113
|
+
record.with_sequence(expected_position + index + 1)
|
|
114
|
+
end
|
|
115
|
+
target.concat(appended)
|
|
116
|
+
appended.freeze
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def read(agent_id, after: nil, limit: nil)
|
|
121
|
+
@owner.synchronize do
|
|
122
|
+
result = Array(@owner.state[:journals][agent_id.to_s])
|
|
123
|
+
result = result.drop(Integer(after)) if after
|
|
124
|
+
result = result.first(limit) if limit
|
|
125
|
+
result.dup.freeze
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def head(agent_id)
|
|
130
|
+
@owner.synchronize { Array(@owner.state[:journals][agent_id.to_s]).length }
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def delete(agent_id)
|
|
134
|
+
@owner.synchronize { @owner.state[:journals].delete(agent_id.to_s) }
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
class Executions
|
|
139
|
+
def initialize(owner) = @owner = owner
|
|
140
|
+
|
|
141
|
+
def create_active(execution)
|
|
142
|
+
@owner.synchronize do
|
|
143
|
+
if @owner.state[:executions].key?(execution.execution_id.to_s)
|
|
144
|
+
raise ConflictError, "execution already exists: #{execution.execution_id}"
|
|
145
|
+
end
|
|
146
|
+
active = @owner.state[:executions].values.find do |candidate|
|
|
147
|
+
candidate.agent_id == execution.agent_id && candidate.active?
|
|
148
|
+
end
|
|
149
|
+
raise Phronomy::AgentBusyError, "agent is busy: #{execution.agent_id}" if active
|
|
150
|
+
@owner.state[:executions][execution.execution_id] = execution
|
|
151
|
+
end
|
|
152
|
+
execution
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def load(execution_id)
|
|
156
|
+
@owner.synchronize do
|
|
157
|
+
@owner.state[:executions].fetch(execution_id.to_s) do
|
|
158
|
+
raise NotFoundError, "execution not found: #{execution_id}"
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def save(execution_id, expected_revision:, execution:)
|
|
164
|
+
@owner.synchronize do
|
|
165
|
+
current = load(execution_id)
|
|
166
|
+
unless current.execution_revision == expected_revision
|
|
167
|
+
raise ConflictError,
|
|
168
|
+
"execution revision conflict: expected #{expected_revision}, actual #{current.execution_revision}"
|
|
169
|
+
end
|
|
170
|
+
unless execution.execution_id.to_s == execution_id.to_s
|
|
171
|
+
raise ConflictError,
|
|
172
|
+
"Execution identity mismatch: #{execution.execution_id} != #{execution_id}"
|
|
173
|
+
end
|
|
174
|
+
unless execution.execution_revision == expected_revision + 1
|
|
175
|
+
raise ConflictError,
|
|
176
|
+
"execution save must advance revision exactly once: " \
|
|
177
|
+
"expected #{expected_revision + 1}, got #{execution.execution_revision}"
|
|
178
|
+
end
|
|
179
|
+
@owner.state[:executions][execution_id.to_s] = execution
|
|
180
|
+
end
|
|
181
|
+
execution
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def list_active(agent_id)
|
|
185
|
+
@owner.synchronize do
|
|
186
|
+
@owner.state[:executions].values.select do |execution|
|
|
187
|
+
execution.agent_id == agent_id.to_s && execution.active?
|
|
188
|
+
end.freeze
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def delete(execution_id)
|
|
193
|
+
@owner.synchronize { @owner.state[:executions].delete(execution_id.to_s) }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def delete_for_agent(agent_id)
|
|
197
|
+
@owner.synchronize do
|
|
198
|
+
@owner.state[:executions].delete_if { |_id, execution| execution.agent_id == agent_id.to_s }
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
# Raises AgentBusyError if there is an active execution for agent_id.
|
|
203
|
+
# Must be called from within a transaction (monitor already held).
|
|
204
|
+
def assert_idle!(agent_id)
|
|
205
|
+
active = @owner.state[:executions].values.find do |candidate|
|
|
206
|
+
candidate.agent_id == agent_id.to_s && candidate.active?
|
|
207
|
+
end
|
|
208
|
+
raise Phronomy::AgentBusyError, "agent has an active or suspended execution: #{agent_id}" if active
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
|
|
212
|
+
attr_reader :state
|
|
213
|
+
|
|
214
|
+
def initialize
|
|
215
|
+
@monitor = Monitor.new
|
|
216
|
+
@state = {contents: {}, agents: {}, journals: {}, executions: {}}
|
|
217
|
+
@contents = Contents.new(self)
|
|
218
|
+
@agents = Agents.new(self)
|
|
219
|
+
@journals = Journals.new(self)
|
|
220
|
+
@executions = Executions.new(self)
|
|
221
|
+
@activations = Phronomy::Agent::ActivationRegistry.new
|
|
222
|
+
super(contents: @contents, agents: @agents, journals: @journals,
|
|
223
|
+
executions: @executions, activations: @activations)
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def capabilities
|
|
227
|
+
{atomic_all: true, atomic_admission: true, optimistic_revision: true}.freeze
|
|
228
|
+
end
|
|
229
|
+
|
|
230
|
+
def transaction
|
|
231
|
+
synchronize do
|
|
232
|
+
snapshot = Marshal.load(Marshal.dump(@state))
|
|
233
|
+
begin
|
|
234
|
+
yield self
|
|
235
|
+
rescue
|
|
236
|
+
@state = snapshot
|
|
237
|
+
raise
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def synchronize(&block)
|
|
243
|
+
@monitor.synchronize(&block)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
class Persistence
|
|
5
|
+
class ConflictError < Phronomy::Error; end
|
|
6
|
+
class NotFoundError < Phronomy::Error; end
|
|
7
|
+
class UnsupportedBackendError < Phronomy::Error; end
|
|
8
|
+
|
|
9
|
+
attr_reader :contents, :agents, :journals, :executions, :activations
|
|
10
|
+
|
|
11
|
+
def initialize(contents:, agents:, journals:, executions:, activations:)
|
|
12
|
+
@contents = contents
|
|
13
|
+
@agents = agents
|
|
14
|
+
@journals = journals
|
|
15
|
+
@executions = executions
|
|
16
|
+
@activations = activations
|
|
17
|
+
validate_capabilities!
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def capabilities
|
|
21
|
+
{atomic_all: false, atomic_admission: false}.freeze
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def transaction
|
|
25
|
+
raise UnsupportedBackendError, "#{self.class} does not provide atomic_all"
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def validate_capabilities!
|
|
31
|
+
required = {atomic_all: true, atomic_admission: true}
|
|
32
|
+
missing = required.reject { |key, value| capabilities[key] == value }
|
|
33
|
+
return if missing.empty?
|
|
34
|
+
|
|
35
|
+
raise UnsupportedBackendError,
|
|
36
|
+
"Persistence backend lacks required capabilities: #{missing.keys.join(", ")}"
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
data/lib/phronomy/tools/agent.rb
CHANGED
|
@@ -3,69 +3,47 @@
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
module Tools
|
|
5
5
|
# Wraps a Phronomy::Agent::Base subclass as a callable tool so that a parent
|
|
6
|
-
# agent can delegate sub-
|
|
7
|
-
#
|
|
8
|
-
# Use Agent.from_agent to generate a concrete tool class. The generated
|
|
9
|
-
# class is anonymous; assign it to a constant when you need a stable name.
|
|
10
|
-
#
|
|
11
|
-
# @example Wrap an existing agent
|
|
12
|
-
# SummarizerTool = Phronomy::Tools::Agent.from_agent(
|
|
13
|
-
# SummarizerAgent,
|
|
14
|
-
# tool_name: "summarize",
|
|
15
|
-
# description: "Summarizes a long text and returns a brief summary"
|
|
16
|
-
# )
|
|
17
|
-
#
|
|
18
|
-
# class OrchestratorAgent < Phronomy::Agent::Base
|
|
19
|
-
# model "openai/gpt-4o-mini"
|
|
20
|
-
# instructions "You are an orchestrator that delegates to specialist agents."
|
|
21
|
-
# tools SummarizerTool
|
|
22
|
-
# end
|
|
6
|
+
# agent can delegate a one-shot sub-task through the same stateful pipeline.
|
|
23
7
|
class Agent < Phronomy::Agent::Context::Capability::Base
|
|
24
8
|
description "Wraps an agent as a tool"
|
|
25
9
|
param :input, type: :string, desc: "The input to forward to the wrapped agent"
|
|
26
10
|
|
|
27
11
|
class << self
|
|
28
|
-
# Generates a Phronomy::Tools::Agent subclass that delegates #execute to
|
|
29
|
-
# an instance of +agent_class+.
|
|
30
|
-
#
|
|
31
|
-
# @param agent_class [Class] a Phronomy::Agent::Base subclass
|
|
32
|
-
# @param tool_name [String, nil] function name exposed to the LLM;
|
|
33
|
-
# defaults to a snake_case derivation of the agent class name
|
|
34
|
-
# @param description [String, nil] description exposed to the LLM;
|
|
35
|
-
# defaults to "Delegates to <AgentClassName>"
|
|
36
|
-
# @return [Class] an anonymous Phronomy::Tools::Agent subclass
|
|
37
|
-
# @api public
|
|
38
12
|
def from_agent(agent_class, tool_name: nil, description: nil)
|
|
39
13
|
raise ArgumentError, "agent_class must be a Class" unless agent_class.is_a?(Class)
|
|
14
|
+
unless agent_class <= Phronomy::Agent::Base
|
|
15
|
+
raise ArgumentError,
|
|
16
|
+
"agent_class must inherit from Phronomy::Agent::Base"
|
|
17
|
+
end
|
|
40
18
|
|
|
41
|
-
|
|
19
|
+
# Fail at Tool definition time rather than on the first Tool call.
|
|
20
|
+
agent_class.agent_definition
|
|
42
21
|
|
|
22
|
+
klass = Class.new(self)
|
|
43
23
|
effective_name = tool_name || derive_name(agent_class)
|
|
44
24
|
effective_desc = description || "Delegates to #{agent_class.name || "an agent"}"
|
|
45
25
|
|
|
46
26
|
klass.tool_name(effective_name)
|
|
47
27
|
klass.description(effective_desc)
|
|
48
|
-
|
|
49
28
|
klass.define_method(:execute) do |input:|
|
|
50
|
-
result =
|
|
29
|
+
result = Phronomy::Agent.run_once(
|
|
30
|
+
definition: agent_class,
|
|
31
|
+
input: input
|
|
32
|
+
)
|
|
51
33
|
result[:output].to_s
|
|
52
34
|
end
|
|
53
|
-
|
|
54
35
|
klass
|
|
55
36
|
end
|
|
56
37
|
|
|
57
38
|
private
|
|
58
39
|
|
|
59
|
-
# Derives a snake_case tool name from the agent class name.
|
|
60
|
-
# e.g. "My::SummarizerAgent" → "summarizer"
|
|
61
|
-
# "TranslatorAgent" → "translator"
|
|
62
40
|
def derive_name(agent_class)
|
|
63
41
|
return "agent_tool" unless agent_class.name
|
|
64
42
|
|
|
65
43
|
agent_class.name
|
|
66
44
|
.split("::").last
|
|
67
|
-
.gsub(/([A-Z]+)([A-Z][a-z])/, '
|
|
68
|
-
.gsub(/([a-z
|
|
45
|
+
.gsub(/([A-Z]+)([A-Z][a-z])/, '\\1_\\2')
|
|
46
|
+
.gsub(/([a-z\\d])([A-Z])/, '\\1_\\2')
|
|
69
47
|
.downcase
|
|
70
48
|
.sub(/_agent$/, "")
|
|
71
49
|
.sub(/_tool$/, "")
|