phronomy 0.17.0 → 0.19.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 (107) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +122 -1134
  3. data/README.md +70 -1280
  4. data/benchmark/bench_regression.rb +25 -5
  5. data/docs/changelog/0.14-and-earlier.md +1137 -0
  6. data/docs/decisions/008-orchestrator-uses-os-threads.md +46 -48
  7. data/docs/decisions/009-state-store-abstraction.md +1 -1
  8. data/docs/decisions/010-cooperative-first-concurrency.md +155 -235
  9. data/docs/decisions/014-unified-persistence-durable-state.md +268 -0
  10. data/docs/features.md +93 -0
  11. data/docs/getting-started.md +387 -0
  12. data/docs/migrations/0.15.md +35 -0
  13. data/docs/migrations/0.16.md +43 -0
  14. data/docs/migrations/0.19.md +148 -0
  15. data/docs/runtime-and-concurrency.md +349 -0
  16. data/examples/workflows/generic_task_event_mapping.rb +14 -6
  17. data/lib/phronomy/agent/agent_invocation_session_builder.rb +2 -2
  18. data/lib/phronomy/agent/async_event_api.rb +3 -3
  19. data/lib/phronomy/agent/base.rb +116 -55
  20. data/lib/phronomy/agent/context/capability/base.rb +13 -3
  21. data/lib/phronomy/agent/context_assembler.rb +13 -3
  22. data/lib/phronomy/agent/execution_coordinator.rb +423 -252
  23. data/lib/phronomy/agent/journal_projection.rb +5 -1
  24. data/lib/phronomy/agent/shared_state.rb +2 -0
  25. data/lib/phronomy/agent/tool_executor.rb +29 -71
  26. data/lib/phronomy/agent/tool_invocation.rb +97 -47
  27. data/lib/phronomy/agent/tool_invocation_session_builder.rb +55 -161
  28. data/lib/phronomy/configuration.rb +7 -30
  29. data/lib/phronomy/diagnostics.rb +12 -41
  30. data/lib/phronomy/engine/concurrency/async_queue.rb +5 -188
  31. data/lib/phronomy/engine/concurrency/cancellation_scope.rb +6 -7
  32. data/lib/phronomy/engine/concurrency/cancellation_token.rb +48 -3
  33. data/lib/phronomy/engine/concurrency/deadline.rb +2 -3
  34. data/lib/phronomy/engine/concurrency/offload_pool.rb +696 -0
  35. data/lib/phronomy/engine/concurrency/pool_registry.rb +5 -5
  36. data/lib/phronomy/engine/event_loop.rb +170 -193
  37. data/lib/phronomy/engine/fsm_session.rb +6 -4
  38. data/lib/phronomy/engine/runtime/timer_queue.rb +48 -71
  39. data/lib/phronomy/engine/runtime/timer_service.rb +13 -21
  40. data/lib/phronomy/engine/runtime.rb +52 -158
  41. data/lib/phronomy/engine/task.rb +136 -277
  42. data/lib/phronomy/llm_adapter/base.rb +14 -14
  43. data/lib/phronomy/llm_adapter/ruby_llm.rb +3 -4
  44. data/lib/phronomy/llm_adapter.rb +2 -2
  45. data/lib/phronomy/metrics.rb +15 -30
  46. data/lib/phronomy/multi_agent/fan_out_invocation.rb +146 -0
  47. data/lib/phronomy/multi_agent/fan_out_session_builder.rb +125 -0
  48. data/lib/phronomy/multi_agent/handoff.rb +1 -0
  49. data/lib/phronomy/multi_agent/orchestrator.rb +147 -99
  50. data/lib/phronomy/multi_agent/team_coordinator.rb +2 -0
  51. data/lib/phronomy/persistence/in_memory.rb +113 -8
  52. data/lib/phronomy/persistence.rb +12 -3
  53. data/lib/phronomy/testing/eval/comparison.rb +23 -0
  54. data/lib/phronomy/testing/eval/dataset.rb +27 -0
  55. data/lib/phronomy/testing/eval/eval_case.rb +13 -0
  56. data/lib/phronomy/testing/eval/eval_result.rb +16 -0
  57. data/lib/phronomy/testing/eval/metrics.rb +43 -0
  58. data/lib/phronomy/testing/eval/runner.rb +52 -0
  59. data/lib/phronomy/testing/eval/scorer/base.rb +15 -0
  60. data/lib/phronomy/testing/eval/scorer/exact_match.rb +25 -0
  61. data/lib/phronomy/testing/eval/scorer/includes_scorer.rb +25 -0
  62. data/lib/phronomy/testing/eval/scorer/llm_judge.rb +46 -0
  63. data/lib/phronomy/testing/eval/scorer.rb +10 -0
  64. data/lib/phronomy/testing/eval.rb +9 -0
  65. data/lib/phronomy/testing/fake_clock.rb +6 -53
  66. data/lib/phronomy/testing.rb +2 -6
  67. data/lib/phronomy/tools/agent.rb +141 -6
  68. data/lib/phronomy/vector_store/async_backend.rb +21 -17
  69. data/lib/phronomy/vector_store/base.rb +2 -2
  70. data/lib/phronomy/vector_store/embeddings/base.rb +6 -5
  71. data/lib/phronomy/version.rb +1 -1
  72. data/lib/phronomy/workflow.rb +10 -9
  73. data/lib/phronomy/workflow_runner.rb +363 -99
  74. data/lib/phronomy.rb +10 -4
  75. data/scripts/api_snapshot.rb +4 -4
  76. metadata +25 -33
  77. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +0 -561
  78. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +0 -439
  79. data/lib/phronomy/engine/runtime/fake_scheduler.rb +0 -165
  80. data/lib/phronomy/engine/runtime/runtime_metrics.rb +0 -116
  81. data/lib/phronomy/engine/runtime/scheduler.rb +0 -98
  82. data/lib/phronomy/engine/runtime/scheduler_timer_adapter.rb +0 -79
  83. data/lib/phronomy/engine/runtime/task_registry.rb +0 -95
  84. data/lib/phronomy/engine/runtime/thread_scheduler.rb +0 -30
  85. data/lib/phronomy/engine/task/backend.rb +0 -80
  86. data/lib/phronomy/engine/task/deferred_backend.rb +0 -73
  87. data/lib/phronomy/engine/task/fiber_backend.rb +0 -157
  88. data/lib/phronomy/engine/task/immediate_backend.rb +0 -89
  89. data/lib/phronomy/engine/task/mapped_backend.rb +0 -90
  90. data/lib/phronomy/engine/task/thread_backend.rb +0 -84
  91. data/lib/phronomy/engine/task_group.rb +0 -193
  92. data/lib/phronomy/eval/comparison.rb +0 -47
  93. data/lib/phronomy/eval/dataset.rb +0 -45
  94. data/lib/phronomy/eval/eval_case.rb +0 -17
  95. data/lib/phronomy/eval/eval_result.rb +0 -29
  96. data/lib/phronomy/eval/metrics.rb +0 -66
  97. data/lib/phronomy/eval/runner.rb +0 -94
  98. data/lib/phronomy/eval/scorer/base.rb +0 -22
  99. data/lib/phronomy/eval/scorer/exact_match.rb +0 -31
  100. data/lib/phronomy/eval/scorer/includes_scorer.rb +0 -32
  101. data/lib/phronomy/eval/scorer/llm_judge.rb +0 -72
  102. data/lib/phronomy/eval/scorer.rb +0 -9
  103. data/lib/phronomy/eval.rb +0 -7
  104. data/lib/phronomy/state_store/base.rb +0 -48
  105. data/lib/phronomy/state_store/in_memory.rb +0 -62
  106. data/lib/phronomy/testing/fake_scheduler.rb +0 -104
  107. data/lib/phronomy/testing/scheduler_helpers.rb +0 -68
@@ -252,24 +252,22 @@ module Phronomy
252
252
  new(agent_id: agent_id, persistence: persistence, load_existing: true)
253
253
  end
254
254
 
255
- def approve(execution_id, approval_request_id:, persistence:, approved: true, config: {})
256
- approve_async(
257
- execution_id,
258
- approval_request_id: approval_request_id,
259
- approved: approved,
260
- config: config,
261
- persistence: persistence
262
- ).wait_result
263
- end
264
-
265
- def approve_async(execution_id, approval_request_id:, persistence:, approved: true, config: {})
266
- execution = persistence.executions.load(execution_id)
267
- load(execution.agent_id, persistence: persistence).approve_async(
268
- execution_id,
269
- approval_request_id: approval_request_id,
270
- approved: approved,
271
- config: config
272
- )
255
+ # Resolves the live Agent instance that currently owns execution_id in
256
+ # this process. This is a Runtime-local lookup, not durable rehydration.
257
+ def live_for_execution(execution_id)
258
+ activation = Phronomy::Runtime.instance.__agent_activations.fetch(execution_id)
259
+ unless activation
260
+ raise Phronomy::ExecutionRehydrationRequiredError,
261
+ "no live activation for #{execution_id}; durable rehydration is required"
262
+ end
263
+
264
+ agent = activation.agent
265
+ unless agent.is_a?(self)
266
+ raise ArgumentError,
267
+ "live activation #{execution_id} belongs to #{agent.class}, not #{self}"
268
+ end
269
+
270
+ agent
273
271
  end
274
272
  end
275
273
 
@@ -283,21 +281,33 @@ module Phronomy
283
281
  metadata: {},
284
282
  load_existing: false
285
283
  )
286
- @persistence = persistence || Phronomy::Persistence::InMemory.new
284
+ @persistence = persistence ||
285
+ Phronomy.configuration.persistence ||
286
+ Phronomy::Persistence::InMemory.new
287
287
  @agent_id = agent_id.to_s.freeze
288
- @root = if load_existing
289
- loaded = @persistence.agents.load(@agent_id)
290
- definition = self.class.agent_definition
291
- unless loaded.agent_definition_id == definition.fetch(:id) &&
292
- loaded.definition_version == definition.fetch(:version)
293
- raise Phronomy::ConfigurationError,
294
- "Agent definition mismatch for #{@agent_id}: stored " \
295
- "#{loaded.agent_definition_id}@#{loaded.definition_version}, runtime " \
296
- "#{definition.fetch(:id)}@#{definition.fetch(:version)}"
288
+
289
+ if load_existing
290
+ root = records = nil
291
+ @persistence.transaction do |tx|
292
+ root = tx.agents.load(@agent_id)
293
+ records = tx.journals.read(
294
+ @agent_id,
295
+ limit: root.journal_position
296
+ )
297
297
  end
298
- loaded
298
+ validate_loaded_definition!(root)
299
+ @root = root
300
+ @_phronomy_journal_records = Array(records).dup.freeze
299
301
  else
300
- create_agent_root!(context: context, knowledge: knowledge, metadata: metadata)
302
+ @root = create_agent_root!(
303
+ context: context,
304
+ knowledge: knowledge,
305
+ metadata: metadata
306
+ )
307
+ @_phronomy_journal_records = @persistence.journals.read(
308
+ @agent_id,
309
+ limit: @root.journal_position
310
+ ).dup.freeze
301
311
  end
302
312
  end
303
313
 
@@ -306,7 +316,10 @@ module Phronomy
306
316
  end
307
317
 
308
318
  def journal_projection
309
- Agent::JournalProjection.new(persistence: persistence, agent_root: @root)
319
+ Agent::JournalProjection.new(
320
+ agent_root: @root,
321
+ records: _journal_records_snapshot
322
+ )
310
323
  end
311
324
 
312
325
  def transcript
@@ -334,13 +347,14 @@ module Phronomy
334
347
  end
335
348
  end
336
349
 
337
- # Appends persistent Knowledge to the Agent Journal.
338
- # Knowledge is an optional Context candidate; it is not part of #transcript.
350
+ # Appends persistent Knowledge to the Agent Journal. The live Agent owns the
351
+ # current logical root/Journal view; Persistence is advanced optimistically.
339
352
  def add_knowledge(content, metadata: {})
353
+ current = agent_root
340
354
  next_root = nil
355
+ appended = nil
341
356
  persistence.transaction do |tx|
342
357
  tx.executions.assert_idle!(agent_id)
343
- current = tx.agents.load(agent_id)
344
358
  record = build_knowledge_record(
345
359
  tx: tx,
346
360
  root: current,
@@ -363,6 +377,7 @@ module Phronomy
363
377
  root: next_root
364
378
  )
365
379
  end
380
+ _append_journal_records(appended)
366
381
  @root = next_root
367
382
  self
368
383
  end
@@ -394,6 +409,7 @@ module Phronomy
394
409
  tx.agents.delete(agent_id)
395
410
  end
396
411
  @root = nil
412
+ @_phronomy_journal_records = [].freeze
397
413
  true
398
414
  end
399
415
 
@@ -404,6 +420,17 @@ module Phronomy
404
420
 
405
421
  private
406
422
 
423
+ def validate_loaded_definition!(loaded)
424
+ definition = self.class.agent_definition
425
+ return if loaded.agent_definition_id == definition.fetch(:id) &&
426
+ loaded.definition_version == definition.fetch(:version)
427
+
428
+ raise Phronomy::ConfigurationError,
429
+ "Agent definition mismatch for #{@agent_id}: stored " \
430
+ "#{loaded.agent_definition_id}@#{loaded.definition_version}, runtime " \
431
+ "#{definition.fetch(:id)}@#{definition.fetch(:version)}"
432
+ end
433
+
407
434
  def create_agent_root!(context:, knowledge:, metadata:)
408
435
  definition = self.class.agent_definition
409
436
  root = Agent::AgentRoot.create(
@@ -480,10 +507,11 @@ module Phronomy
480
507
  end
481
508
 
482
509
  def mutate_context!(kind, context_affecting: true)
510
+ current = agent_root
483
511
  next_root = nil
512
+ appended = nil
484
513
  persistence.transaction do |tx|
485
514
  tx.executions.assert_idle!(agent_id)
486
- current = tx.agents.load(agent_id)
487
515
  record = Agent::JournalRecord.new(
488
516
  agent_id: agent_id,
489
517
  kind: kind,
@@ -502,8 +530,13 @@ module Phronomy
502
530
  context_revision: context_affecting ?
503
531
  yield_context_revision(current, proposed) : current.context_revision
504
532
  )
505
- tx.agents.save(agent_id, expected_revision: current.agent_revision, root: next_root)
533
+ tx.agents.save(
534
+ agent_id,
535
+ expected_revision: current.agent_revision,
536
+ root: next_root
537
+ )
506
538
  end
539
+ _append_journal_records(appended)
507
540
  @root = next_root
508
541
  end
509
542
 
@@ -511,6 +544,18 @@ module Phronomy
511
544
  (proposed.context_revision == current.context_revision) ? current.context_revision + 1 : proposed.context_revision
512
545
  end
513
546
 
547
+ def _journal_records_snapshot
548
+ @_phronomy_journal_records || [].freeze
549
+ end
550
+
551
+ def _append_journal_records(records)
552
+ incoming = Array(records)
553
+ return _journal_records_snapshot if incoming.empty?
554
+
555
+ @_phronomy_journal_records =
556
+ (_journal_records_snapshot + incoming).freeze
557
+ end
558
+
514
559
  public
515
560
 
516
561
  def _add_handoff_tool(tool_class)
@@ -553,35 +598,20 @@ module Phronomy
553
598
  [effective_thread_id, effective_config]
554
599
  end
555
600
 
556
- def _check_scheduler_reentrancy(sync_method, async_method)
601
+ def _check_event_loop_reentrancy(sync_method, async_method)
557
602
  if Phronomy::Runtime.instance.event_loop.current?
558
- raise Phronomy::SchedulerReentrancyError,
603
+ raise Phronomy::EventLoopReentrancyError,
559
604
  "#{self.class.name}##{sync_method} cannot run on the EventLoop thread. " \
560
605
  "Use #{async_method} and return immediately."
561
606
  end
562
-
563
- return unless Phronomy::Task.current
564
-
565
- msg = "#{self.class.name}##{sync_method} called from inside a scheduler task. " \
566
- "This blocks the scheduler until the inner invocation completes, preventing " \
567
- "other tasks from making progress. Use #{async_method} + await instead."
568
- if Phronomy.configuration.strict_runtime_guards
569
- raise Phronomy::SchedulerReentrancyError, msg
570
- elsif Phronomy.configuration.logger
571
- Phronomy.configuration.logger.warn(msg)
572
- else
573
- Kernel.warn("[phronomy] WARNING: #{msg}")
574
- end
575
607
  end
576
608
 
577
609
  def _complete_result_task(task, result)
578
- task.backend.unblock(result, nil)
579
- task.transition!(:completed, value: result)
610
+ task.complete(result)
580
611
  end
581
612
 
582
613
  def _fail_result_task(task, error)
583
- task.backend.unblock(nil, error)
584
- task.transition!(:failed, error: error)
614
+ task.fail(error)
585
615
  end
586
616
 
587
617
  def _translated_error(error)
@@ -782,6 +812,10 @@ module Phronomy
782
812
  return resolved if result_filters.empty?
783
813
 
784
814
  effective_name = resolved.new.name
815
+ custom_async_call =
816
+ resolved.instance_method(:call_async).owner !=
817
+ Phronomy::Agent::Context::Capability::Base
818
+
785
819
  Class.new(resolved) do
786
820
  tool_name effective_name
787
821
  define_method(:call) do |args, **kwargs|
@@ -790,6 +824,33 @@ module Phronomy
790
824
  filter.call(val, tool_name: name, args: args)
791
825
  }
792
826
  end
827
+
828
+ # Base#call_async ultimately executes #call, so the synchronous wrapper
829
+ # above already applies filters for ordinary Tools. Agent-backed Tools
830
+ # override #call_async and bypass #call; only those custom async Tools
831
+ # need an asynchronous result-filter wrapper here.
832
+ if custom_async_call
833
+ define_method(:call_async) do |args, **kwargs|
834
+ source = super(args, **kwargs)
835
+ filtered = Phronomy::Task.deferred(name: "tool-filter-#{name}")
836
+ source.on_complete do |value, error|
837
+ if error
838
+ filtered.fail(error)
839
+ next
840
+ end
841
+
842
+ begin
843
+ result = result_filters.inject(value) { |val, filter|
844
+ filter.call(val, tool_name: name, args: args)
845
+ }
846
+ filtered.complete(result)
847
+ rescue => filter_error
848
+ filtered.fail(filter_error)
849
+ end
850
+ end
851
+ filtered
852
+ end
853
+ end
793
854
  end
794
855
  end
795
856
  end
@@ -113,16 +113,22 @@ module Phronomy
113
113
 
114
114
  public
115
115
 
116
+ # Declares whether Tool work is safe to run cooperatively on the EventLoop
117
+ # or must be offloaded to a bounded worker pool.
118
+ #
119
+ # Phronomy does not classify the reason for offloading. Blocking I/O,
120
+ # CPU-bound synchronous work, and other long synchronous calls all use
121
+ # +:offloaded+. The application owns that workload classification.
116
122
  # @api public
117
123
  def execution_mode(value = nil)
118
124
  if value.nil?
119
125
  return @execution_mode if instance_variable_defined?(:@execution_mode)
120
126
  return superclass.execution_mode if superclass.respond_to?(:execution_mode)
121
127
 
122
- return :blocking_io
128
+ return :offloaded
123
129
  end
124
130
 
125
- valid = %i[cooperative blocking_io cpu_bound external_process]
131
+ valid = %i[cooperative offloaded]
126
132
  unless valid.include?(value)
127
133
  raise ArgumentError,
128
134
  "execution_mode must be one of #{valid.inspect}, got #{value.inspect}"
@@ -296,7 +302,11 @@ module Phronomy
296
302
  end
297
303
 
298
304
  # @api public
299
- def call_async(args, cancellation_token: nil, config: {})
305
+ def call_async(
306
+ args,
307
+ cancellation_token: nil,
308
+ config: {}
309
+ )
300
310
  Phronomy::Agent::ToolExecutor.call_async(
301
311
  tool: self,
302
312
  args: args,
@@ -14,18 +14,20 @@ module Phronomy
14
14
  agent:,
15
15
  persistence:,
16
16
  policy: ContextPolicies::Default.new,
17
- candidate_resolver: nil
17
+ candidate_resolver: nil,
18
+ journal_records: nil
18
19
  )
19
20
  @agent = agent
20
21
  @persistence = persistence
21
22
  @policy = policy
23
+ @journal_records = journal_records
22
24
  @candidate_resolver = candidate_resolver || ContextCandidateResolver.new(
23
25
  content_loader: method(:fetch_content)
24
26
  )
25
27
  end
26
28
 
27
29
  def build_initial(input:, agent_root:, execution:, config: {}, patch: LLMInputPatch.empty)
28
- projection = JournalProjection.new(persistence: @persistence, agent_root: agent_root)
30
+ projection = journal_projection(agent_root)
29
31
  model_cfg = effective_model_config(config, patch)
30
32
  tool_set = ToolDefinitionSet.build(@agent)
31
33
  system_text = build_system_text(input)
@@ -65,7 +67,7 @@ module Phronomy
65
67
  config: {},
66
68
  patch: LLMInputPatch.empty
67
69
  )
68
- projection = JournalProjection.new(persistence: @persistence, agent_root: agent_root)
70
+ projection = journal_projection(agent_root)
69
71
  model_cfg = effective_model_config(config, patch)
70
72
  hook_candidates = normalize_candidates(patch.segment_candidates)
71
73
  system_segments = base_manifest.segments.select do |segment|
@@ -96,6 +98,14 @@ module Phronomy
96
98
 
97
99
  private
98
100
 
101
+ def journal_projection(agent_root)
102
+ if @journal_records
103
+ JournalProjection.new(agent_root: agent_root, records: @journal_records)
104
+ else
105
+ JournalProjection.new(persistence: @persistence, agent_root: agent_root)
106
+ end
107
+ end
108
+
99
109
  def assemble(
100
110
  agent_root:,
101
111
  execution:,