agentforge-py 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentforge/__init__.py +114 -0
- agentforge/_testing/__init__.py +19 -0
- agentforge/_testing/fake_llm.py +126 -0
- agentforge/_testing/fake_tool.py +122 -0
- agentforge/_tools/__init__.py +14 -0
- agentforge/_tools/calculator.py +102 -0
- agentforge/_tools/decorator.py +300 -0
- agentforge/_tools/file_read.py +112 -0
- agentforge/_tools/shell.py +134 -0
- agentforge/_tools/web_search.py +207 -0
- agentforge/agent.py +817 -0
- agentforge/auth.py +42 -0
- agentforge/cli/__init__.py +18 -0
- agentforge/cli/_build.py +323 -0
- agentforge/cli/_scaffold_state.py +250 -0
- agentforge/cli/_shared_scaffold.py +174 -0
- agentforge/cli/config_cmd.py +174 -0
- agentforge/cli/db_cmd.py +262 -0
- agentforge/cli/debug_cmd.py +168 -0
- agentforge/cli/docs_cmd.py +217 -0
- agentforge/cli/eval_cmd.py +181 -0
- agentforge/cli/health_cmd.py +139 -0
- agentforge/cli/list_modules.py +85 -0
- agentforge/cli/main.py +81 -0
- agentforge/cli/manifest_apply.py +368 -0
- agentforge/cli/module_cmd.py +247 -0
- agentforge/cli/new_cmd.py +171 -0
- agentforge/cli/run_cmd.py +234 -0
- agentforge/cli/upgrade_cmd.py +230 -0
- agentforge/config/__init__.py +45 -0
- agentforge/eval/__init__.py +18 -0
- agentforge/eval/consistency.py +107 -0
- agentforge/eval/coverage.py +100 -0
- agentforge/eval/format_compliance.py +107 -0
- agentforge/eval/regression.py +143 -0
- agentforge/findings.py +166 -0
- agentforge/guardrails/__init__.py +32 -0
- agentforge/guardrails/allowlist.py +49 -0
- agentforge/guardrails/capability_check.py +58 -0
- agentforge/guardrails/engine.py +289 -0
- agentforge/guardrails/pii_redact_basic.py +61 -0
- agentforge/guardrails/prompt_injection_basic.py +90 -0
- agentforge/memory/__init__.py +16 -0
- agentforge/memory/in_memory.py +130 -0
- agentforge/memory/in_memory_graph.py +262 -0
- agentforge/memory/in_memory_vector.py +167 -0
- agentforge/pipeline/__init__.py +26 -0
- agentforge/pipeline/engine.py +189 -0
- agentforge/pipeline/errors.py +19 -0
- agentforge/pipeline/tool.py +93 -0
- agentforge/py.typed +0 -0
- agentforge/recording.py +189 -0
- agentforge/renderers/__init__.py +28 -0
- agentforge/renderers/_defaults.py +32 -0
- agentforge/renderers/markdown.py +44 -0
- agentforge/renderers/patch_applier.py +46 -0
- agentforge/renderers/registry.py +108 -0
- agentforge/renderers/scorecard.py +59 -0
- agentforge/renderers/span_table.py +71 -0
- agentforge/replay.py +260 -0
- agentforge/resolver_register.py +41 -0
- agentforge/retrieval.py +410 -0
- agentforge/runtime.py +63 -0
- agentforge/strategies/__init__.py +27 -0
- agentforge/strategies/_base.py +280 -0
- agentforge/strategies/_plan.py +93 -0
- agentforge/strategies/multi_agent.py +541 -0
- agentforge/strategies/plan_execute.py +506 -0
- agentforge/strategies/react.py +237 -0
- agentforge/strategies/tot.py +472 -0
- agentforge/templates/_shared/.cursorrules +12 -0
- agentforge/templates/_shared/.github/copilot-instructions.md +13 -0
- agentforge/templates/_shared/.gitkeep +0 -0
- agentforge/templates/_shared/AGENTS.md.tmpl +123 -0
- agentforge/templates/_shared/CLAUDE.md +13 -0
- agentforge/templates/_shared/docs/runbooks/01-set-up-new-agent.md.tmpl +67 -0
- agentforge/templates/_shared/docs/runbooks/02-add-a-tool.md +67 -0
- agentforge/templates/_shared/docs/runbooks/03-add-a-pipeline-task.md +69 -0
- agentforge/templates/_shared/docs/runbooks/04-pick-reasoning-strategy.md +67 -0
- agentforge/templates/_shared/docs/runbooks/05-write-prompts.md +75 -0
- agentforge/templates/_shared/docs/runbooks/06-test-your-agent.md +75 -0
- agentforge/templates/_shared/docs/runbooks/07-debug-a-run.md +70 -0
- agentforge/templates/_shared/docs/runbooks/08-add-memory.md +75 -0
- agentforge/templates/_shared/docs/runbooks/09-add-mcp.md +78 -0
- agentforge/templates/_shared/docs/runbooks/10-add-evaluators.md +76 -0
- agentforge/templates/_shared/docs/runbooks/11-add-safety-guardrails.md +83 -0
- agentforge/templates/_shared/docs/runbooks/12-add-observability.md +77 -0
- agentforge/templates/_shared/docs/runbooks/13-configure-multi-provider.md +91 -0
- agentforge/templates/_shared/docs/runbooks/14-deploy-your-agent.md +70 -0
- agentforge/templates/_shared/docs/runbooks/15-upgrade-your-agent.md +67 -0
- agentforge/templates/_shared/docs/runbooks/16-configuration-reference.md +81 -0
- agentforge/templates/_shared/docs/runbooks/17-add-reranker.md +78 -0
- agentforge/templates/_shared/docs/runbooks/18-add-hybrid-search.md +78 -0
- agentforge/templates/_shared/docs/runbooks/19-add-graphrag.md +83 -0
- agentforge/templates/_shared/docs/runbooks/20-apply-schema-migrations.md +92 -0
- agentforge/templates/_shared/docs/runbooks/21-use-streaming-guardrails.md +82 -0
- agentforge/templates/_shared/docs/runbooks/README.md.tmpl +68 -0
- agentforge/templates/code-reviewer/.env.example +8 -0
- agentforge/templates/code-reviewer/.gitignore +7 -0
- agentforge/templates/code-reviewer/README.md +12 -0
- agentforge/templates/code-reviewer/agentforge.yaml +23 -0
- agentforge/templates/code-reviewer/copier.yml +34 -0
- agentforge/templates/code-reviewer/pyproject.toml +18 -0
- agentforge/templates/code-reviewer/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/code-reviewer/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
- agentforge/templates/docs-qa/.env.example +8 -0
- agentforge/templates/docs-qa/.gitignore +7 -0
- agentforge/templates/docs-qa/README.md +14 -0
- agentforge/templates/docs-qa/agentforge.yaml +19 -0
- agentforge/templates/docs-qa/copier.yml +31 -0
- agentforge/templates/docs-qa/pyproject.toml +18 -0
- agentforge/templates/docs-qa/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/docs-qa/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
- agentforge/templates/minimal/.env.example +11 -0
- agentforge/templates/minimal/.gitignore +10 -0
- agentforge/templates/minimal/README.md +28 -0
- agentforge/templates/minimal/agentforge.yaml +10 -0
- agentforge/templates/minimal/copier.yml +52 -0
- agentforge/templates/minimal/pyproject.toml +18 -0
- agentforge/templates/minimal/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/minimal/src/{{project_slug.replace('-', '_')}}/main.py +34 -0
- agentforge/templates/patch-bot/.env.example +8 -0
- agentforge/templates/patch-bot/.gitignore +7 -0
- agentforge/templates/patch-bot/README.md +13 -0
- agentforge/templates/patch-bot/agentforge.yaml +15 -0
- agentforge/templates/patch-bot/copier.yml +31 -0
- agentforge/templates/patch-bot/pyproject.toml +18 -0
- agentforge/templates/patch-bot/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/patch-bot/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
- agentforge/templates/research/.env.example +8 -0
- agentforge/templates/research/.gitignore +7 -0
- agentforge/templates/research/README.md +14 -0
- agentforge/templates/research/agentforge.yaml +17 -0
- agentforge/templates/research/copier.yml +31 -0
- agentforge/templates/research/pyproject.toml +18 -0
- agentforge/templates/research/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/research/src/{{project_slug.replace('-', '_')}}/main.py +31 -0
- agentforge/templates/triage/.env.example +8 -0
- agentforge/templates/triage/.gitignore +7 -0
- agentforge/templates/triage/README.md +14 -0
- agentforge/templates/triage/agentforge.yaml +25 -0
- agentforge/templates/triage/copier.yml +31 -0
- agentforge/templates/triage/pyproject.toml +18 -0
- agentforge/templates/triage/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/triage/src/{{project_slug.replace('-', '_')}}/main.py +30 -0
- agentforge/testing/__init__.py +69 -0
- agentforge/testing/conformance.py +40 -0
- agentforge/testing/factory.py +89 -0
- agentforge/testing/fixtures.py +42 -0
- agentforge/testing/llm.py +235 -0
- agentforge/testing/recording.py +177 -0
- agentforge/tools/__init__.py +41 -0
- agentforge_py-0.2.1.dist-info/METADATA +158 -0
- agentforge_py-0.2.1.dist-info/RECORD +157 -0
- agentforge_py-0.2.1.dist-info/WHEEL +4 -0
- agentforge_py-0.2.1.dist-info/entry_points.txt +2 -0
- agentforge_py-0.2.1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
"""`agentforge add/remove/swap module` commands (feat-010b).
|
|
2
|
+
|
|
3
|
+
These are the destructive CLI commands deferred from feat-010
|
|
4
|
+
PR #16. They edit `agentforge.yaml`, apply per-module manifest
|
|
5
|
+
files, and shell out to `pip install` / `pip uninstall`.
|
|
6
|
+
|
|
7
|
+
The pip subprocess is injected via the `pip_run` callable so tests
|
|
8
|
+
can mock it without actually installing packages. Production uses
|
|
9
|
+
`python -m pip` in the active venv.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import argparse
|
|
15
|
+
import subprocess # nosec B404
|
|
16
|
+
import sys
|
|
17
|
+
from collections.abc import Callable, Sequence
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import yaml
|
|
22
|
+
from agentforge_core.production.exceptions import ModuleError
|
|
23
|
+
from agentforge_core.values.manifest import Manifest
|
|
24
|
+
|
|
25
|
+
from agentforge.cli.manifest_apply import (
|
|
26
|
+
apply_manifest,
|
|
27
|
+
read_applied,
|
|
28
|
+
reverse_manifest,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
PipRunner = Callable[[Sequence[str]], int]
|
|
32
|
+
"""Signature: `runner(["install", "agentforge-X"]) -> exit_code`."""
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def register_module_cmd(sub: argparse._SubParsersAction) -> None: # type: ignore[type-arg]
|
|
36
|
+
"""Attach `agentforge add/remove/swap module` to the parent
|
|
37
|
+
subparser action."""
|
|
38
|
+
# `agentforge add module X` — slot into a nested `add` subparser
|
|
39
|
+
# so we have room for `agentforge add tool`, etc. later.
|
|
40
|
+
add = sub.add_parser("add", help="Install + wire a module into this agent.")
|
|
41
|
+
add_sub = add.add_subparsers(dest="add_target", required=True)
|
|
42
|
+
add_mod = add_sub.add_parser("module", help="Install + apply a module manifest.")
|
|
43
|
+
add_mod.add_argument(
|
|
44
|
+
"distribution", help="Module distribution (e.g. agentforge-memory-postgres)."
|
|
45
|
+
)
|
|
46
|
+
add_mod.set_defaults(_handler=_run_add_module)
|
|
47
|
+
|
|
48
|
+
remove = sub.add_parser("remove", help="Remove a module from this agent.")
|
|
49
|
+
remove_sub = remove.add_subparsers(dest="remove_target", required=True)
|
|
50
|
+
rm_mod = remove_sub.add_parser("module", help="Reverse + uninstall a module.")
|
|
51
|
+
rm_mod.add_argument("distribution", help="Module distribution to remove.")
|
|
52
|
+
rm_mod.set_defaults(_handler=_run_remove_module)
|
|
53
|
+
|
|
54
|
+
swap = sub.add_parser(
|
|
55
|
+
"swap",
|
|
56
|
+
help="Replace one module with another in the same category.",
|
|
57
|
+
)
|
|
58
|
+
swap.add_argument("category", help="Module category (memory, providers, etc.).")
|
|
59
|
+
swap.add_argument("from_dist", metavar="FROM", help="Distribution to remove.")
|
|
60
|
+
swap.add_argument("to_dist", metavar="TO", help="Distribution to install + apply.")
|
|
61
|
+
swap.set_defaults(_handler=_run_swap)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
# ----------------------------------------------------------------------
|
|
65
|
+
# add module
|
|
66
|
+
# ----------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _run_add_module(
|
|
70
|
+
args: argparse.Namespace,
|
|
71
|
+
*,
|
|
72
|
+
pip_run: PipRunner | None = None,
|
|
73
|
+
cwd: Path | None = None,
|
|
74
|
+
package_root: Path | None = None,
|
|
75
|
+
) -> int:
|
|
76
|
+
"""Install a module via pip + apply its manifest.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
pip_run: Injected pip runner; defaults to `python -m pip`.
|
|
80
|
+
cwd: Working directory; defaults to `Path.cwd()`.
|
|
81
|
+
package_root: For tests — skip the importlib.resources lookup
|
|
82
|
+
and read manifest + templates from this directory.
|
|
83
|
+
"""
|
|
84
|
+
runner = pip_run if pip_run is not None else _default_pip_runner
|
|
85
|
+
work_dir = cwd if cwd is not None else Path.cwd()
|
|
86
|
+
distribution = args.distribution
|
|
87
|
+
|
|
88
|
+
sys.stdout.write(f" → installing {distribution}\n")
|
|
89
|
+
code = runner(["install", distribution])
|
|
90
|
+
if code != 0:
|
|
91
|
+
sys.stderr.write(f"pip install {distribution} failed (exit {code})\n")
|
|
92
|
+
return code
|
|
93
|
+
|
|
94
|
+
try:
|
|
95
|
+
manifest = _load_manifest(distribution, package_root=package_root)
|
|
96
|
+
except ModuleError as exc:
|
|
97
|
+
sys.stderr.write(f"manifest load failed: {exc}\n")
|
|
98
|
+
return 1
|
|
99
|
+
|
|
100
|
+
if read_applied(work_dir, distribution) is not None:
|
|
101
|
+
sys.stdout.write(" → already applied (state file present); skipping\n")
|
|
102
|
+
_print_next_steps(manifest)
|
|
103
|
+
return 0
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
apply_manifest(
|
|
107
|
+
manifest,
|
|
108
|
+
distribution=distribution,
|
|
109
|
+
cwd=work_dir,
|
|
110
|
+
package_root=package_root,
|
|
111
|
+
)
|
|
112
|
+
except ModuleError as exc:
|
|
113
|
+
sys.stderr.write(f"manifest apply failed: {exc}\n")
|
|
114
|
+
return 1
|
|
115
|
+
|
|
116
|
+
sys.stdout.write(f" → applied manifest for {distribution}\n")
|
|
117
|
+
_print_next_steps(manifest)
|
|
118
|
+
return 0
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# ----------------------------------------------------------------------
|
|
122
|
+
# remove module
|
|
123
|
+
# ----------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _run_remove_module(
|
|
127
|
+
args: argparse.Namespace,
|
|
128
|
+
*,
|
|
129
|
+
pip_run: PipRunner | None = None,
|
|
130
|
+
cwd: Path | None = None,
|
|
131
|
+
package_root: Path | None = None,
|
|
132
|
+
) -> int:
|
|
133
|
+
runner = pip_run if pip_run is not None else _default_pip_runner
|
|
134
|
+
work_dir = cwd if cwd is not None else Path.cwd()
|
|
135
|
+
distribution = args.distribution
|
|
136
|
+
|
|
137
|
+
applied = read_applied(work_dir, distribution)
|
|
138
|
+
if applied is None:
|
|
139
|
+
state_dir = work_dir / ".agentforge-state"
|
|
140
|
+
sys.stderr.write(
|
|
141
|
+
f"No applied state for {distribution} in {state_dir}; nothing to remove.\n"
|
|
142
|
+
)
|
|
143
|
+
return 1
|
|
144
|
+
|
|
145
|
+
# The reverse needs the manifest's config_block (state stores only
|
|
146
|
+
# what landed, not the original block). Try to read the manifest
|
|
147
|
+
# from the still-installed package; fall back to "skip config-block
|
|
148
|
+
# reverse" if the package is already gone.
|
|
149
|
+
config_block: dict[str, Any] = {}
|
|
150
|
+
try:
|
|
151
|
+
manifest = _load_manifest(distribution, package_root=package_root)
|
|
152
|
+
config_block = manifest.config_block
|
|
153
|
+
except ModuleError:
|
|
154
|
+
# Module already uninstalled / manifest gone. Reverse what we can.
|
|
155
|
+
config_block = {}
|
|
156
|
+
|
|
157
|
+
reverse_manifest(applied, cwd=work_dir, config_block=config_block)
|
|
158
|
+
sys.stdout.write(f" → reversed manifest for {distribution}\n")
|
|
159
|
+
|
|
160
|
+
sys.stdout.write(f" → uninstalling {distribution}\n")
|
|
161
|
+
code = runner(["uninstall", "-y", distribution])
|
|
162
|
+
if code != 0:
|
|
163
|
+
sys.stderr.write(f"pip uninstall {distribution} failed (exit {code})\n")
|
|
164
|
+
return code
|
|
165
|
+
sys.stdout.write(" → done.\n")
|
|
166
|
+
return 0
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ----------------------------------------------------------------------
|
|
170
|
+
# swap
|
|
171
|
+
# ----------------------------------------------------------------------
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _run_swap(
|
|
175
|
+
args: argparse.Namespace,
|
|
176
|
+
*,
|
|
177
|
+
pip_run: PipRunner | None = None,
|
|
178
|
+
cwd: Path | None = None,
|
|
179
|
+
package_root: Path | None = None,
|
|
180
|
+
) -> int:
|
|
181
|
+
"""`agentforge swap <category> <from> <to>` — remove + add atomic-ish.
|
|
182
|
+
|
|
183
|
+
Not transactional: if `add` fails after `remove` succeeded, the
|
|
184
|
+
agent is left without either module. Documented in the runbook.
|
|
185
|
+
"""
|
|
186
|
+
# Build a fake namespace for the underlying remove + add calls.
|
|
187
|
+
remove_ns = argparse.Namespace(distribution=args.from_dist)
|
|
188
|
+
add_ns = argparse.Namespace(distribution=args.to_dist)
|
|
189
|
+
sys.stdout.write(f" → swap: removing {args.from_dist}, installing {args.to_dist}\n")
|
|
190
|
+
code = _run_remove_module(remove_ns, pip_run=pip_run, cwd=cwd, package_root=package_root)
|
|
191
|
+
if code != 0:
|
|
192
|
+
return code
|
|
193
|
+
return _run_add_module(add_ns, pip_run=pip_run, cwd=cwd, package_root=package_root)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# ----------------------------------------------------------------------
|
|
197
|
+
# Helpers
|
|
198
|
+
# ----------------------------------------------------------------------
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _load_manifest(distribution: str, *, package_root: Path | None) -> Manifest:
|
|
202
|
+
"""Load `<package>/manifest.yaml` for `distribution`.
|
|
203
|
+
|
|
204
|
+
Args:
|
|
205
|
+
package_root: When set, read `manifest.yaml` from this directory
|
|
206
|
+
(test injection). Otherwise read via `importlib.resources`.
|
|
207
|
+
"""
|
|
208
|
+
if package_root is not None:
|
|
209
|
+
path = package_root / "manifest.yaml"
|
|
210
|
+
if not path.exists():
|
|
211
|
+
raise ModuleError(f"manifest.yaml not found in package_root {package_root}.")
|
|
212
|
+
with path.open() as fh:
|
|
213
|
+
raw = yaml.safe_load(fh) or {}
|
|
214
|
+
return Manifest.model_validate(raw)
|
|
215
|
+
|
|
216
|
+
from importlib import resources # noqa: PLC0415 — lazy import
|
|
217
|
+
|
|
218
|
+
package_name = distribution.replace("-", "_")
|
|
219
|
+
try:
|
|
220
|
+
package_files = resources.files(package_name)
|
|
221
|
+
except (ModuleNotFoundError, TypeError) as exc:
|
|
222
|
+
raise ModuleError(f"Cannot locate package files for {package_name!r}: {exc}.") from exc
|
|
223
|
+
manifest_resource = package_files.joinpath("manifest.yaml")
|
|
224
|
+
try:
|
|
225
|
+
text = manifest_resource.read_text(encoding="utf-8")
|
|
226
|
+
except FileNotFoundError as exc:
|
|
227
|
+
raise ModuleError(
|
|
228
|
+
f"{package_name} does not ship a manifest.yaml — cannot `add` it."
|
|
229
|
+
) from exc
|
|
230
|
+
raw = yaml.safe_load(text) or {}
|
|
231
|
+
return Manifest.model_validate(raw)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _print_next_steps(manifest: Manifest) -> None:
|
|
235
|
+
if not manifest.next_steps:
|
|
236
|
+
return
|
|
237
|
+
sys.stdout.write(" Next:\n")
|
|
238
|
+
for step in manifest.next_steps:
|
|
239
|
+
sys.stdout.write(f" - {step}\n")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _default_pip_runner(args: Sequence[str]) -> int:
|
|
243
|
+
"""Run `python -m pip <args>` in the active venv."""
|
|
244
|
+
cmd = [sys.executable, "-m", "pip", *args]
|
|
245
|
+
# No untrusted input — args is built from CLI arg `distribution`
|
|
246
|
+
# which is just a distribution name string. shell=False (default).
|
|
247
|
+
return subprocess.run(cmd, check=False).returncode # noqa: S603 # nosec B603
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""`agentforge new <name>` — scaffold a new agent from a template.
|
|
2
|
+
|
|
3
|
+
feat-011 ships six templates inside `agentforge/templates/<name>/`
|
|
4
|
+
(see Implementation status §4.4 — local templates instead of the
|
|
5
|
+
spec's separate-repo design; migration to `agentforge-templates`
|
|
6
|
+
is a 0.4+ follow-up).
|
|
7
|
+
|
|
8
|
+
Copier handles the render; this module is the CLI wrapper +
|
|
9
|
+
template resolution. The lock file + marker headers are written
|
|
10
|
+
post-render in chunk 3.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import argparse
|
|
16
|
+
import sys
|
|
17
|
+
from collections.abc import Sequence
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
|
|
20
|
+
from agentforge_core.production.exceptions import ModuleError
|
|
21
|
+
|
|
22
|
+
from agentforge.cli._scaffold_state import (
|
|
23
|
+
prepend_markers,
|
|
24
|
+
write_managed_files_lock,
|
|
25
|
+
)
|
|
26
|
+
from agentforge.cli._shared_scaffold import inject_shared_scaffold
|
|
27
|
+
|
|
28
|
+
_TEMPLATES = ("minimal", "code-reviewer", "patch-bot", "docs-qa", "triage", "research")
|
|
29
|
+
"""Templates shipped with the framework — discoverable via
|
|
30
|
+
`agentforge new --help`. Each lives at
|
|
31
|
+
`agentforge/templates/<name>/`."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def register_new_cmd(sub: argparse._SubParsersAction) -> None: # type: ignore[type-arg]
|
|
35
|
+
"""Attach `agentforge new` to the parent subparser action."""
|
|
36
|
+
new = sub.add_parser(
|
|
37
|
+
"new",
|
|
38
|
+
help="Scaffold a new AgentForge project from a template.",
|
|
39
|
+
)
|
|
40
|
+
new.add_argument(
|
|
41
|
+
"project_slug",
|
|
42
|
+
help="Project name / directory (kebab-case, e.g. 'my-pr-reviewer').",
|
|
43
|
+
)
|
|
44
|
+
new.add_argument(
|
|
45
|
+
"--template",
|
|
46
|
+
choices=_TEMPLATES,
|
|
47
|
+
default="minimal",
|
|
48
|
+
help="Template to scaffold from (default: minimal).",
|
|
49
|
+
)
|
|
50
|
+
new.add_argument(
|
|
51
|
+
"--provider",
|
|
52
|
+
choices=("bedrock", "anthropic", "openai"),
|
|
53
|
+
default=None,
|
|
54
|
+
help="LLM provider (bedrock, anthropic, openai). Prompted when --no-prompts is not set.",
|
|
55
|
+
)
|
|
56
|
+
new.add_argument(
|
|
57
|
+
"--no-prompts",
|
|
58
|
+
action="store_true",
|
|
59
|
+
help="Batch mode — use defaults for every Copier question.",
|
|
60
|
+
)
|
|
61
|
+
new.add_argument(
|
|
62
|
+
"--dst",
|
|
63
|
+
type=Path,
|
|
64
|
+
default=None,
|
|
65
|
+
help="Destination directory (default: ./<project_slug>).",
|
|
66
|
+
)
|
|
67
|
+
new.set_defaults(_handler=_run_new)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _run_new(args: argparse.Namespace) -> int:
|
|
71
|
+
"""Render the chosen template into `args.dst` (or `./<slug>`)."""
|
|
72
|
+
template_root = _template_root(args.template)
|
|
73
|
+
if template_root is None:
|
|
74
|
+
sys.stderr.write(
|
|
75
|
+
f"Template {args.template!r} not shipped with this install. "
|
|
76
|
+
f"Known: {', '.join(_TEMPLATES)}.\n"
|
|
77
|
+
)
|
|
78
|
+
return 1
|
|
79
|
+
|
|
80
|
+
dst = args.dst if args.dst is not None else Path.cwd() / args.project_slug
|
|
81
|
+
answers: dict[str, object] = {"project_slug": args.project_slug}
|
|
82
|
+
if args.provider is not None:
|
|
83
|
+
answers["llm_provider"] = args.provider
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
_run_copier(str(template_root), str(dst), answers, defaults=args.no_prompts)
|
|
87
|
+
except ModuleError as exc:
|
|
88
|
+
sys.stderr.write(f"scaffolding failed: {exc}\n")
|
|
89
|
+
return 1
|
|
90
|
+
|
|
91
|
+
# feat-011 chunk 3: write the lock + prepend marker headers. Done
|
|
92
|
+
# post-render so the lock reflects exactly what landed on disk.
|
|
93
|
+
template_version = _template_version()
|
|
94
|
+
write_managed_files_lock(
|
|
95
|
+
dst,
|
|
96
|
+
template_name=args.template,
|
|
97
|
+
template_version=template_version,
|
|
98
|
+
)
|
|
99
|
+
prepend_markers(dst, template_name=args.template, template_version=template_version)
|
|
100
|
+
|
|
101
|
+
# feat-019: inject shared runbooks + AGENTS.md / CLAUDE.md /
|
|
102
|
+
# .cursorrules / .github/copilot-instructions.md into every
|
|
103
|
+
# scaffolded agent.
|
|
104
|
+
shared_count = inject_shared_scaffold(
|
|
105
|
+
dst,
|
|
106
|
+
template_name=args.template,
|
|
107
|
+
template_version=template_version,
|
|
108
|
+
)
|
|
109
|
+
if shared_count:
|
|
110
|
+
sys.stdout.write(f" → wrote {shared_count} shared scaffold files (runbooks + AI rules)\n")
|
|
111
|
+
|
|
112
|
+
sys.stdout.write(f" → done. Next: cd {args.project_slug} && uv sync\n")
|
|
113
|
+
return 0
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _template_version() -> str:
|
|
117
|
+
"""Resolve the installed `agentforge` version — used as the
|
|
118
|
+
template's `source_version` in the lock file."""
|
|
119
|
+
from importlib.metadata import PackageNotFoundError, version # noqa: PLC0415
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
return version("agentforge")
|
|
123
|
+
except PackageNotFoundError: # pragma: no cover
|
|
124
|
+
return "0.0.0+unknown"
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _template_root(name: str) -> Path | None:
|
|
128
|
+
"""Resolve the template directory under `agentforge/templates/`.
|
|
129
|
+
|
|
130
|
+
Uses `importlib.resources` so it works from the installed wheel.
|
|
131
|
+
"""
|
|
132
|
+
from importlib import resources # noqa: PLC0415
|
|
133
|
+
|
|
134
|
+
try:
|
|
135
|
+
root_traversable = resources.files("agentforge.templates").joinpath(name)
|
|
136
|
+
except ModuleNotFoundError:
|
|
137
|
+
return None
|
|
138
|
+
# Materialise to a Path — Copier needs a filesystem path, not a
|
|
139
|
+
# MultiplexedPath. `as_file` returns a context-managed temporary
|
|
140
|
+
# path for zipped distributions; for editable installs (the
|
|
141
|
+
# common case) the underlying path is real.
|
|
142
|
+
with resources.as_file(root_traversable) as path:
|
|
143
|
+
if not path.exists() or not (path / "copier.yml").exists():
|
|
144
|
+
return None
|
|
145
|
+
return Path(path)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _run_copier(
|
|
149
|
+
src: str,
|
|
150
|
+
dst: str,
|
|
151
|
+
data: dict[str, object],
|
|
152
|
+
*,
|
|
153
|
+
defaults: bool,
|
|
154
|
+
) -> None:
|
|
155
|
+
"""Run Copier's render. Wrapped so tests can mock the call."""
|
|
156
|
+
from copier import run_copy # noqa: PLC0415 — lazy to keep import cost off other CLI paths
|
|
157
|
+
|
|
158
|
+
try:
|
|
159
|
+
run_copy(
|
|
160
|
+
src_path=src,
|
|
161
|
+
dst_path=dst,
|
|
162
|
+
data=data,
|
|
163
|
+
defaults=defaults,
|
|
164
|
+
unsafe=False,
|
|
165
|
+
quiet=False,
|
|
166
|
+
)
|
|
167
|
+
except Exception as exc:
|
|
168
|
+
raise ModuleError(f"copier render failed: {exc}") from exc
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
__all__: Sequence[str] = ["register_new_cmd"]
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""`agentforge run` — invoke an Agent against a task (feat-017 chunk 4).
|
|
2
|
+
|
|
3
|
+
Configurable from the command line:
|
|
4
|
+
|
|
5
|
+
agentforge run "Review this PR"
|
|
6
|
+
agentforge run --task-file ./task.txt
|
|
7
|
+
agentforge run --override agent.budget.usd=10 "..."
|
|
8
|
+
agentforge run --output-format json "..."
|
|
9
|
+
agentforge run --replay 01HX... --to-step 5
|
|
10
|
+
agentforge run --record "..." # writes step claims to memory
|
|
11
|
+
|
|
12
|
+
Exit codes (locked in feat-017 §4):
|
|
13
|
+
|
|
14
|
+
0 success
|
|
15
|
+
1 generic error
|
|
16
|
+
2 config invalid
|
|
17
|
+
3 budget exceeded
|
|
18
|
+
4 guardrail tripped
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import argparse
|
|
24
|
+
import asyncio
|
|
25
|
+
import json
|
|
26
|
+
import sys
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any
|
|
29
|
+
|
|
30
|
+
from agentforge_core.production.exceptions import (
|
|
31
|
+
BudgetExceeded,
|
|
32
|
+
GuardrailViolation,
|
|
33
|
+
ModuleError,
|
|
34
|
+
)
|
|
35
|
+
from pydantic import ValidationError
|
|
36
|
+
|
|
37
|
+
from agentforge.agent import Agent
|
|
38
|
+
from agentforge.cli._build import build_agent_from_config, build_memory_from_config
|
|
39
|
+
from agentforge.replay import ReplayLLMClient, load_pipeline_result
|
|
40
|
+
|
|
41
|
+
EXIT_OK = 0
|
|
42
|
+
EXIT_GENERIC = 1
|
|
43
|
+
EXIT_CONFIG_INVALID = 2
|
|
44
|
+
EXIT_BUDGET = 3
|
|
45
|
+
EXIT_GUARDRAIL = 4
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def register_run_cmd(sub: argparse._SubParsersAction) -> None: # type: ignore[type-arg]
|
|
49
|
+
parser = sub.add_parser(
|
|
50
|
+
"run",
|
|
51
|
+
help="Run an agent against a task.",
|
|
52
|
+
description="Run an agent against a task and print its output.",
|
|
53
|
+
)
|
|
54
|
+
parser.add_argument("task", nargs="?", default=None, help="Task text to run.")
|
|
55
|
+
parser.add_argument(
|
|
56
|
+
"--task-file",
|
|
57
|
+
type=Path,
|
|
58
|
+
default=None,
|
|
59
|
+
help="Read the task body from a file (alternative to positional).",
|
|
60
|
+
)
|
|
61
|
+
parser.add_argument(
|
|
62
|
+
"--path",
|
|
63
|
+
type=Path,
|
|
64
|
+
default=None,
|
|
65
|
+
help="Path to agentforge.yaml (defaults to ./agentforge.yaml or $AGENTFORGE_CONFIG).",
|
|
66
|
+
)
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--env",
|
|
69
|
+
default=None,
|
|
70
|
+
help="Override AGENTFORGE_ENV for this run (selects agentforge.<env>.yaml).",
|
|
71
|
+
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
"--override",
|
|
74
|
+
action="append",
|
|
75
|
+
default=[],
|
|
76
|
+
metavar="KEY=VALUE",
|
|
77
|
+
help="Dotted-path config override (repeatable), e.g. agent.budget.usd=5.",
|
|
78
|
+
)
|
|
79
|
+
parser.add_argument(
|
|
80
|
+
"--output-format",
|
|
81
|
+
choices=("rich", "json", "plain"),
|
|
82
|
+
default=None,
|
|
83
|
+
help="How to print the result. Default: rich if stdout is a TTY else plain.",
|
|
84
|
+
)
|
|
85
|
+
parser.add_argument(
|
|
86
|
+
"--replay",
|
|
87
|
+
default=None,
|
|
88
|
+
metavar="RUN_ID",
|
|
89
|
+
help="Replay a previously recorded run instead of calling the LLM.",
|
|
90
|
+
)
|
|
91
|
+
parser.add_argument(
|
|
92
|
+
"--to-step",
|
|
93
|
+
type=int,
|
|
94
|
+
default=None,
|
|
95
|
+
help="Stop replay after this many emitted steps (debug aid).",
|
|
96
|
+
)
|
|
97
|
+
parser.add_argument(
|
|
98
|
+
"--record",
|
|
99
|
+
action="store_true",
|
|
100
|
+
help="Persist this run's steps + result to the configured memory store.",
|
|
101
|
+
)
|
|
102
|
+
parser.set_defaults(_handler=_run_handler)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _run_handler(args: argparse.Namespace) -> int:
|
|
106
|
+
return asyncio.run(_dispatch(args))
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
async def _dispatch(args: argparse.Namespace) -> int:
|
|
110
|
+
task = _resolve_task(args)
|
|
111
|
+
if task is None:
|
|
112
|
+
sys.stderr.write("agentforge run: must provide a task or --task-file.\n")
|
|
113
|
+
return EXIT_GENERIC
|
|
114
|
+
|
|
115
|
+
config_or_code = _load_config_or_exit(args)
|
|
116
|
+
if isinstance(config_or_code, int):
|
|
117
|
+
return config_or_code
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
agent, replay_pipeline = await _build_for_run(args, config_or_code)
|
|
121
|
+
except ModuleError as exc:
|
|
122
|
+
sys.stderr.write(f"agentforge run: failed to construct agent: {exc}\n")
|
|
123
|
+
return EXIT_GENERIC
|
|
124
|
+
|
|
125
|
+
return await _run_and_emit(agent, task, args.output_format, replay_pipeline=replay_pipeline)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _load_config_or_exit(args: argparse.Namespace) -> Any:
|
|
129
|
+
"""Load config; return the config object or an exit code int."""
|
|
130
|
+
from agentforge_core.config.loader import load_config # noqa: PLC0415
|
|
131
|
+
|
|
132
|
+
try:
|
|
133
|
+
return load_config(args.path, env=args.env, overrides=list(args.override) or None)
|
|
134
|
+
except ValidationError as exc:
|
|
135
|
+
sys.stderr.write(f"agentforge run: config invalid:\n{exc}\n")
|
|
136
|
+
return EXIT_CONFIG_INVALID
|
|
137
|
+
except ModuleError as exc:
|
|
138
|
+
sys.stderr.write(f"agentforge run: {exc}\n")
|
|
139
|
+
return EXIT_CONFIG_INVALID
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
async def _run_and_emit(
|
|
143
|
+
agent: Agent,
|
|
144
|
+
task: str,
|
|
145
|
+
output_format: str | None,
|
|
146
|
+
*,
|
|
147
|
+
replay_pipeline: Any | None = None,
|
|
148
|
+
) -> int:
|
|
149
|
+
try:
|
|
150
|
+
result = await agent.run(task, replay_pipeline=replay_pipeline)
|
|
151
|
+
except BudgetExceeded as exc:
|
|
152
|
+
sys.stderr.write(f"agentforge run: budget exceeded: {exc}\n")
|
|
153
|
+
return EXIT_BUDGET
|
|
154
|
+
except GuardrailViolation as exc:
|
|
155
|
+
sys.stderr.write(f"agentforge run: guardrail tripped: {exc}\n")
|
|
156
|
+
return EXIT_GUARDRAIL
|
|
157
|
+
except ModuleError as exc:
|
|
158
|
+
sys.stderr.write(f"agentforge run: {exc}\n")
|
|
159
|
+
return EXIT_GENERIC
|
|
160
|
+
_emit(result, output_format)
|
|
161
|
+
return EXIT_OK
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
async def _build_for_run(args: argparse.Namespace, config: Any) -> tuple[Agent, Any | None]:
|
|
165
|
+
"""Wire an Agent, optionally substituting the LLM with a replay client.
|
|
166
|
+
|
|
167
|
+
Returns ``(agent, replay_pipeline)`` — the second tuple item is a
|
|
168
|
+
previously recorded `PipelineResult` (or ``None``) that the run
|
|
169
|
+
handler threads into `Agent.run(replay_pipeline=...)` so a
|
|
170
|
+
side-effect-bearing pipeline doesn't re-execute on replay.
|
|
171
|
+
"""
|
|
172
|
+
if args.replay is not None:
|
|
173
|
+
memory = build_memory_from_config(config)
|
|
174
|
+
if memory is None:
|
|
175
|
+
msg = "--replay requires modules.memory to be configured."
|
|
176
|
+
raise ModuleError(msg)
|
|
177
|
+
replay_llm = await ReplayLLMClient.from_recording(memory, args.replay)
|
|
178
|
+
replay_pipeline = await load_pipeline_result(memory, args.replay)
|
|
179
|
+
return Agent(model=replay_llm, memory=memory), replay_pipeline
|
|
180
|
+
return await build_agent_from_config(config, enable_recording=args.record), None
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def _resolve_task(args: argparse.Namespace) -> str | None:
|
|
184
|
+
if args.task is not None and args.task_file is not None:
|
|
185
|
+
msg = "agentforge run: pass either positional task or --task-file, not both.\n"
|
|
186
|
+
sys.stderr.write(msg)
|
|
187
|
+
return None
|
|
188
|
+
if args.task is not None:
|
|
189
|
+
return str(args.task)
|
|
190
|
+
if args.task_file is not None:
|
|
191
|
+
return Path(args.task_file).read_text(encoding="utf-8").strip()
|
|
192
|
+
return None
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
def _emit(result: Any, output_format: str | None) -> None:
|
|
196
|
+
fmt = output_format or ("rich" if sys.stdout.isatty() else "plain")
|
|
197
|
+
if fmt == "json":
|
|
198
|
+
print(json.dumps(result.model_dump(mode="json"), indent=2))
|
|
199
|
+
return
|
|
200
|
+
if fmt == "rich":
|
|
201
|
+
_print_rich(result)
|
|
202
|
+
return
|
|
203
|
+
# Plain — just the output.
|
|
204
|
+
output = result.output
|
|
205
|
+
if isinstance(output, dict):
|
|
206
|
+
print(json.dumps(output))
|
|
207
|
+
else:
|
|
208
|
+
print(output)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _print_rich(result: Any) -> None:
|
|
212
|
+
try:
|
|
213
|
+
from rich.console import Console # noqa: PLC0415
|
|
214
|
+
from rich.table import Table # noqa: PLC0415
|
|
215
|
+
except ImportError:
|
|
216
|
+
# Rich not installed — fall back to plain.
|
|
217
|
+
_emit(result, "plain")
|
|
218
|
+
return
|
|
219
|
+
console = Console()
|
|
220
|
+
table = Table(title="Run summary", show_header=True)
|
|
221
|
+
table.add_column("Field")
|
|
222
|
+
table.add_column("Value")
|
|
223
|
+
table.add_row("run_id", result.run_id)
|
|
224
|
+
table.add_row("finish_reason", str(result.finish_reason))
|
|
225
|
+
table.add_row("steps", str(len(result.steps)))
|
|
226
|
+
table.add_row("cost_usd", f"{result.cost_usd:.4f}")
|
|
227
|
+
table.add_row("tokens_in/out", f"{result.tokens_in} / {result.tokens_out}")
|
|
228
|
+
table.add_row("duration_ms", str(result.duration_ms))
|
|
229
|
+
console.print(table)
|
|
230
|
+
console.print()
|
|
231
|
+
console.print(result.output)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
__all__ = ["register_run_cmd"]
|