crewai-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.
- crewai_stackresolve-0.1.0/.gitignore +6 -0
- crewai_stackresolve-0.1.0/LICENSE +21 -0
- crewai_stackresolve-0.1.0/PKG-INFO +89 -0
- crewai_stackresolve-0.1.0/README.md +67 -0
- crewai_stackresolve-0.1.0/crewai_stackresolve/__init__.py +29 -0
- crewai_stackresolve-0.1.0/crewai_stackresolve/_common.py +98 -0
- crewai_stackresolve-0.1.0/crewai_stackresolve/tools.py +216 -0
- crewai_stackresolve-0.1.0/pyproject.toml +31 -0
- crewai_stackresolve-0.1.0/tests/test_tools.py +137 -0
|
@@ -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,89 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: crewai-stackresolve
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CrewAI 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
|
+
Author: StackResolve
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: agent-tools,agentready,ai-agents,crewai,mcp,stackresolve
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Requires-Python: <3.14,>=3.10
|
|
16
|
+
Requires-Dist: crewai>=0.100.0
|
|
17
|
+
Requires-Dist: pydantic>=2.0
|
|
18
|
+
Requires-Dist: stackresolve>=0.1.0
|
|
19
|
+
Provides-Extra: test
|
|
20
|
+
Requires-Dist: pytest>=7.0; extra == 'test'
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
|
|
23
|
+
# crewai-stackresolve
|
|
24
|
+
|
|
25
|
+
CrewAI tools for [StackResolve](https://stackresolve.dev): web intelligence for AI agents.
|
|
26
|
+
|
|
27
|
+
Choose software for a task, compare vendors, audit agent-readiness, and pull structured
|
|
28
|
+
company facts. One tool call instead of a search-and-scrape loop.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install crewai-stackresolve
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Quickstart
|
|
35
|
+
|
|
36
|
+
```python
|
|
37
|
+
from crewai import Agent, Task, Crew
|
|
38
|
+
from crewai_stackresolve import stackresolve_tools
|
|
39
|
+
|
|
40
|
+
analyst = Agent(
|
|
41
|
+
role="Tooling analyst",
|
|
42
|
+
goal="Pick the right software for a task and justify the cost",
|
|
43
|
+
backstory="You evaluate vendors on agent-readiness, not marketing copy.",
|
|
44
|
+
tools=stackresolve_tools(),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
task = Task(
|
|
48
|
+
description="We need to scrape javascript-heavy sites. Recommend a tool and its cost.",
|
|
49
|
+
expected_output="A recommendation with the AgentReady score and current pricing.",
|
|
50
|
+
agent=analyst,
|
|
51
|
+
)
|
|
52
|
+
|
|
53
|
+
print(Crew(agents=[analyst], tasks=[task]).kickoff())
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## Tools
|
|
57
|
+
|
|
58
|
+
Find software for a task, Search the software registry, Compare products, Audit agent
|
|
59
|
+
readiness, Get company facts, Get company pricing, Find competitors, Research a company.
|
|
60
|
+
|
|
61
|
+
All of them work without a key, subject to an anonymous rate limit. A free key from
|
|
62
|
+
[stackresolve.dev/developers](https://stackresolve.dev/developers) raises it:
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
stackresolve_tools(api_key="ar_...") # or set STACKRESOLVE_API_KEY
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Import a single tool if you do not want the whole set:
|
|
69
|
+
|
|
70
|
+
```python
|
|
71
|
+
from crewai_stackresolve import StackResolveAudit
|
|
72
|
+
agent = Agent(role="Auditor", tools=[StackResolveAudit()])
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
## Behavior worth knowing
|
|
76
|
+
|
|
77
|
+
Tools return readable text on failure rather than raising, so a timeout or an exhausted
|
|
78
|
+
allowance does not end the crew's run. Output is capped at 6,000 characters and stays
|
|
79
|
+
valid JSON when truncated.
|
|
80
|
+
|
|
81
|
+
## Also available
|
|
82
|
+
|
|
83
|
+
Hosted MCP server: `https://mcp.stackresolve.dev/mcp`. LangChain: `pip install
|
|
84
|
+
langchain-stackresolve`. LlamaIndex: `pip install llama-index-tools-stackresolve`.
|
|
85
|
+
TypeScript: `npm install stackresolve`.
|
|
86
|
+
|
|
87
|
+
## License
|
|
88
|
+
|
|
89
|
+
MIT
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# crewai-stackresolve
|
|
2
|
+
|
|
3
|
+
CrewAI tools for [StackResolve](https://stackresolve.dev): web intelligence for AI agents.
|
|
4
|
+
|
|
5
|
+
Choose software for a task, compare vendors, audit agent-readiness, and pull structured
|
|
6
|
+
company facts. One tool call instead of a search-and-scrape loop.
|
|
7
|
+
|
|
8
|
+
```bash
|
|
9
|
+
pip install crewai-stackresolve
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
## Quickstart
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
from crewai import Agent, Task, Crew
|
|
16
|
+
from crewai_stackresolve import stackresolve_tools
|
|
17
|
+
|
|
18
|
+
analyst = Agent(
|
|
19
|
+
role="Tooling analyst",
|
|
20
|
+
goal="Pick the right software for a task and justify the cost",
|
|
21
|
+
backstory="You evaluate vendors on agent-readiness, not marketing copy.",
|
|
22
|
+
tools=stackresolve_tools(),
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
task = Task(
|
|
26
|
+
description="We need to scrape javascript-heavy sites. Recommend a tool and its cost.",
|
|
27
|
+
expected_output="A recommendation with the AgentReady score and current pricing.",
|
|
28
|
+
agent=analyst,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
print(Crew(agents=[analyst], tasks=[task]).kickoff())
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## Tools
|
|
35
|
+
|
|
36
|
+
Find software for a task, Search the software registry, Compare products, Audit agent
|
|
37
|
+
readiness, Get company facts, Get company pricing, Find competitors, Research a company.
|
|
38
|
+
|
|
39
|
+
All of them work 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
|
+
stackresolve_tools(api_key="ar_...") # or set STACKRESOLVE_API_KEY
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Import a single tool if you do not want the whole set:
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from crewai_stackresolve import StackResolveAudit
|
|
50
|
+
agent = Agent(role="Auditor", tools=[StackResolveAudit()])
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
## Behavior worth knowing
|
|
54
|
+
|
|
55
|
+
Tools return readable text on failure rather than raising, so a timeout or an exhausted
|
|
56
|
+
allowance does not end the crew's run. Output is capped at 6,000 characters and stays
|
|
57
|
+
valid JSON when truncated.
|
|
58
|
+
|
|
59
|
+
## Also available
|
|
60
|
+
|
|
61
|
+
Hosted MCP server: `https://mcp.stackresolve.dev/mcp`. LangChain: `pip install
|
|
62
|
+
langchain-stackresolve`. LlamaIndex: `pip install llama-index-tools-stackresolve`.
|
|
63
|
+
TypeScript: `npm install stackresolve`.
|
|
64
|
+
|
|
65
|
+
## License
|
|
66
|
+
|
|
67
|
+
MIT
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""StackResolve tools for CrewAI."""
|
|
2
|
+
|
|
3
|
+
from .tools import (
|
|
4
|
+
ALL_TOOL_CLASSES,
|
|
5
|
+
StackResolveAudit,
|
|
6
|
+
StackResolveCompareProducts,
|
|
7
|
+
StackResolveFindCompetitors,
|
|
8
|
+
StackResolveFindTools,
|
|
9
|
+
StackResolveGetCompany,
|
|
10
|
+
StackResolveGetPricing,
|
|
11
|
+
StackResolveResearchCompany,
|
|
12
|
+
StackResolveSearchTools,
|
|
13
|
+
stackresolve_tools,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__version__ = "0.1.0"
|
|
17
|
+
__all__ = [
|
|
18
|
+
"stackresolve_tools",
|
|
19
|
+
"StackResolveFindTools",
|
|
20
|
+
"StackResolveSearchTools",
|
|
21
|
+
"StackResolveCompareProducts",
|
|
22
|
+
"StackResolveAudit",
|
|
23
|
+
"StackResolveGetCompany",
|
|
24
|
+
"StackResolveGetPricing",
|
|
25
|
+
"StackResolveFindCompetitors",
|
|
26
|
+
"StackResolveResearchCompany",
|
|
27
|
+
"ALL_TOOL_CLASSES",
|
|
28
|
+
"__version__",
|
|
29
|
+
]
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""Shared plumbing for the StackResolve framework adapters.
|
|
2
|
+
|
|
3
|
+
Vendored into each adapter package rather than published as a fourth PyPI package:
|
|
4
|
+
one more dependency on every install is a worse trade than ~80 duplicated lines,
|
|
5
|
+
and the adapters must be able to move independently of each other.
|
|
6
|
+
|
|
7
|
+
Keep this file the single source and re-copy it, so a fix like the uncaught-timeout
|
|
8
|
+
one lands everywhere at once.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
from typing import Any, Dict, Optional
|
|
16
|
+
|
|
17
|
+
from stackresolve import StackResolve, StackResolveError
|
|
18
|
+
|
|
19
|
+
# Tool output goes straight into a prompt, so cap it. Registry and research payloads
|
|
20
|
+
# can run to tens of kilobytes and the tail is rarely what the model needs.
|
|
21
|
+
MAX_CHARS = 6000
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def build_client(
|
|
25
|
+
api_key: Optional[str] = None, base_url: Optional[str] = None
|
|
26
|
+
) -> StackResolve:
|
|
27
|
+
"""Construct an SDK client, falling back to the standard environment variables."""
|
|
28
|
+
opts: Dict[str, Any] = {}
|
|
29
|
+
key = api_key or os.environ.get("STACKRESOLVE_API_KEY")
|
|
30
|
+
if key:
|
|
31
|
+
opts["api_key"] = key
|
|
32
|
+
url = base_url or os.environ.get("STACKRESOLVE_BASE_URL")
|
|
33
|
+
if url:
|
|
34
|
+
opts["base_url"] = url
|
|
35
|
+
return StackResolve(**opts)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def dump(payload: Any, max_chars: int = MAX_CHARS) -> str:
|
|
39
|
+
"""Serialize to JSON, truncating on a character budget and staying valid JSON.
|
|
40
|
+
|
|
41
|
+
Slicing the serialized text mid-token would leave output a caller re-parsing tool
|
|
42
|
+
results cannot load, and that failure would only appear on large payloads.
|
|
43
|
+
"""
|
|
44
|
+
text = json.dumps(payload, indent=2, default=str, ensure_ascii=False)
|
|
45
|
+
if len(text) <= max_chars:
|
|
46
|
+
return text
|
|
47
|
+
return json.dumps(
|
|
48
|
+
{
|
|
49
|
+
"truncated": True,
|
|
50
|
+
"omitted_characters": len(text) - max_chars,
|
|
51
|
+
"note": (
|
|
52
|
+
f"Result exceeded {max_chars} characters. 'partial' holds the start of "
|
|
53
|
+
"the JSON payload. Narrow the query for a complete result."
|
|
54
|
+
),
|
|
55
|
+
"partial": text[:max_chars],
|
|
56
|
+
},
|
|
57
|
+
indent=2,
|
|
58
|
+
ensure_ascii=False,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def call(fn: Any, *args: Any, **kwargs: Any) -> str:
|
|
63
|
+
"""Run an SDK call and return JSON, converting any failure into readable text.
|
|
64
|
+
|
|
65
|
+
A tool that raises ends the agent's turn. A tool that explains what went wrong lets
|
|
66
|
+
the model recover, so a 402 becomes an instruction rather than a traceback.
|
|
67
|
+
|
|
68
|
+
The catch-all matters as much as the status branches: a read timeout, a refused
|
|
69
|
+
connection, or a DNS failure is an ordinary condition for a network tool.
|
|
70
|
+
"""
|
|
71
|
+
try:
|
|
72
|
+
return dump(fn(*args, **kwargs))
|
|
73
|
+
except StackResolveError as err:
|
|
74
|
+
if err.status in (401, 403):
|
|
75
|
+
return (
|
|
76
|
+
"StackResolve rejected the credentials. Set STACKRESOLVE_API_KEY to a "
|
|
77
|
+
"key from https://stackresolve.dev/developers and retry."
|
|
78
|
+
)
|
|
79
|
+
if err.status == 402:
|
|
80
|
+
return (
|
|
81
|
+
"This StackResolve tool is metered and the free allowance is used up. "
|
|
82
|
+
"Registry reads (search, profiles, compare) still work."
|
|
83
|
+
)
|
|
84
|
+
if err.status == 429:
|
|
85
|
+
return "StackResolve rate limit reached. Wait a moment and retry."
|
|
86
|
+
return f"StackResolve API error {err.status}: {err.body}"
|
|
87
|
+
except Exception as err: # noqa: BLE001 - a tool must never end the agent's turn
|
|
88
|
+
return (
|
|
89
|
+
f"StackResolve request failed: {type(err).__name__}: {err}. This is usually "
|
|
90
|
+
"a timeout or a network problem. Retry, or check STACKRESOLVE_BASE_URL if "
|
|
91
|
+
"it is set."
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def requirements(**kwargs: Any) -> Optional[Dict[str, Any]]:
|
|
96
|
+
"""Drop unset capability filters so the API sees only what the caller asked for."""
|
|
97
|
+
reqs = {k: v for k, v in kwargs.items() if v is not None}
|
|
98
|
+
return reqs or None
|
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
"""CrewAI tools for StackResolve.
|
|
2
|
+
|
|
3
|
+
from crewai import Agent
|
|
4
|
+
from crewai_stackresolve import stackresolve_tools
|
|
5
|
+
|
|
6
|
+
analyst = Agent(role="Tooling analyst", tools=stackresolve_tools())
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from typing import Any, List, Optional, Type
|
|
12
|
+
|
|
13
|
+
from crewai.tools import BaseTool
|
|
14
|
+
from pydantic import BaseModel, ConfigDict, Field, PrivateAttr
|
|
15
|
+
from stackresolve import StackResolve
|
|
16
|
+
|
|
17
|
+
from ._common import build_client, call, requirements
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _StackResolveTool(BaseTool):
|
|
21
|
+
"""Shared plumbing: one client, uniform error handling."""
|
|
22
|
+
|
|
23
|
+
model_config = ConfigDict(arbitrary_types_allowed=True)
|
|
24
|
+
|
|
25
|
+
_client: Optional[StackResolve] = PrivateAttr(default=None)
|
|
26
|
+
|
|
27
|
+
def __init__(
|
|
28
|
+
self,
|
|
29
|
+
client: Optional[StackResolve] = None,
|
|
30
|
+
api_key: Optional[str] = None,
|
|
31
|
+
base_url: Optional[str] = None,
|
|
32
|
+
**kwargs: Any,
|
|
33
|
+
) -> None:
|
|
34
|
+
super().__init__(**kwargs)
|
|
35
|
+
self._client = client if client is not None else build_client(api_key, base_url)
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def client(self) -> StackResolve:
|
|
39
|
+
if self._client is None: # pragma: no cover - defensive
|
|
40
|
+
self._client = build_client()
|
|
41
|
+
return self._client
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FindToolsInput(BaseModel):
|
|
45
|
+
task: str = Field(
|
|
46
|
+
description="The engineering task in plain English, e.g. 'scrape javascript-heavy sites'."
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class StackResolveFindTools(_StackResolveTool):
|
|
51
|
+
name: str = "Find software for a task"
|
|
52
|
+
description: str = (
|
|
53
|
+
"Find the best software for an engineering task, ranked by how well it works "
|
|
54
|
+
"for AI agents. Returns candidates with an AgentReady score (0-100), what each "
|
|
55
|
+
"does, and why it fits. Use this to choose a library, API, or service with "
|
|
56
|
+
"current scored options rather than recalling one from memory."
|
|
57
|
+
)
|
|
58
|
+
args_schema: Type[BaseModel] = FindToolsInput
|
|
59
|
+
|
|
60
|
+
def _run(self, task: str) -> str:
|
|
61
|
+
return call(self.client.find_tools, task)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SearchToolsInput(BaseModel):
|
|
65
|
+
query: str = Field(description="Search query for the StackResolve registry.")
|
|
66
|
+
api: Optional[bool] = Field(default=None, description="Require a public API.")
|
|
67
|
+
mcp: Optional[bool] = Field(default=None, description="Require an MCP server.")
|
|
68
|
+
cli: Optional[bool] = Field(default=None, description="Require a CLI.")
|
|
69
|
+
openapi: Optional[bool] = Field(
|
|
70
|
+
default=None, description="Require a published OpenAPI spec."
|
|
71
|
+
)
|
|
72
|
+
self_serve: Optional[bool] = Field(
|
|
73
|
+
default=None, description="Require self-serve signup with no sales call."
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class StackResolveSearchTools(_StackResolveTool):
|
|
78
|
+
name: str = "Search the software registry"
|
|
79
|
+
description: str = (
|
|
80
|
+
"Search the StackResolve registry, filtering on capabilities an agent needs: a "
|
|
81
|
+
"public API, an MCP server, a CLI, an OpenAPI spec, or self-serve signup. Use "
|
|
82
|
+
"this when the requirement is concrete."
|
|
83
|
+
)
|
|
84
|
+
args_schema: Type[BaseModel] = SearchToolsInput
|
|
85
|
+
|
|
86
|
+
def _run(
|
|
87
|
+
self,
|
|
88
|
+
query: str,
|
|
89
|
+
api: Optional[bool] = None,
|
|
90
|
+
mcp: Optional[bool] = None,
|
|
91
|
+
cli: Optional[bool] = None,
|
|
92
|
+
openapi: Optional[bool] = None,
|
|
93
|
+
self_serve: Optional[bool] = None,
|
|
94
|
+
) -> str:
|
|
95
|
+
return call(
|
|
96
|
+
self.client.search,
|
|
97
|
+
query,
|
|
98
|
+
requirements(
|
|
99
|
+
api=api, mcp=mcp, cli=cli, openapi=openapi, self_serve=self_serve
|
|
100
|
+
),
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class CompareInput(BaseModel):
|
|
105
|
+
slugs: List[str] = Field(
|
|
106
|
+
description="Registry slugs to compare, e.g. ['firecrawl', 'apify']."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
class StackResolveCompareProducts(_StackResolveTool):
|
|
111
|
+
name: str = "Compare products"
|
|
112
|
+
description: str = (
|
|
113
|
+
"Compare two or more registry products side by side on AgentReady scores, "
|
|
114
|
+
"capabilities, and pricing. Use after narrowing to a shortlist."
|
|
115
|
+
)
|
|
116
|
+
args_schema: Type[BaseModel] = CompareInput
|
|
117
|
+
|
|
118
|
+
def _run(self, slugs: List[str]) -> str:
|
|
119
|
+
return call(self.client.compare, slugs)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class DomainInput(BaseModel):
|
|
123
|
+
domain: str = Field(description="Domain, e.g. 'stripe.com'.")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class StackResolveAudit(_StackResolveTool):
|
|
127
|
+
name: str = "Audit agent readiness"
|
|
128
|
+
description: str = (
|
|
129
|
+
"Audit a domain for agent readiness. Returns a 0-100 score, sub-scores for "
|
|
130
|
+
"discovery, understanding, adoption, and operability, and the checks that "
|
|
131
|
+
"failed. Use it to judge whether an agent can actually work with a vendor."
|
|
132
|
+
)
|
|
133
|
+
args_schema: Type[BaseModel] = DomainInput
|
|
134
|
+
|
|
135
|
+
def _run(self, domain: str) -> str:
|
|
136
|
+
return call(self.client.audit, domain)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class StackResolveGetCompany(_StackResolveTool):
|
|
140
|
+
name: str = "Get company facts"
|
|
141
|
+
description: str = (
|
|
142
|
+
"Get structured facts about a company from its domain: what it does, category, "
|
|
143
|
+
"size, funding, and location. Prefer this over searching the web and reading "
|
|
144
|
+
"pages, which costs many more tool calls and returns unstructured text."
|
|
145
|
+
)
|
|
146
|
+
args_schema: Type[BaseModel] = DomainInput
|
|
147
|
+
|
|
148
|
+
def _run(self, domain: str) -> str:
|
|
149
|
+
return call(self.client.get_company, domain)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class StackResolveGetPricing(_StackResolveTool):
|
|
153
|
+
name: str = "Get company pricing"
|
|
154
|
+
description: str = (
|
|
155
|
+
"Get a company's current pricing as structured data: plans, prices, billing "
|
|
156
|
+
"period, and what each tier includes. Pricing pages change often, so prefer "
|
|
157
|
+
"this over recalling a price from memory."
|
|
158
|
+
)
|
|
159
|
+
args_schema: Type[BaseModel] = DomainInput
|
|
160
|
+
|
|
161
|
+
def _run(self, domain: str) -> str:
|
|
162
|
+
return call(self.client.get_pricing, domain)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
class StackResolveFindCompetitors(_StackResolveTool):
|
|
166
|
+
name: str = "Find competitors"
|
|
167
|
+
description: str = (
|
|
168
|
+
"Find a company's competitors from its domain, with a short note on how each "
|
|
169
|
+
"one differs. Use this to widen a shortlist."
|
|
170
|
+
)
|
|
171
|
+
args_schema: Type[BaseModel] = DomainInput
|
|
172
|
+
|
|
173
|
+
def _run(self, domain: str) -> str:
|
|
174
|
+
return call(self.client.get_competitors, domain)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class ResearchInput(BaseModel):
|
|
178
|
+
domain: str = Field(description="Company domain, e.g. 'anthropic.com'.")
|
|
179
|
+
question: Optional[str] = Field(
|
|
180
|
+
default=None, description="Optional specific question. Omit for a summary."
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
class StackResolveResearchCompany(_StackResolveTool):
|
|
185
|
+
name: str = "Research a company"
|
|
186
|
+
description: str = (
|
|
187
|
+
"Run deep research on a company and get a synthesized answer with sources. Use "
|
|
188
|
+
"this when the answer needs current web evidence rather than a stored fact. "
|
|
189
|
+
"Slower and metered, so try the company facts tool first."
|
|
190
|
+
)
|
|
191
|
+
args_schema: Type[BaseModel] = ResearchInput
|
|
192
|
+
|
|
193
|
+
def _run(self, domain: str, question: Optional[str] = None) -> str:
|
|
194
|
+
return call(self.client.research, domain, question)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
ALL_TOOL_CLASSES = [
|
|
198
|
+
StackResolveFindTools,
|
|
199
|
+
StackResolveSearchTools,
|
|
200
|
+
StackResolveCompareProducts,
|
|
201
|
+
StackResolveAudit,
|
|
202
|
+
StackResolveGetCompany,
|
|
203
|
+
StackResolveGetPricing,
|
|
204
|
+
StackResolveFindCompetitors,
|
|
205
|
+
StackResolveResearchCompany,
|
|
206
|
+
]
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def stackresolve_tools(
|
|
210
|
+
api_key: Optional[str] = None,
|
|
211
|
+
base_url: Optional[str] = None,
|
|
212
|
+
client: Optional[StackResolve] = None,
|
|
213
|
+
) -> List[BaseTool]:
|
|
214
|
+
"""Every StackResolve tool, sharing one HTTP client."""
|
|
215
|
+
shared = client if client is not None else build_client(api_key, base_url)
|
|
216
|
+
return [cls(client=shared) for cls in ALL_TOOL_CLASSES]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "crewai-stackresolve"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "CrewAI tools for StackResolve: find, compare, and audit software for AI agents, plus structured company research."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10,<3.14"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
license-files = ["LICENSE"]
|
|
13
|
+
authors = [{ name = "StackResolve" }]
|
|
14
|
+
keywords = ["crewai", "stackresolve", "agentready", "ai-agents", "agent-tools", "mcp"]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 4 - Beta",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
]
|
|
21
|
+
dependencies = ["crewai>=0.100.0", "stackresolve>=0.1.0", "pydantic>=2.0"]
|
|
22
|
+
|
|
23
|
+
[project.optional-dependencies]
|
|
24
|
+
test = ["pytest>=7.0"]
|
|
25
|
+
|
|
26
|
+
[project.urls]
|
|
27
|
+
Homepage = "https://stackresolve.dev"
|
|
28
|
+
Repository = "https://github.com/autorevai/stackresolve"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["crewai_stackresolve"]
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Unit tests for the StackResolve CrewAI 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 crewai_stackresolve import (
|
|
11
|
+
ALL_TOOL_CLASSES,
|
|
12
|
+
StackResolveAudit,
|
|
13
|
+
StackResolveFindTools,
|
|
14
|
+
StackResolveGetPricing,
|
|
15
|
+
StackResolveSearchTools,
|
|
16
|
+
stackresolve_tools,
|
|
17
|
+
)
|
|
18
|
+
from crewai_stackresolve._common import MAX_CHARS
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class FakeClient:
|
|
22
|
+
def __init__(self, payload=None, error=None):
|
|
23
|
+
self.payload = payload if payload is not None else {"ok": True}
|
|
24
|
+
self.error = error
|
|
25
|
+
self.calls = []
|
|
26
|
+
|
|
27
|
+
def _handle(self, name, *args, **kwargs):
|
|
28
|
+
self.calls.append((name, args, kwargs))
|
|
29
|
+
if self.error is not None:
|
|
30
|
+
raise self.error
|
|
31
|
+
return self.payload
|
|
32
|
+
|
|
33
|
+
def find_tools(self, task):
|
|
34
|
+
return self._handle("find_tools", task)
|
|
35
|
+
|
|
36
|
+
def search(self, query, requirements=None):
|
|
37
|
+
return self._handle("search", query, requirements)
|
|
38
|
+
|
|
39
|
+
def compare(self, slugs):
|
|
40
|
+
return self._handle("compare", slugs)
|
|
41
|
+
|
|
42
|
+
def audit(self, domain):
|
|
43
|
+
return self._handle("audit", domain)
|
|
44
|
+
|
|
45
|
+
def get_company(self, domain):
|
|
46
|
+
return self._handle("get_company", domain)
|
|
47
|
+
|
|
48
|
+
def get_pricing(self, domain):
|
|
49
|
+
return self._handle("get_pricing", domain)
|
|
50
|
+
|
|
51
|
+
def get_competitors(self, domain):
|
|
52
|
+
return self._handle("get_competitors", domain)
|
|
53
|
+
|
|
54
|
+
def research(self, domain, question=None):
|
|
55
|
+
return self._handle("research", domain, question)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_factory_returns_every_tool():
|
|
59
|
+
tools = stackresolve_tools(client=FakeClient())
|
|
60
|
+
assert len(tools) == len(ALL_TOOL_CLASSES) == 8
|
|
61
|
+
names = [t.name for t in tools]
|
|
62
|
+
assert len(set(names)) == len(names), "tool names must be unique"
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def test_factory_shares_one_client():
|
|
66
|
+
client = FakeClient()
|
|
67
|
+
assert all(t.client is client for t in stackresolve_tools(client=client))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def test_every_tool_has_a_usable_description():
|
|
71
|
+
for tool in stackresolve_tools(client=FakeClient()):
|
|
72
|
+
# CrewAI routes on the description, so it must carry real guidance.
|
|
73
|
+
assert len(tool.description) > 80, tool.name
|
|
74
|
+
assert tool.args_schema is not None, tool.name
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def test_find_tools_passes_the_task_through():
|
|
78
|
+
client = FakeClient(payload={"results": [{"slug": "firecrawl"}]})
|
|
79
|
+
out = StackResolveFindTools(client=client).run(task="scrape a site")
|
|
80
|
+
assert client.calls == [("find_tools", ("scrape a site",), {})]
|
|
81
|
+
assert json.loads(out)["results"][0]["slug"] == "firecrawl"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_search_drops_unset_requirement_flags():
|
|
85
|
+
client = FakeClient(payload=[])
|
|
86
|
+
StackResolveSearchTools(client=client).run(query="payments", mcp=True, cli=False)
|
|
87
|
+
assert client.calls[0][1][1] == {"mcp": True, "cli": False}
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_search_sends_none_when_no_requirements_given():
|
|
91
|
+
client = FakeClient(payload=[])
|
|
92
|
+
StackResolveSearchTools(client=client).run(query="email")
|
|
93
|
+
assert client.calls[0][1][1] is None
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def test_truncated_output_is_still_valid_json():
|
|
97
|
+
client = FakeClient(payload={"rows": [{"v": "x" * 200} for _ in range(200)]})
|
|
98
|
+
parsed = json.loads(StackResolveAudit(client=client).run(domain="stripe.com"))
|
|
99
|
+
assert parsed["truncated"] is True
|
|
100
|
+
assert parsed["omitted_characters"] > 0
|
|
101
|
+
assert len(parsed["partial"]) == MAX_CHARS
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@pytest.mark.parametrize(
|
|
105
|
+
"status,expected",
|
|
106
|
+
[
|
|
107
|
+
(401, "STACKRESOLVE_API_KEY"),
|
|
108
|
+
(402, "free allowance"),
|
|
109
|
+
(429, "rate limit"),
|
|
110
|
+
(500, "API error 500"),
|
|
111
|
+
],
|
|
112
|
+
)
|
|
113
|
+
def test_api_errors_become_readable_text(status, expected):
|
|
114
|
+
client = FakeClient(error=StackResolveError(status, {"detail": "nope"}))
|
|
115
|
+
assert expected in StackResolveGetPricing(client=client).run(domain="vercel.com")
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
@pytest.mark.parametrize(
|
|
119
|
+
"exc",
|
|
120
|
+
[
|
|
121
|
+
ConnectionError("[Errno 61] Connection refused"),
|
|
122
|
+
TimeoutError("the read operation timed out"),
|
|
123
|
+
],
|
|
124
|
+
)
|
|
125
|
+
def test_transport_failures_do_not_escape(exc):
|
|
126
|
+
"""Regression: an uncaught timeout would end the crew's run."""
|
|
127
|
+
client = FakeClient(error=exc)
|
|
128
|
+
out = StackResolveAudit(client=client).run(domain="stripe.com")
|
|
129
|
+
assert "StackResolve request failed" in out
|
|
130
|
+
assert type(exc).__name__ in out
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def test_research_forwards_the_optional_question():
|
|
134
|
+
client = FakeClient()
|
|
135
|
+
tools = {t.name: t for t in stackresolve_tools(client=client)}
|
|
136
|
+
tools["Research a company"].run(domain="anthropic.com", question="SLA?")
|
|
137
|
+
assert client.calls[0] == ("research", ("anthropic.com", "SLA?"), {})
|