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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +56 -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/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 +281 -0
- data/lib/phronomy/workflow_context.rb +119 -0
- data/lib/phronomy/workflow_runner.rb +262 -0
- data/lib/phronomy.rb +30 -34
- metadata +25 -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
|
@@ -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,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
|