phronomy 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/.mutant.yml +8 -7
- data/CHANGELOG.md +151 -1
- data/README.md +170 -47
- data/Rakefile +33 -0
- data/benchmark/baseline.json +1 -1
- data/benchmark/bench_context_assembler.rb +2 -2
- data/benchmark/bench_regression.rb +6 -5
- data/benchmark/bench_token_estimator.rb +5 -5
- data/benchmark/bench_tool_schema.rb +1 -1
- data/benchmark/bench_vector_store.rb +1 -1
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +24 -0
- data/docs/decisions/006-no-built-in-guardrails.md +20 -2
- data/docs/decisions/010-cooperative-first-concurrency.md +248 -0
- data/lib/phronomy/agent/base.rb +285 -137
- data/lib/phronomy/agent/checkpoint.rb +118 -0
- data/lib/phronomy/agent/concerns/suspendable.rb +15 -0
- data/lib/phronomy/agent/context/conversation/compaction_context.rb +117 -0
- data/lib/phronomy/agent/context/conversation/trigger_context.rb +43 -0
- data/lib/phronomy/agent/context/conversation/trim_context.rb +82 -0
- data/lib/phronomy/agent/context/instruction/prompt_template.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/base.rb +45 -0
- data/lib/phronomy/agent/context/knowledge/embeddings/ruby_llm_embeddings.rb +51 -0
- data/lib/phronomy/agent/context/knowledge/loader/base.rb +31 -0
- data/lib/phronomy/agent/context/knowledge/loader/csv_loader.rb +62 -0
- data/lib/phronomy/agent/context/knowledge/loader/markdown_loader.rb +82 -0
- data/lib/phronomy/agent/context/knowledge/loader/plain_text_loader.rb +28 -0
- data/lib/phronomy/agent/context/knowledge/source/base.rb +60 -0
- data/lib/phronomy/agent/context/knowledge/source/entity_knowledge.rb +102 -0
- data/lib/phronomy/agent/context/knowledge/source/rag_knowledge.rb +63 -0
- data/lib/phronomy/agent/context/knowledge/source/static_knowledge.rb +58 -0
- data/lib/phronomy/agent/context/knowledge/splitter/base.rb +53 -0
- data/lib/phronomy/agent/context/knowledge/splitter/fixed_size_splitter.rb +57 -0
- data/lib/phronomy/agent/context/knowledge/splitter/recursive_splitter.rb +111 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/async_backend.rb +116 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/base.rb +95 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/in_memory.rb +109 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/pgvector.rb +133 -0
- data/lib/phronomy/agent/context/knowledge/vector_store/redis_search.rb +198 -0
- data/lib/phronomy/agent/fsm.rb +42 -65
- data/lib/phronomy/agent/invocation_pipeline.rb +99 -0
- data/lib/phronomy/agent/lifecycle/fsm_session.rb +251 -0
- data/lib/phronomy/agent/lifecycle/phase_machine_builder.rb +249 -0
- data/lib/phronomy/agent/react_agent.rb +27 -14
- data/lib/phronomy/agent/runner.rb +2 -2
- data/lib/phronomy/agent/tool_executor.rb +108 -0
- data/lib/phronomy/concurrency/async_queue.rb +157 -0
- data/lib/phronomy/concurrency/blocking_adapter_pool.rb +443 -0
- data/lib/phronomy/concurrency/cancellation_scope.rb +125 -0
- data/lib/phronomy/concurrency/cancellation_token.rb +140 -0
- data/lib/phronomy/concurrency/concurrency_gate.rb +157 -0
- data/lib/phronomy/concurrency/deadline.rb +65 -0
- data/lib/phronomy/concurrency/gate_registry.rb +52 -0
- data/lib/phronomy/concurrency/pool_registry.rb +57 -0
- data/lib/phronomy/configuration.rb +142 -0
- data/lib/phronomy/context.rb +2 -8
- data/lib/phronomy/diagnostics.rb +62 -0
- data/lib/phronomy/embeddings.rb +2 -2
- data/lib/phronomy/eval/runner.rb +13 -9
- data/lib/phronomy/eval/scorer/llm_judge.rb +12 -1
- data/lib/phronomy/event_loop.rb +184 -46
- data/lib/phronomy/guardrail/prompt_injection_guardrail.rb +58 -0
- data/lib/phronomy/invocation_context.rb +152 -0
- data/lib/phronomy/knowledge_source.rb +0 -5
- data/lib/phronomy/llm_adapter/base.rb +104 -0
- data/lib/phronomy/llm_adapter/ruby_llm.rb +47 -0
- data/lib/phronomy/llm_adapter.rb +20 -0
- data/lib/phronomy/{context → llm_context_window}/assembler.rb +18 -3
- data/lib/phronomy/{context → llm_context_window}/context_version_cache.rb +1 -1
- data/lib/phronomy/{context → llm_context_window}/token_budget.rb +7 -4
- data/lib/phronomy/{context → llm_context_window}/token_estimator.rb +3 -3
- data/lib/phronomy/loader.rb +4 -4
- data/lib/phronomy/metrics.rb +38 -0
- data/lib/phronomy/{agent → multi_agent}/handoff.rb +2 -2
- data/lib/phronomy/{agent → multi_agent}/orchestrator.rb +151 -126
- data/lib/phronomy/multi_agent/parallel_tool_chat.rb +149 -0
- data/lib/phronomy/{agent → multi_agent}/team_coordinator.rb +2 -2
- data/lib/phronomy/runtime/deterministic_scheduler.rb +412 -0
- data/lib/phronomy/runtime/fake_scheduler.rb +165 -0
- data/lib/phronomy/runtime/runtime_metrics.rb +117 -0
- data/lib/phronomy/runtime/scheduler.rb +98 -0
- data/lib/phronomy/runtime/scheduler_timer_adapter.rb +79 -0
- data/lib/phronomy/runtime/task_registry.rb +48 -0
- data/lib/phronomy/runtime/thread_scheduler.rb +30 -0
- data/lib/phronomy/runtime/timer_queue.rb +106 -0
- data/lib/phronomy/runtime/timer_service.rb +42 -0
- data/lib/phronomy/runtime.rb +389 -0
- data/lib/phronomy/splitter.rb +3 -3
- data/lib/phronomy/task/backend.rb +80 -0
- data/lib/phronomy/task/fiber_backend.rb +157 -0
- data/lib/phronomy/task/immediate_backend.rb +89 -0
- data/lib/phronomy/task/thread_backend.rb +84 -0
- data/lib/phronomy/task.rb +275 -0
- data/lib/phronomy/task_group.rb +265 -0
- data/lib/phronomy/testing/fake_clock.rb +109 -0
- data/lib/phronomy/testing/fake_scheduler.rb +104 -0
- data/lib/phronomy/testing/scheduler_helpers.rb +59 -0
- data/lib/phronomy/testing.rb +12 -0
- data/lib/phronomy/tool/base.rb +156 -7
- data/lib/phronomy/tool/mcp_tool.rb +47 -16
- data/lib/phronomy/tool/scope_policy.rb +50 -0
- data/lib/phronomy/tracing/null_tracer.rb +3 -1
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +34 -0
- data/lib/phronomy/vector_store.rb +2 -2
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +52 -5
- data/lib/phronomy/workflow_context.rb +37 -2
- data/lib/phronomy/workflow_runner.rb +28 -77
- data/lib/phronomy.rb +43 -0
- metadata +73 -33
- data/lib/phronomy/agent/parallel_tool_chat.rb +0 -92
- data/lib/phronomy/cancellation_token.rb +0 -92
- data/lib/phronomy/context/compaction_context.rb +0 -111
- data/lib/phronomy/context/trigger_context.rb +0 -39
- data/lib/phronomy/context/trim_context.rb +0 -75
- data/lib/phronomy/embeddings/base.rb +0 -22
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +0 -45
- data/lib/phronomy/fsm_session.rb +0 -201
- data/lib/phronomy/knowledge_source/base.rb +0 -36
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +0 -96
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +0 -57
- data/lib/phronomy/knowledge_source/static_knowledge.rb +0 -52
- data/lib/phronomy/loader/base.rb +0 -25
- data/lib/phronomy/loader/csv_loader.rb +0 -56
- data/lib/phronomy/loader/markdown_loader.rb +0 -76
- data/lib/phronomy/loader/plain_text_loader.rb +0 -22
- data/lib/phronomy/prompt_template.rb +0 -96
- data/lib/phronomy/splitter/base.rb +0 -47
- data/lib/phronomy/splitter/fixed_size_splitter.rb +0 -51
- data/lib/phronomy/splitter/recursive_splitter.rb +0 -105
- data/lib/phronomy/vector_store/base.rb +0 -82
- data/lib/phronomy/vector_store/in_memory.rb +0 -93
- data/lib/phronomy/vector_store/pgvector.rb +0 -127
- data/lib/phronomy/vector_store/redis_search.rb +0 -192
|
@@ -5,7 +5,6 @@ require "net/http"
|
|
|
5
5
|
require "open3"
|
|
6
6
|
require "securerandom"
|
|
7
7
|
require "shellwords"
|
|
8
|
-
require "timeout"
|
|
9
8
|
require "uri"
|
|
10
9
|
|
|
11
10
|
module Phronomy
|
|
@@ -67,7 +66,7 @@ module Phronomy
|
|
|
67
66
|
|
|
68
67
|
def build_tool_class(tool_name, server_uri, tool_def)
|
|
69
68
|
klass = Class.new(McpTool)
|
|
70
|
-
klass.
|
|
69
|
+
klass.tool_name(tool_name)
|
|
71
70
|
klass.instance_variable_set(:@mcp_server_uri, server_uri)
|
|
72
71
|
|
|
73
72
|
# Register description and params from the MCP tool definition.
|
|
@@ -118,7 +117,9 @@ module Phronomy
|
|
|
118
117
|
# {HttpTransport}. Defaults to 30 seconds.
|
|
119
118
|
# @param env [Hash, nil] environment variable overrides for the subprocess.
|
|
120
119
|
# When provided, only these variables are added/overridden; the parent environment
|
|
121
|
-
# is still inherited
|
|
120
|
+
# is still inherited. Use +nil+ as a value to unset a variable in the child process
|
|
121
|
+
# (e.g. +{ "SECRET" => nil }+). An empty string value (+""+ ) sets the variable to
|
|
122
|
+
# an empty string — it does NOT unset it.
|
|
122
123
|
# @param cwd [String, nil] working directory for the subprocess.
|
|
123
124
|
# Defaults to the current process's working directory.
|
|
124
125
|
# @param startup_timeout [Numeric, nil] seconds to wait for the server to
|
|
@@ -138,6 +139,7 @@ module Phronomy
|
|
|
138
139
|
@stderr = nil
|
|
139
140
|
@wait_thr = nil
|
|
140
141
|
@stderr_thread = nil
|
|
142
|
+
@stderr_op = nil
|
|
141
143
|
end
|
|
142
144
|
|
|
143
145
|
# Shut down the child process and close its IO streams.
|
|
@@ -149,10 +151,17 @@ module Phronomy
|
|
|
149
151
|
@stdout = nil
|
|
150
152
|
@stderr = nil
|
|
151
153
|
stderr_thread = @stderr_thread
|
|
154
|
+
stderr_op = @stderr_op
|
|
152
155
|
wait_thr = @wait_thr
|
|
153
156
|
@stderr_thread = nil
|
|
157
|
+
@stderr_op = nil
|
|
154
158
|
@wait_thr = nil
|
|
155
159
|
stderr_thread&.join(1)
|
|
160
|
+
begin
|
|
161
|
+
stderr_op&.await(timeout: 1.0)
|
|
162
|
+
rescue
|
|
163
|
+
nil
|
|
164
|
+
end
|
|
156
165
|
wait_thr&.join(5)
|
|
157
166
|
end
|
|
158
167
|
|
|
@@ -209,31 +218,53 @@ module Phronomy
|
|
|
209
218
|
# Drain stderr asynchronously to prevent the pipe buffer from filling
|
|
210
219
|
# and deadlocking the child process. Errors inside the drain thread are
|
|
211
220
|
# silently ignored since stderr content is diagnostics-only.
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
221
|
+
#
|
|
222
|
+
# Prefer BlockingAdapterPool when a Runtime is configured so that this
|
|
223
|
+
# file eventually needs no direct Thread.new (Issue #360). Fall back to
|
|
224
|
+
# Thread.new when no pool is available (no EventLoop / bare invocation).
|
|
225
|
+
pool = begin; Phronomy::Runtime.instance&.blocking_io; rescue; nil; end
|
|
226
|
+
if pool
|
|
227
|
+
@stderr_op = pool.submit {
|
|
228
|
+
begin
|
|
229
|
+
@stderr.read
|
|
230
|
+
rescue
|
|
231
|
+
nil
|
|
232
|
+
end
|
|
233
|
+
}
|
|
234
|
+
@stderr_thread = nil
|
|
235
|
+
else
|
|
236
|
+
@stderr_thread = Thread.new {
|
|
237
|
+
begin
|
|
238
|
+
@stderr.read
|
|
239
|
+
rescue
|
|
240
|
+
nil
|
|
241
|
+
end
|
|
242
|
+
}
|
|
243
|
+
@stderr_op = nil
|
|
216
244
|
end
|
|
217
245
|
|
|
218
246
|
if @startup_timeout
|
|
219
|
-
|
|
247
|
+
unless IO.select([@stdout], nil, nil, @startup_timeout)
|
|
248
|
+
close
|
|
249
|
+
raise Phronomy::ToolError,
|
|
250
|
+
"MCP stdio server did not start within #{@startup_timeout} seconds"
|
|
251
|
+
end
|
|
252
|
+
line = @stdout.gets
|
|
253
|
+
@stdout.ungetbyte(line) if line
|
|
220
254
|
end
|
|
221
|
-
rescue Timeout::Error
|
|
222
|
-
close
|
|
223
|
-
raise Phronomy::ToolError,
|
|
224
|
-
"MCP stdio server did not start within #{@startup_timeout} seconds"
|
|
225
255
|
end
|
|
226
256
|
|
|
227
257
|
def rpc_call(method, params)
|
|
228
258
|
ensure_started!
|
|
229
259
|
payload = JSON.generate(jsonrpc: "2.0", id: SecureRandom.uuid, method: method, params: params)
|
|
230
260
|
@stdin.puts(payload)
|
|
231
|
-
|
|
261
|
+
unless IO.select([@stdout], nil, nil, @read_timeout)
|
|
262
|
+
raise Phronomy::ToolError,
|
|
263
|
+
"MCP stdio server did not respond within #{@read_timeout} seconds"
|
|
264
|
+
end
|
|
265
|
+
raw = @stdout.gets
|
|
232
266
|
raise Phronomy::ToolError, "MCP server closed the connection unexpectedly" if raw.nil?
|
|
233
267
|
JSON.parse(raw)
|
|
234
|
-
rescue Timeout::Error
|
|
235
|
-
raise Phronomy::ToolError,
|
|
236
|
-
"MCP stdio server did not respond within #{@read_timeout} seconds"
|
|
237
268
|
end
|
|
238
269
|
|
|
239
270
|
def parse_schema_params(properties, required_names: [])
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Tool
|
|
5
|
+
# Evaluates whether a tool with a given scope may execute.
|
|
6
|
+
#
|
|
7
|
+
# A ScopePolicy is a callable that receives +(tool_class, scope, agent)+ and
|
|
8
|
+
# returns one of:
|
|
9
|
+
# +:allow+ — proceed immediately without an approval gate.
|
|
10
|
+
# +:reject+ — block execution; the tool returns a denial message.
|
|
11
|
+
# +:approve+ — delegate to the agent's approval handler (if registered);
|
|
12
|
+
# when no handler is registered the call is rejected.
|
|
13
|
+
#
|
|
14
|
+
# The {Default} instance is used automatically when no custom policy is
|
|
15
|
+
# configured on an agent.
|
|
16
|
+
#
|
|
17
|
+
# @example Custom policy that allows everything
|
|
18
|
+
# agent.scope_policy = ->(_tool_class, _scope, _agent) { :allow }
|
|
19
|
+
#
|
|
20
|
+
# @example Strict policy that rejects all write scopes
|
|
21
|
+
# agent.scope_policy = ->(_tc, scope, _agent) {
|
|
22
|
+
# scope == :write ? :reject : :allow
|
|
23
|
+
# }
|
|
24
|
+
class ScopePolicy
|
|
25
|
+
# Scopes that must go through an approval gate before execution.
|
|
26
|
+
APPROVAL_REQUIRED_SCOPES = %i[write admin external_network filesystem process external_process].freeze
|
|
27
|
+
|
|
28
|
+
# Scopes that are always permitted without approval.
|
|
29
|
+
ALWAYS_ALLOWED_SCOPES = %i[read_only].freeze
|
|
30
|
+
|
|
31
|
+
# Returns +:allow+ for always-allowed scopes, +:approve+ for high-risk
|
|
32
|
+
# scopes, and +:allow+ for anything else (including +nil+).
|
|
33
|
+
#
|
|
34
|
+
# @param _tool_class [Class]
|
|
35
|
+
# @param scope [Symbol, nil]
|
|
36
|
+
# @param _agent [Object]
|
|
37
|
+
# @return [:allow, :approve, :reject]
|
|
38
|
+
# @api private
|
|
39
|
+
def call(_tool_class, scope, _agent)
|
|
40
|
+
return :allow if scope.nil? || ALWAYS_ALLOWED_SCOPES.include?(scope)
|
|
41
|
+
return :approve if APPROVAL_REQUIRED_SCOPES.include?(scope)
|
|
42
|
+
|
|
43
|
+
:allow
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Shared singleton used when no custom policy is configured.
|
|
47
|
+
DEFAULT = new.freeze
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -16,7 +16,9 @@ module Phronomy
|
|
|
16
16
|
# Returns a minimal span object with the given name.
|
|
17
17
|
def start_span(name, **) = SpanStruct.new(name)
|
|
18
18
|
|
|
19
|
-
# Does nothing.
|
|
19
|
+
# Does nothing. Explicit nil is equivalent to an empty method body; the
|
|
20
|
+
# mutation "remove nil" is accepted as it does not change observable behaviour.
|
|
21
|
+
# mutant:disable
|
|
20
22
|
def finish_span(span, **) = nil
|
|
21
23
|
end
|
|
22
24
|
end
|
|
@@ -52,6 +52,40 @@ module Phronomy
|
|
|
52
52
|
end
|
|
53
53
|
span.finish
|
|
54
54
|
end
|
|
55
|
+
|
|
56
|
+
# Overrides Base#trace to use OTel +in_span+, which pushes the span onto
|
|
57
|
+
# the OTel Context stack while the block runs. Nested +trace+ calls
|
|
58
|
+
# automatically inherit the current span as their parent, establishing the
|
|
59
|
+
# correct parent/child hierarchy in the trace tree.
|
|
60
|
+
#
|
|
61
|
+
# @param name [String] span name
|
|
62
|
+
# @param input [Object, nil] input to record as a span attribute
|
|
63
|
+
# @param meta [Hash] additional metadata (e.g. task_id, user_id)
|
|
64
|
+
# @yield [span] the active OTel span
|
|
65
|
+
# @return [Object] the block's return value
|
|
66
|
+
# @api private
|
|
67
|
+
def trace(name, input: nil, **meta)
|
|
68
|
+
attrs = {}
|
|
69
|
+
attrs["phronomy.input"] = input.to_s if input
|
|
70
|
+
meta.each { |k, v| attrs["phronomy.#{k}"] = v.to_s unless v.nil? }
|
|
71
|
+
|
|
72
|
+
result = nil
|
|
73
|
+
@otel_tracer.in_span(name, attributes: attrs) do |span|
|
|
74
|
+
result, usage = yield span
|
|
75
|
+
span.set_attribute("phronomy.output", result.to_s) if result
|
|
76
|
+
if usage
|
|
77
|
+
span.set_attribute("llm.usage.input_tokens", usage.input)
|
|
78
|
+
span.set_attribute("llm.usage.output_tokens", usage.output)
|
|
79
|
+
total = (usage.input || 0) + (usage.output || 0)
|
|
80
|
+
span.set_attribute("llm.usage.total_tokens", total)
|
|
81
|
+
end
|
|
82
|
+
rescue => e
|
|
83
|
+
span.record_exception(e)
|
|
84
|
+
span.status = OpenTelemetry::Trace::Status.error(e.message)
|
|
85
|
+
raise
|
|
86
|
+
end
|
|
87
|
+
result
|
|
88
|
+
end
|
|
55
89
|
end
|
|
56
90
|
end
|
|
57
91
|
end
|
|
@@ -4,8 +4,8 @@ module Phronomy
|
|
|
4
4
|
# Vector store implementations for embedding-based semantic search.
|
|
5
5
|
#
|
|
6
6
|
# Sub-classes are auto-loaded by Zeitwerk:
|
|
7
|
-
# Phronomy::VectorStore::Base
|
|
8
|
-
# Phronomy::VectorStore::InMemory
|
|
7
|
+
# Phronomy::Agent::Context::Knowledge::VectorStore::Base
|
|
8
|
+
# Phronomy::Agent::Context::Knowledge::VectorStore::InMemory
|
|
9
9
|
module VectorStore
|
|
10
10
|
end
|
|
11
11
|
end
|
data/lib/phronomy/version.rb
CHANGED
data/lib/phronomy/workflow.rb
CHANGED
|
@@ -81,12 +81,35 @@ module Phronomy
|
|
|
81
81
|
# Executes the workflow from the initial state.
|
|
82
82
|
# @param input [Hash] initial context field values
|
|
83
83
|
# @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id: }
|
|
84
|
+
# @param invocation_context [Phronomy::InvocationContext, nil] optional first-class context
|
|
85
|
+
# object. When present, +thread_id+, +cancellation_token+, and +deadline+ are
|
|
86
|
+
# derived from it (existing +config:+ keys take precedence). The object is also
|
|
87
|
+
# stored in +config[:invocation_context]+ for downstream tracing.
|
|
84
88
|
# @return [Object] final context
|
|
85
89
|
# @api public
|
|
86
|
-
def invoke(input, config: {})
|
|
90
|
+
def invoke(input, config: {}, invocation_context: nil)
|
|
91
|
+
if invocation_context
|
|
92
|
+
config = _apply_invocation_context(config, invocation_context)
|
|
93
|
+
end
|
|
87
94
|
@runner.invoke(input, config: config)
|
|
88
95
|
end
|
|
89
96
|
|
|
97
|
+
# Invokes this workflow asynchronously and returns a {Phronomy::Task}.
|
|
98
|
+
#
|
|
99
|
+
# @param input [Hash]
|
|
100
|
+
# @param config [Hash]
|
|
101
|
+
# @param invocation_context [Phronomy::InvocationContext, nil]
|
|
102
|
+
# @return [Phronomy::Task]
|
|
103
|
+
# @api public
|
|
104
|
+
def invoke_async(input, config: {}, invocation_context: nil)
|
|
105
|
+
if invocation_context
|
|
106
|
+
config = _apply_invocation_context(config, invocation_context)
|
|
107
|
+
end
|
|
108
|
+
Phronomy::Runtime.instance.spawn(name: "workflow-invoke-async") do
|
|
109
|
+
invoke(input, config: config)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
|
|
90
113
|
# Resumes a halted workflow. Generic resume that works for all halt types.
|
|
91
114
|
# @param state [Object] halted context
|
|
92
115
|
# @param input [Hash, nil] optional field updates to merge before resuming
|
|
@@ -116,6 +139,23 @@ module Phronomy
|
|
|
116
139
|
@runner.stream(input, config: config, &block)
|
|
117
140
|
end
|
|
118
141
|
|
|
142
|
+
private
|
|
143
|
+
|
|
144
|
+
# Merges an {InvocationContext} into the config hash.
|
|
145
|
+
# Existing +config+ keys take precedence (backward-compat).
|
|
146
|
+
def _apply_invocation_context(config, ic)
|
|
147
|
+
effective = config.merge(invocation_context: ic)
|
|
148
|
+
effective = effective.merge(thread_id: ic.thread_id) if effective[:thread_id].nil? && ic.thread_id
|
|
149
|
+
if effective[:cancellation_token].nil?
|
|
150
|
+
if (tok = ic.effective_timeout_token)
|
|
151
|
+
effective = effective.merge(cancellation_token: tok)
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
effective
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
public
|
|
158
|
+
|
|
119
159
|
# ---------------------------------------------------------------------------
|
|
120
160
|
# Internal DSL builder
|
|
121
161
|
# ---------------------------------------------------------------------------
|
|
@@ -139,6 +179,8 @@ module Phronomy
|
|
|
139
179
|
@transitions = []
|
|
140
180
|
# Set of wait state names
|
|
141
181
|
@wait_state_names = []
|
|
182
|
+
# { state_name => Numeric } — per-state action timeout in seconds
|
|
183
|
+
@action_timeouts = {}
|
|
142
184
|
end
|
|
143
185
|
|
|
144
186
|
# Declares the initial (entry) state.
|
|
@@ -151,13 +193,17 @@ module Phronomy
|
|
|
151
193
|
# rubocop:enable Style/TrivialAccessors
|
|
152
194
|
|
|
153
195
|
# Declares an action state.
|
|
154
|
-
# @param name
|
|
155
|
-
# @param action
|
|
196
|
+
# @param name [Symbol] state name
|
|
197
|
+
# @param action [#call, nil] optional entry action shorthand.
|
|
156
198
|
# +state :generate, action: MY_PROC+ is equivalent to
|
|
157
199
|
# +state :generate; entry :generate, MY_PROC+.
|
|
200
|
+
# @param action_timeout [Numeric, nil] seconds before an async (Task-returning)
|
|
201
|
+
# entry action is cancelled and {Phronomy::ActionTimeoutError} is raised.
|
|
202
|
+
# Only applies when the action returns a {Task} or {PendingOperation}.
|
|
158
203
|
# @api public
|
|
159
|
-
def state(name, action: nil)
|
|
204
|
+
def state(name, action: nil, action_timeout: nil)
|
|
160
205
|
@declared_states << name
|
|
206
|
+
@action_timeouts[name] = action_timeout if action_timeout
|
|
161
207
|
entry(name, action) if action
|
|
162
208
|
end
|
|
163
209
|
|
|
@@ -309,7 +355,8 @@ module Phronomy
|
|
|
309
355
|
external_events: external_events,
|
|
310
356
|
entry_point: @initial || @declared_states.first,
|
|
311
357
|
wait_state_names: @wait_state_names,
|
|
312
|
-
state_store: @state_store
|
|
358
|
+
state_store: @state_store,
|
|
359
|
+
action_timeouts: @action_timeouts.dup
|
|
313
360
|
)
|
|
314
361
|
|
|
315
362
|
Workflow.new(runner)
|
|
@@ -42,7 +42,16 @@ module Phronomy
|
|
|
42
42
|
end
|
|
43
43
|
|
|
44
44
|
@fields[name] = {type: type, default: default}
|
|
45
|
-
|
|
45
|
+
|
|
46
|
+
# Define getter.
|
|
47
|
+
attr_reader name
|
|
48
|
+
|
|
49
|
+
# Define write-guarded setter. Mutation from outside the EventLoop
|
|
50
|
+
# dispatch thread raises WorkflowContextOwnershipError in EventLoop mode.
|
|
51
|
+
define_method(:"#{name}=") do |value|
|
|
52
|
+
_assert_write_permitted!
|
|
53
|
+
instance_variable_set(:"@#{name}", value)
|
|
54
|
+
end
|
|
46
55
|
end
|
|
47
56
|
|
|
48
57
|
def fields
|
|
@@ -61,6 +70,7 @@ module Phronomy
|
|
|
61
70
|
# :<state> — resuming at <state> (workflow paused before its execution)
|
|
62
71
|
# @return [Symbol]
|
|
63
72
|
# @api public
|
|
73
|
+
# mutant:disable - @phase is always non-nil (set to :__end__ in initialize, only changed by set_graph_metadata which never sets nil), so the || :__end__ fallback branch is never reached — all mutations of the right-hand side are genuine equivalents
|
|
64
74
|
def phase
|
|
65
75
|
@phase || :__end__
|
|
66
76
|
end
|
|
@@ -68,6 +78,7 @@ module Phronomy
|
|
|
68
78
|
# Returns true if the workflow is paused mid-execution (not yet completed).
|
|
69
79
|
# @return [Boolean]
|
|
70
80
|
# @api public
|
|
81
|
+
# mutant:disable - phase != :__end__ vs !phase.eql?(:__end__) vs !phase.equal?(:__end__) are genuine equivalents for Symbol (Symbols are interned so == / eql? / equal? all behave identically)
|
|
71
82
|
def halted?
|
|
72
83
|
phase != :__end__
|
|
73
84
|
end
|
|
@@ -76,19 +87,23 @@ module Phronomy
|
|
|
76
87
|
# @param thread_id [String, nil]
|
|
77
88
|
# @param phase [Symbol, nil]
|
|
78
89
|
# @api public
|
|
90
|
+
# mutant:disable - mutations replacing return value `self` with nil or removing the last line are genuine equivalents: callers chain on the return value only in merge which immediately discards it
|
|
79
91
|
def set_graph_metadata(thread_id: nil, phase: nil)
|
|
80
92
|
@thread_id = thread_id unless thread_id.nil?
|
|
81
93
|
@phase = phase unless phase.nil?
|
|
82
94
|
self
|
|
83
95
|
end
|
|
84
96
|
|
|
97
|
+
# mutant:disable - multiple genuine equivalent mutations: is_a?(Proc) vs instance_of?(Proc) (Proc has no subclasses in practice), config[]/fetch() for always-present :default key, @thread_id=nil removal (unset ivar is already nil), @phase=:__end__ → nil or removal (phase method returns :__end__ via @phase||:__end__ fallback), raise message #{.inspect} vs #{} (spec checks exception class not message text)
|
|
85
98
|
def initialize(**attrs)
|
|
86
99
|
unknown = attrs.keys - self.class.fields.keys
|
|
87
100
|
raise ArgumentError, "Unknown WorkflowContext field(s): #{unknown.inspect}" unless unknown.empty?
|
|
88
101
|
|
|
89
102
|
self.class.fields.each do |name, config|
|
|
90
103
|
default = config[:default].is_a?(Proc) ? config[:default].call : config[:default]
|
|
91
|
-
|
|
104
|
+
# Bypass the write guard in initialize — ownership enforcement begins
|
|
105
|
+
# after construction is complete.
|
|
106
|
+
instance_variable_set(:"@#{name}", attrs.fetch(name, default))
|
|
92
107
|
end
|
|
93
108
|
@thread_id = nil
|
|
94
109
|
@phase = :__end__
|
|
@@ -103,6 +118,7 @@ module Phronomy
|
|
|
103
118
|
# @return [self.class] new context instance
|
|
104
119
|
# @raise [ArgumentError] if updates contains keys that are not declared fields
|
|
105
120
|
# @api public
|
|
121
|
+
# mutant:disable - multiple genuine equivalent mutations: send/public_send/__send__ are identical (all field accessors are public), fields[]/fetch() and field_config[]/fetch() for always-present keys, updates[]/fetch() when updates.key?(name) is already true, Array() wrapping for append fields that always hold Arrays, (send||{})/send equivalence for merge fields that always hold Hashes, deep_dup_value(send) vs send are equivalent under killfork (coverage selection does not trace the deep_dup_value call site across the fork boundary), raise message inspect vs to_s (spec checks exception class only)
|
|
106
122
|
def merge(updates)
|
|
107
123
|
unknown = updates.keys - self.class.fields.keys
|
|
108
124
|
raise ArgumentError, "Unknown WorkflowContext field(s): #{unknown.inspect}" unless unknown.empty?
|
|
@@ -134,6 +150,7 @@ module Phronomy
|
|
|
134
150
|
# Converts user-defined fields to a Hash (excludes internal workflow metadata).
|
|
135
151
|
# @return [Hash]
|
|
136
152
|
# @api public
|
|
153
|
+
# mutant:disable - send/public_send/__send__ are genuine equivalents (all field accessors are public methods)
|
|
137
154
|
def to_h
|
|
138
155
|
self.class.fields.keys.each_with_object({}) do |name, h|
|
|
139
156
|
h[name] = send(name)
|
|
@@ -142,11 +159,29 @@ module Phronomy
|
|
|
142
159
|
|
|
143
160
|
private
|
|
144
161
|
|
|
162
|
+
# Asserts that the calling thread is allowed to mutate this context.
|
|
163
|
+
# No-op when EventLoop mode is disabled.
|
|
164
|
+
# @raise [Phronomy::WorkflowContextOwnershipError] when called from a
|
|
165
|
+
# non-EventLoop thread in EventLoop mode.
|
|
166
|
+
# @api private
|
|
167
|
+
# mutant:disable - multiple genuine equivalent mutations: defined?(Phronomy::EventLoop)&& removal is genuine because EventLoop is always loaded in the killfork environment; true&& is genuine (truthy guard); EventLoop.current? resolves to Phronomy::EventLoop.current? within the Phronomy module; WorkflowContextOwnershipError resolves to Phronomy::WorkflowContextOwnershipError within the module; raise without message or with nil message is genuine (spec checks exception class, not message text)
|
|
168
|
+
def _assert_write_permitted!
|
|
169
|
+
return unless defined?(Phronomy::EventLoop) &&
|
|
170
|
+
Phronomy.configuration.event_loop
|
|
171
|
+
return if Phronomy::EventLoop.current?
|
|
172
|
+
|
|
173
|
+
raise Phronomy::WorkflowContextOwnershipError,
|
|
174
|
+
"WorkflowContext fields may only be mutated from the EventLoop dispatch " \
|
|
175
|
+
"thread. Use context.merge(...) to produce a new context, or deliver " \
|
|
176
|
+
"updates as event payloads."
|
|
177
|
+
end
|
|
178
|
+
|
|
145
179
|
# Performs a deep copy of a value for immutable context propagation.
|
|
146
180
|
# Arrays and Hashes are deep-duplicated recursively.
|
|
147
181
|
# Immutable values (nil, Symbol, Integer, Float, true/false, frozen String) are returned as-is.
|
|
148
182
|
# Other objects are dup'd (best-effort shallow copy for custom types).
|
|
149
183
|
# Objects that cannot be dup'd (e.g. Proc, Method) are returned as-is.
|
|
184
|
+
# mutant:disable - multiple genuine equivalent mutations: each class in the when clause (NilClass/Symbol/Integer/Float/TrueClass/FalseClass) can be removed or replaced with nil because all those types are frozen so the else-branch val.frozen? guard returns the same result; return val vs val is also equivalent; if val.frozen? vs if self.frozen? is equivalent since self is never frozen in this context
|
|
150
185
|
def deep_dup_value(val)
|
|
151
186
|
case val
|
|
152
187
|
when Array
|
|
@@ -45,7 +45,7 @@ module Phronomy
|
|
|
45
45
|
# Sentinel value for the terminal state of a workflow.
|
|
46
46
|
FINISH = :__end__
|
|
47
47
|
|
|
48
|
-
def initialize(state_class:, entry_actions:, declared_states:, auto_transitions:, external_events:, entry_point:, exit_actions: {}, wait_state_names: [], state_store: nil)
|
|
48
|
+
def initialize(state_class:, entry_actions:, declared_states:, auto_transitions:, external_events:, entry_point:, exit_actions: {}, wait_state_names: [], state_store: nil, action_timeouts: {})
|
|
49
49
|
@state_class = state_class
|
|
50
50
|
@entry_actions = entry_actions # { state_name => [callable, ...] }
|
|
51
51
|
@declared_states = declared_states
|
|
@@ -55,7 +55,17 @@ module Phronomy
|
|
|
55
55
|
@entry_point = entry_point
|
|
56
56
|
@wait_state_names = wait_state_names
|
|
57
57
|
@state_store = state_store
|
|
58
|
-
@
|
|
58
|
+
@action_timeouts = action_timeouts # { state_name => seconds }
|
|
59
|
+
@phase_machine_class = Agent::Lifecycle::PhaseMachineBuilder.new(
|
|
60
|
+
entry_point: @entry_point,
|
|
61
|
+
declared_states: @declared_states,
|
|
62
|
+
wait_state_names: @wait_state_names,
|
|
63
|
+
external_events: @external_events,
|
|
64
|
+
entry_actions: @entry_actions,
|
|
65
|
+
action_timeouts: @action_timeouts,
|
|
66
|
+
auto_transitions: auto_transitions,
|
|
67
|
+
exit_actions: exit_actions
|
|
68
|
+
).build
|
|
59
69
|
end
|
|
60
70
|
|
|
61
71
|
# Executes the workflow from the initial state.
|
|
@@ -159,7 +169,7 @@ module Phronomy
|
|
|
159
169
|
|
|
160
170
|
# Builds an FSMSession for the given context. Used in EventLoop mode.
|
|
161
171
|
def build_session_for(context:, recursion_limit:, resume_event: nil, resume_phase: nil)
|
|
162
|
-
Phronomy::FSMSession.new(
|
|
172
|
+
Phronomy::Agent::Lifecycle::FSMSession.new(
|
|
163
173
|
id: context.thread_id,
|
|
164
174
|
context: context,
|
|
165
175
|
entry_point: @entry_point,
|
|
@@ -170,6 +180,7 @@ module Phronomy
|
|
|
170
180
|
external_events: @external_events,
|
|
171
181
|
phase_machine_class: @phase_machine_class,
|
|
172
182
|
recursion_limit: recursion_limit,
|
|
183
|
+
action_timeouts: @action_timeouts,
|
|
173
184
|
resume_event: resume_event,
|
|
174
185
|
resume_phase: resume_phase
|
|
175
186
|
)
|
|
@@ -211,7 +222,20 @@ module Phronomy
|
|
|
211
222
|
# The entry point has no prior transition, so we invoke its entry actions directly.
|
|
212
223
|
@entry_actions[current_state]&.each do |c|
|
|
213
224
|
result = c.call(ctx)
|
|
214
|
-
|
|
225
|
+
if result.is_a?(Phronomy::Task)
|
|
226
|
+
timeout_secs = @action_timeouts[current_state]
|
|
227
|
+
if timeout_secs
|
|
228
|
+
if result.join(timeout_secs).nil?
|
|
229
|
+
result.cancel!
|
|
230
|
+
raise Phronomy::ActionTimeoutError,
|
|
231
|
+
"Action in state #{current_state.inspect} timed out after #{timeout_secs}s"
|
|
232
|
+
end
|
|
233
|
+
end
|
|
234
|
+
task_result = result.await
|
|
235
|
+
ctx = task_result if task_result.is_a?(Phronomy::WorkflowContext)
|
|
236
|
+
elsif result.is_a?(Phronomy::WorkflowContext)
|
|
237
|
+
ctx = result
|
|
238
|
+
end
|
|
215
239
|
end
|
|
216
240
|
tracker.context = ctx
|
|
217
241
|
end
|
|
@@ -295,79 +319,6 @@ module Phronomy
|
|
|
295
319
|
# before_transition from — exit callbacks (invoked when leaving a state)
|
|
296
320
|
#
|
|
297
321
|
# Guard lambdas bridge the PhaseTracker and WorkflowContext via +m.context+.
|
|
298
|
-
def build_phase_machine_class(auto_transitions, exit_actions)
|
|
299
|
-
entry = @entry_point
|
|
300
|
-
all_states = (@declared_states + @wait_state_names + [:__end__]).uniq
|
|
301
|
-
auto_trans = auto_transitions # Array of { from:, to:, guard: }
|
|
302
|
-
ext_events = @external_events
|
|
303
|
-
entry_acts = @entry_actions
|
|
304
|
-
exit_acts = exit_actions
|
|
305
|
-
|
|
306
|
-
Class.new do
|
|
307
|
-
# Holds the current WorkflowContext so guards and callbacks can read it.
|
|
308
|
-
attr_accessor :context
|
|
309
|
-
|
|
310
|
-
state_machine :phase, initial: entry do
|
|
311
|
-
all_states.each { |s| state s }
|
|
312
|
-
|
|
313
|
-
# Auto-fire transitions: all auto transitions unified under :state_completed.
|
|
314
|
-
# Includes unguarded (unconditional) and guarded (conditional) transitions.
|
|
315
|
-
# Declaration order is preserved; guards are evaluated before unguarded fallbacks.
|
|
316
|
-
event :state_completed do
|
|
317
|
-
auto_trans.each do |t|
|
|
318
|
-
if t[:guard]
|
|
319
|
-
guard_proc = t[:guard]
|
|
320
|
-
transition t[:from] => t[:to], :if => ->(m) { guard_proc.call(m.context) }
|
|
321
|
-
else
|
|
322
|
-
transition t[:from] => t[:to]
|
|
323
|
-
end
|
|
324
|
-
end
|
|
325
|
-
end
|
|
326
|
-
|
|
327
|
-
# External events: human-in-the-loop triggers from wait states.
|
|
328
|
-
ext_events.each do |ev_name, transitions|
|
|
329
|
-
event ev_name do
|
|
330
|
-
transitions.each do |t|
|
|
331
|
-
if t[:guard]
|
|
332
|
-
guard_proc = t[:guard]
|
|
333
|
-
transition t[:from] => t[:to], :if => ->(m) { guard_proc.call(m.context) }
|
|
334
|
-
else
|
|
335
|
-
transition t[:from] => t[:to]
|
|
336
|
-
end
|
|
337
|
-
end
|
|
338
|
-
end
|
|
339
|
-
end
|
|
340
|
-
|
|
341
|
-
# Entry callbacks: fire after_transition into each state.
|
|
342
|
-
# Each callable is registered as a separate callback; state_machines
|
|
343
|
-
# accumulates them and fires in declaration order.
|
|
344
|
-
# If the callable returns a WorkflowContext (e.g. via s.merge(...)),
|
|
345
|
-
# the returned context replaces the current one on the tracker.
|
|
346
|
-
entry_acts.each do |state_name, callables|
|
|
347
|
-
callables.each do |callable|
|
|
348
|
-
after_transition to: state_name do |machine|
|
|
349
|
-
result = callable.call(machine.context)
|
|
350
|
-
machine.context = result if result.is_a?(Phronomy::WorkflowContext)
|
|
351
|
-
end
|
|
352
|
-
end
|
|
353
|
-
end
|
|
354
|
-
|
|
355
|
-
# Exit callbacks: fire before_transition out of each state.
|
|
356
|
-
# Each callable is registered as a separate callback; state_machines
|
|
357
|
-
# accumulates them and fires in declaration order.
|
|
358
|
-
exit_acts.each do |state_name, callables|
|
|
359
|
-
callables.each do |callable|
|
|
360
|
-
before_transition from: state_name do |machine|
|
|
361
|
-
callable.call(machine.context)
|
|
362
|
-
end
|
|
363
|
-
end
|
|
364
|
-
end
|
|
365
|
-
end
|
|
366
|
-
end
|
|
367
|
-
rescue => e
|
|
368
|
-
raise ArgumentError, "Failed to build phase machine: #{e.message}"
|
|
369
|
-
end
|
|
370
|
-
|
|
371
322
|
# Creates a PhaseTracker instance initialized to +from_state+.
|
|
372
323
|
def new_phase_machine(from_state)
|
|
373
324
|
machine = @phase_machine_class.new
|
data/lib/phronomy.rb
CHANGED
|
@@ -8,10 +8,15 @@ loader = Zeitwerk::Loader.for_gem
|
|
|
8
8
|
# Teach Zeitwerk that "llm" maps to "LLM" so that file names such as
|
|
9
9
|
# ruby_llm_embeddings.rb resolve to RubyLLMEmbeddings (not RubyLlmEmbeddings).
|
|
10
10
|
loader.inflector.inflect("ruby_llm_embeddings" => "RubyLLMEmbeddings")
|
|
11
|
+
loader.inflector.inflect("rag_knowledge" => "RAGKnowledge")
|
|
11
12
|
# FSMSession: Zeitwerk would infer "FsmSession" — override to "FSMSession".
|
|
12
13
|
loader.inflector.inflect("fsm_session" => "FSMSession")
|
|
13
14
|
# AgentFSM: Zeitwerk would infer "Fsm" — override to "FSM".
|
|
14
15
|
loader.inflector.inflect("fsm" => "FSM")
|
|
16
|
+
# LLMAdapter: Zeitwerk would infer "LlmAdapter" — override to "LLMAdapter".
|
|
17
|
+
loader.inflector.inflect("llm_adapter" => "LLMAdapter")
|
|
18
|
+
# LLMAdapter::RubyLLM: "ruby_llm" maps to "RubyLLM" (not "RubyLlm").
|
|
19
|
+
loader.inflector.inflect("ruby_llm" => "RubyLLM")
|
|
15
20
|
loader.setup
|
|
16
21
|
|
|
17
22
|
require_relative "phronomy/version"
|
|
@@ -50,6 +55,22 @@ module Phronomy
|
|
|
50
55
|
# Separate from TimeoutError (deadline exceeded) — this is an intentional stop.
|
|
51
56
|
class CancellationError < Error; end
|
|
52
57
|
|
|
58
|
+
# Raised when {Agent#invoke} (a synchronous, blocking call) is attempted from
|
|
59
|
+
# inside an active scheduler task and +strict_runtime_guards+ is enabled.
|
|
60
|
+
#
|
|
61
|
+
# Calling a blocking invocation from within a scheduler task stalls the
|
|
62
|
+
# scheduler until the inner invocation completes, preventing other tasks from
|
|
63
|
+
# making progress (hidden deadlock risk). Use {Agent#invoke_async} followed by
|
|
64
|
+
# +#await+ inside scheduler tasks instead.
|
|
65
|
+
#
|
|
66
|
+
# This error is only raised when:
|
|
67
|
+
# Phronomy.configure { |c| c.strict_runtime_guards = true }
|
|
68
|
+
#
|
|
69
|
+
# By default a warning is logged and execution continues.
|
|
70
|
+
#
|
|
71
|
+
# @see Phronomy::Runtime.in_scheduler_context?
|
|
72
|
+
class SchedulerReentrancyError < Error; end
|
|
73
|
+
|
|
53
74
|
# Raised by {Phronomy::GeneratorVerifier#invoke} when +raise_if_untrusted: true+
|
|
54
75
|
# and the pipeline's combined confidence score falls below the configured threshold.
|
|
55
76
|
#
|
|
@@ -76,6 +97,28 @@ module Phronomy
|
|
|
76
97
|
end
|
|
77
98
|
end
|
|
78
99
|
|
|
100
|
+
# Raised when an operation is submitted to a {BlockingAdapterPool} that has
|
|
101
|
+
# already been shut down via {BlockingAdapterPool#shutdown}.
|
|
102
|
+
class PoolShutdownError < Error; end
|
|
103
|
+
|
|
104
|
+
# Raised when a concurrency limit is exceeded and the configured backpressure
|
|
105
|
+
# strategy is +:raise+. The caller should back off and retry.
|
|
106
|
+
class BackpressureError < Error; end
|
|
107
|
+
|
|
108
|
+
# Raised by {CancellationScope#pop_queue} when the deadline expires before a
|
|
109
|
+
# result is available. Extends {TimeoutError} for backwards compatibility.
|
|
110
|
+
class ScopeTimeoutError < TimeoutError; end
|
|
111
|
+
|
|
112
|
+
# Raised when a Workflow entry/exit action task exceeds the +action_timeout:+
|
|
113
|
+
# configured for its state. Extends {TimeoutError}.
|
|
114
|
+
class ActionTimeoutError < TimeoutError; end
|
|
115
|
+
|
|
116
|
+
# Raised when a {Phronomy::WorkflowContext} field is mutated from a thread
|
|
117
|
+
# that does not own the context (i.e. not the EventLoop dispatch thread).
|
|
118
|
+
# Only raised in EventLoop mode. Use +context.merge(...)+ to produce a new
|
|
119
|
+
# context, or deliver updates as +:child_completed+ event payloads.
|
|
120
|
+
class WorkflowContextOwnershipError < Error; end
|
|
121
|
+
|
|
79
122
|
class << self
|
|
80
123
|
def configuration
|
|
81
124
|
@configuration ||= Configuration.new
|