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,262 @@
1
+ """`InMemoryGraphStore` — process-local `GraphStore` reference impl.
2
+
3
+ Plain dict of nodes plus an adjacency list. Suitable for tests, demos,
4
+ and small knowledge graphs (~thousands of nodes). Production
5
+ deployments swap to `agentforge-memory-neo4j` or
6
+ `agentforge-memory-surrealdb` — both pass the same
7
+ `run_graph_conformance` suite.
8
+
9
+ Design notes:
10
+ - All operations are O(degree) or smaller; pattern matching iterates
11
+ edges. Fine for small graphs, sluggish past tens of thousands of
12
+ edges. Native graph DBs declare richer capabilities (`"cypher"`,
13
+ `"transactions"`); this one does not.
14
+ - Idempotent upserts: re-adding a node or edge replaces its
15
+ properties. The contract requires this for both shapes.
16
+ - Edges that would orphan are blocked by `delete_node(cascade=False)`.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ from collections import OrderedDict, deque
22
+ from typing import Any, Literal
23
+
24
+ from agentforge_core.contracts.graph_store import GraphStore
25
+ from agentforge_core.values.graph import (
26
+ GraphEdge,
27
+ GraphNode,
28
+ GraphPattern,
29
+ GraphSegment,
30
+ Path,
31
+ )
32
+
33
+
34
+ class InMemoryGraphStore(GraphStore):
35
+ """In-process `GraphStore` backed by dicts + adjacency lists."""
36
+
37
+ def __init__(self) -> None:
38
+ self._nodes: OrderedDict[str, GraphNode] = OrderedDict()
39
+ # Outgoing edges keyed by src; ordered for determinism.
40
+ self._out: dict[str, list[GraphEdge]] = {}
41
+ # Incoming edges keyed by dst; same ordering.
42
+ self._in: dict[str, list[GraphEdge]] = {}
43
+
44
+ async def add_node(self, node: GraphNode) -> None:
45
+ self._nodes[node.id] = node
46
+ self._out.setdefault(node.id, [])
47
+ self._in.setdefault(node.id, [])
48
+
49
+ async def add_edge(self, edge: GraphEdge) -> None:
50
+ if edge.src not in self._nodes:
51
+ msg = f"add_edge: source node {edge.src!r} does not exist"
52
+ raise ValueError(msg)
53
+ if edge.dst not in self._nodes:
54
+ msg = f"add_edge: destination node {edge.dst!r} does not exist"
55
+ raise ValueError(msg)
56
+ # Idempotent on (src, dst, edge_type): replace existing entry.
57
+ self._out[edge.src] = [
58
+ e
59
+ for e in self._out[edge.src]
60
+ if not (e.dst == edge.dst and e.edge_type == edge.edge_type)
61
+ ]
62
+ self._in[edge.dst] = [
63
+ e
64
+ for e in self._in[edge.dst]
65
+ if not (e.src == edge.src and e.edge_type == edge.edge_type)
66
+ ]
67
+ self._out[edge.src].append(edge)
68
+ self._in[edge.dst].append(edge)
69
+
70
+ async def get_node(self, node_id: str) -> GraphNode | None:
71
+ return self._nodes.get(node_id)
72
+
73
+ async def get_edges(
74
+ self,
75
+ node_id: str,
76
+ *,
77
+ edge_type: str | None = None,
78
+ direction: Literal["out", "in", "any"] = "out",
79
+ ) -> list[GraphEdge]:
80
+ if node_id not in self._nodes:
81
+ return []
82
+ candidates: list[GraphEdge]
83
+ if direction == "out":
84
+ candidates = list(self._out.get(node_id, ()))
85
+ elif direction == "in":
86
+ candidates = list(self._in.get(node_id, ()))
87
+ else:
88
+ candidates = [*self._out.get(node_id, ()), *self._in.get(node_id, ())]
89
+ if edge_type is not None:
90
+ candidates = [e for e in candidates if e.edge_type == edge_type]
91
+ return candidates
92
+
93
+ async def match(self, pattern: GraphPattern, *, limit: int = 50) -> list[Path]:
94
+ if limit < 1:
95
+ msg = f"limit must be >= 1, got {limit}"
96
+ raise ValueError(msg)
97
+
98
+ # Seed with every node — drivers like Neo4j let the optimiser
99
+ # pick a driving node; we walk all candidates that satisfy the
100
+ # first segment's source label + node filter.
101
+ seg0 = pattern.segments[0]
102
+ node_filter0 = pattern.node_filters[0] if pattern.node_filters else {}
103
+ starts = [n for n in self._nodes.values() if _node_matches(n, seg0.src_label, node_filter0)]
104
+
105
+ results: list[Path] = []
106
+ for start in starts:
107
+ self._walk_pattern(
108
+ start=start,
109
+ pattern=pattern,
110
+ seg_index=0,
111
+ visited_nodes=[start],
112
+ visited_edges=[],
113
+ results=results,
114
+ limit=limit,
115
+ )
116
+ if len(results) >= limit:
117
+ break
118
+ return results[:limit]
119
+
120
+ def _walk_pattern(
121
+ self,
122
+ *,
123
+ start: GraphNode,
124
+ pattern: GraphPattern,
125
+ seg_index: int,
126
+ visited_nodes: list[GraphNode],
127
+ visited_edges: list[GraphEdge],
128
+ results: list[Path],
129
+ limit: int,
130
+ ) -> None:
131
+ if seg_index == len(pattern.segments):
132
+ results.append(Path(nodes=tuple(visited_nodes), edges=tuple(visited_edges)))
133
+ return
134
+ if len(results) >= limit:
135
+ return
136
+
137
+ seg = pattern.segments[seg_index]
138
+ next_filter = pattern.node_filters[seg_index + 1] if pattern.node_filters else {}
139
+ for edge in self._edges_for_segment(start.id, seg):
140
+ other_id = edge.dst if edge.src == start.id else edge.src
141
+ other = self._nodes.get(other_id)
142
+ if other is None:
143
+ continue
144
+ if not _node_matches(other, seg.dst_label, next_filter):
145
+ continue
146
+ self._walk_pattern(
147
+ start=other,
148
+ pattern=pattern,
149
+ seg_index=seg_index + 1,
150
+ visited_nodes=[*visited_nodes, other],
151
+ visited_edges=[*visited_edges, edge],
152
+ results=results,
153
+ limit=limit,
154
+ )
155
+ if len(results) >= limit:
156
+ return
157
+
158
+ def _edges_for_segment(self, node_id: str, seg: GraphSegment) -> list[GraphEdge]:
159
+ edges: list[GraphEdge]
160
+ if seg.direction == "out":
161
+ edges = list(self._out.get(node_id, ()))
162
+ elif seg.direction == "in":
163
+ edges = list(self._in.get(node_id, ()))
164
+ else:
165
+ edges = [*self._out.get(node_id, ()), *self._in.get(node_id, ())]
166
+ if seg.edge_type is not None:
167
+ edges = [e for e in edges if e.edge_type == seg.edge_type]
168
+ return edges
169
+
170
+ async def traverse(
171
+ self,
172
+ start_id: str,
173
+ *,
174
+ edge_types: tuple[str, ...] | None = None,
175
+ max_depth: int = 3,
176
+ limit: int = 50,
177
+ ) -> list[Path]:
178
+ if max_depth < 1:
179
+ msg = f"max_depth must be >= 1, got {max_depth}"
180
+ raise ValueError(msg)
181
+ if limit < 1:
182
+ msg = f"limit must be >= 1, got {limit}"
183
+ raise ValueError(msg)
184
+ if start_id not in self._nodes:
185
+ return []
186
+
187
+ start_node = self._nodes[start_id]
188
+ results: list[Path] = []
189
+ # BFS frontier of (current_node, path-so-far-nodes, path-so-far-edges)
190
+ frontier: deque[tuple[str, list[GraphNode], list[GraphEdge]]] = deque(
191
+ [(start_id, [start_node], [])]
192
+ )
193
+ while frontier and len(results) < limit:
194
+ current, path_nodes, path_edges = frontier.popleft()
195
+ if len(path_edges) >= max_depth:
196
+ continue
197
+ for edge in self._out.get(current, ()):
198
+ if edge_types is not None and edge.edge_type not in edge_types:
199
+ continue
200
+ target = self._nodes.get(edge.dst)
201
+ if target is None:
202
+ continue
203
+ # Avoid cycles: don't revisit a node already in this path.
204
+ if any(n.id == target.id for n in path_nodes):
205
+ continue
206
+ new_nodes = [*path_nodes, target]
207
+ new_edges = [*path_edges, edge]
208
+ results.append(Path(nodes=tuple(new_nodes), edges=tuple(new_edges)))
209
+ if len(results) >= limit:
210
+ break
211
+ frontier.append((target.id, new_nodes, new_edges))
212
+ return results[:limit]
213
+
214
+ async def delete_node(self, node_id: str, *, cascade: bool = False) -> bool:
215
+ if node_id not in self._nodes:
216
+ return False
217
+ outgoing = self._out.get(node_id, [])
218
+ incoming = self._in.get(node_id, [])
219
+ if not cascade and (outgoing or incoming):
220
+ msg = (
221
+ f"delete_node: {node_id!r} has {len(outgoing) + len(incoming)} "
222
+ f"incident edge(s); pass cascade=True to remove them"
223
+ )
224
+ raise ValueError(msg)
225
+ # Cascade removes incident edges from the *other* side too.
226
+ for e in list(outgoing):
227
+ self._in[e.dst] = [x for x in self._in[e.dst] if x is not e]
228
+ for e in list(incoming):
229
+ self._out[e.src] = [x for x in self._out[e.src] if x is not e]
230
+ self._out.pop(node_id, None)
231
+ self._in.pop(node_id, None)
232
+ self._nodes.pop(node_id, None)
233
+ return True
234
+
235
+ async def delete_edge(self, src: str, dst: str, *, edge_type: str) -> bool:
236
+ out = self._out.get(src, [])
237
+ match = next((e for e in out if e.dst == dst and e.edge_type == edge_type), None)
238
+ if match is None:
239
+ return False
240
+ self._out[src] = [e for e in out if e is not match]
241
+ self._in[dst] = [e for e in self._in.get(dst, ()) if e is not match]
242
+ return True
243
+
244
+ async def close(self) -> None:
245
+ self._nodes.clear()
246
+ self._out.clear()
247
+ self._in.clear()
248
+
249
+
250
+ # ----------------------------------------------------------------------
251
+ # Helpers
252
+ # ----------------------------------------------------------------------
253
+
254
+
255
+ def _node_matches(
256
+ node: GraphNode,
257
+ label: str | None,
258
+ properties_filter: dict[str, Any],
259
+ ) -> bool:
260
+ if label is not None and label not in node.labels:
261
+ return False
262
+ return all(node.properties.get(k) == v for k, v in properties_filter.items())
@@ -0,0 +1,167 @@
1
+ """`InMemoryVectorStore` — process-local `VectorStore` reference impl.
2
+
3
+ Brute-force cosine similarity over a Python dict. Suitable for tests,
4
+ demos, and small RAG corpora (~hundreds of items). Production
5
+ deployments swap to `agentforge-memory-sqlite` (zero-deps persistent)
6
+ or `agentforge-memory-postgres` (scaled, native ANN) — both pass the
7
+ same `run_vector_conformance` suite.
8
+
9
+ Design notes:
10
+
11
+ - Dimensions are fixed at construction. Mismatched vectors raise
12
+ `ValueError` immediately, before they can corrupt the index.
13
+ - Search is O(N) per call — fine for small corpora, sluggish past
14
+ a few thousand items. The capability vocabulary lets future
15
+ drivers declare `"native_ann"`; this one does not.
16
+ - Vectors are L2-normalised before storage so similarity is a
17
+ plain dot product at search time. Callers don't have to
18
+ pre-normalise.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import math
24
+ from collections import OrderedDict
25
+ from typing import Any
26
+
27
+ from agentforge_core._bm25 import _BM25Index
28
+ from agentforge_core.contracts.vector_store import VectorStore
29
+ from agentforge_core.values.vector import VectorItem, VectorMatch
30
+
31
+
32
+ class InMemoryVectorStore(VectorStore):
33
+ """In-process `VectorStore` backed by a dict + brute-force search."""
34
+
35
+ def __init__(self, *, dimensions: int) -> None:
36
+ if dimensions < 1:
37
+ raise ValueError(f"dimensions must be >= 1, got {dimensions}")
38
+ self._dim = dimensions
39
+ # id -> (normalised vector, text, metadata)
40
+ self._items: OrderedDict[str, tuple[tuple[float, ...], str, dict[str, Any]]] = OrderedDict()
41
+ # Lazy BM25 index for hybrid search — rebuilt on demand after
42
+ # any upsert/delete. `None` means "needs rebuild on next
43
+ # lexical_search call".
44
+ self._bm25: _BM25Index | None = None
45
+
46
+ def dimensions(self) -> int:
47
+ return self._dim
48
+
49
+ def capabilities(self) -> set[str]:
50
+ return {"hybrid_search"}
51
+
52
+ async def upsert(self, items: list[VectorItem]) -> None:
53
+ for item in items:
54
+ if len(item.vector) != self._dim:
55
+ raise ValueError(
56
+ f"vector for id={item.id!r} has length {len(item.vector)} "
57
+ f"but store dimensions={self._dim}"
58
+ )
59
+ self._items[item.id] = (
60
+ _l2_normalise(item.vector),
61
+ item.text,
62
+ dict(item.metadata),
63
+ )
64
+ self._bm25 = None # invalidate
65
+
66
+ async def search(
67
+ self,
68
+ query_vector: tuple[float, ...],
69
+ *,
70
+ limit: int = 5,
71
+ filter_metadata: dict[str, Any] | None = None,
72
+ ) -> list[VectorMatch]:
73
+ if limit < 1:
74
+ raise ValueError(f"limit must be >= 1, got {limit}")
75
+ if len(query_vector) != self._dim:
76
+ raise ValueError(
77
+ f"query vector has length {len(query_vector)} but store dimensions={self._dim}"
78
+ )
79
+
80
+ query = _l2_normalise(query_vector)
81
+ scored: list[tuple[float, str, str, dict[str, Any]]] = []
82
+ for item_id, (vec, text, meta) in self._items.items():
83
+ if filter_metadata is not None and not _matches_filter(meta, filter_metadata):
84
+ continue
85
+ # Cosine similarity in [-1, 1]. Clamped to [0, 1]: 1.0
86
+ # means identical direction, 0.0 means orthogonal-or-
87
+ # anti-correlated. For text embeddings this drops
88
+ # essentially no information (anti-correlation is rare).
89
+ similarity = sum(a * b for a, b in zip(query, vec, strict=True))
90
+ clamped = max(0.0, min(1.0, similarity))
91
+ scored.append((clamped, item_id, text, meta))
92
+
93
+ scored.sort(key=lambda row: row[0], reverse=True)
94
+ top = scored[:limit]
95
+ return [
96
+ VectorMatch(id=item_id, text=text, metadata=dict(meta), score=score)
97
+ for score, item_id, text, meta in top
98
+ ]
99
+
100
+ async def lexical_search(
101
+ self,
102
+ query: str,
103
+ *,
104
+ limit: int = 5,
105
+ filter_metadata: dict[str, Any] | None = None,
106
+ ) -> list[VectorMatch]:
107
+ if limit < 1:
108
+ raise ValueError(f"limit must be >= 1, got {limit}")
109
+ if not self._items:
110
+ return []
111
+
112
+ if self._bm25 is None:
113
+ idx = _BM25Index()
114
+ for item_id, (_vec, text, _meta) in self._items.items():
115
+ idx.add(item_id, text)
116
+ self._bm25 = idx
117
+
118
+ # Over-fetch by a small factor so the post-filter has room to
119
+ # work even when many top BM25 hits get filtered out by
120
+ # metadata. The fuser eventually narrows to `limit` anyway.
121
+ over_fetch = limit if filter_metadata is None else limit * 4
122
+ scored = self._bm25.score(query, limit=max(over_fetch, 1))
123
+ if not scored:
124
+ return []
125
+
126
+ max_score = scored[0][1]
127
+ matches: list[VectorMatch] = []
128
+ for doc_id, raw in scored:
129
+ _vec, text, meta = self._items[doc_id]
130
+ if filter_metadata is not None and not _matches_filter(meta, filter_metadata):
131
+ continue
132
+ normalised = raw / max_score if max_score > 0.0 else 0.0
133
+ matches.append(VectorMatch(id=doc_id, text=text, metadata=dict(meta), score=normalised))
134
+ if len(matches) >= limit:
135
+ break
136
+ return matches
137
+
138
+ async def delete(self, ids: list[str]) -> int:
139
+ removed = 0
140
+ for item_id in ids:
141
+ if self._items.pop(item_id, None) is not None:
142
+ removed += 1
143
+ if removed:
144
+ self._bm25 = None # invalidate
145
+ return removed
146
+
147
+ async def close(self) -> None:
148
+ self._items.clear()
149
+ self._bm25 = None
150
+
151
+
152
+ # ----------------------------------------------------------------------
153
+ # Helpers
154
+ # ----------------------------------------------------------------------
155
+
156
+
157
+ def _l2_normalise(vector: tuple[float, ...]) -> tuple[float, ...]:
158
+ """Return `vector / ||vector||`; falls back to the input if zero."""
159
+ norm = math.sqrt(sum(x * x for x in vector))
160
+ if norm == 0.0:
161
+ return vector
162
+ return tuple(x / norm for x in vector)
163
+
164
+
165
+ def _matches_filter(metadata: dict[str, Any], filter_md: dict[str, Any]) -> bool:
166
+ """True if every (k, v) in `filter_md` matches `metadata`."""
167
+ return all(metadata.get(k) == v for k, v in filter_md.items())
@@ -0,0 +1,26 @@
1
+ """Pipeline engine + Task ABC (feat-015).
2
+
3
+ `Pipeline` runs a DAG of deterministic `Task` instances before the
4
+ agent's reasoning loop starts; their consolidated findings are
5
+ exposed to the LLM as a system-prompt addendum and via a built-in
6
+ ``pipeline_findings`` tool.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from agentforge_core.contracts.task import Task
12
+ from agentforge_core.values.pipeline import PipelineResult
13
+
14
+ from agentforge.pipeline.engine import OnTaskError, Pipeline
15
+ from agentforge.pipeline.errors import PipelineFailure
16
+ from agentforge.pipeline.tool import PipelineFindingsInput, PipelineFindingsTool
17
+
18
+ __all__ = [
19
+ "OnTaskError",
20
+ "Pipeline",
21
+ "PipelineFailure",
22
+ "PipelineFindingsInput",
23
+ "PipelineFindingsTool",
24
+ "PipelineResult",
25
+ "Task",
26
+ ]
@@ -0,0 +1,189 @@
1
+ """`Pipeline` — deterministic-task DAG engine (feat-015).
2
+
3
+ Runs a set of `Task` instances as a DAG (resolved via
4
+ ``Task.depends_on``), respecting a ``max_concurrent`` semaphore. Each
5
+ task's execution is bounded by ``Task.timeout_s``. Failures are
6
+ handled per ``on_task_error``:
7
+
8
+ - ``"continue"`` (default): the failed task emits a
9
+ ``SimpleFinding`` with category ``"pipeline.task_failure"``;
10
+ dependents still run (they see the failure-finding in their
11
+ merged context under ``"pipeline_findings_so_far"``).
12
+ - ``"fail"``: the engine cancels outstanding tasks and raises
13
+ `PipelineFailure`. Findings collected before the failure are
14
+ lost — the caller treats the run as aborted.
15
+
16
+ The output is a frozen `PipelineResult` with consolidated findings,
17
+ per-task durations, the failures map, and total cost.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import time
24
+ from collections.abc import Mapping
25
+ from typing import Any, Literal
26
+
27
+ from agentforge_core.contracts.task import Task
28
+ from agentforge_core.values.pipeline import PipelineResult
29
+
30
+ from agentforge.findings import SimpleFinding
31
+ from agentforge.pipeline.errors import PipelineFailure
32
+
33
+ OnTaskError = Literal["continue", "fail"]
34
+
35
+
36
+ class Pipeline:
37
+ """A DAG of deterministic (or LLM-using) tasks.
38
+
39
+ Construct once, ``run(context)`` once per agent invocation.
40
+ Validation (duplicate names, missing deps, cycles) runs at
41
+ construction so misconfigurations fail before the first run.
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ tasks: list[Task],
47
+ *,
48
+ max_concurrent: int = 4,
49
+ on_task_error: OnTaskError = "continue",
50
+ ) -> None:
51
+ if max_concurrent < 1:
52
+ raise ValueError(f"max_concurrent must be >= 1, got {max_concurrent}")
53
+ if on_task_error not in ("continue", "fail"):
54
+ raise ValueError(f"on_task_error must be 'continue' or 'fail', got {on_task_error!r}")
55
+ self._tasks = list(tasks)
56
+ self._max_concurrent = max_concurrent
57
+ self._on_task_error = on_task_error
58
+ self._by_name: dict[str, Task] = {}
59
+ for t in self._tasks:
60
+ tname = type(t).name
61
+ if tname in self._by_name:
62
+ raise ValueError(f"duplicate task name in pipeline: {tname!r}")
63
+ self._by_name[tname] = t
64
+ self._validate_dag()
65
+
66
+ @property
67
+ def tasks(self) -> tuple[Task, ...]:
68
+ return tuple(self._tasks)
69
+
70
+ @property
71
+ def max_concurrent(self) -> int:
72
+ return self._max_concurrent
73
+
74
+ @property
75
+ def on_task_error(self) -> OnTaskError:
76
+ return self._on_task_error
77
+
78
+ def _validate_dag(self) -> None:
79
+ for t in self._tasks:
80
+ for dep in type(t).depends_on:
81
+ if dep not in self._by_name:
82
+ raise ValueError(f"task {type(t).name!r} depends on unknown task {dep!r}")
83
+ # cycle detection via DFS
84
+ WHITE, GREY, BLACK = 0, 1, 2 # noqa: N806
85
+ color = dict.fromkeys(self._by_name, WHITE)
86
+
87
+ def visit(name: str, stack: list[str]) -> None:
88
+ if color[name] == GREY:
89
+ cycle = " -> ".join([*stack, name])
90
+ raise ValueError(f"cycle in pipeline DAG: {cycle}")
91
+ if color[name] == BLACK:
92
+ return
93
+ color[name] = GREY
94
+ for dep in type(self._by_name[name]).depends_on:
95
+ visit(dep, [*stack, name])
96
+ color[name] = BLACK
97
+
98
+ for name in self._by_name:
99
+ visit(name, [])
100
+
101
+ async def run(self, context: Mapping[str, Any]) -> PipelineResult:
102
+ """Execute the pipeline once and return consolidated output."""
103
+ loop = asyncio.get_running_loop()
104
+ state = _RunState(
105
+ semaphore=asyncio.Semaphore(self._max_concurrent),
106
+ # Each task gets a future of its emitted findings (or [] on
107
+ # failure when on_task_error="continue"); dependents await.
108
+ futures={name: loop.create_future() for name in self._by_name},
109
+ )
110
+ runners = [asyncio.create_task(self._run_one(t, context, state)) for t in self._tasks]
111
+ try:
112
+ await asyncio.gather(*runners)
113
+ except PipelineFailure:
114
+ for r in runners:
115
+ if not r.done():
116
+ r.cancel()
117
+ await asyncio.gather(*runners, return_exceptions=True)
118
+ raise
119
+
120
+ return PipelineResult(
121
+ findings=tuple(state.accumulated),
122
+ task_durations_ms=state.durations,
123
+ task_failures=state.failures,
124
+ total_cost_usd=state.total_cost,
125
+ )
126
+
127
+ async def _run_one(self, task: Task, context: Mapping[str, Any], state: _RunState) -> None:
128
+ tname = type(task).name
129
+ dep_results = [await state.futures[dep] for dep in type(task).depends_on]
130
+ prior: list[Any] = []
131
+ for batch in dep_results:
132
+ prior.extend(batch)
133
+ async with state.lock:
134
+ snapshot = list(state.accumulated)
135
+ merged: dict[str, Any] = dict(context)
136
+ existing = merged.get("pipeline_findings_so_far", [])
137
+ merged["pipeline_findings_so_far"] = [*existing, *prior, *snapshot]
138
+
139
+ start = time.monotonic()
140
+ async with state.semaphore:
141
+ try:
142
+ timeout = float(type(task).timeout_s)
143
+ findings = await asyncio.wait_for(task.run(merged), timeout=timeout)
144
+ except Exception as exc:
145
+ await self._record_failure(tname, exc, start, state)
146
+ return
147
+ elapsed = int((time.monotonic() - start) * 1000)
148
+ state.durations[tname] = elapsed
149
+ state.total_cost += float(type(task).cost_estimate_usd)
150
+ async with state.lock:
151
+ state.accumulated.extend(findings)
152
+ state.futures[tname].set_result(list(findings))
153
+
154
+ async def _record_failure(
155
+ self, tname: str, exc: BaseException, start: float, state: _RunState
156
+ ) -> None:
157
+ elapsed = int((time.monotonic() - start) * 1000)
158
+ state.durations[tname] = elapsed
159
+ state.failures[tname] = str(exc) if str(exc) else repr(exc)
160
+ if self._on_task_error == "fail":
161
+ state.futures[tname].set_exception(PipelineFailure(tname, exc))
162
+ raise PipelineFailure(tname, exc) from exc
163
+ failure_finding = SimpleFinding(
164
+ severity="error",
165
+ category="pipeline.task_failure",
166
+ message=f"task {tname!r} failed: {state.failures[tname]}",
167
+ rule_id=tname,
168
+ )
169
+ async with state.lock:
170
+ state.accumulated.append(failure_finding)
171
+ state.futures[tname].set_result([failure_finding])
172
+
173
+
174
+ class _RunState:
175
+ """Mutable per-run scratch shared across `_run_one` invocations."""
176
+
177
+ def __init__(
178
+ self,
179
+ *,
180
+ semaphore: asyncio.Semaphore,
181
+ futures: dict[str, asyncio.Future[list[Any]]],
182
+ ) -> None:
183
+ self.semaphore = semaphore
184
+ self.futures = futures
185
+ self.accumulated: list[Any] = []
186
+ self.durations: dict[str, int] = {}
187
+ self.failures: dict[str, str] = {}
188
+ self.total_cost = 0.0
189
+ self.lock = asyncio.Lock()
@@ -0,0 +1,19 @@
1
+ """Pipeline-specific exceptions (feat-015)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from agentforge_core.production.exceptions import AgentForgeError
6
+
7
+
8
+ class PipelineFailure(AgentForgeError): # noqa: N818 # locked name; parity with BudgetExceeded / GuardrailViolation
9
+ """Raised when a `Pipeline` configured with ``on_task_error="fail"``
10
+ encounters a task that raised.
11
+
12
+ The first failed task's name is on ``task_name``; the original
13
+ exception is chained via ``__cause__``.
14
+ """
15
+
16
+ def __init__(self, task_name: str, cause: BaseException) -> None:
17
+ super().__init__(f"pipeline task {task_name!r} failed: {cause}")
18
+ self.task_name = task_name
19
+ self.__cause__ = cause