agentx-kit 0.2.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.
Files changed (58) hide show
  1. agentx/__init__.py +55 -0
  2. agentx/cli.py +230 -0
  3. agentx/config.py +34 -0
  4. agentx/frameworks/__init__.py +5 -0
  5. agentx/frameworks/crewai_agent.py +52 -0
  6. agentx/frameworks/langchain_agent.py +43 -0
  7. agentx/guardrails.py +89 -0
  8. agentx/memory/__init__.py +4 -0
  9. agentx/memory/store.py +78 -0
  10. agentx/observability.py +103 -0
  11. agentx/prompts/__init__.py +8 -0
  12. agentx/prompts/templates.py +40 -0
  13. agentx/providers/__init__.py +15 -0
  14. agentx/providers/base.py +50 -0
  15. agentx/providers/factory.py +71 -0
  16. agentx/providers/registry.py +165 -0
  17. agentx/rag/__init__.py +8 -0
  18. agentx/rag/pipeline.py +121 -0
  19. agentx/reliability.py +112 -0
  20. agentx/scaffold/__init__.py +14 -0
  21. agentx/scaffold/generator.py +190 -0
  22. agentx/scaffold/prompts_store.py +99 -0
  23. agentx/scaffold/spec.py +85 -0
  24. agentx/scaffold/templates/Dockerfile.j2 +17 -0
  25. agentx/scaffold/templates/README.md.j2 +46 -0
  26. agentx/scaffold/templates/ci.yml.j2 +41 -0
  27. agentx/scaffold/templates/docker-compose.yml.j2 +9 -0
  28. agentx/scaffold/templates/dockerignore.j2 +11 -0
  29. agentx/scaffold/templates/env.example.j2 +12 -0
  30. agentx/scaffold/templates/evals/dataset.json.j2 +10 -0
  31. agentx/scaffold/templates/evals/run_evals.py.j2 +70 -0
  32. agentx/scaffold/templates/gitignore.j2 +8 -0
  33. agentx/scaffold/templates/mcp_servers.json.j2 +7 -0
  34. agentx/scaffold/templates/pkg/__init__.py.j2 +3 -0
  35. agentx/scaffold/templates/pkg/agents.py.j2 +77 -0
  36. agentx/scaffold/templates/pkg/config.py.j2 +25 -0
  37. agentx/scaffold/templates/pkg/guardrails.py.j2 +21 -0
  38. agentx/scaffold/templates/pkg/main.py.j2 +79 -0
  39. agentx/scaffold/templates/pkg/memory.py.j2 +17 -0
  40. agentx/scaffold/templates/pkg/observability.py.j2 +17 -0
  41. agentx/scaffold/templates/pkg/prompts.py.j2 +45 -0
  42. agentx/scaffold/templates/pkg/rag.py.j2 +37 -0
  43. agentx/scaffold/templates/pkg/server.py.j2 +85 -0
  44. agentx/scaffold/templates/pkg/tools.py.j2 +16 -0
  45. agentx/scaffold/templates/pyproject.toml.j2 +28 -0
  46. agentx/scaffold/templates/skills_seed.json.j2 +6 -0
  47. agentx/scaffold/wizard.py +125 -0
  48. agentx/skills/__init__.py +4 -0
  49. agentx/skills/registry.py +63 -0
  50. agentx/structured.py +37 -0
  51. agentx/tools/__init__.py +5 -0
  52. agentx/tools/builtin.py +45 -0
  53. agentx/tools/mcp.py +64 -0
  54. agentx_kit-0.2.0.dist-info/METADATA +289 -0
  55. agentx_kit-0.2.0.dist-info/RECORD +58 -0
  56. agentx_kit-0.2.0.dist-info/WHEEL +4 -0
  57. agentx_kit-0.2.0.dist-info/entry_points.txt +2 -0
  58. agentx_kit-0.2.0.dist-info/licenses/LICENSE +21 -0
agentx/__init__.py ADDED
@@ -0,0 +1,55 @@
1
+ """AgentX — provider-agnostic agentic framework + project scaffolder.
2
+
3
+ Quick start (library):
4
+
5
+ from agentx import get_chat_model
6
+ llm = get_chat_model("openai", "gpt-4o-mini")
7
+ print(llm.invoke("Hello").content)
8
+
9
+ Quick start (scaffolder):
10
+
11
+ $ agentx new # interactive wizard → generates a uv project
12
+
13
+ Public API is intentionally small and stable; capability modules (rag, memory,
14
+ tools, skills, frameworks) are imported lazily so installing one provider extra
15
+ is enough to get started.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ __version__ = "0.2.0"
20
+
21
+ from .providers import ( # noqa: E402
22
+ ProviderSpec,
23
+ get_chat_model,
24
+ get_crewai_llm,
25
+ list_providers,
26
+ )
27
+ from .guardrails import GuardrailError, apply_guards # noqa: E402
28
+ from .observability import get_callbacks, setup_tracing, telemetry_enabled # noqa: E402
29
+ from .reliability import ( # noqa: E402
30
+ UsageLimitExceeded,
31
+ UsageLimits,
32
+ UsageTracker,
33
+ build_resilient_chat,
34
+ )
35
+ from .structured import structured_model # noqa: E402
36
+
37
+ __all__ = [
38
+ "__version__",
39
+ # providers
40
+ "ProviderSpec",
41
+ "get_chat_model",
42
+ "get_crewai_llm",
43
+ "list_providers",
44
+ # enterprise runtime
45
+ "setup_tracing",
46
+ "get_callbacks",
47
+ "telemetry_enabled",
48
+ "build_resilient_chat",
49
+ "UsageLimits",
50
+ "UsageTracker",
51
+ "UsageLimitExceeded",
52
+ "apply_guards",
53
+ "GuardrailError",
54
+ "structured_model",
55
+ ]
agentx/cli.py ADDED
@@ -0,0 +1,230 @@
1
+ """AgentX command-line interface.
2
+
3
+ agentx new # interactive wizard → scaffold a project
4
+ agentx providers # list supported LLM providers + required env vars
5
+ agentx version
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+
11
+ import typer
12
+ from rich.console import Console
13
+ from rich.panel import Panel
14
+ from rich.table import Table
15
+
16
+ from . import __version__
17
+ from .providers import all_specs, get_spec
18
+ from .scaffold import AgentSpec, ProjectSpec, generate_project, run_wizard
19
+ from .scaffold import prompts_store
20
+
21
+ app = typer.Typer(
22
+ add_completion=False,
23
+ help="AgentX — provider-agnostic agentic framework + project scaffolder.",
24
+ no_args_is_help=True,
25
+ )
26
+ console = Console()
27
+
28
+
29
+ @app.command()
30
+ def version() -> None:
31
+ """Print the installed version."""
32
+ console.print(f"agentx {__version__}")
33
+
34
+
35
+ @app.command()
36
+ def providers() -> None:
37
+ """List supported LLM providers and the env vars each needs."""
38
+ table = Table(title="Supported LLM providers", show_lines=False)
39
+ table.add_column("id", style="cyan", no_wrap=True)
40
+ table.add_column("provider")
41
+ table.add_column("extra", style="green")
42
+ table.add_column("default model")
43
+ table.add_column("env vars", style="yellow")
44
+ for s in all_specs():
45
+ table.add_row(s.id, s.label, s.extra, s.default_model, ", ".join(s.env_vars) or "— (local)")
46
+ console.print(table)
47
+
48
+
49
+ def _result_panel(result, spec: ProjectSpec) -> None:
50
+ lines = [f"[bold green]✓[/] Project '{spec.slug}' created at:", f" {result.target_dir}", ""]
51
+ lines += [f" • {m}" for m in result.messages]
52
+ lines += [
53
+ "",
54
+ "[bold]Next steps:[/]",
55
+ f" cd {result.target_dir.name}",
56
+ " cp .env.example .env # add your credentials",
57
+ " uv sync" if not result.synced else " # deps already installed",
58
+ f" uv run {spec.slug}",
59
+ ]
60
+ console.print(Panel("\n".join(lines), title="AgentX", border_style="cyan"))
61
+
62
+
63
+ @app.command()
64
+ def new(
65
+ name: str = typer.Option(None, "--name", "-n", help="Project name."),
66
+ out: Path = typer.Option(None, "--out", "-o", help="Target directory (default ./<name>)."),
67
+ yes: bool = typer.Option(False, "--yes", "-y", help="Non-interactive: use defaults + options below."),
68
+ framework: str = typer.Option("langgraph", help="langgraph | crewai (with --yes)."),
69
+ provider: str = typer.Option("openai", help="Provider id (with --yes)."),
70
+ model: str = typer.Option("", help="Model id (with --yes; blank = provider default)."),
71
+ agents: int = typer.Option(1, help="Number of agents (with --yes)."),
72
+ prompt: str = typer.Option("", "--prompt", "-p", help="System prompt for the first agent (with --yes)."),
73
+ role: str = typer.Option("Helpful Assistant", help="Role for the first agent (with --yes)."),
74
+ goal: str = typer.Option("Help the user accomplish their task accurately.", help="Goal for the first agent (with --yes)."),
75
+ rag: bool = typer.Option(False, help="Include RAG (with --yes)."),
76
+ memory: str = typer.Option("none", help="none|short|long|both (with --yes)."),
77
+ mcp: bool = typer.Option(False, help="Include MCP tools (with --yes)."),
78
+ skills: bool = typer.Option(False, help="Include skills registry (with --yes)."),
79
+ enterprise: bool = typer.Option(False, "--enterprise", help="Enable the full enterprise pack (tracing, guardrails, FastAPI, Docker, CI, evals)."),
80
+ observability: bool = typer.Option(False, help="OpenTelemetry/Langfuse observability (with --yes)."),
81
+ guardrails: bool = typer.Option(False, help="Input/output guardrails (with --yes)."),
82
+ serve: bool = typer.Option(False, help="FastAPI server (REST + SSE) (with --yes)."),
83
+ docker: bool = typer.Option(False, help="Dockerfile + docker-compose (with --yes)."),
84
+ ci: bool = typer.Option(False, help="GitHub Actions CI (with --yes)."),
85
+ evals: bool = typer.Option(False, help="LLM-as-judge eval harness (with --yes)."),
86
+ no_venv: bool = typer.Option(False, "--no-venv", help="Do not create a .venv."),
87
+ sync: bool = typer.Option(False, "--sync", help="Run `uv sync` after generating."),
88
+ overwrite: bool = typer.Option(False, help="Overwrite a non-empty target directory."),
89
+ ) -> None:
90
+ """Scaffold a new agentic project (interactive by default)."""
91
+ if yes:
92
+ try:
93
+ get_spec(provider)
94
+ except KeyError as exc:
95
+ raise typer.BadParameter(str(exc)) from exc
96
+ agent_specs = []
97
+ for i in range(max(1, agents)):
98
+ a_name = f"agent_{i+1}" if agents > 1 else "assistant"
99
+ # The --prompt/--role/--goal flags configure the first agent.
100
+ if i == 0:
101
+ agent_specs.append(AgentSpec(name=a_name, role=role, goal=goal, system_prompt=prompt))
102
+ else:
103
+ agent_specs.append(AgentSpec(name=a_name))
104
+ spec = ProjectSpec(
105
+ name=name or "my-agent", framework=framework, provider=provider, model=model,
106
+ agents=agent_specs, use_rag=rag, memory=memory, use_mcp=mcp, use_skills=skills,
107
+ prompt_style="custom" if prompt else "default",
108
+ observability=observability, guardrails=guardrails, serve=serve,
109
+ docker=docker, ci=ci, evals=evals,
110
+ create_venv=not no_venv, run_sync=sync,
111
+ )
112
+ if enterprise:
113
+ spec.enable_enterprise()
114
+ else:
115
+ spec = run_wizard(name)
116
+ if spec is None:
117
+ console.print("[yellow]Cancelled.[/]")
118
+ raise typer.Exit(1)
119
+ if enterprise:
120
+ spec.enable_enterprise()
121
+ if no_venv:
122
+ spec.create_venv = False
123
+ if sync:
124
+ spec.run_sync = True
125
+
126
+ target = out or Path.cwd() / spec.slug
127
+ try:
128
+ result = generate_project(spec, target, overwrite=overwrite)
129
+ except FileExistsError as exc:
130
+ console.print(f"[red]{exc}[/]")
131
+ raise typer.Exit(1) from exc
132
+ _result_panel(result, spec)
133
+
134
+
135
+ # --------------------------------------------------------------------------- #
136
+ # `agentx prompt …` — manage prompts in an EXISTING generated project
137
+ # --------------------------------------------------------------------------- #
138
+ prompt_app = typer.Typer(help="Manage agent prompts in a generated project (edits prompts.json).", no_args_is_help=True)
139
+ app.add_typer(prompt_app, name="prompt")
140
+
141
+
142
+ def _resolve_prompts_file(project: Path | None) -> Path:
143
+ path = prompts_store.find_prompts_file(project)
144
+ if path is None:
145
+ console.print(
146
+ "[red]No prompts.json found.[/] Run this inside a AgentX project "
147
+ "(or pass --project /path/to/project)."
148
+ )
149
+ raise typer.Exit(1)
150
+ return path
151
+
152
+
153
+ def _read_text_arg(text: str | None, from_file: Path | None) -> str:
154
+ if from_file:
155
+ return Path(from_file).read_text(encoding="utf-8").strip()
156
+ return (text or "").strip()
157
+
158
+
159
+ @prompt_app.command("list")
160
+ def prompt_list(project: Path = typer.Option(None, "--project", help="Project dir (default: search from cwd).")) -> None:
161
+ """List agents and their (resolved) prompts."""
162
+ path = _resolve_prompts_file(project)
163
+ data = prompts_store.load(path)
164
+ table = Table(title=f"Agents in {path.parent.name}/prompts.json")
165
+ table.add_column("agent", style="cyan")
166
+ table.add_column("role")
167
+ table.add_column("prompt", overflow="fold")
168
+ for name, meta in data.get("agents", {}).items():
169
+ sp = meta.get("system_prompt") or f"(auto from role/goal: {meta.get('goal','')})"
170
+ table.add_row(name, meta.get("role", ""), sp)
171
+ console.print(table)
172
+
173
+
174
+ @prompt_app.command("set")
175
+ def prompt_set(
176
+ agent: str = typer.Argument(..., help="Agent name to update."),
177
+ text: str = typer.Option("", "--text", "-t", help="New system prompt text."),
178
+ from_file: Path = typer.Option(None, "--file", "-f", help="Read prompt text from a file."),
179
+ project: Path = typer.Option(None, "--project"),
180
+ ) -> None:
181
+ """Set/replace an existing agent's system prompt."""
182
+ path = _resolve_prompts_file(project)
183
+ body = _read_text_arg(text, from_file)
184
+ if not body:
185
+ body = typer.edit("\n# Enter the system prompt above this line.\n") or ""
186
+ body = body.split("\n# Enter the system prompt")[0].strip()
187
+ try:
188
+ prompts_store.set_prompt(path, agent, body)
189
+ except KeyError as exc:
190
+ console.print(f"[red]{exc}[/]")
191
+ raise typer.Exit(1) from exc
192
+ console.print(f"[green]✓[/] Updated prompt for '{agent}'.")
193
+
194
+
195
+ @prompt_app.command("add")
196
+ def prompt_add(
197
+ agent: str = typer.Argument(..., help="New agent name."),
198
+ role: str = typer.Option("", "--role"),
199
+ goal: str = typer.Option("", "--goal"),
200
+ text: str = typer.Option("", "--text", "-t", help="System prompt (blank = auto from role/goal)."),
201
+ from_file: Path = typer.Option(None, "--file", "-f"),
202
+ project: Path = typer.Option(None, "--project"),
203
+ ) -> None:
204
+ """Add a new agent; the project picks it up automatically on next run."""
205
+ path = _resolve_prompts_file(project)
206
+ try:
207
+ prompts_store.add_agent(path, agent, role=role, goal=goal, text=_read_text_arg(text, from_file))
208
+ except KeyError as exc:
209
+ console.print(f"[red]{exc}[/]")
210
+ raise typer.Exit(1) from exc
211
+ console.print(f"[green]✓[/] Added agent '{agent}'. It will run on next start — no code changes needed.")
212
+
213
+
214
+ @prompt_app.command("remove")
215
+ def prompt_remove(
216
+ agent: str = typer.Argument(..., help="Agent name to remove."),
217
+ project: Path = typer.Option(None, "--project"),
218
+ ) -> None:
219
+ """Remove an agent from the project."""
220
+ path = _resolve_prompts_file(project)
221
+ try:
222
+ prompts_store.remove_agent(path, agent)
223
+ except (KeyError, ValueError) as exc:
224
+ console.print(f"[red]{exc}[/]")
225
+ raise typer.Exit(1) from exc
226
+ console.print(f"[green]✓[/] Removed agent '{agent}'.")
227
+
228
+
229
+ if __name__ == "__main__":
230
+ app()
agentx/config.py ADDED
@@ -0,0 +1,34 @@
1
+ """Runtime configuration for the agentx library (pydantic-settings).
2
+
3
+ Reads from environment / a local ``.env``. Only generic, cross-provider knobs
4
+ live here; provider credentials are read by each provider's SDK from their own
5
+ standard env vars (see ``agentx.providers.registry``).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from functools import lru_cache
10
+
11
+ from pydantic import Field
12
+ from pydantic_settings import BaseSettings, SettingsConfigDict
13
+
14
+
15
+ class Settings(BaseSettings):
16
+ model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
17
+
18
+ # Default provider/model used when none is passed explicitly.
19
+ default_provider: str = Field(default="openai", alias="AGENTX_PROVIDER")
20
+ default_model: str = Field(default="", alias="AGENTX_MODEL")
21
+ temperature: float = Field(default=0.3, alias="AGENTX_TEMPERATURE")
22
+ max_tokens: int | None = Field(default=None, alias="AGENTX_MAX_TOKENS")
23
+ request_timeout: int = Field(default=120, alias="AGENTX_REQUEST_TIMEOUT")
24
+
25
+ # Local backends.
26
+ ollama_base_url: str = Field(default="http://localhost:11434", alias="OLLAMA_BASE_URL")
27
+ openrouter_base_url: str = Field(
28
+ default="https://openrouter.ai/api/v1", alias="OPENROUTER_BASE_URL"
29
+ )
30
+
31
+
32
+ @lru_cache
33
+ def get_settings() -> Settings:
34
+ return Settings()
@@ -0,0 +1,5 @@
1
+ """Framework adapters — build agents on LangGraph or CrewAI from common inputs."""
2
+ from .langchain_agent import build_react_agent, run_agent
3
+ from .crewai_agent import build_crewai_agent, build_crew
4
+
5
+ __all__ = ["build_react_agent", "run_agent", "build_crewai_agent", "build_crew"]
@@ -0,0 +1,52 @@
1
+ """CrewAI adapter — build Agents and a Crew from common inputs.
2
+
3
+ Uses our provider factory's ``get_crewai_llm`` so the same provider ids work
4
+ across frameworks. Requires ``agentx-kit[crewai]``.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Any
9
+
10
+ from ..providers import get_crewai_llm
11
+
12
+
13
+ def _require_crewai():
14
+ try:
15
+ import crewai # type: ignore
16
+
17
+ return crewai
18
+ except ImportError as exc:
19
+ raise ImportError(
20
+ "CrewAI is required. Install with: uv pip install 'agentx-kit[crewai]'"
21
+ ) from exc
22
+
23
+
24
+ def build_crewai_agent(
25
+ role: str,
26
+ goal: str,
27
+ backstory: str = "",
28
+ provider: str | None = None,
29
+ model: str | None = None,
30
+ tools: list[Any] | None = None,
31
+ **kwargs: Any,
32
+ ):
33
+ """Return a configured CrewAI ``Agent``."""
34
+ crewai = _require_crewai()
35
+ llm = get_crewai_llm(provider, model)
36
+ return crewai.Agent(
37
+ role=role,
38
+ goal=goal,
39
+ backstory=backstory or f"An expert acting as {role}.",
40
+ tools=tools or [],
41
+ llm=llm,
42
+ verbose=kwargs.pop("verbose", True),
43
+ allow_delegation=kwargs.pop("allow_delegation", False),
44
+ **kwargs,
45
+ )
46
+
47
+
48
+ def build_crew(agents: list[Any], tasks: list[Any], **kwargs: Any):
49
+ """Return a CrewAI ``Crew`` from agents + tasks (sequential by default)."""
50
+ crewai = _require_crewai()
51
+ process = kwargs.pop("process", None) or crewai.Process.sequential
52
+ return crewai.Crew(agents=agents, tasks=tasks, process=process, verbose=kwargs.pop("verbose", True), **kwargs)
@@ -0,0 +1,43 @@
1
+ """LangGraph agent adapter.
2
+
3
+ ``build_react_agent`` wires a provider-agnostic chat model (from our factory) +
4
+ tools + an optional system prompt into a LangGraph ReAct agent. Requires
5
+ ``agentx-kit[langgraph]`` plus the chosen provider extra.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from typing import Any
10
+
11
+ from ..providers import get_chat_model
12
+
13
+
14
+ def build_react_agent(
15
+ provider: str | None = None,
16
+ model: str | None = None,
17
+ tools: list[Any] | None = None,
18
+ system_prompt: str | None = None,
19
+ **model_kwargs: Any,
20
+ ):
21
+ """Return a compiled LangGraph ReAct agent (a runnable)."""
22
+ try:
23
+ from langgraph.prebuilt import create_react_agent
24
+ except ImportError as exc:
25
+ raise ImportError(
26
+ "LangGraph is required. Install with: uv pip install 'agentx-kit[langgraph]'"
27
+ ) from exc
28
+
29
+ llm = get_chat_model(provider, model, **model_kwargs)
30
+ tools = tools or []
31
+ if system_prompt:
32
+ return create_react_agent(llm, tools, prompt=system_prompt)
33
+ return create_react_agent(llm, tools)
34
+
35
+
36
+ def run_agent(agent, user_input: str) -> str:
37
+ """Invoke a LangGraph agent with a single user message; return final text."""
38
+ result = agent.invoke({"messages": [{"role": "user", "content": user_input}]})
39
+ messages = result.get("messages", []) if isinstance(result, dict) else []
40
+ if messages:
41
+ last = messages[-1]
42
+ return getattr(last, "content", None) or (last.get("content", "") if isinstance(last, dict) else "")
43
+ return str(result)
agentx/guardrails.py ADDED
@@ -0,0 +1,89 @@
1
+ """Guardrails: input/output validation, PII redaction, and content checks.
2
+
3
+ Lightweight, dependency-free defaults that cover the common enterprise asks
4
+ (PII redaction, banned content, length caps). Compose your own by passing
5
+ callables to :func:`apply_guards`. For heavier needs (jailbreak/moderation),
6
+ wire in Guardrails-AI or NeMo Guardrails in your project.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field
12
+
13
+ _PII_PATTERNS = {
14
+ "email": re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}"),
15
+ "phone": re.compile(r"\b(?:\+?\d{1,3}[\s.-]?)?(?:\(?\d{3}\)?[\s.-]?)\d{3}[\s.-]?\d{4}\b"),
16
+ "ssn": re.compile(r"\b\d{3}-\d{2}-\d{4}\b"),
17
+ "credit_card": re.compile(r"\b(?:\d[ -]?){13,16}\b"),
18
+ }
19
+
20
+
21
+ class GuardrailError(ValueError):
22
+ """Raised by a guard when input/output is rejected outright."""
23
+
24
+
25
+ @dataclass
26
+ class GuardResult:
27
+ text: str
28
+ violations: list[str] = field(default_factory=list)
29
+
30
+ @property
31
+ def ok(self) -> bool:
32
+ return not self.violations
33
+
34
+
35
+ def redact_pii(text: str, kinds: tuple[str, ...] | None = None) -> GuardResult:
36
+ """Replace detected PII with ``[REDACTED:<kind>]``."""
37
+ out = text
38
+ found: list[str] = []
39
+ for kind, pattern in _PII_PATTERNS.items():
40
+ if kinds and kind not in kinds:
41
+ continue
42
+ if pattern.search(out):
43
+ found.append(f"pii:{kind}")
44
+ out = pattern.sub(f"[REDACTED:{kind}]", out)
45
+ return GuardResult(out, found)
46
+
47
+
48
+ def block_banned(text: str, banned: list[str]) -> GuardResult:
49
+ lowered = text.lower()
50
+ hits = [w for w in banned if w.lower() in lowered]
51
+ return GuardResult(text, [f"banned:{w}" for w in hits])
52
+
53
+
54
+ def enforce_max_length(text: str, max_chars: int) -> GuardResult:
55
+ if len(text) > max_chars:
56
+ return GuardResult(text[:max_chars], [f"truncated:{len(text)}>{max_chars}"])
57
+ return GuardResult(text)
58
+
59
+
60
+ def apply_guards(text: str, guards: list, raise_on_violation: bool = False) -> GuardResult:
61
+ """Run guards in order, threading the (possibly transformed) text through.
62
+
63
+ Each guard is a callable ``str -> GuardResult``. Violations accumulate.
64
+ """
65
+ violations: list[str] = []
66
+ current = text
67
+ for guard in guards:
68
+ result = guard(current)
69
+ current = result.text
70
+ violations.extend(result.violations)
71
+ if violations and raise_on_violation:
72
+ raise GuardrailError("; ".join(violations))
73
+ return GuardResult(current, violations)
74
+
75
+
76
+ def default_input_guards(banned: list[str] | None = None, max_chars: int = 8000) -> list:
77
+ return [
78
+ lambda t: enforce_max_length(t, max_chars),
79
+ lambda t: block_banned(t, banned or []),
80
+ ]
81
+
82
+
83
+ def default_output_guards(redact: bool = True, max_chars: int = 16000) -> list:
84
+ # Redact first, then cap length last so max_chars is the final guarantee.
85
+ guards = []
86
+ if redact:
87
+ guards.append(lambda t: redact_pii(t))
88
+ guards.append(lambda t: enforce_max_length(t, max_chars))
89
+ return guards
@@ -0,0 +1,4 @@
1
+ """Agent memory: short-term (windowed) + long-term (persistent JSONL)."""
2
+ from .store import ConversationMemory, LongTermMemory
3
+
4
+ __all__ = ["ConversationMemory", "LongTermMemory"]
agentx/memory/store.py ADDED
@@ -0,0 +1,78 @@
1
+ """Two-tier agent memory — dependency-free, works with any framework.
2
+
3
+ * ``ConversationMemory`` — short-term, in-process windowed buffer of turns.
4
+ * ``LongTermMemory`` — append-only JSONL persisted per session, survives restarts.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import json
9
+ import threading
10
+ from collections import deque
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+ _lock = threading.Lock()
15
+
16
+
17
+ def _now() -> str:
18
+ return datetime.now(timezone.utc).isoformat()
19
+
20
+
21
+ class ConversationMemory:
22
+ """A rolling window of the most recent (role, content) turns."""
23
+
24
+ def __init__(self, max_turns: int = 12):
25
+ self.max_turns = max_turns
26
+ self._turns: deque[tuple[str, str]] = deque(maxlen=max_turns)
27
+
28
+ def add(self, role: str, content: str) -> None:
29
+ self._turns.append((role, content))
30
+
31
+ def add_user(self, content: str) -> None:
32
+ self.add("user", content)
33
+
34
+ def add_ai(self, content: str) -> None:
35
+ self.add("assistant", content)
36
+
37
+ def as_messages(self) -> list[dict]:
38
+ """Return turns as chat-style message dicts."""
39
+ return [{"role": r, "content": c} for r, c in self._turns]
40
+
41
+ def transcript(self) -> str:
42
+ return "\n".join(f"{r}: {c}" for r, c in self._turns)
43
+
44
+ def clear(self) -> None:
45
+ self._turns.clear()
46
+
47
+
48
+ class LongTermMemory:
49
+ """Append-only JSONL memory keyed by session id."""
50
+
51
+ def __init__(self, path: str | Path):
52
+ self.path = Path(path)
53
+ self.path.parent.mkdir(parents=True, exist_ok=True)
54
+
55
+ def add(self, role: str, content: str, **meta) -> dict:
56
+ event = {"ts": _now(), "role": role, "content": content, **meta}
57
+ with _lock:
58
+ with self.path.open("a", encoding="utf-8") as fh:
59
+ fh.write(json.dumps(event) + "\n")
60
+ return event
61
+
62
+ def history(self, limit: int | None = None) -> list[dict]:
63
+ if not self.path.exists():
64
+ return []
65
+ rows = []
66
+ for line in self.path.read_text(encoding="utf-8").splitlines():
67
+ line = line.strip()
68
+ if not line:
69
+ continue
70
+ try:
71
+ rows.append(json.loads(line))
72
+ except json.JSONDecodeError:
73
+ continue
74
+ return rows[-limit:] if limit else rows
75
+
76
+ def clear(self) -> None:
77
+ if self.path.exists():
78
+ self.path.unlink()