phronomy 0.10.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +41 -1
  3. data/README.md +35 -24
  4. data/benchmark/bench_agent_invoke.rb +1 -0
  5. data/lib/phronomy/agent/base.rb +223 -95
  6. data/lib/phronomy/agent/concerns/error_translation.rb +1 -1
  7. data/lib/phronomy/agent/concerns/filterable.rb +184 -0
  8. data/lib/phronomy/agent/concerns/retryable.rb +9 -5
  9. data/lib/phronomy/agent/context/capability/base.rb +2 -2
  10. data/lib/phronomy/agent/invocation_context.rb +171 -0
  11. data/lib/phronomy/agent/invocation_session.rb +337 -0
  12. data/lib/phronomy/agent/phase_machine_builder.rb +189 -0
  13. data/lib/phronomy/agent/suspended_session_registry.rb +54 -0
  14. data/lib/phronomy/agent/tool_call_intercepted.rb +26 -0
  15. data/lib/phronomy/configuration.rb +0 -6
  16. data/lib/phronomy/{concurrency → engine/concurrency}/async_queue.rb +68 -5
  17. data/lib/phronomy/{concurrency → engine/concurrency}/blocking_adapter_pool.rb +9 -3
  18. data/lib/phronomy/{event_loop.rb → engine/event_loop.rb} +71 -37
  19. data/lib/phronomy/engine/fsm_session.rb +248 -0
  20. data/lib/phronomy/{runtime → engine/runtime}/deterministic_scheduler.rb +28 -1
  21. data/lib/phronomy/{runtime → engine/runtime}/fake_scheduler.rb +1 -1
  22. data/lib/phronomy/{runtime.rb → engine/runtime.rb} +2 -4
  23. data/lib/phronomy/{task → engine/task}/backend.rb +2 -2
  24. data/lib/phronomy/engine/task/deferred_backend.rb +73 -0
  25. data/lib/phronomy/{task → engine/task}/fiber_backend.rb +1 -1
  26. data/lib/phronomy/{task → engine/task}/immediate_backend.rb +1 -1
  27. data/lib/phronomy/{task → engine/task}/mapped_backend.rb +15 -3
  28. data/lib/phronomy/{task → engine/task}/thread_backend.rb +1 -1
  29. data/lib/phronomy/{task.rb → engine/task.rb} +19 -7
  30. data/lib/phronomy/eval/scorer/llm_judge.rb +1 -1
  31. data/lib/phronomy/filter/base.rb +56 -0
  32. data/lib/phronomy/{guardrail/prompt_injection_guardrail.rb → filter/prompt_injection_filter.rb} +20 -13
  33. data/lib/phronomy/filter.rb +5 -0
  34. data/lib/phronomy/generator_verifier.rb +21 -13
  35. data/lib/phronomy/multi_agent/orchestrator.rb +4 -4
  36. data/lib/phronomy/multi_agent/parallel_tool_chat.rb +3 -3
  37. data/lib/phronomy/multi_agent/team_coordinator.rb +1 -2
  38. data/lib/phronomy/tools/mcp.rb +1 -1
  39. data/lib/phronomy/version.rb +1 -1
  40. data/lib/phronomy/workflow/phase_machine_builder.rb +24 -13
  41. data/lib/phronomy/workflow.rb +6 -3
  42. data/lib/phronomy/workflow_context.rb +7 -5
  43. data/lib/phronomy/workflow_runner.rb +77 -27
  44. data/lib/phronomy.rb +24 -13
  45. data/scripts/api_snapshot.rb +2 -2
  46. metadata +42 -44
  47. data/lib/phronomy/agent/checkpoint.rb +0 -208
  48. data/lib/phronomy/agent/checkpoint_store.rb +0 -97
  49. data/lib/phronomy/agent/concerns/guardrailable.rb +0 -45
  50. data/lib/phronomy/agent/concerns/suspendable.rb +0 -190
  51. data/lib/phronomy/agent/suspend_signal.rb +0 -37
  52. data/lib/phronomy/context.rb +0 -12
  53. data/lib/phronomy/guardrail/base.rb +0 -45
  54. data/lib/phronomy/guardrail/input_guardrail.rb +0 -19
  55. data/lib/phronomy/guardrail/output_guardrail.rb +0 -19
  56. data/lib/phronomy/guardrail.rb +0 -7
  57. data/lib/phronomy/tool.rb +0 -9
  58. data/lib/phronomy/workflow/fsm_session.rb +0 -249
  59. /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_scope.rb +0 -0
  60. /data/lib/phronomy/{concurrency → engine/concurrency}/cancellation_token.rb +0 -0
  61. /data/lib/phronomy/{concurrency → engine/concurrency}/concurrency_gate.rb +0 -0
  62. /data/lib/phronomy/{concurrency → engine/concurrency}/deadline.rb +0 -0
  63. /data/lib/phronomy/{concurrency → engine/concurrency}/gate_registry.rb +0 -0
  64. /data/lib/phronomy/{concurrency → engine/concurrency}/pool_registry.rb +0 -0
  65. /data/lib/phronomy/{runtime → engine/runtime}/runtime_metrics.rb +0 -0
  66. /data/lib/phronomy/{runtime → engine/runtime}/scheduler.rb +0 -0
  67. /data/lib/phronomy/{runtime → engine/runtime}/scheduler_timer_adapter.rb +0 -0
  68. /data/lib/phronomy/{runtime → engine/runtime}/task_registry.rb +0 -0
  69. /data/lib/phronomy/{runtime → engine/runtime}/thread_scheduler.rb +0 -0
  70. /data/lib/phronomy/{runtime → engine/runtime}/timer_queue.rb +0 -0
  71. /data/lib/phronomy/{runtime → engine/runtime}/timer_service.rb +0 -0
  72. /data/lib/phronomy/{task_group.rb → engine/task_group.rb} +0 -0
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f8bcaf54fe256791053b04c22df3b58595d267b2c0ee16b67369fe2ecb36f19d
4
- data.tar.gz: 9be01ec03a79ec5d557a3c51fc442992cd012a07e682a43cfa4c9e0358110fc8
3
+ metadata.gz: 2a6262c12bc063819c0e1656699f47666b06b182747d6ce85843d2a74988c358
4
+ data.tar.gz: 6529858071913e61a6a840fe03e36620322bfddd50e415b054314124f9b7fcd1
5
5
  SHA512:
6
- metadata.gz: 44de75b5cc59ddf380aeb0be3abcf2a2c566cb8a1db6c35540525b86c74e7e0c06e75a3581e30276a25eea7f5f0334226e05b54d7d9694ed8f2b881d04a54e60
7
- data.tar.gz: 76821f0cad1a918b762bb1d5737f902cc67b726f8dafb0dcf646b5cfc8dd4173527733a8a04e92e71ebea9a152fdb6ebaea20d9ccde3037f32d7fc32f5b05497
6
+ metadata.gz: 6b1441aab8f7197c23bd84fea750388280ee00036e729f47de13b0113249261f72fb10f01b5e9a78fb2464fcb0ebac5fd07d91eef7d4b5e83fb973b463c0630e
7
+ data.tar.gz: d9c0401177c9d59900543c7ff5bbae698401fa0dc5ca7e33272458f41eddff2fa61caa501a5735b273f59b44de96e1fc70b0c5a648bb159066f7facd18b5aa82
data/CHANGELOG.md CHANGED
@@ -9,7 +9,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
- ---
12
+ ### Added
13
+
14
+ - **`Phronomy::Filter::Base` — unified value filter interface** (#389):
15
+ A single abstract base class `Filter::Base` with one method `call(value, **context)`
16
+ covers all three agent boundaries — user input, LLM output, and tool return values.
17
+ Subclasses return the (possibly transformed) value to continue, or call `block!` /
18
+ `raise Phronomy::FilterBlockError` to reject. The same filter instance can be
19
+ registered at multiple sites. Guardrails registered via `add_input_guardrail` /
20
+ `add_output_guardrail` are automatically included at the front of the filter chain.
21
+
22
+ ```ruby
23
+ class PiiMaskFilter < Phronomy::Filter::Base
24
+ def call(value, **_context)
25
+ value.to_s.gsub(/\b\d{2,4}-\d{2,4}-\d{4}\b/, "[PHONE]")
26
+ end
27
+ end
28
+
29
+ f = PiiMaskFilter.new
30
+ agent.add_input_filter(f)
31
+ agent.add_output_filter(f)
32
+ agent.add_tool_result_filter(CustomerDataTool, f)
33
+ ```
34
+
35
+ Class-level DSL counterparts: `input_filter`, `output_filter`, `tool_result_filter`.
36
+ The `tools(Hash)` DSL also accepts a `:result_filter` key for per-tool scoping.
37
+
38
+ - **`Guardrail::Base#call` — Filter::Base-compatible interface**:
39
+ `Guardrail::Base` now implements `call(value, **_context)`, which calls `check(value)`
40
+ and returns the value unchanged (or raises `GuardrailError`). Existing guardrail
41
+ subclasses require no changes.
42
+
43
+ ### Changed
44
+
45
+ - **Guardrail execution unified into the filter chain**:
46
+ Guardrails registered via `add_input_guardrail` / `add_output_guardrail` now run
47
+ as the first entries in `run_input_filters!` / `run_output_filters!`. The separate
48
+ `run_input_guardrails!` / `run_output_guardrails!` call sites in `invoke_once`,
49
+ `_stream_impl`, and `Suspendable#resume` have been removed. Behaviour is unchanged
50
+ — guardrails still run before any filters and `GuardrailError` still propagates.
51
+
52
+
13
53
 
14
54
  ## [0.10.0] - 2026-06-08
15
55
 
data/README.md CHANGED
@@ -7,7 +7,7 @@
7
7
  > We apologise for the instability this may cause.
8
8
 
9
9
  **Phronomy** is a Ruby AI agent framework inspired by open-source AI agent frameworks.
10
- It provides composable building blocks — Workflows, Agents, Tools, Guardrails, and Tracing — all powered by [RubyLLM](https://github.com/crmne/ruby_llm) for LLM abstraction.
10
+ It provides composable building blocks — Workflows, Agents, Tools, Filters, and Tracing — all powered by [RubyLLM](https://github.com/crmne/ruby_llm) for LLM abstraction.
11
11
 
12
12
  ## Features
13
13
 
@@ -31,8 +31,8 @@ It provides composable building blocks — Workflows, Agents, Tools, Guardrails,
31
31
  | **Agent** — ReAct-style tool-calling agents with guardrails and conversation history | Stable |
32
32
  | **Before-Completion Hook** — Three-tier LLM parameter injection | Stable |
33
33
  | **Context Management** — Token budget calculation, estimation, and pruning; `Agent::Base` protected hooks: `build_context` (overridable), `trim_messages`, `trim_to_budget`, `compact_messages`, `budget_exceeded?`, `drop_messages_over` | Stable |
34
- | **Guardrails** — Input/output validation with custom `InputGuardrail`/`OutputGuardrail` | Beta |
35
- | **`PromptInjectionGuardrail`** — Built-in `InputGuardrail` subclass that detects prompt-injection patterns; usable standalone or as part of a guardrail chain | Beta |
34
+ | **Filters** — Input/output transformation and blocking via `Filter::Base`; call `block!(reason)` to reject and raise `FilterBlockError` | Beta |
35
+ | **`PromptInjectionFilter`** — Built-in `Filter::Base` subclass that detects prompt-injection patterns; usable standalone or as part of a filter chain | Beta |
36
36
  | **`Agent::Context::Capability::Base.redact_params` / `.max_result_size`** — Class-level DSL: `redact_params` masks parameter values in log/trace output; `max_result_size` truncates oversized tool results before they reach the LLM | Beta |
37
37
  | **Output Parser** — JSON and Struct-mapped parsers for structured LLM responses | Stable |
38
38
  | **Eval Framework** — Dataset-driven evaluation with multiple scorer types | Beta |
@@ -78,6 +78,7 @@ It provides composable building blocks — Workflows, Agents, Tools, Guardrails,
78
78
  | **Agent::SharedState** — Shared state pattern: peer agents collaborate via a shared KnowledgeStore; `member` DSL with per-agent instructions and `coordination` team protocol | Experimental |
79
79
  | **`ScopePolicy`** — Configurable policy callable that maps (tool, scope, agent) to `:allow`/`:approve`/`:reject`; default policy auto-routes high-risk scopes through the approval gate | Experimental |
80
80
  | **HITL Checkpoint/Resume** — `Agent::Base#invoke` returns `{ suspended: true, checkpoint: Checkpoint }` when an approval-required tool is encountered without a synchronous handler; `Agent::Base#resume(checkpoint, approved:)` resumes execution; `Agent::Base.resume(checkpoint, approved:)` (class-level) resolves the agent class automatically; `Checkpoint#to_h` / `Checkpoint.from_h` for serialization; `Agent::Base#checkpoint_store=` for custom idempotency backends; `CheckpointAlreadyResumedError` raised on duplicate resume | Experimental |
81
+ | **`Filter::Base` — unified value filter interface** — `Phronomy::Filter::Base` with a single abstract method `call(value, **context)`; apply to user input (`add_input_filter` / `input_filter` DSL), final LLM output (`add_output_filter` / `output_filter` DSL), or individual tool return values (`add_tool_result_filter(tool_class?, filter)` / `tool_result_filter` DSL); filters transform values and return the result, or raise `Phronomy::FilterBlockError` to reject; filter chains are composable; the same filter instance can be reused across all three sites | Beta |
81
82
 
82
83
  > **Public API boundary**: The tables above are the complete list of classes, modules, and features
83
84
  > intended for gem consumers. Every entry has an associated stability label.
@@ -252,30 +253,31 @@ result = OrchestratorAgent.new.invoke("Write a blog post about Ruby 3.4 features
252
253
  puts result[:output]
253
254
  ```
254
255
 
255
- ### Guardrails — Input/output validation
256
+ ### Filters — Input/output transformation and blocking
256
257
 
257
- Call `fail!(reason)` inside `check` to reject it raises `Phronomy::GuardrailError`.
258
- When a guardrail rejects, `invoke` raises instead of returning an output.
258
+ Filters sit between user input and the LLM (input filters) or between the LLM response and the caller (output filters).
259
+ A filter may **transform** the value (return the modified value) or **block** it (call `block!(reason)`, which raises `Phronomy::FilterBlockError`).
259
260
 
260
261
  ```ruby
261
- class NoSensitiveDataGuardrail < Phronomy::Guardrail::InputGuardrail
262
- def check(input)
263
- fail!("Credit card numbers are not allowed") if input.match?(/\d{4}-\d{4}-\d{4}-\d{4}/)
262
+ class NoCreditCardFilter < Phronomy::Filter::Base
263
+ def call(value, **_context)
264
+ block!("Credit card numbers are not allowed") if value.match?(/\d{4}-\d{4}-\d{4}-\d{4}/)
265
+ value
264
266
  end
265
267
  end
266
268
 
267
269
  agent = ResearchAgent.new
268
- agent.add_input_guardrail(NoSensitiveDataGuardrail.new)
270
+ agent.add_input_filter(NoCreditCardFilter.new)
269
271
 
270
272
  begin
271
273
  agent.invoke("Charge 4111-1111-1111-1111")
272
- rescue Phronomy::GuardrailError => e
274
+ rescue Phronomy::FilterBlockError => e
273
275
  puts e.message # => "Credit card numbers are not allowed"
274
276
  end
275
277
  ```
276
278
 
277
- > **Note:** Phronomy includes `PromptInjectionGuardrail`, a built-in pattern-based
278
- > input guardrail that detects common injection patterns (see the feature table above).
279
+ > **Note:** Phronomy includes `PromptInjectionFilter`, a built-in pattern-based
280
+ > input filter that detects common injection patterns (see the feature table above).
279
281
  > PII scanning and content classification are **not** provided by the framework;
280
282
  > that logic must be implemented by the application. Reference implementations for
281
283
  > common patterns are available in `phronomy-examples` (example 06).
@@ -601,14 +603,16 @@ Understanding when to use each prevents scheduler stalls and hidden deadlocks.
601
603
  | Context | Recommended API |
602
604
  |---------|----------------|
603
605
  | Top-level application code, Rails controller, background job | `agent.invoke(input)` — blocks the calling thread until done |
604
- | Inside a `Runtime#spawn` block, `TaskGroup`, Workflow action, Tool `execute` | `agent.invoke_async(input).await` — non-blocking within the scheduler |
606
+ | Workflow action / EventLoop callback | `agent.invoke_async(input).map { |r| ctx.merge(output: r[:output]) }` — returns a Task and resumes by state transition |
607
+ | Top-level code that wants explicit async | `agent.invoke_async(input).wait_result` — blocks the calling thread until the Task completes |
605
608
 
606
609
  ### Why this matters
607
610
 
608
611
  `invoke` is a synchronous wrapper that calls `invoke_async` and then _blocks_ the calling
609
- thread until the task completes. When called from **inside** an active scheduler task, the
610
- calling task blocks the scheduler thread, preventing other tasks from making progress — a
611
- hidden deadlock when all scheduler threads are occupied.
612
+ thread until the task completes. It is intended for top-level application threads such as
613
+ Rails controller actions, CLI scripts, or background jobs. Inside EventLoop-driven
614
+ workflow actions, return a Task and let `Task#map` / `Task#on_complete` drive the next
615
+ state transition instead of waiting inside the EventLoop thread.
612
616
 
613
617
  ### Runtime guard
614
618
 
@@ -628,14 +632,21 @@ You can also query the current context directly:
628
632
  Phronomy::Runtime.in_scheduler_context? # => true if called from inside a task
629
633
  ```
630
634
 
631
- ### Migration: invokeinvoke_async
635
+ ### Migration: blocking wait Task mapping
632
636
 
633
637
  ```ruby
634
- # Before (blocks scheduler if called from inside a task)
638
+ # Top-level synchronous use
635
639
  result = my_agent.invoke("Hello")
636
640
 
637
- # After (safe inside tasks and TaskGroups)
638
- result = my_agent.invoke_async("Hello").await
641
+ # Explicit async from top-level code
642
+ result = my_agent.invoke_async("Hello").wait_result
643
+
644
+ # Workflow action / EventLoop-safe use
645
+ NODE = ->(ctx) {
646
+ my_agent.invoke_async("Hello").map { |result|
647
+ ctx.merge(answer: result[:output])
648
+ }
649
+ }
639
650
  ```
640
651
 
641
652
  ### :immediate backend (synchronous / test mode)
@@ -851,11 +862,11 @@ span attributes by default (`trace_pii: false`). To include full content in trac
851
862
  Phronomy configuration. Evaluate whether your tracing backend (OTLP collector, Jaeger,
852
863
  Honeycomb, etc.) meets your data-retention and privacy requirements.
853
864
 
854
- **Prompt injection** — Phronomy provides `PromptInjectionGuardrail`, a built-in
855
- pattern-based input guardrail that detects common injection patterns (ignore/override
865
+ **Prompt injection** — Phronomy provides `PromptInjectionFilter`, a built-in
866
+ pattern-based input filter that detects common injection patterns (ignore/override
856
867
  instructions, role-switching phrases, etc.). It is a useful starting point, not a
857
868
  comprehensive defence; applications processing untrusted input should layer additional
858
- custom guardrails as needed (see the Guardrails section above).
869
+ custom filters as needed (see the Filters section above).
859
870
 
860
871
  **Tool and MCP security** — Tools can perform real-world side effects (database
861
872
  writes, API calls, file deletion). Treat tool execution as a privileged operation:
@@ -39,6 +39,7 @@ class BenchStubChat
39
39
  def with_temperature(_) = self
40
40
  def with_cache_instructions(_) = self
41
41
  def with_output_schema(_) = self
42
+ def on_tool_call(&) = self
42
43
  def last_message = @response
43
44
 
44
45
  def ask(_)
@@ -1,11 +1,9 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "securerandom"
4
- require_relative "checkpoint_store"
5
4
  require_relative "concerns/retryable"
6
- require_relative "concerns/guardrailable"
5
+ require_relative "concerns/filterable"
7
6
  require_relative "concerns/before_completion"
8
- require_relative "concerns/suspendable"
9
7
  require_relative "concerns/error_translation"
10
8
 
11
9
  module Phronomy
@@ -34,9 +32,8 @@ module Phronomy
34
32
  class Base
35
33
  include Phronomy::Runnable
36
34
  include Concerns::Retryable
37
- include Concerns::Guardrailable
35
+ include Concerns::Filterable
38
36
  include Concerns::BeforeCompletion
39
- include Concerns::Suspendable
40
37
  include Concerns::ErrorTranslation
41
38
 
42
39
  class << self
@@ -376,25 +373,21 @@ module Phronomy
376
373
  end
377
374
  end
378
375
 
379
- # Resumes a suspended invocation identified by +checkpoint+ without
380
- # requiring the original agent instance to be kept in memory.
376
+ # Continues a suspended invocation identified by +session_id+.
381
377
  #
382
- # Validates that the checkpoint was created by this agent class, then
383
- # instantiates a fresh agent and delegates to {Suspendable#resume}.
378
+ # Instantiates a fresh agent and delegates to the instance-level #approve.
379
+ # When +approved: false+, the agent rejects the pending tool call and ends
380
+ # the invocation.
384
381
  #
385
- # @param checkpoint [Phronomy::Agent::Checkpoint]
382
+ # @param session_id [String] the session_id from the suspended result hash
386
383
  # @param approved [Boolean] +true+ to execute the pending tool; +false+ to deny
387
384
  # @param config [Hash] same runtime options as {#invoke}
388
385
  # @return [Hash] same shape as {#invoke} — may contain +suspended: true+ if
389
386
  # another approval-required tool is encountered during continuation
390
- # @raise [ArgumentError] when +checkpoint.agent_class+ does not match this class
387
+ # @raise [ArgumentError] when no suspended session matches +session_id+
391
388
  # @api public
392
- def resume(checkpoint, approved:, config: {})
393
- if checkpoint.agent_class && checkpoint.agent_class != name
394
- raise ArgumentError,
395
- "checkpoint belongs to #{checkpoint.agent_class}, cannot resume with #{name}"
396
- end
397
- new.resume(checkpoint, approved: approved, config: config)
389
+ def approve(session_id, approved: true, config: {})
390
+ new.approve(session_id, approved: approved, config: config)
398
391
  end
399
392
  end
400
393
 
@@ -416,9 +409,38 @@ module Phronomy
416
409
  @_handoff_tools || []
417
410
  end
418
411
 
412
+ # Registers a synchronous approval callback that is invoked before
413
+ # executing any tool that has +requires_approval true+ set.
414
+ # The block receives the tool name (String) and the arguments Hash, and
415
+ # must return a truthy value to allow execution.
416
+ # Returning a falsy value causes the tool to return a denial message.
417
+ #
418
+ # When no handler is registered and a tool with +requires_approval+ is
419
+ # called, #invoke returns a suspended result hash containing a
420
+ # +session_id+. Call #approve to continue execution.
421
+ #
422
+ # @example
423
+ # agent.on_approval_required { |tool_name, args| prompt_user(tool_name, args) }
424
+ # @return [self]
425
+ # @api public
426
+ def on_approval_required(&block)
427
+ @approval_handler = block
428
+ self
429
+ end
430
+
431
+ # Registers a scope policy callable for this agent instance.
432
+ #
433
+ # The callable receives +(tool_class, scope, agent)+ and must return
434
+ # +:allow+, +:reject+, or +:approve+.
435
+ #
436
+ # @param policy [#call]
437
+ # @return [void]
438
+ # @api public
439
+ attr_writer :scope_policy
440
+
419
441
  # Invokes the agent with the given input and returns a result Hash.
420
442
  # Applies the retry policy configured via {.retry_policy} when transient
421
- # errors occur. {Phronomy::GuardrailError} is never retried.
443
+ # errors occur. {Phronomy::FilterBlockError} is never retried.
422
444
  #
423
445
  # @param input [String, Hash] the user message; a Hash may supply
424
446
  # +:message+, +:query+, or +:user+ as the text key, plus any template
@@ -439,7 +461,7 @@ module Phronomy
439
461
  # @return [Hash] +{ output: String, messages: Array, usage: Phronomy::TokenUsage }+,
440
462
  # or +{ output: nil, suspended: true, checkpoint: Phronomy::Agent::Checkpoint,
441
463
  # messages: Array }+ when the invocation was suspended awaiting tool approval.
442
- # @raise [Phronomy::GuardrailError] when an input or output guardrail rejects the value
464
+ # @raise [Phronomy::FilterBlockError] when an input or output filter rejects the value
443
465
  # @example Normal invocation
444
466
  # result = MyAgent.new.invoke("What is Ruby?")
445
467
  # puts result[:output]
@@ -468,7 +490,10 @@ module Phronomy
468
490
 
469
491
  timeout_sec = self.class.invoke_timeout
470
492
  unless timeout_sec
471
- return invoke_async(input, messages: messages, thread_id: thread_id, config: config).await
493
+ return trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
494
+ result = invoke_async(input, messages: messages, thread_id: thread_id, config: config).wait_result
495
+ [result, result[:usage]]
496
+ end
472
497
  end
473
498
 
474
499
  # invoke_timeout: create a CancellationScope with deadline, pass its token
@@ -482,7 +507,7 @@ module Phronomy
482
507
  # Bridge the task result to an AsyncQueue so scope.pop_queue can observe the deadline.
483
508
  completion_queue = Phronomy::Concurrency::AsyncQueue.new
484
509
  Phronomy::Runtime.instance.spawn(name: "invoke-timeout-bridge:#{(self.class.name || "agent").downcase}") do
485
- completion_queue.push(task.await)
510
+ completion_queue.push(task.wait_result)
486
511
  rescue => e
487
512
  completion_queue.push(e)
488
513
  end
@@ -508,7 +533,7 @@ module Phronomy
508
533
  #
509
534
  # @example
510
535
  # task = agent.invoke_async("Hello!")
511
- # result = task.await # => { output: "...", messages: [...], usage: ... }
536
+ # result = task.wait_result # => { output: "...", messages: [...], usage: ... }
512
537
  #
513
538
  # @param input [String, Hash]
514
539
  # @param messages [Array]
@@ -524,11 +549,11 @@ module Phronomy
524
549
  bp = Phronomy.configuration.backpressure
525
550
  on_full = (bp == :raise) ? :reject : (bp || :wait)
526
551
  bp_timeout = Phronomy.configuration.backpressure_timeout
552
+ result_task = Phronomy::Task.deferred(name: "agent-#{(self.class.name || "anonymous").downcase}-async")
527
553
  gate = Phronomy::Runtime.instance.gate(:agent)
528
- Phronomy::Runtime.instance.spawn(name: "agent-#{(self.class.name || "anonymous").downcase}-async") do
529
- gate.acquire(on_full: on_full, timeout: bp_timeout) do
530
- _invoke_impl(input, messages: messages, thread_id: thread_id, config: config)
531
- end
554
+ gate.acquire(on_full: on_full, timeout: bp_timeout) do
555
+ _start_invoke_attempt(result_task, input, messages: messages, thread_id: thread_id, config: config, attempt: 0)
556
+ return result_task
532
557
  end
533
558
  end
534
559
 
@@ -603,7 +628,7 @@ module Phronomy
603
628
  # Streaming implementation for #stream.
604
629
  def _stream_impl(input, messages: [], thread_id: nil, config: {}, &block)
605
630
  trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
606
- run_input_guardrails!(input)
631
+ input = run_input_filters!(input)
607
632
 
608
633
  chat = build_chat
609
634
  user_message = extract_message(input)
@@ -634,7 +659,7 @@ module Phronomy
634
659
  run_before_completion_hooks!(chat, config)
635
660
 
636
661
  output, usage = _drain_stream(chat, user_message, config, &block)
637
- run_output_guardrails!(output)
662
+ output = run_output_filters!(output)
638
663
 
639
664
  result = {output: output, messages: chat.messages, usage: usage}
640
665
  block.call(StreamEvent.new(type: :done, payload: result))
@@ -809,34 +834,154 @@ module Phronomy
809
834
  end
810
835
  protected :instance_knowledge_chunks
811
836
 
812
- # Performs a single (non-retried) invocation. Extracted so that #invoke can
813
- # wrap it in a retry loop without duplicating the LLM interaction logic.
814
- def invoke_once(input, messages: [], thread_id: nil, config: {})
815
- trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
816
- run_input_guardrails!(input)
817
-
818
- user_message = extract_message(input)
819
- chat = build_chat
820
- context = build_context(
821
- input,
822
- messages: messages, thread_id: thread_id, config: config,
823
- budget: build_token_budget, instruction: build_instructions(input),
824
- tools: self.class.tools + _handoff_tools
837
+ # Runs the agent invocation through the FSM-based execution engine.
838
+ # Called by Retryable#_invoke_impl (which wraps it in a retry loop).
839
+ # Returns the result hash: { output:, messages:, usage: } on success,
840
+ # or { suspended: true, session_id:, messages: } when awaiting approval.
841
+ # @api private
842
+ def _invoke_via_fsm(input, messages: [], thread_id: nil, config: {})
843
+ effective_config = thread_id ? config.merge(thread_id: thread_id) : config
844
+ # Fail fast when the token is already cancelled before any LLM call.
845
+ check_cancellation!(effective_config, "invocation cancelled")
846
+ # Ensure EventLoop is running. start is idempotent when already alive.
847
+ Phronomy::EventLoop.instance.start
848
+ trace("agent.invoke", input: input, **_build_caller_meta(effective_config)) do |_span|
849
+ session = Agent::InvocationSession.build(
850
+ agent: self,
851
+ input: input,
852
+ messages: messages,
853
+ config: effective_config
825
854
  )
826
- _apply_context_to_chat(chat, context)
855
+ completion_queue = Phronomy::EventLoop.instance.register(session)
856
+ ctx = completion_queue.pop
857
+ raise ctx if ctx.is_a?(Exception)
858
+ result = _extract_invoke_result(ctx, session.id)
859
+ [result, result[:usage]]
860
+ end
861
+ end
827
862
 
828
- run_before_completion_hooks!(chat, config)
829
- _register_suspension_hook!(chat)
830
- check_cancellation!(config, "invocation cancelled before LLM call")
863
+ # Starts a single invocation attempt and wires retry/translation onto result_task.
864
+ # Non-blocking: registers with EventLoop and returns immediately.
865
+ # On error, retries via timer_queue when policy allows; otherwise translates
866
+ # and resolves result_task as failed.
867
+ # @api private
868
+ def _start_invoke_attempt(result_task, input, messages:, thread_id:, config:, attempt:)
869
+ effective_config = thread_id ? config.merge(thread_id: thread_id) : config
870
+ check_cancellation!(effective_config, "invocation cancelled")
871
+ Phronomy::EventLoop.instance.start
872
+ session = Agent::InvocationSession.build(
873
+ agent: self,
874
+ input: input,
875
+ messages: messages,
876
+ config: effective_config
877
+ )
878
+ source_task = Phronomy::Task.deferred(name: "#{result_task.name}-attempt-#{attempt}")
879
+ Phronomy::EventLoop.instance.register(session, completion: source_task)
880
+ session_id = session.id
881
+ policy = self.class._retry_policy
882
+
883
+ source_task.on_complete do |ctx, error|
884
+ retriable = error &&
885
+ !error.is_a?(Phronomy::FilterBlockError) &&
886
+ !error.is_a?(Phronomy::CancellationError) &&
887
+ policy && attempt < policy[:times]
888
+
889
+ if retriable
890
+ wait = compute_agent_retry_wait(policy[:wait], policy[:base], attempt)
891
+ # Call _sleep_proc for instrumentation (test spy records the duration;
892
+ # in production this is a no-op since timer_queue handles the actual delay).
893
+ self.class._sleep_proc.call(wait) if wait > 0
894
+ do_retry = -> {
895
+ _start_invoke_attempt(
896
+ result_task, input,
897
+ messages: messages, thread_id: thread_id, config: config,
898
+ attempt: attempt + 1
899
+ )
900
+ }
901
+ if wait > 0
902
+ Phronomy::Runtime.instance.timer_queue.schedule(seconds: wait, &do_retry)
903
+ else
904
+ do_retry.call
905
+ end
906
+ elsif error
907
+ begin
908
+ translate_and_reraise!(error)
909
+ rescue => translated
910
+ result_task.backend.unblock(nil, translated)
911
+ result_task.transition!(:failed, error: translated)
912
+ end
913
+ else
914
+ begin
915
+ result = _extract_invoke_result(ctx, session_id)
916
+ result_task.backend.unblock(result, nil)
917
+ result_task.transition!(:completed, value: result)
918
+ rescue => e
919
+ result_task.backend.unblock(nil, e)
920
+ result_task.transition!(:failed, error: e)
921
+ end
922
+ end
923
+ end
924
+ rescue => e
925
+ result_task.backend.unblock(nil, e)
926
+ result_task.transition!(:failed, error: e)
927
+ end
831
928
 
832
- result, usage = _complete_with_suspension_guard(
833
- chat, user_message, config,
834
- thread_id: thread_id, original_input: input
835
- )
836
- next [result, usage] if result[:suspended]
929
+ # Continues a suspended invocation identified by +session_id+.
930
+ # When +approved: true+, executes the pending tool and continues.
931
+ # When +approved: false+, rejects the tool call and ends the invocation.
932
+ #
933
+ # @param session_id [String]
934
+ # @param approved [Boolean]
935
+ # @param config [Hash]
936
+ # @return [Hash]
937
+ # @api public
938
+ def approve(session_id, approved: true, config: {})
939
+ ctx = Agent::SuspendedSessionRegistry.fetch(session_id)
940
+ raise ArgumentError, "No suspended session found: #{session_id}" unless ctx
941
+
942
+ # Reset approval_required so executing_tool_action proceeds instead of
943
+ # re-suspending when called after the :approve FSM transition.
944
+ ctx.approval_required = false
945
+ ctx.approved = true if approved # signals executing_tool to run the tool
946
+ ctx.rejected = !approved # signals _extract_invoke_result for rejection
947
+
948
+ if approved
949
+ _resume_fsm(ctx, :approve)
950
+ else
951
+ _resume_fsm(ctx, :reject)
952
+ end
953
+ end
954
+ public :approve
837
955
 
838
- run_output_guardrails!(result[:output])
839
- [result, usage]
956
+ # Builds and runs a resume FSMSession for the given context and event.
957
+ # @api private
958
+ def _resume_fsm(ctx, event)
959
+ session = Agent::InvocationSession.build_for_resume(
960
+ agent: self,
961
+ context: ctx,
962
+ resume_event: event,
963
+ resume_phase: :awaiting_approval
964
+ )
965
+ completion_queue = Phronomy::EventLoop.instance.register(session)
966
+ resumed_ctx = completion_queue.pop
967
+ raise resumed_ctx if resumed_ctx.is_a?(Exception)
968
+ _extract_invoke_result(resumed_ctx, session.id)
969
+ end
970
+
971
+ # Interprets the InvocationContext after FSM completion/halt and returns
972
+ # the appropriate result hash or raises the block error.
973
+ # @api private
974
+ def _extract_invoke_result(ctx, session_id)
975
+ if ctx.phase == :awaiting_approval
976
+ Agent::SuspendedSessionRegistry.store(session_id, ctx)
977
+ {suspended: true, session_id: session_id, messages: ctx.messages}
978
+ elsif ctx.input_blocked? || ctx.output_blocked?
979
+ raise ctx.block_error
980
+ elsif ctx.rejected
981
+ # Rejected path: :reject event → :blocked terminal
982
+ {rejected: true, messages: ctx.messages}
983
+ else
984
+ {output: ctx.output, messages: ctx.messages, usage: ctx.usage}
840
985
  end
841
986
  end
842
987
 
@@ -857,36 +1002,6 @@ module Phronomy
857
1002
  context[:messages].each { |msg| chat.messages << msg }
858
1003
  end
859
1004
 
860
- # Submits the LLM call via LLMAdapter and handles SuspendSignal.
861
- # Sets/clears the chat cancellation token around the call so that
862
- # ParallelToolChat can observe cancellation without Thread.current.
863
- # Returns [result_hash, usage_or_nil].
864
- def _complete_with_suspension_guard(chat, user_message, config, thread_id:, original_input:)
865
- chat.cancellation_token = config[:cancellation_token] if chat.respond_to?(:cancellation_token=)
866
- begin
867
- adapter = Phronomy.configuration.llm_adapter
868
- response = adapter.complete_async(chat, user_message, config: config).await
869
- rescue SuspendSignal => signal
870
- checkpoint = Checkpoint.new(
871
- checkpoint_id: SecureRandom.uuid,
872
- agent_class: self.class.name,
873
- requested_at: Time.now.utc,
874
- thread_id: thread_id,
875
- original_input: original_input,
876
- messages: chat.messages.dup,
877
- pending_tool_name: signal.tool_name,
878
- pending_tool_args: signal.args,
879
- pending_tool_call_id: signal.tool_call_id
880
- )
881
- return [{output: nil, suspended: true, checkpoint: checkpoint, messages: chat.messages}, nil]
882
- ensure
883
- chat.cancellation_token = nil if chat.respond_to?(:cancellation_token=)
884
- end
885
- output = response.content
886
- usage = Phronomy::TokenUsage.from_tokens(response.tokens)
887
- [{output: output, messages: chat.messages, usage: usage}, usage]
888
- end
889
-
890
1005
  def _drain_stream(chat, user_message, config, &block)
891
1006
  adapter = Phronomy.configuration.llm_adapter
892
1007
  chunk_queue = Phronomy::Concurrency::AsyncQueue.new(max_size: Phronomy.configuration.stream_queue_max_size)
@@ -899,7 +1014,7 @@ module Phronomy
899
1014
  check_cancellation!(config, "invocation cancelled during streaming")
900
1015
  end
901
1016
 
902
- response = pending.await
1017
+ response = pending.blocking_wait
903
1018
  [response.content, Phronomy::TokenUsage.from_tokens(response.tokens)]
904
1019
  end
905
1020
 
@@ -1076,21 +1191,34 @@ module Phronomy
1076
1191
  end
1077
1192
 
1078
1193
  # Step 3: wrap with approval gate when handler is registered.
1079
- return resolved unless resolved.requires_approval && @approval_handler
1194
+ if resolved.requires_approval && @approval_handler
1195
+ handler = @approval_handler
1196
+ # Capture the effective tool name before building the anonymous subclass.
1197
+ # Class-level instance variables (@tool_name) are not inherited through
1198
+ # subclassing, so the wrapper must set it explicitly.
1199
+ effective_name = resolved.new.name
1200
+ resolved = Class.new(resolved) do
1201
+ tool_name effective_name
1202
+ define_method(:call) do |args|
1203
+ if handler.call(name, args)
1204
+ super(args)
1205
+ else
1206
+ "Tool execution denied."
1207
+ end
1208
+ end
1209
+ end
1210
+ end
1080
1211
 
1081
- handler = @approval_handler
1082
- # Capture the effective tool name before building the anonymous subclass.
1083
- # Class-level instance variables (@tool_name) are not inherited through
1084
- # subclassing, so the wrapper must set it explicitly.
1085
- effective_name = resolved.new.name
1212
+ # Step 4: wrap with tool result filters when registered.
1213
+ result_filters = _tool_result_filters_for(tool_class)
1214
+ return resolved if result_filters.empty?
1215
+
1216
+ effective_name4 = resolved.new.name
1086
1217
  Class.new(resolved) do
1087
- tool_name effective_name
1218
+ tool_name effective_name4
1088
1219
  define_method(:call) do |args|
1089
- if handler.call(name, args)
1090
- super(args)
1091
- else
1092
- "Tool execution denied."
1093
- end
1220
+ result = super(args)
1221
+ result_filters.inject(result) { |val, f| f.call(val, tool_name: name, args: args) }
1094
1222
  end
1095
1223
  end
1096
1224
  end
@@ -36,7 +36,7 @@ module Phronomy
36
36
  when RubyLLM::Error
37
37
  raise Phronomy::TransportError, error.message
38
38
  else
39
- raise # bare re-raise preserves $! and its backtrace unchanged
39
+ raise error # preserve original class, message, and backtrace
40
40
  end
41
41
  end
42
42
  end