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,471 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ # Symmetric Agent async event contract layered onto Agent::Base.
6
+ #
7
+ # invoke_async and stream_async share lifecycle/tool events. stream_async
8
+ # additionally emits :token events. The returned Task remains a normal Task
9
+ # and is settled after the terminal event listener returns.
10
+ #
11
+ # @api private
12
+ module AsyncEventApi
13
+ def invoke(
14
+ input,
15
+ messages: [],
16
+ thread_id: nil,
17
+ config: {},
18
+ invocation_context: nil,
19
+ on_event: nil
20
+ )
21
+ if invocation_context
22
+ thread_id, config = _apply_invocation_context(
23
+ thread_id,
24
+ config,
25
+ invocation_context
26
+ )
27
+ end
28
+ _check_scheduler_reentrancy(:invoke, :invoke_async)
29
+
30
+ trace(
31
+ "agent.invoke",
32
+ input: input,
33
+ **_build_caller_meta(config)
34
+ ) do |_span|
35
+ result = invoke_async(
36
+ input,
37
+ messages: messages,
38
+ thread_id: thread_id,
39
+ config: config,
40
+ on_event: on_event
41
+ ).wait_result
42
+ [result, result[:usage]]
43
+ end
44
+ end
45
+
46
+ def invoke_async(
47
+ input,
48
+ messages: [],
49
+ thread_id: nil,
50
+ config: {},
51
+ invocation_context: nil,
52
+ on_tool_approval_required: nil,
53
+ on_event: nil
54
+ )
55
+ if invocation_context
56
+ thread_id, config = _apply_invocation_context(
57
+ thread_id,
58
+ config,
59
+ invocation_context
60
+ )
61
+ end
62
+
63
+ result_task = Phronomy::Task.deferred(
64
+ name:
65
+ "agent-#{(self.class.name || "anonymous").downcase}-async"
66
+ )
67
+ approval_snapshot = _approval_configuration_snapshot(
68
+ on_tool_approval_required
69
+ )
70
+ _start_invocation(
71
+ result_task,
72
+ input,
73
+ messages: messages,
74
+ thread_id: thread_id,
75
+ config: config,
76
+ approval_snapshot: approval_snapshot,
77
+ mode: :invoke,
78
+ on_event: on_event
79
+ )
80
+ result_task
81
+ end
82
+
83
+ def stream_async(
84
+ input,
85
+ messages: [],
86
+ thread_id: nil,
87
+ config: {},
88
+ invocation_context: nil,
89
+ on_tool_approval_required: nil,
90
+ on_event: nil,
91
+ &block
92
+ )
93
+ listener = resolve_event_listener(on_event, block)
94
+ unless listener
95
+ raise ArgumentError,
96
+ "stream_async requires on_event: or a block"
97
+ end
98
+
99
+ if invocation_context
100
+ thread_id, config = _apply_invocation_context(
101
+ thread_id,
102
+ config,
103
+ invocation_context
104
+ )
105
+ end
106
+
107
+ result_task = Phronomy::Task.deferred(
108
+ name:
109
+ "agent-#{(self.class.name || "anonymous").downcase}" \
110
+ "-stream-async"
111
+ )
112
+ approval_snapshot = _approval_configuration_snapshot(
113
+ on_tool_approval_required
114
+ )
115
+ _start_invocation(
116
+ result_task,
117
+ input,
118
+ messages: messages,
119
+ thread_id: thread_id,
120
+ config: config,
121
+ approval_snapshot: approval_snapshot,
122
+ mode: :stream,
123
+ on_event: listener
124
+ )
125
+ result_task
126
+ end
127
+
128
+ def stream(
129
+ input,
130
+ messages: [],
131
+ thread_id: nil,
132
+ config: {},
133
+ invocation_context: nil,
134
+ on_tool_approval_required: nil,
135
+ on_event: nil,
136
+ &block
137
+ )
138
+ listener = resolve_event_listener(on_event, block)
139
+ unless listener
140
+ raise ArgumentError,
141
+ "stream requires on_event: or a block"
142
+ end
143
+
144
+ if invocation_context
145
+ thread_id, config = _apply_invocation_context(
146
+ thread_id,
147
+ config,
148
+ invocation_context
149
+ )
150
+ end
151
+ _check_scheduler_reentrancy(:stream, :stream_async)
152
+
153
+ trace(
154
+ "agent.stream",
155
+ input: input,
156
+ **_build_caller_meta(config)
157
+ ) do |_span|
158
+ result = stream_async(
159
+ input,
160
+ messages: messages,
161
+ thread_id: thread_id,
162
+ config: config,
163
+ on_tool_approval_required:
164
+ on_tool_approval_required,
165
+ on_event: listener
166
+ ).wait_result
167
+ [result, result[:usage]]
168
+ end
169
+ end
170
+
171
+ private
172
+
173
+ def resolve_event_listener(keyword_listener, block_listener)
174
+ if keyword_listener && block_listener
175
+ raise ArgumentError,
176
+ "Provide either on_event: or a block, not both"
177
+ end
178
+ keyword_listener || block_listener
179
+ end
180
+
181
+ # Installs the Application listener before EventLoop admission.
182
+ # Cancellation is checked by the first Agent entry action on the EventLoop
183
+ # thread so even pre-cancelled invocations produce a terminal event before
184
+ # the returned Task settles.
185
+ def _start_invocation(
186
+ result_task,
187
+ input,
188
+ messages:,
189
+ thread_id:,
190
+ config:,
191
+ approval_snapshot:,
192
+ mode: :invoke,
193
+ on_event: nil
194
+ )
195
+ effective_config =
196
+ thread_id ? config.merge(thread_id: thread_id) : config
197
+ runtime = Phronomy::Runtime.instance
198
+ event_loop = runtime.event_loop
199
+ session = Agent::AgentInvocationSessionBuilder.build(
200
+ agent: self,
201
+ input: input,
202
+ messages: messages,
203
+ config: effective_config,
204
+ approval_policy: approval_snapshot[:policy],
205
+ approval_listener: approval_snapshot[:listener],
206
+ mode: mode,
207
+ on_event: on_event,
208
+ runtime: runtime
209
+ )
210
+ callback_error_policy =
211
+ Phronomy.configuration.stream_callback_error_policy
212
+ source_task = Phronomy::Task.deferred(
213
+ name: "#{result_task.name}-source"
214
+ )
215
+ source_task.on_complete do |invocation, error|
216
+ completed_invocation = invocation || session.context
217
+ _handle_agent_completion(
218
+ result_task: result_task,
219
+ invocation: completed_invocation,
220
+ error: error,
221
+ mode: mode,
222
+ listener: on_event,
223
+ event_loop: event_loop,
224
+ callback_error_policy: callback_error_policy
225
+ )
226
+ end
227
+
228
+ event_loop.register(session, completion: source_task)
229
+ rescue => error
230
+ _fail_result_task(result_task, error)
231
+ end
232
+
233
+ def _handle_agent_completion(
234
+ result_task:,
235
+ invocation:,
236
+ error:,
237
+ mode:,
238
+ listener:,
239
+ event_loop:,
240
+ callback_error_policy:
241
+ )
242
+ if listener && !event_loop.current?
243
+ completion_error = error || Phronomy::Error.new(
244
+ "Agent event delivery occurred outside the EventLoop"
245
+ )
246
+ _fail_result_task(
247
+ result_task,
248
+ _translated_error(completion_error)
249
+ )
250
+ return
251
+ end
252
+
253
+ result = nil
254
+ execution_error = nil
255
+ begin
256
+ raise error if error
257
+
258
+ result = _extract_invoke_result(invocation)
259
+ rescue => caught
260
+ execution_error = _translated_error(caught)
261
+ end
262
+ if execution_error
263
+ execution_error = normalize_terminal_error(
264
+ execution_error,
265
+ invocation
266
+ )
267
+ end
268
+
269
+ if execution_error
270
+ terminal_event = StreamEvent.new(
271
+ type: terminal_error_event_type(execution_error),
272
+ payload: {error: execution_error}
273
+ )
274
+ callback_error = _deliver_stream_event(
275
+ listener,
276
+ terminal_event
277
+ )
278
+ if callback_error
279
+ report_agent_event_callback_error(
280
+ callback_error,
281
+ event: terminal_event,
282
+ invocation_id: invocation&.id,
283
+ callback_error_policy: callback_error_policy
284
+ )
285
+ end
286
+
287
+ # Listener failure never replaces an Agent execution failure.
288
+ _fail_result_task(result_task, execution_error)
289
+ return
290
+ end
291
+
292
+ terminal_event = _build_stream_terminal_event(result)
293
+ callback_error = _deliver_stream_event(
294
+ listener,
295
+ terminal_event
296
+ )
297
+
298
+ unless callback_error
299
+ _complete_result_task(result_task, result)
300
+ return
301
+ end
302
+
303
+ report_agent_event_callback_error(
304
+ callback_error,
305
+ event: terminal_event,
306
+ invocation_id: invocation&.id,
307
+ callback_error_policy: callback_error_policy
308
+ )
309
+
310
+ if callback_error_policy == :fail_task
311
+ wrapped = _build_stream_callback_error(
312
+ event_type: terminal_event.type,
313
+ callback_error: callback_error,
314
+ result: result
315
+ )
316
+ _fail_result_task(result_task, wrapped)
317
+ else
318
+ _complete_result_task(result_task, result)
319
+ end
320
+ end
321
+
322
+ def terminal_error_event_type(error)
323
+ case error
324
+ when Phronomy::TimeoutError
325
+ :timeout
326
+ when Phronomy::CancellationError
327
+ :cancelled
328
+ else
329
+ :error
330
+ end
331
+ end
332
+
333
+ def normalize_terminal_error(error, invocation)
334
+ return error unless error.is_a?(Phronomy::CancellationError)
335
+ return error unless invocation_timeout_expired?(invocation)
336
+
337
+ timeout_error = Phronomy::TimeoutError.new(error.message)
338
+ timeout_error.set_backtrace(error.backtrace)
339
+ timeout_error
340
+ end
341
+
342
+ def invocation_timeout_expired?(invocation)
343
+ config = invocation&.config || {}
344
+ invocation_context = config[:invocation_context]
345
+ deadline = invocation_context&.deadline
346
+ return true if deadline&.expired?
347
+
348
+ token = config[:cancellation_token]
349
+ return false unless token
350
+
351
+ remaining =
352
+ if token.respond_to?(:remaining_monotonic_seconds)
353
+ token.remaining_monotonic_seconds
354
+ end
355
+ return true if remaining == 0.0
356
+
357
+ wall_deadline = token.deadline if token.respond_to?(:deadline)
358
+ wall_deadline && Time.now >= wall_deadline
359
+ end
360
+
361
+ def report_agent_event_callback_error(
362
+ callback_error,
363
+ event:,
364
+ invocation_id:,
365
+ callback_error_policy:
366
+ )
367
+ _report_stream_callback_error(
368
+ callback_error,
369
+ event: event,
370
+ invocation_id: invocation_id,
371
+ callback_error_policy: callback_error_policy
372
+ )
373
+ end
374
+
375
+ def _register_tool_invocation_session(
376
+ event_loop,
377
+ runtime,
378
+ child,
379
+ session
380
+ )
381
+ completion = Phronomy::Task.deferred(
382
+ name: "tool-session:#{child.id}"
383
+ )
384
+ completion.on_complete do |_result, error|
385
+ next unless error
386
+
387
+ child.mark_framework_failed!(error)
388
+ runtime.event_loop.post_to_session(
389
+ Phronomy::Event.new(
390
+ type: :tool_failed,
391
+ target_id: child.parent_agent_invocation_id,
392
+ payload: {tool_invocation_id: child.id}
393
+ )
394
+ )
395
+ end
396
+ event_loop.register(session, completion: completion)
397
+ end
398
+
399
+ def _start_approval_resume(
400
+ result_task,
401
+ invocation,
402
+ approved:,
403
+ config:
404
+ )
405
+ invocation.merge_config!(config)
406
+ invocation.begin_approval_resume!(approved: approved)
407
+ runtime = Phronomy::Runtime.instance
408
+ event_loop = runtime.event_loop
409
+ source_task = Phronomy::Task.deferred(
410
+ name: "#{result_task.name}-source"
411
+ )
412
+ parent_session =
413
+ Agent::AgentInvocationSessionBuilder.build_for_resume(
414
+ agent_invocation: invocation,
415
+ resume_event: :resume,
416
+ resume_phase: :suspended,
417
+ runtime: runtime
418
+ )
419
+ listener = invocation.event_listener
420
+ mode = invocation.mode
421
+ callback_error_policy =
422
+ Phronomy.configuration.stream_callback_error_policy
423
+
424
+ source_task.on_complete do |completed_invocation, error|
425
+ invocation_result = completed_invocation || parent_session.context
426
+ _handle_agent_completion(
427
+ result_task: result_task,
428
+ invocation: invocation_result,
429
+ error: error,
430
+ mode: mode,
431
+ listener: listener,
432
+ event_loop: event_loop,
433
+ callback_error_policy: callback_error_policy
434
+ )
435
+ end
436
+
437
+ event_loop.register(
438
+ parent_session,
439
+ completion: source_task
440
+ )
441
+
442
+ invocation.tool_invocations.each do |child|
443
+ child_session =
444
+ if child.awaiting_approval?
445
+ Agent::ToolInvocationSessionBuilder.build_for_resume(
446
+ tool_invocation: child,
447
+ resume_event: approved ? :approve : :reject,
448
+ resume_phase: :awaiting_approval,
449
+ runtime: runtime
450
+ )
451
+ elsif !approved && child.authorized?
452
+ Agent::ToolInvocationSessionBuilder.build_for_resume(
453
+ tool_invocation: child,
454
+ resume_event: :cancel,
455
+ resume_phase: :authorized,
456
+ runtime: runtime
457
+ )
458
+ end
459
+ if child_session
460
+ _register_tool_invocation_session(
461
+ event_loop,
462
+ runtime,
463
+ child,
464
+ child_session
465
+ )
466
+ end
467
+ end
468
+ end
469
+ end
470
+ end
471
+ end