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
@@ -40,6 +40,7 @@ class BenchStubChat
40
40
  def with_cache_instructions(_) = self
41
41
  def with_output_schema(_) = self
42
42
  def on_tool_call(&) = self
43
+ def before_tool_call(&) = self
43
44
  def last_message = @response
44
45
 
45
46
  def ask(_)
@@ -71,12 +72,14 @@ BENCH_RESP = BenchAgentMessage.assistant("benchmark complete")
71
72
  BENCH_RESP_CHAT = BenchStubChat.new(BENCH_RESP)
72
73
 
73
74
  bench_minimal_class = Class.new(Phronomy::Agent::Base) do
75
+ agent_definition id: "bench-minimal", version: 1
74
76
  model "stub-model"
75
77
 
76
78
  define_method(:build_chat) { |*| BenchStubChat.new(BENCH_RESP) }
77
79
  end
78
80
 
79
81
  bench_tool_class = Class.new(Phronomy::Agent::Base) do
82
+ agent_definition id: "bench-tool", version: 1
80
83
  model "stub-model"
81
84
  tools BenchNullTool
82
85
 
@@ -88,6 +88,7 @@ end
88
88
  # Target 4: Orchestrator#dispatch_parallel overhead (10 stub agents, no LLM)
89
89
  # ---------------------------------------------------------------------------
90
90
  stub_agent_class = Class.new(Phronomy::Agent::Base) do
91
+ agent_definition id: "bench-stub", version: 1
91
92
  define_method(:invoke) do |_input, messages: [], thread_id: nil, config: {}|
92
93
  {output: "stub", messages: []}
93
94
  end
@@ -129,22 +130,6 @@ t6 = Benchmark.measure("CancellationToken#raise_if_cancelled! (no-op)") do
129
130
  RAISE_ITERATIONS.times { RAISE_TOKEN.raise_if_cancelled! }
130
131
  end
131
132
 
132
- # ---------------------------------------------------------------------------
133
- # Target 7: Agent::Base#trim_messages on a 2000-message history
134
- # ---------------------------------------------------------------------------
135
- BenchMsg = Struct.new(:content) unless defined?(BenchMsg)
136
-
137
- TRIM_MESSAGES = Array.new(2_000) { |i| BenchMsg.new("msg #{i}") }
138
- TRIM_ITERATIONS = 500
139
-
140
- bench_trim_agent = Class.new(Phronomy::Agent::Base).new
141
-
142
- t7 = Benchmark.measure("Agent::Base#trim_messages (2000-msg history)") do
143
- TRIM_ITERATIONS.times do
144
- bench_trim_agent.send(:trim_messages, TRIM_MESSAGES, keep: 1_800)
145
- end
146
- end
147
-
148
133
  # ---------------------------------------------------------------------------
149
134
  # Print results and store in REGRESSION_RESULTS
150
135
  # ---------------------------------------------------------------------------
@@ -158,8 +143,7 @@ metrics = {
158
143
  "tool_params_schema_definition" => [t3, REGRESSION_ITERATIONS],
159
144
  "dispatch_parallel_10" => [t4, PARALLEL_ITERATIONS],
160
145
  "cancellation_token_cancelled" => [t5, 8 * CANCEL_ITERATIONS],
161
- "cancellation_token_raise_if_cancelled_noop" => [t6, RAISE_ITERATIONS],
162
- "trim_messages_2000" => [t7, TRIM_ITERATIONS]
146
+ "cancellation_token_raise_if_cancelled_noop" => [t6, RAISE_ITERATIONS]
163
147
  }
164
148
 
165
149
  REGRESSION_RESULTS = {} # rubocop:disable Style/MutableConstant
@@ -54,6 +54,7 @@ class BenchKnowledgeSource < Phronomy::Agent::Context::Knowledge::Base
54
54
  end
55
55
 
56
56
  class BenchAgentWithKnowledge < Phronomy::Agent::Base
57
+ agent_definition id: "bench-knowledge", version: 1
57
58
  model "gpt-4o-mini"
58
59
  static_knowledge BenchKnowledgeSource.new
59
60
  end
@@ -2,7 +2,46 @@
2
2
 
3
3
  ## Status
4
4
 
5
- Proposed — 2026-05-31
5
+ Partially Superseded by ADR-012 — 2026-08-08
6
+
7
+ Originally proposed — 2026-05-31.
8
+
9
+ ## Supersession Note
10
+
11
+ ADR-012, **Canonical Complete Execution Log and Context Policy**, supersedes the architectural parts of this ADR that treat the legacy `build_context` / `LlmContextWindow::Assembler` path as the long-term single authority for LLM input.
12
+
13
+ In particular, the following parts of this ADR are no longer normative for the stateful Agent architecture:
14
+
15
+ * **D1 — `build_context` is the single authority for all LLM input**
16
+ * **D2 — Assembler handles all four regions including Capability**
17
+ * **D3 — `build_context` includes all tools**
18
+ * **D5 — Previous context stored as an instance variable**
19
+
20
+ The replacement authority model is:
21
+
22
+ ```text
23
+ Canonical Journal
24
+
25
+ Context Policy
26
+
27
+ LLM Call Manifest
28
+
29
+ Runtime Projection
30
+
31
+ RubyLLM / Provider
32
+ ```
33
+
34
+ Under ADR-012:
35
+
36
+ * the **Journal** is authoritative for logical execution facts observed by Phronomy;
37
+ * the **Manifest** is authoritative for the logical input fixed for one specific LLM Call;
38
+ * Context Policy determines which canonical history is selected for that Manifest;
39
+ * selection, pruning, and compaction do not delete or rewrite the canonical Journal;
40
+ * Tool protocol dependencies are preserved independently from semantic Context-selection policy.
41
+
42
+ The problem statements and historical analysis in this ADR remain useful as design history. Decisions or implementation notes that do not conflict with ADR-012 may still describe valid constraints, but ADR-012 is authoritative whenever the two documents differ.
43
+
44
+ See: `012-canonical-execution-log-and-context-policy.md`
6
45
 
7
46
  ## Context
8
47
 
@@ -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