phronomy 0.7.1 → 0.8.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 (104) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +16 -16
  3. data/benchmark/bench_context_assembler.rb +2 -2
  4. data/benchmark/bench_regression.rb +5 -5
  5. data/benchmark/bench_token_estimator.rb +5 -5
  6. data/benchmark/bench_tool_schema.rb +1 -1
  7. data/benchmark/bench_vector_store.rb +1 -1
  8. data/lib/phronomy/agent/base.rb +86 -123
  9. data/lib/phronomy/agent/checkpoint.rb +118 -0
  10. data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
  11. data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
  12. data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
  13. data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
  14. data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
  15. data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
  16. data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
  17. data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
  18. data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
  19. data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
  20. data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
  21. data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
  22. data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
  23. data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
  24. data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
  25. data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
  26. data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
  27. data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
  28. data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
  29. data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
  30. data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
  31. data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
  32. data/lib/phronomy/agent/fsm.rb +1 -1
  33. data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
  34. data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
  35. data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
  36. data/lib/phronomy/agent/react_agent.rb +19 -14
  37. data/lib/phronomy/agent/runner.rb +2 -2
  38. data/lib/phronomy/agent/tool_executor.rb +108 -0
  39. data/lib/phronomy/concurrency/async_queue.rb +157 -0
  40. data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
  41. data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
  42. data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
  43. data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
  44. data/lib/phronomy/concurrency/deadline.rb +65 -0
  45. data/lib/phronomy/{runtime → concurrency}/gate_registry.rb +1 -1
  46. data/lib/phronomy/{runtime → concurrency}/pool_registry.rb +1 -1
  47. data/lib/phronomy/context.rb +2 -8
  48. data/lib/phronomy/embeddings.rb +2 -2
  49. data/lib/phronomy/eval/runner.rb +4 -0
  50. data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
  51. data/lib/phronomy/event_loop.rb +7 -7
  52. data/lib/phronomy/invocation_context.rb +3 -3
  53. data/lib/phronomy/knowledge_source.rb +0 -5
  54. data/lib/phronomy/llm_adapter/ruby_llm.rb +17 -11
  55. data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
  56. data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
  57. data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
  58. data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
  59. data/lib/phronomy/loader.rb +4 -4
  60. data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
  61. data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +6 -6
  62. data/lib/phronomy/{agent → multi_agent}/parallel_tool_chat.rb +4 -4
  63. data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
  64. data/lib/phronomy/runtime.rb +19 -4
  65. data/lib/phronomy/splitter.rb +3 -3
  66. data/lib/phronomy/task_group.rb +1 -1
  67. data/lib/phronomy/tool/base.rb +50 -9
  68. data/lib/phronomy/tracing/null_tracer.rb +3 -1
  69. data/lib/phronomy/vector_store.rb +2 -2
  70. data/lib/phronomy/version.rb +1 -1
  71. data/lib/phronomy/workflow_context.rb +8 -0
  72. data/lib/phronomy/workflow_runner.rb +11 -131
  73. data/lib/phronomy.rb +1 -0
  74. metadata +44 -42
  75. data/lib/phronomy/async_queue.rb +0 -155
  76. data/lib/phronomy/blocking_adapter_pool.rb +0 -435
  77. data/lib/phronomy/cancellation_scope.rb +0 -123
  78. data/lib/phronomy/cancellation_token.rb +0 -133
  79. data/lib/phronomy/concurrency_gate.rb +0 -155
  80. data/lib/phronomy/context/compaction_context.rb +0 -111
  81. data/lib/phronomy/context/trigger_context.rb +0 -39
  82. data/lib/phronomy/context/trim_context.rb +0 -75
  83. data/lib/phronomy/deadline.rb +0 -63
  84. data/lib/phronomy/embeddings/base.rb +0 -39
  85. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
  86. data/lib/phronomy/fsm_session.rb +0 -247
  87. data/lib/phronomy/knowledge_source/base.rb +0 -54
  88. data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
  89. data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
  90. data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
  91. data/lib/phronomy/loader/base.rb +0 -25
  92. data/lib/phronomy/loader/csv_loader.rb +0 -56
  93. data/lib/phronomy/loader/markdown_loader.rb +0 -76
  94. data/lib/phronomy/loader/plain_text_loader.rb +0 -22
  95. data/lib/phronomy/prompt_template.rb +0 -96
  96. data/lib/phronomy/splitter/base.rb +0 -47
  97. data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
  98. data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
  99. data/lib/phronomy/tool_executor.rb +0 -106
  100. data/lib/phronomy/vector_store/async_backend.rb +0 -110
  101. data/lib/phronomy/vector_store/base.rb +0 -89
  102. data/lib/phronomy/vector_store/in_memory.rb +0 -93
  103. data/lib/phronomy/vector_store/pgvector.rb +0 -127
  104. data/lib/phronomy/vector_store/redis_search.rb +0 -192
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Centralises tool execution routing based on {Tool::Base.execution_mode}.
6
+ #
7
+ # This is the single place in the framework that decides *how* a tool call is
8
+ # dispatched:
9
+ #
10
+ # - +:cooperative+ — dispatched via +Runtime#spawn+ through the configured
11
+ # scheduler. Under the +:fiber+ backend this avoids an
12
+ # extra OS thread; under the +:thread+ backend it is
13
+ # backed by +ThreadScheduler+ (one thread per task).
14
+ # - +:blocking_io+ — submitted to +BlockingAdapterPool+ when the runtime
15
+ # provides a pool; falls back to +Runtime#spawn+ otherwise.
16
+ # - +:cpu_bound+ — emits a deprecation-style warning then falls back to
17
+ # +:blocking_io+ routing (no process pool available yet).
18
+ # - +:external_process+ — falls back to +:blocking_io+ routing (no process
19
+ # manager available yet).
20
+ #
21
+ # All paths return an object that responds to +#await+ (+Phronomy::Task+ or
22
+ # +BlockingAdapterPool::PendingOperation+), so callers can collect results
23
+ # uniformly.
24
+ #
25
+ # @note Non-goals
26
+ # ToolExecutor deliberately does NOT provide:
27
+ # - A CPU-bound process pool. CPU-intensive tool work must be handled at the
28
+ # application layer (e.g., fork, Sidekiq, separate OS processes). The
29
+ # framework will not add a +ProcessPoolExecutor+ equivalent.
30
+ # - An external process manager. Spawning or supervising subprocesses is
31
+ # out of scope for this module.
32
+ # - Additional core execution routes beyond scheduler-backed cooperative
33
+ # execution and BlockingAdapterPool-backed blocking I/O isolation.
34
+ # The +:cpu_bound+ and +:external_process+ modes are accepted for
35
+ # compatibility but both fall back to +:blocking_io+ routing with a
36
+ # one-time warning. If a genuinely new core execution route is needed,
37
+ # a new ADR is required.
38
+ # These non-goals follow from the cooperative-first, non-preemptive
39
+ # concurrency model (ADR-010): framework components must not assume the
40
+ # caller's concurrency model, and CPU/process management belongs to the
41
+ # application layer.
42
+ #
43
+ # @api private
44
+ module ToolExecutor
45
+ # Tracks tool classes that have already emitted an execution_mode warning so
46
+ # that the same warning is only logged once per process lifetime.
47
+ WARNED_MODES = Set.new
48
+ WARNED_MODES_MUTEX = Mutex.new
49
+ private_constant :WARNED_MODES, :WARNED_MODES_MUTEX
50
+
51
+ # Dispatches a single tool call asynchronously according to its
52
+ # +execution_mode+ and returns an awaitable.
53
+ #
54
+ # @param tool [Phronomy::Tool::Base] the tool instance to invoke
55
+ # @param args [Hash] argument hash to pass to {Tool::Base#call}
56
+ # @param cancellation_token [Phronomy::Concurrency::CancellationToken, nil]
57
+ # @param runtime [Phronomy::Runtime] runtime to use for spawning
58
+ # (defaults to {Runtime.instance}; injectable for tests)
59
+ # @return [#await] a {Phronomy::Task} or {BlockingAdapterPool::PendingOperation}
60
+ # @api private
61
+ def self.call_async(tool:, args:, cancellation_token: nil, runtime: Phronomy::Runtime.instance)
62
+ ct = cancellation_token
63
+ mode = tool.class.execution_mode
64
+
65
+ # Warn and normalise unsupported modes to :blocking_io.
66
+ # Each (tool class, mode) pair emits the warning at most once per process
67
+ # lifetime to avoid log flooding in high-throughput scenarios.
68
+ if mode == :cpu_bound || mode == :external_process
69
+ warn_key = [tool.class.name, mode]
70
+ newly_warned = WARNED_MODES_MUTEX.synchronize { WARNED_MODES.add?(warn_key) }
71
+ if newly_warned
72
+ msg = if mode == :cpu_bound
73
+ "[Phronomy] Tool #{tool.class.name} declares execution_mode :cpu_bound, " \
74
+ "which has no dedicated executor. " \
75
+ "Falling back to blocking_io (BlockingAdapterPool). " \
76
+ "Use :blocking_io explicitly to suppress this warning."
77
+ else
78
+ "[Phronomy] Tool #{tool.class.name} declares execution_mode :external_process, " \
79
+ "which has no dedicated process manager. " \
80
+ "Falling back to blocking_io (BlockingAdapterPool)."
81
+ end
82
+ if Phronomy.configuration.logger
83
+ Phronomy.configuration.logger.warn(msg)
84
+ else
85
+ warn msg
86
+ end
87
+ end
88
+ mode = :blocking_io
89
+ end
90
+
91
+ pool = begin
92
+ runtime&.blocking_io
93
+ rescue
94
+ nil
95
+ end
96
+
97
+ if mode == :cooperative || pool.nil?
98
+ runtime.spawn(name: "tool-#{tool.class.name.to_s.split("::").last}") do
99
+ tool.call(args, cancellation_token: ct)
100
+ end
101
+ else
102
+ # Submit directly to pool — no wrapping Task thread required.
103
+ pool.submit(cancellation_token: ct) { tool.call(args, cancellation_token: ct) }
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,157 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Concurrency
5
+ # A thread-safe FIFO queue for passing values between concurrent tasks.
6
+ #
7
+ # Wraps +Thread::Queue+ so that callers do not need to reference the Ruby
8
+ # standard-library type directly. A future implementation may replace the
9
+ # backing primitive without changing call sites.
10
+ #
11
+ # @example Producer / consumer
12
+ # queue = Phronomy::Concurrency::AsyncQueue.new
13
+ # Runtime.instance.spawn { queue.push(expensive_io()) }
14
+ # value = queue.pop # blocks until the producer pushes
15
+ # @api private
16
+ class AsyncQueue
17
+ # @param max_size [Integer, nil] optional upper bound on queue depth.
18
+ # When set, {#push} blocks the caller until a slot is available.
19
+ # @api private
20
+ def initialize(max_size: nil)
21
+ @queue = max_size ? SizedQueue.new(max_size) : Thread::Queue.new
22
+ @max_size = max_size
23
+ end
24
+
25
+ # Enqueues +item+.
26
+ # In a cooperative scheduler context with a bounded queue (max_size:), suspends
27
+ # the current Fiber via a scheduler signal when the queue is full rather than
28
+ # blocking the OS thread. Without a scheduler, falls back to the standard
29
+ # SizedQueue blocking behaviour.
30
+ # @param item [Object] value to enqueue
31
+ # @return [self]
32
+ # @api private
33
+ def push(item)
34
+ scheduler = Phronomy::Runtime::Scheduler.current
35
+ if scheduler && @max_size
36
+ _push_cooperative(scheduler, item)
37
+ else
38
+ @queue.push(item)
39
+ scheduler.raise_signal(@coop_signal) if scheduler && @coop_signal
40
+ end
41
+ self
42
+ end
43
+
44
+ # Dequeues and returns the next item.
45
+ # In a cooperative scheduler context, suspends the current Fiber (yielding
46
+ # control back to the scheduler) rather than blocking the OS thread.
47
+ #
48
+ # When +timeout+ is given the semantics depend on the active backend:
49
+ #
50
+ # * **Thread backend** (`:thread`) — uses real wall-clock time via
51
+ # +Thread::Queue#pop(timeout:)+. Requires Ruby 3.2+.
52
+ # Returns +nil+ if no item arrives within the specified number of real seconds.
53
+ # * **DeterministicScheduler / `:fiber` backend** — uses the scheduler's
54
+ # *virtual time* (+scheduler.virtual_time+). The timeout elapses only when
55
+ # the virtual clock is advanced (e.g. via {Phronomy::Testing::FakeClock#advance}).
56
+ # In tests this means the timeout is fully deterministic and does not depend on
57
+ # actual elapsed wall time. However, in production `:fiber` mode the timeout
58
+ # may never expire unless the scheduler explicitly advances virtual time.
59
+ #
60
+ # @note The `:fiber` backend is **EXPERIMENTAL**. Real-time timeout behaviour
61
+ # in production workloads is not guaranteed and may differ from wall-clock
62
+ # expectations.
63
+ # @note **Cooperative timeout limitation**: on the cooperative path, the
64
+ # deadline is re-checked *after* a wake-up signal arrives. If virtual time
65
+ # has already passed the deadline when the consumer is woken by a producer
66
+ # push, the consumer returns +nil+ rather than the pushed item. Without any
67
+ # wake-up signal the waiting Fiber remains suspended even after
68
+ # +scheduler.advance+ — the timeout does not self-fire.
69
+ # @param timeout [Numeric, nil] seconds to wait before returning +nil+.
70
+ # Semantics are wall-clock on `:thread` and virtual-time on `:fiber`.
71
+ # @return [Object, nil] the next item, or +nil+ when timeout expires
72
+ # @api private
73
+ def pop(timeout: nil)
74
+ scheduler = Phronomy::Runtime::Scheduler.current
75
+ if scheduler
76
+ _pop_cooperative(scheduler, timeout: timeout)
77
+ elsif timeout
78
+ @queue.pop(timeout: timeout)
79
+ else
80
+ @queue.pop
81
+ end
82
+ end
83
+
84
+ # Returns the current number of items in the queue.
85
+ # @return [Integer]
86
+ # @api private
87
+ def size
88
+ @queue.size
89
+ end
90
+
91
+ # Returns +true+ when the queue contains no items.
92
+ # @return [Boolean]
93
+ # @api private
94
+ def empty?
95
+ @queue.empty?
96
+ end
97
+
98
+ # Closes the queue. Subsequent {#pop} calls raise +ClosedQueueError+.
99
+ # @return [self]
100
+ # @api private
101
+ def close
102
+ @queue.close
103
+ self
104
+ end
105
+
106
+ private
107
+
108
+ # Cooperative pop for DeterministicScheduler context.
109
+ # Suspends the current Fiber via the scheduler's signal mechanism rather than
110
+ # blocking the OS thread. Because cooperative mode is single-threaded, the
111
+ # empty?/pop pair is race-free (no other Fiber can run between the two calls).
112
+ # After dequeuing, notifies any push-waiter so that a backpressure-suspended
113
+ # producer can be unblocked.
114
+ # @api private
115
+ # @param scheduler [Runtime::Scheduler]
116
+ # @param timeout [Numeric, nil]
117
+ # @return [Object, nil]
118
+ def _pop_cooperative(scheduler, timeout:)
119
+ @coop_signal ||= scheduler.new_signal
120
+ deadline = timeout ? (scheduler.virtual_time + timeout) : nil
121
+
122
+ loop do
123
+ unless @queue.empty?
124
+ item = @queue.pop(timeout: 0)
125
+ # Notify a push-waiter (bounded queue) that a slot opened up.
126
+ scheduler.raise_signal(@push_signal) if @push_signal
127
+ return item
128
+ end
129
+ return nil if deadline && scheduler.virtual_time >= deadline
130
+ scheduler.wait_for_signal(@coop_signal)
131
+ return nil if deadline && scheduler.virtual_time >= deadline
132
+ end
133
+ end
134
+
135
+ # Cooperative push for DeterministicScheduler context with a bounded queue.
136
+ # Suspends the current Fiber via a scheduler signal when the queue is full,
137
+ # rather than blocking the OS thread.
138
+ # @api private
139
+ # @param scheduler [Runtime::Scheduler]
140
+ # @param item [Object]
141
+ # @return [void]
142
+ def _push_cooperative(scheduler, item)
143
+ @push_signal ||= scheduler.new_signal
144
+
145
+ loop do
146
+ unless @queue.size >= @max_size
147
+ @queue.push(item)
148
+ # Notify any pop-waiter that an item is now available.
149
+ scheduler.raise_signal(@coop_signal) if @coop_signal
150
+ return
151
+ end
152
+ scheduler.wait_for_signal(@push_signal)
153
+ end
154
+ end
155
+ end
156
+ end
157
+ end