phronomy 0.5.4 → 0.6.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/CHANGELOG.md +41 -0
- data/README.md +24 -25
- data/lib/phronomy/agent/base.rb +88 -2
- data/lib/phronomy/agent/fsm.rb +165 -0
- data/lib/phronomy/agent/parallel_tool_chat.rb +75 -0
- data/lib/phronomy/configuration.rb +6 -0
- data/lib/phronomy/context.rb +0 -1
- data/lib/phronomy/event.rb +14 -0
- data/lib/phronomy/event_loop.rb +147 -0
- data/lib/phronomy/fsm_session.rb +194 -0
- data/lib/phronomy/generator_verifier.rb +22 -22
- data/lib/phronomy/guardrail.rb +0 -1
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +83 -71
- data/lib/phronomy/workflow_context.rb +1 -1
- data/lib/phronomy/workflow_runner.rb +167 -112
- data/lib/phronomy.rb +4 -0
- metadata +7 -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
|
@@ -49,7 +49,7 @@ module Phronomy
|
|
|
49
49
|
# Encoding:
|
|
50
50
|
# :__end__ — workflow completed (or not yet started)
|
|
51
51
|
# :awaiting_<name> — halted at a wait_state(:awaiting_<name>) declaration
|
|
52
|
-
# :<
|
|
52
|
+
# :<state> — resuming at <state> (workflow paused before its execution)
|
|
53
53
|
# @return [Symbol]
|
|
54
54
|
def phase
|
|
55
55
|
@phase || :__end__
|
|
@@ -5,7 +5,7 @@ require "state_machines"
|
|
|
5
5
|
|
|
6
6
|
module Phronomy
|
|
7
7
|
# Execution engine for compiled workflows.
|
|
8
|
-
# Manages
|
|
8
|
+
# Manages state entry/exit action execution, phase transitions, halt/resume, and wait states.
|
|
9
9
|
# Instantiated by Phronomy::Workflow and used internally.
|
|
10
10
|
#
|
|
11
11
|
# == Design principle
|
|
@@ -16,39 +16,41 @@ module Phronomy
|
|
|
16
16
|
# the PhaseTracker itself. This ensures that "what happens next" is always
|
|
17
17
|
# determined by the declared state machine topology, never by Phronomy internals.
|
|
18
18
|
#
|
|
19
|
-
#
|
|
19
|
+
# Entry and exit actions are registered as state_machines +after_transition to:+
|
|
20
|
+
# and +before_transition from:+ callbacks respectively. The WorkflowContext is
|
|
21
|
+
# mutable; actions receive it and modify fields in place.
|
|
20
22
|
#
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
23
|
+
# The sole exception is the initial state: state_machines does not fire transition
|
|
24
|
+
# callbacks on initialization, so the entry action for the entry point is invoked
|
|
25
|
+
# directly by WorkflowRunner before the main execution loop begins.
|
|
24
26
|
#
|
|
25
|
-
#
|
|
26
|
-
#
|
|
27
|
+
# == Two transition categories registered in PhaseTracker
|
|
28
|
+
#
|
|
29
|
+
# 1. state_completed — all auto-fire transitions (with or without guards).
|
|
30
|
+
# Fired when an action state's action completes.
|
|
27
31
|
# Guards are evaluated in declaration order; first match wins.
|
|
28
|
-
#
|
|
32
|
+
# (declared with +transition from: :foo, to: :bar+ or
|
|
33
|
+
# +transition from: :foo, guard: ..., to: :bar+)
|
|
29
34
|
#
|
|
30
|
-
#
|
|
35
|
+
# 2. <event_name> — external events triggered by human input, originating
|
|
31
36
|
# from wait states
|
|
32
|
-
# (declared with +
|
|
37
|
+
# (declared with +transition from: :awaiting, on: :approve, to: :run+)
|
|
33
38
|
class WorkflowRunner
|
|
34
39
|
include Phronomy::Runnable
|
|
35
40
|
|
|
36
41
|
# Sentinel value for the terminal state of a workflow.
|
|
37
42
|
FINISH = :__end__
|
|
38
43
|
|
|
39
|
-
def initialize(state_class:,
|
|
40
|
-
external_events:, entry_point:, wait_state_names: [],
|
|
41
|
-
before_callbacks: {}, after_callbacks: {})
|
|
44
|
+
def initialize(state_class:, entry_actions:, declared_states:, auto_transitions:, external_events:, entry_point:, exit_actions: {}, wait_state_names: [])
|
|
42
45
|
@state_class = state_class
|
|
43
|
-
@
|
|
44
|
-
@
|
|
45
|
-
|
|
46
|
+
@entry_actions = entry_actions # { state_name => [callable, ...] }
|
|
47
|
+
@declared_states = declared_states
|
|
48
|
+
# Lookup set: states with at least one auto-fire transition declared.
|
|
49
|
+
@auto_state_set = auto_transitions.each_with_object({}) { |t, h| h[t[:from]] = true }
|
|
46
50
|
@external_events = external_events # { name => [{from:, to:, guard:}, ...] }
|
|
47
51
|
@entry_point = entry_point
|
|
48
52
|
@wait_state_names = wait_state_names
|
|
49
|
-
@
|
|
50
|
-
@after_callbacks = after_callbacks.dup
|
|
51
|
-
@phase_machine_class = build_phase_machine_class
|
|
53
|
+
@phase_machine_class = build_phase_machine_class(auto_transitions, exit_actions)
|
|
52
54
|
end
|
|
53
55
|
|
|
54
56
|
# Executes the workflow from the initial state.
|
|
@@ -60,12 +62,16 @@ module Phronomy
|
|
|
60
62
|
caller_meta[:user_id] = config[:user_id] if config[:user_id]
|
|
61
63
|
caller_meta[:session_id] = config[:session_id] if config[:session_id]
|
|
62
64
|
|
|
63
|
-
trace("
|
|
65
|
+
trace("workflow.invoke", input: input.inspect, **caller_meta) do |_span|
|
|
64
66
|
thread_id = config[:thread_id] || SecureRandom.uuid
|
|
65
67
|
recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
|
|
66
68
|
state = @state_class.new(**input)
|
|
67
69
|
state.set_graph_metadata(thread_id: thread_id)
|
|
68
|
-
result =
|
|
70
|
+
result = if Phronomy.configuration.event_loop
|
|
71
|
+
run_via_event_loop(state, recursion_limit: recursion_limit)
|
|
72
|
+
else
|
|
73
|
+
run_workflow(state, recursion_limit: recursion_limit)
|
|
74
|
+
end
|
|
69
75
|
[result, nil]
|
|
70
76
|
end
|
|
71
77
|
end
|
|
@@ -92,9 +98,6 @@ module Phronomy
|
|
|
92
98
|
event = event.to_sym
|
|
93
99
|
current_phase = state.phase
|
|
94
100
|
|
|
95
|
-
tracker = new_phase_machine(current_phase)
|
|
96
|
-
tracker.context = state
|
|
97
|
-
|
|
98
101
|
ev_to_fire = if event == :resume
|
|
99
102
|
# Find the first external event that can originate from the current wait state.
|
|
100
103
|
name, = @external_events.find { |_, ts| ts.any? { |t| t[:from] == current_phase } }
|
|
@@ -111,14 +114,16 @@ module Phronomy
|
|
|
111
114
|
event
|
|
112
115
|
end
|
|
113
116
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
117
|
+
if Phronomy.configuration.event_loop
|
|
118
|
+
run_via_event_loop(state,
|
|
119
|
+
recursion_limit: Phronomy.configuration.recursion_limit,
|
|
120
|
+
resume_event: ev_to_fire, resume_phase: current_phase)
|
|
121
|
+
else
|
|
122
|
+
run_workflow(state, resume_event: ev_to_fire, resume_phase: current_phase)
|
|
123
|
+
end
|
|
119
124
|
end
|
|
120
125
|
|
|
121
|
-
# Streaming execution. Yields {
|
|
126
|
+
# Streaming execution. Yields { state: Symbol, context: Object } after each state action completes.
|
|
122
127
|
# @param input [Hash]
|
|
123
128
|
# @param config [Hash]
|
|
124
129
|
# @yield [Hash]
|
|
@@ -128,145 +133,173 @@ module Phronomy
|
|
|
128
133
|
recursion_limit = config.fetch(:recursion_limit, Phronomy.configuration.recursion_limit)
|
|
129
134
|
state = @state_class.new(**input)
|
|
130
135
|
state.set_graph_metadata(thread_id: thread_id)
|
|
131
|
-
|
|
136
|
+
run_workflow(state, recursion_limit: recursion_limit, &block)
|
|
132
137
|
end
|
|
133
138
|
|
|
134
139
|
private
|
|
135
140
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
141
|
+
# Builds an FSMSession for the given context. Used in EventLoop mode.
|
|
142
|
+
def build_session_for(context:, recursion_limit:, resume_event: nil, resume_phase: nil)
|
|
143
|
+
Phronomy::FSMSession.new(
|
|
144
|
+
id: context.thread_id,
|
|
145
|
+
context: context,
|
|
146
|
+
entry_point: @entry_point,
|
|
147
|
+
entry_actions: @entry_actions,
|
|
148
|
+
auto_state_set: @auto_state_set,
|
|
149
|
+
declared_states: @declared_states,
|
|
150
|
+
wait_state_names: @wait_state_names,
|
|
151
|
+
external_events: @external_events,
|
|
152
|
+
phase_machine_class: @phase_machine_class,
|
|
153
|
+
recursion_limit: recursion_limit,
|
|
154
|
+
resume_event: resume_event,
|
|
155
|
+
resume_phase: resume_phase
|
|
156
|
+
)
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# Executes the workflow via the singleton EventLoop.
|
|
160
|
+
# Blocks the calling thread on a completion queue until the workflow
|
|
161
|
+
# finishes, halts at a wait state, or raises an error.
|
|
162
|
+
def run_via_event_loop(context, recursion_limit:, resume_event: nil, resume_phase: nil)
|
|
163
|
+
session = build_session_for(
|
|
164
|
+
context: context, recursion_limit: recursion_limit,
|
|
165
|
+
resume_event: resume_event, resume_phase: resume_phase
|
|
166
|
+
)
|
|
167
|
+
completion_queue = Phronomy::EventLoop.instance.register(session)
|
|
168
|
+
result = completion_queue.pop
|
|
169
|
+
raise result if result.is_a?(Exception)
|
|
170
|
+
result
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def run_workflow(ctx, resume_event: nil, resume_phase: nil, recursion_limit: 25, &event_block)
|
|
174
|
+
if resume_event
|
|
175
|
+
# -- Resume from a wait state -------------------------------------------
|
|
176
|
+
# Fire the external event on a tracker positioned at the wait state.
|
|
177
|
+
# state_machines will invoke before_transition (exit) and after_transition
|
|
178
|
+
# (entry) callbacks as part of the transition, so both actions fire here.
|
|
179
|
+
current_state = resume_phase
|
|
180
|
+
tracker = new_phase_machine(current_state)
|
|
181
|
+
tracker.context = ctx
|
|
182
|
+
fire_event!(tracker, resume_event, current_state)
|
|
183
|
+
next_phase = tracker.phase.to_sym
|
|
184
|
+
current_state = (next_phase == current_state) ? FINISH : next_phase
|
|
185
|
+
else
|
|
186
|
+
# -- Fresh start --------------------------------------------------------
|
|
187
|
+
current_state = @entry_point
|
|
188
|
+
tracker = new_phase_machine(current_state)
|
|
189
|
+
tracker.context = ctx
|
|
190
|
+
# state_machines only fires after_transition callbacks on transitions.
|
|
191
|
+
# The entry point has no prior transition, so we invoke its entry actions directly.
|
|
192
|
+
@entry_actions[current_state]&.each { |c| c.call(ctx) }
|
|
193
|
+
end
|
|
194
|
+
|
|
195
|
+
# Event queue: decouple action execution from transition firing.
|
|
196
|
+
# Events are enqueued after visiting a state and processed at the top
|
|
142
197
|
# of the next iteration so that guards always see the freshest context.
|
|
143
198
|
event_queue = []
|
|
144
199
|
step = 0
|
|
145
200
|
|
|
146
201
|
loop do
|
|
147
|
-
break if
|
|
202
|
+
break if current_state == FINISH
|
|
148
203
|
|
|
149
204
|
# -- Process next pending event -----------------------------------------
|
|
150
205
|
# Dequeue one event and fire it against the state machine. Guards are
|
|
151
|
-
# evaluated here (at fire time)
|
|
152
|
-
# node that enqueued the event.
|
|
206
|
+
# evaluated here (at fire time). Entry/exit callbacks fire inside fire_event!.
|
|
153
207
|
if (event = event_queue.shift)
|
|
154
208
|
if step >= recursion_limit
|
|
155
209
|
raise Phronomy::RecursionLimitError,
|
|
156
210
|
"Recursion limit (#{recursion_limit}) exceeded"
|
|
157
211
|
end
|
|
158
212
|
|
|
159
|
-
fire_event!(tracker, event,
|
|
213
|
+
fire_event!(tracker, event, current_state)
|
|
160
214
|
next_phase = tracker.phase.to_sym
|
|
161
|
-
# When next_phase ==
|
|
162
|
-
|
|
215
|
+
# When next_phase == current_state no transition matched → terminal state.
|
|
216
|
+
current_state = (next_phase == current_state) ? FINISH : next_phase
|
|
163
217
|
step += 1
|
|
164
218
|
next
|
|
165
219
|
end
|
|
166
220
|
|
|
167
221
|
# -- Queue empty: check for halt -----------------------------------------
|
|
168
222
|
# Auto-halt at wait states: persist phase in context and return to caller.
|
|
169
|
-
# The caller resumes via send_event
|
|
170
|
-
if @wait_state_names.include?(
|
|
171
|
-
|
|
172
|
-
return
|
|
223
|
+
# The caller resumes via send_event.
|
|
224
|
+
if @wait_state_names.include?(current_state)
|
|
225
|
+
ctx.set_graph_metadata(thread_id: ctx.thread_id, phase: current_state)
|
|
226
|
+
return ctx
|
|
173
227
|
end
|
|
174
228
|
|
|
175
|
-
# --
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
result = node_fn.call(state)
|
|
180
|
-
state = case result
|
|
181
|
-
when Hash then state.merge(result)
|
|
182
|
-
when @state_class then result
|
|
183
|
-
when nil then state
|
|
184
|
-
else
|
|
185
|
-
raise ArgumentError,
|
|
186
|
-
"Node #{current_node} returned #{result.class}; " \
|
|
187
|
-
"expected Hash, #{@state_class}, or nil"
|
|
229
|
+
# -- Validate state is known --------------------------------------------
|
|
230
|
+
unless @declared_states.include?(current_state)
|
|
231
|
+
raise ArgumentError, "State #{current_state.inspect} is not defined"
|
|
188
232
|
end
|
|
189
233
|
|
|
190
|
-
#
|
|
191
|
-
|
|
234
|
+
# -- Emit stream event and enqueue transition ---------------------------
|
|
235
|
+
# Entry action for current_state has already been invoked (either by the
|
|
236
|
+
# initial manual call above, or by the after_transition callback fired
|
|
237
|
+
# inside fire_event! on the previous iteration).
|
|
238
|
+
event_block&.call({state: current_state, context: ctx})
|
|
192
239
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
# route event: user-named event carrying guarded conditional branches.
|
|
198
|
-
# No enqueue: terminal node — next iteration exits via FINISH check.
|
|
199
|
-
if @after_transitions.key?(current_node)
|
|
200
|
-
event_queue << :node_completed
|
|
201
|
-
elsif @route_transitions.key?(current_node)
|
|
202
|
-
event_queue << @route_transitions[current_node][:event_name]
|
|
240
|
+
# state_completed: unified event for all auto-fire transitions.
|
|
241
|
+
# No enqueue: terminal state — next iteration exits via FINISH check.
|
|
242
|
+
if @auto_state_set.key?(current_state)
|
|
243
|
+
event_queue << :state_completed
|
|
203
244
|
else
|
|
204
|
-
|
|
245
|
+
current_state = FINISH
|
|
205
246
|
end
|
|
206
247
|
end
|
|
207
248
|
|
|
208
|
-
|
|
209
|
-
|
|
249
|
+
ctx.set_graph_metadata(thread_id: ctx.thread_id, phase: :__end__)
|
|
250
|
+
ctx
|
|
210
251
|
end
|
|
211
252
|
|
|
212
253
|
# Fires +event_name+ on +tracker+, raising a descriptive error if no
|
|
213
254
|
# transition matches. state_machines event methods return false when no
|
|
214
255
|
# transition can be taken (invalid state or all guards fail).
|
|
215
|
-
def fire_event!(tracker, event_name,
|
|
256
|
+
def fire_event!(tracker, event_name, from_state)
|
|
216
257
|
return if tracker.send(event_name)
|
|
217
258
|
|
|
218
259
|
raise ArgumentError,
|
|
219
|
-
"Transition from #{
|
|
260
|
+
"Transition from #{from_state.inspect} via event #{event_name.inspect} failed. " \
|
|
220
261
|
"Ensure at least one guard matches or add a fallback (no-guard) transition."
|
|
221
262
|
end
|
|
222
263
|
|
|
223
264
|
# Builds the PhaseTracker class backed by state_machines.
|
|
224
265
|
#
|
|
225
|
-
#
|
|
226
|
-
#
|
|
227
|
-
#
|
|
228
|
-
#
|
|
266
|
+
# Four event/callback types are registered:
|
|
267
|
+
# state_completed — all auto-fire transitions (guarded and unguarded)
|
|
268
|
+
# <external_name> — external events originating from wait states
|
|
269
|
+
# after_transition to — entry callbacks (invoked when entering a state)
|
|
270
|
+
# before_transition from — exit callbacks (invoked when leaving a state)
|
|
229
271
|
#
|
|
230
272
|
# Guard lambdas bridge the PhaseTracker and WorkflowContext via +m.context+.
|
|
231
|
-
def build_phase_machine_class
|
|
273
|
+
def build_phase_machine_class(auto_transitions, exit_actions)
|
|
232
274
|
entry = @entry_point
|
|
233
|
-
all_states = (@
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
275
|
+
all_states = (@declared_states + @wait_state_names + [:__end__]).uniq
|
|
276
|
+
auto_trans = auto_transitions # Array of { from:, to:, guard: }
|
|
277
|
+
ext_events = @external_events
|
|
278
|
+
entry_acts = @entry_actions
|
|
279
|
+
exit_acts = exit_actions
|
|
237
280
|
|
|
238
281
|
Class.new do
|
|
239
|
-
# Holds the current WorkflowContext so guards can read it.
|
|
282
|
+
# Holds the current WorkflowContext so guards and callbacks can read it.
|
|
240
283
|
attr_accessor :context
|
|
241
284
|
|
|
242
285
|
state_machine :phase, initial: entry do
|
|
243
286
|
all_states.each { |s| state s }
|
|
244
287
|
|
|
245
|
-
#
|
|
246
|
-
#
|
|
247
|
-
#
|
|
248
|
-
event :
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
# Declaration order is preserved; guards first, unguarded fallback last.
|
|
256
|
-
route_trans.each do |from, routing|
|
|
257
|
-
event routing[:event_name] do
|
|
258
|
-
routing[:entries].each do |t|
|
|
259
|
-
if t[:guard]
|
|
260
|
-
guard_proc = t[:guard]
|
|
261
|
-
transition from => t[:to], :if => ->(m) { guard_proc.call(m.context) }
|
|
262
|
-
else
|
|
263
|
-
transition from => t[:to]
|
|
264
|
-
end
|
|
288
|
+
# Auto-fire transitions: all auto transitions unified under :state_completed.
|
|
289
|
+
# Includes unguarded (unconditional) and guarded (conditional) transitions.
|
|
290
|
+
# Declaration order is preserved; guards are evaluated before unguarded fallbacks.
|
|
291
|
+
event :state_completed do
|
|
292
|
+
auto_trans.each do |t|
|
|
293
|
+
if t[:guard]
|
|
294
|
+
guard_proc = t[:guard]
|
|
295
|
+
transition t[:from] => t[:to], :if => ->(m) { guard_proc.call(m.context) }
|
|
296
|
+
else
|
|
297
|
+
transition t[:from] => t[:to]
|
|
265
298
|
end
|
|
266
299
|
end
|
|
267
300
|
end
|
|
268
301
|
|
|
269
|
-
#
|
|
302
|
+
# External events: human-in-the-loop triggers from wait states.
|
|
270
303
|
ext_events.each do |ev_name, transitions|
|
|
271
304
|
event ev_name do
|
|
272
305
|
transitions.each do |t|
|
|
@@ -279,18 +312,40 @@ module Phronomy
|
|
|
279
312
|
end
|
|
280
313
|
end
|
|
281
314
|
end
|
|
315
|
+
|
|
316
|
+
# Entry callbacks: fire after_transition into each state.
|
|
317
|
+
# Each callable is registered as a separate callback; state_machines
|
|
318
|
+
# accumulates them and fires in declaration order.
|
|
319
|
+
entry_acts.each do |state_name, callables|
|
|
320
|
+
callables.each do |callable|
|
|
321
|
+
after_transition to: state_name do |machine|
|
|
322
|
+
callable.call(machine.context)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
325
|
+
end
|
|
326
|
+
|
|
327
|
+
# Exit callbacks: fire before_transition out of each state.
|
|
328
|
+
# Each callable is registered as a separate callback; state_machines
|
|
329
|
+
# accumulates them and fires in declaration order.
|
|
330
|
+
exit_acts.each do |state_name, callables|
|
|
331
|
+
callables.each do |callable|
|
|
332
|
+
before_transition from: state_name do |machine|
|
|
333
|
+
callable.call(machine.context)
|
|
334
|
+
end
|
|
335
|
+
end
|
|
336
|
+
end
|
|
282
337
|
end
|
|
283
338
|
end
|
|
284
339
|
rescue => e
|
|
285
340
|
raise ArgumentError, "Failed to build phase machine: #{e.message}"
|
|
286
341
|
end
|
|
287
342
|
|
|
288
|
-
# Creates a PhaseTracker instance initialized to +
|
|
289
|
-
def new_phase_machine(
|
|
343
|
+
# Creates a PhaseTracker instance initialized to +from_state+.
|
|
344
|
+
def new_phase_machine(from_state)
|
|
290
345
|
machine = @phase_machine_class.new
|
|
291
346
|
# Override the initial state set by state_machine's initializer so we can
|
|
292
|
-
# resume from an arbitrary
|
|
293
|
-
machine.instance_variable_set(:@phase,
|
|
347
|
+
# resume from an arbitrary state (e.g. after a wait state).
|
|
348
|
+
machine.instance_variable_set(:@phase, from_state.to_s)
|
|
294
349
|
machine
|
|
295
350
|
end
|
|
296
351
|
end
|
data/lib/phronomy.rb
CHANGED
|
@@ -8,6 +8,10 @@ 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
|
+
# FSMSession: Zeitwerk would infer "FsmSession" — override to "FSMSession".
|
|
12
|
+
loader.inflector.inflect("fsm_session" => "FSMSession")
|
|
13
|
+
# AgentFSM: Zeitwerk would infer "Fsm" — override to "FSM".
|
|
14
|
+
loader.inflector.inflect("fsm" => "FSM")
|
|
11
15
|
loader.setup
|
|
12
16
|
|
|
13
17
|
require_relative "phronomy/version"
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: phronomy
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.6.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Raizo T.C.S
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-05-
|
|
11
|
+
date: 2026-05-21 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: ruby_llm
|
|
@@ -73,8 +73,10 @@ files:
|
|
|
73
73
|
- lib/phronomy/agent/concerns/guardrailable.rb
|
|
74
74
|
- lib/phronomy/agent/concerns/retryable.rb
|
|
75
75
|
- lib/phronomy/agent/concerns/suspendable.rb
|
|
76
|
+
- lib/phronomy/agent/fsm.rb
|
|
76
77
|
- lib/phronomy/agent/handoff.rb
|
|
77
78
|
- lib/phronomy/agent/orchestrator.rb
|
|
79
|
+
- lib/phronomy/agent/parallel_tool_chat.rb
|
|
78
80
|
- lib/phronomy/agent/react_agent.rb
|
|
79
81
|
- lib/phronomy/agent/runner.rb
|
|
80
82
|
- lib/phronomy/agent/shared_state.rb
|
|
@@ -83,7 +85,6 @@ files:
|
|
|
83
85
|
- lib/phronomy/configuration.rb
|
|
84
86
|
- lib/phronomy/context.rb
|
|
85
87
|
- lib/phronomy/context/assembler.rb
|
|
86
|
-
- lib/phronomy/context/builder.rb
|
|
87
88
|
- lib/phronomy/context/compaction_context.rb
|
|
88
89
|
- lib/phronomy/context/context_version_cache.rb
|
|
89
90
|
- lib/phronomy/context/token_budget.rb
|
|
@@ -105,12 +106,12 @@ files:
|
|
|
105
106
|
- lib/phronomy/eval/scorer/exact_match.rb
|
|
106
107
|
- lib/phronomy/eval/scorer/includes_scorer.rb
|
|
107
108
|
- lib/phronomy/eval/scorer/llm_judge.rb
|
|
109
|
+
- lib/phronomy/event.rb
|
|
110
|
+
- lib/phronomy/event_loop.rb
|
|
111
|
+
- lib/phronomy/fsm_session.rb
|
|
108
112
|
- lib/phronomy/generator_verifier.rb
|
|
109
113
|
- lib/phronomy/guardrail.rb
|
|
110
114
|
- lib/phronomy/guardrail/base.rb
|
|
111
|
-
- lib/phronomy/guardrail/builtin.rb
|
|
112
|
-
- lib/phronomy/guardrail/builtin/pii_pattern_detector.rb
|
|
113
|
-
- lib/phronomy/guardrail/builtin/prompt_injection_detector.rb
|
|
114
115
|
- lib/phronomy/guardrail/input_guardrail.rb
|
|
115
116
|
- lib/phronomy/guardrail/output_guardrail.rb
|
|
116
117
|
- lib/phronomy/knowledge_source.rb
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
# frozen_string_literal: true
|
|
2
|
-
|
|
3
|
-
module Phronomy
|
|
4
|
-
module Context
|
|
5
|
-
# Assembles ordered context sections (system prompt, knowledge, conversation
|
|
6
|
-
# history) within a given token budget.
|
|
7
|
-
#
|
|
8
|
-
# Usage:
|
|
9
|
-
# builder = Phronomy::Context::Builder.new(budget: budget)
|
|
10
|
-
# builder.add_system(instructions_text)
|
|
11
|
-
# builder.add_knowledge(knowledge_text)
|
|
12
|
-
# builder.add_messages(messages)
|
|
13
|
-
# messages_to_send = builder.build
|
|
14
|
-
#
|
|
15
|
-
# Sections are added in priority order. When the budget is exceeded the
|
|
16
|
-
# lower-priority tail of each section is truncated.
|
|
17
|
-
class Builder
|
|
18
|
-
# @param budget [Phronomy::Context::TokenBudget]
|
|
19
|
-
def initialize(budget:)
|
|
20
|
-
@budget = budget
|
|
21
|
-
@system = nil
|
|
22
|
-
@knowledge = []
|
|
23
|
-
@messages = []
|
|
24
|
-
end
|
|
25
|
-
|
|
26
|
-
# Set the system instructions text (highest priority).
|
|
27
|
-
# @param text [String]
|
|
28
|
-
def add_system(text)
|
|
29
|
-
@system = text.to_s
|
|
30
|
-
self
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
# Append knowledge/RAG text (medium priority).
|
|
34
|
-
# @param text [String]
|
|
35
|
-
def add_knowledge(text)
|
|
36
|
-
@knowledge << text.to_s
|
|
37
|
-
self
|
|
38
|
-
end
|
|
39
|
-
|
|
40
|
-
# Set conversation messages (lowest priority — oldest are dropped first).
|
|
41
|
-
# @param messages [Array] list of message-like objects with #role and #content
|
|
42
|
-
def add_messages(messages)
|
|
43
|
-
@messages = Array(messages)
|
|
44
|
-
self
|
|
45
|
-
end
|
|
46
|
-
|
|
47
|
-
# Assemble the context respecting the token budget.
|
|
48
|
-
#
|
|
49
|
-
# Returns a hash with:
|
|
50
|
-
# :system [String, nil] system prompt (instructions + knowledge)
|
|
51
|
-
# :messages [Array] conversation messages that fit within the budget
|
|
52
|
-
#
|
|
53
|
-
# @return [Hash]
|
|
54
|
-
def build
|
|
55
|
-
used = 0
|
|
56
|
-
|
|
57
|
-
# System prompt is always included (budget enforcement is informational only).
|
|
58
|
-
system_text = [@system, *@knowledge].compact.join("\n\n")
|
|
59
|
-
used += TokenEstimator.estimate(system_text)
|
|
60
|
-
|
|
61
|
-
# Conversation messages — keep as many recent messages as fit.
|
|
62
|
-
remaining = @budget.available(used: used)
|
|
63
|
-
kept = fit_messages_to_budget(@messages, remaining)
|
|
64
|
-
|
|
65
|
-
{
|
|
66
|
-
system: system_text.empty? ? nil : system_text,
|
|
67
|
-
messages: kept
|
|
68
|
-
}
|
|
69
|
-
end
|
|
70
|
-
|
|
71
|
-
private
|
|
72
|
-
|
|
73
|
-
# Greedily accumulate messages from newest to oldest, stop when budget runs out.
|
|
74
|
-
def fit_messages_to_budget(messages, token_limit)
|
|
75
|
-
return messages if token_limit <= 0 && messages.empty?
|
|
76
|
-
|
|
77
|
-
accumulated = 0
|
|
78
|
-
result = []
|
|
79
|
-
|
|
80
|
-
messages.reverse_each do |msg|
|
|
81
|
-
tokens = TokenEstimator.estimate(msg.content.to_s)
|
|
82
|
-
break if accumulated + tokens > token_limit
|
|
83
|
-
|
|
84
|
-
accumulated += tokens
|
|
85
|
-
result.unshift(msg)
|
|
86
|
-
end
|
|
87
|
-
|
|
88
|
-
result
|
|
89
|
-
end
|
|
90
|
-
end
|
|
91
|
-
end
|
|
92
|
-
end
|