phronomy 0.15.0 → 0.16.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 (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +107 -18
  3. data/README.md +300 -75
  4. data/benchmark/bench_agent_invoke.rb +3 -0
  5. data/benchmark/bench_regression.rb +2 -18
  6. data/benchmark/bench_tool_schema.rb +1 -0
  7. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +40 -1
  8. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
  9. data/lib/phronomy/agent/activation_registry.rb +28 -0
  10. data/lib/phronomy/agent/agent_execution.rb +97 -0
  11. data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
  12. data/lib/phronomy/agent/agent_invocation.rb +42 -10
  13. data/lib/phronomy/agent/agent_invocation_session_builder.rb +50 -11
  14. data/lib/phronomy/agent/agent_root.rb +67 -0
  15. data/lib/phronomy/agent/async_event_api.rb +55 -393
  16. data/lib/phronomy/agent/base.rb +301 -641
  17. data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
  18. data/lib/phronomy/agent/context_assembler.rb +321 -0
  19. data/lib/phronomy/agent/context_candidate.rb +47 -0
  20. data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
  21. data/lib/phronomy/agent/context_importer.rb +217 -0
  22. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
  23. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
  24. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
  25. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +188 -0
  26. data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
  27. data/lib/phronomy/agent/context_plan.rb +25 -0
  28. data/lib/phronomy/agent/context_plan_validator.rb +167 -0
  29. data/lib/phronomy/agent/context_policies/default.rb +53 -0
  30. data/lib/phronomy/agent/context_policy.rb +15 -0
  31. data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
  32. data/lib/phronomy/agent/context_policy_registry.rb +46 -0
  33. data/lib/phronomy/agent/context_request.rb +35 -0
  34. data/lib/phronomy/agent/context_selection_unit.rb +38 -0
  35. data/lib/phronomy/agent/derived_content_spec.rb +34 -0
  36. data/lib/phronomy/agent/execution_coordinator.rb +1123 -0
  37. data/lib/phronomy/agent/fsm_runtime_adapter.rb +210 -0
  38. data/lib/phronomy/agent/immutable.rb +31 -0
  39. data/lib/phronomy/agent/journal_projection.rb +34 -0
  40. data/lib/phronomy/agent/journal_record.rb +67 -0
  41. data/lib/phronomy/agent/llm_call_record.rb +51 -0
  42. data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
  43. data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
  44. data/lib/phronomy/agent/llm_input_patch.rb +21 -0
  45. data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
  46. data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
  47. data/lib/phronomy/agent/ruby_llm_materializer.rb +298 -0
  48. data/lib/phronomy/agent/token_budget_resolver.rb +69 -0
  49. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
  50. data/lib/phronomy/agent/tool_definition_set.rb +55 -0
  51. data/lib/phronomy/agent.rb +14 -16
  52. data/lib/phronomy/agent_busy_error.rb +5 -0
  53. data/lib/phronomy/canonical_json.rb +136 -0
  54. data/lib/phronomy/configuration.rb +9 -4
  55. data/lib/phronomy/content_store/base.rb +51 -0
  56. data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
  57. data/lib/phronomy/engine/event_loop.rb +3 -0
  58. data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
  59. data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
  60. data/lib/phronomy/llm_context_window/assembler.rb +8 -8
  61. data/lib/phronomy/multi_agent/orchestrator.rb +1 -0
  62. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
  63. data/lib/phronomy/multi_agent/team_coordinator.rb +6 -2
  64. data/lib/phronomy/persistence/in_memory.rb +247 -0
  65. data/lib/phronomy/persistence.rb +39 -0
  66. data/lib/phronomy/tools/agent.rb +14 -36
  67. data/lib/phronomy/version.rb +1 -1
  68. data/lib/phronomy.rb +11 -0
  69. data/scripts/add_to_h_to_token_doubles.rb +33 -0
  70. data/scripts/add_to_h_unnamed_doubles.rb +27 -0
  71. data/scripts/migrate_spec_agent_definition.rb +108 -0
  72. data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
  73. data/scripts/migrate_spec_inline_pass3.rb +24 -0
  74. metadata +54 -47
  75. data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
  76. data/lib/phronomy/agent/before_completion_context.rb +0 -47
  77. data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Phronomy
6
+ module ContentStore
7
+ class IntegrityError < Phronomy::Error; end
8
+
9
+ class Base
10
+ def put(_bytes, canonicalization_version:) = raise(NotImplementedError)
11
+ def fetch(_content_id) = raise(NotImplementedError)
12
+ def exist?(_content_id) = raise(NotImplementedError)
13
+
14
+ def put_text(text)
15
+ value = String(text).encode(Encoding::UTF_8)
16
+ raise ArgumentError, "invalid UTF-8 text" unless value.valid_encoding?
17
+
18
+ put(value, canonicalization_version: 1)
19
+ end
20
+
21
+ def put_json(value)
22
+ put(
23
+ Phronomy::CanonicalJSON.dump(value),
24
+ canonicalization_version: Phronomy::CanonicalJSON::VERSION
25
+ )
26
+ end
27
+
28
+ def fetch_text(content_id)
29
+ value = fetch(content_id).force_encoding(Encoding::UTF_8)
30
+ unless value.valid_encoding?
31
+ raise IntegrityError, "content is not UTF-8: #{content_id}"
32
+ end
33
+ value
34
+ end
35
+
36
+ def fetch_json(content_id)
37
+ Phronomy::CanonicalJSON.load(fetch(content_id))
38
+ end
39
+
40
+ def fetch_many(content_ids)
41
+ Array(content_ids).uniq.to_h { |content_id| [content_id, fetch(content_id)] }
42
+ end
43
+
44
+ private
45
+
46
+ def content_id_for(bytes)
47
+ "sha256:#{Digest::SHA256.hexdigest(bytes)}"
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Raised when mandatory context content (system instructions, tool definitions,
5
+ # current input) exhausts the model's context window, leaving no room for
6
+ # prior conversation history.
7
+ class ContextBudgetExceededError < Error; end
8
+ end
@@ -319,6 +319,9 @@ module Phronomy
319
319
  @fsms[session.id] = session
320
320
  @waiting[session.id] = waiter if waiter
321
321
  session.start
322
+ when :agent_terminal_ready
323
+ cmd = event.payload.fetch(:command)
324
+ cmd.coordinator.deliver_on_event_loop(cmd)
322
325
  end
323
326
  end
324
327
 
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ class ExecutionRehydrationRequiredError < Phronomy::Error; end
5
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Raised when output token reserve cannot be determined at budget construction
5
+ # time (e.g. no explicit max_output_tokens set and registry value equals the
6
+ # context window).
7
+ class InvalidContextBudgetConfigurationError < Error; end
8
+ end
@@ -14,9 +14,10 @@ module Phronomy
14
14
  # 4. Conversation — historical messages added via #add_messages
15
15
  #
16
16
  # Token budgeting:
17
- # When a budget is given, conversation messages are trimmed from oldest to
18
- # newest until they fit. Capability token cost is estimated and deducted
19
- # from the budget before conversation trimming so the reserve is accurate.
17
+ # When a budget is given, Assembler validates that the supplied conversation
18
+ # messages fit after instruction, knowledge, and capability costs are
19
+ # accounted for. It does not prune messages; Manifest-first Context Policy
20
+ # is responsible for context selection.
20
21
  # Knowledge chunks are always included in full (they are assumed to be
21
22
  # pre-screened by the caller). When no budget is given all messages are
22
23
  # passed through unchanged.
@@ -44,7 +45,7 @@ module Phronomy
44
45
  end
45
46
 
46
47
  # @param budget [Phronomy::LlmContextWindow::TokenBudget, nil]
47
- # when nil no token trimming is performed
48
+ # when nil no budget validation is performed
48
49
  # @api private
49
50
  # mutant:disable - @instruction = nil deletion is a genuine equivalent (uninitialized Ruby instance variables return nil)
50
51
  def initialize(budget: nil)
@@ -57,7 +58,7 @@ module Phronomy
57
58
 
58
59
  # Register tool classes (Region 2).
59
60
  # Estimates their token cost and deducts it from the budget so that
60
- # conversation trimming accounts for tool definition overhead.
61
+ # budget estimation accounts for tool definition overhead.
61
62
  #
62
63
  # @param tool_classes [Array<Class, Object>] tool classes or instances
63
64
  # @return [self]
@@ -131,8 +132,7 @@ module Phronomy
131
132
  # Raises {Phronomy::ContextLengthError} when a budget is set and the
132
133
  # conversation messages do not fit within the remaining token allowance.
133
134
  # No automatic trimming is performed — callers must pre-process messages
134
- # (e.g. via Agent::Base#trim_messages or #compact_messages) before
135
- # passing them to the Assembler.
135
+ # before passing them to the Assembler.
136
136
  #
137
137
  # mutant:disable - multiple genuine equivalent mutations: map{}.join("\n\n") → map{} is genuine; `unless knowledge_text.empty?` vs ternary is genuine; `{ system: unless system_text.empty? }` vs ternary is genuine; `messages:` shorthand vs `messages: messages` is genuine
138
138
  def build
@@ -149,7 +149,7 @@ module Phronomy
149
149
  raise Phronomy::ContextLengthError,
150
150
  "Context exceeds token budget: messages require #{msg_tokens} tokens but " \
151
151
  "only #{remaining} available (context_window=#{@budget.context_window}, " \
152
- "used_by_system=#{used}). Override build_context to trim or compact messages."
152
+ "used_by_system=#{used}). Use the Context Policy path to manage message budget."
153
153
  end
154
154
  end
155
155
 
@@ -42,6 +42,7 @@ module Phronomy
42
42
  # @example Fan-out (same agent, multiple inputs)
43
43
  # results = fan_out(agent: TranslationAgent, inputs: ["Hello", "World"])
44
44
  class Orchestrator < Agent::Base
45
+ agent_definition id: "orchestrator", version: 1
45
46
  # Declares a named subagent and registers it as a tool accessible to the
46
47
  # LLM during an +invoke+ call.
47
48
  #
@@ -31,16 +31,18 @@ module Phronomy
31
31
  return super if tool_calls.size <= 1
32
32
 
33
33
  if @on[:tool_call_batch]
34
- tool_calls.each { @on[:new_message]&.call }
34
+ tool_calls.each { run_callbacks(:before_message, :new_message) }
35
35
  @on[:tool_call_batch].call(tool_calls)
36
36
  return
37
37
  end
38
38
 
39
39
  # Direct ParallelToolChat fallback. Agent execution never reaches this
40
40
  # branch because AgentInvocation installs the batch interceptor first.
41
+ # RubyLLM >= 1.15 additive callbacks are dispatched together with their
42
+ # legacy equivalents, matching RubyLLM::Chat semantics.
41
43
  tool_calls.each do |tool_call|
42
- @on[:new_message]&.call
43
- @on[:tool_call]&.call(tool_call)
44
+ run_callbacks(:before_message, :new_message)
45
+ run_callbacks(:before_tool_call, :tool_call, tool_call)
44
46
  end
45
47
 
46
48
  cancellation_token = @cancellation_token
@@ -78,7 +80,7 @@ module Phronomy
78
80
  halt_result = nil
79
81
  tool_results.each do |item|
80
82
  result = item[:result]
81
- @on[:tool_result]&.call(result)
83
+ run_callbacks(:after_tool_result, :tool_result, result)
82
84
  tool_payload = result.is_a?(RubyLLM::Tool::Halt) ? result.content : result
83
85
  content = content_like?(tool_payload) ? tool_payload : tool_payload.to_s
84
86
  message = add_message(
@@ -86,7 +88,7 @@ module Phronomy
86
88
  content: content,
87
89
  tool_call_id: item[:tool_call].id
88
90
  )
89
- @on[:end_message]&.call(message)
91
+ run_callbacks(:after_message, :end_message, message)
90
92
  halt_result = result if result.is_a?(RubyLLM::Tool::Halt)
91
93
  end
92
94
 
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "securerandom"
4
+
3
5
  module Phronomy
4
6
  module MultiAgent
5
7
  # Implements the "Agent teams" coordination pattern (Anthropic blog, Pattern 3).
@@ -212,8 +214,9 @@ module Phronomy
212
214
  worker = scheduler ? scheduler.call(available) : default_scheduler(available)
213
215
 
214
216
  begin
215
- result = worker.agent.invoke(task[:description], messages: worker.messages)
216
- worker.messages = result[:messages]
217
+ # Worker agent retains conversation history in its Journal.
218
+ result = worker.agent.invoke(task[:description])
219
+ worker.messages = Array(result[:messages])
217
220
  worker.status = :available
218
221
  entry = {task: task, result: result[:output], worker: worker.index, error: nil}
219
222
  assignments << entry
@@ -253,6 +256,7 @@ module Phronomy
253
256
  finalize_tool = build_finalize_tool(task_queue)
254
257
 
255
258
  coordinator_class = Class.new(Phronomy::Agent::Base) do
259
+ agent_definition id: "team-coordinator-#{SecureRandom.hex(4)}", version: 1
256
260
  model coordinator_model_val
257
261
  provider coordinator_provider_val if coordinator_provider_val
258
262
  instructions coordinator_instructions_val
@@ -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$/, "")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.15.0"
4
+ VERSION = "0.16.0"
5
5
  end
data/lib/phronomy.rb CHANGED
@@ -20,6 +20,17 @@ loader.inflector.inflect("llm_adapter" => "LLMAdapter")
20
20
  loader.inflector.inflect("llm_operation_result" => "LLMOperationResult")
21
21
  # LLMAdapter::RubyLLM: "ruby_llm" maps to "RubyLLM" (not "RubyLlm").
22
22
  loader.inflector.inflect("ruby_llm" => "RubyLLM")
23
+ # CanonicalJSON: preserve uppercase JSON acronym.
24
+ loader.inflector.inflect("canonical_json" => "CanonicalJSON")
25
+ # RubyLLMMaterializer: preserve the double-uppercase LLM acronym.
26
+ loader.inflector.inflect("ruby_llm_materializer" => "RubyLLMMaterializer")
27
+ # LLMCallRecord: preserve uppercase LLM acronym.
28
+ loader.inflector.inflect("llm_call_record" => "LLMCallRecord")
29
+ # LLMInputManifest: preserve uppercase LLM acronym.
30
+ loader.inflector.inflect("llm_input_manifest" => "LLMInputManifest")
31
+ # LLMInputBuildContext / LLMInputPatch: preserve uppercase LLM acronym.
32
+ loader.inflector.inflect("llm_input_build_context" => "LLMInputBuildContext")
33
+ loader.inflector.inflect("llm_input_patch" => "LLMInputPatch")
23
34
  # Collapse engine/ so that its contents autoload directly under Phronomy::
24
35
  # (no Engine:: prefix). e.g. engine/event_loop.rb => Phronomy::EventLoop.
25
36
  # This allows the execution engine to be organised in its own subdirectory
@@ -0,0 +1,33 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Adds to_h to all Tokens-like doubles so ExecutionCoordinator can serialize usage.
5
+
6
+ files = Dir.glob("spec/**/*.rb").sort
7
+
8
+ total = 0
9
+ files.each do |f|
10
+ content = File.read(f)
11
+ modified = content.gsub(
12
+ /double\(("[^"]+"),\s*(input: \d+, output: \d+, cached: \d+, cache_creation: \d+)\)/
13
+ ) do
14
+ name = $1
15
+ attrs = $2
16
+ next $& if $&.include?("to_h")
17
+
18
+ h_entries = attrs.split(",").map(&:strip).map do |pair|
19
+ k, v = pair.split(":").map(&:strip)
20
+ "\"#{k}\" => #{v}"
21
+ end.join(", ")
22
+
23
+ total += 1
24
+ "double(#{name}, #{attrs}, to_h: {#{h_entries}})"
25
+ end
26
+
27
+ if modified != content
28
+ File.write(f, modified)
29
+ puts " patched: #{f}"
30
+ end
31
+ end
32
+
33
+ puts "\nAdded to_h to #{total} Tokens doubles."
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Adds to_h to unnamed (no-string-name) Tokens doubles like double(input: 5, ...)
5
+
6
+ files = Dir.glob("spec/**/*.rb").sort
7
+
8
+ total = 0
9
+ files.each do |f|
10
+ content = File.read(f)
11
+ modified = content.gsub(
12
+ /double\((input: (\d+), output: (\d+), cached: (\d+), cache_creation: (\d+))\)/
13
+ ) do
14
+ attrs, a, b, c, d = $1, $2, $3, $4, $5
15
+ next $& if $&.include?("to_h")
16
+
17
+ total += 1
18
+ "double(#{attrs}, to_h: {\"input\" => #{a}, \"output\" => #{b}, \"cached\" => #{c}, \"cache_creation\" => #{d}})"
19
+ end
20
+
21
+ if modified != content
22
+ File.write(f, modified)
23
+ puts " patched: #{f}"
24
+ end
25
+ end
26
+
27
+ puts "\nAdded to_h to #{total} unnamed Tokens doubles."