phronomy 0.15.1 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +105 -28
  3. data/README.md +300 -75
  4. data/benchmark/bench_agent_invoke.rb +3 -0
  5. data/benchmark/bench_regression.rb +2 -18
  6. data/benchmark/bench_tool_schema.rb +1 -0
  7. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +40 -1
  8. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
  9. data/lib/phronomy/agent/activation_registry.rb +28 -0
  10. data/lib/phronomy/agent/agent_execution.rb +97 -0
  11. data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
  12. data/lib/phronomy/agent/agent_invocation.rb +42 -10
  13. data/lib/phronomy/agent/agent_invocation_session_builder.rb +50 -11
  14. data/lib/phronomy/agent/agent_root.rb +67 -0
  15. data/lib/phronomy/agent/async_event_api.rb +55 -475
  16. data/lib/phronomy/agent/base.rb +301 -285
  17. data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
  18. data/lib/phronomy/agent/context_assembler.rb +321 -0
  19. data/lib/phronomy/agent/context_candidate.rb +47 -0
  20. data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
  21. data/lib/phronomy/agent/context_importer.rb +217 -0
  22. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
  23. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
  24. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
  25. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +188 -0
  26. data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
  27. data/lib/phronomy/agent/context_plan.rb +25 -0
  28. data/lib/phronomy/agent/context_plan_validator.rb +167 -0
  29. data/lib/phronomy/agent/context_policies/default.rb +53 -0
  30. data/lib/phronomy/agent/context_policy.rb +15 -0
  31. data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
  32. data/lib/phronomy/agent/context_policy_registry.rb +46 -0
  33. data/lib/phronomy/agent/context_request.rb +35 -0
  34. data/lib/phronomy/agent/context_selection_unit.rb +38 -0
  35. data/lib/phronomy/agent/derived_content_spec.rb +34 -0
  36. data/lib/phronomy/agent/execution_coordinator.rb +1123 -0
  37. data/lib/phronomy/agent/fsm_runtime_adapter.rb +210 -0
  38. data/lib/phronomy/agent/immutable.rb +31 -0
  39. data/lib/phronomy/agent/journal_projection.rb +34 -0
  40. data/lib/phronomy/agent/journal_record.rb +67 -0
  41. data/lib/phronomy/agent/llm_call_record.rb +51 -0
  42. data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
  43. data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
  44. data/lib/phronomy/agent/llm_input_patch.rb +21 -0
  45. data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
  46. data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
  47. data/lib/phronomy/agent/ruby_llm_materializer.rb +298 -0
  48. data/lib/phronomy/agent/token_budget_resolver.rb +69 -0
  49. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
  50. data/lib/phronomy/agent/tool_definition_set.rb +55 -0
  51. data/lib/phronomy/agent.rb +14 -16
  52. data/lib/phronomy/agent_busy_error.rb +5 -0
  53. data/lib/phronomy/canonical_json.rb +136 -0
  54. data/lib/phronomy/configuration.rb +9 -4
  55. data/lib/phronomy/content_store/base.rb +51 -0
  56. data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
  57. data/lib/phronomy/engine/event_loop.rb +3 -0
  58. data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
  59. data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
  60. data/lib/phronomy/llm_context_window/assembler.rb +8 -8
  61. data/lib/phronomy/multi_agent/orchestrator.rb +1 -0
  62. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
  63. data/lib/phronomy/multi_agent/team_coordinator.rb +6 -2
  64. data/lib/phronomy/persistence/in_memory.rb +247 -0
  65. data/lib/phronomy/persistence.rb +39 -0
  66. data/lib/phronomy/tools/agent.rb +14 -36
  67. data/lib/phronomy/version.rb +1 -1
  68. data/lib/phronomy.rb +11 -0
  69. data/scripts/add_to_h_to_token_doubles.rb +33 -0
  70. data/scripts/add_to_h_unnamed_doubles.rb +27 -0
  71. data/scripts/migrate_spec_agent_definition.rb +108 -0
  72. data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
  73. data/scripts/migrate_spec_inline_pass3.rb +24 -0
  74. metadata +54 -7
  75. data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
  76. data/lib/phronomy/agent/before_completion_context.rb +0 -47
  77. data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
@@ -0,0 +1,167 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ class ContextPlanValidator
6
+ ValidatedContextPlan = Data.define(
7
+ :plan, :units, :selected_units, :selected_candidates
8
+ ) do
9
+ def initialize(**values)
10
+ super(**values.merge(
11
+ units: Array(values[:units]).freeze,
12
+ selected_units: Array(values[:selected_units]).freeze,
13
+ selected_candidates: Array(values[:selected_candidates]).freeze
14
+ ))
15
+ freeze
16
+ end
17
+ end
18
+
19
+ def validate!(request:, plan:)
20
+ unless plan.is_a?(ContextPlan)
21
+ raise ArgumentError, "Context Policy returned #{plan.class}, expected #{ContextPlan}"
22
+ end
23
+
24
+ units = request.parts.fetch(:unit_builder).build(request.candidates)
25
+ units = request.parts.fetch(:required_context_resolver).resolve(
26
+ request: request,
27
+ units: units
28
+ )
29
+ unit_index = units.to_h { |unit| [unit.unit_id, unit] }
30
+ unknown = plan.selected_unit_ids.reject { |unit_id| unit_index.key?(unit_id) }
31
+ raise ArgumentError, "ContextPlan contains unknown units: #{unknown.inspect}" unless unknown.empty?
32
+
33
+ selected_units = plan.selected_unit_ids.map { |unit_id| unit_index.fetch(unit_id) }
34
+ required = units.reject { |unit| unit.requirement == :optional }.map(&:unit_id)
35
+ missing_required = required - plan.selected_unit_ids
36
+ unless missing_required.empty?
37
+ raise Phronomy::ContextBudgetExceededError,
38
+ "ContextPlan omitted required units: #{missing_required.inspect}"
39
+ end
40
+
41
+ candidate_index = request.candidates.to_h { |candidate| [candidate.candidate_id, candidate] }
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
53
+ )
54
+ end
55
+
56
+ private
57
+
58
+ def validate_tool_dependencies!(all_candidates, selected_candidates)
59
+ selected_ids = selected_candidates.to_h { |candidate| [candidate.candidate_id, true] }
60
+ validate_canonical_tool_dependencies!(all_candidates, selected_ids)
61
+ validate_legacy_tool_dependencies!(all_candidates, selected_candidates, selected_ids)
62
+ end
63
+
64
+ def validate_canonical_tool_dependencies!(all_candidates, selected_ids)
65
+ assistants = Array(all_candidates).select { |candidate| candidate.category == :assistant_message }
66
+ tool_messages = Array(all_candidates).select { |candidate| candidate.category == :tool_message }
67
+ .group_by(&:tool_call_id)
68
+ assistant_by_tool_call_id = {}
69
+
70
+ tool_messages.each do |tool_call_id, messages|
71
+ if messages.length > 1
72
+ raise ArgumentError, "duplicate Tool message in Context candidates: #{tool_call_id}"
73
+ end
74
+ end
75
+
76
+ assistants.each do |assistant|
77
+ canonical_tool_call_ids(assistant).each do |tool_call_id|
78
+ if assistant_by_tool_call_id.key?(tool_call_id)
79
+ raise ArgumentError,
80
+ "duplicate assistant Tool Call id in Context candidates: #{tool_call_id}"
81
+ end
82
+ assistant_by_tool_call_id[tool_call_id] = assistant
83
+ end
84
+ end
85
+
86
+ assistants.each do |assistant|
87
+ next unless selected_ids[assistant.candidate_id]
88
+
89
+ canonical_tool_call_ids(assistant).each do |tool_call_id|
90
+ messages = tool_messages.fetch(tool_call_id, [])
91
+ if messages.empty?
92
+ raise ArgumentError,
93
+ "ContextPlan selected assistant Tool Call without a Tool message: #{tool_call_id}"
94
+ end
95
+ missing = messages.reject { |message| selected_ids[message.candidate_id] }
96
+ unless missing.empty?
97
+ raise ArgumentError,
98
+ "ContextPlan split assistant/Tool message dependency: #{tool_call_id}"
99
+ end
100
+ end
101
+ end
102
+
103
+ tool_messages.each do |tool_call_id, messages|
104
+ messages.each do |message|
105
+ next unless selected_ids[message.candidate_id]
106
+
107
+ assistant = assistant_by_tool_call_id[tool_call_id]
108
+ unless assistant && selected_ids[assistant.candidate_id]
109
+ raise ArgumentError,
110
+ "ContextPlan selected orphan Tool message: #{tool_call_id}"
111
+ end
112
+ end
113
+ end
114
+ end
115
+
116
+ # Compatibility for Journal state written by the preceding stateful
117
+ # refactor, where assistant content, Tool Calls and Tool Results were
118
+ # flattened into separate Context candidates.
119
+ def validate_legacy_tool_dependencies!(all_candidates, selected_candidates, selected_ids)
120
+ all_calls = Array(all_candidates).select { |candidate| candidate.category == :tool_call }
121
+ .to_h { |candidate| [candidate.tool_call_id, candidate] }
122
+ all_results = Array(all_candidates).select { |candidate| candidate.category == :tool_result }
123
+ .group_by(&:tool_call_id)
124
+
125
+ selected_candidates.each do |candidate|
126
+ case candidate.category
127
+ when :tool_result
128
+ call = all_calls[candidate.tool_call_id]
129
+ unless call && selected_ids[call.candidate_id]
130
+ raise ArgumentError,
131
+ "ContextPlan selected orphan Tool Result: #{candidate.tool_call_id}"
132
+ end
133
+ when :tool_call
134
+ results = all_results.fetch(candidate.tool_call_id, [])
135
+ if results.empty?
136
+ raise ArgumentError,
137
+ "ContextPlan selected Tool Call without a Tool Result: #{candidate.tool_call_id}"
138
+ end
139
+ missing = results.reject { |result| selected_ids[result.candidate_id] }
140
+ unless missing.empty?
141
+ raise ArgumentError,
142
+ "ContextPlan split Tool Call/Result dependency: #{candidate.tool_call_id}"
143
+ end
144
+ end
145
+ end
146
+ end
147
+
148
+ def canonical_tool_call_ids(candidate)
149
+ Array(candidate.metadata["tool_call_ids"] || candidate.metadata[:tool_call_ids])
150
+ .compact
151
+ .map(&:to_s)
152
+ end
153
+
154
+ def validate_derived_contents!(request, plan)
155
+ known = request.candidates.to_h { |candidate| [candidate.candidate_id, true] }
156
+ plan.derived_contents.each do |derived|
157
+ unless derived.is_a?(DerivedContentSpec)
158
+ raise ArgumentError,
159
+ "ContextPlan derived content must be DerivedContentSpec, got #{derived.class}"
160
+ end
161
+ unknown = derived.coverage_candidate_ids.reject { |id| known[id] }
162
+ raise ArgumentError, "Derived content covers unknown candidates: #{unknown.inspect}" unless unknown.empty?
163
+ end
164
+ end
165
+ end
166
+ end
167
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module ContextPolicies
6
+ class Default < ContextPolicy
7
+ DESCRIPTOR = ContextPolicyDescriptor.new(
8
+ id: "default-recent-v1",
9
+ version: 1,
10
+ config: {}
11
+ )
12
+
13
+ def initialize(config = {})
14
+ @config = Immutable.copy(config || {})
15
+ end
16
+
17
+ def descriptor
18
+ return DESCRIPTOR if @config.empty?
19
+ ContextPolicyDescriptor.new(id: DESCRIPTOR.id, version: DESCRIPTOR.version, config: @config)
20
+ end
21
+
22
+ def call(request)
23
+ units = request.parts.fetch(:unit_builder).build(request.candidates)
24
+ units = request.parts.fetch(:required_context_resolver).resolve(
25
+ request: request,
26
+ units: units
27
+ )
28
+ ordered = request.parts.fetch(:recent_first_selector).order(
29
+ request: request,
30
+ units: units
31
+ )
32
+ selected = request.parts.fetch(:token_budget_packer).pack(
33
+ request: request,
34
+ units: ordered
35
+ )
36
+
37
+ ContextPlan.new(
38
+ selected_unit_ids: selected.map(&:unit_id),
39
+ derived_contents: [],
40
+ selected_tool_ids: [],
41
+ ordering_hints: {"history" => "chronological"},
42
+ policy_descriptor: descriptor,
43
+ metadata: {
44
+ "candidate_count" => request.candidates.length,
45
+ "unit_count" => units.length,
46
+ "selected_unit_count" => selected.length
47
+ }
48
+ )
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ class ContextPolicy
6
+ def call(_request)
7
+ raise NotImplementedError, "#{self.class}#call is not implemented"
8
+ end
9
+
10
+ def descriptor
11
+ raise NotImplementedError, "#{self.class}#descriptor is not implemented"
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Phronomy
6
+ module Agent
7
+ ContextPolicyDescriptor = Data.define(:id, :version, :config) do
8
+ def initialize(id:, version:, config: {})
9
+ normalized_config = Immutable.copy(config || {})
10
+ Immutable.validate_canonical_json!(normalized_config, label: "Context Policy config")
11
+ super(
12
+ id: id.to_s.freeze,
13
+ version: Integer(version),
14
+ config: normalized_config
15
+ )
16
+ raise ArgumentError, "Context Policy id must not be empty" if id.empty?
17
+ raise ArgumentError, "Context Policy version must be positive" unless version.positive?
18
+ freeze
19
+ end
20
+
21
+ def self.from_h(hash)
22
+ descriptor = new(
23
+ id: hash.fetch("id") { hash.fetch(:id) },
24
+ version: hash.fetch("version") { hash.fetch(:version) },
25
+ config: hash["config"] || hash[:config] || {}
26
+ )
27
+ expected_digest = hash["config_digest"] || hash[:config_digest]
28
+ if expected_digest && expected_digest.to_s != descriptor.config_digest
29
+ raise Phronomy::ConfigurationError,
30
+ "Context Policy config digest mismatch for #{descriptor.id}@#{descriptor.version}"
31
+ end
32
+ descriptor
33
+ end
34
+
35
+ def config_digest
36
+ "sha256:#{Digest::SHA256.hexdigest(Phronomy::CanonicalJSON.dump(config))}"
37
+ end
38
+
39
+ def to_h
40
+ {
41
+ "id" => id,
42
+ "version" => version,
43
+ "config" => config,
44
+ "config_digest" => config_digest
45
+ }
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,46 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ class ContextPolicyRegistry
6
+ class << self
7
+ def default
8
+ @default ||= new.tap do |registry|
9
+ registry.register(id: "default-recent-v1", version: 1) do |config|
10
+ ContextPolicies::Default.new(config)
11
+ end
12
+ end
13
+ end
14
+ end
15
+
16
+ def initialize
17
+ @factories = {}
18
+ @mutex = Mutex.new
19
+ end
20
+
21
+ def register(id:, version:, &factory)
22
+ raise ArgumentError, "Context Policy factory is required" unless factory
23
+
24
+ key = [id.to_s, Integer(version)].freeze
25
+ @mutex.synchronize do
26
+ raise ArgumentError, "Context Policy already registered: #{key.join("@")}" if @factories.key?(key)
27
+ @factories[key] = factory
28
+ end
29
+ self
30
+ end
31
+
32
+ def resolve(descriptor)
33
+ key = [descriptor.id.to_s, Integer(descriptor.version)]
34
+ factory = @mutex.synchronize { @factories[key] }
35
+ raise Phronomy::ConfigurationError, "Unknown Context Policy: #{key.join("@")}" unless factory
36
+
37
+ policy = factory.call(descriptor.config)
38
+ unless policy.is_a?(ContextPolicy)
39
+ raise Phronomy::ConfigurationError,
40
+ "Context Policy factory returned #{policy.class}, expected #{ContextPolicy}"
41
+ end
42
+ policy
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ ContextRequest = Data.define(
6
+ :agent_id,
7
+ :execution_id,
8
+ :call_sequence,
9
+ :call_mode,
10
+ :candidates,
11
+ :token_budget,
12
+ :model_config,
13
+ :previous_manifest,
14
+ :required_coverage,
15
+ :parts,
16
+ :metadata
17
+ ) do
18
+ def initialize(**values)
19
+ super(**values.merge(
20
+ agent_id: values.fetch(:agent_id).to_s.freeze,
21
+ execution_id: values[:execution_id]&.to_s&.freeze,
22
+ call_sequence: Integer(values.fetch(:call_sequence)),
23
+ call_mode: values.fetch(:call_mode).to_sym,
24
+ candidates: Array(values[:candidates]).freeze,
25
+ model_config: Immutable.copy(values[:model_config] || {}),
26
+ required_coverage: Array(values[:required_coverage]).map(&:to_s).freeze,
27
+ parts: (values[:parts] || {}).dup.freeze,
28
+ metadata: Immutable.copy(values[:metadata] || {})
29
+ ))
30
+ raise ArgumentError, "ContextRequest call_sequence must be positive" unless call_sequence.positive?
31
+ freeze
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ ContextSelectionUnit = Data.define(
6
+ :unit_id,
7
+ :candidate_ids,
8
+ :dependency_unit_ids,
9
+ :kind,
10
+ :requirement,
11
+ :priority,
12
+ :sequence_range,
13
+ :metadata
14
+ ) do
15
+ def initialize(**values)
16
+ normalized = values.merge(
17
+ unit_id: values.fetch(:unit_id).to_s.freeze,
18
+ candidate_ids: Array(values[:candidate_ids]).map(&:to_s).freeze,
19
+ dependency_unit_ids: Array(values[:dependency_unit_ids]).map(&:to_s).freeze,
20
+ kind: values.fetch(:kind).to_sym,
21
+ requirement: (values[:requirement] || :optional).to_sym,
22
+ priority: Integer(values[:priority] || 0),
23
+ sequence_range: Array(values[:sequence_range]).map { |v| Integer(v) }.freeze,
24
+ metadata: Immutable.copy(values[:metadata] || {})
25
+ )
26
+ unless %i[protocol_required declared_required optional].include?(normalized[:requirement])
27
+ raise ArgumentError, "unknown ContextSelectionUnit requirement: #{normalized[:requirement].inspect}"
28
+ end
29
+ unless normalized[:sequence_range].length == 2
30
+ raise ArgumentError, "ContextSelectionUnit sequence_range must contain two integers"
31
+ end
32
+
33
+ super(**normalized)
34
+ freeze
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ DerivedContentSpec = Data.define(
6
+ :content,
7
+ :content_format,
8
+ :category,
9
+ :role,
10
+ :coverage_candidate_ids,
11
+ :source_record_ids,
12
+ :source_content_refs,
13
+ :transformer_id,
14
+ :transformer_version,
15
+ :metadata
16
+ ) do
17
+ def initialize(**values)
18
+ super(**values.merge(
19
+ content: Immutable.copy(values[:content]),
20
+ content_format: values.fetch(:content_format).to_sym,
21
+ category: values.fetch(:category).to_sym,
22
+ role: values[:role]&.to_sym,
23
+ coverage_candidate_ids: Array(values[:coverage_candidate_ids]).map(&:to_s).freeze,
24
+ source_record_ids: Array(values[:source_record_ids]).map(&:to_s).freeze,
25
+ source_content_refs: Array(values[:source_content_refs]).map(&:to_s).freeze,
26
+ transformer_id: values.fetch(:transformer_id).to_s.freeze,
27
+ transformer_version: Integer(values.fetch(:transformer_version)),
28
+ metadata: Immutable.copy(values[:metadata] || {})
29
+ ))
30
+ freeze
31
+ end
32
+ end
33
+ end
34
+ end