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
agentforge/auth.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Concrete `AuthPolicy` implementations shipped with the framework
|
|
2
|
+
(feat-014).
|
|
3
|
+
|
|
4
|
+
`EnvBearerAuth(token_env_var)` reads a comma-separated list of
|
|
5
|
+
valid bearer tokens from an environment variable; each token
|
|
6
|
+
maps to a `Principal` whose id is the token itself. Suitable for
|
|
7
|
+
small/internal deployments; production deployments typically
|
|
8
|
+
implement their own `AuthPolicy` against a real identity
|
|
9
|
+
provider.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
from agentforge_core.contracts.auth import AuthPolicy
|
|
17
|
+
from agentforge_core.values.auth import Principal
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class EnvBearerAuth(AuthPolicy):
|
|
21
|
+
"""Bearer-token policy backed by a comma-separated env var.
|
|
22
|
+
|
|
23
|
+
Format of ``$<token_env_var>``: ``"token1,token2,token3"``.
|
|
24
|
+
Each token is its own principal id (no token → identity map).
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
def __init__(self, token_env_var: str = "API_TOKENS") -> None: # noqa: S107 # nosec B107 — env-var NAME
|
|
28
|
+
self._var = token_env_var
|
|
29
|
+
|
|
30
|
+
async def authenticate(self, bearer_token: str | None) -> Principal | None:
|
|
31
|
+
if bearer_token is None or not bearer_token:
|
|
32
|
+
return None
|
|
33
|
+
raw = os.environ.get(self._var, "")
|
|
34
|
+
if not raw:
|
|
35
|
+
return None
|
|
36
|
+
valid = {t.strip() for t in raw.split(",") if t.strip()}
|
|
37
|
+
if bearer_token in valid:
|
|
38
|
+
return Principal(id=bearer_token)
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
__all__ = ["EnvBearerAuth"]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""`agentforge` CLI (feat-010).
|
|
2
|
+
|
|
3
|
+
Read-only commands only in this PR:
|
|
4
|
+
- `agentforge list modules [--category <cat>]`
|
|
5
|
+
|
|
6
|
+
The destructive commands (`add`, `swap`, `remove`) edit
|
|
7
|
+
`agentforge.yaml` and apply a per-module `manifest.yaml`. Both
|
|
8
|
+
depend on feat-012 (Configuration system); they ship as a follow-
|
|
9
|
+
up sub-feat once that lands.
|
|
10
|
+
|
|
11
|
+
Entry point: `[project.scripts] agentforge = "agentforge.cli.main:main"`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from agentforge.cli.main import main
|
|
17
|
+
|
|
18
|
+
__all__ = ["main"]
|
agentforge/cli/_build.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
"""Shared CLI helper: build an `Agent` from `agentforge.yaml` (feat-017).
|
|
2
|
+
|
|
3
|
+
`agentforge run`, `eval`, `debug`, `db ...`, and `health` all need to
|
|
4
|
+
go from a config file on disk to a ready-to-run `Agent`. This module
|
|
5
|
+
centralises that wiring so each command stays small.
|
|
6
|
+
|
|
7
|
+
The helper:
|
|
8
|
+
|
|
9
|
+
1. Loads + validates the config (feat-012's `load_config`).
|
|
10
|
+
2. Resolves every module declared in `modules.*` and `agent.*` via
|
|
11
|
+
the global `Resolver` (feat-010).
|
|
12
|
+
3. Instantiates each module with the per-entry `config` dict.
|
|
13
|
+
4. Hands the wired-up objects to `Agent(...)`.
|
|
14
|
+
5. Optionally installs the recording hook from feat-017 chunk 1
|
|
15
|
+
when `enable_recording=True` is set.
|
|
16
|
+
|
|
17
|
+
Errors are surfaced as `ModuleError` so the CLI can map them to
|
|
18
|
+
deterministic exit codes (per feat-017 §4 — config invalid → 2).
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import TYPE_CHECKING, Any
|
|
25
|
+
|
|
26
|
+
from agentforge_core.config.loader import load_config
|
|
27
|
+
from agentforge_core.config.schema import AgentForgeConfig
|
|
28
|
+
from agentforge_core.contracts.embedding import EmbeddingClient
|
|
29
|
+
from agentforge_core.contracts.evaluator import Evaluator
|
|
30
|
+
from agentforge_core.contracts.graph_store import GraphStore
|
|
31
|
+
from agentforge_core.contracts.llm import LLMClient
|
|
32
|
+
from agentforge_core.contracts.memory import MemoryStore
|
|
33
|
+
from agentforge_core.contracts.reranker import Reranker
|
|
34
|
+
from agentforge_core.contracts.vector_store import VectorStore
|
|
35
|
+
from agentforge_core.production.exceptions import ModuleError
|
|
36
|
+
from agentforge_core.resolver import Resolver
|
|
37
|
+
from agentforge_core.values.retrieval import GraphExpansion
|
|
38
|
+
|
|
39
|
+
from agentforge.agent import Agent
|
|
40
|
+
from agentforge.memory import InMemoryStore
|
|
41
|
+
from agentforge.pipeline import Pipeline
|
|
42
|
+
from agentforge.retrieval import Retriever
|
|
43
|
+
|
|
44
|
+
if TYPE_CHECKING:
|
|
45
|
+
from agentforge_core.contracts.tool import Tool
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
async def load_and_build(
|
|
49
|
+
*,
|
|
50
|
+
path: Path | str | None = None,
|
|
51
|
+
env: str | None = None,
|
|
52
|
+
overrides: list[str] | None = None,
|
|
53
|
+
enable_recording: bool = False,
|
|
54
|
+
) -> Agent:
|
|
55
|
+
"""Load config (feat-012) and construct a wired `Agent`.
|
|
56
|
+
|
|
57
|
+
Sole entrypoint every CLI command uses. Honours
|
|
58
|
+
`AGENTFORGE_CONFIG` / `AGENTFORGE_ENV` env vars (resolved inside
|
|
59
|
+
`load_config`); accepts dotted-path overrides like
|
|
60
|
+
`agent.budget.usd=5.0`.
|
|
61
|
+
"""
|
|
62
|
+
config = load_config(path, env=env, overrides=overrides)
|
|
63
|
+
return await build_agent_from_config(config, enable_recording=enable_recording)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
async def build_agent_from_config(
|
|
67
|
+
config: AgentForgeConfig,
|
|
68
|
+
*,
|
|
69
|
+
enable_recording: bool = False,
|
|
70
|
+
) -> Agent:
|
|
71
|
+
"""Build an `Agent` from an already-loaded `AgentForgeConfig`.
|
|
72
|
+
|
|
73
|
+
Splits out from `load_and_build` for tests + reuse — tests build
|
|
74
|
+
a `AgentForgeConfig` directly and skip the YAML parse.
|
|
75
|
+
"""
|
|
76
|
+
memory = build_memory_from_config(config)
|
|
77
|
+
if memory is not None:
|
|
78
|
+
await _maybe_init_schema(memory)
|
|
79
|
+
evaluators = build_evaluators_from_config(config)
|
|
80
|
+
pipeline = build_pipeline_from_config(config)
|
|
81
|
+
retriever = build_retriever_from_config(config)
|
|
82
|
+
llm = _resolve_llm(config)
|
|
83
|
+
strategy = config.agent.strategy if isinstance(config.agent.strategy, str) else None
|
|
84
|
+
|
|
85
|
+
return Agent(
|
|
86
|
+
model=llm,
|
|
87
|
+
memory=memory if memory is not None else InMemoryStore(),
|
|
88
|
+
evaluators=evaluators,
|
|
89
|
+
strategy=strategy,
|
|
90
|
+
retriever=retriever,
|
|
91
|
+
system_prompt=config.agent.system_prompt,
|
|
92
|
+
budget_usd=config.agent.budget.usd,
|
|
93
|
+
max_iterations=config.agent.max_iterations,
|
|
94
|
+
record_runs=memory if enable_recording and memory is not None else None,
|
|
95
|
+
pipeline=pipeline,
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def build_memory_from_config(config: AgentForgeConfig) -> MemoryStore | None:
|
|
100
|
+
"""Resolve + instantiate `modules.memory`. Returns None when absent."""
|
|
101
|
+
if config.modules.memory is None:
|
|
102
|
+
return None
|
|
103
|
+
cls = _resolve_class("memory", config.modules.memory.driver)
|
|
104
|
+
instance = _instantiate(cls, config.modules.memory.config)
|
|
105
|
+
if not isinstance(instance, MemoryStore):
|
|
106
|
+
msg = (
|
|
107
|
+
f"Resolved memory driver {config.modules.memory.driver!r} "
|
|
108
|
+
f"({cls.__name__}) does not implement MemoryStore."
|
|
109
|
+
)
|
|
110
|
+
raise ModuleError(msg)
|
|
111
|
+
return instance
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def build_evaluators_from_config(config: AgentForgeConfig) -> list[Evaluator]:
|
|
115
|
+
"""Resolve + instantiate every entry in `modules.evaluators`."""
|
|
116
|
+
out: list[Evaluator] = []
|
|
117
|
+
for entry in config.modules.evaluators:
|
|
118
|
+
cls = _resolve_class("evaluators", entry.name)
|
|
119
|
+
instance = _instantiate(cls, entry.config)
|
|
120
|
+
if not isinstance(instance, Evaluator):
|
|
121
|
+
msg = (
|
|
122
|
+
f"Resolved evaluator {entry.name!r} ({cls.__name__}) does not implement Evaluator."
|
|
123
|
+
)
|
|
124
|
+
raise ModuleError(msg)
|
|
125
|
+
out.append(instance)
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def build_pipeline_from_config(config: AgentForgeConfig) -> Pipeline | None:
|
|
130
|
+
"""Resolve + instantiate `modules.pipeline.tasks` (feat-015).
|
|
131
|
+
|
|
132
|
+
Returns ``None`` when the pipeline block is absent, disabled, or
|
|
133
|
+
has no tasks. Each task name resolves under the `"tasks"`
|
|
134
|
+
resolver category (register via
|
|
135
|
+
`agentforge.resolver_register.register_task` or via an
|
|
136
|
+
`agentforge.tasks` entry point).
|
|
137
|
+
"""
|
|
138
|
+
from agentforge_core.contracts.task import Task as TaskBase # noqa: PLC0415
|
|
139
|
+
|
|
140
|
+
cfg = config.modules.pipeline
|
|
141
|
+
if cfg is None or not cfg.enabled or not cfg.tasks:
|
|
142
|
+
return None
|
|
143
|
+
tasks: list[TaskBase] = []
|
|
144
|
+
for entry in cfg.tasks:
|
|
145
|
+
cls = _resolve_class("tasks", entry.name)
|
|
146
|
+
instance = _instantiate(cls, entry.config)
|
|
147
|
+
if not isinstance(instance, TaskBase):
|
|
148
|
+
msg = f"Resolved task {entry.name!r} ({cls.__name__}) does not implement Task."
|
|
149
|
+
raise ModuleError(msg)
|
|
150
|
+
tasks.append(instance)
|
|
151
|
+
return Pipeline(
|
|
152
|
+
tasks,
|
|
153
|
+
max_concurrent=cfg.max_concurrent,
|
|
154
|
+
on_task_error=cfg.on_task_error,
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def build_retriever_from_config(config: AgentForgeConfig) -> Retriever | None:
|
|
159
|
+
"""Resolve + instantiate the top-level `retrieval:` block.
|
|
160
|
+
|
|
161
|
+
feat-021 follow-up. Returns ``None`` when no `retrieval:`
|
|
162
|
+
block is set. Otherwise resolves three sub-components:
|
|
163
|
+
|
|
164
|
+
- ``retrieval.vector_store.driver`` → ``vector_stores``
|
|
165
|
+
category → instantiated `VectorStore`.
|
|
166
|
+
- ``retrieval.embedder.driver`` → ``embeddings`` category →
|
|
167
|
+
instantiated `EmbeddingClient`.
|
|
168
|
+
- ``retrieval.reranker.name`` (optional) → ``rerankers``
|
|
169
|
+
category → instantiated `Reranker`.
|
|
170
|
+
|
|
171
|
+
The three are wired into a `Retriever` with the top-level
|
|
172
|
+
knobs (``top_k`` / ``over_fetch_factor`` / ``batch_size``)
|
|
173
|
+
forwarded to its constructor.
|
|
174
|
+
|
|
175
|
+
Raises:
|
|
176
|
+
ModuleError: a referenced module isn't registered or
|
|
177
|
+
its instance doesn't implement the expected ABC.
|
|
178
|
+
"""
|
|
179
|
+
r = config.retrieval
|
|
180
|
+
if r is None:
|
|
181
|
+
return None
|
|
182
|
+
|
|
183
|
+
store_cls = _resolve_class("vector_stores", r.vector_store.driver)
|
|
184
|
+
store = _instantiate(store_cls, r.vector_store.config)
|
|
185
|
+
if not isinstance(store, VectorStore):
|
|
186
|
+
msg = (
|
|
187
|
+
f"Resolved vector_store {r.vector_store.driver!r} "
|
|
188
|
+
f"({store_cls.__name__}) does not implement VectorStore."
|
|
189
|
+
)
|
|
190
|
+
raise ModuleError(msg)
|
|
191
|
+
|
|
192
|
+
embedder_cls = _resolve_class("embeddings", r.embedder.driver)
|
|
193
|
+
embedder = _instantiate(embedder_cls, r.embedder.config)
|
|
194
|
+
if not isinstance(embedder, EmbeddingClient):
|
|
195
|
+
msg = (
|
|
196
|
+
f"Resolved embedder {r.embedder.driver!r} "
|
|
197
|
+
f"({embedder_cls.__name__}) does not implement EmbeddingClient."
|
|
198
|
+
)
|
|
199
|
+
raise ModuleError(msg)
|
|
200
|
+
|
|
201
|
+
reranker: Reranker | None = None
|
|
202
|
+
if r.reranker is not None:
|
|
203
|
+
reranker_cls = _resolve_class("rerankers", r.reranker.name)
|
|
204
|
+
reranker_instance = _instantiate(reranker_cls, r.reranker.config)
|
|
205
|
+
if not isinstance(reranker_instance, Reranker):
|
|
206
|
+
msg = (
|
|
207
|
+
f"Resolved reranker {r.reranker.name!r} "
|
|
208
|
+
f"({reranker_cls.__name__}) does not implement Reranker."
|
|
209
|
+
)
|
|
210
|
+
raise ModuleError(msg)
|
|
211
|
+
reranker = reranker_instance
|
|
212
|
+
|
|
213
|
+
graph_expansion: GraphExpansion | None = None
|
|
214
|
+
if r.graph_expansion is not None:
|
|
215
|
+
ge_cfg = r.graph_expansion
|
|
216
|
+
graph_store_cls = _resolve_class("graph_stores", ge_cfg.store.driver)
|
|
217
|
+
graph_store_instance = _instantiate(graph_store_cls, ge_cfg.store.config)
|
|
218
|
+
if not isinstance(graph_store_instance, GraphStore):
|
|
219
|
+
msg = (
|
|
220
|
+
f"Resolved graph_store {ge_cfg.store.driver!r} "
|
|
221
|
+
f"({graph_store_cls.__name__}) does not implement GraphStore."
|
|
222
|
+
)
|
|
223
|
+
raise ModuleError(msg)
|
|
224
|
+
graph_expansion = GraphExpansion(
|
|
225
|
+
store=graph_store_instance,
|
|
226
|
+
max_hops=ge_cfg.max_hops,
|
|
227
|
+
edge_types=tuple(ge_cfg.edge_types) if ge_cfg.edge_types is not None else None,
|
|
228
|
+
text_property=ge_cfg.text_property,
|
|
229
|
+
decay=ge_cfg.decay,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
return Retriever(
|
|
233
|
+
store=store,
|
|
234
|
+
embedder=embedder,
|
|
235
|
+
reranker=reranker,
|
|
236
|
+
top_k=r.top_k,
|
|
237
|
+
over_fetch_factor=r.over_fetch_factor,
|
|
238
|
+
batch_size=r.batch_size,
|
|
239
|
+
mode=r.mode,
|
|
240
|
+
rrf_k=r.rrf_k,
|
|
241
|
+
graph_expansion=graph_expansion,
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
def build_tools_from_config(config: AgentForgeConfig) -> list[Tool]:
|
|
246
|
+
"""Resolve tools listed under `agent.tools` (string or dict form)."""
|
|
247
|
+
from agentforge_core.contracts.tool import Tool as ToolBase # noqa: PLC0415
|
|
248
|
+
|
|
249
|
+
tools: list[ToolBase] = []
|
|
250
|
+
for entry in config.agent.tools:
|
|
251
|
+
name = entry if isinstance(entry, str) else next(iter(entry))
|
|
252
|
+
cfg = {} if isinstance(entry, str) else entry[name]
|
|
253
|
+
cls = _resolve_class("tools", name)
|
|
254
|
+
instance = _instantiate(cls, cfg)
|
|
255
|
+
if not isinstance(instance, ToolBase):
|
|
256
|
+
msg = f"Resolved tool {name!r} ({cls.__name__}) does not implement Tool."
|
|
257
|
+
raise ModuleError(msg)
|
|
258
|
+
tools.append(instance)
|
|
259
|
+
return tools
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _resolve_llm(config: AgentForgeConfig) -> LLMClient | str | None:
|
|
263
|
+
"""Pick the LLM definition out of `config.agent.model` /
|
|
264
|
+
`config.providers["default"]`.
|
|
265
|
+
|
|
266
|
+
We hand a string back to `Agent.__init__` when the model is a
|
|
267
|
+
plain `"<provider>:<model>"` string — `Agent` already knows how
|
|
268
|
+
to resolve that. When `agent.model` is missing but
|
|
269
|
+
`providers["default"]` is present, we synthesize the string from
|
|
270
|
+
the named-provider record.
|
|
271
|
+
"""
|
|
272
|
+
raw = config.agent.model
|
|
273
|
+
if isinstance(raw, str):
|
|
274
|
+
return raw
|
|
275
|
+
default = config.providers.get("default")
|
|
276
|
+
if default is None or default.model is None:
|
|
277
|
+
return None
|
|
278
|
+
return f"{default.type}:{default.model}"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _resolve_class(category: str, name: str) -> type:
|
|
282
|
+
return Resolver.global_().resolve(category, name)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _instantiate(cls: type, cfg: dict[str, Any]) -> Any:
|
|
286
|
+
"""Construct an instance of `cls` from a YAML config dict.
|
|
287
|
+
|
|
288
|
+
Preference order:
|
|
289
|
+
|
|
290
|
+
1. ``cls.from_config(**cfg)`` — keyword-friendly factory
|
|
291
|
+
(preferred for new modules; matches the
|
|
292
|
+
`SentenceTransformersReranker.from_config(*, model=...)`
|
|
293
|
+
shape from feat-021).
|
|
294
|
+
2. ``cls.from_config(cfg)`` — legacy dict-positional shape
|
|
295
|
+
(no in-tree callers today; kept as a defensive fallback
|
|
296
|
+
so externally-shipped modules that ship the older shape
|
|
297
|
+
still load).
|
|
298
|
+
3. ``cls(**cfg)`` — plain constructor.
|
|
299
|
+
"""
|
|
300
|
+
from_config = getattr(cls, "from_config", None)
|
|
301
|
+
if callable(from_config):
|
|
302
|
+
try:
|
|
303
|
+
return from_config(**cfg)
|
|
304
|
+
except TypeError:
|
|
305
|
+
return from_config(cfg)
|
|
306
|
+
return cls(**cfg)
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
async def _maybe_init_schema(memory: MemoryStore) -> None:
|
|
310
|
+
init = getattr(memory, "init_schema", None)
|
|
311
|
+
if callable(init):
|
|
312
|
+
await init()
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
__all__ = [
|
|
316
|
+
"build_agent_from_config",
|
|
317
|
+
"build_evaluators_from_config",
|
|
318
|
+
"build_memory_from_config",
|
|
319
|
+
"build_pipeline_from_config",
|
|
320
|
+
"build_retriever_from_config",
|
|
321
|
+
"build_tools_from_config",
|
|
322
|
+
"load_and_build",
|
|
323
|
+
]
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
"""Lock-file + marker-header machinery for feat-011 chunks 3-5.
|
|
2
|
+
|
|
3
|
+
State layout (per spec §4.2):
|
|
4
|
+
|
|
5
|
+
.agentforge-state/
|
|
6
|
+
├── answers.yml # Copier answers, written by Copier itself
|
|
7
|
+
└── managed-files.lock # { path: { hash, source_module,
|
|
8
|
+
source_version,
|
|
9
|
+
forked: bool } }
|
|
10
|
+
|
|
11
|
+
Marker header form (per spec §4.2):
|
|
12
|
+
|
|
13
|
+
AGENTFORGE-MANAGED: <module>@<version> hash:<sha256-prefix>
|
|
14
|
+
|
|
15
|
+
Where `<module>` is `template:<template-name>` for files from
|
|
16
|
+
`agentforge new`, or `<distribution>` for files from
|
|
17
|
+
`agentforge add module`.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import hashlib
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import yaml
|
|
27
|
+
from agentforge_core.production.exceptions import ModuleError
|
|
28
|
+
|
|
29
|
+
_STATE_DIR = Path(".agentforge-state")
|
|
30
|
+
_LOCK_FILE = _STATE_DIR / "managed-files.lock"
|
|
31
|
+
_ANSWERS_FILE = _STATE_DIR / "answers.yml"
|
|
32
|
+
|
|
33
|
+
_MARKER_PREFIX = "AGENTFORGE-MANAGED:"
|
|
34
|
+
_HASH_PREFIX_LEN = 12
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def lock_path(cwd: Path) -> Path:
|
|
38
|
+
return cwd / _LOCK_FILE
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def answers_path(cwd: Path) -> Path:
|
|
42
|
+
return cwd / _ANSWERS_FILE
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def read_lock(cwd: Path) -> dict[str, dict[str, Any]]:
|
|
46
|
+
"""Read the managed-files lock. Empty dict if absent."""
|
|
47
|
+
path = lock_path(cwd)
|
|
48
|
+
if not path.exists():
|
|
49
|
+
return {}
|
|
50
|
+
with path.open() as fh:
|
|
51
|
+
raw = yaml.safe_load(fh) or {}
|
|
52
|
+
if not isinstance(raw, dict):
|
|
53
|
+
raise ModuleError(f"{path} must be a top-level mapping; got {type(raw).__name__}.")
|
|
54
|
+
return raw
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def write_lock(cwd: Path, lock: dict[str, dict[str, Any]]) -> None:
|
|
58
|
+
"""Write the managed-files lock; creates the state dir."""
|
|
59
|
+
path = lock_path(cwd)
|
|
60
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
with path.open("w") as fh:
|
|
62
|
+
yaml.safe_dump(lock, fh, sort_keys=True)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def hash_content(content: str) -> str:
|
|
66
|
+
"""sha256 of `content` UTF-8 encoded. Caller takes a prefix."""
|
|
67
|
+
return hashlib.sha256(content.encode("utf-8")).hexdigest()
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def marker_for(
|
|
71
|
+
suffix: str, source_module: str, source_version: str, hash_prefix: str
|
|
72
|
+
) -> str | None:
|
|
73
|
+
"""Format the marker header line for a file with the given suffix.
|
|
74
|
+
|
|
75
|
+
Returns `None` for file extensions where comment markers aren't
|
|
76
|
+
practical (e.g. binary). Suffixes follow the same per-language
|
|
77
|
+
mapping as feat-010b's manifest applier.
|
|
78
|
+
"""
|
|
79
|
+
body = f"{_MARKER_PREFIX} {source_module}@{source_version} hash:{hash_prefix}"
|
|
80
|
+
if suffix in {".py", ".sh", ".yaml", ".yml", ".toml", ".ini", ".env", ".sql", ".cfg"}:
|
|
81
|
+
return f"# {body}"
|
|
82
|
+
if suffix in {".js", ".ts", ".tsx", ".jsx", ".css"}:
|
|
83
|
+
return f"// {body}"
|
|
84
|
+
if suffix in {".html", ".xml", ".md"}:
|
|
85
|
+
return f"<!-- {body} -->"
|
|
86
|
+
return None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def write_managed_files_lock(
|
|
90
|
+
cwd: Path,
|
|
91
|
+
*,
|
|
92
|
+
template_name: str,
|
|
93
|
+
template_version: str,
|
|
94
|
+
rendered_root: Path | None = None,
|
|
95
|
+
) -> dict[str, dict[str, Any]]:
|
|
96
|
+
"""Walk `cwd`, write a lock entry for every framework-managed
|
|
97
|
+
file, return the lock dict.
|
|
98
|
+
|
|
99
|
+
`rendered_root` defaults to `cwd`; pass a different path when
|
|
100
|
+
running against a Copier-rendered scaffold that isn't the cwd.
|
|
101
|
+
|
|
102
|
+
Files counted as "managed" are every file the template produced.
|
|
103
|
+
Identification is by the framework marker header: any file with
|
|
104
|
+
the `AGENTFORGE-MANAGED:` line at the top counts. For the
|
|
105
|
+
initial scaffold (no markers yet), the caller writes the lock
|
|
106
|
+
from the Copier render's file list — see `prepend_markers`.
|
|
107
|
+
"""
|
|
108
|
+
root = rendered_root if rendered_root is not None else cwd
|
|
109
|
+
lock: dict[str, dict[str, Any]] = {}
|
|
110
|
+
for path in root.rglob("*"):
|
|
111
|
+
if not path.is_file():
|
|
112
|
+
continue
|
|
113
|
+
rel = path.relative_to(root)
|
|
114
|
+
# Skip the state dir itself.
|
|
115
|
+
if rel.parts and rel.parts[0] == _STATE_DIR.name:
|
|
116
|
+
continue
|
|
117
|
+
try:
|
|
118
|
+
content = path.read_text(encoding="utf-8")
|
|
119
|
+
except (UnicodeDecodeError, OSError):
|
|
120
|
+
continue # binary or unreadable — skip
|
|
121
|
+
h = hash_content(content)
|
|
122
|
+
lock[str(rel).replace("\\", "/")] = {
|
|
123
|
+
"hash": h,
|
|
124
|
+
"source_module": f"template:{template_name}",
|
|
125
|
+
"source_version": template_version,
|
|
126
|
+
"forked": False,
|
|
127
|
+
}
|
|
128
|
+
write_lock(cwd, lock)
|
|
129
|
+
return lock
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def prepend_markers(
|
|
133
|
+
cwd: Path,
|
|
134
|
+
*,
|
|
135
|
+
template_name: str,
|
|
136
|
+
template_version: str,
|
|
137
|
+
) -> None:
|
|
138
|
+
"""For every file in the lock that supports a comment-marker
|
|
139
|
+
extension, prepend `# AGENTFORGE-MANAGED: ...` if not already
|
|
140
|
+
present. Idempotent."""
|
|
141
|
+
lock = read_lock(cwd)
|
|
142
|
+
for rel_path, entry in lock.items():
|
|
143
|
+
path = cwd / rel_path
|
|
144
|
+
if not path.exists():
|
|
145
|
+
continue
|
|
146
|
+
suffix = path.suffix
|
|
147
|
+
marker = marker_for(
|
|
148
|
+
suffix,
|
|
149
|
+
entry.get("source_module", f"template:{template_name}"),
|
|
150
|
+
entry.get("source_version", template_version),
|
|
151
|
+
entry["hash"][:_HASH_PREFIX_LEN],
|
|
152
|
+
)
|
|
153
|
+
if marker is None:
|
|
154
|
+
continue
|
|
155
|
+
try:
|
|
156
|
+
content = path.read_text(encoding="utf-8")
|
|
157
|
+
except (UnicodeDecodeError, OSError):
|
|
158
|
+
continue
|
|
159
|
+
if _MARKER_PREFIX in content.split("\n", 1)[0]:
|
|
160
|
+
continue # already marked
|
|
161
|
+
path.write_text(marker + "\n" + content, encoding="utf-8")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def strip_marker(path: Path) -> bool:
|
|
165
|
+
"""Strip the framework marker header (if present) from `path`.
|
|
166
|
+
|
|
167
|
+
Returns True if a marker was stripped. Used by `agentforge fork`.
|
|
168
|
+
"""
|
|
169
|
+
if not path.exists():
|
|
170
|
+
return False
|
|
171
|
+
try:
|
|
172
|
+
content = path.read_text(encoding="utf-8")
|
|
173
|
+
except (UnicodeDecodeError, OSError):
|
|
174
|
+
return False
|
|
175
|
+
lines = content.split("\n", 1)
|
|
176
|
+
if not lines or _MARKER_PREFIX not in lines[0]:
|
|
177
|
+
return False
|
|
178
|
+
rest = lines[1] if len(lines) > 1 else ""
|
|
179
|
+
path.write_text(rest, encoding="utf-8")
|
|
180
|
+
return True
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
def file_status(cwd: Path, rel_path: str, entry: dict[str, Any]) -> str:
|
|
184
|
+
"""Classify a tracked file as 'managed', 'forked', 'drifted', or
|
|
185
|
+
'missing'."""
|
|
186
|
+
if entry.get("forked"):
|
|
187
|
+
return "forked"
|
|
188
|
+
path = cwd / rel_path
|
|
189
|
+
if not path.exists():
|
|
190
|
+
return "missing"
|
|
191
|
+
try:
|
|
192
|
+
content = path.read_text(encoding="utf-8")
|
|
193
|
+
except (UnicodeDecodeError, OSError):
|
|
194
|
+
return "managed"
|
|
195
|
+
# Strip the framework marker line before hashing — markers
|
|
196
|
+
# change the bytes-on-disk but not the underlying content.
|
|
197
|
+
body = _strip_marker_for_hash(content)
|
|
198
|
+
if hash_content(body) == entry["hash"]:
|
|
199
|
+
return "managed"
|
|
200
|
+
return "drifted"
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _strip_marker_for_hash(content: str) -> str:
|
|
204
|
+
"""Drop the leading AGENTFORGE-MANAGED line for hash comparison."""
|
|
205
|
+
if _MARKER_PREFIX not in content.split("\n", 1)[0]:
|
|
206
|
+
return content
|
|
207
|
+
parts = content.split("\n", 1)
|
|
208
|
+
return parts[1] if len(parts) > 1 else ""
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
# ----------------------------------------------------------------------
|
|
212
|
+
# Three-section managed / custom format (feat-019)
|
|
213
|
+
# ----------------------------------------------------------------------
|
|
214
|
+
|
|
215
|
+
END_MANAGED_MARKER = "<!-- agentforge:end-managed -->"
|
|
216
|
+
"""Closes the framework-managed section. Anything after this marker is
|
|
217
|
+
developer-owned (the custom section) and survives upgrades."""
|
|
218
|
+
|
|
219
|
+
CUSTOM_START_MARKER = "<!-- agentforge:custom -->"
|
|
220
|
+
CUSTOM_END_MARKER = "<!-- agentforge:end-custom -->"
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def split_three_section(content: str) -> tuple[str, str]:
|
|
224
|
+
"""Split markdown / text content into (managed, custom).
|
|
225
|
+
|
|
226
|
+
The managed section is everything up to and including the
|
|
227
|
+
`<!-- agentforge:end-managed -->` marker. The custom section is
|
|
228
|
+
everything after that. Returns (managed, custom). When the marker
|
|
229
|
+
is absent, the entire content is treated as managed.
|
|
230
|
+
"""
|
|
231
|
+
if END_MANAGED_MARKER not in content:
|
|
232
|
+
return content, ""
|
|
233
|
+
head, _, tail = content.partition(END_MANAGED_MARKER)
|
|
234
|
+
managed = head + END_MANAGED_MARKER
|
|
235
|
+
custom = tail
|
|
236
|
+
return managed, custom
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def merge_three_section(new_managed: str, existing_custom: str) -> str:
|
|
240
|
+
"""Stitch a freshly rendered managed section onto a preserved
|
|
241
|
+
custom section.
|
|
242
|
+
|
|
243
|
+
`new_managed` should already include the `END_MANAGED_MARKER`. If
|
|
244
|
+
it doesn't, the marker is appended so the on-disk file remains
|
|
245
|
+
parseable by future `split_three_section` calls.
|
|
246
|
+
"""
|
|
247
|
+
managed = new_managed
|
|
248
|
+
if END_MANAGED_MARKER not in managed:
|
|
249
|
+
managed = managed.rstrip() + "\n\n" + END_MANAGED_MARKER + "\n"
|
|
250
|
+
return managed.rstrip() + "\n" + existing_custom.lstrip("\n")
|