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
|
@@ -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
|
|
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
|
|
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.
|
|
237
|
-
|
|
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
|