agentforge-py 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agentforge/__init__.py +114 -0
- agentforge/_testing/__init__.py +19 -0
- agentforge/_testing/fake_llm.py +126 -0
- agentforge/_testing/fake_tool.py +122 -0
- agentforge/_tools/__init__.py +14 -0
- agentforge/_tools/calculator.py +102 -0
- agentforge/_tools/decorator.py +300 -0
- agentforge/_tools/file_read.py +112 -0
- agentforge/_tools/shell.py +134 -0
- agentforge/_tools/web_search.py +207 -0
- agentforge/agent.py +817 -0
- agentforge/auth.py +42 -0
- agentforge/cli/__init__.py +18 -0
- agentforge/cli/_build.py +323 -0
- agentforge/cli/_scaffold_state.py +250 -0
- agentforge/cli/_shared_scaffold.py +174 -0
- agentforge/cli/config_cmd.py +174 -0
- agentforge/cli/db_cmd.py +262 -0
- agentforge/cli/debug_cmd.py +168 -0
- agentforge/cli/docs_cmd.py +217 -0
- agentforge/cli/eval_cmd.py +181 -0
- agentforge/cli/health_cmd.py +139 -0
- agentforge/cli/list_modules.py +85 -0
- agentforge/cli/main.py +81 -0
- agentforge/cli/manifest_apply.py +368 -0
- agentforge/cli/module_cmd.py +247 -0
- agentforge/cli/new_cmd.py +171 -0
- agentforge/cli/run_cmd.py +234 -0
- agentforge/cli/upgrade_cmd.py +230 -0
- agentforge/config/__init__.py +45 -0
- agentforge/eval/__init__.py +18 -0
- agentforge/eval/consistency.py +107 -0
- agentforge/eval/coverage.py +100 -0
- agentforge/eval/format_compliance.py +107 -0
- agentforge/eval/regression.py +143 -0
- agentforge/findings.py +166 -0
- agentforge/guardrails/__init__.py +32 -0
- agentforge/guardrails/allowlist.py +49 -0
- agentforge/guardrails/capability_check.py +58 -0
- agentforge/guardrails/engine.py +289 -0
- agentforge/guardrails/pii_redact_basic.py +61 -0
- agentforge/guardrails/prompt_injection_basic.py +90 -0
- agentforge/memory/__init__.py +16 -0
- agentforge/memory/in_memory.py +130 -0
- agentforge/memory/in_memory_graph.py +262 -0
- agentforge/memory/in_memory_vector.py +167 -0
- agentforge/pipeline/__init__.py +26 -0
- agentforge/pipeline/engine.py +189 -0
- agentforge/pipeline/errors.py +19 -0
- agentforge/pipeline/tool.py +93 -0
- agentforge/py.typed +0 -0
- agentforge/recording.py +189 -0
- agentforge/renderers/__init__.py +28 -0
- agentforge/renderers/_defaults.py +32 -0
- agentforge/renderers/markdown.py +44 -0
- agentforge/renderers/patch_applier.py +46 -0
- agentforge/renderers/registry.py +108 -0
- agentforge/renderers/scorecard.py +59 -0
- agentforge/renderers/span_table.py +71 -0
- agentforge/replay.py +260 -0
- agentforge/resolver_register.py +41 -0
- agentforge/retrieval.py +410 -0
- agentforge/runtime.py +63 -0
- agentforge/strategies/__init__.py +27 -0
- agentforge/strategies/_base.py +280 -0
- agentforge/strategies/_plan.py +93 -0
- agentforge/strategies/multi_agent.py +541 -0
- agentforge/strategies/plan_execute.py +506 -0
- agentforge/strategies/react.py +237 -0
- agentforge/strategies/tot.py +472 -0
- agentforge/templates/_shared/.cursorrules +12 -0
- agentforge/templates/_shared/.github/copilot-instructions.md +13 -0
- agentforge/templates/_shared/.gitkeep +0 -0
- agentforge/templates/_shared/AGENTS.md.tmpl +123 -0
- agentforge/templates/_shared/CLAUDE.md +13 -0
- agentforge/templates/_shared/docs/runbooks/01-set-up-new-agent.md.tmpl +67 -0
- agentforge/templates/_shared/docs/runbooks/02-add-a-tool.md +67 -0
- agentforge/templates/_shared/docs/runbooks/03-add-a-pipeline-task.md +69 -0
- agentforge/templates/_shared/docs/runbooks/04-pick-reasoning-strategy.md +67 -0
- agentforge/templates/_shared/docs/runbooks/05-write-prompts.md +75 -0
- agentforge/templates/_shared/docs/runbooks/06-test-your-agent.md +75 -0
- agentforge/templates/_shared/docs/runbooks/07-debug-a-run.md +70 -0
- agentforge/templates/_shared/docs/runbooks/08-add-memory.md +75 -0
- agentforge/templates/_shared/docs/runbooks/09-add-mcp.md +78 -0
- agentforge/templates/_shared/docs/runbooks/10-add-evaluators.md +76 -0
- agentforge/templates/_shared/docs/runbooks/11-add-safety-guardrails.md +83 -0
- agentforge/templates/_shared/docs/runbooks/12-add-observability.md +77 -0
- agentforge/templates/_shared/docs/runbooks/13-configure-multi-provider.md +91 -0
- agentforge/templates/_shared/docs/runbooks/14-deploy-your-agent.md +70 -0
- agentforge/templates/_shared/docs/runbooks/15-upgrade-your-agent.md +67 -0
- agentforge/templates/_shared/docs/runbooks/16-configuration-reference.md +81 -0
- agentforge/templates/_shared/docs/runbooks/17-add-reranker.md +78 -0
- agentforge/templates/_shared/docs/runbooks/18-add-hybrid-search.md +78 -0
- agentforge/templates/_shared/docs/runbooks/19-add-graphrag.md +83 -0
- agentforge/templates/_shared/docs/runbooks/20-apply-schema-migrations.md +92 -0
- agentforge/templates/_shared/docs/runbooks/21-use-streaming-guardrails.md +82 -0
- agentforge/templates/_shared/docs/runbooks/README.md.tmpl +68 -0
- agentforge/templates/code-reviewer/.env.example +8 -0
- agentforge/templates/code-reviewer/.gitignore +7 -0
- agentforge/templates/code-reviewer/README.md +12 -0
- agentforge/templates/code-reviewer/agentforge.yaml +23 -0
- agentforge/templates/code-reviewer/copier.yml +34 -0
- agentforge/templates/code-reviewer/pyproject.toml +18 -0
- agentforge/templates/code-reviewer/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/code-reviewer/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
- agentforge/templates/docs-qa/.env.example +8 -0
- agentforge/templates/docs-qa/.gitignore +7 -0
- agentforge/templates/docs-qa/README.md +14 -0
- agentforge/templates/docs-qa/agentforge.yaml +19 -0
- agentforge/templates/docs-qa/copier.yml +31 -0
- agentforge/templates/docs-qa/pyproject.toml +18 -0
- agentforge/templates/docs-qa/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/docs-qa/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
- agentforge/templates/minimal/.env.example +11 -0
- agentforge/templates/minimal/.gitignore +10 -0
- agentforge/templates/minimal/README.md +28 -0
- agentforge/templates/minimal/agentforge.yaml +10 -0
- agentforge/templates/minimal/copier.yml +52 -0
- agentforge/templates/minimal/pyproject.toml +18 -0
- agentforge/templates/minimal/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/minimal/src/{{project_slug.replace('-', '_')}}/main.py +34 -0
- agentforge/templates/patch-bot/.env.example +8 -0
- agentforge/templates/patch-bot/.gitignore +7 -0
- agentforge/templates/patch-bot/README.md +13 -0
- agentforge/templates/patch-bot/agentforge.yaml +15 -0
- agentforge/templates/patch-bot/copier.yml +31 -0
- agentforge/templates/patch-bot/pyproject.toml +18 -0
- agentforge/templates/patch-bot/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/patch-bot/src/{{project_slug.replace('-', '_')}}/main.py +32 -0
- agentforge/templates/research/.env.example +8 -0
- agentforge/templates/research/.gitignore +7 -0
- agentforge/templates/research/README.md +14 -0
- agentforge/templates/research/agentforge.yaml +17 -0
- agentforge/templates/research/copier.yml +31 -0
- agentforge/templates/research/pyproject.toml +18 -0
- agentforge/templates/research/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/research/src/{{project_slug.replace('-', '_')}}/main.py +31 -0
- agentforge/templates/triage/.env.example +8 -0
- agentforge/templates/triage/.gitignore +7 -0
- agentforge/templates/triage/README.md +14 -0
- agentforge/templates/triage/agentforge.yaml +25 -0
- agentforge/templates/triage/copier.yml +31 -0
- agentforge/templates/triage/pyproject.toml +18 -0
- agentforge/templates/triage/src/{{project_slug.replace('-', '_')}}/__init__.py +5 -0
- agentforge/templates/triage/src/{{project_slug.replace('-', '_')}}/main.py +30 -0
- agentforge/testing/__init__.py +69 -0
- agentforge/testing/conformance.py +40 -0
- agentforge/testing/factory.py +89 -0
- agentforge/testing/fixtures.py +42 -0
- agentforge/testing/llm.py +235 -0
- agentforge/testing/recording.py +177 -0
- agentforge/tools/__init__.py +41 -0
- agentforge_py-0.2.1.dist-info/METADATA +158 -0
- agentforge_py-0.2.1.dist-info/RECORD +157 -0
- agentforge_py-0.2.1.dist-info/WHEEL +4 -0
- agentforge_py-0.2.1.dist-info/entry_points.txt +2 -0
- agentforge_py-0.2.1.dist-info/licenses/LICENSE +202 -0
|
@@ -0,0 +1,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
|