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,634 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "securerandom"
4
+
5
+ module Phronomy
6
+ module Agent
7
+ # Mutable state for one concrete ToolCall.
8
+ #
9
+ # The object owns Tool-domain state. FSMSession owns transitions, while
10
+ # ToolInvocationSessionBuilder converts Task completion into explicit
11
+ # Tool-internal events.
12
+ #
13
+ # @api private
14
+ class ToolInvocation
15
+ AuthorizationOutcome = Struct.new(
16
+ :decision,
17
+ :facts,
18
+ :reason,
19
+ :error,
20
+ :cancelled
21
+ )
22
+
23
+ ExecutionOutcome = Struct.new(
24
+ :result,
25
+ :error,
26
+ :cancelled
27
+ )
28
+
29
+ PREFLIGHT_SETTLED_STATES = %i[
30
+ authorized awaiting_approval rejected failed cancelled completed
31
+ ].freeze
32
+ TERMINAL_STATES = %i[completed rejected failed cancelled].freeze
33
+
34
+ attr_reader :id,
35
+ :parent_agent_invocation_id,
36
+ :agent,
37
+ :tool,
38
+ :tool_name,
39
+ :tool_call_id,
40
+ :raw_arguments,
41
+ :arguments,
42
+ :facts,
43
+ :final_decision,
44
+ :authorization_reason,
45
+ :result,
46
+ :error,
47
+ :status,
48
+ :session_id,
49
+ :phase,
50
+ :config,
51
+ :approval_policy,
52
+ :approval_context,
53
+ :origin,
54
+ :metadata
55
+
56
+ def self.missing(
57
+ parent_agent_invocation_id:,
58
+ agent:,
59
+ tool_call:,
60
+ config: {}
61
+ )
62
+ new(
63
+ parent_agent_invocation_id: parent_agent_invocation_id,
64
+ agent: agent,
65
+ tool: nil,
66
+ tool_call: tool_call,
67
+ config: config
68
+ ).tap do |invocation|
69
+ invocation.send(:complete_missing_tool!)
70
+ end
71
+ end
72
+
73
+ def initialize(
74
+ parent_agent_invocation_id:,
75
+ agent:,
76
+ tool:,
77
+ tool_call:,
78
+ config:,
79
+ approval_policy: nil,
80
+ approval_context: {},
81
+ id: SecureRandom.uuid
82
+ )
83
+ @id = id.to_s
84
+ @parent_agent_invocation_id =
85
+ parent_agent_invocation_id.to_s
86
+ @agent = agent
87
+ @tool = tool
88
+ @tool_name = tool_call.name.to_s
89
+ @tool_call_id =
90
+ tool_call.respond_to?(:id) ? tool_call.id : nil
91
+ raw_arguments =
92
+ if tool_call.respond_to?(:arguments)
93
+ tool_call.arguments || {}
94
+ else
95
+ {}
96
+ end
97
+ @raw_arguments = immutable_copy(raw_arguments)
98
+ @config = config
99
+ @approval_policy = approval_policy
100
+ @approval_context =
101
+ immutable_copy(approval_context || {})
102
+ @origin =
103
+ if tool&.respond_to?(:tool_origin)
104
+ tool.tool_origin.to_sym
105
+ else
106
+ :local
107
+ end
108
+ @metadata =
109
+ immutable_copy(
110
+ tool&.respond_to?(:approval_metadata) ?
111
+ tool.approval_metadata :
112
+ {}
113
+ )
114
+
115
+ @arguments = nil
116
+ @facts = {}.freeze
117
+ @final_decision = nil
118
+ @authorization_reason = nil
119
+ @result = nil
120
+ @error = nil
121
+ @approval_consumed = false
122
+ @status = :created
123
+ @session_id = nil
124
+ @phase = nil
125
+ end
126
+
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
+ # Applies Tool-internal asynchronous completion events on the EventLoop
133
+ # thread. The return value tells FSMSession that the event was consumed by
134
+ # the context before its declared transition is evaluated.
135
+ def handle_fsm_event(event)
136
+ case event.type
137
+ when :authorization_completed
138
+ outcome = event.payload
139
+ if outcome.is_a?(Exception)
140
+ outcome =
141
+ AuthorizationOutcome.new(error: outcome)
142
+ end
143
+ apply_authorization_outcome(outcome)
144
+ true
145
+ when :execution_completed
146
+ outcome = event.payload
147
+ if outcome.is_a?(Exception)
148
+ outcome = ExecutionOutcome.new(
149
+ error: outcome,
150
+ cancelled: outcome.is_a?(Phronomy::CancellationError)
151
+ )
152
+ end
153
+ apply_execution_outcome(outcome)
154
+ true
155
+ else
156
+ false
157
+ end
158
+ end
159
+
160
+ # Deprecated internal compatibility hook. FSMSession no longer calls
161
+ # this method; asynchronous results enter through explicit events.
162
+ def apply_fsm_action_result(outcome)
163
+ event_type =
164
+ case outcome
165
+ when AuthorizationOutcome
166
+ :authorization_completed
167
+ when ExecutionOutcome
168
+ :execution_completed
169
+ else
170
+ return self
171
+ end
172
+ handle_fsm_event(
173
+ Phronomy::Event.new(
174
+ type: event_type,
175
+ target_id: @id,
176
+ payload: outcome
177
+ )
178
+ )
179
+ self
180
+ end
181
+
182
+ def validate!
183
+ return self if terminal?
184
+
185
+ validated, schema_error =
186
+ if @tool.respond_to?(:validate_and_coerce, true)
187
+ @tool.send(:validate_and_coerce, @raw_arguments)
188
+ else
189
+ [@raw_arguments, nil]
190
+ end
191
+
192
+ if schema_error
193
+ if @tool.class.respond_to?(:on_schema_error) &&
194
+ @tool.class.on_schema_error == :raise
195
+ @error = Phronomy::ToolError.new(
196
+ "#{@tool.class.name} schema error: #{schema_error}"
197
+ )
198
+ @status = :failed
199
+ else
200
+ @result = "Schema validation failed: #{schema_error}"
201
+ @status = :completed
202
+ end
203
+ return self
204
+ end
205
+
206
+ @arguments = immutable_copy(validated || {})
207
+ @status = :valid
208
+ self
209
+ rescue => error
210
+ @error = error
211
+ @status = :failed
212
+ self
213
+ end
214
+
215
+ def authorization_task(runtime: Phronomy::Runtime.instance)
216
+ pool = runtime.pool(
217
+ :authorization,
218
+ size: Phronomy.configuration.authorization_pool_size,
219
+ queue_size: Phronomy.configuration.authorization_queue_size
220
+ )
221
+ timeout = @config.fetch(
222
+ :authorization_timeout,
223
+ Phronomy.configuration.authorization_timeout
224
+ )
225
+ cancellation_token = @config[:cancellation_token]
226
+ pending = pool.submit(
227
+ timeout: timeout,
228
+ cancellation_token: cancellation_token,
229
+ on_full: :raise
230
+ ) do
231
+ evaluate_authorization
232
+ end
233
+
234
+ task = Phronomy::Task.deferred(
235
+ name: "tool-authorization:#{@tool_name}"
236
+ )
237
+ pending.on_complete do |outcome, error|
238
+ resolved =
239
+ if error
240
+ authorization_failure_outcome(error)
241
+ else
242
+ outcome
243
+ end
244
+ task.backend.unblock(resolved, nil)
245
+ task.transition!(:completed, value: resolved)
246
+ end
247
+ task
248
+ rescue => error
249
+ task = Phronomy::Task.deferred(
250
+ name: "tool-authorization:#{@tool_name}"
251
+ )
252
+ outcome = authorization_failure_outcome(error)
253
+ task.backend.unblock(outcome, nil)
254
+ task.transition!(:completed, value: outcome)
255
+ task
256
+ end
257
+
258
+ def execution_task(runtime: Phronomy::Runtime.instance)
259
+ pending =
260
+ Phronomy::Agent::ToolExecutor.call_invocation_async(
261
+ tool_invocation: self,
262
+ cancellation_token: @config[:cancellation_token],
263
+ config: @config,
264
+ runtime: runtime
265
+ )
266
+ task = Phronomy::Task.deferred(
267
+ name: "tool-execution:#{@tool_name}"
268
+ )
269
+ pending.on_complete do |result, error|
270
+ outcome =
271
+ if error
272
+ ExecutionOutcome.new(
273
+ error: error,
274
+ cancelled: error.is_a?(
275
+ Phronomy::CancellationError
276
+ )
277
+ )
278
+ else
279
+ ExecutionOutcome.new(result: result)
280
+ end
281
+ task.backend.unblock(outcome, nil)
282
+ task.transition!(:completed, value: outcome)
283
+ end
284
+ task
285
+ rescue => error
286
+ task = Phronomy::Task.deferred(
287
+ name: "tool-execution:#{@tool_name}"
288
+ )
289
+ outcome = ExecutionOutcome.new(
290
+ error: error,
291
+ cancelled: error.is_a?(Phronomy::CancellationError)
292
+ )
293
+ task.backend.unblock(outcome, nil)
294
+ task.transition!(:completed, value: outcome)
295
+ task
296
+ end
297
+
298
+ def mark_awaiting_approval!
299
+ @status = :awaiting_approval
300
+ self
301
+ end
302
+
303
+ def mark_authorized!
304
+ @approval_consumed = true if @status == :awaiting_approval
305
+ @status = :authorized
306
+ self
307
+ end
308
+
309
+ def mark_queued!
310
+ @status = :queued
311
+ self
312
+ end
313
+
314
+ def mark_running!
315
+ @status = :running
316
+ self
317
+ end
318
+
319
+ def mark_rejected!
320
+ @final_decision = :reject
321
+ @status = :rejected
322
+ self
323
+ end
324
+
325
+ def mark_cancelled!
326
+ @status = :cancelled
327
+ self
328
+ end
329
+
330
+ def mark_framework_failed!(error)
331
+ @error = error
332
+ @status = :failed
333
+ self
334
+ end
335
+
336
+ def validation_passed?
337
+ @status == :valid
338
+ end
339
+
340
+ def validation_completed?
341
+ @status == :completed
342
+ end
343
+
344
+ def failed?
345
+ @status == :failed
346
+ end
347
+
348
+ def cancelled?
349
+ @status == :cancelled
350
+ end
351
+
352
+ def rejected?
353
+ @status == :rejected
354
+ end
355
+
356
+ def awaiting_approval?
357
+ @status == :awaiting_approval
358
+ end
359
+
360
+ def authorized?
361
+ @status == :authorized
362
+ end
363
+
364
+ def execution_completed?
365
+ @status == :completed
366
+ end
367
+
368
+ def preflight_settled?
369
+ PREFLIGHT_SETTLED_STATES.include?(@status)
370
+ end
371
+
372
+ def terminal?
373
+ TERMINAL_STATES.include?(@status)
374
+ end
375
+
376
+ def dispatchable?
377
+ return false unless @status == :queued
378
+
379
+ @final_decision == :allow || @approval_consumed
380
+ end
381
+
382
+ def tool_schema
383
+ @tool&.respond_to?(:params_schema) ?
384
+ @tool.params_schema :
385
+ {}
386
+ end
387
+
388
+ def display_arguments
389
+ redact_for_display(@arguments || @raw_arguments)
390
+ end
391
+
392
+ def display_facts
393
+ sensitive_values = sensitive_argument_values
394
+ redact_value(@facts, sensitive_values)
395
+ end
396
+
397
+ private
398
+
399
+ def evaluate_authorization
400
+ request = build_request(
401
+ facts: {},
402
+ default_decision: nil
403
+ )
404
+ facts = evaluate_facts
405
+ request = request.with(facts: facts)
406
+ default_decision = evaluate_default_decision(request)
407
+ request = request.with(
408
+ default_decision: default_decision
409
+ )
410
+ decision =
411
+ if @approval_policy
412
+ @approval_policy.call(request)
413
+ else
414
+ default_decision
415
+ end
416
+ decision = decision.to_sym if decision.respond_to?(:to_sym)
417
+
418
+ unless ApprovalEvaluationRequest::VALID_DECISIONS
419
+ .include?(decision)
420
+ raise Phronomy::ConfigurationError,
421
+ "tool_approval_policy must return :allow, " \
422
+ ":require_approval, or :reject " \
423
+ "(got #{decision.inspect})"
424
+ end
425
+
426
+ reason =
427
+ if decision == :require_approval
428
+ if @origin == :mcp
429
+ "MCP Tool execution requires approval"
430
+ else
431
+ "Tool execution requires approval"
432
+ end
433
+ end
434
+
435
+ AuthorizationOutcome.new(
436
+ decision: decision,
437
+ facts: facts,
438
+ reason: reason
439
+ )
440
+ end
441
+
442
+ def evaluate_facts
443
+ callable =
444
+ if @tool.class.respond_to?(:approval_facts)
445
+ @tool.class.approval_facts
446
+ end
447
+ return {} unless callable
448
+
449
+ value = callable.call(
450
+ @arguments,
451
+ @approval_context
452
+ )
453
+ unless value.nil? || value.is_a?(Hash)
454
+ raise Phronomy::ConfigurationError,
455
+ "approval_facts must return a Hash or nil " \
456
+ "(got #{value.class})"
457
+ end
458
+ immutable_copy(value || {})
459
+ end
460
+
461
+ def evaluate_default_decision(request)
462
+ requirement =
463
+ if @tool.respond_to?(:requires_approval)
464
+ @tool.requires_approval
465
+ else
466
+ false
467
+ end
468
+ if requirement.respond_to?(:call)
469
+ requirement = requirement.call(request)
470
+ end
471
+
472
+ case requirement
473
+ when true
474
+ :require_approval
475
+ when false, nil
476
+ :allow
477
+ else
478
+ raise Phronomy::ConfigurationError,
479
+ "requires_approval callable must return true or false " \
480
+ "(got #{requirement.inspect})"
481
+ end
482
+ end
483
+
484
+ def build_request(facts:, default_decision:)
485
+ ApprovalEvaluationRequest.new(
486
+ agent: @agent,
487
+ agent_invocation_id: @parent_agent_invocation_id,
488
+ tool: @tool,
489
+ tool_name: @tool_name,
490
+ tool_schema: tool_schema,
491
+ tool_invocation_id: @id,
492
+ tool_call_id: @tool_call_id,
493
+ arguments: @arguments,
494
+ facts: facts,
495
+ invocation_context: @approval_context,
496
+ origin: @origin,
497
+ metadata: @metadata,
498
+ default_decision: default_decision
499
+ )
500
+ end
501
+
502
+ def authorization_failure_outcome(error)
503
+ if error.is_a?(Phronomy::TimeoutError) ||
504
+ error.is_a?(Phronomy::TransportError) ||
505
+ error.is_a?(Phronomy::BackpressureError)
506
+ AuthorizationOutcome.new(
507
+ decision: :require_approval,
508
+ facts: {},
509
+ reason:
510
+ "Authorization could not be completed safely: " \
511
+ "#{error.message}"
512
+ )
513
+ elsif error.is_a?(Phronomy::CancellationError)
514
+ AuthorizationOutcome.new(
515
+ error: error,
516
+ cancelled: true
517
+ )
518
+ else
519
+ AuthorizationOutcome.new(error: error)
520
+ end
521
+ end
522
+
523
+ def apply_authorization_outcome(outcome)
524
+ unless outcome.is_a?(AuthorizationOutcome)
525
+ raise Phronomy::Error,
526
+ "Expected AuthorizationOutcome, got #{outcome.class}"
527
+ end
528
+
529
+ @facts = immutable_copy(outcome.facts || {})
530
+ @authorization_reason = outcome.reason
531
+ @error = outcome.error
532
+
533
+ if outcome.cancelled
534
+ @status = :cancelled
535
+ elsif outcome.error
536
+ @status = :failed
537
+ else
538
+ @final_decision = outcome.decision
539
+ @status =
540
+ case outcome.decision
541
+ when :allow
542
+ :authorized
543
+ when :require_approval
544
+ :awaiting_approval
545
+ when :reject
546
+ :rejected
547
+ end
548
+ end
549
+ end
550
+
551
+ def apply_execution_outcome(outcome)
552
+ unless outcome.is_a?(ExecutionOutcome)
553
+ raise Phronomy::Error,
554
+ "Expected ExecutionOutcome, got #{outcome.class}"
555
+ end
556
+
557
+ @result = outcome.result
558
+ @error = outcome.error
559
+ @status =
560
+ if outcome.cancelled
561
+ :cancelled
562
+ elsif outcome.error
563
+ :failed
564
+ else
565
+ :completed
566
+ end
567
+ end
568
+
569
+ def complete_missing_tool!
570
+ @result = "Tool not found: #{@tool_name}"
571
+ @status = :completed
572
+ end
573
+
574
+ def immutable_copy(value)
575
+ case value
576
+ when Hash
577
+ value.each_with_object({}) do |(key, item), result|
578
+ result[immutable_copy(key)] =
579
+ immutable_copy(item)
580
+ end.freeze
581
+ when Array
582
+ value.map { |item| immutable_copy(item) }.freeze
583
+ when String
584
+ value.dup.freeze
585
+ else
586
+ value
587
+ end
588
+ end
589
+
590
+ def redact_for_display(value)
591
+ if @tool&.respond_to?(:redacted_args, true)
592
+ immutable_copy(
593
+ @tool.send(:redacted_args, value || {})
594
+ )
595
+ else
596
+ immutable_copy(value || {})
597
+ end
598
+ end
599
+
600
+ def sensitive_argument_values
601
+ return [] unless @tool&.class&.respond_to?(:redact_params)
602
+
603
+ normalized =
604
+ (@arguments || @raw_arguments || {})
605
+ .transform_keys(&:to_sym)
606
+ @tool.class.redact_params.filter_map do |name|
607
+ normalized[name]
608
+ end
609
+ end
610
+
611
+ def redact_value(value, sensitive_values)
612
+ if sensitive_values.any? { |sensitive| sensitive == value }
613
+ return "[REDACTED]"
614
+ end
615
+
616
+ case value
617
+ when Hash
618
+ value.each_with_object({}) do |(key, item), result|
619
+ result[key] =
620
+ redact_value(item, sensitive_values)
621
+ end.freeze
622
+ when Array
623
+ value.map do |item|
624
+ redact_value(item, sensitive_values)
625
+ end.freeze
626
+ when String
627
+ "[REDACTED]"
628
+ else
629
+ value
630
+ end
631
+ end
632
+ end
633
+ end
634
+ end