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.
Files changed (157) hide show
  1. agentforge/__init__.py +114 -0
  2. agentforge/_testing/__init__.py +19 -0
  3. agentforge/_testing/fake_llm.py +126 -0
  4. agentforge/_testing/fake_tool.py +122 -0
  5. agentforge/_tools/__init__.py +14 -0
  6. agentforge/_tools/calculator.py +102 -0
  7. agentforge/_tools/decorator.py +300 -0
  8. agentforge/_tools/file_read.py +112 -0
  9. agentforge/_tools/shell.py +134 -0
  10. agentforge/_tools/web_search.py +207 -0
  11. agentforge/agent.py +817 -0
  12. agentforge/auth.py +42 -0
  13. agentforge/cli/__init__.py +18 -0
  14. agentforge/cli/_build.py +323 -0
  15. agentforge/cli/_scaffold_state.py +250 -0
  16. agentforge/cli/_shared_scaffold.py +174 -0
  17. agentforge/cli/config_cmd.py +174 -0
  18. agentforge/cli/db_cmd.py +262 -0
  19. agentforge/cli/debug_cmd.py +168 -0
  20. agentforge/cli/docs_cmd.py +217 -0
  21. agentforge/cli/eval_cmd.py +181 -0
  22. agentforge/cli/health_cmd.py +139 -0
  23. agentforge/cli/list_modules.py +85 -0
  24. agentforge/cli/main.py +81 -0
  25. agentforge/cli/manifest_apply.py +368 -0
  26. agentforge/cli/module_cmd.py +247 -0
  27. agentforge/cli/new_cmd.py +171 -0
  28. agentforge/cli/run_cmd.py +234 -0
  29. agentforge/cli/upgrade_cmd.py +230 -0
  30. agentforge/config/__init__.py +45 -0
  31. agentforge/eval/__init__.py +18 -0
  32. agentforge/eval/consistency.py +107 -0
  33. agentforge/eval/coverage.py +100 -0
  34. agentforge/eval/format_compliance.py +107 -0
  35. agentforge/eval/regression.py +143 -0
  36. agentforge/findings.py +166 -0
  37. agentforge/guardrails/__init__.py +32 -0
  38. agentforge/guardrails/allowlist.py +49 -0
  39. agentforge/guardrails/capability_check.py +58 -0
  40. agentforge/guardrails/engine.py +289 -0
  41. agentforge/guardrails/pii_redact_basic.py +61 -0
  42. agentforge/guardrails/prompt_injection_basic.py +90 -0
  43. agentforge/memory/__init__.py +16 -0
  44. agentforge/memory/in_memory.py +130 -0
  45. agentforge/memory/in_memory_graph.py +262 -0
  46. agentforge/memory/in_memory_vector.py +167 -0
  47. agentforge/pipeline/__init__.py +26 -0
  48. agentforge/pipeline/engine.py +189 -0
  49. agentforge/pipeline/errors.py +19 -0
  50. agentforge/pipeline/tool.py +93 -0
  51. agentforge/py.typed +0 -0
  52. agentforge/recording.py +189 -0
  53. agentforge/renderers/__init__.py +28 -0
  54. agentforge/renderers/_defaults.py +32 -0
  55. agentforge/renderers/markdown.py +44 -0
  56. agentforge/renderers/patch_applier.py +46 -0
  57. agentforge/renderers/registry.py +108 -0
  58. agentforge/renderers/scorecard.py +59 -0
  59. agentforge/renderers/span_table.py +71 -0
  60. agentforge/replay.py +260 -0
  61. agentforge/resolver_register.py +41 -0
  62. agentforge/retrieval.py +410 -0
  63. agentforge/runtime.py +63 -0
  64. agentforge/strategies/__init__.py +27 -0
  65. agentforge/strategies/_base.py +280 -0
  66. agentforge/strategies/_plan.py +93 -0
  67. agentforge/strategies/multi_agent.py +541 -0
  68. agentforge/strategies/plan_execute.py +506 -0
  69. agentforge/strategies/react.py +237 -0
  70. agentforge/strategies/tot.py +472 -0
  71. agentforge/templates/_shared/.cursorrules +12 -0
  72. agentforge/templates/_shared/.github/copilot-instructions.md +13 -0
  73. agentforge/templates/_shared/.gitkeep +0 -0
  74. agentforge/templates/_shared/AGENTS.md.tmpl +123 -0
  75. agentforge/templates/_shared/CLAUDE.md +13 -0
  76. agentforge/templates/_shared/docs/runbooks/01-set-up-new-agent.md.tmpl +67 -0
  77. agentforge/templates/_shared/docs/runbooks/02-add-a-tool.md +67 -0
  78. agentforge/templates/_shared/docs/runbooks/03-add-a-pipeline-task.md +69 -0
  79. agentforge/templates/_shared/docs/runbooks/04-pick-reasoning-strategy.md +67 -0
  80. agentforge/templates/_shared/docs/runbooks/05-write-prompts.md +75 -0
  81. agentforge/templates/_shared/docs/runbooks/06-test-your-agent.md +75 -0
  82. agentforge/templates/_shared/docs/runbooks/07-debug-a-run.md +70 -0
  83. agentforge/templates/_shared/docs/runbooks/08-add-memory.md +75 -0
  84. agentforge/templates/_shared/docs/runbooks/09-add-mcp.md +78 -0
  85. agentforge/templates/_shared/docs/runbooks/10-add-evaluators.md +76 -0
  86. agentforge/templates/_shared/docs/runbooks/11-add-safety-guardrails.md +83 -0
  87. agentforge/templates/_shared/docs/runbooks/12-add-observability.md +77 -0
  88. agentforge/templates/_shared/docs/runbooks/13-configure-multi-provider.md +91 -0
  89. agentforge/templates/_shared/docs/runbooks/14-deploy-your-agent.md +70 -0
  90. agentforge/templates/_shared/docs/runbooks/15-upgrade-your-agent.md +67 -0
  91. agentforge/templates/_shared/docs/runbooks/16-configuration-reference.md +81 -0
  92. agentforge/templates/_shared/docs/runbooks/17-add-reranker.md +78 -0
  93. agentforge/templates/_shared/docs/runbooks/18-add-hybrid-search.md +78 -0
  94. agentforge/templates/_shared/docs/runbooks/19-add-graphrag.md +83 -0
  95. agentforge/templates/_shared/docs/runbooks/20-apply-schema-migrations.md +92 -0
  96. agentforge/templates/_shared/docs/runbooks/21-use-streaming-guardrails.md +82 -0
  97. agentforge/templates/_shared/docs/runbooks/README.md.tmpl +68 -0
  98. agentforge/templates/code-reviewer/.env.example +8 -0
  99. agentforge/templates/code-reviewer/.gitignore +7 -0
  100. agentforge/templates/code-reviewer/README.md +12 -0
  101. agentforge/templates/code-reviewer/agentforge.yaml +23 -0
  102. agentforge/templates/code-reviewer/copier.yml +34 -0
  103. agentforge/templates/code-reviewer/pyproject.toml +18 -0
  104. agentforge/templates/code-reviewer/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  105. agentforge/templates/code-reviewer/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
  106. agentforge/templates/docs-qa/.env.example +8 -0
  107. agentforge/templates/docs-qa/.gitignore +7 -0
  108. agentforge/templates/docs-qa/README.md +14 -0
  109. agentforge/templates/docs-qa/agentforge.yaml +19 -0
  110. agentforge/templates/docs-qa/copier.yml +31 -0
  111. agentforge/templates/docs-qa/pyproject.toml +18 -0
  112. agentforge/templates/docs-qa/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  113. agentforge/templates/docs-qa/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
  114. agentforge/templates/minimal/.env.example +11 -0
  115. agentforge/templates/minimal/.gitignore +10 -0
  116. agentforge/templates/minimal/README.md +28 -0
  117. agentforge/templates/minimal/agentforge.yaml +10 -0
  118. agentforge/templates/minimal/copier.yml +52 -0
  119. agentforge/templates/minimal/pyproject.toml +18 -0
  120. agentforge/templates/minimal/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  121. agentforge/templates/minimal/src/{{project_slug.replace('-', '_')}}/main.py +34 -0
  122. agentforge/templates/patch-bot/.env.example +8 -0
  123. agentforge/templates/patch-bot/.gitignore +7 -0
  124. agentforge/templates/patch-bot/README.md +13 -0
  125. agentforge/templates/patch-bot/agentforge.yaml +15 -0
  126. agentforge/templates/patch-bot/copier.yml +31 -0
  127. agentforge/templates/patch-bot/pyproject.toml +18 -0
  128. agentforge/templates/patch-bot/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  129. agentforge/templates/patch-bot/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
  130. agentforge/templates/research/.env.example +8 -0
  131. agentforge/templates/research/.gitignore +7 -0
  132. agentforge/templates/research/README.md +14 -0
  133. agentforge/templates/research/agentforge.yaml +17 -0
  134. agentforge/templates/research/copier.yml +31 -0
  135. agentforge/templates/research/pyproject.toml +18 -0
  136. agentforge/templates/research/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  137. agentforge/templates/research/src/{{project_slug.replace('-', '_')}}/main.py +31 -0
  138. agentforge/templates/triage/.env.example +8 -0
  139. agentforge/templates/triage/.gitignore +7 -0
  140. agentforge/templates/triage/README.md +14 -0
  141. agentforge/templates/triage/agentforge.yaml +25 -0
  142. agentforge/templates/triage/copier.yml +31 -0
  143. agentforge/templates/triage/pyproject.toml +18 -0
  144. agentforge/templates/triage/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
  145. agentforge/templates/triage/src/{{project_slug.replace('-', '_')}}/main.py +30 -0
  146. agentforge/testing/__init__.py +69 -0
  147. agentforge/testing/conformance.py +40 -0
  148. agentforge/testing/factory.py +89 -0
  149. agentforge/testing/fixtures.py +42 -0
  150. agentforge/testing/llm.py +235 -0
  151. agentforge/testing/recording.py +177 -0
  152. agentforge/tools/__init__.py +41 -0
  153. agentforge_py-0.2.1.dist-info/METADATA +158 -0
  154. agentforge_py-0.2.1.dist-info/RECORD +157 -0
  155. agentforge_py-0.2.1.dist-info/WHEEL +4 -0
  156. agentforge_py-0.2.1.dist-info/entry_points.txt +2 -0
  157. 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"]