phronomy 0.16.0 → 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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +54 -0
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +124 -92
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +4 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +1 -1
  10. data/benchmark/bench_tool_schema.rb +2 -35
  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 +2 -2
  14. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  15. data/lib/phronomy/agent/agent_invocation.rb +2 -36
  16. data/lib/phronomy/agent/agent_invocation_session_builder.rb +156 -93
  17. data/lib/phronomy/agent/agent_root.rb +1 -2
  18. data/lib/phronomy/agent/base.rb +135 -314
  19. data/lib/phronomy/agent/context/capability/base.rb +166 -297
  20. data/lib/phronomy/agent/context_assembler.rb +65 -29
  21. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +19 -89
  22. data/lib/phronomy/agent/context_plan_validator.rb +0 -33
  23. data/lib/phronomy/agent/execution_coordinator.rb +0 -1
  24. data/lib/phronomy/agent/journal_projection.rb +28 -2
  25. data/lib/phronomy/agent/ruby_llm_materializer.rb +2 -111
  26. data/lib/phronomy/agent/shared_state.rb +46 -138
  27. data/lib/phronomy/agent/token_budget_resolver.rb +5 -4
  28. data/lib/phronomy/agent/tool_invocation.rb +108 -314
  29. data/lib/phronomy/agent.rb +6 -10
  30. data/lib/phronomy/configuration.rb +15 -158
  31. data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
  32. data/lib/phronomy/engine/runtime.rb +15 -230
  33. data/lib/phronomy/engine/task_group.rb +30 -102
  34. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  35. data/lib/phronomy/multi_agent/orchestrator.rb +152 -204
  36. data/lib/phronomy/multi_agent/team_coordinator.rb +42 -133
  37. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  38. data/lib/phronomy/version.rb +1 -1
  39. data/lib/phronomy.rb +3 -120
  40. data/scripts/api_snapshot.rb +1 -12
  41. metadata +3 -9
  42. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  43. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  44. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  45. data/lib/phronomy/agent/fsm_runtime_adapter.rb +0 -210
  46. data/lib/phronomy/knowledge_source.rb +0 -12
  47. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  48. data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
@@ -3,191 +3,36 @@
3
3
  module Phronomy
4
4
  # Holds global configuration for the entire framework.
5
5
  # Configure via the Phronomy.configure block.
6
- #
7
- # @example
8
- # Phronomy.configure do |config|
9
- # config.default_model = "claude-3-5-sonnet-20241022"
10
- # config.recursion_limit = 50
11
- # end
12
6
  class Configuration
13
7
  STREAM_CALLBACK_ERROR_POLICIES = %i[report fail_task].freeze
14
- private_constant :STREAM_CALLBACK_ERROR_POLICIES
8
+ RUNTIME_BACKENDS = %i[thread immediate fiber].freeze
9
+ private_constant :STREAM_CALLBACK_ERROR_POLICIES, :RUNTIME_BACKENDS
15
10
 
16
- # Default LLM model name (nil delegates to RubyLLM default)
17
11
  attr_accessor :default_model
18
-
19
- # Default embedding model name
20
12
  attr_accessor :default_embedding_model
21
-
22
- # Tracer instance
23
13
  attr_accessor :tracer
24
-
25
- # Global before_llm_input hook callable (Proc / lambda).
26
- # Called before every LLM request across all agents.
27
- # Receives a {Phronomy::Agent::LLMInputBuildContext}; must return a
28
- # {Phronomy::Agent::LLMInputPatch} or nil to pass through unchanged.
29
14
  attr_accessor :before_llm_input
30
-
31
- # Default output token reservation when an agent does not set max_output_tokens
32
- # and the model registry value equals the context window (making it unusable
33
- # as a per-request output reserve). Integer or nil.
34
15
  attr_accessor :default_output_reserve
35
-
36
- # Recursion limit for graph execution (default: 25)
37
16
  attr_accessor :recursion_limit
38
-
39
- # When true, agent LLM calls use {Phronomy::MultiAgent::ParallelToolChat}
40
- # for concurrent tool dispatch within a single agent turn.
41
- # Defaults to false.
42
- #
43
- # Previously, this was automatically enabled when +event_loop+ was true.
44
- # As of Phase 3, +parallel_tool_execution+ is a separate setting that must
45
- # be explicitly enabled.
46
- # @example
47
- # Phronomy.configure { |c| c.parallel_tool_execution = true }
48
- # @return [Boolean]
49
17
  attr_accessor :parallel_tool_execution
50
-
51
- # When true, user input and LLM output are recorded in trace spans.
52
- # Defaults to false; set to true only in environments where PII capture is acceptable.
53
- # Set to false in privacy-sensitive environments to prevent PII from reaching
54
- # the tracing backend (OTel, Langfuse, etc.).
55
18
  attr_accessor :trace_pii
56
-
57
- # Optional logger for framework diagnostic messages (e.g. unreachable-state warnings).
58
- # Must respond to +#warn(message)+. When nil (default), messages are written to +$stderr+
59
- # via +Kernel#warn+.
60
- # @example
61
- # Phronomy.configure { |c| c.logger = Rails.logger }
62
19
  attr_accessor :logger
63
-
64
- # Grace period (in seconds) before the EventLoop background thread is force-killed
65
- # after a cooperative stop request. Applies both to the overall thread join
66
- # and to the drain-and-cancel phase when +stop(drain: true)+ is used.
67
- # Default: 5 seconds.
68
- # @see Phronomy::EventLoop#stop
69
20
  attr_accessor :event_loop_stop_grace_seconds
70
-
71
- # Global state store for workflow persistence.
72
- # When set, WorkflowRunner routes all state reads and writes through this store.
73
- # Must be an instance of a class that inherits from Phronomy::StateStore::Base.
74
- # Defaults to +nil+ (no persistence — state lives only for the duration of invoke).
75
- # @example
76
- # Phronomy.configure { |c| c.state_store = Phronomy::StateStore::InMemory.new }
77
21
  attr_accessor :state_store
78
-
79
- # Maximum byte length of a tool result returned to the LLM.
80
- # When a tool returns a String longer than this limit, the string is truncated
81
- # and a warning is logged. Set to +nil+ (default) to disable truncation.
82
- # @example
83
- # Phronomy.configure { |c| c.tool_result_max_size = 8192 }
84
22
  attr_accessor :tool_result_max_size
85
-
86
- # LLM adapter used by Agent::Base to perform LLM calls.
87
- # Must be an instance of a class that inherits from
88
- # {Phronomy::LLMAdapter::Base}. Defaults to
89
- # {Phronomy::LLMAdapter::RubyLLM} which delegates to +chat.ask+ via
90
- # {BlockingAdapterPool}.
91
- # Set to a custom adapter to swap in an alternative LLM client without
92
- # changing any agent code.
93
- # @example
94
- # Phronomy.configure { |c| c.llm_adapter = MyAsyncLLMAdapter.new }
95
23
  attr_accessor :llm_adapter
96
-
97
- # Set to +nil+ to disable the warning.
98
- # @return [Numeric, nil]
99
24
  attr_accessor :event_loop_starvation_threshold_seconds
100
-
101
- # Warn when processing a single event on the EventLoop thread takes longer
102
- # than this many seconds (long-running task / blocking-on-loop detection).
103
- # Set to +nil+ to disable the warning.
104
- # @return [Numeric, nil]
105
25
  attr_accessor :event_loop_dispatch_threshold_seconds
106
-
107
- # When true, enables all blocking operation diagnostics (Issue #279).
108
- # Equivalent to setting all diagnostic thresholds to their defaults.
109
- # @return [Boolean]
110
26
  attr_accessor :scheduler_debug
111
-
112
- # Wall-clock threshold (milliseconds) after which a task that has not
113
- # yielded the scheduler emits a warning log. nil disables the check.
114
- # @return [Float, nil]
115
27
  attr_accessor :blocking_detect_threshold_ms
116
-
117
- # Determines how an unhandled Application exception from a terminal stream
118
- # callback affects the Task returned by Agent#stream_async or
119
- # Agent#approve_async.
120
- #
121
- # +:report+ logs the callback failure and preserves the Agent result.
122
- # +:fail_task+ logs the callback failure and fails the current Task with
123
- # {Phronomy::StreamCallbackError}. Neither policy terminates EventLoop.
124
- #
125
- # Default: +:report+.
126
- # @return [:report, :fail_task]
127
28
  attr_reader :stream_callback_error_policy
128
-
129
- # Number of OS worker threads in the default {BlockingAdapterPool}.
130
- # All LLM calls, MCP tool calls, and other blocking I/O share this pool.
131
- # Increase for higher LLM/tool throughput; decrease to limit
132
- # concurrency (e.g. to stay within a provider's rate limit).
133
- # Default: 10.
134
- # @return [Integer]
135
29
  attr_accessor :blocking_io_pool_size
136
-
137
- # Maximum number of operations that may wait in the {BlockingAdapterPool}
138
- # queue before {Phronomy::BackpressureError} is raised (on_full: :raise) or
139
- # the caller blocks (on_full: :wait, the default). Default: 100.
140
- # @return [Integer]
141
30
  attr_accessor :blocking_io_queue_size
142
-
143
- # Worker count for Tool authorization evaluation. The named pool is owned
144
- # by Runtime#pool(:authorization) and shares PoolRegistry lifecycle.
145
- # @return [Integer]
146
31
  attr_accessor :authorization_pool_size
147
-
148
- # Maximum queued Tool authorization evaluations.
149
- # @return [Integer]
150
32
  attr_accessor :authorization_queue_size
151
-
152
- # Operation-wide deadline for approval_facts, requires_approval callables,
153
- # and Agent#tool_approval_policy. Timeout fails closed to Human approval.
154
- # @return [Numeric]
155
33
  attr_accessor :authorization_timeout
156
-
157
- # Scheduler starvation threshold (milliseconds).
158
- # When a task waits more than this many milliseconds after calling
159
- # +runtime.yield+ before being resumed, the wait is counted as a starvation
160
- # event. Used by the fairness regression test and by the
161
- # +tasks_waiting_over_threshold+ metric on {Phronomy::Runtime}.
162
- # Default: 50ms.
163
- # @return [Numeric]
164
34
  attr_accessor :starvation_threshold_ms
165
-
166
- # Scheduler backend to use for new {Phronomy::Runtime} instances.
167
- #
168
- # | Value | Scheduler | Typical use |
169
- # |-------|-----------|-------------|
170
- # | +:thread+ | {Runtime::ThreadScheduler} | **Default** — production-ready; one OS thread per task |
171
- # | +:immediate+ | {Runtime::FakeScheduler} | Tests — tasks run synchronously, no extra threads |
172
- # | +:fiber+ | {Runtime::DeterministicScheduler} (autorun) | **EXPERIMENTAL** — Fiber-based cooperative scheduler; do not use as production default |
173
- # | +:cooperative+ | {Runtime::FakeScheduler} | **Deprecated** — alias for +:immediate+; do not use in new code |
174
- #
175
- # The default is +:thread+. The +:fiber+ backend remains experimental and opt-in;
176
- # it will not become the default until integration test coverage is production grade
177
- # and virtual-time/timeout semantics are fully resolved (see Issues #350, #347, #348).
178
- #
179
- # When this setting is changed, the change only takes effect on the NEXT
180
- # call to {Runtime.instance} that auto-creates a new instance (i.e. after the
181
- # previous instance has been replaced or reset). To replace the current
182
- # instance immediately call +Phronomy::Runtime.instance = nil+ first.
183
- #
184
- # @return [:thread, :immediate, :fiber]
185
- attr_accessor :runtime_backend
186
-
187
- # When +true+, calling {Agent#invoke} from inside a scheduler task
188
- # raises {SchedulerReentrancyError}. When +false+ (default), a warning
189
- # is logged instead so that existing callers have time to migrate.
190
- # @return [Boolean]
35
+ attr_reader :runtime_backend
191
36
  attr_accessor :strict_runtime_guards
192
37
 
193
38
  def stream_callback_error_policy=(value)
@@ -200,6 +45,18 @@ module Phronomy
200
45
  @stream_callback_error_policy = value
201
46
  end
202
47
 
48
+ # Scheduler backend used for newly-created Runtime instances.
49
+ # Supported values are :thread, :immediate, and :fiber.
50
+ def runtime_backend=(value)
51
+ value = value.to_sym if value.respond_to?(:to_sym)
52
+ unless RUNTIME_BACKENDS.include?(value)
53
+ allowed = RUNTIME_BACKENDS.map(&:inspect).join(", ")
54
+ raise Phronomy::ConfigurationError,
55
+ "runtime_backend must be one of: #{allowed}"
56
+ end
57
+ @runtime_backend = value
58
+ end
59
+
203
60
  def initialize
204
61
  @recursion_limit = 25
205
62
  @tracer = Phronomy::Tracing::NullTracer.new
@@ -2,90 +2,33 @@
2
2
 
3
3
  module Phronomy
4
4
  module Concurrency
5
- # Provides cooperative cancellation for agent invocations.
6
- #
7
- # Pass a token to an agent via +config: { cancellation_token: token }+.
8
- # The agent checks the token before each LLM call and raises
9
- # {Phronomy::CancellationError} when the token is cancelled or the
10
- # optional deadline has passed.
11
- #
12
- # A token may be shared across multiple agent invocations and across threads;
13
- # all access to internal state is protected by a Mutex.
14
- #
15
- # @example Explicit cancel from another thread
16
- # token = Phronomy::Concurrency::CancellationToken.new
17
- # Thread.new { sleep 5; token.cancel! }
18
- # result = agent.invoke("...", config: { cancellation_token: token })
19
- #
20
- # @example Hard deadline via monotonic clock (recommended)
21
- # token = Phronomy::Concurrency::CancellationToken.timeout_after(30)
22
- # result = agent.invoke("...", config: { cancellation_token: token })
23
- #
24
- # @example Hard deadline via wall-clock (legacy)
25
- # token = Phronomy::Concurrency::CancellationToken.new(deadline: Time.now + 30)
26
- # result = agent.invoke("...", config: { cancellation_token: token })
27
- #
28
- # @example Propagate to parallel workers
29
- # token = Phronomy::Concurrency::CancellationToken.new
30
- # orchestrator.dispatch_parallel(task1, task2, cancellation_token: token)
5
+ # Cooperative cancellation token for Agent/Tool work.
31
6
  class CancellationToken
32
- # Returns a new token that will expire after +seconds+ seconds, measured
33
- # with the monotonic clock (+Process::CLOCK_MONOTONIC+). Unlike constructing
34
- # a token with +deadline: Time.now + seconds+, this factory is immune to NTP
35
- # adjustments and DST transitions.
36
- #
37
- # @param seconds [Numeric] duration in seconds until the token expires.
38
- # @return [CancellationToken]
7
+ # Creates a token that expires after +seconds+ measured with the monotonic clock.
39
8
  # @api public
40
9
  def self.timeout_after(seconds)
41
10
  monotonic_deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + seconds
42
11
  new(monotonic_deadline: monotonic_deadline)
43
12
  end
44
13
 
45
- # @param deadline [Time, nil] optional wall-clock deadline; the token reports
46
- # +cancelled?+ as +true+ once +Time.now >= deadline+. Prefer
47
- # {.timeout_after} for duration-based cancellation.
48
- # @param monotonic_deadline [Float, nil] internal monotonic timestamp set by
49
- # {.timeout_after}; prefer that factory method over passing this directly.
14
+ # @param monotonic_deadline [Float, nil] internal monotonic timestamp.
50
15
  # @api public
51
- # mutant:disable - removing @cancelled = false is equivalent because nil is falsey
52
- def initialize(deadline: nil, monotonic_deadline: nil)
16
+ def initialize(monotonic_deadline: nil)
53
17
  @cancelled = false
54
- @deadline = deadline
55
18
  @monotonic_deadline = monotonic_deadline
56
19
  @mutex = Mutex.new
57
20
  @cancel_callbacks = []
58
21
  end
59
22
 
60
- # @return [Time, nil] the wall-clock deadline passed to {#initialize}, or +nil+.
61
- attr_reader :deadline
62
-
63
- # Returns the remaining seconds until the monotonic deadline fires, or +nil+
64
- # when no monotonic deadline is set. Returns 0.0 if already past.
65
- # @return [Float, nil]
66
23
  # @api public
67
24
  def remaining_monotonic_seconds
68
25
  return nil if @monotonic_deadline.nil?
26
+
69
27
  remaining = @monotonic_deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
70
28
  [remaining, 0.0].max
71
29
  end
72
30
 
73
- # Registers a one-shot callback invoked when this token is explicitly
74
- # cancelled via {#cancel!}. If the token is already cancelled, the block
75
- # is called immediately (still within the caller's thread).
76
- #
77
- # Callbacks are NOT fired for deadline-based cancellation (i.e. when
78
- # {#cancelled?} returns +true+ due to +@monotonic_deadline+ expiry). Use
79
- # {Phronomy::Concurrency::CancellationScope#deadline_in}, which registers
80
- # a timer via {Runtime#timer_queue} and calls {#cancel!} on expiry — this
81
- # fires all +on_cancel+ callbacks automatically. {.timeout_after} is a
82
- # lightweight alternative that uses lazy clock comparison only and does
83
- # NOT trigger callbacks on expiry.
84
- #
85
- # @yield called with no arguments when (or if) the token is cancelled
86
- # @return [self]
87
31
  # @api public
88
- # mutant:disable - mutex removal mutation is GVL-safe equivalent under MRI
89
32
  def on_cancel(&block)
90
33
  already_cancelled = @mutex.synchronize do
91
34
  if @cancelled
@@ -99,14 +42,11 @@ module Phronomy
99
42
  self
100
43
  end
101
44
 
102
- # Mark the token as cancelled and fire any registered {#on_cancel} callbacks.
103
- # Thread-safe; idempotent — calling multiple times has no additional effect.
104
- # @return [self]
105
45
  # @api public
106
- # mutant:disable - mutex removal and dup-vs-ref mutations are GVL-safe equivalents
107
46
  def cancel!
108
47
  callbacks = @mutex.synchronize do
109
48
  return self if @cancelled
49
+
110
50
  @cancelled = true
111
51
  @cancel_callbacks.dup
112
52
  end
@@ -114,28 +54,15 @@ module Phronomy
114
54
  self
115
55
  end
116
56
 
117
- # Returns +true+ when the token has been explicitly cancelled via {#cancel!},
118
- # when the wall-clock deadline has passed, or when the monotonic deadline
119
- # (set by {.timeout_after}) has elapsed. Thread-safe.
120
- # @return [Boolean]
121
57
  # @api public
122
- # mutant:disable - mutex removal on @cancelled read is GVL-safe equivalent under MRI
123
58
  def cancelled?
124
59
  return true if @mutex.synchronize { @cancelled }
125
- return true if !@deadline.nil? && Time.now >= @deadline
60
+
126
61
  !@monotonic_deadline.nil? &&
127
62
  Process.clock_gettime(Process::CLOCK_MONOTONIC) >= @monotonic_deadline
128
63
  end
129
64
 
130
- # Raises {Phronomy::CancellationError} if the token is cancelled.
131
- # A convenience method for cooperative cancellation checks inside tools,
132
- # RAG loaders, and hooks, replacing the +if cancelled? then raise+ pattern.
133
- #
134
- # @param message [String] optional error message
135
- # @return [nil] when the token is not cancelled
136
- # @raise [Phronomy::CancellationError] when the token is cancelled
137
65
  # @api public
138
- # mutant:disable - raise(CancellationError) resolves to raise(Phronomy::CancellationError) in this namespace
139
66
  def raise_if_cancelled!(message = "invocation cancelled")
140
67
  raise Phronomy::CancellationError, message if cancelled?
141
68
  end