phronomy 0.1.4 → 0.2.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.
@@ -170,6 +170,26 @@ module Phronomy
170
170
  @model_class.where(thread_id: thread_id).where("created_at < ?", older_than).delete_all
171
171
  end
172
172
 
173
+ # Returns the next seq number to use for new raw messages for +thread_id+.
174
+ # Derived from MAX(seq) in the database; since purge_older_than does not
175
+ # touch raw records, this value is always correct.
176
+ #
177
+ # @param thread_id [String]
178
+ # @return [Integer]
179
+ def next_seq(thread_id:)
180
+ return 0 unless @raw_model_class
181
+
182
+ ((@raw_model_class.where(thread_id: thread_id).maximum(:seq) || -1) + 1)
183
+ end
184
+
185
+ # Delegates to the block directly; serialisation of concurrent saves
186
+ # for the same thread_id is the caller's responsibility (e.g. DB-level
187
+ # transaction isolation or application-layer queuing).
188
+ # @param thread_id [String]
189
+ def with_thread_lock(thread_id:)
190
+ yield
191
+ end
192
+
173
193
  private
174
194
 
175
195
  def ensure_raw_model!
@@ -127,6 +127,28 @@ module Phronomy
127
127
  def purge_older_than(thread_id:, older_than:)
128
128
  # no-op by default
129
129
  end
130
+
131
+ # Returns the next seq number to assign when appending new raw messages
132
+ # for +thread_id+. Must be monotonically increasing and must survive
133
+ # purge_older_than (i.e. the counter must not reset when old raw records
134
+ # are deleted by a TTL purge).
135
+ #
136
+ # @param thread_id [String]
137
+ # @return [Integer]
138
+ def next_seq(thread_id:)
139
+ raise NotImplementedError, "#{self.class}#next_seq is not implemented"
140
+ end
141
+
142
+ # Executes the block while holding a per-thread-id lock for +thread_id+.
143
+ # Used by ConversationManager to prevent concurrent compaction for the
144
+ # same thread. The default implementation yields without locking; backends
145
+ # that require serialisation should override this method.
146
+ #
147
+ # @param thread_id [String]
148
+ # @yield
149
+ def with_thread_lock(thread_id:)
150
+ yield
151
+ end
130
152
  end
131
153
  end
132
154
  end
@@ -3,18 +3,19 @@
3
3
  module Phronomy
4
4
  module Memory
5
5
  module Storage
6
- # In-process Hash-backed storage for conversation messages.
6
+ # In-process storage for conversation messages backed by per-thread-id
7
+ # {Phronomy::Actor} instances from {Phronomy::ThreadActorRegistry}.
7
8
  # Messages are lost when the process exits.
8
9
  #
9
10
  # @example
10
11
  # storage = Phronomy::Memory::Storage::InMemory.new
11
12
  # manager = Phronomy::Memory::ConversationManager.new(storage: storage, ...)
12
13
  class InMemory < Base
14
+ # Thread-local key for per-thread-id storage data (namespaced by store
15
+ # instance object_id to support multiple independent InMemory stores).
16
+ THREAD_DATA_KEY = :phronomy_storage_in_memory_data
17
+
13
18
  def initialize
14
- @mutex = Mutex.new
15
- @store = {}
16
- @raw_store = {} # thread_id => [{seq:, message:}, ...]
17
- @compaction_store = {} # thread_id => [{start_seq:, end_seq:, summary_text:}, ...]
18
19
  end
19
20
 
20
21
  # -----------------------------------------------------------------------
@@ -24,21 +25,20 @@ module Phronomy
24
25
  # @param thread_id [String]
25
26
  # @return [Array]
26
27
  def load(thread_id:)
27
- @mutex.synchronize { (@store[thread_id] || []).dup }
28
+ Phronomy::ThreadActorRegistry.for(thread_id).call { (thread_data.store || []).dup }
28
29
  end
29
30
 
30
31
  # @param thread_id [String]
31
32
  # @param messages [Array]
32
33
  def save(thread_id:, messages:)
33
- @mutex.synchronize { @store[thread_id] = messages.dup }
34
+ Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.store = messages.dup }
34
35
  end
35
36
 
36
37
  # @param thread_id [String]
37
38
  def clear(thread_id:)
38
- @mutex.synchronize do
39
- @store.delete(thread_id)
40
- @raw_store.delete(thread_id)
41
- @compaction_store.delete(thread_id)
39
+ store_id = object_id
40
+ Phronomy::ThreadActorRegistry.for(thread_id).call do
41
+ (Thread.current[THREAD_DATA_KEY] ||= {}).delete(store_id)
42
42
  end
43
43
  end
44
44
 
@@ -49,25 +49,45 @@ module Phronomy
49
49
  # @param thread_id [String]
50
50
  # @param messages [Array]
51
51
  # @param starting_seq [Integer]
52
- def append_raw(thread_id:, messages:, starting_seq:)
53
- now = Time.now
54
- @mutex.synchronize do
55
- @raw_store[thread_id] ||= []
52
+ # @param recorded_at [Time, nil] timestamp for test overrides; defaults to +Time.now+
53
+ def append_raw(thread_id:, messages:, starting_seq:, recorded_at: nil)
54
+ now = recorded_at || Time.now
55
+ Phronomy::ThreadActorRegistry.for(thread_id).call do
56
+ data = thread_data
56
57
  messages.each_with_index do |msg, i|
57
- @raw_store[thread_id] << {seq: starting_seq + i, message: msg, recorded_at: now}
58
+ seq = starting_seq + i
59
+ data.raw_messages << {seq: seq, message: msg, recorded_at: now}
60
+ data.hwm = [data.hwm, seq].max
58
61
  end
59
62
  end
60
63
  end
61
64
 
65
+ # @param thread_id [String]
66
+ # @return [Integer]
67
+ def next_seq(thread_id:)
68
+ Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.hwm + 1 }
69
+ end
70
+
71
+ # Routes +block+ through the per-thread-id {Phronomy::Actor}, serialising
72
+ # all operations for the same thread. Reentrant calls (the block itself
73
+ # calling storage methods that also route through the Actor) are safe
74
+ # because {Phronomy::Actor#call} detects the same-thread case and executes
75
+ # inline.
76
+ #
77
+ # @param thread_id [String]
78
+ def with_thread_lock(thread_id:, &block)
79
+ Phronomy::ThreadActorRegistry.for(thread_id).call(&block)
80
+ end
81
+
62
82
  # @param thread_id [String]
63
83
  # @return [Array<Hash>]
64
84
  def load_raw(thread_id:)
65
- @mutex.synchronize { (@raw_store[thread_id] || []).dup }
85
+ Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.raw_messages.dup }
66
86
  end
67
87
 
68
88
  # @param thread_id [String]
69
89
  def clear_raw(thread_id:)
70
- @mutex.synchronize { @raw_store.delete(thread_id) }
90
+ Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.raw_messages.clear }
71
91
  end
72
92
 
73
93
  # -----------------------------------------------------------------------
@@ -79,21 +99,20 @@ module Phronomy
79
99
  # @param end_seq [Integer]
80
100
  # @param summary_text [String]
81
101
  def save_compaction(thread_id:, start_seq:, end_seq:, summary_text:)
82
- @mutex.synchronize do
83
- @compaction_store[thread_id] ||= []
84
- @compaction_store[thread_id] << {start_seq: start_seq, end_seq: end_seq, summary_text: summary_text}
102
+ Phronomy::ThreadActorRegistry.for(thread_id).call do
103
+ thread_data.compactions << {start_seq: start_seq, end_seq: end_seq, summary_text: summary_text}
85
104
  end
86
105
  end
87
106
 
88
107
  # @param thread_id [String]
89
108
  # @return [Array<Hash>]
90
109
  def load_compactions(thread_id:)
91
- @mutex.synchronize { (@compaction_store[thread_id] || []).dup }
110
+ Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.compactions.dup }
92
111
  end
93
112
 
94
113
  # @param thread_id [String]
95
114
  def clear_compactions(thread_id:)
96
- @mutex.synchronize { @compaction_store.delete(thread_id) }
115
+ Phronomy::ThreadActorRegistry.for(thread_id).call { thread_data.compactions.clear }
97
116
  end
98
117
 
99
118
  # Remove raw messages recorded before +older_than+ for this thread.
@@ -101,10 +120,30 @@ module Phronomy
101
120
  # @param thread_id [String]
102
121
  # @param older_than [Time]
103
122
  def purge_older_than(thread_id:, older_than:)
104
- @mutex.synchronize do
105
- next unless @raw_store[thread_id]
123
+ Phronomy::ThreadActorRegistry.for(thread_id).call do
124
+ thread_data.raw_messages.reject! { |entry| entry[:recorded_at] && entry[:recorded_at] < older_than }
125
+ end
126
+ end
127
+
128
+ private
129
+
130
+ # Returns (or lazily initialises) the {ThreadData} for the current Actor
131
+ # thread and this storage instance. Must only be called from within a
132
+ # {Phronomy::ThreadActorRegistry.for} block so that +Thread.current+ is
133
+ # the correct Actor thread.
134
+ def thread_data
135
+ (Thread.current[THREAD_DATA_KEY] ||= {})[object_id] ||= ThreadData.new
136
+ end
137
+
138
+ # Value object holding all per-thread-id storage state.
139
+ class ThreadData
140
+ attr_accessor :store, :raw_messages, :compactions, :hwm
106
141
 
107
- @raw_store[thread_id].reject! { |entry| entry[:recorded_at] && entry[:recorded_at] < older_than }
142
+ def initialize
143
+ @store = nil
144
+ @raw_messages = []
145
+ @compactions = []
146
+ @hwm = -1
108
147
  end
109
148
  end
110
149
  end
@@ -38,7 +38,7 @@ module Phronomy
38
38
  end
39
39
 
40
40
  # Serializes and upserts the state for the given thread_id.
41
- # @param state [Object] includes Phronomy::Graph::State
41
+ # @param state [Object] includes Phronomy::WorkflowContext
42
42
  # @return [self]
43
43
  def save(state)
44
44
  json = serialize_state(state)
@@ -7,11 +7,11 @@ module Phronomy
7
7
  # Abstract base class for state persistence backends.
8
8
  # Subclasses must implement save, load, and clear.
9
9
  #
10
- # The state object passed to save must include Phronomy::Graph::State
11
- # and have a non-nil thread_id (set automatically by CompiledGraph#invoke).
10
+ # The state object passed to save must include Phronomy::WorkflowContext
11
+ # and have a non-nil thread_id (set automatically by WorkflowRunner#invoke).
12
12
  class Base
13
13
  # Persists the state. The thread_id is read from state.thread_id.
14
- # @param state [Object] object including Phronomy::Graph::State
14
+ # @param state [Object] object including Phronomy::WorkflowContext
15
15
  # @return [self]
16
16
  def save(state)
17
17
  raise NotImplementedError, "#{self.class}#save is not implemented"
@@ -40,8 +40,7 @@ module Phronomy
40
40
  state_class: state.class.name,
41
41
  state_data: json_safe(state.to_h),
42
42
  thread_id: state.thread_id,
43
- current_nodes: state.current_nodes&.map(&:to_s),
44
- halted_before: state.halted_before
43
+ phase: state.phase&.to_s
45
44
  )
46
45
  end
47
46
 
@@ -54,37 +53,36 @@ module Phronomy
54
53
  state = state_class.new(**state_data)
55
54
  state.set_graph_metadata(
56
55
  thread_id: data[:thread_id],
57
- current_nodes: data[:current_nodes]&.map(&:to_sym),
58
- halted_before: data[:halted_before]
56
+ phase: data[:phase]&.to_sym
59
57
  )
60
58
  state
61
59
  end
62
60
 
63
- # Resolves and validates a state class name.
64
- # When a registry has been configured via +Phronomy::Graph.register_state_class+,
61
+ # Resolves and validates a context class name.
62
+ # When a registry has been configured via +Phronomy.register_workflow_context+,
65
63
  # only registered classes are accepted — this prevents unintended autoloading
66
64
  # of arbitrary files from an untrusted class name stored in Redis/DB.
67
65
  # When no registry is configured, falls back to Object.const_get with a check
68
- # that the resolved class includes Phronomy::Graph::State.
66
+ # that the resolved class includes Phronomy::WorkflowContext.
69
67
  def safe_state_class(class_name)
70
- registry = Phronomy::Graph.state_class_registry
68
+ registry = Phronomy.workflow_context_registry
71
69
  if registry
72
70
  klass = registry[class_name.to_s]
73
71
  unless klass
74
72
  raise ArgumentError,
75
- "Unregistered state class: #{class_name.inspect}. " \
76
- "Call Phronomy::Graph.register_state_class(#{class_name}) at startup."
73
+ "Unregistered context class: #{class_name.inspect}. " \
74
+ "Call Phronomy.register_workflow_context(#{class_name}) at startup."
77
75
  end
78
76
  return klass
79
77
  end
80
78
 
81
79
  klass = Object.const_get(class_name.to_s)
82
- unless klass.is_a?(Class) && klass.include?(Phronomy::Graph::State)
83
- raise ArgumentError, "Invalid state class: #{class_name.inspect}"
80
+ unless klass.is_a?(Class) && klass.include?(Phronomy::WorkflowContext)
81
+ raise ArgumentError, "Invalid context class: #{class_name.inspect}"
84
82
  end
85
83
  klass
86
84
  rescue NameError
87
- raise ArgumentError, "Unknown state class: #{class_name.inspect}"
85
+ raise ArgumentError, "Unknown context class: #{class_name.inspect}"
88
86
  end
89
87
 
90
88
  # Recursively converts objects to JSON-safe primitives.
@@ -2,37 +2,50 @@
2
2
 
3
3
  module Phronomy
4
4
  module StateStore
5
- # In-memory state store. Stores state objects keyed by thread_id.
6
- # State objects are stored directly (no serialization), so this
7
- # backend is suitable for single-process use only.
5
+ # In-memory state store backed by per-thread-id {Phronomy::Actor} instances
6
+ # from {Phronomy::ThreadActorRegistry}. Suitable for single-process use only.
8
7
  class InMemory < Base
8
+ # Thread-local key for per-thread-id state data (namespaced by store
9
+ # instance object_id to support multiple independent InMemory stores).
10
+ THREAD_DATA_KEY = :phronomy_state_store_in_memory_data
11
+
9
12
  def initialize
10
- @store = {}
11
- @mutex = Mutex.new
12
13
  end
13
14
 
14
- # @param state [Object] includes Phronomy::Graph::State; must have a non-nil thread_id
15
+ # @param state [Object] includes Phronomy::WorkflowContext; must have a non-nil thread_id
15
16
  # @return [self]
16
17
  def save(state)
17
- @mutex.synchronize { @store[state.thread_id] = state }
18
+ store_id = object_id
19
+ Phronomy::ThreadActorRegistry.for(state.thread_id).call do
20
+ (Thread.current[THREAD_DATA_KEY] ||= {})[store_id] = state
21
+ end
18
22
  self
19
23
  end
20
24
 
21
25
  # @param thread_id [String]
22
26
  # @return [Object, nil] state object or nil
23
27
  def load(thread_id)
24
- @mutex.synchronize { @store[thread_id] }
28
+ store_id = object_id
29
+ Phronomy::ThreadActorRegistry.for(thread_id).call do
30
+ (Thread.current[THREAD_DATA_KEY] ||= {})[store_id]
31
+ end
25
32
  end
26
33
 
27
34
  # @param thread_id [String]
28
35
  # @return [self]
29
36
  def clear(thread_id)
30
- @mutex.synchronize { @store.delete(thread_id) }
37
+ store_id = object_id
38
+ Phronomy::ThreadActorRegistry.for(thread_id).call do
39
+ (Thread.current[THREAD_DATA_KEY] ||= {}).delete(store_id)
40
+ end
31
41
  self
32
42
  end
33
43
 
34
44
  def clear_all
35
- @mutex.synchronize { @store.clear }
45
+ store_id = object_id
46
+ Phronomy::ThreadActorRegistry.each_actor do |actor|
47
+ actor.call { (Thread.current[THREAD_DATA_KEY] ||= {}).delete(store_id) }
48
+ end
36
49
  self
37
50
  end
38
51
  end
@@ -33,7 +33,7 @@ module Phronomy
33
33
  @ttl = ttl
34
34
  end
35
35
 
36
- # @param state [Object] includes Phronomy::Graph::State
36
+ # @param state [Object] includes Phronomy::WorkflowContext
37
37
  # @return [self]
38
38
  def save(state)
39
39
  serialized = serialize_state(state)
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Global per-thread-id {Actor} registry.
5
+ #
6
+ # Maps the +:thread_id+ key from the +config:+ argument passed to
7
+ # {Phronomy::Agent::Base#invoke} to a {Phronomy::Actor} instance.
8
+ # Each thread_id gets exactly one Actor so that all operations for the same
9
+ # conversation are serialised automatically.
10
+ #
11
+ # @example
12
+ # Phronomy::ThreadActorRegistry.for("user-42").call do
13
+ # # runs sequentially on the Actor's thread
14
+ # end
15
+ module ThreadActorRegistry
16
+ @actors = {}
17
+ @registry_actor = Actor.new
18
+
19
+ class << self
20
+ # Returns (or lazily creates) the {Actor} for +thread_id+.
21
+ #
22
+ # @param thread_id [String]
23
+ # @return [Phronomy::Actor]
24
+ def for(thread_id)
25
+ @registry_actor.call { @actors[thread_id] ||= Actor.new }
26
+ end
27
+
28
+ # Gracefully stops the Actor for +thread_id+ and removes it from the
29
+ # registry. The next call to {.for} with the same id creates a fresh Actor.
30
+ #
31
+ # @param thread_id [String]
32
+ def stop(thread_id)
33
+ @registry_actor.call { @actors.delete(thread_id) }&.stop
34
+ end
35
+
36
+ # Stops and removes every registered Actor.
37
+ # Intended for test teardown and process shutdown.
38
+ def clear_all
39
+ actors = @registry_actor.call { @actors.values.tap { @actors.clear } }
40
+ actors.each(&:stop)
41
+ end
42
+
43
+ # Yields each currently registered Actor.
44
+ # A snapshot is taken so the registry cannot change while callers iterate.
45
+ #
46
+ # @yield [Phronomy::Actor]
47
+ def each_actor(&block)
48
+ @registry_actor.call { @actors.values.dup }.each(&block)
49
+ end
50
+ end
51
+ end
52
+ end
@@ -58,7 +58,7 @@ module Phronomy
58
58
  end
59
59
 
60
60
  # Sets the access scope for this tool (metadata; enforcement is the responsibility of
61
- # the Graph/Guardrail layer).
61
+ # the Workflow/Guardrail layer).
62
62
  # @param value [Symbol] e.g. :read_only, :write, :admin
63
63
  def scope(value = nil)
64
64
  return @scope if value.nil?
@@ -87,7 +87,7 @@ module Phronomy
87
87
  # Split the command string into an argv array so that Open3 executes
88
88
  # it directly without going through the shell, preventing injection.
89
89
  @command = Shellwords.split(command)
90
- @mutex = Mutex.new
90
+ @actor = Phronomy::Actor.new
91
91
  @stdin = nil
92
92
  @stdout = nil
93
93
  @stderr = nil
@@ -97,20 +97,21 @@ module Phronomy
97
97
 
98
98
  # Shut down the child process and close its IO streams.
99
99
  def close
100
- @mutex.synchronize do
100
+ stderr_thread, wait_thr = @actor.call do
101
101
  @stdin&.close
102
102
  @stdout&.close
103
103
  @stderr&.close
104
104
  @stdin = nil
105
105
  @stdout = nil
106
106
  @stderr = nil
107
+ t = [@stderr_thread, @wait_thr]
108
+ @stderr_thread = nil
109
+ @wait_thr = nil
110
+ t
107
111
  end
108
- # Join the stderr drain thread and the child process outside the mutex
109
- # to avoid holding the lock during potentially slow joins.
110
- @stderr_thread&.join(1)
111
- @wait_thr&.join(5)
112
- @stderr_thread = nil
113
- @wait_thr = nil
112
+ # Join outside the Actor to avoid blocking the Actor thread on slow joins.
113
+ stderr_thread&.join(1)
114
+ wait_thr&.join(5)
114
115
  end
115
116
 
116
117
  # Retrieve the tool definition from the server using the MCP `tools/list` method.
@@ -167,7 +168,7 @@ module Phronomy
167
168
  end
168
169
 
169
170
  def rpc_call(method, params)
170
- @mutex.synchronize do
171
+ @actor.call do
171
172
  ensure_started!
172
173
  payload = JSON.generate(jsonrpc: "2.0", id: SecureRandom.uuid, method: method, params: params)
173
174
  @stdin.puts(payload)
@@ -36,7 +36,7 @@ module Phronomy
36
36
  @secret_key = secret_key
37
37
  @host = host.chomp("/")
38
38
  @http = nil
39
- @http_mutex = Mutex.new
39
+ @actor = Phronomy::Actor.new
40
40
  end
41
41
 
42
42
  # Returns a plain Hash that records the span start state.
@@ -90,13 +90,13 @@ module Phronomy
90
90
  req["Authorization"] = "Basic #{Base64.strict_encode64("#{@public_key}:#{@secret_key}")}"
91
91
  req.body = JSON.generate({batch: events})
92
92
 
93
- @http_mutex.synchronize do
93
+ @actor.call do
94
94
  @http ||= build_http(uri)
95
95
  @http.request(req)
96
96
  end
97
97
  rescue IOError, Errno::ECONNRESET, Errno::EPIPE => e
98
98
  # Connection was reset; drop the cached connection and warn.
99
- @http_mutex.synchronize { @http = nil }
99
+ @actor.call { @http = nil }
100
100
  warn "[Phronomy::LangfuseTracer] Ingestion failed: #{e.class}: #{e.message}"
101
101
  nil
102
102
  rescue => e
@@ -56,7 +56,7 @@ module Phronomy
56
56
  # Internal graph state — not part of the public API.
57
57
  # @private
58
58
  class PipelineState
59
- include Phronomy::Graph::State
59
+ include Phronomy::WorkflowContext
60
60
 
61
61
  field :input, type: :replace, default: -> { "" }
62
62
  field :draft, type: :replace, default: -> {}
@@ -89,7 +89,7 @@ module Phronomy
89
89
  @threshold = confidence_threshold.to_f
90
90
  @max_iterations = max_iterations.to_i
91
91
  @input_delimiter = input_delimiter
92
- @graph_mutex = Mutex.new
92
+ @actor = Phronomy::Actor.new
93
93
  @compiled_graph = nil
94
94
  end
95
95
 
@@ -118,66 +118,58 @@ module Phronomy
118
118
  [(state.self_score || 0.0).to_f, (state.review_score || 0.0).to_f].min
119
119
  end
120
120
 
121
- # Returns the compiled graph, building and caching it on first call.
122
- # Thread-safe via double-checked locking.
121
+ # Returns the compiled workflow, building and caching it on first call.
123
122
  def compiled_graph
124
- return @compiled_graph if @compiled_graph
125
-
126
- @graph_mutex.synchronize do
127
- @compiled_graph ||= build_graph.compile
128
- end
123
+ @actor.call { @compiled_graph ||= build_workflow }
129
124
  end
130
125
 
131
- def build_graph
126
+ def build_workflow
132
127
  draft_agent = @draft_agent_class.new
133
128
  review_agent = @review_agent_class.new
134
129
  threshold = @threshold
135
130
  max_iter = @max_iterations
131
+ pipeline = self
136
132
 
137
- graph = Phronomy::Graph::StateGraph.new(PipelineState)
133
+ Phronomy::Workflow.define(PipelineState) do
134
+ initial :draft
138
135
 
139
- graph.add_node(:draft) do |state|
140
- feedback = state.review_notes.last
141
- prompt = draft_prompt(state.input, feedback)
142
- result = draft_agent.invoke(prompt)
143
- parsed = safe_parse_draft(result[:output])
144
- state.merge(
145
- draft: parsed[:answer].to_s,
146
- self_score: clamp(parsed[:confidence]),
147
- citations: normalize_citations(parsed[:citations]),
148
- iteration: state.iteration + 1
149
- )
150
- end
136
+ state :draft, action: ->(state) {
137
+ feedback = state.review_notes.last
138
+ prompt = pipeline.__send__(:draft_prompt, state.input, feedback)
139
+ result = draft_agent.invoke(prompt)
140
+ parsed = pipeline.__send__(:safe_parse_draft, result[:output])
141
+ state.merge(
142
+ draft: parsed[:answer].to_s,
143
+ self_score: pipeline.__send__(:clamp, parsed[:confidence]),
144
+ citations: pipeline.__send__(:normalize_citations, parsed[:citations]),
145
+ iteration: state.iteration + 1
146
+ )
147
+ }
151
148
 
152
- graph.add_node(:review) do |state|
153
- prompt = review_prompt(state.input, state.draft, state.citations)
154
- result = review_agent.invoke(prompt)
155
- parsed = safe_parse_review(result[:output])
156
- state.merge(
157
- review_score: clamp(parsed[:score]),
158
- approved: parsed[:approved] == true,
159
- review_notes: parsed[:feedback].to_s
160
- )
161
- end
149
+ state :review, action: ->(state) {
150
+ prompt = pipeline.__send__(:review_prompt, state.input, state.draft, state.citations)
151
+ result = review_agent.invoke(prompt)
152
+ parsed = pipeline.__send__(:safe_parse_review, result[:output])
153
+ state.merge(
154
+ review_score: pipeline.__send__(:clamp, parsed[:score]),
155
+ approved: parsed[:approved] == true,
156
+ review_notes: parsed[:feedback].to_s
157
+ )
158
+ }
162
159
 
163
- graph.add_node(:finalize) do |state|
164
- state.merge(output: state.draft)
165
- end
160
+ state :finalize, action: ->(state) { state.merge(output: state.draft) }
166
161
 
167
- graph.set_entry_point(:draft)
168
- graph.add_edge(:draft, :review)
169
- graph.add_conditional_edges(
170
- :review,
171
- ->(state) {
172
- confidence = [state.self_score || 0.0, state.review_score || 0.0].min
173
- passed = confidence >= threshold && state.approved
174
- exhausted = state.iteration >= max_iter
175
- (passed || exhausted) ? :finalize : :draft
176
- }
177
- )
178
- graph.add_edge(:finalize, Phronomy::Graph::StateGraph::FINISH)
162
+ after :draft, to: :review
163
+ after :finalize, to: :__finish__
179
164
 
180
- graph
165
+ event :route_review, from: :review,
166
+ guard: ->(state) {
167
+ confidence = [state.self_score || 0.0, state.review_score || 0.0].min
168
+ (confidence >= threshold && state.approved) || state.iteration >= max_iter
169
+ },
170
+ to: :finalize
171
+ event :route_review, from: :review, to: :draft
172
+ end
181
173
  end
182
174
 
183
175
  # Wraps +input+ with the configured delimiter pair when +input_delimiter+ is set.