phronomy 0.11.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 (59) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +17 -8
  3. data/benchmark/bench_agent_invoke.rb +1 -0
  4. data/lib/phronomy/agent/base.rb +192 -78
  5. data/lib/phronomy/agent/concerns/error_translation.rb +1 -1
  6. data/lib/phronomy/agent/concerns/retryable.rb +6 -2
  7. data/lib/phronomy/agent/invocation_context.rb +171 -0
  8. data/lib/phronomy/agent/invocation_session.rb +337 -0
  9. data/lib/phronomy/agent/phase_machine_builder.rb +189 -0
  10. data/lib/phronomy/agent/suspended_session_registry.rb +54 -0
  11. data/lib/phronomy/agent/tool_call_intercepted.rb +26 -0
  12. data/lib/phronomy/configuration.rb +0 -6
  13. data/lib/phronomy/{concurrency → engine/concurrency}/async_queue.rb +68 -5
  14. data/lib/phronomy/{concurrency → engine/concurrency}/blocking_adapter_pool.rb +9 -3
  15. data/lib/phronomy/{event_loop.rb → engine/event_loop.rb} +71 -37
  16. data/lib/phronomy/engine/fsm_session.rb +248 -0
  17. data/lib/phronomy/{runtime → engine/runtime}/deterministic_scheduler.rb +28 -1
  18. data/lib/phronomy/{runtime → engine/runtime}/fake_scheduler.rb +1 -1
  19. data/lib/phronomy/{runtime.rb → engine/runtime.rb} +2 -4
  20. data/lib/phronomy/{task → engine/task}/backend.rb +2 -2
  21. data/lib/phronomy/engine/task/deferred_backend.rb +73 -0
  22. data/lib/phronomy/{task → engine/task}/fiber_backend.rb +1 -1
  23. data/lib/phronomy/{task → engine/task}/immediate_backend.rb +1 -1
  24. data/lib/phronomy/{task → engine/task}/mapped_backend.rb +15 -3
  25. data/lib/phronomy/{task → engine/task}/thread_backend.rb +1 -1
  26. data/lib/phronomy/{task.rb → engine/task.rb} +19 -7
  27. data/lib/phronomy/eval/scorer/llm_judge.rb +1 -1
  28. data/lib/phronomy/generator_verifier.rb +20 -11
  29. data/lib/phronomy/multi_agent/orchestrator.rb +4 -4
  30. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +3 -3
  31. data/lib/phronomy/tools/mcp.rb +1 -1
  32. data/lib/phronomy/version.rb +1 -1
  33. data/lib/phronomy/workflow/phase_machine_builder.rb +24 -13
  34. data/lib/phronomy/workflow.rb +6 -3
  35. data/lib/phronomy/workflow_context.rb +7 -5
  36. data/lib/phronomy/workflow_runner.rb +77 -27
  37. data/lib/phronomy.rb +17 -9
  38. metadata +35 -35
  39. data/lib/phronomy/agent/checkpoint.rb +0 -208
  40. data/lib/phronomy/agent/checkpoint_store.rb +0 -97
  41. data/lib/phronomy/agent/concerns/suspendable.rb +0 -190
  42. data/lib/phronomy/agent/suspend_signal.rb +0 -37
  43. data/lib/phronomy/context.rb +0 -12
  44. data/lib/phronomy/tool.rb +0 -9
  45. data/lib/phronomy/workflow/fsm_session.rb +0 -249
  46. /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_scope.rb +0 -0
  47. /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_token.rb +0 -0
  48. /data/lib/phronomy/{concurrency → engine/concurrency}/concurrency_gate.rb +0 -0
  49. /data/lib/phronomy/{concurrency → engine/concurrency}/deadline.rb +0 -0
  50. /data/lib/phronomy/{concurrency → engine/concurrency}/gate_registry.rb +0 -0
  51. /data/lib/phronomy/{concurrency → engine/concurrency}/pool_registry.rb +0 -0
  52. /data/lib/phronomy/{runtime → engine/runtime}/runtime_metrics.rb +0 -0
  53. /data/lib/phronomy/{runtime → engine/runtime}/scheduler.rb +0 -0
  54. /data/lib/phronomy/{runtime → engine/runtime}/scheduler_timer_adapter.rb +0 -0
  55. /data/lib/phronomy/{runtime → engine/runtime}/task_registry.rb +0 -0
  56. /data/lib/phronomy/{runtime → engine/runtime}/thread_scheduler.rb +0 -0
  57. /data/lib/phronomy/{runtime → engine/runtime}/timer_queue.rb +0 -0
  58. /data/lib/phronomy/{runtime → engine/runtime}/timer_service.rb +0 -0
  59. /data/lib/phronomy/{task_group.rb → engine/task_group.rb} +0 -0
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phronomy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.11.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raizo T.C.S
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-16 00:00:00.000000000 Z
11
+ date: 2026-07-19 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm
@@ -105,34 +105,54 @@ files:
105
105
  - lib/phronomy/agent.rb
106
106
  - lib/phronomy/agent/base.rb
107
107
  - lib/phronomy/agent/before_completion_context.rb
108
- - lib/phronomy/agent/checkpoint.rb
109
- - lib/phronomy/agent/checkpoint_store.rb
110
108
  - lib/phronomy/agent/concerns/before_completion.rb
111
109
  - lib/phronomy/agent/concerns/error_translation.rb
112
110
  - lib/phronomy/agent/concerns/filterable.rb
113
111
  - lib/phronomy/agent/concerns/retryable.rb
114
- - lib/phronomy/agent/concerns/suspendable.rb
115
112
  - lib/phronomy/agent/context/capability/base.rb
116
113
  - lib/phronomy/agent/context/capability/scope_policy.rb
117
114
  - lib/phronomy/agent/context/instruction/prompt_template.rb
118
115
  - lib/phronomy/agent/context/knowledge/base.rb
119
116
  - lib/phronomy/agent/context/knowledge/entity_knowledge.rb
120
117
  - lib/phronomy/agent/context/knowledge/static_knowledge.rb
118
+ - lib/phronomy/agent/invocation_context.rb
119
+ - lib/phronomy/agent/invocation_session.rb
120
+ - lib/phronomy/agent/phase_machine_builder.rb
121
121
  - lib/phronomy/agent/runner.rb
122
122
  - lib/phronomy/agent/shared_state.rb
123
- - lib/phronomy/agent/suspend_signal.rb
123
+ - lib/phronomy/agent/suspended_session_registry.rb
124
+ - lib/phronomy/agent/tool_call_intercepted.rb
124
125
  - lib/phronomy/agent/tool_executor.rb
125
- - lib/phronomy/concurrency/async_queue.rb
126
- - lib/phronomy/concurrency/blocking_adapter_pool.rb
127
- - lib/phronomy/concurrency/cancellation_scope.rb
128
- - lib/phronomy/concurrency/cancellation_token.rb
129
- - lib/phronomy/concurrency/concurrency_gate.rb
130
- - lib/phronomy/concurrency/deadline.rb
131
- - lib/phronomy/concurrency/gate_registry.rb
132
- - lib/phronomy/concurrency/pool_registry.rb
133
126
  - lib/phronomy/configuration.rb
134
- - lib/phronomy/context.rb
135
127
  - lib/phronomy/diagnostics.rb
128
+ - lib/phronomy/engine/concurrency/async_queue.rb
129
+ - lib/phronomy/engine/concurrency/blocking_adapter_pool.rb
130
+ - lib/phronomy/engine/concurrency/cancellation_scope.rb
131
+ - lib/phronomy/engine/concurrency/cancellation_token.rb
132
+ - lib/phronomy/engine/concurrency/concurrency_gate.rb
133
+ - lib/phronomy/engine/concurrency/deadline.rb
134
+ - lib/phronomy/engine/concurrency/gate_registry.rb
135
+ - lib/phronomy/engine/concurrency/pool_registry.rb
136
+ - lib/phronomy/engine/event_loop.rb
137
+ - lib/phronomy/engine/fsm_session.rb
138
+ - lib/phronomy/engine/runtime.rb
139
+ - lib/phronomy/engine/runtime/deterministic_scheduler.rb
140
+ - lib/phronomy/engine/runtime/fake_scheduler.rb
141
+ - lib/phronomy/engine/runtime/runtime_metrics.rb
142
+ - lib/phronomy/engine/runtime/scheduler.rb
143
+ - lib/phronomy/engine/runtime/scheduler_timer_adapter.rb
144
+ - lib/phronomy/engine/runtime/task_registry.rb
145
+ - lib/phronomy/engine/runtime/thread_scheduler.rb
146
+ - lib/phronomy/engine/runtime/timer_queue.rb
147
+ - lib/phronomy/engine/runtime/timer_service.rb
148
+ - lib/phronomy/engine/task.rb
149
+ - lib/phronomy/engine/task/backend.rb
150
+ - lib/phronomy/engine/task/deferred_backend.rb
151
+ - lib/phronomy/engine/task/fiber_backend.rb
152
+ - lib/phronomy/engine/task/immediate_backend.rb
153
+ - lib/phronomy/engine/task/mapped_backend.rb
154
+ - lib/phronomy/engine/task/thread_backend.rb
155
+ - lib/phronomy/engine/task_group.rb
136
156
  - lib/phronomy/eval.rb
137
157
  - lib/phronomy/eval/comparison.rb
138
158
  - lib/phronomy/eval/dataset.rb
@@ -146,7 +166,6 @@ files:
146
166
  - lib/phronomy/eval/scorer/includes_scorer.rb
147
167
  - lib/phronomy/eval/scorer/llm_judge.rb
148
168
  - lib/phronomy/event.rb
149
- - lib/phronomy/event_loop.rb
150
169
  - lib/phronomy/filter.rb
151
170
  - lib/phronomy/filter/base.rb
152
171
  - lib/phronomy/filter/prompt_injection_filter.rb
@@ -171,31 +190,13 @@ files:
171
190
  - lib/phronomy/output_parser/structured_parser.rb
172
191
  - lib/phronomy/ruby_llm_patches.rb
173
192
  - lib/phronomy/runnable.rb
174
- - lib/phronomy/runtime.rb
175
- - lib/phronomy/runtime/deterministic_scheduler.rb
176
- - lib/phronomy/runtime/fake_scheduler.rb
177
- - lib/phronomy/runtime/runtime_metrics.rb
178
- - lib/phronomy/runtime/scheduler.rb
179
- - lib/phronomy/runtime/scheduler_timer_adapter.rb
180
- - lib/phronomy/runtime/task_registry.rb
181
- - lib/phronomy/runtime/thread_scheduler.rb
182
- - lib/phronomy/runtime/timer_queue.rb
183
- - lib/phronomy/runtime/timer_service.rb
184
193
  - lib/phronomy/state_store/base.rb
185
194
  - lib/phronomy/state_store/in_memory.rb
186
- - lib/phronomy/task.rb
187
- - lib/phronomy/task/backend.rb
188
- - lib/phronomy/task/fiber_backend.rb
189
- - lib/phronomy/task/immediate_backend.rb
190
- - lib/phronomy/task/mapped_backend.rb
191
- - lib/phronomy/task/thread_backend.rb
192
- - lib/phronomy/task_group.rb
193
195
  - lib/phronomy/testing.rb
194
196
  - lib/phronomy/testing/fake_clock.rb
195
197
  - lib/phronomy/testing/fake_scheduler.rb
196
198
  - lib/phronomy/testing/scheduler_helpers.rb
197
199
  - lib/phronomy/token_usage.rb
198
- - lib/phronomy/tool.rb
199
200
  - lib/phronomy/tools/agent.rb
200
201
  - lib/phronomy/tools/mcp.rb
201
202
  - lib/phronomy/tools/vector_search.rb
@@ -221,7 +222,6 @@ files:
221
222
  - lib/phronomy/vector_store/splitter/recursive_splitter.rb
222
223
  - lib/phronomy/version.rb
223
224
  - lib/phronomy/workflow.rb
224
- - lib/phronomy/workflow/fsm_session.rb
225
225
  - lib/phronomy/workflow/phase_machine_builder.rb
226
226
  - lib/phronomy/workflow_context.rb
227
227
  - lib/phronomy/workflow_runner.rb
@@ -1,208 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "securerandom"
4
-
5
- module Phronomy
6
- module Agent
7
- # Encapsulates the suspended state of an agent invocation.
8
- #
9
- # A Checkpoint is returned as the +:checkpoint+ key of the result hash when
10
- # an approval-required tool is encountered and no synchronous
11
- # on_approval_required handler has been registered.
12
- #
13
- # Pass the checkpoint to Agent::Base#resume to continue execution after
14
- # obtaining an approval decision from the user or an external system.
15
- #
16
- # @example Suspend and resume
17
- # result = agent.invoke("Do task X")
18
- # if result[:suspended]
19
- # approved = prompt_user(result[:checkpoint].pending_tool_name)
20
- # result = agent.resume(result[:checkpoint], approved: approved)
21
- # end
22
- # puts result[:output]
23
- class Checkpoint
24
- # @return [String] a globally unique identifier for this checkpoint;
25
- # used as an idempotency key when guarding against duplicate resumes
26
- attr_reader :checkpoint_id
27
-
28
- # @return [String, nil] the fully-qualified name of the agent class that
29
- # created this checkpoint (e.g. +"MyApp::ReviewAgent"+); used by the
30
- # class-level +resume+ method to validate the correct agent is used
31
- attr_reader :agent_class
32
-
33
- # @return [Time] the UTC timestamp when this checkpoint was created
34
- attr_reader :requested_at
35
-
36
- # @return [String, nil] the thread_id from the invocation config
37
- attr_reader :thread_id
38
-
39
- # @return [String, Hash] the original input passed to #invoke; stored so
40
- # that #resume can re-apply dynamic system instructions (e.g. Proc or
41
- # PromptTemplate-based instructions that depend on the input value).
42
- attr_reader :original_input
43
-
44
- # @return [Array<RubyLLM::Message>] conversation messages up to and including
45
- # the assistant message that requested the pending tool call
46
- attr_reader :messages
47
-
48
- # @return [String] the name of the tool awaiting approval
49
- attr_reader :pending_tool_name
50
-
51
- # @return [Hash] the arguments the LLM passed to the pending tool
52
- attr_reader :pending_tool_args
53
-
54
- # @return [String] the tool_call_id from the LLM response (required to
55
- # inject the tool result message on resume)
56
- attr_reader :pending_tool_call_id
57
-
58
- # @param checkpoint_id [String] unique identifier; defaults to a new UUID
59
- # @param agent_class [String, nil] fully-qualified agent class name
60
- # @param requested_at [Time] when the checkpoint was created; defaults to +Time.now.utc+
61
- # @param thread_id [String, nil]
62
- # @param original_input [String, Hash] the input passed to the original #invoke call
63
- # @param messages [Array<RubyLLM::Message>]
64
- # @param pending_tool_name [String]
65
- # @param pending_tool_args [Hash]
66
- # @param pending_tool_call_id [String]
67
- # @api public
68
- def initialize(thread_id:, original_input:, messages:, pending_tool_name:, pending_tool_args:, pending_tool_call_id:,
69
- checkpoint_id: SecureRandom.uuid, agent_class: nil, requested_at: Time.now.utc)
70
- @checkpoint_id = checkpoint_id
71
- @agent_class = agent_class
72
- @requested_at = requested_at
73
- @thread_id = thread_id
74
- @original_input = original_input
75
- @messages = messages.dup.freeze
76
- @pending_tool_name = pending_tool_name
77
- @pending_tool_args = pending_tool_args
78
- @pending_tool_call_id = pending_tool_call_id
79
- end
80
-
81
- # Converts this checkpoint to a plain Hash suitable for JSON / Marshal serialization.
82
- #
83
- # All values are plain Ruby objects (String, Symbol, Hash, Array, Numeric,
84
- # nil). +RubyLLM::Message+ objects in +:messages+ are deep-converted so that
85
- # any embedded +RubyLLM::ToolCall+ objects are also serialized as plain hashes.
86
- #
87
- # @example Round-trip via JSON
88
- # json = JSON.generate(checkpoint.to_h)
89
- # checkpoint2 = Phronomy::Agent::Checkpoint.from_h(JSON.parse(json))
90
- #
91
- # @return [Hash]
92
- # @api public
93
- def to_h
94
- {
95
- checkpoint_id: @checkpoint_id,
96
- agent_class: @agent_class,
97
- requested_at: @requested_at&.iso8601,
98
- thread_id: @thread_id,
99
- original_input: @original_input,
100
- messages: @messages.map { |m| serialize_message(m) },
101
- pending_tool_name: @pending_tool_name,
102
- pending_tool_args: @pending_tool_args,
103
- pending_tool_call_id: @pending_tool_call_id
104
- }
105
- end
106
-
107
- # Reconstructs a +Checkpoint+ from a plain Hash (e.g. produced by {#to_h}
108
- # and deserialized from JSON or Marshal).
109
- #
110
- # Hash keys may be either Symbols or Strings; both are accepted.
111
- # +RubyLLM::ToolCall+ objects inside message +:tool_calls+ arrays are
112
- # reconstructed from their hash representations.
113
- #
114
- # @param h [Hash] a hash previously produced by {#to_h}
115
- # @return [Checkpoint]
116
- # @api public
117
- def self.from_h(h)
118
- h = h.transform_keys { |k|
119
- begin
120
- k.to_sym
121
- rescue
122
- k
123
- end
124
- }
125
- messages = Array(h[:messages]).map { |m| deserialize_message(m) }
126
- requested_at_raw = h[:requested_at]
127
- requested_at = requested_at_raw ? Time.parse(requested_at_raw.to_s).utc : nil
128
- new(
129
- checkpoint_id: h[:checkpoint_id]&.to_s || SecureRandom.uuid,
130
- agent_class: h[:agent_class]&.to_s,
131
- requested_at: requested_at || Time.now.utc,
132
- thread_id: h[:thread_id],
133
- original_input: h[:original_input],
134
- messages: messages,
135
- pending_tool_name: h[:pending_tool_name]&.to_s,
136
- pending_tool_args: h[:pending_tool_args] ? h[:pending_tool_args].transform_keys { |k|
137
- begin
138
- k.to_sym
139
- rescue
140
- k
141
- end
142
- } : {},
143
- pending_tool_call_id: h[:pending_tool_call_id]&.to_s
144
- )
145
- end
146
-
147
- private
148
-
149
- # Converts a +RubyLLM::Message+ to a plain Hash, ensuring that any
150
- # embedded +RubyLLM::ToolCall+ objects in +:tool_calls+ are also converted.
151
- #
152
- # @param msg [RubyLLM::Message]
153
- # @return [Hash]
154
- # @api private
155
- def serialize_message(msg)
156
- h = msg.to_h
157
- return h unless h[:tool_calls]
158
-
159
- h.merge(tool_calls: h[:tool_calls].map { |tc|
160
- tc.respond_to?(:to_h) ? tc.to_h : tc
161
- })
162
- end
163
-
164
- # Reconstructs a +RubyLLM::Message+ from a plain Hash.
165
- # +RubyLLM::ToolCall+ entries in +:tool_calls+ are re-instantiated.
166
- #
167
- # @param h [Hash]
168
- # @return [RubyLLM::Message]
169
- # @api private
170
- def self.deserialize_message(h)
171
- h = h.transform_keys { |k|
172
- begin
173
- k.to_sym
174
- rescue
175
- k
176
- end
177
- }
178
- if h[:tool_calls]
179
- h = h.merge(tool_calls: Array(h[:tool_calls]).map { |tc|
180
- next tc if tc.is_a?(RubyLLM::ToolCall)
181
-
182
- tc = tc.transform_keys { |k|
183
- begin
184
- k.to_sym
185
- rescue
186
- k
187
- end
188
- }
189
- RubyLLM::ToolCall.new(
190
- id: tc[:id].to_s,
191
- name: tc[:name].to_s,
192
- arguments: (tc[:arguments] || {}).transform_keys { |k|
193
- begin
194
- k.to_sym
195
- rescue
196
- k
197
- end
198
- },
199
- thought_signature: tc[:thought_signature]
200
- )
201
- })
202
- end
203
- RubyLLM::Message.new(h)
204
- end
205
- private_class_method :deserialize_message
206
- end
207
- end
208
- end
@@ -1,97 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Agent
5
- # Default in-memory idempotency store for {Checkpoint} resume operations.
6
- #
7
- # Tracks consumed checkpoint IDs so that calling {Agent::Base#resume} twice
8
- # with the same checkpoint raises {Phronomy::CheckpointAlreadyResumedError}
9
- # instead of silently executing the approved tool a second time.
10
- #
11
- # This implementation is *not thread-safe*. It assumes a single agent instance
12
- # is accessed from only one thread at a time, which is the expected usage pattern.
13
- # Agent instances themselves are not thread-safe (state like +@messages+, +@config+
14
- # is not protected), so concurrent calls to the same agent instance are unsupported.
15
- #
16
- # Each agent instance gets its own store by default, so no sharing occurs unless
17
- # the caller explicitly assigns the same store object to multiple agents.
18
- #
19
- # For distributed environments (multiple processes or background jobs), swap this
20
- # for a custom implementation backed by Redis, ActiveRecord, or another shared store.
21
- # *Your custom store implementation is responsible for ensuring thread-safety* if
22
- # your application shares the same store instance across multiple threads.
23
- #
24
- # @example Plugging in a custom store
25
- # agent = MyAgent.new
26
- # agent.checkpoint_store = MyRedis::CheckpointStore.new
27
- #
28
- # @example Duck-type contract required by any replacement
29
- # # consumed?(checkpoint_id) => Boolean
30
- # # consume!(checkpoint_id) => void; raises CheckpointAlreadyResumedError if duplicate
31
- # # cleanup!(checkpoint_id) => void (optional); removes tracking for the checkpoint
32
- # # clear! => void (optional); removes all tracked checkpoints
33
- #
34
- # @api public
35
- class CheckpointStore
36
- def initialize
37
- @consumed = Set.new
38
- end
39
-
40
- # Returns +true+ if the given checkpoint ID has already been consumed.
41
- #
42
- # @param checkpoint_id [String]
43
- # @return [Boolean]
44
- # @api public
45
- def consumed?(checkpoint_id)
46
- @consumed.include?(checkpoint_id)
47
- end
48
-
49
- # Marks +checkpoint_id+ as consumed, or raises if it was already consumed.
50
- #
51
- # @param checkpoint_id [String]
52
- # @raise [Phronomy::CheckpointAlreadyResumedError]
53
- # @return [void]
54
- # @api public
55
- def consume!(checkpoint_id)
56
- if @consumed.include?(checkpoint_id)
57
- raise Phronomy::CheckpointAlreadyResumedError,
58
- "checkpoint #{checkpoint_id} has already been resumed"
59
- end
60
- @consumed.add(checkpoint_id)
61
- nil
62
- end
63
-
64
- # Removes tracking for a specific checkpoint ID.
65
- #
66
- # Use this to explicitly discard a checkpoint when the application
67
- # determines it is no longer needed (e.g., user abandons an approval
68
- # workflow).
69
- #
70
- # This method is optional in the duck-type contract. Custom store
71
- # implementations may choose not to implement it.
72
- #
73
- # @param checkpoint_id [String]
74
- # @return [void]
75
- # @api public
76
- def cleanup!(checkpoint_id)
77
- @consumed.delete(checkpoint_id)
78
- nil
79
- end
80
-
81
- # Removes all tracked checkpoint IDs.
82
- #
83
- # Use this for test cleanup, periodic maintenance, or application
84
- # shutdown.
85
- #
86
- # This method is optional in the duck-type contract. Custom store
87
- # implementations may choose not to implement it.
88
- #
89
- # @return [void]
90
- # @api public
91
- def clear!
92
- @consumed.clear
93
- nil
94
- end
95
- end
96
- end
97
- end
@@ -1,190 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "securerandom"
4
-
5
- module Phronomy
6
- module Agent
7
- module Concerns
8
- # Adds suspend/resume and tool-approval support to an agent.
9
- #
10
- # Included in {Phronomy::Agent::Base}. When a tool decorated with
11
- # +requires_approval true+ is called and no synchronous approval handler
12
- # has been registered, the invocation is suspended and a
13
- # @api private
14
- # {Phronomy::Agent::Checkpoint} is returned so the caller can resume later.
15
- module Suspendable
16
- # Registers a callback that is invoked before executing any tool that has
17
- # +requires_approval true+ set. The block receives the tool name (String)
18
- # and the arguments Hash, and must return a truthy value to allow execution.
19
- # Returning a falsy value causes the tool to return a denial message instead
20
- # of executing.
21
- #
22
- # When no handler is registered and a tool with +requires_approval+ is
23
- # called, #invoke returns a suspended result hash containing a
24
- # {Phronomy::Agent::Checkpoint}. Call #resume to continue execution after
25
- # obtaining an approval decision from the user or an external system.
26
- #
27
- # @example Synchronous handler
28
- # agent = MyAgent.new
29
- # agent.on_approval_required { |tool_name, args| prompt_user(tool_name, args) }
30
- # @return [self]
31
- # @api private
32
- def on_approval_required(&block)
33
- @approval_handler = block
34
- self
35
- end
36
-
37
- # Registers a scope policy callable for this agent instance.
38
- #
39
- # The callable receives +(tool_class, scope, agent)+ and must return
40
- # +:allow+, +:reject+, or +:approve+.
41
- #
42
- # @example Reject all write-scoped tools
43
- # agent.scope_policy = ->(_tc, scope, _agent) { scope == :write ? :reject : :allow }
44
- #
45
- # @param policy [#call]
46
- # @return [void]
47
- # @api public
48
- def scope_policy=(policy)
49
- @scope_policy = policy
50
- end
51
-
52
- # Sets the idempotency store used to guard against duplicate resumes.
53
- #
54
- # The store must respond to:
55
- # - +consumed?(checkpoint_id)+ ⇒ Boolean
56
- # - +consume!(checkpoint_id)+ ⇒ void; raises {Phronomy::CheckpointAlreadyResumedError} on duplicate
57
- #
58
- # Defaults to a per-instance {Phronomy::Agent::CheckpointStore} (in-memory, not thread-safe).
59
- # Assign a shared persistent store when resuming across processes (e.g. Redis-backed).
60
- # Custom stores are responsible for ensuring thread-safety if shared across threads.
61
- #
62
- # @param store [#consumed?, #consume!]
63
- # @return [void]
64
- # @api public
65
- def checkpoint_store=(store)
66
- @checkpoint_store = store
67
- end
68
-
69
- # Resumes a previously suspended invocation from a {Phronomy::Agent::Checkpoint}.
70
- #
71
- # This method reconstructs the conversation state captured at suspension
72
- # time, injects the tool result (executed or denied), and continues the
73
- # LLM loop until it produces a final answer.
74
- #
75
- # @param checkpoint [Phronomy::Agent::Checkpoint] the checkpoint returned by
76
- # the suspended #invoke call
77
- # @param approved [Boolean] +true+ to execute the pending tool; +false+
78
- # to inject a denial message and let the LLM handle it gracefully
79
- # @param config [Hash] same runtime options as #invoke
80
- # @return [Hash] +{ output: String, suspended: false, messages: Array, usage: Phronomy::TokenUsage }+
81
- # or +{ output: nil, suspended: true, checkpoint: Phronomy::Agent::Checkpoint, messages: Array }+
82
- # when a second approval-required tool is encountered during continuation
83
- # @raise [Phronomy::FilterBlockError] when an output filter rejects the value
84
- # @raise [Phronomy::CheckpointAlreadyResumedError] when the checkpoint has already been consumed
85
- # @api private
86
- def resume(checkpoint, approved:, config: {})
87
- # Guard against duplicate resumes using the idempotency store.
88
- _checkpoint_store.consume!(checkpoint.checkpoint_id)
89
- # Build a fresh chat with all tools registered.
90
- chat = build_chat
91
-
92
- # Re-apply system instructions and register tools so the LLM has the
93
- # same persona/context as the original invocation. build_context
94
- # includes all tool classes (static + handoff) via add_capability.
95
- context = build_context(checkpoint.original_input, messages: [])
96
- apply_instructions(chat, context[:system]) if context[:system]
97
- (context[:tool_classes] || []).each { |tc| chat.with_tool(prepare_tool_class(tc)) }
98
-
99
- # Restore the full conversation (history + user + assistant with tool call).
100
- checkpoint.messages.each { |msg| chat.messages << msg }
101
-
102
- # Determine the tool result: execute it or inject a denial string.
103
- tool_result =
104
- if approved
105
- tool_instance = chat.tools[checkpoint.pending_tool_name.to_sym]
106
- tool_instance ? tool_instance.call(checkpoint.pending_tool_args) : "Tool not found."
107
- else
108
- "Tool execution denied."
109
- end
110
-
111
- # Inject the tool result so the LLM can continue.
112
- chat.add_message(
113
- role: :tool,
114
- content: tool_result.to_s,
115
- tool_call_id: checkpoint.pending_tool_call_id
116
- )
117
-
118
- # Re-register the suspension hook so that any further requires_approval
119
- # tools encountered during continuation are intercepted rather than
120
- # executed without approval (cascading / chained approval scenario).
121
- _register_suspension_hook!(chat)
122
-
123
- # Continue the LLM loop. Rescue SuspendSignal so that a second
124
- # approval-required tool produces a new checkpoint instead of running
125
- # without consent.
126
- begin
127
- response = chat.complete
128
- rescue SuspendSignal => signal
129
- new_checkpoint = Checkpoint.new(
130
- checkpoint_id: SecureRandom.uuid,
131
- agent_class: self.class.name,
132
- requested_at: Time.now.utc,
133
- thread_id: checkpoint.thread_id,
134
- original_input: checkpoint.original_input,
135
- messages: chat.messages.dup,
136
- pending_tool_name: signal.tool_name,
137
- pending_tool_args: signal.args,
138
- pending_tool_call_id: signal.tool_call_id
139
- )
140
- return {output: nil, suspended: true, checkpoint: new_checkpoint, messages: chat.messages}
141
- end
142
-
143
- output = response.content
144
- usage = Phronomy::TokenUsage.from_tokens(response.tokens)
145
-
146
- output = run_output_filters!(output)
147
-
148
- {output: output, suspended: false, messages: chat.messages, usage: usage}
149
- end
150
-
151
- private
152
-
153
- # Registers an on_tool_call hook on the chat object that raises SuspendSignal
154
- # when an approval-required tool is about to be executed and no synchronous
155
- # on_approval_required handler has been registered.
156
- #
157
- # Does nothing when:
158
- # - a synchronous handler is already registered (@approval_handler is set), or
159
- # - none of the agent's tools have requires_approval set.
160
- #
161
- # @param chat [RubyLLM::Chat]
162
- # @api private
163
- def _register_suspension_hook!(chat)
164
- return if @approval_handler
165
- return if self.class.tools.none? { |tc| tc.requires_approval }
166
-
167
- chat.on_tool_call do |tool_call|
168
- tool_instance = chat.tools[tool_call.name.to_sym]
169
- if tool_instance&.requires_approval
170
- raise SuspendSignal.new(
171
- tool_name: tool_call.name,
172
- args: tool_call.arguments,
173
- tool_call_id: tool_call.id
174
- )
175
- end
176
- end
177
- end
178
-
179
- # Returns the checkpoint idempotency store for this instance, lazily
180
- # initialising a default in-memory {Phronomy::Agent::CheckpointStore}.
181
- #
182
- # @return [#consumed?, #consume!]
183
- # @api private
184
- def _checkpoint_store
185
- @checkpoint_store ||= CheckpointStore.new
186
- end
187
- end
188
- end
189
- end
190
- end
@@ -1,37 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Agent
5
- # Raised internally inside the on_tool_call hook when an approval-required
6
- # tool is encountered and no synchronous on_approval_required handler has
7
- # been registered. Caught by Agent::Base#invoke_once to produce a
8
- # suspended result hash containing a Checkpoint.
9
- #
10
- # This class is intentionally NOT part of the public API. Callers should
11
- # @api private
12
- # inspect the +:suspended+ key in the result hash returned by #invoke.
13
- #
14
- # @api private
15
- class SuspendSignal < StandardError
16
- # @return [String] the name of the tool that triggered the suspension
17
- attr_reader :tool_name
18
-
19
- # @return [Hash] the arguments the LLM passed to the tool
20
- attr_reader :args
21
-
22
- # @return [String] the tool_call_id from the LLM response
23
- attr_reader :tool_call_id
24
-
25
- # @param tool_name [String]
26
- # @param args [Hash]
27
- # @param tool_call_id [String]
28
- # @api private
29
- def initialize(tool_name:, args:, tool_call_id:)
30
- super("Agent suspended waiting for approval of tool: #{tool_name}")
31
- @tool_name = tool_name
32
- @args = args
33
- @tool_call_id = tool_call_id
34
- end
35
- end
36
- end
37
- end
@@ -1,12 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- # Context management utilities: token estimation, budget calculation, and
5
- # context assembly.
6
- #
7
- # Sub-modules are auto-loaded by Zeitwerk:
8
- # Phronomy::LlmContextWindow::TokenEstimator
9
- # Phronomy::LlmContextWindow::TokenBudget
10
- module Context
11
- end
12
- end
data/lib/phronomy/tool.rb DELETED
@@ -1,9 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # This file is intentionally empty.
4
- # Tool definitions have moved to Phronomy::Agent::Context::Capability.
5
- # See lib/phronomy/agent/context/capability/.
6
- module Phronomy
7
- module Tool
8
- end
9
- end