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.
- checksums.yaml +4 -4
- data/.mutant.yml +3 -4
- data/CHANGELOG.md +200 -10
- data/CONTRIBUTING.md +81 -9
- data/README.md +15 -6
- data/VERIFY.sh +587 -0
- data/benchmark/bench_agent_invoke.rb +2 -2
- data/benchmark/bench_context_assembler.rb +39 -68
- data/benchmark/bench_regression.rb +2 -2
- data/docs/architecture/agent-context.md +174 -0
- data/docs/architecture/before-llm-input.md +78 -0
- data/docs/architecture/context-management.md +232 -0
- data/docs/architecture/knowledge-and-rag.md +130 -0
- data/docs/architecture/multi-agent-handoff.md +152 -0
- data/docs/architecture/persistence.md +175 -0
- data/docs/architecture/removed/agent-context.md +72 -0
- data/docs/architecture/security-boundaries.md +173 -0
- data/docs/architecture/tracing.md +194 -0
- data/docs/architecture.md +82 -0
- data/docs/archive/design/archived/04_api_design.md +507 -0
- data/docs/archive/design/archived/09_guardrails.md +186 -0
- data/docs/archive/design/archived/17_rails_integration.md +175 -0
- data/docs/archive/design/historical/00_design_philosophy.md +122 -0
- data/docs/archive/design/historical/01_rubyllm_evaluation.md +178 -0
- data/docs/archive/design/historical/06_design_decisions.md +143 -0
- data/docs/changelog/0.14-and-earlier.md +1 -1
- data/docs/decisions/001-rubyllm-as-provider-layer.md +6 -1
- data/docs/decisions/002-workflow-context-immutability.md +26 -1
- data/docs/decisions/006-no-built-in-guardrails.md +2 -1
- data/docs/decisions/012-canonical-execution-log-and-context-policy.md +120 -38
- data/docs/decisions/014-unified-persistence-durable-state.md +9 -2
- data/docs/decisions/016-semantic-multi-agent-handoff.md +112 -0
- data/docs/decisions/017-design-authority-and-adr-governance.md +200 -0
- data/docs/decisions/018-durability-guarantees-and-failure-model.md +488 -0
- data/docs/decisions/019-filter-contract-and-security-boundaries.md +229 -0
- data/docs/decisions/020-canonical-workflow-instance-identity.md +177 -0
- data/docs/decisions/021-generic-agent-invocation-identity-removal.md +119 -0
- data/docs/decisions/022-agent-execution-parent-identity-and-runtime-routing-boundary.md +193 -0
- data/docs/decisions/023-fsm-session-incarnation-identity-and-routing.md +139 -0
- data/docs/decisions/024-event-loop-single-writer-agent-runtime.md +188 -0
- data/docs/decisions/025-process-local-agent-ownership-and-runtime-admission.md +249 -0
- data/docs/decisions/026-workflow-runtime-admission-and-durable-terminal-barrier.md +257 -0
- data/docs/decisions/027-llm-adapter-provider-boundary.md +93 -0
- data/docs/decisions/README.md +172 -0
- data/docs/features.md +31 -11
- data/docs/getting-started.md +77 -45
- data/docs/migrations/0.19.md +14 -7
- data/docs/migrations/0.22.md +390 -0
- data/docs/persistence-backends.md +88 -38
- data/docs/runtime-and-concurrency.md +227 -33
- data/examples/README.md +13 -0
- data/lib/phronomy/agent/agent_execution.rb +19 -15
- data/lib/phronomy/agent/agent_invocation.rb +288 -93
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +236 -202
- data/lib/phronomy/agent/agent_root.rb +3 -3
- data/lib/phronomy/agent/approval_evaluation_request.rb +37 -19
- data/lib/phronomy/agent/async_event_api.rb +145 -72
- data/lib/phronomy/agent/base.rb +388 -181
- data/lib/phronomy/agent/concerns/before_llm_input.rb +1 -1
- data/lib/phronomy/agent/context_assembler.rb +437 -178
- data/lib/phronomy/agent/context_candidate_resolver.rb +2 -2
- data/lib/phronomy/agent/context_plan.rb +18 -13
- data/lib/phronomy/agent/context_plan_validator.rb +246 -88
- data/lib/phronomy/agent/context_policies/default.rb +123 -34
- data/lib/phronomy/agent/context_policy.rb +109 -3
- data/lib/phronomy/agent/context_policy_input.rb +244 -0
- data/lib/phronomy/agent/context_policy_input_builder.rb +241 -0
- data/lib/phronomy/agent/execution_coordinator.rb +1975 -587
- data/lib/phronomy/agent/journal_record.rb +17 -4
- data/lib/phronomy/agent/llm_input_build_context.rb +1 -1
- data/lib/phronomy/agent/llm_input_manifest.rb +277 -2
- data/lib/phronomy/agent/llm_operation_result.rb +12 -7
- data/lib/phronomy/agent/phase_machine_builder.rb +19 -7
- data/lib/phronomy/agent/provider_call_outcome.rb +23 -7
- data/lib/phronomy/agent/recovery_coordinator/continuation.rb +271 -0
- data/lib/phronomy/agent/recovery_coordinator/installation.rb +427 -0
- data/lib/phronomy/agent/recovery_coordinator/resolution.rb +635 -0
- data/lib/phronomy/agent/recovery_coordinator.rb +211 -0
- data/lib/phronomy/agent/recovery_support.rb +512 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +22 -13
- data/lib/phronomy/agent/selection/candidate.rb +53 -0
- data/lib/phronomy/agent/selection/constraint.rb +49 -0
- data/lib/phronomy/agent/shared_state.rb +38 -1
- data/lib/phronomy/agent/tool_approval_request.rb +33 -5
- data/lib/phronomy/agent/tool_definition_set.rb +49 -3
- data/lib/phronomy/agent/tool_invocation.rb +336 -102
- data/lib/phronomy/agent/tool_invocation_session_builder.rb +49 -45
- data/lib/phronomy/agent.rb +20 -2
- data/lib/phronomy/agent_already_exists_error.rb +5 -0
- data/lib/phronomy/agent_purged_error.rb +5 -0
- data/lib/phronomy/engine/concurrency/offload_pool.rb +17 -3
- data/lib/phronomy/engine/concurrency/physical_completion_task.rb +135 -0
- data/lib/phronomy/engine/event_loop.rb +622 -63
- data/lib/phronomy/engine/fsm_session.rb +194 -21
- data/lib/phronomy/engine/runtime/agent_ownership_registry.rb +352 -0
- data/lib/phronomy/engine/runtime.rb +77 -20
- data/lib/phronomy/generator_verifier.rb +12 -14
- data/lib/phronomy/invocation_context.rb +9 -29
- data/lib/phronomy/multi_agent/admission_registry.rb +51 -0
- data/lib/phronomy/multi_agent/coordination_state.rb +18 -0
- data/lib/phronomy/multi_agent/coordinator.rb +154 -0
- data/lib/phronomy/multi_agent/execution_coordinator.rb +116 -0
- data/lib/phronomy/multi_agent/fan_out_invocation.rb +24 -33
- data/lib/phronomy/multi_agent/fan_out_session_builder.rb +12 -19
- data/lib/phronomy/multi_agent/handoff.rb +24 -45
- data/lib/phronomy/multi_agent/handoff_capability_factory.rb +87 -0
- data/lib/phronomy/multi_agent/handoff_context.rb +95 -0
- data/lib/phronomy/multi_agent/handoff_policy.rb +137 -0
- data/lib/phronomy/multi_agent/handoff_projection.rb +191 -0
- data/lib/phronomy/multi_agent/handoff_request.rb +45 -0
- data/lib/phronomy/multi_agent/orchestrator.rb +12 -15
- data/lib/phronomy/multi_agent/runner.rb +98 -0
- data/lib/phronomy/persistence/durable_codec.rb +646 -0
- data/lib/phronomy/persistence/durable_record.rb +117 -0
- data/lib/phronomy/persistence/in_memory.rb +210 -134
- data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
- data/lib/phronomy/persistence/repository_facades.rb +316 -0
- data/lib/phronomy/persistence.rb +81 -41
- data/lib/phronomy/recovery.rb +186 -0
- data/lib/phronomy/testing/persistence_contract/a_journal_repository.rb +2 -2
- data/lib/phronomy/testing/persistence_contract/a_persistence_backend.rb +1 -1
- data/lib/phronomy/testing/persistence_contract/a_workflow_state_repository.rb +19 -19
- data/lib/phronomy/testing/persistence_contract/an_agent_repository.rb +3 -3
- data/lib/phronomy/testing/persistence_contract/an_execution_repository.rb +5 -5
- data/lib/phronomy/tracing/automatic.rb +176 -0
- data/lib/phronomy/tracing/base.rb +11 -2
- data/lib/phronomy/tracing/langfuse_tracer.rb +20 -12
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +3 -6
- data/lib/phronomy/workflow_context.rb +14 -5
- data/lib/phronomy/workflow_recovery.rb +123 -0
- data/lib/phronomy/workflow_runner.rb +468 -256
- data/lib/phronomy.rb +6 -0
- data/scripts/api_snapshot.rb +12 -0
- data/sig/phronomy/agent.rbs +209 -7
- data/sig/phronomy/multi_agent.rbs +39 -0
- data/sig/phronomy/persistence.rbs +62 -4
- data/sig/phronomy/runtime.rbs +1 -4
- data/sig/phronomy/workflow.rbs +2 -2
- data/sig/phronomy.rbs +10 -0
- metadata +65 -17
- data/examples/workflows/agent_event_mapping.rb +0 -101
- data/examples/workflows/generic_task_event_mapping.rb +0 -66
- data/lib/phronomy/agent/activation_registry.rb +0 -28
- data/lib/phronomy/agent/agent_execution_activation.rb +0 -172
- data/lib/phronomy/agent/context_candidate.rb +0 -47
- data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +0 -53
- data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +0 -56
- data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +0 -30
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +0 -118
- data/lib/phronomy/agent/context_policy_descriptor.rb +0 -49
- data/lib/phronomy/agent/context_policy_registry.rb +0 -46
- data/lib/phronomy/agent/context_request.rb +0 -35
- data/lib/phronomy/agent/context_selection_unit.rb +0 -38
- data/lib/phronomy/agent/derived_content_spec.rb +0 -34
- 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
|