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,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,8 +26,9 @@ 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
- :messages,
31
32
  :chat,
32
33
  :output,
33
34
  :usage,
@@ -50,23 +51,22 @@ module Phronomy
50
51
  :tool_invocations,
51
52
  :session_id,
52
53
  :phase,
53
- :mode
54
+ :mode,
55
+ :current_llm_call_id,
56
+ :tool_batch_llm_call_id
54
57
 
55
58
  def initialize(
56
59
  agent:,
57
60
  input:,
58
- messages:,
59
61
  config:,
60
62
  approval_policy: nil,
61
63
  approval_listener: nil,
62
64
  event_listener: nil,
63
- stream_listener: nil,
64
65
  mode: nil,
65
66
  id: nil
66
67
  )
67
68
  @agent = agent
68
69
  @input = input
69
- @messages = Array(messages)
70
70
  @config = config
71
71
  @thread_id = config[:thread_id]
72
72
  @id = (id || config[:agent_invocation_id] || SecureRandom.uuid).to_s
@@ -76,8 +76,8 @@ module Phronomy
76
76
  end
77
77
  @approval_policy = invocation_policy || approval_policy
78
78
  @approval_listener = approval_listener
79
- @event_listener = event_listener || stream_listener
80
- @mode = (mode || (stream_listener ? :stream : :invoke)).to_sym
79
+ @event_listener = event_listener
80
+ @mode = (mode || :invoke).to_sym
81
81
 
82
82
  @chat = nil
83
83
  @output = nil
@@ -96,15 +96,8 @@ module Phronomy
96
96
  @error = nil
97
97
  @session_id = nil
98
98
  @phase = nil
99
- end
100
-
101
- # Compatibility aliases for existing internal callers.
102
- def stream_listener
103
- @event_listener
104
- end
105
-
106
- def stream_listener=(listener)
107
- @event_listener = listener
99
+ @current_llm_call_id = nil
100
+ @tool_batch_llm_call_id = nil
108
101
  end
109
102
 
110
103
  def streaming?
@@ -116,6 +109,11 @@ module Phronomy
116
109
  @phase = phase
117
110
  end
118
111
 
112
+ def begin_llm_call!(llm_call_id)
113
+ @current_llm_call_id = llm_call_id.to_s
114
+ self
115
+ end
116
+
119
117
  def pending_tool_calls=(calls)
120
118
  @pending_tool_calls = Array(calls)
121
119
  end
@@ -128,6 +126,7 @@ module Phronomy
128
126
  @pending_tool_calls = []
129
127
  @tool_invocations = []
130
128
  @approval_request = nil
129
+ @tool_batch_llm_call_id = nil
131
130
  end
132
131
 
133
132
  def handle_fsm_event(event)
@@ -146,6 +145,11 @@ module Phronomy
146
145
  return true
147
146
  end
148
147
 
148
+ if CALLBACK_FAILED_EVENTS.include?(event.type)
149
+ @error ||= event.payload.fetch(:failure).to_stream_callback_error
150
+ return true
151
+ end
152
+
149
153
  return false unless TOOL_EVENT_TYPES.include?(event.type)
150
154
 
151
155
  invocation = tool_invocation(
@@ -164,36 +168,19 @@ module Phronomy
164
168
  true
165
169
  end
166
170
 
167
- # Deprecated internal compatibility hook. FSMSession no longer calls
168
- # this method; asynchronous results enter through explicit events.
169
- def apply_fsm_action_result(result)
170
- event_type =
171
- if result.respond_to?(:error) &&
172
- result.error &&
173
- !result.error.is_a?(ToolCallIntercepted)
174
- :llm_failed
175
- else
176
- :llm_completed
177
- end
178
- handle_fsm_event(
179
- Phronomy::Event.new(
180
- type: event_type,
181
- target_id: @id,
182
- payload: result
183
- )
184
- )
185
- self
186
- end
187
-
188
- def accept_tool_calls!(tool_calls)
171
+ def accept_tool_calls!(tool_calls, llm_call_id: nil)
189
172
  @user_message_sent = true
190
173
  @pending_tool_calls = Array(tool_calls)
191
- @messages = @chat.messages
174
+ @tool_batch_llm_call_id = (llm_call_id || @current_llm_call_id)&.to_s
175
+ @current_llm_call_id = nil
192
176
  @pending_tool_calls.each do |tool_call|
193
177
  deliver_event(
194
178
  StreamEvent.new(
195
179
  type: :tool_call,
196
- payload: {tool_call: tool_call}
180
+ payload: {
181
+ tool_call: tool_call,
182
+ llm_call_id: @tool_batch_llm_call_id
183
+ }.compact
197
184
  )
198
185
  )
199
186
  end
@@ -208,8 +195,8 @@ module Phronomy
208
195
  @user_message_sent = true
209
196
  @output = response.content
210
197
  @usage = Phronomy::TokenUsage.from_tokens(response.tokens)
211
- @messages = @chat.messages
212
198
  @pending_tool_calls = []
199
+ @current_llm_call_id = nil
213
200
  self
214
201
  end
215
202
 
@@ -255,9 +242,10 @@ module Phronomy
255
242
 
256
243
  def record_tool_results!
257
244
  @tool_invocations.each do |invocation|
245
+ tool_content = invocation.result.to_s
258
246
  @chat.add_message(
259
247
  role: :tool,
260
- content: invocation.result.to_s,
248
+ content: tool_content,
261
249
  tool_call_id: invocation.tool_call_id
262
250
  )
263
251
  deliver_event(
@@ -266,12 +254,17 @@ module Phronomy
266
254
  payload: {
267
255
  tool_call_id: invocation.tool_call_id,
268
256
  tool_name: invocation.tool_name,
269
- tool_result: invocation.result
270
- }
257
+ tool_result: invocation.result,
258
+ tool_message: {
259
+ "role" => "tool",
260
+ "content" => tool_content,
261
+ "tool_call_id" => invocation.tool_call_id.to_s
262
+ },
263
+ llm_call_id: @tool_batch_llm_call_id
264
+ }.compact
271
265
  )
272
266
  )
273
267
  end
274
- @messages = @chat.messages
275
268
  clear_tool_batch!
276
269
  self
277
270
  end
@@ -361,6 +354,7 @@ module Phronomy
361
354
  end
362
355
 
363
356
  if event.type == :llm_failed
357
+ @current_llm_call_id = nil
364
358
  @error = result.error ||
365
359
  Phronomy::Error.new("LLM operation failed without an error")
366
360
  return
@@ -368,8 +362,12 @@ module Phronomy
368
362
 
369
363
  if result.error
370
364
  if result.error.is_a?(ToolCallIntercepted)
371
- accept_tool_calls!(result.error.tool_calls)
365
+ accept_tool_calls!(
366
+ result.error.tool_calls,
367
+ llm_call_id: result.error.llm_call_id
368
+ )
372
369
  else
370
+ @current_llm_call_id = nil
373
371
  @error = result.error
374
372
  end
375
373
  else