agentforge-py 0.2.1__py3-none-any.whl

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