agentforge-core 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.
Files changed (58) hide show
  1. agentforge_core/__init__.py +228 -0
  2. agentforge_core/_bm25.py +132 -0
  3. agentforge_core/config/__init__.py +62 -0
  4. agentforge_core/config/loader.py +239 -0
  5. agentforge_core/config/module_schemas.py +208 -0
  6. agentforge_core/config/schema.py +424 -0
  7. agentforge_core/contracts/__init__.py +52 -0
  8. agentforge_core/contracts/auth.py +33 -0
  9. agentforge_core/contracts/chat.py +118 -0
  10. agentforge_core/contracts/embedding.py +71 -0
  11. agentforge_core/contracts/evaluator.py +56 -0
  12. agentforge_core/contracts/finding.py +39 -0
  13. agentforge_core/contracts/graph_store.py +180 -0
  14. agentforge_core/contracts/guardrails.py +129 -0
  15. agentforge_core/contracts/llm.py +152 -0
  16. agentforge_core/contracts/memory.py +113 -0
  17. agentforge_core/contracts/migrator.py +120 -0
  18. agentforge_core/contracts/renderer.py +57 -0
  19. agentforge_core/contracts/reranker.py +91 -0
  20. agentforge_core/contracts/strategy.py +70 -0
  21. agentforge_core/contracts/task.py +73 -0
  22. agentforge_core/contracts/tool.py +71 -0
  23. agentforge_core/contracts/vector_store.py +151 -0
  24. agentforge_core/migrations/__init__.py +14 -0
  25. agentforge_core/migrations/discover.py +77 -0
  26. agentforge_core/migrations/template.py +34 -0
  27. agentforge_core/observability/__init__.py +18 -0
  28. agentforge_core/observability/tracing.py +37 -0
  29. agentforge_core/production/__init__.py +77 -0
  30. agentforge_core/production/budget.py +134 -0
  31. agentforge_core/production/exceptions.py +136 -0
  32. agentforge_core/production/fallback.py +321 -0
  33. agentforge_core/production/log_filter.py +49 -0
  34. agentforge_core/production/log_format.py +117 -0
  35. agentforge_core/production/run_context.py +108 -0
  36. agentforge_core/py.typed +0 -0
  37. agentforge_core/resolver/__init__.py +38 -0
  38. agentforge_core/resolver/discover.py +145 -0
  39. agentforge_core/resolver/resolve.py +168 -0
  40. agentforge_core/testing/__init__.py +45 -0
  41. agentforge_core/testing/conformance.py +1138 -0
  42. agentforge_core/values/__init__.py +103 -0
  43. agentforge_core/values/auth.py +20 -0
  44. agentforge_core/values/chat.py +131 -0
  45. agentforge_core/values/claim.py +30 -0
  46. agentforge_core/values/graph.py +136 -0
  47. agentforge_core/values/guardrails.py +49 -0
  48. agentforge_core/values/manifest.py +129 -0
  49. agentforge_core/values/messages.py +153 -0
  50. agentforge_core/values/module.py +40 -0
  51. agentforge_core/values/pipeline.py +43 -0
  52. agentforge_core/values/retrieval.py +53 -0
  53. agentforge_core/values/state.py +118 -0
  54. agentforge_core/values/vector.py +59 -0
  55. agentforge_core-0.2.1.dist-info/METADATA +66 -0
  56. agentforge_core-0.2.1.dist-info/RECORD +58 -0
  57. agentforge_core-0.2.1.dist-info/WHEEL +4 -0
  58. agentforge_core-0.2.1.dist-info/licenses/LICENSE +202 -0
@@ -0,0 +1,33 @@
1
+ """`AuthPolicy` — locked authentication contract (feat-014).
2
+
3
+ Both `agentforge-chat-http` (feat-020) and `agentforge-a2a`
4
+ (feat-014) need to validate incoming bearer tokens against
5
+ configured credentials. This contract unifies them.
6
+
7
+ Server-side validation only: `authenticate(bearer_token) ->
8
+ Principal | None`. Client-side credential attachment is
9
+ dict-driven (per-peer config carries `{type, token, cert,
10
+ key, ...}`) — no policy abstraction; outgoing transports build
11
+ the right httpx parameters from the dict.
12
+
13
+ Per ADR-0007 the methods on this ABC are locked once the
14
+ feature ships. Adding a method is a major-version bump.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from abc import ABC, abstractmethod
20
+
21
+ from agentforge_core.values.auth import Principal
22
+
23
+
24
+ class AuthPolicy(ABC):
25
+ """Validates incoming bearer credentials against configured
26
+ identities. Implementations are typically env-backed
27
+ (`EnvBearerAuth`) or registry-backed."""
28
+
29
+ @abstractmethod
30
+ async def authenticate(self, bearer_token: str | None) -> Principal | None:
31
+ """Return a `Principal` when the token is valid, else
32
+ ``None``. ``None`` input (missing header) must yield
33
+ ``None``."""
@@ -0,0 +1,118 @@
1
+ """Chat-agent contracts (feat-020).
2
+
3
+ `ChatHistoryStore` and `HistoryTruncationStrategy` are the two locked
4
+ ABCs the chat layer ships against. Drivers (in-memory + sqlite +
5
+ postgres + redis) all implement the same `ChatHistoryStore` shape;
6
+ truncation strategies (sliding-window, token-budget, summarise-oldest,
7
+ hybrid) all implement the same `HistoryTruncationStrategy` shape.
8
+
9
+ Per ADR-0007, methods on these ABCs are locked once the feature
10
+ ships. Adding a method is a major version bump.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from abc import ABC, abstractmethod
16
+ from collections.abc import Mapping
17
+ from datetime import datetime
18
+ from typing import Any
19
+
20
+ from agentforge_core.values.chat import ChatTurn, SessionInfo
21
+
22
+
23
+ class ChatHistoryStore(ABC):
24
+ """Persistent store for chat turns, isolated by `session_id`.
25
+
26
+ All read/write methods take `session_id`; cross-session access
27
+ is impossible without explicitly passing the id. Drivers
28
+ typically index on `(session_id, created_at)` so `load()` is
29
+ sub-linear w.r.t. total store size.
30
+ """
31
+
32
+ @abstractmethod
33
+ async def append(self, turn: ChatTurn) -> None:
34
+ """Persist a single chat turn."""
35
+
36
+ @abstractmethod
37
+ async def load(
38
+ self,
39
+ session_id: str,
40
+ *,
41
+ limit: int | None = None,
42
+ before: datetime | None = None,
43
+ after: datetime | None = None,
44
+ roles: list[str] | None = None,
45
+ ) -> list[ChatTurn]:
46
+ """Load turns for ``session_id`` in chronological order
47
+ (oldest first). Filters apply pre-limit."""
48
+
49
+ @abstractmethod
50
+ async def count(self, session_id: str) -> int:
51
+ """Total turn count for ``session_id``."""
52
+
53
+ @abstractmethod
54
+ async def delete_session(self, session_id: str) -> int:
55
+ """Delete every turn for ``session_id``. Returns the number
56
+ of turns removed."""
57
+
58
+ @abstractmethod
59
+ async def list_sessions(
60
+ self,
61
+ *,
62
+ owner: str | None = None,
63
+ limit: int = 100,
64
+ before: datetime | None = None,
65
+ ) -> list[SessionInfo]:
66
+ """List sessions, optionally filtered by owner. Ordered by
67
+ ``last_active_at`` descending."""
68
+
69
+ @abstractmethod
70
+ async def update_session_metadata(self, session_id: str, metadata: Mapping[str, Any]) -> None:
71
+ """Merge ``metadata`` into the session's metadata dict.
72
+
73
+ Implementations may overwrite top-level keys; nested merging
74
+ is the caller's responsibility.
75
+ """
76
+
77
+ @abstractmethod
78
+ async def expire_before(self, cutoff: datetime) -> int:
79
+ """TTL sweep: delete every session whose ``last_active_at <
80
+ cutoff``. Returns the number of sessions removed. Drivers
81
+ without TTL support return 0."""
82
+
83
+ @abstractmethod
84
+ async def close(self) -> None:
85
+ """Release driver resources (DB pool, file handles, etc.)."""
86
+
87
+ def capabilities(self) -> set[str]:
88
+ """Optional capability bag.
89
+
90
+ Subset of: ``"ttl"``, ``"encryption_at_rest"``,
91
+ ``"full_text_search"``, ``"streaming_load"``.
92
+ """
93
+ return set()
94
+
95
+ def supports(self, capability: str) -> bool:
96
+ return capability in self.capabilities()
97
+
98
+
99
+ class HistoryTruncationStrategy(ABC):
100
+ """Decides which prior turns to include in the next LLM call.
101
+
102
+ Truncation runs every turn, between `load()` and the agent call.
103
+ Returns a possibly-empty subset of ``all_turns`` (ordered).
104
+ Invariants every strategy honours (covered by the conformance
105
+ harness):
106
+
107
+ - Order-preserving (output is a subsequence of input).
108
+ - Tool-call / tool-result pairs are never split.
109
+ """
110
+
111
+ @abstractmethod
112
+ async def select(
113
+ self,
114
+ all_turns: list[ChatTurn],
115
+ next_user_message: str,
116
+ context: Mapping[str, Any],
117
+ ) -> list[ChatTurn]:
118
+ """Return the subset of ``all_turns`` to feed to the LLM."""
@@ -0,0 +1,71 @@
1
+ """`EmbeddingClient` — locked embeddings provider abstraction.
2
+
3
+ Embedding providers (Bedrock Titan / Cohere, OpenAI, etc.) implement
4
+ this ABC. The vector store / retrieval layer (feat-007) consumes
5
+ `EmbeddingClient`, never the concrete driver type, so swapping
6
+ providers is a string-id swap.
7
+
8
+ Per ADR-0007 the surface is locked at v0.1: adding a method is a
9
+ major version bump. Optional capabilities (e.g. multimodal embeddings)
10
+ are layered the same way as on `LLMClient` — declared in
11
+ `capabilities()` and gated via `supports()`.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from abc import ABC, abstractmethod
17
+
18
+ from agentforge_core.values.messages import EmbeddingResponse
19
+
20
+
21
+ class EmbeddingClient(ABC):
22
+ """Provider-agnostic text-embedding client.
23
+
24
+ Implementations:
25
+ - normalise the provider's response into `EmbeddingResponse`
26
+ - declare the model's vector dimensionality up front via
27
+ `dimensions()` so callers can size storage before the call
28
+ - compute `cost_usd` from token usage and a per-model price
29
+ table inside the driver (consistent with `LLMClient`)
30
+ """
31
+
32
+ @abstractmethod
33
+ async def embed(self, texts: list[str]) -> EmbeddingResponse:
34
+ """Embed a batch of texts.
35
+
36
+ Args:
37
+ texts: One or more texts to embed. Empty list raises
38
+ `ValueError` (no provider supports zero-length batches
39
+ and the cost would be ambiguous).
40
+
41
+ Returns:
42
+ `EmbeddingResponse` carrying one vector per input text in
43
+ input order. Every vector has length `self.dimensions()`.
44
+ """
45
+
46
+ @abstractmethod
47
+ async def close(self) -> None:
48
+ """Release any resources (HTTP clients, connection pools)."""
49
+
50
+ @abstractmethod
51
+ def dimensions(self) -> int:
52
+ """The vector dimensionality every `embed()` call returns.
53
+
54
+ Drivers declare this without a network round-trip — it is a
55
+ property of the configured model. Callers use this to size
56
+ storage (e.g. vector-store column widths) before the first
57
+ embed call.
58
+ """
59
+
60
+ def capabilities(self) -> set[str]:
61
+ """Optional capabilities this driver supports.
62
+
63
+ Default empty set. Closed vocabulary (additions are minor
64
+ bumps): `"multimodal"` (image / audio inputs in addition to
65
+ text), `"matryoshka"` (truncatable variable-length vectors).
66
+ """
67
+ return set()
68
+
69
+ def supports(self, capability: str) -> bool:
70
+ """True if this client declares the given capability."""
71
+ return capability in self.capabilities()
@@ -0,0 +1,56 @@
1
+ """`Evaluator` — the locked post-run evaluator ABC, plus `EvalResult`.
2
+
3
+ feat-001 ships only the contract and the result type. feat-006 ships
4
+ deterministic graders (coverage, consistency, regression-vs-baseline,
5
+ format-compliance) and LLM-judge graders (correctness, faithfulness,
6
+ groundedness, hallucination, relevance, helpfulness) via the
7
+ `agentforge-eval-geval` module.
8
+
9
+ Evaluators run *after* the reasoning loop completes and score the
10
+ agent's output (per `docs/features/feat-006-evaluators-and-benchmarks.md`).
11
+ This is distinct from real-time validators (feat-018) which block /
12
+ redact at the moment a violation happens.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from abc import ABC, abstractmethod
18
+ from typing import Any, ClassVar
19
+
20
+ from pydantic import BaseModel, ConfigDict, Field
21
+
22
+
23
+ class EvalResult(BaseModel):
24
+ """The outcome of evaluating one finding (or output)."""
25
+
26
+ model_config = ConfigDict(frozen=True, strict=True)
27
+
28
+ evaluator: str
29
+ score: float
30
+ """Conventionally in [0, 1]; NaN allowed for "not applicable"."""
31
+
32
+ label: str | None = None
33
+ """Optional discrete label such as "pass" / "fail" / "warn"."""
34
+
35
+ reasoning: str | None = None
36
+ """LLM-judge rationale or rule-based explanation."""
37
+
38
+ raw: dict[str, Any] = Field(default_factory=dict)
39
+ """Driver-specific extra detail — never required, never relied on."""
40
+
41
+
42
+ class Evaluator(ABC):
43
+ """Post-run quality scorer.
44
+
45
+ Subclasses declare:
46
+
47
+ name: str — identifier surfaced in EvalResult
48
+ cost_estimate_usd: float — per-evaluation cost (0 for non-LLM)
49
+ """
50
+
51
+ name: ClassVar[str]
52
+ cost_estimate_usd: ClassVar[float] = 0.0
53
+
54
+ @abstractmethod
55
+ async def evaluate(self, finding: Any, context: dict[str, Any]) -> EvalResult:
56
+ """Score `finding` against this evaluator's rubric."""
@@ -0,0 +1,39 @@
1
+ """`Finding` — structural Protocol the agent's output items satisfy.
2
+
3
+ Per feat-008 / ADR-0012, `Finding` is a `runtime_checkable` Protocol
4
+ rather than a single dataclass. Shipped variants (`SimpleFinding`,
5
+ `PatchFinding`, `NarrativeFinding`, `MultiSpanFinding`) live in the
6
+ runtime package; they satisfy this Protocol structurally without
7
+ needing to inherit from anything.
8
+
9
+ Custom variants from agent code or third-party packages also satisfy
10
+ the Protocol simply by declaring the required attributes — no
11
+ registration ceremony.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Protocol, runtime_checkable
17
+
18
+
19
+ @runtime_checkable
20
+ class Finding(Protocol):
21
+ """Minimum shape any pipeline / agent output item satisfies.
22
+
23
+ The runtime checks `isinstance(x, Finding)` opportunistically (e.g.
24
+ when storing as a `Claim.payload`); the check is structural and
25
+ tolerant.
26
+ """
27
+
28
+ severity: str
29
+ """One of "critical" | "warning" | "suggestion" | "info"."""
30
+
31
+ category: str
32
+ """Free-form categorisation: "style", "security", "answer", etc."""
33
+
34
+ message: str
35
+ """Short human-readable summary (one or two sentences)."""
36
+
37
+ def to_dict(self) -> dict[str, Any]:
38
+ """Serialise to a JSON-compatible dict for persistence / transport."""
39
+ ...
@@ -0,0 +1,180 @@
1
+ """`GraphStore` — locked graph-traversal ABC.
2
+
3
+ A graph store is distinct from `MemoryStore` (claim audit log) and
4
+ `VectorStore` (similarity search): the shapes don't unify cleanly.
5
+ `MemoryStore` filters by structured metadata; `VectorStore` ranks by
6
+ cosine similarity; `GraphStore` walks relationships — multi-hop
7
+ queries, pattern matching, ontology traversal. Forcing graph traversal
8
+ into either of the existing ABCs would degrade them; keeping
9
+ `GraphStore` separate respects the contract layer's purpose (one ABC
10
+ per concern, not one per backend).
11
+
12
+ Per ADR-0007 the surface is locked at v0.1: adding a method is a
13
+ major version bump. Optional capabilities (e.g. native Cypher
14
+ support, transactions, embedded vector search) layer the same way as
15
+ `LLMClient` capabilities — declared via `capabilities()` and gated via
16
+ `supports()`.
17
+
18
+ Conformance: every shipped or third-party driver must pass
19
+ `agentforge_core.testing.run_graph_conformance` (lands alongside this
20
+ contract).
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from abc import ABC, abstractmethod
26
+ from typing import Literal
27
+
28
+ from agentforge_core.values.graph import (
29
+ GraphEdge,
30
+ GraphNode,
31
+ GraphPattern,
32
+ Path,
33
+ )
34
+
35
+
36
+ class GraphStore(ABC):
37
+ """Provider-agnostic property graph.
38
+
39
+ Implementations:
40
+ - treat `add_node` and `add_edge` as idempotent upserts
41
+ (re-adding the same `id` / `(src, dst, edge_type)` replaces
42
+ the prior record's `properties`)
43
+ - reject edges whose `src` or `dst` references an unknown node —
44
+ callers must `add_node` first; this keeps the graph
45
+ well-formed and matches Cypher / SurrealQL behaviour
46
+ - return `Path` results with `len(edges) == len(nodes) - 1` and
47
+ edges in chain order
48
+
49
+ Cross-driver invariants enforced by the conformance suite:
50
+ - round-trip: `add_node(N); get_node(N.id)` returns an equal node
51
+ - edge readback: `add_edge(E); get_edges(E.src)` includes E
52
+ - pattern match: a one-segment pattern returns paths of length 2
53
+ - traversal: depth-bounded BFS does not exceed `max_depth`
54
+ - cascade delete: `delete_node(id, cascade=True)` removes
55
+ adjacent edges; `cascade=False` raises if edges remain
56
+ """
57
+
58
+ @abstractmethod
59
+ async def add_node(self, node: GraphNode) -> None:
60
+ """Insert or replace `node` (idempotent upsert by `node.id`)."""
61
+
62
+ @abstractmethod
63
+ async def add_edge(self, edge: GraphEdge) -> None:
64
+ """Insert or replace `edge` (idempotent upsert by
65
+ `(src, dst, edge_type)`).
66
+
67
+ Raises:
68
+ ValueError: `edge.src` or `edge.dst` references an unknown
69
+ node. Callers must add nodes before edges.
70
+ """
71
+
72
+ @abstractmethod
73
+ async def get_node(self, node_id: str) -> GraphNode | None:
74
+ """Return the node with this id, or `None` if absent."""
75
+
76
+ @abstractmethod
77
+ async def get_edges(
78
+ self,
79
+ node_id: str,
80
+ *,
81
+ edge_type: str | None = None,
82
+ direction: Literal["out", "in", "any"] = "out",
83
+ ) -> list[GraphEdge]:
84
+ """Return edges incident on `node_id`.
85
+
86
+ Args:
87
+ node_id: The node whose edges to fetch.
88
+ edge_type: If set, only edges of this type. `None` returns
89
+ all types.
90
+ direction: `"out"` returns edges where `src == node_id`;
91
+ `"in"` returns edges where `dst == node_id`; `"any"`
92
+ returns the union.
93
+ """
94
+
95
+ @abstractmethod
96
+ async def match(
97
+ self,
98
+ pattern: GraphPattern,
99
+ *,
100
+ limit: int = 50,
101
+ ) -> list[Path]:
102
+ """Return paths matching `pattern`, capped at `limit`.
103
+
104
+ Drivers may evaluate the pattern via Cypher (Neo4j),
105
+ SurrealQL (SurrealDB), or in-memory walking (the reference
106
+ implementation). The return shape is the same.
107
+
108
+ Raises:
109
+ ValueError: `limit < 1`.
110
+ """
111
+
112
+ @abstractmethod
113
+ async def traverse(
114
+ self,
115
+ start_id: str,
116
+ *,
117
+ edge_types: tuple[str, ...] | None = None,
118
+ max_depth: int = 3,
119
+ limit: int = 50,
120
+ ) -> list[Path]:
121
+ """Breadth-first traversal from `start_id`.
122
+
123
+ Returns every path of length 1..`max_depth` starting from
124
+ `start_id`, restricted to `edge_types` if given. Useful for
125
+ knowledge-graph expansion (pull a neighbourhood for retrieval
126
+ augmentation).
127
+
128
+ Args:
129
+ start_id: The seed node. If absent, returns an empty list.
130
+ edge_types: If set, only traverse edges of these types.
131
+ max_depth: Hop limit (>= 1).
132
+ limit: Maximum number of paths to return (>= 1).
133
+
134
+ Raises:
135
+ ValueError: `max_depth < 1` or `limit < 1`.
136
+ """
137
+
138
+ @abstractmethod
139
+ async def delete_node(self, node_id: str, *, cascade: bool = False) -> bool:
140
+ """Delete a node by id. Returns True if a node was removed.
141
+
142
+ Args:
143
+ node_id: The node to delete.
144
+ cascade: If True, also delete every edge incident on the
145
+ node. If False (default) and the node still has edges,
146
+ raises `ValueError` — drivers must not orphan edges.
147
+
148
+ Raises:
149
+ ValueError: `cascade=False` and the node has incident edges.
150
+ """
151
+
152
+ @abstractmethod
153
+ async def delete_edge(self, src: str, dst: str, *, edge_type: str) -> bool:
154
+ """Delete an edge by `(src, dst, edge_type)`. Returns True if
155
+ an edge was removed.
156
+
157
+ Unknown triples return False (no exception).
158
+ """
159
+
160
+ @abstractmethod
161
+ async def close(self) -> None:
162
+ """Release backing resources (connections, file handles)."""
163
+
164
+ def capabilities(self) -> set[str]:
165
+ """Optional capabilities this driver supports.
166
+
167
+ Default empty set. Closed vocabulary (additions are minor
168
+ bumps): `"transactions"` (multi-statement atomic writes),
169
+ `"cypher"` (driver speaks Cypher natively),
170
+ `"surrealql"` (driver speaks SurrealQL natively),
171
+ `"vector"` (driver also indexes embeddings — typically also
172
+ implements `VectorStore`), `"live_query"` (driver pushes
173
+ change notifications), `"fulltext"` (driver indexes node /
174
+ edge property text).
175
+ """
176
+ return set()
177
+
178
+ def supports(self, capability: str) -> bool:
179
+ """True if this driver declares the given capability."""
180
+ return capability in self.capabilities()
@@ -0,0 +1,129 @@
1
+ """Guardrail ABCs (feat-018).
2
+
3
+ Three locked ABCs:
4
+
5
+ - `InputValidator.validate(content, context)` — runs before each
6
+ LLM call on the user-visible input.
7
+ - `OutputValidator.validate(content, context)` — runs after each
8
+ LLM call on the model's output.
9
+ - `ToolCallGate.authorize(tool_name, tool, args, context)` — runs
10
+ before tool dispatch.
11
+
12
+ All three return `ValidationResult`. Implementations are async so
13
+ they can integrate with HTTP-based validators (LLM Guard,
14
+ Presidio, Llama Guard) without blocking the event loop.
15
+
16
+ The `name: str` ClassVar identifies the validator in audit events
17
+ and config-resolution paths.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ from abc import ABC, abstractmethod
23
+ from typing import TYPE_CHECKING, Any, ClassVar
24
+
25
+ from agentforge_core.values.guardrails import ValidationResult
26
+
27
+ if TYPE_CHECKING:
28
+ from agentforge_core.contracts.tool import Tool
29
+
30
+
31
+ class InputValidator(ABC):
32
+ """Validates user input before the agent's first LLM call.
33
+
34
+ Subclasses set ClassVars `name`, `description`, and
35
+ `cost_estimate_ms` (rough per-call latency in milliseconds).
36
+ """
37
+
38
+ name: ClassVar[str]
39
+ description: ClassVar[str]
40
+ cost_estimate_ms: ClassVar[int] = 0
41
+
42
+ @abstractmethod
43
+ async def validate(self, content: str, context: dict[str, Any]) -> ValidationResult:
44
+ """Validate `content`. `context` carries `run_id`, `project`,
45
+ `agent`, and any per-call metadata."""
46
+
47
+ def __init_subclass__(cls, **kwargs: Any) -> None:
48
+ super().__init_subclass__(**kwargs)
49
+ _require_attrs(cls, ("name", "description"))
50
+
51
+
52
+ class OutputValidator(ABC):
53
+ """Validates the model's output after each LLM call.
54
+
55
+ Output validators MAY redact: set `redacted_content` on the
56
+ returned `ValidationResult` to the post-redaction text. The
57
+ framework forwards that content downstream when
58
+ `policy.on_output_violation == "redact"`.
59
+ """
60
+
61
+ name: ClassVar[str]
62
+ description: ClassVar[str]
63
+ cost_estimate_ms: ClassVar[int] = 0
64
+
65
+ @abstractmethod
66
+ async def validate(self, content: str, context: dict[str, Any]) -> ValidationResult:
67
+ """Validate `content` (the LLM's text output)."""
68
+
69
+ def __init_subclass__(cls, **kwargs: Any) -> None:
70
+ super().__init_subclass__(**kwargs)
71
+ _require_attrs(cls, ("name", "description"))
72
+
73
+
74
+ class ToolCallGate(ABC):
75
+ """Authorises a tool invocation before dispatch.
76
+
77
+ Receives the tool instance so gates can inspect `tool.capabilities`
78
+ or other static metadata.
79
+ """
80
+
81
+ name: ClassVar[str]
82
+ description: ClassVar[str]
83
+ cost_estimate_ms: ClassVar[int] = 0
84
+
85
+ @abstractmethod
86
+ async def authorize(
87
+ self,
88
+ tool_name: str,
89
+ tool: Tool,
90
+ args: dict[str, Any],
91
+ context: dict[str, Any],
92
+ ) -> ValidationResult:
93
+ """Authorise the upcoming tool call."""
94
+
95
+ def __init_subclass__(cls, **kwargs: Any) -> None:
96
+ super().__init_subclass__(**kwargs)
97
+ _require_attrs(cls, ("name", "description"))
98
+
99
+
100
+ def _require_attrs(cls: type, attrs: tuple[str, ...]) -> None:
101
+ """Enforce the ClassVar contract on concrete subclasses."""
102
+ import inspect # noqa: PLC0415
103
+
104
+ if inspect.isabstract(cls):
105
+ return
106
+ for attr in attrs:
107
+ if attr not in cls.__dict__ and not _inherited(cls, attr):
108
+ msg = (
109
+ f"{cls.__name__} must declare class attribute {attr!r} "
110
+ "(every guardrail validator carries a stable name and "
111
+ "human-readable description for audit events)."
112
+ )
113
+ raise TypeError(msg)
114
+
115
+
116
+ def _inherited(cls: type, attr: str) -> bool:
117
+ for base in cls.__mro__[1:]:
118
+ if base is object:
119
+ continue
120
+ if attr in base.__dict__ and base.__module__ != cls.__module__:
121
+ return True
122
+ return False
123
+
124
+
125
+ __all__ = [
126
+ "InputValidator",
127
+ "OutputValidator",
128
+ "ToolCallGate",
129
+ ]