Skip to content

Commit fbd9c79

Browse files
committed
Respect the backend results unless top_k specified explicitly, add python only crewAI example
1 parent 4d704ab commit fbd9c79

File tree

4 files changed

+182
-16
lines changed

4 files changed

+182
-16
lines changed

examples/crewai_integration.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
"""
22
This example demonstrates how to use StackOne tools with CrewAI.
33
4+
Note: This example is Python only. CrewAI does not have an official
5+
TypeScript/Node.js library.
6+
47
CrewAI uses LangChain tools natively.
58
69
```bash

examples/crewai_semantic_search.py

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""
2+
CrewAI meeting booking agent powered by semantic search.
3+
4+
Note: This example is Python only. CrewAI does not have an official
5+
TypeScript/Node.js library.
6+
7+
Instead of hardcoding tool names, this example uses semantic search to discover
8+
scheduling tools (e.g., Calendly) from natural language queries like "book a
9+
meeting" or "check availability".
10+
11+
Prerequisites:
12+
- STACKONE_API_KEY environment variable set
13+
- STACKONE_ACCOUNT_ID environment variable set (Calendly-linked account)
14+
- OPENAI_API_KEY environment variable set (for CrewAI's LLM)
15+
16+
```bash
17+
uv run examples/crewai_semantic_search.py
18+
```
19+
"""
20+
21+
import os
22+
23+
from crewai import Agent, Crew, Task
24+
from dotenv import load_dotenv
25+
26+
from stackone_ai import StackOneToolSet
27+
28+
load_dotenv()
29+
30+
_account_ids = [aid.strip() for aid in os.getenv("STACKONE_ACCOUNT_ID", "").split(",") if aid.strip()]
31+
32+
33+
def crewai_semantic_search() -> None:
34+
toolset = StackOneToolSet()
35+
36+
# Step 1: Preview — lightweight search returning action names and scores
37+
# search_action_names() queries the semantic API without fetching full
38+
# tool definitions. Useful for inspecting what's available before committing.
39+
preview = toolset.search_action_names(
40+
"book a meeting or check availability",
41+
account_ids=_account_ids
42+
)
43+
print("Semantic search preview (action names only):")
44+
for r in preview:
45+
print(f" [{r.similarity_score:.2f}] {r.action_name} ({r.connector_key})")
46+
print()
47+
48+
# Step 2: Full discovery — fetch matching tools ready for framework use
49+
# search_tools() fetches tools from linked accounts, runs semantic search,
50+
# and returns only tools the user has access to.
51+
tools = toolset.search_tools(
52+
"schedule meetings, check availability, list events",
53+
connector="calendly",
54+
account_ids=_account_ids
55+
)
56+
assert len(tools) > 0, "Expected at least one scheduling tool"
57+
58+
print(f"Discovered {len(tools)} scheduling tools:")
59+
for tool in tools:
60+
print(f" - {tool.name}: {tool.description[:80]}...")
61+
print()
62+
63+
# Step 3: Convert to CrewAI format
64+
crewai_tools = tools.to_crewai()
65+
66+
# Step 4: Create a CrewAI meeting booking agent
67+
agent = Agent(
68+
role="Meeting Booking Agent",
69+
goal="Help users manage their calendar by discovering and booking meetings, "
70+
"checking availability, and listing upcoming events.",
71+
backstory="You are an AI assistant specialized in calendar management. "
72+
"You have access to scheduling tools discovered via semantic search "
73+
"and can help users with all meeting-related tasks.",
74+
llm="gpt-4o-mini",
75+
tools=crewai_tools,
76+
max_iter=2,
77+
verbose=True,
78+
)
79+
80+
task = Task(
81+
description="List upcoming scheduled events to give an overview of the calendar.",
82+
agent=agent,
83+
expected_output="A summary of upcoming events or a confirmation that events were retrieved.",
84+
)
85+
86+
crew = Crew(agents=[agent], tasks=[task])
87+
88+
result = crew.kickoff()
89+
assert result is not None, "Expected result to be returned"
90+
print(f"\nCrew result: {result}")
91+
92+
93+
if __name__ == "__main__":
94+
crewai_semantic_search()

stackone_ai/models.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -450,6 +450,64 @@ def _run(self, **kwargs: Any) -> Any:
450450

451451
return StackOneLangChainTool()
452452

453+
def to_crewai(self) -> Any:
454+
"""Convert this tool to CrewAI format
455+
456+
Requires the ``crewai`` package (``pip install crewai``).
457+
458+
Returns:
459+
Tool as a ``crewai.tools.BaseTool`` instance
460+
"""
461+
try:
462+
from crewai.tools.base_tool import BaseTool as CrewAIBaseTool
463+
except ImportError as e:
464+
raise ImportError("crewai is required for to_crewai(). Install with: pip install crewai") from e
465+
466+
schema_props: dict[str, Any] = {}
467+
annotations: dict[str, Any] = {}
468+
469+
for name, details in self.parameters.properties.items():
470+
python_type: type = str
471+
if isinstance(details, dict):
472+
type_str = details.get("type", "string")
473+
if type_str == "number":
474+
python_type = float
475+
elif type_str == "integer":
476+
python_type = int
477+
elif type_str == "boolean":
478+
python_type = bool
479+
480+
field = Field(description=details.get("description", ""))
481+
else:
482+
field = Field(description="")
483+
484+
schema_props[name] = field
485+
annotations[name] = python_type
486+
487+
schema_class = type(
488+
f"{self.name.title()}Args",
489+
(BaseModel,),
490+
{
491+
"__annotations__": annotations,
492+
"__module__": __name__,
493+
**schema_props,
494+
},
495+
)
496+
497+
parent_tool = self
498+
_name = parent_tool.name
499+
_description = parent_tool.description
500+
501+
class StackOneCrewAITool(CrewAIBaseTool):
502+
name: str = _name
503+
description: str = _description
504+
args_schema: type[BaseModel] = schema_class
505+
506+
def _run(self, **kwargs: Any) -> Any:
507+
return parent_tool.execute(kwargs)
508+
509+
return StackOneCrewAITool()
510+
453511
def set_account_id(self, account_id: str | None) -> None:
454512
"""Set the account ID for this tool
455513
@@ -558,6 +616,16 @@ def to_langchain(self) -> Sequence[BaseTool]:
558616
"""
559617
return [tool.to_langchain() for tool in self.tools]
560618

619+
def to_crewai(self) -> list[Any]:
620+
"""Convert all tools to CrewAI format
621+
622+
Requires the ``crewai`` package (``pip install crewai``).
623+
624+
Returns:
625+
List of tools as ``crewai.tools.BaseTool`` instances
626+
"""
627+
return [tool.to_crewai() for tool in self.tools]
628+
561629
def utility_tools(
562630
self,
563631
hybrid_alpha: float | None = None,

stackone_ai/toolset.py

Lines changed: 17 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -306,7 +306,7 @@ def search_tools(
306306
query: str,
307307
*,
308308
connector: str | None = None,
309-
top_k: int = 10,
309+
top_k: int | None = None,
310310
min_score: float = 0.0,
311311
account_ids: list[str] | None = None,
312312
fallback_to_local: bool = True,
@@ -321,7 +321,7 @@ def search_tools(
321321
query: Natural language description of needed functionality
322322
(e.g., "create employee", "send a message")
323323
connector: Optional provider/connector filter (e.g., "bamboohr", "slack")
324-
top_k: Maximum number of tools to return (default: 10)
324+
top_k: Maximum number of tools to return. If None, uses the backend default.
325325
min_score: Minimum similarity score threshold 0-1 (default: 0.0)
326326
account_ids: Optional account IDs (uses set_accounts() if not provided)
327327
fallback_to_local: If True, fall back to local BM25+TF-IDF search on API failure
@@ -372,11 +372,11 @@ def search_tools(
372372
]
373373

374374
# Step 3b: If not enough results, make per-connector calls for missing connectors
375-
if len(filtered_results) < top_k and not connector:
375+
if not connector and (top_k is None or len(filtered_results) < top_k):
376376
found_connectors = {r.connector_key.lower() for r in filtered_results}
377377
missing_connectors = available_connectors - found_connectors
378378
for missing in missing_connectors:
379-
if len(filtered_results) >= top_k:
379+
if top_k is not None and len(filtered_results) >= top_k:
380380
break
381381
try:
382382
extra = self.semantic_client.search(query=query, connector=missing, top_k=top_k)
@@ -385,7 +385,7 @@ def search_tools(
385385
fr.action_name for fr in filtered_results
386386
}:
387387
filtered_results.append(r)
388-
if len(filtered_results) >= top_k:
388+
if top_k is not None and len(filtered_results) >= top_k:
389389
break
390390
except SemanticSearchError:
391391
continue
@@ -401,7 +401,7 @@ def search_tools(
401401
if norm not in seen_names:
402402
seen_names.add(norm)
403403
deduped.append(r)
404-
filtered_results = deduped[:top_k]
404+
filtered_results = deduped[:top_k] if top_k is not None else deduped
405405

406406
if not filtered_results:
407407
return Tools([])
@@ -425,10 +425,11 @@ def search_tools(
425425
search_tool = utility.get_tool("tool_search")
426426

427427
if search_tool:
428+
fallback_limit = top_k * 3 if top_k is not None else 100
428429
result = search_tool.execute(
429430
{
430431
"query": query,
431-
"limit": top_k * 3, # Over-fetch to account for connector filtering
432+
"limit": fallback_limit,
432433
"minScore": min_score,
433434
}
434435
)
@@ -441,7 +442,7 @@ def search_tools(
441442
for name in matched_names
442443
if name in tool_map and name.split("_")[0].lower() in filter_connectors
443444
]
444-
return Tools(matched_tools[:top_k])
445+
return Tools(matched_tools[:top_k] if top_k is not None else matched_tools)
445446

446447
return all_tools
447448

@@ -451,7 +452,7 @@ def search_action_names(
451452
*,
452453
connector: str | None = None,
453454
account_ids: list[str] | None = None,
454-
top_k: int = 10,
455+
top_k: int | None = None,
455456
min_score: float = 0.0,
456457
) -> list[SemanticSearchResult]:
457458
"""Search for action names without fetching tools.
@@ -465,15 +466,15 @@ def search_action_names(
465466
account_ids: Optional account IDs to scope results to connectors
466467
available in those accounts (uses set_accounts() if not provided).
467468
When provided, results are filtered to only matching connectors.
468-
top_k: Maximum number of results (default: 10)
469+
top_k: Maximum number of results. If None, uses the backend default.
469470
min_score: Minimum similarity score threshold 0-1 (default: 0.0)
470471
471472
Returns:
472473
List of SemanticSearchResult with action names, scores, and metadata
473474
474475
Examples:
475476
# Lightweight: inspect results before fetching
476-
results = toolset.search_action_names("manage employees", top_k=10)
477+
results = toolset.search_action_names("manage employees")
477478
for r in results:
478479
print(f"{r.action_name}: {r.similarity_score:.2f}")
479480
@@ -501,7 +502,7 @@ def search_action_names(
501502
response = self.semantic_client.search(
502503
query=query,
503504
connector=connector,
504-
top_k=None if available_connectors else top_k,
505+
top_k=top_k,
505506
)
506507
except SemanticSearchError as e:
507508
logger.warning("Semantic search failed: %s", e)
@@ -516,11 +517,11 @@ def search_action_names(
516517
results = [r for r in results if r.connector_key.lower() in connector_set]
517518

518519
# If not enough results, make per-connector calls for missing connectors
519-
if len(results) < top_k and not connector:
520+
if not connector and (top_k is None or len(results) < top_k):
520521
found_connectors = {r.connector_key.lower() for r in results}
521522
missing_connectors = connector_set - found_connectors
522523
for missing in missing_connectors:
523-
if len(results) >= top_k:
524+
if top_k is not None and len(results) >= top_k:
524525
break
525526
try:
526527
extra = self.semantic_client.search(query=query, connector=missing, top_k=top_k)
@@ -529,7 +530,7 @@ def search_action_names(
529530
er.action_name for er in results
530531
}:
531532
results.append(r)
532-
if len(results) >= top_k:
533+
if top_k is not None and len(results) >= top_k:
533534
break
534535
except SemanticSearchError:
535536
continue
@@ -553,7 +554,7 @@ def search_action_names(
553554
description=r.description,
554555
)
555556
)
556-
return normalized[:top_k]
557+
return normalized[:top_k] if top_k is not None else normalized
557558

558559
def _filter_by_provider(self, tool_name: str, providers: list[str]) -> bool:
559560
"""Check if a tool name matches any of the provider filters

0 commit comments

Comments
 (0)