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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +141 -0
- data/README.md +13 -1
- data/RELEASE_CHECKLIST.md +4 -0
- data/docs/architecture/structured-output-and-scope.md +5 -4
- data/docs/architecture/trust-acceptance-followup.md +15 -0
- data/docs/context-policy-budgeting.md +108 -0
- data/docs/examples/shared_state_custom_sharing.rb +145 -0
- data/docs/features.md +4 -0
- data/docs/mcp-client.md +4 -2
- data/docs/migrations/shared-state-multi-agent.md +5 -0
- data/docs/runtime-and-concurrency.md +195 -0
- data/docs/shared-state-sharing.md +223 -0
- data/lib/phronomy/agent/approval_wait.rb +8 -0
- data/lib/phronomy/agent/async_event_api.rb +60 -0
- data/lib/phronomy/agent/base.rb +54 -1
- data/lib/phronomy/agent/execution/exact_execution.rb +4 -0
- data/lib/phronomy/agent/execution/execution_coordinator.rb +100 -2
- data/lib/phronomy/agent/journal/journal_projection.rb +24 -1
- data/lib/phronomy/agent/lifecycle/hosted_invocation.rb +67 -0
- data/lib/phronomy/agent/lifecycle/lifetime_api.rb +30 -0
- data/lib/phronomy/agent/lifecycle/operation_lease.rb +7 -0
- data/lib/phronomy/agent/lifecycle/operation_result.rb +12 -0
- data/lib/phronomy/agent/lifecycle/ownership_registry.rb +86 -14
- data/lib/phronomy/agent/recovery/recovery_coordinator/installation.rb +1 -1
- data/lib/phronomy/agent/store.rb +52 -0
- data/lib/phronomy/agent/tool_execution/tool_approval_request.rb +3 -2
- data/lib/phronomy/agent/tool_execution/tool_invocation.rb +12 -36
- data/lib/phronomy/context/context_policy.rb +11 -2
- data/lib/phronomy/context/context_policy_input.rb +26 -0
- data/lib/phronomy/context/context_policy_input_builder.rb +27 -19
- data/lib/phronomy/engine/fsm_session.rb +9 -3
- data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +87 -5
- data/lib/phronomy/multi_agent/orchestrator.rb +30 -18
- data/lib/phronomy/multi_agent/shared_state/binding.rb +44 -0
- data/lib/phronomy/multi_agent/shared_state/member_lifecycle_error.rb +32 -0
- data/lib/phronomy/multi_agent/shared_state.rb +147 -2
- data/lib/phronomy/tool/base.rb +69 -3
- data/lib/phronomy/tool/schema.rb +23 -11
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow/execution/workflow.rb +9 -0
- data/lib/phronomy/workflow/execution/workflow_runner.rb +62 -14
- data/scripts/api_snapshot.rb +8 -3
- data/sig/phronomy/agent/execution_contract.rbs +13 -0
- data/sig/phronomy/agent/store.rbs +1 -0
- data/sig/phronomy/agent.rbs +2 -0
- data/sig/phronomy/context.rbs +3 -0
- data/sig/phronomy/multi_agent.rbs +28 -2
- data/sig/phronomy/tool.rbs +1 -0
- data/sig/phronomy/workflow.rbs +1 -0
- 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
|
-
|
|
151
|
-
|
|
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
|
-
|
|
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[
|
|
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
|
-
|
|
178
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
96
|
-
|
|
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
|
-
|
|
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
|
-
|
|
260
|
+
context[:knowledge] = active_knowledge_snapshot if inherits_knowledge && !invocation
|
|
241
261
|
if invocation
|
|
242
|
-
|
|
243
|
-
|
|
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
|
-
#
|
|
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
|
-
|
|
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,
|