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.
- checksums.yaml +4 -4
- data/.mutant.yml +8 -9
- data/CHANGELOG.md +159 -28
- data/CONTRIBUTING.md +28 -16
- data/README.md +400 -143
- data/benchmark/baseline.json +2 -3
- data/benchmark/bench_agent_invoke.rb +7 -4
- data/benchmark/bench_context_assembler.rb +134 -34
- data/benchmark/bench_regression.rb +3 -19
- data/benchmark/bench_tool_schema.rb +2 -34
- data/docs/decisions/005-static-knowledge-class-level-cache.md +12 -1
- data/docs/decisions/010-cooperative-first-concurrency.md +7 -0
- data/docs/decisions/011-build-context-as-single-llm-input-authority.md +40 -1
- data/docs/decisions/012-canonical-execution-log-and-context-policy.md +69 -0
- data/docs/decisions/013-journal-backed-knowledge-as-context-candidates.md +122 -0
- data/lib/phronomy/agent/activation_registry.rb +28 -0
- data/lib/phronomy/agent/agent_execution.rb +97 -0
- data/lib/phronomy/agent/agent_execution_activation.rb +172 -0
- data/lib/phronomy/agent/agent_invocation.rb +44 -46
- data/lib/phronomy/agent/agent_invocation_session_builder.rb +206 -104
- data/lib/phronomy/agent/agent_root.rb +66 -0
- data/lib/phronomy/agent/async_event_api.rb +55 -475
- data/lib/phronomy/agent/base.rb +351 -514
- data/lib/phronomy/agent/concerns/before_llm_input.rb +66 -0
- data/lib/phronomy/agent/context/capability/base.rb +166 -297
- data/lib/phronomy/agent/context_assembler.rb +357 -0
- data/lib/phronomy/agent/context_candidate.rb +47 -0
- data/lib/phronomy/agent/context_candidate_resolver.rb +65 -0
- data/lib/phronomy/agent/context_importer.rb +217 -0
- data/lib/phronomy/agent/context_parts/budget/token_budget_packer.rb +53 -0
- data/lib/phronomy/agent/context_parts/requirements/required_context_resolver.rb +56 -0
- data/lib/phronomy/agent/context_parts/selectors/recent_first_selector.rb +30 -0
- data/lib/phronomy/agent/context_parts/unit_builders/dependency_aware_unit_builder.rb +118 -0
- data/lib/phronomy/agent/context_parts/validators/final_budget_validator.rb +37 -0
- data/lib/phronomy/agent/context_plan.rb +25 -0
- data/lib/phronomy/agent/context_plan_validator.rb +134 -0
- data/lib/phronomy/agent/context_policies/default.rb +53 -0
- data/lib/phronomy/agent/context_policy.rb +15 -0
- data/lib/phronomy/agent/context_policy_descriptor.rb +49 -0
- data/lib/phronomy/agent/context_policy_registry.rb +46 -0
- data/lib/phronomy/agent/context_request.rb +35 -0
- data/lib/phronomy/agent/context_selection_unit.rb +38 -0
- data/lib/phronomy/agent/derived_content_spec.rb +34 -0
- data/lib/phronomy/agent/execution_coordinator.rb +1122 -0
- data/lib/phronomy/agent/immutable.rb +31 -0
- data/lib/phronomy/agent/journal_projection.rb +60 -0
- data/lib/phronomy/agent/journal_record.rb +67 -0
- data/lib/phronomy/agent/llm_call_record.rb +51 -0
- data/lib/phronomy/agent/llm_input_build_context.rb +17 -0
- data/lib/phronomy/agent/llm_input_manifest.rb +103 -0
- data/lib/phronomy/agent/llm_input_patch.rb +21 -0
- data/lib/phronomy/agent/phase_machine_builder.rb +12 -0
- data/lib/phronomy/agent/provider_call_outcome.rb +90 -0
- data/lib/phronomy/agent/ruby_llm_materializer.rb +189 -0
- data/lib/phronomy/agent/shared_state.rb +46 -138
- data/lib/phronomy/agent/token_budget_resolver.rb +70 -0
- data/lib/phronomy/agent/tool_call_intercepted.rb +11 -4
- data/lib/phronomy/agent/tool_definition_set.rb +55 -0
- data/lib/phronomy/agent/tool_invocation.rb +108 -314
- data/lib/phronomy/agent.rb +10 -16
- data/lib/phronomy/agent_busy_error.rb +5 -0
- data/lib/phronomy/canonical_json.rb +136 -0
- data/lib/phronomy/configuration.rb +17 -155
- data/lib/phronomy/content_store/base.rb +51 -0
- data/lib/phronomy/context_budget_exceeded_error.rb +8 -0
- data/lib/phronomy/engine/concurrency/cancellation_token.rb +7 -80
- data/lib/phronomy/engine/event_loop.rb +3 -0
- data/lib/phronomy/engine/runtime.rb +15 -230
- data/lib/phronomy/engine/task_group.rb +30 -102
- data/lib/phronomy/execution_rehydration_required_error.rb +5 -0
- data/lib/phronomy/invalid_context_budget_configuration_error.rb +8 -0
- data/lib/phronomy/llm_context_window/token_budget.rb +8 -79
- data/lib/phronomy/multi_agent/orchestrator.rb +153 -204
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +7 -5
- data/lib/phronomy/multi_agent/team_coordinator.rb +46 -133
- data/lib/phronomy/persistence/in_memory.rb +247 -0
- data/lib/phronomy/persistence.rb +39 -0
- data/lib/phronomy/tools/agent.rb +14 -36
- data/lib/phronomy/vector_store/in_memory.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy.rb +9 -115
- data/scripts/add_to_h_to_token_doubles.rb +33 -0
- data/scripts/add_to_h_unnamed_doubles.rb +27 -0
- data/scripts/api_snapshot.rb +1 -12
- data/scripts/migrate_spec_agent_definition.rb +108 -0
- data/scripts/migrate_spec_agent_definition_pass2.rb +53 -0
- data/scripts/migrate_spec_inline_pass3.rb +24 -0
- metadata +54 -13
- data/lib/phronomy/agent/agent_invocation_registry.rb +0 -75
- data/lib/phronomy/agent/before_completion_context.rb +0 -47
- data/lib/phronomy/agent/concerns/before_completion.rb +0 -111
- data/lib/phronomy/agent/context/knowledge/base.rb +0 -58
- data/lib/phronomy/agent/context/knowledge/entity_knowledge.rb +0 -102
- data/lib/phronomy/agent/context/knowledge/static_knowledge.rb +0 -58
- data/lib/phronomy/knowledge_source.rb +0 -12
- data/lib/phronomy/llm_context_window/assembler.rb +0 -191
- 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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|