phronomy 0.13.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 (64) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +155 -0
  3. data/README.md +266 -38
  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/docs/mcp-client.md +75 -0
  8. data/examples/workflows/agent_event_mapping.rb +104 -0
  9. data/examples/workflows/generic_task_event_mapping.rb +58 -0
  10. data/gemfiles/mcp_1_0.gemfile +9 -0
  11. data/lib/phronomy/agent/agent_invocation.rb +385 -0
  12. data/lib/phronomy/agent/agent_invocation_registry.rb +75 -0
  13. data/lib/phronomy/agent/agent_invocation_session_builder.rb +448 -0
  14. data/lib/phronomy/agent/approval_evaluation_request.rb +102 -0
  15. data/lib/phronomy/agent/async_event_api.rb +471 -0
  16. data/lib/phronomy/agent/base.rb +509 -420
  17. data/lib/phronomy/agent/context/capability/base.rb +57 -119
  18. data/lib/phronomy/agent/llm_operation_result.rb +23 -0
  19. data/lib/phronomy/agent/phase_machine_builder.rb +75 -136
  20. data/lib/phronomy/agent/tool_approval_request.rb +121 -0
  21. data/lib/phronomy/agent/tool_call_intercepted.rb +11 -15
  22. data/lib/phronomy/agent/tool_executor.rb +47 -69
  23. data/lib/phronomy/agent/tool_invocation.rb +634 -0
  24. data/lib/phronomy/agent/tool_invocation_session_builder.rb +378 -0
  25. data/lib/phronomy/agent.rb +21 -9
  26. data/lib/phronomy/configuration.rb +58 -53
  27. data/lib/phronomy/diagnostics.rb +1 -1
  28. data/lib/phronomy/engine/concurrency/blocking_adapter_pool.rb +230 -118
  29. data/lib/phronomy/engine/concurrency/cancellation_token.rb +5 -1
  30. data/lib/phronomy/engine/concurrency/pool_registry.rb +8 -3
  31. data/lib/phronomy/engine/event_loop.rb +507 -303
  32. data/lib/phronomy/engine/fsm_session.rb +181 -140
  33. data/lib/phronomy/engine/runtime/deterministic_scheduler.rb +1 -1
  34. data/lib/phronomy/engine/runtime/shutdown_result.rb +62 -0
  35. data/lib/phronomy/engine/runtime/task_registry.rb +62 -15
  36. data/lib/phronomy/engine/runtime.rb +247 -57
  37. data/lib/phronomy/engine/task.rb +5 -10
  38. data/lib/phronomy/event.rb +8 -8
  39. data/lib/phronomy/generator_verifier.rb +253 -142
  40. data/lib/phronomy/invalid_async_entry_action_error.rb +9 -0
  41. data/lib/phronomy/invalid_async_transition_action_error.rb +11 -0
  42. data/lib/phronomy/invalid_async_workflow_action_error.rb +9 -0
  43. data/lib/phronomy/invocation_context.rb +5 -19
  44. data/lib/phronomy/llm_adapter/base.rb +25 -34
  45. data/lib/phronomy/metrics.rb +6 -3
  46. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +54 -89
  47. data/lib/phronomy/stream_callback_error.rb +35 -0
  48. data/lib/phronomy/testing/scheduler_helpers.rb +12 -3
  49. data/lib/phronomy/tools/mcp.rb +410 -81
  50. data/lib/phronomy/version.rb +1 -1
  51. data/lib/phronomy/workflow/phase_machine_builder.rb +129 -182
  52. data/lib/phronomy/workflow.rb +122 -261
  53. data/lib/phronomy/workflow_context.rb +55 -104
  54. data/lib/phronomy/workflow_runner.rb +239 -291
  55. data/lib/phronomy.rb +30 -23
  56. data/scripts/check_readme_runnable.rb +4 -1
  57. metadata +63 -11
  58. data/lib/phronomy/agent/concerns/retryable.rb +0 -103
  59. data/lib/phronomy/agent/context/capability/scope_policy.rb +0 -54
  60. data/lib/phronomy/agent/invocation_context.rb +0 -171
  61. data/lib/phronomy/agent/invocation_session.rb +0 -346
  62. data/lib/phronomy/agent/suspended_session_registry.rb +0 -54
  63. data/lib/phronomy/engine/concurrency/concurrency_gate.rb +0 -157
  64. data/lib/phronomy/engine/concurrency/gate_registry.rb +0 -51
@@ -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