phronomy 0.31.0 → 0.33.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +141 -0
  3. data/README.md +13 -1
  4. data/RELEASE_CHECKLIST.md +4 -0
  5. data/docs/architecture/structured-output-and-scope.md +5 -4
  6. data/docs/architecture/trust-acceptance-followup.md +15 -0
  7. data/docs/context-policy-budgeting.md +108 -0
  8. data/docs/examples/shared_state_custom_sharing.rb +145 -0
  9. data/docs/features.md +4 -0
  10. data/docs/mcp-client.md +4 -2
  11. data/docs/migrations/shared-state-multi-agent.md +5 -0
  12. data/docs/runtime-and-concurrency.md +195 -0
  13. data/docs/shared-state-sharing.md +223 -0
  14. data/lib/phronomy/agent/approval_wait.rb +8 -0
  15. data/lib/phronomy/agent/async_event_api.rb +60 -0
  16. data/lib/phronomy/agent/base.rb +54 -1
  17. data/lib/phronomy/agent/execution/exact_execution.rb +4 -0
  18. data/lib/phronomy/agent/execution/execution_coordinator.rb +100 -2
  19. data/lib/phronomy/agent/journal/journal_projection.rb +24 -1
  20. data/lib/phronomy/agent/lifecycle/hosted_invocation.rb +67 -0
  21. data/lib/phronomy/agent/lifecycle/lifetime_api.rb +30 -0
  22. data/lib/phronomy/agent/lifecycle/operation_lease.rb +7 -0
  23. data/lib/phronomy/agent/lifecycle/operation_result.rb +12 -0
  24. data/lib/phronomy/agent/lifecycle/ownership_registry.rb +86 -14
  25. data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +1 -1
  26. data/lib/phronomy/agent/store.rb +52 -0
  27. data/lib/phronomy/agent/tool_execution/tool_approval_request.rb +3 -2
  28. data/lib/phronomy/agent/tool_execution/tool_invocation.rb +12 -36
  29. data/lib/phronomy/context/context_policy.rb +11 -2
  30. data/lib/phronomy/context/context_policy_input.rb +26 -0
  31. data/lib/phronomy/context/context_policy_input_builder.rb +27 -19
  32. data/lib/phronomy/engine/fsm_session.rb +9 -3
  33. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +87 -5
  34. data/lib/phronomy/multi_agent/orchestrator.rb +30 -18
  35. data/lib/phronomy/multi_agent/shared_state/binding.rb +44 -0
  36. data/lib/phronomy/multi_agent/shared_state/member_lifecycle_error.rb +32 -0
  37. data/lib/phronomy/multi_agent/shared_state.rb +147 -2
  38. data/lib/phronomy/tool/base.rb +69 -3
  39. data/lib/phronomy/tool/schema.rb +23 -11
  40. data/lib/phronomy/version.rb +1 -1
  41. data/lib/phronomy/workflow/execution/workflow.rb +9 -0
  42. data/lib/phronomy/workflow/execution/workflow_runner.rb +62 -14
  43. data/scripts/api_snapshot.rb +8 -3
  44. data/sig/phronomy/agent/execution_contract.rbs +13 -0
  45. data/sig/phronomy/agent/store.rbs +1 -0
  46. data/sig/phronomy/agent.rbs +2 -0
  47. data/sig/phronomy/context.rbs +3 -0
  48. data/sig/phronomy/multi_agent.rbs +28 -2
  49. data/sig/phronomy/tool.rbs +1 -0
  50. data/sig/phronomy/workflow.rbs +1 -0
  51. metadata +9 -2
@@ -56,6 +56,32 @@ module Phronomy
56
56
  freeze
57
57
  end
58
58
 
59
+ # Reports the input limit supplied to this Policy without exposing the
60
+ # framework's budget representation. Unknown capability remains nil.
61
+ # @return [Integer, nil]
62
+ # @api public
63
+ def input_token_limit
64
+ token_budget&.effective_input_limit
65
+ end
66
+
67
+ # Returns a selection input with an application limit. A known limit may
68
+ # only be retained or reduced. An unknown limit accepts an explicit cap
69
+ # without asserting that the model can accept that many tokens.
70
+ # @param limit [Integer] positive selection limit
71
+ # @return [ContextPolicyInput]
72
+ # @raise [ArgumentError] for an invalid limit or a known-limit increase
73
+ # @api public
74
+ def with_input_token_limit(limit)
75
+ unless limit.is_a?(Integer) && limit.positive?
76
+ raise ArgumentError, "input token limit must be a positive Integer"
77
+ end
78
+ if input_token_limit && limit > input_token_limit
79
+ raise ArgumentError, "input token limit must not exceed the current limit"
80
+ end
81
+
82
+ with(token_budget: Phronomy::LlmContextWindow::TokenBudget.new(max_input_tokens: limit))
83
+ end
84
+
59
85
  private
60
86
 
61
87
  def validate_items!(items, expected_class, label)
@@ -147,8 +147,9 @@ module Phronomy
147
147
  ordered = Array(items).sort_by { |item| [item.sequence || 0, item.id] }
148
148
  assistants = ordered.select { |item| item.kind == :assistant_message }
149
149
  tool_messages = ordered.select { |item| item.kind == :tool_message }
150
- tool_messages_by_id = tool_messages.group_by(&:tool_call_id)
151
- tool_messages_by_id.each do |tool_call_id, messages|
150
+ tool_messages_by_call = tool_messages.group_by { |item| tool_exchange_key(item, item.tool_call_id) }
151
+ tool_messages_by_call.each_value do |messages|
152
+ tool_call_id = messages.first.tool_call_id
152
153
  if tool_call_id.nil? || tool_call_id.empty?
153
154
  raise ArgumentError, "Tool message Context item requires tool_call_id"
154
155
  end
@@ -160,11 +161,12 @@ module Phronomy
160
161
  assistant_by_tool_call = {}
161
162
  assistants.each do |assistant|
162
163
  assistant.tool_call_ids.each do |tool_call_id|
163
- if assistant_by_tool_call.key?(tool_call_id)
164
+ key = tool_exchange_key(assistant, tool_call_id)
165
+ if assistant_by_tool_call.key?(key)
164
166
  raise ArgumentError,
165
167
  "duplicate assistant Tool Call id in Context Policy input: #{tool_call_id}"
166
168
  end
167
- assistant_by_tool_call[tool_call_id] = assistant
169
+ assistant_by_tool_call[key] = assistant
168
170
  end
169
171
  end
170
172
 
@@ -173,26 +175,25 @@ module Phronomy
173
175
  ordered.each do |item|
174
176
  next if claimed[item.id]
175
177
 
176
- if item.kind == :assistant_message && !item.tool_call_ids.empty?
177
- messages = item.tool_call_ids.map do |tool_call_id|
178
- tool_messages_by_id.fetch(tool_call_id) do
178
+ assistant = if item.kind == :assistant_message && !item.tool_call_ids.empty?
179
+ item
180
+ elsif item.kind == :tool_message
181
+ assistant_by_tool_call.fetch(tool_exchange_key(item, item.tool_call_id)) do
182
+ raise ArgumentError, "orphan Tool message in Context Policy input: #{item.tool_call_id}"
183
+ end
184
+ end
185
+
186
+ group = if assistant
187
+ messages = assistant.tool_call_ids.map do |tool_call_id|
188
+ key = tool_exchange_key(assistant, tool_call_id)
189
+ tool_messages_by_call.fetch(key) do
179
190
  raise ArgumentError,
180
191
  "assistant Tool Call has no Tool message in Context Policy input: #{tool_call_id}"
181
192
  end.first
182
193
  end
183
- group = ([item] + messages).uniq.sort_by { |member| [member.sequence || 0, member.id] }
184
- elsif item.kind == :tool_message
185
- assistant = assistant_by_tool_call[item.tool_call_id]
186
- unless assistant
187
- raise ArgumentError,
188
- "orphan Tool message in Context Policy input: #{item.tool_call_id}"
189
- end
190
- next if claimed[assistant.id]
191
- group = ([assistant] + assistant.tool_call_ids.map do |tool_call_id|
192
- tool_messages_by_id.fetch(tool_call_id).first
193
- end).uniq.sort_by { |member| [member.sequence || 0, member.id] }
194
+ [assistant, *messages].sort_by { |member| [member.sequence || 0, member.id] }
194
195
  else
195
- group = [item]
196
+ [item]
196
197
  end
197
198
 
198
199
  group.each { |member| claimed[member.id] = true }
@@ -202,6 +203,13 @@ module Phronomy
202
203
  mark_framework_required(groups, execution_id: execution_id, call_mode: call_mode)
203
204
  end
204
205
 
206
+ # Providers can number Tool calls afresh for each LLM response. Both native
207
+ # journal records carry the same durable LLM call ID, including after reload.
208
+ def tool_exchange_key(item, tool_call_id)
209
+ origin = item.provenance
210
+ [origin.agent_id, origin.execution_id, origin.llm_call_id, tool_call_id]
211
+ end
212
+
205
213
  def mark_framework_required(groups, execution_id:, call_mode:)
206
214
  return groups.map(&:freeze).freeze unless call_mode.to_sym == :complete
207
215
 
@@ -96,6 +96,7 @@ module Phronomy
96
96
  recursion_limit_scope: :session,
97
97
  resume_event: nil,
98
98
  resume_phase: nil,
99
+ require_resume_transition: false,
99
100
  stable_observer: nil,
100
101
  context_metadata: {},
101
102
  event_sink: nil,
@@ -132,6 +133,7 @@ module Phronomy
132
133
  @event_loop = event_loop
133
134
  @resume_event = resume_event
134
135
  @resume_phase = resume_phase
136
+ @require_resume_transition = require_resume_transition
135
137
  @stable_observer = stable_observer
136
138
  @terminal_policy = terminal_policy
137
139
  @terminal_lifecycle_state = :running
@@ -153,7 +155,8 @@ module Phronomy
153
155
  type: @resume_event,
154
156
  target_id: @id,
155
157
  payload: nil
156
- )
158
+ ),
159
+ require_transition: @require_resume_transition
157
160
  )
158
161
  else
159
162
  @current_state = @entry_point
@@ -242,7 +245,7 @@ module Phronomy
242
245
  end
243
246
  end
244
247
 
245
- def fire_and_advance!(event)
248
+ def fire_and_advance!(event, require_transition: false)
246
249
  automatic = event.type == :state_completed
247
250
  exhausted = if @recursion_limit_scope == :automatic_chain
248
251
  automatic && @automatic_steps >= @recursion_limit
@@ -258,7 +261,10 @@ module Phronomy
258
261
  clear_selected_transition!
259
262
  @tracker.current_event = event if @tracker.respond_to?(:current_event=)
260
263
  transitioned = fire_event!(@tracker, event.type, @current_state)
261
- return unless transitioned
264
+ unless transitioned
265
+ raise ArgumentError, "Event #{event.type.inspect} was rejected at #{@current_state.inspect}" if require_transition
266
+ return
267
+ end
262
268
 
263
269
  @ctx = @tracker.context
264
270
  @context = @ctx
@@ -83,6 +83,91 @@ module Phronomy
83
83
  extension&.state_ref ? persistence.contents.fetch_json(extension.state_ref) : {"children" => [], "cancel_requested" => false}
84
84
  end
85
85
 
86
+ # Validate both ends of the reservation in one durable snapshot. Only
87
+ # one direct, unfinished child belongs to this hosted handoff.
88
+ def child_approval_wait(parent_id, parent_execution_id, execution_id, approval_id, parent_revision: nil, live_child: nil)
89
+ store = persistence.agent_store
90
+ persistence.coordinator.atomic do |scope|
91
+ extension = store.execution_extension(agent_id: parent_id, execution_id: parent_execution_id,
92
+ binding_key: BINDING_KEY, scope: scope)
93
+ next unless extension&.state_ref && extension.binding_version == 1
94
+ snapshot = state_for(extension)
95
+ children = snapshot.fetch("children")
96
+ next unless !snapshot["cancel_requested"] && children.size == 1
97
+ child = children.first
98
+ next unless !child.fetch("imported") && child.fetch("execution_id") == execution_id
99
+ next unless matching_child_class?(child, live_child)
100
+ next unless store.single_dispatch_wait?(agent_id: parent_id, execution_id: parent_execution_id,
101
+ invocation_id: child.fetch("slot"), name: "dispatch_to_#{child.fetch("name")}", expected_revision: parent_revision, scope: scope)
102
+ next unless hosted_retentions?(parent_id, parent_execution_id, scope)
103
+
104
+ child_id = child.fetch("agent_id")
105
+ owner = {"kind" => "subagent", "parent_execution_id" => parent_execution_id,
106
+ "parent_agent_id" => parent_id, "slot" => child.fetch("slot")}
107
+ retention = Phronomy::Agent::Retention.new(agent_id: child_id, execution_id: execution_id,
108
+ owner_key: "subagent:#{parent_execution_id}")
109
+ wait = store.approval_wait_for(agent_id: child_id, execution_id: execution_id,
110
+ correlation: owner, retentions: [retention], scope: scope)
111
+ wait if wait&.approval_request_id == approval_id
112
+ end
113
+ end
114
+
115
+ # A terminal record alone does not show that its result reached the
116
+ # parent. Check the native slot, exact reservation and holds together.
117
+ def completed_child_pair?(parent_id, parent_execution_id, child, live_child:, scope:)
118
+ store = persistence.agent_store
119
+ return false unless store.terminal_execution?(agent_id: parent_id, execution_id: parent_execution_id, scope: scope)
120
+ children = stored_children(parent_id, parent_execution_id, scope)
121
+ return false unless children&.size == 1
122
+ slot = children.first
123
+ return false unless slot.fetch("imported") == true && slot.fetch("agent_id") == child.agent_id &&
124
+ slot.fetch("execution_id") == child.execution_id && matching_child_class?(slot, live_child)
125
+
126
+ owner = {"kind" => "subagent", "parent_agent_id" => parent_id, "parent_execution_id" => parent_execution_id,
127
+ "slot" => slot.fetch("slot")}
128
+ return false unless store.terminal_execution?(agent_id: child.agent_id, execution_id: child.execution_id,
129
+ reservation: owner, scope: scope)
130
+ hold = Phronomy::Agent::Retention.new(agent_id: child.agent_id, execution_id: child.execution_id,
131
+ owner_key: "subagent:#{parent_execution_id}")
132
+ return false unless store.retained_references(agent_id: child.agent_id, scope: scope) == [hold]
133
+
134
+ hosted_retentions?(parent_id, parent_execution_id, scope) do |prior_id|
135
+ prior = stored_children(parent_id, prior_id, scope)
136
+ prior && !prior.empty? && prior.all? { |entry| entry.fetch("imported") == true }
137
+ end
138
+ end
139
+
140
+ private def stored_children(parent_id, execution_id, scope)
141
+ extension = persistence.agent_store.execution_extension(agent_id: parent_id, execution_id: execution_id,
142
+ binding_key: BINDING_KEY, scope: scope)
143
+ state_for(extension).fetch("children") if extension&.state_ref && extension.binding_version == 1
144
+ end
145
+
146
+ private def matching_child_class?(slot, live_child)
147
+ klass = self.class.registered_child_class(@agent_class, slot)
148
+ klass && (!live_child || (live_child.agent_id == slot.fetch("agent_id") && live_child.is_a?(klass) &&
149
+ live_child.class.agent_definition == klass.agent_definition && live_child.persistence.equal?(persistence.agent_store)))
150
+ end
151
+
152
+ # Retaining the history of an earlier completed native dispatch on this
153
+ # same Agent does not give anyone a live interest in the current run.
154
+ private def hosted_retentions?(agent_id, execution_id, scope)
155
+ store = persistence.agent_store
156
+ current = Phronomy::Agent::Retention.new(agent_id: agent_id, execution_id: execution_id, owner_key: "subagent:#{execution_id}")
157
+ retentions = store.retained_references(agent_id: agent_id, scope: scope)
158
+ retentions.include?(current) && (retentions - [current]).all? do |retention|
159
+ retention.execution_id && retention.owner_key == "subagent:#{retention.execution_id}" &&
160
+ store.terminal_execution?(agent_id: agent_id, execution_id: retention.execution_id, scope: scope) &&
161
+ (!block_given? || yield(retention.execution_id))
162
+ end
163
+ end
164
+
165
+ def self.registered_child_class(parent_class, child)
166
+ settings = parent_class.registered_subagents.find { |name, _| name.to_s == child.fetch("name") }&.last
167
+ klass = settings&.fetch(:agent_class)
168
+ klass if klass && klass.agent_definition.transform_keys(&:to_s) == child.fetch("definition")
169
+ end
170
+
86
171
  def self.start(parent:, tool_invocation_id:, parent_execution_id:, config:)
87
172
  completion = Phronomy::TaskResult.deferred(name: "durable-subagent:#{tool_invocation_id}")
88
173
  preparation = Phronomy::Execution.submit(on_full: :raise) do
@@ -92,11 +177,8 @@ module Phronomy
92
177
  snapshot = store.contents.fetch_json(extension.state_ref)
93
178
  child = snapshot.fetch("children").find { |entry| entry.fetch("slot") == tool_invocation_id }
94
179
  raise Phronomy::ExecutionRehydrationRequiredError, "Missing reserved child slot" unless child
95
- settings = parent.class.registered_subagents.find { |name, _| name.to_s == child.fetch("name") }&.last
96
- unless settings && settings.fetch(:agent_class).agent_definition.transform_keys(&:to_s) == child.fetch("definition")
97
- raise Phronomy::ConfigurationError, "Registered child definition changed"
98
- end
99
- klass = settings.fetch(:agent_class)
180
+ klass = registered_child_class(parent.class, child)
181
+ raise Phronomy::ConfigurationError, "Registered child definition changed" unless klass
100
182
  id = child.fetch("agent_id")
101
183
  retention = Phronomy::Agent::Retention.new(agent_id: id, execution_id: child.fetch("execution_id"), owner_key: "subagent:#{parent_execution_id}")
102
184
  agent = klass.get(id)
@@ -29,6 +29,16 @@ module Phronomy
29
29
  @participant = @coordination_store && DurableSubagentCoordinator.new(agent_class: self.class, persistence: @coordination_store)
30
30
  end
31
31
 
32
+ # Release the local parent and its directly dispatched child after the
33
+ # child's outcome has been imported. The stored history remains intact.
34
+ # @api public
35
+ def unload_with_completed_child!(parent_execution_id, execution_id:)
36
+ owner = {"kind" => "subagent", "parent_agent_id" => agent_id, "parent_execution_id" => parent_execution_id}
37
+ __unload_with_peer_execution!(execution_id, reserved_by: owner) do |child, live_child, scope|
38
+ @participant&.completed_child_pair?(agent_id, parent_execution_id, child, live_child: live_child, scope: scope)
39
+ end
40
+ end
41
+
32
42
  # Current composition, never serialized in an execution record.
33
43
  def __invocation_config(config)
34
44
  if !self.class.registered_subagents.empty? && !@participant
@@ -38,6 +48,14 @@ module Phronomy
38
48
  phronomy_execution_participant: @participant).compact
39
49
  end
40
50
 
51
+ # Answers the current wait of a recovered direct child. If the same
52
+ # execution suspends again the host can release the pair with this handle.
53
+ # @api public
54
+ def approve_hosted_child(parent_execution_id, execution_id:, approval_request_id:, approved: true, config: {})
55
+ resume_hosted_child_approval(parent_execution_id, execution_id: execution_id,
56
+ approval_request_id: approval_request_id, approved: approved, config: config)
57
+ end
58
+
41
59
  def self.subagent(name, agent_class, on_error: :raise, inherit_knowledge: true)
42
60
  # A subagent Tool is logically asynchronous: ToolInvocation starts the
43
61
  # child Agent and resumes when its completion TaskResult settles. It must not
@@ -224,6 +242,8 @@ module Phronomy
224
242
 
225
243
  private
226
244
 
245
+ def hosted_child_approval_reader = @participant&.method(:child_approval_wait)
246
+
227
247
  def prepare_tool_class(tool_class, invocation: nil)
228
248
  prepared = super
229
249
  return prepared unless self.class._subagent_tool_classes.include?(tool_class)
@@ -234,28 +254,20 @@ module Phronomy
234
254
  end&.last
235
255
  inherits_knowledge = registration ? registration.fetch(:inherit_knowledge, true) : true
236
256
 
237
- captured_context = {parent: self}
257
+ context = {parent: self}
238
258
  # Invocation-owned Tools inherit the durable child slot's knowledge,
239
259
  # captured by the existing preparation transaction off EventLoop.
240
- captured_context[:knowledge] = active_knowledge_snapshot if inherits_knowledge && !invocation
260
+ context[:knowledge] = active_knowledge_snapshot if inherits_knowledge && !invocation
241
261
  if invocation
242
- captured_context[:config] = invocation.config
243
- captured_context[:invocation_context] = invocation.config[:invocation_context]
244
- end
245
- captured_context.freeze
246
-
247
- effective_name = prepared.new.name
248
- Class.new(prepared) do
249
- tool_name effective_name
250
- define_method(:call) do |args, **kwargs|
251
- self._orchestrator_context = captured_context
252
- super(args, **kwargs)
253
- end
254
- define_method(:call_async) do |args, **kwargs|
255
- self._orchestrator_context = captured_context
256
- super(args, **kwargs)
257
- end
262
+ context[:config] = invocation.config
263
+ context[:invocation_context] = invocation.config[:invocation_context]
258
264
  end
265
+
266
+ # Cached Tool methods can outlive an execution. Only the instance used
267
+ # for this dispatch should hold its parent and invocation configuration.
268
+ tool = prepared.new
269
+ tool._orchestrator_context = context.freeze
270
+ tool
259
271
  end
260
272
 
261
273
  def active_knowledge_snapshot = knowledge_snapshot
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module MultiAgent
5
+ class SharedState
6
+ # Connects one application's shared data to the Tools offered to members.
7
+ # The application owns the store's shape, operations and resource lifetime.
8
+ # @api public
9
+ class Binding
10
+ # @param store [Object] opaque application data passed to coordination callbacks
11
+ # @param tools [#call] builder accepting member: (Agent class) and cycle:
12
+ # @api public
13
+ def initialize(store:, tools:)
14
+ raise ArgumentError, "sharing tools must respond to call" unless tools.respond_to?(:call)
15
+
16
+ @store = store
17
+ @tools = tools
18
+ freeze
19
+ end
20
+
21
+ # @api public
22
+ attr_reader :store
23
+
24
+ # Build invocation-bound Tools using the existing Agent.tools declaration shape.
25
+ # The builder receives the registered Agent class, not an Agent instance.
26
+ # @return [Hash<Class, String, nil>] Tool classes mapped to aliases or nil
27
+ # @api public
28
+ def tool_definitions(member:, cycle:)
29
+ definitions = @tools.call(member: member, cycle: cycle)
30
+ unless definitions.is_a?(Hash)
31
+ raise ArgumentError, "sharing tools must return a Hash of ToolClass => alias_or_nil"
32
+ end
33
+
34
+ definitions.to_h do |tool_class, alias_name|
35
+ unless tool_class.is_a?(Class) && tool_class <= Phronomy::Tool::Base
36
+ raise ArgumentError, "sharing tools must contain Phronomy::Tool::Base classes"
37
+ end
38
+ [tool_class, alias_name&.to_s&.dup&.freeze]
39
+ end.freeze
40
+ end
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module MultiAgent
5
+ class SharedState
6
+ # Keeps the member handle when invocation-scoped sharing cannot finish safely.
7
+ # Callers choose approval, cancellation or release through existing Agent APIs.
8
+ # @api public
9
+ class MemberLifecycleError < Phronomy::Error
10
+ # @api public
11
+ attr_reader :agent, :result, :reason, :primary_error, :release_error
12
+
13
+ # The live Agent and application resources are process-local, not a
14
+ # durable recipe for rebuilding the sharing provider or its Tools.
15
+ # @api public
16
+ def initialize(agent:, result:, reason:, primary_error: nil, release_error: nil)
17
+ @agent = agent
18
+ @result = result
19
+ @reason = reason
20
+ @primary_error = primary_error
21
+ @release_error = release_error
22
+ message = if reason == :suspended
23
+ "SharedState sharing cannot complete while a member is suspended"
24
+ else
25
+ "SharedState sharing could not release a member: #{release_error&.message}"
26
+ end
27
+ super(message)
28
+ end
29
+ end
30
+ end
31
+ end
32
+ end
@@ -1,8 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "digest"
4
+
3
5
  module Phronomy
4
6
  module MultiAgent
5
- # Implements peer coordination through an invocation-local KnowledgeStore.
7
+ # Coordinates members through default findings or application-defined shared data.
6
8
  # @api public
7
9
  class SharedState
8
10
  # Semantic revision of the framework-owned SharedState instrumentation
@@ -70,6 +72,29 @@ module Phronomy
70
72
  block ? @aggregator = block : @aggregator
71
73
  end
72
74
 
75
+ # Declare application-owned sharing for each invocation. The provider's
76
+ # open(input:, config:) method must synchronously yield one Binding.
77
+ # Its block scope owns any connection or other application resource.
78
+ # Increment version when the shared Tool schemas or meanings change.
79
+ # @param provider [#open]
80
+ # @param id [String] stable application sharing definition identity
81
+ # @param version [Integer] positive sharing definition revision
82
+ # @api public
83
+ def sharing(provider, id:, version:)
84
+ raise ArgumentError, "sharing provider must respond to open" unless provider.respond_to?(:open)
85
+ unless id.is_a?(String) && !id.strip.empty?
86
+ raise ArgumentError, "sharing id must be a nonempty String"
87
+ end
88
+ unless version.is_a?(Integer) && version.positive?
89
+ raise ArgumentError, "sharing version must be a positive Integer"
90
+ end
91
+
92
+ @sharing = {provider: provider, id: id.dup.freeze, version: version}.freeze
93
+ end
94
+
95
+ # @api private
96
+ def _sharing = @sharing
97
+
73
98
  def _members = Array(@members)
74
99
  def _coordination = @coordination
75
100
  def _max_cycles = @max_cycles
@@ -81,6 +106,8 @@ module Phronomy
81
106
  # @api public
82
107
  def invoke(input, config: {})
83
108
  validate_termination!
109
+ return invoke_with_sharing(input, config, self.class._sharing) if self.class._sharing
110
+
84
111
  store = KnowledgeStore.new
85
112
  cycles, terminated_by = coordinate_members(input, store)
86
113
 
@@ -97,7 +124,11 @@ module Phronomy
97
124
  cycle_limit = max_cycles || Float::INFINITY
98
125
 
99
126
  (1..cycle_limit).each do |cycle|
100
- invoke_members(input, store, cycle)
127
+ if block_given?
128
+ yield cycle
129
+ else
130
+ invoke_members(input, store, cycle)
131
+ end
101
132
  completed_cycles = cycle
102
133
 
103
134
  if self.class._terminate_when&.call(store)
@@ -137,6 +168,120 @@ module Phronomy
137
168
  "max_cycles or timeout must be configured before invoking SharedState"
138
169
  end
139
170
 
171
+ def invoke_with_sharing(input, config, sharing)
172
+ unless self.class._aggregator
173
+ raise ArgumentError, "aggregate must be configured when sharing is configured"
174
+ end
175
+
176
+ open_sharing(sharing, input, config) do |binding|
177
+ cycles, terminated_by = coordinate_members(input, binding.store) do |cycle|
178
+ invoke_shared_members(input, binding, cycle, sharing)
179
+ end
180
+ {output: aggregate_findings(binding.store), cycles: cycles, terminated_by: terminated_by}
181
+ end
182
+ end
183
+
184
+ def open_sharing(sharing, input, config)
185
+ opening_thread = Thread.current
186
+ active = true
187
+ entered = completed = false
188
+ result = callback_error = nil
189
+
190
+ sharing.fetch(:provider).open(input: input, config: config) do |binding|
191
+ unless active && Thread.current.equal?(opening_thread)
192
+ raise ArgumentError, "sharing provider must yield synchronously within open"
193
+ end
194
+ raise ArgumentError, "sharing provider must yield exactly once" if entered
195
+
196
+ entered = true
197
+ unless binding.is_a?(Binding)
198
+ raise ArgumentError, "sharing provider must yield a SharedState::Binding"
199
+ end
200
+
201
+ result = yield binding
202
+ completed = true
203
+ rescue => error
204
+ callback_error = error
205
+ raise
206
+ end
207
+
208
+ raise callback_error if callback_error
209
+ unless entered && completed
210
+ raise ArgumentError, "sharing provider must yield exactly once and complete the block"
211
+ end
212
+
213
+ result
214
+ ensure
215
+ active = false
216
+ end
217
+
218
+ def invoke_shared_members(input, binding, cycle, sharing)
219
+ self.class._members.each do |member_config|
220
+ member = member_config.fetch(:klass)
221
+ definitions = shared_tool_definitions(member, binding, cycle)
222
+ instrumented = build_shared_researcher(member, definitions, sharing)
223
+ prompt = shared_prompt(input, member_config[:instruction])
224
+ invoke_shared_researcher(instrumented, prompt)
225
+ end
226
+ end
227
+
228
+ def invoke_shared_researcher(instrumented, prompt)
229
+ agent = instrumented.new
230
+ suspended = false
231
+ result = agent.invoke(prompt)
232
+ if result.is_a?(Hash) && result[:suspended]
233
+ suspended = true
234
+ raise MemberLifecycleError.new(agent: agent, result: result, reason: :suspended)
235
+ end
236
+ result
237
+ ensure
238
+ release_shared_member(agent, result, $!) if agent && !suspended
239
+ end
240
+
241
+ def release_shared_member(agent, result, primary_error)
242
+ agent.unload!
243
+ rescue => release_error
244
+ error = MemberLifecycleError.new(agent: agent, result: result, reason: :release_failed,
245
+ primary_error: primary_error, release_error: release_error)
246
+ raise error, cause: primary_error || release_error
247
+ end
248
+
249
+ def shared_tool_definitions(member, binding, cycle)
250
+ definitions = member.tools.to_h { |tool| [tool, member.tool_aliases[tool]] }
251
+ added = binding.tool_definitions(member: member, cycle: cycle)
252
+ unless (definitions.keys & added.keys).empty?
253
+ raise ArgumentError, "sharing Tool classes must not replace member Tools"
254
+ end
255
+
256
+ definitions = definitions.merge(added)
257
+ names = definitions.map { |tool, alias_name| (alias_name || tool.tool_name).to_s }
258
+ if names.any?(&:empty?) || names.uniq.length != names.length
259
+ raise ArgumentError, "sharing and member Tools must have distinct nonempty names or aliases"
260
+ end
261
+ definitions
262
+ end
263
+
264
+ def build_shared_researcher(member, definitions, sharing)
265
+ definition = shared_definition_for(member, sharing)
266
+ member.with_tools(definitions, id: definition.fetch(:id), version: definition.fetch(:version))
267
+ end
268
+
269
+ def shared_definition_for(member, sharing)
270
+ parent = member.agent_definition
271
+ identity = [parent.fetch(:id), parent.fetch(:version), sharing.fetch(:id), sharing.fetch(:version)]
272
+ digest = Digest::SHA256.hexdigest(Phronomy::CanonicalJSON.dump(identity))
273
+ {id: "Phronomy::MultiAgent::SharedState::Application/#{digest}", version: 1}
274
+ end
275
+
276
+ def shared_prompt(input, instruction)
277
+ guide = self.class._coordination ||
278
+ "Use the provided Tools to collaborate through the application's shared data."
279
+ parts = [guide]
280
+ parts << "\nYour specific focus for this session: #{instruction}" if instruction
281
+ parts << "\n\nTask: #{input}"
282
+ parts.join
283
+ end
284
+
140
285
  def invoke_researcher(
141
286
  researcher_class,
142
287
  store,