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,137 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module MultiAgent
5
+ class HandoffPolicy
6
+ CATEGORIES = %i[current_request history knowledge tool_exchanges].freeze
7
+ MODES = %i[required forbidden selectable].freeze
8
+ DEFAULTS = %i[include exclude].freeze
9
+
10
+ Rule = Data.define(:category, :mode, :default) do
11
+ def initialize(category:, mode:, default: nil)
12
+ category = category.to_sym
13
+ mode = mode.to_sym
14
+ unless HandoffPolicy::CATEGORIES.include?(category)
15
+ raise ArgumentError, "unknown Handoff policy category: #{category.inspect}"
16
+ end
17
+ unless HandoffPolicy::MODES.include?(mode)
18
+ raise ArgumentError, "unknown Handoff policy mode: #{mode.inspect}"
19
+ end
20
+ if mode == :selectable
21
+ default = (default || :exclude).to_sym
22
+ unless HandoffPolicy::DEFAULTS.include?(default)
23
+ raise ArgumentError, "selectable default must be :include or :exclude"
24
+ end
25
+ elsif default
26
+ raise ArgumentError, "default is valid only for selectable Handoff rules"
27
+ end
28
+ super
29
+ freeze
30
+ end
31
+ end
32
+
33
+ class Builder
34
+ def initialize
35
+ @rules = {}
36
+ end
37
+
38
+ def required(category)
39
+ set(category, :required)
40
+ end
41
+
42
+ def forbidden(category)
43
+ set(category, :forbidden)
44
+ end
45
+
46
+ def selectable(category, default: :exclude)
47
+ set(category, :selectable, default: default)
48
+ end
49
+
50
+ def build
51
+ missing = CATEGORIES - @rules.keys
52
+ unless missing.empty?
53
+ raise ArgumentError,
54
+ "HandoffPolicy must define all categories; missing: #{missing.inspect}"
55
+ end
56
+ HandoffPolicy.new(@rules.values)
57
+ end
58
+
59
+ private
60
+
61
+ def set(category, mode, default: nil)
62
+ category = category.to_sym
63
+ if @rules.key?(category)
64
+ raise ArgumentError, "Handoff policy category already defined: #{category.inspect}"
65
+ end
66
+ @rules[category] = Rule.new(category: category, mode: mode, default: default)
67
+ self
68
+ end
69
+ end
70
+
71
+ private_constant :Rule, :Builder
72
+
73
+ def self.define(&block)
74
+ raise ArgumentError, "HandoffPolicy.define requires a block" unless block
75
+ builder = Builder.new
76
+ builder.instance_eval(&block)
77
+ builder.build
78
+ end
79
+
80
+ def self.default
81
+ @default ||= define do
82
+ required :current_request
83
+ selectable :history, default: :include
84
+ selectable :knowledge, default: :exclude
85
+ selectable :tool_exchanges, default: :include
86
+ end
87
+ end
88
+
89
+ def initialize(rules)
90
+ @rules = Array(rules).to_h { |rule| [rule.category, rule] }.freeze
91
+ missing = CATEGORIES - @rules.keys
92
+ extra = @rules.keys - CATEGORIES
93
+ unless missing.empty? && extra.empty?
94
+ raise ArgumentError,
95
+ "HandoffPolicy categories mismatch; missing=#{missing.inspect} extra=#{extra.inspect}"
96
+ end
97
+ freeze
98
+ end
99
+
100
+ def rule(category)
101
+ @rules.fetch(category.to_sym)
102
+ end
103
+
104
+ def required?(category)
105
+ rule(category).mode == :required
106
+ end
107
+
108
+ def forbidden?(category)
109
+ rule(category).mode == :forbidden
110
+ end
111
+
112
+ def selectable?(category)
113
+ rule(category).mode == :selectable
114
+ end
115
+
116
+ def default_include?(category)
117
+ selected = rule(category)
118
+ selected.mode == :required ||
119
+ (selected.mode == :selectable && selected.default == :include)
120
+ end
121
+
122
+ def selectable_categories
123
+ CATEGORIES.select { |category| selectable?(category) }.freeze
124
+ end
125
+
126
+ def to_h
127
+ CATEGORIES.to_h do |category|
128
+ selected = rule(category)
129
+ [category.to_s, {
130
+ "mode" => selected.mode.to_s,
131
+ "default" => selected.default&.to_s
132
+ }.compact]
133
+ end.freeze
134
+ end
135
+ end
136
+ end
137
+ end
@@ -0,0 +1,191 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module MultiAgent
5
+ class HandoffProjection
6
+ CONTROL_CATEGORIES = %i[instruction handoff_responsibility].freeze
7
+ JSON_CATEGORIES = %i[assistant_message tool_message].freeze
8
+
9
+ def build(request:, manifest:, persistence:, source_agent:)
10
+ unless request.is_a?(HandoffRequest)
11
+ raise ArgumentError, "request must be a HandoffRequest"
12
+ end
13
+ unless manifest.is_a?(Phronomy::Agent::LLMInputManifest)
14
+ raise ArgumentError, "manifest must be an LLMInputManifest"
15
+ end
16
+
17
+ groups = project_visible_groups(manifest)
18
+ selected = groups.values.select do |group|
19
+ include_group?(request, group.fetch(:policy_category))
20
+ end
21
+
22
+ items = selected.flat_map do |group|
23
+ group.fetch(:segments).map do |segment|
24
+ materialize_item(
25
+ segment,
26
+ policy_category: group.fetch(:policy_category),
27
+ persistence: persistence,
28
+ source_agent: source_agent,
29
+ target_agent: request.handoff.target_agent
30
+ )
31
+ end
32
+ end
33
+
34
+ HandoffContext.new(
35
+ responsibility: request.responsibility,
36
+ items: items
37
+ )
38
+ end
39
+
40
+ private
41
+
42
+ def project_visible_groups(manifest)
43
+ groups = {}
44
+ manifest.segments.each do |segment|
45
+ policy_category = policy_category_for(segment)
46
+ next unless policy_category
47
+
48
+ key = selection_group_key(segment, policy_category)
49
+ group = groups[key] ||= {
50
+ policy_category: policy_category,
51
+ segments: []
52
+ }
53
+ if group[:policy_category] != policy_category
54
+ raise Phronomy::HandoffError,
55
+ "one Handoff projection group spans incompatible policy categories"
56
+ end
57
+ group[:segments] << segment
58
+ end
59
+ groups
60
+ end
61
+
62
+ def policy_category_for(segment)
63
+ explicit = segment.metadata["handoff_policy_category"] ||
64
+ segment.metadata[:handoff_policy_category]
65
+ return explicit.to_sym if explicit
66
+
67
+ semantic_category =
68
+ segment.metadata["context_policy_semantic_category"] ||
69
+ segment.metadata[:context_policy_semantic_category]
70
+ case semantic_category&.to_sym
71
+ when :instruction
72
+ return nil
73
+ when :knowledge
74
+ return :knowledge
75
+ when :conversation
76
+ return :current_request if segment.delivery.to_sym == :ask_argument
77
+ return :history
78
+ end
79
+
80
+ return :current_request if segment.delivery.to_sym == :ask_argument
81
+ return nil if CONTROL_CATEGORIES.include?(segment.category.to_sym)
82
+ return :knowledge if segment.category.to_sym == :knowledge
83
+ unit_kind = segment.metadata["selection_unit_kind"] ||
84
+ segment.metadata[:selection_unit_kind]
85
+ return :tool_exchanges if unit_kind.to_s == "tool_exchange"
86
+
87
+ case segment.category.to_sym
88
+ when :external_message, :assistant_message, :tool_message,
89
+ :conversation, :memory, :summary, :structured_state
90
+ :history
91
+ end
92
+ end
93
+
94
+ def selection_group_key(segment, policy_category)
95
+ conversation_group_id =
96
+ segment.metadata["context_policy_conversation_group_id"] ||
97
+ segment.metadata[:context_policy_conversation_group_id]
98
+ if conversation_group_id
99
+ return "context-policy-conversation:#{conversation_group_id}"
100
+ end
101
+
102
+ # Compatibility for finalized pre-ACS-04 manifests. New manifests use
103
+ # context_policy_conversation_group_id and do not recreate Selection::Unit.
104
+ unit_id = segment.metadata["selection_unit_id"] ||
105
+ segment.metadata[:selection_unit_id]
106
+ return "unit:#{unit_id}" if unit_id
107
+
108
+ "segment:#{policy_category}:#{segment.position}"
109
+ end
110
+
111
+ def include_group?(request, category)
112
+ policy = request.handoff.policy
113
+ return true if policy.required?(category)
114
+ return false if policy.forbidden?(category)
115
+
116
+ request.selection_intent.fetch(category) do
117
+ policy.default_include?(category)
118
+ end
119
+ end
120
+
121
+ def materialize_item(segment, policy_category:, persistence:, source_agent:, target_agent:)
122
+ bytes = persistence.contents.fetch(segment.content_ref)
123
+ category = segment.category.to_sym
124
+ metadata = segment.metadata.to_h.transform_keys(&:to_s)
125
+ format = content_format_for(segment, metadata)
126
+ content = (format == :json) ? Phronomy::CanonicalJSON.load(bytes) : bytes.to_s
127
+ provenance = provenance_for(
128
+ metadata,
129
+ segment: segment,
130
+ source_agent: source_agent,
131
+ target_agent: target_agent
132
+ )
133
+
134
+ HandoffContext::Item.new(
135
+ candidate_category: category,
136
+ policy_category: policy_category,
137
+ role: segment.role,
138
+ content: content,
139
+ content_format: format,
140
+ tool_call_id: segment.tool_call_id,
141
+ provenance: provenance,
142
+ metadata: metadata.except(
143
+ "context_policy_content_format",
144
+ "context_policy_conversation_group_id",
145
+ "selection_candidate_id", "selection_unit_id", "selection_unit_kind",
146
+ "handoff_policy_category", "handoff_provenance"
147
+ )
148
+ )
149
+ end
150
+
151
+ def content_format_for(segment, metadata)
152
+ explicit = metadata["context_policy_content_format"] ||
153
+ metadata[:context_policy_content_format]
154
+ if explicit
155
+ format = explicit.to_sym
156
+ unless Phronomy::Agent::ContextPolicyInput::CONTENT_FORMATS.include?(format)
157
+ raise Phronomy::HandoffError,
158
+ "unsupported Context content format in Manifest: #{explicit.inspect}"
159
+ end
160
+ return format
161
+ end
162
+
163
+ JSON_CATEGORIES.include?(segment.category.to_sym) ? :json : :text
164
+ end
165
+
166
+ def provenance_for(metadata, segment:, source_agent:, target_agent:)
167
+ inherited = metadata["handoff_provenance"]
168
+ if inherited
169
+ base = HandoffContext::Provenance.new(
170
+ origin_agent_id: inherited["origin_agent_id"] || inherited[:origin_agent_id],
171
+ origin_record_id: inherited["origin_record_id"] || inherited[:origin_record_id],
172
+ origin_execution_id: inherited["origin_execution_id"] || inherited[:origin_execution_id],
173
+ origin_llm_call_id: inherited["origin_llm_call_id"] || inherited[:origin_llm_call_id],
174
+ origin_tool_call_id: inherited["origin_tool_call_id"] || inherited[:origin_tool_call_id],
175
+ transfer_path: inherited["transfer_path"] || inherited[:transfer_path]
176
+ )
177
+ return base.forwarded_to(target_agent.agent_id)
178
+ end
179
+
180
+ HandoffContext::Provenance.new(
181
+ origin_agent_id: metadata["source_agent_id"] || source_agent.agent_id,
182
+ origin_record_id: metadata["journal_record_id"],
183
+ origin_execution_id: metadata["source_execution_id"],
184
+ origin_llm_call_id: metadata["llm_call_id"],
185
+ origin_tool_call_id: segment.tool_call_id,
186
+ transfer_path: [source_agent.agent_id, target_agent.agent_id]
187
+ )
188
+ end
189
+ end
190
+ end
191
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module MultiAgent
5
+ # Typed private control request produced from one intercepted Handoff capability.
6
+ # @api private
7
+ HandoffRequest = Data.define(
8
+ :handoff, :responsibility, :selection_intent, :llm_call_id, :tool_call_id
9
+ ) do
10
+ def initialize(handoff:, responsibility:, selection_intent:, llm_call_id: nil, tool_call_id: nil)
11
+ responsibility = responsibility.to_s.strip
12
+ raise ArgumentError, "Handoff responsibility must not be empty" if responsibility.empty?
13
+ unless handoff.is_a?(Handoff)
14
+ raise ArgumentError, "handoff must be a Phronomy::MultiAgent::Handoff"
15
+ end
16
+
17
+ normalized = handoff.policy.selectable_categories.to_h do |category|
18
+ raw = selection_intent.fetch(category) do
19
+ handoff.policy.default_include?(category)
20
+ end
21
+ unless raw == true || raw == false
22
+ raise ArgumentError,
23
+ "Handoff selection for #{category.inspect} must be boolean"
24
+ end
25
+ [category, raw]
26
+ end.freeze
27
+
28
+ unknown = selection_intent.keys.map(&:to_sym) - handoff.policy.selectable_categories
29
+ unless unknown.empty?
30
+ raise ArgumentError,
31
+ "Handoff selection attempts to override non-selectable categories: #{unknown.inspect}"
32
+ end
33
+
34
+ super(
35
+ handoff: handoff,
36
+ responsibility: responsibility.freeze,
37
+ selection_intent: normalized,
38
+ llm_call_id: llm_call_id&.to_s&.freeze,
39
+ tool_call_id: tool_call_id&.to_s&.freeze
40
+ )
41
+ freeze
42
+ end
43
+ end
44
+ end
45
+ end
@@ -50,7 +50,6 @@ module Phronomy
50
50
 
51
51
  source = agent.invoke_async(
52
52
  input,
53
- thread_id: ctx[:thread_id] || parent_ic&.thread_id,
54
53
  config: task_config
55
54
  )
56
55
  result_task = Phronomy::Task.deferred(
@@ -151,7 +150,6 @@ module Phronomy
151
150
  agent:,
152
151
  inputs:,
153
152
  config: {},
154
- thread_id: nil,
155
153
  max_concurrency: nil,
156
154
  on_error: :raise,
157
155
  timeout: nil,
@@ -161,7 +159,7 @@ module Phronomy
161
159
  )
162
160
  dispatch_parallel(
163
161
  *inputs.map do |input|
164
- {agent: agent, input: input, config: config, thread_id: thread_id}
162
+ {agent: agent, input: input, config: config}
165
163
  end,
166
164
  max_concurrency: max_concurrency,
167
165
  on_error: on_error,
@@ -176,7 +174,6 @@ module Phronomy
176
174
  agent:,
177
175
  inputs:,
178
176
  config: {},
179
- thread_id: nil,
180
177
  max_concurrency: nil,
181
178
  on_error: :raise,
182
179
  timeout: nil,
@@ -186,7 +183,7 @@ module Phronomy
186
183
  )
187
184
  dispatch_parallel_async(
188
185
  *inputs.map do |input|
189
- {agent: agent, input: input, config: config, thread_id: thread_id}
186
+ {agent: agent, input: input, config: config}
190
187
  end,
191
188
  max_concurrency: max_concurrency,
192
189
  on_error: on_error,
@@ -201,7 +198,6 @@ module Phronomy
201
198
  agent_class,
202
199
  input,
203
200
  config: nil,
204
- thread_id: nil,
205
201
  inherit_knowledge: true
206
202
  )
207
203
  if Phronomy::Runtime.in_event_loop_context?
@@ -213,8 +209,7 @@ module Phronomy
213
209
  inherit_knowledge: inherit_knowledge
214
210
  ).invoke_async(
215
211
  input,
216
- config: config || {},
217
- thread_id: thread_id
212
+ config: config || {}
218
213
  ).wait_result
219
214
  end
220
215
 
@@ -233,11 +228,8 @@ module Phronomy
233
228
  captured_context = {}
234
229
  captured_context[:knowledge] = active_knowledge_snapshot if inherits_knowledge
235
230
  if invocation
236
- captured_context.merge!(
237
- thread_id: invocation.thread_id,
238
- config: invocation.config,
239
- invocation_context: invocation.config[:invocation_context]
240
- )
231
+ captured_context[:config] = invocation.config
232
+ captured_context[:invocation_context] = invocation.config[:invocation_context]
241
233
  end
242
234
  captured_context.freeze
243
235
 
@@ -291,6 +283,12 @@ module Phronomy
291
283
  raise ArgumentError, "fan-out task must be a Hash" unless task.is_a?(Hash)
292
284
  raise ArgumentError, "fan-out task requires :agent" unless task[:agent]
293
285
  raise ArgumentError, "fan-out task requires :input" unless task.key?(:input)
286
+ if task.key?(:thread_id) || task.key?("thread_id")
287
+ raise ArgumentError,
288
+ "fan-out task generic thread_id was removed; " \
289
+ "use purpose-specific domain identifiers or application tracing metadata"
290
+ end
291
+ _reject_removed_generic_identity_keys!(task.fetch(:config, {}))
294
292
  end
295
293
  end
296
294
 
@@ -324,8 +322,7 @@ module Phronomy
324
322
  index: index,
325
323
  agent: child_agent,
326
324
  input: task.fetch(:input),
327
- config: task_config,
328
- thread_id: task[:thread_id] || invocation_context&.thread_id
325
+ config: task_config
329
326
  )
330
327
  end
331
328
  end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module MultiAgent
5
+ class Runner
6
+ MAX_HANDOFFS = 20
7
+
8
+ attr_reader :main_agent, :handoffs
9
+
10
+ # @api public
11
+ def initialize(main_agent:, handoffs: [])
12
+ @main_agent = main_agent
13
+ @handoffs = Array(handoffs).freeze
14
+ @coordinator = Coordinator.attach(
15
+ main_agent: main_agent,
16
+ handoffs: @handoffs
17
+ )
18
+ end
19
+
20
+ # @api public
21
+ def invoke(input, config: {})
22
+ trace_handle = Phronomy::Tracing::Automatic.start(
23
+ "multi_agent.turn",
24
+ input: input,
25
+ main_agent_id: @main_agent.agent_id,
26
+ **@main_agent.send(:_build_caller_meta, config)
27
+ )
28
+ result = nil
29
+ operation_error = nil
30
+
31
+ @coordinator = Coordinator.attach(
32
+ main_agent: @main_agent,
33
+ handoffs: @handoffs
34
+ )
35
+ runtime = Phronomy::Runtime.instance
36
+ runtime.__admit_multi_agent(@coordinator)
37
+ handoffs_taken = 0
38
+ current_input = input
39
+
40
+ result = loop do
41
+ state = @coordinator.snapshot
42
+ active_agent = state.active_agent
43
+ bindings = @coordinator.outgoing_bindings(active_agent)
44
+ agent_result = active_agent.invoke(
45
+ current_input,
46
+ config: config.merge(
47
+ phronomy_handoff_bindings: bindings,
48
+ phronomy_handoff_context: state.active_handoff_context
49
+ )
50
+ )
51
+
52
+ request = agent_result[:handoff_request]
53
+ break public_result(agent_result, active_agent) unless request
54
+
55
+ if handoffs_taken >= MAX_HANDOFFS
56
+ raise Phronomy::HandoffError,
57
+ "Exceeded maximum Handoffs (#{MAX_HANDOFFS}) in one user turn"
58
+ end
59
+
60
+ manifest = agent_result.fetch(:_phronomy_handoff_manifest)
61
+ context = HandoffProjection.new.build(
62
+ request: request,
63
+ manifest: manifest,
64
+ persistence: active_agent.persistence,
65
+ source_agent: active_agent
66
+ )
67
+ @coordinator.transition!(request, context)
68
+ current_input = request.responsibility
69
+ handoffs_taken += 1
70
+ end
71
+ result
72
+ rescue => error
73
+ operation_error = error
74
+ raise
75
+ ensure
76
+ runtime&.__release_multi_agent(@coordinator) if defined?(@coordinator)
77
+ trace_output = if result.is_a?(Hash)
78
+ result[:output] || result["output"]
79
+ else
80
+ result
81
+ end
82
+ Phronomy::Tracing::Automatic.finish(
83
+ trace_handle,
84
+ output: trace_output,
85
+ error: operation_error
86
+ )
87
+ end
88
+
89
+ private
90
+
91
+ def public_result(result, agent)
92
+ result.reject { |key, _| key.to_s.start_with?("_phronomy_") }
93
+ .except(:handoff_request)
94
+ .merge(agent: agent)
95
+ end
96
+ end
97
+ end
98
+ end