phronomy 0.15.1 → 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 (97) hide show
  1. checksums.yaml +4 -4
  2. data/.mutant.yml +8 -9
  3. data/CHANGELOG.md +159 -28
  4. data/CONTRIBUTING.md +28 -16
  5. data/README.md +400 -143
  6. data/benchmark/baseline.json +2 -3
  7. data/benchmark/bench_agent_invoke.rb +7 -4
  8. data/benchmark/bench_context_assembler.rb +134 -34
  9. data/benchmark/bench_regression.rb +3 -19
  10. data/benchmark/bench_tool_schema.rb +2 -34
  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 +40 -1
  14. data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
  15. data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
  16. data/lib/phronomy/agent/activation_registry.rb +28 -0
  17. data/lib/phronomy/agent/agent_execution.rb +97 -0
  18. data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
  19. data/lib/phronomy/agent/agent_invocation.rb +44 -46
  20. data/lib/phronomy/agent/agent_invocation_session_builder.rb +206 -104
  21. data/lib/phronomy/agent/agent_root.rb +66 -0
  22. data/lib/phronomy/agent/async_event_api.rb +55 -475
  23. data/lib/phronomy/agent/base.rb +351 -514
  24. data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
  25. data/lib/phronomy/agent/context/capability/base.rb +166 -297
  26. data/lib/phronomy/agent/context_assembler.rb +357 -0
  27. data/lib/phronomy/agent/context_candidate.rb +47 -0
  28. data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
  29. data/lib/phronomy/agent/context_importer.rb +217 -0
  30. data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
  31. data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
  32. data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
  33. data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +118 -0
  34. data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
  35. data/lib/phronomy/agent/context_plan.rb +25 -0
  36. data/lib/phronomy/agent/context_plan_validator.rb +134 -0
  37. data/lib/phronomy/agent/context_policies/default.rb +53 -0
  38. data/lib/phronomy/agent/context_policy.rb +15 -0
  39. data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
  40. data/lib/phronomy/agent/context_policy_registry.rb +46 -0
  41. data/lib/phronomy/agent/context_request.rb +35 -0
  42. data/lib/phronomy/agent/context_selection_unit.rb +38 -0
  43. data/lib/phronomy/agent/derived_content_spec.rb +34 -0
  44. data/lib/phronomy/agent/execution_coordinator.rb +1122 -0
  45. data/lib/phronomy/agent/immutable.rb +31 -0
  46. data/lib/phronomy/agent/journal_projection.rb +60 -0
  47. data/lib/phronomy/agent/journal_record.rb +67 -0
  48. data/lib/phronomy/agent/llm_call_record.rb +51 -0
  49. data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
  50. data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
  51. data/lib/phronomy/agent/llm_input_patch.rb +21 -0
  52. data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
  53. data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
  54. data/lib/phronomy/agent/ruby_llm_materializer.rb +189 -0
  55. data/lib/phronomy/agent/shared_state.rb +46 -138
  56. data/lib/phronomy/agent/token_budget_resolver.rb +70 -0
  57. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
  58. data/lib/phronomy/agent/tool_definition_set.rb +55 -0
  59. data/lib/phronomy/agent/tool_invocation.rb +108 -314
  60. data/lib/phronomy/agent.rb +10 -16
  61. data/lib/phronomy/agent_busy_error.rb +5 -0
  62. data/lib/phronomy/canonical_json.rb +136 -0
  63. data/lib/phronomy/configuration.rb +17 -155
  64. data/lib/phronomy/content_store/base.rb +51 -0
  65. data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
  66. data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
  67. data/lib/phronomy/engine/event_loop.rb +3 -0
  68. data/lib/phronomy/engine/runtime.rb +15 -230
  69. data/lib/phronomy/engine/task_group.rb +30 -102
  70. data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
  71. data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
  72. data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
  73. data/lib/phronomy/multi_agent/orchestrator.rb +153 -204
  74. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
  75. data/lib/phronomy/multi_agent/team_coordinator.rb +46 -133
  76. data/lib/phronomy/persistence/in_memory.rb +247 -0
  77. data/lib/phronomy/persistence.rb +39 -0
  78. data/lib/phronomy/tools/agent.rb +14 -36
  79. data/lib/phronomy/vector_store/in_memory.rb +2 -2
  80. data/lib/phronomy/version.rb +1 -1
  81. data/lib/phronomy.rb +9 -115
  82. data/scripts/add_to_h_to_token_doubles.rb +33 -0
  83. data/scripts/add_to_h_unnamed_doubles.rb +27 -0
  84. data/scripts/api_snapshot.rb +1 -12
  85. data/scripts/migrate_spec_agent_definition.rb +108 -0
  86. data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
  87. data/scripts/migrate_spec_inline_pass3.rb +24 -0
  88. metadata +54 -13
  89. data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
  90. data/lib/phronomy/agent/before_completion_context.rb +0 -47
  91. data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
  92. data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
  93. data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
  94. data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
  95. data/lib/phronomy/knowledge_source.rb +0 -12
  96. data/lib/phronomy/llm_context_window/assembler.rb +0 -191
  97. data/lib/phronomy/llm_context_window/context_version_cache.rb +0 -52
@@ -0,0 +1,136 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Phronomy
6
+ # Phronomy Canonical JSON v1.
7
+ #
8
+ # v1 accepts JSON-native values only, orders object names by UTF-16 code
9
+ # units, and emits ECMAScript/JCS-compatible number forms for IEEE-754
10
+ # doubles. Ruby-specific and non-interoperable numeric values must be
11
+ # converted by a domain codec before serialization.
12
+ class CanonicalJSON
13
+ VERSION = 1
14
+ MAX_SAFE_INTEGER = 9_007_199_254_740_991
15
+
16
+ class << self
17
+ def dump(value)
18
+ serialize(value)
19
+ end
20
+
21
+ def load(bytes)
22
+ JSON.parse(bytes)
23
+ end
24
+
25
+ private
26
+
27
+ def serialize(value)
28
+ case value
29
+ when Hash
30
+ serialize_hash(value)
31
+ when Array
32
+ "[#{value.map { |child| serialize(child) }.join(",")}]"
33
+ when String
34
+ JSON.generate(ensure_utf8(value))
35
+ when Integer
36
+ serialize_integer(value)
37
+ when Float
38
+ serialize_float(value)
39
+ when TrueClass then "true"
40
+ when FalseClass then "false"
41
+ when NilClass then "null"
42
+ else
43
+ raise ArgumentError,
44
+ "unsupported Phronomy Canonical JSON v1 value: #{value.class}"
45
+ end
46
+ end
47
+
48
+ def serialize_hash(value)
49
+ normalized = {}
50
+ value.each do |key, child|
51
+ unless key.is_a?(String)
52
+ raise ArgumentError,
53
+ "canonical JSON object keys must be String, got #{key.class}"
54
+ end
55
+ canonical_key = ensure_utf8(key)
56
+ if normalized.key?(canonical_key)
57
+ raise ArgumentError,
58
+ "duplicate canonical JSON key: #{canonical_key.inspect}"
59
+ end
60
+ normalized[canonical_key] = child
61
+ end
62
+
63
+ members = normalized.sort_by { |key, _| utf16_sort_key(key) }.map do |key, child|
64
+ "#{JSON.generate(key)}:#{serialize(child)}"
65
+ end
66
+ "{#{members.join(",")}}"
67
+ end
68
+
69
+ def serialize_integer(value)
70
+ if value.abs > MAX_SAFE_INTEGER
71
+ raise ArgumentError,
72
+ "integer exceeds canonical JSON safe range; encode it as a String: #{value}"
73
+ end
74
+ value.to_s
75
+ end
76
+
77
+ def serialize_float(value)
78
+ raise ArgumentError, "non-finite number is not canonical JSON" unless value.finite?
79
+ raise ArgumentError, "-0.0 is not canonical JSON v1" if negative_zero?(value)
80
+ return "0" if value.zero?
81
+
82
+ raw = value.to_s.downcase
83
+ return normalize_plain_decimal(raw) unless raw.include?("e")
84
+
85
+ sign = raw.start_with?("-") ? "-" : ""
86
+ raw = raw.delete_prefix("-")
87
+ mantissa, exponent_text = raw.split("e", 2)
88
+ exponent = Integer(exponent_text, 10)
89
+ integer_part, fractional_part = mantissa.split(".", 2)
90
+ fractional_part ||= ""
91
+ digits = (integer_part + fractional_part).sub(/0+\z/, "")
92
+ digits = "0" if digits.empty?
93
+ decimal_position = integer_part.length + exponent
94
+
95
+ body = if decimal_position > 0 && decimal_position <= 21
96
+ if decimal_position >= digits.length
97
+ digits + ("0" * (decimal_position - digits.length))
98
+ else
99
+ "#{digits[0, decimal_position]}.#{digits[decimal_position..]}"
100
+ end
101
+ elsif decimal_position <= 0 && decimal_position > -6
102
+ "0.#{"0" * -decimal_position}#{digits}"
103
+ else
104
+ scientific_exponent = decimal_position - 1
105
+ fraction = digits[1..]
106
+ coefficient = (fraction.nil? || fraction.empty?) ? digits[0] : "#{digits[0]}.#{fraction}"
107
+ exponent_sign = scientific_exponent.negative? ? "" : "+"
108
+ "#{coefficient}e#{exponent_sign}#{scientific_exponent}"
109
+ end
110
+ "#{sign}#{body}"
111
+ end
112
+
113
+ def normalize_plain_decimal(raw)
114
+ raw = raw.delete_suffix(".0")
115
+ (raw == "-0") ? "0" : raw
116
+ end
117
+
118
+ def negative_zero?(value)
119
+ value.zero? && (1.0 / value).negative?
120
+ end
121
+
122
+ def utf16_sort_key(value)
123
+ value.encode(Encoding::UTF_16BE).bytes
124
+ end
125
+
126
+ def ensure_utf8(value)
127
+ text = value.dup.encode(Encoding::UTF_8)
128
+ raise ArgumentError, "invalid UTF-8 string" unless text.valid_encoding?
129
+
130
+ text
131
+ rescue EncodingError => error
132
+ raise ArgumentError, "invalid UTF-8 string: #{error.message}"
133
+ end
134
+ end
135
+ end
136
+ end
@@ -3,186 +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_completion hook callable (Proc / lambda).
26
- # Called before every LLM request across all agents.
27
- # Receives a {Phronomy::Agent::BeforeCompletionContext}; must return a Hash
28
- # of params to merge, or nil to pass through unchanged.
29
- attr_accessor :before_completion
30
-
31
- # Recursion limit for graph execution (default: 25)
14
+ attr_accessor :before_llm_input
15
+ attr_accessor :default_output_reserve
32
16
  attr_accessor :recursion_limit
33
-
34
- # When true, agent LLM calls use {Phronomy::MultiAgent::ParallelToolChat}
35
- # for concurrent tool dispatch within a single agent turn.
36
- # Defaults to false.
37
- #
38
- # Previously, this was automatically enabled when +event_loop+ was true.
39
- # As of Phase 3, +parallel_tool_execution+ is a separate setting that must
40
- # be explicitly enabled.
41
- # @example
42
- # Phronomy.configure { |c| c.parallel_tool_execution = true }
43
- # @return [Boolean]
44
17
  attr_accessor :parallel_tool_execution
45
-
46
- # When true, user input and LLM output are recorded in trace spans.
47
- # Defaults to false; set to true only in environments where PII capture is acceptable.
48
- # Set to false in privacy-sensitive environments to prevent PII from reaching
49
- # the tracing backend (OTel, Langfuse, etc.).
50
18
  attr_accessor :trace_pii
51
-
52
- # Optional logger for framework diagnostic messages (e.g. unreachable-state warnings).
53
- # Must respond to +#warn(message)+. When nil (default), messages are written to +$stderr+
54
- # via +Kernel#warn+.
55
- # @example
56
- # Phronomy.configure { |c| c.logger = Rails.logger }
57
19
  attr_accessor :logger
58
-
59
- # Grace period (in seconds) before the EventLoop background thread is force-killed
60
- # after a cooperative stop request. Applies both to the overall thread join
61
- # and to the drain-and-cancel phase when +stop(drain: true)+ is used.
62
- # Default: 5 seconds.
63
- # @see Phronomy::EventLoop#stop
64
20
  attr_accessor :event_loop_stop_grace_seconds
65
-
66
- # Global state store for workflow persistence.
67
- # When set, WorkflowRunner routes all state reads and writes through this store.
68
- # Must be an instance of a class that inherits from Phronomy::StateStore::Base.
69
- # Defaults to +nil+ (no persistence — state lives only for the duration of invoke).
70
- # @example
71
- # Phronomy.configure { |c| c.state_store = Phronomy::StateStore::InMemory.new }
72
21
  attr_accessor :state_store
73
-
74
- # Maximum byte length of a tool result returned to the LLM.
75
- # When a tool returns a String longer than this limit, the string is truncated
76
- # and a warning is logged. Set to +nil+ (default) to disable truncation.
77
- # @example
78
- # Phronomy.configure { |c| c.tool_result_max_size = 8192 }
79
22
  attr_accessor :tool_result_max_size
80
-
81
- # LLM adapter used by Agent::Base to perform LLM calls.
82
- # Must be an instance of a class that inherits from
83
- # {Phronomy::LLMAdapter::Base}. Defaults to
84
- # {Phronomy::LLMAdapter::RubyLLM} which delegates to +chat.ask+ via
85
- # {BlockingAdapterPool}.
86
- # Set to a custom adapter to swap in an alternative LLM client without
87
- # changing any agent code.
88
- # @example
89
- # Phronomy.configure { |c| c.llm_adapter = MyAsyncLLMAdapter.new }
90
23
  attr_accessor :llm_adapter
91
-
92
- # Set to +nil+ to disable the warning.
93
- # @return [Numeric, nil]
94
24
  attr_accessor :event_loop_starvation_threshold_seconds
95
-
96
- # Warn when processing a single event on the EventLoop thread takes longer
97
- # than this many seconds (long-running task / blocking-on-loop detection).
98
- # Set to +nil+ to disable the warning.
99
- # @return [Numeric, nil]
100
25
  attr_accessor :event_loop_dispatch_threshold_seconds
101
-
102
- # When true, enables all blocking operation diagnostics (Issue #279).
103
- # Equivalent to setting all diagnostic thresholds to their defaults.
104
- # @return [Boolean]
105
26
  attr_accessor :scheduler_debug
106
-
107
- # Wall-clock threshold (milliseconds) after which a task that has not
108
- # yielded the scheduler emits a warning log. nil disables the check.
109
- # @return [Float, nil]
110
27
  attr_accessor :blocking_detect_threshold_ms
111
-
112
- # Determines how an unhandled Application exception from a terminal stream
113
- # callback affects the Task returned by Agent#stream_async or
114
- # Agent#approve_async.
115
- #
116
- # +:report+ logs the callback failure and preserves the Agent result.
117
- # +:fail_task+ logs the callback failure and fails the current Task with
118
- # {Phronomy::StreamCallbackError}. Neither policy terminates EventLoop.
119
- #
120
- # Default: +:report+.
121
- # @return [:report, :fail_task]
122
28
  attr_reader :stream_callback_error_policy
123
-
124
- # Number of OS worker threads in the default {BlockingAdapterPool}.
125
- # All LLM calls, MCP tool calls, and other blocking I/O share this pool.
126
- # Increase for higher LLM/tool throughput; decrease to limit
127
- # concurrency (e.g. to stay within a provider's rate limit).
128
- # Default: 10.
129
- # @return [Integer]
130
29
  attr_accessor :blocking_io_pool_size
131
-
132
- # Maximum number of operations that may wait in the {BlockingAdapterPool}
133
- # queue before {Phronomy::BackpressureError} is raised (on_full: :raise) or
134
- # the caller blocks (on_full: :wait, the default). Default: 100.
135
- # @return [Integer]
136
30
  attr_accessor :blocking_io_queue_size
137
-
138
- # Worker count for Tool authorization evaluation. The named pool is owned
139
- # by Runtime#pool(:authorization) and shares PoolRegistry lifecycle.
140
- # @return [Integer]
141
31
  attr_accessor :authorization_pool_size
142
-
143
- # Maximum queued Tool authorization evaluations.
144
- # @return [Integer]
145
32
  attr_accessor :authorization_queue_size
146
-
147
- # Operation-wide deadline for approval_facts, requires_approval callables,
148
- # and Agent#tool_approval_policy. Timeout fails closed to Human approval.
149
- # @return [Numeric]
150
33
  attr_accessor :authorization_timeout
151
-
152
- # Scheduler starvation threshold (milliseconds).
153
- # When a task waits more than this many milliseconds after calling
154
- # +runtime.yield+ before being resumed, the wait is counted as a starvation
155
- # event. Used by the fairness regression test and by the
156
- # +tasks_waiting_over_threshold+ metric on {Phronomy::Runtime}.
157
- # Default: 50ms.
158
- # @return [Numeric]
159
34
  attr_accessor :starvation_threshold_ms
160
-
161
- # Scheduler backend to use for new {Phronomy::Runtime} instances.
162
- #
163
- # | Value | Scheduler | Typical use |
164
- # |-------|-----------|-------------|
165
- # | +:thread+ | {Runtime::ThreadScheduler} | **Default** — production-ready; one OS thread per task |
166
- # | +:immediate+ | {Runtime::FakeScheduler} | Tests — tasks run synchronously, no extra threads |
167
- # | +:fiber+ | {Runtime::DeterministicScheduler} (autorun) | **EXPERIMENTAL** — Fiber-based cooperative scheduler; do not use as production default |
168
- # | +:cooperative+ | {Runtime::FakeScheduler} | **Deprecated** — alias for +:immediate+; do not use in new code |
169
- #
170
- # The default is +:thread+. The +:fiber+ backend remains experimental and opt-in;
171
- # it will not become the default until integration test coverage is production grade
172
- # and virtual-time/timeout semantics are fully resolved (see Issues #350, #347, #348).
173
- #
174
- # When this setting is changed, the change only takes effect on the NEXT
175
- # call to {Runtime.instance} that auto-creates a new instance (i.e. after the
176
- # previous instance has been replaced or reset). To replace the current
177
- # instance immediately call +Phronomy::Runtime.instance = nil+ first.
178
- #
179
- # @return [:thread, :immediate, :fiber]
180
- attr_accessor :runtime_backend
181
-
182
- # When +true+, calling {Agent#invoke} from inside a scheduler task
183
- # raises {SchedulerReentrancyError}. When +false+ (default), a warning
184
- # is logged instead so that existing callers have time to migrate.
185
- # @return [Boolean]
35
+ attr_reader :runtime_backend
186
36
  attr_accessor :strict_runtime_guards
187
37
 
188
38
  def stream_callback_error_policy=(value)
@@ -195,6 +45,18 @@ module Phronomy
195
45
  @stream_callback_error_policy = value
196
46
  end
197
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
+
198
60
  def initialize
199
61
  @recursion_limit = 25
200
62
  @tracer = Phronomy::Tracing::NullTracer.new
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+
5
+ module Phronomy
6
+ module ContentStore
7
+ class IntegrityError < Phronomy::Error; end
8
+
9
+ class Base
10
+ def put(_bytes, canonicalization_version:) = raise(NotImplementedError)
11
+ def fetch(_content_id) = raise(NotImplementedError)
12
+ def exist?(_content_id) = raise(NotImplementedError)
13
+
14
+ def put_text(text)
15
+ value = String(text).encode(Encoding::UTF_8)
16
+ raise ArgumentError, "invalid UTF-8 text" unless value.valid_encoding?
17
+
18
+ put(value, canonicalization_version: 1)
19
+ end
20
+
21
+ def put_json(value)
22
+ put(
23
+ Phronomy::CanonicalJSON.dump(value),
24
+ canonicalization_version: Phronomy::CanonicalJSON::VERSION
25
+ )
26
+ end
27
+
28
+ def fetch_text(content_id)
29
+ value = fetch(content_id).force_encoding(Encoding::UTF_8)
30
+ unless value.valid_encoding?
31
+ raise IntegrityError, "content is not UTF-8: #{content_id}"
32
+ end
33
+ value
34
+ end
35
+
36
+ def fetch_json(content_id)
37
+ Phronomy::CanonicalJSON.load(fetch(content_id))
38
+ end
39
+
40
+ def fetch_many(content_ids)
41
+ Array(content_ids).uniq.to_h { |content_id| [content_id, fetch(content_id)] }
42
+ end
43
+
44
+ private
45
+
46
+ def content_id_for(bytes)
47
+ "sha256:#{Digest::SHA256.hexdigest(bytes)}"
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Raised when mandatory context content (system instructions, tool definitions,
5
+ # current input) exhausts the model's context window, leaving no room for
6
+ # prior conversation history.
7
+ class ContextBudgetExceededError < Error; end
8
+ end
@@ -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
@@ -319,6 +319,9 @@ module Phronomy
319
319
  @fsms[session.id] = session
320
320
  @waiting[session.id] = waiter if waiter
321
321
  session.start
322
+ when :agent_terminal_ready
323
+ cmd = event.payload.fetch(:command)
324
+ cmd.coordinator.deliver_on_event_loop(cmd)
322
325
  end
323
326
  end
324
327