phronomy 0.23.0 → 0.24.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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +2 -2
  3. data/CHANGELOG.md +15 -0
  4. data/CONTRIBUTING.md +2 -2
  5. data/README.md +1 -1
  6. data/docs/architecture/multi-agent-handoff.md +35 -40
  7. data/docs/architecture/persistence.md +19 -8
  8. data/docs/architecture.md +8 -1
  9. data/docs/decisions/016-semantic-multi-agent-handoff.md +3 -1
  10. data/docs/decisions/028-preparing-recovery-replay-contract.md +106 -0
  11. data/docs/decisions/029-semantic-completion-and-application-effect-boundary.md +220 -0
  12. data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +235 -0
  13. data/docs/decisions/031-durable-multi-agent-coordination.md +301 -0
  14. data/docs/decisions/README.md +5 -1
  15. data/docs/design/durable-semantic-coordination/CHANGELOG_V2_REVISION_2.md +33 -0
  16. data/docs/design/durable-semantic-coordination/CONTINUATION_DECISION_REFACTOR.md +191 -0
  17. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +862 -0
  18. data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +106 -0
  19. data/docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md +179 -0
  20. data/docs/design/durable-semantic-coordination/RESPONSIBILITY_BOUNDARY_REVIEW.md +302 -0
  21. data/docs/features.md +35 -1
  22. data/docs/migrations/durable-semantic-coordination-v2.md +65 -0
  23. data/docs/persistence-backends.md +42 -3
  24. data/lib/phronomy/agent/agent_execution.rb +2 -2
  25. data/lib/phronomy/agent/agent_invocation.rb +1 -1
  26. data/lib/phronomy/agent/async_event_api.rb +18 -1
  27. data/lib/phronomy/agent/base.rb +28 -0
  28. data/lib/phronomy/agent/context_assembler.rb +1 -1
  29. data/lib/phronomy/agent/exact_execution.rb +153 -0
  30. data/lib/phronomy/agent/execution_cancellation.rb +25 -0
  31. data/lib/phronomy/agent/execution_coordinator.rb +484 -27
  32. data/lib/phronomy/{multi_agent → agent}/handoff.rb +4 -4
  33. data/lib/phronomy/{multi_agent → agent}/handoff_capability_factory.rb +3 -45
  34. data/lib/phronomy/{multi_agent → agent}/handoff_context.rb +26 -1
  35. data/lib/phronomy/{multi_agent/execution_coordinator.rb → agent/handoff_execution_coordinator.rb} +32 -5
  36. data/lib/phronomy/{multi_agent → agent}/handoff_policy.rb +7 -1
  37. data/lib/phronomy/{multi_agent → agent}/handoff_projection.rb +18 -2
  38. data/lib/phronomy/{multi_agent → agent}/handoff_request.rb +2 -2
  39. data/lib/phronomy/agent/handoff_runner.rb +178 -0
  40. data/lib/phronomy/agent/handoff_state.rb +43 -0
  41. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +114 -211
  42. data/lib/phronomy/agent/recovery_coordinator/installation.rb +84 -130
  43. data/lib/phronomy/agent/recovery_coordinator/resolution.rb +76 -200
  44. data/lib/phronomy/agent/recovery_coordinator.rb +11 -5
  45. data/lib/phronomy/agent/recovery_support.rb +15 -23
  46. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -8
  47. data/lib/phronomy/agent/tool_invocation.rb +4 -2
  48. data/lib/phronomy/engine/runtime/team_ownership_registry.rb +77 -0
  49. data/lib/phronomy/engine/runtime.rb +16 -1
  50. data/lib/phronomy/multi_agent/durable_subagent_coordinator.rb +134 -0
  51. data/lib/phronomy/multi_agent/orchestrator.rb +59 -11
  52. data/lib/phronomy/multi_agent/team_coordinator.rb +473 -125
  53. data/lib/phronomy/multi_agent/team_execution.rb +44 -0
  54. data/lib/phronomy/multi_agent/team_root.rb +41 -0
  55. data/lib/phronomy/persistence/durable_codec.rb +60 -0
  56. data/lib/phronomy/persistence/in_memory.rb +264 -2
  57. data/lib/phronomy/persistence/repository_facades.rb +221 -2
  58. data/lib/phronomy/persistence.rb +95 -1
  59. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -0
  60. data/lib/phronomy/testing/persistence_contract/coordination_repositories.rb +137 -0
  61. data/lib/phronomy/testing/persistence_contract.rb +5 -0
  62. data/lib/phronomy/tools/agent.rb +1 -1
  63. data/lib/phronomy/version.rb +1 -1
  64. data/scripts/api_snapshot.rb +3 -3
  65. data/sig/phronomy/handoff.rbs +41 -0
  66. data/sig/phronomy/multi_agent.rbs +28 -32
  67. data/sig/phronomy/persistence.rbs +64 -3
  68. metadata +30 -12
  69. data/lib/phronomy/multi_agent/coordination_state.rb +0 -18
  70. data/lib/phronomy/multi_agent/coordinator.rb +0 -154
  71. data/lib/phronomy/multi_agent/runner.rb +0 -98
@@ -4,6 +4,7 @@ require_relative "runtime/timer_queue"
4
4
  require_relative "runtime/shutdown_result"
5
5
  require_relative "runtime/timer_service"
6
6
  require_relative "runtime/agent_ownership_registry"
7
+ require_relative "runtime/team_ownership_registry"
7
8
 
8
9
  module Phronomy
9
10
  class Runtime
@@ -65,6 +66,7 @@ module Phronomy
65
66
  )
66
67
  @multi_agent_admissions = Phronomy::MultiAgent::AdmissionRegistry.new
67
68
  @agent_ownership_registry = AgentOwnershipRegistry.new(runtime: self)
69
+ @team_ownership_registry = TeamOwnershipRegistry.new
68
70
  @lifecycle_mutex = Mutex.new
69
71
  @shutdown_mutex = Mutex.new
70
72
  @state = :running
@@ -109,6 +111,16 @@ module Phronomy
109
111
  loop_instance&.agent_execution_owner(execution_id)
110
112
  end
111
113
 
114
+ # @api private
115
+ def __team_owner(id, klass:, create:, persistence:, &block)
116
+ @team_ownership_registry.fetch(id, klass: klass, create: create, persistence: persistence, &block)
117
+ end
118
+
119
+ # @api private
120
+ def __get_team(id, klass:)
121
+ @team_ownership_registry.get(id.to_s, klass: klass)
122
+ end
123
+
112
124
  # @api private
113
125
  def __create_agent(agent_id, expected_class:, &block)
114
126
  @agent_ownership_registry.create(agent_id, expected_class: expected_class, &block)
@@ -226,9 +238,11 @@ module Phronomy
226
238
  end
227
239
  loop_instance&.begin_draining
228
240
  @agent_ownership_registry.begin_draining
241
+ @team_ownership_registry.begin_draining
229
242
 
230
243
  admission_idle = @multi_agent_admissions.wait_until_idle(drain_deadline)
231
244
  agent_ownership_stable = @agent_ownership_registry.wait_until_stable(drain_deadline)
245
+ team_ownership_stable = @team_ownership_registry.wait_until_stable(drain_deadline)
232
246
  loop_idle = !loop_instance || loop_instance.wait_until_idle(drain_deadline)
233
247
 
234
248
  @lifecycle_mutex.synchronize do
@@ -243,7 +257,7 @@ module Phronomy
243
257
  end
244
258
 
245
259
  subsystem_error = shutdown_pools_and_timer
246
- cleanup_complete = admission_idle && agent_ownership_stable && loop_idle &&
260
+ cleanup_complete = admission_idle && agent_ownership_stable && team_ownership_stable && loop_idle &&
247
261
  (!loop_instance || !loop_instance.thread_alive?) &&
248
262
  event_loop_status != :cancel_timeout &&
249
263
  subsystem_error.nil?
@@ -256,6 +270,7 @@ module Phronomy
256
270
  end
257
271
 
258
272
  @agent_ownership_registry.shutdown! if cleanup_complete
273
+ @team_ownership_registry.shutdown! if cleanup_complete
259
274
 
260
275
  result = ShutdownResult.new(
261
276
  runtime_outcome: runtime_outcome,
@@ -0,0 +1,134 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Phronomy
6
+ module MultiAgent
7
+ # Agent-owned transactions reserve child work; Task callbacks only wake observers.
8
+ # @api private
9
+ class DurableSubagentCoordinator
10
+ KEY = "multi_agent_coordination_ref"
11
+
12
+ def self.prepare(parent, execution, tx:)
13
+ ref = execution.metadata[KEY]
14
+ snapshot = ref ? tx.contents.fetch_json(ref) : {"kind" => "static_subagent", "children" => []}
15
+ children = snapshot.fetch("children").map(&:dup)
16
+ children.each do |child|
17
+ begin
18
+ exact = tx.executions.load(child.fetch("execution_id"))
19
+ rescue Phronomy::Persistence::NotFoundError
20
+ next
21
+ end
22
+ raise Phronomy::Persistence::ConflictError, "Child owner mismatch" unless exact.agent_id == child.fetch("agent_id")
23
+ child.merge!("state" => exact.status.to_s, "result_ref" => exact.result_ref, "error_ref" => exact.error_ref)
24
+ end
25
+ Array(execution.metadata[Phronomy::Agent::RecoverySupport::TOOL_BATCH_METADATA_KEY]).each do |tool|
26
+ registration = parent.class.registered_subagents.find { |name, _| "dispatch_to_#{name}" == tool.fetch("tool_name") }
27
+ next unless registration
28
+ next if children.any? { |child| child.fetch("slot") == tool.fetch("tool_invocation_id") }
29
+ name, settings = registration
30
+ definition = settings.fetch(:agent_class).agent_definition
31
+ knowledge = settings.fetch(:inherit_knowledge) ? parent.send(:active_knowledge_snapshot) : []
32
+ children << {
33
+ "slot" => tool.fetch("tool_invocation_id"), "name" => name.to_s,
34
+ "definition" => definition.transform_keys(&:to_s),
35
+ "agent_id" => SecureRandom.uuid, "execution_id" => SecureRandom.uuid,
36
+ "input_ref" => tx.contents.put_text(tool.fetch("arguments").fetch("input")),
37
+ "durable_context_ref" => execution.metadata["durable_context_ref"],
38
+ "knowledge_ref" => tx.contents.put_json(Phronomy::Agent::RecoverySupport.canonical_copy(knowledge)),
39
+ "state" => "reserved", "result_ref" => nil, "error_ref" => nil,
40
+ "on_error" => settings.fetch(:on_error).to_s
41
+ }
42
+ end
43
+ return execution if children.empty?
44
+ value = tx.contents.put_json(snapshot.merge("children" => children))
45
+ execution.with(execution_revision: execution.execution_revision, metadata: execution.metadata.merge(KEY => value))
46
+ end
47
+
48
+ def self.start(parent:, tool_invocation_id:, parent_execution_id:, config:)
49
+ runtime = Phronomy::Runtime.instance
50
+ completion = Phronomy::Task.deferred(name: "durable-subagent:#{tool_invocation_id}")
51
+ preparation = runtime.offload.submit(on_full: :raise) do
52
+ current = parent.persistence.executions.load(parent_execution_id)
53
+ raise Phronomy::Persistence::ConflictError, "Parent owner mismatch" unless current.agent_id == parent.agent_id
54
+ snapshot = parent.persistence.contents.fetch_json(current.metadata.fetch(KEY))
55
+ child = snapshot.fetch("children").find { |entry| entry.fetch("slot") == tool_invocation_id }
56
+ raise Phronomy::ExecutionRehydrationRequiredError, "Missing reserved child slot" unless child
57
+ definition = parent.class.registered_subagents.find { |name, _| name.to_s == child.fetch("name") }&.last
58
+ unless definition && definition.fetch(:agent_class).agent_definition.transform_keys(&:to_s) == child.fetch("definition")
59
+ raise Phronomy::ConfigurationError, "Registered child definition changed: #{child.fetch("name")}"
60
+ end
61
+ klass = definition.fetch(:agent_class)
62
+ id = child.fetch("agent_id")
63
+ agent = klass.get(id)
64
+ unless agent
65
+ begin
66
+ parent.persistence.agents.load(id)
67
+ exists = true
68
+ rescue Phronomy::Persistence::NotFoundError
69
+ exists = false
70
+ end
71
+ listener = parent.send(:_phronomy_event_listener)
72
+ agent = if exists
73
+ klass.load(id, persistence: parent.persistence, on_event: listener)
74
+ else
75
+ knowledge = parent.persistence.contents.fetch_json(child.fetch("knowledge_ref"))
76
+ context = Phronomy::Agent::ContextImporter::ImportedContext.new(records: knowledge.map do |item|
77
+ Phronomy::Agent::ContextImporter::ImportedRecord.new(kind: :knowledge,
78
+ channel: :context, role: :user, content: item.fetch("content"),
79
+ content_format: :text, metadata: item.fetch("metadata"))
80
+ end)
81
+ klass.create(agent_id: id, persistence: parent.persistence,
82
+ context: context, on_event: listener)
83
+ end
84
+ end
85
+ unless agent.persistence.equal?(parent.persistence)
86
+ raise Phronomy::ConfigurationError, "Child Persistence instance mismatch"
87
+ end
88
+ durable_context = child["durable_context_ref"] && parent.persistence.contents.fetch_json(child["durable_context_ref"])
89
+ [child, agent, parent.persistence.contents.fetch_text(child.fetch("input_ref")), durable_context]
90
+ end
91
+ preparation.on_complete do |prepared, failure|
92
+ if failure
93
+ completion.fail(Phronomy::ExecutionRehydrationRequiredError.new("Child reservation requires recovery: #{failure.message}"))
94
+ next
95
+ end
96
+ child, agent, input, durable_context = prepared
97
+ child_config = {cancellation_token: config[:cancellation_token] || Phronomy::Concurrency::CancellationToken.new,
98
+ invocation_context: config[:invocation_context],
99
+ phronomy_coordination: {"kind" => "subagent", "parent_execution_id" => parent_execution_id,
100
+ "parent_agent_id" => parent.agent_id, "slot" => tool_invocation_id}}.compact
101
+ child_config = child_config.merge(durable_context: durable_context) if child["durable_context_ref"]
102
+ source = Phronomy::Agent::ExactExecution.start(agent: agent,
103
+ execution_id: child.fetch("execution_id"), input: input, config: child_config)
104
+ source.on_complete do |result, error|
105
+ if error
106
+ if error.is_a?(Phronomy::CancellationError)
107
+ completion.fail(error)
108
+ else
109
+ completion.fail(Phronomy::ExecutionRehydrationRequiredError.new(
110
+ "Child #{child.fetch("execution_id")} is unfinished: #{error.message}"
111
+ ))
112
+ end
113
+ elsif result[:error]
114
+ if child.fetch("on_error") == "skip"
115
+ completion.complete(nil)
116
+ else
117
+ completion.fail(Phronomy::Agent::RecoverySupport.error_from_failure(result[:error]))
118
+ end
119
+ else
120
+ completion.complete(result[:output])
121
+ end
122
+ end
123
+ rescue => error
124
+ completion.fail(Phronomy::ExecutionRehydrationRequiredError.new("Child coordination requires recovery: #{error.message}"))
125
+ end
126
+ completion
127
+ rescue => error
128
+ completion ||= Phronomy::Task.deferred(name: "durable-subagent")
129
+ completion.fail(error)
130
+ completion
131
+ end
132
+ end
133
+ end
134
+ end
@@ -6,11 +6,33 @@ module Phronomy
6
6
  class Orchestrator < Agent::Base
7
7
  agent_definition id: "orchestrator", version: 1
8
8
 
9
+ # Own one live cancellation token shared with this invocation's static
10
+ # children, including when Recovery reconstructs the invocation.
11
+ # @api private
12
+ def __invocation_config(config)
13
+ config.merge(cancellation_token: config[:cancellation_token] || Phronomy::Concurrency::CancellationToken.new)
14
+ end
15
+
16
+ # Resumes retained static child coordination with current class wiring.
17
+ # @api public
18
+ def resume(execution_id, config: {})
19
+ if Phronomy::Runtime.in_event_loop_context?
20
+ raise Phronomy::EventLoopReentrancyError, "Orchestrator#resume cannot block EventLoop"
21
+ end
22
+ execution = persistence.executions.load(execution_id)
23
+ raise Phronomy::Persistence::ConflictError, "Parent execution owner mismatch" unless execution.agent_id == agent_id
24
+ input = persistence.contents.fetch_text(execution.metadata.fetch("current_input_ref"))
25
+ result = Phronomy::Agent::ExactExecution.start(agent: self, execution_id: execution_id, input: input, config: config).wait_result
26
+ raise Phronomy::Agent::RecoverySupport.error_from_failure(result[:error]) if result[:error]
27
+ result
28
+ end
29
+
9
30
  def self.subagent(name, agent_class, on_error: :raise, inherit_knowledge: true)
10
31
  # A subagent Tool is logically asynchronous: ToolInvocation starts the
11
32
  # child Agent and resumes when its completion Task settles. It must not
12
33
  # occupy an OffloadPool worker while waiting for the child.
13
34
  tool_class = Class.new(Phronomy::Tools::Agent) do
35
+ def self.__framework_owned_operation? = true
14
36
  tool_name "dispatch_to_#{name}"
15
37
  description "Dispatch work to the #{name} subagent (#{agent_class.name})"
16
38
 
@@ -24,6 +46,20 @@ module Phronomy
24
46
  ).wait_result
25
47
  end
26
48
 
49
+ # Enter exact child reconciliation even when the parent token is
50
+ # cancelled: an already admitted child must be settled, not forgotten.
51
+ define_method(:call_async) do |args, cancellation_token: nil, config: {}|
52
+ if @_orchestrator_context&.fetch(:parent, nil) && config[:phronomy_tool_invocation_id]
53
+ validated, schema_error = send(:validate_and_coerce, args)
54
+ raise Phronomy::ToolError, schema_error if schema_error
55
+ execute_async(**validated, cancellation_token: cancellation_token, config: config)
56
+ else
57
+ super(args, cancellation_token: cancellation_token, config: config)
58
+ end
59
+ rescue => error
60
+ Phronomy::Task.deferred(name: "subagent-dispatch-failed").tap { |task| task.fail(error) }
61
+ end
62
+
27
63
  define_method(:execute_async) do |input:, cancellation_token: nil, config: {}|
28
64
  ctx = @_orchestrator_context || {}
29
65
  parent_ic = ctx[:invocation_context]
@@ -38,20 +74,19 @@ module Phronomy
38
74
  task_config = task_config.merge(invocation_context: child_ic)
39
75
  end
40
76
 
77
+ parent = ctx[:parent]
78
+ if parent && task_config[:phronomy_tool_invocation_id]
79
+ return DurableSubagentCoordinator.start(parent: parent,
80
+ tool_invocation_id: task_config.fetch(:phronomy_tool_invocation_id),
81
+ parent_execution_id: task_config.fetch(:execution_id), config: task_config)
82
+ end
41
83
  agent = agent_class.new
42
84
  if inherit_knowledge
43
85
  Array(ctx[:knowledge]).each do |entry|
44
- agent.add_knowledge(
45
- entry.fetch(:content),
46
- metadata: entry.fetch(:metadata, {})
47
- )
86
+ agent.add_knowledge(entry.fetch(:content), metadata: entry.fetch(:metadata, {}))
48
87
  end
49
88
  end
50
-
51
- source = agent.invoke_async(
52
- input,
53
- config: task_config
54
- )
89
+ source = agent.invoke_async(input, config: task_config)
55
90
  result_task = Phronomy::Task.deferred(
56
91
  name: "subagent-tool-#{name}"
57
92
  )
@@ -213,6 +248,17 @@ module Phronomy
213
248
  ).wait_result
214
249
  end
215
250
 
251
+ # @api private
252
+ def __framework_tool_replayable?(name)
253
+ self.class.registered_subagents.keys.any? { |key| "dispatch_to_#{key}" == name.to_s }
254
+ end
255
+
256
+ # Reserve child identity/input/knowledge in the parent's existing Agent transaction.
257
+ # @api private
258
+ def __prepare_coordination_record(execution, tx:)
259
+ DurableSubagentCoordinator.prepare(self, execution, tx: tx)
260
+ end
261
+
216
262
  private
217
263
 
218
264
  def prepare_tool_class(tool_class, invocation: nil)
@@ -225,8 +271,10 @@ module Phronomy
225
271
  end&.last
226
272
  inherits_knowledge = registration ? registration.fetch(:inherit_knowledge, true) : true
227
273
 
228
- captured_context = {}
229
- captured_context[:knowledge] = active_knowledge_snapshot if inherits_knowledge
274
+ captured_context = {parent: self}
275
+ # Invocation-owned Tools inherit the durable child slot's knowledge,
276
+ # captured by the existing preparation transaction off EventLoop.
277
+ captured_context[:knowledge] = active_knowledge_snapshot if inherits_knowledge && !invocation
230
278
  if invocation
231
279
  captured_context[:config] = invocation.config
232
280
  captured_context[:invocation_context] = invocation.config[:invocation_context]