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/retrieval.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
"""`Retriever` — high-level adapter over `VectorStore` + `EmbeddingClient`.
|
|
2
|
+
|
|
3
|
+
A vector store on its own takes vectors; a retriever takes *text*
|
|
4
|
+
and routes it through an embedder so callers can think in documents
|
|
5
|
+
and queries instead of raw floats.
|
|
6
|
+
|
|
7
|
+
Typical use:
|
|
8
|
+
|
|
9
|
+
retriever = Retriever(store=store, embedder=embedder, top_k=5)
|
|
10
|
+
await retriever.add_documents([
|
|
11
|
+
"Paris is the capital of France.",
|
|
12
|
+
"The Louvre is in Paris.",
|
|
13
|
+
])
|
|
14
|
+
matches = await retriever.retrieve("Where is the Louvre?")
|
|
15
|
+
|
|
16
|
+
The retriever owns no state of its own — calling `close()` is a
|
|
17
|
+
courtesy that closes the underlying store and embedder for the
|
|
18
|
+
caller. Multi-retriever-over-one-store setups should not call
|
|
19
|
+
`close()` on the retriever.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import logging
|
|
26
|
+
from collections import defaultdict
|
|
27
|
+
from typing import Any, Literal
|
|
28
|
+
|
|
29
|
+
from agentforge_core.contracts.embedding import EmbeddingClient
|
|
30
|
+
from agentforge_core.contracts.reranker import Reranker
|
|
31
|
+
from agentforge_core.contracts.vector_store import VectorStore
|
|
32
|
+
from agentforge_core.values.graph import Path as GraphPath
|
|
33
|
+
from agentforge_core.values.retrieval import GraphExpansion
|
|
34
|
+
from agentforge_core.values.vector import VectorItem, VectorMatch
|
|
35
|
+
from ulid import ULID
|
|
36
|
+
|
|
37
|
+
log = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
RetrieverMode = Literal["vector", "hybrid"]
|
|
40
|
+
"""Retrieval mode: ``"vector"`` (default; cosine search only) or
|
|
41
|
+
``"hybrid"`` (BM25 + cosine fused via Reciprocal Rank Fusion).
|
|
42
|
+
|
|
43
|
+
Hybrid mode requires the underlying ``VectorStore`` to declare the
|
|
44
|
+
``"hybrid_search"`` capability (feat-022)."""
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class Retriever:
|
|
48
|
+
"""Wraps `VectorStore` + `EmbeddingClient` for text-in / text-out RAG.
|
|
49
|
+
|
|
50
|
+
Args:
|
|
51
|
+
store: Backing `VectorStore`. Its `dimensions()` must match
|
|
52
|
+
`embedder.dimensions()`.
|
|
53
|
+
embedder: Backing `EmbeddingClient`.
|
|
54
|
+
top_k: Default match count returned by `retrieve()`. Callers
|
|
55
|
+
can override per-call via the `top_k` kwarg.
|
|
56
|
+
batch_size: Maximum texts per embedding call when adding
|
|
57
|
+
documents. Bedrock Titan loops one-at-a-time anyway, but
|
|
58
|
+
other providers (Cohere, OpenAI) batch natively; tuning
|
|
59
|
+
this is a per-provider concern.
|
|
60
|
+
reranker: Optional `Reranker` to apply after the initial
|
|
61
|
+
vector search. When set, `retrieve()` pulls
|
|
62
|
+
``top_k * over_fetch_factor`` candidates from the store
|
|
63
|
+
and reranks them down to ``top_k``. None disables
|
|
64
|
+
reranking (feat-021 default).
|
|
65
|
+
over_fetch_factor: Multiplier for the candidate pool size
|
|
66
|
+
when a reranker is configured. Default 3 (Cohere /
|
|
67
|
+
Voyage best practice). Set to 1 to disable over-fetch
|
|
68
|
+
even when a reranker is set; ignored when
|
|
69
|
+
``reranker is None``.
|
|
70
|
+
|
|
71
|
+
Raises:
|
|
72
|
+
ValueError: store and embedder dimensions don't match,
|
|
73
|
+
``top_k`` / ``batch_size`` / ``over_fetch_factor`` are
|
|
74
|
+
not positive.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
*,
|
|
80
|
+
store: VectorStore,
|
|
81
|
+
embedder: EmbeddingClient,
|
|
82
|
+
top_k: int = 5,
|
|
83
|
+
batch_size: int = 32,
|
|
84
|
+
reranker: Reranker | None = None,
|
|
85
|
+
over_fetch_factor: int = 3,
|
|
86
|
+
mode: RetrieverMode = "vector",
|
|
87
|
+
rrf_k: int = 60,
|
|
88
|
+
graph_expansion: GraphExpansion | None = None,
|
|
89
|
+
) -> None:
|
|
90
|
+
if top_k < 1:
|
|
91
|
+
raise ValueError(f"top_k must be >= 1, got {top_k}")
|
|
92
|
+
if batch_size < 1:
|
|
93
|
+
raise ValueError(f"batch_size must be >= 1, got {batch_size}")
|
|
94
|
+
if over_fetch_factor < 1:
|
|
95
|
+
raise ValueError(f"over_fetch_factor must be >= 1, got {over_fetch_factor}")
|
|
96
|
+
if rrf_k < 1:
|
|
97
|
+
raise ValueError(f"rrf_k must be >= 1, got {rrf_k}")
|
|
98
|
+
if mode not in ("vector", "hybrid"):
|
|
99
|
+
raise ValueError(f"mode must be 'vector' or 'hybrid', got {mode!r}")
|
|
100
|
+
if mode == "hybrid" and not store.supports("hybrid_search"):
|
|
101
|
+
raise ValueError(
|
|
102
|
+
f"Retriever(mode='hybrid') requires a VectorStore that "
|
|
103
|
+
f"declares the 'hybrid_search' capability; "
|
|
104
|
+
f"{type(store).__name__} does not."
|
|
105
|
+
)
|
|
106
|
+
if store.dimensions() != embedder.dimensions():
|
|
107
|
+
raise ValueError(
|
|
108
|
+
f"store dimensions ({store.dimensions()}) do not match "
|
|
109
|
+
f"embedder dimensions ({embedder.dimensions()})"
|
|
110
|
+
)
|
|
111
|
+
self._store = store
|
|
112
|
+
self._embedder = embedder
|
|
113
|
+
self._top_k = top_k
|
|
114
|
+
self._batch_size = batch_size
|
|
115
|
+
self._reranker = reranker
|
|
116
|
+
self._over_fetch_factor = over_fetch_factor
|
|
117
|
+
self._mode: RetrieverMode = mode
|
|
118
|
+
self._rrf_k = rrf_k
|
|
119
|
+
self._graph_expansion = graph_expansion
|
|
120
|
+
|
|
121
|
+
@property
|
|
122
|
+
def store(self) -> VectorStore:
|
|
123
|
+
return self._store
|
|
124
|
+
|
|
125
|
+
@property
|
|
126
|
+
def embedder(self) -> EmbeddingClient:
|
|
127
|
+
return self._embedder
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def reranker(self) -> Reranker | None:
|
|
131
|
+
return self._reranker
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def mode(self) -> RetrieverMode:
|
|
135
|
+
return self._mode
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def rrf_k(self) -> int:
|
|
139
|
+
return self._rrf_k
|
|
140
|
+
|
|
141
|
+
@property
|
|
142
|
+
def graph_expansion(self) -> GraphExpansion | None:
|
|
143
|
+
return self._graph_expansion
|
|
144
|
+
|
|
145
|
+
async def add_documents(
|
|
146
|
+
self,
|
|
147
|
+
texts: list[str],
|
|
148
|
+
*,
|
|
149
|
+
ids: list[str] | None = None,
|
|
150
|
+
metadata: list[dict[str, Any]] | None = None,
|
|
151
|
+
) -> list[str]:
|
|
152
|
+
"""Embed and upsert `texts` into the store.
|
|
153
|
+
|
|
154
|
+
Args:
|
|
155
|
+
texts: One or more documents to index. Empty list is a no-op.
|
|
156
|
+
ids: Optional caller-supplied ids. If omitted, ULIDs are
|
|
157
|
+
generated. Length must match `texts`.
|
|
158
|
+
metadata: Optional per-document metadata. Length must match
|
|
159
|
+
`texts`. Defaults to empty dict per document.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
The list of ids actually stored (caller-supplied or
|
|
163
|
+
generated), in the order of the input texts.
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
ValueError: `ids` or `metadata` length disagrees with `texts`.
|
|
167
|
+
"""
|
|
168
|
+
if not texts:
|
|
169
|
+
return []
|
|
170
|
+
if ids is not None and len(ids) != len(texts):
|
|
171
|
+
raise ValueError(f"ids has {len(ids)} entries but texts has {len(texts)}")
|
|
172
|
+
if metadata is not None and len(metadata) != len(texts):
|
|
173
|
+
raise ValueError(f"metadata has {len(metadata)} entries but texts has {len(texts)}")
|
|
174
|
+
|
|
175
|
+
resolved_ids = ids if ids is not None else [str(ULID()) for _ in texts]
|
|
176
|
+
resolved_meta = metadata if metadata is not None else [{} for _ in texts]
|
|
177
|
+
|
|
178
|
+
# Embed in batches; Cohere supports native batching, Titan
|
|
179
|
+
# loops internally — driver decides the actual fan-out.
|
|
180
|
+
items: list[VectorItem] = []
|
|
181
|
+
for start in range(0, len(texts), self._batch_size):
|
|
182
|
+
chunk = texts[start : start + self._batch_size]
|
|
183
|
+
response = await self._embedder.embed(chunk)
|
|
184
|
+
for offset, vector in enumerate(response.vectors):
|
|
185
|
+
global_idx = start + offset
|
|
186
|
+
items.append(
|
|
187
|
+
VectorItem(
|
|
188
|
+
id=resolved_ids[global_idx],
|
|
189
|
+
vector=tuple(vector),
|
|
190
|
+
text=chunk[offset],
|
|
191
|
+
metadata=resolved_meta[global_idx],
|
|
192
|
+
)
|
|
193
|
+
)
|
|
194
|
+
|
|
195
|
+
await self._store.upsert(items)
|
|
196
|
+
return resolved_ids
|
|
197
|
+
|
|
198
|
+
async def retrieve(
|
|
199
|
+
self,
|
|
200
|
+
query: str,
|
|
201
|
+
*,
|
|
202
|
+
top_k: int | None = None,
|
|
203
|
+
filter_metadata: dict[str, Any] | None = None,
|
|
204
|
+
) -> list[VectorMatch]:
|
|
205
|
+
"""Embed `query` and return the top matches from the store.
|
|
206
|
+
|
|
207
|
+
When a `Reranker` is configured, the retriever first pulls
|
|
208
|
+
``top_k * over_fetch_factor`` candidates from the vector
|
|
209
|
+
store, then reranks them down to ``top_k``. Without a
|
|
210
|
+
reranker the original ``top_k`` candidates are returned
|
|
211
|
+
as-is.
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
query: The user's question / prompt to embed and search.
|
|
215
|
+
top_k: Override the constructor's default. Must be >= 1.
|
|
216
|
+
filter_metadata: Conjunctive equality filter on items'
|
|
217
|
+
metadata (forwarded to `VectorStore.search`).
|
|
218
|
+
|
|
219
|
+
Raises:
|
|
220
|
+
ValueError: `top_k` < 1.
|
|
221
|
+
"""
|
|
222
|
+
limit = top_k if top_k is not None else self._top_k
|
|
223
|
+
if limit < 1:
|
|
224
|
+
raise ValueError(f"top_k must be >= 1, got {limit}")
|
|
225
|
+
|
|
226
|
+
# Stage 1 — base retrieval (vector or hybrid). Over-fetch when
|
|
227
|
+
# a reranker is set so the reranker has a wider candidate pool;
|
|
228
|
+
# otherwise pull exactly `limit` seeds.
|
|
229
|
+
candidate_width = limit * self._over_fetch_factor if self._reranker is not None else limit
|
|
230
|
+
if self._mode == "hybrid":
|
|
231
|
+
candidates = await self._retrieve_hybrid_candidates(
|
|
232
|
+
query, candidate_width=candidate_width, filter_metadata=filter_metadata
|
|
233
|
+
)
|
|
234
|
+
else:
|
|
235
|
+
candidates = await self._retrieve_vector_candidates(
|
|
236
|
+
query, candidate_width=candidate_width, filter_metadata=filter_metadata
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
# Stage 2 — optional graph expansion. Augments the candidate
|
|
240
|
+
# set with N-hop neighbours of the seed hits. When no reranker
|
|
241
|
+
# is configured, the expanded set is returned as-is (top_k is
|
|
242
|
+
# treated as a minimum direct-hit count, not a hard cap).
|
|
243
|
+
if self._graph_expansion is not None:
|
|
244
|
+
candidates = await self._expand_via_graph(
|
|
245
|
+
candidates,
|
|
246
|
+
expansion=self._graph_expansion,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
# Stage 3 — optional rerank narrows to top_k. Without a
|
|
250
|
+
# reranker the candidate set is returned in seed-then-expansion
|
|
251
|
+
# order; when no graph_expansion is set the slice is exactly
|
|
252
|
+
# top_k, when graph_expansion is set the expansion neighbours
|
|
253
|
+
# are appended after the top_k seeds.
|
|
254
|
+
if self._reranker is None:
|
|
255
|
+
if self._graph_expansion is None:
|
|
256
|
+
return candidates[:limit]
|
|
257
|
+
return candidates
|
|
258
|
+
return await self._reranker.rerank(query, candidates, top_k=limit)
|
|
259
|
+
|
|
260
|
+
async def _retrieve_vector_candidates(
|
|
261
|
+
self,
|
|
262
|
+
query: str,
|
|
263
|
+
*,
|
|
264
|
+
candidate_width: int,
|
|
265
|
+
filter_metadata: dict[str, Any] | None,
|
|
266
|
+
) -> list[VectorMatch]:
|
|
267
|
+
"""Pure vector top-`candidate_width` retrieval (no rerank)."""
|
|
268
|
+
response = await self._embedder.embed([query])
|
|
269
|
+
query_vector = tuple(response.vectors[0])
|
|
270
|
+
return await self._store.search(
|
|
271
|
+
query_vector,
|
|
272
|
+
limit=candidate_width,
|
|
273
|
+
filter_metadata=filter_metadata,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
async def _retrieve_hybrid_candidates(
|
|
277
|
+
self,
|
|
278
|
+
query: str,
|
|
279
|
+
*,
|
|
280
|
+
candidate_width: int,
|
|
281
|
+
filter_metadata: dict[str, Any] | None,
|
|
282
|
+
) -> list[VectorMatch]:
|
|
283
|
+
"""Hybrid retrieval: vector + lexical fused via RRF.
|
|
284
|
+
|
|
285
|
+
Pulls ``candidate_width`` from each path in parallel and fuses
|
|
286
|
+
by rank. Reranking is the caller's responsibility (deferred to
|
|
287
|
+
the unified pipeline in :meth:`retrieve`).
|
|
288
|
+
"""
|
|
289
|
+
response = await self._embedder.embed([query])
|
|
290
|
+
query_vector = tuple(response.vectors[0])
|
|
291
|
+
vec_task = self._store.search(
|
|
292
|
+
query_vector, limit=candidate_width, filter_metadata=filter_metadata
|
|
293
|
+
)
|
|
294
|
+
lex_task = self._store.lexical_search(
|
|
295
|
+
query, limit=candidate_width, filter_metadata=filter_metadata
|
|
296
|
+
)
|
|
297
|
+
vec_matches, lex_matches = await asyncio.gather(vec_task, lex_task)
|
|
298
|
+
return self._rrf_fuse(vec_matches, lex_matches, limit=candidate_width)
|
|
299
|
+
|
|
300
|
+
async def _expand_via_graph(
|
|
301
|
+
self,
|
|
302
|
+
seeds: list[VectorMatch],
|
|
303
|
+
*,
|
|
304
|
+
expansion: GraphExpansion,
|
|
305
|
+
) -> list[VectorMatch]:
|
|
306
|
+
"""Expand each seed by traversing the graph up to
|
|
307
|
+
``expansion.max_hops`` hops; merge results with the seeds.
|
|
308
|
+
|
|
309
|
+
Direct seeds keep their score + order at the head; expansion
|
|
310
|
+
nodes follow, sorted by decayed score desc. Dedup is by id —
|
|
311
|
+
the seed wins.
|
|
312
|
+
"""
|
|
313
|
+
if not seeds:
|
|
314
|
+
return []
|
|
315
|
+
|
|
316
|
+
async def _traverse(seed: VectorMatch) -> tuple[VectorMatch, list[GraphPath]]:
|
|
317
|
+
try:
|
|
318
|
+
paths = await expansion.store.traverse(
|
|
319
|
+
start_id=seed.id,
|
|
320
|
+
edge_types=expansion.edge_types,
|
|
321
|
+
max_depth=expansion.max_hops,
|
|
322
|
+
limit=max(len(seeds), 1) * expansion.max_hops * 4,
|
|
323
|
+
)
|
|
324
|
+
except Exception:
|
|
325
|
+
log.debug("graph traverse failed for seed %s", seed.id, exc_info=True)
|
|
326
|
+
return seed, []
|
|
327
|
+
return seed, paths
|
|
328
|
+
|
|
329
|
+
results = await asyncio.gather(*(_traverse(s) for s in seeds))
|
|
330
|
+
|
|
331
|
+
seed_ids = {s.id for s in seeds}
|
|
332
|
+
# Keyed by node id; track best (highest) decayed score per id.
|
|
333
|
+
expanded_by_id: dict[str, tuple[float, int, VectorMatch]] = {}
|
|
334
|
+
for seed, paths in results:
|
|
335
|
+
if not paths:
|
|
336
|
+
log.debug("no graph paths found for seed %s", seed.id)
|
|
337
|
+
continue
|
|
338
|
+
for path in paths:
|
|
339
|
+
# path.nodes[0] is the seed; nodes[i] is at depth i.
|
|
340
|
+
for depth, node in enumerate(path.nodes):
|
|
341
|
+
if depth == 0:
|
|
342
|
+
continue
|
|
343
|
+
if node.id in seed_ids:
|
|
344
|
+
continue
|
|
345
|
+
score = float(seed.score) * (float(expansion.decay) ** depth)
|
|
346
|
+
prior = expanded_by_id.get(node.id)
|
|
347
|
+
if prior is not None and prior[0] >= score:
|
|
348
|
+
continue
|
|
349
|
+
text = str(node.properties.get(expansion.text_property, ""))
|
|
350
|
+
merged_meta: dict[str, Any] = dict(node.properties)
|
|
351
|
+
merged_meta["agentforge.expanded_from"] = seed.id
|
|
352
|
+
merged_meta["agentforge.hop"] = depth
|
|
353
|
+
expanded_by_id[node.id] = (
|
|
354
|
+
score,
|
|
355
|
+
depth,
|
|
356
|
+
VectorMatch(
|
|
357
|
+
id=node.id,
|
|
358
|
+
text=text,
|
|
359
|
+
metadata=merged_meta,
|
|
360
|
+
score=score,
|
|
361
|
+
),
|
|
362
|
+
)
|
|
363
|
+
|
|
364
|
+
expansion_matches = sorted(
|
|
365
|
+
(m for _, _, m in expanded_by_id.values()),
|
|
366
|
+
key=lambda m: m.score,
|
|
367
|
+
reverse=True,
|
|
368
|
+
)
|
|
369
|
+
return list(seeds) + expansion_matches
|
|
370
|
+
|
|
371
|
+
def _rrf_fuse(
|
|
372
|
+
self,
|
|
373
|
+
vec: list[VectorMatch],
|
|
374
|
+
lex: list[VectorMatch],
|
|
375
|
+
*,
|
|
376
|
+
limit: int,
|
|
377
|
+
) -> list[VectorMatch]:
|
|
378
|
+
"""Fuse two ranked lists via Reciprocal Rank Fusion.
|
|
379
|
+
|
|
380
|
+
``RRF_score(d) = Σ_L 1 / (k + rank_L(d))`` where ``rank_L(d)``
|
|
381
|
+
is the 1-indexed rank of ``d`` in list ``L`` (omitted from
|
|
382
|
+
the sum when ``d`` is absent). Cormack/Clarke/Büttcher 2009.
|
|
383
|
+
The fused score is written onto the returned ``VectorMatch``
|
|
384
|
+
objects; callers that need the per-path scores must inspect
|
|
385
|
+
the inputs themselves.
|
|
386
|
+
"""
|
|
387
|
+
scores: dict[str, float] = defaultdict(float)
|
|
388
|
+
matches_by_id: dict[str, VectorMatch] = {}
|
|
389
|
+
for rank, m in enumerate(vec, start=1):
|
|
390
|
+
scores[m.id] += 1.0 / (self._rrf_k + rank)
|
|
391
|
+
matches_by_id[m.id] = m
|
|
392
|
+
for rank, m in enumerate(lex, start=1):
|
|
393
|
+
scores[m.id] += 1.0 / (self._rrf_k + rank)
|
|
394
|
+
matches_by_id.setdefault(m.id, m)
|
|
395
|
+
fused_ids = sorted(scores.items(), key=lambda kv: kv[1], reverse=True)[:limit]
|
|
396
|
+
return [matches_by_id[id_].model_copy(update={"score": score}) for id_, score in fused_ids]
|
|
397
|
+
|
|
398
|
+
async def close(self) -> None:
|
|
399
|
+
"""Close the underlying store, embedder, and reranker.
|
|
400
|
+
|
|
401
|
+
Convenience for callers that own all three. If the retriever
|
|
402
|
+
shares any of them with other components, do NOT call this.
|
|
403
|
+
"""
|
|
404
|
+
await self._store.close()
|
|
405
|
+
await self._embedder.close()
|
|
406
|
+
if self._reranker is not None:
|
|
407
|
+
await self._reranker.close()
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
__all__ = ["Retriever"]
|
agentforge/runtime.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""`RuntimeContext` — per-run execution context shared with strategies.
|
|
2
|
+
|
|
3
|
+
Lives in `agentforge` (not `agentforge-core`) because it references
|
|
4
|
+
the framework's runtime concerns — `BudgetPolicy`, the active
|
|
5
|
+
`LLMClient`, the agent's tool catalogue, the active `MemoryStore`.
|
|
6
|
+
`agentforge-core` defines those contracts; `agentforge` consumes
|
|
7
|
+
them.
|
|
8
|
+
|
|
9
|
+
`Agent.run()` constructs a `RuntimeContext` per run and stores it
|
|
10
|
+
on `state.metadata` under `RUNTIME_KEY`. Strategies access it via
|
|
11
|
+
`agentforge.strategies._base.get_runtime(state)`.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import TYPE_CHECKING
|
|
18
|
+
|
|
19
|
+
from agentforge_core.contracts.graph_store import GraphStore
|
|
20
|
+
from agentforge_core.contracts.llm import LLMClient
|
|
21
|
+
from agentforge_core.contracts.memory import MemoryStore
|
|
22
|
+
from agentforge_core.contracts.tool import Tool
|
|
23
|
+
from agentforge_core.production.budget import BudgetPolicy
|
|
24
|
+
|
|
25
|
+
if TYPE_CHECKING:
|
|
26
|
+
from agentforge.retrieval import Retriever
|
|
27
|
+
|
|
28
|
+
RUNTIME_KEY = "__agentforge_runtime__"
|
|
29
|
+
"""Documented key under `AgentState.metadata` where the runtime is bound."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@dataclass(frozen=True, slots=True)
|
|
33
|
+
class RuntimeContext:
|
|
34
|
+
"""Per-run execution context.
|
|
35
|
+
|
|
36
|
+
Constructed by `Agent.run()` once per run and bound to
|
|
37
|
+
`state.metadata[RUNTIME_KEY]`. Strategies read via
|
|
38
|
+
`get_runtime(state)`.
|
|
39
|
+
|
|
40
|
+
Frozen — once bound, the context does not change for the
|
|
41
|
+
duration of the run. `BudgetPolicy` is itself mutable (the
|
|
42
|
+
strategy calls `.check()`, `.reserve()`, `.commit()`); the
|
|
43
|
+
immutability here is on the *binding*, not on the budget's
|
|
44
|
+
internal counters.
|
|
45
|
+
"""
|
|
46
|
+
|
|
47
|
+
llm: LLMClient
|
|
48
|
+
tools: tuple[Tool, ...]
|
|
49
|
+
memory: MemoryStore
|
|
50
|
+
budget: BudgetPolicy
|
|
51
|
+
system_prompt: str | None = None
|
|
52
|
+
retriever: Retriever | None = None
|
|
53
|
+
"""Optional RAG retriever (feat-007). Strategies that want to
|
|
54
|
+
ground responses in indexed documents check `runtime.retriever
|
|
55
|
+
is not None` and call `retriever.retrieve(query)`."""
|
|
56
|
+
graph_store: GraphStore | None = None
|
|
57
|
+
"""Optional knowledge-graph store (feat-009). Strategies that want
|
|
58
|
+
to traverse a graph during reasoning check `runtime.graph_store is
|
|
59
|
+
not None` and call `graph_store.traverse(...)` or `.match(...)`.
|
|
60
|
+
|
|
61
|
+
Usually unset for vanilla agents; populated when the user passes
|
|
62
|
+
`Agent(graph_store=...)` or configures a graph driver via
|
|
63
|
+
`agentforge.yaml`."""
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Reasoning strategies — ReAct, Plan-Execute, Tree-of-Thoughts, Multi-Agent.
|
|
2
|
+
|
|
3
|
+
All four shipped stable from v0.1 per feat-002 / ADR-0008.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from agentforge.strategies._base import (
|
|
9
|
+
StrategyBase,
|
|
10
|
+
get_runtime,
|
|
11
|
+
)
|
|
12
|
+
from agentforge.strategies._plan import Plan, PlanStep
|
|
13
|
+
from agentforge.strategies.multi_agent import MultiAgentSupervisor
|
|
14
|
+
from agentforge.strategies.plan_execute import PlanExecuteLoop
|
|
15
|
+
from agentforge.strategies.react import ReActLoop
|
|
16
|
+
from agentforge.strategies.tot import TreeOfThoughts
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"MultiAgentSupervisor",
|
|
20
|
+
"Plan",
|
|
21
|
+
"PlanExecuteLoop",
|
|
22
|
+
"PlanStep",
|
|
23
|
+
"ReActLoop",
|
|
24
|
+
"StrategyBase",
|
|
25
|
+
"TreeOfThoughts",
|
|
26
|
+
"get_runtime",
|
|
27
|
+
]
|