agent-weave-lib 0.1.0__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.
- agent_weave/__init__.py +63 -0
- agent_weave/agent.py +177 -0
- agent_weave/cli.py +109 -0
- agent_weave/config.py +51 -0
- agent_weave/errors.py +64 -0
- agent_weave/guardrails.py +144 -0
- agent_weave/llm/__init__.py +5 -0
- agent_weave/llm/anthropic_backend.py +174 -0
- agent_weave/llm/base.py +43 -0
- agent_weave/llm/openai_backend.py +153 -0
- agent_weave/memory/__init__.py +10 -0
- agent_weave/memory/base.py +53 -0
- agent_weave/memory/conversation.py +84 -0
- agent_weave/models.py +140 -0
- agent_weave/py.typed +0 -0
- agent_weave/react.py +309 -0
- agent_weave/team.py +247 -0
- agent_weave/tool.py +152 -0
- agent_weave_lib-0.1.0.dist-info/METADATA +296 -0
- agent_weave_lib-0.1.0.dist-info/RECORD +23 -0
- agent_weave_lib-0.1.0.dist-info/WHEEL +4 -0
- agent_weave_lib-0.1.0.dist-info/entry_points.txt +2 -0
- agent_weave_lib-0.1.0.dist-info/licenses/LICENSE +21 -0
agent_weave/__init__.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""agent-weave: Lightweight AI agent framework."""
|
|
2
|
+
|
|
3
|
+
from .agent import Agent
|
|
4
|
+
from .config import AgentSettings
|
|
5
|
+
from .errors import (
|
|
6
|
+
AgentWeaveError,
|
|
7
|
+
ConfigurationError,
|
|
8
|
+
GuardrailViolation,
|
|
9
|
+
LLMBackendError,
|
|
10
|
+
MaxIterationsError,
|
|
11
|
+
TokenBudgetExceededError,
|
|
12
|
+
ToolExecutionError,
|
|
13
|
+
ToolNotFoundError,
|
|
14
|
+
)
|
|
15
|
+
from .guardrails import (
|
|
16
|
+
BlockedWordsGuardrail,
|
|
17
|
+
Guardrail,
|
|
18
|
+
GuardrailPipeline,
|
|
19
|
+
MaxLengthGuardrail,
|
|
20
|
+
PIIGuardrail,
|
|
21
|
+
RegexGuardrail,
|
|
22
|
+
TokenBudgetGuardrail,
|
|
23
|
+
)
|
|
24
|
+
from .models import AgentResult, AgentStep, Message, ToolCall, ToolResult
|
|
25
|
+
from .team import Strategy, Team, TeamResult
|
|
26
|
+
from .tool import Tool, tool
|
|
27
|
+
|
|
28
|
+
__version__ = "0.1.0"
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
# Core
|
|
32
|
+
"Agent",
|
|
33
|
+
"Tool",
|
|
34
|
+
"tool",
|
|
35
|
+
"Team",
|
|
36
|
+
"Strategy",
|
|
37
|
+
# Settings
|
|
38
|
+
"AgentSettings",
|
|
39
|
+
# Models
|
|
40
|
+
"AgentResult",
|
|
41
|
+
"AgentStep",
|
|
42
|
+
"Message",
|
|
43
|
+
"TeamResult",
|
|
44
|
+
"ToolCall",
|
|
45
|
+
"ToolResult",
|
|
46
|
+
# Guardrails
|
|
47
|
+
"Guardrail",
|
|
48
|
+
"GuardrailPipeline",
|
|
49
|
+
"BlockedWordsGuardrail",
|
|
50
|
+
"MaxLengthGuardrail",
|
|
51
|
+
"PIIGuardrail",
|
|
52
|
+
"RegexGuardrail",
|
|
53
|
+
"TokenBudgetGuardrail",
|
|
54
|
+
# Errors
|
|
55
|
+
"AgentWeaveError",
|
|
56
|
+
"ConfigurationError",
|
|
57
|
+
"GuardrailViolation",
|
|
58
|
+
"LLMBackendError",
|
|
59
|
+
"MaxIterationsError",
|
|
60
|
+
"TokenBudgetExceededError",
|
|
61
|
+
"ToolExecutionError",
|
|
62
|
+
"ToolNotFoundError",
|
|
63
|
+
]
|
agent_weave/agent.py
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
"""Core Agent class for agent-weave."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Any, Sequence
|
|
6
|
+
|
|
7
|
+
from .config import AgentSettings
|
|
8
|
+
from .errors import ConfigurationError
|
|
9
|
+
from .guardrails import (
|
|
10
|
+
Guardrail,
|
|
11
|
+
GuardrailPipeline,
|
|
12
|
+
TokenBudgetGuardrail,
|
|
13
|
+
)
|
|
14
|
+
from .llm.base import LLMBackend
|
|
15
|
+
from .memory import ConversationMemory, Memory
|
|
16
|
+
from .models import AgentResult, Message
|
|
17
|
+
from .react import ReActEngine
|
|
18
|
+
from .tool import Tool
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Agent:
|
|
22
|
+
"""A self-contained AI agent with tools, memory, and guardrails.
|
|
23
|
+
|
|
24
|
+
Usage::
|
|
25
|
+
|
|
26
|
+
from agent_weave import Agent, tool
|
|
27
|
+
from agent_weave.llm.openai_backend import OpenAIBackend
|
|
28
|
+
|
|
29
|
+
@tool(description="Search the web")
|
|
30
|
+
def web_search(query: str) -> str:
|
|
31
|
+
return f"Results for: {query}"
|
|
32
|
+
|
|
33
|
+
agent = Agent(
|
|
34
|
+
name="researcher",
|
|
35
|
+
llm=OpenAIBackend(api_key="sk-..."),
|
|
36
|
+
tools=[web_search],
|
|
37
|
+
system_prompt="You are a research assistant.",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
result = agent.run("Find the latest AI trends in 2025")
|
|
41
|
+
print(result.output)
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
name: str,
|
|
47
|
+
*,
|
|
48
|
+
llm: LLMBackend,
|
|
49
|
+
tools: Sequence[Tool] | None = None,
|
|
50
|
+
system_prompt: str = "You are a helpful AI assistant.",
|
|
51
|
+
memory: Memory | None = None,
|
|
52
|
+
model: str | None = None,
|
|
53
|
+
temperature: float | None = None,
|
|
54
|
+
max_iterations: int | None = None,
|
|
55
|
+
token_budget: int | None = None,
|
|
56
|
+
output_guardrails: list[Guardrail] | None = None,
|
|
57
|
+
settings: AgentSettings | None = None,
|
|
58
|
+
verbose: bool | None = None,
|
|
59
|
+
) -> None:
|
|
60
|
+
if not name.strip():
|
|
61
|
+
raise ConfigurationError("Agent name cannot be empty.")
|
|
62
|
+
|
|
63
|
+
self.name = name.strip()
|
|
64
|
+
self._llm = llm
|
|
65
|
+
self._tools = list(tools or [])
|
|
66
|
+
self._system_prompt = system_prompt
|
|
67
|
+
self._memory = memory or ConversationMemory()
|
|
68
|
+
self._settings = settings or AgentSettings()
|
|
69
|
+
|
|
70
|
+
self._model = model or self._settings.default_model
|
|
71
|
+
self._temperature = temperature
|
|
72
|
+
self._max_iterations = max_iterations or self._settings.max_iterations
|
|
73
|
+
self._verbose = verbose if verbose is not None else self._settings.verbose
|
|
74
|
+
|
|
75
|
+
# Guardrails.
|
|
76
|
+
self._token_budget: TokenBudgetGuardrail | None = None
|
|
77
|
+
if token_budget or self._settings.token_budget:
|
|
78
|
+
self._token_budget = TokenBudgetGuardrail(
|
|
79
|
+
budget=token_budget or self._settings.token_budget or 0
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
self._output_guardrails: GuardrailPipeline | None = None
|
|
83
|
+
if output_guardrails:
|
|
84
|
+
self._output_guardrails = GuardrailPipeline(output_guardrails)
|
|
85
|
+
|
|
86
|
+
@property
|
|
87
|
+
def tools(self) -> list[Tool]:
|
|
88
|
+
return list(self._tools)
|
|
89
|
+
|
|
90
|
+
@property
|
|
91
|
+
def tool_names(self) -> list[str]:
|
|
92
|
+
return [t.name for t in self._tools]
|
|
93
|
+
|
|
94
|
+
def add_tool(self, tool_instance: Tool) -> None:
|
|
95
|
+
"""Register an additional tool."""
|
|
96
|
+
self._tools.append(tool_instance)
|
|
97
|
+
|
|
98
|
+
def run(self, task: str) -> AgentResult:
|
|
99
|
+
"""Run the agent on a task (synchronous).
|
|
100
|
+
|
|
101
|
+
Sets up memory with system prompt + user task, then
|
|
102
|
+
runs the ReAct loop until completion.
|
|
103
|
+
"""
|
|
104
|
+
self._prepare_memory(task)
|
|
105
|
+
|
|
106
|
+
engine = self._build_engine()
|
|
107
|
+
return engine.run(self.name)
|
|
108
|
+
|
|
109
|
+
async def arun(self, task: str) -> AgentResult:
|
|
110
|
+
"""Run the agent on a task (asynchronous)."""
|
|
111
|
+
self._prepare_memory(task)
|
|
112
|
+
|
|
113
|
+
engine = self._build_engine()
|
|
114
|
+
return await engine.arun(self.name)
|
|
115
|
+
|
|
116
|
+
def chat(self, message: str) -> AgentResult:
|
|
117
|
+
"""Continue a conversation (preserves history).
|
|
118
|
+
|
|
119
|
+
Unlike ``run()``, this does NOT reset memory. It appends
|
|
120
|
+
the new user message and continues the conversation.
|
|
121
|
+
"""
|
|
122
|
+
# Ensure system prompt exists on first call.
|
|
123
|
+
if self._memory.size == 0:
|
|
124
|
+
self._memory.add(
|
|
125
|
+
Message(role="system", content=self._system_prompt)
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
self._memory.add(Message(role="user", content=message))
|
|
129
|
+
|
|
130
|
+
engine = self._build_engine()
|
|
131
|
+
return engine.run(self.name)
|
|
132
|
+
|
|
133
|
+
async def achat(self, message: str) -> AgentResult:
|
|
134
|
+
"""Continue a conversation (async, preserves history)."""
|
|
135
|
+
if self._memory.size == 0:
|
|
136
|
+
self._memory.add(
|
|
137
|
+
Message(role="system", content=self._system_prompt)
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
self._memory.add(Message(role="user", content=message))
|
|
141
|
+
|
|
142
|
+
engine = self._build_engine()
|
|
143
|
+
return await engine.arun(self.name)
|
|
144
|
+
|
|
145
|
+
def reset(self) -> None:
|
|
146
|
+
"""Clear memory and token budget."""
|
|
147
|
+
self._memory.clear()
|
|
148
|
+
if self._token_budget:
|
|
149
|
+
self._token_budget.reset()
|
|
150
|
+
|
|
151
|
+
def _prepare_memory(self, task: str) -> None:
|
|
152
|
+
"""Reset memory and load system prompt + user task."""
|
|
153
|
+
self._memory.clear()
|
|
154
|
+
if self._token_budget:
|
|
155
|
+
self._token_budget.reset()
|
|
156
|
+
|
|
157
|
+
self._memory.add(Message(role="system", content=self._system_prompt))
|
|
158
|
+
self._memory.add(Message(role="user", content=task))
|
|
159
|
+
|
|
160
|
+
def _build_engine(self) -> ReActEngine:
|
|
161
|
+
return ReActEngine(
|
|
162
|
+
llm=self._llm,
|
|
163
|
+
tools=self._tools,
|
|
164
|
+
memory=self._memory,
|
|
165
|
+
max_iterations=self._max_iterations,
|
|
166
|
+
model=self._model,
|
|
167
|
+
temperature=self._temperature,
|
|
168
|
+
token_budget=self._token_budget,
|
|
169
|
+
output_guardrails=self._output_guardrails,
|
|
170
|
+
verbose=self._verbose,
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
def __repr__(self) -> str:
|
|
174
|
+
return (
|
|
175
|
+
f"Agent(name={self.name!r}, model={self._model!r}, "
|
|
176
|
+
f"tools={self.tool_names})"
|
|
177
|
+
)
|
agent_weave/cli.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""CLI interface for agent-weave."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import sys
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _get_agent():
|
|
12
|
+
"""Build a default agent from environment variables."""
|
|
13
|
+
from .agent import Agent
|
|
14
|
+
from .llm.openai_backend import OpenAIBackend
|
|
15
|
+
|
|
16
|
+
api_key = os.getenv("OPENAI_API_KEY")
|
|
17
|
+
if not api_key:
|
|
18
|
+
print("Error: OPENAI_API_KEY environment variable is required.", file=sys.stderr)
|
|
19
|
+
sys.exit(1)
|
|
20
|
+
|
|
21
|
+
model = os.getenv("AGENTWEAVE_DEFAULT_MODEL", "gpt-4o-mini")
|
|
22
|
+
|
|
23
|
+
backend = OpenAIBackend(api_key=api_key, default_model=model)
|
|
24
|
+
return Agent(
|
|
25
|
+
name="cli-agent",
|
|
26
|
+
llm=backend,
|
|
27
|
+
system_prompt="You are a helpful AI assistant. Be concise.",
|
|
28
|
+
model=model,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def cmd_run(args: argparse.Namespace) -> None:
|
|
33
|
+
"""Run a task with the agent."""
|
|
34
|
+
agent = _get_agent()
|
|
35
|
+
result = agent.run(args.task)
|
|
36
|
+
print(result.output)
|
|
37
|
+
|
|
38
|
+
if args.verbose:
|
|
39
|
+
print(f"\n--- Stats ---")
|
|
40
|
+
print(f"Iterations: {result.total_iterations}")
|
|
41
|
+
print(f"Tokens: {result.total_tokens:,}")
|
|
42
|
+
print(f"Tool calls: {result.total_tool_calls}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def cmd_chat(args: argparse.Namespace) -> None:
|
|
46
|
+
"""Interactive chat with the agent."""
|
|
47
|
+
agent = _get_agent()
|
|
48
|
+
print("Agent Weave Chat (type 'quit' to exit)")
|
|
49
|
+
print("-" * 40)
|
|
50
|
+
|
|
51
|
+
while True:
|
|
52
|
+
try:
|
|
53
|
+
user_input = input("\nYou: ").strip()
|
|
54
|
+
except (EOFError, KeyboardInterrupt):
|
|
55
|
+
print("\nGoodbye!")
|
|
56
|
+
break
|
|
57
|
+
|
|
58
|
+
if user_input.lower() in ("quit", "exit", "q"):
|
|
59
|
+
print("Goodbye!")
|
|
60
|
+
break
|
|
61
|
+
|
|
62
|
+
if not user_input:
|
|
63
|
+
continue
|
|
64
|
+
|
|
65
|
+
result = agent.chat(user_input)
|
|
66
|
+
print(f"\nAgent: {result.output}")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def cmd_info(args: argparse.Namespace) -> None:
|
|
70
|
+
"""Show library info."""
|
|
71
|
+
from . import __version__
|
|
72
|
+
info = {
|
|
73
|
+
"name": "agent-weave",
|
|
74
|
+
"version": __version__,
|
|
75
|
+
"python": sys.version,
|
|
76
|
+
}
|
|
77
|
+
print(json.dumps(info, indent=2))
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def main() -> None:
|
|
81
|
+
parser = argparse.ArgumentParser(
|
|
82
|
+
prog="agent-weave",
|
|
83
|
+
description="Lightweight AI agent framework.",
|
|
84
|
+
)
|
|
85
|
+
subparsers = parser.add_subparsers(dest="command")
|
|
86
|
+
|
|
87
|
+
# Run command.
|
|
88
|
+
run_parser = subparsers.add_parser("run", help="Run a task")
|
|
89
|
+
run_parser.add_argument("task", help="The task to execute")
|
|
90
|
+
run_parser.add_argument("-v", "--verbose", action="store_true")
|
|
91
|
+
run_parser.set_defaults(func=cmd_run)
|
|
92
|
+
|
|
93
|
+
# Chat command.
|
|
94
|
+
chat_parser = subparsers.add_parser("chat", help="Interactive chat")
|
|
95
|
+
chat_parser.set_defaults(func=cmd_chat)
|
|
96
|
+
|
|
97
|
+
# Info command.
|
|
98
|
+
info_parser = subparsers.add_parser("info", help="Show library info")
|
|
99
|
+
info_parser.set_defaults(func=cmd_info)
|
|
100
|
+
|
|
101
|
+
args = parser.parse_args()
|
|
102
|
+
if hasattr(args, "func"):
|
|
103
|
+
args.func(args)
|
|
104
|
+
else:
|
|
105
|
+
parser.print_help()
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
if __name__ == "__main__":
|
|
109
|
+
main()
|
agent_weave/config.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
"""Configuration for agent-weave."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _as_bool(value: str | None, *, default: bool) -> bool:
|
|
10
|
+
if value is None:
|
|
11
|
+
return default
|
|
12
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _as_float(value: str | None, *, default: float | None) -> float | None:
|
|
16
|
+
if value is None or value.strip() == "":
|
|
17
|
+
return default
|
|
18
|
+
return float(value)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _as_int(value: str | None, *, default: int) -> int:
|
|
22
|
+
if value is None or value.strip() == "":
|
|
23
|
+
return default
|
|
24
|
+
return int(value)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class AgentSettings:
|
|
29
|
+
"""Global settings for agent-weave."""
|
|
30
|
+
|
|
31
|
+
default_model: str = "gpt-4o-mini"
|
|
32
|
+
max_iterations: int = 10
|
|
33
|
+
request_timeout: float | None = 60.0
|
|
34
|
+
token_budget: int | None = None
|
|
35
|
+
verbose: bool = False
|
|
36
|
+
|
|
37
|
+
@classmethod
|
|
38
|
+
def from_env(cls) -> "AgentSettings":
|
|
39
|
+
return cls(
|
|
40
|
+
default_model=os.getenv("AGENTWEAVE_DEFAULT_MODEL", "gpt-4o-mini"),
|
|
41
|
+
max_iterations=_as_int(
|
|
42
|
+
os.getenv("AGENTWEAVE_MAX_ITERATIONS"), default=10
|
|
43
|
+
),
|
|
44
|
+
request_timeout=_as_float(
|
|
45
|
+
os.getenv("AGENTWEAVE_REQUEST_TIMEOUT"), default=60.0
|
|
46
|
+
),
|
|
47
|
+
token_budget=_as_int(
|
|
48
|
+
os.getenv("AGENTWEAVE_TOKEN_BUDGET"), default=0
|
|
49
|
+
) or None,
|
|
50
|
+
verbose=_as_bool(os.getenv("AGENTWEAVE_VERBOSE"), default=False),
|
|
51
|
+
)
|
agent_weave/errors.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Custom exceptions for agent-weave."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AgentWeaveError(Exception):
|
|
7
|
+
"""Base exception for agent-weave."""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ToolExecutionError(AgentWeaveError):
|
|
11
|
+
"""Raised when a tool fails during execution."""
|
|
12
|
+
|
|
13
|
+
def __init__(self, tool_name: str, message: str) -> None:
|
|
14
|
+
self.tool_name = tool_name
|
|
15
|
+
super().__init__(f"Tool '{tool_name}' failed: {message}")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ToolNotFoundError(AgentWeaveError):
|
|
19
|
+
"""Raised when a requested tool is not registered."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, tool_name: str, available: list[str] | None = None) -> None:
|
|
22
|
+
self.tool_name = tool_name
|
|
23
|
+
avail = ", ".join(available) if available else "none"
|
|
24
|
+
super().__init__(
|
|
25
|
+
f"Tool '{tool_name}' not found. Available tools: {avail}"
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class MaxIterationsError(AgentWeaveError):
|
|
30
|
+
"""Raised when the ReAct loop exceeds the maximum iterations."""
|
|
31
|
+
|
|
32
|
+
def __init__(self, max_iterations: int) -> None:
|
|
33
|
+
self.max_iterations = max_iterations
|
|
34
|
+
super().__init__(
|
|
35
|
+
f"Agent exceeded maximum iterations ({max_iterations}). "
|
|
36
|
+
"Increase max_iterations or simplify the task."
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class TokenBudgetExceededError(AgentWeaveError):
|
|
41
|
+
"""Raised when the token budget is exhausted."""
|
|
42
|
+
|
|
43
|
+
def __init__(self, budget: int, used: int) -> None:
|
|
44
|
+
self.budget = budget
|
|
45
|
+
self.used = used
|
|
46
|
+
super().__init__(
|
|
47
|
+
f"Token budget exceeded: used {used:,} of {budget:,} allowed tokens."
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class LLMBackendError(AgentWeaveError):
|
|
52
|
+
"""Raised when the LLM backend fails."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class GuardrailViolation(AgentWeaveError):
|
|
56
|
+
"""Raised when a guardrail check fails."""
|
|
57
|
+
|
|
58
|
+
def __init__(self, guardrail_name: str, message: str) -> None:
|
|
59
|
+
self.guardrail_name = guardrail_name
|
|
60
|
+
super().__init__(f"Guardrail '{guardrail_name}': {message}")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class ConfigurationError(AgentWeaveError):
|
|
64
|
+
"""Raised for invalid configuration."""
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
"""Guardrails for agent-weave — input/output validators and budget controls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from abc import ABC, abstractmethod
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .errors import GuardrailViolation, TokenBudgetExceededError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class Guardrail(ABC):
|
|
14
|
+
"""Base class for all guardrails."""
|
|
15
|
+
|
|
16
|
+
name: str = "base"
|
|
17
|
+
|
|
18
|
+
@abstractmethod
|
|
19
|
+
def check(self, value: str, *, context: dict[str, Any] | None = None) -> str:
|
|
20
|
+
"""Validate and optionally transform the value.
|
|
21
|
+
|
|
22
|
+
Returns the (possibly modified) value.
|
|
23
|
+
Raises ``GuardrailViolation`` if the check fails.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MaxLengthGuardrail(Guardrail):
|
|
28
|
+
"""Reject output that exceeds a character limit."""
|
|
29
|
+
|
|
30
|
+
name = "max_length"
|
|
31
|
+
|
|
32
|
+
def __init__(self, max_chars: int = 10_000) -> None:
|
|
33
|
+
self._max = max_chars
|
|
34
|
+
|
|
35
|
+
def check(self, value: str, *, context: dict[str, Any] | None = None) -> str:
|
|
36
|
+
if len(value) > self._max:
|
|
37
|
+
raise GuardrailViolation(
|
|
38
|
+
self.name,
|
|
39
|
+
f"Output length {len(value):,} exceeds limit of {self._max:,} chars.",
|
|
40
|
+
)
|
|
41
|
+
return value
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class BlockedWordsGuardrail(Guardrail):
|
|
45
|
+
"""Reject output that contains any blocked words."""
|
|
46
|
+
|
|
47
|
+
name = "blocked_words"
|
|
48
|
+
|
|
49
|
+
def __init__(self, words: list[str]) -> None:
|
|
50
|
+
self._words = [w.lower() for w in words]
|
|
51
|
+
|
|
52
|
+
def check(self, value: str, *, context: dict[str, Any] | None = None) -> str:
|
|
53
|
+
lower_val = value.lower()
|
|
54
|
+
for word in self._words:
|
|
55
|
+
if word in lower_val:
|
|
56
|
+
raise GuardrailViolation(
|
|
57
|
+
self.name, f"Blocked word detected: '{word}'"
|
|
58
|
+
)
|
|
59
|
+
return value
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class RegexGuardrail(Guardrail):
|
|
63
|
+
"""Reject output that matches a forbidden regex pattern."""
|
|
64
|
+
|
|
65
|
+
name = "regex_filter"
|
|
66
|
+
|
|
67
|
+
def __init__(self, pattern: str, *, message: str = "Forbidden pattern detected.") -> None:
|
|
68
|
+
self._pattern = re.compile(pattern, re.IGNORECASE)
|
|
69
|
+
self._message = message
|
|
70
|
+
|
|
71
|
+
def check(self, value: str, *, context: dict[str, Any] | None = None) -> str:
|
|
72
|
+
if self._pattern.search(value):
|
|
73
|
+
raise GuardrailViolation(self.name, self._message)
|
|
74
|
+
return value
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class PIIGuardrail(Guardrail):
|
|
78
|
+
"""Detect common PII patterns (emails, phone numbers, SSNs)."""
|
|
79
|
+
|
|
80
|
+
name = "pii_filter"
|
|
81
|
+
|
|
82
|
+
_PATTERNS = {
|
|
83
|
+
"email": re.compile(r"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+"),
|
|
84
|
+
"phone": re.compile(r"\b\d{3}[-.]?\d{3}[-.]?\d{4}\b"),
|
|
85
|
+
"ssn": re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
def __init__(self, *, redact: bool = False) -> None:
|
|
89
|
+
self._redact = redact
|
|
90
|
+
|
|
91
|
+
def check(self, value: str, *, context: dict[str, Any] | None = None) -> str:
|
|
92
|
+
for pii_type, pattern in self._PATTERNS.items():
|
|
93
|
+
if pattern.search(value):
|
|
94
|
+
if self._redact:
|
|
95
|
+
value = pattern.sub(f"[REDACTED_{pii_type.upper()}]", value)
|
|
96
|
+
else:
|
|
97
|
+
raise GuardrailViolation(
|
|
98
|
+
self.name,
|
|
99
|
+
f"PII detected: {pii_type}. Set redact=True to auto-redact.",
|
|
100
|
+
)
|
|
101
|
+
return value
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@dataclass
|
|
105
|
+
class TokenBudgetGuardrail:
|
|
106
|
+
"""Track and enforce a token budget across the agent run."""
|
|
107
|
+
|
|
108
|
+
budget: int
|
|
109
|
+
_used: int = field(default=0, init=False)
|
|
110
|
+
|
|
111
|
+
@property
|
|
112
|
+
def used(self) -> int:
|
|
113
|
+
return self._used
|
|
114
|
+
|
|
115
|
+
@property
|
|
116
|
+
def remaining(self) -> int:
|
|
117
|
+
return max(0, self.budget - self._used)
|
|
118
|
+
|
|
119
|
+
def consume(self, tokens: int) -> None:
|
|
120
|
+
"""Record token usage. Raises if budget exceeded."""
|
|
121
|
+
self._used += tokens
|
|
122
|
+
if self._used > self.budget:
|
|
123
|
+
raise TokenBudgetExceededError(self.budget, self._used)
|
|
124
|
+
|
|
125
|
+
def reset(self) -> None:
|
|
126
|
+
self._used = 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
class GuardrailPipeline:
|
|
130
|
+
"""Run a sequence of guardrails on a value."""
|
|
131
|
+
|
|
132
|
+
def __init__(self, guardrails: list[Guardrail] | None = None) -> None:
|
|
133
|
+
self._guardrails = list(guardrails or [])
|
|
134
|
+
|
|
135
|
+
def add(self, guardrail: Guardrail) -> "GuardrailPipeline":
|
|
136
|
+
self._guardrails.append(guardrail)
|
|
137
|
+
return self
|
|
138
|
+
|
|
139
|
+
def run(self, value: str, *, context: dict[str, Any] | None = None) -> str:
|
|
140
|
+
"""Run all guardrails in sequence. Returns the (possibly modified) value."""
|
|
141
|
+
result = value
|
|
142
|
+
for rail in self._guardrails:
|
|
143
|
+
result = rail.check(result, context=context)
|
|
144
|
+
return result
|