phronomy 0.14.0 → 0.15.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 (51) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +77 -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 +553 -0
  14. data/lib/phronomy/agent/base.rb +242 -509
  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 -47
  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
@@ -0,0 +1,82 @@
1
+ # ADR-011: Delegate Transport Timeout and Retry to Adapters
2
+
3
+ ## Status
4
+
5
+ Accepted
6
+
7
+ ## Context
8
+
9
+ Phronomy accumulated execution-policy settings at several layers:
10
+
11
+ - `Agent::Base.retry_policy`, which replayed the complete Agent invocation;
12
+ - `Agent::Base.invoke_timeout`, which imposed an Agent-class deadline;
13
+ - `config[:llm_timeout]`, applied outside RubyLLM;
14
+ - Tool `retry_on` and `config[:tool_timeout]`;
15
+ - `max_parallel_tools` and the unused `InvocationContext#provider_limits`.
16
+
17
+ The policies did not share a reliable resource model. In particular, replaying
18
+ an Agent or Tool can repeat external side effects, and a pool-level timeout does
19
+ not terminate a blocking provider call that is already running. RubyLLM already
20
+ owns LLM request timeout, transient-error retry, backoff, and jitter. Tool
21
+ implementations commonly use clients that own equivalent transport behavior.
22
+
23
+ ## Decision
24
+
25
+ 1. RubyLLM, or another configured LLM adapter, owns LLM transport timeout,
26
+ retry, backoff, jitter, and provider rate-limit handling.
27
+ 2. Phronomy translates final provider errors but does not replay the complete
28
+ Agent invocation.
29
+ 3. Tool implementations or their underlying clients own Tool-specific timeout
30
+ and retry. Phronomy does not provide a generic Tool replay policy.
31
+ 4. Agent classes do not own a default invocation timeout. Callers may pass an
32
+ `InvocationContext` containing a `deadline` or `cancellation_token` when a
33
+ specific root operation needs a boundary.
34
+ 5. Phronomy retains the timeout mechanisms for boundaries it owns: Workflow
35
+ actions, authorization evaluation, Orchestrator aggregate waits, Runtime
36
+ shutdown/drain, and generic concurrency primitives.
37
+ 6. Parallel Tool execution is a boolean mode. When enabled, all authorized
38
+ calls in the intercepted batch are dispatched. Runtime's bounded workers and
39
+ queues remain the coarse process-protection mechanism.
40
+ 7. No resource manager, provider limiter, priority scheduler, or compatibility
41
+ shim is introduced by this change.
42
+
43
+ ## Consequences
44
+
45
+ ### Positive
46
+
47
+ - LLM transport behavior has one configuration authority.
48
+ - Agent and Tool side effects are not implicitly replayed by the framework.
49
+ - Timeout behavior is controlled at the layer capable of interrupting or
50
+ cancelling the underlying I/O safely.
51
+ - Invocation context remains small and contains only values consumed by the
52
+ execution path.
53
+ - The Agent FSM has one invocation attempt and one error propagation path.
54
+
55
+ ### Tradeoffs
56
+
57
+ - Applications must configure RubyLLM explicitly when its defaults are not
58
+ appropriate for production.
59
+ - Custom LLM adapters must document and implement their own transport policy.
60
+ - Tool authors are responsible for idempotency when they choose to retry in a
61
+ Tool or client.
62
+ - Enabling parallel Tool execution dispatches the complete authorized batch;
63
+ applications should leave it disabled when unbounded batch fan-out is not
64
+ acceptable.
65
+
66
+ ## Example
67
+
68
+ ```ruby
69
+ RubyLLM.configure do |config|
70
+ config.request_timeout = 120
71
+ config.max_retries = 3
72
+ config.retry_interval = 0.1
73
+ config.retry_backoff_factor = 2
74
+ config.retry_interval_randomness = 0.5
75
+ end
76
+
77
+ context = Phronomy::InvocationContext.new(
78
+ deadline: Phronomy::Concurrency::Deadline.in(30)
79
+ )
80
+
81
+ result = MyAgent.new.invoke("Hello", invocation_context: context)
82
+ ```
@@ -0,0 +1,104 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "phronomy"
4
+ require "securerandom"
5
+
6
+ class GenerationContext
7
+ include Phronomy::WorkflowContext
8
+
9
+ field :prompt
10
+ field :generation_request_id
11
+ field :answer
12
+ field :error_message
13
+
14
+ # This is application logic. Phronomy transports the event and payload but
15
+ # does not decide which Agent invocation is current or where the result lives.
16
+ def handle_fsm_event(event)
17
+ request_id = event.payload[:generation_request_id]
18
+ return :consume unless request_id == generation_request_id
19
+
20
+ case event.type
21
+ when :generation_completed
22
+ self.answer = event.payload[:agent_result][:output]
23
+ when :generation_failed
24
+ self.error_message = event.payload[:error].message
25
+ end
26
+ false
27
+ end
28
+ end
29
+
30
+ class AnswerAgent < Phronomy::Agent::Base
31
+ model "gpt-4o-mini"
32
+ instructions "Answer clearly and briefly."
33
+ end
34
+
35
+ agent = AnswerAgent.new
36
+ workflow = nil
37
+
38
+ workflow = Phronomy::Workflow.define(GenerationContext) do
39
+ initial :generating
40
+
41
+ state :generating, action: ->(context) {
42
+ request_id = context.generation_request_id
43
+
44
+ # The Agent Task is intentionally not returned from the entry action.
45
+ # on_event is the application-level integration channel.
46
+ agent.invoke_async(
47
+ context.prompt,
48
+ on_event: ->(agent_event) {
49
+ workflow_event =
50
+ case agent_event.type
51
+ when :done
52
+ :generation_completed
53
+ when :error, :timeout, :cancelled, :approval_required
54
+ :generation_failed
55
+ end
56
+ next unless workflow_event
57
+
58
+ workflow.signal(
59
+ thread_id: context.thread_id,
60
+ event: workflow_event,
61
+ payload: {
62
+ generation_request_id: request_id,
63
+ agent_result: (
64
+ agent_event.payload if agent_event.type == :done
65
+ ),
66
+ error:
67
+ agent_event.payload[:error] ||
68
+ Phronomy::Error.new(
69
+ "Agent requested Tool approval"
70
+ )
71
+ }
72
+ )
73
+ }
74
+ )
75
+
76
+ context
77
+ }
78
+
79
+ state :succeeded
80
+ state :failed
81
+
82
+ transition(
83
+ from: :generating,
84
+ on: :generation_completed,
85
+ to: :succeeded
86
+ )
87
+ transition(
88
+ from: :generating,
89
+ on: :generation_failed,
90
+ to: :failed
91
+ )
92
+ transition from: :succeeded, to: :__finish__
93
+ transition from: :failed, to: :__finish__
94
+ end
95
+
96
+ result = workflow.invoke(
97
+ {
98
+ prompt: "What is Run-to-Completion?",
99
+ generation_request_id: SecureRandom.uuid
100
+ },
101
+ config: {thread_id: SecureRandom.uuid}
102
+ )
103
+
104
+ puts(result.answer || result.error_message)
@@ -0,0 +1,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "phronomy"
4
+
5
+ class ImportContext
6
+ include Phronomy::WorkflowContext
7
+
8
+ field :record_count, default: 0
9
+ field :error_message
10
+
11
+ def handle_fsm_event(event)
12
+ case event.type
13
+ when :import_completed
14
+ self.record_count = event.payload[:record_count]
15
+ when :import_failed
16
+ self.error_message = event.payload[:error].message
17
+ end
18
+ false
19
+ end
20
+ end
21
+
22
+ workflow = nil
23
+
24
+ workflow = Phronomy::Workflow.define(ImportContext) do
25
+ initial :importing
26
+
27
+ state :importing, action: ->(context) {
28
+ task = Phronomy::Runtime.instance.spawn do
29
+ # Replace with application-owned asynchronous work.
30
+ 100
31
+ end
32
+
33
+ task.on_complete do |record_count, error|
34
+ workflow.signal(
35
+ thread_id: context.thread_id,
36
+ event: error ? :import_failed : :import_completed,
37
+ payload: {
38
+ record_count: record_count,
39
+ error: error
40
+ }
41
+ )
42
+ end
43
+
44
+ # Do not return task. The state is active after this synchronous entry ends.
45
+ context
46
+ }
47
+
48
+ state :completed
49
+ state :failed
50
+
51
+ transition from: :importing, on: :import_completed, to: :completed
52
+ transition from: :importing, on: :import_failed, to: :failed
53
+ transition from: :completed, to: :__finish__
54
+ transition from: :failed, to: :__finish__
55
+ end
56
+
57
+ result = workflow.invoke({})
58
+ puts "Imported #{result.record_count} records"
@@ -0,0 +1,385 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Phronomy
6
+ module Agent
7
+ # Mutable domain state for one Agent invocation.
8
+ #
9
+ # AgentInvocation interprets Agent-internal events. FSMSession owns the
10
+ # transition mechanics, while Agent::Base projects the terminal outcome to
11
+ # both the Application listener and the returned Task.
12
+ #
13
+ # @api private
14
+ class AgentInvocation
15
+ TOOL_EVENT_TYPES = %i[
16
+ tool_authorized
17
+ tool_approval_required
18
+ tool_completed
19
+ tool_failed
20
+ tool_rejected
21
+ tool_cancelled
22
+ ].freeze
23
+
24
+ LLM_EVENT_TYPES = %i[
25
+ llm_completed
26
+ llm_failed
27
+ ].freeze
28
+
29
+ attr_accessor :input,
30
+ :messages,
31
+ :chat,
32
+ :output,
33
+ :usage,
34
+ :input_blocked,
35
+ :output_blocked,
36
+ :block_error,
37
+ :user_message_sent,
38
+ :event_listener,
39
+ :approval_request,
40
+ :rejected,
41
+ :error
42
+
43
+ attr_reader :id,
44
+ :agent,
45
+ :config,
46
+ :thread_id,
47
+ :approval_policy,
48
+ :approval_listener,
49
+ :pending_tool_calls,
50
+ :tool_invocations,
51
+ :session_id,
52
+ :phase,
53
+ :mode
54
+
55
+ def initialize(
56
+ agent:,
57
+ input:,
58
+ messages:,
59
+ config:,
60
+ approval_policy: nil,
61
+ approval_listener: nil,
62
+ event_listener: nil,
63
+ stream_listener: nil,
64
+ mode: nil,
65
+ id: nil
66
+ )
67
+ @agent = agent
68
+ @input = input
69
+ @messages = Array(messages)
70
+ @config = config
71
+ @thread_id = config[:thread_id]
72
+ @id = (id || config[:agent_invocation_id] || SecureRandom.uuid).to_s
73
+ invocation_context = config[:invocation_context]
74
+ invocation_policy = if invocation_context&.respond_to?(:approval_policy)
75
+ invocation_context.approval_policy
76
+ end
77
+ @approval_policy = invocation_policy || approval_policy
78
+ @approval_listener = approval_listener
79
+ @event_listener = event_listener || stream_listener
80
+ @mode = (mode || (stream_listener ? :stream : :invoke)).to_sym
81
+
82
+ @chat = nil
83
+ @output = nil
84
+ @usage = nil
85
+ @input_blocked = false
86
+ @output_blocked = false
87
+ @block_error = nil
88
+ @user_message_sent = false
89
+ @pending_tool_calls = []
90
+ @tool_invocations = []
91
+ @approval_request = nil
92
+ @rejected = false
93
+ @human_rejection = false
94
+ @approval_resume_in_progress = false
95
+ @pending_approval_resolution_ids = []
96
+ @error = nil
97
+ @session_id = nil
98
+ @phase = nil
99
+ end
100
+
101
+ # Compatibility aliases for existing internal callers.
102
+ def stream_listener
103
+ @event_listener
104
+ end
105
+
106
+ def stream_listener=(listener)
107
+ @event_listener = listener
108
+ end
109
+
110
+ def streaming?
111
+ @mode == :stream
112
+ end
113
+
114
+ def set_graph_metadata(thread_id: nil, phase: nil)
115
+ @session_id = thread_id if thread_id
116
+ @phase = phase
117
+ end
118
+
119
+ def pending_tool_calls=(calls)
120
+ @pending_tool_calls = Array(calls)
121
+ end
122
+
123
+ def tool_invocations=(invocations)
124
+ @tool_invocations = Array(invocations)
125
+ end
126
+
127
+ def clear_tool_batch!
128
+ @pending_tool_calls = []
129
+ @tool_invocations = []
130
+ @approval_request = nil
131
+ end
132
+
133
+ def handle_fsm_event(event)
134
+ if event.type == :llm_stream_chunk
135
+ deliver_event(
136
+ StreamEvent.new(
137
+ type: :token,
138
+ payload: {content: event.payload.fetch(:content)}
139
+ )
140
+ )
141
+ return true
142
+ end
143
+
144
+ if LLM_EVENT_TYPES.include?(event.type)
145
+ apply_llm_event(event)
146
+ return true
147
+ end
148
+
149
+ return false unless TOOL_EVENT_TYPES.include?(event.type)
150
+
151
+ invocation = tool_invocation(
152
+ event.payload&.fetch(:tool_invocation_id, nil)
153
+ )
154
+ return true unless invocation
155
+
156
+ @error ||= invocation.error if invocation.failed? || invocation.cancelled?
157
+ @rejected = true if invocation.rejected?
158
+ if @approval_resume_in_progress &&
159
+ @pending_approval_resolution_ids.delete(invocation.id)
160
+ if @pending_approval_resolution_ids.empty?
161
+ @approval_resume_in_progress = false
162
+ end
163
+ end
164
+ true
165
+ end
166
+
167
+ # Deprecated internal compatibility hook. FSMSession no longer calls
168
+ # this method; asynchronous results enter through explicit events.
169
+ def apply_fsm_action_result(result)
170
+ event_type =
171
+ if result.respond_to?(:error) &&
172
+ result.error &&
173
+ !result.error.is_a?(ToolCallIntercepted)
174
+ :llm_failed
175
+ else
176
+ :llm_completed
177
+ end
178
+ handle_fsm_event(
179
+ Phronomy::Event.new(
180
+ type: event_type,
181
+ target_id: @id,
182
+ payload: result
183
+ )
184
+ )
185
+ self
186
+ end
187
+
188
+ def accept_tool_calls!(tool_calls)
189
+ @user_message_sent = true
190
+ @pending_tool_calls = Array(tool_calls)
191
+ @messages = @chat.messages
192
+ @pending_tool_calls.each do |tool_call|
193
+ deliver_event(
194
+ StreamEvent.new(
195
+ type: :tool_call,
196
+ payload: {tool_call: tool_call}
197
+ )
198
+ )
199
+ end
200
+ self
201
+ end
202
+
203
+ def apply_llm_response!(response)
204
+ unless response
205
+ raise Phronomy::Error, "LLM operation completed without a response"
206
+ end
207
+
208
+ @user_message_sent = true
209
+ @output = response.content
210
+ @usage = Phronomy::TokenUsage.from_tokens(response.tokens)
211
+ @messages = @chat.messages
212
+ @pending_tool_calls = []
213
+ self
214
+ end
215
+
216
+ def tool_invocation(id)
217
+ @tool_invocations.find { |invocation| invocation.id == id.to_s }
218
+ end
219
+
220
+ def merge_config!(values)
221
+ @config.merge!(values) unless values.empty?
222
+ self
223
+ end
224
+
225
+ def approval_context
226
+ return @config[:approval_context] if @config[:approval_context]
227
+
228
+ context = @config[:invocation_context]
229
+ return {} unless context
230
+
231
+ %i[
232
+ thread_id session_id user_id token_budget
233
+ task_id parent_task_id
234
+ ].each_with_object({}) do |name, result|
235
+ if context.respond_to?(name)
236
+ result[name] = context.public_send(name)
237
+ end
238
+ end
239
+ end
240
+
241
+ def begin_approval_resume!(approved:)
242
+ @human_rejection = !approved
243
+ @pending_approval_resolution_ids = @tool_invocations
244
+ .select(&:awaiting_approval?)
245
+ .map(&:id)
246
+ @approval_resume_in_progress =
247
+ !@pending_approval_resolution_ids.empty?
248
+ self
249
+ end
250
+
251
+ def prepare_approval_request!
252
+ @approval_request = ToolApprovalRequest.build(self)
253
+ self
254
+ end
255
+
256
+ def record_tool_results!
257
+ @tool_invocations.each do |invocation|
258
+ @chat.add_message(
259
+ role: :tool,
260
+ content: invocation.result.to_s,
261
+ tool_call_id: invocation.tool_call_id
262
+ )
263
+ deliver_event(
264
+ StreamEvent.new(
265
+ type: :tool_result,
266
+ payload: {
267
+ tool_call_id: invocation.tool_call_id,
268
+ tool_name: invocation.tool_name,
269
+ tool_result: invocation.result
270
+ }
271
+ )
272
+ )
273
+ end
274
+ @messages = @chat.messages
275
+ clear_tool_batch!
276
+ self
277
+ end
278
+
279
+ def input_passed?
280
+ !@input_blocked
281
+ end
282
+
283
+ def input_blocked?
284
+ @input_blocked
285
+ end
286
+
287
+ def output_passed?
288
+ !@output_blocked
289
+ end
290
+
291
+ def output_blocked?
292
+ @output_blocked
293
+ end
294
+
295
+ def tool_call_pending?
296
+ !@pending_tool_calls.empty?
297
+ end
298
+
299
+ def preflight_complete?
300
+ !@tool_invocations.empty? &&
301
+ @tool_invocations.all?(&:preflight_settled?)
302
+ end
303
+
304
+ def approval_required?
305
+ !@human_rejection &&
306
+ !@approval_resume_in_progress &&
307
+ preflight_complete? &&
308
+ @tool_invocations.any?(&:awaiting_approval?)
309
+ end
310
+
311
+ def ready_to_dispatch?
312
+ !@approval_resume_in_progress &&
313
+ preflight_complete? &&
314
+ @tool_invocations.none?(&:awaiting_approval?) &&
315
+ @tool_invocations.none?(&:rejected?) &&
316
+ @tool_invocations.none?(&:failed?) &&
317
+ @tool_invocations.none?(&:cancelled?) &&
318
+ @tool_invocations.any?(&:authorized?)
319
+ end
320
+
321
+ def tool_batch_terminal?
322
+ !@tool_invocations.empty? &&
323
+ @tool_invocations.all?(&:terminal?)
324
+ end
325
+
326
+ def tool_batch_failed?
327
+ return false if @human_rejection || @approval_resume_in_progress
328
+
329
+ no_rejection = @tool_invocations.none?(&:rejected?)
330
+ preflight_failure =
331
+ preflight_complete? &&
332
+ @tool_invocations.any? do |invocation|
333
+ invocation.failed? || invocation.cancelled?
334
+ end
335
+ terminal_failure =
336
+ tool_batch_terminal? &&
337
+ @tool_invocations.any? do |invocation|
338
+ invocation.failed? || invocation.cancelled?
339
+ end
340
+ no_rejection && (preflight_failure || terminal_failure)
341
+ end
342
+
343
+ def tool_batch_rejected?
344
+ return false unless @tool_invocations.any?(&:rejected?)
345
+
346
+ @human_rejection ? tool_batch_terminal? : preflight_complete?
347
+ end
348
+
349
+ def tool_batch_completed?
350
+ tool_batch_terminal? &&
351
+ @tool_invocations.all?(&:execution_completed?)
352
+ end
353
+
354
+ private
355
+
356
+ def apply_llm_event(event)
357
+ result = event.payload
358
+ unless result.is_a?(LLMOperationResult)
359
+ raise Phronomy::Error,
360
+ "Expected LLMOperationResult, got #{result.class}"
361
+ end
362
+
363
+ if event.type == :llm_failed
364
+ @error = result.error ||
365
+ Phronomy::Error.new("LLM operation failed without an error")
366
+ return
367
+ end
368
+
369
+ if result.error
370
+ if result.error.is_a?(ToolCallIntercepted)
371
+ accept_tool_calls!(result.error.tool_calls)
372
+ else
373
+ @error = result.error
374
+ end
375
+ else
376
+ apply_llm_response!(result.response)
377
+ end
378
+ end
379
+
380
+ def deliver_event(event)
381
+ @event_listener&.call(event)
382
+ end
383
+ end
384
+ end
385
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # In-process registry for suspended AgentInvocation aggregates.
6
+ #
7
+ # The registry is the single in-process source for pending Human approval.
8
+ # Cross-process persistence remains outside the scope of this implementation.
9
+ #
10
+ # @api private
11
+ module AgentInvocationRegistry
12
+ Entry = Struct.new(:invocation, :approval_request)
13
+
14
+ @entries = {}
15
+ @approval_index = {}
16
+ @mutex = Mutex.new
17
+
18
+ def self.store_suspended(invocation, approval_request)
19
+ @mutex.synchronize do
20
+ invocation_id = invocation.id
21
+ request_id = approval_request.id
22
+ if @entries.key?(invocation_id) || @approval_index.key?(request_id)
23
+ raise Phronomy::Error,
24
+ "Suspended AgentInvocation is already registered: #{invocation_id}"
25
+ end
26
+
27
+ @entries[invocation_id] = Entry.new(
28
+ invocation: invocation,
29
+ approval_request: approval_request
30
+ )
31
+ @approval_index[request_id] = invocation_id
32
+ end
33
+ approval_request
34
+ end
35
+
36
+ # Atomically removes and returns one pending approval aggregate.
37
+ # Duplicate approval commands therefore cannot execute the Tool twice.
38
+ def self.consume_approval(agent_invocation_id, approval_request_id)
39
+ @mutex.synchronize do
40
+ indexed_invocation_id = @approval_index[approval_request_id.to_s]
41
+ return nil unless indexed_invocation_id == agent_invocation_id.to_s
42
+
43
+ entry = @entries.delete(indexed_invocation_id)
44
+ return nil unless entry
45
+
46
+ @approval_index.delete(entry.approval_request.id)
47
+ entry
48
+ end
49
+ end
50
+
51
+ def self.lookup(agent_invocation_id)
52
+ @mutex.synchronize { @entries[agent_invocation_id.to_s] }
53
+ end
54
+
55
+ def self.exists?(agent_invocation_id)
56
+ @mutex.synchronize { @entries.key?(agent_invocation_id.to_s) }
57
+ end
58
+
59
+ def self.remove_terminal(agent_invocation_id)
60
+ @mutex.synchronize do
61
+ entry = @entries.delete(agent_invocation_id.to_s)
62
+ @approval_index.delete(entry.approval_request.id) if entry
63
+ entry
64
+ end
65
+ end
66
+
67
+ def self.clear!
68
+ @mutex.synchronize do
69
+ @entries.clear
70
+ @approval_index.clear
71
+ end
72
+ end
73
+ end
74
+ end
75
+ end