phronomy 0.22.0 → 0.24.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 +215 -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 +147 -0
- data/docs/architecture/persistence.md +186 -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 +89 -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 +114 -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/028-preparing-recovery-replay-contract.md +106 -0
- data/docs/decisions/029-semantic-completion-and-application-effect-boundary.md +220 -0
- data/docs/decisions/030-agent-handoff-domain-and-durable-responsibility.md +235 -0
- data/docs/decisions/031-durable-multi-agent-coordination.md +301 -0
- data/docs/decisions/README.md +176 -0
- data/docs/design/durable-semantic-coordination/CHANGELOG_V2_REVISION_2.md +33 -0
- data/docs/design/durable-semantic-coordination/CONTINUATION_DECISION_REFACTOR.md +191 -0
- data/docs/design/durable-semantic-coordination/IMPLEMENTATION_DESIGN_V2.md +862 -0
- data/docs/design/durable-semantic-coordination/IMPLEMENTATION_REPORT.md +106 -0
- data/docs/design/durable-semantic-coordination/RECOVERY_CONTRACT_CLARIFICATIONS.md +179 -0
- data/docs/design/durable-semantic-coordination/RESPONSIBILITY_BOUNDARY_REVIEW.md +302 -0
- data/docs/features.md +65 -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/migrations/durable-semantic-coordination-v2.md +65 -0
- data/docs/persistence-backends.md +129 -40
- data/docs/runtime-and-concurrency.md +227 -33
- data/examples/README.md +13 -0
- data/lib/phronomy/agent/agent_execution.rb +21 -17
- 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 +162 -72
- data/lib/phronomy/agent/base.rb +416 -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/exact_execution.rb +153 -0
- data/lib/phronomy/agent/execution_cancellation.rb +25 -0
- data/lib/phronomy/agent/execution_coordinator.rb +2417 -572
- data/lib/phronomy/agent/handoff.rb +43 -0
- data/lib/phronomy/agent/handoff_capability_factory.rb +45 -0
- data/lib/phronomy/agent/handoff_context.rb +120 -0
- data/lib/phronomy/agent/handoff_execution_coordinator.rb +143 -0
- data/lib/phronomy/agent/handoff_policy.rb +143 -0
- data/lib/phronomy/agent/handoff_projection.rb +207 -0
- data/lib/phronomy/agent/handoff_request.rb +45 -0
- data/lib/phronomy/agent/handoff_runner.rb +178 -0
- data/lib/phronomy/agent/handoff_state.rb +43 -0
- 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 +174 -0
- data/lib/phronomy/agent/recovery_coordinator/installation.rb +381 -0
- data/lib/phronomy/agent/recovery_coordinator/resolution.rb +511 -0
- data/lib/phronomy/agent/recovery_coordinator.rb +217 -0
- data/lib/phronomy/agent/recovery_support.rb +504 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +16 -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 +339 -103
- 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/team_ownership_registry.rb +77 -0
- data/lib/phronomy/engine/runtime.rb +92 -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/durable_subagent_coordinator.rb +134 -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/orchestrator.rb +71 -26
- data/lib/phronomy/multi_agent/team_coordinator.rb +473 -125
- data/lib/phronomy/multi_agent/team_execution.rb +44 -0
- data/lib/phronomy/multi_agent/team_root.rb +41 -0
- data/lib/phronomy/persistence/durable_codec.rb +706 -0
- data/lib/phronomy/persistence/durable_record.rb +117 -0
- data/lib/phronomy/persistence/in_memory.rb +471 -133
- data/lib/phronomy/persistence/migration/initial_format_migration.rb +226 -0
- data/lib/phronomy/persistence/repository_facades.rb +535 -0
- data/lib/phronomy/persistence.rb +174 -40
- 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 +2 -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/testing/persistence_contract/coordination_repositories.rb +137 -0
- data/lib/phronomy/testing/persistence_contract.rb +5 -0
- data/lib/phronomy/tools/agent.rb +1 -1
- 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/handoff.rbs +41 -0
- data/sig/phronomy/multi_agent.rbs +35 -0
- data/sig/phronomy/persistence.rbs +123 -4
- data/sig/phronomy/runtime.rbs +1 -4
- data/sig/phronomy/workflow.rbs +2 -2
- data/sig/phronomy.rbs +10 -0
- metadata +84 -18
- 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
- data/lib/phronomy/multi_agent/handoff.rb +0 -64
|
@@ -1,14 +1,120 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "securerandom"
|
|
4
|
+
|
|
3
5
|
module Phronomy
|
|
4
6
|
module Agent
|
|
5
7
|
class ContextPolicy
|
|
6
|
-
def call(
|
|
8
|
+
def call(_input)
|
|
7
9
|
raise NotImplementedError, "#{self.class}#call is not implemented"
|
|
8
10
|
end
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
+
protected
|
|
13
|
+
|
|
14
|
+
def instruction_item(
|
|
15
|
+
content:,
|
|
16
|
+
role: :system,
|
|
17
|
+
kind: :instruction,
|
|
18
|
+
content_format: nil,
|
|
19
|
+
required: false,
|
|
20
|
+
metadata: {}
|
|
21
|
+
)
|
|
22
|
+
ContextPolicyInput::InstructionItem.new(
|
|
23
|
+
id: generated_item_id(:instruction),
|
|
24
|
+
kind: kind,
|
|
25
|
+
role: role,
|
|
26
|
+
content: content,
|
|
27
|
+
content_format: content_format || inferred_content_format(content),
|
|
28
|
+
estimated_tokens: estimate_content(content),
|
|
29
|
+
required: required,
|
|
30
|
+
provenance: generated_provenance,
|
|
31
|
+
metadata: metadata
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def knowledge_item(
|
|
36
|
+
content:,
|
|
37
|
+
role: :user,
|
|
38
|
+
kind: :knowledge,
|
|
39
|
+
content_format: nil,
|
|
40
|
+
required: false,
|
|
41
|
+
metadata: {}
|
|
42
|
+
)
|
|
43
|
+
ContextPolicyInput::KnowledgeItem.new(
|
|
44
|
+
id: generated_item_id(:knowledge),
|
|
45
|
+
kind: kind,
|
|
46
|
+
role: role,
|
|
47
|
+
content: content,
|
|
48
|
+
content_format: content_format || inferred_content_format(content),
|
|
49
|
+
estimated_tokens: estimate_content(content),
|
|
50
|
+
required: required,
|
|
51
|
+
provenance: generated_provenance,
|
|
52
|
+
metadata: metadata
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def conversation_item(
|
|
57
|
+
content:,
|
|
58
|
+
role:,
|
|
59
|
+
kind: :conversation,
|
|
60
|
+
content_format: nil,
|
|
61
|
+
sequence: nil,
|
|
62
|
+
required: false,
|
|
63
|
+
tool_call_id: nil,
|
|
64
|
+
tool_call_ids: [],
|
|
65
|
+
delivery: :chat_message,
|
|
66
|
+
metadata: {}
|
|
67
|
+
)
|
|
68
|
+
ContextPolicyInput::ConversationItem.new(
|
|
69
|
+
id: generated_item_id(:conversation),
|
|
70
|
+
kind: kind,
|
|
71
|
+
role: role,
|
|
72
|
+
content: content,
|
|
73
|
+
content_format: content_format || inferred_content_format(content),
|
|
74
|
+
sequence: sequence,
|
|
75
|
+
estimated_tokens: estimate_content(content),
|
|
76
|
+
required: required,
|
|
77
|
+
provenance: generated_provenance,
|
|
78
|
+
tool_call_id: tool_call_id,
|
|
79
|
+
tool_call_ids: tool_call_ids,
|
|
80
|
+
delivery: delivery,
|
|
81
|
+
metadata: metadata
|
|
82
|
+
)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def plan(
|
|
86
|
+
instruction: [],
|
|
87
|
+
knowledge: [],
|
|
88
|
+
tools: [],
|
|
89
|
+
conversation: [],
|
|
90
|
+
metadata: {}
|
|
91
|
+
)
|
|
92
|
+
ContextPlan.new(
|
|
93
|
+
instruction: instruction,
|
|
94
|
+
knowledge: knowledge,
|
|
95
|
+
tools: tools,
|
|
96
|
+
conversation: conversation,
|
|
97
|
+
metadata: metadata
|
|
98
|
+
)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
private
|
|
102
|
+
|
|
103
|
+
def generated_item_id(category)
|
|
104
|
+
"policy:#{category}:#{SecureRandom.uuid}".freeze
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def generated_provenance
|
|
108
|
+
ContextPolicyInput::Provenance.new(origin: :policy_generated)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def inferred_content_format(content)
|
|
112
|
+
content.is_a?(String) ? :text : :json
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def estimate_content(content)
|
|
116
|
+
bytes = content.is_a?(String) ? content : Phronomy::CanonicalJSON.dump(content)
|
|
117
|
+
Phronomy::LlmContextWindow::TokenEstimator.estimate(bytes)
|
|
12
118
|
end
|
|
13
119
|
end
|
|
14
120
|
end
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
ContextPolicyInput = Data.define(
|
|
6
|
+
:agent_id,
|
|
7
|
+
:execution_id,
|
|
8
|
+
:call_sequence,
|
|
9
|
+
:call_mode,
|
|
10
|
+
:instruction,
|
|
11
|
+
:knowledge,
|
|
12
|
+
:tools,
|
|
13
|
+
:conversation,
|
|
14
|
+
:token_budget,
|
|
15
|
+
:model_config,
|
|
16
|
+
:previous_manifest,
|
|
17
|
+
:metadata
|
|
18
|
+
) do
|
|
19
|
+
def initialize(**values)
|
|
20
|
+
normalized_instruction = Array(values[:instruction]).freeze
|
|
21
|
+
normalized_knowledge = Array(values[:knowledge]).freeze
|
|
22
|
+
normalized_tools = Array(values[:tools]).freeze
|
|
23
|
+
normalized_conversation = Array(values[:conversation]).map do |group|
|
|
24
|
+
normalized_group = Array(group).freeze
|
|
25
|
+
raise ArgumentError, "ContextPolicyInput conversation groups must not be empty" if normalized_group.empty?
|
|
26
|
+
normalized_group
|
|
27
|
+
end.freeze
|
|
28
|
+
|
|
29
|
+
validate_items!(normalized_instruction, ContextPolicyInput::InstructionItem, :instruction)
|
|
30
|
+
validate_items!(normalized_knowledge, ContextPolicyInput::KnowledgeItem, :knowledge)
|
|
31
|
+
validate_items!(normalized_tools, ContextPolicyInput::ToolItem, :tools)
|
|
32
|
+
normalized_conversation.each do |group|
|
|
33
|
+
validate_items!(group, ContextPolicyInput::ConversationItem, :conversation)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
call_sequence = Integer(values.fetch(:call_sequence))
|
|
37
|
+
raise ArgumentError, "ContextPolicyInput call_sequence must be positive" unless call_sequence.positive?
|
|
38
|
+
|
|
39
|
+
call_mode = values.fetch(:call_mode).to_sym
|
|
40
|
+
unless LLMInputManifest::CALL_MODES.include?(call_mode)
|
|
41
|
+
raise ArgumentError, "unknown ContextPolicyInput call_mode: #{call_mode.inspect}"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
super(**values.merge(
|
|
45
|
+
agent_id: values.fetch(:agent_id).to_s.freeze,
|
|
46
|
+
execution_id: values[:execution_id]&.to_s&.freeze,
|
|
47
|
+
call_sequence: call_sequence,
|
|
48
|
+
call_mode: call_mode,
|
|
49
|
+
instruction: normalized_instruction,
|
|
50
|
+
knowledge: normalized_knowledge,
|
|
51
|
+
tools: normalized_tools,
|
|
52
|
+
conversation: normalized_conversation,
|
|
53
|
+
model_config: Immutable.copy(values[:model_config] || {}),
|
|
54
|
+
metadata: Immutable.copy(values[:metadata] || {})
|
|
55
|
+
))
|
|
56
|
+
freeze
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
private
|
|
60
|
+
|
|
61
|
+
def validate_items!(items, expected_class, label)
|
|
62
|
+
invalid = items.reject { |item| item.is_a?(expected_class) }
|
|
63
|
+
return if invalid.empty?
|
|
64
|
+
|
|
65
|
+
raise ArgumentError,
|
|
66
|
+
"ContextPolicyInput #{label} contains #{invalid.first.class}; expected #{expected_class}"
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
class ContextPolicyInput
|
|
71
|
+
CONTENT_FORMATS = %i[text json].freeze
|
|
72
|
+
DELIVERIES = %i[ask_argument chat_message].freeze
|
|
73
|
+
FRAMEWORK_METADATA_KEYS = %w[
|
|
74
|
+
phronomy_origin
|
|
75
|
+
context_policy_origin
|
|
76
|
+
context_policy_item_id
|
|
77
|
+
context_policy_semantic_category
|
|
78
|
+
context_policy_content_format
|
|
79
|
+
context_policy_conversation_group_id
|
|
80
|
+
handoff_policy_category
|
|
81
|
+
handoff_provenance
|
|
82
|
+
selection_candidate_id
|
|
83
|
+
selection_unit_id
|
|
84
|
+
selection_unit_kind
|
|
85
|
+
journal_record_id
|
|
86
|
+
source_agent_id
|
|
87
|
+
source_execution_id
|
|
88
|
+
llm_call_id
|
|
89
|
+
].freeze
|
|
90
|
+
|
|
91
|
+
Provenance = Data.define(
|
|
92
|
+
:origin,
|
|
93
|
+
:content_ref,
|
|
94
|
+
:record_id,
|
|
95
|
+
:agent_id,
|
|
96
|
+
:execution_id,
|
|
97
|
+
:llm_call_id
|
|
98
|
+
) do
|
|
99
|
+
def initialize(
|
|
100
|
+
origin:,
|
|
101
|
+
content_ref: nil,
|
|
102
|
+
record_id: nil,
|
|
103
|
+
agent_id: nil,
|
|
104
|
+
execution_id: nil,
|
|
105
|
+
llm_call_id: nil
|
|
106
|
+
)
|
|
107
|
+
super(
|
|
108
|
+
origin: origin.to_sym,
|
|
109
|
+
content_ref: content_ref&.to_s&.freeze,
|
|
110
|
+
record_id: record_id&.to_s&.freeze,
|
|
111
|
+
agent_id: agent_id&.to_s&.freeze,
|
|
112
|
+
execution_id: execution_id&.to_s&.freeze,
|
|
113
|
+
llm_call_id: llm_call_id&.to_s&.freeze
|
|
114
|
+
)
|
|
115
|
+
freeze
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
InstructionItem = Data.define(
|
|
120
|
+
:id, :kind, :role, :content, :content_format,
|
|
121
|
+
:estimated_tokens, :required, :provenance, :metadata
|
|
122
|
+
) do
|
|
123
|
+
def initialize(**values)
|
|
124
|
+
super(**ContextPolicyInput.send(:normalize_content_item_values, values))
|
|
125
|
+
freeze
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def required? = required
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
KnowledgeItem = Data.define(
|
|
132
|
+
:id, :kind, :role, :content, :content_format,
|
|
133
|
+
:estimated_tokens, :required, :provenance, :metadata
|
|
134
|
+
) do
|
|
135
|
+
def initialize(**values)
|
|
136
|
+
super(**ContextPolicyInput.send(:normalize_content_item_values, values))
|
|
137
|
+
freeze
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def required? = required
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
ToolItem = Data.define(
|
|
144
|
+
:id, :definition, :estimated_tokens, :required, :provenance, :metadata
|
|
145
|
+
) do
|
|
146
|
+
def initialize(**values)
|
|
147
|
+
provenance = ContextPolicyInput.send(:normalize_provenance, values[:provenance])
|
|
148
|
+
definition = Immutable.copy(values.fetch(:definition))
|
|
149
|
+
unless definition.is_a?(Hash) && definition["name"].to_s != ""
|
|
150
|
+
raise ArgumentError, "ContextPolicyInput ToolItem definition requires a non-empty name"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
super(
|
|
154
|
+
id: values.fetch(:id).to_s.freeze,
|
|
155
|
+
definition: definition,
|
|
156
|
+
estimated_tokens: ContextPolicyInput.send(:normalize_estimated_tokens, values[:estimated_tokens]),
|
|
157
|
+
required: !!values[:required],
|
|
158
|
+
provenance: provenance,
|
|
159
|
+
metadata: Immutable.copy(values[:metadata] || {})
|
|
160
|
+
)
|
|
161
|
+
raise ArgumentError, "ContextPolicyInput ToolItem id must not be empty" if id.empty?
|
|
162
|
+
freeze
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def required? = required
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
ConversationItem = Data.define(
|
|
169
|
+
:id, :kind, :role, :content, :content_format,
|
|
170
|
+
:sequence, :estimated_tokens, :required, :provenance,
|
|
171
|
+
:tool_call_id, :tool_call_ids, :delivery, :metadata
|
|
172
|
+
) do
|
|
173
|
+
def initialize(**values)
|
|
174
|
+
normalized = ContextPolicyInput.send(:normalize_content_item_values, values)
|
|
175
|
+
delivery = (values[:delivery] || :chat_message).to_sym
|
|
176
|
+
unless ContextPolicyInput::DELIVERIES.include?(delivery)
|
|
177
|
+
raise ArgumentError, "unknown ContextPolicyInput delivery: #{delivery.inspect}"
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
super(**normalized.merge(
|
|
181
|
+
sequence: values[:sequence] && Integer(values[:sequence]),
|
|
182
|
+
tool_call_id: values[:tool_call_id]&.to_s&.freeze,
|
|
183
|
+
tool_call_ids: Array(values[:tool_call_ids]).compact.map(&:to_s).freeze,
|
|
184
|
+
delivery: delivery
|
|
185
|
+
))
|
|
186
|
+
freeze
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def required? = required
|
|
190
|
+
|
|
191
|
+
def with_required(required)
|
|
192
|
+
return self if required? == !!required
|
|
193
|
+
self.class.new(**to_h.merge(required: !!required))
|
|
194
|
+
end
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
class << self
|
|
198
|
+
private
|
|
199
|
+
|
|
200
|
+
def normalize_content_item_values(values)
|
|
201
|
+
id = values.fetch(:id).to_s.freeze
|
|
202
|
+
raise ArgumentError, "ContextPolicyInput item id must not be empty" if id.empty?
|
|
203
|
+
|
|
204
|
+
kind = values.fetch(:kind).to_sym
|
|
205
|
+
role = values[:role]&.to_sym
|
|
206
|
+
content_format = (values[:content_format] || infer_content_format(values[:content])).to_sym
|
|
207
|
+
unless ContextPolicyInput::CONTENT_FORMATS.include?(content_format)
|
|
208
|
+
raise ArgumentError, "unknown ContextPolicyInput content format: #{content_format.inspect}"
|
|
209
|
+
end
|
|
210
|
+
|
|
211
|
+
{
|
|
212
|
+
id: id,
|
|
213
|
+
kind: kind,
|
|
214
|
+
role: role,
|
|
215
|
+
content: Immutable.copy(values[:content]),
|
|
216
|
+
content_format: content_format,
|
|
217
|
+
estimated_tokens: normalize_estimated_tokens(values[:estimated_tokens]),
|
|
218
|
+
required: !!values[:required],
|
|
219
|
+
provenance: normalize_provenance(values[:provenance]),
|
|
220
|
+
metadata: Immutable.copy(values[:metadata] || {})
|
|
221
|
+
}
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
def infer_content_format(content)
|
|
225
|
+
content.is_a?(String) ? :text : :json
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def normalize_estimated_tokens(value)
|
|
229
|
+
result = Integer(value || 0)
|
|
230
|
+
raise ArgumentError, "estimated_tokens must be non-negative" if result.negative?
|
|
231
|
+
result
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def normalize_provenance(value)
|
|
235
|
+
return value if value.is_a?(ContextPolicyInput::Provenance)
|
|
236
|
+
return ContextPolicyInput::Provenance.new(origin: :unknown) if value.nil?
|
|
237
|
+
return ContextPolicyInput::Provenance.new(**value) if value.is_a?(Hash)
|
|
238
|
+
|
|
239
|
+
raise ArgumentError, "ContextPolicyInput item provenance must be Provenance or Hash"
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
end
|
|
244
|
+
end
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Agent
|
|
5
|
+
class ContextPolicyInputBuilder
|
|
6
|
+
KNOWLEDGE_KINDS = %i[knowledge memory summary structured_state].freeze
|
|
7
|
+
CONVERSATION_KINDS = %i[external_message assistant_message tool_message current_input current_request].freeze
|
|
8
|
+
|
|
9
|
+
def initialize(content_loader:)
|
|
10
|
+
@content_loader = content_loader
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def build(
|
|
14
|
+
agent_id:,
|
|
15
|
+
execution_id:,
|
|
16
|
+
call_sequence:,
|
|
17
|
+
call_mode:,
|
|
18
|
+
candidates:,
|
|
19
|
+
instruction:,
|
|
20
|
+
tools:,
|
|
21
|
+
token_budget:, model_config:, previous_manifest:, current_input: nil,
|
|
22
|
+
metadata: {}
|
|
23
|
+
)
|
|
24
|
+
instruction_items = Array(instruction).dup
|
|
25
|
+
knowledge = []
|
|
26
|
+
conversation = []
|
|
27
|
+
|
|
28
|
+
Array(candidates).each do |candidate|
|
|
29
|
+
case semantic_category(candidate)
|
|
30
|
+
when :instruction
|
|
31
|
+
instruction_items << instruction_item(candidate)
|
|
32
|
+
when :knowledge
|
|
33
|
+
knowledge << knowledge_item(candidate)
|
|
34
|
+
when :conversation
|
|
35
|
+
conversation << conversation_item(candidate)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
conversation << current_input if current_input
|
|
39
|
+
|
|
40
|
+
groups = conversation_groups(
|
|
41
|
+
conversation,
|
|
42
|
+
execution_id: execution_id,
|
|
43
|
+
call_mode: call_mode
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
ContextPolicyInput.new(
|
|
47
|
+
agent_id: agent_id,
|
|
48
|
+
execution_id: execution_id,
|
|
49
|
+
call_sequence: call_sequence,
|
|
50
|
+
call_mode: call_mode,
|
|
51
|
+
instruction: instruction_items,
|
|
52
|
+
knowledge: knowledge,
|
|
53
|
+
tools: tools,
|
|
54
|
+
conversation: groups,
|
|
55
|
+
token_budget: token_budget,
|
|
56
|
+
model_config: model_config,
|
|
57
|
+
previous_manifest: previous_manifest,
|
|
58
|
+
metadata: metadata
|
|
59
|
+
)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
private
|
|
63
|
+
|
|
64
|
+
def semantic_category(candidate)
|
|
65
|
+
explicit = candidate.metadata["context_policy_semantic_category"] ||
|
|
66
|
+
candidate.metadata[:context_policy_semantic_category]
|
|
67
|
+
if explicit
|
|
68
|
+
normalized = explicit.to_sym
|
|
69
|
+
return normalized if %i[instruction knowledge conversation].include?(normalized)
|
|
70
|
+
|
|
71
|
+
raise ArgumentError,
|
|
72
|
+
"unsupported Context Policy semantic category metadata: #{explicit.inspect}"
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
kind = candidate.category
|
|
76
|
+
normalized = kind.to_sym
|
|
77
|
+
return :instruction if normalized == :instruction || normalized == :handoff_responsibility
|
|
78
|
+
return :knowledge if KNOWLEDGE_KINDS.include?(normalized)
|
|
79
|
+
return :conversation if CONVERSATION_KINDS.include?(normalized)
|
|
80
|
+
|
|
81
|
+
raise ArgumentError, "unsupported Context Policy candidate category: #{kind.inspect}"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def instruction_item(candidate)
|
|
85
|
+
ContextPolicyInput::InstructionItem.new(
|
|
86
|
+
**common_content_values(candidate),
|
|
87
|
+
role: candidate.role || :system
|
|
88
|
+
)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def knowledge_item(candidate)
|
|
92
|
+
ContextPolicyInput::KnowledgeItem.new(
|
|
93
|
+
**common_content_values(candidate),
|
|
94
|
+
role: candidate.role || :user
|
|
95
|
+
)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def conversation_item(candidate)
|
|
99
|
+
values = common_content_values(candidate)
|
|
100
|
+
ContextPolicyInput::ConversationItem.new(
|
|
101
|
+
**values,
|
|
102
|
+
role: candidate.role,
|
|
103
|
+
sequence: candidate.sequence,
|
|
104
|
+
tool_call_id: candidate.tool_call_id,
|
|
105
|
+
tool_call_ids: canonical_tool_call_ids(candidate),
|
|
106
|
+
delivery: :chat_message
|
|
107
|
+
)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def common_content_values(candidate)
|
|
111
|
+
format = content_format(candidate)
|
|
112
|
+
{
|
|
113
|
+
id: candidate.candidate_id,
|
|
114
|
+
kind: candidate.category,
|
|
115
|
+
content: load_content(candidate.content_ref, format),
|
|
116
|
+
content_format: format,
|
|
117
|
+
estimated_tokens: Integer(candidate.metadata["estimated_tokens"] || 0),
|
|
118
|
+
required: candidate.constraint.required?,
|
|
119
|
+
provenance: ContextPolicyInput::Provenance.new(
|
|
120
|
+
origin: candidate.source_kind,
|
|
121
|
+
content_ref: candidate.content_ref,
|
|
122
|
+
record_id: candidate.record_id,
|
|
123
|
+
agent_id: candidate.agent_id,
|
|
124
|
+
execution_id: candidate.execution_id,
|
|
125
|
+
llm_call_id: candidate.llm_call_id
|
|
126
|
+
),
|
|
127
|
+
metadata: candidate.metadata
|
|
128
|
+
}
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def content_format(candidate)
|
|
132
|
+
explicit = candidate.metadata["content_format"] || candidate.metadata[:content_format]
|
|
133
|
+
return explicit.to_sym if explicit
|
|
134
|
+
return :json if %i[assistant_message tool_message].include?(candidate.category.to_sym)
|
|
135
|
+
|
|
136
|
+
:text
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def load_content(content_ref, format)
|
|
140
|
+
bytes = @content_loader.call(content_ref)
|
|
141
|
+
return bytes if format == :text
|
|
142
|
+
|
|
143
|
+
Phronomy::CanonicalJSON.load(bytes)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def conversation_groups(items, execution_id:, call_mode:)
|
|
147
|
+
ordered = Array(items).sort_by { |item| [item.sequence || 0, item.id] }
|
|
148
|
+
assistants = ordered.select { |item| item.kind == :assistant_message }
|
|
149
|
+
tool_messages = ordered.select { |item| item.kind == :tool_message }
|
|
150
|
+
tool_messages_by_id = tool_messages.group_by(&:tool_call_id)
|
|
151
|
+
tool_messages_by_id.each do |tool_call_id, messages|
|
|
152
|
+
if tool_call_id.nil? || tool_call_id.empty?
|
|
153
|
+
raise ArgumentError, "Tool message Context item requires tool_call_id"
|
|
154
|
+
end
|
|
155
|
+
if messages.length > 1
|
|
156
|
+
raise ArgumentError, "duplicate Tool message in Context Policy input: #{tool_call_id}"
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
assistant_by_tool_call = {}
|
|
161
|
+
assistants.each do |assistant|
|
|
162
|
+
assistant.tool_call_ids.each do |tool_call_id|
|
|
163
|
+
if assistant_by_tool_call.key?(tool_call_id)
|
|
164
|
+
raise ArgumentError,
|
|
165
|
+
"duplicate assistant Tool Call id in Context Policy input: #{tool_call_id}"
|
|
166
|
+
end
|
|
167
|
+
assistant_by_tool_call[tool_call_id] = assistant
|
|
168
|
+
end
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
claimed = {}
|
|
172
|
+
groups = []
|
|
173
|
+
ordered.each do |item|
|
|
174
|
+
next if claimed[item.id]
|
|
175
|
+
|
|
176
|
+
if item.kind == :assistant_message && !item.tool_call_ids.empty?
|
|
177
|
+
messages = item.tool_call_ids.map do |tool_call_id|
|
|
178
|
+
tool_messages_by_id.fetch(tool_call_id) do
|
|
179
|
+
raise ArgumentError,
|
|
180
|
+
"assistant Tool Call has no Tool message in Context Policy input: #{tool_call_id}"
|
|
181
|
+
end.first
|
|
182
|
+
end
|
|
183
|
+
group = ([item] + messages).uniq.sort_by { |member| [member.sequence || 0, member.id] }
|
|
184
|
+
elsif item.kind == :tool_message
|
|
185
|
+
assistant = assistant_by_tool_call[item.tool_call_id]
|
|
186
|
+
unless assistant
|
|
187
|
+
raise ArgumentError,
|
|
188
|
+
"orphan Tool message in Context Policy input: #{item.tool_call_id}"
|
|
189
|
+
end
|
|
190
|
+
next if claimed[assistant.id]
|
|
191
|
+
group = ([assistant] + assistant.tool_call_ids.map do |tool_call_id|
|
|
192
|
+
tool_messages_by_id.fetch(tool_call_id).first
|
|
193
|
+
end).uniq.sort_by { |member| [member.sequence || 0, member.id] }
|
|
194
|
+
else
|
|
195
|
+
group = [item]
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
group.each { |member| claimed[member.id] = true }
|
|
199
|
+
groups << group
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
mark_framework_required(groups, execution_id: execution_id, call_mode: call_mode)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def mark_framework_required(groups, execution_id:, call_mode:)
|
|
206
|
+
return groups.map(&:freeze).freeze unless call_mode.to_sym == :complete
|
|
207
|
+
|
|
208
|
+
current_request_ids = groups.flatten.select do |item|
|
|
209
|
+
item.kind == :external_message &&
|
|
210
|
+
item.provenance.origin == :working &&
|
|
211
|
+
item.provenance.execution_id.to_s == execution_id.to_s
|
|
212
|
+
end.map(&:id).to_h { |id| [id, true] }
|
|
213
|
+
|
|
214
|
+
current_tool_groups = groups.select do |group|
|
|
215
|
+
group.any? do |item|
|
|
216
|
+
%i[assistant_message tool_message].include?(item.kind) &&
|
|
217
|
+
item.provenance.origin == :working &&
|
|
218
|
+
item.provenance.execution_id.to_s == execution_id.to_s
|
|
219
|
+
end && group.any? { |item| item.kind == :tool_message }
|
|
220
|
+
end
|
|
221
|
+
latest_tool_group = current_tool_groups.max_by do |group|
|
|
222
|
+
group.filter_map(&:sequence).max || 0
|
|
223
|
+
end
|
|
224
|
+
latest_tool_ids = Array(latest_tool_group).map(&:id).to_h { |id| [id, true] }
|
|
225
|
+
|
|
226
|
+
groups.map do |group|
|
|
227
|
+
group.map do |item|
|
|
228
|
+
required = item.required? || current_request_ids[item.id] || latest_tool_ids[item.id]
|
|
229
|
+
item.with_required(required)
|
|
230
|
+
end.freeze
|
|
231
|
+
end.freeze
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def canonical_tool_call_ids(candidate)
|
|
235
|
+
Array(candidate.metadata["tool_call_ids"] || candidate.metadata[:tool_call_ids])
|
|
236
|
+
.compact
|
|
237
|
+
.map(&:to_s)
|
|
238
|
+
end
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
end
|