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.
- agentx/__init__.py +55 -0
- agentx/cli.py +230 -0
- agentx/config.py +34 -0
- agentx/frameworks/__init__.py +5 -0
- agentx/frameworks/crewai_agent.py +52 -0
- agentx/frameworks/langchain_agent.py +43 -0
- agentx/guardrails.py +89 -0
- agentx/memory/__init__.py +4 -0
- agentx/memory/store.py +78 -0
- agentx/observability.py +103 -0
- agentx/prompts/__init__.py +8 -0
- agentx/prompts/templates.py +40 -0
- agentx/providers/__init__.py +15 -0
- agentx/providers/base.py +50 -0
- agentx/providers/factory.py +71 -0
- agentx/providers/registry.py +165 -0
- agentx/rag/__init__.py +8 -0
- agentx/rag/pipeline.py +121 -0
- agentx/reliability.py +112 -0
- agentx/scaffold/__init__.py +14 -0
- agentx/scaffold/generator.py +190 -0
- agentx/scaffold/prompts_store.py +99 -0
- agentx/scaffold/spec.py +85 -0
- agentx/scaffold/templates/Dockerfile.j2 +17 -0
- agentx/scaffold/templates/README.md.j2 +46 -0
- agentx/scaffold/templates/ci.yml.j2 +41 -0
- agentx/scaffold/templates/docker-compose.yml.j2 +9 -0
- agentx/scaffold/templates/dockerignore.j2 +11 -0
- agentx/scaffold/templates/env.example.j2 +12 -0
- agentx/scaffold/templates/evals/dataset.json.j2 +10 -0
- agentx/scaffold/templates/evals/run_evals.py.j2 +70 -0
- agentx/scaffold/templates/gitignore.j2 +8 -0
- agentx/scaffold/templates/mcp_servers.json.j2 +7 -0
- agentx/scaffold/templates/pkg/__init__.py.j2 +3 -0
- agentx/scaffold/templates/pkg/agents.py.j2 +77 -0
- agentx/scaffold/templates/pkg/config.py.j2 +25 -0
- agentx/scaffold/templates/pkg/guardrails.py.j2 +21 -0
- agentx/scaffold/templates/pkg/main.py.j2 +79 -0
- agentx/scaffold/templates/pkg/memory.py.j2 +17 -0
- agentx/scaffold/templates/pkg/observability.py.j2 +17 -0
- agentx/scaffold/templates/pkg/prompts.py.j2 +45 -0
- agentx/scaffold/templates/pkg/rag.py.j2 +37 -0
- agentx/scaffold/templates/pkg/server.py.j2 +85 -0
- agentx/scaffold/templates/pkg/tools.py.j2 +16 -0
- agentx/scaffold/templates/pyproject.toml.j2 +28 -0
- agentx/scaffold/templates/skills_seed.json.j2 +6 -0
- agentx/scaffold/wizard.py +125 -0
- agentx/skills/__init__.py +4 -0
- agentx/skills/registry.py +63 -0
- agentx/structured.py +37 -0
- agentx/tools/__init__.py +5 -0
- agentx/tools/builtin.py +45 -0
- agentx/tools/mcp.py +64 -0
- agentx_kit-0.2.0.dist-info/METADATA +289 -0
- agentx_kit-0.2.0.dist-info/RECORD +58 -0
- agentx_kit-0.2.0.dist-info/WHEEL +4 -0
- agentx_kit-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|
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()
|