phronomy 0.9.1 → 0.11.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 7e84ccabf84c48e16cdb968c1f7b69f2348b24a70e477aa39bbbe1244d34edfc
4
- data.tar.gz: f31dc2d1c4ed4bb7717e88278f1ced3debd0177f1f7a8042b170421a5d8e7493
3
+ metadata.gz: e73d1b49c4638232021afad5cc807466c4bb7b2cfcfe60446930288e868a3db9
4
+ data.tar.gz: 624e783c532fc2ea009db99a7bda4a1308f06019bc0e776a78d206b70031b352
5
5
  SHA512:
6
- metadata.gz: 1c1ab4d05c27930b84abbad09f5c59027f9bfcddf9a89aa485608afdcd22ba50fcf971c2185a815206edfc37b29abb0fa99b7f80a8fa3f436c1d6a97b5ad38e4
7
- data.tar.gz: 04016a561705ff24c4a6b9f8bb3d6918c303071f7bf97d94d70313b95f796ae561fee29fad9e7e620928655bf7e2007751cfa217bd973d83d2ad4d26d9754e3e
6
+ metadata.gz: 0ce003a8f0b0c85cd29a744b86ee89ad7414c511ea46c1ad446227824100174b6bfae76de8a4caa32428a50f77606af8006c17ae307eb514692a92829331570c
7
+ data.tar.gz: 10a3e965b58c730d5ba21eefc3c7151725a26e6accc24dbde04dd91546aa6c65bea801faae0fe5ef1908995a78c259159712c0f8dffe3f19938fa64b14988c16
data/CHANGELOG.md CHANGED
@@ -9,12 +9,90 @@ 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.
13
37
 
14
- ## [0.9.1] - 2026-06-06
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
+
53
+
54
+ ## [0.10.0] - 2026-06-08
15
55
 
16
56
  ### Added
17
57
 
58
+ - **`Task#map` — transform a Task's completed value** (#384):
59
+ `task.map { |result| ctx.merge(answer: result[:output]) }` returns a new `Task`
60
+ whose completed value is the block's return value. Primary use-case: wire
61
+ `Agent::Base#invoke_async` into a Workflow entry action so the agent result
62
+ reaches `WorkflowContext` through the standard `:action_completed` path without
63
+ requiring any changes to `FSMSession`. If the source task fails or is cancelled,
64
+ the mapped task propagates the error without calling the block.
65
+
66
+ ### Removed
67
+
68
+ - **`Agent::Base#run_as_child` removed** (#384):
69
+ Introduced when agents had their own FSM (`Agent::FSM`). After `Agent::FSM` was
70
+ removed in v0.9.0, the `:child_completed` event payload was silently discarded by
71
+ `FSMSession`, so `ctx.answer` was never populated. Migrate to
72
+ `invoke_async + Task#map`:
73
+
74
+ ```ruby
75
+ # Before (removed)
76
+ entry :translate, ->(ctx) { TranslationAgent.new.run_as_child(ctx.query, ctx: ctx) }
77
+ transition from: :translate, on: :child_completed, to: :done
78
+
79
+ # After (recommended)
80
+ entry :translate, ->(ctx) {
81
+ TranslationAgent.new.invoke_async(ctx.query).map { |r| ctx.merge(answer: r[:output]) }
82
+ }
83
+ transition from: :translate, to: :done
84
+ ```
85
+
86
+ ### Added
87
+
88
+ - **`Task#map` — transform a Task's completed value** (post-v0.9.0):
89
+ `task.map { |result| ctx.merge(answer: result[:output]) }` returns a new `Task`
90
+ whose completed value is the block's return value. The primary use-case is
91
+ connecting `Agent::Base#invoke_async` to a Workflow entry action so the agent
92
+ result populates the `WorkflowContext` through the standard `:action_completed`
93
+ path. If the source task fails or is cancelled, the mapped task propagates the
94
+ error without calling the block.
95
+
18
96
  - **`Phronomy::Diagnostics` and `SchedulerReentrancyError`** (#278, #279):
19
97
  `Phronomy::Diagnostics` exposes a snapshot of current scheduler state
20
98
  (`pending_count`, `active_tasks`, `pool_utilization`, etc.) for debugging and
@@ -233,6 +311,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
233
311
 
234
312
  ### Added (post-v0.9.0)
235
313
 
314
+ - **`Agent::Base#run_as_child` removed** (post-v0.9.0):
315
+ `run_as_child` was introduced when agents had their own FSM. After `Agent::FSM`
316
+ was removed, the `:child_completed` event payload was silently discarded by
317
+ `FSMSession`, meaning `ctx.answer` was never populated. The method has been
318
+ removed. Use `invoke_async + Task#map` instead:
319
+
320
+ ```ruby
321
+ # Before (deprecated)
322
+ entry :translate, ->(ctx) { TranslationAgent.new.run_as_child(ctx.query, ctx: ctx) }
323
+ transition from: :translate, on: :child_completed, to: :done
324
+
325
+ # After (recommended)
326
+ entry :translate, ->(ctx) {
327
+ TranslationAgent.new.invoke_async(ctx.query).map { |r| ctx.merge(answer: r[:output]) }
328
+ }
329
+ transition from: :translate, to: :done
330
+ ```
331
+
236
332
  - **`Phronomy::Agent::CheckpointStore` — idempotency store for HITL resume** (post-v0.9.0):
237
333
  New in-memory store tracks consumed checkpoint IDs. Calling `Agent::Base#resume` twice
238
334
  with the same checkpoint raises `Phronomy::CheckpointAlreadyResumedError` instead of
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 |
@@ -54,8 +54,9 @@ It provides composable building blocks — Workflows, Agents, Tools, Guardrails,
54
54
  | Feature | Stability |
55
55
  |---|---|
56
56
  | **Workflow EventLoop Mode** — Opt-in event-driven execution: `Phronomy.configure { \|c\| c.event_loop = true }` | Experimental |
57
- | **Agent EventLoop Mode** — `Agent#invoke` (non-blocking via EventLoop), `Agent#run_as_child` (child-FSM pattern for Workflow integration), parallel tool dispatch via `ParallelToolChat` | Experimental |
57
+ | **Agent EventLoop Mode** — `Agent#invoke` (non-blocking via EventLoop), `Agent#invoke_async` + `Task#map` (child-agent pattern for Workflow integration), parallel tool dispatch via `ParallelToolChat` | Experimental |
58
58
  | **`invoke_async` / `call_async`** — `Agent::Base#invoke_async` and `Workflow#invoke_async` return a `Task`; `Agent::Context::Capability::Base#call_async` similarly; compatible with EventLoop and standalone contexts | Experimental |
59
+ | **`Task#map`** — transforms a `Task`'s completed value via a block; returns a new `Task` whose value is the block's return value; if the source task fails or is cancelled the mapped task propagates the error without calling the block; primary use-case: `invoke_async.map { \|r\| ctx.merge(answer: r[:output]) }` to wire agent results into a `WorkflowContext` | Experimental |
59
60
  | **CancellationToken** — Cooperative cancellation via `cancel!`/`cancelled?`/`raise_if_cancelled!`; `timeout_after(seconds)` for monotonic-clock deadlines; optional `deadline:` (wall-clock) for backward compatibility; passed as `config: { cancellation_token: token }` to agents and `dispatch_parallel`; injected into `tool.execute` when the method declares a `cancellation_token:` keyword | Experimental |
60
61
  | **`dispatch_parallel` / `fan_out` `force_kill:` option** — `force_kill: false` (default) leaves timed-out workers running and raises `TimeoutError` immediately; `force_kill: true` restores the old `Thread#kill` behaviour with a `logger.warn` | Beta |
61
62
  | **`execution_mode` DSL on `Agent::Context::Capability::Base`** — Declares how a tool's `execute` should be dispatched: `:cooperative` (same scheduler thread), `:blocking_io` (default; offloaded to `BlockingAdapterPool`), `:cpu_bound`, `:external_process` | Experimental |
@@ -77,6 +78,7 @@ It provides composable building blocks — Workflows, Agents, Tools, Guardrails,
77
78
  | **Agent::SharedState** — Shared state pattern: peer agents collaborate via a shared KnowledgeStore; `member` DSL with per-agent instructions and `coordination` team protocol | Experimental |
78
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 |
79
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 |
80
82
 
81
83
  > **Public API boundary**: The tables above are the complete list of classes, modules, and features
82
84
  > intended for gem consumers. Every entry has an associated stability label.
@@ -199,20 +201,18 @@ final = app.send_event(state: state, event: :approve)
199
201
  puts "Approved: #{final.approved}" # => true
200
202
  ```
201
203
 
202
- In EventLoop mode (`c.event_loop = true`), `Agent#run_as_child` spawns a child agent
203
- asynchronously. When the child succeeds, `:child_completed` is dispatched with the result
204
- `{ output:, messages:, usage: }` as its payload; when it fails, `:child_failed` is
205
- dispatched. Always declare both transitions to avoid a stuck workflow:
204
+ In EventLoop mode (`c.event_loop = true`), use `invoke_async + Task#map` to run an agent
205
+ asynchronously inside a Workflow entry action. The mapped Task returns a `WorkflowContext`,
206
+ which `FSMSession` picks up via the standard `:action_completed` path:
206
207
 
207
208
  ```ruby
208
- # EventLoop mode: workflow that runs an agent as a child FSM.
209
- # The result { output:, messages:, usage: } arrives as the :child_completed event
210
- # payload — write it back to the context in the target state's entry action.
211
- entry :run_agent, ->(ctx) {
212
- MyAgent.new.run_as_child(ctx.query, ctx: ctx)
209
+ # EventLoop mode: workflow that runs an agent and captures the result.
210
+ entry :translate, ->(ctx) {
211
+ TranslationAgent.new.invoke_async(ctx.query).map do |result|
212
+ ctx.merge(answer: result[:output]) # returns WorkflowContext
213
+ end
213
214
  }
214
- transition from: :run_agent, on: :child_completed, to: :done
215
- transition from: :run_agent, on: :child_failed, to: :handle_error
215
+ transition from: :translate, to: :done # no on: needed
216
216
  ```
217
217
 
218
218
  ### Multi-Agent — Agent-as-Tool pattern
@@ -253,30 +253,31 @@ result = OrchestratorAgent.new.invoke("Write a blog post about Ruby 3.4 features
253
253
  puts result[:output]
254
254
  ```
255
255
 
256
- ### Guardrails — Input/output validation
256
+ ### Filters — Input/output transformation and blocking
257
257
 
258
- Call `fail!(reason)` inside `check` to reject it raises `Phronomy::GuardrailError`.
259
- 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`).
260
260
 
261
261
  ```ruby
262
- class NoSensitiveDataGuardrail < Phronomy::Guardrail::InputGuardrail
263
- def check(input)
264
- 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
265
266
  end
266
267
  end
267
268
 
268
269
  agent = ResearchAgent.new
269
- agent.add_input_guardrail(NoSensitiveDataGuardrail.new)
270
+ agent.add_input_filter(NoCreditCardFilter.new)
270
271
 
271
272
  begin
272
273
  agent.invoke("Charge 4111-1111-1111-1111")
273
- rescue Phronomy::GuardrailError => e
274
+ rescue Phronomy::FilterBlockError => e
274
275
  puts e.message # => "Credit card numbers are not allowed"
275
276
  end
276
277
  ```
277
278
 
278
- > **Note:** Phronomy includes `PromptInjectionGuardrail`, a built-in pattern-based
279
- > 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).
280
281
  > PII scanning and content classification are **not** provided by the framework;
281
282
  > that logic must be implemented by the application. Reference implementations for
282
283
  > common patterns are available in `phronomy-examples` (example 06).
@@ -852,11 +853,11 @@ span attributes by default (`trace_pii: false`). To include full content in trac
852
853
  Phronomy configuration. Evaluate whether your tracing backend (OTLP collector, Jaeger,
853
854
  Honeycomb, etc.) meets your data-retention and privacy requirements.
854
855
 
855
- **Prompt injection** — Phronomy provides `PromptInjectionGuardrail`, a built-in
856
- pattern-based input guardrail that detects common injection patterns (ignore/override
856
+ **Prompt injection** — Phronomy provides `PromptInjectionFilter`, a built-in
857
+ pattern-based input filter that detects common injection patterns (ignore/override
857
858
  instructions, role-switching phrases, etc.). It is a useful starting point, not a
858
859
  comprehensive defence; applications processing untrusted input should layer additional
859
- custom guardrails as needed (see the Guardrails section above).
860
+ custom filters as needed (see the Filters section above).
860
861
 
861
862
  **Tool and MCP security** — Tools can perform real-world side effects (database
862
863
  writes, API calls, file deletion). Treat tool execution as a privileged operation:
@@ -3,7 +3,7 @@
3
3
  require "securerandom"
4
4
  require_relative "checkpoint_store"
5
5
  require_relative "concerns/retryable"
6
- require_relative "concerns/guardrailable"
6
+ require_relative "concerns/filterable"
7
7
  require_relative "concerns/before_completion"
8
8
  require_relative "concerns/suspendable"
9
9
  require_relative "concerns/error_translation"
@@ -34,7 +34,7 @@ module Phronomy
34
34
  class Base
35
35
  include Phronomy::Runnable
36
36
  include Concerns::Retryable
37
- include Concerns::Guardrailable
37
+ include Concerns::Filterable
38
38
  include Concerns::BeforeCompletion
39
39
  include Concerns::Suspendable
40
40
  include Concerns::ErrorTranslation
@@ -418,7 +418,7 @@ module Phronomy
418
418
 
419
419
  # Invokes the agent with the given input and returns a result Hash.
420
420
  # Applies the retry policy configured via {.retry_policy} when transient
421
- # errors occur. {Phronomy::GuardrailError} is never retried.
421
+ # errors occur. {Phronomy::FilterBlockError} is never retried.
422
422
  #
423
423
  # @param input [String, Hash] the user message; a Hash may supply
424
424
  # +:message+, +:query+, or +:user+ as the text key, plus any template
@@ -439,7 +439,7 @@ module Phronomy
439
439
  # @return [Hash] +{ output: String, messages: Array, usage: Phronomy::TokenUsage }+,
440
440
  # or +{ output: nil, suspended: true, checkpoint: Phronomy::Agent::Checkpoint,
441
441
  # messages: Array }+ when the invocation was suspended awaiting tool approval.
442
- # @raise [Phronomy::GuardrailError] when an input or output guardrail rejects the value
442
+ # @raise [Phronomy::FilterBlockError] when an input or output filter rejects the value
443
443
  # @example Normal invocation
444
444
  # result = MyAgent.new.invoke("What is Ruby?")
445
445
  # puts result[:output]
@@ -532,56 +532,6 @@ module Phronomy
532
532
  end
533
533
  end
534
534
 
535
- # Registers this agent as a child {AgentFSM} inside the given Workflow context.
536
- #
537
- # Use this method from a Workflow entry action (running on the EventLoop thread)
538
- # instead of {#invoke}, which would raise a deadlock error because +invoke+ blocks
539
- # on a +Thread::Queue+ when EventLoop mode is active.
540
- #
541
- # The agent runs asynchronously in a background IO thread. When it finishes, the
542
- # parent {FSMSession} receives a +:child_completed+ event whose payload is the
543
- # result hash +{ output:, messages:, usage: }+. Declare an +on: :child_completed+
544
- # transition in your Workflow to advance to the next state.
545
- #
546
- # The result is delivered exclusively as the +:child_completed+ event payload.
547
- # The parent Workflow task is the sole owner of the parent +WorkflowContext+ and
548
- # applies the result after receiving the event — no background thread writes to
549
- # the parent context directly.
550
- #
551
- # @example
552
- # entry :run_agent, ->(ctx) { MyAgent.new.run_as_child(ctx.query, ctx: ctx) }
553
- # transition from: :run_agent, on: :child_completed, to: :process_result
554
- #
555
- # @param input [String, Hash] user input passed to the agent
556
- # @param ctx [Object] a WorkflowContext that responds to +#thread_id+
557
- # @param messages [Array] prior conversation history
558
- # @param config [Hash] invocation config (forwarded to +_invoke_impl+)
559
- # @return [nil] the caller must not wait on any return value;
560
- # the result arrives as a +:child_completed+ event
561
- # @raise [Phronomy::Error] when EventLoop mode is not enabled
562
- # @api public
563
- def run_as_child(input, ctx:, messages: [], config: {})
564
- unless Phronomy.configuration.event_loop
565
- raise Phronomy::Error,
566
- "run_as_child requires EventLoop mode. " \
567
- "Enable with: Phronomy.configure { |c| c.event_loop = true }"
568
- end
569
-
570
- parent_id = ctx.thread_id
571
- thread_id = "#{parent_id}_agent_#{SecureRandom.uuid}"
572
- Phronomy::Runtime.instance.spawn(name: "agent-child:#{thread_id}") do
573
- result = _invoke_impl(input, messages: messages, thread_id: thread_id, config: config)
574
- Phronomy::EventLoop.instance.post(
575
- Phronomy::Event.new(type: :child_completed, target_id: parent_id, payload: result)
576
- )
577
- rescue => e
578
- Phronomy::EventLoop.instance.post(
579
- Phronomy::Event.new(type: :child_failed, target_id: parent_id, payload: e)
580
- )
581
- end
582
- nil
583
- end
584
-
585
535
  # Streaming version of #invoke. Yields {Phronomy::Agent::StreamEvent} objects
586
536
  # as they are produced by the underlying LLM.
587
537
  #
@@ -653,7 +603,7 @@ module Phronomy
653
603
  # Streaming implementation for #stream.
654
604
  def _stream_impl(input, messages: [], thread_id: nil, config: {}, &block)
655
605
  trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
656
- run_input_guardrails!(input)
606
+ input = run_input_filters!(input)
657
607
 
658
608
  chat = build_chat
659
609
  user_message = extract_message(input)
@@ -684,7 +634,7 @@ module Phronomy
684
634
  run_before_completion_hooks!(chat, config)
685
635
 
686
636
  output, usage = _drain_stream(chat, user_message, config, &block)
687
- run_output_guardrails!(output)
637
+ output = run_output_filters!(output)
688
638
 
689
639
  result = {output: output, messages: chat.messages, usage: usage}
690
640
  block.call(StreamEvent.new(type: :done, payload: result))
@@ -863,7 +813,7 @@ module Phronomy
863
813
  # wrap it in a retry loop without duplicating the LLM interaction logic.
864
814
  def invoke_once(input, messages: [], thread_id: nil, config: {})
865
815
  trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
866
- run_input_guardrails!(input)
816
+ input = run_input_filters!(input)
867
817
 
868
818
  user_message = extract_message(input)
869
819
  chat = build_chat
@@ -885,7 +835,8 @@ module Phronomy
885
835
  )
886
836
  next [result, usage] if result[:suspended]
887
837
 
888
- run_output_guardrails!(result[:output])
838
+ filtered_output = run_output_filters!(result[:output])
839
+ result = result.merge(output: filtered_output) unless filtered_output.equal?(result[:output])
889
840
  [result, usage]
890
841
  end
891
842
  end
@@ -1126,21 +1077,34 @@ module Phronomy
1126
1077
  end
1127
1078
 
1128
1079
  # Step 3: wrap with approval gate when handler is registered.
1129
- return resolved unless resolved.requires_approval && @approval_handler
1080
+ if resolved.requires_approval && @approval_handler
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
1086
+ resolved = Class.new(resolved) do
1087
+ tool_name effective_name
1088
+ define_method(:call) do |args|
1089
+ if handler.call(name, args)
1090
+ super(args)
1091
+ else
1092
+ "Tool execution denied."
1093
+ end
1094
+ end
1095
+ end
1096
+ end
1130
1097
 
1131
- handler = @approval_handler
1132
- # Capture the effective tool name before building the anonymous subclass.
1133
- # Class-level instance variables (@tool_name) are not inherited through
1134
- # subclassing, so the wrapper must set it explicitly.
1135
- effective_name = resolved.new.name
1098
+ # Step 4: wrap with tool result filters when registered.
1099
+ result_filters = _tool_result_filters_for(tool_class)
1100
+ return resolved if result_filters.empty?
1101
+
1102
+ effective_name4 = resolved.new.name
1136
1103
  Class.new(resolved) do
1137
- tool_name effective_name
1104
+ tool_name effective_name4
1138
1105
  define_method(:call) do |args|
1139
- if handler.call(name, args)
1140
- super(args)
1141
- else
1142
- "Tool execution denied."
1143
- end
1106
+ result = super(args)
1107
+ result_filters.inject(result) { |val, f| f.call(val, tool_name: name, args: args) }
1144
1108
  end
1145
1109
  end
1146
1110
  end
@@ -0,0 +1,184 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Agent
5
+ module Concerns
6
+ # Adds input, output, and tool-result filter support to an agent.
7
+ #
8
+ # Filters transform (or block) values at three call sites:
9
+ # - *input* — the raw user input string, before the LLM is called
10
+ # - *output* — the final LLM output string, before it is returned
11
+ # - *tool result* — the return value of each tool call
12
+ #
13
+ # Each filter in the chain receives the value returned by the previous one.
14
+ # A {Phronomy::FilterBlockError} raised inside any filter propagates to the
15
+ # caller.
16
+ #
17
+ # @api private
18
+ module Filterable
19
+ def self.included(base)
20
+ base.extend(ClassMethods)
21
+ end
22
+
23
+ # Class-level DSL mixed into the including agent class.
24
+ module ClassMethods
25
+ # Registers a filter applied to every invocation's user input.
26
+ # Accepts either a {Phronomy::Filter::Base} instance or a subclass;
27
+ # when a class is given it is instantiated with +.new+.
28
+ # @param filter [Phronomy::Filter::Base, Class<Phronomy::Filter::Base>]
29
+ # @return [void]
30
+ # @api public
31
+ def input_filter(filter)
32
+ @_class_input_filters ||= []
33
+ @_class_input_filters << _resolve_filter(filter)
34
+ end
35
+
36
+ # Registers a filter applied to every invocation's final LLM output.
37
+ # Accepts either a {Phronomy::Filter::Base} instance or a subclass;
38
+ # when a class is given it is instantiated with +.new+.
39
+ # @param filter [Phronomy::Filter::Base, Class<Phronomy::Filter::Base>]
40
+ # @return [void]
41
+ # @api public
42
+ def output_filter(filter)
43
+ @_class_output_filters ||= []
44
+ @_class_output_filters << _resolve_filter(filter)
45
+ end
46
+
47
+ # Registers a filter applied to every tool result for all tools.
48
+ # Accepts either a {Phronomy::Filter::Base} instance or a subclass;
49
+ # when a class is given it is instantiated with +.new+.
50
+ # @param filter [Phronomy::Filter::Base, Class<Phronomy::Filter::Base>]
51
+ # @return [void]
52
+ # @api public
53
+ def tool_result_filter(filter)
54
+ @_class_tool_result_filters ||= []
55
+ @_class_tool_result_filters << _resolve_filter(filter)
56
+ end
57
+
58
+ # @return [Array<Phronomy::Filter::Base>]
59
+ # @api private
60
+ def _class_input_filters
61
+ @_class_input_filters || []
62
+ end
63
+
64
+ # @return [Array<Phronomy::Filter::Base>]
65
+ # @api private
66
+ def _class_output_filters
67
+ @_class_output_filters || []
68
+ end
69
+
70
+ # @return [Array<Phronomy::Filter::Base>]
71
+ # @api private
72
+ def _class_tool_result_filters
73
+ @_class_tool_result_filters || []
74
+ end
75
+
76
+ private
77
+
78
+ # Coerce +filter+ to an instance: if a Class is passed, call +.new+;
79
+ # otherwise return the object as-is.
80
+ # @param filter [Phronomy::Filter::Base, Class<Phronomy::Filter::Base>]
81
+ # @return [Phronomy::Filter::Base]
82
+ # @api private
83
+ def _resolve_filter(filter)
84
+ filter.is_a?(Class) ? filter.new : filter
85
+ end
86
+ end
87
+
88
+ # Registers an input filter on this instance.
89
+ # Accepts either a {Phronomy::Filter::Base} instance or a subclass;
90
+ # when a class is given it is instantiated with +.new+.
91
+ # Runs in addition to any class-level input filters.
92
+ # @param filter [Phronomy::Filter::Base, Class<Phronomy::Filter::Base>]
93
+ # @return [self]
94
+ # @api public
95
+ def add_input_filter(filter)
96
+ @_instance_input_filters ||= []
97
+ @_instance_input_filters << _resolve_filter(filter)
98
+ self
99
+ end
100
+
101
+ # Registers an output filter on this instance.
102
+ # Accepts either a {Phronomy::Filter::Base} instance or a subclass;
103
+ # when a class is given it is instantiated with +.new+.
104
+ # Runs in addition to any class-level output filters.
105
+ # @param filter [Phronomy::Filter::Base, Class<Phronomy::Filter::Base>]
106
+ # @return [self]
107
+ # @api public
108
+ def add_output_filter(filter)
109
+ @_instance_output_filters ||= []
110
+ @_instance_output_filters << _resolve_filter(filter)
111
+ self
112
+ end
113
+
114
+ # Registers a tool result filter on this instance.
115
+ #
116
+ # When called with two arguments, the filter is scoped to the given tool
117
+ # class only. When called with one argument, it applies to all tools.
118
+ # Accepts either a {Phronomy::Filter::Base} instance or a subclass;
119
+ # when a class is given it is instantiated with +.new+.
120
+ #
121
+ # @overload add_tool_result_filter(filter)
122
+ # @param filter [Phronomy::Filter::Base, Class<Phronomy::Filter::Base>] applied to every tool
123
+ # @overload add_tool_result_filter(tool_class, filter)
124
+ # @param tool_class [Class] scope the filter to this tool
125
+ # @param filter [Phronomy::Filter::Base, Class<Phronomy::Filter::Base>]
126
+ # @return [self]
127
+ # @api public
128
+ def add_tool_result_filter(tool_class_or_filter, filter = nil)
129
+ if filter.nil?
130
+ # Single-argument form: apply to all tools.
131
+ @_instance_tool_result_filters ||= []
132
+ @_instance_tool_result_filters << _resolve_filter(tool_class_or_filter)
133
+ else
134
+ # Two-argument form: scoped to one tool class.
135
+ @_scoped_tool_result_filters ||= {}
136
+ (@_scoped_tool_result_filters[tool_class_or_filter] ||= []) << _resolve_filter(filter)
137
+ end
138
+ self
139
+ end
140
+
141
+ private
142
+
143
+ # Run input filters (class-level then instance-level).
144
+ # @param input [String, Hash] the raw user input
145
+ # @return [String, Hash] the (possibly transformed) input
146
+ # @api private
147
+ def run_input_filters!(input)
148
+ class_filters = self.class._class_input_filters
149
+ inst_filters = @_instance_input_filters || []
150
+ (class_filters + inst_filters).inject(input) { |val, f| f.call(val) }
151
+ end
152
+
153
+ # Run output filters (class-level then instance-level).
154
+ # @param output [String] the LLM output
155
+ # @return [String] the (possibly transformed) output
156
+ # @api private
157
+ def run_output_filters!(output)
158
+ class_filters = self.class._class_output_filters
159
+ inst_filters = @_instance_output_filters || []
160
+ (class_filters + inst_filters).inject(output) { |val, f| f.call(val) }
161
+ end
162
+
163
+ # Collect all tool-result filters (global + scoped) for a given tool class.
164
+ # @param tool_class [Class]
165
+ # @return [Array<Phronomy::Filter::Base>]
166
+ # @api private
167
+ def _tool_result_filters_for(tool_class)
168
+ global = self.class._class_tool_result_filters + (@_instance_tool_result_filters || [])
169
+ scoped = (@_scoped_tool_result_filters || {})[tool_class] || []
170
+ global + scoped
171
+ end
172
+
173
+ # Coerce +filter+ to an instance: if a Class is passed, call +.new+;
174
+ # otherwise return the object as-is.
175
+ # @param filter [Phronomy::Filter::Base, Class<Phronomy::Filter::Base>]
176
+ # @return [Phronomy::Filter::Base]
177
+ # @api private
178
+ def _resolve_filter(filter)
179
+ filter.is_a?(Class) ? filter.new : filter
180
+ end
181
+ end
182
+ end
183
+ end
184
+ end
@@ -6,7 +6,7 @@ module Phronomy
6
6
  # Adds configurable retry behaviour to an agent.
7
7
  #
8
8
  # Included in {Phronomy::Agent::Base}. The retry loop wraps the full
9
- # #invoke_once call; {Phronomy::GuardrailError} is never retried.
9
+ # #invoke_once call; {Phronomy::FilterBlockError} is never retried.
10
10
  # @api private
11
11
  module Retryable
12
12
  def self.included(base)
@@ -16,7 +16,7 @@ module Phronomy
16
16
  # Class-level DSL methods mixed into the including agent class.
17
17
  module ClassMethods
18
18
  # Configures a retry policy that wraps the full #invoke call.
19
- # GuardrailError is never retried regardless of this setting.
19
+ # FilterBlockError is never retried regardless of this setting.
20
20
  #
21
21
  # @param times [Integer] maximum retry attempts (default: 0)
22
22
  # @param wait [Symbol, Numeric] :exponential, :linear, or a fixed Float
@@ -60,7 +60,7 @@ module Phronomy
60
60
  attempt = 0
61
61
  begin
62
62
  invoke_once(input, messages: messages, thread_id: thread_id, config: config)
63
- rescue Phronomy::GuardrailError
63
+ rescue Phronomy::FilterBlockError
64
64
  raise
65
65
  rescue Phronomy::CancellationError
66
66
  raise # Never retry after cancellation.
@@ -80,7 +80,7 @@ module Phronomy
80
80
  # @return [Hash] +{ output: String, suspended: false, messages: Array, usage: Phronomy::TokenUsage }+
81
81
  # or +{ output: nil, suspended: true, checkpoint: Phronomy::Agent::Checkpoint, messages: Array }+
82
82
  # when a second approval-required tool is encountered during continuation
83
- # @raise [Phronomy::GuardrailError] when an output guardrail rejects the value
83
+ # @raise [Phronomy::FilterBlockError] when an output filter rejects the value
84
84
  # @raise [Phronomy::CheckpointAlreadyResumedError] when the checkpoint has already been consumed
85
85
  # @api private
86
86
  def resume(checkpoint, approved:, config: {})
@@ -143,7 +143,7 @@ module Phronomy
143
143
  output = response.content
144
144
  usage = Phronomy::TokenUsage.from_tokens(response.tokens)
145
145
 
146
- run_output_guardrails!(output)
146
+ output = run_output_filters!(output)
147
147
 
148
148
  {output: output, suspended: false, messages: chat.messages, usage: usage}
149
149
  end
@@ -92,7 +92,7 @@ module Phronomy
92
92
  public
93
93
 
94
94
  # Sets the access scope for this tool (metadata; enforcement is the responsibility of
95
- # the Workflow/Guardrail layer).
95
+ # the Workflow/Filter layer).
96
96
  # @param value [Symbol] e.g. :read_only, :write, :admin
97
97
  # @api public
98
98
  # mutant:disable - neutral failure: unparser round-trip produces different source
@@ -218,7 +218,7 @@ module Phronomy
218
218
  # retried up to +times+ times with the specified wait strategy.
219
219
  # Multiple policies can be registered and are evaluated in order.
220
220
  #
221
- # GuardrailError is never retried regardless of this configuration.
221
+ # FilterBlockError is never retried regardless of this configuration.
222
222
  #
223
223
  # @param exception_classes [Array<Class>] exception classes to retry on
224
224
  # @param times [Integer] maximum retry attempts (default: 1)
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Phronomy
4
+ module Filter
5
+ # Abstract base class for value filters.
6
+ #
7
+ # A filter may either transform a value (return the new value) or block it
8
+ # (raise {Phronomy::FilterBlockError}). The same filter instance can be
9
+ # registered at multiple call sites — input, output, and tool result.
10
+ #
11
+ # @example PII masking filter
12
+ # class PiiMaskFilter < Phronomy::Filter::Base
13
+ # def call(value, **_context)
14
+ # value.to_s
15
+ # .gsub(/\b\d{2,4}-\d{2,4}-\d{4}\b/, "[PHONE]")
16
+ # .gsub(/\b(?:\d{4}[- ]?){3}\d{4}\b/, "[CARD]")
17
+ # end
18
+ # end
19
+ #
20
+ # @example Blocking filter
21
+ # class NoBadWordFilter < Phronomy::Filter::Base
22
+ # def call(value, **_context)
23
+ # block!("Forbidden content detected") if value.to_s.include?("badword")
24
+ # value
25
+ # end
26
+ # end
27
+ #
28
+ # @api public
29
+ class Base
30
+ # Process +value+ and return the (possibly transformed) result.
31
+ #
32
+ # The +context+ keyword arguments vary by call site:
33
+ # - Tool result: +{ tool_name: String, args: Hash }+
34
+ # - Input / output: +(empty)+
35
+ #
36
+ # @param value [Object] the value being filtered
37
+ # @param context [Hash] optional call-site metadata
38
+ # @return [Object] the transformed value (or the original if unchanged)
39
+ # @raise [Phronomy::FilterBlockError] to reject the value
40
+ # @api public
41
+ def call(value, **_context)
42
+ raise NotImplementedError, "#{self.class}#call is not implemented"
43
+ end
44
+
45
+ protected
46
+
47
+ # Reject the value with a human-readable reason.
48
+ # @param reason [String]
49
+ # @raise [Phronomy::FilterBlockError]
50
+ # @api public
51
+ def block!(reason)
52
+ raise Phronomy::FilterBlockError.new(reason, filter: self)
53
+ end
54
+ end
55
+ end
56
+ end
@@ -1,28 +1,31 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- module Guardrail
4
+ module Filter
5
5
  # Detects potential prompt injection attempts in the agent input.
6
6
  #
7
7
  # Prompt injection is an attack where an adversary embeds LLM instructions
8
8
  # inside data sources (e.g. RAG chunks, tool results, user input) to override
9
9
  # the agent's intended behaviour.
10
10
  #
11
- # This guardrail scans the input string for common injection patterns and
12
- # calls {#fail!} when a match is found. It is intended to be registered as
13
- # an input guardrail on agents that consume untrusted external content.
11
+ # This filter scans the input string for common injection patterns and
12
+ # calls {#block!} when a match is found. It is intended to be registered as
13
+ # an input filter on agents that consume untrusted external content.
14
14
  #
15
15
  # @example
16
16
  # class MyAgent < Phronomy::Agent::Base
17
17
  # model "gpt-4o"
18
- # input_guardrails Phronomy::Guardrail::PromptInjectionGuardrail.new
18
+ # input_filter Phronomy::Filter::PromptInjectionFilter
19
19
  # end
20
20
  #
21
21
  # @example Custom patterns
22
- # guard = Phronomy::Guardrail::PromptInjectionGuardrail.new(
22
+ # filter = Phronomy::Filter::PromptInjectionFilter.new(
23
23
  # extra_patterns: [/exfiltrate/i]
24
24
  # )
25
- class PromptInjectionGuardrail < InputGuardrail
25
+ # agent.add_input_filter(filter)
26
+ #
27
+ # @api public
28
+ class PromptInjectionFilter < Base
26
29
  # Common prompt injection / jailbreak patterns.
27
30
  DEFAULT_PATTERNS = [
28
31
  /ignore\s+(previous|prior|all)\s+instructions?/i,
@@ -38,20 +41,24 @@ module Phronomy
38
41
  ].freeze
39
42
 
40
43
  # @param extra_patterns [Array<Regexp>] additional patterns to scan for
41
- # @api private
44
+ # @api public
42
45
  def initialize(extra_patterns: [])
43
46
  super()
44
47
  @patterns = DEFAULT_PATTERNS + extra_patterns
45
48
  end
46
49
 
47
50
  # Scans the input string for injection patterns.
48
- # @param input [String, Hash]
49
- # @api private
50
- def check(input)
51
- text = input.is_a?(Hash) ? input.values.join(" ") : input.to_s
51
+ # @param value [String, Hash]
52
+ # @param context [Hash]
53
+ # @return [String, Hash] the original value when no injection is detected
54
+ # @raise [Phronomy::FilterBlockError] when a pattern matches
55
+ # @api public
56
+ def call(value, **_context)
57
+ text = value.is_a?(Hash) ? value.values.join(" ") : value.to_s
52
58
  @patterns.each do |pattern|
53
- fail!("Potential prompt injection detected") if text.match?(pattern)
59
+ block!("Potential prompt injection detected") if text.match?(pattern)
54
60
  end
61
+ value
55
62
  end
56
63
  end
57
64
  end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Convenience require for Filter sub-classes.
4
+ require_relative "filter/base"
5
+ require_relative "filter/prompt_injection_filter"
@@ -66,8 +66,7 @@ module Phronomy
66
66
  # @!attribute [r] trusted
67
67
  # @return [Boolean] true when confidence >= threshold
68
68
  Result = Struct.new(
69
- :output, :confidence, :citations, :iterations, :review_notes, :trusted,
70
- keyword_init: true
69
+ :output, :confidence, :citations, :iterations, :review_notes, :trusted
71
70
  ) do
72
71
  # @return [Boolean] true when confidence >= threshold
73
72
  alias_method :trusted?, :trusted
@@ -47,8 +47,7 @@ module Phronomy
47
47
  :index, # Integer — 0-based worker index
48
48
  :agent, # Agent::Base instance
49
49
  :messages, # Array — accumulated conversation history
50
- :status, # Symbol — :idle | :available | :done
51
- keyword_init: true
50
+ :status # Symbol — :idle | :available | :done
52
51
  ) do
53
52
  # Returns true when this worker is ready to accept the next task.
54
53
  def available? = [:idle, :available].include?(status)
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "timeout"
4
+
5
+ module Phronomy
6
+ class Task
7
+ # Backend for Tasks created by {Task#map}.
8
+ #
9
+ # A mapped task's lifecycle is driven entirely by the +on_complete+
10
+ # callback of its source task — it never spawns a thread of its own.
11
+ # +MappedBackend+ transitions the owning task to +:running+ immediately
12
+ # on initialization so that +FSMSession+ treats it as an in-progress
13
+ # async action. Completion (or failure) is triggered externally via
14
+ # {Task#transition!} from the +on_complete+ callback registered by
15
+ # {Task#map}.
16
+ #
17
+ # +await+ and +join+ block until {#unblock} is called, which {Task#map}
18
+ # arranges by registering a second +on_complete+ callback on the *mapped*
19
+ # task itself after the transform callback has been registered.
20
+ #
21
+ # @api private
22
+ class MappedBackend < Backend
23
+ def initialize(task:, &)
24
+ super
25
+ @done_queue = Queue.new
26
+ task.transition!(:running)
27
+ end
28
+
29
+ # Unblocks +await+ / +join+. Called by {Task#map} after the mapped task
30
+ # reaches a terminal state.
31
+ # @api private
32
+ def unblock(value, error)
33
+ @done_queue.push([value, error])
34
+ end
35
+
36
+ # Blocks until the mapped task reaches a terminal state.
37
+ # @return [Object] the mapped value
38
+ # @raise [Exception] if the source task or the map block raised an error
39
+ # @api private
40
+ def await
41
+ value, error = @done_queue.pop
42
+ raise error if error
43
+
44
+ value
45
+ end
46
+
47
+ # Returns +false+ — a mapped task has no independent thread to kill.
48
+ # @return [Boolean]
49
+ # @api private
50
+ def alive?
51
+ false
52
+ end
53
+
54
+ # No-op — mapped tasks carry no independent thread to cancel.
55
+ # @return [self]
56
+ # @api private
57
+ def cancel!
58
+ self
59
+ end
60
+
61
+ # Blocks until the mapped task completes, with an optional timeout.
62
+ # @param limit [Numeric, nil]
63
+ # @return [Object, nil] +nil+ on timeout
64
+ # @api private
65
+ def join(limit = nil)
66
+ if limit.nil?
67
+ await
68
+ else
69
+ begin
70
+ Timeout.timeout(limit) { await }
71
+ rescue Timeout::Error
72
+ nil
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
78
+ end
data/lib/phronomy/task.rb CHANGED
@@ -4,6 +4,7 @@ require_relative "task/backend"
4
4
  require_relative "task/thread_backend"
5
5
  require_relative "task/immediate_backend"
6
6
  require_relative "task/fiber_backend"
7
+ require_relative "task/mapped_backend"
7
8
 
8
9
  module Phronomy
9
10
  # A single unit of concurrent work.
@@ -195,6 +196,58 @@ module Phronomy
195
196
  self
196
197
  end
197
198
 
199
+ # Returns a new {Task} whose completed value is the result of applying
200
+ # +block+ to this task's completed value.
201
+ #
202
+ # If this task fails or is cancelled, the mapped task also fails/is
203
+ # cancelled with the same error. The block is never called in error cases.
204
+ #
205
+ # The primary use-case is transforming an agent result into a
206
+ # {WorkflowContext} so that a Workflow entry action can return a Task
207
+ # whose value is picked up by {Workflow::FSMSession} via the existing
208
+ # +:action_completed+ path:
209
+ #
210
+ # @example Returning agent output into a Workflow state field
211
+ # entry :translate, ->(ctx) {
212
+ # TranslationAgent.new.invoke_async(ctx.query).map do |result|
213
+ # ctx.merge(answer: result[:output]) # returns WorkflowContext
214
+ # end
215
+ # }
216
+ #
217
+ # @yield [value] the completed value of this task
218
+ # @yieldreturn [Object] the value for the mapped task
219
+ # @return [Task] a new task that completes when this task does
220
+ # @api public
221
+ def map(&block)
222
+ # MappedBackend drives the task lifecycle entirely via on_complete;
223
+ # it never spawns a thread of its own.
224
+ mapped = self.class.spawn(
225
+ name: "#{@name}-mapped",
226
+ parent: @parent,
227
+ backend_class: MappedBackend
228
+ ) {}
229
+
230
+ on_complete do |value, error|
231
+ mapped_value = nil
232
+ mapped_error = error
233
+ unless error
234
+ begin
235
+ mapped_value = block.call(value)
236
+ rescue => e
237
+ mapped_error = e
238
+ end
239
+ end
240
+ if mapped_error
241
+ mapped.transition!(:failed, error: mapped_error)
242
+ else
243
+ mapped.transition!(:completed, value: mapped_value)
244
+ end
245
+ # Unblock mapped.await / mapped.join after the terminal transition.
246
+ mapped.backend.unblock(mapped_value, mapped_error)
247
+ end
248
+ mapped
249
+ end
250
+
198
251
  # Returns +true+ once the task has finished (success, error, or cancellation).
199
252
  # @return [Boolean]
200
253
  # @api private
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.9.1"
4
+ VERSION = "0.11.0"
5
5
  end
data/lib/phronomy.rb CHANGED
@@ -87,12 +87,15 @@ module Phronomy
87
87
  end
88
88
  end
89
89
 
90
- class GuardrailError < Error
91
- attr_reader :guardrail
90
+ # Raised by a {Phronomy::Filter::Base} subclass when the filter rejects a
91
+ # value without transforming it (blocking the pipeline).
92
+ # @api public
93
+ class FilterBlockError < Error
94
+ attr_reader :filter
92
95
 
93
- def initialize(message, guardrail: nil)
96
+ def initialize(message, filter: nil)
94
97
  super(message)
95
- @guardrail = guardrail
98
+ @filter = filter
96
99
  end
97
100
  end
98
101
 
@@ -123,7 +126,8 @@ module Phronomy
123
126
  # Raised when a {Phronomy::WorkflowContext} field is mutated from a thread
124
127
  # that does not own the context (i.e. not the EventLoop dispatch thread).
125
128
  # Only raised in EventLoop mode. Use +context.merge(...)+ to produce a new
126
- # context, or deliver updates as +:child_completed+ event payloads.
129
+ # context, or deliver updates as +:action_completed+ event payloads
130
+ # via {Agent::Base#invoke_async} + {Task#map}.
127
131
  class WorkflowContextOwnershipError < Error; end
128
132
 
129
133
  class << self
@@ -32,8 +32,8 @@ PUBLIC_API_ENTRIES = [
32
32
  # Beta
33
33
  Phronomy::MultiAgent::Orchestrator,
34
34
  Phronomy::MultiAgent::TeamCoordinator,
35
- Phronomy::Guardrail::InputGuardrail,
36
- Phronomy::Guardrail::OutputGuardrail,
35
+ Phronomy::Filter::Base,
36
+ Phronomy::Filter::PromptInjectionFilter,
37
37
  Phronomy::VectorStore::Base,
38
38
  Phronomy::VectorStore::InMemory,
39
39
  Phronomy::VectorStore::Embeddings::Base,
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: phronomy
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.1
4
+ version: 0.11.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Raizo T.C.S
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-06-05 00:00:00.000000000 Z
11
+ date: 2026-06-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: ruby_llm
@@ -64,9 +64,9 @@ dependencies:
64
64
  - - "~>"
65
65
  - !ruby/object:Gem::Version
66
66
  version: '0.6'
67
- description: Phronomy provides composable building blocks Agents, Workflows, Tools,
68
- Guardrails, and Tracing — for building AI agents in Ruby. Powered by RubyLLM for
69
- LLM abstraction.
67
+ description: Phronomy is a Ruby AI agent framework that provides composable building
68
+ blocks — Agents, Workflows, Tools, Filters, and Tracing — for building AI agents
69
+ in Ruby. Powered by RubyLLM for LLM abstraction.
70
70
  email:
71
71
  - raizo.tcs@gmail.com
72
72
  executables: []
@@ -109,7 +109,7 @@ files:
109
109
  - lib/phronomy/agent/checkpoint_store.rb
110
110
  - lib/phronomy/agent/concerns/before_completion.rb
111
111
  - lib/phronomy/agent/concerns/error_translation.rb
112
- - lib/phronomy/agent/concerns/guardrailable.rb
112
+ - lib/phronomy/agent/concerns/filterable.rb
113
113
  - lib/phronomy/agent/concerns/retryable.rb
114
114
  - lib/phronomy/agent/concerns/suspendable.rb
115
115
  - lib/phronomy/agent/context/capability/base.rb
@@ -147,12 +147,10 @@ files:
147
147
  - lib/phronomy/eval/scorer/llm_judge.rb
148
148
  - lib/phronomy/event.rb
149
149
  - lib/phronomy/event_loop.rb
150
+ - lib/phronomy/filter.rb
151
+ - lib/phronomy/filter/base.rb
152
+ - lib/phronomy/filter/prompt_injection_filter.rb
150
153
  - lib/phronomy/generator_verifier.rb
151
- - lib/phronomy/guardrail.rb
152
- - lib/phronomy/guardrail/base.rb
153
- - lib/phronomy/guardrail/input_guardrail.rb
154
- - lib/phronomy/guardrail/output_guardrail.rb
155
- - lib/phronomy/guardrail/prompt_injection_guardrail.rb
156
154
  - lib/phronomy/invocation_context.rb
157
155
  - lib/phronomy/knowledge_source.rb
158
156
  - lib/phronomy/llm_adapter.rb
@@ -189,6 +187,7 @@ files:
189
187
  - lib/phronomy/task/backend.rb
190
188
  - lib/phronomy/task/fiber_backend.rb
191
189
  - lib/phronomy/task/immediate_backend.rb
190
+ - lib/phronomy/task/mapped_backend.rb
192
191
  - lib/phronomy/task/thread_backend.rb
193
192
  - lib/phronomy/task_group.rb
194
193
  - lib/phronomy/testing.rb
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Agent
5
- module Concerns
6
- # Adds input and output guardrail support to an agent.
7
- #
8
- # Included in {Phronomy::Agent::Base}. Guardrails are run on the raw
9
- # input string before the LLM is called, and on the raw output string
10
- # before the result is returned to the caller.
11
- # @api private
12
- module Guardrailable
13
- # Attach a guardrail that validates input before every #invoke call.
14
- # @param guardrail [Phronomy::Guardrail::InputGuardrail]
15
- # @return [self]
16
- # @api private
17
- def add_input_guardrail(guardrail)
18
- @input_guardrails ||= []
19
- @input_guardrails << guardrail
20
- self
21
- end
22
-
23
- # Attach a guardrail that validates output before it is returned.
24
- # @param guardrail [Phronomy::Guardrail::OutputGuardrail]
25
- # @return [self]
26
- # @api private
27
- def add_output_guardrail(guardrail)
28
- @output_guardrails ||= []
29
- @output_guardrails << guardrail
30
- self
31
- end
32
-
33
- private
34
-
35
- def run_input_guardrails!(input)
36
- (@input_guardrails || []).each { |g| g.run!(input) }
37
- end
38
-
39
- def run_output_guardrails!(output)
40
- (@output_guardrails || []).each { |g| g.run!(output) }
41
- end
42
- end
43
- end
44
- end
45
- end
@@ -1,45 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Guardrail
5
- # Abstract base class for all guardrails.
6
- #
7
- # Subclasses override #check to validate input or output.
8
- # Call #fail! inside #check to reject with a reason.
9
- #
10
- # @example
11
- # class NoPIIGuardrail < Phronomy::Guardrail::InputGuardrail
12
- # def check(input)
13
- # fail!("PII detected") if input.to_s.match?(/\d{3}-\d{2}-\d{4}/)
14
- # end
15
- # end
16
- class Base
17
- # Validate the value. Subclasses must implement this method.
18
- # @param value [Object] the input or output being checked
19
- # @raise [Phronomy::GuardrailError] if the guardrail rejects the value
20
- # @api public
21
- def check(value)
22
- raise NotImplementedError, "#{self.class}#check is not implemented"
23
- end
24
-
25
- # Run the check, raising GuardrailError on failure.
26
- # @param value [Object]
27
- # @return [Object] the original value (unchanged) when the check passes
28
- # @api public
29
- def run!(value)
30
- check(value)
31
- value
32
- end
33
-
34
- protected
35
-
36
- # Call inside #check to reject the value.
37
- # @param reason [String] human-readable rejection reason
38
- # @raise [Phronomy::GuardrailError]
39
- # @api public
40
- def fail!(reason)
41
- raise Phronomy::GuardrailError.new(reason, guardrail: self)
42
- end
43
- end
44
- end
45
- end
@@ -1,19 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Guardrail
5
- # Guardrail applied to agent/chain input before it reaches the LLM.
6
- #
7
- # @example
8
- # class NoCreditCardGuardrail < Phronomy::Guardrail::InputGuardrail
9
- # def check(input)
10
- # fail!("Credit card numbers are not allowed") if input.to_s.match?(/\d{4}[- ]\d{4}[- ]\d{4}[- ]\d{4}/)
11
- # end
12
- # end
13
- #
14
- # agent = MyAgent.new
15
- # agent.add_input_guardrail(NoCreditCardGuardrail.new)
16
- class InputGuardrail < Base
17
- end
18
- end
19
- end
@@ -1,19 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Phronomy
4
- module Guardrail
5
- # Guardrail applied to agent/chain output before it is returned to the caller.
6
- #
7
- # @example
8
- # class NoSecretsGuardrail < Phronomy::Guardrail::OutputGuardrail
9
- # def check(output)
10
- # fail!("Response contains a secret key") if output.to_s.match?(/sk-[A-Za-z0-9]{32,}/)
11
- # end
12
- # end
13
- #
14
- # agent = MyAgent.new
15
- # agent.add_output_guardrail(NoSecretsGuardrail.new)
16
- class OutputGuardrail < Base
17
- end
18
- end
19
- end
@@ -1,7 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # Convenience require for Guardrail sub-classes.
4
- # Zeitwerk auto-loads individual files; this is only needed for explicit requires.
5
- require_relative "guardrail/base"
6
- require_relative "guardrail/input_guardrail"
7
- require_relative "guardrail/output_guardrail"