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,69 @@
1
+ # 012 — Canonical Complete Execution Log and Context Policy
2
+
3
+ ## Status
4
+
5
+ Accepted for the stateful Agent refactor. This decision supersedes the parts of ADR-011 that made the legacy `build_context`/Assembler path the long-term LLM-input authority.
6
+
7
+ ## Decision
8
+
9
+ Phronomy records the logical execution facts it observes as an append-only Canonical Complete Execution Log. Context selection, pruning and compaction do not rewrite or delete those raw facts. They decide only which representation is materialized into one LLM Call Manifest.
10
+
11
+ Journal and Manifest are separate authorities:
12
+
13
+ - **Journal** is the authoritative record of logical execution facts that Phronomy observed.
14
+ - **Manifest** is the authoritative record of the logical input fixed for one particular LLM Call.
15
+ - Runtime Projection is derived from the Manifest and must not add semantic content that the Manifest did not select.
16
+
17
+ The persistent identity axes have narrow responsibilities:
18
+
19
+ - `agent_id` identifies the owning Agent.
20
+ - `execution_id` identifies one AgentExecution. It is provenance, not a Context-selection atom.
21
+ - `llm_call_id` identifies one runtime Provider LLM Call. It is allocated before transport starts and correlates that call's outcome. It is provenance, not a semantic-compaction boundary.
22
+ - `tool_call_id` links an assistant message's Tool Call with the corresponding Tool execution/message.
23
+ - Journal `sequence` is canonical chronology.
24
+
25
+ No `message_group_id`, import-only source provenance ID, or synthetic imported `execution_id` / `llm_call_id` is introduced.
26
+
27
+ ### Message preservation
28
+
29
+ A logical message that Phronomy receives is not flattened merely to make later Context assembly convenient.
30
+
31
+ - A Provider assistant response is captured as one complete assistant message containing its observable `content` and all Tool Calls.
32
+ - An imported assistant message is journaled as one assistant message with the structure supplied by the Import contract.
33
+ - A Tool value returned by Phronomy Tool execution is an execution fact (`tool_result`).
34
+ - The Tool-role message actually appended to the LLM conversation is a separate logical fact (`tool_message`).
35
+ - Imported Tool-role messages are journaled directly as `tool_message` records; Phronomy does not invent a separate raw Tool execution result for an execution it did not observe.
36
+
37
+ The Journal therefore does not need to infer or reconstruct a source message boundary that Phronomy already observed. Context Policy can inspect Tool Call IDs contained in an assistant message and form protocol-safe selection units with the corresponding Tool messages.
38
+
39
+ ### Import boundary
40
+
41
+ The application supplying imported history is responsible for satisfying Phronomy's Import contract. Phronomy interprets valid input according to that contract and rejects only data that is invalid under the contract, such as unsupported roles, missing Tool Call IDs, orphan/duplicate Tool results, unresolved Tool calls, or malformed message structure.
42
+
43
+ External resource acquisition is not a `ContextImporter` responsibility. Files, URLs and similar resources are obtained and interpreted by the Application or Tool that owns that capability; Phronomy journals the logical content/results it actually receives. `ContextImporter` therefore does not introduce RubyLLM-specific attachment handling or an attachment-specific reject path.
44
+
45
+ Phronomy must not reject an otherwise valid input merely because an internal flattened representation would lose information. In particular, two separately supplied assistant messages remain two separate Journal messages.
46
+
47
+ ### Manifest boundary
48
+
49
+ The Manifest fixes what one LLM Call will actually receive after Context Policy selection. A historical raw Tool return value and the Tool message produced from it are not interchangeable: the raw result belongs to the execution log, while the message selected for an LLM Call belongs to the Manifest input path.
50
+
51
+ One Provider response is captured as a Phronomy-owned `ProviderCallOutcome` before Agent-owned Tool execution starts. The canonical assistant-message record is produced from that outcome, not from Application callback delivery.
52
+
53
+ Context selection is expressed separately through `ContextCandidate`, dependency-aware `ContextSelectionUnit`, `ContextRequest`, `ContextPolicy`, validated `ContextPlan`, and final token-budget validation. An assistant message containing Tool Calls and the corresponding Tool messages form an atomic protocol unit. Ordinary messages in the same `execution_id` remain independently selectable.
54
+
55
+ ## RubyLLM boundary
56
+
57
+ Agent-owned Tool execution requires RubyLLM's additive callback contract introduced in RubyLLM 1.15. Phronomy therefore requires `ruby_llm >= 1.15, < 2`.
58
+
59
+ RubyLLM 1.15 adds the complete assistant message to `Chat#messages` before `before_tool_call` callbacks run. Phronomy captures the immutable Provider outcome at that boundary and raises `ToolCallIntercepted` only as an internal control transfer so approval, suspension, parallel dispatch and durable state remain Phronomy-owned.
60
+
61
+ ## Consequences
62
+
63
+ - Canonical execution history is independent of the current Context budget or policy.
64
+ - Import and runtime histories converge on the same canonical assistant/tool-message model without synthetic grouping identity.
65
+ - Old optional working history may be excluded from a follow-up Manifest without being deleted.
66
+ - Raw Tool results remain available as execution facts even when the corresponding Tool message is omitted from a later Manifest.
67
+ - Tool protocol dependencies are validated independently from semantic selection policy.
68
+ - Context Policy can later introduce deterministic derived/compacted records without replacing their raw sources.
69
+ - Public custom Context Policy APIs, transaction-boundary restructuring, deterministic compaction, Manifest v2/tool subsets, and legacy Assembler removal remain later phases.
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ class ActivationRegistry
6
+ def initialize
7
+ @mutex = Mutex.new
8
+ @records = {}
9
+ end
10
+
11
+ def register(activation)
12
+ @mutex.synchronize do
13
+ raise ArgumentError, "activation already registered: #{activation.execution_id}" if @records.key?(activation.execution_id)
14
+ @records[activation.execution_id] = activation
15
+ end
16
+ activation
17
+ end
18
+
19
+ def fetch(execution_id)
20
+ @mutex.synchronize { @records[execution_id.to_s] }
21
+ end
22
+
23
+ def delete(execution_id)
24
+ @mutex.synchronize { @records.delete(execution_id.to_s) }
25
+ end
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Phronomy
7
+ module Agent
8
+ class AgentExecution
9
+ ACTIVE_STATUSES = %i[preparing active suspended].freeze
10
+ TERMINAL_STATUSES = %i[completed failed cancelled rejected blocked].freeze
11
+ TRANSITIONS = {
12
+ preparing: %i[preparing active failed cancelled blocked],
13
+ active: %i[active suspended completed failed cancelled rejected blocked],
14
+ suspended: %i[suspended active failed cancelled],
15
+ completed: %i[completed],
16
+ failed: %i[failed],
17
+ cancelled: %i[cancelled],
18
+ rejected: %i[rejected],
19
+ blocked: %i[blocked]
20
+ }.freeze
21
+
22
+ ATTRIBUTES = %i[
23
+ execution_id agent_id execution_revision status phase
24
+ base_agent_revision base_context_revision base_journal_position
25
+ working_records llm_calls approval_request result_ref error_ref
26
+ created_at updated_at terminal_reason metadata
27
+ ].freeze
28
+ attr_reader(*ATTRIBUTES)
29
+
30
+ def self.start(agent_root:, input_record:, metadata: {})
31
+ now = Time.now.utc.iso8601(6)
32
+ new(
33
+ execution_id: SecureRandom.uuid,
34
+ agent_id: agent_root.agent_id,
35
+ execution_revision: 0,
36
+ status: :preparing,
37
+ phase: :preparing,
38
+ base_agent_revision: agent_root.agent_revision,
39
+ base_context_revision: agent_root.context_revision,
40
+ base_journal_position: agent_root.journal_position,
41
+ working_records: [input_record],
42
+ llm_calls: [],
43
+ approval_request: nil,
44
+ result_ref: nil,
45
+ error_ref: nil,
46
+ created_at: now,
47
+ updated_at: now,
48
+ terminal_reason: nil,
49
+ metadata: metadata
50
+ )
51
+ end
52
+
53
+ def initialize(**attributes)
54
+ ATTRIBUTES.each do |name|
55
+ value = attributes.fetch(name)
56
+ value = value.to_sym if %i[status phase].include?(name)
57
+ instance_variable_set("@#{name}", Immutable.copy(value))
58
+ end
59
+ raise ArgumentError, "unknown execution status: #{status.inspect}" unless TRANSITIONS.key?(status)
60
+ raise ArgumentError, "execution_revision must be non-negative" if execution_revision.negative?
61
+ Immutable.validate_canonical_json!(metadata, label: "Execution metadata")
62
+ if approval_request
63
+ Immutable.validate_canonical_json!(approval_request, label: "Approval request")
64
+ end
65
+ freeze
66
+ end
67
+
68
+ def active?
69
+ ACTIVE_STATUSES.include?(status)
70
+ end
71
+
72
+ def terminal?
73
+ TERMINAL_STATUSES.include?(status)
74
+ end
75
+
76
+ def with(**changes)
77
+ next_status = changes.fetch(:status, status).to_sym
78
+ unless TRANSITIONS.fetch(status).include?(next_status)
79
+ raise ArgumentError, "illegal AgentExecution transition: #{status} -> #{next_status}"
80
+ end
81
+ values = ATTRIBUTES.to_h { |name| [name, public_send(name)] }.merge(changes)
82
+ values[:execution_revision] = execution_revision + 1 unless changes.key?(:execution_revision)
83
+ values[:updated_at] = Time.now.utc.iso8601(6) unless changes.key?(:updated_at)
84
+ self.class.new(**values)
85
+ end
86
+
87
+ def to_h
88
+ ATTRIBUTES.to_h do |name|
89
+ value = public_send(name)
90
+ value = value.map(&:to_h) if name == :working_records
91
+ value = value.map(&:to_h) if name == :llm_calls
92
+ [name.to_s, value]
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,172 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+ require "time"
5
+
6
+ module Phronomy
7
+ module Agent
8
+ class AgentExecutionActivation
9
+ attr_reader :execution_id, :agent, :application_listener, :coordinator,
10
+ :base_manifest
11
+ attr_accessor :invocation, :session
12
+
13
+ def initialize(
14
+ execution:,
15
+ agent:,
16
+ runtime_projection:,
17
+ coordinator:,
18
+ application_listener: nil
19
+ )
20
+ @execution_id = execution.execution_id
21
+ @execution = execution
22
+ @agent = agent
23
+ @runtime_projection = runtime_projection
24
+ @base_manifest = runtime_projection.manifest
25
+ @coordinator = coordinator
26
+ @application_listener = application_listener
27
+ @mutex = Mutex.new
28
+ @active_call = nil
29
+ @llm_results = []
30
+ @runtime_events = []
31
+ @callback_errors = []
32
+ end
33
+
34
+ def execution
35
+ @mutex.synchronize { @execution }
36
+ end
37
+
38
+ def replace_execution(value)
39
+ @mutex.synchronize { @execution = value }
40
+ end
41
+
42
+ def runtime_projection
43
+ @mutex.synchronize { @runtime_projection }
44
+ end
45
+
46
+ def replace_runtime_projection(value)
47
+ @mutex.synchronize { @runtime_projection = value }
48
+ end
49
+
50
+ # Allocates Provider Call identity before transport begins. This identity is
51
+ # provenance only; Context selection must not use it as a semantic boundary.
52
+ def begin_llm_call(projection)
53
+ call_context = {
54
+ llm_call_id: SecureRandom.uuid,
55
+ manifest_ref: projection.manifest_ref,
56
+ started_at: Time.now.utc.iso8601(6)
57
+ }.freeze
58
+ @mutex.synchronize do
59
+ if @active_call
60
+ raise Phronomy::Error,
61
+ "cannot start a Provider Call while another Provider Call is active"
62
+ end
63
+ @runtime_projection = projection
64
+ @active_call = call_context
65
+ end
66
+ call_context
67
+ end
68
+
69
+ def record_llm_result(response:, error:, streaming:)
70
+ @mutex.synchronize do
71
+ active_call = @active_call
72
+ unless active_call
73
+ raise Phronomy::Error, "LLM result arrived without an active Provider Call"
74
+ end
75
+ @llm_results << {
76
+ llm_call_id: active_call.fetch(:llm_call_id),
77
+ response: response,
78
+ error: error,
79
+ streaming: streaming,
80
+ manifest_ref: active_call.fetch(:manifest_ref),
81
+ started_at: active_call.fetch(:started_at)
82
+ }
83
+ @active_call = nil
84
+ end
85
+ end
86
+
87
+ def runtime_snapshot
88
+ @mutex.synchronize do
89
+ {
90
+ llm_results: @llm_results.dup,
91
+ runtime_events: @runtime_events.dup,
92
+ active_call: @active_call&.dup
93
+ }
94
+ end
95
+ end
96
+
97
+ def acknowledge_runtime_snapshot(snapshot)
98
+ @mutex.synchronize do
99
+ @llm_results.shift(snapshot.fetch(:llm_results).length)
100
+ @runtime_events.shift(snapshot.fetch(:runtime_events).length)
101
+ if snapshot[:active_call] && @active_call == snapshot[:active_call]
102
+ @active_call = nil
103
+ end
104
+ end
105
+ end
106
+
107
+ ApplicationCallbackFailure = Data.define(:event_type, :error) do
108
+ def to_stream_callback_error
109
+ wrapped = Phronomy::StreamCallbackError.new(
110
+ event_type: event_type, original_error: error, result: nil
111
+ )
112
+ begin
113
+ raise wrapped, cause: error
114
+ rescue Phronomy::StreamCallbackError => caught
115
+ caught.set_backtrace(error.backtrace)
116
+ caught
117
+ end
118
+ end
119
+ end
120
+
121
+ attr_reader :callback_failure
122
+
123
+ def callback_failed?
124
+ @mutex.synchronize { !@callback_failure.nil? }
125
+ end
126
+
127
+ # Canonical runtime recording is independent of Application callback health.
128
+ # Once an event is observed it is appended even after a listener has failed.
129
+ def record_event(event)
130
+ listener = @mutex.synchronize do
131
+ @runtime_events << event
132
+ @callback_failure ? nil : @application_listener
133
+ end
134
+ return unless listener
135
+
136
+ listener.call(event)
137
+ rescue => callback_error
138
+ failure = ApplicationCallbackFailure.new(
139
+ event_type: event.type, error: callback_error
140
+ )
141
+ @mutex.synchronize do
142
+ @callback_failure ||= failure
143
+ @application_listener = nil
144
+ end
145
+ notify_callback_failure(failure)
146
+ end
147
+
148
+ private
149
+
150
+ def notify_callback_failure(failure)
151
+ invocation = @mutex.synchronize { @invocation }
152
+ if invocation
153
+ accepted = Phronomy::Runtime.instance.event_loop.post_to_session(
154
+ Phronomy::Event.new(
155
+ type: :application_callback_failed,
156
+ target_id: invocation.id,
157
+ payload: {failure: failure}
158
+ )
159
+ )
160
+ unless accepted
161
+ Phronomy.configuration.logger&.warn(
162
+ "[Phronomy] Callback failure recorded but could not notify FSM: execution_id=#{@execution_id}"
163
+ )
164
+ end
165
+ end
166
+ Phronomy.configuration.logger&.warn(
167
+ "[Phronomy] Application event listener failed: #{failure.error.class}: #{failure.error.message}"
168
+ )
169
+ end
170
+ end
171
+ end
172
+ end
@@ -26,6 +26,8 @@ module Phronomy
26
26
  llm_failed
27
27
  ].freeze
28
28
 
29
+ CALLBACK_FAILED_EVENTS = %i[application_callback_failed].freeze
30
+
29
31
  attr_accessor :input,
30
32
  :messages,
31
33
  :chat,
@@ -50,7 +52,9 @@ module Phronomy
50
52
  :tool_invocations,
51
53
  :session_id,
52
54
  :phase,
53
- :mode
55
+ :mode,
56
+ :current_llm_call_id,
57
+ :tool_batch_llm_call_id
54
58
 
55
59
  def initialize(
56
60
  agent:,
@@ -96,9 +100,10 @@ module Phronomy
96
100
  @error = nil
97
101
  @session_id = nil
98
102
  @phase = nil
103
+ @current_llm_call_id = nil
104
+ @tool_batch_llm_call_id = nil
99
105
  end
100
106
 
101
- # Compatibility aliases for existing internal callers.
102
107
  def stream_listener
103
108
  @event_listener
104
109
  end
@@ -116,6 +121,11 @@ module Phronomy
116
121
  @phase = phase
117
122
  end
118
123
 
124
+ def begin_llm_call!(llm_call_id)
125
+ @current_llm_call_id = llm_call_id.to_s
126
+ self
127
+ end
128
+
119
129
  def pending_tool_calls=(calls)
120
130
  @pending_tool_calls = Array(calls)
121
131
  end
@@ -128,6 +138,7 @@ module Phronomy
128
138
  @pending_tool_calls = []
129
139
  @tool_invocations = []
130
140
  @approval_request = nil
141
+ @tool_batch_llm_call_id = nil
131
142
  end
132
143
 
133
144
  def handle_fsm_event(event)
@@ -146,6 +157,11 @@ module Phronomy
146
157
  return true
147
158
  end
148
159
 
160
+ if CALLBACK_FAILED_EVENTS.include?(event.type)
161
+ @error ||= event.payload.fetch(:failure).to_stream_callback_error
162
+ return true
163
+ end
164
+
149
165
  return false unless TOOL_EVENT_TYPES.include?(event.type)
150
166
 
151
167
  invocation = tool_invocation(
@@ -164,8 +180,6 @@ module Phronomy
164
180
  true
165
181
  end
166
182
 
167
- # Deprecated internal compatibility hook. FSMSession no longer calls
168
- # this method; asynchronous results enter through explicit events.
169
183
  def apply_fsm_action_result(result)
170
184
  event_type =
171
185
  if result.respond_to?(:error) &&
@@ -185,15 +199,20 @@ module Phronomy
185
199
  self
186
200
  end
187
201
 
188
- def accept_tool_calls!(tool_calls)
202
+ def accept_tool_calls!(tool_calls, llm_call_id: nil)
189
203
  @user_message_sent = true
190
204
  @pending_tool_calls = Array(tool_calls)
191
205
  @messages = @chat.messages
206
+ @tool_batch_llm_call_id = (llm_call_id || @current_llm_call_id)&.to_s
207
+ @current_llm_call_id = nil
192
208
  @pending_tool_calls.each do |tool_call|
193
209
  deliver_event(
194
210
  StreamEvent.new(
195
211
  type: :tool_call,
196
- payload: {tool_call: tool_call}
212
+ payload: {
213
+ tool_call: tool_call,
214
+ llm_call_id: @tool_batch_llm_call_id
215
+ }.compact
197
216
  )
198
217
  )
199
218
  end
@@ -210,6 +229,7 @@ module Phronomy
210
229
  @usage = Phronomy::TokenUsage.from_tokens(response.tokens)
211
230
  @messages = @chat.messages
212
231
  @pending_tool_calls = []
232
+ @current_llm_call_id = nil
213
233
  self
214
234
  end
215
235
 
@@ -255,9 +275,10 @@ module Phronomy
255
275
 
256
276
  def record_tool_results!
257
277
  @tool_invocations.each do |invocation|
278
+ tool_content = invocation.result.to_s
258
279
  @chat.add_message(
259
280
  role: :tool,
260
- content: invocation.result.to_s,
281
+ content: tool_content,
261
282
  tool_call_id: invocation.tool_call_id
262
283
  )
263
284
  deliver_event(
@@ -266,8 +287,14 @@ module Phronomy
266
287
  payload: {
267
288
  tool_call_id: invocation.tool_call_id,
268
289
  tool_name: invocation.tool_name,
269
- tool_result: invocation.result
270
- }
290
+ tool_result: invocation.result,
291
+ tool_message: {
292
+ "role" => "tool",
293
+ "content" => tool_content,
294
+ "tool_call_id" => invocation.tool_call_id.to_s
295
+ },
296
+ llm_call_id: @tool_batch_llm_call_id
297
+ }.compact
271
298
  )
272
299
  )
273
300
  end
@@ -361,6 +388,7 @@ module Phronomy
361
388
  end
362
389
 
363
390
  if event.type == :llm_failed
391
+ @current_llm_call_id = nil
364
392
  @error = result.error ||
365
393
  Phronomy::Error.new("LLM operation failed without an error")
366
394
  return
@@ -368,8 +396,12 @@ module Phronomy
368
396
 
369
397
  if result.error
370
398
  if result.error.is_a?(ToolCallIntercepted)
371
- accept_tool_calls!(result.error.tool_calls)
399
+ accept_tool_calls!(
400
+ result.error.tool_calls,
401
+ llm_call_id: result.error.llm_call_id
402
+ )
372
403
  else
404
+ @current_llm_call_id = nil
373
405
  @error = result.error
374
406
  end
375
407
  else
@@ -132,6 +132,17 @@ module Phronomy
132
132
  ],
133
133
  resume: [
134
134
  {from: :suspended, to: :waiting_for_tools, guard: nil}
135
+ ],
136
+ application_callback_failed: [
137
+ {from: :filtering_input, to: :failed, guard: nil},
138
+ {from: :building_context, to: :failed, guard: nil},
139
+ {from: :calling_llm, to: :failed, guard: nil},
140
+ {from: :starting_tools, to: :failed, guard: nil},
141
+ {from: :evaluating_tools, to: :failed, guard: nil},
142
+ {from: :waiting_for_tools, to: :failed, guard: nil},
143
+ {from: :dispatching_tools, to: :failed, guard: nil},
144
+ {from: :recording_tool_results, to: :failed, guard: nil},
145
+ {from: :output_filtering, to: :failed, guard: nil}
135
146
  ]
136
147
  )
137
148
  end
@@ -206,30 +217,58 @@ module Phronomy
206
217
  invocation.chat,
207
218
  invocation.config
208
219
  )
209
- install_tool_interceptors(invocation.chat)
220
+ install_tool_interceptors(invocation.chat, invocation)
210
221
  invocation
211
222
  end
212
223
  private_class_method :building_context_action
213
224
 
214
- def self.install_tool_interceptors(chat)
225
+ # RubyLLM >= 1.15 adds the complete assistant Message to chat.messages
226
+ # before before_tool_call is fired. Capture that Message at the control
227
+ # boundary instead of trying to recover it after the exception unwinds.
228
+ def self.install_tool_interceptors(chat, invocation = nil)
229
+ unless chat.respond_to?(:before_tool_call)
230
+ raise Phronomy::ConfigurationError,
231
+ "Agent-owned Tool execution requires RubyLLM >= 1.15 (before_tool_call callback)"
232
+ end
233
+
215
234
  if chat.respond_to?(:on_tool_call_batch)
216
235
  chat.on_tool_call_batch do |tool_calls|
217
- raise Phronomy::Agent::ToolCallIntercepted.new(tool_calls)
236
+ raise build_tool_interception(chat, tool_calls, invocation)
218
237
  end
219
238
  end
220
239
 
221
- if chat.respond_to?(:before_tool_call)
222
- chat.before_tool_call do |tool_call|
223
- raise Phronomy::Agent::ToolCallIntercepted.new(tool_call)
224
- end
225
- else
226
- chat.on_tool_call do |tool_call|
227
- raise Phronomy::Agent::ToolCallIntercepted.new(tool_call)
228
- end
240
+ chat.before_tool_call do |tool_call|
241
+ raise build_tool_interception(chat, [tool_call], invocation)
229
242
  end
230
243
  end
231
244
  private_class_method :install_tool_interceptors
232
245
 
246
+ def self.build_tool_interception(chat, fallback_tool_calls, invocation)
247
+ assistant_message = chat.messages.last
248
+ unless assistant_message&.respond_to?(:role) &&
249
+ assistant_message.role.to_sym == :assistant &&
250
+ assistant_message.respond_to?(:tool_calls)
251
+ raise Phronomy::Error,
252
+ "RubyLLM Tool callback fired before the complete assistant message was observable"
253
+ end
254
+
255
+ message_tool_calls = assistant_message.tool_calls
256
+ tool_calls = if message_tool_calls.respond_to?(:values)
257
+ message_tool_calls.values
258
+ else
259
+ Array(message_tool_calls)
260
+ end
261
+ tool_calls = Array(fallback_tool_calls) if tool_calls.empty?
262
+
263
+ ToolCallIntercepted.new(
264
+ tool_calls,
265
+ assistant_message: assistant_message,
266
+ assistant_outcome: ProviderCallOutcome.capture(assistant_message),
267
+ llm_call_id: invocation&.current_llm_call_id
268
+ )
269
+ end
270
+ private_class_method :build_tool_interception
271
+
233
272
  def self.calling_llm_action(agent, runtime, invocation)
234
273
  user_message = invocation.user_message_sent ?
235
274
  nil :
@@ -0,0 +1,67 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ module Phronomy
6
+ module Agent
7
+ class AgentRoot
8
+ LIFECYCLE_STATUSES = %i[idle active suspended closed invalidated].freeze
9
+
10
+ ATTRIBUTES = %i[
11
+ agent_id agent_definition_id definition_version agent_revision
12
+ context_revision journal_position lifecycle_status transcript_generation memory_generation
13
+ created_at updated_at metadata
14
+ ].freeze
15
+
16
+ attr_reader(*ATTRIBUTES)
17
+
18
+ def self.create(agent_id:, agent_definition_id:, definition_version:, metadata: {})
19
+ now = Time.now.utc.iso8601(6)
20
+ new(
21
+ agent_id: agent_id,
22
+ agent_definition_id: agent_definition_id,
23
+ definition_version: definition_version,
24
+ agent_revision: 0,
25
+ context_revision: 0,
26
+ journal_position: 0,
27
+ lifecycle_status: :idle,
28
+ transcript_generation: 0,
29
+ memory_generation: 0,
30
+ created_at: now,
31
+ updated_at: now,
32
+ metadata: metadata
33
+ )
34
+ end
35
+
36
+ def initialize(**attributes)
37
+ ATTRIBUTES.each do |name|
38
+ value = attributes.fetch(name)
39
+ value = value.to_sym if name == :lifecycle_status
40
+ instance_variable_set("@#{name}", Immutable.copy(value))
41
+ end
42
+ unless LIFECYCLE_STATUSES.include?(lifecycle_status)
43
+ raise ArgumentError, "unknown Agent lifecycle status: #{lifecycle_status.inspect}"
44
+ end
45
+ raise ArgumentError, "agent_revision must be non-negative" if agent_revision.negative?
46
+ raise ArgumentError, "context_revision must be non-negative" if context_revision.negative?
47
+ raise ArgumentError, "journal_position must be non-negative" if journal_position.negative?
48
+ Immutable.validate_canonical_json!(metadata, label: "Agent metadata")
49
+ freeze
50
+ end
51
+
52
+ def with(**changes)
53
+ values = to_h.transform_keys(&:to_sym).merge(changes)
54
+ values[:updated_at] = Time.now.utc.iso8601(6) unless changes.key?(:updated_at)
55
+ self.class.new(**values)
56
+ end
57
+
58
+ def to_h
59
+ ATTRIBUTES.to_h { |name| [name.to_s, public_send(name)] }
60
+ end
61
+
62
+ def self.from_h(hash)
63
+ new(**ATTRIBUTES.to_h { |name| [name, hash.fetch(name.to_s)] })
64
+ end
65
+ end
66
+ end
67
+ end