phronomy 0.15.0 → 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 +107 -18
  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 -393
  16. data/lib/phronomy/agent/base.rb +301 -641
  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 -47
  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,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Adapts the current AgentInvocation FSM to the ideal manifest-first model.
6
+ # Every LLM Call receives a freshly fixed Manifest. Persistence and
7
+ # projection preparation run on BlockingAdapterPool, never on EventLoop.
8
+ module FsmRuntimeAdapter
9
+ private
10
+
11
+ def filtering_input_action(_agent, invocation)
12
+ invocation.input = invocation.config.fetch(:phronomy_filtered_input)
13
+ invocation
14
+ end
15
+
16
+ def building_context_action(agent, invocation)
17
+ projection = invocation.config.fetch(:phronomy_runtime_projection)
18
+ invocation.chat = agent.send(:build_chat, model_config: projection.model_config)
19
+ agent.send(
20
+ :_apply_context_to_chat,
21
+ invocation.chat,
22
+ {
23
+ system: projection.system,
24
+ messages: projection.messages,
25
+ tool_classes: projection.tool_classes,
26
+ model_config: projection.model_config
27
+ }
28
+ )
29
+ install_tool_interceptors(invocation.chat, invocation)
30
+ invocation
31
+ end
32
+
33
+ def calling_llm_action(agent, runtime, invocation)
34
+ prepare_and_start_llm_call(
35
+ agent,
36
+ runtime,
37
+ invocation,
38
+ streaming: false
39
+ )
40
+ invocation
41
+ end
42
+
43
+ def calling_llm_stream_action(agent, runtime, invocation)
44
+ prepare_and_start_llm_call(
45
+ agent,
46
+ runtime,
47
+ invocation,
48
+ streaming: true
49
+ )
50
+ invocation
51
+ end
52
+
53
+ def prepare_and_start_llm_call(agent, runtime, invocation, streaming:)
54
+ activation = invocation.config.fetch(:phronomy_activation)
55
+ if invocation.user_message_sent
56
+ preparation = runtime.blocking_io.submit do
57
+ activation.coordinator.prepare_next_llm_call(activation)
58
+ end
59
+ preparation.on_complete do |projection, error|
60
+ if error
61
+ post_preparation_failure(runtime, invocation, error, streaming: streaming)
62
+ else
63
+ start_provider_call(
64
+ agent,
65
+ runtime,
66
+ invocation,
67
+ activation,
68
+ projection,
69
+ streaming: streaming,
70
+ replace_messages: true
71
+ )
72
+ end
73
+ end
74
+ else
75
+ start_provider_call(
76
+ agent,
77
+ runtime,
78
+ invocation,
79
+ activation,
80
+ activation.runtime_projection,
81
+ streaming: streaming,
82
+ replace_messages: false
83
+ )
84
+ end
85
+ end
86
+
87
+ def start_provider_call(
88
+ agent,
89
+ runtime,
90
+ invocation,
91
+ activation,
92
+ projection,
93
+ streaming:,
94
+ replace_messages:
95
+ )
96
+ call_started = false
97
+ agent.send(
98
+ :check_cancellation!,
99
+ invocation.config,
100
+ "invocation cancelled before LLM call"
101
+ )
102
+ if replace_messages
103
+ invocation.chat = agent.send(:build_chat, model_config: projection.model_config)
104
+ agent.send(
105
+ :_apply_context_to_chat,
106
+ invocation.chat,
107
+ {
108
+ system: projection.system,
109
+ messages: projection.messages,
110
+ tool_classes: projection.tool_classes,
111
+ model_config: projection.model_config
112
+ }
113
+ )
114
+ install_tool_interceptors(invocation.chat, invocation)
115
+ invocation.config[:phronomy_runtime_projection] = projection
116
+ end
117
+
118
+ call_context = activation.begin_llm_call(projection)
119
+ call_started = true
120
+ invocation.begin_llm_call!(call_context.fetch(:llm_call_id))
121
+ message = projection.ask_message
122
+
123
+ operation = if streaming
124
+ Phronomy.configuration.llm_adapter.stream_async(
125
+ invocation.chat,
126
+ message,
127
+ config: invocation.config
128
+ ) do |chunk|
129
+ agent.send(
130
+ :check_cancellation!,
131
+ invocation.config,
132
+ "invocation cancelled during streaming"
133
+ )
134
+ post_to_invocation!(
135
+ runtime,
136
+ invocation.id,
137
+ :llm_stream_chunk,
138
+ {content: chunk.content}
139
+ )
140
+ end
141
+ else
142
+ Phronomy.configuration.llm_adapter.complete_async(
143
+ invocation.chat,
144
+ message,
145
+ config: invocation.config
146
+ )
147
+ end
148
+ observe_manifest_call(
149
+ operation,
150
+ activation,
151
+ invocation,
152
+ runtime: runtime,
153
+ streaming: streaming
154
+ )
155
+ rescue => error
156
+ if call_started
157
+ activation.record_llm_result(
158
+ response: canonical_response_for(nil, error),
159
+ error: error,
160
+ streaming: streaming
161
+ )
162
+ end
163
+ post_llm_result(runtime, invocation, nil, error, streaming: streaming)
164
+ end
165
+
166
+ def observe_manifest_call(operation, activation, invocation, runtime:, streaming:)
167
+ operation.on_complete do |response, error|
168
+ activation.record_llm_result(
169
+ response: canonical_response_for(response, error),
170
+ error: error,
171
+ streaming: streaming
172
+ )
173
+ post_llm_result(
174
+ runtime,
175
+ invocation,
176
+ response,
177
+ error,
178
+ streaming: streaming
179
+ )
180
+ end
181
+ end
182
+
183
+ def canonical_response_for(response, error)
184
+ if error.is_a?(ToolCallIntercepted)
185
+ error.assistant_outcome || ProviderCallOutcome.capture(response)
186
+ else
187
+ ProviderCallOutcome.capture(response)
188
+ end
189
+ end
190
+
191
+ def post_preparation_failure(runtime, invocation, error, streaming:)
192
+ post_llm_result(runtime, invocation, nil, error, streaming: streaming)
193
+ end
194
+
195
+ def post_llm_result(runtime, invocation, response, error, streaming:)
196
+ result = LLMOperationResult.new(
197
+ response: response,
198
+ error: error,
199
+ streaming: streaming
200
+ )
201
+ event_type = if error && !error.is_a?(ToolCallIntercepted)
202
+ :llm_failed
203
+ else
204
+ :llm_completed
205
+ end
206
+ post_to_invocation!(runtime, invocation.id, event_type, result)
207
+ end
208
+ end
209
+ end
210
+ end
@@ -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,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ class JournalProjection
6
+ MESSAGE_KINDS = %i[
7
+ external_message assistant_message tool_message
8
+ llm_message tool_call tool_result
9
+ ].freeze
10
+
11
+ def initialize(persistence:, agent_root:)
12
+ @persistence = persistence
13
+ @agent_root = agent_root
14
+ end
15
+
16
+ def transcript_records
17
+ generation = @agent_root.transcript_generation
18
+ records.select do |record|
19
+ record.context_candidate &&
20
+ record.context_generation == generation &&
21
+ MESSAGE_KINDS.include?(record.kind)
22
+ end
23
+ end
24
+
25
+ def llm_call_records
26
+ records.select { |record| record.kind == :llm_call_recorded }
27
+ end
28
+
29
+ def records
30
+ @records ||= @persistence.journals.read(@agent_root.agent_id)
31
+ end
32
+ end
33
+ end
34
+ 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