phronomy 0.10.0 → 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: f8bcaf54fe256791053b04c22df3b58595d267b2c0ee16b67369fe2ecb36f19d
4
- data.tar.gz: 9be01ec03a79ec5d557a3c51fc442992cd012a07e682a43cfa4c9e0358110fc8
3
+ metadata.gz: e73d1b49c4638232021afad5cc807466c4bb7b2cfcfe60446930288e868a3db9
4
+ data.tar.gz: 624e783c532fc2ea009db99a7bda4a1308f06019bc0e776a78d206b70031b352
5
5
  SHA512:
6
- metadata.gz: 44de75b5cc59ddf380aeb0be3abcf2a2c566cb8a1db6c35540525b86c74e7e0c06e75a3581e30276a25eea7f5f0334226e05b54d7d9694ed8f2b881d04a54e60
7
- data.tar.gz: 76821f0cad1a918b762bb1d5737f902cc67b726f8dafb0dcf646b5cfc8dd4173527733a8a04e92e71ebea9a152fdb6ebaea20d9ccde3037f32d7fc32f5b05497
6
+ metadata.gz: 0ce003a8f0b0c85cd29a744b86ee89ad7414c511ea46c1ad446227824100174b6bfae76de8a4caa32428a50f77606af8006c17ae307eb514692a92829331570c
7
+ data.tar.gz: 10a3e965b58c730d5ba21eefc3c7151725a26e6accc24dbde04dd91546aa6c65bea801faae0fe5ef1908995a78c259159712c0f8dffe3f19938fa64b14988c16
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).
@@ -851,11 +853,11 @@ span attributes by default (`trace_pii: false`). To include full content in trac
851
853
  Phronomy configuration. Evaluate whether your tracing backend (OTLP collector, Jaeger,
852
854
  Honeycomb, etc.) meets your data-retention and privacy requirements.
853
855
 
854
- **Prompt injection** — Phronomy provides `PromptInjectionGuardrail`, a built-in
855
- 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
856
858
  instructions, role-switching phrases, etc.). It is a useful starting point, not a
857
859
  comprehensive defence; applications processing untrusted input should layer additional
858
- custom guardrails as needed (see the Guardrails section above).
860
+ custom filters as needed (see the Filters section above).
859
861
 
860
862
  **Tool and MCP security** — Tools can perform real-world side effects (database
861
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]
@@ -603,7 +603,7 @@ module Phronomy
603
603
  # Streaming implementation for #stream.
604
604
  def _stream_impl(input, messages: [], thread_id: nil, config: {}, &block)
605
605
  trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
606
- run_input_guardrails!(input)
606
+ input = run_input_filters!(input)
607
607
 
608
608
  chat = build_chat
609
609
  user_message = extract_message(input)
@@ -634,7 +634,7 @@ module Phronomy
634
634
  run_before_completion_hooks!(chat, config)
635
635
 
636
636
  output, usage = _drain_stream(chat, user_message, config, &block)
637
- run_output_guardrails!(output)
637
+ output = run_output_filters!(output)
638
638
 
639
639
  result = {output: output, messages: chat.messages, usage: usage}
640
640
  block.call(StreamEvent.new(type: :done, payload: result))
@@ -813,7 +813,7 @@ module Phronomy
813
813
  # wrap it in a retry loop without duplicating the LLM interaction logic.
814
814
  def invoke_once(input, messages: [], thread_id: nil, config: {})
815
815
  trace("agent.invoke", input: input, **_build_caller_meta(config)) do |_span|
816
- run_input_guardrails!(input)
816
+ input = run_input_filters!(input)
817
817
 
818
818
  user_message = extract_message(input)
819
819
  chat = build_chat
@@ -835,7 +835,8 @@ module Phronomy
835
835
  )
836
836
  next [result, usage] if result[:suspended]
837
837
 
838
- 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])
839
840
  [result, usage]
840
841
  end
841
842
  end
@@ -1076,21 +1077,34 @@ module Phronomy
1076
1077
  end
1077
1078
 
1078
1079
  # Step 3: wrap with approval gate when handler is registered.
1079
- 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
1097
+
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?
1080
1101
 
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
1102
+ effective_name4 = resolved.new.name
1086
1103
  Class.new(resolved) do
1087
- tool_name effective_name
1104
+ tool_name effective_name4
1088
1105
  define_method(:call) do |args|
1089
- if handler.call(name, args)
1090
- super(args)
1091
- else
1092
- "Tool execution denied."
1093
- end
1106
+ result = super(args)
1107
+ result_filters.inject(result) { |val, f| f.call(val, tool_name: name, args: args) }
1094
1108
  end
1095
1109
  end
1096
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)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Phronomy
4
- VERSION = "0.10.0"
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
 
@@ -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.10.0
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-07 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
@@ -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"