phronomy 0.10.0 → 0.11.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/README.md +35 -24
  4. data/benchmark/bench_agent_invoke.rb +1 -0
  5. data/lib/phronomy/agent/base.rb +223 -95
  6. data/lib/phronomy/agent/concerns/error_translation.rb +1 -1
  7. data/lib/phronomy/agent/concerns/filterable.rb +184 -0
  8. data/lib/phronomy/agent/concerns/retryable.rb +9 -5
  9. data/lib/phronomy/agent/context/capability/base.rb +2 -2
  10. data/lib/phronomy/agent/invocation_context.rb +171 -0
  11. data/lib/phronomy/agent/invocation_session.rb +337 -0
  12. data/lib/phronomy/agent/phase_machine_builder.rb +189 -0
  13. data/lib/phronomy/agent/suspended_session_registry.rb +54 -0
  14. data/lib/phronomy/agent/tool_call_intercepted.rb +26 -0
  15. data/lib/phronomy/configuration.rb +0 -6
  16. data/lib/phronomy/{concurrency → engine/concurrency}/async_queue.rb +68 -5
  17. data/lib/phronomy/{concurrency → engine/concurrency}/blocking_adapter_pool.rb +9 -3
  18. data/lib/phronomy/{event_loop.rb → engine/event_loop.rb} +71 -37
  19. data/lib/phronomy/engine/fsm_session.rb +248 -0
  20. data/lib/phronomy/{runtime → engine/runtime}/deterministic_scheduler.rb +28 -1
  21. data/lib/phronomy/{runtime → engine/runtime}/fake_scheduler.rb +1 -1
  22. data/lib/phronomy/{runtime.rb → engine/runtime.rb} +2 -4
  23. data/lib/phronomy/{task → engine/task}/backend.rb +2 -2
  24. data/lib/phronomy/engine/task/deferred_backend.rb +73 -0
  25. data/lib/phronomy/{task → engine/task}/fiber_backend.rb +1 -1
  26. data/lib/phronomy/{task → engine/task}/immediate_backend.rb +1 -1
  27. data/lib/phronomy/{task → engine/task}/mapped_backend.rb +15 -3
  28. data/lib/phronomy/{task → engine/task}/thread_backend.rb +1 -1
  29. data/lib/phronomy/{task.rb → engine/task.rb} +19 -7
  30. data/lib/phronomy/eval/scorer/llm_judge.rb +1 -1
  31. data/lib/phronomy/filter/base.rb +56 -0
  32. data/lib/phronomy/{guardrail/prompt_injection_guardrail.rb → filter/prompt_injection_filter.rb} +20 -13
  33. data/lib/phronomy/filter.rb +5 -0
  34. data/lib/phronomy/generator_verifier.rb +21 -13
  35. data/lib/phronomy/multi_agent/orchestrator.rb +4 -4
  36. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +3 -3
  37. data/lib/phronomy/multi_agent/team_coordinator.rb +1 -2
  38. data/lib/phronomy/tools/mcp.rb +1 -1
  39. data/lib/phronomy/version.rb +1 -1
  40. data/lib/phronomy/workflow/phase_machine_builder.rb +24 -13
  41. data/lib/phronomy/workflow.rb +6 -3
  42. data/lib/phronomy/workflow_context.rb +7 -5
  43. data/lib/phronomy/workflow_runner.rb +77 -27
  44. data/lib/phronomy.rb +24 -13
  45. data/scripts/api_snapshot.rb +2 -2
  46. metadata +42 -44
  47. data/lib/phronomy/agent/checkpoint.rb +0 -208
  48. data/lib/phronomy/agent/checkpoint_store.rb +0 -97
  49. data/lib/phronomy/agent/concerns/guardrailable.rb +0 -45
  50. data/lib/phronomy/agent/concerns/suspendable.rb +0 -190
  51. data/lib/phronomy/agent/suspend_signal.rb +0 -37
  52. data/lib/phronomy/context.rb +0 -12
  53. data/lib/phronomy/guardrail/base.rb +0 -45
  54. data/lib/phronomy/guardrail/input_guardrail.rb +0 -19
  55. data/lib/phronomy/guardrail/output_guardrail.rb +0 -19
  56. data/lib/phronomy/guardrail.rb +0 -7
  57. data/lib/phronomy/tool.rb +0 -9
  58. data/lib/phronomy/workflow/fsm_session.rb +0 -249
  59. /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_scope.rb +0 -0
  60. /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_token.rb +0 -0
  61. /data/lib/phronomy/{concurrency → engine/concurrency}/concurrency_gate.rb +0 -0
  62. /data/lib/phronomy/{concurrency → engine/concurrency}/deadline.rb +0 -0
  63. /data/lib/phronomy/{concurrency → engine/concurrency}/gate_registry.rb +0 -0
  64. /data/lib/phronomy/{concurrency → engine/concurrency}/pool_registry.rb +0 -0
  65. /data/lib/phronomy/{runtime → engine/runtime}/runtime_metrics.rb +0 -0
  66. /data/lib/phronomy/{runtime → engine/runtime}/scheduler.rb +0 -0
  67. /data/lib/phronomy/{runtime → engine/runtime}/scheduler_timer_adapter.rb +0 -0
  68. /data/lib/phronomy/{runtime → engine/runtime}/task_registry.rb +0 -0
  69. /data/lib/phronomy/{runtime → engine/runtime}/thread_scheduler.rb +0 -0
  70. /data/lib/phronomy/{runtime → engine/runtime}/timer_queue.rb +0 -0
  71. /data/lib/phronomy/{runtime → engine/runtime}/timer_service.rb +0 -0
  72. /data/lib/phronomy/{task_group.rb → engine/task_group.rb} +0 -0
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module Concerns
6
+ # Adds input, output, and tool-result filter support to an agent.
7
+ #
8
+ # Filters transform (or block) values at three call sites:
9
+ # - *input* — the raw user input string, before the LLM is called
10
+ # - *output* — the final LLM output string, before it is returned
11
+ # - *tool result* — the return value of each tool call
12
+ #
13
+ # Each filter in the chain receives the value returned by the previous one.
14
+ # A {Phronomy::FilterBlockError} raised inside any filter propagates to the
15
+ # caller.
16
+ #
17
+ # @api private
18
+ module Filterable
19
+ def self.included(base)
20
+ base.extend(ClassMethods)
21
+ end
22
+
23
+ # Class-level DSL mixed into the including agent class.
24
+ module ClassMethods
25
+ # Registers a filter applied to every invocation's user input.
26
+ # Accepts either a {Phronomy::Filter::Base} instance or a subclass;
27
+ # when a class is given it is instantiated with +.new+.
28
+ # @param filter [Phronomy::Filter::Base, Class<Phronomy::Filter::Base>]
29
+ # @return [void]
30
+ # @api public
31
+ def input_filter(filter)
32
+ @_class_input_filters ||= []
33
+ @_class_input_filters << _resolve_filter(filter)
34
+ end
35
+
36
+ # Registers a filter applied to every invocation's final LLM output.
37
+ # Accepts either a {Phronomy::Filter::Base} instance or a subclass;
38
+ # when a class is given it is instantiated with +.new+.
39
+ # @param filter [Phronomy::Filter::Base, Class<Phronomy::Filter::Base>]
40
+ # @return [void]
41
+ # @api public
42
+ def output_filter(filter)
43
+ @_class_output_filters ||= []
44
+ @_class_output_filters << _resolve_filter(filter)
45
+ end
46
+
47
+ # Registers a filter applied to every tool result for all tools.
48
+ # Accepts either a {Phronomy::Filter::Base} instance or a subclass;
49
+ # when a class is given it is instantiated with +.new+.
50
+ # @param filter [Phronomy::Filter::Base, Class<Phronomy::Filter::Base>]
51
+ # @return [void]
52
+ # @api public
53
+ def tool_result_filter(filter)
54
+ @_class_tool_result_filters ||= []
55
+ @_class_tool_result_filters << _resolve_filter(filter)
56
+ end
57
+
58
+ # @return [Array<Phronomy::Filter::Base>]
59
+ # @api private
60
+ def _class_input_filters
61
+ @_class_input_filters || []
62
+ end
63
+
64
+ # @return [Array<Phronomy::Filter::Base>]
65
+ # @api private
66
+ def _class_output_filters
67
+ @_class_output_filters || []
68
+ end
69
+
70
+ # @return [Array<Phronomy::Filter::Base>]
71
+ # @api private
72
+ def _class_tool_result_filters
73
+ @_class_tool_result_filters || []
74
+ end
75
+
76
+ private
77
+
78
+ # Coerce +filter+ to an instance: if a Class is passed, call +.new+;
79
+ # otherwise return the object as-is.
80
+ # @param filter [Phronomy::Filter::Base, Class<Phronomy::Filter::Base>]
81
+ # @return [Phronomy::Filter::Base]
82
+ # @api private
83
+ def _resolve_filter(filter)
84
+ filter.is_a?(Class) ? filter.new : filter
85
+ end
86
+ end
87
+
88
+ # Registers an input filter on this instance.
89
+ # Accepts either a {Phronomy::Filter::Base} instance or a subclass;
90
+ # when a class is given it is instantiated with +.new+.
91
+ # Runs in addition to any class-level input filters.
92
+ # @param filter [Phronomy::Filter::Base, Class<Phronomy::Filter::Base>]
93
+ # @return [self]
94
+ # @api public
95
+ def add_input_filter(filter)
96
+ @_instance_input_filters ||= []
97
+ @_instance_input_filters << _resolve_filter(filter)
98
+ self
99
+ end
100
+
101
+ # Registers an output filter on this instance.
102
+ # Accepts either a {Phronomy::Filter::Base} instance or a subclass;
103
+ # when a class is given it is instantiated with +.new+.
104
+ # Runs in addition to any class-level output filters.
105
+ # @param filter [Phronomy::Filter::Base, Class<Phronomy::Filter::Base>]
106
+ # @return [self]
107
+ # @api public
108
+ def add_output_filter(filter)
109
+ @_instance_output_filters ||= []
110
+ @_instance_output_filters << _resolve_filter(filter)
111
+ self
112
+ end
113
+
114
+ # Registers a tool result filter on this instance.
115
+ #
116
+ # When called with two arguments, the filter is scoped to the given tool
117
+ # class only. When called with one argument, it applies to all tools.
118
+ # Accepts either a {Phronomy::Filter::Base} instance or a subclass;
119
+ # when a class is given it is instantiated with +.new+.
120
+ #
121
+ # @overload add_tool_result_filter(filter)
122
+ # @param filter [Phronomy::Filter::Base, Class<Phronomy::Filter::Base>] applied to every tool
123
+ # @overload add_tool_result_filter(tool_class, filter)
124
+ # @param tool_class [Class] scope the filter to this tool
125
+ # @param filter [Phronomy::Filter::Base, Class<Phronomy::Filter::Base>]
126
+ # @return [self]
127
+ # @api public
128
+ def add_tool_result_filter(tool_class_or_filter, filter = nil)
129
+ if filter.nil?
130
+ # Single-argument form: apply to all tools.
131
+ @_instance_tool_result_filters ||= []
132
+ @_instance_tool_result_filters << _resolve_filter(tool_class_or_filter)
133
+ else
134
+ # Two-argument form: scoped to one tool class.
135
+ @_scoped_tool_result_filters ||= {}
136
+ (@_scoped_tool_result_filters[tool_class_or_filter] ||= []) << _resolve_filter(filter)
137
+ end
138
+ self
139
+ end
140
+
141
+ private
142
+
143
+ # Run input filters (class-level then instance-level).
144
+ # @param input [String, Hash] the raw user input
145
+ # @return [String, Hash] the (possibly transformed) input
146
+ # @api private
147
+ def run_input_filters!(input)
148
+ class_filters = self.class._class_input_filters
149
+ inst_filters = @_instance_input_filters || []
150
+ (class_filters + inst_filters).inject(input) { |val, f| f.call(val) }
151
+ end
152
+
153
+ # Run output filters (class-level then instance-level).
154
+ # @param output [String] the LLM output
155
+ # @return [String] the (possibly transformed) output
156
+ # @api private
157
+ def run_output_filters!(output)
158
+ class_filters = self.class._class_output_filters
159
+ inst_filters = @_instance_output_filters || []
160
+ (class_filters + inst_filters).inject(output) { |val, f| f.call(val) }
161
+ end
162
+
163
+ # Collect all tool-result filters (global + scoped) for a given tool class.
164
+ # @param tool_class [Class]
165
+ # @return [Array<Phronomy::Filter::Base>]
166
+ # @api private
167
+ def _tool_result_filters_for(tool_class)
168
+ global = self.class._class_tool_result_filters + (@_instance_tool_result_filters || [])
169
+ scoped = (@_scoped_tool_result_filters || {})[tool_class] || []
170
+ global + scoped
171
+ end
172
+
173
+ # Coerce +filter+ to an instance: if a Class is passed, call +.new+;
174
+ # otherwise return the object as-is.
175
+ # @param filter [Phronomy::Filter::Base, Class<Phronomy::Filter::Base>]
176
+ # @return [Phronomy::Filter::Base]
177
+ # @api private
178
+ def _resolve_filter(filter)
179
+ filter.is_a?(Class) ? filter.new : filter
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
@@ -6,7 +6,7 @@ module Phronomy
6
6
  # Adds configurable retry behaviour to an agent.
7
7
  #
8
8
  # Included in {Phronomy::Agent::Base}. The retry loop wraps the full
9
- # #invoke_once call; {Phronomy::GuardrailError} is never retried.
9
+ # #invoke_once call; {Phronomy::FilterBlockError} is never retried.
10
10
  # @api private
11
11
  module Retryable
12
12
  def self.included(base)
@@ -16,7 +16,7 @@ module Phronomy
16
16
  # Class-level DSL methods mixed into the including agent class.
17
17
  module ClassMethods
18
18
  # Configures a retry policy that wraps the full #invoke call.
19
- # GuardrailError is never retried regardless of this setting.
19
+ # FilterBlockError is never retried regardless of this setting.
20
20
  #
21
21
  # @param times [Integer] maximum retry attempts (default: 0)
22
22
  # @param wait [Symbol, Numeric] :exponential, :linear, or a fixed Float
@@ -35,7 +35,11 @@ module Phronomy
35
35
  # @return [Hash, nil]
36
36
  attr_reader :_retry_policy
37
37
 
38
- # Injectable sleep callable for testing (shared with Tool::Base pattern).
38
+ # Injectable sleep callable for testing.
39
+ # In the EventLoop-driven invoke path, actual delay is handled by
40
+ # timer_queue (non-blocking); _sleep_proc is called only as an
41
+ # instrumentation hook. The default is a no-op; tests override it
42
+ # with a spy lambda to record sleep durations.
39
43
  # @return [#call]
40
44
  # @api private
41
45
  def _sleep_proc
@@ -59,8 +63,8 @@ module Phronomy
59
63
  policy = self.class._retry_policy
60
64
  attempt = 0
61
65
  begin
62
- invoke_once(input, messages: messages, thread_id: thread_id, config: config)
63
- rescue Phronomy::GuardrailError
66
+ _invoke_via_fsm(input, messages: messages, thread_id: thread_id, config: config)
67
+ rescue Phronomy::FilterBlockError
64
68
  raise
65
69
  rescue Phronomy::CancellationError
66
70
  raise # Never retry after cancellation.
@@ -92,7 +92,7 @@ module Phronomy
92
92
  public
93
93
 
94
94
  # Sets the access scope for this tool (metadata; enforcement is the responsibility of
95
- # the Workflow/Guardrail layer).
95
+ # the Workflow/Filter layer).
96
96
  # @param value [Symbol] e.g. :read_only, :write, :admin
97
97
  # @api public
98
98
  # mutant:disable - neutral failure: unparser round-trip produces different source
@@ -218,7 +218,7 @@ module Phronomy
218
218
  # retried up to +times+ times with the specified wait strategy.
219
219
  # Multiple policies can be registered and are evaluated in order.
220
220
  #
221
- # GuardrailError is never retried regardless of this configuration.
221
+ # FilterBlockError is never retried regardless of this configuration.
222
222
  #
223
223
  # @param exception_classes [Array<Class>] exception classes to retry on
224
224
  # @param times [Integer] maximum retry attempts (default: 1)
@@ -0,0 +1,171 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Holds the mutable state for a single Agent#invoke execution.
6
+ #
7
+ # An InvocationContext is created at the start of each invoke call and
8
+ # passed through every FSM state as the context object. It plays the same
9
+ # role as WorkflowContext does for Workflow executions.
10
+ #
11
+ # Fields:
12
+ # input — the (possibly filter-transformed) user input
13
+ # messages — conversation history (Array of RubyLLM::Message)
14
+ # chat — the RubyLLM::Chat object built during :building_context
15
+ # output — the final LLM text response
16
+ # usage — Phronomy::TokenUsage after completion
17
+ # tool_call_pending — whether the last LLM response contained a tool call
18
+ # approval_required — whether the pending tool requires human approval
19
+ # input_blocked — whether an input filter called block!
20
+ # output_blocked — whether an output filter called block!
21
+ # block_error — the FilterBlockError raised (for :blocked terminal)
22
+ # pending_tool_call — the intercepted tool_call object (name/arguments/id)
23
+ # user_message_sent — true after the first LLM call (continuation uses nil msg)
24
+ # sync_approval_handler — true when agent has an on_approval_required block
25
+ # session_id — FSM session id (set by set_graph_metadata)
26
+ # phase — terminal FSM phase (set by set_graph_metadata)
27
+ #
28
+ # @api private
29
+ class InvocationContext
30
+ # @return [String, Hash] user input (may be transformed by input filters)
31
+ attr_accessor :input
32
+
33
+ # @return [Array] conversation history
34
+ attr_accessor :messages
35
+
36
+ # @return [Object, nil] RubyLLM::Chat instance
37
+ attr_accessor :chat
38
+
39
+ # @return [String, nil] final LLM output text
40
+ attr_accessor :output
41
+
42
+ # @return [Phronomy::TokenUsage, nil]
43
+ attr_accessor :usage
44
+
45
+ # @return [Boolean] true when calling_llm returned a tool call
46
+ attr_accessor :tool_call_pending
47
+
48
+ # @return [Boolean] true when the pending tool requires human approval
49
+ attr_accessor :approval_required
50
+
51
+ # @return [Boolean] true when an input filter called block!
52
+ attr_accessor :input_blocked
53
+
54
+ # @return [Boolean] true when an output filter called block!
55
+ attr_accessor :output_blocked
56
+
57
+ # @return [Phronomy::FilterBlockError, nil]
58
+ attr_accessor :block_error
59
+
60
+ # @return [Object, nil] intercepted RubyLLM tool_call (name/arguments/id)
61
+ attr_accessor :pending_tool_call
62
+
63
+ # @return [Boolean] true after the first LLM call (continuation passes nil msg)
64
+ attr_accessor :user_message_sent
65
+
66
+ # @return [Boolean] true when Agent.approve was called (signals executing_tool
67
+ # to execute the pending tool rather than re-suspending)
68
+ attr_accessor :approved
69
+
70
+ # @return [Boolean] true when the pending tool call was rejected via
71
+ # Agent.approve(session_id, approved: false)
72
+ attr_accessor :rejected
73
+
74
+ # @return [String, nil] FSM session id (set by set_graph_metadata)
75
+ attr_reader :session_id
76
+
77
+ # @return [Symbol, nil] terminal FSM phase (set by set_graph_metadata)
78
+ attr_reader :phase
79
+
80
+ # @return [Boolean] true when agent has a synchronous on_approval_required handler
81
+ attr_reader :sync_approval_handler
82
+
83
+ # @return [Phronomy::Agent::Base] the agent instance driving this invocation
84
+ attr_reader :agent
85
+
86
+ # @return [Hash] the config hash passed to invoke
87
+ attr_reader :config
88
+
89
+ # @return [String, nil] thread_id from config
90
+ attr_reader :thread_id
91
+
92
+ # @param agent [Phronomy::Agent::Base]
93
+ # @param input [String, Hash]
94
+ # @param messages [Array]
95
+ # @param config [Hash]
96
+ # @api private
97
+ def initialize(agent:, input:, messages:, config:)
98
+ @agent = agent
99
+ @input = input
100
+ @messages = Array(messages)
101
+ @config = config
102
+ @thread_id = config[:thread_id]
103
+ @chat = nil
104
+ @output = nil
105
+ @usage = nil
106
+ @tool_call_pending = false
107
+ @approval_required = false
108
+ @input_blocked = false
109
+ @output_blocked = false
110
+ @block_error = nil
111
+ @pending_tool_call = nil
112
+ @user_message_sent = false
113
+ @approved = false
114
+ @rejected = false
115
+ @sync_approval_handler = !agent.instance_variable_get(:@approval_handler).nil?
116
+ @session_id = nil
117
+ @phase = nil
118
+ end
119
+
120
+ # Called by FSMSession#finish! and FSMSession#halt! to record the
121
+ # terminal/halted phase and the session id.
122
+ # Mirrors the WorkflowContext interface so FSMSession works with both.
123
+ # @param thread_id [String, nil]
124
+ # @param phase [Symbol, nil]
125
+ # @return [void]
126
+ # @api private
127
+ def set_graph_metadata(thread_id: nil, phase: nil)
128
+ @session_id = thread_id if thread_id
129
+ @phase = phase
130
+ end
131
+
132
+ # Guard helpers used by PhaseMachineBuilder transitions.
133
+
134
+ # @return [Boolean]
135
+ # @api private
136
+ def input_passed?
137
+ !@input_blocked
138
+ end
139
+
140
+ # @return [Boolean]
141
+ # @api private
142
+ def input_blocked?
143
+ @input_blocked
144
+ end
145
+
146
+ # @return [Boolean]
147
+ # @api private
148
+ def output_passed?
149
+ !@output_blocked
150
+ end
151
+
152
+ # @return [Boolean]
153
+ # @api private
154
+ def output_blocked?
155
+ @output_blocked
156
+ end
157
+
158
+ # @return [Boolean]
159
+ # @api private
160
+ def tool_call_pending?
161
+ @tool_call_pending
162
+ end
163
+
164
+ # @return [Boolean]
165
+ # @api private
166
+ def approval_required?
167
+ @approval_required
168
+ end
169
+ end
170
+ end
171
+ end