phronomy 0.1.4 → 0.2.1

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.
Files changed (35) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +89 -0
  3. data/README.md +49 -38
  4. data/docs/trustworthy_ai_enhancements.md +4 -4
  5. data/lib/phronomy/actor.rb +68 -0
  6. data/lib/phronomy/agent/base.rb +80 -52
  7. data/lib/phronomy/context/context_version_cache.rb +10 -33
  8. data/lib/phronomy/memory/conversation_manager.rb +9 -38
  9. data/lib/phronomy/memory/retrieval/semantic.rb +7 -7
  10. data/lib/phronomy/memory/storage/active_record.rb +20 -0
  11. data/lib/phronomy/memory/storage/base.rb +22 -0
  12. data/lib/phronomy/memory/storage/in_memory.rb +65 -26
  13. data/lib/phronomy/state_store/active_record.rb +1 -1
  14. data/lib/phronomy/state_store/base.rb +14 -16
  15. data/lib/phronomy/state_store/file.rb +85 -0
  16. data/lib/phronomy/state_store/in_memory.rb +23 -10
  17. data/lib/phronomy/state_store/redis.rb +1 -1
  18. data/lib/phronomy/thread_actor_registry.rb +52 -0
  19. data/lib/phronomy/tool/base.rb +1 -1
  20. data/lib/phronomy/tool/mcp_tool.rb +10 -9
  21. data/lib/phronomy/tracing/langfuse_tracer.rb +3 -3
  22. data/lib/phronomy/trust_pipeline.rb +41 -49
  23. data/lib/phronomy/vector_store/in_memory.rb +5 -7
  24. data/lib/phronomy/vector_store/redis_search.rb +4 -6
  25. data/lib/phronomy/version.rb +1 -1
  26. data/lib/phronomy/workflow.rb +221 -0
  27. data/lib/phronomy/workflow_context.rb +119 -0
  28. data/lib/phronomy/workflow_runner.rb +285 -0
  29. data/lib/phronomy.rb +30 -34
  30. metadata +26 -10
  31. data/lib/phronomy/graph/compiled_graph.rb +0 -191
  32. data/lib/phronomy/graph/parallel_node.rb +0 -193
  33. data/lib/phronomy/graph/state.rb +0 -105
  34. data/lib/phronomy/graph/state_graph.rb +0 -149
  35. data/lib/phronomy/graph.rb +0 -13
@@ -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.
@@ -14,14 +14,13 @@ module Phronomy
14
14
  class InMemory < Base
15
15
  def initialize
16
16
  @documents = {}
17
- @mutex = Mutex.new
18
17
  end
19
18
 
20
19
  # @param id [String]
21
20
  # @param embedding [Array<Float>]
22
21
  # @param metadata [Hash]
23
22
  def add(id:, embedding:, metadata: {})
24
- @mutex.synchronize { @documents[id] = {embedding: embedding, metadata: metadata} }
23
+ @documents[id] = {embedding: embedding, metadata: metadata}
25
24
  self
26
25
  end
27
26
 
@@ -29,8 +28,7 @@ module Phronomy
29
28
  # @param k [Integer]
30
29
  # @return [Array<Hash>] sorted by descending score
31
30
  def search(query_embedding:, k: 5)
32
- snapshot = @mutex.synchronize { @documents.dup }
33
- results = snapshot.map do |id, doc|
31
+ results = @documents.map do |id, doc|
34
32
  score = cosine_similarity(query_embedding, doc[:embedding])
35
33
  {id: id, score: score, metadata: doc[:metadata]}
36
34
  end
@@ -38,18 +36,18 @@ module Phronomy
38
36
  end
39
37
 
40
38
  def remove(id:)
41
- @mutex.synchronize { @documents.delete(id) }
39
+ @documents.delete(id)
42
40
  self
43
41
  end
44
42
 
45
43
  def clear
46
- @mutex.synchronize { @documents.clear }
44
+ @documents.clear
47
45
  self
48
46
  end
49
47
 
50
48
  # @return [Integer] number of documents stored
51
49
  def size
52
- @mutex.synchronize { @documents.size }
50
+ @documents.size
53
51
  end
54
52
 
55
53
  private
@@ -38,7 +38,7 @@ module Phronomy
38
38
  @index_name = index_name
39
39
  @dimension = dimension
40
40
  @index_created = false
41
- @mutex = Mutex.new
41
+ @actor = Phronomy::Actor.new
42
42
  end
43
43
 
44
44
  # @param id [String]
@@ -80,7 +80,7 @@ module Phronomy
80
80
  end
81
81
 
82
82
  def clear
83
- @mutex.synchronize do
83
+ @actor.call do
84
84
  begin
85
85
  @redis.call("FT.DROPINDEX", @index_name, "DD")
86
86
  rescue => e
@@ -94,10 +94,8 @@ module Phronomy
94
94
  private
95
95
 
96
96
  def ensure_index!(dim)
97
- return if @index_created # fast path outside lock
98
-
99
- @mutex.synchronize do
100
- return if @index_created # re-check inside lock
97
+ @actor.call do
98
+ next if @index_created
101
99
 
102
100
  @dimension ||= dim
103
101
  begin
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.1.4"
4
+ VERSION = "0.2.1"
5
5
  end
@@ -0,0 +1,221 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "workflow_runner"
4
+ require_relative "runnable"
5
+
6
+ module Phronomy
7
+ # StateChart-style workflow definition DSL.
8
+ #
9
+ # Defines agent workflows in terms of *states* and *events* backed by
10
+ # Phronomy::WorkflowRunner. This is the primary high-level API
11
+ # for graph-based execution in phronomy.
12
+ #
13
+ # == Basic usage
14
+ #
15
+ # app = Phronomy::Workflow.define(MyContext) do
16
+ # initial :fetch
17
+ #
18
+ # state :fetch, action: FETCH_NODE
19
+ # state :process, action: PROCESS_NODE
20
+ #
21
+ # after :fetch, to: :process
22
+ # after :process, to: :__finish__
23
+ # end
24
+ #
25
+ # result = app.invoke({ url: "https://example.com" })
26
+ #
27
+ # == Wait states
28
+ #
29
+ # app = Phronomy::Workflow.define(MyContext) do
30
+ # initial :propose
31
+ #
32
+ # state :propose, action: PROPOSE_NODE
33
+ # wait_state :awaiting_approval
34
+ # state :execute, action: EXECUTE_NODE
35
+ #
36
+ # after :propose, to: :awaiting_approval
37
+ # after :execute, to: :__finish__
38
+ #
39
+ # event :approve, from: :awaiting_approval, to: :execute
40
+ # event :reject, from: :awaiting_approval, to: :propose
41
+ # end
42
+ #
43
+ # halted = app.invoke({ ... })
44
+ # final = app.send_event(state: halted, event: :approve)
45
+ #
46
+ # == Conditional transitions
47
+ #
48
+ # event :route, from: :decide, guard: ->(s) { s.score > 5 }, to: :high
49
+ # event :route, from: :decide, to: :low # fallback (no guard)
50
+ #
51
+ class Workflow
52
+ include Phronomy::Runnable
53
+
54
+ # Defines a new Workflow.
55
+ # @param context_class [Class] class that includes Phronomy::WorkflowContext
56
+ # @param state_store [Object, nil] optional state store override (passed to WorkflowRunner)
57
+ # @yield block evaluated in DSL context
58
+ # @return [Phronomy::Workflow] compiled and ready-to-run workflow instance
59
+ def self.define(context_class, state_store: nil, &block)
60
+ builder = Builder.new(context_class, state_store: state_store)
61
+ builder.instance_eval(&block)
62
+ builder.build
63
+ end
64
+
65
+ # @param runner [Phronomy::WorkflowRunner]
66
+ def initialize(runner)
67
+ @runner = runner
68
+ end
69
+
70
+ # Executes the workflow from the initial state.
71
+ # @param input [Hash] initial context field values
72
+ # @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id: }
73
+ # @return [Object] final context
74
+ def invoke(input, config: {})
75
+ @runner.invoke(input, config: config)
76
+ end
77
+
78
+ # Resumes a halted workflow. Generic resume that works for all halt types.
79
+ # @param state [Object] halted context
80
+ # @param input [Hash, nil] optional field updates to merge before resuming
81
+ # @return [Object] final context
82
+ def resume(state:, input: nil)
83
+ @runner.resume(state: state, input: input)
84
+ end
85
+
86
+ # Fires a named event to advance a halted workflow.
87
+ # @param state [Object] halted context
88
+ # @param event [Symbol] event name (e.g. :approve, :reject, :resume)
89
+ # @param input [Hash, nil] optional field updates to merge before resuming
90
+ # @return [Object] final context
91
+ def send_event(state:, event:, input: nil)
92
+ @runner.send_event(state: state, event: event, input: input)
93
+ end
94
+
95
+ # Streaming execution. Yields { node: Symbol, state: Object } after each node.
96
+ # @param input [Hash]
97
+ # @param config [Hash]
98
+ # @yield [Hash]
99
+ # @return [Object] final context
100
+ def stream(input, config: {}, &block)
101
+ @runner.stream(input, config: config, &block)
102
+ end
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # Internal DSL builder
106
+ # ---------------------------------------------------------------------------
107
+
108
+ # DSL builder for Phronomy::Workflow.define.
109
+ # Collects state/event/transition declarations and produces a WorkflowRunner.
110
+ class Builder
111
+ FINISH = Phronomy::WorkflowRunner::FINISH
112
+
113
+ def initialize(context_class, state_store: nil)
114
+ @context_class = context_class
115
+ @state_store = state_store
116
+ @initial = nil
117
+ # { node_name => callable }
118
+ @states = {}
119
+ # Array of { from:, to: } — auto-transitions after a state action
120
+ @after_transitions = []
121
+ # Array of { name:, from:, to:, guard: } — event-driven transitions
122
+ @event_transitions = []
123
+ # Set of wait state names
124
+ @wait_state_names = []
125
+ end
126
+
127
+ # Declares the initial (entry) state.
128
+ # @param state_name [Symbol]
129
+ # rubocop:disable Style/TrivialAccessors
130
+ def initial(state_name)
131
+ @initial = state_name
132
+ end
133
+ # rubocop:enable Style/TrivialAccessors
134
+
135
+ # Declares an action state.
136
+ # @param name [Symbol] state name
137
+ # @param action [#call, nil] callable invoked when entering the state.
138
+ # If nil, the state is treated as a no-op pass-through.
139
+ def state(name, action: nil)
140
+ @states[name] = action || ->(s) { s }
141
+ end
142
+
143
+ # Declares a wait state that automatically halts execution when reached.
144
+ # No action is registered; the workflow pauses here until an event resumes it.
145
+ # @param name [Symbol] wait state name (conventionally :awaiting_something)
146
+ def wait_state(name)
147
+ @wait_state_names << name
148
+ end
149
+
150
+ # Declares an automatic transition that fires after a state's action completes.
151
+ # @param from [Symbol] source state name
152
+ # @param to [Symbol] destination state name or :__finish__
153
+ def after(from, to:)
154
+ dest = (to == :__finish__) ? FINISH : to
155
+ @after_transitions << {from: from, to: dest}
156
+ end
157
+
158
+ # Declares an event-driven transition.
159
+ # When +guard:+ is provided, the transition is taken only if the guard
160
+ # returns truthy for the current context. Multiple events with the same
161
+ # name and source are evaluated in declaration order; the first passing
162
+ # guard wins.
163
+ # @param name [Symbol] event name
164
+ # @param from [Symbol] source state where this event can be fired
165
+ # @param to [Symbol] destination state or :__finish__
166
+ # @param guard [Proc, nil] optional guard — receives context, returns truthy/falsy
167
+ def event(name, from:, to:, guard: nil)
168
+ dest = (to == :__finish__) ? FINISH : to
169
+ @event_transitions << {name: name, from: from, to: dest, guard: guard}
170
+ end
171
+
172
+ # Builds and returns a Phronomy::Workflow backed by a WorkflowRunner.
173
+ def build
174
+ nodes = @states.dup
175
+
176
+ # After-transitions: { from => to }
177
+ # Unconditional transitions that fire automatically after an action state completes.
178
+ after_transitions = @after_transitions.each_with_object({}) do |t, h|
179
+ h[t[:from]] = t[:to]
180
+ end
181
+
182
+ # Route transitions: { from => {event_name:, entries: [{guard:, to:}, ...]} }
183
+ # Events declared from action states (not wait states) fire automatically
184
+ # after the action completes. The event name is used to register the
185
+ # state_machines event and may be any symbol (e.g. :route, :route_review).
186
+ # Declaration order is preserved so guarded entries appear before fallbacks.
187
+ route_transitions = {}
188
+
189
+ # External events: { event_name => [{from:, to:, guard:}, ...] }
190
+ # Events declared from wait states, triggered by human input (e.g. :approve).
191
+ external_events = {}
192
+
193
+ @event_transitions.each do |t|
194
+ if @wait_state_names.include?(t[:from])
195
+ # Source is a wait state → external event
196
+ external_events[t[:name]] ||= []
197
+ external_events[t[:name]] << {from: t[:from], to: t[:to], guard: t[:guard]}
198
+ else
199
+ # Source is an action state → routing event (auto-fires after action)
200
+ # The event name is taken from the first declaration for each from-state.
201
+ route_transitions[t[:from]] ||= {event_name: t[:name], entries: []}
202
+ route_transitions[t[:from]][:entries] << {guard: t[:guard], to: t[:to]}
203
+ end
204
+ end
205
+
206
+ runner = Phronomy::WorkflowRunner.new(
207
+ state_class: @context_class,
208
+ nodes: nodes,
209
+ after_transitions: after_transitions,
210
+ route_transitions: route_transitions,
211
+ external_events: external_events,
212
+ entry_point: @initial || nodes.keys.first,
213
+ wait_state_names: @wait_state_names,
214
+ state_store: @state_store
215
+ )
216
+
217
+ Workflow.new(runner)
218
+ end
219
+ end
220
+ end
221
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ # Module for defining workflow context (the data that travels through a workflow).
5
+ # Include in a class and use the field DSL to declare context fields.
6
+ #
7
+ # In StateChart terminology this is the "extended state" or "context" —
8
+ # data associated with the current execution that does not affect transitions
9
+ # directly, as opposed to the current phase (which is the machine's state).
10
+ #
11
+ # Field update policies:
12
+ # :replace (default) -- overwrites with the new value
13
+ # :append -- appends to an Array
14
+ # :merge -- deep-merges into a Hash
15
+ #
16
+ # @example
17
+ # class ScanContext
18
+ # include Phronomy::WorkflowContext
19
+ # field :messages, type: :append, default: -> { [] }
20
+ # field :query, type: :replace
21
+ # field :metadata, type: :merge, default: -> { {} }
22
+ # end
23
+ module WorkflowContext
24
+ def self.included(base)
25
+ base.extend(ClassMethods)
26
+ base.instance_variable_set(:@fields, {})
27
+ end
28
+
29
+ module ClassMethods
30
+ # Defines a context field.
31
+ # @param name [Symbol]
32
+ # @param type [Symbol] :replace / :append / :merge
33
+ # @param default [Object, Proc, nil]
34
+ def field(name, type: :replace, default: nil)
35
+ @fields[name] = {type: type, default: default}
36
+ attr_accessor name
37
+ end
38
+
39
+ def fields
40
+ @fields
41
+ end
42
+ end
43
+
44
+ # Internal workflow metadata accessors (not user-defined fields).
45
+ # These are preserved through merge but excluded from to_h.
46
+ attr_reader :thread_id
47
+
48
+ # Returns the current execution phase of the workflow.
49
+ # Encoding:
50
+ # :__end__ — workflow completed (or not yet started)
51
+ # :awaiting_<name> — halted at a wait_state(:awaiting_<name>) declaration
52
+ # :<node> — resuming at <node> (workflow paused before its execution)
53
+ # @return [Symbol]
54
+ def phase
55
+ @phase || :__end__
56
+ end
57
+
58
+ # Returns true if the workflow is paused mid-execution (not yet completed).
59
+ # @return [Boolean]
60
+ def halted?
61
+ phase != :__end__
62
+ end
63
+
64
+ # Sets internal workflow metadata. Returns self.
65
+ # @param thread_id [String, nil]
66
+ # @param phase [Symbol, nil]
67
+ def set_graph_metadata(thread_id: nil, phase: nil)
68
+ @thread_id = thread_id unless thread_id.nil?
69
+ @phase = phase unless phase.nil?
70
+ self
71
+ end
72
+
73
+ def initialize(**attrs)
74
+ self.class.fields.each do |name, config|
75
+ default = config[:default].is_a?(Proc) ? config[:default].call : config[:default]
76
+ send(:"#{name}=", attrs.fetch(name, default))
77
+ end
78
+ @thread_id = nil
79
+ @phase = :__end__
80
+ end
81
+
82
+ # Immutably updates context fields. Returns a new instance with the applied changes.
83
+ # Internal workflow metadata (thread_id, phase) is preserved.
84
+ # @param updates [Hash] { field_name => new_value }
85
+ # @return [self.class] new context instance
86
+ def merge(updates)
87
+ new_attrs = {}
88
+ self.class.fields.each_key do |name|
89
+ field_config = self.class.fields[name]
90
+ new_attrs[name] = if updates.key?(name)
91
+ case field_config[:type]
92
+ when :append
93
+ Array(send(name)) + Array(updates[name])
94
+ when :merge
95
+ (send(name) || {}).merge(updates[name])
96
+ else
97
+ updates[name]
98
+ end
99
+ else
100
+ send(name)
101
+ end
102
+ end
103
+ new_context = self.class.new(**new_attrs)
104
+ new_context.set_graph_metadata(
105
+ thread_id: @thread_id,
106
+ phase: @phase
107
+ )
108
+ new_context
109
+ end
110
+
111
+ # Converts user-defined fields to a Hash (excludes internal workflow metadata).
112
+ # @return [Hash]
113
+ def to_h
114
+ self.class.fields.keys.each_with_object({}) do |name, h|
115
+ h[name] = send(name)
116
+ end
117
+ end
118
+ end
119
+ end