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.
Files changed (97) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +159 -28
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +400 -143
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +7 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +3 -19
  10. data/benchmark/bench_tool_schema.rb +2 -34
  11. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  12. data/docs/decisions/010-cooperative-first-concurrency.md +7 -0
  13. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +40 -1
  14. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
  15. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  16. data/lib/phronomy/agent/activation_registry.rb +28 -0
  17. data/lib/phronomy/agent/agent_execution.rb +97 -0
  18. data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
  19. data/lib/phronomy/agent/agent_invocation.rb +44 -46
  20. data/lib/phronomy/agent/agent_invocation_session_builder.rb +206 -104
  21. data/lib/phronomy/agent/agent_root.rb +66 -0
  22. data/lib/phronomy/agent/async_event_api.rb +55 -475
  23. data/lib/phronomy/agent/base.rb +351 -514
  24. data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
  25. data/lib/phronomy/agent/context/capability/base.rb +166 -297
  26. data/lib/phronomy/agent/context_assembler.rb +357 -0
  27. data/lib/phronomy/agent/context_candidate.rb +47 -0
  28. data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
  29. data/lib/phronomy/agent/context_importer.rb +217 -0
  30. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
  31. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
  32. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
  33. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +118 -0
  34. data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
  35. data/lib/phronomy/agent/context_plan.rb +25 -0
  36. data/lib/phronomy/agent/context_plan_validator.rb +134 -0
  37. data/lib/phronomy/agent/context_policies/default.rb +53 -0
  38. data/lib/phronomy/agent/context_policy.rb +15 -0
  39. data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
  40. data/lib/phronomy/agent/context_policy_registry.rb +46 -0
  41. data/lib/phronomy/agent/context_request.rb +35 -0
  42. data/lib/phronomy/agent/context_selection_unit.rb +38 -0
  43. data/lib/phronomy/agent/derived_content_spec.rb +34 -0
  44. data/lib/phronomy/agent/execution_coordinator.rb +1122 -0
  45. data/lib/phronomy/agent/immutable.rb +31 -0
  46. data/lib/phronomy/agent/journal_projection.rb +60 -0
  47. data/lib/phronomy/agent/journal_record.rb +67 -0
  48. data/lib/phronomy/agent/llm_call_record.rb +51 -0
  49. data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
  50. data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
  51. data/lib/phronomy/agent/llm_input_patch.rb +21 -0
  52. data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
  53. data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
  54. data/lib/phronomy/agent/ruby_llm_materializer.rb +189 -0
  55. data/lib/phronomy/agent/shared_state.rb +46 -138
  56. data/lib/phronomy/agent/token_budget_resolver.rb +70 -0
  57. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
  58. data/lib/phronomy/agent/tool_definition_set.rb +55 -0
  59. data/lib/phronomy/agent/tool_invocation.rb +108 -314
  60. data/lib/phronomy/agent.rb +10 -16
  61. data/lib/phronomy/agent_busy_error.rb +5 -0
  62. data/lib/phronomy/canonical_json.rb +136 -0
  63. data/lib/phronomy/configuration.rb +17 -155
  64. data/lib/phronomy/content_store/base.rb +51 -0
  65. data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
  66. data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
  67. data/lib/phronomy/engine/event_loop.rb +3 -0
  68. data/lib/phronomy/engine/runtime.rb +15 -230
  69. data/lib/phronomy/engine/task_group.rb +30 -102
  70. data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
  71. data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
  72. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  73. data/lib/phronomy/multi_agent/orchestrator.rb +153 -204
  74. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
  75. data/lib/phronomy/multi_agent/team_coordinator.rb +46 -133
  76. data/lib/phronomy/persistence/in_memory.rb +247 -0
  77. data/lib/phronomy/persistence.rb +39 -0
  78. data/lib/phronomy/tools/agent.rb +14 -36
  79. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  80. data/lib/phronomy/version.rb +1 -1
  81. data/lib/phronomy.rb +9 -115
  82. data/scripts/add_to_h_to_token_doubles.rb +33 -0
  83. data/scripts/add_to_h_unnamed_doubles.rb +27 -0
  84. data/scripts/api_snapshot.rb +1 -12
  85. data/scripts/migrate_spec_agent_definition.rb +108 -0
  86. data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
  87. data/scripts/migrate_spec_inline_pass3.rb +24 -0
  88. metadata +54 -13
  89. data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
  90. data/lib/phronomy/agent/before_completion_context.rb +0 -47
  91. data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
  92. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  93. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  94. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  95. data/lib/phronomy/knowledge_source.rb +0 -12
  96. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  97. 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
- # Implements the "Agent teams" coordination pattern (Anthropic blog, Pattern 3).
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, # Integer — 0-based worker index
48
- :agent, # Agent::Base instance
49
- :messages, # Array — accumulated conversation history
50
- :status # Symbol — :idle | :available | :done
10
+ :index,
11
+ :agent,
12
+ :transcript_size,
13
+ :status
51
14
  ) do
52
- # Returns true when this worker is ready to accept the next task.
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
- raise ArgumentError, "pool :agent must be configured before invoking" unless self.class._worker_agent
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
- raise ArgumentError, "pool :agent must be configured before invoking" unless self.class._worker_agent
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 |i|
204
- WorkerState.new(index: i, agent: agent_class.new, messages: [], status: :idle)
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], messages: worker.messages)
216
- worker.messages = result[:messages]
119
+ result = worker.agent.invoke(task[:description])
120
+ worker.transcript_size = worker.agent.transcript.length
217
121
  worker.status = :available
218
- entry = {task: task, result: result[:output], worker: worker.index, error: nil}
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 => e
130
+ rescue => error
222
131
  worker.status = :available
223
132
  raise unless on_error == :skip
224
133
 
225
- entry = {task: task, result: nil, worker: worker.index, error: e}
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 { |w| w.status = :done }
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 { |w| w.messages.size }
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 enqueue_tool, finalize_tool
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 = {id: task_queue.size + 1, description: description, metadata: metadata, enqueued_at: Time.now}
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
@@ -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-tasks to a fully-capable sub-agent.
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
- klass = Class.new(self)
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 = agent_class.new.invoke(input)
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])/, '\1_\2')
68
- .gsub(/([a-z\d])([A-Z])/, '\1_\2')
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$/, "")