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
@@ -2,11 +2,17 @@
2
2
 
3
3
  module Phronomy
4
4
  module Tools
5
- # Wraps a Phronomy::Agent::Base subclass as a callable tool so that a parent
6
- # agent can delegate a one-shot sub-task through the same stateful pipeline.
5
+ # Wraps a Phronomy::Agent::Base subclass as a callable Tool.
6
+ #
7
+ # Agent-backed Tools are logically asynchronous rather than offloaded
8
+ # synchronous operations. Their ToolInvocation starts the child Agent and then
9
+ # returns to EventLoop immediately; the Tool completion handle settles when the
10
+ # child Agent FSMSession finishes. An OffloadPool worker is therefore never
11
+ # consumed merely to wait for another Agent.
7
12
  class Agent < Phronomy::Agent::Context::Capability::Base
8
- description "Wraps an agent as a tool"
9
- param :input, type: :string, desc: "The input to forward to the wrapped agent"
13
+ execution_mode :cooperative
14
+ description "Wraps a Phronomy::Agent as a Tool"
15
+ param :input, type: :string, desc: "The input to forward to the wrapped Agent"
10
16
 
11
17
  class << self
12
18
  def from_agent(agent_class, tool_name: nil, description: nil)
@@ -25,13 +31,38 @@ module Phronomy
25
31
 
26
32
  klass.tool_name(effective_name)
27
33
  klass.description(effective_desc)
28
- klass.define_method(:execute) do |input:|
34
+
35
+ # Preserve the synchronous Tool API for top-level callers. ToolInvocation
36
+ # never uses this path for Agent-backed Tools; it calls #call_async.
37
+ klass.define_method(:execute) do |input:, cancellation_token: nil|
38
+ invoke_options = {}
39
+ if cancellation_token
40
+ invoke_options[:config] = {cancellation_token: cancellation_token}
41
+ end
29
42
  result = Phronomy::Agent.run_once(
30
43
  definition: agent_class,
31
- input: input
44
+ input: input,
45
+ **invoke_options
32
46
  )
33
47
  result[:output].to_s
34
48
  end
49
+
50
+ # Internal asynchronous execution protocol used by Agent#call_async.
51
+ # The child Agent owns its own FSMSession/EventLoop lifecycle; this
52
+ # method only returns its completion handle and performs a short map.
53
+ klass.define_method(:execute_async) do |input:, cancellation_token: nil, config: {}|
54
+ persistence = Phronomy::Persistence::InMemory.new
55
+ agent = agent_class.create(persistence: persistence)
56
+ task_config = (config || {}).dup
57
+ if cancellation_token && !task_config[:cancellation_token]
58
+ task_config[:cancellation_token] = cancellation_token
59
+ end
60
+
61
+ agent.invoke_async(input, config: task_config).map do |result|
62
+ result[:output].to_s
63
+ end
64
+ end
65
+ klass.send(:private, :execute_async)
35
66
  klass
36
67
  end
37
68
 
@@ -49,6 +80,110 @@ module Phronomy
49
80
  .sub(/_tool$/, "")
50
81
  end
51
82
  end
83
+
84
+ # Agent-backed Tools have an asynchronous implementation that does not use
85
+ # ToolExecutor/OffloadPool. Validation and Tool error policy still match
86
+ # Capability::Base#call.
87
+ def call_async(
88
+ args,
89
+ cancellation_token: nil,
90
+ config: {}
91
+ )
92
+ cancellation_token&.raise_if_cancelled!
93
+ validated_args, schema_error = send(:validate_and_coerce, args)
94
+
95
+ if schema_error
96
+ return schema_error_task(schema_error)
97
+ end
98
+
99
+ source = execute_async(
100
+ **(validated_args || {}),
101
+ cancellation_token: cancellation_token,
102
+ config: config || {}
103
+ )
104
+ unless source.respond_to?(:on_complete)
105
+ raise Phronomy::ToolError,
106
+ "#{self.class.name} asynchronous execution must return a completion handle"
107
+ end
108
+
109
+ result_task = Phronomy::Task.deferred(name: "agent-tool-#{name}")
110
+ source.on_complete do |result, error|
111
+ if error
112
+ settle_async_error(result_task, error)
113
+ next
114
+ end
115
+
116
+ begin
117
+ result_task.complete(send(:truncate_result_if_needed, result))
118
+ rescue => result_error
119
+ settle_async_error(result_task, result_error)
120
+ end
121
+ end
122
+ result_task
123
+ rescue Phronomy::ToolError, Phronomy::CancellationError => error
124
+ failed_task(error)
125
+ rescue => error
126
+ result_task = Phronomy::Task.deferred(name: "agent-tool-#{name}")
127
+ settle_async_error(result_task, error)
128
+ result_task
129
+ end
130
+
131
+ private
132
+
133
+ # Subclasses created by .from_agent and Orchestrator override this method.
134
+ # It deliberately remains private so it is not part of the public Tool API.
135
+ def execute_async(input:, cancellation_token: nil, config: {})
136
+ task = Phronomy::Task.deferred(name: "agent-tool-#{name}-fallback")
137
+ begin
138
+ task.complete(execute(input: input, cancellation_token: cancellation_token))
139
+ rescue => error
140
+ task.fail(error)
141
+ end
142
+ task
143
+ end
144
+
145
+ def schema_error_task(schema_error)
146
+ task = Phronomy::Task.deferred(name: "agent-tool-#{name}-schema")
147
+ if self.class.on_schema_error == :raise
148
+ task.fail(Phronomy::ToolError.new(
149
+ "#{self.class.name} schema error: #{schema_error}"
150
+ ))
151
+ else
152
+ task.complete("Schema validation failed: #{schema_error}")
153
+ end
154
+ task
155
+ end
156
+
157
+ def failed_task(error)
158
+ Phronomy::Task.deferred(name: "agent-tool-#{name}-failed").tap do |task|
159
+ task.fail(error)
160
+ end
161
+ end
162
+
163
+ def settle_async_error(task, error)
164
+ if error.is_a?(Phronomy::ToolError) || error.is_a?(Phronomy::CancellationError)
165
+ task.fail(error)
166
+ return task
167
+ end
168
+
169
+ if self.class.on_error == :suppress
170
+ message = "[Phronomy] Tool #{self.class.name} suppressed error: " \
171
+ "#{error.class}: #{error.message}"
172
+ if Phronomy.configuration.logger
173
+ Phronomy.configuration.logger.warn(message)
174
+ else
175
+ warn message
176
+ end
177
+ task.complete("Tool error suppressed: #{error.message}")
178
+ else
179
+ wrapped = Phronomy::ToolError.new(
180
+ "#{self.class.name} execution failed: #{error.message}"
181
+ )
182
+ wrapped.set_backtrace(error.backtrace)
183
+ task.fail(wrapped)
184
+ end
185
+ task
186
+ end
52
187
  end
53
188
  end
54
189
  end
@@ -7,7 +7,7 @@ module Phronomy
7
7
  # Mixing this module into a VectorStore class provides three choices:
8
8
  #
9
9
  # 1. **Do nothing** — inherits default implementations from {VectorStore::Base}
10
- # that route through {BlockingAdapterPool} (the previous behaviour).
10
+ # that route through {OffloadPool}.
11
11
  #
12
12
  # 2. **Override selectively** — override only the async methods where the
13
13
  # backend has a native async driver, while the remaining methods fall back
@@ -30,7 +30,7 @@ module Phronomy
30
30
  module AsyncBackend
31
31
  # Async variant of {VectorStore::Base#add}.
32
32
  #
33
- # Submits the add call to {BlockingAdapterPool} by default.
33
+ # Submits the add call to {OffloadPool} by default.
34
34
  # Override to use a native async driver.
35
35
  #
36
36
  # @param id [String]
@@ -38,12 +38,13 @@ module Phronomy
38
38
  # @param metadata [Hash]
39
39
  # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
40
40
  # @param timeout [Numeric, nil]
41
- # @return [BlockingAdapterPool::PendingOperation]
41
+ # @return [OffloadPool::PendingOperation]
42
42
  # @api public
43
43
  def add_async(id:, embedding:, metadata: {}, cancellation_token: nil, timeout: nil)
44
- Phronomy::Runtime.instance.blocking_io.submit(
44
+ Phronomy::Runtime.instance.offload.submit(
45
45
  timeout: timeout,
46
- cancellation_token: cancellation_token
46
+ cancellation_token: cancellation_token,
47
+ on_full: :raise
47
48
  ) do
48
49
  add(id: id, embedding: embedding, metadata: metadata, cancellation_token: cancellation_token)
49
50
  end
@@ -51,19 +52,20 @@ module Phronomy
51
52
 
52
53
  # Async variant of {VectorStore::Base#search}.
53
54
  #
54
- # Submits the search call to {BlockingAdapterPool} by default.
55
+ # Submits the search call to {OffloadPool} by default.
55
56
  # Override to use a native async driver.
56
57
  #
57
58
  # @param query_embedding [Array<Float>]
58
59
  # @param k [Integer]
59
60
  # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
60
61
  # @param timeout [Numeric, nil]
61
- # @return [BlockingAdapterPool::PendingOperation]
62
+ # @return [OffloadPool::PendingOperation]
62
63
  # @api public
63
64
  def search_async(query_embedding:, k: 5, cancellation_token: nil, timeout: nil)
64
- Phronomy::Runtime.instance.blocking_io.submit(
65
+ Phronomy::Runtime.instance.offload.submit(
65
66
  timeout: timeout,
66
- cancellation_token: cancellation_token
67
+ cancellation_token: cancellation_token,
68
+ on_full: :raise
67
69
  ) do
68
70
  search(query_embedding: query_embedding, k: k, cancellation_token: cancellation_token)
69
71
  end
@@ -71,18 +73,19 @@ module Phronomy
71
73
 
72
74
  # Async variant of {VectorStore::Base#remove}.
73
75
  #
74
- # Submits the remove call to {BlockingAdapterPool} by default.
76
+ # Submits the remove call to {OffloadPool} by default.
75
77
  # Override to use a native async driver.
76
78
  #
77
79
  # @param id [String]
78
80
  # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
79
81
  # @param timeout [Numeric, nil]
80
- # @return [BlockingAdapterPool::PendingOperation]
82
+ # @return [OffloadPool::PendingOperation]
81
83
  # @api public
82
84
  def remove_async(id:, cancellation_token: nil, timeout: nil)
83
- Phronomy::Runtime.instance.blocking_io.submit(
85
+ Phronomy::Runtime.instance.offload.submit(
84
86
  timeout: timeout,
85
- cancellation_token: cancellation_token
87
+ cancellation_token: cancellation_token,
88
+ on_full: :raise
86
89
  ) do
87
90
  remove(id: id)
88
91
  end
@@ -90,17 +93,18 @@ module Phronomy
90
93
 
91
94
  # Async variant of {VectorStore::Base#clear}.
92
95
  #
93
- # Submits the clear call to {BlockingAdapterPool} by default.
96
+ # Submits the clear call to {OffloadPool} by default.
94
97
  # Override to use a native async driver.
95
98
  #
96
99
  # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
97
100
  # @param timeout [Numeric, nil]
98
- # @return [BlockingAdapterPool::PendingOperation]
101
+ # @return [OffloadPool::PendingOperation]
99
102
  # @api public
100
103
  def clear_async(cancellation_token: nil, timeout: nil)
101
- Phronomy::Runtime.instance.blocking_io.submit(
104
+ Phronomy::Runtime.instance.offload.submit(
102
105
  timeout: timeout,
103
- cancellation_token: cancellation_token
106
+ cancellation_token: cancellation_token,
107
+ on_full: :raise
104
108
  ) do
105
109
  clear
106
110
  end
@@ -9,8 +9,8 @@ module Phronomy
9
9
  #
10
10
  # Async methods (`search_async`, `add_async`, `remove_async`, `clear_async`)
11
11
  # are provided by the {AsyncBackend} mixin which defaults to routing calls
12
- # through {BlockingAdapterPool}. Backends with native async drivers may
13
- # override individual async methods without touching the pool at all.
12
+ # through {OffloadPool}. Backends with native async drivers may override
13
+ # individual async methods without touching the pool at all.
14
14
  class Base
15
15
  include AsyncBackend
16
16
 
@@ -19,18 +19,19 @@ module Phronomy
19
19
  raise NotImplementedError, "#{self.class}#embed is not implemented"
20
20
  end
21
21
 
22
- # Submits an {#embed} call to {BlockingAdapterPool} and returns a
23
- # {BlockingAdapterPool::PendingOperation}.
22
+ # Submits an {#embed} call to {OffloadPool} and returns an
23
+ # {OffloadPool::PendingOperation}.
24
24
  #
25
25
  # @param text [String]
26
26
  # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
27
27
  # @param timeout [Numeric, nil] seconds before the operation is abandoned
28
- # @return [BlockingAdapterPool::PendingOperation]
28
+ # @return [OffloadPool::PendingOperation]
29
29
  # @api public
30
30
  def embed_async(text, cancellation_token = nil, timeout: nil)
31
- Phronomy::Runtime.instance.blocking_io.submit(
31
+ Phronomy::Runtime.instance.offload.submit(
32
32
  timeout: timeout,
33
- cancellation_token: cancellation_token
33
+ cancellation_token: cancellation_token,
34
+ on_full: :raise
34
35
  ) do
35
36
  embed(text, cancellation_token)
36
37
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.17.0"
4
+ VERSION = "0.19.0"
5
5
  end
@@ -8,8 +8,8 @@ module Phronomy
8
8
  class Workflow
9
9
  include Phronomy::Runnable
10
10
 
11
- def self.define(context_class, state_store: nil, &block)
12
- builder = Builder.new(context_class, state_store: state_store)
11
+ def self.define(context_class, persistence: nil, &block)
12
+ builder = Builder.new(context_class, persistence: persistence)
13
13
  builder.instance_eval(&block)
14
14
  builder.build
15
15
  end
@@ -41,12 +41,13 @@ module Phronomy
41
41
  @runner.send_event(state: state, event: event, input: input)
42
42
  end
43
43
 
44
- # Sends an event to an active Workflow session without blocking.
44
+ # Sends an event to an active Workflow execution without blocking.
45
45
  #
46
- # This method is safe to call from an Agent/Tool listener running on the
47
- # EventLoop thread because it only enqueues a later dispatch.
46
+ # +thread_id+ is the logical/durable Workflow identity. EventLoop resolves it
47
+ # to the currently owning Runtime-only fsm_session_id. InvocationContext's
48
+ # application session_id is unrelated to this routing.
48
49
  #
49
- # @return [Boolean] true when admitted; false when the session is not live
50
+ # @return [Boolean] true when admitted; false when the Workflow is not live
50
51
  # or Runtime shutdown has begun
51
52
  # @api public
52
53
  def signal(thread_id:, event:, payload: nil)
@@ -76,9 +77,9 @@ module Phronomy
76
77
  class Builder
77
78
  FINISH = Phronomy::WorkflowRunner::FINISH
78
79
 
79
- def initialize(context_class, state_store: nil)
80
+ def initialize(context_class, persistence: nil)
80
81
  @context_class = context_class
81
- @state_store = state_store
82
+ @persistence = persistence
82
83
  @initial = nil
83
84
  @declared_states = []
84
85
  @entry_actions = {}
@@ -167,7 +168,7 @@ module Phronomy
167
168
  external_events: external_events,
168
169
  entry_point: @initial || @declared_states.first,
169
170
  wait_state_names: @wait_state_names.dup,
170
- state_store: @state_store
171
+ persistence: @persistence
171
172
  )
172
173
  Workflow.new(runner)
173
174
  end