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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +89 -0
- data/README.md +49 -38
- data/docs/trustworthy_ai_enhancements.md +4 -4
- data/lib/phronomy/actor.rb +68 -0
- data/lib/phronomy/agent/base.rb +80 -52
- data/lib/phronomy/context/context_version_cache.rb +10 -33
- data/lib/phronomy/memory/conversation_manager.rb +9 -38
- data/lib/phronomy/memory/retrieval/semantic.rb +7 -7
- data/lib/phronomy/memory/storage/active_record.rb +20 -0
- data/lib/phronomy/memory/storage/base.rb +22 -0
- data/lib/phronomy/memory/storage/in_memory.rb +65 -26
- data/lib/phronomy/state_store/active_record.rb +1 -1
- data/lib/phronomy/state_store/base.rb +14 -16
- data/lib/phronomy/state_store/file.rb +85 -0
- data/lib/phronomy/state_store/in_memory.rb +23 -10
- data/lib/phronomy/state_store/redis.rb +1 -1
- data/lib/phronomy/thread_actor_registry.rb +52 -0
- data/lib/phronomy/tool/base.rb +1 -1
- data/lib/phronomy/tool/mcp_tool.rb +10 -9
- data/lib/phronomy/tracing/langfuse_tracer.rb +3 -3
- data/lib/phronomy/trust_pipeline.rb +41 -49
- data/lib/phronomy/vector_store/in_memory.rb +5 -7
- data/lib/phronomy/vector_store/redis_search.rb +4 -6
- data/lib/phronomy/version.rb +1 -1
- data/lib/phronomy/workflow.rb +221 -0
- data/lib/phronomy/workflow_context.rb +119 -0
- data/lib/phronomy/workflow_runner.rb +285 -0
- data/lib/phronomy.rb +30 -34
- metadata +26 -10
- data/lib/phronomy/graph/compiled_graph.rb +0 -191
- data/lib/phronomy/graph/parallel_node.rb +0 -193
- data/lib/phronomy/graph/state.rb +0 -105
- data/lib/phronomy/graph/state_graph.rb +0 -149
- 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::
|
|
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
|
-
@
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
133
|
+
Phronomy::Workflow.define(PipelineState) do
|
|
134
|
+
initial :draft
|
|
138
135
|
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
164
|
-
state.merge(output: state.draft)
|
|
165
|
-
end
|
|
160
|
+
state :finalize, action: ->(state) { state.merge(output: state.draft) }
|
|
166
161
|
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
-
@
|
|
39
|
+
@documents.delete(id)
|
|
42
40
|
self
|
|
43
41
|
end
|
|
44
42
|
|
|
45
43
|
def clear
|
|
46
|
-
@
|
|
44
|
+
@documents.clear
|
|
47
45
|
self
|
|
48
46
|
end
|
|
49
47
|
|
|
50
48
|
# @return [Integer] number of documents stored
|
|
51
49
|
def size
|
|
52
|
-
@
|
|
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
|
-
@
|
|
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
|
-
@
|
|
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
|
-
|
|
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
|
data/lib/phronomy/version.rb
CHANGED
|
@@ -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
|