agentforge-py 0.2.1__py3-none-any.whl
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.
- agentforge/__init__.py +114 -0
- agentforge/_testing/__init__.py +19 -0
- agentforge/_testing/fake_llm.py +126 -0
- agentforge/_testing/fake_tool.py +122 -0
- agentforge/_tools/__init__.py +14 -0
- agentforge/_tools/calculator.py +102 -0
- agentforge/_tools/decorator.py +300 -0
- agentforge/_tools/file_read.py +112 -0
- agentforge/_tools/shell.py +134 -0
- agentforge/_tools/web_search.py +207 -0
- agentforge/agent.py +817 -0
- agentforge/auth.py +42 -0
- agentforge/cli/__init__.py +18 -0
- agentforge/cli/_build.py +323 -0
- agentforge/cli/_scaffold_state.py +250 -0
- agentforge/cli/_shared_scaffold.py +174 -0
- agentforge/cli/config_cmd.py +174 -0
- agentforge/cli/db_cmd.py +262 -0
- agentforge/cli/debug_cmd.py +168 -0
- agentforge/cli/docs_cmd.py +217 -0
- agentforge/cli/eval_cmd.py +181 -0
- agentforge/cli/health_cmd.py +139 -0
- agentforge/cli/list_modules.py +85 -0
- agentforge/cli/main.py +81 -0
- agentforge/cli/manifest_apply.py +368 -0
- agentforge/cli/module_cmd.py +247 -0
- agentforge/cli/new_cmd.py +171 -0
- agentforge/cli/run_cmd.py +234 -0
- agentforge/cli/upgrade_cmd.py +230 -0
- agentforge/config/__init__.py +45 -0
- agentforge/eval/__init__.py +18 -0
- agentforge/eval/consistency.py +107 -0
- agentforge/eval/coverage.py +100 -0
- agentforge/eval/format_compliance.py +107 -0
- agentforge/eval/regression.py +143 -0
- agentforge/findings.py +166 -0
- agentforge/guardrails/__init__.py +32 -0
- agentforge/guardrails/allowlist.py +49 -0
- agentforge/guardrails/capability_check.py +58 -0
- agentforge/guardrails/engine.py +289 -0
- agentforge/guardrails/pii_redact_basic.py +61 -0
- agentforge/guardrails/prompt_injection_basic.py +90 -0
- agentforge/memory/__init__.py +16 -0
- agentforge/memory/in_memory.py +130 -0
- agentforge/memory/in_memory_graph.py +262 -0
- agentforge/memory/in_memory_vector.py +167 -0
- agentforge/pipeline/__init__.py +26 -0
- agentforge/pipeline/engine.py +189 -0
- agentforge/pipeline/errors.py +19 -0
- agentforge/pipeline/tool.py +93 -0
- agentforge/py.typed +0 -0
- agentforge/recording.py +189 -0
- agentforge/renderers/__init__.py +28 -0
- agentforge/renderers/_defaults.py +32 -0
- agentforge/renderers/markdown.py +44 -0
- agentforge/renderers/patch_applier.py +46 -0
- agentforge/renderers/registry.py +108 -0
- agentforge/renderers/scorecard.py +59 -0
- agentforge/renderers/span_table.py +71 -0
- agentforge/replay.py +260 -0
- agentforge/resolver_register.py +41 -0
- agentforge/retrieval.py +410 -0
- agentforge/runtime.py +63 -0
- agentforge/strategies/__init__.py +27 -0
- agentforge/strategies/_base.py +280 -0
- agentforge/strategies/_plan.py +93 -0
- agentforge/strategies/multi_agent.py +541 -0
- agentforge/strategies/plan_execute.py +506 -0
- agentforge/strategies/react.py +237 -0
- agentforge/strategies/tot.py +472 -0
- agentforge/templates/_shared/.cursorrules +12 -0
- agentforge/templates/_shared/.github/copilot-instructions.md +13 -0
- agentforge/templates/_shared/.gitkeep +0 -0
- agentforge/templates/_shared/AGENTS.md.tmpl +123 -0
- agentforge/templates/_shared/CLAUDE.md +13 -0
- agentforge/templates/_shared/docs/runbooks/01-set-up-new-agent.md.tmpl +67 -0
- agentforge/templates/_shared/docs/runbooks/02-add-a-tool.md +67 -0
- agentforge/templates/_shared/docs/runbooks/03-add-a-pipeline-task.md +69 -0
- agentforge/templates/_shared/docs/runbooks/04-pick-reasoning-strategy.md +67 -0
- agentforge/templates/_shared/docs/runbooks/05-write-prompts.md +75 -0
- agentforge/templates/_shared/docs/runbooks/06-test-your-agent.md +75 -0
- agentforge/templates/_shared/docs/runbooks/07-debug-a-run.md +70 -0
- agentforge/templates/_shared/docs/runbooks/08-add-memory.md +75 -0
- agentforge/templates/_shared/docs/runbooks/09-add-mcp.md +78 -0
- agentforge/templates/_shared/docs/runbooks/10-add-evaluators.md +76 -0
- agentforge/templates/_shared/docs/runbooks/11-add-safety-guardrails.md +83 -0
- agentforge/templates/_shared/docs/runbooks/12-add-observability.md +77 -0
- agentforge/templates/_shared/docs/runbooks/13-configure-multi-provider.md +91 -0
- agentforge/templates/_shared/docs/runbooks/14-deploy-your-agent.md +70 -0
- agentforge/templates/_shared/docs/runbooks/15-upgrade-your-agent.md +67 -0
- agentforge/templates/_shared/docs/runbooks/16-configuration-reference.md +81 -0
- agentforge/templates/_shared/docs/runbooks/17-add-reranker.md +78 -0
- agentforge/templates/_shared/docs/runbooks/18-add-hybrid-search.md +78 -0
- agentforge/templates/_shared/docs/runbooks/19-add-graphrag.md +83 -0
- agentforge/templates/_shared/docs/runbooks/20-apply-schema-migrations.md +92 -0
- agentforge/templates/_shared/docs/runbooks/21-use-streaming-guardrails.md +82 -0
- agentforge/templates/_shared/docs/runbooks/README.md.tmpl +68 -0
- agentforge/templates/code-reviewer/.env.example +8 -0
- agentforge/templates/code-reviewer/.gitignore +7 -0
- agentforge/templates/code-reviewer/README.md +12 -0
- agentforge/templates/code-reviewer/agentforge.yaml +23 -0
- agentforge/templates/code-reviewer/copier.yml +34 -0
- agentforge/templates/code-reviewer/pyproject.toml +18 -0
- agentforge/templates/code-reviewer/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/code-reviewer/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
- agentforge/templates/docs-qa/.env.example +8 -0
- agentforge/templates/docs-qa/.gitignore +7 -0
- agentforge/templates/docs-qa/README.md +14 -0
- agentforge/templates/docs-qa/agentforge.yaml +19 -0
- agentforge/templates/docs-qa/copier.yml +31 -0
- agentforge/templates/docs-qa/pyproject.toml +18 -0
- agentforge/templates/docs-qa/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/docs-qa/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
- agentforge/templates/minimal/.env.example +11 -0
- agentforge/templates/minimal/.gitignore +10 -0
- agentforge/templates/minimal/README.md +28 -0
- agentforge/templates/minimal/agentforge.yaml +10 -0
- agentforge/templates/minimal/copier.yml +52 -0
- agentforge/templates/minimal/pyproject.toml +18 -0
- agentforge/templates/minimal/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/minimal/src/{{project_slug.replace('-', '_')}}/main.py +34 -0
- agentforge/templates/patch-bot/.env.example +8 -0
- agentforge/templates/patch-bot/.gitignore +7 -0
- agentforge/templates/patch-bot/README.md +13 -0
- agentforge/templates/patch-bot/agentforge.yaml +15 -0
- agentforge/templates/patch-bot/copier.yml +31 -0
- agentforge/templates/patch-bot/pyproject.toml +18 -0
- agentforge/templates/patch-bot/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/patch-bot/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
- agentforge/templates/research/.env.example +8 -0
- agentforge/templates/research/.gitignore +7 -0
- agentforge/templates/research/README.md +14 -0
- agentforge/templates/research/agentforge.yaml +17 -0
- agentforge/templates/research/copier.yml +31 -0
- agentforge/templates/research/pyproject.toml +18 -0
- agentforge/templates/research/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/research/src/{{project_slug.replace('-', '_')}}/main.py +31 -0
- agentforge/templates/triage/.env.example +8 -0
- agentforge/templates/triage/.gitignore +7 -0
- agentforge/templates/triage/README.md +14 -0
- agentforge/templates/triage/agentforge.yaml +25 -0
- agentforge/templates/triage/copier.yml +31 -0
- agentforge/templates/triage/pyproject.toml +18 -0
- agentforge/templates/triage/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/triage/src/{{project_slug.replace('-', '_')}}/main.py +30 -0
- agentforge/testing/__init__.py +69 -0
- agentforge/testing/conformance.py +40 -0
- agentforge/testing/factory.py +89 -0
- agentforge/testing/fixtures.py +42 -0
- agentforge/testing/llm.py +235 -0
- agentforge/testing/recording.py +177 -0
- agentforge/tools/__init__.py +41 -0
- agentforge_py-0.2.1.dist-info/METADATA +158 -0
- agentforge_py-0.2.1.dist-info/RECORD +157 -0
- agentforge_py-0.2.1.dist-info/WHEEL +4 -0
- agentforge_py-0.2.1.dist-info/entry_points.txt +2 -0
- agentforge_py-0.2.1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""Guardrail engine — runs validators + emits audit events (feat-018).
|
|
2
|
+
|
|
3
|
+
Glues `InputValidator` / `OutputValidator` / `ToolCallGate` lists to
|
|
4
|
+
the agent's lifecycle. `GuardrailEngine` is constructed once per
|
|
5
|
+
`Agent` from the config + the framework-wide policy, then:
|
|
6
|
+
|
|
7
|
+
- `await engine.check_input(content, ctx)` is invoked once at the
|
|
8
|
+
start of `agent.run(task)`.
|
|
9
|
+
- The engine wraps the LLM client (`engine.wrap_llm(llm)`) so every
|
|
10
|
+
`.call(...)` output flows through `OutputValidator`s.
|
|
11
|
+
- The engine wraps each tool (`engine.wrap_tool(tool)`) so every
|
|
12
|
+
`.run(...)` is gated through `ToolCallGate`s.
|
|
13
|
+
|
|
14
|
+
All decisions append to `engine.events` (a list of dicts conforming
|
|
15
|
+
to `RunResult.guardrail_events` shape) and emit a structured log
|
|
16
|
+
record on the `agentforge.audit` channel.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import hashlib
|
|
22
|
+
import logging
|
|
23
|
+
from collections.abc import Awaitable, Callable
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
from agentforge_core.config.schema import GuardrailPolicy
|
|
27
|
+
from agentforge_core.contracts.guardrails import (
|
|
28
|
+
InputValidator,
|
|
29
|
+
OutputValidator,
|
|
30
|
+
ToolCallGate,
|
|
31
|
+
)
|
|
32
|
+
from agentforge_core.contracts.llm import LLMClient
|
|
33
|
+
from agentforge_core.contracts.tool import Tool
|
|
34
|
+
from agentforge_core.production.exceptions import GuardrailViolation, ModuleError
|
|
35
|
+
from agentforge_core.values.guardrails import ValidationResult
|
|
36
|
+
from agentforge_core.values.messages import LLMResponse, Message, ToolSpec
|
|
37
|
+
|
|
38
|
+
_audit_log = logging.getLogger("agentforge.audit")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class GuardrailEngine:
|
|
42
|
+
"""Per-Agent engine that runs validators + records decisions."""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
input_validators: list[InputValidator],
|
|
48
|
+
output_validators: list[OutputValidator],
|
|
49
|
+
tool_gates: list[ToolCallGate],
|
|
50
|
+
policy: GuardrailPolicy,
|
|
51
|
+
) -> None:
|
|
52
|
+
self._inputs = list(input_validators)
|
|
53
|
+
self._outputs = list(output_validators)
|
|
54
|
+
self._gates = list(tool_gates)
|
|
55
|
+
self._policy = policy
|
|
56
|
+
self.events: list[dict[str, Any]] = []
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------
|
|
59
|
+
# Input
|
|
60
|
+
# ------------------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
async def check_input(self, content: str, context: dict[str, Any]) -> str:
|
|
63
|
+
"""Run every input validator. Returns the (possibly redacted)
|
|
64
|
+
content for downstream use; raises `GuardrailViolation` on
|
|
65
|
+
block."""
|
|
66
|
+
return await self._run_stage(
|
|
67
|
+
stage="input",
|
|
68
|
+
validators=self._inputs,
|
|
69
|
+
content=content,
|
|
70
|
+
context=context,
|
|
71
|
+
action=self._policy.on_input_violation,
|
|
72
|
+
call=lambda v, c, ctx: v.validate(c, ctx),
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
async def check_output(self, content: str, context: dict[str, Any]) -> str:
|
|
76
|
+
"""Run every output validator post-LLM call."""
|
|
77
|
+
return await self._run_stage(
|
|
78
|
+
stage="output",
|
|
79
|
+
validators=self._outputs,
|
|
80
|
+
content=content,
|
|
81
|
+
context=context,
|
|
82
|
+
action=self._policy.on_output_violation,
|
|
83
|
+
call=lambda v, c, ctx: v.validate(c, ctx),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
# ------------------------------------------------------------------
|
|
87
|
+
# Wrappers
|
|
88
|
+
# ------------------------------------------------------------------
|
|
89
|
+
|
|
90
|
+
def wrap_llm(self, real: LLMClient, context_factory: Callable[[], dict[str, Any]]) -> LLMClient:
|
|
91
|
+
"""Wrap a real `LLMClient` so every `.call()` result flows
|
|
92
|
+
through the configured `OutputValidator`s.
|
|
93
|
+
|
|
94
|
+
Validators see the model's text content; tool_calls are
|
|
95
|
+
passed through untouched (those go through `ToolCallGate`s
|
|
96
|
+
on dispatch).
|
|
97
|
+
"""
|
|
98
|
+
return _GuardedLLMClient(real=real, engine=self, context_factory=context_factory)
|
|
99
|
+
|
|
100
|
+
def wrap_tool(self, real: Tool, context_factory: Callable[[], dict[str, Any]]) -> Tool:
|
|
101
|
+
"""Wrap a real `Tool` so every `.run(...)` is gated."""
|
|
102
|
+
return _wrap_tool(real, self, context_factory)
|
|
103
|
+
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
# Internals
|
|
106
|
+
# ------------------------------------------------------------------
|
|
107
|
+
|
|
108
|
+
async def _run_stage(
|
|
109
|
+
self,
|
|
110
|
+
*,
|
|
111
|
+
stage: str,
|
|
112
|
+
validators: list[Any],
|
|
113
|
+
content: str,
|
|
114
|
+
context: dict[str, Any],
|
|
115
|
+
action: str,
|
|
116
|
+
call: Callable[[Any, str, dict[str, Any]], Awaitable[ValidationResult]],
|
|
117
|
+
) -> str:
|
|
118
|
+
current = content
|
|
119
|
+
for v in validators:
|
|
120
|
+
|
|
121
|
+
async def _call(
|
|
122
|
+
validator: Any = v,
|
|
123
|
+
content_value: str = current,
|
|
124
|
+
ctx: dict[str, Any] = context,
|
|
125
|
+
) -> ValidationResult:
|
|
126
|
+
return await call(validator, content_value, ctx)
|
|
127
|
+
|
|
128
|
+
result = await self._safe_invoke(v, _call)
|
|
129
|
+
self._emit(stage=stage, validator=v, result=result, content=current, action=action)
|
|
130
|
+
if result.passed:
|
|
131
|
+
continue
|
|
132
|
+
if action == "block":
|
|
133
|
+
msg = (
|
|
134
|
+
f"guardrail block: validator={v.name!r} stage={stage} "
|
|
135
|
+
f"violations={list(result.violations)!r}"
|
|
136
|
+
)
|
|
137
|
+
raise GuardrailViolation(msg)
|
|
138
|
+
if action == "redact" and result.redacted_content is not None:
|
|
139
|
+
current = result.redacted_content
|
|
140
|
+
# warn / allow: continue without modification
|
|
141
|
+
return current
|
|
142
|
+
|
|
143
|
+
async def authorize_tool(
|
|
144
|
+
self,
|
|
145
|
+
tool_name: str,
|
|
146
|
+
tool: Tool,
|
|
147
|
+
args: dict[str, Any],
|
|
148
|
+
context: dict[str, Any],
|
|
149
|
+
) -> None:
|
|
150
|
+
action = self._policy.on_tool_violation
|
|
151
|
+
for gate in self._gates:
|
|
152
|
+
|
|
153
|
+
async def _call(g: ToolCallGate = gate) -> ValidationResult:
|
|
154
|
+
return await g.authorize(tool_name, tool, args, context)
|
|
155
|
+
|
|
156
|
+
result = await self._safe_invoke(gate, _call)
|
|
157
|
+
self._emit(
|
|
158
|
+
stage="tool",
|
|
159
|
+
validator=gate,
|
|
160
|
+
result=result,
|
|
161
|
+
content=f"{tool_name}:{sorted(args)}",
|
|
162
|
+
action=action,
|
|
163
|
+
)
|
|
164
|
+
if result.passed:
|
|
165
|
+
continue
|
|
166
|
+
if action == "block":
|
|
167
|
+
msg = (
|
|
168
|
+
f"guardrail block: tool gate={gate.name!r} tool={tool_name!r} "
|
|
169
|
+
f"violations={list(result.violations)!r}"
|
|
170
|
+
)
|
|
171
|
+
raise GuardrailViolation(msg)
|
|
172
|
+
|
|
173
|
+
async def _safe_invoke(
|
|
174
|
+
self,
|
|
175
|
+
validator: Any,
|
|
176
|
+
call: Callable[[], Awaitable[ValidationResult]],
|
|
177
|
+
) -> ValidationResult:
|
|
178
|
+
try:
|
|
179
|
+
return await call()
|
|
180
|
+
except (RuntimeError, ModuleError, ValueError) as exc:
|
|
181
|
+
if self._policy.fail_open:
|
|
182
|
+
_audit_log.warning(
|
|
183
|
+
"validator %s raised; fail_open=True so the call proceeds: %s",
|
|
184
|
+
validator.name,
|
|
185
|
+
exc,
|
|
186
|
+
)
|
|
187
|
+
return ValidationResult.ok()
|
|
188
|
+
return ValidationResult(
|
|
189
|
+
passed=False,
|
|
190
|
+
score=0.0,
|
|
191
|
+
violations=("validator_error",),
|
|
192
|
+
metadata={"error": str(exc)},
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
def _emit(
|
|
196
|
+
self,
|
|
197
|
+
*,
|
|
198
|
+
stage: str,
|
|
199
|
+
validator: Any,
|
|
200
|
+
result: ValidationResult,
|
|
201
|
+
content: str,
|
|
202
|
+
action: str,
|
|
203
|
+
) -> None:
|
|
204
|
+
event = {
|
|
205
|
+
"stage": stage,
|
|
206
|
+
"validator": validator.name,
|
|
207
|
+
"passed": result.passed,
|
|
208
|
+
"violations": list(result.violations),
|
|
209
|
+
"score": result.score,
|
|
210
|
+
"action": action,
|
|
211
|
+
"content_hash": _hash(content),
|
|
212
|
+
}
|
|
213
|
+
self.events.append(event)
|
|
214
|
+
log = _audit_log.info if result.passed else _audit_log.warning
|
|
215
|
+
log(
|
|
216
|
+
"guardrail %s: %s passed=%s violations=%s action=%s",
|
|
217
|
+
stage,
|
|
218
|
+
validator.name,
|
|
219
|
+
result.passed,
|
|
220
|
+
list(result.violations),
|
|
221
|
+
action,
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
def _hash(content: str) -> str:
|
|
226
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()[:16]
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
class _GuardedLLMClient(LLMClient):
|
|
230
|
+
"""LLM wrapper that runs output validators after each `.call(...)`."""
|
|
231
|
+
|
|
232
|
+
def __init__(
|
|
233
|
+
self,
|
|
234
|
+
*,
|
|
235
|
+
real: LLMClient,
|
|
236
|
+
engine: GuardrailEngine,
|
|
237
|
+
context_factory: Callable[[], dict[str, Any]],
|
|
238
|
+
) -> None:
|
|
239
|
+
self._real = real
|
|
240
|
+
self._engine = engine
|
|
241
|
+
self._ctx = context_factory
|
|
242
|
+
|
|
243
|
+
async def call(
|
|
244
|
+
self,
|
|
245
|
+
system: str,
|
|
246
|
+
messages: list[Message],
|
|
247
|
+
tools: list[ToolSpec] | None = None,
|
|
248
|
+
) -> LLMResponse:
|
|
249
|
+
response = await self._real.call(system, messages, tools)
|
|
250
|
+
if not response.content:
|
|
251
|
+
return response
|
|
252
|
+
validated = await self._engine.check_output(response.content, self._ctx())
|
|
253
|
+
if validated == response.content:
|
|
254
|
+
return response
|
|
255
|
+
return response.model_copy(update={"content": validated})
|
|
256
|
+
|
|
257
|
+
async def close(self) -> None:
|
|
258
|
+
await self._real.close()
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _wrap_tool(
|
|
262
|
+
real: Tool,
|
|
263
|
+
engine: GuardrailEngine,
|
|
264
|
+
context_factory: Callable[[], dict[str, Any]],
|
|
265
|
+
) -> Tool:
|
|
266
|
+
"""Build a per-instance Tool wrapper that consults `engine` before
|
|
267
|
+
forwarding to `real.run(...)`."""
|
|
268
|
+
|
|
269
|
+
real_name = type(real).name
|
|
270
|
+
real_description = type(real).description
|
|
271
|
+
real_input_schema = type(real).input_schema
|
|
272
|
+
real_caps = type(real).capabilities
|
|
273
|
+
|
|
274
|
+
async def _run(_self: Tool, **kwargs: Any) -> Any:
|
|
275
|
+
await engine.authorize_tool(real_name, real, kwargs, context_factory())
|
|
276
|
+
return await real.run(**kwargs)
|
|
277
|
+
|
|
278
|
+
cls_namespace: dict[str, Any] = {
|
|
279
|
+
"name": real_name,
|
|
280
|
+
"description": real_description,
|
|
281
|
+
"input_schema": real_input_schema,
|
|
282
|
+
"capabilities": real_caps,
|
|
283
|
+
"run": _run,
|
|
284
|
+
}
|
|
285
|
+
synthesized = type(f"Guarded_{type(real).__name__}", (Tool,), cls_namespace)
|
|
286
|
+
return synthesized() # type: ignore[no-any-return]
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
__all__ = ["GuardrailEngine"]
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""`pii_redact_basic` — regex-based PII detection + redaction
|
|
2
|
+
(feat-018).
|
|
3
|
+
|
|
4
|
+
Output validator. Replaces detected PII with `<redacted:KIND>`
|
|
5
|
+
placeholders so downstream consumers can still parse around the
|
|
6
|
+
redactions.
|
|
7
|
+
|
|
8
|
+
Patterns are conservative — they catch obvious cases (RFC-822
|
|
9
|
+
email, US-shaped SSN / phone, common credit-card and IPv4
|
|
10
|
+
formats) without trying to be exhaustive. For richer coverage,
|
|
11
|
+
install `agentforge-guard-presidio`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from typing import Any, ClassVar
|
|
18
|
+
|
|
19
|
+
from agentforge_core.contracts.guardrails import OutputValidator
|
|
20
|
+
from agentforge_core.resolver import register
|
|
21
|
+
from agentforge_core.values.guardrails import ValidationResult
|
|
22
|
+
|
|
23
|
+
_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
|
24
|
+
("email", re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")),
|
|
25
|
+
("phone_us", re.compile(r"\b(?:\+?1[-.\s]?)?\(?\d{3}\)?[-.\s]?\d{3}[-.\s]?\d{4}\b")),
|
|
26
|
+
("ssn", re.compile(r"\b\d{3}-\d{2}-\d{4}\b")),
|
|
27
|
+
("credit_card", re.compile(r"\b(?:\d[ -]*?){13,16}\b")),
|
|
28
|
+
("ipv4", re.compile(r"\b\d{1,3}(?:\.\d{1,3}){3}\b")),
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@register("guardrails.output", "pii_redact_basic")
|
|
33
|
+
class PIIRedactBasic(OutputValidator):
|
|
34
|
+
"""Regex-based PII detector + redactor (basic tier)."""
|
|
35
|
+
|
|
36
|
+
name: ClassVar[str] = "pii_redact_basic"
|
|
37
|
+
description: ClassVar[str] = (
|
|
38
|
+
"Regex-based PII detector (email / phone / SSN / credit-card / IPv4) "
|
|
39
|
+
"that emits `<redacted:KIND>` placeholders in the output."
|
|
40
|
+
)
|
|
41
|
+
cost_estimate_ms: ClassVar[int] = 2
|
|
42
|
+
|
|
43
|
+
async def validate(self, content: str, context: dict[str, Any]) -> ValidationResult:
|
|
44
|
+
del context
|
|
45
|
+
violations: list[str] = []
|
|
46
|
+
redacted = content
|
|
47
|
+
for kind, pattern in _PATTERNS:
|
|
48
|
+
if pattern.search(redacted):
|
|
49
|
+
violations.append(kind)
|
|
50
|
+
redacted = pattern.sub(f"<redacted:{kind}>", redacted)
|
|
51
|
+
if not violations:
|
|
52
|
+
return ValidationResult.ok()
|
|
53
|
+
return ValidationResult(
|
|
54
|
+
passed=False,
|
|
55
|
+
score=0.0,
|
|
56
|
+
violations=tuple(violations),
|
|
57
|
+
redacted_content=redacted,
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
__all__ = ["PIIRedactBasic"]
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
"""`prompt_injection_basic` — regex pattern matching for the most
|
|
2
|
+
common prompt-injection phrases (feat-018).
|
|
3
|
+
|
|
4
|
+
This is the *basic* tier — it catches the obvious cases ("ignore
|
|
5
|
+
previous instructions") and almost nothing else. Production
|
|
6
|
+
deployments install `agentforge-guard-llmguard` or
|
|
7
|
+
`agentforge-guard-llamaguard` for richer coverage.
|
|
8
|
+
|
|
9
|
+
The pattern set is intentionally conservative: false positives are
|
|
10
|
+
expensive (block user) so we only flag phrases with very high
|
|
11
|
+
prior probability of malicious intent.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import re
|
|
17
|
+
from typing import Any, ClassVar
|
|
18
|
+
|
|
19
|
+
from agentforge_core.contracts.guardrails import InputValidator
|
|
20
|
+
from agentforge_core.resolver import register
|
|
21
|
+
from agentforge_core.values.guardrails import ValidationResult
|
|
22
|
+
|
|
23
|
+
_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
|
24
|
+
(
|
|
25
|
+
"ignore_previous",
|
|
26
|
+
re.compile(
|
|
27
|
+
r"\bignore\s+(?:all\s+)?(?:previous|prior)\s+(?:instructions?|prompts?|messages?)\b",
|
|
28
|
+
re.IGNORECASE,
|
|
29
|
+
),
|
|
30
|
+
),
|
|
31
|
+
(
|
|
32
|
+
"disregard_instructions",
|
|
33
|
+
re.compile(r"\bdisregard\s+(?:all\s+)?(?:your\s+)?instructions?\b", re.IGNORECASE),
|
|
34
|
+
),
|
|
35
|
+
(
|
|
36
|
+
"system_prompt_leak",
|
|
37
|
+
re.compile(
|
|
38
|
+
r"\b(?:reveal|print|show|repeat|leak|dump)\s+(?:your|the)\s+(?:system\s+)?(?:prompt|instructions)\b",
|
|
39
|
+
re.IGNORECASE,
|
|
40
|
+
),
|
|
41
|
+
),
|
|
42
|
+
(
|
|
43
|
+
"act_as_jailbreak",
|
|
44
|
+
re.compile(
|
|
45
|
+
r"\bact\s+as\s+(?:dan|jailbroken|developer\s+mode|do\s+anything\s+now)\b", re.IGNORECASE
|
|
46
|
+
),
|
|
47
|
+
),
|
|
48
|
+
(
|
|
49
|
+
"new_persona",
|
|
50
|
+
re.compile(
|
|
51
|
+
r"\byou\s+are\s+now\s+(?:dan|jailbroken|in\s+developer\s+mode)\b", re.IGNORECASE
|
|
52
|
+
),
|
|
53
|
+
),
|
|
54
|
+
(
|
|
55
|
+
"bypass_safety",
|
|
56
|
+
re.compile(
|
|
57
|
+
r"\b(?:bypass|disable|turn\s+off)\s+(?:safety|guardrails?|filter|moderation)\b",
|
|
58
|
+
re.IGNORECASE,
|
|
59
|
+
),
|
|
60
|
+
),
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@register("guardrails.input", "prompt_injection_basic")
|
|
65
|
+
class PromptInjectionBasic(InputValidator):
|
|
66
|
+
"""Regex-based prompt-injection detector (basic tier)."""
|
|
67
|
+
|
|
68
|
+
name: ClassVar[str] = "prompt_injection_basic"
|
|
69
|
+
description: ClassVar[str] = (
|
|
70
|
+
"Regex-based detector for the most common prompt-injection phrases. "
|
|
71
|
+
"Conservative pattern set — false positives are expensive."
|
|
72
|
+
)
|
|
73
|
+
cost_estimate_ms: ClassVar[int] = 1
|
|
74
|
+
|
|
75
|
+
async def validate(self, content: str, context: dict[str, Any]) -> ValidationResult:
|
|
76
|
+
del context
|
|
77
|
+
violations: list[str] = []
|
|
78
|
+
for rule, pattern in _PATTERNS:
|
|
79
|
+
if pattern.search(content):
|
|
80
|
+
violations.append(rule)
|
|
81
|
+
if violations:
|
|
82
|
+
return ValidationResult(
|
|
83
|
+
passed=False,
|
|
84
|
+
score=0.0,
|
|
85
|
+
violations=tuple(violations),
|
|
86
|
+
)
|
|
87
|
+
return ValidationResult.ok()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
__all__ = ["PromptInjectionBasic"]
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Default in-process memory implementations.
|
|
2
|
+
|
|
3
|
+
`InMemoryStore` (claim audit log) and `InMemoryVectorStore` (semantic
|
|
4
|
+
search) ship with `agentforge` so a fresh `Agent(...)` has
|
|
5
|
+
durable-shaped state out of the box without external infra. Both are
|
|
6
|
+
safe defaults; production deployments swap to real drivers
|
|
7
|
+
(`agentforge-memory-sqlite`, `-postgres`) via `agentforge.yaml`.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from agentforge.memory.in_memory import InMemoryStore
|
|
13
|
+
from agentforge.memory.in_memory_graph import InMemoryGraphStore
|
|
14
|
+
from agentforge.memory.in_memory_vector import InMemoryVectorStore
|
|
15
|
+
|
|
16
|
+
__all__ = ["InMemoryGraphStore", "InMemoryStore", "InMemoryVectorStore"]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""`InMemoryStore` — process-local `MemoryStore` implementation.
|
|
2
|
+
|
|
3
|
+
Used as the default when no persistence module is configured. Loses
|
|
4
|
+
data on process exit (by design — for tests, demos, ephemeral runs).
|
|
5
|
+
For durable storage swap to a feat-005 driver via `agentforge.yaml`.
|
|
6
|
+
|
|
7
|
+
Implementation notes:
|
|
8
|
+
|
|
9
|
+
- Storage is an `OrderedDict[str, Claim]` keyed by claim id, so
|
|
10
|
+
insertion order is preserved (sortable ULID ids already provide
|
|
11
|
+
monotonic ordering, but the dict guarantees it after deletes /
|
|
12
|
+
supersedes).
|
|
13
|
+
- All operations are ``async def`` to match the `MemoryStore`
|
|
14
|
+
contract; they don't actually do I/O so they complete on the
|
|
15
|
+
current event-loop tick.
|
|
16
|
+
- Thread safety is NOT a goal — this is for in-process single-loop
|
|
17
|
+
use. Multi-process / multi-worker deployments use feat-005 drivers.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from collections import OrderedDict
|
|
23
|
+
from collections.abc import AsyncIterator
|
|
24
|
+
from datetime import datetime
|
|
25
|
+
|
|
26
|
+
from agentforge_core.contracts.memory import MemoryStore
|
|
27
|
+
from agentforge_core.production.exceptions import ModuleError
|
|
28
|
+
from agentforge_core.values.claim import Claim
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class InMemoryStore(MemoryStore):
|
|
32
|
+
"""Process-local `MemoryStore` backed by an in-memory ``OrderedDict``."""
|
|
33
|
+
|
|
34
|
+
def __init__(self) -> None:
|
|
35
|
+
self._items: OrderedDict[str, Claim] = OrderedDict()
|
|
36
|
+
|
|
37
|
+
async def put(self, claim: Claim) -> str:
|
|
38
|
+
self._items[claim.id] = claim
|
|
39
|
+
return claim.id
|
|
40
|
+
|
|
41
|
+
async def get(self, claim_id: str) -> Claim | None:
|
|
42
|
+
return self._items.get(claim_id)
|
|
43
|
+
|
|
44
|
+
async def query(
|
|
45
|
+
self,
|
|
46
|
+
*,
|
|
47
|
+
project: str | None = None,
|
|
48
|
+
agent: str | None = None,
|
|
49
|
+
category: str | None = None,
|
|
50
|
+
run_id: str | None = None,
|
|
51
|
+
limit: int = 100,
|
|
52
|
+
) -> list[Claim]:
|
|
53
|
+
results: list[Claim] = []
|
|
54
|
+
for claim in self._items.values():
|
|
55
|
+
if project is not None and claim.project != project:
|
|
56
|
+
continue
|
|
57
|
+
if agent is not None and claim.agent != agent:
|
|
58
|
+
continue
|
|
59
|
+
if category is not None and claim.category != category:
|
|
60
|
+
continue
|
|
61
|
+
if run_id is not None and claim.run_id != run_id:
|
|
62
|
+
continue
|
|
63
|
+
results.append(claim)
|
|
64
|
+
if len(results) >= limit:
|
|
65
|
+
break
|
|
66
|
+
return results
|
|
67
|
+
|
|
68
|
+
async def supersede(self, old_id: str, new_claim: Claim) -> str:
|
|
69
|
+
if old_id not in self._items:
|
|
70
|
+
raise ModuleError(f"Cannot supersede unknown claim id: {old_id!r}")
|
|
71
|
+
if new_claim.supersedes is None:
|
|
72
|
+
new_claim = new_claim.model_copy(update={"supersedes": old_id})
|
|
73
|
+
elif new_claim.supersedes != old_id:
|
|
74
|
+
raise ModuleError(
|
|
75
|
+
f"new_claim.supersedes={new_claim.supersedes!r} does not match old_id={old_id!r}"
|
|
76
|
+
)
|
|
77
|
+
self._items[new_claim.id] = new_claim
|
|
78
|
+
return new_claim.id
|
|
79
|
+
|
|
80
|
+
def stream(
|
|
81
|
+
self,
|
|
82
|
+
*,
|
|
83
|
+
project: str | None = None,
|
|
84
|
+
agent: str | None = None,
|
|
85
|
+
category: str | None = None,
|
|
86
|
+
run_id: str | None = None,
|
|
87
|
+
) -> AsyncIterator[Claim]:
|
|
88
|
+
async def _agen() -> AsyncIterator[Claim]:
|
|
89
|
+
for claim in list(self._items.values()):
|
|
90
|
+
if project is not None and claim.project != project:
|
|
91
|
+
continue
|
|
92
|
+
if agent is not None and claim.agent != agent:
|
|
93
|
+
continue
|
|
94
|
+
if category is not None and claim.category != category:
|
|
95
|
+
continue
|
|
96
|
+
if run_id is not None and claim.run_id != run_id:
|
|
97
|
+
continue
|
|
98
|
+
yield claim
|
|
99
|
+
|
|
100
|
+
return _agen()
|
|
101
|
+
|
|
102
|
+
async def delete(
|
|
103
|
+
self,
|
|
104
|
+
*,
|
|
105
|
+
run_id: str | None = None,
|
|
106
|
+
older_than: datetime | None = None,
|
|
107
|
+
category: str | None = None,
|
|
108
|
+
) -> int:
|
|
109
|
+
if run_id is None and older_than is None and category is None:
|
|
110
|
+
raise ModuleError(
|
|
111
|
+
"delete() requires at least one filter; refusing to wipe every claim."
|
|
112
|
+
)
|
|
113
|
+
keep: OrderedDict[str, Claim] = OrderedDict()
|
|
114
|
+
removed = 0
|
|
115
|
+
for cid, claim in self._items.items():
|
|
116
|
+
if run_id is not None and claim.run_id != run_id:
|
|
117
|
+
keep[cid] = claim
|
|
118
|
+
continue
|
|
119
|
+
if category is not None and claim.category != category:
|
|
120
|
+
keep[cid] = claim
|
|
121
|
+
continue
|
|
122
|
+
if older_than is not None and claim.created_at >= older_than:
|
|
123
|
+
keep[cid] = claim
|
|
124
|
+
continue
|
|
125
|
+
removed += 1
|
|
126
|
+
self._items = keep
|
|
127
|
+
return removed
|
|
128
|
+
|
|
129
|
+
async def close(self) -> None:
|
|
130
|
+
self._items.clear()
|