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,207 @@
|
|
|
1
|
+
"""`web_search` — pluggable web-search tool (feat-004).
|
|
2
|
+
|
|
3
|
+
The default backend is a DuckDuckGo HTML scrape — fragile but
|
|
4
|
+
dependency-free. Real backends (Serper, Tavily, Brave) ship as
|
|
5
|
+
separate module packages later. Users can swap in any callable:
|
|
6
|
+
|
|
7
|
+
from agentforge.tools import WebSearchTool
|
|
8
|
+
|
|
9
|
+
async def my_backend(query: str, *, max_results: int) -> list[SearchResult]:
|
|
10
|
+
...
|
|
11
|
+
|
|
12
|
+
custom = WebSearchTool(search_fn=my_backend)
|
|
13
|
+
agent = Agent(tools=[custom, ...])
|
|
14
|
+
|
|
15
|
+
Capabilities: `{"network"}`.
|
|
16
|
+
|
|
17
|
+
Live integration tests for the default DuckDuckGo backend are gated
|
|
18
|
+
on `RUN_LIVE_WEB=1`; CI does not run them. Unit tests substitute a
|
|
19
|
+
fake `search_fn` so the tool itself can be exercised without
|
|
20
|
+
network access.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import asyncio
|
|
26
|
+
import logging
|
|
27
|
+
import re
|
|
28
|
+
from collections.abc import Awaitable, Callable
|
|
29
|
+
from typing import Any, ClassVar
|
|
30
|
+
from urllib.parse import parse_qs, quote_plus, unquote, urlparse
|
|
31
|
+
from urllib.request import Request, urlopen
|
|
32
|
+
|
|
33
|
+
from agentforge_core.contracts.tool import Tool
|
|
34
|
+
from pydantic import BaseModel, Field
|
|
35
|
+
|
|
36
|
+
logger = logging.getLogger(__name__)
|
|
37
|
+
|
|
38
|
+
_DEFAULT_USER_AGENT = (
|
|
39
|
+
"Mozilla/5.0 (compatible; agentforge/0.1; +https://github.com/Scaffoldic/agentforge-py)"
|
|
40
|
+
)
|
|
41
|
+
_DEFAULT_TIMEOUT_S = 10.0
|
|
42
|
+
_DEFAULT_MAX_RESULTS = 5
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class SearchResult(BaseModel):
|
|
46
|
+
"""A single web-search hit. The shape is provider-agnostic so
|
|
47
|
+
different `search_fn` implementations can return the same type."""
|
|
48
|
+
|
|
49
|
+
title: str
|
|
50
|
+
url: str
|
|
51
|
+
snippet: str = ""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class _WebSearchInput(BaseModel):
|
|
55
|
+
"""Input schema for `web_search`."""
|
|
56
|
+
|
|
57
|
+
query: str = Field(min_length=1, description="The search query string.")
|
|
58
|
+
max_results: int = Field(
|
|
59
|
+
default=_DEFAULT_MAX_RESULTS,
|
|
60
|
+
ge=1,
|
|
61
|
+
le=20,
|
|
62
|
+
description="Number of results to return (1-20).",
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
SearchFn = Callable[..., Awaitable[list[SearchResult]]]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class WebSearchTool(Tool):
|
|
70
|
+
"""Web search via a pluggable backend.
|
|
71
|
+
|
|
72
|
+
`search_fn` defaults to a DuckDuckGo HTML scraper — fragile;
|
|
73
|
+
overridable. Custom backends should accept `(query: str, *,
|
|
74
|
+
max_results: int)` and return `list[SearchResult]`.
|
|
75
|
+
"""
|
|
76
|
+
|
|
77
|
+
name: ClassVar[str] = "web_search"
|
|
78
|
+
description: ClassVar[str] = (
|
|
79
|
+
"Search the web for the given query. Returns a list of "
|
|
80
|
+
"{title, url, snippet} hits as JSON-serialisable dicts."
|
|
81
|
+
)
|
|
82
|
+
input_schema: ClassVar[type[BaseModel]] = _WebSearchInput
|
|
83
|
+
capabilities: ClassVar[frozenset[str]] = frozenset({"network"})
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
*,
|
|
88
|
+
search_fn: SearchFn | None = None,
|
|
89
|
+
timeout_s: float = _DEFAULT_TIMEOUT_S,
|
|
90
|
+
) -> None:
|
|
91
|
+
if timeout_s <= 0:
|
|
92
|
+
msg = f"timeout_s must be > 0, got {timeout_s}"
|
|
93
|
+
raise ValueError(msg)
|
|
94
|
+
self._search_fn: SearchFn = search_fn or _duckduckgo_search
|
|
95
|
+
self._timeout_s = timeout_s
|
|
96
|
+
|
|
97
|
+
async def run(self, **kwargs: Any) -> list[dict[str, str]]:
|
|
98
|
+
query: str = kwargs["query"]
|
|
99
|
+
max_results: int = kwargs.get("max_results", _DEFAULT_MAX_RESULTS)
|
|
100
|
+
try:
|
|
101
|
+
results = await asyncio.wait_for(
|
|
102
|
+
self._search_fn(query, max_results=max_results),
|
|
103
|
+
timeout=self._timeout_s,
|
|
104
|
+
)
|
|
105
|
+
except TimeoutError:
|
|
106
|
+
msg = f"web_search: backend exceeded timeout_s={self._timeout_s}"
|
|
107
|
+
raise TimeoutError(msg) from None
|
|
108
|
+
# Serialise to JSON-friendly dicts; keeps the LLM contract
|
|
109
|
+
# simple (no Pydantic model in the tool's return value).
|
|
110
|
+
return [r.model_dump() for r in results]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# ----------------------------------------------------------------------
|
|
114
|
+
# Default backend — DuckDuckGo HTML scrape
|
|
115
|
+
# ----------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
async def _duckduckgo_search(
|
|
119
|
+
query: str, *, max_results: int = _DEFAULT_MAX_RESULTS
|
|
120
|
+
) -> list[SearchResult]:
|
|
121
|
+
"""Default search backend — DuckDuckGo's HTML page scrape.
|
|
122
|
+
|
|
123
|
+
Fragile by design (DuckDuckGo can change its HTML at any time).
|
|
124
|
+
Emits a warning log if it falls over so operators can swap to a
|
|
125
|
+
real backend (Serper, Tavily, Brave).
|
|
126
|
+
"""
|
|
127
|
+
url = f"https://html.duckduckgo.com/html/?q={quote_plus(query)}"
|
|
128
|
+
request = Request( # noqa: S310 — explicit https URL with constant scheme
|
|
129
|
+
url, headers={"User-Agent": _DEFAULT_USER_AGENT}
|
|
130
|
+
)
|
|
131
|
+
loop = asyncio.get_running_loop()
|
|
132
|
+
try:
|
|
133
|
+
# urlopen is sync; run in executor to avoid blocking the loop.
|
|
134
|
+
html = await loop.run_in_executor(None, _fetch_text, request)
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
logger.warning(
|
|
137
|
+
"web_search: DuckDuckGo backend failed (%s). "
|
|
138
|
+
"Swap to a real backend (Serper/Tavily/Brave) by passing "
|
|
139
|
+
"search_fn=...",
|
|
140
|
+
exc,
|
|
141
|
+
)
|
|
142
|
+
return []
|
|
143
|
+
return _parse_duckduckgo_html(html, max_results=max_results)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _fetch_text(request: Request) -> str:
|
|
147
|
+
# The Request URL is constructed in `_duckduckgo_search` with a
|
|
148
|
+
# constant `https://html.duckduckgo.com/...` prefix; it is never
|
|
149
|
+
# derived from caller input. Bandit B310's "audit url open for
|
|
150
|
+
# permitted schemes" warning doesn't apply.
|
|
151
|
+
with urlopen(request, timeout=_DEFAULT_TIMEOUT_S) as resp: # noqa: S310 # nosec B310
|
|
152
|
+
body: bytes = resp.read()
|
|
153
|
+
return body.decode("utf-8", errors="replace")
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# Match DuckDuckGo HTML result blocks: <a class="result__a" href="...">title</a>
|
|
157
|
+
# followed by <a class="result__snippet">snippet</a>. The href is a
|
|
158
|
+
# DuckDuckGo redirect that wraps the real URL in a `uddg=` param.
|
|
159
|
+
_RESULT_RE = re.compile(
|
|
160
|
+
r'<a[^>]*class="result__a"[^>]*href="([^"]+)"[^>]*>(.*?)</a>'
|
|
161
|
+
r'.*?<a[^>]*class="result__snippet"[^>]*>(.*?)</a>',
|
|
162
|
+
re.DOTALL,
|
|
163
|
+
)
|
|
164
|
+
_TAG_RE = re.compile(r"<[^>]+>")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _parse_duckduckgo_html(html: str, *, max_results: int) -> list[SearchResult]:
|
|
168
|
+
"""Extract titles, URLs, and snippets from DuckDuckGo's HTML.
|
|
169
|
+
|
|
170
|
+
Returns at most `max_results` hits. Empty list on parse failure.
|
|
171
|
+
"""
|
|
172
|
+
out: list[SearchResult] = []
|
|
173
|
+
for match in _RESULT_RE.finditer(html):
|
|
174
|
+
if len(out) >= max_results:
|
|
175
|
+
break
|
|
176
|
+
href, title_html, snippet_html = match.groups()
|
|
177
|
+
url = _unwrap_ddg_redirect(href)
|
|
178
|
+
out.append(
|
|
179
|
+
SearchResult(
|
|
180
|
+
title=_strip_html(title_html).strip(),
|
|
181
|
+
url=url,
|
|
182
|
+
snippet=_strip_html(snippet_html).strip(),
|
|
183
|
+
)
|
|
184
|
+
)
|
|
185
|
+
return out
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _unwrap_ddg_redirect(href: str) -> str:
|
|
189
|
+
"""DuckDuckGo wraps result URLs as `/l/?kh=-1&uddg=<encoded>`.
|
|
190
|
+
Pull the real URL back out."""
|
|
191
|
+
if not href.startswith(("/l/", "//duckduckgo.com/l/")):
|
|
192
|
+
return href
|
|
193
|
+
parsed = urlparse(href if href.startswith("//") else f"https:{href}")
|
|
194
|
+
qs = parse_qs(parsed.query)
|
|
195
|
+
raw = qs.get("uddg", [""])[0]
|
|
196
|
+
return unquote(raw) or href
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _strip_html(s: str) -> str:
|
|
200
|
+
return _TAG_RE.sub("", s)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# Default instance — DuckDuckGo backend, 10s timeout.
|
|
204
|
+
web_search = WebSearchTool()
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
__all__ = ["SearchResult", "WebSearchTool", "web_search"]
|