phronomy 0.14.0 → 0.15.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +65 -0
  3. data/README.md +236 -57
  4. data/benchmark/bench_agent_invoke.rb +2 -3
  5. data/docs/decisions/004-invoke-timeout-is-not-cancellation.md +14 -67
  6. data/docs/decisions/011-delegate-transport-policy-to-adapters.md +82 -0
  7. data/examples/workflows/agent_event_mapping.rb +104 -0
  8. data/examples/workflows/generic_task_event_mapping.rb +58 -0
  9. data/lib/phronomy/agent/agent_invocation.rb +385 -0
  10. data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
  11. data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
  12. data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
  13. data/lib/phronomy/agent/async_event_api.rb +471 -0
  14. data/lib/phronomy/agent/base.rb +500 -411
  15. data/lib/phronomy/agent/context/capability/base.rb +51 -119
  16. data/lib/phronomy/agent/llm_operation_result.rb +23 -0
  17. data/lib/phronomy/agent/phase_machine_builder.rb +75 -137
  18. data/lib/phronomy/agent/tool_approval_request.rb +121 -0
  19. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
  20. data/lib/phronomy/agent/tool_executor.rb +47 -69
  21. data/lib/phronomy/agent/tool_invocation.rb +634 -0
  22. data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
  23. data/lib/phronomy/agent.rb +21 -9
  24. data/lib/phronomy/configuration.rb +42 -6
  25. data/lib/phronomy/engine/event_loop.rb +269 -112
  26. data/lib/phronomy/engine/fsm_session.rb +180 -142
  27. data/lib/phronomy/engine/task.rb +5 -10
  28. data/lib/phronomy/event.rb +8 -8
  29. data/lib/phronomy/generator_verifier.rb +253 -142
  30. data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
  31. data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
  32. data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
  33. data/lib/phronomy/invocation_context.rb +5 -19
  34. data/lib/phronomy/llm_adapter/base.rb +25 -34
  35. data/lib/phronomy/metrics.rb +2 -0
  36. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
  37. data/lib/phronomy/stream_callback_error.rb +35 -0
  38. data/lib/phronomy/tools/mcp.rb +25 -0
  39. data/lib/phronomy/version.rb +1 -1
  40. data/lib/phronomy/workflow/phase_machine_builder.rb +129 -186
  41. data/lib/phronomy/workflow.rb +122 -261
  42. data/lib/phronomy/workflow_context.rb +54 -102
  43. data/lib/phronomy/workflow_runner.rb +238 -300
  44. data/lib/phronomy.rb +6 -4
  45. data/scripts/check_readme_runnable.rb +4 -1
  46. metadata +18 -7
  47. data/lib/phronomy/agent/concerns/retryable.rb +0 -103
  48. data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
  49. data/lib/phronomy/agent/invocation_context.rb +0 -171
  50. data/lib/phronomy/agent/invocation_session.rb +0 -352
  51. data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
@@ -1,25 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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.
4
+ # Module for defining Workflow context data.
6
5
  #
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).
6
+ # In StateChart terminology this is the extended state/context, as opposed to
7
+ # the current FSM phase.
10
8
  #
11
- # Field update policies:
12
- # :replace (default) -- overwrites with the new value
13
- # :append -- appends to an Array
14
- # :merge -- shallow-merges into a Hash (top-level keys are merged; nested objects are replaced)
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
9
+ # An application context may define +handle_fsm_event(event)+. FSMSession calls
10
+ # it on the EventLoop thread before evaluating a declared transition. The
11
+ # method may mutate the context and return +false+ to continue transition
12
+ # evaluation, return a replacement WorkflowContext, or return +:consume+ to
13
+ # discard a stale/unrelated event without firing a transition.
23
14
  module WorkflowContext
24
15
  def self.included(base)
25
16
  base.extend(ClassMethods)
@@ -27,12 +18,6 @@ module Phronomy
27
18
  end
28
19
 
29
20
  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
- # @raise [ArgumentError] if +default+ is a plain Array or Hash (use a Proc instead)
35
- # @api public
36
21
  def field(name, type: :replace, default: nil)
37
22
  if default.is_a?(Array) || default.is_a?(Hash)
38
23
  raise ArgumentError,
@@ -42,12 +27,8 @@ module Phronomy
42
27
  end
43
28
 
44
29
  @fields[name] = {type: type, default: default}
45
-
46
- # Define getter.
47
30
  attr_reader name
48
31
 
49
- # Define write-guarded setter. Mutation from outside the EventLoop
50
- # dispatch thread raises WorkflowContextOwnershipError in EventLoop mode.
51
32
  define_method(:"#{name}=") do |value|
52
33
  _assert_write_permitted!
53
34
  instance_variable_set(:"@#{name}", value)
@@ -59,86 +40,71 @@ module Phronomy
59
40
  end
60
41
  end
61
42
 
62
- # Internal workflow metadata accessors (not user-defined fields).
63
- # These are preserved through merge but excluded from to_h.
64
43
  attr_reader :thread_id
65
44
 
66
- # Returns the current execution phase of the workflow.
67
- # Encoding:
68
- # :__end__ — workflow completed (or not yet started)
69
- # :awaiting_<name> — halted at a wait_state(:awaiting_<name>) declaration
70
- # :<state> — resuming at <state> (workflow paused before its execution)
71
- # @return [Symbol]
72
- # @api public
73
- # mutant:disable - @phase is always non-nil (set to :__end__ in initialize, only changed by set_graph_metadata which never sets nil), so the || :__end__ fallback branch is never reached — all mutations of the right-hand side are genuine equivalents
74
45
  def phase
75
46
  @phase || :__end__
76
47
  end
77
48
 
78
- # Returns true if the workflow is paused mid-execution (not yet completed).
79
- # @return [Boolean]
80
- # @api public
81
- # mutant:disable - phase != :__end__ vs !phase.eql?(:__end__) vs !phase.equal?(:__end__) are genuine equivalents for Symbol (Symbols are interned so == / eql? / equal? all behave identically)
82
49
  def halted?
83
50
  phase != :__end__
84
51
  end
85
52
 
86
- # Sets internal workflow metadata. Returns self.
87
- # @param thread_id [String, nil]
88
- # @param phase [Symbol, nil]
89
- # @api public
90
- # mutant:disable - mutations replacing return value `self` with nil or removing the last line are genuine equivalents: callers chain on the return value only in merge which immediately discards it
91
53
  def set_graph_metadata(thread_id: nil, phase: nil)
92
54
  @thread_id = thread_id unless thread_id.nil?
93
55
  @phase = phase unless phase.nil?
94
56
  self
95
57
  end
96
58
 
97
- # mutant:disable - multiple genuine equivalent mutations: is_a?(Proc) vs instance_of?(Proc) (Proc has no subclasses in practice), config[]/fetch() for always-present :default key, @thread_id=nil removal (unset ivar is already nil), @phase=:__end__ → nil or removal (phase method returns :__end__ via @phase||:__end__ fallback), raise message #{.inspect} vs #{} (spec checks exception class not message text)
98
59
  def initialize(**attrs)
99
60
  unknown = attrs.keys - self.class.fields.keys
100
- raise ArgumentError, "Unknown WorkflowContext field(s): #{unknown.inspect}" unless unknown.empty?
61
+ unless unknown.empty?
62
+ raise ArgumentError,
63
+ "Unknown WorkflowContext field(s): #{unknown.inspect}"
64
+ end
101
65
 
102
66
  self.class.fields.each do |name, config|
103
- default = config[:default].is_a?(Proc) ? config[:default].call : config[:default]
104
- # Bypass the write guard in initialize — ownership enforcement begins
105
- # after construction is complete.
106
- instance_variable_set(:"@#{name}", attrs.fetch(name, default))
67
+ default =
68
+ if config[:default].is_a?(Proc)
69
+ config[:default].call
70
+ else
71
+ config[:default]
72
+ end
73
+ instance_variable_set(
74
+ :"@#{name}",
75
+ attrs.fetch(name, default)
76
+ )
107
77
  end
108
78
  @thread_id = nil
109
79
  @phase = :__end__
110
80
  end
111
81
 
112
- # Returns a new context instance with the specified field updates applied.
113
- # Updated fields follow the field's declared +:type+ semantics (:replace, :append,
114
- # or :merge). Unchanged fields are deep-copied on a best-effort basis — objects
115
- # that do not support +#dup+ (e.g. integers, frozen objects) are carried over
116
- # by reference. Internal workflow metadata (thread_id, phase) is preserved.
117
- # @param updates [Hash] { field_name => new_value }
118
- # @return [self.class] new context instance
119
- # @raise [ArgumentError] if updates contains keys that are not declared fields
120
- # @api public
121
- # mutant:disable - multiple genuine equivalent mutations: send/public_send/__send__ are identical (all field accessors are public), fields[]/fetch() and field_config[]/fetch() for always-present keys, updates[]/fetch() when updates.key?(name) is already true, Array() wrapping for append fields that always hold Arrays, (send||{})/send equivalence for merge fields that always hold Hashes, deep_dup_value(send) vs send are equivalent under killfork (coverage selection does not trace the deep_dup_value call site across the fork boundary), raise message inspect vs to_s (spec checks exception class only)
82
+ # Returns a new context with field update policies applied.
122
83
  def merge(updates)
123
84
  unknown = updates.keys - self.class.fields.keys
124
- raise ArgumentError, "Unknown WorkflowContext field(s): #{unknown.inspect}" unless unknown.empty?
85
+ unless unknown.empty?
86
+ raise ArgumentError,
87
+ "Unknown WorkflowContext field(s): #{unknown.inspect}"
88
+ end
125
89
 
126
90
  new_attrs = {}
127
91
  self.class.fields.each_key do |name|
128
92
  field_config = self.class.fields[name]
129
- new_attrs[name] = if updates.key?(name)
130
- case field_config[:type]
131
- when :append
132
- Array(send(name)) + Array(updates[name])
133
- when :merge
134
- (send(name) || {}).merge(updates[name])
93
+ new_attrs[name] =
94
+ if updates.key?(name)
95
+ case field_config[:type]
96
+ when :append
97
+ Array(public_send(name)) + Array(updates[name])
98
+ when :merge
99
+ (public_send(name) || {}).merge(updates[name])
100
+ else
101
+ updates[name]
102
+ end
135
103
  else
136
- updates[name]
104
+ deep_dup_value(public_send(name))
137
105
  end
138
- else
139
- deep_dup_value(send(name))
140
- end
141
106
  end
107
+
142
108
  new_context = self.class.new(**new_attrs)
143
109
  new_context.set_graph_metadata(
144
110
  thread_id: @thread_id,
@@ -147,28 +113,18 @@ module Phronomy
147
113
  new_context
148
114
  end
149
115
 
150
- # Converts user-defined fields to a Hash (excludes internal workflow metadata).
151
- # @return [Hash]
152
- # @api public
153
- # mutant:disable - send/public_send/__send__ are genuine equivalents (all field accessors are public methods)
154
116
  def to_h
155
- self.class.fields.keys.each_with_object({}) do |name, h|
156
- h[name] = send(name)
117
+ self.class.fields.keys.each_with_object({}) do |name, result|
118
+ result[name] = public_send(name)
157
119
  end
158
120
  end
159
121
 
160
122
  private
161
123
 
162
- # Asserts that the calling thread is allowed to mutate this context.
163
- # Raises WorkflowContextOwnershipError when called from outside the EventLoop
164
- # dispatch thread, unless we are inside a synchronous execution context
165
- # (e.g. Workflow#stream using the run_workflow sync path).
166
- # @raise [Phronomy::WorkflowContextOwnershipError]
167
- # @api private
168
- # mutant:disable - multiple genuine equivalent mutations: defined?(Phronomy::EventLoop)&& removal is genuine because EventLoop is always loaded in the killfork environment; true&& is genuine (truthy guard); EventLoop.current? resolves to Phronomy::EventLoop.current? within the Phronomy module; WorkflowContextOwnershipError resolves to Phronomy::WorkflowContextOwnershipError within the module; raise without message or with nil message is genuine (spec checks exception class, not message text)
124
+ # Workflow field mutation is permitted only on the Runtime-owned EventLoop
125
+ # dispatch thread. All Workflow execution APIs now use that same path; there
126
+ # is no caller-thread synchronous exception.
169
127
  def _assert_write_permitted!
170
- # Allow mutations when executing synchronously (e.g. Workflow#stream via run_workflow).
171
- return if Thread.current[:phronomy_sync_execution]
172
128
  return if Phronomy::Runtime.in_event_loop_context?
173
129
 
174
130
  raise Phronomy::WorkflowContextOwnershipError,
@@ -177,27 +133,23 @@ module Phronomy
177
133
  "updates as event payloads."
178
134
  end
179
135
 
180
- # Performs a deep copy of a value for immutable context propagation.
181
- # Arrays and Hashes are deep-duplicated recursively.
182
- # Immutable values (nil, Symbol, Integer, Float, true/false, frozen String) are returned as-is.
183
- # Other objects are dup'd (best-effort shallow copy for custom types).
184
- # Objects that cannot be dup'd (e.g. Proc, Method) are returned as-is.
185
- # mutant:disable - multiple genuine equivalent mutations: each class in the when clause (NilClass/Symbol/Integer/Float/TrueClass/FalseClass) can be removed or replaced with nil because all those types are frozen so the else-branch val.frozen? guard returns the same result; return val vs val is also equivalent; if val.frozen? vs if self.frozen? is equivalent since self is never frozen in this context
186
- def deep_dup_value(val)
187
- case val
136
+ def deep_dup_value(value)
137
+ case value
188
138
  when Array
189
- val.map { |v| deep_dup_value(v) }
139
+ value.map { |item| deep_dup_value(item) }
190
140
  when Hash
191
- val.each_with_object({}) { |(k, v), h| h[k] = deep_dup_value(v) }
141
+ value.each_with_object({}) do |(key, item), result|
142
+ result[key] = deep_dup_value(item)
143
+ end
192
144
  when NilClass, Symbol, Integer, Float, TrueClass, FalseClass
193
- val
145
+ value
194
146
  else
195
- return val if val.frozen?
147
+ return value if value.frozen?
196
148
 
197
149
  begin
198
- val.dup
150
+ value.dup
199
151
  rescue TypeError
200
- val
152
+ value
201
153
  end
202
154
  end
203
155
  end