phronomy 0.1.3 → 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.
Files changed (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +56 -0
  3. data/README.md +49 -38
  4. data/docs/trustworthy_ai_enhancements.md +4 -4
  5. data/lib/generators/phronomy/install/templates/create_phronomy_messages.rb.tt +1 -1
  6. data/lib/phronomy/actor.rb +68 -0
  7. data/lib/phronomy/agent/base.rb +125 -91
  8. data/lib/phronomy/agent/handoff.rb +2 -2
  9. data/lib/phronomy/agent/react_agent.rb +51 -33
  10. data/lib/phronomy/context/assembler.rb +11 -3
  11. data/lib/phronomy/context/compaction_context.rb +1 -3
  12. data/lib/phronomy/context/context_version_cache.rb +7 -16
  13. data/lib/phronomy/eval/runner.rb +39 -11
  14. data/lib/phronomy/guardrail/builtin/pii_pattern_detector.rb +47 -3
  15. data/lib/phronomy/memory/compression/summary.rb +4 -3
  16. data/lib/phronomy/memory/compression/tool_output_pruner.rb +11 -6
  17. data/lib/phronomy/memory/conversation_manager.rb +25 -16
  18. data/lib/phronomy/memory/retrieval/semantic.rb +21 -5
  19. data/lib/phronomy/memory/storage/active_record.rb +32 -10
  20. data/lib/phronomy/memory/storage/base.rb +22 -0
  21. data/lib/phronomy/memory/storage/in_memory.rb +65 -26
  22. data/lib/phronomy/state_store/active_record.rb +1 -1
  23. data/lib/phronomy/state_store/base.rb +14 -16
  24. data/lib/phronomy/state_store/in_memory.rb +23 -9
  25. data/lib/phronomy/state_store/redis.rb +1 -1
  26. data/lib/phronomy/thread_actor_registry.rb +52 -0
  27. data/lib/phronomy/tool/base.rb +9 -2
  28. data/lib/phronomy/tool/mcp_tool.rb +28 -4
  29. data/lib/phronomy/tracing/base.rb +0 -2
  30. data/lib/phronomy/tracing/langfuse_tracer.rb +24 -6
  31. data/lib/phronomy/tracing/null_tracer.rb +6 -3
  32. data/lib/phronomy/trust_pipeline.rb +60 -52
  33. data/lib/phronomy/vector_store/redis_search.rb +28 -23
  34. data/lib/phronomy/version.rb +1 -1
  35. data/lib/phronomy/workflow.rb +281 -0
  36. data/lib/phronomy/workflow_context.rb +119 -0
  37. data/lib/phronomy/workflow_runner.rb +262 -0
  38. data/lib/phronomy.rb +30 -34
  39. metadata +25 -10
  40. data/lib/phronomy/graph/compiled_graph.rb +0 -183
  41. data/lib/phronomy/graph/parallel_node.rb +0 -193
  42. data/lib/phronomy/graph/state.rb +0 -105
  43. data/lib/phronomy/graph/state_graph.rb +0 -148
  44. 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: -> {}
@@ -75,14 +75,21 @@ module Phronomy
75
75
  # @param review_agent [Class] subclass of Phronomy::Agent::Base
76
76
  # @param confidence_threshold [Float] answers below this are retried (default: 0.7)
77
77
  # @param max_iterations [Integer] maximum draft-review cycles (default: 3)
78
+ # @param input_delimiter [Array<String>, nil] optional two-element array
79
+ # [start_tag, end_tag] used to wrap user input in prompts, e.g.
80
+ # ["<user_input>", "</user_input>"] or
81
+ # ["=== user input start ===", "=== user input end ==="].
82
+ # When nil (default), input is embedded as-is for backward compatibility.
78
83
  def initialize(draft_agent:, review_agent:,
79
84
  confidence_threshold: DEFAULT_CONFIDENCE_THRESHOLD,
80
- max_iterations: DEFAULT_MAX_ITERATIONS)
85
+ max_iterations: DEFAULT_MAX_ITERATIONS,
86
+ input_delimiter: nil)
81
87
  @draft_agent_class = draft_agent
82
88
  @review_agent_class = review_agent
83
89
  @threshold = confidence_threshold.to_f
84
90
  @max_iterations = max_iterations.to_i
85
- @graph_mutex = Mutex.new
91
+ @input_delimiter = input_delimiter
92
+ @actor = Phronomy::Actor.new
86
93
  @compiled_graph = nil
87
94
  end
88
95
 
@@ -111,66 +118,67 @@ module Phronomy
111
118
  [(state.self_score || 0.0).to_f, (state.review_score || 0.0).to_f].min
112
119
  end
113
120
 
114
- # Returns the compiled graph, building and caching it on first call.
115
- # Thread-safe via double-checked locking.
121
+ # Returns the compiled workflow, building and caching it on first call.
116
122
  def compiled_graph
117
- return @compiled_graph if @compiled_graph
118
-
119
- @graph_mutex.synchronize do
120
- @compiled_graph ||= build_graph.compile
121
- end
123
+ @actor.call { @compiled_graph ||= build_workflow }
122
124
  end
123
125
 
124
- def build_graph
126
+ def build_workflow
125
127
  draft_agent = @draft_agent_class.new
126
128
  review_agent = @review_agent_class.new
127
129
  threshold = @threshold
128
130
  max_iter = @max_iterations
131
+ pipeline = self
129
132
 
130
- graph = Phronomy::Graph::StateGraph.new(PipelineState)
131
-
132
- graph.add_node(:draft) do |state|
133
- feedback = state.review_notes.last
134
- prompt = draft_prompt(state.input, feedback)
135
- result = draft_agent.invoke(prompt)
136
- parsed = safe_parse_draft(result[:output])
137
- state.merge(
138
- draft: parsed[:answer].to_s,
139
- self_score: clamp(parsed[:confidence]),
140
- citations: normalize_citations(parsed[:citations]),
141
- iteration: state.iteration + 1
142
- )
143
- end
133
+ Phronomy::Workflow.define(PipelineState) do
134
+ initial :draft
144
135
 
145
- graph.add_node(:review) do |state|
146
- prompt = review_prompt(state.input, state.draft, state.citations)
147
- result = review_agent.invoke(prompt)
148
- parsed = safe_parse_review(result[:output])
149
- state.merge(
150
- review_score: clamp(parsed[:score]),
151
- approved: parsed[:approved] == true,
152
- review_notes: parsed[:feedback].to_s
153
- )
154
- 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
+ }
148
+
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
+ }
159
+
160
+ state :finalize, action: ->(state) { state.merge(output: state.draft) }
161
+
162
+ after :draft, to: :review
163
+ after :finalize, to: :__finish__
155
164
 
156
- graph.add_node(:finalize) do |state|
157
- state.merge(output: state.draft)
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
158
172
  end
173
+ end
159
174
 
160
- graph.set_entry_point(:draft)
161
- graph.add_edge(:draft, :review)
162
- graph.add_conditional_edges(
163
- :review,
164
- ->(state) {
165
- confidence = [state.self_score || 0.0, state.review_score || 0.0].min
166
- passed = confidence >= threshold && state.approved
167
- exhausted = state.iteration >= max_iter
168
- (passed || exhausted) ? :finalize : :draft
169
- }
170
- )
171
- graph.add_edge(:finalize, Phronomy::Graph::StateGraph::FINISH)
175
+ # Wraps +input+ with the configured delimiter pair when +input_delimiter+ is set.
176
+ # When no delimiter is configured the input is returned unchanged.
177
+ def wrap_input(input)
178
+ return input unless @input_delimiter
172
179
 
173
- graph
180
+ start_tag, end_tag = @input_delimiter
181
+ "#{start_tag}\n#{input}\n#{end_tag}"
174
182
  end
175
183
 
176
184
  # Builds the prompt sent to the DraftAgent for each iteration.
@@ -186,7 +194,7 @@ module Phronomy
186
194
  end
187
195
  lines += [
188
196
  "",
189
- "Question: #{input}",
197
+ "Question: #{wrap_input(input)}",
190
198
  "",
191
199
  "RESPOND ONLY WITH VALID JSON (no text outside the JSON block):",
192
200
  '{"answer":"<full answer>","confidence":<0.0-1.0>,' \
@@ -205,7 +213,7 @@ module Phronomy
205
213
  [
206
214
  "You are a rigorous quality reviewer. Evaluate the draft answer below.",
207
215
  "",
208
- "Question: #{input}",
216
+ "Question: #{wrap_input(input)}",
209
217
  "",
210
218
  "Draft answer:",
211
219
  draft.to_s,
@@ -38,6 +38,7 @@ module Phronomy
38
38
  @index_name = index_name
39
39
  @dimension = dimension
40
40
  @index_created = false
41
+ @actor = Phronomy::Actor.new
41
42
  end
42
43
 
43
44
  # @param id [String]
@@ -79,37 +80,41 @@ module Phronomy
79
80
  end
80
81
 
81
82
  def clear
82
- begin
83
- @redis.call("FT.DROPINDEX", @index_name, "DD")
84
- rescue => e
85
- raise unless e.message.to_s.include?("Unknown Index name")
83
+ @actor.call do
84
+ begin
85
+ @redis.call("FT.DROPINDEX", @index_name, "DD")
86
+ rescue => e
87
+ raise unless e.message.to_s.include?("Unknown Index name")
88
+ end
89
+ @index_created = false
86
90
  end
87
- @index_created = false
88
91
  self
89
92
  end
90
93
 
91
94
  private
92
95
 
93
96
  def ensure_index!(dim)
94
- return if @index_created
95
-
96
- @dimension ||= dim
97
- begin
98
- @redis.call(
99
- "FT.CREATE", @index_name,
100
- "ON", "HASH",
101
- "PREFIX", 1, DOC_PREFIX,
102
- "SCHEMA",
103
- "embedding", "VECTOR", "FLAT", 6,
104
- "TYPE", "FLOAT32",
105
- "DIM", @dimension,
106
- "DISTANCE_METRIC", "COSINE",
107
- "metadata", "TEXT"
108
- )
109
- rescue => e
110
- raise unless e.message.to_s.include?("Index already exists")
97
+ @actor.call do
98
+ next if @index_created
99
+
100
+ @dimension ||= dim
101
+ begin
102
+ @redis.call(
103
+ "FT.CREATE", @index_name,
104
+ "ON", "HASH",
105
+ "PREFIX", 1, DOC_PREFIX,
106
+ "SCHEMA",
107
+ "embedding", "VECTOR", "FLAT", 6,
108
+ "TYPE", "FLOAT32",
109
+ "DIM", @dimension,
110
+ "DISTANCE_METRIC", "COSINE",
111
+ "metadata", "TEXT"
112
+ )
113
+ rescue => e
114
+ raise unless e.message.to_s.include?("Index already exists")
115
+ end
116
+ @index_created = true
111
117
  end
112
- @index_created = true
113
118
  end
114
119
 
115
120
  # Pack a Float array as a FLOAT32 binary string for RediSearch.
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.1.3"
4
+ VERSION = "0.2.0"
5
5
  end
@@ -0,0 +1,281 @@
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
+ # @yield block evaluated in DSL context
57
+ # @return [Phronomy::Workflow] compiled and ready-to-run workflow instance
58
+ def self.define(context_class, &block)
59
+ builder = Builder.new(context_class)
60
+ builder.instance_eval(&block)
61
+ builder.build
62
+ end
63
+
64
+ # @param runner [Phronomy::WorkflowRunner]
65
+ def initialize(runner)
66
+ @runner = runner
67
+ end
68
+
69
+ # Executes the workflow from the initial state.
70
+ # @param input [Hash] initial context field values
71
+ # @param config [Hash] { thread_id:, recursion_limit:, user_id:, session_id: }
72
+ # @return [Object] final context
73
+ def invoke(input, config: {})
74
+ @runner.invoke(input, config: config)
75
+ end
76
+
77
+ # Resumes a halted workflow. Generic resume that works for all halt types.
78
+ # @param state [Object] halted context
79
+ # @param input [Hash, nil] optional field updates to merge before resuming
80
+ # @return [Object] final context
81
+ def resume(state:, input: nil)
82
+ @runner.resume(state: state, input: input)
83
+ end
84
+
85
+ # Fires a named event to advance a halted workflow.
86
+ # @param state [Object] halted context
87
+ # @param event [Symbol] event name (e.g. :approve, :reject, :resume)
88
+ # @param input [Hash, nil] optional field updates to merge before resuming
89
+ # @return [Object] final context
90
+ def send_event(state:, event:, input: nil)
91
+ @runner.send_event(state: state, event: event, input: input)
92
+ end
93
+
94
+ # Streaming execution. Yields { node: Symbol, state: Object } after each node.
95
+ # @param input [Hash]
96
+ # @param config [Hash]
97
+ # @yield [Hash]
98
+ # @return [Object] final context
99
+ def stream(input, config: {}, &block)
100
+ @runner.stream(input, config: config, &block)
101
+ end
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # Internal DSL builder
105
+ # ---------------------------------------------------------------------------
106
+
107
+ # DSL builder for Phronomy::Workflow.define.
108
+ # Collects state/event/transition declarations and produces a WorkflowRunner.
109
+ class Builder
110
+ FINISH = Phronomy::WorkflowRunner::FINISH
111
+
112
+ def initialize(context_class)
113
+ @context_class = context_class
114
+ @initial = nil
115
+ # { node_name => callable }
116
+ @states = {}
117
+ # Array of { from:, to: } — auto-transitions after a state action
118
+ @after_transitions = []
119
+ # Array of { name:, from:, to:, guard: } — event-driven transitions
120
+ @event_transitions = []
121
+ # Set of wait state names
122
+ @wait_state_names = []
123
+ end
124
+
125
+ # Declares the initial (entry) state.
126
+ # @param state_name [Symbol]
127
+ # rubocop:disable Style/TrivialAccessors
128
+ def initial(state_name)
129
+ @initial = state_name
130
+ end
131
+ # rubocop:enable Style/TrivialAccessors
132
+
133
+ # Declares an action state.
134
+ # @param name [Symbol] state name
135
+ # @param action [#call, nil] callable invoked when entering the state.
136
+ # If nil, the state is treated as a no-op pass-through.
137
+ def state(name, action: nil)
138
+ @states[name] = action || ->(s) { s }
139
+ end
140
+
141
+ # Declares a wait state that automatically halts execution when reached.
142
+ # No action is registered; the workflow pauses here until an event resumes it.
143
+ # @param name [Symbol] wait state name (conventionally :awaiting_something)
144
+ def wait_state(name)
145
+ @wait_state_names << name
146
+ end
147
+
148
+ # Declares an automatic transition that fires after a state's action completes.
149
+ # @param from [Symbol] source state name
150
+ # @param to [Symbol] destination state name or :__finish__
151
+ def after(from, to:)
152
+ dest = (to == :__finish__) ? FINISH : to
153
+ @after_transitions << {from: from, to: dest}
154
+ end
155
+
156
+ # Declares an event-driven transition.
157
+ # When +guard:+ is provided, the transition is taken only if the guard
158
+ # returns truthy for the current context. Multiple events with the same
159
+ # name and source are evaluated in declaration order; the first passing
160
+ # guard wins.
161
+ # @param name [Symbol] event name
162
+ # @param from [Symbol] source state where this event can be fired
163
+ # @param to [Symbol] destination state or :__finish__
164
+ # @param guard [Proc, nil] optional guard — receives context, returns truthy/falsy
165
+ def event(name, from:, to:, guard: nil)
166
+ dest = (to == :__finish__) ? FINISH : to
167
+ @event_transitions << {name: name, from: from, to: dest, guard: guard}
168
+ end
169
+
170
+ # Builds and returns a Phronomy::Workflow backed by a WorkflowRunner.
171
+ def build
172
+ nodes = @states.dup
173
+ edges = build_edges
174
+ conditional_edges = build_conditional_edges
175
+ wait_states = build_wait_states
176
+
177
+ runner = Phronomy::WorkflowRunner.new(
178
+ state_class: @context_class,
179
+ nodes: nodes,
180
+ edges: edges,
181
+ conditional_edges: conditional_edges,
182
+ entry_point: @initial || nodes.keys.first,
183
+ wait_states: wait_states
184
+ )
185
+
186
+ Workflow.new(runner)
187
+ end
188
+
189
+ private
190
+
191
+ # Converts @after_transitions and non-guarded @event_transitions into
192
+ # the edges hash expected by WorkflowRunner: { from => [{to:, condition:}] }
193
+ #
194
+ # Event transitions whose from-node also has guarded transitions are omitted
195
+ # here; they are handled inside build_conditional_edges as a fallback.
196
+ def build_edges
197
+ edges = {}
198
+
199
+ # After-transitions (unconditional edges fired after action completes)
200
+ @after_transitions.each do |t|
201
+ edges[t[:from]] ||= []
202
+ edges[t[:from]] << {to: t[:to], condition: nil}
203
+ end
204
+
205
+ # Collect from-nodes that already have at least one guarded event
206
+ from_with_guards = @event_transitions.select { |t| t[:guard] }.map { |t| t[:from] }.to_set
207
+
208
+ # Unconditional event transitions are plain edges ONLY when no guarded
209
+ # event exists from the same source node. When guards are present the
210
+ # unguarded transition acts as a fallback and is wired inside
211
+ # build_conditional_edges instead.
212
+ @event_transitions.reject { |t| t[:guard] }.each do |t|
213
+ next if from_with_guards.include?(t[:from])
214
+ edges[t[:from]] ||= []
215
+ edges[t[:from]] << {to: t[:to], condition: nil}
216
+ end
217
+
218
+ edges
219
+ end
220
+
221
+ # Converts guarded event transitions into the conditional_edges hash:
222
+ # { from => { condition: Proc, mapping: nil } }
223
+ #
224
+ # Multiple guarded transitions from the same source are combined into a
225
+ # single routing proc. An unguarded transition from the same source is
226
+ # used as an automatic fallback when all guards fail.
227
+ def build_conditional_edges
228
+ conditional_edges = {}
229
+
230
+ guarded = @event_transitions.select { |t| t[:guard] }
231
+ guarded.group_by { |t| t[:from] }.each do |from, transitions|
232
+ # Unguarded fallback for this from-node (may be nil)
233
+ fallback = @event_transitions.find { |t| t[:from] == from && t[:guard].nil? }
234
+
235
+ routing = lambda do |state|
236
+ matched = transitions.find { |t| t[:guard].call(state) }
237
+ next matched[:to] if matched
238
+ fallback&.fetch(:to)
239
+ end
240
+ conditional_edges[from] = {condition: routing, mapping: nil}
241
+ end
242
+
243
+ conditional_edges
244
+ end
245
+
246
+ # Converts wait_state declarations plus event-driven transitions *to*
247
+ # wait states into the wait_states hash:
248
+ # { wait_state_name => { resume_event: Symbol, resume_to: Symbol } }
249
+ #
250
+ # For each wait state, we look for the first event declared as
251
+ # `event :X, from: :wait_state_name, to: :Y` and use that as the
252
+ # resume_event / resume_to pair. If multiple events exist for the same
253
+ # wait state, subsequent ones are registered as additional named events.
254
+ def build_wait_states
255
+ wait_states = {}
256
+
257
+ @wait_state_names.each do |ws|
258
+ # Find events that originate from this wait state
259
+ outgoing = @event_transitions.select { |t| t[:from] == ws }
260
+ primary = outgoing.first
261
+
262
+ wait_states[ws] = {
263
+ resume_event: primary&.fetch(:name),
264
+ resume_to: primary&.fetch(:to)
265
+ }
266
+
267
+ # Additional events from the same wait state are also registered so
268
+ # that send_event(:other_event) works for branching wait states.
269
+ outgoing.drop(1).each do |t|
270
+ wait_states[:"#{ws}__#{t[:name]}"] = {
271
+ resume_event: t[:name],
272
+ resume_to: t[:to]
273
+ }
274
+ end
275
+ end
276
+
277
+ wait_states
278
+ end
279
+ end
280
+ end
281
+ 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