phronomy 0.15.1 → 0.17.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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +159 -28
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +400 -143
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +7 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +3 -19
  10. data/benchmark/bench_tool_schema.rb +2 -34
  11. data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
  12. data/docs/decisions/010-cooperative-first-concurrency.md +7 -0
  13. data/docs/decisions/011-build-context-as-single-llm-input-authority.md +40 -1
  14. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
  15. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  16. data/lib/phronomy/agent/activation_registry.rb +28 -0
  17. data/lib/phronomy/agent/agent_execution.rb +97 -0
  18. data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
  19. data/lib/phronomy/agent/agent_invocation.rb +44 -46
  20. data/lib/phronomy/agent/agent_invocation_session_builder.rb +206 -104
  21. data/lib/phronomy/agent/agent_root.rb +66 -0
  22. data/lib/phronomy/agent/async_event_api.rb +55 -475
  23. data/lib/phronomy/agent/base.rb +351 -514
  24. data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
  25. data/lib/phronomy/agent/context/capability/base.rb +166 -297
  26. data/lib/phronomy/agent/context_assembler.rb +357 -0
  27. data/lib/phronomy/agent/context_candidate.rb +47 -0
  28. data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
  29. data/lib/phronomy/agent/context_importer.rb +217 -0
  30. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
  31. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
  32. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
  33. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +118 -0
  34. data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
  35. data/lib/phronomy/agent/context_plan.rb +25 -0
  36. data/lib/phronomy/agent/context_plan_validator.rb +134 -0
  37. data/lib/phronomy/agent/context_policies/default.rb +53 -0
  38. data/lib/phronomy/agent/context_policy.rb +15 -0
  39. data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
  40. data/lib/phronomy/agent/context_policy_registry.rb +46 -0
  41. data/lib/phronomy/agent/context_request.rb +35 -0
  42. data/lib/phronomy/agent/context_selection_unit.rb +38 -0
  43. data/lib/phronomy/agent/derived_content_spec.rb +34 -0
  44. data/lib/phronomy/agent/execution_coordinator.rb +1122 -0
  45. data/lib/phronomy/agent/immutable.rb +31 -0
  46. data/lib/phronomy/agent/journal_projection.rb +60 -0
  47. data/lib/phronomy/agent/journal_record.rb +67 -0
  48. data/lib/phronomy/agent/llm_call_record.rb +51 -0
  49. data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
  50. data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
  51. data/lib/phronomy/agent/llm_input_patch.rb +21 -0
  52. data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
  53. data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
  54. data/lib/phronomy/agent/ruby_llm_materializer.rb +189 -0
  55. data/lib/phronomy/agent/shared_state.rb +46 -138
  56. data/lib/phronomy/agent/token_budget_resolver.rb +70 -0
  57. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
  58. data/lib/phronomy/agent/tool_definition_set.rb +55 -0
  59. data/lib/phronomy/agent/tool_invocation.rb +108 -314
  60. data/lib/phronomy/agent.rb +10 -16
  61. data/lib/phronomy/agent_busy_error.rb +5 -0
  62. data/lib/phronomy/canonical_json.rb +136 -0
  63. data/lib/phronomy/configuration.rb +17 -155
  64. data/lib/phronomy/content_store/base.rb +51 -0
  65. data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
  66. data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
  67. data/lib/phronomy/engine/event_loop.rb +3 -0
  68. data/lib/phronomy/engine/runtime.rb +15 -230
  69. data/lib/phronomy/engine/task_group.rb +30 -102
  70. data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
  71. data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
  72. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  73. data/lib/phronomy/multi_agent/orchestrator.rb +153 -204
  74. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
  75. data/lib/phronomy/multi_agent/team_coordinator.rb +46 -133
  76. data/lib/phronomy/persistence/in_memory.rb +247 -0
  77. data/lib/phronomy/persistence.rb +39 -0
  78. data/lib/phronomy/tools/agent.rb +14 -36
  79. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  80. data/lib/phronomy/version.rb +1 -1
  81. data/lib/phronomy.rb +9 -115
  82. data/scripts/add_to_h_to_token_doubles.rb +33 -0
  83. data/scripts/add_to_h_unnamed_doubles.rb +27 -0
  84. data/scripts/api_snapshot.rb +1 -12
  85. data/scripts/migrate_spec_agent_definition.rb +108 -0
  86. data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
  87. data/scripts/migrate_spec_inline_pass3.rb +24 -0
  88. metadata +54 -13
  89. data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
  90. data/lib/phronomy/agent/before_completion_context.rb +0 -47
  91. data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
  92. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  93. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  94. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  95. data/lib/phronomy/knowledge_source.rb +0 -12
  96. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  97. data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module Immutable
6
+ module_function
7
+
8
+ def copy(value)
9
+ case value
10
+ when Hash
11
+ value.each_with_object({}) do |(key, child), result|
12
+ result[copy(key)] = copy(child)
13
+ end.freeze
14
+ when Array
15
+ value.map { |child| copy(child) }.freeze
16
+ when String
17
+ value.dup.freeze
18
+ else
19
+ value
20
+ end
21
+ end
22
+
23
+ def validate_canonical_json!(value, label: "value")
24
+ Phronomy::CanonicalJSON.dump(value)
25
+ true
26
+ rescue ArgumentError => error
27
+ raise ArgumentError, "#{label} is not canonical JSON compatible: #{error.message}"
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ class JournalProjection
6
+ MESSAGE_KINDS = %i[
7
+ external_message
8
+ assistant_message
9
+ tool_message
10
+ ].freeze
11
+ KNOWLEDGE_RESET_KINDS = %i[knowledge_cleared context_reset].freeze
12
+
13
+ def initialize(persistence:, agent_root:)
14
+ @persistence = persistence
15
+ @agent_root = agent_root
16
+ end
17
+
18
+ def transcript_records
19
+ generation = @agent_root.transcript_generation
20
+ records.select do |record|
21
+ record.context_candidate &&
22
+ record.context_generation == generation &&
23
+ MESSAGE_KINDS.include?(record.kind)
24
+ end
25
+ end
26
+
27
+ # Returns all persistent records eligible for Context selection.
28
+ # Knowledge has an independent logical lifetime from the transcript:
29
+ # clear_transcript! does not remove Knowledge, while knowledge_cleared and
30
+ # context_reset invalidate earlier Knowledge records by Journal position.
31
+ def context_records
32
+ (transcript_records + active_knowledge_records)
33
+ .sort_by { |record| [record.sequence || 0, record.record_id] }
34
+ .freeze
35
+ end
36
+
37
+ def llm_call_records
38
+ records.select { |record| record.kind == :llm_call_recorded }
39
+ end
40
+
41
+ def records
42
+ @records ||= @persistence.journals.read(@agent_root.agent_id)
43
+ end
44
+
45
+ private
46
+
47
+ def active_knowledge_records
48
+ reset_sequence = records.filter_map do |record|
49
+ record.sequence if KNOWLEDGE_RESET_KINDS.include?(record.kind)
50
+ end.max.to_i
51
+
52
+ records.select do |record|
53
+ record.context_candidate &&
54
+ record.kind == :knowledge &&
55
+ record.sequence.to_i > reset_sequence
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Phronomy
7
+ module Agent
8
+ # One immutable entry in the Agent's append-only Canonical Complete Execution Log.
9
+ #
10
+ # Raw logical execution facts are never rewritten for Context selection or
11
+ # compaction. Derived Context artifacts must be appended separately and keep
12
+ # stable provenance back to their raw source records.
13
+ class JournalRecord
14
+ ATTRIBUTES = %i[
15
+ record_id agent_id sequence execution_id llm_call_id kind channel role content_ref
16
+ parent_id causation_id correlation_id visibility context_generation
17
+ context_candidate occurred_at metadata
18
+ ].freeze
19
+
20
+ attr_reader(*ATTRIBUTES)
21
+
22
+ def initialize(
23
+ agent_id:,
24
+ kind:,
25
+ channel:,
26
+ record_id: SecureRandom.uuid,
27
+ sequence: nil,
28
+ execution_id: nil,
29
+ llm_call_id: nil,
30
+ role: nil,
31
+ content_ref: nil,
32
+ parent_id: nil,
33
+ causation_id: nil,
34
+ correlation_id: nil,
35
+ visibility: :agent,
36
+ context_generation: 0,
37
+ context_candidate: false,
38
+ occurred_at: Time.now.utc.iso8601(6),
39
+ metadata: {}
40
+ )
41
+ values = binding.local_variables.to_h { |name| [name, binding.local_variable_get(name)] }
42
+ ATTRIBUTES.each do |name|
43
+ value = values.fetch(name)
44
+ value = value.to_sym if %i[kind channel role visibility].include?(name) && value
45
+ instance_variable_set("@#{name}", Immutable.copy(value))
46
+ end
47
+ Immutable.validate_canonical_json!(metadata, label: "Journal metadata")
48
+ freeze
49
+ end
50
+
51
+ def with_sequence(sequence)
52
+ self.class.from_h(to_h.merge("sequence" => sequence))
53
+ end
54
+
55
+ def to_h
56
+ ATTRIBUTES.to_h { |name| [name.to_s, public_send(name)] }
57
+ end
58
+
59
+ def self.from_h(hash)
60
+ new(**ATTRIBUTES.to_h do |name|
61
+ key = hash.key?(name.to_s) ? name.to_s : name
62
+ [name, hash[key]]
63
+ end)
64
+ end
65
+ end
66
+ end
67
+ end
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Phronomy
7
+ module Agent
8
+ class LLMCallRecord
9
+ STATUSES = %i[completed failed cancelled].freeze
10
+
11
+ ATTRIBUTES = %i[
12
+ llm_call_id execution_id sequence status manifest_ref output_ref
13
+ error_ref usage_ref started_at completed_at metadata
14
+ ].freeze
15
+ attr_reader(*ATTRIBUTES)
16
+
17
+ def initialize(
18
+ execution_id:,
19
+ sequence:,
20
+ status:,
21
+ manifest_ref:,
22
+ llm_call_id: SecureRandom.uuid,
23
+ output_ref: nil,
24
+ error_ref: nil,
25
+ usage_ref: nil,
26
+ started_at: Time.now.utc.iso8601(6),
27
+ completed_at: nil,
28
+ metadata: {}
29
+ )
30
+ values = binding.local_variables.to_h { |name| [name, binding.local_variable_get(name)] }
31
+ ATTRIBUTES.each do |name|
32
+ value = values.fetch(name)
33
+ value = value.to_sym if name == :status
34
+ instance_variable_set("@#{name}", Immutable.copy(value))
35
+ end
36
+ raise ArgumentError, "unknown LLM Call status: #{status.inspect}" unless STATUSES.include?(status)
37
+ raise ArgumentError, "LLM Call sequence must be positive" unless sequence.positive?
38
+ Immutable.validate_canonical_json!(metadata, label: "LLM Call metadata")
39
+ freeze
40
+ end
41
+
42
+ def to_h
43
+ ATTRIBUTES.to_h do |name|
44
+ value = public_send(name)
45
+ value = value.to_s if name == :status
46
+ [name.to_s, value]
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Immutable metadata passed to before_llm_input hooks. Runtime objects such
6
+ # as RubyLLM::Chat, messages and the mutable Agent instance are not exposed.
7
+ LLMInputBuildContext = Data.define(
8
+ :agent_id, :agent_definition_id, :definition_version,
9
+ :config, :call_sequence
10
+ ) do
11
+ def initialize(**values)
12
+ super(**values.merge(config: Immutable.copy(values.fetch(:config, {}))))
13
+ freeze
14
+ end
15
+ end
16
+ end
17
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ class LLMInputManifest
6
+ VERSION = 1
7
+ CALL_MODES = %i[ask complete].freeze
8
+
9
+ Segment = Data.define(
10
+ :position, :category, :role, :content_ref, :delivery,
11
+ :tool_call_id, :metadata
12
+ ) do
13
+ def initialize(**values)
14
+ super(**values.merge(metadata: Immutable.copy(values[:metadata] || {})))
15
+ freeze
16
+ end
17
+
18
+ def to_h
19
+ {
20
+ "position" => position,
21
+ "category" => category.to_s,
22
+ "role" => role&.to_s,
23
+ "content_ref" => content_ref,
24
+ "delivery" => delivery.to_s,
25
+ "tool_call_id" => tool_call_id,
26
+ "metadata" => metadata
27
+ }.compact
28
+ end
29
+ end
30
+
31
+ attr_reader :version, :call_sequence, :call_mode,
32
+ :assembly_policy_version, :segments,
33
+ :model_config_ref, :tool_definitions_ref, :response_schema_ref,
34
+ :ruby_llm_version, :adapter_name, :adapter_version
35
+
36
+ def initialize(
37
+ call_sequence:,
38
+ call_mode:,
39
+ segments:,
40
+ model_config_ref:,
41
+ tool_definitions_ref: nil,
42
+ response_schema_ref: nil,
43
+ assembly_policy_version: 1,
44
+ ruby_llm_version: nil,
45
+ adapter_name: nil,
46
+ adapter_version: nil,
47
+ version: VERSION
48
+ )
49
+ @version = Integer(version)
50
+ @call_sequence = Integer(call_sequence)
51
+ @call_mode = call_mode.to_sym
52
+ @assembly_policy_version = Integer(assembly_policy_version)
53
+ @segments = Array(segments).sort_by(&:position).freeze
54
+ @model_config_ref = model_config_ref.to_s.freeze
55
+ @tool_definitions_ref = tool_definitions_ref&.to_s&.freeze
56
+ @response_schema_ref = response_schema_ref&.to_s&.freeze
57
+ @ruby_llm_version = ruby_llm_version&.to_s&.freeze
58
+ @adapter_name = adapter_name&.to_s&.freeze
59
+ @adapter_version = adapter_version&.to_s&.freeze
60
+ validate!
61
+ freeze
62
+ end
63
+
64
+ def referenced_content_refs
65
+ ([model_config_ref, tool_definitions_ref, response_schema_ref] +
66
+ segments.map(&:content_ref)).compact.uniq.freeze
67
+ end
68
+
69
+ def to_h
70
+ {
71
+ "version" => version,
72
+ "call_sequence" => call_sequence,
73
+ "call_mode" => call_mode.to_s,
74
+ "assembly_policy_version" => assembly_policy_version,
75
+ "segments" => segments.map(&:to_h),
76
+ "model_config_ref" => model_config_ref,
77
+ "tool_definitions_ref" => tool_definitions_ref,
78
+ "response_schema_ref" => response_schema_ref,
79
+ "ruby_llm_version" => ruby_llm_version,
80
+ "adapter_name" => adapter_name,
81
+ "adapter_version" => adapter_version
82
+ }.compact
83
+ end
84
+
85
+ private
86
+
87
+ def validate!
88
+ raise ArgumentError, "invalid manifest call mode: #{call_mode.inspect}" unless CALL_MODES.include?(call_mode)
89
+ raise ArgumentError, "call_sequence must be positive" unless call_sequence.positive?
90
+ expected = (0...segments.length).to_a
91
+ actual = segments.map(&:position)
92
+ raise ArgumentError, "manifest positions must be contiguous: #{actual.inspect}" unless actual == expected
93
+
94
+ ask_count = segments.count { |segment| segment.delivery == :ask_argument }
95
+ expected_ask_count = (call_mode == :ask) ? 1 : 0
96
+ unless ask_count == expected_ask_count
97
+ raise ArgumentError,
98
+ "#{call_mode} manifest requires #{expected_ask_count} ask_argument segment(s), got #{ask_count}"
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Typed, canonicalizable input customization returned by before_llm_input.
6
+ # v1 deliberately exposes only fields that are fully applied to every call.
7
+ LLMInputPatch = Data.define(:model_config_patch, :segment_candidates) do
8
+ def initialize(model_config_patch: nil, segment_candidates: nil)
9
+ super(
10
+ model_config_patch: model_config_patch && Immutable.copy(model_config_patch),
11
+ segment_candidates: segment_candidates && Immutable.copy(Array(segment_candidates))
12
+ )
13
+ freeze
14
+ end
15
+
16
+ def self.empty
17
+ new
18
+ end
19
+ end
20
+ end
21
+ end
@@ -99,6 +99,18 @@ module Phronomy
99
99
  transition suspended: :waiting_for_tools
100
100
  end
101
101
 
102
+ event :application_callback_failed do
103
+ transition filtering_input: :failed
104
+ transition building_context: :failed
105
+ transition calling_llm: :failed
106
+ transition starting_tools: :failed
107
+ transition evaluating_tools: :failed
108
+ transition waiting_for_tools: :failed
109
+ transition dispatching_tools: :failed
110
+ transition recording_tool_results: :failed
111
+ transition output_filtering: :failed
112
+ end
113
+
102
114
  entry_actions.each do |state_name, callables|
103
115
  callables.each do |callable|
104
116
  after_transition(
@@ -0,0 +1,90 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Immutable, Phronomy-owned snapshot of one completed Provider assistant output.
6
+ # It is captured before Agent-owned Tool execution begins, so durable logging
7
+ # never depends on RubyLLM's later control flow or Application callbacks.
8
+ ProviderCallOutcome = Data.define(:role, :content, :tool_calls, :usage, :metadata) do
9
+ def self.capture(message)
10
+ return if message.nil?
11
+
12
+ calls = if message.respond_to?(:tool_calls) && message.tool_calls
13
+ source = message.tool_calls.respond_to?(:values) ? message.tool_calls.values : Array(message.tool_calls)
14
+ source.map { |call| normalize(tool_call_hash(call)) }
15
+ else
16
+ []
17
+ end
18
+ usage = if message.respond_to?(:tokens) && message.tokens
19
+ normalize(message.tokens.respond_to?(:to_h) ? message.tokens.to_h : message.tokens)
20
+ else
21
+ {}
22
+ end
23
+ metadata = {}
24
+ metadata["model_id"] = message.model_id.to_s if message.respond_to?(:model_id) && message.model_id
25
+
26
+ new(
27
+ role: message.respond_to?(:role) ? message.role : :assistant,
28
+ content: normalize(message.respond_to?(:content) ? message.content : nil),
29
+ tool_calls: calls,
30
+ usage: usage,
31
+ metadata: metadata
32
+ )
33
+ end
34
+
35
+ def self.tool_call_hash(call)
36
+ return call.to_h if call.respond_to?(:to_h)
37
+
38
+ {
39
+ "id" => call.respond_to?(:id) ? call.id : nil,
40
+ "name" => call.respond_to?(:name) ? call.name : nil,
41
+ "arguments" => call.respond_to?(:arguments) ? call.arguments : {}
42
+ }.compact
43
+ end
44
+ private_class_method :tool_call_hash
45
+
46
+ def self.normalize(value)
47
+ case value
48
+ when Hash
49
+ value.to_h { |key, child| [key.to_s, normalize(child)] }
50
+ when Array
51
+ value.map { |child| normalize(child) }
52
+ when Symbol
53
+ value.to_s
54
+ when String, Integer, Float, TrueClass, FalseClass, NilClass
55
+ value
56
+ else
57
+ if value.respond_to?(:to_h)
58
+ normalize(value.to_h)
59
+ else
60
+ value.to_s
61
+ end
62
+ end
63
+ end
64
+ private_class_method :normalize
65
+
66
+ def initialize(role:, content:, tool_calls:, usage: {}, metadata: {})
67
+ super(
68
+ role: role&.to_sym,
69
+ content: Immutable.copy(content),
70
+ tool_calls: Immutable.copy(Array(tool_calls)),
71
+ usage: Immutable.copy(usage || {}),
72
+ metadata: Immutable.copy(metadata || {})
73
+ )
74
+ Immutable.validate_canonical_json!(content, label: "Provider assistant content")
75
+ Immutable.validate_canonical_json!(tool_calls, label: "Provider Tool Calls")
76
+ Immutable.validate_canonical_json!(usage, label: "Provider usage")
77
+ Immutable.validate_canonical_json!(metadata, label: "Provider outcome metadata")
78
+ freeze
79
+ end
80
+
81
+ def content_present?
82
+ !content.nil? && !(content.respond_to?(:empty?) && content.empty?)
83
+ end
84
+
85
+ def tool_call?
86
+ !tool_calls.empty?
87
+ end
88
+ end
89
+ end
90
+ end
@@ -0,0 +1,189 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ class RubyLLMMaterializer
6
+ RuntimeProjection = Data.define(
7
+ :system, :messages, :tool_classes, :ask_message,
8
+ :model_config, :manifest, :manifest_ref
9
+ )
10
+
11
+ def initialize(agent:, persistence:)
12
+ @agent = agent
13
+ @persistence = persistence
14
+ end
15
+
16
+ def materialize(manifest:, manifest_ref:)
17
+ verify_manifest_identity!(manifest, manifest_ref)
18
+ verify_adapter!(manifest)
19
+ tool_set = verify_tool_definitions!(manifest)
20
+ model_config = fetch_json(manifest.model_config_ref)
21
+ system_parts = []
22
+ message_segments = []
23
+ ask_message = nil
24
+
25
+ manifest.segments.each do |segment|
26
+ case segment.delivery
27
+ when :ask_argument
28
+ ask_message = @persistence.contents.fetch_text(segment.content_ref)
29
+ when :chat_message
30
+ if segment.role == :system
31
+ system_parts << @persistence.contents.fetch_text(segment.content_ref)
32
+ else
33
+ message_segments << segment
34
+ end
35
+ else
36
+ raise ArgumentError, "unknown manifest delivery: #{segment.delivery.inspect}"
37
+ end
38
+ end
39
+
40
+ RuntimeProjection.new(
41
+ system: system_parts.empty? ? nil : system_parts.join("\n\n"),
42
+ messages: materialize_message_segments(message_segments).freeze,
43
+ tool_classes: tool_set.runtime_tools,
44
+ ask_message: ask_message,
45
+ model_config: Immutable.copy(model_config),
46
+ manifest: manifest,
47
+ manifest_ref: manifest_ref
48
+ )
49
+ end
50
+
51
+ def materialize_journal_record(record)
52
+ materialize_segment(segment_from_record(record))
53
+ end
54
+
55
+ def materialize_journal_records(records)
56
+ segments = Array(records).each_with_index.map do |record, index|
57
+ segment_from_record(record, position: index)
58
+ end
59
+ materialize_message_segments(segments).freeze
60
+ end
61
+
62
+ private
63
+
64
+ def segment_from_record(record, position: 0)
65
+ LLMInputManifest::Segment.new(
66
+ position: position,
67
+ category: record.kind,
68
+ role: record.role,
69
+ content_ref: record.content_ref,
70
+ delivery: :chat_message,
71
+ tool_call_id: record.metadata["tool_call_id"] || record.metadata[:tool_call_id],
72
+ metadata: record.metadata.merge(
73
+ "journal_record_id" => record.record_id,
74
+ "journal_sequence" => record.sequence,
75
+ "llm_call_id" => record.llm_call_id
76
+ ).compact
77
+ )
78
+ end
79
+
80
+ def verify_manifest_identity!(manifest, manifest_ref)
81
+ expected = @persistence.contents.fetch(manifest_ref)
82
+ actual = Phronomy::CanonicalJSON.dump(manifest.to_h)
83
+ return if expected == actual
84
+
85
+ raise Phronomy::ContentStore::IntegrityError,
86
+ "manifest content does not match manifest_ref: #{manifest_ref}"
87
+ end
88
+
89
+ def verify_adapter!(manifest)
90
+ actual = Phronomy.configuration.llm_adapter.class.name
91
+ return if manifest.adapter_name.nil? || manifest.adapter_name == actual
92
+
93
+ raise Phronomy::ConfigurationError,
94
+ "LLM adapter changed after manifest creation: #{manifest.adapter_name} -> #{actual}"
95
+ end
96
+
97
+ def verify_tool_definitions!(manifest)
98
+ tool_set = ToolDefinitionSet.build(@agent)
99
+ return tool_set unless manifest.tool_definitions_ref
100
+
101
+ expected = @persistence.contents.fetch(manifest.tool_definitions_ref)
102
+ actual = Phronomy::CanonicalJSON.dump(tool_set.definitions)
103
+ unless expected == actual
104
+ raise Phronomy::ConfigurationError,
105
+ "Agent tool definitions changed after manifest creation"
106
+ end
107
+ tool_set
108
+ end
109
+
110
+ def fetch_json(content_ref)
111
+ Phronomy::CanonicalJSON.load(@persistence.contents.fetch(content_ref))
112
+ end
113
+
114
+ def materialize_message_segments(segments)
115
+ segments.map { |segment| materialize_segment(segment) }
116
+ end
117
+
118
+ def materialize_segment(segment)
119
+ case segment.category.to_sym
120
+ when :assistant_message
121
+ materialize_canonical_message(segment, expected_role: :assistant)
122
+ when :tool_message
123
+ materialize_canonical_message(segment, expected_role: :tool)
124
+ when :tool_result
125
+ raise ArgumentError, "raw Tool result is not an LLM message"
126
+ else
127
+ RubyLLM::Message.new(
128
+ role: segment.role,
129
+ content: @persistence.contents.fetch_text(segment.content_ref),
130
+ tool_call_id: segment.tool_call_id
131
+ )
132
+ end
133
+ end
134
+
135
+ def materialize_canonical_message(segment, expected_role:)
136
+ payload = fetch_json(segment.content_ref)
137
+ role = payload.fetch("role").to_sym
138
+ unless role == expected_role
139
+ raise ArgumentError,
140
+ "canonical #{segment.category} role mismatch: #{role.inspect}"
141
+ end
142
+ if segment.role && segment.role.to_sym != role
143
+ raise ArgumentError,
144
+ "manifest role does not match canonical message: #{segment.role.inspect} != #{role.inspect}"
145
+ end
146
+
147
+ tool_calls = materialize_tool_calls(payload["tool_calls"])
148
+ message = RubyLLM::Message.new(
149
+ role: role,
150
+ content: initial_content_for(role, payload.fetch("content", nil), tool_calls),
151
+ tool_calls: tool_calls.empty? ? nil : tool_calls,
152
+ tool_call_id: payload["tool_call_id"],
153
+ model_id: payload["model_id"]
154
+ )
155
+
156
+ # RubyLLM normalizes Hash content during Message initialization. A
157
+ # canonical message may legitimately contain structured assistant
158
+ # content, so restore the captured logical value after construction.
159
+ message.content = payload["content"] if payload.key?("content")
160
+ message
161
+ end
162
+
163
+ def initial_content_for(role, content, tool_calls)
164
+ return "" if role == :assistant && content.nil? && !tool_calls.empty?
165
+
166
+ content.nil? ? "" : content
167
+ end
168
+
169
+ def materialize_tool_calls(payloads)
170
+ Array(payloads).each_with_object({}) do |payload, result|
171
+ tool_call_id = payload.fetch("id").to_s
172
+ if result.key?(tool_call_id)
173
+ raise ArgumentError, "duplicate Tool Call in assistant message: #{tool_call_id}"
174
+ end
175
+ result[tool_call_id] = materialize_tool_call(payload)
176
+ end
177
+ end
178
+
179
+ def materialize_tool_call(payload)
180
+ RubyLLM::ToolCall.new(
181
+ id: payload.fetch("id").to_s,
182
+ name: payload.fetch("name"),
183
+ arguments: payload.fetch("arguments", {}),
184
+ thought_signature: payload["thought_signature"]
185
+ )
186
+ end
187
+ end
188
+ end
189
+ end