phronomy 0.6.0 → 0.7.1

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 (143) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +22 -0
  3. data/CHANGELOG.md +488 -0
  4. data/CONTRIBUTING.md +102 -0
  5. data/README.md +374 -36
  6. data/RELEASE_CHECKLIST.md +86 -0
  7. data/Rakefile +33 -0
  8. data/SECURITY.md +80 -0
  9. data/benchmark/baseline.json +9 -0
  10. data/benchmark/bench_agent_invoke.rb +105 -0
  11. data/benchmark/bench_context_assembler.rb +46 -0
  12. data/benchmark/bench_regression.rb +172 -0
  13. data/benchmark/bench_token_estimator.rb +44 -0
  14. data/benchmark/bench_tool_schema.rb +69 -0
  15. data/benchmark/bench_vector_store.rb +39 -0
  16. data/benchmark/bench_workflow.rb +55 -0
  17. data/benchmark/run_all.rb +118 -0
  18. data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
  19. data/docs/decisions/002-workflow-context-immutability.md +42 -0
  20. data/docs/decisions/003-event-loop-singleton.md +48 -0
  21. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +75 -0
  22. data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
  23. data/docs/decisions/006-no-built-in-guardrails.md +66 -0
  24. data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
  25. data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
  26. data/docs/decisions/009-state-store-abstraction.md +141 -0
  27. data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
  28. data/lib/phronomy/agent/base.rb +416 -49
  29. data/lib/phronomy/agent/before_completion_context.rb +1 -0
  30. data/lib/phronomy/agent/checkpoint.rb +1 -0
  31. data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
  32. data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
  33. data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
  34. data/lib/phronomy/agent/concerns/retryable.rb +12 -1
  35. data/lib/phronomy/agent/concerns/suspendable.rb +19 -0
  36. data/lib/phronomy/agent/fsm.rb +44 -52
  37. data/lib/phronomy/agent/handoff.rb +3 -0
  38. data/lib/phronomy/agent/orchestrator.rb +191 -54
  39. data/lib/phronomy/agent/parallel_tool_chat.rb +87 -13
  40. data/lib/phronomy/agent/react_agent.rb +16 -6
  41. data/lib/phronomy/agent/runner.rb +2 -0
  42. data/lib/phronomy/agent/shared_state.rb +11 -0
  43. data/lib/phronomy/agent/suspend_signal.rb +2 -0
  44. data/lib/phronomy/agent/team_coordinator.rb +17 -5
  45. data/lib/phronomy/async_queue.rb +155 -0
  46. data/lib/phronomy/blocking_adapter_pool.rb +435 -0
  47. data/lib/phronomy/cancellation_scope.rb +123 -0
  48. data/lib/phronomy/cancellation_token.rb +133 -0
  49. data/lib/phronomy/concurrency_gate.rb +155 -0
  50. data/lib/phronomy/configuration.rb +168 -2
  51. data/lib/phronomy/context/assembler.rb +6 -0
  52. data/lib/phronomy/context/compaction_context.rb +2 -0
  53. data/lib/phronomy/context/context_version_cache.rb +2 -0
  54. data/lib/phronomy/context/token_budget.rb +3 -0
  55. data/lib/phronomy/context/token_estimator.rb +9 -2
  56. data/lib/phronomy/context/trigger_context.rb +1 -0
  57. data/lib/phronomy/context/trim_context.rb +4 -0
  58. data/lib/phronomy/deadline.rb +63 -0
  59. data/lib/phronomy/diagnostics.rb +62 -0
  60. data/lib/phronomy/embeddings/base.rb +22 -2
  61. data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
  62. data/lib/phronomy/eval/comparison.rb +2 -0
  63. data/lib/phronomy/eval/dataset.rb +4 -0
  64. data/lib/phronomy/eval/metrics.rb +6 -0
  65. data/lib/phronomy/eval/runner.rb +11 -9
  66. data/lib/phronomy/eval/scorer/base.rb +1 -0
  67. data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
  68. data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
  69. data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
  70. data/lib/phronomy/event_loop.rb +275 -30
  71. data/lib/phronomy/fsm_session.rb +57 -4
  72. data/lib/phronomy/generator_verifier.rb +2 -0
  73. data/lib/phronomy/guardrail/base.rb +3 -0
  74. data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
  75. data/lib/phronomy/invocation_context.rb +152 -0
  76. data/lib/phronomy/knowledge_source/base.rb +24 -2
  77. data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
  78. data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
  79. data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
  80. data/lib/phronomy/llm_adapter/base.rb +104 -0
  81. data/lib/phronomy/llm_adapter/ruby_llm.rb +41 -0
  82. data/lib/phronomy/llm_adapter.rb +20 -0
  83. data/lib/phronomy/loader/base.rb +1 -0
  84. data/lib/phronomy/loader/csv_loader.rb +2 -0
  85. data/lib/phronomy/loader/markdown_loader.rb +2 -0
  86. data/lib/phronomy/loader/plain_text_loader.rb +1 -0
  87. data/lib/phronomy/metrics.rb +38 -0
  88. data/lib/phronomy/output_parser/base.rb +1 -0
  89. data/lib/phronomy/output_parser/json_parser.rb +22 -3
  90. data/lib/phronomy/output_parser/structured_parser.rb +2 -0
  91. data/lib/phronomy/prompt_template.rb +5 -0
  92. data/lib/phronomy/runnable.rb +20 -3
  93. data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
  94. data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
  95. data/lib/phronomy/runtime/gate_registry.rb +52 -0
  96. data/lib/phronomy/runtime/pool_registry.rb +57 -0
  97. data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
  98. data/lib/phronomy/runtime/scheduler.rb +98 -0
  99. data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
  100. data/lib/phronomy/runtime/task_registry.rb +48 -0
  101. data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
  102. data/lib/phronomy/runtime/timer_queue.rb +106 -0
  103. data/lib/phronomy/runtime/timer_service.rb +42 -0
  104. data/lib/phronomy/runtime.rb +374 -0
  105. data/lib/phronomy/splitter/base.rb +2 -0
  106. data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
  107. data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
  108. data/lib/phronomy/state_store/base.rb +48 -0
  109. data/lib/phronomy/state_store/in_memory.rb +62 -0
  110. data/lib/phronomy/task/backend.rb +80 -0
  111. data/lib/phronomy/task/fiber_backend.rb +157 -0
  112. data/lib/phronomy/task/immediate_backend.rb +89 -0
  113. data/lib/phronomy/task/thread_backend.rb +84 -0
  114. data/lib/phronomy/task.rb +275 -0
  115. data/lib/phronomy/task_group.rb +265 -0
  116. data/lib/phronomy/testing/fake_clock.rb +109 -0
  117. data/lib/phronomy/testing/fake_scheduler.rb +104 -0
  118. data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
  119. data/lib/phronomy/testing.rb +12 -0
  120. data/lib/phronomy/tool/agent_tool.rb +1 -0
  121. data/lib/phronomy/tool/base.rb +298 -28
  122. data/lib/phronomy/tool/mcp_tool.rb +103 -17
  123. data/lib/phronomy/tool/scope_policy.rb +50 -0
  124. data/lib/phronomy/tool_executor.rb +106 -0
  125. data/lib/phronomy/tracing/base.rb +3 -0
  126. data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
  127. data/lib/phronomy/tracing/open_telemetry_tracer.rb +36 -0
  128. data/lib/phronomy/vector_store/async_backend.rb +110 -0
  129. data/lib/phronomy/vector_store/base.rb +40 -7
  130. data/lib/phronomy/vector_store/in_memory.rb +16 -7
  131. data/lib/phronomy/vector_store/pgvector.rb +40 -9
  132. data/lib/phronomy/vector_store/redis_search.rb +29 -8
  133. data/lib/phronomy/version.rb +1 -1
  134. data/lib/phronomy/workflow.rb +147 -11
  135. data/lib/phronomy/workflow_context.rb +83 -6
  136. data/lib/phronomy/workflow_runner.rb +106 -7
  137. data/lib/phronomy.rb +112 -1
  138. data/scripts/api_snapshot.rb +91 -0
  139. data/scripts/check_api_annotations.rb +68 -0
  140. data/scripts/check_private_enforcement.rb +93 -0
  141. data/scripts/check_readme_runnable.rb +98 -0
  142. data/scripts/run_mutation.sh +46 -0
  143. metadata +83 -2
@@ -0,0 +1,123 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Represents a bounded execution scope that owns a {CancellationToken} and
5
+ # optionally a {Deadline}.
6
+ #
7
+ # +CancellationScope+ replaces ad-hoc +Timeout.timeout+ calls in agent and
8
+ # tool code. All work performed within a scope should observe the scope's
9
+ # token; when the scope is cancelled (explicitly or by deadline expiry) the
10
+ # token is cancelled and all child tasks that check it will stop.
11
+ #
12
+ # @example Time-bounded invocation
13
+ # scope = Phronomy::CancellationScope.new.deadline_in(30)
14
+ # result = scope.pop_queue(completion_queue) do
15
+ # raise Phronomy::TimeoutError, "timed out"
16
+ # end
17
+ #
18
+ # @example Explicit cancellation
19
+ # scope = Phronomy::CancellationScope.new
20
+ # Phronomy::Runtime.instance.spawn(name: "worker") do
21
+ # scope.token.raise_if_cancelled!
22
+ # # ... do work ...
23
+ # end
24
+ # scope.cancel! if some_condition
25
+ class CancellationScope
26
+ # @return [CancellationToken] the token owned by this scope
27
+ attr_reader :token
28
+
29
+ # @return [Deadline, nil] the deadline attached to this scope, if any
30
+ attr_reader :deadline
31
+
32
+ # @param parent_token [CancellationToken, nil] when provided, cancellation of
33
+ # the parent token is propagated to this scope's token via a callback
34
+ # (for explicit cancel) and/or the Runtime timer queue (for monotonic
35
+ # deadline expiry). No polling thread is spawned.
36
+ # @api private
37
+ def initialize(parent_token: nil)
38
+ @token = Phronomy::CancellationToken.new
39
+ @deadline = nil
40
+
41
+ if parent_token
42
+ # Propagate explicit cancel() from parent to child via callback.
43
+ parent_token.on_cancel { @token.cancel! }
44
+
45
+ # Propagate monotonic-deadline expiry from parent to child via the
46
+ # timer queue (avoids a polling thread).
47
+ remaining = parent_token.remaining_monotonic_seconds
48
+ if !remaining.nil?
49
+ if remaining <= 0
50
+ @token.cancel!
51
+ else
52
+ Phronomy::Runtime.instance.timer_queue.schedule(seconds: remaining) do
53
+ @token.cancel!
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
59
+
60
+ # Attaches a deadline that will cancel this scope after +seconds+.
61
+ #
62
+ # @param seconds [Numeric] timeout duration
63
+ # @return [self]
64
+ # @api private
65
+ def deadline_in(seconds)
66
+ @deadline = Phronomy::Deadline.in(seconds)
67
+ @deadline.attach_to(@token)
68
+ self
69
+ end
70
+
71
+ # Cancels this scope immediately.
72
+ # @return [void]
73
+ # @api private
74
+ def cancel!
75
+ @token.cancel!
76
+ end
77
+
78
+ # Returns +true+ if this scope has been cancelled.
79
+ # @return [Boolean]
80
+ # @api private
81
+ def cancelled?
82
+ @token.cancelled?
83
+ end
84
+
85
+ # Returns the remaining time in seconds before the deadline expires,
86
+ # or +nil+ when no deadline is set.
87
+ # @return [Float, nil]
88
+ # @api private
89
+ def remaining_seconds
90
+ @deadline&.remaining_seconds
91
+ end
92
+
93
+ # Pops from +queue+ with a timeout derived from the attached deadline (or
94
+ # +fallback_timeout+ seconds when no deadline is set). If the pop times out,
95
+ # the scope is cancelled and the block is called (or a {TimeoutError} raised).
96
+ #
97
+ # @param queue [Phronomy::AsyncQueue] the queue to pop from
98
+ # @param fallback_timeout [Numeric, nil] used when no deadline is attached
99
+ # @yield called when the operation times out
100
+ # @raise [Phronomy::TimeoutError] when no block is given and a timeout occurs
101
+ # @return [Object] the popped value
102
+ # @api private
103
+ def pop_queue(queue, fallback_timeout: nil)
104
+ timeout = @deadline&.remaining_seconds || fallback_timeout
105
+ result = if timeout
106
+ queue.pop(timeout: timeout)
107
+ else
108
+ queue.pop
109
+ end
110
+
111
+ if result.nil?
112
+ cancel!
113
+ if block_given?
114
+ yield
115
+ else
116
+ raise Phronomy::TimeoutError, "CancellationScope timed out"
117
+ end
118
+ end
119
+
120
+ result
121
+ end
122
+ end
123
+ end
@@ -0,0 +1,133 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Provides cooperative cancellation for agent invocations.
5
+ #
6
+ # Pass a token to an agent via +config: { cancellation_token: token }+.
7
+ # The agent checks the token before each LLM call and raises
8
+ # {Phronomy::CancellationError} when the token is cancelled or the
9
+ # optional deadline has passed.
10
+ #
11
+ # A token may be shared across multiple agent invocations and across threads;
12
+ # all access to internal state is protected by a Mutex.
13
+ #
14
+ # @example Explicit cancel from another thread
15
+ # token = Phronomy::CancellationToken.new
16
+ # Thread.new { sleep 5; token.cancel! }
17
+ # result = agent.invoke("...", config: { cancellation_token: token })
18
+ #
19
+ # @example Hard deadline via monotonic clock (recommended)
20
+ # token = Phronomy::CancellationToken.timeout_after(30)
21
+ # result = agent.invoke("...", config: { cancellation_token: token })
22
+ #
23
+ # @example Hard deadline via wall-clock (legacy)
24
+ # token = Phronomy::CancellationToken.new(deadline: Time.now + 30)
25
+ # result = agent.invoke("...", config: { cancellation_token: token })
26
+ #
27
+ # @example Propagate to parallel workers
28
+ # token = Phronomy::CancellationToken.new
29
+ # orchestrator.dispatch_parallel(task1, task2, cancellation_token: token)
30
+ class CancellationToken
31
+ # Returns a new token that will expire after +seconds+ seconds, measured
32
+ # with the monotonic clock (+Process::CLOCK_MONOTONIC+). Unlike constructing
33
+ # a token with +deadline: Time.now + seconds+, this factory is immune to NTP
34
+ # adjustments and DST transitions.
35
+ #
36
+ # @param seconds [Numeric] duration in seconds until the token expires.
37
+ # @return [CancellationToken]
38
+ # @api public
39
+ def self.timeout_after(seconds)
40
+ monotonic_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
41
+ new(monotonic_deadline: monotonic_deadline)
42
+ end
43
+
44
+ # @param deadline [Time, nil] optional wall-clock deadline; the token reports
45
+ # +cancelled?+ as +true+ once +Time.now >= deadline+. Prefer
46
+ # {.timeout_after} for duration-based cancellation.
47
+ # @param monotonic_deadline [Float, nil] internal monotonic timestamp set by
48
+ # {.timeout_after}; prefer that factory method over passing this directly.
49
+ # @api public
50
+ def initialize(deadline: nil, monotonic_deadline: nil)
51
+ @cancelled = false
52
+ @deadline = deadline
53
+ @monotonic_deadline = monotonic_deadline
54
+ @mutex = Mutex.new
55
+ @cancel_callbacks = []
56
+ end
57
+
58
+ # @return [Time, nil] the wall-clock deadline passed to {#initialize}, or +nil+.
59
+ attr_reader :deadline
60
+
61
+ # Returns the remaining seconds until the monotonic deadline fires, or +nil+
62
+ # when no monotonic deadline is set. Returns 0.0 if already past.
63
+ # @return [Float, nil]
64
+ # @api public
65
+ def remaining_monotonic_seconds
66
+ return nil if @monotonic_deadline.nil?
67
+ remaining = @monotonic_deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
68
+ [remaining, 0.0].max
69
+ end
70
+
71
+ # Registers a one-shot callback invoked when this token is explicitly
72
+ # cancelled via {#cancel!}. If the token is already cancelled, the block
73
+ # is called immediately (still within the caller's thread).
74
+ #
75
+ # Callbacks are NOT fired for deadline-based cancellation (i.e. when
76
+ # {#cancelled?} returns +true+ due to +@monotonic_deadline+ expiry). Use
77
+ # {Runtime#timer_queue} to schedule deadline callbacks.
78
+ #
79
+ # @yield called with no arguments when (or if) the token is cancelled
80
+ # @return [self]
81
+ # @api public
82
+ def on_cancel(&block)
83
+ already_cancelled = @mutex.synchronize do
84
+ if @cancelled
85
+ true
86
+ else
87
+ @cancel_callbacks << block
88
+ false
89
+ end
90
+ end
91
+ block.call if already_cancelled
92
+ self
93
+ end
94
+
95
+ # Mark the token as cancelled and fire any registered {#on_cancel} callbacks.
96
+ # Thread-safe; idempotent — calling multiple times has no additional effect.
97
+ # @return [self]
98
+ # @api public
99
+ def cancel!
100
+ callbacks = @mutex.synchronize do
101
+ return self if @cancelled
102
+ @cancelled = true
103
+ @cancel_callbacks.dup
104
+ end
105
+ callbacks.each(&:call)
106
+ self
107
+ end
108
+
109
+ # Returns +true+ when the token has been explicitly cancelled via {#cancel!},
110
+ # when the wall-clock deadline has passed, or when the monotonic deadline
111
+ # (set by {.timeout_after}) has elapsed. Thread-safe.
112
+ # @return [Boolean]
113
+ # @api public
114
+ def cancelled?
115
+ return true if @mutex.synchronize { @cancelled }
116
+ return true if !@deadline.nil? && Time.now >= @deadline
117
+ !@monotonic_deadline.nil? &&
118
+ Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @monotonic_deadline
119
+ end
120
+
121
+ # Raises {Phronomy::CancellationError} if the token is cancelled.
122
+ # A convenience method for cooperative cancellation checks inside tools,
123
+ # RAG loaders, and hooks, replacing the +if cancelled? then raise+ pattern.
124
+ #
125
+ # @param message [String] optional error message
126
+ # @return [nil] when the token is not cancelled
127
+ # @raise [Phronomy::CancellationError] when the token is cancelled
128
+ # @api public
129
+ def raise_if_cancelled!(message = "invocation cancelled")
130
+ raise Phronomy::CancellationError, message if cancelled?
131
+ end
132
+ end
133
+ end
@@ -0,0 +1,155 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # A counting semaphore that enforces a concurrency cap across a named
5
+ # resource category (e.g. agent tasks, tool tasks, LLM calls).
6
+ #
7
+ # When +max_concurrent+ is +nil+ the gate is a no-op and all callers
8
+ # pass through immediately without acquiring a slot.
9
+ #
10
+ # Backpressure behaviour when the gate is full is controlled by the
11
+ # +on_full:+ keyword:
12
+ # +:reject+ — raise {Phronomy::BackpressureError} immediately
13
+ # +:wait+ — block the calling fiber/thread until a slot is free
14
+ # +:timeout+ — like +:wait+ but raises {Phronomy::BackpressureError}
15
+ # after +timeout:+ seconds if no slot becomes available
16
+ #
17
+ # @example
18
+ # gate = Phronomy::ConcurrencyGate.new(max_concurrent: 5, name: :agent)
19
+ # gate.acquire(on_full: :reject) do
20
+ # run_agent_task
21
+ # end
22
+ class ConcurrencyGate
23
+ # @param max_concurrent [Integer, nil] concurrency cap; nil = unlimited
24
+ # @param name [Symbol, String, nil] human-readable label used in error messages
25
+ # @api private
26
+ def initialize(max_concurrent:, name: nil)
27
+ @max = max_concurrent
28
+ @name = name
29
+ @mutex = Mutex.new
30
+ @cond = ConditionVariable.new
31
+ @count = 0
32
+ end
33
+
34
+ # Returns the configured cap (or nil when unlimited).
35
+ attr_reader :max
36
+
37
+ # Returns the name label.
38
+ attr_reader :name
39
+
40
+ # Returns the number of slots currently in use.
41
+ def current_count
42
+ @mutex.synchronize { @count }
43
+ end
44
+
45
+ # Acquires a slot, executes +block+, then releases the slot.
46
+ # When the gate is unlimited (max is nil) the block runs directly.
47
+ #
48
+ # @param on_full [:reject, :wait, :timeout] backpressure strategy
49
+ # @param timeout [Numeric, nil] seconds before +:timeout+ gives up
50
+ # @yield
51
+ # @return block return value
52
+ # @raise [Phronomy::BackpressureError] when +:reject+ or +:timeout+ fires
53
+ # @api private
54
+ def acquire(on_full: :wait, timeout: nil, &block)
55
+ return block.call if @max.nil?
56
+
57
+ _acquire_slot(on_full: on_full, timeout: timeout)
58
+ begin
59
+ block.call
60
+ ensure
61
+ _release_slot
62
+ end
63
+ end
64
+
65
+ private
66
+
67
+ def _acquire_slot(on_full:, timeout:)
68
+ scheduler = Phronomy::Runtime::Scheduler.current
69
+ if scheduler
70
+ _acquire_slot_coop(scheduler, on_full: on_full, timeout: timeout)
71
+ else
72
+ _acquire_slot_threaded(on_full: on_full, timeout: timeout)
73
+ end
74
+ end
75
+
76
+ def _acquire_slot_coop(scheduler, on_full:, timeout:)
77
+ # In cooperative mode all tasks run on the same thread, so no mutex needed.
78
+ deadline = timeout ? (scheduler.virtual_time + timeout) : nil
79
+ @coop_signal ||= scheduler.new_signal
80
+
81
+ loop do
82
+ if @count < @max
83
+ @count += 1
84
+ return
85
+ end
86
+
87
+ case on_full
88
+ when :reject
89
+ raise Phronomy::BackpressureError,
90
+ "ConcurrencyGate[#{@name}] at capacity (#{@max}); " \
91
+ "increase max_concurrent_#{@name}_tasks or retry later"
92
+ when :timeout
93
+ if deadline && scheduler.virtual_time >= deadline
94
+ raise Phronomy::BackpressureError,
95
+ "ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
96
+ end
97
+ scheduler.wait_for_signal(@coop_signal)
98
+ if deadline && scheduler.virtual_time >= deadline
99
+ raise Phronomy::BackpressureError,
100
+ "ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
101
+ end
102
+ else # :wait
103
+ scheduler.wait_for_signal(@coop_signal)
104
+ end
105
+ end
106
+ end
107
+
108
+ def _acquire_slot_threaded(on_full:, timeout:)
109
+ deadline = timeout ? (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout) : nil
110
+
111
+ @mutex.synchronize do
112
+ loop do
113
+ if @count < @max
114
+ @count += 1
115
+ return
116
+ end
117
+
118
+ case on_full
119
+ when :reject
120
+ raise Phronomy::BackpressureError,
121
+ "ConcurrencyGate[#{@name}] at capacity (#{@max}); " \
122
+ "increase max_concurrent_#{@name}_tasks or retry later"
123
+ when :timeout
124
+ remaining = deadline ? (deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)) : nil
125
+ if remaining && remaining <= 0
126
+ raise Phronomy::BackpressureError,
127
+ "ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
128
+ end
129
+ @cond.wait(@mutex, remaining || nil)
130
+ # re-check deadline after wakeup
131
+ if deadline && Process.clock_gettime(Process::CLOCK_MONOTONIC) >= deadline
132
+ raise Phronomy::BackpressureError,
133
+ "ConcurrencyGate[#{@name}] timed out waiting for a free slot (cap: #{@max})"
134
+ end
135
+ else # :wait
136
+ @cond.wait(@mutex)
137
+ end
138
+ end
139
+ end
140
+ end
141
+
142
+ def _release_slot
143
+ scheduler = Phronomy::Runtime::Scheduler.current
144
+ if scheduler && @coop_signal
145
+ @count -= 1
146
+ scheduler.raise_signal(@coop_signal)
147
+ else
148
+ @mutex.synchronize do
149
+ @count -= 1
150
+ @cond.signal
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
@@ -33,16 +33,182 @@ module Phronomy
33
33
  # @see Phronomy::EventLoop
34
34
  attr_accessor :event_loop
35
35
 
36
- # When true (default), user input and LLM output are recorded in trace spans.
36
+ # When true, user input and LLM output are recorded in trace spans.
37
+ # Defaults to false; set to true only in environments where PII capture is acceptable.
37
38
  # Set to false in privacy-sensitive environments to prevent PII from reaching
38
39
  # the tracing backend (OTel, Langfuse, etc.).
39
40
  attr_accessor :trace_pii
40
41
 
42
+ # Optional logger for framework diagnostic messages (e.g. unreachable-state warnings).
43
+ # Must respond to +#warn(message)+. When nil (default), messages are written to +$stderr+
44
+ # via +Kernel#warn+.
45
+ # @example
46
+ # Phronomy.configure { |c| c.logger = Rails.logger }
47
+ attr_accessor :logger
48
+
49
+ # Grace period (in seconds) before the EventLoop background thread is force-killed
50
+ # after a cooperative stop request. Applies both to the overall thread join
51
+ # and to the drain-and-cancel phase when +stop(drain: true)+ is used.
52
+ # Default: 5 seconds.
53
+ # @see Phronomy::EventLoop#stop
54
+ attr_accessor :event_loop_stop_grace_seconds
55
+
56
+ # Global state store for workflow persistence.
57
+ # When set, WorkflowRunner routes all state reads and writes through this store.
58
+ # Must be an instance of a class that inherits from Phronomy::StateStore::Base.
59
+ # Defaults to +nil+ (no persistence — state lives only for the duration of invoke).
60
+ # @example
61
+ # Phronomy.configure { |c| c.state_store = Phronomy::StateStore::InMemory.new }
62
+ attr_accessor :state_store
63
+
64
+ # Maximum byte length of a tool result returned to the LLM.
65
+ # When a tool returns a String longer than this limit, the string is truncated
66
+ # and a warning is logged. Set to +nil+ (default) to disable truncation.
67
+ # @example
68
+ # Phronomy.configure { |c| c.tool_result_max_size = 8192 }
69
+ attr_accessor :tool_result_max_size
70
+
71
+ # LLM adapter used by Agent::Base to perform LLM calls.
72
+ # Must be an instance of a class that inherits from
73
+ # {Phronomy::LLMAdapter::Base}. Defaults to
74
+ # {Phronomy::LLMAdapter::RubyLLM} which delegates to +chat.ask+ via
75
+ # {BlockingAdapterPool}.
76
+ # Set to a custom adapter to swap in an alternative LLM client without
77
+ # changing any agent code.
78
+ # @example
79
+ # Phronomy.configure { |c| c.llm_adapter = MyAsyncLLMAdapter.new }
80
+ attr_accessor :llm_adapter
81
+
82
+ # Default backpressure strategy for {BlockingAdapterPool#submit} when the
83
+ # queue is full. One of +:wait+ (block until a slot is available),
84
+ # +:raise+ (raise {Phronomy::BackpressureError}), or +:timeout+ (raise
85
+ # {Phronomy::TimeoutError} after +backpressure_timeout+ seconds).
86
+ # @return [:wait, :raise, :timeout]
87
+ attr_accessor :backpressure
88
+
89
+ # Seconds to wait before raising {Phronomy::TimeoutError} when
90
+ # +backpressure+ is +:timeout+.
91
+ # @return [Numeric, nil]
92
+ attr_accessor :backpressure_timeout
93
+
94
+ # Warn when an event spends longer than this many seconds waiting in the
95
+ # EventLoop queue before being dispatched (starvation detection).
96
+ # Set to +nil+ to disable the warning.
97
+ # @return [Numeric, nil]
98
+ attr_accessor :event_loop_starvation_threshold_seconds
99
+
100
+ # Warn when processing a single event on the EventLoop thread takes longer
101
+ # than this many seconds (long-running task / blocking-on-loop detection).
102
+ # Set to +nil+ to disable the warning.
103
+ # @return [Numeric, nil]
104
+ attr_accessor :event_loop_dispatch_threshold_seconds
105
+
106
+ # When true, enables all blocking operation diagnostics (Issue #279).
107
+ # Equivalent to setting all diagnostic thresholds to their defaults.
108
+ # @return [Boolean]
109
+ attr_accessor :scheduler_debug
110
+
111
+ # Wall-clock threshold (milliseconds) after which a task that has not
112
+ # yielded the scheduler emits a warning log. nil disables the check.
113
+ # @return [Float, nil]
114
+ attr_accessor :blocking_detect_threshold_ms
115
+
116
+ # Maximum number of concurrent agent tasks (invoke_async calls in-flight).
117
+ # nil = unlimited (default). When at capacity, behaviour is controlled by
118
+ # +backpressure+ (:wait, :raise/:reject, :timeout).
119
+ # @return [Integer, nil]
120
+ attr_accessor :max_concurrent_agent_tasks
121
+
122
+ # Maximum number of concurrent tool tasks (parallel tool calls in-flight).
123
+ # nil = unlimited (default).
124
+ # @return [Integer, nil]
125
+ attr_accessor :max_concurrent_tool_tasks
126
+
127
+ # Maximum number of concurrent workflow tasks.
128
+ # nil = unlimited (default).
129
+ # @return [Integer, nil]
130
+ attr_accessor :max_concurrent_workflow_tasks
131
+
132
+ # Maximum number of concurrent LLM calls in-flight.
133
+ # nil = unlimited (default).
134
+ # @return [Integer, nil]
135
+ attr_accessor :max_concurrent_llm_calls
136
+
137
+ # Upper bound on the number of streaming token chunks that may be buffered
138
+ # in the {AsyncQueue} used by {Agent#stream} before the LLM producer is
139
+ # throttled. When nil (default), the queue is unbounded.
140
+ # @return [Integer, nil]
141
+ attr_accessor :stream_queue_max_size
142
+
143
+ # Maximum number of concurrent RAG knowledge-source fetches in-flight.
144
+ # nil = unlimited (default).
145
+ # @return [Integer, nil]
146
+ attr_accessor :max_concurrent_rag_fetches
147
+
148
+ # Maximum number of concurrent vector-store searches in-flight.
149
+ # nil = unlimited (default).
150
+ # @return [Integer, nil]
151
+ attr_accessor :max_concurrent_vector_searches
152
+
153
+ # Scheduler starvation threshold (milliseconds).
154
+ # When a task waits more than this many milliseconds after calling
155
+ # +runtime.yield+ before being resumed, the wait is counted as a starvation
156
+ # event. Used by the fairness regression test and by the
157
+ # +tasks_waiting_over_threshold+ metric on {Phronomy::Runtime}.
158
+ # Default: 50ms.
159
+ # @return [Numeric]
160
+ attr_accessor :starvation_threshold_ms
161
+
162
+ # Scheduler backend to use for new {Phronomy::Runtime} instances.
163
+ #
164
+ # | Value | Scheduler | Typical use |
165
+ # |-------|-----------|-------------|
166
+ # | +:thread+ | {Runtime::ThreadScheduler} | **Default** — production-ready; one OS thread per task |
167
+ # | +:immediate+ | {Runtime::FakeScheduler} | Tests — tasks run synchronously, no extra threads |
168
+ # | +:fiber+ | {Runtime::DeterministicScheduler} (autorun) | **EXPERIMENTAL** — Fiber-based cooperative scheduler; do not use as production default |
169
+ # | +:cooperative+ | {Runtime::FakeScheduler} | **Deprecated** — alias for +:immediate+; do not use in new code |
170
+ #
171
+ # The default is +:thread+. The +:fiber+ backend remains experimental and opt-in;
172
+ # it will not become the default until integration test coverage is production grade
173
+ # and virtual-time/timeout semantics are fully resolved (see Issues #350, #347, #348).
174
+ #
175
+ # When this setting is changed, the change only takes effect on the NEXT
176
+ # call to {Runtime.instance} that auto-creates a new instance (i.e. after the
177
+ # previous instance has been replaced or reset). To replace the current
178
+ # instance immediately call +Phronomy::Runtime.instance = nil+ first.
179
+ #
180
+ # @return [:thread, :immediate, :fiber]
181
+ attr_accessor :runtime_backend
182
+
183
+ # When +true+, calling {Agent#invoke} from inside a scheduler task
184
+ # raises {SchedulerReentrancyError}. When +false+ (default), a warning
185
+ # is logged instead so that existing callers have time to migrate.
186
+ # @return [Boolean]
187
+ attr_accessor :strict_runtime_guards
188
+
41
189
  def initialize
42
190
  @recursion_limit = 25
43
191
  @tracer = Phronomy::Tracing::NullTracer.new
44
- @trace_pii = true
192
+ @trace_pii = false
45
193
  @event_loop = false
194
+ @event_loop_stop_grace_seconds = 5
195
+ @llm_adapter = Phronomy::LLMAdapter::RubyLLM.new
196
+ @backpressure = :wait
197
+ @backpressure_timeout = nil
198
+ @event_loop_starvation_threshold_seconds = nil
199
+ @event_loop_dispatch_threshold_seconds = nil
200
+ @scheduler_debug = false
201
+ @blocking_detect_threshold_ms = nil
202
+ @max_concurrent_agent_tasks = nil
203
+ @max_concurrent_tool_tasks = nil
204
+ @max_concurrent_workflow_tasks = nil
205
+ @max_concurrent_llm_calls = nil
206
+ @stream_queue_max_size = nil
207
+ @max_concurrent_rag_fetches = nil
208
+ @max_concurrent_vector_searches = nil
209
+ @starvation_threshold_ms = 50
210
+ @runtime_backend = :thread
211
+ @strict_runtime_guards = false
46
212
  end
47
213
  end
48
214
  end
@@ -35,12 +35,14 @@ module Phronomy
35
35
  # @param type [Symbol, String]
36
36
  # @param trusted [Boolean]
37
37
  # @return [String]
38
+ # @api private
38
39
  def self.xml_tag(text, type:, trusted: false)
39
40
  "<context type=\"#{CGI.escapeHTML(type.to_s)}\" trusted=\"#{trusted}\">\n#{CGI.escapeHTML(text.to_s)}\n</context>"
40
41
  end
41
42
 
42
43
  # @param budget [Phronomy::Context::TokenBudget, nil]
43
44
  # when nil no token trimming is performed
45
+ # @api private
44
46
  def initialize(budget: nil)
45
47
  @budget = budget
46
48
  @instruction = nil
@@ -53,6 +55,7 @@ module Phronomy
53
55
  #
54
56
  # @param text [String]
55
57
  # @return [self]
58
+ # @api private
56
59
  def add_instruction(text)
57
60
  @instruction = text.to_s
58
61
  self
@@ -67,6 +70,7 @@ module Phronomy
67
70
  # @param source [String, nil] optional source label (e.g. filename); included in the
68
71
  # XML tag so the LLM can produce grounded citations. Omitted when nil.
69
72
  # @return [self]
73
+ # @api private
70
74
  def add_knowledge(text, type:, trusted: false, source: nil)
71
75
  @knowledge_chunks << {text: text.to_s, type: type.to_s, trusted: trusted, source: source}
72
76
  self
@@ -76,6 +80,7 @@ module Phronomy
76
80
  #
77
81
  # @param messages [Array] message-like objects with #role and #content
78
82
  # @return [self]
83
+ # @api private
79
84
  def add_messages(messages)
80
85
  @messages = Array(messages)
81
86
  self
@@ -86,6 +91,7 @@ module Phronomy
86
91
  # @return [Hash{Symbol => Object}]
87
92
  # :system [String, nil] combined system prompt (instruction + knowledge XML tags)
88
93
  # :messages [Array] conversation messages, trimmed to budget if set
94
+ # @api private
89
95
  def build
90
96
  knowledge_text = @knowledge_chunks.map { |c| xml_context_tag(c) }.join("\n\n")
91
97
  system_parts = [@instruction, knowledge_text.empty? ? nil : knowledge_text].compact
@@ -45,6 +45,7 @@ module Phronomy
45
45
  # @param thread_id [String, nil] used when saving compaction records
46
46
  # @param memory [Object, nil] memory object; must respond to #save_compaction
47
47
  # for compaction records to be persisted
48
+ # @api private
48
49
  def initialize(message_elements:, budget:, thread_id: nil, memory: nil)
49
50
  @message_elements = message_elements.dup
50
51
  @budget = budget
@@ -67,6 +68,7 @@ module Phronomy
67
68
  # @yieldparam elements [Array<Hash>] the selected message elements
68
69
  # @yieldreturn [String] summary text to replace the selected messages
69
70
  # @return [Array] the updated result_messages array
71
+ # @api private
70
72
  def compact(range)
71
73
  # Normalise: Integer index → single-element Array; Range → Array slice.
72
74
  raw = @message_elements[range]
@@ -25,6 +25,7 @@ module Phronomy
25
25
  #
26
26
  # @param fingerprint [String] SHA-256 hex digest to compare
27
27
  # @return [Boolean]
28
+ # @api private
28
29
  def valid?(fingerprint)
29
30
  !@fingerprint.nil? && !@system_text.nil? && @fingerprint == fingerprint
30
31
  end
@@ -33,6 +34,7 @@ module Phronomy
33
34
  #
34
35
  # @param fingerprint [String] new SHA-256 hex digest
35
36
  # @param system_text [String] fully assembled system prompt text
37
+ # @api private
36
38
  def update(fingerprint:, system_text:)
37
39
  @fingerprint = fingerprint
38
40
  @system_text = system_text.to_s