phronomy 0.5.4 → 0.7.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 +21 -0
- data/CHANGELOG.md +379 -0
- data/CONTRIBUTING.md +102 -0
- data/README.md +262 -48
- data/RELEASE_CHECKLIST.md +86 -0
- data/SECURITY.md +80 -0
- data/benchmark/baseline.json +9 -0
- data/benchmark/bench_agent_invoke.rb +105 -0
- data/benchmark/bench_context_assembler.rb +46 -0
- data/benchmark/bench_regression.rb +171 -0
- data/benchmark/bench_token_estimator.rb +44 -0
- data/benchmark/bench_tool_schema.rb +69 -0
- data/benchmark/bench_vector_store.rb +39 -0
- data/benchmark/bench_workflow.rb +55 -0
- data/benchmark/run_all.rb +118 -0
- data/docs/decisions/001-rubyllm-as-provider-layer.md +42 -0
- data/docs/decisions/002-workflow-context-immutability.md +42 -0
- data/docs/decisions/003-event-loop-singleton.md +48 -0
- data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +51 -0
- data/docs/decisions/005-static-knowledge-class-level-cache.md +45 -0
- data/docs/decisions/006-no-built-in-guardrails.md +48 -0
- data/docs/decisions/007-mcp-is-beta-stability.md +51 -0
- data/docs/decisions/008-orchestrator-uses-os-threads.md +52 -0
- data/docs/decisions/009-state-store-abstraction.md +141 -0
- data/lib/phronomy/agent/base.rb +281 -13
- data/lib/phronomy/agent/before_completion_context.rb +1 -0
- data/lib/phronomy/agent/checkpoint.rb +1 -0
- data/lib/phronomy/agent/concerns/before_completion.rb +6 -0
- data/lib/phronomy/agent/concerns/error_translation.rb +45 -0
- data/lib/phronomy/agent/concerns/guardrailable.rb +3 -0
- data/lib/phronomy/agent/concerns/retryable.rb +12 -1
- data/lib/phronomy/agent/concerns/suspendable.rb +4 -0
- data/lib/phronomy/agent/fsm.rb +180 -0
- data/lib/phronomy/agent/handoff.rb +3 -0
- data/lib/phronomy/agent/orchestrator.rb +123 -11
- data/lib/phronomy/agent/parallel_tool_chat.rb +92 -0
- data/lib/phronomy/agent/react_agent.rb +8 -6
- data/lib/phronomy/agent/runner.rb +2 -0
- data/lib/phronomy/agent/shared_state.rb +11 -0
- data/lib/phronomy/agent/suspend_signal.rb +2 -0
- data/lib/phronomy/agent/team_coordinator.rb +17 -5
- data/lib/phronomy/cancellation_token.rb +92 -0
- data/lib/phronomy/configuration.rb +32 -2
- data/lib/phronomy/context/assembler.rb +6 -0
- data/lib/phronomy/context/compaction_context.rb +2 -0
- data/lib/phronomy/context/context_version_cache.rb +2 -0
- data/lib/phronomy/context/token_budget.rb +3 -0
- data/lib/phronomy/context/token_estimator.rb +9 -2
- data/lib/phronomy/context/trigger_context.rb +1 -0
- data/lib/phronomy/context/trim_context.rb +4 -0
- data/lib/phronomy/context.rb +0 -1
- data/lib/phronomy/embeddings/base.rb +5 -2
- data/lib/phronomy/embeddings/ruby_llm_embeddings.rb +6 -2
- data/lib/phronomy/eval/comparison.rb +2 -0
- data/lib/phronomy/eval/dataset.rb +4 -0
- data/lib/phronomy/eval/metrics.rb +6 -0
- data/lib/phronomy/eval/runner.rb +2 -0
- data/lib/phronomy/eval/scorer/base.rb +1 -0
- data/lib/phronomy/eval/scorer/exact_match.rb +2 -0
- data/lib/phronomy/eval/scorer/includes_scorer.rb +2 -0
- data/lib/phronomy/eval/scorer/llm_judge.rb +2 -0
- data/lib/phronomy/event.rb +14 -0
- data/lib/phronomy/event_loop.rb +254 -0
- data/lib/phronomy/fsm_session.rb +201 -0
- data/lib/phronomy/generator_verifier.rb +24 -22
- data/lib/phronomy/guardrail/base.rb +3 -0
- data/lib/phronomy/guardrail.rb +0 -1
- data/lib/phronomy/knowledge_source/base.rb +6 -2
- data/lib/phronomy/knowledge_source/entity_knowledge.rb +7 -2
- data/lib/phronomy/knowledge_source/rag_knowledge.rb +8 -4
- data/lib/phronomy/knowledge_source/static_knowledge.rb +7 -2
- data/lib/phronomy/loader/base.rb +1 -0
- data/lib/phronomy/loader/csv_loader.rb +2 -0
- data/lib/phronomy/loader/markdown_loader.rb +2 -0
- data/lib/phronomy/loader/plain_text_loader.rb +1 -0
- data/lib/phronomy/output_parser/base.rb +1 -0
- data/lib/phronomy/output_parser/json_parser.rb +22 -3
- data/lib/phronomy/output_parser/structured_parser.rb +2 -0
- data/lib/phronomy/prompt_template.rb +5 -0
- data/lib/phronomy/runnable.rb +20 -3
- data/lib/phronomy/splitter/base.rb +2 -0
- data/lib/phronomy/splitter/fixed_size_splitter.rb +2 -0
- data/lib/phronomy/splitter/recursive_splitter.rb +2 -0
- data/lib/phronomy/state_store/base.rb +48 -0
- data/lib/phronomy/state_store/in_memory.rb +62 -0
- data/lib/phronomy/tool/agent_tool.rb +1 -0
- data/lib/phronomy/tool/base.rb +189 -27
- data/lib/phronomy/tool/mcp_tool.rb +68 -13
- data/lib/phronomy/tracing/base.rb +3 -0
- data/lib/phronomy/tracing/langfuse_tracer.rb +2 -0
- data/lib/phronomy/tracing/open_telemetry_tracer.rb +2 -0
- data/lib/phronomy/vector_store/base.rb +33 -7
- data/lib/phronomy/vector_store/in_memory.rb +16 -7
- data/lib/phronomy/vector_store/pgvector.rb +40 -9
- data/lib/phronomy/vector_store/redis_search.rb +29 -8
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +175 -74
- data/lib/phronomy/workflow_context.rb +55 -5
- data/lib/phronomy/workflow_runner.rb +197 -114
- data/lib/phronomy.rb +74 -1
- data/scripts/api_snapshot.rb +91 -0
- data/scripts/check_api_annotations.rb +68 -0
- data/scripts/check_private_enforcement.rb +93 -0
- data/scripts/check_readme_runnable.rb +98 -0
- data/scripts/run_mutation.sh +46 -0
- metadata +50 -6
- data/lib/phronomy/context/builder.rb +0 -92
- data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +0 -100
- data/lib/phronomy/guardrail/builtin/prompt_injection_detector.rb +0 -67
- data/lib/phronomy/guardrail/builtin.rb +0 -16
|
@@ -12,11 +12,13 @@ module Phronomy
|
|
|
12
12
|
# ExactMatch.new.score(actual: "paris", expected: "Paris") # => 0.0
|
|
13
13
|
class ExactMatch < Base
|
|
14
14
|
# @param case_sensitive [Boolean] default true
|
|
15
|
+
# @api public
|
|
15
16
|
def initialize(case_sensitive: true)
|
|
16
17
|
@case_sensitive = case_sensitive
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
# @return [Float] 1.0 on match, 0.0 otherwise
|
|
21
|
+
# @api public
|
|
20
22
|
def score(actual:, expected:, input: nil)
|
|
21
23
|
a = actual.to_s.strip
|
|
22
24
|
e = expected.to_s.strip
|
|
@@ -13,11 +13,13 @@ module Phronomy
|
|
|
13
13
|
# IncludesScorer.new.score(actual: "The answer is 42.", expected: "42") # => 1.0
|
|
14
14
|
class IncludesScorer < Base
|
|
15
15
|
# @param case_sensitive [Boolean] default false
|
|
16
|
+
# @api public
|
|
16
17
|
def initialize(case_sensitive: false)
|
|
17
18
|
@case_sensitive = case_sensitive
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
# @return [Float] 1.0 if actual contains expected, 0.0 otherwise
|
|
22
|
+
# @api public
|
|
21
23
|
def score(actual:, expected:, input: nil)
|
|
22
24
|
a = actual.to_s
|
|
23
25
|
e = expected.to_s
|
|
@@ -36,6 +36,7 @@ module Phronomy
|
|
|
36
36
|
# @param prompt_template [String] format string with %<input>s, %<expected>s, %<actual>s
|
|
37
37
|
# @param raise_on_error [Boolean] when true, re-raises scoring exceptions instead of
|
|
38
38
|
# returning 0.0. Use this in batch eval pipelines where silent failures are unacceptable.
|
|
39
|
+
# @api public
|
|
39
40
|
def initialize(model:, prompt_template: DEFAULT_PROMPT, raise_on_error: false)
|
|
40
41
|
@model = model
|
|
41
42
|
@prompt_template = prompt_template
|
|
@@ -43,6 +44,7 @@ module Phronomy
|
|
|
43
44
|
end
|
|
44
45
|
|
|
45
46
|
# @return [Float] score in [0.0, 1.0]; 0.0 on error when raise_on_error is false
|
|
47
|
+
# @api public
|
|
46
48
|
def score(actual:, expected:, input: nil)
|
|
47
49
|
prompt = format(@prompt_template, input: input.to_s, expected: expected.to_s, actual: actual.to_s)
|
|
48
50
|
response = RubyLLM.chat(model: @model).ask(prompt)
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# Immutable event struct used for inter-FSM communication via EventLoop.
|
|
5
|
+
#
|
|
6
|
+
# @param type [Symbol] event identifier (:start, :state_completed,
|
|
7
|
+
# :finished, :halted, :error, or any user-defined name)
|
|
8
|
+
# @param target_id [String] FSMSession identifier — matches WorkflowContext#thread_id
|
|
9
|
+
# @param payload [Object] optional data attached to the event:
|
|
10
|
+
# - final/halted context for :finished/:halted
|
|
11
|
+
# - Exception for :error
|
|
12
|
+
# - nil for :start / :state_completed
|
|
13
|
+
Event = Data.define(:type, :target_id, :payload)
|
|
14
|
+
end
|
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# Singleton event loop that manages all FSMSession instances.
|
|
5
|
+
#
|
|
6
|
+
# A single background thread reads from a global Thread::Queue and dispatches
|
|
7
|
+
# events to their target FSMSession. IO work (LLM calls, tool calls) runs in
|
|
8
|
+
# separate IO threads that post events back to the loop via EventLoop#post.
|
|
9
|
+
#
|
|
10
|
+
# Activated with: +Phronomy.configure { |c| c.event_loop = true }+
|
|
11
|
+
#
|
|
12
|
+
# == Fork safety
|
|
13
|
+
#
|
|
14
|
+
# +EventLoop.instance+ is lazily initialized. The background thread is not
|
|
15
|
+
# created until the first call, so Puma worker forking does not duplicate the
|
|
16
|
+
# thread. No +after_fork+ hook is required.
|
|
17
|
+
#
|
|
18
|
+
# == Deadlock warning
|
|
19
|
+
#
|
|
20
|
+
# Do NOT call +Workflow#invoke+ (in EventLoop mode) from within a workflow
|
|
21
|
+
# entry action. The entry action runs on the EventLoop thread; a nested
|
|
22
|
+
# +invoke+ would block waiting for the same thread to process events →
|
|
23
|
+
# deadlock. Use the async IO pattern instead (spawn a Thread, post events
|
|
24
|
+
# back to the EventLoop).
|
|
25
|
+
class EventLoop
|
|
26
|
+
# Returns the singleton instance, creating and starting it on first call.
|
|
27
|
+
def self.instance
|
|
28
|
+
@instance ||= new.tap(&:start)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Stops and destroys the singleton. Primarily used in tests.
|
|
32
|
+
# @api private
|
|
33
|
+
def self.reset!
|
|
34
|
+
@instance&.stop
|
|
35
|
+
@instance = nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize
|
|
39
|
+
@queue = Thread::Queue.new # global event queue (thread-safe; no Mutex needed)
|
|
40
|
+
@fsms = {} # { id => FSMSession } — EventLoop thread only
|
|
41
|
+
@waiting = {} # { id => completion_queue } — EventLoop thread only
|
|
42
|
+
# Mutex-backed FSM count for drain-mode shutdown.
|
|
43
|
+
@fsm_count_mutex = Mutex.new
|
|
44
|
+
@fsm_count_cond = ConditionVariable.new
|
|
45
|
+
@fsm_count = 0
|
|
46
|
+
# Token cancelled when shutdown is requested; new child sessions receive it.
|
|
47
|
+
@shutdown_token = Phronomy::CancellationToken.new
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Registers an FSMSession for execution and returns a completion queue.
|
|
51
|
+
#
|
|
52
|
+
# The session and its completion queue are handed off to the EventLoop thread
|
|
53
|
+
# via the queue payload, so +@fsms+ and +@waiting+ are exclusively written
|
|
54
|
+
# and read by the EventLoop thread. No Mutex is required.
|
|
55
|
+
#
|
|
56
|
+
# The caller blocks on +completion_queue.pop+ to receive the final context
|
|
57
|
+
# (WorkflowContext) once the workflow finishes or halts. If an error occurred,
|
|
58
|
+
# the popped value will be an Exception — callers are responsible for re-raising it.
|
|
59
|
+
#
|
|
60
|
+
# @param fsm_session [Phronomy::FSMSession]
|
|
61
|
+
# @return [Thread::Queue] resolves to final/halted context, or an Exception
|
|
62
|
+
# @api private
|
|
63
|
+
def register(fsm_session)
|
|
64
|
+
if Thread.current[:phronomy_event_loop_thread]
|
|
65
|
+
raise Phronomy::Error,
|
|
66
|
+
"Cannot call Workflow#invoke (EventLoop mode) from within an EventLoop " \
|
|
67
|
+
"entry action. Use the async IO pattern: spawn a Thread, post events " \
|
|
68
|
+
"back via Phronomy::EventLoop.instance.post(...) instead."
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
completion_queue = Thread::Queue.new
|
|
72
|
+
# Pass both session and completion_queue in the event payload so that the
|
|
73
|
+
# 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}))
|
|
76
|
+
completion_queue
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
# Enqueues an {AgentFSM} as a fire-and-forget child session.
|
|
80
|
+
#
|
|
81
|
+
# Unlike {#register}, this method:
|
|
82
|
+
# - Is safe to call from the EventLoop thread (entry actions).
|
|
83
|
+
# - Does NOT block — no completion queue is created.
|
|
84
|
+
# - Delegates `:finished`/`:error` cleanup to the EventLoop via posted events.
|
|
85
|
+
#
|
|
86
|
+
# @param agent_fsm [Phronomy::Agent::FSM]
|
|
87
|
+
# @return [nil]
|
|
88
|
+
# @api private
|
|
89
|
+
def enqueue_child(agent_fsm)
|
|
90
|
+
@queue.push(Event.new(type: :start, target_id: agent_fsm.id,
|
|
91
|
+
payload: {session: agent_fsm, completion: nil}))
|
|
92
|
+
nil
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# Posts an event to the loop. Safe to call from any thread (including IO threads).
|
|
96
|
+
#
|
|
97
|
+
# @param event [Phronomy::Event]
|
|
98
|
+
# @api private
|
|
99
|
+
def post(event)
|
|
100
|
+
@queue.push(event)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Starts the background event loop thread.
|
|
104
|
+
# @return [self]
|
|
105
|
+
# @api private
|
|
106
|
+
def start
|
|
107
|
+
return self if @thread&.alive?
|
|
108
|
+
|
|
109
|
+
# Reset shutdown state so the loop can be restarted after a stop.
|
|
110
|
+
@shutdown_token = Phronomy::CancellationToken.new
|
|
111
|
+
@fsm_count_mutex.synchronize { @fsm_count = 0 }
|
|
112
|
+
@running = true
|
|
113
|
+
@thread = Thread.new do
|
|
114
|
+
Thread.current[:phronomy_event_loop_thread] = true
|
|
115
|
+
run_loop
|
|
116
|
+
end
|
|
117
|
+
@thread.abort_on_exception = false
|
|
118
|
+
self
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Stops the background thread. Used in tests only.
|
|
122
|
+
#
|
|
123
|
+
# Sends a cooperative shutdown sentinel to the event queue so that the
|
|
124
|
+
# worker thread can finish any in-flight handler before exiting. Waits up
|
|
125
|
+
# to +timeout+ seconds for a clean shutdown; if the thread is still alive
|
|
126
|
+
# afterwards it is force-killed as a last resort.
|
|
127
|
+
#
|
|
128
|
+
# @param timeout [Numeric] seconds to wait for cooperative shutdown. Defaults
|
|
129
|
+
# to +Phronomy.configuration.event_loop_stop_grace_seconds+ (5 s).
|
|
130
|
+
# @param drain [Boolean] when +true+, wait for all active FSMSessions to
|
|
131
|
+
# complete before signalling the loop to stop. Bounded by +timeout+.
|
|
132
|
+
# Defaults to +false+.
|
|
133
|
+
# @param force_kill [Boolean] when +true+, the worker thread is killed with
|
|
134
|
+
# +Thread#kill+ if it does not stop within +timeout+. When +false+
|
|
135
|
+
# (default), the thread is never killed; the method returns +:timeout+
|
|
136
|
+
# instead. +false+ is safer for production because +Thread#kill+ can
|
|
137
|
+
# interrupt +ensure+ blocks.
|
|
138
|
+
# @return [Symbol] shutdown status:
|
|
139
|
+
# - +:clean+ — loop exited cooperatively with no active sessions discarded
|
|
140
|
+
# - +:drained_with_discards+ — drain mode requested but sessions remained;
|
|
141
|
+
# they were discarded and the loop was stopped
|
|
142
|
+
# - +:timeout+ — the worker thread did not stop in time and +force_kill:+ is +false+
|
|
143
|
+
# - +:force_killed+ — the worker thread did not stop in time and was killed
|
|
144
|
+
# @api private
|
|
145
|
+
def stop(timeout: Phronomy.configuration.event_loop_stop_grace_seconds, drain: false, force_kill: false)
|
|
146
|
+
@shutdown_token.cancel!
|
|
147
|
+
status = :clean
|
|
148
|
+
|
|
149
|
+
if drain
|
|
150
|
+
# Wait for active sessions to finish, bounded by timeout.
|
|
151
|
+
deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
|
|
152
|
+
@fsm_count_mutex.synchronize do
|
|
153
|
+
while @fsm_count > 0
|
|
154
|
+
remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
155
|
+
break if remaining <= 0
|
|
156
|
+
@fsm_count_cond.wait(@fsm_count_mutex, remaining)
|
|
157
|
+
end
|
|
158
|
+
status = :drained_with_discards if @fsm_count > 0
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
@running = false
|
|
163
|
+
@queue.push(:__stop__) # unblock queue.pop so the worker can see @running = false
|
|
164
|
+
begin
|
|
165
|
+
@thread&.join(timeout)
|
|
166
|
+
rescue
|
|
167
|
+
# Thread may have terminated with an exception (e.g. simulated crash in
|
|
168
|
+
# tests). Suppress the re-raise so the cleanup below always runs.
|
|
169
|
+
nil
|
|
170
|
+
end
|
|
171
|
+
if @thread&.alive?
|
|
172
|
+
if force_kill
|
|
173
|
+
Phronomy.configuration.logger&.warn(
|
|
174
|
+
"[Phronomy] EventLoop thread did not stop within #{timeout}s; force-killing. " \
|
|
175
|
+
"This is a last resort — check for blocking operations in event handlers."
|
|
176
|
+
)
|
|
177
|
+
@thread.kill
|
|
178
|
+
status = :force_killed
|
|
179
|
+
else
|
|
180
|
+
Phronomy.configuration.logger&.warn(
|
|
181
|
+
"[Phronomy] EventLoop thread did not stop within #{timeout}s; abandoning " \
|
|
182
|
+
"(force_kill: false). Check for blocking operations in event handlers."
|
|
183
|
+
)
|
|
184
|
+
status = :timeout
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
@thread = nil
|
|
188
|
+
status
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
private
|
|
192
|
+
|
|
193
|
+
def run_loop
|
|
194
|
+
while @running
|
|
195
|
+
event = @queue.pop
|
|
196
|
+
# :__stop__ is used purely as an unblock signal for @queue.pop; the
|
|
197
|
+
# actual stop condition is @running == false (set before the push).
|
|
198
|
+
# Treating it as `next` instead of `break` prevents a stale sentinel
|
|
199
|
+
# (left by a previous stop call that raced with thread start) from
|
|
200
|
+
# immediately terminating a freshly restarted EventLoop.
|
|
201
|
+
next if event == :__stop__
|
|
202
|
+
|
|
203
|
+
case event.type
|
|
204
|
+
when :finished, :halted, :error
|
|
205
|
+
# All three terminal events share the same cleanup path.
|
|
206
|
+
# Both @fsms and @waiting are exclusively owned by this thread.
|
|
207
|
+
@fsms.delete(event.target_id)
|
|
208
|
+
cq = @waiting.delete(event.target_id)
|
|
209
|
+
cq&.push(event.payload)
|
|
210
|
+
# Decrement active FSM count and signal drain waiters.
|
|
211
|
+
@fsm_count_mutex.synchronize do
|
|
212
|
+
@fsm_count -= 1
|
|
213
|
+
@fsm_count_cond.signal if @fsm_count <= 0
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
when :start
|
|
217
|
+
# session and completion_queue arrive together in the payload so that
|
|
218
|
+
# this thread is the sole writer of @fsms and @waiting.
|
|
219
|
+
# completion may be nil for fire-and-forget child sessions (AgentFSM).
|
|
220
|
+
session = event.payload[:session]
|
|
221
|
+
cq = event.payload[:completion]
|
|
222
|
+
|
|
223
|
+
# When shutdown has been requested, reject new sessions with a
|
|
224
|
+
# CancellationError rather than starting new LLM calls that would
|
|
225
|
+
# be interrupted by force-kill.
|
|
226
|
+
if @shutdown_token.cancelled? && cq
|
|
227
|
+
cq.push(Phronomy::CancellationError.new("EventLoop is shutting down"))
|
|
228
|
+
next
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
@fsms[event.target_id] = session
|
|
232
|
+
@waiting[event.target_id] = cq if cq
|
|
233
|
+
@fsm_count_mutex.synchronize { @fsm_count += 1 }
|
|
234
|
+
session.start
|
|
235
|
+
|
|
236
|
+
else
|
|
237
|
+
fsm = @fsms[event.target_id]
|
|
238
|
+
if fsm
|
|
239
|
+
fsm.handle(event)
|
|
240
|
+
else
|
|
241
|
+
# Warn when an event is dropped due to an unknown target_id so that
|
|
242
|
+
# mis-typed IDs and handler-deregistration races are visible.
|
|
243
|
+
warn "[Phronomy::EventLoop] Dropped event #{event.type.inspect} — " \
|
|
244
|
+
"no handler for target_id #{event.target_id.inspect}"
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
end
|
|
248
|
+
rescue => e
|
|
249
|
+
# Unblock all waiting callers if the loop dies unexpectedly.
|
|
250
|
+
@waiting.values.each { |cq| cq.push(e) }
|
|
251
|
+
raise
|
|
252
|
+
end
|
|
253
|
+
end
|
|
254
|
+
end
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Phronomy
|
|
4
|
+
# Event-driven execution wrapper for a single workflow run.
|
|
5
|
+
#
|
|
6
|
+
# Created by WorkflowRunner and registered with EventLoop. All public methods
|
|
7
|
+
# are called from the EventLoop thread — FSMSession is NOT thread-safe and must
|
|
8
|
+
# not be accessed concurrently from multiple threads.
|
|
9
|
+
#
|
|
10
|
+
# == Lifecycle
|
|
11
|
+
#
|
|
12
|
+
# register(session) → EventLoop posts :start → session.start
|
|
13
|
+
# ↓ (auto-transition present)
|
|
14
|
+
# EventLoop posts :state_completed → session.handle
|
|
15
|
+
# ↓ (repeat)
|
|
16
|
+
# session posts :finished or :halted
|
|
17
|
+
# ↓
|
|
18
|
+
# EventLoop pushes ctx to completion_queue → caller unblocks
|
|
19
|
+
#
|
|
20
|
+
# == Async IO pattern (EventLoop mode only)
|
|
21
|
+
#
|
|
22
|
+
# When a state has no auto-transition and is not a wait_state, but has an
|
|
23
|
+
# external event registered (e.g. +transition from: :fetching, on: :fetch_done+),
|
|
24
|
+
# the FSMSession stays registered in the EventLoop and waits for that event.
|
|
25
|
+
# The entry action is expected to spawn an IO thread that posts the event back:
|
|
26
|
+
#
|
|
27
|
+
# entry :fetching, ->(ctx) {
|
|
28
|
+
# Thread.new {
|
|
29
|
+
# ctx.result = http.get(ctx.url)
|
|
30
|
+
# Phronomy::EventLoop.instance.post(
|
|
31
|
+
# Phronomy::Event.new(type: :fetch_done, target_id: ctx.thread_id, payload: nil)
|
|
32
|
+
# )
|
|
33
|
+
# }
|
|
34
|
+
# }
|
|
35
|
+
# transition from: :fetching, on: :fetch_done, to: :process
|
|
36
|
+
class FSMSession
|
|
37
|
+
FINISH = WorkflowRunner::FINISH
|
|
38
|
+
|
|
39
|
+
# @return [String] workflow thread_id (matches WorkflowContext#thread_id)
|
|
40
|
+
attr_reader :id
|
|
41
|
+
|
|
42
|
+
# @param id [String]
|
|
43
|
+
# @param context [Object] includes Phronomy::WorkflowContext
|
|
44
|
+
# @param entry_point [Symbol] initial state name
|
|
45
|
+
# @param entry_actions [Hash] { state_name => [callable, ...] }
|
|
46
|
+
# @param auto_state_set [Hash] { state_name => true }
|
|
47
|
+
# @param declared_states [Array<Symbol>] all action state names
|
|
48
|
+
# @param wait_state_names [Array<Symbol>]
|
|
49
|
+
# @param external_events [Hash] { event_name => [{from:, to:, guard:}] }
|
|
50
|
+
# @param phase_machine_class [Class] state_machines-backed phase tracker class
|
|
51
|
+
# @param recursion_limit [Integer]
|
|
52
|
+
# @param resume_event [Symbol, nil] external event to fire when resuming
|
|
53
|
+
# @param resume_phase [Symbol, nil] wait state name to resume from
|
|
54
|
+
# @api private
|
|
55
|
+
def initialize(id:, context:, entry_point:, entry_actions:, auto_state_set:,
|
|
56
|
+
declared_states:, wait_state_names:, external_events:, phase_machine_class:,
|
|
57
|
+
recursion_limit:, resume_event: nil, resume_phase: nil)
|
|
58
|
+
@id = id
|
|
59
|
+
@ctx = context
|
|
60
|
+
@entry_point = entry_point
|
|
61
|
+
@entry_actions = entry_actions
|
|
62
|
+
@auto_state_set = auto_state_set
|
|
63
|
+
@declared_states = declared_states
|
|
64
|
+
@wait_state_names = wait_state_names
|
|
65
|
+
@external_events = external_events
|
|
66
|
+
@phase_machine_class = phase_machine_class
|
|
67
|
+
@recursion_limit = recursion_limit
|
|
68
|
+
@resume_event = resume_event
|
|
69
|
+
@resume_phase = resume_phase
|
|
70
|
+
@step = 0
|
|
71
|
+
@done = false
|
|
72
|
+
@current_state = nil
|
|
73
|
+
@tracker = nil
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Begins workflow execution. Called by EventLoop on :start event.
|
|
77
|
+
def start
|
|
78
|
+
if @resume_event
|
|
79
|
+
# Resume from wait state: position tracker at the wait state, then fire the
|
|
80
|
+
# external event. state_machines fires before_transition (exit) and
|
|
81
|
+
# after_transition (entry) callbacks, so both actions execute here.
|
|
82
|
+
@current_state = @resume_phase
|
|
83
|
+
@tracker = build_tracker(@current_state)
|
|
84
|
+
@tracker.context = @ctx
|
|
85
|
+
fire_and_advance!(@resume_event)
|
|
86
|
+
else
|
|
87
|
+
# Fresh start: state_machines does not fire callbacks on initialization,
|
|
88
|
+
# so we invoke the entry action for the initial state manually.
|
|
89
|
+
@current_state = @entry_point
|
|
90
|
+
@tracker = build_tracker(@current_state)
|
|
91
|
+
@tracker.context = @ctx
|
|
92
|
+
(@entry_actions[@current_state] || []).each do |c|
|
|
93
|
+
result = c.call(@ctx)
|
|
94
|
+
@ctx = result if result.is_a?(Phronomy::WorkflowContext)
|
|
95
|
+
end
|
|
96
|
+
@tracker.context = @ctx
|
|
97
|
+
advance_or_halt
|
|
98
|
+
end
|
|
99
|
+
rescue => e
|
|
100
|
+
finish_with_error(e)
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# Processes an event dispatched from EventLoop.
|
|
104
|
+
# Called for :state_completed and all user-defined external events.
|
|
105
|
+
#
|
|
106
|
+
# @param event [Phronomy::Event]
|
|
107
|
+
# @api private
|
|
108
|
+
def handle(event)
|
|
109
|
+
return if @done
|
|
110
|
+
|
|
111
|
+
fire_and_advance!(event.type)
|
|
112
|
+
rescue => e
|
|
113
|
+
finish_with_error(e)
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
private
|
|
117
|
+
|
|
118
|
+
# Fires event_name on the phase tracker, updates @current_state, then
|
|
119
|
+
# calls advance_or_halt to decide what to do next.
|
|
120
|
+
def fire_and_advance!(event_name)
|
|
121
|
+
if @step >= @recursion_limit
|
|
122
|
+
raise Phronomy::RecursionLimitError,
|
|
123
|
+
"Recursion limit (#{@recursion_limit}) exceeded"
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
fire_event!(@tracker, event_name, @current_state)
|
|
127
|
+
@ctx = @tracker.context
|
|
128
|
+
next_phase = @tracker.phase.to_sym
|
|
129
|
+
# When next_phase == @current_state, no transition matched → treat as terminal.
|
|
130
|
+
@current_state = (next_phase == @current_state) ? FINISH : next_phase
|
|
131
|
+
@step += 1
|
|
132
|
+
advance_or_halt
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Determines the next action after the FSM has entered @current_state.
|
|
136
|
+
def advance_or_halt
|
|
137
|
+
return finish! if @current_state == FINISH
|
|
138
|
+
|
|
139
|
+
if @wait_state_names.include?(@current_state)
|
|
140
|
+
return halt!
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
if @auto_state_set.key?(@current_state)
|
|
144
|
+
event_loop.post(Event.new(type: :state_completed, target_id: @id, payload: nil))
|
|
145
|
+
return
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
if has_external_event_from?(@current_state)
|
|
149
|
+
# Async IO pattern: the entry action spawned an IO thread that will post
|
|
150
|
+
# an external event back. Stay registered; do nothing here.
|
|
151
|
+
return
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# No transition declared — validate the state is known, then treat as terminal.
|
|
155
|
+
unless @declared_states.include?(@current_state)
|
|
156
|
+
raise ArgumentError, "State #{@current_state.inspect} is not defined"
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
finish!
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def finish!
|
|
163
|
+
@done = true
|
|
164
|
+
@ctx.set_graph_metadata(thread_id: @id, phase: :__end__)
|
|
165
|
+
event_loop.post(Event.new(type: :finished, target_id: @id, payload: @ctx))
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def halt!
|
|
169
|
+
@done = true
|
|
170
|
+
@ctx.set_graph_metadata(thread_id: @id, phase: @current_state)
|
|
171
|
+
event_loop.post(Event.new(type: :halted, target_id: @id, payload: @ctx))
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def finish_with_error(err)
|
|
175
|
+
@done = true
|
|
176
|
+
event_loop.post(Event.new(type: :error, target_id: @id, payload: err))
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def fire_event!(tracker, event_name, from_state)
|
|
180
|
+
return if tracker.send(event_name)
|
|
181
|
+
|
|
182
|
+
raise ArgumentError,
|
|
183
|
+
"Transition from #{from_state.inspect} via event #{event_name.inspect} failed. " \
|
|
184
|
+
"Ensure at least one guard matches or add a fallback (no-guard) transition."
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
def has_external_event_from?(state)
|
|
188
|
+
@external_events.any? { |_, transitions| transitions.any? { |t| t[:from] == state } }
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def build_tracker(from_state)
|
|
192
|
+
machine = @phase_machine_class.new
|
|
193
|
+
machine.instance_variable_set(:@phase, from_state.to_s)
|
|
194
|
+
machine
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def event_loop
|
|
198
|
+
Phronomy::EventLoop.instance
|
|
199
|
+
end
|
|
200
|
+
end
|
|
201
|
+
end
|
|
@@ -113,6 +113,7 @@ module Phronomy
|
|
|
113
113
|
# @param raise_if_untrusted [Boolean] when +true+, raises
|
|
114
114
|
# {Phronomy::LowConfidenceError} if the final result does not meet the
|
|
115
115
|
# confidence threshold (default: false)
|
|
116
|
+
# @api private
|
|
116
117
|
def initialize(
|
|
117
118
|
draft_agent:,
|
|
118
119
|
review_agent:,
|
|
@@ -133,7 +134,7 @@ module Phronomy
|
|
|
133
134
|
@threshold = confidence_threshold.to_f
|
|
134
135
|
@max_iterations = max_iterations.to_i
|
|
135
136
|
@raise_if_untrusted = raise_if_untrusted
|
|
136
|
-
@
|
|
137
|
+
@compiled_workflow = nil
|
|
137
138
|
end
|
|
138
139
|
|
|
139
140
|
# Run the generator-verifier pipeline.
|
|
@@ -143,8 +144,9 @@ module Phronomy
|
|
|
143
144
|
# @return [Result]
|
|
144
145
|
# @raise [Phronomy::LowConfidenceError] when +raise_if_untrusted:+ is +true+
|
|
145
146
|
# and the result does not meet the confidence threshold
|
|
147
|
+
# @api private
|
|
146
148
|
def invoke(input, config: {})
|
|
147
|
-
app =
|
|
149
|
+
app = compiled_workflow
|
|
148
150
|
state = app.invoke({input: input}, config: config)
|
|
149
151
|
confidence = combined_confidence(state)
|
|
150
152
|
trusted = confidence >= @threshold
|
|
@@ -166,8 +168,8 @@ module Phronomy
|
|
|
166
168
|
[(state.self_score || 0.0).to_f, (state.review_score || 0.0).to_f].min
|
|
167
169
|
end
|
|
168
170
|
|
|
169
|
-
def
|
|
170
|
-
@
|
|
171
|
+
def compiled_workflow
|
|
172
|
+
@compiled_workflow ||= build_workflow
|
|
171
173
|
end
|
|
172
174
|
|
|
173
175
|
def build_workflow
|
|
@@ -184,42 +186,42 @@ module Phronomy
|
|
|
184
186
|
Phronomy::Workflow.define(PipelineState) do
|
|
185
187
|
initial :draft
|
|
186
188
|
|
|
187
|
-
state :draft
|
|
189
|
+
state :draft
|
|
190
|
+
state :review
|
|
191
|
+
state :finalize
|
|
192
|
+
|
|
193
|
+
entry :draft, ->(state) {
|
|
188
194
|
feedback = state.review_notes.last
|
|
189
195
|
prompt = dpb.call(state.input, feedback)
|
|
190
196
|
result = draft_agent.invoke(prompt)
|
|
191
197
|
parsed = drp.call(result[:output])
|
|
192
|
-
state.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
iteration: state.iteration + 1
|
|
197
|
-
)
|
|
198
|
+
state.draft = parsed[:answer].to_s
|
|
199
|
+
state.self_score = pipeline.__send__(:clamp, parsed[:confidence])
|
|
200
|
+
state.citations = pipeline.__send__(:normalize_citations, parsed[:citations])
|
|
201
|
+
state.iteration = state.iteration + 1
|
|
198
202
|
}
|
|
199
203
|
|
|
200
|
-
|
|
204
|
+
entry :review, ->(state) {
|
|
201
205
|
prompt = rpb.call(state.input, state.draft, state.citations)
|
|
202
206
|
result = review_agent.invoke(prompt)
|
|
203
207
|
parsed = rrp.call(result[:output])
|
|
204
|
-
state.
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
review_notes: parsed[:feedback].to_s
|
|
208
|
-
)
|
|
208
|
+
state.review_score = pipeline.__send__(:clamp, parsed[:score])
|
|
209
|
+
state.approved = parsed[:approved] == true
|
|
210
|
+
state.review_notes << parsed[:feedback].to_s
|
|
209
211
|
}
|
|
210
212
|
|
|
211
|
-
|
|
213
|
+
entry :finalize, ->(state) { state.output = state.draft }
|
|
212
214
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
+
transition from: :draft, to: :review
|
|
216
|
+
transition from: :finalize, to: :__finish__
|
|
215
217
|
|
|
216
|
-
|
|
218
|
+
transition from: :review,
|
|
217
219
|
guard: ->(state) {
|
|
218
220
|
confidence = [state.self_score || 0.0, state.review_score || 0.0].min
|
|
219
221
|
(confidence >= threshold && state.approved) || state.iteration >= max_iter
|
|
220
222
|
},
|
|
221
223
|
to: :finalize
|
|
222
|
-
|
|
224
|
+
transition from: :review, to: :draft
|
|
223
225
|
end
|
|
224
226
|
end
|
|
225
227
|
|
|
@@ -17,6 +17,7 @@ module Phronomy
|
|
|
17
17
|
# Validate the value. Subclasses must implement this method.
|
|
18
18
|
# @param value [Object] the input or output being checked
|
|
19
19
|
# @raise [Phronomy::GuardrailError] if the guardrail rejects the value
|
|
20
|
+
# @api public
|
|
20
21
|
def check(value)
|
|
21
22
|
raise NotImplementedError, "#{self.class}#check is not implemented"
|
|
22
23
|
end
|
|
@@ -24,6 +25,7 @@ module Phronomy
|
|
|
24
25
|
# Run the check, raising GuardrailError on failure.
|
|
25
26
|
# @param value [Object]
|
|
26
27
|
# @return [Object] the original value (unchanged) when the check passes
|
|
28
|
+
# @api public
|
|
27
29
|
def run!(value)
|
|
28
30
|
check(value)
|
|
29
31
|
value
|
|
@@ -34,6 +36,7 @@ module Phronomy
|
|
|
34
36
|
# Call inside #check to reject the value.
|
|
35
37
|
# @param reason [String] human-readable rejection reason
|
|
36
38
|
# @raise [Phronomy::GuardrailError]
|
|
39
|
+
# @api public
|
|
37
40
|
def fail!(reason)
|
|
38
41
|
raise Phronomy::GuardrailError.new(reason, guardrail: self)
|
|
39
42
|
end
|
data/lib/phronomy/guardrail.rb
CHANGED
|
@@ -11,9 +11,12 @@ module Phronomy
|
|
|
11
11
|
class Base
|
|
12
12
|
# Retrieve knowledge chunks relevant to the given query.
|
|
13
13
|
#
|
|
14
|
-
# @param query
|
|
14
|
+
# @param query [String, nil] the current user input used to select relevant chunks
|
|
15
|
+
# @param cancellation_token [Phronomy::CancellationToken, nil] optional token; raises CancellationError when cancelled
|
|
15
16
|
# @return [Array<Hash>] array of { content: String, type: Symbol }
|
|
16
|
-
|
|
17
|
+
# @api public
|
|
18
|
+
def fetch(query: nil, cancellation_token: nil)
|
|
19
|
+
cancellation_token&.raise_if_cancelled!
|
|
17
20
|
raise NotImplementedError, "#{self.class}#fetch is not implemented"
|
|
18
21
|
end
|
|
19
22
|
|
|
@@ -24,6 +27,7 @@ module Phronomy
|
|
|
24
27
|
# Override in subclasses that return fixed content.
|
|
25
28
|
#
|
|
26
29
|
# @return [Boolean]
|
|
30
|
+
# @api public
|
|
27
31
|
def static?
|
|
28
32
|
false
|
|
29
33
|
end
|