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
@@ -1,56 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Agent
5
- module ContextParts
6
- module Requirements
7
- class RequiredContextResolver
8
- def resolve(request:, units:)
9
- candidates = request.candidates.to_h { |candidate| [candidate.candidate_id, candidate] }
10
- declared = request.required_coverage.to_h { |value| [value.to_s, true] }
11
- latest_tool_unit = latest_current_tool_unit(request, units, candidates)
12
-
13
- Array(units).map do |unit|
14
- requirement = unit.requirement
15
- if unit.unit_id == latest_tool_unit&.unit_id
16
- requirement = :protocol_required
17
- elsif requirement == :optional && unit.candidate_ids.any? do |candidate_id|
18
- candidate = candidates.fetch(candidate_id)
19
- declared[candidate.candidate_id] || declared[candidate.record_id.to_s]
20
- end
21
- requirement = :declared_required
22
- end
23
-
24
- next unit if requirement == unit.requirement
25
-
26
- ContextSelectionUnit.new(
27
- unit_id: unit.unit_id,
28
- candidate_ids: unit.candidate_ids,
29
- dependency_unit_ids: unit.dependency_unit_ids,
30
- kind: unit.kind,
31
- requirement: requirement,
32
- priority: unit.priority,
33
- sequence_range: unit.sequence_range,
34
- metadata: unit.metadata
35
- )
36
- end.freeze
37
- end
38
-
39
- private
40
-
41
- def latest_current_tool_unit(request, units, candidates)
42
- return unless request.call_mode == :complete
43
-
44
- Array(units).select do |unit|
45
- unit.kind == :tool_exchange && unit.candidate_ids.any? do |candidate_id|
46
- candidate = candidates.fetch(candidate_id)
47
- candidate.source_kind == :working &&
48
- candidate.execution_id.to_s == request.execution_id.to_s
49
- end
50
- end.max_by { |unit| unit.sequence_range.last }
51
- end
52
- end
53
- end
54
- end
55
- end
56
- end
@@ -1,30 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Agent
5
- module ContextParts
6
- module Selectors
7
- class RecentFirstSelector
8
- def order(request:, units:)
9
- candidates = request.candidates.to_h { |candidate| [candidate.candidate_id, candidate] }
10
- Array(units).sort_by do |unit|
11
- required_rank = (unit.requirement == :optional) ? 1 : 0
12
- current_rank = current_execution_unit?(request, unit, candidates) ? 0 : 1
13
- [required_rank, current_rank, -unit.priority, -unit.sequence_range.last]
14
- end.freeze
15
- end
16
-
17
- private
18
-
19
- def current_execution_unit?(request, unit, candidates)
20
- unit.candidate_ids.any? do |candidate_id|
21
- candidate = candidates.fetch(candidate_id)
22
- candidate.execution_id.to_s == request.execution_id.to_s &&
23
- candidate.source_kind == :working
24
- end
25
- end
26
- end
27
- end
28
- end
29
- end
30
- end
@@ -1,118 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "digest"
4
-
5
- module Phronomy
6
- module Agent
7
- module ContextParts
8
- module UnitBuilders
9
- class DependencyAwareUnitBuilder
10
- def build(candidates)
11
- ordered = Array(candidates).sort_by do |candidate|
12
- [candidate.sequence || 0, candidate.candidate_id]
13
- end
14
-
15
- canonical_assistants = ordered.select do |candidate|
16
- candidate.category == :assistant_message
17
- end
18
- canonical_tool_messages = ordered.select do |candidate|
19
- candidate.category == :tool_message
20
- end.group_by(&:tool_call_id)
21
- assistant_by_tool_call_id = canonical_assistants.each_with_object({}) do |candidate, result|
22
- canonical_tool_call_ids(candidate).each do |tool_call_id|
23
- if result.key?(tool_call_id)
24
- raise ArgumentError,
25
- "duplicate assistant Tool Call id in Context candidates: #{tool_call_id}"
26
- end
27
- result[tool_call_id] = candidate
28
- end
29
- end
30
-
31
- claimed = Set.new
32
- units = []
33
- ordered.each do |candidate|
34
- next if claimed.include?(candidate.candidate_id)
35
-
36
- group = canonical_tool_exchange(
37
- candidate,
38
- assistant_by_tool_call_id: assistant_by_tool_call_id,
39
- tool_messages: canonical_tool_messages
40
- )
41
-
42
- if group && !group.empty?
43
- group.each { |item| claimed << item.candidate_id }
44
- units << build_unit(group, kind: :tool_exchange)
45
- else
46
- claimed << candidate.candidate_id
47
- units << build_unit([candidate], kind: :message)
48
- end
49
- end
50
-
51
- units.sort_by { |unit| unit.sequence_range.first }.freeze
52
- end
53
-
54
- private
55
-
56
- def canonical_tool_exchange(candidate, assistant_by_tool_call_id:, tool_messages:)
57
- assistant = if candidate.category == :assistant_message
58
- candidate
59
- elsif candidate.category == :tool_message
60
- assistant_by_tool_call_id[candidate.tool_call_id]
61
- end
62
- return unless assistant
63
-
64
- call_ids = canonical_tool_call_ids(assistant)
65
- return if call_ids.empty?
66
-
67
- messages = call_ids.flat_map do |tool_call_id|
68
- tool_messages.fetch(tool_call_id, [])
69
- end
70
- ([assistant] + messages).uniq.sort_by do |item|
71
- [item.sequence || 0, item.candidate_id]
72
- end
73
- end
74
-
75
- def canonical_tool_call_ids(candidate)
76
- Array(candidate.metadata["tool_call_ids"] || candidate.metadata[:tool_call_ids])
77
- .compact
78
- .map(&:to_s)
79
- end
80
-
81
- def build_unit(candidates, kind:)
82
- ids = candidates.map(&:candidate_id)
83
- sequences = candidates.map(&:sequence).compact
84
- requirements = candidates.map(&:requirement)
85
- requirement = if requirements.include?(:protocol_required)
86
- :protocol_required
87
- elsif requirements.include?(:declared_required)
88
- :declared_required
89
- else
90
- :optional
91
- end
92
- tool_call_ids = candidates.flat_map do |candidate|
93
- call_ids = canonical_tool_call_ids(candidate)
94
- call_ids << candidate.tool_call_id if candidate.tool_call_id
95
- call_ids
96
- end.compact.uniq
97
- llm_call_ids = candidates.map(&:llm_call_id).compact.uniq
98
- digest = Digest::SHA256.hexdigest(ids.join("\0"))[0, 20]
99
-
100
- ContextSelectionUnit.new(
101
- unit_id: "#{kind}:#{digest}",
102
- candidate_ids: ids,
103
- dependency_unit_ids: [],
104
- kind: kind,
105
- requirement: requirement,
106
- priority: candidates.map(&:priority).max || 0,
107
- sequence_range: [sequences.min || 0, sequences.max || 0],
108
- metadata: {
109
- "tool_call_ids" => tool_call_ids,
110
- "llm_call_ids" => llm_call_ids
111
- }
112
- )
113
- end
114
- end
115
- end
116
- end
117
- end
118
- end
@@ -1,49 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "digest"
4
-
5
- module Phronomy
6
- module Agent
7
- ContextPolicyDescriptor = Data.define(:id, :version, :config) do
8
- def initialize(id:, version:, config: {})
9
- normalized_config = Immutable.copy(config || {})
10
- Immutable.validate_canonical_json!(normalized_config, label: "Context Policy config")
11
- super(
12
- id: id.to_s.freeze,
13
- version: Integer(version),
14
- config: normalized_config
15
- )
16
- raise ArgumentError, "Context Policy id must not be empty" if id.empty?
17
- raise ArgumentError, "Context Policy version must be positive" unless version.positive?
18
- freeze
19
- end
20
-
21
- def self.from_h(hash)
22
- descriptor = new(
23
- id: hash.fetch("id") { hash.fetch(:id) },
24
- version: hash.fetch("version") { hash.fetch(:version) },
25
- config: hash["config"] || hash[:config] || {}
26
- )
27
- expected_digest = hash["config_digest"] || hash[:config_digest]
28
- if expected_digest && expected_digest.to_s != descriptor.config_digest
29
- raise Phronomy::ConfigurationError,
30
- "Context Policy config digest mismatch for #{descriptor.id}@#{descriptor.version}"
31
- end
32
- descriptor
33
- end
34
-
35
- def config_digest
36
- "sha256:#{Digest::SHA256.hexdigest(Phronomy::CanonicalJSON.dump(config))}"
37
- end
38
-
39
- def to_h
40
- {
41
- "id" => id,
42
- "version" => version,
43
- "config" => config,
44
- "config_digest" => config_digest
45
- }
46
- end
47
- end
48
- end
49
- end
@@ -1,46 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Agent
5
- class ContextPolicyRegistry
6
- class << self
7
- def default
8
- @default ||= new.tap do |registry|
9
- registry.register(id: "default-recent-v1", version: 1) do |config|
10
- ContextPolicies::Default.new(config)
11
- end
12
- end
13
- end
14
- end
15
-
16
- def initialize
17
- @factories = {}
18
- @mutex = Mutex.new
19
- end
20
-
21
- def register(id:, version:, &factory)
22
- raise ArgumentError, "Context Policy factory is required" unless factory
23
-
24
- key = [id.to_s, Integer(version)].freeze
25
- @mutex.synchronize do
26
- raise ArgumentError, "Context Policy already registered: #{key.join("@")}" if @factories.key?(key)
27
- @factories[key] = factory
28
- end
29
- self
30
- end
31
-
32
- def resolve(descriptor)
33
- key = [descriptor.id.to_s, Integer(descriptor.version)]
34
- factory = @mutex.synchronize { @factories[key] }
35
- raise Phronomy::ConfigurationError, "Unknown Context Policy: #{key.join("@")}" unless factory
36
-
37
- policy = factory.call(descriptor.config)
38
- unless policy.is_a?(ContextPolicy)
39
- raise Phronomy::ConfigurationError,
40
- "Context Policy factory returned #{policy.class}, expected #{ContextPolicy}"
41
- end
42
- policy
43
- end
44
- end
45
- end
46
- end
@@ -1,35 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Agent
5
- ContextRequest = Data.define(
6
- :agent_id,
7
- :execution_id,
8
- :call_sequence,
9
- :call_mode,
10
- :candidates,
11
- :token_budget,
12
- :model_config,
13
- :previous_manifest,
14
- :required_coverage,
15
- :parts,
16
- :metadata
17
- ) do
18
- def initialize(**values)
19
- super(**values.merge(
20
- agent_id: values.fetch(:agent_id).to_s.freeze,
21
- execution_id: values[:execution_id]&.to_s&.freeze,
22
- call_sequence: Integer(values.fetch(:call_sequence)),
23
- call_mode: values.fetch(:call_mode).to_sym,
24
- candidates: Array(values[:candidates]).freeze,
25
- model_config: Immutable.copy(values[:model_config] || {}),
26
- required_coverage: Array(values[:required_coverage]).map(&:to_s).freeze,
27
- parts: (values[:parts] || {}).dup.freeze,
28
- metadata: Immutable.copy(values[:metadata] || {})
29
- ))
30
- raise ArgumentError, "ContextRequest call_sequence must be positive" unless call_sequence.positive?
31
- freeze
32
- end
33
- end
34
- end
35
- end
@@ -1,38 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Agent
5
- ContextSelectionUnit = Data.define(
6
- :unit_id,
7
- :candidate_ids,
8
- :dependency_unit_ids,
9
- :kind,
10
- :requirement,
11
- :priority,
12
- :sequence_range,
13
- :metadata
14
- ) do
15
- def initialize(**values)
16
- normalized = values.merge(
17
- unit_id: values.fetch(:unit_id).to_s.freeze,
18
- candidate_ids: Array(values[:candidate_ids]).map(&:to_s).freeze,
19
- dependency_unit_ids: Array(values[:dependency_unit_ids]).map(&:to_s).freeze,
20
- kind: values.fetch(:kind).to_sym,
21
- requirement: (values[:requirement] || :optional).to_sym,
22
- priority: Integer(values[:priority] || 0),
23
- sequence_range: Array(values[:sequence_range]).map { |v| Integer(v) }.freeze,
24
- metadata: Immutable.copy(values[:metadata] || {})
25
- )
26
- unless %i[protocol_required declared_required optional].include?(normalized[:requirement])
27
- raise ArgumentError, "unknown ContextSelectionUnit requirement: #{normalized[:requirement].inspect}"
28
- end
29
- unless normalized[:sequence_range].length == 2
30
- raise ArgumentError, "ContextSelectionUnit sequence_range must contain two integers"
31
- end
32
-
33
- super(**normalized)
34
- freeze
35
- end
36
- end
37
- end
38
- end
@@ -1,34 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Agent
5
- DerivedContentSpec = Data.define(
6
- :content,
7
- :content_format,
8
- :category,
9
- :role,
10
- :coverage_candidate_ids,
11
- :source_record_ids,
12
- :source_content_refs,
13
- :transformer_id,
14
- :transformer_version,
15
- :metadata
16
- ) do
17
- def initialize(**values)
18
- super(**values.merge(
19
- content: Immutable.copy(values[:content]),
20
- content_format: values.fetch(:content_format).to_sym,
21
- category: values.fetch(:category).to_sym,
22
- role: values[:role]&.to_sym,
23
- coverage_candidate_ids: Array(values[:coverage_candidate_ids]).map(&:to_s).freeze,
24
- source_record_ids: Array(values[:source_record_ids]).map(&:to_s).freeze,
25
- source_content_refs: Array(values[:source_content_refs]).map(&:to_s).freeze,
26
- transformer_id: values.fetch(:transformer_id).to_s.freeze,
27
- transformer_version: Integer(values.fetch(:transformer_version)),
28
- metadata: Immutable.copy(values[:metadata] || {})
29
- ))
30
- freeze
31
- end
32
- end
33
- end
34
- end
@@ -1,97 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Agent
5
- # Orchestrates a multi-agent conversation by routing between agents
6
- # based on handoff tool calls detected in each agent's conversation history.
7
- #
8
- # Runner configures handoff tools on source agent instances at construction
9
- # time, then coordinates invocations: after each agent turn it inspects
10
- # the returned messages for a handoff sentinel and routes accordingly.
11
- #
12
- # @example Linear handoff (triage → billing)
13
- # triage = TriageAgent.new
14
- # billing = BillingAgent.new
15
- # runner = Phronomy::Agent::Runner.new(
16
- # agents: [triage, billing],
17
- # routes: { triage => [billing] }
18
- # )
19
- # result = runner.invoke("I need help with my invoice")
20
- # puts result[:output]
21
- # puts result[:agent].class # => BillingAgent
22
- class Runner
23
- # Maximum number of agent handoffs allowed per invoke call.
24
- MAX_HANDOFFS = 20
25
-
26
- attr_reader :agents
27
-
28
- # @param agents [Array<Phronomy::Agent::Base>]
29
- # agents managed by this runner; first element is the entry agent
30
- # @param routes [Hash{Phronomy::Agent::Base => Array<Phronomy::Agent::Base>}]
31
- # declares which target agents each source agent may hand off to;
32
- # when omitted no handoffs are configured and the entry agent handles everything
33
- # @api public
34
- def initialize(agents:, routes: {})
35
- @agents = Array(agents)
36
- raise ArgumentError, "At least one agent is required" if @agents.empty?
37
-
38
- @entry_agent = @agents.first
39
- @sentinel_map = {}
40
- build_handoffs(routes)
41
- end
42
-
43
- # Invokes the runner with the given input, routing between agents as needed.
44
- # Stops when an agent's turn produces no handoff signal, or when MAX_HANDOFFS
45
- # is reached (raises HandoffError in that case).
46
- #
47
- # @param input [String, Hash] the initial user message
48
- # @param config [Hash] forwarded to each agent's #invoke
49
- # @return [Hash] { output:, messages:, usage:, agent: }
50
- # @raise [Phronomy::HandoffError] if more than MAX_HANDOFFS handoffs occur
51
- # @api public
52
- def invoke(input, config: {})
53
- current = @entry_agent
54
- handoffs_taken = 0
55
-
56
- loop do
57
- # Check before invoking so we raise after exactly MAX_HANDOFFS handoffs,
58
- # not after MAX_HANDOFFS + 1 LLM calls.
59
- if handoffs_taken >= MAX_HANDOFFS
60
- raise Phronomy::HandoffError, "Exceeded maximum handoffs (#{MAX_HANDOFFS})"
61
- end
62
-
63
- result = current.invoke(input, config: config)
64
- target = find_handoff_target(result[:messages])
65
- return result.merge(agent: current) unless target
66
-
67
- current = target
68
- handoffs_taken += 1
69
- end
70
- end
71
-
72
- private
73
-
74
- def build_handoffs(routes)
75
- routes.each do |source_agent, target_agents|
76
- Array(target_agents).each do |target_agent|
77
- handoff = Phronomy::MultiAgent::Handoff.new(target_agent: target_agent)
78
- @sentinel_map[handoff.sentinel] = target_agent
79
- source_agent._add_handoff_tool(handoff.to_tool_class)
80
- end
81
- end
82
- end
83
-
84
- def find_handoff_target(messages)
85
- messages.reverse_each do |msg|
86
- next unless msg.role.to_sym == :tool
87
-
88
- content = msg.content.to_s
89
- next unless content.start_with?(Phronomy::MultiAgent::Handoff::SENTINEL_PREFIX)
90
-
91
- return @sentinel_map[content]
92
- end
93
- nil
94
- end
95
- end
96
- end
97
- end