langchain-stackresolve 0.1.0__tar.gz

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.
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ dist/
3
+ build/
4
+ *.egg-info/
5
+ __pycache__/
6
+ .pytest_cache/
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 StackResolve
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,137 @@
1
+ Metadata-Version: 2.5
2
+ Name: langchain-stackresolve
3
+ Version: 0.1.0
4
+ Summary: LangChain tools for StackResolve: find, compare, and audit software for AI agents, plus structured company research.
5
+ Project-URL: Homepage, https://stackresolve.dev
6
+ Project-URL: Repository, https://github.com/autorevai/stackresolve
7
+ Project-URL: Documentation, https://stackresolve.dev/docs
8
+ Author: StackResolve
9
+ License: MIT
10
+ License-File: LICENSE
11
+ Keywords: agent-tools,agentready,ai-agents,companydata,langchain,mcp,stackresolve
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
17
+ Requires-Python: >=3.9
18
+ Requires-Dist: langchain-core>=0.3.0
19
+ Requires-Dist: pydantic>=2.0
20
+ Requires-Dist: stackresolve>=0.1.0
21
+ Provides-Extra: test
22
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'test'
23
+ Requires-Dist: pytest>=7.0; extra == 'test'
24
+ Description-Content-Type: text/markdown
25
+
26
+ # langchain-stackresolve
27
+
28
+ LangChain tools for [StackResolve](https://stackresolve.dev): web intelligence for AI agents.
29
+
30
+ Pick software for a task, compare vendors, check whether a product is agent-ready, and
31
+ pull structured company facts. One tool call instead of a search-and-scrape loop.
32
+
33
+ ```bash
34
+ pip install langchain-stackresolve
35
+ ```
36
+
37
+ ## Quickstart
38
+
39
+ Every tool works without a key, subject to an anonymous rate limit. A free key from
40
+ [stackresolve.dev/developers](https://stackresolve.dev/developers) raises it.
41
+
42
+ ```python
43
+ import os
44
+ from langchain_stackresolve import StackResolveToolkit
45
+ from langchain.agents import create_agent
46
+
47
+ os.environ["STACKRESOLVE_API_KEY"] = "ar_..." # optional, raises the rate limit
48
+
49
+ agent = create_agent(
50
+ model="claude-sonnet-5",
51
+ tools=StackResolveToolkit().get_tools(),
52
+ )
53
+
54
+ result = agent.invoke({
55
+ "messages": [{
56
+ "role": "user",
57
+ "content": "I need to scrape javascript-heavy sites. What should I use, "
58
+ "and what does it cost?",
59
+ }]
60
+ })
61
+ print(result["messages"][-1].content)
62
+ ```
63
+
64
+ The agent calls `stackresolve_find_tools` to get scored candidates, then
65
+ `stackresolve_get_pricing` on the winner. Two calls, structured answers, no scraping.
66
+
67
+ ## Single tools
68
+
69
+ Import only what you need:
70
+
71
+ ```python
72
+ from langchain_stackresolve import StackResolveAudit, StackResolveFindTools
73
+
74
+ audit = StackResolveAudit()
75
+ print(audit.invoke({"domain": "stripe.com"}))
76
+
77
+ find = StackResolveFindTools()
78
+ print(find.invoke({"task": "send transactional email from a Node service"}))
79
+ ```
80
+
81
+ ## Tools
82
+
83
+ | Tool | What it answers |
84
+ |---|---|
85
+ | `stackresolve_find_tools` | "What should I use for this task?" Ranked, with AgentReady scores. |
86
+ | `stackresolve_search_tools` | Registry search filtered on API, MCP, CLI, OpenAPI, or self-serve. |
87
+ | `stackresolve_compare_products` | Side-by-side on scores, capabilities, and pricing. |
88
+ | `stackresolve_audit` | 0-100 agent-readiness score for a domain, plus failing checks. |
89
+ | `stackresolve_get_company` | Structured company facts from a domain. |
90
+ | `stackresolve_get_pricing` | Current plans and prices as structured data. |
91
+ | `stackresolve_find_competitors` | Competitors, with how each one differs. |
92
+ | `stackresolve_research_company` | Deep research with sources, answering a question. |
93
+
94
+ All eight work without a key, subject to an anonymous rate limit. A free key raises the
95
+ limit and is required for account endpoints (monitors, usage, discovery runs), which this
96
+ package does not expose.
97
+
98
+ ## Configuration
99
+
100
+ The toolkit and every tool accept `api_key` and `base_url`, and otherwise read
101
+ `STACKRESOLVE_API_KEY` and `STACKRESOLVE_BASE_URL` from the environment.
102
+
103
+ ```python
104
+ toolkit = StackResolveToolkit(api_key="ar_...")
105
+ ```
106
+
107
+ Pass an existing SDK client to share connection state:
108
+
109
+ ```python
110
+ from stackresolve import StackResolve
111
+
112
+ client = StackResolve(api_key="ar_...")
113
+ tools = StackResolveToolkit(client=client).get_tools()
114
+ ```
115
+
116
+ ## Errors
117
+
118
+ Tools return a readable message rather than raising, so a failed call does not end the
119
+ agent's turn. A missing key, an exhausted allowance, and a rate limit each come back as
120
+ text the model can act on.
121
+
122
+ ## Output size
123
+
124
+ Tool output is capped at 6,000 characters and marked when truncated, so a large registry
125
+ or research payload cannot flood the context window. Change it with
126
+ `langchain_stackresolve.tools.MAX_CHARS`.
127
+
128
+ ## Also available
129
+
130
+ - Hosted MCP server (no install): `https://mcp.stackresolve.dev/mcp`
131
+ - TypeScript SDK and CLI: `npm install stackresolve`
132
+ - Python SDK on its own: `pip install stackresolve`
133
+ - REST API: `https://api.stackresolve.dev`, OpenAPI at `/openapi.json`
134
+
135
+ ## License
136
+
137
+ MIT
@@ -0,0 +1,112 @@
1
+ # langchain-stackresolve
2
+
3
+ LangChain tools for [StackResolve](https://stackresolve.dev): web intelligence for AI agents.
4
+
5
+ Pick software for a task, compare vendors, check whether a product is agent-ready, and
6
+ pull structured company facts. One tool call instead of a search-and-scrape loop.
7
+
8
+ ```bash
9
+ pip install langchain-stackresolve
10
+ ```
11
+
12
+ ## Quickstart
13
+
14
+ Every tool works without a key, subject to an anonymous rate limit. A free key from
15
+ [stackresolve.dev/developers](https://stackresolve.dev/developers) raises it.
16
+
17
+ ```python
18
+ import os
19
+ from langchain_stackresolve import StackResolveToolkit
20
+ from langchain.agents import create_agent
21
+
22
+ os.environ["STACKRESOLVE_API_KEY"] = "ar_..." # optional, raises the rate limit
23
+
24
+ agent = create_agent(
25
+ model="claude-sonnet-5",
26
+ tools=StackResolveToolkit().get_tools(),
27
+ )
28
+
29
+ result = agent.invoke({
30
+ "messages": [{
31
+ "role": "user",
32
+ "content": "I need to scrape javascript-heavy sites. What should I use, "
33
+ "and what does it cost?",
34
+ }]
35
+ })
36
+ print(result["messages"][-1].content)
37
+ ```
38
+
39
+ The agent calls `stackresolve_find_tools` to get scored candidates, then
40
+ `stackresolve_get_pricing` on the winner. Two calls, structured answers, no scraping.
41
+
42
+ ## Single tools
43
+
44
+ Import only what you need:
45
+
46
+ ```python
47
+ from langchain_stackresolve import StackResolveAudit, StackResolveFindTools
48
+
49
+ audit = StackResolveAudit()
50
+ print(audit.invoke({"domain": "stripe.com"}))
51
+
52
+ find = StackResolveFindTools()
53
+ print(find.invoke({"task": "send transactional email from a Node service"}))
54
+ ```
55
+
56
+ ## Tools
57
+
58
+ | Tool | What it answers |
59
+ |---|---|
60
+ | `stackresolve_find_tools` | "What should I use for this task?" Ranked, with AgentReady scores. |
61
+ | `stackresolve_search_tools` | Registry search filtered on API, MCP, CLI, OpenAPI, or self-serve. |
62
+ | `stackresolve_compare_products` | Side-by-side on scores, capabilities, and pricing. |
63
+ | `stackresolve_audit` | 0-100 agent-readiness score for a domain, plus failing checks. |
64
+ | `stackresolve_get_company` | Structured company facts from a domain. |
65
+ | `stackresolve_get_pricing` | Current plans and prices as structured data. |
66
+ | `stackresolve_find_competitors` | Competitors, with how each one differs. |
67
+ | `stackresolve_research_company` | Deep research with sources, answering a question. |
68
+
69
+ All eight work without a key, subject to an anonymous rate limit. A free key raises the
70
+ limit and is required for account endpoints (monitors, usage, discovery runs), which this
71
+ package does not expose.
72
+
73
+ ## Configuration
74
+
75
+ The toolkit and every tool accept `api_key` and `base_url`, and otherwise read
76
+ `STACKRESOLVE_API_KEY` and `STACKRESOLVE_BASE_URL` from the environment.
77
+
78
+ ```python
79
+ toolkit = StackResolveToolkit(api_key="ar_...")
80
+ ```
81
+
82
+ Pass an existing SDK client to share connection state:
83
+
84
+ ```python
85
+ from stackresolve import StackResolve
86
+
87
+ client = StackResolve(api_key="ar_...")
88
+ tools = StackResolveToolkit(client=client).get_tools()
89
+ ```
90
+
91
+ ## Errors
92
+
93
+ Tools return a readable message rather than raising, so a failed call does not end the
94
+ agent's turn. A missing key, an exhausted allowance, and a rate limit each come back as
95
+ text the model can act on.
96
+
97
+ ## Output size
98
+
99
+ Tool output is capped at 6,000 characters and marked when truncated, so a large registry
100
+ or research payload cannot flood the context window. Change it with
101
+ `langchain_stackresolve.tools.MAX_CHARS`.
102
+
103
+ ## Also available
104
+
105
+ - Hosted MCP server (no install): `https://mcp.stackresolve.dev/mcp`
106
+ - TypeScript SDK and CLI: `npm install stackresolve`
107
+ - Python SDK on its own: `pip install stackresolve`
108
+ - REST API: `https://api.stackresolve.dev`, OpenAPI at `/openapi.json`
109
+
110
+ ## License
111
+
112
+ MIT
@@ -0,0 +1,36 @@
1
+ """LangChain integration for StackResolve.
2
+
3
+ Web intelligence for AI agents: find, compare, and audit software for a task, and pull
4
+ structured company research, in one tool call instead of a search-and-scrape loop.
5
+ """
6
+
7
+ from .toolkit import StackResolveToolkit
8
+ from .tools import (
9
+ ALL_TOOL_CLASSES,
10
+ StackResolveAudit,
11
+ StackResolveBaseTool,
12
+ StackResolveCompareProducts,
13
+ StackResolveFindCompetitors,
14
+ StackResolveFindTools,
15
+ StackResolveGetCompany,
16
+ StackResolveGetPricing,
17
+ StackResolveResearchCompany,
18
+ StackResolveSearchTools,
19
+ )
20
+
21
+ __version__ = "0.1.0"
22
+
23
+ __all__ = [
24
+ "StackResolveToolkit",
25
+ "StackResolveBaseTool",
26
+ "StackResolveFindTools",
27
+ "StackResolveSearchTools",
28
+ "StackResolveAudit",
29
+ "StackResolveCompareProducts",
30
+ "StackResolveGetCompany",
31
+ "StackResolveGetPricing",
32
+ "StackResolveFindCompetitors",
33
+ "StackResolveResearchCompany",
34
+ "ALL_TOOL_CLASSES",
35
+ "__version__",
36
+ ]
@@ -0,0 +1,45 @@
1
+ """Toolkit that hands an agent every StackResolve tool with one shared client."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, List, Optional
6
+
7
+ from langchain_core.tools import BaseTool
8
+ from pydantic import BaseModel, ConfigDict, PrivateAttr
9
+ from stackresolve import StackResolve
10
+
11
+ from .tools import ALL_TOOL_CLASSES, _build_client
12
+
13
+
14
+ class StackResolveToolkit(BaseModel):
15
+ """All StackResolve tools, sharing one HTTP client.
16
+
17
+ Registry reads work without a key. Metered tools (audit, research) need one, from
18
+ https://stackresolve.dev/developers, passed here or set as STACKRESOLVE_API_KEY.
19
+
20
+ from langchain_stackresolve import StackResolveToolkit
21
+
22
+ tools = StackResolveToolkit().get_tools()
23
+ """
24
+
25
+ model_config = ConfigDict(arbitrary_types_allowed=True)
26
+
27
+ _client: StackResolve = PrivateAttr()
28
+
29
+ def __init__(
30
+ self,
31
+ client: Optional[StackResolve] = None,
32
+ api_key: Optional[str] = None,
33
+ base_url: Optional[str] = None,
34
+ **kwargs: Any,
35
+ ) -> None:
36
+ super().__init__(**kwargs)
37
+ self._client = client if client is not None else _build_client(api_key, base_url)
38
+
39
+ @property
40
+ def client(self) -> StackResolve:
41
+ return self._client
42
+
43
+ def get_tools(self) -> List[BaseTool]:
44
+ """Every tool in the toolkit, each bound to the shared client."""
45
+ return [cls(client=self._client) for cls in ALL_TOOL_CLASSES]
@@ -0,0 +1,417 @@
1
+ """LangChain tools over the StackResolve API.
2
+
3
+ Each tool wraps one StackResolve SDK call and returns compact JSON, so an agent can
4
+ pick a tool for a task, compare vendors, check whether a product is agent-ready, and
5
+ pull structured company facts without running its own search-and-scrape loop.
6
+
7
+ from langchain_stackresolve import StackResolveToolkit
8
+
9
+ tools = StackResolveToolkit().get_tools()
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import os
16
+ from typing import Any, Dict, List, Optional, Type
17
+
18
+ from langchain_core.callbacks import (
19
+ AsyncCallbackManagerForToolRun,
20
+ CallbackManagerForToolRun,
21
+ )
22
+ from langchain_core.runnables.config import run_in_executor
23
+ from langchain_core.tools import BaseTool
24
+ from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
25
+ from stackresolve import StackResolve, StackResolveError
26
+
27
+ # Tool output goes straight into a prompt, so cap it. Registry and research payloads
28
+ # can run to tens of kilobytes and the tail is rarely what the model needs.
29
+ MAX_CHARS = 6000
30
+
31
+
32
+ def _dump(payload: Any, max_chars: int = MAX_CHARS) -> str:
33
+ """Serialize a payload to JSON, truncating on a character budget.
34
+
35
+ Truncation stays valid JSON. Slicing the serialized text mid-token would leave
36
+ output that a caller re-parsing tool results cannot load, and that failure would
37
+ only show up on large payloads.
38
+ """
39
+ text = json.dumps(payload, indent=2, default=str, ensure_ascii=False)
40
+ if len(text) <= max_chars:
41
+ return text
42
+ return json.dumps(
43
+ {
44
+ "truncated": True,
45
+ "omitted_characters": len(text) - max_chars,
46
+ "note": (
47
+ f"Result exceeded {max_chars} characters. 'partial' holds the start of "
48
+ "the JSON payload. Narrow the query for a complete result."
49
+ ),
50
+ "partial": text[:max_chars],
51
+ },
52
+ indent=2,
53
+ ensure_ascii=False,
54
+ )
55
+
56
+
57
+ def _build_client(
58
+ api_key: Optional[str] = None, base_url: Optional[str] = None
59
+ ) -> StackResolve:
60
+ """Construct an SDK client, falling back to the standard environment variables."""
61
+ opts: Dict[str, Any] = {}
62
+ key = api_key or os.environ.get("STACKRESOLVE_API_KEY")
63
+ if key:
64
+ opts["api_key"] = key
65
+ url = base_url or os.environ.get("STACKRESOLVE_BASE_URL")
66
+ if url:
67
+ opts["base_url"] = url
68
+ return StackResolve(**opts)
69
+
70
+
71
+ class StackResolveBaseTool(BaseTool):
72
+ """Shared plumbing: one client, uniform error handling, async via executor.
73
+
74
+ Subclasses set ``name``, ``description``, and ``args_schema``, then call
75
+ ``self._call(self.client.<method>, ...)`` from ``_run``.
76
+ """
77
+
78
+ model_config = ConfigDict(arbitrary_types_allowed=True)
79
+
80
+ _client: Optional[StackResolve] = PrivateAttr(default=None)
81
+
82
+ def __init__(
83
+ self,
84
+ client: Optional[StackResolve] = None,
85
+ api_key: Optional[str] = None,
86
+ base_url: Optional[str] = None,
87
+ **kwargs: Any,
88
+ ) -> None:
89
+ super().__init__(**kwargs)
90
+ self._client = client if client is not None else _build_client(api_key, base_url)
91
+
92
+ @property
93
+ def client(self) -> StackResolve:
94
+ if self._client is None: # pragma: no cover - defensive
95
+ self._client = _build_client()
96
+ return self._client
97
+
98
+ def _call(self, fn: Any, *args: Any, **kwargs: Any) -> str:
99
+ """Run an SDK call and return JSON, converting any failure into readable text.
100
+
101
+ A tool that raises ends the agent's turn. A tool that explains what went wrong
102
+ lets the model recover, so a 402 becomes an instruction rather than a traceback.
103
+
104
+ The catch-all matters as much as the status branches: a read timeout, a refused
105
+ connection, or a DNS failure is an ordinary condition for a network tool, and
106
+ letting one escape would kill the turn just as surely as an API error.
107
+ """
108
+ try:
109
+ return _dump(fn(*args, **kwargs))
110
+ except StackResolveError as err:
111
+ if err.status in (401, 403):
112
+ return (
113
+ "StackResolve rejected the credentials. Set STACKRESOLVE_API_KEY "
114
+ "to a key from https://stackresolve.dev/developers and retry."
115
+ )
116
+ if err.status == 402:
117
+ return (
118
+ "This StackResolve tool is metered and the free allowance is used "
119
+ "up. Registry reads (search, profiles, compare) still work."
120
+ )
121
+ if err.status == 429:
122
+ return "StackResolve rate limit reached. Wait a moment and retry."
123
+ return f"StackResolve API error {err.status}: {err.body}"
124
+ except Exception as err: # noqa: BLE001 - a tool must never end the agent's turn
125
+ return (
126
+ f"StackResolve request failed: {type(err).__name__}: {err}. "
127
+ "This is usually a timeout or a network problem. Retry, or check "
128
+ "STACKRESOLVE_BASE_URL if it is set."
129
+ )
130
+
131
+ async def _acall(self, fn: Any, *args: Any, **kwargs: Any) -> str:
132
+ return await run_in_executor(None, self._call, fn, *args, **kwargs)
133
+
134
+
135
+ # --------------------------------------------------------------------------------
136
+ # AgentReady: pick, compare, and vet software
137
+ # --------------------------------------------------------------------------------
138
+
139
+
140
+ class FindToolsInput(BaseModel):
141
+ task: str = Field(
142
+ description=(
143
+ "The engineering task in plain English, e.g. 'scrape javascript-heavy "
144
+ "sites' or 'send transactional email from a Node service'."
145
+ )
146
+ )
147
+
148
+
149
+ class StackResolveFindTools(StackResolveBaseTool):
150
+ """Task in, ranked agent-ready tools out."""
151
+
152
+ name: str = "stackresolve_find_tools"
153
+ description: str = (
154
+ "Find the best software for an engineering task, ranked by how well it works "
155
+ "for AI agents. Returns candidate tools with an AgentReady score (0-100), what "
156
+ "each one does, and why it fits. Use this when you need to choose a library, "
157
+ "API, or service and want current, scored options rather than guessing from "
158
+ "memory. Input is a plain-English task description."
159
+ )
160
+ args_schema: Type[BaseModel] = FindToolsInput
161
+
162
+ def _run(
163
+ self, task: str, run_manager: Optional[CallbackManagerForToolRun] = None
164
+ ) -> str:
165
+ return self._call(self.client.find_tools, task)
166
+
167
+ async def _arun(
168
+ self, task: str, run_manager: Optional[AsyncCallbackManagerForToolRun] = None
169
+ ) -> str:
170
+ return await self._acall(self.client.find_tools, task)
171
+
172
+
173
+ class SearchToolsInput(BaseModel):
174
+ query: str = Field(description="Search query for the StackResolve registry.")
175
+ api: Optional[bool] = Field(default=None, description="Require a public API.")
176
+ mcp: Optional[bool] = Field(default=None, description="Require an MCP server.")
177
+ cli: Optional[bool] = Field(default=None, description="Require a CLI.")
178
+ openapi: Optional[bool] = Field(
179
+ default=None, description="Require a published OpenAPI spec."
180
+ )
181
+ self_serve: Optional[bool] = Field(
182
+ default=None, description="Require self-serve signup with no sales call."
183
+ )
184
+
185
+
186
+ def _requirements(**kwargs: Any) -> Optional[Dict[str, Any]]:
187
+ reqs = {k: v for k, v in kwargs.items() if v is not None}
188
+ return reqs or None
189
+
190
+
191
+ class StackResolveSearchTools(StackResolveBaseTool):
192
+ """Registry search with hard requirements."""
193
+
194
+ name: str = "stackresolve_search_tools"
195
+ description: str = (
196
+ "Search the StackResolve registry for software, filtering on capabilities an "
197
+ "agent needs: a public API, an MCP server, a CLI, an OpenAPI spec, or self-serve "
198
+ "signup. Use this when the requirement is concrete, e.g. 'a payments provider "
199
+ "with an MCP server and self-serve signup'. Free, no API key needed."
200
+ )
201
+ args_schema: Type[BaseModel] = SearchToolsInput
202
+
203
+ def _run(
204
+ self,
205
+ query: str,
206
+ api: Optional[bool] = None,
207
+ mcp: Optional[bool] = None,
208
+ cli: Optional[bool] = None,
209
+ openapi: Optional[bool] = None,
210
+ self_serve: Optional[bool] = None,
211
+ run_manager: Optional[CallbackManagerForToolRun] = None,
212
+ ) -> str:
213
+ reqs = _requirements(
214
+ api=api, mcp=mcp, cli=cli, openapi=openapi, self_serve=self_serve
215
+ )
216
+ return self._call(self.client.search, query, reqs)
217
+
218
+ async def _arun(
219
+ self,
220
+ query: str,
221
+ api: Optional[bool] = None,
222
+ mcp: Optional[bool] = None,
223
+ cli: Optional[bool] = None,
224
+ openapi: Optional[bool] = None,
225
+ self_serve: Optional[bool] = None,
226
+ run_manager: Optional[AsyncCallbackManagerForToolRun] = None,
227
+ ) -> str:
228
+ reqs = _requirements(
229
+ api=api, mcp=mcp, cli=cli, openapi=openapi, self_serve=self_serve
230
+ )
231
+ return await self._acall(self.client.search, query, reqs)
232
+
233
+
234
+ class AuditInput(BaseModel):
235
+ domain: str = Field(description="Domain to audit, e.g. 'stripe.com'.")
236
+
237
+
238
+ class StackResolveAudit(StackResolveBaseTool):
239
+ """Agent-readiness score and failing checks for a domain."""
240
+
241
+ name: str = "stackresolve_audit"
242
+ description: str = (
243
+ "Audit a domain for agent readiness. Returns a 0-100 AgentReady score, "
244
+ "sub-scores for discovery, understanding, adoption, and operability, plus the "
245
+ "specific checks that failed. Use this to judge whether an agent can actually "
246
+ "work with a vendor before recommending it, or to check a site you are building."
247
+ )
248
+ args_schema: Type[BaseModel] = AuditInput
249
+
250
+ def _run(
251
+ self, domain: str, run_manager: Optional[CallbackManagerForToolRun] = None
252
+ ) -> str:
253
+ return self._call(self.client.audit, domain)
254
+
255
+ async def _arun(
256
+ self, domain: str, run_manager: Optional[AsyncCallbackManagerForToolRun] = None
257
+ ) -> str:
258
+ return await self._acall(self.client.audit, domain)
259
+
260
+
261
+ class CompareInput(BaseModel):
262
+ slugs: List[str] = Field(
263
+ description="Registry slugs to compare side by side, e.g. ['firecrawl', 'apify']."
264
+ )
265
+
266
+
267
+ class StackResolveCompareProducts(StackResolveBaseTool):
268
+ """Side-by-side product comparison."""
269
+
270
+ name: str = "stackresolve_compare_products"
271
+ description: str = (
272
+ "Compare two or more products from the StackResolve registry side by side on "
273
+ "AgentReady scores, capabilities, and pricing. Use this after narrowing to a "
274
+ "shortlist. Input is a list of registry slugs, which you can get from "
275
+ "stackresolve_find_tools or stackresolve_search_tools."
276
+ )
277
+ args_schema: Type[BaseModel] = CompareInput
278
+
279
+ def _run(
280
+ self, slugs: List[str], run_manager: Optional[CallbackManagerForToolRun] = None
281
+ ) -> str:
282
+ return self._call(self.client.compare, slugs)
283
+
284
+ async def _arun(
285
+ self,
286
+ slugs: List[str],
287
+ run_manager: Optional[AsyncCallbackManagerForToolRun] = None,
288
+ ) -> str:
289
+ return await self._acall(self.client.compare, slugs)
290
+
291
+
292
+ # --------------------------------------------------------------------------------
293
+ # CompanyData: structured research in one call
294
+ # --------------------------------------------------------------------------------
295
+
296
+
297
+ class DomainInput(BaseModel):
298
+ domain: str = Field(description="Company domain, e.g. 'vercel.com'.")
299
+
300
+
301
+ class StackResolveGetCompany(StackResolveBaseTool):
302
+ """Structured company facts."""
303
+
304
+ name: str = "stackresolve_get_company"
305
+ description: str = (
306
+ "Get structured facts about a company from its domain: what it does, category, "
307
+ "size, funding, and location. Use this instead of searching the web and reading "
308
+ "pages, which costs many more tool calls and returns unstructured text."
309
+ )
310
+ args_schema: Type[BaseModel] = DomainInput
311
+
312
+ def _run(
313
+ self, domain: str, run_manager: Optional[CallbackManagerForToolRun] = None
314
+ ) -> str:
315
+ return self._call(self.client.get_company, domain)
316
+
317
+ async def _arun(
318
+ self, domain: str, run_manager: Optional[AsyncCallbackManagerForToolRun] = None
319
+ ) -> str:
320
+ return await self._acall(self.client.get_company, domain)
321
+
322
+
323
+ class StackResolveGetPricing(StackResolveBaseTool):
324
+ """Current pricing for a vendor."""
325
+
326
+ name: str = "stackresolve_get_pricing"
327
+ description: str = (
328
+ "Get a company's current pricing as structured data: plans, prices, billing "
329
+ "period, and what each tier includes. Use this for cost questions and "
330
+ "build-vs-buy comparisons. Pricing pages change often, so prefer this over "
331
+ "recalling a price from memory."
332
+ )
333
+ args_schema: Type[BaseModel] = DomainInput
334
+
335
+ def _run(
336
+ self, domain: str, run_manager: Optional[CallbackManagerForToolRun] = None
337
+ ) -> str:
338
+ return self._call(self.client.get_pricing, domain)
339
+
340
+ async def _arun(
341
+ self, domain: str, run_manager: Optional[AsyncCallbackManagerForToolRun] = None
342
+ ) -> str:
343
+ return await self._acall(self.client.get_pricing, domain)
344
+
345
+
346
+ class StackResolveFindCompetitors(StackResolveBaseTool):
347
+ """Competitors for a company."""
348
+
349
+ name: str = "stackresolve_find_competitors"
350
+ description: str = (
351
+ "Find a company's competitors from its domain, with a short note on how each "
352
+ "one differs. Use this to widen a shortlist or to answer 'what else is there'."
353
+ )
354
+ args_schema: Type[BaseModel] = DomainInput
355
+
356
+ def _run(
357
+ self, domain: str, run_manager: Optional[CallbackManagerForToolRun] = None
358
+ ) -> str:
359
+ return self._call(self.client.get_competitors, domain)
360
+
361
+ async def _arun(
362
+ self, domain: str, run_manager: Optional[AsyncCallbackManagerForToolRun] = None
363
+ ) -> str:
364
+ return await self._acall(self.client.get_competitors, domain)
365
+
366
+
367
+ class ResearchInput(BaseModel):
368
+ domain: str = Field(description="Company domain to research, e.g. 'anthropic.com'.")
369
+ question: Optional[str] = Field(
370
+ default=None,
371
+ description=(
372
+ "Optional specific question, e.g. 'what is their enterprise SLA?'. Omit for "
373
+ "a general research summary."
374
+ ),
375
+ )
376
+
377
+
378
+ class StackResolveResearchCompany(StackResolveBaseTool):
379
+ """Deep research on a company, answering a specific question."""
380
+
381
+ name: str = "stackresolve_research_company"
382
+ description: str = (
383
+ "Run deep research on a company and get a synthesized answer with sources. "
384
+ "Takes a domain and an optional question. Use this when the answer needs current "
385
+ "web evidence rather than a stored fact, for example recent funding, a policy "
386
+ "change, or a support commitment. Slower and metered, so try "
387
+ "stackresolve_get_company first for basic facts."
388
+ )
389
+ args_schema: Type[BaseModel] = ResearchInput
390
+
391
+ def _run(
392
+ self,
393
+ domain: str,
394
+ question: Optional[str] = None,
395
+ run_manager: Optional[CallbackManagerForToolRun] = None,
396
+ ) -> str:
397
+ return self._call(self.client.research, domain, question)
398
+
399
+ async def _arun(
400
+ self,
401
+ domain: str,
402
+ question: Optional[str] = None,
403
+ run_manager: Optional[AsyncCallbackManagerForToolRun] = None,
404
+ ) -> str:
405
+ return await self._acall(self.client.research, domain, question)
406
+
407
+
408
+ ALL_TOOL_CLASSES = [
409
+ StackResolveFindTools,
410
+ StackResolveSearchTools,
411
+ StackResolveAudit,
412
+ StackResolveCompareProducts,
413
+ StackResolveGetCompany,
414
+ StackResolveGetPricing,
415
+ StackResolveFindCompetitors,
416
+ StackResolveResearchCompany,
417
+ ]
@@ -0,0 +1,48 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "langchain-stackresolve"
7
+ version = "0.1.0"
8
+ description = "LangChain tools for StackResolve: find, compare, and audit software for AI agents, plus structured company research."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ license-files = ["LICENSE"]
13
+ authors = [{ name = "StackResolve" }]
14
+ keywords = [
15
+ "langchain",
16
+ "stackresolve",
17
+ "agentready",
18
+ "companydata",
19
+ "ai-agents",
20
+ "agent-tools",
21
+ "mcp",
22
+ ]
23
+ classifiers = [
24
+ "Development Status :: 4 - Beta",
25
+ "Intended Audience :: Developers",
26
+ "License :: OSI Approved :: MIT License",
27
+ "Programming Language :: Python :: 3",
28
+ "Topic :: Software Development :: Libraries :: Python Modules",
29
+ ]
30
+ dependencies = [
31
+ "langchain-core>=0.3.0",
32
+ "stackresolve>=0.1.0",
33
+ "pydantic>=2.0",
34
+ ]
35
+
36
+ [project.optional-dependencies]
37
+ test = ["pytest>=7.0", "pytest-asyncio>=0.23"]
38
+
39
+ [project.urls]
40
+ Homepage = "https://stackresolve.dev"
41
+ Repository = "https://github.com/autorevai/stackresolve"
42
+ Documentation = "https://stackresolve.dev/docs"
43
+
44
+ [tool.hatch.build.targets.wheel]
45
+ packages = ["langchain_stackresolve"]
46
+
47
+ [tool.pytest.ini_options]
48
+ asyncio_mode = "auto"
@@ -0,0 +1,176 @@
1
+ """Unit tests for the StackResolve LangChain tools. No network."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+
7
+ import pytest
8
+ from stackresolve import StackResolveError
9
+
10
+ from langchain_stackresolve import (
11
+ ALL_TOOL_CLASSES,
12
+ StackResolveAudit,
13
+ StackResolveFindTools,
14
+ StackResolveGetPricing,
15
+ StackResolveSearchTools,
16
+ StackResolveToolkit,
17
+ )
18
+ from langchain_stackresolve.tools import MAX_CHARS
19
+
20
+
21
+ class FakeClient:
22
+ """Records calls and returns canned payloads, so tests never hit the API."""
23
+
24
+ def __init__(self, payload=None, error=None):
25
+ self.payload = payload if payload is not None else {"ok": True}
26
+ self.error = error
27
+ self.calls = []
28
+
29
+ def _handle(self, name, *args, **kwargs):
30
+ self.calls.append((name, args, kwargs))
31
+ if self.error is not None:
32
+ raise self.error
33
+ return self.payload
34
+
35
+ def find_tools(self, task):
36
+ return self._handle("find_tools", task)
37
+
38
+ def search(self, query, requirements=None):
39
+ return self._handle("search", query, requirements)
40
+
41
+ def audit(self, domain):
42
+ return self._handle("audit", domain)
43
+
44
+ def compare(self, slugs):
45
+ return self._handle("compare", slugs)
46
+
47
+ def get_company(self, domain):
48
+ return self._handle("get_company", domain)
49
+
50
+ def get_pricing(self, domain):
51
+ return self._handle("get_pricing", domain)
52
+
53
+ def get_competitors(self, domain):
54
+ return self._handle("get_competitors", domain)
55
+
56
+ def research(self, domain, question=None):
57
+ return self._handle("research", domain, question)
58
+
59
+
60
+ def test_toolkit_exposes_every_tool():
61
+ tools = StackResolveToolkit(client=FakeClient()).get_tools()
62
+ assert len(tools) == len(ALL_TOOL_CLASSES) == 8
63
+ names = [t.name for t in tools]
64
+ assert len(set(names)) == len(names), "tool names must be unique"
65
+ assert all(n.startswith("stackresolve_") for n in names)
66
+
67
+
68
+ def test_toolkit_shares_one_client():
69
+ client = FakeClient()
70
+ tools = StackResolveToolkit(client=client).get_tools()
71
+ assert all(t.client is client for t in tools)
72
+
73
+
74
+ def test_every_tool_has_a_usable_description():
75
+ for tool in StackResolveToolkit(client=FakeClient()).get_tools():
76
+ # The description is what the model routes on, so it must be substantial.
77
+ assert len(tool.description) > 80, tool.name
78
+ assert tool.args_schema is not None, tool.name
79
+
80
+
81
+ def test_find_tools_passes_the_task_through():
82
+ client = FakeClient(payload={"results": [{"slug": "firecrawl", "agentready": 91}]})
83
+ out = StackResolveFindTools(client=client).invoke({"task": "scrape a site"})
84
+ assert client.calls == [("find_tools", ("scrape a site",), {})]
85
+ assert json.loads(out)["results"][0]["slug"] == "firecrawl"
86
+
87
+
88
+ def test_search_builds_requirements_and_drops_unset_flags():
89
+ client = FakeClient(payload=[])
90
+ StackResolveSearchTools(client=client).invoke(
91
+ {"query": "payments", "mcp": True, "self_serve": False}
92
+ )
93
+ name, args, _ = client.calls[0]
94
+ assert name == "search"
95
+ assert args[0] == "payments"
96
+ assert args[1] == {"mcp": True, "self_serve": False}
97
+
98
+
99
+ def test_search_sends_none_when_no_requirements_given():
100
+ client = FakeClient(payload=[])
101
+ StackResolveSearchTools(client=client).invoke({"query": "email"})
102
+ assert client.calls[0][1][1] is None
103
+
104
+
105
+ def test_output_is_truncated_on_the_character_budget():
106
+ client = FakeClient(payload={"rows": [{"v": "x" * 200} for _ in range(200)]})
107
+ out = StackResolveAudit(client=client).invoke({"domain": "stripe.com"})
108
+ assert "truncated" in out
109
+
110
+
111
+ def test_truncated_output_is_still_valid_json():
112
+ """A caller that re-parses tool output must not break on a large payload."""
113
+ client = FakeClient(payload={"rows": [{"v": "x" * 200} for _ in range(200)]})
114
+ out = StackResolveAudit(client=client).invoke({"domain": "stripe.com"})
115
+ parsed = json.loads(out) # would raise before the envelope fix
116
+ assert parsed["truncated"] is True
117
+ assert parsed["omitted_characters"] > 0
118
+ assert parsed["partial"].startswith("{")
119
+
120
+
121
+ @pytest.mark.parametrize(
122
+ "status,expected",
123
+ [
124
+ (401, "STACKRESOLVE_API_KEY"),
125
+ (403, "STACKRESOLVE_API_KEY"),
126
+ (402, "free allowance"),
127
+ (429, "rate limit"),
128
+ (500, "API error 500"),
129
+ ],
130
+ )
131
+ def test_api_errors_become_readable_text_instead_of_raising(status, expected):
132
+ client = FakeClient(error=StackResolveError(status, {"detail": "nope"}))
133
+ out = StackResolveGetPricing(client=client).invoke({"domain": "vercel.com"})
134
+ assert expected in out
135
+
136
+
137
+ @pytest.mark.parametrize(
138
+ "exc",
139
+ [
140
+ ConnectionError("[Errno 61] Connection refused"),
141
+ TimeoutError("the read operation timed out"),
142
+ ValueError("malformed response body"),
143
+ ],
144
+ )
145
+ def test_transport_failures_do_not_escape_and_kill_the_agent_turn(exc):
146
+ """Regression: only StackResolveError was caught, so a timeout ended the turn."""
147
+ client = FakeClient(error=exc)
148
+ out = StackResolveAudit(client=client).invoke({"domain": "stripe.com"})
149
+ assert "StackResolve request failed" in out
150
+ assert type(exc).__name__ in out
151
+
152
+
153
+ async def test_transport_failures_do_not_escape_on_the_async_path():
154
+ client = FakeClient(error=ConnectionError("[Errno 61] Connection refused"))
155
+ out = await StackResolveAudit(client=client).ainvoke({"domain": "stripe.com"})
156
+ assert "StackResolve request failed" in out
157
+
158
+
159
+ async def test_async_path_reaches_the_client():
160
+ client = FakeClient(payload={"name": "Vercel"})
161
+ out = await StackResolveAudit(client=client).ainvoke({"domain": "vercel.com"})
162
+ assert client.calls == [("audit", ("vercel.com",), {})]
163
+ assert json.loads(out)["name"] == "Vercel"
164
+
165
+
166
+ def test_research_forwards_the_optional_question():
167
+ client = FakeClient()
168
+ tools = {t.name: t for t in StackResolveToolkit(client=client).get_tools()}
169
+ tools["stackresolve_research_company"].invoke(
170
+ {"domain": "anthropic.com", "question": "what is the enterprise SLA?"}
171
+ )
172
+ assert client.calls[0] == (
173
+ "research",
174
+ ("anthropic.com", "what is the enterprise SLA?"),
175
+ {},
176
+ )