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/reliability.py
ADDED
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""Reliability: provider fallbacks, retries, and token/cost budgets.
|
|
2
|
+
|
|
3
|
+
- ``build_resilient_chat`` wraps a primary model with retries and sequential
|
|
4
|
+
fallbacks to other providers/models (à la pydantic-ai's FallbackModel +
|
|
5
|
+
the 'circular fallback' pattern from production templates).
|
|
6
|
+
- ``UsageLimits`` + ``UsageTracker`` enforce per-run request/token/cost budgets
|
|
7
|
+
to stop runaway agent loops (à la pydantic-ai ``UsageLimits``).
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import logging
|
|
12
|
+
from dataclasses import dataclass, field
|
|
13
|
+
|
|
14
|
+
from .providers import get_chat_model
|
|
15
|
+
|
|
16
|
+
logger = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def build_resilient_chat(
|
|
20
|
+
provider: str | None = None,
|
|
21
|
+
model: str | None = None,
|
|
22
|
+
fallbacks: list[tuple[str, str]] | None = None,
|
|
23
|
+
retries: int = 2,
|
|
24
|
+
**kwargs,
|
|
25
|
+
):
|
|
26
|
+
"""Return a chat model with retry + ordered provider/model fallbacks.
|
|
27
|
+
|
|
28
|
+
``fallbacks`` is a list of ``(provider, model)`` tried in order if the
|
|
29
|
+
primary fails. Built on LangChain's ``.with_retry()`` / ``.with_fallbacks()``.
|
|
30
|
+
"""
|
|
31
|
+
primary = get_chat_model(provider, model, **kwargs)
|
|
32
|
+
if retries and retries > 1:
|
|
33
|
+
primary = primary.with_retry(stop_after_attempt=retries, wait_exponential_jitter=True)
|
|
34
|
+
if fallbacks:
|
|
35
|
+
alts = []
|
|
36
|
+
for fb_provider, fb_model in fallbacks:
|
|
37
|
+
try:
|
|
38
|
+
alt = get_chat_model(fb_provider, fb_model, **kwargs)
|
|
39
|
+
alts.append(alt.with_retry(stop_after_attempt=retries, wait_exponential_jitter=True))
|
|
40
|
+
except Exception as exc: # noqa: BLE001 - a missing extra shouldn't kill the chain
|
|
41
|
+
logger.warning("Skipping fallback %s/%s: %s", fb_provider, fb_model, exc)
|
|
42
|
+
if alts:
|
|
43
|
+
primary = primary.with_fallbacks(alts)
|
|
44
|
+
return primary
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class UsageLimitExceeded(RuntimeError):
|
|
48
|
+
"""Raised when a configured request/token/cost budget is exceeded."""
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@dataclass
|
|
52
|
+
class UsageLimits:
|
|
53
|
+
max_requests: int | None = None
|
|
54
|
+
max_total_tokens: int | None = None
|
|
55
|
+
max_cost_usd: float | None = None
|
|
56
|
+
# USD per 1K tokens (total). Override per your provider/model pricing.
|
|
57
|
+
price_per_1k_tokens: float = 0.0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class UsageTracker:
|
|
62
|
+
"""Accumulates usage and enforces ``UsageLimits``.
|
|
63
|
+
|
|
64
|
+
Call :meth:`record` with token counts after each model call (or attach
|
|
65
|
+
:meth:`as_callback` to LangChain). Raises ``UsageLimitExceeded`` on breach.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
limits: UsageLimits = field(default_factory=UsageLimits)
|
|
69
|
+
requests: int = 0
|
|
70
|
+
total_tokens: int = 0
|
|
71
|
+
|
|
72
|
+
@property
|
|
73
|
+
def cost_usd(self) -> float:
|
|
74
|
+
return round(self.total_tokens / 1000.0 * self.limits.price_per_1k_tokens, 6)
|
|
75
|
+
|
|
76
|
+
def record(self, tokens: int = 0) -> None:
|
|
77
|
+
self.requests += 1
|
|
78
|
+
self.total_tokens += max(0, int(tokens))
|
|
79
|
+
self._enforce()
|
|
80
|
+
|
|
81
|
+
def _enforce(self) -> None:
|
|
82
|
+
lim = self.limits
|
|
83
|
+
if lim.max_requests is not None and self.requests > lim.max_requests:
|
|
84
|
+
raise UsageLimitExceeded(f"max_requests exceeded ({self.requests} > {lim.max_requests})")
|
|
85
|
+
if lim.max_total_tokens is not None and self.total_tokens > lim.max_total_tokens:
|
|
86
|
+
raise UsageLimitExceeded(f"max_total_tokens exceeded ({self.total_tokens} > {lim.max_total_tokens})")
|
|
87
|
+
if lim.max_cost_usd is not None and self.cost_usd > lim.max_cost_usd:
|
|
88
|
+
raise UsageLimitExceeded(f"max_cost_usd exceeded (${self.cost_usd} > ${lim.max_cost_usd})")
|
|
89
|
+
|
|
90
|
+
def as_callback(self):
|
|
91
|
+
"""Return a LangChain callback handler that records token usage."""
|
|
92
|
+
from langchain_core.callbacks import BaseCallbackHandler
|
|
93
|
+
|
|
94
|
+
tracker = self
|
|
95
|
+
|
|
96
|
+
class _UsageCallback(BaseCallbackHandler):
|
|
97
|
+
def on_llm_end(self, response, **kwargs): # noqa: ANN001
|
|
98
|
+
tokens = 0
|
|
99
|
+
try:
|
|
100
|
+
usage = (response.llm_output or {}).get("token_usage") or {}
|
|
101
|
+
tokens = usage.get("total_tokens", 0)
|
|
102
|
+
if not tokens: # some providers attach usage to message metadata
|
|
103
|
+
for gen in getattr(response, "generations", []) or []:
|
|
104
|
+
for g in gen:
|
|
105
|
+
meta = getattr(getattr(g, "message", None), "usage_metadata", None)
|
|
106
|
+
if meta:
|
|
107
|
+
tokens += meta.get("total_tokens", 0)
|
|
108
|
+
except Exception: # noqa: BLE001
|
|
109
|
+
tokens = 0
|
|
110
|
+
tracker.record(tokens)
|
|
111
|
+
|
|
112
|
+
return _UsageCallback()
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
"""Scaffolder: interactive wizard + project generator + prompt management."""
|
|
2
|
+
from .spec import AgentSpec, ProjectSpec
|
|
3
|
+
from .generator import GenerationResult, generate_project
|
|
4
|
+
from .wizard import run_wizard
|
|
5
|
+
from . import prompts_store
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AgentSpec",
|
|
9
|
+
"ProjectSpec",
|
|
10
|
+
"GenerationResult",
|
|
11
|
+
"generate_project",
|
|
12
|
+
"run_wizard",
|
|
13
|
+
"prompts_store",
|
|
14
|
+
]
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Project generator — renders templates into a new project and sets up uv.
|
|
2
|
+
|
|
3
|
+
``generate_project(spec, target_dir)`` is pure/testable: it writes files and,
|
|
4
|
+
unless disabled, runs ``uv venv`` (creating ``.venv``) and optionally ``uv sync``.
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from jinja2 import Environment, FileSystemLoader, StrictUndefined
|
|
14
|
+
|
|
15
|
+
from ..providers import get_spec
|
|
16
|
+
from . import prompts_store
|
|
17
|
+
from .spec import ProjectSpec
|
|
18
|
+
|
|
19
|
+
TEMPLATES_DIR = Path(__file__).parent / "templates"
|
|
20
|
+
|
|
21
|
+
# (template, output-relative-path, include?) — output paths use {pkg} placeholder.
|
|
22
|
+
_FILE_PLAN: list[tuple[str, str]] = [
|
|
23
|
+
("pyproject.toml.j2", "pyproject.toml"),
|
|
24
|
+
("README.md.j2", "README.md"),
|
|
25
|
+
("env.example.j2", ".env.example"),
|
|
26
|
+
("gitignore.j2", ".gitignore"),
|
|
27
|
+
("pkg/__init__.py.j2", "src/{pkg}/__init__.py"),
|
|
28
|
+
("pkg/config.py.j2", "src/{pkg}/config.py"),
|
|
29
|
+
("pkg/prompts.py.j2", "src/{pkg}/prompts.py"),
|
|
30
|
+
("pkg/agents.py.j2", "src/{pkg}/agents.py"),
|
|
31
|
+
("pkg/main.py.j2", "src/{pkg}/main.py"),
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class GenerationResult:
|
|
37
|
+
target_dir: Path
|
|
38
|
+
files: list[Path]
|
|
39
|
+
venv_created: bool
|
|
40
|
+
synced: bool
|
|
41
|
+
messages: list[str]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _extras(spec: ProjectSpec) -> list[str]:
|
|
45
|
+
"""Compute the agentx extras the generated project needs."""
|
|
46
|
+
extras = {get_spec(spec.provider).extra}
|
|
47
|
+
extras.add("langgraph" if spec.framework == "langgraph" else "crewai")
|
|
48
|
+
if spec.use_rag:
|
|
49
|
+
extras.add("rag")
|
|
50
|
+
if spec.use_mcp:
|
|
51
|
+
extras.add("mcp")
|
|
52
|
+
if spec.observability:
|
|
53
|
+
extras.add("observability")
|
|
54
|
+
if spec.serve:
|
|
55
|
+
extras.add("server")
|
|
56
|
+
# Deterministic order for reproducible pyproject output.
|
|
57
|
+
order = ["langgraph", "crewai", "openai", "azure", "openrouter", "anthropic",
|
|
58
|
+
"google", "vertex", "bedrock", "groq", "ollama", "rag", "mcp",
|
|
59
|
+
"observability", "server"]
|
|
60
|
+
return [e for e in order if e in extras]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _context(spec: ProjectSpec) -> dict:
|
|
64
|
+
provider_spec = get_spec(spec.provider)
|
|
65
|
+
return {
|
|
66
|
+
"spec": spec,
|
|
67
|
+
"pkg": spec.package,
|
|
68
|
+
"model": spec.model or provider_spec.default_model,
|
|
69
|
+
"provider_label": provider_spec.label,
|
|
70
|
+
"provider_env": list(provider_spec.env_vars),
|
|
71
|
+
"extras": _extras(spec),
|
|
72
|
+
"extras_str": ",".join(_extras(spec)),
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _env() -> Environment:
|
|
77
|
+
return Environment(
|
|
78
|
+
loader=FileSystemLoader(str(TEMPLATES_DIR)),
|
|
79
|
+
trim_blocks=True,
|
|
80
|
+
lstrip_blocks=True,
|
|
81
|
+
keep_trailing_newline=True,
|
|
82
|
+
undefined=StrictUndefined,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _conditional_files(spec: ProjectSpec) -> list[tuple[str, str]]:
|
|
87
|
+
plan: list[tuple[str, str]] = []
|
|
88
|
+
if spec.use_rag:
|
|
89
|
+
plan.append(("pkg/rag.py.j2", "src/{pkg}/rag.py"))
|
|
90
|
+
if spec.needs_memory:
|
|
91
|
+
plan.append(("pkg/memory.py.j2", "src/{pkg}/memory.py"))
|
|
92
|
+
if spec.use_mcp:
|
|
93
|
+
plan.append(("pkg/tools.py.j2", "src/{pkg}/tools.py"))
|
|
94
|
+
plan.append(("mcp_servers.json.j2", "mcp_servers.json"))
|
|
95
|
+
if spec.use_skills:
|
|
96
|
+
plan.append(("skills_seed.json.j2", "data/skills/star-method.json"))
|
|
97
|
+
if spec.observability:
|
|
98
|
+
plan.append(("pkg/observability.py.j2", "src/{pkg}/observability.py"))
|
|
99
|
+
if spec.guardrails:
|
|
100
|
+
plan.append(("pkg/guardrails.py.j2", "src/{pkg}/guardrails.py"))
|
|
101
|
+
if spec.serve:
|
|
102
|
+
plan.append(("pkg/server.py.j2", "src/{pkg}/server.py"))
|
|
103
|
+
if spec.docker:
|
|
104
|
+
plan.append(("Dockerfile.j2", "Dockerfile"))
|
|
105
|
+
plan.append(("docker-compose.yml.j2", "docker-compose.yml"))
|
|
106
|
+
plan.append(("dockerignore.j2", ".dockerignore"))
|
|
107
|
+
if spec.ci:
|
|
108
|
+
plan.append(("ci.yml.j2", ".github/workflows/ci.yml"))
|
|
109
|
+
if spec.evals:
|
|
110
|
+
plan.append(("evals/run_evals.py.j2", "evals/run_evals.py"))
|
|
111
|
+
plan.append(("evals/dataset.json.j2", "evals/dataset.json"))
|
|
112
|
+
return plan
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _write_manifest(target: Path, spec: ProjectSpec) -> Path:
|
|
116
|
+
"""Write agentx.json — a declarative summary of the generated project."""
|
|
117
|
+
import json
|
|
118
|
+
|
|
119
|
+
manifest = {
|
|
120
|
+
"name": spec.slug,
|
|
121
|
+
"framework": spec.framework,
|
|
122
|
+
"provider": spec.provider,
|
|
123
|
+
"model": spec.model or get_spec(spec.provider).default_model,
|
|
124
|
+
"python_version": ">=3.10,<3.14",
|
|
125
|
+
"agents": [a.name for a in spec.agents],
|
|
126
|
+
"features": {
|
|
127
|
+
"rag": spec.use_rag,
|
|
128
|
+
"memory": spec.memory,
|
|
129
|
+
"mcp": spec.use_mcp,
|
|
130
|
+
"skills": spec.use_skills,
|
|
131
|
+
"observability": spec.observability,
|
|
132
|
+
"guardrails": spec.guardrails,
|
|
133
|
+
"serve": spec.serve,
|
|
134
|
+
"docker": spec.docker,
|
|
135
|
+
"ci": spec.ci,
|
|
136
|
+
"evals": spec.evals,
|
|
137
|
+
},
|
|
138
|
+
"extras": _extras(spec),
|
|
139
|
+
"telemetry_opt_out": False,
|
|
140
|
+
}
|
|
141
|
+
path = target / "agentx.json"
|
|
142
|
+
path.write_text(json.dumps(manifest, indent=2) + "\n", encoding="utf-8")
|
|
143
|
+
return path
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def generate_project(spec: ProjectSpec, target_dir: str | Path, overwrite: bool = False) -> GenerationResult:
|
|
147
|
+
"""Render the project for ``spec`` into ``target_dir``."""
|
|
148
|
+
target = Path(target_dir).expanduser().resolve()
|
|
149
|
+
if target.exists() and any(target.iterdir()) and not overwrite:
|
|
150
|
+
raise FileExistsError(f"Target directory '{target}' exists and is not empty. Use overwrite=True.")
|
|
151
|
+
target.mkdir(parents=True, exist_ok=True)
|
|
152
|
+
|
|
153
|
+
env = _env()
|
|
154
|
+
ctx = _context(spec)
|
|
155
|
+
written: list[Path] = []
|
|
156
|
+
messages: list[str] = []
|
|
157
|
+
|
|
158
|
+
for template_name, out_rel in _FILE_PLAN + _conditional_files(spec):
|
|
159
|
+
out_path = target / out_rel.format(pkg=spec.package)
|
|
160
|
+
out_path.parent.mkdir(parents=True, exist_ok=True)
|
|
161
|
+
rendered = env.get_template(template_name).render(**ctx)
|
|
162
|
+
out_path.write_text(rendered, encoding="utf-8")
|
|
163
|
+
written.append(out_path)
|
|
164
|
+
|
|
165
|
+
# The prompt source of truth — edited by hand or via `agentx prompt`.
|
|
166
|
+
written.append(prompts_store.write_prompts(target, spec))
|
|
167
|
+
# A single declarative manifest of the project (à la langgraph.json).
|
|
168
|
+
written.append(_write_manifest(target, spec))
|
|
169
|
+
|
|
170
|
+
venv_created = synced = False
|
|
171
|
+
uv = shutil.which("uv")
|
|
172
|
+
if spec.create_venv:
|
|
173
|
+
if not uv:
|
|
174
|
+
messages.append("`uv` not found on PATH — skipped .venv creation. Install uv: https://docs.astral.sh/uv/")
|
|
175
|
+
else:
|
|
176
|
+
try:
|
|
177
|
+
subprocess.run([uv, "venv"], cwd=target, check=True, capture_output=True, timeout=120)
|
|
178
|
+
venv_created = True
|
|
179
|
+
messages.append("Created .venv via `uv venv`.")
|
|
180
|
+
except Exception as exc: # noqa: BLE001
|
|
181
|
+
messages.append(f"`uv venv` failed: {exc!r}")
|
|
182
|
+
if venv_created and spec.run_sync:
|
|
183
|
+
try:
|
|
184
|
+
subprocess.run([uv, "sync"], cwd=target, check=True, timeout=900)
|
|
185
|
+
synced = True
|
|
186
|
+
messages.append("Installed dependencies via `uv sync`.")
|
|
187
|
+
except Exception as exc: # noqa: BLE001
|
|
188
|
+
messages.append(f"`uv sync` failed (run it manually): {exc!r}")
|
|
189
|
+
|
|
190
|
+
return GenerationResult(target, written, venv_created, synced, messages)
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""Read/write helpers for a generated project's ``prompts.json``.
|
|
2
|
+
|
|
3
|
+
The generated project is *prompt-file driven*: ``prompts.json`` is the source of
|
|
4
|
+
truth for which agents exist and what their system prompts are. Editing it (by
|
|
5
|
+
hand or via ``agentx prompt``) changes the running project — no code edits needed.
|
|
6
|
+
|
|
7
|
+
Schema::
|
|
8
|
+
|
|
9
|
+
{
|
|
10
|
+
"with_rag": false,
|
|
11
|
+
"agents": {
|
|
12
|
+
"<name>": {"role": "...", "goal": "...", "system_prompt": "..."}
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import json
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from .spec import ProjectSpec, to_snake
|
|
22
|
+
|
|
23
|
+
FILENAME = "prompts.json"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def build_prompts_data(spec: ProjectSpec) -> dict:
|
|
27
|
+
"""Build the prompts.json payload from a ProjectSpec."""
|
|
28
|
+
return {
|
|
29
|
+
"with_rag": spec.use_rag,
|
|
30
|
+
"agents": {
|
|
31
|
+
a.name: {"role": a.role, "goal": a.goal, "system_prompt": a.system_prompt}
|
|
32
|
+
for a in spec.agents
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def write_prompts(target_dir: str | Path, spec: ProjectSpec) -> Path:
|
|
38
|
+
path = Path(target_dir) / FILENAME
|
|
39
|
+
path.write_text(json.dumps(build_prompts_data(spec), indent=2) + "\n", encoding="utf-8")
|
|
40
|
+
return path
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def find_prompts_file(start: str | Path | None = None) -> Path | None:
|
|
44
|
+
"""Walk up from ``start`` (or cwd) to find a project's prompts.json."""
|
|
45
|
+
base = Path(start or Path.cwd()).resolve()
|
|
46
|
+
for d in [base, *base.parents]:
|
|
47
|
+
candidate = d / FILENAME
|
|
48
|
+
if candidate.exists():
|
|
49
|
+
return candidate
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def load(path: str | Path) -> dict:
|
|
54
|
+
data = json.loads(Path(path).read_text(encoding="utf-8"))
|
|
55
|
+
data.setdefault("agents", {})
|
|
56
|
+
data.setdefault("with_rag", False)
|
|
57
|
+
return data
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def save(path: str | Path, data: dict) -> None:
|
|
61
|
+
Path(path).write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def set_prompt(path: str | Path, name: str, text: str) -> dict:
|
|
65
|
+
"""Set/replace an existing agent's system prompt."""
|
|
66
|
+
data = load(path)
|
|
67
|
+
key = to_snake(name)
|
|
68
|
+
if key not in data["agents"]:
|
|
69
|
+
raise KeyError(f"No agent named '{key}'. Use `add` to create it.")
|
|
70
|
+
data["agents"][key]["system_prompt"] = text
|
|
71
|
+
save(path, data)
|
|
72
|
+
return data
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def add_agent(path: str | Path, name: str, role: str = "", goal: str = "", text: str = "") -> dict:
|
|
76
|
+
"""Add a new agent. The project picks it up automatically on next run."""
|
|
77
|
+
data = load(path)
|
|
78
|
+
key = to_snake(name)
|
|
79
|
+
if key in data["agents"]:
|
|
80
|
+
raise KeyError(f"Agent '{key}' already exists. Use `set` to change its prompt.")
|
|
81
|
+
data["agents"][key] = {
|
|
82
|
+
"role": role or f"{name} agent",
|
|
83
|
+
"goal": goal or "Help the user accomplish their task accurately.",
|
|
84
|
+
"system_prompt": text,
|
|
85
|
+
}
|
|
86
|
+
save(path, data)
|
|
87
|
+
return data
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def remove_agent(path: str | Path, name: str) -> dict:
|
|
91
|
+
data = load(path)
|
|
92
|
+
key = to_snake(name)
|
|
93
|
+
if key not in data["agents"]:
|
|
94
|
+
raise KeyError(f"No agent named '{key}'.")
|
|
95
|
+
if len(data["agents"]) == 1:
|
|
96
|
+
raise ValueError("Cannot remove the last remaining agent.")
|
|
97
|
+
del data["agents"][key]
|
|
98
|
+
save(path, data)
|
|
99
|
+
return data
|
agentx/scaffold/spec.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""``ProjectSpec`` — the single source of truth for a generation run.
|
|
2
|
+
|
|
3
|
+
Both the interactive wizard and the programmatic API produce a ``ProjectSpec``;
|
|
4
|
+
the generator consumes it. Keeping this validated and standalone makes the whole
|
|
5
|
+
scaffolder testable without a TTY.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from typing import Literal
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel, Field, field_validator
|
|
13
|
+
|
|
14
|
+
Framework = Literal["langgraph", "crewai"]
|
|
15
|
+
MemoryMode = Literal["none", "short", "long", "both"]
|
|
16
|
+
PromptStyle = Literal["default", "custom"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def to_snake(name: str) -> str:
|
|
20
|
+
s = re.sub(r"[^0-9a-zA-Z]+", "_", name.strip().lower()).strip("_")
|
|
21
|
+
return s or "app"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class AgentSpec(BaseModel):
|
|
25
|
+
name: str = "assistant"
|
|
26
|
+
role: str = "Helpful Assistant"
|
|
27
|
+
goal: str = "Help the user accomplish their task accurately."
|
|
28
|
+
# Optional explicit system prompt. Blank → derived from role/goal at runtime.
|
|
29
|
+
system_prompt: str = ""
|
|
30
|
+
|
|
31
|
+
@field_validator("name")
|
|
32
|
+
@classmethod
|
|
33
|
+
def _slug(cls, v: str) -> str:
|
|
34
|
+
return to_snake(v)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ProjectSpec(BaseModel):
|
|
38
|
+
name: str = Field(..., description="Project name (directory + package base).")
|
|
39
|
+
framework: Framework = "langgraph"
|
|
40
|
+
provider: str = "openai"
|
|
41
|
+
model: str = "" # blank → provider default
|
|
42
|
+
agents: list[AgentSpec] = Field(default_factory=lambda: [AgentSpec()])
|
|
43
|
+
use_rag: bool = False
|
|
44
|
+
memory: MemoryMode = "none"
|
|
45
|
+
use_mcp: bool = False
|
|
46
|
+
use_skills: bool = False
|
|
47
|
+
prompt_style: PromptStyle = "default"
|
|
48
|
+
# ----- enterprise features -----
|
|
49
|
+
observability: bool = False # OpenTelemetry / Langfuse tracing wiring
|
|
50
|
+
guardrails: bool = False # input/output guardrails module
|
|
51
|
+
serve: bool = False # FastAPI server (REST + SSE streaming)
|
|
52
|
+
docker: bool = False # Dockerfile + docker-compose.yml
|
|
53
|
+
ci: bool = False # GitHub Actions (lint + test [+ eval])
|
|
54
|
+
evals: bool = False # LLM-as-judge eval harness (+ CI gate)
|
|
55
|
+
create_venv: bool = True
|
|
56
|
+
run_sync: bool = False
|
|
57
|
+
# When set, generated pyproject depends on agentx from this local path
|
|
58
|
+
# (editable) instead of PyPI — used for local dev/testing.
|
|
59
|
+
agentx_local_path: str | None = None
|
|
60
|
+
|
|
61
|
+
def enable_enterprise(self) -> "ProjectSpec":
|
|
62
|
+
"""Turn on the full enterprise feature set in one call."""
|
|
63
|
+
self.observability = self.guardrails = self.serve = True
|
|
64
|
+
self.docker = self.ci = self.evals = True
|
|
65
|
+
return self
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def package(self) -> str:
|
|
69
|
+
return to_snake(self.name)
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def slug(self) -> str:
|
|
73
|
+
return re.sub(r"[^0-9a-zA-Z]+", "-", self.name.strip().lower()).strip("-") or "app"
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def needs_memory(self) -> bool:
|
|
77
|
+
return self.memory != "none"
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def use_short_memory(self) -> bool:
|
|
81
|
+
return self.memory in ("short", "both")
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def use_long_memory(self) -> bool:
|
|
85
|
+
return self.memory in ("long", "both")
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
FROM python:3.12-slim
|
|
2
|
+
|
|
3
|
+
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1
|
|
4
|
+
WORKDIR /app
|
|
5
|
+
|
|
6
|
+
# uv for fast, reproducible installs
|
|
7
|
+
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
|
8
|
+
|
|
9
|
+
COPY . .
|
|
10
|
+
RUN uv pip install --system .
|
|
11
|
+
|
|
12
|
+
{% if spec.serve %}
|
|
13
|
+
EXPOSE 8000
|
|
14
|
+
CMD ["uvicorn", "{{ pkg }}.server:app", "--host", "0.0.0.0", "--port", "8000"]
|
|
15
|
+
{% else %}
|
|
16
|
+
CMD ["python", "-m", "{{ pkg }}.main"]
|
|
17
|
+
{% endif %}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# {{ spec.slug }}
|
|
2
|
+
|
|
3
|
+
Agentic app generated by **AgentX**.
|
|
4
|
+
|
|
5
|
+
- **Framework:** {{ spec.framework }}
|
|
6
|
+
- **Provider:** {{ provider_label }} (`{{ model }}`)
|
|
7
|
+
- **Agents:** {% for a in spec.agents %}`{{ a.name }}`{% if not loop.last %}, {% endif %}{% endfor %}
|
|
8
|
+
|
|
9
|
+
- **RAG:** {{ "yes" if spec.use_rag else "no" }}
|
|
10
|
+
- **Memory:** {{ spec.memory }}
|
|
11
|
+
- **MCP tools:** {{ "yes" if spec.use_mcp else "no" }}
|
|
12
|
+
- **Skills:** {{ "yes" if spec.use_skills else "no" }}
|
|
13
|
+
|
|
14
|
+
## Setup
|
|
15
|
+
```bash
|
|
16
|
+
uv sync # install dependencies into .venv
|
|
17
|
+
cp .env.example .env # then fill in your credentials
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Run
|
|
21
|
+
```bash
|
|
22
|
+
uv run {{ spec.slug }}
|
|
23
|
+
# or:
|
|
24
|
+
uv run python -m {{ pkg }}.main
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## Layout
|
|
28
|
+
```
|
|
29
|
+
src/{{ pkg }}/
|
|
30
|
+
├── config.py # provider/model selection
|
|
31
|
+
├── prompts.py # per-agent system prompts
|
|
32
|
+
├── agents.py # agent (and crew) definitions
|
|
33
|
+
{% if spec.use_rag %}├── rag.py # knowledge base / retriever tool
|
|
34
|
+
{% endif %}
|
|
35
|
+
{% if spec.needs_memory %}├── memory.py # conversation / long-term memory
|
|
36
|
+
{% endif %}
|
|
37
|
+
{% if spec.use_mcp %}├── tools.py # MCP tool loading
|
|
38
|
+
{% endif %}
|
|
39
|
+
└── main.py # entry point
|
|
40
|
+
```
|
|
41
|
+
{% if spec.use_rag %}
|
|
42
|
+
Add `.txt`/`.md` files under `knowledge/` to populate the RAG index.
|
|
43
|
+
{% endif %}
|
|
44
|
+
{% if spec.use_mcp %}
|
|
45
|
+
Edit `mcp_servers.json` to configure your MCP servers.
|
|
46
|
+
{% endif %}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
name: CI
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
push:
|
|
5
|
+
branches: [main]
|
|
6
|
+
pull_request:
|
|
7
|
+
|
|
8
|
+
jobs:
|
|
9
|
+
test:
|
|
10
|
+
runs-on: ubuntu-latest
|
|
11
|
+
steps:
|
|
12
|
+
- uses: actions/checkout@v4
|
|
13
|
+
- uses: astral-sh/setup-uv@v5
|
|
14
|
+
with:
|
|
15
|
+
python-version: "3.12"
|
|
16
|
+
- name: Install
|
|
17
|
+
run: uv pip install --system -e . pytest ruff
|
|
18
|
+
- name: Lint
|
|
19
|
+
run: ruff check . || true
|
|
20
|
+
- name: Compile
|
|
21
|
+
run: python -m compileall src
|
|
22
|
+
- name: Tests
|
|
23
|
+
run: pytest -q || echo "no tests yet"
|
|
24
|
+
{% if spec.evals %}
|
|
25
|
+
|
|
26
|
+
evals:
|
|
27
|
+
runs-on: ubuntu-latest
|
|
28
|
+
steps:
|
|
29
|
+
- uses: actions/checkout@v4
|
|
30
|
+
- uses: astral-sh/setup-uv@v5
|
|
31
|
+
with:
|
|
32
|
+
python-version: "3.12"
|
|
33
|
+
- name: Install
|
|
34
|
+
run: uv pip install --system -e .
|
|
35
|
+
- name: Run eval gate
|
|
36
|
+
env:
|
|
37
|
+
# Add your provider key as a repo secret to enable the gate.
|
|
38
|
+
OPENAI_API_KEY: {% raw %}${{ secrets.OPENAI_API_KEY }}{% endraw %}
|
|
39
|
+
|
|
40
|
+
run: python evals/run_evals.py
|
|
41
|
+
{% endif %}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# --- Provider selection (read by agentx) ---
|
|
2
|
+
AGENTX_PROVIDER={{ spec.provider }}
|
|
3
|
+
AGENTX_MODEL={{ model }}
|
|
4
|
+
AGENTX_TEMPERATURE=0.3
|
|
5
|
+
|
|
6
|
+
# --- Credentials for {{ provider_label }} ---
|
|
7
|
+
{% for v in provider_env %}
|
|
8
|
+
{{ v }}=
|
|
9
|
+
{% endfor %}
|
|
10
|
+
{% if not provider_env %}
|
|
11
|
+
# {{ provider_label }} is local — no API key required.
|
|
12
|
+
{% endif %}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"input": "Say hello and briefly state what you can help with.",
|
|
4
|
+
"criteria": "Greets the user and clearly explains the agent's purpose/capabilities."
|
|
5
|
+
},
|
|
6
|
+
{
|
|
7
|
+
"input": "What are your main capabilities?",
|
|
8
|
+
"criteria": "Lists relevant, concrete capabilities without hallucinating unavailable features."
|
|
9
|
+
}
|
|
10
|
+
]
|