phronomy 0.22.0 → 0.23.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 (156) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +3 -4
  3. data/CHANGELOG.md +200 -10
  4. data/CONTRIBUTING.md +81 -9
  5. data/README.md +15 -6
  6. data/VERIFY.sh +587 -0
  7. data/benchmark/bench_agent_invoke.rb +2 -2
  8. data/benchmark/bench_context_assembler.rb +39 -68
  9. data/benchmark/bench_regression.rb +2 -2
  10. data/docs/architecture/agent-context.md +174 -0
  11. data/docs/architecture/before-llm-input.md +78 -0
  12. data/docs/architecture/context-management.md +232 -0
  13. data/docs/architecture/knowledge-and-rag.md +130 -0
  14. data/docs/architecture/multi-agent-handoff.md +152 -0
  15. data/docs/architecture/persistence.md +175 -0
  16. data/docs/architecture/removed/agent-context.md +72 -0
  17. data/docs/architecture/security-boundaries.md +173 -0
  18. data/docs/architecture/tracing.md +194 -0
  19. data/docs/architecture.md +82 -0
  20. data/docs/archive/design/archived/04_api_design.md +507 -0
  21. data/docs/archive/design/archived/09_guardrails.md +186 -0
  22. data/docs/archive/design/archived/17_rails_integration.md +175 -0
  23. data/docs/archive/design/historical/00_design_philosophy.md +122 -0
  24. data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
  25. data/docs/archive/design/historical/06_design_decisions.md +143 -0
  26. data/docs/changelog/0.14-and-earlier.md +1 -1
  27. data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
  28. data/docs/decisions/002-workflow-context-immutability.md +26 -1
  29. data/docs/decisions/006-no-built-in-guardrails.md +2 -1
  30. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
  31. data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
  32. data/docs/decisions/016-semantic-multi-agent-handoff.md +112 -0
  33. data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
  34. data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
  35. data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
  36. data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
  37. data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
  38. data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
  39. data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
  40. data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
  41. data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
  42. data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
  43. data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
  44. data/docs/decisions/README.md +172 -0
  45. data/docs/features.md +31 -11
  46. data/docs/getting-started.md +77 -45
  47. data/docs/migrations/0.19.md +14 -7
  48. data/docs/migrations/0.22.md +390 -0
  49. data/docs/persistence-backends.md +88 -38
  50. data/docs/runtime-and-concurrency.md +227 -33
  51. data/examples/README.md +13 -0
  52. data/lib/phronomy/agent/agent_execution.rb +19 -15
  53. data/lib/phronomy/agent/agent_invocation.rb +288 -93
  54. data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
  55. data/lib/phronomy/agent/agent_root.rb +3 -3
  56. data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
  57. data/lib/phronomy/agent/async_event_api.rb +145 -72
  58. data/lib/phronomy/agent/base.rb +388 -181
  59. data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
  60. data/lib/phronomy/agent/context_assembler.rb +437 -178
  61. data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
  62. data/lib/phronomy/agent/context_plan.rb +18 -13
  63. data/lib/phronomy/agent/context_plan_validator.rb +246 -88
  64. data/lib/phronomy/agent/context_policies/default.rb +123 -34
  65. data/lib/phronomy/agent/context_policy.rb +109 -3
  66. data/lib/phronomy/agent/context_policy_input.rb +244 -0
  67. data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
  68. data/lib/phronomy/agent/execution_coordinator.rb +1975 -587
  69. data/lib/phronomy/agent/journal_record.rb +17 -4
  70. data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
  71. data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
  72. data/lib/phronomy/agent/llm_operation_result.rb +12 -7
  73. data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
  74. data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
  75. data/lib/phronomy/agent/recovery_coordinator/continuation.rb +271 -0
  76. data/lib/phronomy/agent/recovery_coordinator/installation.rb +427 -0
  77. data/lib/phronomy/agent/recovery_coordinator/resolution.rb +635 -0
  78. data/lib/phronomy/agent/recovery_coordinator.rb +211 -0
  79. data/lib/phronomy/agent/recovery_support.rb +512 -0
  80. data/lib/phronomy/agent/ruby_llm_materializer.rb +22 -13
  81. data/lib/phronomy/agent/selection/candidate.rb +53 -0
  82. data/lib/phronomy/agent/selection/constraint.rb +49 -0
  83. data/lib/phronomy/agent/shared_state.rb +38 -1
  84. data/lib/phronomy/agent/tool_approval_request.rb +33 -5
  85. data/lib/phronomy/agent/tool_definition_set.rb +49 -3
  86. data/lib/phronomy/agent/tool_invocation.rb +336 -102
  87. data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
  88. data/lib/phronomy/agent.rb +20 -2
  89. data/lib/phronomy/agent_already_exists_error.rb +5 -0
  90. data/lib/phronomy/agent_purged_error.rb +5 -0
  91. data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
  92. data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
  93. data/lib/phronomy/engine/event_loop.rb +622 -63
  94. data/lib/phronomy/engine/fsm_session.rb +194 -21
  95. data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
  96. data/lib/phronomy/engine/runtime.rb +77 -20
  97. data/lib/phronomy/generator_verifier.rb +12 -14
  98. data/lib/phronomy/invocation_context.rb +9 -29
  99. data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
  100. data/lib/phronomy/multi_agent/coordination_state.rb +18 -0
  101. data/lib/phronomy/multi_agent/coordinator.rb +154 -0
  102. data/lib/phronomy/multi_agent/execution_coordinator.rb +116 -0
  103. data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
  104. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
  105. data/lib/phronomy/multi_agent/handoff.rb +24 -45
  106. data/lib/phronomy/multi_agent/handoff_capability_factory.rb +87 -0
  107. data/lib/phronomy/multi_agent/handoff_context.rb +95 -0
  108. data/lib/phronomy/multi_agent/handoff_policy.rb +137 -0
  109. data/lib/phronomy/multi_agent/handoff_projection.rb +191 -0
  110. data/lib/phronomy/multi_agent/handoff_request.rb +45 -0
  111. data/lib/phronomy/multi_agent/orchestrator.rb +12 -15
  112. data/lib/phronomy/multi_agent/runner.rb +98 -0
  113. data/lib/phronomy/persistence/durable_codec.rb +646 -0
  114. data/lib/phronomy/persistence/durable_record.rb +117 -0
  115. data/lib/phronomy/persistence/in_memory.rb +210 -134
  116. data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
  117. data/lib/phronomy/persistence/repository_facades.rb +316 -0
  118. data/lib/phronomy/persistence.rb +81 -41
  119. data/lib/phronomy/recovery.rb +186 -0
  120. data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
  121. data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -1
  122. data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
  123. data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
  124. data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
  125. data/lib/phronomy/tracing/automatic.rb +176 -0
  126. data/lib/phronomy/tracing/base.rb +11 -2
  127. data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
  128. data/lib/phronomy/version.rb +1 -1
  129. data/lib/phronomy/workflow.rb +3 -6
  130. data/lib/phronomy/workflow_context.rb +14 -5
  131. data/lib/phronomy/workflow_recovery.rb +123 -0
  132. data/lib/phronomy/workflow_runner.rb +468 -256
  133. data/lib/phronomy.rb +6 -0
  134. data/scripts/api_snapshot.rb +12 -0
  135. data/sig/phronomy/agent.rbs +209 -7
  136. data/sig/phronomy/multi_agent.rbs +39 -0
  137. data/sig/phronomy/persistence.rbs +62 -4
  138. data/sig/phronomy/runtime.rbs +1 -4
  139. data/sig/phronomy/workflow.rbs +2 -2
  140. data/sig/phronomy.rbs +10 -0
  141. metadata +65 -17
  142. data/examples/workflows/agent_event_mapping.rb +0 -101
  143. data/examples/workflows/generic_task_event_mapping.rb +0 -66
  144. data/lib/phronomy/agent/activation_registry.rb +0 -28
  145. data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
  146. data/lib/phronomy/agent/context_candidate.rb +0 -47
  147. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
  148. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
  149. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
  150. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
  151. data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
  152. data/lib/phronomy/agent/context_policy_registry.rb +0 -46
  153. data/lib/phronomy/agent/context_request.rb +0 -35
  154. data/lib/phronomy/agent/context_selection_unit.rb +0 -38
  155. data/lib/phronomy/agent/derived_content_spec.rb +0 -34
  156. data/lib/phronomy/agent/runner.rb +0 -97
@@ -0,0 +1,116 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module MultiAgent
5
+ # Handoff-aware specialization of the normal Agent execution coordinator.
6
+ # It changes only the durable terminal semantics of an invocation that
7
+ # produced a typed HandoffRequest. EventLoop ownership/apply remains entirely
8
+ # in Agent::ExecutionCoordinator.
9
+ class ExecutionCoordinator < Phronomy::Agent::ExecutionCoordinator
10
+ private
11
+
12
+ def compute_terminal(operation)
13
+ view = operation.terminal_view
14
+ if view.callback_failure
15
+ return commit_failed_outcome(
16
+ operation,
17
+ view.callback_failure.to_stream_callback_error
18
+ )
19
+ end
20
+ return commit_failed_outcome(operation, view.source_error) if view.source_error
21
+ return commit_suspended(operation) if view.phase == :suspended
22
+
23
+ raise view.block_error if view.input_blocked || view.output_blocked
24
+ raise view.invocation_error if view.invocation_error
25
+
26
+ return commit_handed_off(operation) if view.handoff
27
+
28
+ commit_completed(operation)
29
+ rescue => caught
30
+ commit_failed_outcome(operation, caught)
31
+ end
32
+
33
+ def commit_handed_off(operation)
34
+ current = operation.execution
35
+ root = operation.root
36
+ runtime_snapshot = operation.runtime_snapshot
37
+ request = operation.terminal_view.handoff
38
+ handed_off = next_root = appended = nil
39
+
40
+ @agent.persistence.transaction do |tx|
41
+ encoded_records, call_records = encode_runtime_records(
42
+ current,
43
+ tx: tx,
44
+ snapshot: runtime_snapshot,
45
+ context_candidate: false,
46
+ agent_root: root
47
+ )
48
+ audit_ref = tx.contents.put_json(
49
+ "target_agent_id" => request.target_agent_id,
50
+ "responsibility" => request.responsibility,
51
+ "selection_intent" => request.selection_intent.to_h do |category, included|
52
+ [category.to_s, included]
53
+ end
54
+ )
55
+ audit_record = Phronomy::Agent::JournalRecord.new(
56
+ agent_id: @agent.agent_id,
57
+ execution_id: current.execution_id,
58
+ llm_call_id: request.llm_call_id,
59
+ kind: :execution_handed_off,
60
+ channel: :audit,
61
+ content_ref: audit_ref,
62
+ context_generation: root.transcript_generation,
63
+ context_candidate: false,
64
+ metadata: {
65
+ "target_agent_id" => request.target_agent_id,
66
+ "handoff_tool_call_id" => request.tool_call_id
67
+ }.compact
68
+ )
69
+
70
+ all_records = current.working_records + encoded_records + [audit_record]
71
+ appended = tx.journals.append(
72
+ root.agent_id,
73
+ expected_position: root.journal_position,
74
+ records: all_records
75
+ )
76
+ handed_off = current.with(
77
+ status: :handed_off,
78
+ phase: :handed_off,
79
+ working_records: [],
80
+ llm_calls: current.llm_calls + call_records,
81
+ approval_request: nil,
82
+ terminal_reason: "handed_off"
83
+ )
84
+ tx.executions.save(
85
+ current.execution_id,
86
+ expected_revision: current.execution_revision,
87
+ execution: handed_off
88
+ )
89
+ context_changed = appended.any?(&:context_candidate)
90
+ next_root = root.with(
91
+ agent_revision: root.agent_revision + 1,
92
+ context_revision: root.context_revision + (context_changed ? 1 : 0),
93
+ journal_position: root.journal_position + appended.length,
94
+ lifecycle_status: :idle
95
+ )
96
+ tx.agents.save(
97
+ root.agent_id,
98
+ expected_revision: root.agent_revision,
99
+ root: next_root
100
+ )
101
+ end
102
+
103
+ result = result_base(handed_off, next_root)
104
+ TerminalOutcome.new(
105
+ type: :handed_off,
106
+ execution: handed_off,
107
+ root: next_root,
108
+ appended_records: Array(appended).freeze,
109
+ result: result.freeze,
110
+ error: nil,
111
+ approval_request: nil
112
+ )
113
+ end
114
+ end
115
+ end
116
+ end
@@ -1,17 +1,14 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "securerandom"
4
-
5
3
  module Phronomy
6
4
  module MultiAgent
7
5
  # Mutable FSM context for one fan-out operation.
8
6
  class FanOutInvocation
9
- Child = Data.define(:index, :agent, :input, :config, :thread_id)
7
+ Child = Data.define(:index, :agent, :input, :config)
10
8
 
11
- attr_reader :id, :phase, :results
9
+ attr_reader :phase, :results
12
10
 
13
11
  def initialize(children:, max_concurrency:, on_error:)
14
- @id = SecureRandom.uuid.to_s
15
12
  @phase = nil
16
13
  @children = children
17
14
  @max_concurrency = max_concurrency || children.length
@@ -19,15 +16,13 @@ module Phronomy
19
16
  @pending = children.dup
20
17
  @active = {}
21
18
  @results = Array.new(children.length)
22
- @child_errors = Array.new(children.length) # indexed by input order
23
- @fatal_error = nil # driver_failed / timeout / cancel
19
+ @child_errors = Array.new(children.length)
20
+ @fatal_error = nil
24
21
  @cancelled = false
25
22
  @timed_out = false
26
- @session_id = nil
27
23
  end
28
24
 
29
- def set_graph_metadata(thread_id: nil, phase: nil)
30
- @session_id = thread_id if thread_id
25
+ def set_graph_metadata(phase: nil)
31
26
  @phase = phase
32
27
  end
33
28
 
@@ -41,9 +36,6 @@ module Phronomy
41
36
  if child_error
42
37
  if @on_error == :raise
43
38
  @child_errors[index] = child_error
44
- # Signal cancellation so other children can stop early, but continue
45
- # waiting for all active children to respond so we can return the
46
- # first error in INPUT ORDER (not arrival order).
47
39
  cancel_active_children! unless @child_errors.any?(&:itself)
48
40
  end
49
41
  else
@@ -69,27 +61,28 @@ module Phronomy
69
61
  end
70
62
  end
71
63
 
72
- def start_available!(runtime)
64
+ def start_available!(runtime, event_sink:)
73
65
  return self if @fatal_error
74
66
 
75
67
  while @active.length < @max_concurrency && (child = @pending.shift)
76
68
  child_config = build_child_config(child.config)
77
69
  handle = child.agent.invoke_async(
78
70
  child.input,
79
- config: child_config,
80
- thread_id: child.thread_id
71
+ config: child_config
81
72
  )
82
- @active[child.index] = {handle: handle, token: child_config[:cancellation_token]}
83
- # [child.index].each creates a block parameter with a unique binding
84
- # per iteration, avoiding the while-loop variable capture problem.
73
+ @active[child.index] = {
74
+ handle: handle,
75
+ token: child_config[:cancellation_token]
76
+ }
85
77
  [child.index].each do |captured_index|
86
78
  handle.on_complete do |result, error|
87
- runtime.event_loop.post_to_session(
88
- Phronomy::Event.new(
89
- type: :child_completed,
90
- target_id: @id,
91
- payload: {index: captured_index, result: result, error: error}
92
- )
79
+ event_sink.post(
80
+ :child_completed,
81
+ {
82
+ index: captured_index,
83
+ result: result,
84
+ error: error
85
+ }
93
86
  )
94
87
  end
95
88
  end
@@ -98,13 +91,7 @@ module Phronomy
98
91
  rescue => caught
99
92
  @fatal_error ||= caught
100
93
  cancel_active_children!
101
- runtime.event_loop.post_to_session(
102
- Phronomy::Event.new(
103
- type: :driver_failed,
104
- target_id: @id,
105
- payload: {error: caught}
106
- )
107
- )
94
+ event_sink.post(:driver_failed, {error: caught})
108
95
  self
109
96
  end
110
97
 
@@ -114,7 +101,11 @@ module Phronomy
114
101
 
115
102
  def failed?
116
103
  return true if @fatal_error
117
- @on_error == :raise && @pending.empty? && @active.empty? && @child_errors.any?(&:itself)
104
+
105
+ @on_error == :raise &&
106
+ @pending.empty? &&
107
+ @active.empty? &&
108
+ @child_errors.any?(&:itself)
118
109
  end
119
110
 
120
111
  def error
@@ -16,14 +16,15 @@ module Phronomy
16
16
  cancellation_token: nil,
17
17
  runtime: Phronomy::Runtime.instance
18
18
  )
19
- result = Phronomy::Task.deferred(name: "fan-out:#{invocation.id}")
19
+ result = Phronomy::Task.deferred(name: "fan-out")
20
20
 
21
21
  if cancellation_token&.cancelled?
22
22
  result.fail(Phronomy::CancellationError.new("fan-out cancelled"))
23
23
  return result
24
24
  end
25
25
 
26
- source = Phronomy::Task.deferred(name: "fan-out-source:#{invocation.id}")
26
+ session = build(invocation: invocation, runtime: runtime)
27
+ source = Phronomy::Task.deferred(name: "fan-out-source:#{session.id}")
27
28
  source.on_complete do |context, error|
28
29
  if error
29
30
  result.fail(error)
@@ -34,27 +35,18 @@ module Phronomy
34
35
  end
35
36
  end
36
37
 
37
- session = build(invocation: invocation, runtime: runtime)
38
38
  runtime.event_loop.register(session, completion: source)
39
-
40
39
  if timeout
41
40
  runtime.timer_queue.schedule(seconds: timeout) do
42
- runtime.event_loop.post_to_session(
43
- Phronomy::Event.new(
44
- type: :timeout,
45
- target_id: invocation.id,
46
- payload: {message: "dispatch_parallel timed out after #{timeout}s"}
47
- )
41
+ session.event_sink.post(
42
+ :timeout,
43
+ {message: "dispatch_parallel timed out after #{timeout}s"}
48
44
  )
49
45
  end
50
46
  end
51
-
52
47
  cancellation_token&.on_cancel do
53
- runtime.event_loop.post_to_session(
54
- Phronomy::Event.new(type: :cancel, target_id: invocation.id, payload: nil)
55
- )
48
+ session.event_sink.post(:cancel, nil)
56
49
  end
57
-
58
50
  result
59
51
  rescue => error
60
52
  result ||= Phronomy::Task.deferred(name: "fan-out:registration")
@@ -63,7 +55,8 @@ module Phronomy
63
55
  end
64
56
 
65
57
  def self.build(invocation:, runtime:)
66
- phase_machine = build_phase_machine(runtime)
58
+ event_sink = Phronomy::FSMSession::EventSink.new(event_loop: runtime.event_loop)
59
+ phase_machine = build_phase_machine(runtime, event_sink)
67
60
  external_events = {
68
61
  child_completed: [{from: :running, to: :running, guard: nil}],
69
62
  driver_failed: [{from: :running, to: :failed, guard: nil}],
@@ -72,8 +65,8 @@ module Phronomy
72
65
  }
73
66
 
74
67
  Phronomy::FSMSession.new(
75
- id: invocation.id,
76
68
  context: invocation,
69
+ event_sink: event_sink,
77
70
  entry_point: :idle,
78
71
  entry_actions: {},
79
72
  auto_state_set: AUTO_STATE_SET,
@@ -87,7 +80,7 @@ module Phronomy
87
80
  end
88
81
  private_class_method :build
89
82
 
90
- def self.build_phase_machine(runtime)
83
+ def self.build_phase_machine(runtime, event_sink)
91
84
  Class.new do
92
85
  attr_accessor :context, :current_event
93
86
 
@@ -114,7 +107,7 @@ module Phronomy
114
107
  event(:cancel) { transition running: :cancelled }
115
108
 
116
109
  after_transition to: :running do |machine|
117
- machine.context.start_available!(runtime)
110
+ machine.context.start_available!(runtime, event_sink: event_sink)
118
111
  end
119
112
  end
120
113
  end
@@ -4,60 +4,39 @@ require "securerandom"
4
4
 
5
5
  module Phronomy
6
6
  module MultiAgent
7
- # Represents a transfer edge from one agent to another.
8
- # Creates an anonymous Phronomy::Agent::Context::Capability::Base subclass that the source agent
9
- # exposes to the LLM as a +transfer_to_<name>+ function.
10
- # The tool's execute method returns a sentinel string that Runner uses to
11
- # detect which target agent to route to next.
12
- #
13
- # @example
14
- # billing = BillingAgent.new
15
- # handoff = Phronomy::MultiAgent::Handoff.new(target_agent: billing)
16
- # tool_class = handoff.to_tool_class
7
+ # Application-defined semantic edge for transferring active responsibility
8
+ # from one live Agent instance to another.
17
9
  class Handoff
18
- # Prefix embedded in tool results so Runner can detect handoffs.
19
- SENTINEL_PREFIX = "__PHRONOMY_HANDOFF__"
10
+ attr_reader :source_agent, :target_agent, :policy, :description
20
11
 
21
- attr_reader :target_agent, :tool_name, :description
22
-
23
- # @param target_agent [Phronomy::Agent::Base] the agent to hand off to
24
- # @param description [String, nil] overrides the auto-generated tool description
25
- # @api public
26
- def initialize(target_agent:, description: nil)
27
- @target_agent = target_agent
28
- klass_name = target_agent.class.name&.split("::")&.last || "Agent"
29
- # Use a UUID so that two handoffs targeting the same class remain distinct.
30
- @uuid = SecureRandom.uuid
31
- @tool_name = "transfer_to_#{snake_case(klass_name)}_#{@uuid.delete("-")[0, 8]}"
32
- @description = description || "Transfer the conversation to #{klass_name}."
33
- end
34
-
35
- # Builds an anonymous Phronomy::Agent::Context::Capability::Base subclass for this handoff.
36
- # @return [Class<Phronomy::Agent::Context::Capability::Base>]
37
12
  # @api public
38
- def to_tool_class
39
- sentinel_value = sentinel
40
- tn = tool_name
41
- desc = description
42
- Class.new(Phronomy::Agent::Context::Capability::Base) do
43
- tool_name tn
44
- description desc
45
- execution_mode :cooperative
46
- define_method(:execute) { sentinel_value }
13
+ def initialize(source_agent:, target_agent:, policy: HandoffPolicy.default, description: nil)
14
+ unless source_agent.is_a?(Phronomy::Agent::Base) &&
15
+ target_agent.is_a?(Phronomy::Agent::Base)
16
+ raise ArgumentError, "source_agent and target_agent must be Agent::Base instances"
17
+ end
18
+ if source_agent.equal?(target_agent)
19
+ raise ArgumentError, "Handoff source_agent and target_agent must be different instances"
20
+ end
21
+ unless policy.is_a?(HandoffPolicy)
22
+ raise ArgumentError, "policy must be a Phronomy::MultiAgent::HandoffPolicy"
47
23
  end
48
- end
49
24
 
50
- # The sentinel string embedded in the tool result.
51
- # @return [String]
52
- # @api public
53
- def sentinel
54
- "#{SENTINEL_PREFIX}:#{target_agent.class.name}:#{@uuid}"
25
+ @source_agent = source_agent
26
+ @target_agent = target_agent
27
+ @policy = policy
28
+ @description = (description || default_description).to_s.freeze
29
+ @transport_key = SecureRandom.hex(8).freeze
30
+ freeze
55
31
  end
56
32
 
57
33
  private
58
34
 
59
- def snake_case(klass_name)
60
- klass_name.gsub(/([A-Z])/) { "_#{$1}" }.downcase.delete_prefix("_")
35
+ attr_reader :transport_key
36
+
37
+ def default_description
38
+ target_name = target_agent.class.name || "target Agent"
39
+ "Transfer active responsibility to #{target_name}."
61
40
  end
62
41
  end
63
42
  end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "weakref"
4
+
5
+ module Phronomy
6
+ module MultiAgent
7
+ class HandoffCapabilityFactory
8
+ REGISTRY_MUTEX = Mutex.new
9
+ private_constant :REGISTRY_MUTEX
10
+
11
+ Binding = Data.define(:handoff, :tool_class, :tool_name) do
12
+ def initialize(handoff:, tool_class:, tool_name:)
13
+ super(handoff: handoff, tool_class: tool_class, tool_name: tool_name.to_s.freeze)
14
+ freeze
15
+ end
16
+ end
17
+
18
+ def self.build(handoff)
19
+ key = handoff.send(:transport_key)
20
+ source_name = class_slug(handoff.source_agent.class)
21
+ target_name = class_slug(handoff.target_agent.class)
22
+ tool_name = "phronomy_handoff_#{source_name}_to_#{target_name}_#{key}"
23
+ description = handoff.description
24
+ policy = handoff.policy
25
+
26
+ klass = Class.new(Phronomy::Agent::Context::Capability::Base) do
27
+ self.tool_name(tool_name)
28
+ self.description(description)
29
+ execution_mode :cooperative
30
+ param :responsibility,
31
+ type: :string,
32
+ required: true,
33
+ desc: "The concrete responsibility the target Agent must continue."
34
+
35
+ policy.selectable_categories.each do |category|
36
+ param :"include_#{category}",
37
+ type: :boolean,
38
+ required: false,
39
+ desc: "Whether to transfer selectable #{category} Context."
40
+ end
41
+
42
+ define_method(:execute) do |**_args|
43
+ raise Phronomy::HandoffError,
44
+ "Handoff capabilities are control-plane operations and must not execute as Tools"
45
+ end
46
+ end
47
+
48
+ binding = Binding.new(handoff: handoff, tool_class: klass, tool_name: tool_name)
49
+ registry_mutex.synchronize { registry[tool_name] = WeakRef.new(binding) }
50
+ binding
51
+ end
52
+
53
+ def self.lookup(tool_name)
54
+ key = tool_name.to_s
55
+ registry_mutex.synchronize do
56
+ reference = registry[key]
57
+ return nil unless reference
58
+
59
+ begin
60
+ reference.__getobj__
61
+ rescue WeakRef::RefError
62
+ registry.delete(key)
63
+ nil
64
+ end
65
+ end
66
+ end
67
+
68
+ def self.registry
69
+ @registry ||= {}
70
+ end
71
+ private_class_method :registry
72
+
73
+ def self.registry_mutex
74
+ REGISTRY_MUTEX
75
+ end
76
+ private_class_method :registry_mutex
77
+
78
+ def self.class_slug(klass)
79
+ raw = (klass.name || "agent").gsub("::", "_")
80
+ raw.gsub(/([a-z\d])([A-Z])/, '\\1_\\2')
81
+ .gsub(/[^a-zA-Z0-9_]/, "_")
82
+ .downcase
83
+ end
84
+ private_class_method :class_slug
85
+ end
86
+ end
87
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module MultiAgent
5
+ class HandoffContext
6
+ Provenance = Data.define(
7
+ :origin_agent_id,
8
+ :origin_record_id,
9
+ :origin_execution_id,
10
+ :origin_llm_call_id,
11
+ :origin_tool_call_id,
12
+ :transfer_path
13
+ ) do
14
+ def initialize(**values)
15
+ super(**values.merge(
16
+ origin_agent_id: values[:origin_agent_id]&.to_s&.freeze,
17
+ origin_record_id: values[:origin_record_id]&.to_s&.freeze,
18
+ origin_execution_id: values[:origin_execution_id]&.to_s&.freeze,
19
+ origin_llm_call_id: values[:origin_llm_call_id]&.to_s&.freeze,
20
+ origin_tool_call_id: values[:origin_tool_call_id]&.to_s&.freeze,
21
+ transfer_path: Array(values[:transfer_path]).map(&:to_s).freeze
22
+ ))
23
+ freeze
24
+ end
25
+
26
+ def forwarded_to(agent_id)
27
+ self.class.new(
28
+ origin_agent_id: origin_agent_id,
29
+ origin_record_id: origin_record_id,
30
+ origin_execution_id: origin_execution_id,
31
+ origin_llm_call_id: origin_llm_call_id,
32
+ origin_tool_call_id: origin_tool_call_id,
33
+ transfer_path: transfer_path + [agent_id.to_s]
34
+ )
35
+ end
36
+
37
+ def to_h
38
+ {
39
+ "origin_agent_id" => origin_agent_id,
40
+ "origin_record_id" => origin_record_id,
41
+ "origin_execution_id" => origin_execution_id,
42
+ "origin_llm_call_id" => origin_llm_call_id,
43
+ "origin_tool_call_id" => origin_tool_call_id,
44
+ "transfer_path" => transfer_path
45
+ }.compact.freeze
46
+ end
47
+ end
48
+
49
+ Item = Data.define(
50
+ :candidate_category,
51
+ :policy_category,
52
+ :role,
53
+ :content,
54
+ :content_format,
55
+ :tool_call_id,
56
+ :provenance,
57
+ :metadata
58
+ ) do
59
+ def initialize(**values)
60
+ provenance = values.fetch(:provenance)
61
+ unless provenance.is_a?(Provenance)
62
+ raise ArgumentError, "HandoffContext::Item provenance must be Provenance"
63
+ end
64
+ format = (values[:content_format] || :text).to_sym
65
+ unless %i[text json].include?(format)
66
+ raise ArgumentError, "unsupported Handoff Context content format: #{format.inspect}"
67
+ end
68
+ super(**values.merge(
69
+ candidate_category: values.fetch(:candidate_category).to_sym,
70
+ policy_category: values.fetch(:policy_category).to_sym,
71
+ role: values[:role]&.to_sym,
72
+ content: Phronomy::Agent::Immutable.copy(values.fetch(:content)),
73
+ content_format: format,
74
+ tool_call_id: values[:tool_call_id]&.to_s&.freeze,
75
+ provenance: provenance,
76
+ metadata: Phronomy::Agent::Immutable.copy(values[:metadata] || {})
77
+ ))
78
+ freeze
79
+ end
80
+ end
81
+
82
+ attr_reader :responsibility, :items
83
+
84
+ def initialize(responsibility:, items: [])
85
+ @responsibility = responsibility.to_s.freeze
86
+ @items = Array(items).freeze
87
+ raise ArgumentError, "Handoff Context responsibility must not be empty" if @responsibility.strip.empty?
88
+ unless @items.all? { |item| item.is_a?(Item) }
89
+ raise ArgumentError, "Handoff Context items must be HandoffContext::Item values"
90
+ end
91
+ freeze
92
+ end
93
+ end
94
+ end
95
+ end