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
|
@@ -43,7 +43,7 @@ module Phronomy
|
|
|
43
43
|
"source_sequence" => record.sequence
|
|
44
44
|
)
|
|
45
45
|
|
|
46
|
-
|
|
46
|
+
Selection::Candidate.new(
|
|
47
47
|
candidate_id: "record:#{record.record_id}",
|
|
48
48
|
source_kind: source_kind,
|
|
49
49
|
category: record.kind,
|
|
@@ -55,7 +55,7 @@ module Phronomy
|
|
|
55
55
|
llm_call_id: record.llm_call_id,
|
|
56
56
|
tool_call_id: tool_call_id,
|
|
57
57
|
sequence: sequence,
|
|
58
|
-
|
|
58
|
+
constraint: Selection::Constraint.selectable(origin: :context_policy),
|
|
59
59
|
priority: (source_kind == :working) ? 100 : 0,
|
|
60
60
|
metadata: metadata
|
|
61
61
|
)
|
|
@@ -3,21 +3,26 @@
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
module Agent
|
|
5
5
|
ContextPlan = Data.define(
|
|
6
|
-
:
|
|
7
|
-
:
|
|
8
|
-
:
|
|
9
|
-
:
|
|
10
|
-
:policy_descriptor,
|
|
6
|
+
:instruction,
|
|
7
|
+
:knowledge,
|
|
8
|
+
:tools,
|
|
9
|
+
:conversation,
|
|
11
10
|
:metadata
|
|
12
11
|
) do
|
|
13
|
-
def initialize(
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
def initialize(
|
|
13
|
+
instruction: [],
|
|
14
|
+
knowledge: [],
|
|
15
|
+
tools: [],
|
|
16
|
+
conversation: [],
|
|
17
|
+
metadata: {}
|
|
18
|
+
)
|
|
19
|
+
super(
|
|
20
|
+
instruction: Array(instruction).freeze,
|
|
21
|
+
knowledge: Array(knowledge).freeze,
|
|
22
|
+
tools: Array(tools).freeze,
|
|
23
|
+
conversation: Array(conversation).map { |group| Array(group).freeze }.freeze,
|
|
24
|
+
metadata: Immutable.copy(metadata || {})
|
|
25
|
+
)
|
|
21
26
|
freeze
|
|
22
27
|
end
|
|
23
28
|
end
|
|
@@ -3,131 +3,289 @@
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
module Agent
|
|
5
5
|
class ContextPlanValidator
|
|
6
|
-
|
|
7
|
-
:
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
6
|
+
RESERVED_KIND_CATEGORIES = {
|
|
7
|
+
instruction: :instruction,
|
|
8
|
+
handoff_responsibility: :instruction,
|
|
9
|
+
knowledge: :knowledge,
|
|
10
|
+
memory: :knowledge,
|
|
11
|
+
summary: :knowledge,
|
|
12
|
+
structured_state: :knowledge,
|
|
13
|
+
external_message: :conversation,
|
|
14
|
+
assistant_message: :conversation,
|
|
15
|
+
tool_message: :conversation,
|
|
16
|
+
current_input: :conversation,
|
|
17
|
+
current_request: :conversation,
|
|
18
|
+
conversation: :conversation,
|
|
19
|
+
tool_result: :conversation
|
|
20
|
+
}.freeze
|
|
18
21
|
|
|
19
|
-
def validate!(
|
|
22
|
+
def validate!(input:, plan:)
|
|
23
|
+
unless input.is_a?(ContextPolicyInput)
|
|
24
|
+
raise ArgumentError, "ContextPlanValidator expected ContextPolicyInput"
|
|
25
|
+
end
|
|
20
26
|
unless plan.is_a?(ContextPlan)
|
|
21
27
|
raise ArgumentError, "Context Policy returned #{plan.class}, expected #{ContextPlan}"
|
|
22
28
|
end
|
|
23
29
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
validate_flat_category!(
|
|
31
|
+
input: input.instruction,
|
|
32
|
+
output: plan.instruction,
|
|
33
|
+
item_class: ContextPolicyInput::InstructionItem,
|
|
34
|
+
label: :instruction,
|
|
35
|
+
generated_allowed: true
|
|
28
36
|
)
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
selected_candidates = selected_units.flat_map(&:candidate_ids).uniq.map do |candidate_id|
|
|
43
|
-
candidate_index.fetch(candidate_id)
|
|
44
|
-
end
|
|
45
|
-
validate_tool_dependencies!(request.candidates, selected_candidates)
|
|
46
|
-
validate_derived_contents!(request, plan)
|
|
47
|
-
|
|
48
|
-
ValidatedContextPlan.new(
|
|
49
|
-
plan: plan,
|
|
50
|
-
units: units,
|
|
51
|
-
selected_units: selected_units,
|
|
52
|
-
selected_candidates: selected_candidates
|
|
37
|
+
validate_flat_category!(
|
|
38
|
+
input: input.knowledge,
|
|
39
|
+
output: plan.knowledge,
|
|
40
|
+
item_class: ContextPolicyInput::KnowledgeItem,
|
|
41
|
+
label: :knowledge,
|
|
42
|
+
generated_allowed: true
|
|
43
|
+
)
|
|
44
|
+
validate_flat_category!(
|
|
45
|
+
input: input.tools,
|
|
46
|
+
output: plan.tools,
|
|
47
|
+
item_class: ContextPolicyInput::ToolItem,
|
|
48
|
+
label: :tools,
|
|
49
|
+
generated_allowed: false
|
|
53
50
|
)
|
|
51
|
+
validate_conversation!(input.conversation, plan.conversation)
|
|
52
|
+
validate_unique_ids!(plan)
|
|
53
|
+
plan
|
|
54
54
|
end
|
|
55
55
|
|
|
56
56
|
private
|
|
57
57
|
|
|
58
|
-
def
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
58
|
+
def validate_flat_category!(input:, output:, item_class:, label:, generated_allowed:)
|
|
59
|
+
input_by_id = input.to_h { |item| [item.id, item] }
|
|
60
|
+
output.each do |item|
|
|
61
|
+
unless item.is_a?(item_class)
|
|
62
|
+
raise ArgumentError,
|
|
63
|
+
"ContextPlan #{label} contains #{item.class}; expected #{item_class}"
|
|
64
|
+
end
|
|
62
65
|
|
|
63
|
-
|
|
64
|
-
assistants = Array(all_candidates).select { |candidate| candidate.category == :assistant_message }
|
|
65
|
-
tool_messages = Array(all_candidates).select { |candidate| candidate.category == :tool_message }
|
|
66
|
-
.group_by(&:tool_call_id)
|
|
67
|
-
assistant_by_tool_call_id = {}
|
|
66
|
+
validate_semantic_kind!(item, label)
|
|
68
67
|
|
|
69
|
-
|
|
70
|
-
if
|
|
71
|
-
|
|
68
|
+
source = input_by_id[item.id]
|
|
69
|
+
if source
|
|
70
|
+
unless source == item
|
|
71
|
+
raise ArgumentError, "ContextPlan modified input item #{item.id.inspect} in #{label}"
|
|
72
|
+
end
|
|
73
|
+
elsif !generated_allowed || item.provenance.origin != :policy_generated
|
|
74
|
+
raise ArgumentError, "ContextPlan contains unknown #{label} item: #{item.id.inspect}"
|
|
75
|
+
else
|
|
76
|
+
validate_policy_generated_metadata!(item)
|
|
72
77
|
end
|
|
73
78
|
end
|
|
74
79
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
+
missing = input.select(&:required?).reject do |required|
|
|
81
|
+
output.any? { |item| item.id == required.id }
|
|
82
|
+
end
|
|
83
|
+
return if missing.empty?
|
|
84
|
+
|
|
85
|
+
raise Phronomy::ContextBudgetExceededError,
|
|
86
|
+
"ContextPlan omitted required #{label} item(s): #{missing.map(&:id).inspect}"
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def validate_conversation!(input_groups, output_groups)
|
|
90
|
+
input_by_group_ids = input_groups.to_h do |group|
|
|
91
|
+
[group.map(&:id), group]
|
|
92
|
+
end
|
|
93
|
+
selected_input_ids = {}
|
|
94
|
+
|
|
95
|
+
output_groups.each do |group|
|
|
96
|
+
unless group.is_a?(Array) && !group.empty?
|
|
97
|
+
raise ArgumentError, "ContextPlan conversation groups must be non-empty Arrays"
|
|
98
|
+
end
|
|
99
|
+
unless group.all? { |item| item.is_a?(ContextPolicyInput::ConversationItem) }
|
|
100
|
+
raise ArgumentError, "ContextPlan conversation group contains a non-ConversationItem"
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
group.each { |item| validate_semantic_kind!(item, :conversation) }
|
|
104
|
+
|
|
105
|
+
ids = group.map(&:id)
|
|
106
|
+
matching = input_by_group_ids[ids]
|
|
107
|
+
if matching
|
|
108
|
+
unless matching == group
|
|
109
|
+
raise ArgumentError, "ContextPlan modified an input conversation group: #{ids.inspect}"
|
|
80
110
|
end
|
|
81
|
-
|
|
111
|
+
ids.each { |id| selected_input_ids[id] = true }
|
|
112
|
+
next
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
overlaps = ids.any? do |id|
|
|
116
|
+
input_groups.any? { |input_group| input_group.any? { |item| item.id == id } }
|
|
82
117
|
end
|
|
118
|
+
if overlaps
|
|
119
|
+
raise ArgumentError,
|
|
120
|
+
"ContextPlan split, merged, or reordered an input conversation group: #{ids.inspect}"
|
|
121
|
+
end
|
|
122
|
+
unless group.all? { |item| item.provenance.origin == :policy_generated }
|
|
123
|
+
raise ArgumentError, "ContextPlan contains unknown conversation item(s): #{ids.inspect}"
|
|
124
|
+
end
|
|
125
|
+
validate_generated_conversation_group!(group)
|
|
83
126
|
end
|
|
84
127
|
|
|
85
|
-
|
|
86
|
-
|
|
128
|
+
missing_groups = input_groups.select do |group|
|
|
129
|
+
group.any?(&:required?) && group.none? { |item| selected_input_ids[item.id] }
|
|
130
|
+
end
|
|
131
|
+
return if missing_groups.empty?
|
|
132
|
+
|
|
133
|
+
raise Phronomy::ContextBudgetExceededError,
|
|
134
|
+
"ContextPlan omitted required conversation group(s): " \
|
|
135
|
+
"#{missing_groups.map { |group| group.map(&:id) }.inspect}"
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
def validate_semantic_kind!(item, semantic_category)
|
|
139
|
+
return unless item.respond_to?(:kind)
|
|
140
|
+
|
|
141
|
+
reserved_category = RESERVED_KIND_CATEGORIES[item.kind.to_sym]
|
|
142
|
+
return unless reserved_category && reserved_category != semantic_category
|
|
143
|
+
|
|
144
|
+
raise ArgumentError,
|
|
145
|
+
"ContextPlan #{semantic_category} item #{item.id.inspect} uses reserved " \
|
|
146
|
+
"#{item.kind.inspect} kind for #{reserved_category}"
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def validate_policy_generated_metadata!(item)
|
|
150
|
+
conflicts = item.metadata.keys.map(&:to_s) & ContextPolicyInput::FRAMEWORK_METADATA_KEYS
|
|
151
|
+
return if conflicts.empty?
|
|
152
|
+
|
|
153
|
+
raise ArgumentError,
|
|
154
|
+
"Policy-generated Context item #{item.id.inspect} metadata uses " \
|
|
155
|
+
"Framework-reserved key(s): #{conflicts.sort.inspect}"
|
|
156
|
+
end
|
|
87
157
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
158
|
+
def validate_generated_conversation_group!(group)
|
|
159
|
+
group.each do |item|
|
|
160
|
+
validate_policy_generated_metadata!(item)
|
|
161
|
+
validate_generated_conversation_item!(item)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
assistants = group.select { |item| item.kind == :assistant_message }
|
|
165
|
+
tools = group.select { |item| item.kind == :tool_message }
|
|
166
|
+
protocol_items = assistants + tools
|
|
167
|
+
if protocol_items.any? && protocol_items.length != group.length
|
|
168
|
+
raise ArgumentError,
|
|
169
|
+
"Policy-generated Tool exchange group may contain only assistant_message and tool_message items"
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
tool_by_id = tools.group_by(&:tool_call_id)
|
|
173
|
+
positions = group.each_with_index.to_h
|
|
174
|
+
|
|
175
|
+
tool_by_id.each do |tool_call_id, messages|
|
|
176
|
+
if tool_call_id.nil? || tool_call_id.empty? || messages.length != 1
|
|
177
|
+
raise ArgumentError,
|
|
178
|
+
"Policy-generated conversation group has invalid Tool message dependency"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
assistant_by_call = {}
|
|
183
|
+
assistants.each do |assistant|
|
|
184
|
+
assistant.tool_call_ids.each do |tool_call_id|
|
|
185
|
+
if assistant_by_call.key?(tool_call_id)
|
|
91
186
|
raise ArgumentError,
|
|
92
|
-
"
|
|
187
|
+
"Policy-generated conversation group has duplicate assistant Tool Call: #{tool_call_id}"
|
|
93
188
|
end
|
|
94
|
-
|
|
95
|
-
|
|
189
|
+
assistant_by_call[tool_call_id] = assistant
|
|
190
|
+
tool = tool_by_id[tool_call_id]&.first
|
|
191
|
+
unless tool
|
|
96
192
|
raise ArgumentError,
|
|
97
|
-
"
|
|
193
|
+
"Policy-generated assistant Tool Call has no Tool message: #{tool_call_id}"
|
|
194
|
+
end
|
|
195
|
+
unless positions.fetch(tool) > positions.fetch(assistant)
|
|
196
|
+
raise ArgumentError,
|
|
197
|
+
"Policy-generated Tool message must follow its assistant Tool Call: #{tool_call_id}"
|
|
98
198
|
end
|
|
99
199
|
end
|
|
100
200
|
end
|
|
101
201
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
202
|
+
tools.each do |tool|
|
|
203
|
+
unless assistant_by_call.key?(tool.tool_call_id)
|
|
204
|
+
raise ArgumentError,
|
|
205
|
+
"Policy-generated conversation group contains orphan Tool message: #{tool.tool_call_id}"
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
end
|
|
105
209
|
|
|
106
|
-
|
|
107
|
-
|
|
210
|
+
def validate_generated_conversation_item!(item)
|
|
211
|
+
case item.kind
|
|
212
|
+
when :assistant_message
|
|
213
|
+
payload = validate_generated_canonical_message!(item, expected_role: :assistant)
|
|
214
|
+
raw_calls = payload["tool_calls"] || payload[:tool_calls]
|
|
215
|
+
unless raw_calls.nil? || raw_calls.is_a?(Array)
|
|
216
|
+
raise ArgumentError,
|
|
217
|
+
"Policy-generated assistant_message tool_calls must be an Array"
|
|
218
|
+
end
|
|
219
|
+
payload_call_ids = Array(raw_calls).map do |call|
|
|
220
|
+
unless call.is_a?(Hash)
|
|
108
221
|
raise ArgumentError,
|
|
109
|
-
"
|
|
222
|
+
"Policy-generated assistant_message Tool Call must be a Hash"
|
|
110
223
|
end
|
|
224
|
+
id = call["id"] || call[:id]
|
|
225
|
+
name = call["name"] || call[:name]
|
|
226
|
+
arguments = if call.key?("arguments")
|
|
227
|
+
call["arguments"]
|
|
228
|
+
else
|
|
229
|
+
call.fetch(:arguments, {})
|
|
230
|
+
end
|
|
231
|
+
if id.to_s.empty? || name.to_s.empty? || !arguments.is_a?(Hash)
|
|
232
|
+
raise ArgumentError,
|
|
233
|
+
"Policy-generated assistant_message has malformed Tool Call"
|
|
234
|
+
end
|
|
235
|
+
id.to_s
|
|
236
|
+
end
|
|
237
|
+
unless payload_call_ids == item.tool_call_ids
|
|
238
|
+
raise ArgumentError,
|
|
239
|
+
"Policy-generated assistant_message Tool Call IDs do not match ConversationItem"
|
|
240
|
+
end
|
|
241
|
+
if item.tool_call_id
|
|
242
|
+
raise ArgumentError,
|
|
243
|
+
"Policy-generated assistant_message must not set tool_call_id"
|
|
244
|
+
end
|
|
245
|
+
when :tool_message
|
|
246
|
+
payload = validate_generated_canonical_message!(item, expected_role: :tool)
|
|
247
|
+
payload_tool_call_id = payload["tool_call_id"] || payload[:tool_call_id]
|
|
248
|
+
if item.tool_call_id.to_s.empty? || payload_tool_call_id.to_s != item.tool_call_id
|
|
249
|
+
raise ArgumentError,
|
|
250
|
+
"Policy-generated tool_message tool_call_id does not match canonical content"
|
|
251
|
+
end
|
|
252
|
+
unless item.tool_call_ids.empty?
|
|
253
|
+
raise ArgumentError,
|
|
254
|
+
"Policy-generated tool_message must not set tool_call_ids"
|
|
255
|
+
end
|
|
256
|
+
when :tool_result
|
|
257
|
+
raise ArgumentError,
|
|
258
|
+
"Policy-generated conversation must not contain raw tool_result items"
|
|
259
|
+
else
|
|
260
|
+
if item.tool_call_id || !item.tool_call_ids.empty?
|
|
261
|
+
raise ArgumentError,
|
|
262
|
+
"Policy-generated non-Tool conversation item must not declare Tool Call IDs"
|
|
111
263
|
end
|
|
112
264
|
end
|
|
113
265
|
end
|
|
114
266
|
|
|
115
|
-
def
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
267
|
+
def validate_generated_canonical_message!(item, expected_role:)
|
|
268
|
+
unless item.content_format == :json && item.content.is_a?(Hash)
|
|
269
|
+
raise ArgumentError,
|
|
270
|
+
"Policy-generated #{item.kind} must use canonical JSON message content"
|
|
271
|
+
end
|
|
120
272
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
raise ArgumentError,
|
|
126
|
-
"ContextPlan derived content must be DerivedContentSpec, got #{derived.class}"
|
|
127
|
-
end
|
|
128
|
-
unknown = derived.coverage_candidate_ids.reject { |id| known[id] }
|
|
129
|
-
raise ArgumentError, "Derived content covers unknown candidates: #{unknown.inspect}" unless unknown.empty?
|
|
273
|
+
payload_role = item.content["role"] || item.content[:role]
|
|
274
|
+
unless item.role == expected_role && payload_role.to_s == expected_role.to_s
|
|
275
|
+
raise ArgumentError,
|
|
276
|
+
"Policy-generated #{item.kind} role does not match canonical content"
|
|
130
277
|
end
|
|
278
|
+
|
|
279
|
+
item.content
|
|
280
|
+
end
|
|
281
|
+
|
|
282
|
+
def validate_unique_ids!(plan)
|
|
283
|
+
ids = plan.instruction.map(&:id) + plan.knowledge.map(&:id) + plan.tools.map(&:id) +
|
|
284
|
+
plan.conversation.flatten.map(&:id)
|
|
285
|
+
duplicates = ids.group_by(&:itself).select { |_id, values| values.length > 1 }.keys
|
|
286
|
+
return if duplicates.empty?
|
|
287
|
+
|
|
288
|
+
raise ArgumentError, "ContextPlan contains duplicate item IDs: #{duplicates.inspect}"
|
|
131
289
|
end
|
|
132
290
|
end
|
|
133
291
|
end
|
|
@@ -4,49 +4,138 @@ module Phronomy
|
|
|
4
4
|
module Agent
|
|
5
5
|
module ContextPolicies
|
|
6
6
|
class Default < ContextPolicy
|
|
7
|
-
|
|
8
|
-
id: "default-recent-v1",
|
|
9
|
-
version: 1,
|
|
10
|
-
config: {}
|
|
11
|
-
)
|
|
7
|
+
CONVERSATION_SHARE = 0.60
|
|
12
8
|
|
|
13
|
-
def
|
|
14
|
-
@
|
|
9
|
+
def self.instance
|
|
10
|
+
@instance ||= new.freeze
|
|
15
11
|
end
|
|
16
12
|
|
|
17
|
-
def
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
13
|
+
def call(input)
|
|
14
|
+
unless input.is_a?(ContextPolicyInput)
|
|
15
|
+
raise ArgumentError, "Default ContextPolicy expected ContextPolicyInput"
|
|
16
|
+
end
|
|
21
17
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
)
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
18
|
+
return all_context(input) unless input.token_budget
|
|
19
|
+
|
|
20
|
+
limit = input.token_budget.effective_input_limit
|
|
21
|
+
required_knowledge = input.knowledge.select(&:required?)
|
|
22
|
+
optional_knowledge = input.knowledge.reject(&:required?)
|
|
23
|
+
required_conversation = input.conversation.select { |group| group.any?(&:required?) }
|
|
24
|
+
optional_conversation = input.conversation.reject { |group| group.any?(&:required?) }
|
|
25
|
+
|
|
26
|
+
fixed_cost = item_cost(input.instruction) + item_cost(input.tools) +
|
|
27
|
+
item_cost(required_knowledge) + group_cost(required_conversation)
|
|
28
|
+
if fixed_cost > limit
|
|
29
|
+
raise Phronomy::ContextBudgetExceededError,
|
|
30
|
+
"Required Context (estimated #{fixed_cost} tokens) exceeds " \
|
|
31
|
+
"available input budget (#{limit} tokens)"
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
remaining = limit - fixed_cost
|
|
35
|
+
conversation_budget = (remaining * CONVERSATION_SHARE).floor
|
|
36
|
+
knowledge_budget = remaining - conversation_budget
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
38
|
+
selected_conversation, older_conversation, conversation_used =
|
|
39
|
+
select_recent_suffix(optional_conversation, conversation_budget)
|
|
40
|
+
selected_knowledge, remaining_knowledge, knowledge_used =
|
|
41
|
+
select_stable_fit(optional_knowledge, knowledge_budget)
|
|
42
|
+
|
|
43
|
+
reusable = remaining - conversation_used - knowledge_used
|
|
44
|
+
if reusable.positive? && !older_conversation.empty?
|
|
45
|
+
more_conversation, _, used =
|
|
46
|
+
select_recent_suffix(older_conversation, reusable)
|
|
47
|
+
selected_conversation = more_conversation + selected_conversation
|
|
48
|
+
reusable -= used
|
|
49
|
+
end
|
|
50
|
+
if reusable.positive? && !remaining_knowledge.empty?
|
|
51
|
+
more_knowledge, _remaining_knowledge, used =
|
|
52
|
+
select_stable_fit(remaining_knowledge, reusable)
|
|
53
|
+
selected_knowledge += more_knowledge
|
|
54
|
+
reusable -= used
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
conversation = (required_conversation + selected_conversation)
|
|
58
|
+
.uniq
|
|
59
|
+
.sort_by { |group| group_sequence(group) }
|
|
60
|
+
knowledge_ids = (required_knowledge + selected_knowledge).to_h { |item| [item.id, true] }
|
|
61
|
+
knowledge = input.knowledge.select { |item| knowledge_ids[item.id] }
|
|
62
|
+
|
|
63
|
+
plan(
|
|
64
|
+
instruction: input.instruction,
|
|
65
|
+
knowledge: knowledge,
|
|
66
|
+
tools: input.tools,
|
|
67
|
+
conversation: conversation,
|
|
43
68
|
metadata: {
|
|
44
|
-
"
|
|
45
|
-
"
|
|
46
|
-
"
|
|
69
|
+
"default_policy" => true,
|
|
70
|
+
"estimated_fixed_tokens" => fixed_cost,
|
|
71
|
+
"estimated_unused_tokens" => reusable
|
|
47
72
|
}
|
|
48
73
|
)
|
|
49
74
|
end
|
|
75
|
+
|
|
76
|
+
private
|
|
77
|
+
|
|
78
|
+
def all_context(input)
|
|
79
|
+
plan(
|
|
80
|
+
instruction: input.instruction,
|
|
81
|
+
knowledge: input.knowledge,
|
|
82
|
+
tools: input.tools,
|
|
83
|
+
conversation: input.conversation,
|
|
84
|
+
metadata: {"default_policy" => true}
|
|
85
|
+
)
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def item_cost(items)
|
|
89
|
+
Array(items).sum { |item| Integer(item.estimated_tokens || 0) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def group_cost(groups)
|
|
93
|
+
Array(groups).sum { |group| item_cost(group) }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def group_sequence(group)
|
|
97
|
+
group.filter_map(&:sequence).min || 0
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def select_recent_suffix(groups, budget)
|
|
101
|
+
ordered = Array(groups).sort_by { |group| group_sequence(group) }
|
|
102
|
+
selected = []
|
|
103
|
+
remaining = Integer(budget)
|
|
104
|
+
stop_index = -1
|
|
105
|
+
|
|
106
|
+
(ordered.length - 1).downto(0) do |index|
|
|
107
|
+
cost = item_cost(ordered[index])
|
|
108
|
+
if cost > remaining
|
|
109
|
+
stop_index = index
|
|
110
|
+
break
|
|
111
|
+
end
|
|
112
|
+
selected.unshift(ordered[index])
|
|
113
|
+
remaining -= cost
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
older = if stop_index >= 0
|
|
117
|
+
ordered[0..stop_index]
|
|
118
|
+
else
|
|
119
|
+
[]
|
|
120
|
+
end
|
|
121
|
+
[selected.freeze, older.freeze, Integer(budget) - remaining]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def select_stable_fit(items, budget)
|
|
125
|
+
remaining = Integer(budget)
|
|
126
|
+
selected = []
|
|
127
|
+
unselected = []
|
|
128
|
+
Array(items).each do |item|
|
|
129
|
+
cost = Integer(item.estimated_tokens || 0)
|
|
130
|
+
if cost <= remaining
|
|
131
|
+
selected << item
|
|
132
|
+
remaining -= cost
|
|
133
|
+
else
|
|
134
|
+
unselected << item
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
[selected.freeze, unselected.freeze, Integer(budget) - remaining]
|
|
138
|
+
end
|
|
50
139
|
end
|
|
51
140
|
end
|
|
52
141
|
end
|