Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions examples/openai_assistant_tool.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"""Use dynavec as an OpenAI Assistant or Function Calling tool.

This adapter allows an OpenAI Assistant or Chat Completions model to perform
vector retrieval over your Dynavec knowledge base directly without paying for
OpenAI's hosted vector store files.

Requirements:
pip install "dynavec[sentence-transformers]"
python examples/openai_assistant_tool.py
"""

import json
from types import SimpleNamespace

from dynavec import Document, Dynavec, DynavecConfig
from dynavec.embeddings import SentenceTransformerEmbedder
from dynavec.integrations.tools import as_openai_tool


def main() -> None:
# 1. Initialize Dynavec with a local/in-memory configuration
embedder = SentenceTransformerEmbedder(model="all-MiniLM-L6-v2")

cfg = DynavecConfig(
vector_bucket="dynavec-demo",
index="openai-assistant-docs",
table="dynavec_assistant_kb",
dimension=embedder.dimension,
region="us-east-1",
auto_provision=True,
)

db = Dynavec(cfg, embedder=embedder)

# 2. Populate knowledge base
db.upsert(
[
Document(
id="doc-1",
text="Dynavec is a serverless vector database running on AWS DynamoDB and S3 Vectors.",
),
Document(
id="doc-2",
text="Dynavec provides sub-50ms hybrid keyword and vector retrieval at zero idle cost.",
),
Document(
id="doc-3",
text="OpenAI Assistants can call custom external retrieval tools during run loops.",
),
],
namespace="docs",
)

# 3. Create the OpenAI Assistant Tool adapter
tool = as_openai_tool(
db,
name="dynavec_search",
description="Search documentation and return relevant knowledge base passages.",
namespace="docs",
top_k=2,
)

print("=== OpenAI Tool Schema ===")
print(json.dumps(tool.schema, indent=2))
print()

# 4. Simulate an Assistant Run loop requesting a tool call
# In real usage with OpenAI SDK:
# run = client.beta.threads.runs.create_and_poll(...)
# if run.status == "requires_action":
# tool_outputs = tool.submit_tool_outputs(run.required_action.submit_tool_outputs.tool_calls)
# client.beta.threads.runs.submit_tool_outputs(..., tool_outputs=tool_outputs)

simulated_tool_call = SimpleNamespace(
id="call_mock_12345",
type="function",
function=SimpleNamespace(
name="dynavec_search",
arguments=json.dumps({"query": "serverless vector database cost"}),
),
)

print("=== Simulating Tool Execution for Assistant Run ===")
outputs = tool.submit_tool_outputs([simulated_tool_call])
print("Submitted Tool Output:")
print(json.dumps(outputs, indent=2))


if __name__ == "__main__":
main()
18 changes: 17 additions & 1 deletion src/dynavec/integrations/__init__.py
Original file line number Diff line number Diff line change
@@ -1 +1,17 @@
"""Framework integrations (LangChain today; LlamaIndex/CrewAI to follow)."""
"""Framework integrations (OpenAI Assistants, LangChain, CrewAI, DSPy, LlamaIndex)."""

from .tools import (
OpenAIAssistantTool,
as_crewai_tool,
as_langchain_tool,
as_openai_tool,
make_retriever_fn,
)

__all__ = [
"OpenAIAssistantTool",
"as_openai_tool",
"as_langchain_tool",
"as_crewai_tool",
"make_retriever_fn",
]
174 changes: 167 additions & 7 deletions src/dynavec/integrations/tools.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
"""Framework-agnostic retrieval tool factory.

Most agent frameworks (LangGraph, CrewAI, Strands, plain function-calling) just
need a **callable** that takes a query string and returns text. This builds one
from a dynavec client / namespace, plus thin wrappers that register it as a
native tool in LangChain and CrewAI when those are installed.
Most agent frameworks (OpenAI Assistants, LangGraph, CrewAI, Strands, plain
function-calling) just need a **callable** that takes a query string and
returns text. This module provides ``make_retriever_fn`` plus first-class
adapters for OpenAI Assistants, LangChain, and CrewAI.
"""

from __future__ import annotations

import json
from collections.abc import Sequence
from typing import Any, Callable

from ..client import Dynavec
Expand Down Expand Up @@ -53,6 +55,166 @@ def retrieve(query: str) -> str:
return retrieve


class OpenAIAssistantTool:
"""OpenAI Assistants and Function Calling tool adapter backed by Dynavec.

Provides OpenAI-compatible function calling schemas, single-tool-call execution,
and batch execution for Assistant run loops without requiring external SDKs.
"""

def __init__(
self,
retriever_fn: Callable[[str], str],
name: str = "dynavec_search",
description: str | None = None,
) -> None:
self.retriever_fn = retriever_fn
self.name = name
self.description = description or (
"Search the knowledge base and return the most relevant passages for a query."
)

@property
def schema(self) -> dict[str, Any]:
"""Return the standard OpenAI Tool definition dictionary."""
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "The search query to retrieve relevant passages.",
}
},
"required": ["query"],
"additionalProperties": False,
},
},
}

def to_openai_tool(self) -> dict[str, Any]:
"""Return the OpenAI Tool definition dictionary (alias for ``schema``)."""
return self.schema

def to_dict(self) -> dict[str, Any]:
"""Return the OpenAI Tool definition dictionary."""
return self.schema

def __call__(self, query: str) -> str:
"""Directly invoke the retriever with a query string."""
return self.retriever_fn(query)

def handle_tool_call(self, tool_call: Any) -> dict[str, str]:
"""Execute retrieval for a single OpenAI ToolCall and return output dict.

Accepts either an OpenAI SDK ``ToolCall`` instance (or Pydantic/Namespace model)
or a dictionary format:
``{"id": "call_123", "function": {"name": "dynavec_search", "arguments": "{\\"query\\": \\"...\\"}"}}``

Returns:
A dictionary formatted for OpenAI ``submit_tool_outputs``:
``{"tool_call_id": "<id>", "output": "<retrieved text>"}``
"""
call_id = getattr(tool_call, "id", None)
if call_id is None and isinstance(tool_call, dict):
call_id = tool_call.get("id") or tool_call.get("tool_call_id") or ""
call_id = str(call_id or "")

fn_obj = getattr(tool_call, "function", None)
if fn_obj is None and isinstance(tool_call, dict):
fn_obj = tool_call.get("function")

raw_args = getattr(fn_obj, "arguments", None)
if raw_args is None and isinstance(fn_obj, dict):
raw_args = fn_obj.get("arguments")
if raw_args is None and isinstance(tool_call, dict):
raw_args = tool_call.get("arguments")

try:
if isinstance(raw_args, str):
try:
parsed_args = json.loads(raw_args)
except (json.JSONDecodeError, TypeError):
parsed_args = {"query": raw_args}
elif isinstance(raw_args, dict):
parsed_args = raw_args
elif raw_args is None:
parsed_args = {}
else:
parsed_args = {"query": str(raw_args)}

if parsed_args is None:
query = ""
elif isinstance(parsed_args, dict):
query = parsed_args.get("query")
if query is None:
for fallback_key in ("q", "input", "search_query", "text", "prompt"):
if fallback_key in parsed_args:
query = parsed_args[fallback_key]
break
if query is None and parsed_args:
query = next((v for v in parsed_args.values() if isinstance(v, str)), "")
query = str(query or "")
elif isinstance(parsed_args, (list, tuple, set)):
query = " ".join(str(x) for x in parsed_args)
else:
query = str(parsed_args)

output = self.retriever_fn(query)
except Exception as exc:
output = f"Error executing tool '{self.name}': {exc}"

return {"tool_call_id": call_id, "output": output}

def submit_tool_outputs(self, tool_calls: Sequence[Any] | None) -> list[dict[str, str]]:
"""Batch-process multiple tool calls from an assistant run loop.

Filters calls matching ``self.name``, executes retrieval for each, and
returns a list of ``{"tool_call_id": ..., "output": ...}`` objects
ready to be passed to ``client.beta.threads.runs.submit_tool_outputs()``.
"""
if not tool_calls:
return []

outputs: list[dict[str, str]] = []
for call in tool_calls:
fn_obj = getattr(call, "function", None)
if fn_obj is None and isinstance(call, dict):
fn_obj = call.get("function")

fn_name = getattr(fn_obj, "name", None)
if fn_name is None and isinstance(fn_obj, dict):
fn_name = fn_obj.get("name")
if fn_name is None and isinstance(call, dict):
fn_name = call.get("name")

if fn_name and fn_name != self.name:
continue

outputs.append(self.handle_tool_call(call))
return outputs


def as_openai_tool(
source: Dynavec | NamespaceView,
*,
name: str = "dynavec_search",
description: str | None = None,
**kw: Any,
) -> OpenAIAssistantTool:
"""Wrap a Dynavec client or NamespaceView as an OpenAI Assistant tool.

Requires zero third-party dependencies. Returns an ``OpenAIAssistantTool``
ready to be registered on an Assistant or passed into chat completions.
"""
fn = make_retriever_fn(source, **kw)
return OpenAIAssistantTool(fn, name=name, description=description)


def as_langchain_tool(source, *, name: str = "dynavec_search", **kw) -> Any:
"""Wrap the retriever as a LangChain ``StructuredTool`` (requires langchain-core)."""
from ..exceptions import MissingDependencyError
Expand All @@ -63,9 +225,7 @@ def as_langchain_tool(source, *, name: str = "dynavec_search", **kw) -> Any:
raise MissingDependencyError("as_langchain_tool", "langchain-core", "langchain") from exc

fn = make_retriever_fn(source, **kw)
return StructuredTool.from_function(
func=fn, name=name, description=fn.__doc__
)
return StructuredTool.from_function(func=fn, name=name, description=fn.__doc__)


def as_crewai_tool(source, *, name: str = "dynavec_search", **kw) -> Any:
Expand Down
Loading
Loading