fleet-memory/hindsight-integrations/strands/hindsight_strands/tools.py
Ben 7fe773c0ee
feat: add Strands Agents SDK integration with Hindsight memory tools (#659)
* feat: add Strands Agents SDK integration with Hindsight memory tools

* fix: add strands docs to versioned docs so build link check passes

* fix(strands): run hindsight client calls in thread pool to avoid event loop conflict with Strands
2026-03-24 17:21:30 +01:00

278 lines
9.8 KiB
Python

"""Strands Agents tool factory for Hindsight memory operations.
Provides a factory function that creates Strands-compatible tool functions
backed by Hindsight's retain/recall/reflect APIs. Tools are plain Python
functions decorated with ``@tool`` — bank_id and client are captured in
the closure at construction time.
"""
from __future__ import annotations
import concurrent.futures
import logging
from typing import Any
_executor = concurrent.futures.ThreadPoolExecutor(max_workers=4)
def _run_in_thread(fn: Any, *args: Any, **kwargs: Any) -> Any:
"""Run a callable in a dedicated thread with a clean event loop.
Strands runs tools inside its own asyncio event loop. The hindsight client
uses asyncio internally (including asyncio.timeout), which conflicts with
an already-running loop. Running in a separate thread gives a fresh loop.
"""
return _executor.submit(fn, *args, **kwargs).result()
from hindsight_client import Hindsight
from strands import tool
from .config import get_config
from .errors import HindsightError
logger = logging.getLogger(__name__)
def _resolve_client(
client: Hindsight | None,
hindsight_api_url: str | None,
api_key: str | None,
) -> Hindsight:
"""Resolve a Hindsight client from explicit args or global config."""
if client is not None:
return client
config = get_config()
url = hindsight_api_url or (config.hindsight_api_url if config else None)
key = api_key or (config.api_key if config else None)
if url is None:
raise HindsightError(
"No Hindsight API URL configured. "
"Pass client= or hindsight_api_url=, or call configure() first."
)
kwargs: dict[str, Any] = {"base_url": url, "timeout": 30.0}
if key:
kwargs["api_key"] = key
return Hindsight(**kwargs)
def create_hindsight_tools(
*,
bank_id: str,
client: Hindsight | None = None,
hindsight_api_url: str | None = None,
api_key: str | None = None,
budget: str = "mid",
max_tokens: int = 4096,
tags: list[str] | None = None,
recall_tags: list[str] | None = None,
recall_tags_match: str = "any",
enable_retain: bool = True,
enable_recall: bool = True,
enable_reflect: bool = True,
) -> list:
"""Create Hindsight memory tools for a Strands agent.
Returns a list of ``@tool``-decorated functions that can be passed
directly to ``Agent(tools=...)``. Each function captures ``bank_id``
and the Hindsight client in its closure — no context modification needed.
Args:
bank_id: The Hindsight memory bank to operate on.
client: Pre-configured Hindsight client (preferred).
hindsight_api_url: API URL (used if no client provided).
api_key: API key (used if no client provided).
budget: Recall/reflect budget level (low/mid/high).
max_tokens: Maximum tokens for recall results.
tags: Tags applied when storing memories via retain.
recall_tags: Tags to filter when searching memories.
recall_tags_match: Tag matching mode (any/all/any_strict/all_strict).
enable_retain: Include the retain (store) tool.
enable_recall: Include the recall (search) tool.
enable_reflect: Include the reflect (synthesize) tool.
Returns:
A list of Strands tool functions.
Raises:
HindsightError: If no client or API URL can be resolved.
"""
resolved_client = _resolve_client(client, hindsight_api_url, api_key)
# Resolve defaults from global config
config = get_config()
effective_tags = tags if tags is not None else (config.tags if config else None)
effective_recall_tags = (
recall_tags
if recall_tags is not None
else (config.recall_tags if config else None)
)
effective_recall_tags_match = recall_tags_match or (
config.recall_tags_match if config else "any"
)
effective_budget = budget or (config.budget if config else "mid")
effective_max_tokens = max_tokens or (config.max_tokens if config else 4096)
created_banks: set[str] = set()
def _ensure_bank(bid: str) -> None:
if bid in created_banks:
return
try:
resolved_client.create_bank(bank_id=bid, name=bid)
created_banks.add(bid)
except Exception:
created_banks.add(bid)
tools = []
if enable_retain:
@tool
def hindsight_retain(content: str) -> str:
"""Store information to long-term memory for later retrieval.
Use this to save important facts, user preferences, decisions,
or any information that should be remembered across conversations.
"""
try:
_ensure_bank(bank_id)
retain_kwargs: dict[str, Any] = {"bank_id": bank_id, "content": content}
if effective_tags:
retain_kwargs["tags"] = effective_tags
_run_in_thread(resolved_client.retain, **retain_kwargs)
return "Memory stored successfully."
except HindsightError:
raise
except Exception as e:
logger.error(f"Retain failed: {e}")
raise HindsightError(f"Retain failed: {e}") from e
tools.append(hindsight_retain)
if enable_recall:
@tool
def hindsight_recall(query: str) -> str:
"""Search long-term memory for relevant information.
Use this to find previously stored facts, preferences, or context.
Returns a numbered list of matching memories.
"""
try:
recall_kwargs: dict[str, Any] = {
"bank_id": bank_id,
"query": query,
"budget": effective_budget,
"max_tokens": effective_max_tokens,
}
if effective_recall_tags:
recall_kwargs["tags"] = effective_recall_tags
recall_kwargs["tags_match"] = effective_recall_tags_match
response = _run_in_thread(resolved_client.recall, **recall_kwargs)
if not response.results:
return "No relevant memories found."
lines = []
for i, result in enumerate(response.results, 1):
lines.append(f"{i}. {result.text}")
return "\n".join(lines)
except HindsightError:
raise
except Exception as e:
logger.error(f"Recall failed: {e}")
raise HindsightError(f"Recall failed: {e}") from e
tools.append(hindsight_recall)
if enable_reflect:
@tool
def hindsight_reflect(query: str) -> str:
"""Synthesize a thoughtful answer from long-term memories.
Use this when you need a coherent summary or reasoned response
about what you know, rather than raw memory facts.
"""
try:
reflect_kwargs: dict[str, Any] = {
"bank_id": bank_id,
"query": query,
"budget": effective_budget,
}
response = _run_in_thread(resolved_client.reflect, **reflect_kwargs)
return response.text or "No relevant memories found."
except HindsightError:
raise
except Exception as e:
logger.error(f"Reflect failed: {e}")
raise HindsightError(f"Reflect failed: {e}") from e
tools.append(hindsight_reflect)
return tools
def memory_instructions(
*,
bank_id: str,
client: Hindsight | None = None,
hindsight_api_url: str | None = None,
api_key: str | None = None,
query: str = "relevant context about the user",
budget: str = "low",
max_results: int = 5,
max_tokens: int = 4096,
prefix: str = "Relevant memories:\n",
tags: list[str] | None = None,
tags_match: str = "any",
) -> str:
"""Pre-recall memories for injection into agent system prompt.
Performs a sync recall and returns a formatted string of memories.
Pass the result to ``Agent(system_prompt=...)`` or prepend it to
your system prompt string.
Args:
bank_id: The Hindsight memory bank to recall from.
client: Pre-configured Hindsight client (preferred).
hindsight_api_url: API URL (used if no client provided).
api_key: API key (used if no client provided).
query: The recall query to find relevant memories.
budget: Recall budget level (low/mid/high).
max_results: Maximum number of memories to include.
max_tokens: Maximum tokens for recall results.
prefix: Text prepended before the memory list.
tags: Tags to filter recall results.
tags_match: Tag matching mode (any/all/any_strict/all_strict).
Returns:
A formatted string of memories, or empty string if none found.
Raises:
HindsightError: If no client or API URL can be resolved.
"""
resolved_client = _resolve_client(client, hindsight_api_url, api_key)
try:
recall_kwargs: dict[str, Any] = {
"bank_id": bank_id,
"query": query,
"budget": budget,
"max_tokens": max_tokens,
}
if tags:
recall_kwargs["tags"] = tags
recall_kwargs["tags_match"] = tags_match
response = _run_in_thread(resolved_client.recall, **recall_kwargs)
results = response.results[:max_results] if response.results else []
if not results:
return ""
lines = [prefix]
for i, result in enumerate(results, 1):
lines.append(f"{i}. {result.text}")
return "\n".join(lines)
except Exception:
# Silently return empty — instructions failures shouldn't block the agent
return ""