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,174 @@
|
|
|
1
|
+
"""Shared scaffold injection (feat-019).
|
|
2
|
+
|
|
3
|
+
After `agentforge new` finishes Copier-rendering the chosen
|
|
4
|
+
template, this module copies the contents of
|
|
5
|
+
`agentforge.templates._shared` into the destination, rendering each
|
|
6
|
+
file through Jinja with the same answer context Copier saw. Each
|
|
7
|
+
shared file gets a marker header and an entry in the managed-files
|
|
8
|
+
lock — they participate in `agentforge upgrade` / `fork` exactly
|
|
9
|
+
like template-rendered files do.
|
|
10
|
+
|
|
11
|
+
The shared directory carries the runbooks, AGENTS.md, CLAUDE.md,
|
|
12
|
+
.cursorrules, and .github/copilot-instructions.md that ship with
|
|
13
|
+
every scaffolded agent. Putting them in one place keeps the
|
|
14
|
+
framework's six templates from each maintaining a near-duplicate
|
|
15
|
+
copy.
|
|
16
|
+
|
|
17
|
+
A `.tmpl` extension on a file marks it as Jinja-templated; the
|
|
18
|
+
extension is stripped on write. Files without `.tmpl` are copied
|
|
19
|
+
verbatim (apart from the marker header prepend).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
from importlib import resources
|
|
25
|
+
from pathlib import Path
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import yaml
|
|
29
|
+
from jinja2 import Environment
|
|
30
|
+
|
|
31
|
+
from agentforge.cli._scaffold_state import (
|
|
32
|
+
END_MANAGED_MARKER,
|
|
33
|
+
answers_path,
|
|
34
|
+
hash_content,
|
|
35
|
+
lock_path,
|
|
36
|
+
marker_for,
|
|
37
|
+
read_lock,
|
|
38
|
+
write_lock,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def inject_shared_scaffold(
|
|
43
|
+
dst: Path,
|
|
44
|
+
*,
|
|
45
|
+
template_name: str,
|
|
46
|
+
template_version: str,
|
|
47
|
+
) -> int:
|
|
48
|
+
"""Copy `_shared/` into the destination after Copier rendered.
|
|
49
|
+
|
|
50
|
+
Returns the count of files written.
|
|
51
|
+
"""
|
|
52
|
+
shared_root = _shared_root()
|
|
53
|
+
if shared_root is None:
|
|
54
|
+
return 0
|
|
55
|
+
|
|
56
|
+
context = _build_context(dst, template_name=template_name)
|
|
57
|
+
# The shared payload is markdown / YAML / plain text — never HTML
|
|
58
|
+
# rendered to a browser — so HTML escaping would actively corrupt
|
|
59
|
+
# the output (e.g. `&` in code blocks becomes `&`).
|
|
60
|
+
env = Environment( # nosec B701 — markdown output, not HTML
|
|
61
|
+
autoescape=False, # noqa: S701
|
|
62
|
+
keep_trailing_newline=True,
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
lock = read_lock(dst)
|
|
66
|
+
count = 0
|
|
67
|
+
for src_path in _walk(shared_root):
|
|
68
|
+
rel_path = src_path.relative_to(shared_root)
|
|
69
|
+
out_rel, content = _render_one(src_path, rel_path, env, context)
|
|
70
|
+
target = dst / out_rel
|
|
71
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
|
|
73
|
+
marker = marker_for(
|
|
74
|
+
target.suffix, f"template:{template_name}", template_version, hash_content(content)[:12]
|
|
75
|
+
)
|
|
76
|
+
body = content
|
|
77
|
+
if marker is not None and not content.lstrip().startswith(marker.strip()):
|
|
78
|
+
body = marker + "\n" + content
|
|
79
|
+
target.write_text(body, encoding="utf-8")
|
|
80
|
+
|
|
81
|
+
lock[str(out_rel)] = {
|
|
82
|
+
"hash": hash_content(content),
|
|
83
|
+
"source_module": f"template:{template_name}:_shared",
|
|
84
|
+
"source_version": template_version,
|
|
85
|
+
"forked": False,
|
|
86
|
+
}
|
|
87
|
+
count += 1
|
|
88
|
+
|
|
89
|
+
write_lock(dst, lock)
|
|
90
|
+
return count
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _walk(root: Path) -> list[Path]:
|
|
94
|
+
"""Walk `root` and return every regular file path."""
|
|
95
|
+
return sorted(path for path in root.rglob("*") if path.is_file())
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _render_one(
|
|
99
|
+
src_path: Path,
|
|
100
|
+
rel_path: Path,
|
|
101
|
+
env: Environment,
|
|
102
|
+
context: dict[str, Any],
|
|
103
|
+
) -> tuple[Path, str]:
|
|
104
|
+
"""Render a single shared file, returning (out_rel_path, content).
|
|
105
|
+
|
|
106
|
+
`.tmpl`-suffixed files have the suffix stripped and are run
|
|
107
|
+
through Jinja. Everything else is copied verbatim.
|
|
108
|
+
"""
|
|
109
|
+
body = src_path.read_text(encoding="utf-8")
|
|
110
|
+
if src_path.suffix == ".tmpl":
|
|
111
|
+
body = env.from_string(body).render(**context)
|
|
112
|
+
out_rel = rel_path.with_suffix("") # drop `.tmpl`
|
|
113
|
+
else:
|
|
114
|
+
out_rel = rel_path
|
|
115
|
+
if not body.endswith("\n"):
|
|
116
|
+
body += "\n"
|
|
117
|
+
if END_MANAGED_MARKER in body:
|
|
118
|
+
# Three-section file (feat-019 chunk 1) — already terminates
|
|
119
|
+
# the managed section explicitly.
|
|
120
|
+
return out_rel, body
|
|
121
|
+
return out_rel, body
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _build_context(dst: Path, *, template_name: str) -> dict[str, Any]:
|
|
125
|
+
"""Pull the Copier answer file into a Jinja context, supplementing
|
|
126
|
+
with framework metadata."""
|
|
127
|
+
answers = _read_answers(dst)
|
|
128
|
+
return {
|
|
129
|
+
"project_name": answers.get("project_name", "My Agent"),
|
|
130
|
+
"project_slug": answers.get("project_slug", "my-agent"),
|
|
131
|
+
"llm_provider": answers.get("llm_provider", "bedrock"),
|
|
132
|
+
"description": answers.get("description", "An AgentForge agent."),
|
|
133
|
+
"template_name": template_name,
|
|
134
|
+
"framework_version": _framework_version(),
|
|
135
|
+
"module_list": [],
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _read_answers(dst: Path) -> dict[str, Any]:
|
|
140
|
+
path = answers_path(dst)
|
|
141
|
+
if not path.exists():
|
|
142
|
+
return {}
|
|
143
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
|
|
144
|
+
if not isinstance(raw, dict):
|
|
145
|
+
return {}
|
|
146
|
+
return {str(k): v for k, v in raw.items()}
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def _framework_version() -> str:
|
|
150
|
+
from importlib.metadata import PackageNotFoundError, version # noqa: PLC0415
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
return version("agentforge")
|
|
154
|
+
except PackageNotFoundError: # pragma: no cover
|
|
155
|
+
return "0.0.0+unknown"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _shared_root() -> Path | None:
|
|
159
|
+
"""Return the on-disk path of the `_shared/` template directory."""
|
|
160
|
+
try:
|
|
161
|
+
traversable = resources.files("agentforge.templates").joinpath("_shared")
|
|
162
|
+
except ModuleNotFoundError:
|
|
163
|
+
return None
|
|
164
|
+
with resources.as_file(traversable) as path:
|
|
165
|
+
if not path.exists() or not path.is_dir():
|
|
166
|
+
return None
|
|
167
|
+
return Path(path)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# Silence unused-imports warning for re-exported markers.
|
|
171
|
+
_ = (lock_path,)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
__all__ = ["inject_shared_scaffold"]
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
"""`agentforge config {validate,show,schema}` — read-only config CLI.
|
|
2
|
+
|
|
3
|
+
All three commands invoke the loader and surface useful output:
|
|
4
|
+
- **validate**: loads the file, runs schema + module-schema
|
|
5
|
+
validation, prints "OK" on success or the error path on failure.
|
|
6
|
+
- **show**: prints the fully-resolved config as YAML; `--resolved`
|
|
7
|
+
expands env vars + overrides (default), `--raw` shows the parsed
|
|
8
|
+
YAML pre-interpolation.
|
|
9
|
+
- **schema**: emits the root `AgentForgeConfig` JSON Schema for
|
|
10
|
+
editor autocomplete (SchemaStore-style).
|
|
11
|
+
|
|
12
|
+
The destructive `add/swap/remove` commands ship in a follow-up
|
|
13
|
+
sub-feat (deferred from feat-010 alongside this PR).
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from collections.abc import Sequence
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import yaml
|
|
26
|
+
from agentforge_core.config import (
|
|
27
|
+
AgentForgeConfig,
|
|
28
|
+
load_config,
|
|
29
|
+
validate_module_configs,
|
|
30
|
+
)
|
|
31
|
+
from agentforge_core.production.exceptions import ModuleError
|
|
32
|
+
from pydantic import ValidationError
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def register_config_cmd(sub: argparse._SubParsersAction) -> None: # type: ignore[type-arg]
|
|
36
|
+
"""Attach the `config` subcommand + its `validate`/`show`/`schema`
|
|
37
|
+
children to the parent subparser action."""
|
|
38
|
+
config_parser = sub.add_parser(
|
|
39
|
+
"config",
|
|
40
|
+
help="Inspect and validate `agentforge.yaml`.",
|
|
41
|
+
)
|
|
42
|
+
config_sub = config_parser.add_subparsers(dest="config_target", required=True)
|
|
43
|
+
|
|
44
|
+
validate = config_sub.add_parser(
|
|
45
|
+
"validate",
|
|
46
|
+
help="Validate `agentforge.yaml` (schema + module schemas).",
|
|
47
|
+
)
|
|
48
|
+
_attach_load_args(validate)
|
|
49
|
+
validate.add_argument(
|
|
50
|
+
"--strict-modules",
|
|
51
|
+
action="store_true",
|
|
52
|
+
help=(
|
|
53
|
+
"Fail when a module the config references isn't installed. "
|
|
54
|
+
"Default: lenient — skip missing modules and only validate "
|
|
55
|
+
"the schema of installed ones."
|
|
56
|
+
),
|
|
57
|
+
)
|
|
58
|
+
validate.set_defaults(_handler=_run_validate)
|
|
59
|
+
|
|
60
|
+
show = config_sub.add_parser(
|
|
61
|
+
"show",
|
|
62
|
+
help="Print the loaded config as YAML.",
|
|
63
|
+
)
|
|
64
|
+
_attach_load_args(show)
|
|
65
|
+
show_mode = show.add_mutually_exclusive_group()
|
|
66
|
+
show_mode.add_argument(
|
|
67
|
+
"--resolved",
|
|
68
|
+
action="store_true",
|
|
69
|
+
help=(
|
|
70
|
+
"Print after env-var interpolation + overrides (default). "
|
|
71
|
+
"Shows exactly what the agent will see."
|
|
72
|
+
),
|
|
73
|
+
)
|
|
74
|
+
show_mode.add_argument(
|
|
75
|
+
"--raw",
|
|
76
|
+
action="store_true",
|
|
77
|
+
help="Print the raw YAML on disk, pre-interpolation.",
|
|
78
|
+
)
|
|
79
|
+
show.set_defaults(_handler=_run_show)
|
|
80
|
+
|
|
81
|
+
schema = config_sub.add_parser(
|
|
82
|
+
"schema",
|
|
83
|
+
help="Print the root config's JSON Schema (for IDE autocomplete).",
|
|
84
|
+
)
|
|
85
|
+
schema.add_argument(
|
|
86
|
+
"--indent",
|
|
87
|
+
type=int,
|
|
88
|
+
default=2,
|
|
89
|
+
help="JSON indent level (default 2).",
|
|
90
|
+
)
|
|
91
|
+
schema.set_defaults(_handler=_run_schema)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _attach_load_args(parser: argparse.ArgumentParser) -> None:
|
|
95
|
+
"""Args shared by `validate` and `show` — point at a file, pick
|
|
96
|
+
an env layer, apply overrides."""
|
|
97
|
+
parser.add_argument(
|
|
98
|
+
"--path",
|
|
99
|
+
type=Path,
|
|
100
|
+
default=None,
|
|
101
|
+
help="Path to agentforge.yaml (default: $AGENTFORGE_CONFIG or ./agentforge.yaml).",
|
|
102
|
+
)
|
|
103
|
+
parser.add_argument(
|
|
104
|
+
"--env",
|
|
105
|
+
default=None,
|
|
106
|
+
help="Environment layer (selects agentforge.<env>.yaml overlay).",
|
|
107
|
+
)
|
|
108
|
+
parser.add_argument(
|
|
109
|
+
"--override",
|
|
110
|
+
action="append",
|
|
111
|
+
default=[],
|
|
112
|
+
metavar="DOTTED.PATH=VALUE",
|
|
113
|
+
help="Override a config value (repeatable).",
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _run_validate(args: argparse.Namespace) -> int:
|
|
118
|
+
try:
|
|
119
|
+
cfg = load_config(args.path, env=args.env, overrides=args.override)
|
|
120
|
+
except ModuleError as exc:
|
|
121
|
+
sys.stderr.write(f"agentforge.yaml load failed: {exc}\n")
|
|
122
|
+
return 1
|
|
123
|
+
except ValidationError as exc:
|
|
124
|
+
_print_validation_errors(exc)
|
|
125
|
+
return 1
|
|
126
|
+
try:
|
|
127
|
+
validate_module_configs(cfg, strict=args.strict_modules)
|
|
128
|
+
except ModuleError as exc:
|
|
129
|
+
sys.stderr.write(f"module config validation failed: {exc}\n")
|
|
130
|
+
return 1
|
|
131
|
+
sys.stdout.write("OK\n")
|
|
132
|
+
return 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _run_show(args: argparse.Namespace) -> int:
|
|
136
|
+
if args.raw:
|
|
137
|
+
return _show_raw(args)
|
|
138
|
+
try:
|
|
139
|
+
cfg = load_config(args.path, env=args.env, overrides=args.override)
|
|
140
|
+
except (ModuleError, ValidationError) as exc:
|
|
141
|
+
sys.stderr.write(f"agentforge.yaml load failed: {exc}\n")
|
|
142
|
+
return 1
|
|
143
|
+
payload = cfg.model_dump(mode="json")
|
|
144
|
+
sys.stdout.write(yaml.safe_dump(payload, sort_keys=False, default_flow_style=False))
|
|
145
|
+
return 0
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _show_raw(args: argparse.Namespace) -> int:
|
|
149
|
+
path = args.path
|
|
150
|
+
if path is None:
|
|
151
|
+
env_path = os.environ.get("AGENTFORGE_CONFIG")
|
|
152
|
+
path = Path(env_path) if env_path else Path.cwd() / "agentforge.yaml"
|
|
153
|
+
if not Path(path).exists():
|
|
154
|
+
sys.stderr.write(f"no config file at {path}\n")
|
|
155
|
+
return 1
|
|
156
|
+
sys.stdout.write(Path(path).read_text())
|
|
157
|
+
return 0
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _run_schema(args: argparse.Namespace) -> int:
|
|
161
|
+
schema = AgentForgeConfig.model_json_schema()
|
|
162
|
+
sys.stdout.write(json.dumps(schema, indent=args.indent) + "\n")
|
|
163
|
+
return 0
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _print_validation_errors(exc: ValidationError) -> None:
|
|
167
|
+
"""Render pydantic errors with their dotted YAML paths."""
|
|
168
|
+
sys.stderr.write("agentforge.yaml validation failed:\n")
|
|
169
|
+
for err in exc.errors(include_url=False):
|
|
170
|
+
loc = ".".join(str(p) for p in err["loc"])
|
|
171
|
+
sys.stderr.write(f" {loc}: {err['msg']}\n")
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
__all__: Sequence[str] = ["register_config_cmd"]
|
agentforge/cli/db_cmd.py
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""`agentforge db` subcommands (feat-017 chunk 7).
|
|
2
|
+
|
|
3
|
+
Routed to the active `MemoryStore`:
|
|
4
|
+
|
|
5
|
+
agentforge db migrate # call init_schema if present
|
|
6
|
+
agentforge db backup --to FILE|- # JSONL dump of every claim
|
|
7
|
+
agentforge db restore --from FILE # bulk put() from a JSONL file
|
|
8
|
+
agentforge db purge --older-than 30d # delete by filter
|
|
9
|
+
--run-id RUN_ID
|
|
10
|
+
--category CAT
|
|
11
|
+
agentforge db query 'category:X agent:Y limit:50'
|
|
12
|
+
|
|
13
|
+
`db migrate` is a no-op (info + exit 0) for drivers without an
|
|
14
|
+
`init_schema` method (InMemoryStore, SqliteMemoryStore which creates
|
|
15
|
+
its schema eagerly).
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import asyncio
|
|
22
|
+
import json
|
|
23
|
+
import re
|
|
24
|
+
import sys
|
|
25
|
+
from datetime import UTC, datetime, timedelta
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any
|
|
28
|
+
|
|
29
|
+
from agentforge_core.production.exceptions import ModuleError
|
|
30
|
+
from agentforge_core.values.claim import Claim
|
|
31
|
+
|
|
32
|
+
from agentforge.cli._build import build_memory_from_config
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def register_db_cmd(sub: argparse._SubParsersAction) -> None: # type: ignore[type-arg]
|
|
36
|
+
parser = sub.add_parser(
|
|
37
|
+
"db",
|
|
38
|
+
help="Operate on the configured memory store (migrate / backup / restore / purge / query).",
|
|
39
|
+
)
|
|
40
|
+
parser.add_argument("--path", type=Path, default=None)
|
|
41
|
+
parser.add_argument("--env", default=None)
|
|
42
|
+
parser.add_argument("--override", action="append", default=[])
|
|
43
|
+
db_sub = parser.add_subparsers(dest="db_command", required=True)
|
|
44
|
+
|
|
45
|
+
db_sub.add_parser(
|
|
46
|
+
"migrate",
|
|
47
|
+
help="Apply pending schema migrations (feat-024) or call init_schema fallback.",
|
|
48
|
+
)
|
|
49
|
+
db_sub.add_parser(
|
|
50
|
+
"migrate-status",
|
|
51
|
+
help="List applied + pending migrations for the configured store (feat-024).",
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
backup = db_sub.add_parser("backup", help="Dump every claim to JSONL.")
|
|
55
|
+
backup.add_argument("--to", required=True, help="Output path or '-' for stdout.")
|
|
56
|
+
|
|
57
|
+
restore = db_sub.add_parser("restore", help="Bulk put() claims from a JSONL file.")
|
|
58
|
+
restore.add_argument("--from", dest="src", required=True, help="Input path or '-'.")
|
|
59
|
+
|
|
60
|
+
purge = db_sub.add_parser("purge", help="Delete claims by filter.")
|
|
61
|
+
purge.add_argument("--older-than", default=None, help="Duration like 30d, 24h, 90m.")
|
|
62
|
+
purge.add_argument("--run-id", default=None)
|
|
63
|
+
purge.add_argument("--category", default=None)
|
|
64
|
+
purge.add_argument("--yes", action="store_true", help="Skip the confirmation prompt.")
|
|
65
|
+
|
|
66
|
+
query = db_sub.add_parser("query", help="Tiny DSL → MemoryStore.query.")
|
|
67
|
+
query.add_argument("dsl", help="Tokens like 'category:X agent:Y run_id:Z'.")
|
|
68
|
+
query.add_argument("--limit", type=int, default=100)
|
|
69
|
+
query.add_argument(
|
|
70
|
+
"--output-format",
|
|
71
|
+
choices=("rich", "json", "plain"),
|
|
72
|
+
default="plain",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
parser.set_defaults(_handler=_db_handler)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _db_handler(args: argparse.Namespace) -> int:
|
|
79
|
+
return asyncio.run(_dispatch(args))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
async def _dispatch(args: argparse.Namespace) -> int:
|
|
83
|
+
from agentforge_core.config.loader import load_config # noqa: PLC0415
|
|
84
|
+
|
|
85
|
+
config = load_config(args.path, env=args.env, overrides=list(args.override) or None)
|
|
86
|
+
memory = build_memory_from_config(config)
|
|
87
|
+
if memory is None:
|
|
88
|
+
sys.stderr.write("agentforge db: modules.memory must be configured.\n")
|
|
89
|
+
return 1
|
|
90
|
+
|
|
91
|
+
dispatch = {
|
|
92
|
+
"migrate": _do_migrate,
|
|
93
|
+
"migrate-status": _do_migrate_status,
|
|
94
|
+
"backup": _do_backup,
|
|
95
|
+
"restore": _do_restore,
|
|
96
|
+
"purge": _do_purge,
|
|
97
|
+
"query": _do_query,
|
|
98
|
+
}
|
|
99
|
+
handler = dispatch[args.db_command]
|
|
100
|
+
return await handler(memory, args)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
async def _do_migrate(memory: Any, args: argparse.Namespace) -> int:
|
|
104
|
+
"""Apply pending migrations via the feat-024 framework when the
|
|
105
|
+
driver exposes a `migrator()` method; otherwise fall back to
|
|
106
|
+
legacy `init_schema()`."""
|
|
107
|
+
del args
|
|
108
|
+
migrator_factory = getattr(memory, "migrator", None)
|
|
109
|
+
if callable(migrator_factory):
|
|
110
|
+
migrator = migrator_factory()
|
|
111
|
+
applied = await migrator.apply_pending()
|
|
112
|
+
if not applied:
|
|
113
|
+
sys.stdout.write(" → schema up to date; no pending migrations.\n")
|
|
114
|
+
else:
|
|
115
|
+
for migration in applied:
|
|
116
|
+
sys.stdout.write(f" → applied {migration.id}_{migration.name}\n")
|
|
117
|
+
return 0
|
|
118
|
+
init = getattr(memory, "init_schema", None)
|
|
119
|
+
if not callable(init):
|
|
120
|
+
sys.stdout.write(
|
|
121
|
+
" → driver has no migrator()/init_schema(); nothing to migrate.\n",
|
|
122
|
+
)
|
|
123
|
+
return 0
|
|
124
|
+
await init()
|
|
125
|
+
sys.stdout.write(" → schema initialised (legacy init_schema path).\n")
|
|
126
|
+
return 0
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
async def _do_migrate_status(memory: Any, args: argparse.Namespace) -> int:
|
|
130
|
+
"""List applied + pending migrations for the configured store
|
|
131
|
+
(feat-024). Drivers without a `migrator()` method print a
|
|
132
|
+
diagnostic and exit 0."""
|
|
133
|
+
del args
|
|
134
|
+
migrator_factory = getattr(memory, "migrator", None)
|
|
135
|
+
if not callable(migrator_factory):
|
|
136
|
+
sys.stdout.write(
|
|
137
|
+
" → driver has no migrator() method; cannot report status.\n",
|
|
138
|
+
)
|
|
139
|
+
return 0
|
|
140
|
+
migrator = migrator_factory()
|
|
141
|
+
statuses = await migrator.status()
|
|
142
|
+
if not statuses:
|
|
143
|
+
sys.stdout.write(" → no migrations bundled with this driver.\n")
|
|
144
|
+
return 0
|
|
145
|
+
for status in statuses:
|
|
146
|
+
if status.applied:
|
|
147
|
+
checksum_flag = "✓" if status.checksum_match else "✗"
|
|
148
|
+
sys.stdout.write(
|
|
149
|
+
f" ✓ {status.migration.id}_{status.migration.name} "
|
|
150
|
+
f"(applied {status.applied_at}; checksum {checksum_flag})\n"
|
|
151
|
+
)
|
|
152
|
+
else:
|
|
153
|
+
sys.stdout.write(f" {status.migration.id}_{status.migration.name} — pending\n")
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def _do_backup(memory: Any, args: argparse.Namespace) -> int:
|
|
158
|
+
target = args.to
|
|
159
|
+
out_stream: Any = (
|
|
160
|
+
sys.stdout if target == "-" else Path(target).open("w", encoding="utf-8") # noqa: SIM115, ASYNC230 — closed in finally
|
|
161
|
+
)
|
|
162
|
+
count = 0
|
|
163
|
+
try:
|
|
164
|
+
async for claim in memory.stream():
|
|
165
|
+
out_stream.write(claim.model_dump_json() + "\n")
|
|
166
|
+
count += 1
|
|
167
|
+
finally:
|
|
168
|
+
if target != "-":
|
|
169
|
+
out_stream.close()
|
|
170
|
+
sys.stderr.write(f" → wrote {count} claims.\n")
|
|
171
|
+
return 0
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
async def _do_restore(memory: Any, args: argparse.Namespace) -> int:
|
|
175
|
+
src = args.src
|
|
176
|
+
text = (
|
|
177
|
+
sys.stdin.read() if src == "-" else Path(src).read_text(encoding="utf-8") # noqa: ASYNC240 — CLI one-shot
|
|
178
|
+
)
|
|
179
|
+
count = 0
|
|
180
|
+
for line in text.splitlines():
|
|
181
|
+
stripped = line.strip()
|
|
182
|
+
if not stripped:
|
|
183
|
+
continue
|
|
184
|
+
await memory.put(Claim.model_validate_json(stripped))
|
|
185
|
+
count += 1
|
|
186
|
+
sys.stdout.write(f" → restored {count} claims.\n")
|
|
187
|
+
return 0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
async def _do_purge(memory: Any, args: argparse.Namespace) -> int:
|
|
191
|
+
older_than = _parse_duration(args.older_than) if args.older_than else None
|
|
192
|
+
if args.run_id is None and args.category is None and older_than is None:
|
|
193
|
+
sys.stderr.write(
|
|
194
|
+
"agentforge db purge: pass at least one of --older-than / --run-id / --category.\n"
|
|
195
|
+
)
|
|
196
|
+
return 1
|
|
197
|
+
if not args.yes:
|
|
198
|
+
sys.stderr.write("Proceed? [y/N]: ")
|
|
199
|
+
sys.stderr.flush()
|
|
200
|
+
confirm = sys.stdin.readline().strip().lower()
|
|
201
|
+
if confirm not in {"y", "yes"}:
|
|
202
|
+
sys.stderr.write("cancelled.\n")
|
|
203
|
+
return 1
|
|
204
|
+
try:
|
|
205
|
+
removed = await memory.delete(
|
|
206
|
+
run_id=args.run_id,
|
|
207
|
+
older_than=older_than,
|
|
208
|
+
category=args.category,
|
|
209
|
+
)
|
|
210
|
+
except ModuleError as exc:
|
|
211
|
+
sys.stderr.write(f"agentforge db purge: {exc}\n")
|
|
212
|
+
return 1
|
|
213
|
+
sys.stdout.write(f" → removed {removed} claims.\n")
|
|
214
|
+
return 0
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
async def _do_query(memory: Any, args: argparse.Namespace) -> int:
|
|
218
|
+
try:
|
|
219
|
+
filters = _parse_dsl(args.dsl)
|
|
220
|
+
except ValueError as exc:
|
|
221
|
+
sys.stderr.write(f"agentforge db query: {exc}\n")
|
|
222
|
+
return 1
|
|
223
|
+
claims = await memory.query(limit=args.limit, **filters)
|
|
224
|
+
if args.output_format == "json":
|
|
225
|
+
print(json.dumps([c.model_dump(mode="json") for c in claims], indent=2))
|
|
226
|
+
else:
|
|
227
|
+
for c in claims:
|
|
228
|
+
print(f"{c.id} {c.category:<20} run={c.run_id:<26} agent={c.agent}")
|
|
229
|
+
return 0
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
_DURATION_RE = re.compile(r"^(\d+)([smhd])$")
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _parse_duration(s: str) -> datetime:
|
|
236
|
+
m = _DURATION_RE.match(s)
|
|
237
|
+
if m is None:
|
|
238
|
+
msg = f"--older-than expects e.g. '30d', '24h', '15m'; got {s!r}."
|
|
239
|
+
raise ValueError(msg)
|
|
240
|
+
n, unit = int(m.group(1)), m.group(2)
|
|
241
|
+
seconds = {"s": 1, "m": 60, "h": 3600, "d": 86400}[unit] * n
|
|
242
|
+
return datetime.now(UTC) - timedelta(seconds=seconds)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
_QUERY_KEYS = {"category", "agent", "project", "run_id"}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
def _parse_dsl(dsl: str) -> dict[str, str]:
|
|
249
|
+
out: dict[str, str] = {}
|
|
250
|
+
for token in dsl.split():
|
|
251
|
+
if ":" not in token:
|
|
252
|
+
msg = f"token {token!r} not in key:value form."
|
|
253
|
+
raise ValueError(msg)
|
|
254
|
+
k, v = token.split(":", 1)
|
|
255
|
+
if k not in _QUERY_KEYS:
|
|
256
|
+
msg = f"unknown query key {k!r}; supported: {sorted(_QUERY_KEYS)}."
|
|
257
|
+
raise ValueError(msg)
|
|
258
|
+
out[k] = v
|
|
259
|
+
return out
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
__all__ = ["register_db_cmd"]
|