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
@@ -6,6 +6,7 @@ module Phronomy
6
6
  #
7
7
  # Blocking/provider work returns through explicit Agent-internal events.
8
8
  # Entry actions start operations and return synchronously.
9
+ # Every LLM Call is prepared from a canonical Manifest and RuntimeProjection.
9
10
  #
10
11
  # @api private
11
12
  class AgentInvocationSessionBuilder
@@ -40,7 +41,6 @@ module Phronomy
40
41
  def self.build(
41
42
  agent:,
42
43
  input:,
43
- messages:,
44
44
  config:,
45
45
  approval_policy: nil,
46
46
  approval_listener: nil,
@@ -51,7 +51,6 @@ module Phronomy
51
51
  invocation = AgentInvocation.new(
52
52
  agent: agent,
53
53
  input: input,
54
- messages: messages,
55
54
  config: config,
56
55
  approval_policy: approval_policy,
57
56
  approval_listener: approval_listener,
@@ -132,6 +131,17 @@ module Phronomy
132
131
  ],
133
132
  resume: [
134
133
  {from: :suspended, to: :waiting_for_tools, guard: nil}
134
+ ],
135
+ application_callback_failed: [
136
+ {from: :filtering_input, to: :failed, guard: nil},
137
+ {from: :building_context, to: :failed, guard: nil},
138
+ {from: :calling_llm, to: :failed, guard: nil},
139
+ {from: :starting_tools, to: :failed, guard: nil},
140
+ {from: :evaluating_tools, to: :failed, guard: nil},
141
+ {from: :waiting_for_tools, to: :failed, guard: nil},
142
+ {from: :dispatching_tools, to: :failed, guard: nil},
143
+ {from: :recording_tool_results, to: :failed, guard: nil},
144
+ {from: :output_filtering, to: :failed, guard: nil}
135
145
  ]
136
146
  )
137
147
  end
@@ -170,84 +180,78 @@ module Phronomy
170
180
  end
171
181
  private_class_method :build_entry_actions
172
182
 
173
- def self.filtering_input_action(agent, invocation)
174
- agent.send(
175
- :check_cancellation!,
176
- invocation.config,
177
- "invocation cancelled before input filtering"
178
- )
179
- invocation.input = agent.send(
180
- :run_input_filters!,
181
- invocation.input
182
- )
183
- invocation
184
- rescue Phronomy::FilterBlockError => error
185
- invocation.input_blocked = true
186
- invocation.block_error = error
183
+ def self.filtering_input_action(_agent, invocation)
184
+ invocation.input = invocation.config.fetch(:phronomy_filtered_input)
187
185
  invocation
188
186
  end
189
187
  private_class_method :filtering_input_action
190
188
 
191
189
  def self.building_context_action(agent, invocation)
192
- invocation.chat = agent.send(:build_chat)
193
- context = agent.send(
194
- :build_context,
195
- invocation.input,
196
- messages: invocation.messages,
197
- thread_id: invocation.thread_id,
198
- config: invocation.config,
199
- budget: agent.send(:build_token_budget),
200
- instruction: agent.send(:build_instructions, invocation.input),
201
- tools: agent.class.tools + agent.send(:_handoff_tools)
202
- )
203
- agent.send(:_apply_context_to_chat, invocation.chat, context)
190
+ projection = invocation.config.fetch(:phronomy_runtime_projection)
191
+ invocation.chat = agent.send(:build_chat, model_config: projection.model_config)
204
192
  agent.send(
205
- :run_before_completion_hooks!,
193
+ :_apply_runtime_projection_to_chat,
206
194
  invocation.chat,
207
- invocation.config
195
+ projection,
196
+ invocation: invocation
208
197
  )
209
- install_tool_interceptors(invocation.chat)
198
+ install_tool_interceptors(invocation.chat, invocation)
210
199
  invocation
211
200
  end
212
201
  private_class_method :building_context_action
213
202
 
214
- def self.install_tool_interceptors(chat)
203
+ # RubyLLM >= 1.15 adds the complete assistant Message to chat.messages
204
+ # before before_tool_call is fired. Capture that Message at the control
205
+ # boundary instead of trying to recover it after the exception unwinds.
206
+ def self.install_tool_interceptors(chat, invocation = nil)
207
+ unless chat.respond_to?(:before_tool_call)
208
+ raise Phronomy::ConfigurationError,
209
+ "Agent-owned Tool execution requires RubyLLM >= 1.15 (before_tool_call callback)"
210
+ end
211
+
215
212
  if chat.respond_to?(:on_tool_call_batch)
216
213
  chat.on_tool_call_batch do |tool_calls|
217
- raise Phronomy::Agent::ToolCallIntercepted.new(tool_calls)
214
+ raise build_tool_interception(chat, tool_calls, invocation)
218
215
  end
219
216
  end
220
217
 
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
218
+ chat.before_tool_call do |tool_call|
219
+ raise build_tool_interception(chat, [tool_call], invocation)
229
220
  end
230
221
  end
231
222
  private_class_method :install_tool_interceptors
232
223
 
233
- def self.calling_llm_action(agent, runtime, invocation)
234
- user_message = invocation.user_message_sent ?
235
- nil :
236
- agent.send(:extract_message, invocation.input)
237
- agent.send(
238
- :check_cancellation!,
239
- invocation.config,
240
- "invocation cancelled before LLM call"
241
- )
242
- operation = Phronomy.configuration.llm_adapter.complete_async(
243
- invocation.chat,
244
- user_message,
245
- config: invocation.config
224
+ def self.build_tool_interception(chat, fallback_tool_calls, invocation)
225
+ assistant_message = chat.messages.last
226
+ unless assistant_message&.respond_to?(:role) &&
227
+ assistant_message.role.to_sym == :assistant &&
228
+ assistant_message.respond_to?(:tool_calls)
229
+ raise Phronomy::Error,
230
+ "RubyLLM Tool callback fired before the complete assistant message was observable"
231
+ end
232
+
233
+ message_tool_calls = assistant_message.tool_calls
234
+ tool_calls = if message_tool_calls.respond_to?(:values)
235
+ message_tool_calls.values
236
+ else
237
+ Array(message_tool_calls)
238
+ end
239
+ tool_calls = Array(fallback_tool_calls) if tool_calls.empty?
240
+
241
+ ToolCallIntercepted.new(
242
+ tool_calls,
243
+ assistant_message: assistant_message,
244
+ assistant_outcome: ProviderCallOutcome.capture(assistant_message),
245
+ llm_call_id: invocation&.current_llm_call_id
246
246
  )
247
- observe_llm_operation(
248
- operation,
247
+ end
248
+ private_class_method :build_tool_interception
249
+
250
+ def self.calling_llm_action(agent, runtime, invocation)
251
+ prepare_and_start_llm_call(
252
+ agent,
253
+ runtime,
249
254
  invocation,
250
- runtime: runtime,
251
255
  streaming: false
252
256
  )
253
257
  invocation
@@ -255,77 +259,175 @@ module Phronomy
255
259
  private_class_method :calling_llm_action
256
260
 
257
261
  def self.calling_llm_stream_action(agent, runtime, invocation)
258
- user_message = invocation.user_message_sent ?
259
- nil :
260
- agent.send(:extract_message, invocation.input)
262
+ prepare_and_start_llm_call(
263
+ agent,
264
+ runtime,
265
+ invocation,
266
+ streaming: true
267
+ )
268
+ invocation
269
+ end
270
+ private_class_method :calling_llm_stream_action
271
+
272
+ def self.prepare_and_start_llm_call(agent, runtime, invocation, streaming:)
273
+ activation = invocation.config.fetch(:phronomy_activation)
274
+ if invocation.user_message_sent
275
+ preparation = runtime.blocking_io.submit do
276
+ activation.coordinator.prepare_next_llm_call(activation)
277
+ end
278
+ preparation.on_complete do |projection, error|
279
+ if error
280
+ post_preparation_failure(runtime, invocation, error, streaming: streaming)
281
+ else
282
+ start_provider_call(
283
+ agent,
284
+ runtime,
285
+ invocation,
286
+ activation,
287
+ projection,
288
+ streaming: streaming,
289
+ replace_messages: true
290
+ )
291
+ end
292
+ end
293
+ else
294
+ start_provider_call(
295
+ agent,
296
+ runtime,
297
+ invocation,
298
+ activation,
299
+ activation.runtime_projection,
300
+ streaming: streaming,
301
+ replace_messages: false
302
+ )
303
+ end
304
+ end
305
+ private_class_method :prepare_and_start_llm_call
306
+
307
+ def self.start_provider_call(
308
+ agent,
309
+ runtime,
310
+ invocation,
311
+ activation,
312
+ projection,
313
+ streaming:,
314
+ replace_messages:
315
+ )
316
+ call_started = false
261
317
  agent.send(
262
318
  :check_cancellation!,
263
319
  invocation.config,
264
320
  "invocation cancelled before LLM call"
265
321
  )
266
-
267
- operation = Phronomy.configuration.llm_adapter.stream_async(
268
- invocation.chat,
269
- user_message,
270
- config: invocation.config
271
- ) do |chunk|
322
+ if replace_messages
323
+ invocation.chat = agent.send(:build_chat, model_config: projection.model_config)
272
324
  agent.send(
273
- :check_cancellation!,
274
- invocation.config,
275
- "invocation cancelled during streaming"
276
- )
277
- post_to_invocation!(
278
- runtime,
279
- invocation.id,
280
- :llm_stream_chunk,
281
- {content: chunk.content}
325
+ :_apply_runtime_projection_to_chat,
326
+ invocation.chat,
327
+ projection,
328
+ invocation: invocation
282
329
  )
330
+ install_tool_interceptors(invocation.chat, invocation)
331
+ invocation.config[:phronomy_runtime_projection] = projection
283
332
  end
284
333
 
285
- observe_llm_operation(
334
+ call_context = activation.begin_llm_call(projection)
335
+ call_started = true
336
+ invocation.begin_llm_call!(call_context.fetch(:llm_call_id))
337
+ message = projection.ask_message
338
+
339
+ operation = if streaming
340
+ Phronomy.configuration.llm_adapter.stream_async(
341
+ invocation.chat,
342
+ message,
343
+ config: invocation.config
344
+ ) do |chunk|
345
+ agent.send(
346
+ :check_cancellation!,
347
+ invocation.config,
348
+ "invocation cancelled during streaming"
349
+ )
350
+ post_to_invocation!(
351
+ runtime,
352
+ invocation.id,
353
+ :llm_stream_chunk,
354
+ {content: chunk.content}
355
+ )
356
+ end
357
+ else
358
+ Phronomy.configuration.llm_adapter.complete_async(
359
+ invocation.chat,
360
+ message,
361
+ config: invocation.config
362
+ )
363
+ end
364
+ observe_manifest_call(
286
365
  operation,
366
+ activation,
287
367
  invocation,
288
368
  runtime: runtime,
289
- streaming: true
369
+ streaming: streaming
290
370
  )
291
- invocation
371
+ rescue => error
372
+ if call_started
373
+ activation.record_llm_result(
374
+ response: canonical_response_for(nil, error),
375
+ error: error,
376
+ streaming: streaming
377
+ )
378
+ end
379
+ post_llm_result(runtime, invocation, nil, error, streaming: streaming)
292
380
  end
293
- private_class_method :calling_llm_stream_action
381
+ private_class_method :start_provider_call
294
382
 
295
- def self.observe_llm_operation(
296
- operation,
297
- invocation,
298
- runtime:,
299
- streaming:
300
- )
383
+ def self.observe_manifest_call(operation, activation, invocation, runtime:, streaming:)
301
384
  operation.on_complete do |response, error|
302
- result = LLMOperationResult.new(
303
- response: response,
385
+ activation.record_llm_result(
386
+ response: canonical_response_for(response, error),
304
387
  error: error,
305
388
  streaming: streaming
306
389
  )
307
- event_type =
308
- if error && !error.is_a?(ToolCallIntercepted)
309
- :llm_failed
310
- else
311
- :llm_completed
312
- end
313
- post_to_invocation!(
390
+ post_llm_result(
314
391
  runtime,
315
- invocation.id,
316
- event_type,
317
- result
392
+ invocation,
393
+ response,
394
+ error,
395
+ streaming: streaming
318
396
  )
319
397
  end
320
398
  end
321
- private_class_method :observe_llm_operation
399
+ private_class_method :observe_manifest_call
322
400
 
323
- def self.post_to_invocation!(
324
- runtime,
325
- invocation_id,
326
- event_type,
327
- payload
328
- )
401
+ def self.canonical_response_for(response, error)
402
+ if error.is_a?(ToolCallIntercepted)
403
+ error.assistant_outcome || ProviderCallOutcome.capture(response)
404
+ else
405
+ ProviderCallOutcome.capture(response)
406
+ end
407
+ end
408
+ private_class_method :canonical_response_for
409
+
410
+ def self.post_preparation_failure(runtime, invocation, error, streaming:)
411
+ post_llm_result(runtime, invocation, nil, error, streaming: streaming)
412
+ end
413
+ private_class_method :post_preparation_failure
414
+
415
+ def self.post_llm_result(runtime, invocation, response, error, streaming:)
416
+ result = LLMOperationResult.new(
417
+ response: response,
418
+ error: error,
419
+ streaming: streaming
420
+ )
421
+ event_type = if error && !error.is_a?(ToolCallIntercepted)
422
+ :llm_failed
423
+ else
424
+ :llm_completed
425
+ end
426
+ post_to_invocation!(runtime, invocation.id, event_type, result)
427
+ end
428
+ private_class_method :post_llm_result
429
+
430
+ def self.post_to_invocation!(runtime, invocation_id, event_type, payload)
329
431
  accepted = runtime.event_loop.post_to_session(
330
432
  Phronomy::Event.new(
331
433
  type: event_type,
@@ -0,0 +1,66 @@
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
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
+ created_at: now,
30
+ updated_at: now,
31
+ metadata: metadata
32
+ )
33
+ end
34
+
35
+ def initialize(**attributes)
36
+ ATTRIBUTES.each do |name|
37
+ value = attributes.fetch(name)
38
+ value = value.to_sym if name == :lifecycle_status
39
+ instance_variable_set("@#{name}", Immutable.copy(value))
40
+ end
41
+ unless LIFECYCLE_STATUSES.include?(lifecycle_status)
42
+ raise ArgumentError, "unknown Agent lifecycle status: #{lifecycle_status.inspect}"
43
+ end
44
+ raise ArgumentError, "agent_revision must be non-negative" if agent_revision.negative?
45
+ raise ArgumentError, "context_revision must be non-negative" if context_revision.negative?
46
+ raise ArgumentError, "journal_position must be non-negative" if journal_position.negative?
47
+ Immutable.validate_canonical_json!(metadata, label: "Agent metadata")
48
+ freeze
49
+ end
50
+
51
+ def with(**changes)
52
+ values = to_h.transform_keys(&:to_sym).merge(changes)
53
+ values[:updated_at] = Time.now.utc.iso8601(6) unless changes.key?(:updated_at)
54
+ self.class.new(**values)
55
+ end
56
+
57
+ def to_h
58
+ ATTRIBUTES.to_h { |name| [name.to_s, public_send(name)] }
59
+ end
60
+
61
+ def self.from_h(hash)
62
+ new(**ATTRIBUTES.to_h { |name| [name, hash.fetch(name.to_s)] })
63
+ end
64
+ end
65
+ end
66
+ end