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
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# Developer-facing diagnostics for blocking operation detection (Issue #279).
|
|
5
|
+
#
|
|
6
|
+
# Provides debug dump utilities that can be called from an IRB / Rails console
|
|
7
|
+
# or in test helpers to inspect the current state of the Runtime.
|
|
8
|
+
#
|
|
9
|
+
# @example Enable diagnostics and print a dump
|
|
10
|
+
# Phronomy.configure { |c| c.scheduler_debug = true }
|
|
11
|
+
# Phronomy::Diagnostics.dump
|
|
12
|
+
module Diagnostics
|
|
13
|
+
# Prints a formatted summary of the current Runtime state to +$stderr+
|
|
14
|
+
# (or the supplied IO).
|
|
15
|
+
#
|
|
16
|
+
# Includes:
|
|
17
|
+
# - BlockingAdapterPool: active workers, queue depth, abandoned count
|
|
18
|
+
# - EventLoop: last / max / average lag in milliseconds
|
|
19
|
+
#
|
|
20
|
+
# @param out [IO] output destination (default: $stderr)
|
|
21
|
+
# @return [void]
|
|
22
|
+
# @api public
|
|
23
|
+
def self.dump(out: $stderr)
|
|
24
|
+
snap = Phronomy::Metrics.snapshot
|
|
25
|
+
|
|
26
|
+
out.puts "[Phronomy::Diagnostics] Runtime state dump"
|
|
27
|
+
out.puts " BlockingAdapterPool:"
|
|
28
|
+
out.puts " pool_size : #{snap[:blocking_pool_size]}"
|
|
29
|
+
out.puts " active_count : #{snap[:blocking_pool_active]}"
|
|
30
|
+
out.puts " queue_depth : #{snap[:blocking_pool_queue_length]}"
|
|
31
|
+
out.puts " abandoned_total : #{snap[:blocking_pool_abandoned_total]}"
|
|
32
|
+
out.puts " EventLoop:"
|
|
33
|
+
out.puts " last_lag_ms : #{snap[:event_loop_lag_last_ms]}"
|
|
34
|
+
out.puts " max_lag_ms : #{snap[:event_loop_lag_max_ms]}"
|
|
35
|
+
out.puts " average_lag_ms : #{snap[:event_loop_lag_average_ms]}"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Returns the diagnostics state as a plain Hash (useful for JSON export).
|
|
39
|
+
#
|
|
40
|
+
# @return [Hash]
|
|
41
|
+
# @api public
|
|
42
|
+
def self.snapshot
|
|
43
|
+
Phronomy::Metrics.snapshot
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Raises an error if +invoke+ (blocking) is called from inside an EventLoop
|
|
47
|
+
# action, preventing accidental scheduler stalls.
|
|
48
|
+
#
|
|
49
|
+
# Called by Agent::Base#invoke and Workflow#invoke before executing.
|
|
50
|
+
#
|
|
51
|
+
# @raise [Phronomy::SchedulerReentrancyError] when called from EventLoop thread
|
|
52
|
+
# @return [void]
|
|
53
|
+
# @api private
|
|
54
|
+
def self.assert_not_in_event_loop!
|
|
55
|
+
return unless Phronomy::EventLoop.current?
|
|
56
|
+
|
|
57
|
+
raise Phronomy::SchedulerReentrancyError,
|
|
58
|
+
"Blocking invoke called from inside an EventLoop action. " \
|
|
59
|
+
"Use invoke_async instead."
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|
data/lib/phronomy/embeddings.rb
CHANGED
|
@@ -4,8 +4,8 @@ module Phronomy
|
|
|
4
4
|
# Embeddings adapters for converting text into vector representations.
|
|
5
5
|
#
|
|
6
6
|
# Sub-classes are auto-loaded by Zeitwerk:
|
|
7
|
-
# Phronomy::Embeddings::Base
|
|
8
|
-
# Phronomy::Embeddings::RubyLLMEmbeddings
|
|
7
|
+
# Phronomy::Agent::Context::Knowledge::Embeddings::Base
|
|
8
|
+
# Phronomy::Agent::Context::Knowledge::Embeddings::RubyLLMEmbeddings
|
|
9
9
|
module Embeddings
|
|
10
10
|
end
|
|
11
11
|
end
|
data/lib/phronomy/eval/runner.rb
CHANGED
|
@@ -28,29 +28,30 @@ module Phronomy
|
|
|
28
28
|
# @param concurrency [Integer] number of parallel threads (default: 1, sequential)
|
|
29
29
|
# @return [Array<EvalResult>]
|
|
30
30
|
# @api public
|
|
31
|
+
# mutant:disable - concurrency default value mutations (0/2) are genuine equivalent because sequential and concurrent paths produce identical results; if concurrency<=1 boundary mutations (==1 / <1 / <=0 / .eql? / .equal? / false / nil / <=2) are genuine equivalent because the concurrent path with concurrency=1 still produces the same Array<EvalResult> via each_slice(1); spawn name: mutations are genuine equivalent (name is only used for logging)
|
|
31
32
|
def run(dataset, callable, concurrency: 1)
|
|
32
33
|
cases = dataset.to_a
|
|
33
34
|
return cases.map { |eval_case| run_one(eval_case, callable) } if concurrency <= 1
|
|
34
35
|
|
|
35
|
-
# Run cases in slices of +concurrency+
|
|
36
|
-
# before the next starts, bounding peak
|
|
37
|
-
# Writing to pre-allocated slots (one per
|
|
38
|
-
#
|
|
36
|
+
# Run cases in slices of +concurrency+ tasks. Each slice is joined
|
|
37
|
+
# before the next starts, bounding peak task count to +concurrency+.
|
|
38
|
+
# Writing to pre-allocated slots (one per task) is safe because each
|
|
39
|
+
# task writes to a unique index and all tasks in a slice are joined
|
|
39
40
|
# before the next slice begins.
|
|
40
|
-
# Exceptions in worker
|
|
41
|
-
#
|
|
41
|
+
# Exceptions in worker tasks are collected and re-raised after all
|
|
42
|
+
# tasks in the slice are joined, preventing orphaned tasks.
|
|
42
43
|
results = Array.new(cases.length)
|
|
43
44
|
cases.each_with_index.each_slice(concurrency) do |batch|
|
|
44
45
|
errors = []
|
|
45
46
|
errors_mu = Mutex.new
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
tasks = batch.map do |eval_case, i|
|
|
48
|
+
Phronomy::Runtime.instance.spawn(name: "eval-case-#{i}") do
|
|
48
49
|
results[i] = run_one(eval_case, callable)
|
|
49
50
|
rescue => e
|
|
50
51
|
errors_mu.synchronize { errors << e }
|
|
51
52
|
end
|
|
52
53
|
end
|
|
53
|
-
|
|
54
|
+
tasks.each(&:join)
|
|
54
55
|
raise errors.first if errors.any?
|
|
55
56
|
end
|
|
56
57
|
results
|
|
@@ -59,6 +60,7 @@ module Phronomy
|
|
|
59
60
|
private
|
|
60
61
|
|
|
61
62
|
# Evaluate a single EvalCase with the given callable and return an EvalResult.
|
|
63
|
+
# mutant:disable - multiple genuine equivalent mutations: latency_ms=+t0 or =t0 are genuine because :millisecond makes all values Integer so be_a(Integer) passes; (actual,usage)=result is genuine because Ruby multi-assign of a String yields usage=nil identical to extract(); score_safely input: nil/eval_case/absent are genuine because ExactMatch and IncludesScorer ignore the :input kwarg; EvalResult error: nil/absent and usage: nil are genuine because on a successful score run score_error and usage are already nil
|
|
62
64
|
def run_one(eval_case, callable)
|
|
63
65
|
t0 = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
|
|
64
66
|
result = callable.call(eval_case.input)
|
|
@@ -71,6 +73,7 @@ module Phronomy
|
|
|
71
73
|
end
|
|
72
74
|
|
|
73
75
|
# Normalises the callable's return value into [actual_string, usage_or_nil].
|
|
76
|
+
# mutant:disable - multiple genuine equivalent mutations: is_a?(Hash) vs instance_of?(Hash) (no Hash subclass in practice); to_s vs to_str (String only); result[:output]/[:usage] vs .fetch(:output)/[:usage] (keys always present when is_a?(Hash)); [result.to_s, nil] vs [result.to_s] because actual,usage=[val] → usage=nil via Ruby multi-assign; result.to_s vs result.to_str for String-only values
|
|
74
77
|
def extract(result)
|
|
75
78
|
if result.is_a?(Hash)
|
|
76
79
|
[result[:output].to_s, result[:usage]]
|
|
@@ -80,6 +83,7 @@ module Phronomy
|
|
|
80
83
|
end
|
|
81
84
|
|
|
82
85
|
# Calls the scorer and returns [score, error]. On failure, returns [0.0, exception].
|
|
86
|
+
# mutant:disable - [scorer.score(**kwargs), nil] vs [scorer.score(**kwargs)]: because score,error=[val] → error=nil via Ruby multi-assign; both produce the same destructuring in the caller
|
|
83
87
|
def score_safely(scorer, **kwargs)
|
|
84
88
|
[scorer.score(**kwargs), nil]
|
|
85
89
|
rescue => e
|
|
@@ -45,9 +45,20 @@ module Phronomy
|
|
|
45
45
|
|
|
46
46
|
# @return [Float] score in [0.0, 1.0]; 0.0 on error when raise_on_error is false
|
|
47
47
|
# @api public
|
|
48
|
+
# mutant:disable - multiple genuine equivalent mutations:
|
|
49
|
+
# actual.to_str / actual: (shorthand) are genuine (callers pass String);
|
|
50
|
+
# expected.to_str / expected: are genuine (String);
|
|
51
|
+
# response.content.strip (no to_s) is genuine (content is String);
|
|
52
|
+
# lstrip/rstrip/no-strip are genuine (whitespace doesn't affect number scanning);
|
|
53
|
+
# scan(/-?\d\.?\d*/) is genuine (for [0,1] range responses, single-digit-before-decimal
|
|
54
|
+
# matches are the same after clamp);
|
|
55
|
+
# response.content.to_str.strip is genuine (String);
|
|
56
|
+
# all warn variations (warn no-arg, warn(nil), warn(e), warn(nil literal),
|
|
57
|
+
# nil-replacing-warn, warn-deletion) are genuine because the rescue block
|
|
58
|
+
# still returns 0.0 — warn is a side-effect not tested by value assertions
|
|
48
59
|
def score(actual:, expected:, input: nil)
|
|
49
60
|
prompt = format(@prompt_template, input: input.to_s, expected: expected.to_s, actual: actual.to_s)
|
|
50
|
-
response = RubyLLM.chat(model: @model).ask(prompt)
|
|
61
|
+
response = Phronomy::Runtime.instance.blocking_io.submit { RubyLLM.chat(model: @model).ask(prompt) }.await
|
|
51
62
|
response.content.to_s.strip.scan(/-?\d+\.?\d*/).first.to_f.clamp(0.0, 1.0)
|
|
52
63
|
rescue => e
|
|
53
64
|
raise if @raise_on_error
|
data/lib/phronomy/event_loop.rb
CHANGED
|
@@ -3,12 +3,37 @@
|
|
|
3
3
|
module Phronomy
|
|
4
4
|
# Singleton event loop that manages all FSMSession instances.
|
|
5
5
|
#
|
|
6
|
-
# A single background thread reads from a global
|
|
7
|
-
# events to their target FSMSession.
|
|
8
|
-
#
|
|
6
|
+
# A single background thread reads from a global {Phronomy::Concurrency::AsyncQueue} and
|
|
7
|
+
# dispatches events to their target FSMSession. IO work (LLM calls, tool
|
|
8
|
+
# calls) must be dispatched via +Runtime.instance.spawn+ or
|
|
9
|
+
# +BlockingAdapterPool+, then post results back to the loop via
|
|
10
|
+
# {EventLoop#post}.
|
|
9
11
|
#
|
|
10
12
|
# Activated with: +Phronomy.configure { |c| c.event_loop = true }+
|
|
11
13
|
#
|
|
14
|
+
# == Threading exception (see ADR-010 Rule 2)
|
|
15
|
+
#
|
|
16
|
+
# +EventLoop+ is a **deliberate exception** to Phronomy's cooperative-first
|
|
17
|
+
# concurrency model. Its dispatch loop is an infinite +while @running+ loop
|
|
18
|
+
# that must never block the framework's own event processing.
|
|
19
|
+
# Running it on a shared scheduler task would consume the scheduler, preventing
|
|
20
|
+
# other tasks from running. Therefore {#start} creates a dedicated
|
|
21
|
+
# {Runtime::ThreadScheduler} — this is correct and intentional per ADR-010.
|
|
22
|
+
# No other framework component should do the same; see the ADR-010 checklist.
|
|
23
|
+
#
|
|
24
|
+
# == Handler constraints
|
|
25
|
+
#
|
|
26
|
+
# Handlers dispatched by the EventLoop run **on the EventLoop thread**.
|
|
27
|
+
# They must not:
|
|
28
|
+
#
|
|
29
|
+
# * Perform blocking operations directly (database queries, LLM calls, HTTP
|
|
30
|
+
# requests). Schedule blocking work via +Runtime.instance.spawn+ or
|
|
31
|
+
# +BlockingAdapterPool+, then post results back with {#post}.
|
|
32
|
+
# * Call +Workflow#invoke+ (or any synchronous +invoke+) from within a
|
|
33
|
+
# handler. That method would block waiting for the EventLoop to process
|
|
34
|
+
# events, causing a deadlock. Use the async pattern: post a follow-up
|
|
35
|
+
# event instead.
|
|
36
|
+
#
|
|
12
37
|
# == Fork safety
|
|
13
38
|
#
|
|
14
39
|
# +EventLoop.instance+ is lazily initialized. The background thread is not
|
|
@@ -20,14 +45,25 @@ module Phronomy
|
|
|
20
45
|
# Do NOT call +Workflow#invoke+ (in EventLoop mode) from within a workflow
|
|
21
46
|
# entry action. The entry action runs on the EventLoop thread; a nested
|
|
22
47
|
# +invoke+ would block waiting for the same thread to process events →
|
|
23
|
-
# deadlock. Use the async
|
|
24
|
-
#
|
|
48
|
+
# deadlock. Use the async pattern instead: schedule work via
|
|
49
|
+
# +Runtime.instance.spawn+ or +BlockingAdapterPool+, then post events back
|
|
50
|
+
# via +Phronomy::EventLoop.instance.post(...)+.
|
|
25
51
|
class EventLoop
|
|
26
52
|
# Returns the singleton instance, creating and starting it on first call.
|
|
27
53
|
def self.instance
|
|
28
54
|
@instance ||= new.tap(&:start)
|
|
29
55
|
end
|
|
30
56
|
|
|
57
|
+
# Returns true when called from within the EventLoop dispatch task.
|
|
58
|
+
# Uses a task-local key set by the Runtime-spawned dispatch task so that
|
|
59
|
+
# the check works correctly for both thread-based and future fiber-based
|
|
60
|
+
# scheduler backends.
|
|
61
|
+
# @return [Boolean]
|
|
62
|
+
# @api private
|
|
63
|
+
def self.current?
|
|
64
|
+
Phronomy::Task.current&.name == "event-loop"
|
|
65
|
+
end
|
|
66
|
+
|
|
31
67
|
# Stops and destroys the singleton. Primarily used in tests.
|
|
32
68
|
# @api private
|
|
33
69
|
def self.reset!
|
|
@@ -36,7 +72,7 @@ module Phronomy
|
|
|
36
72
|
end
|
|
37
73
|
|
|
38
74
|
def initialize
|
|
39
|
-
@queue =
|
|
75
|
+
@queue = Phronomy::Concurrency::AsyncQueue.new # global event queue (thread-safe; no Mutex needed)
|
|
40
76
|
@fsms = {} # { id => FSMSession } — EventLoop thread only
|
|
41
77
|
@waiting = {} # { id => completion_queue } — EventLoop thread only
|
|
42
78
|
# Mutex-backed FSM count for drain-mode shutdown.
|
|
@@ -44,7 +80,43 @@ module Phronomy
|
|
|
44
80
|
@fsm_count_cond = ConditionVariable.new
|
|
45
81
|
@fsm_count = 0
|
|
46
82
|
# Token cancelled when shutdown is requested; new child sessions receive it.
|
|
47
|
-
@shutdown_token = Phronomy::CancellationToken.new
|
|
83
|
+
@shutdown_token = Phronomy::Concurrency::CancellationToken.new
|
|
84
|
+
# Fairness metrics (EventLoop thread only, except where noted)
|
|
85
|
+
@lag_mutex = Mutex.new
|
|
86
|
+
@last_lag_ns = 0
|
|
87
|
+
@max_lag_ns = 0
|
|
88
|
+
@dispatch_count = 0
|
|
89
|
+
@total_lag_ns = 0
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Returns the most recently measured event-loop lag in seconds.
|
|
93
|
+
# Lag is the wall-clock time between {#post} and the moment the event
|
|
94
|
+
# is dequeued for dispatch. Thread-safe.
|
|
95
|
+
# @return [Float]
|
|
96
|
+
# @api private
|
|
97
|
+
def last_lag_seconds
|
|
98
|
+
@lag_mutex.synchronize { @last_lag_ns } / 1_000_000_000.0
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Returns the maximum event-loop lag seen since the loop was started.
|
|
102
|
+
# Thread-safe.
|
|
103
|
+
# @return [Float]
|
|
104
|
+
# @api private
|
|
105
|
+
def max_lag_seconds
|
|
106
|
+
@lag_mutex.synchronize { @max_lag_ns } / 1_000_000_000.0
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Returns the mean event-loop lag across all dispatched events since the
|
|
110
|
+
# loop was started. Returns 0.0 when no events have been dispatched.
|
|
111
|
+
# Thread-safe.
|
|
112
|
+
# @return [Float]
|
|
113
|
+
# @api private
|
|
114
|
+
def average_lag_seconds
|
|
115
|
+
@lag_mutex.synchronize do
|
|
116
|
+
return 0.0 if @dispatch_count.zero?
|
|
117
|
+
|
|
118
|
+
@total_lag_ns.to_f / @dispatch_count / 1_000_000_000.0
|
|
119
|
+
end
|
|
48
120
|
end
|
|
49
121
|
|
|
50
122
|
# Registers an FSMSession for execution and returns a completion queue.
|
|
@@ -57,22 +129,24 @@ module Phronomy
|
|
|
57
129
|
# (WorkflowContext) once the workflow finishes or halts. If an error occurred,
|
|
58
130
|
# the popped value will be an Exception — callers are responsible for re-raising it.
|
|
59
131
|
#
|
|
60
|
-
# @param fsm_session [Phronomy::FSMSession]
|
|
61
|
-
# @return [
|
|
132
|
+
# @param fsm_session [Phronomy::Agent::Lifecycle::FSMSession]
|
|
133
|
+
# @return [Phronomy::Concurrency::AsyncQueue] resolves to final/halted context, or an Exception
|
|
62
134
|
# @api private
|
|
63
135
|
def register(fsm_session)
|
|
64
|
-
if
|
|
136
|
+
if Phronomy::EventLoop.current?
|
|
65
137
|
raise Phronomy::Error,
|
|
66
138
|
"Cannot call Workflow#invoke (EventLoop mode) from within an EventLoop " \
|
|
67
|
-
"entry action.
|
|
68
|
-
"back via
|
|
139
|
+
"entry action. Schedule work via Runtime.instance.spawn or " \
|
|
140
|
+
"BlockingAdapterPool, then post events back via " \
|
|
141
|
+
"Phronomy::EventLoop.instance.post(...) instead."
|
|
69
142
|
end
|
|
70
143
|
|
|
71
|
-
completion_queue =
|
|
144
|
+
completion_queue = Phronomy::Concurrency::AsyncQueue.new
|
|
72
145
|
# Pass both session and completion_queue in the event payload so that the
|
|
73
146
|
# EventLoop thread is the sole writer of @fsms and @waiting.
|
|
74
|
-
@queue.push(Event.new(type: :start, target_id: fsm_session.id,
|
|
75
|
-
payload: {session: fsm_session, completion: completion_queue})
|
|
147
|
+
@queue.push([Event.new(type: :start, target_id: fsm_session.id,
|
|
148
|
+
payload: {session: fsm_session, completion: completion_queue}),
|
|
149
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)])
|
|
76
150
|
completion_queue
|
|
77
151
|
end
|
|
78
152
|
|
|
@@ -87,60 +161,77 @@ module Phronomy
|
|
|
87
161
|
# @return [nil]
|
|
88
162
|
# @api private
|
|
89
163
|
def enqueue_child(agent_fsm)
|
|
90
|
-
@queue.push(Event.new(type: :start, target_id: agent_fsm.id,
|
|
91
|
-
payload: {session: agent_fsm, completion: nil})
|
|
164
|
+
@queue.push([Event.new(type: :start, target_id: agent_fsm.id,
|
|
165
|
+
payload: {session: agent_fsm, completion: nil}),
|
|
166
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)])
|
|
92
167
|
nil
|
|
93
168
|
end
|
|
94
169
|
|
|
95
170
|
# Posts an event to the loop. Safe to call from any thread (including IO threads).
|
|
171
|
+
# The current monotonic clock time is recorded so that the EventLoop can
|
|
172
|
+
# measure the dispatch lag when it dequeues the event.
|
|
96
173
|
#
|
|
174
|
+
# @note **Handler constraint**: do not perform blocking operations or call
|
|
175
|
+
# +Workflow#invoke+ directly from within the handler that processes a
|
|
176
|
+
# posted event. Handlers run on the EventLoop thread; blocking there
|
|
177
|
+
# stalls all session processing. For blocking work, post a new event
|
|
178
|
+
# after the result is ready.
|
|
97
179
|
# @param event [Phronomy::Event]
|
|
98
180
|
# @api private
|
|
99
181
|
def post(event)
|
|
100
|
-
@queue.push(event)
|
|
182
|
+
@queue.push([event, Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)])
|
|
101
183
|
end
|
|
102
184
|
|
|
103
|
-
# Starts the
|
|
185
|
+
# Starts the EventLoop dispatch task under {Runtime} ownership.
|
|
186
|
+
#
|
|
187
|
+
# The dispatch loop runs as a {Phronomy::Task} so that {Runtime#shutdown}
|
|
188
|
+
# can drain it together with all other in-flight tasks. The task is named
|
|
189
|
+
# +"event-loop"+ so that {.current?} can identify it via
|
|
190
|
+
# +Task.current&.name+.
|
|
104
191
|
# @return [self]
|
|
105
192
|
# @api private
|
|
106
193
|
def start
|
|
107
|
-
return self if @
|
|
194
|
+
return self if @task&.alive?
|
|
108
195
|
|
|
109
196
|
# Reset shutdown state so the loop can be restarted after a stop.
|
|
110
|
-
@shutdown_token = Phronomy::CancellationToken.new
|
|
197
|
+
@shutdown_token = Phronomy::Concurrency::CancellationToken.new
|
|
111
198
|
@fsm_count_mutex.synchronize { @fsm_count = 0 }
|
|
112
199
|
@running = true
|
|
113
|
-
|
|
114
|
-
|
|
200
|
+
# The dispatch loop must always run in a real background thread.
|
|
201
|
+
# A cooperative scheduler (FakeScheduler/ImmediateBackend) executes tasks
|
|
202
|
+
# synchronously on the caller's thread, which would block forever inside
|
|
203
|
+
# the run_loop infinite loop. Create a dedicated Runtime with
|
|
204
|
+
# ThreadScheduler to guarantee async execution regardless of the global
|
|
205
|
+
# runtime_backend setting.
|
|
206
|
+
thread_runtime = Phronomy::Runtime.new(scheduler: Phronomy::Runtime::ThreadScheduler.new)
|
|
207
|
+
@task = thread_runtime.spawn(name: "event-loop") do
|
|
115
208
|
run_loop
|
|
116
209
|
end
|
|
117
|
-
@thread.abort_on_exception = false
|
|
118
210
|
self
|
|
119
211
|
end
|
|
120
212
|
|
|
121
|
-
# Stops the
|
|
213
|
+
# Stops the EventLoop dispatch task.
|
|
122
214
|
#
|
|
123
215
|
# Sends a cooperative shutdown sentinel to the event queue so that the
|
|
124
|
-
#
|
|
125
|
-
# to +timeout+ seconds for a clean shutdown; if the
|
|
126
|
-
# afterwards it is
|
|
216
|
+
# dispatch task can finish any in-flight handler before exiting. Waits up
|
|
217
|
+
# to +timeout+ seconds for a clean shutdown; if the task is still alive
|
|
218
|
+
# afterwards it is cancelled (cooperative cancellation via {Task#cancel!}).
|
|
127
219
|
#
|
|
128
220
|
# @param timeout [Numeric] seconds to wait for cooperative shutdown. Defaults
|
|
129
221
|
# to +Phronomy.configuration.event_loop_stop_grace_seconds+ (5 s).
|
|
130
222
|
# @param drain [Boolean] when +true+, wait for all active FSMSessions to
|
|
131
223
|
# complete before signalling the loop to stop. Bounded by +timeout+.
|
|
132
224
|
# Defaults to +false+.
|
|
133
|
-
# @param force_kill [Boolean]
|
|
134
|
-
# +
|
|
135
|
-
#
|
|
136
|
-
#
|
|
137
|
-
# interrupt +ensure+ blocks.
|
|
225
|
+
# @param force_kill [Boolean] deprecated — retained for backward compatibility.
|
|
226
|
+
# When +true+, the dispatch task is cancelled via {Task#cancel!} if it does
|
|
227
|
+
# not stop within +timeout+. +Thread#kill+ is no longer used; cooperative
|
|
228
|
+
# cancellation (raising {CancellationError}) replaces it.
|
|
138
229
|
# @return [Symbol] shutdown status:
|
|
139
230
|
# - +:clean+ — loop exited cooperatively with no active sessions discarded
|
|
140
231
|
# - +:drained_with_discards+ — drain mode requested but sessions remained;
|
|
141
232
|
# they were discarded and the loop was stopped
|
|
142
|
-
# - +:timeout+ — the
|
|
143
|
-
# - +:force_killed+ — the
|
|
233
|
+
# - +:timeout+ — the task did not stop in time and +force_kill:+ is +false+
|
|
234
|
+
# - +:force_killed+ — the task was cancelled because it did not stop in time
|
|
144
235
|
# @api private
|
|
145
236
|
def stop(timeout: Phronomy.configuration.event_loop_stop_grace_seconds, drain: false, force_kill: false)
|
|
146
237
|
@shutdown_token.cancel!
|
|
@@ -160,31 +251,31 @@ module Phronomy
|
|
|
160
251
|
end
|
|
161
252
|
|
|
162
253
|
@running = false
|
|
163
|
-
@queue.push(:__stop__) # unblock queue.pop so the
|
|
254
|
+
@queue.push(:__stop__) # unblock queue.pop so the task can see @running = false
|
|
164
255
|
begin
|
|
165
|
-
@
|
|
256
|
+
@task&.join(timeout)
|
|
166
257
|
rescue
|
|
167
|
-
#
|
|
168
|
-
#
|
|
258
|
+
# Task may have terminated with an error (e.g. simulated crash in tests).
|
|
259
|
+
# Suppress the re-raise so the cleanup below always runs.
|
|
169
260
|
nil
|
|
170
261
|
end
|
|
171
|
-
if @
|
|
262
|
+
if @task&.alive?
|
|
172
263
|
if force_kill
|
|
173
264
|
Phronomy.configuration.logger&.warn(
|
|
174
|
-
"[Phronomy] EventLoop
|
|
265
|
+
"[Phronomy] EventLoop task did not stop within #{timeout}s; cancelling. " \
|
|
175
266
|
"This is a last resort — check for blocking operations in event handlers."
|
|
176
267
|
)
|
|
177
|
-
@
|
|
268
|
+
@task.cancel!
|
|
178
269
|
status = :force_killed
|
|
179
270
|
else
|
|
180
271
|
Phronomy.configuration.logger&.warn(
|
|
181
|
-
"[Phronomy] EventLoop
|
|
272
|
+
"[Phronomy] EventLoop task did not stop within #{timeout}s; abandoning " \
|
|
182
273
|
"(force_kill: false). Check for blocking operations in event handlers."
|
|
183
274
|
)
|
|
184
275
|
status = :timeout
|
|
185
276
|
end
|
|
186
277
|
end
|
|
187
|
-
@
|
|
278
|
+
@task = nil
|
|
188
279
|
status
|
|
189
280
|
end
|
|
190
281
|
|
|
@@ -192,14 +283,22 @@ module Phronomy
|
|
|
192
283
|
|
|
193
284
|
def run_loop
|
|
194
285
|
while @running
|
|
195
|
-
|
|
286
|
+
item = @queue.pop
|
|
196
287
|
# :__stop__ is used purely as an unblock signal for @queue.pop; the
|
|
197
288
|
# actual stop condition is @running == false (set before the push).
|
|
198
289
|
# Treating it as `next` instead of `break` prevents a stale sentinel
|
|
199
290
|
# (left by a previous stop call that raced with thread start) from
|
|
200
291
|
# immediately terminating a freshly restarted EventLoop.
|
|
201
|
-
next if
|
|
292
|
+
next if item == :__stop__
|
|
202
293
|
|
|
294
|
+
# item is [event, posted_at_ns] — unwrap and measure lag
|
|
295
|
+
event, posted_at_ns = item
|
|
296
|
+
dequeued_at_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond)
|
|
297
|
+
lag_ns = dequeued_at_ns - posted_at_ns
|
|
298
|
+
update_lag_metrics(lag_ns)
|
|
299
|
+
check_starvation_lag(lag_ns, event)
|
|
300
|
+
|
|
301
|
+
dispatch_start_ns = dequeued_at_ns
|
|
203
302
|
case event.type
|
|
204
303
|
when :finished, :halted, :error
|
|
205
304
|
# All three terminal events share the same cleanup path.
|
|
@@ -244,11 +343,50 @@ module Phronomy
|
|
|
244
343
|
"no handler for target_id #{event.target_id.inspect}"
|
|
245
344
|
end
|
|
246
345
|
end
|
|
346
|
+
|
|
347
|
+
# Check how long this dispatch took; warn if it exceeds the threshold.
|
|
348
|
+
check_dispatch_time(dispatch_start_ns, event)
|
|
247
349
|
end
|
|
248
350
|
rescue => e
|
|
249
351
|
# Unblock all waiting callers if the loop dies unexpectedly.
|
|
250
352
|
@waiting.values.each { |cq| cq.push(e) }
|
|
251
353
|
raise
|
|
252
354
|
end
|
|
355
|
+
|
|
356
|
+
def update_lag_metrics(lag_ns)
|
|
357
|
+
@lag_mutex.synchronize do
|
|
358
|
+
@last_lag_ns = lag_ns
|
|
359
|
+
@max_lag_ns = lag_ns if lag_ns > @max_lag_ns
|
|
360
|
+
@total_lag_ns += lag_ns
|
|
361
|
+
@dispatch_count += 1
|
|
362
|
+
end
|
|
363
|
+
end
|
|
364
|
+
|
|
365
|
+
def check_starvation_lag(lag_ns, event)
|
|
366
|
+
threshold = Phronomy.configuration.event_loop_starvation_threshold_seconds
|
|
367
|
+
return unless threshold && lag_ns > (threshold * 1_000_000_000)
|
|
368
|
+
|
|
369
|
+
Phronomy.configuration.logger&.warn do
|
|
370
|
+
"[Phronomy::EventLoop] Starvation detected: event #{event.type.inspect} " \
|
|
371
|
+
"for target #{event.target_id.inspect} waited " \
|
|
372
|
+
"#{format("%.3f", lag_ns / 1_000_000_000.0)}s in queue " \
|
|
373
|
+
"(threshold: #{threshold}s)"
|
|
374
|
+
end
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
def check_dispatch_time(dispatch_start_ns, event)
|
|
378
|
+
threshold = Phronomy.configuration.event_loop_dispatch_threshold_seconds
|
|
379
|
+
return unless threshold
|
|
380
|
+
|
|
381
|
+
elapsed_ns = Process.clock_gettime(Process::CLOCK_MONOTONIC, :nanosecond) - dispatch_start_ns
|
|
382
|
+
return unless elapsed_ns > (threshold * 1_000_000_000)
|
|
383
|
+
|
|
384
|
+
Phronomy.configuration.logger&.warn do
|
|
385
|
+
"[Phronomy::EventLoop] Long dispatch: event #{event.type.inspect} " \
|
|
386
|
+
"for target #{event.target_id.inspect} took " \
|
|
387
|
+
"#{format("%.3f", elapsed_ns / 1_000_000_000.0)}s on the EventLoop thread " \
|
|
388
|
+
"(threshold: #{threshold}s). Consider moving blocking work to BlockingAdapterPool."
|
|
389
|
+
end
|
|
390
|
+
end
|
|
253
391
|
end
|
|
254
392
|
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
module Guardrail
|
|
5
|
+
# Detects potential prompt injection attempts in the agent input.
|
|
6
|
+
#
|
|
7
|
+
# Prompt injection is an attack where an adversary embeds LLM instructions
|
|
8
|
+
# inside data sources (e.g. RAG chunks, tool results, user input) to override
|
|
9
|
+
# the agent's intended behaviour.
|
|
10
|
+
#
|
|
11
|
+
# This guardrail scans the input string for common injection patterns and
|
|
12
|
+
# calls {#fail!} when a match is found. It is intended to be registered as
|
|
13
|
+
# an input guardrail on agents that consume untrusted external content.
|
|
14
|
+
#
|
|
15
|
+
# @example
|
|
16
|
+
# class MyAgent < Phronomy::Agent::Base
|
|
17
|
+
# model "gpt-4o"
|
|
18
|
+
# input_guardrails Phronomy::Guardrail::PromptInjectionGuardrail.new
|
|
19
|
+
# end
|
|
20
|
+
#
|
|
21
|
+
# @example Custom patterns
|
|
22
|
+
# guard = Phronomy::Guardrail::PromptInjectionGuardrail.new(
|
|
23
|
+
# extra_patterns: [/exfiltrate/i]
|
|
24
|
+
# )
|
|
25
|
+
class PromptInjectionGuardrail < InputGuardrail
|
|
26
|
+
# Common prompt injection / jailbreak patterns.
|
|
27
|
+
DEFAULT_PATTERNS = [
|
|
28
|
+
/ignore\s+(previous|prior|all)\s+instructions?/i,
|
|
29
|
+
/disregard\s+(previous|prior|all)\s+instructions?/i,
|
|
30
|
+
/forget\s+(previous|prior|all)\s+instructions?/i,
|
|
31
|
+
/override\s+(previous|prior|all)\s+instructions?/i,
|
|
32
|
+
/new\s+instructions?:\s/i,
|
|
33
|
+
/\byour\s+new\s+(role|instructions?|task)\b/i,
|
|
34
|
+
/you\s+are\s+now\s+(a|an)\b/i,
|
|
35
|
+
/\bact\s+as\s+(a|an)\b/i,
|
|
36
|
+
/\bpretend\s+(you\s+are|to\s+be)\b/i,
|
|
37
|
+
/\bdo\s+not\s+follow\s+(your|the)\s+instructions?\b/i
|
|
38
|
+
].freeze
|
|
39
|
+
|
|
40
|
+
# @param extra_patterns [Array<Regexp>] additional patterns to scan for
|
|
41
|
+
# @api private
|
|
42
|
+
def initialize(extra_patterns: [])
|
|
43
|
+
super()
|
|
44
|
+
@patterns = DEFAULT_PATTERNS + extra_patterns
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Scans the input string for injection patterns.
|
|
48
|
+
# @param input [String, Hash]
|
|
49
|
+
# @api private
|
|
50
|
+
def check(input)
|
|
51
|
+
text = input.is_a?(Hash) ? input.values.join(" ") : input.to_s
|
|
52
|
+
@patterns.each do |pattern|
|
|
53
|
+
fail!("Potential prompt injection detected") if text.match?(pattern)
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|