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,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"]