Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f4b844777 |
@@ -65,6 +65,7 @@ jobs:
|
||||
integrations-google-adk: ${{ steps.filter.outputs.integrations-google-adk }}
|
||||
integrations-obsidian: ${{ steps.filter.outputs.integrations-obsidian }}
|
||||
integrations-omo: ${{ steps.filter.outputs.integrations-omo }}
|
||||
integrations-omnigent: ${{ steps.filter.outputs.integrations-omnigent }}
|
||||
integrations-haystack: ${{ steps.filter.outputs.integrations-haystack }}
|
||||
tools-agent-sdk: ${{ steps.filter.outputs.tools-agent-sdk }}
|
||||
integrations-roo-code: ${{ steps.filter.outputs.integrations-roo-code }}
|
||||
@@ -196,6 +197,8 @@ jobs:
|
||||
- 'hindsight-integrations/obsidian/**'
|
||||
integrations-omo:
|
||||
- 'hindsight-integrations/omo/**'
|
||||
integrations-omnigent:
|
||||
- 'hindsight-integrations/omnigent/**'
|
||||
tools-agent-sdk:
|
||||
- 'hindsight-tools/hindsight-agent-sdk/**'
|
||||
integrations-roo-code:
|
||||
@@ -508,6 +511,52 @@ jobs:
|
||||
working-directory: ./hindsight-integrations/omo
|
||||
run: python -m pytest tests/ -v
|
||||
|
||||
test-omnigent-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-omnigent == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
enable-cache: true
|
||||
prune-cache: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Build omnigent integration
|
||||
working-directory: ./hindsight-integrations/omnigent
|
||||
run: uv build
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: ./hindsight-integrations/omnigent
|
||||
run: uv sync --frozen
|
||||
|
||||
- name: Lint
|
||||
working-directory: ./hindsight-integrations/omnigent
|
||||
run: uv run ruff check .
|
||||
|
||||
- name: Run tests
|
||||
working-directory: ./hindsight-integrations/omnigent
|
||||
# PR CI runs only the deterministic bucket; the real-LLM E2E bucket
|
||||
# (requires_real_llm) needs a live Hindsight server and runs separately.
|
||||
# The real-omnigent guard tests skip here (omnigent is a 3.12-only alpha,
|
||||
# not a CI dep); the Omnigent invoke contract is covered by a faithful
|
||||
# in-test replica plus those guard tests when omnigent is installed.
|
||||
run: uv run pytest tests -v -m "not requires_real_llm"
|
||||
|
||||
test-cline-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
@@ -4588,6 +4637,7 @@ jobs:
|
||||
- test-ai-sdk-integration-deno
|
||||
- test-opencode-integration
|
||||
- test-omo-integration
|
||||
- test-omnigent-integration
|
||||
- test-cloudflare-oauth-proxy-integration
|
||||
- build-chat-integration
|
||||
- test-paperclip-integration
|
||||
|
||||
@@ -75,6 +75,7 @@ INTEGRATIONS: dict[str, IntegrationMeta] = {
|
||||
"haystack": IntegrationMeta("hindsight-haystack", "Haystack"),
|
||||
"roo-code": IntegrationMeta("hindsight-roo-code", "Roo Code"),
|
||||
"omo": IntegrationMeta("hindsight-omo", "OMO"),
|
||||
"omnigent": IntegrationMeta("hindsight-omnigent", "Omnigent"),
|
||||
}
|
||||
|
||||
VALID_INTEGRATIONS = list(INTEGRATIONS.keys())
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
---
|
||||
sidebar_position: 37
|
||||
title: "Omnigent Persistent Memory with Hindsight | Integration Guide"
|
||||
description: "Add long-term memory to Omnigent agents. Hindsight's retain, recall, and reflect register as Omnigent type: function tools your agent declares in its YAML."
|
||||
---
|
||||
|
||||
# Omnigent
|
||||
|
||||
Persistent memory for [Omnigent](https://github.com/omnigent-ai/omnigent) agents via Hindsight.
|
||||
Exposes Hindsight's **retain**, **recall**, and **reflect** operations as Omnigent
|
||||
`type: function` tools — plain Python callables your agent declares in its YAML and the LLM
|
||||
can call directly.
|
||||
|
||||
Omnigent invokes function tools with **no session context**, so the Hindsight bank and
|
||||
connection are configured once per agent process (via `configure()` or `HINDSIGHT_*` env
|
||||
vars) rather than per call. The natural model is one memory bank per agent.
|
||||
|
||||
## Features
|
||||
|
||||
- **Omnigent function tools** — `retain` / `recall` / `reflect` referenced by dotted path
|
||||
- **Drop-in YAML** — `tools_yaml()` emits a ready-to-paste `tools:` block with correct schemas
|
||||
- **Configure once** — set the URL, key, and bank globally or through env vars
|
||||
- **System-prompt injection** — `memory_instructions()` pre-recalls memories for the agent prompt
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install hindsight-omnigent
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
|
||||
:::tip Recommended: Hindsight Cloud
|
||||
[Sign up free](https://ui.hindsight.vectorize.io/signup) and grab an API key — no self-hosting required.
|
||||
:::
|
||||
|
||||
**1. Configure Hindsight** in the Python module that hosts your agent's tools:
|
||||
|
||||
```python
|
||||
# my_agent/tools.py
|
||||
from hindsight_omnigent import configure
|
||||
|
||||
# Re-export the callables so Omnigent can resolve them by dotted path.
|
||||
from hindsight_omnigent.tools import recall, reflect, retain # noqa: F401
|
||||
|
||||
configure(
|
||||
hindsight_api_url="https://api.hindsight.vectorize.io",
|
||||
api_key="hsk_...", # or set HINDSIGHT_API_KEY
|
||||
bank_id="user-123", # or set HINDSIGHT_BANK_ID
|
||||
)
|
||||
```
|
||||
|
||||
**2. Declare the tools** in your `agent.yaml`. Generate the block with
|
||||
`python -c "from hindsight_omnigent import tools_yaml; print(tools_yaml())"`:
|
||||
|
||||
```yaml
|
||||
name: memory_agent
|
||||
prompt: You are a helpful assistant with long-term memory.
|
||||
executor:
|
||||
harness: claude-sdk
|
||||
tools:
|
||||
hindsight_retain:
|
||||
type: function
|
||||
description: Store information in long-term memory for later retrieval.
|
||||
callable: hindsight_omnigent.tools.retain
|
||||
parameters: {"type": "object", "properties": {"content": {"type": "string"}}, "required": ["content"]}
|
||||
hindsight_recall:
|
||||
type: function
|
||||
description: Search long-term memory for relevant information.
|
||||
callable: hindsight_omnigent.tools.recall
|
||||
parameters: {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}
|
||||
hindsight_reflect:
|
||||
type: function
|
||||
description: Synthesize a reasoned answer from long-term memories.
|
||||
callable: hindsight_omnigent.tools.reflect
|
||||
parameters: {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}
|
||||
```
|
||||
|
||||
**3. Run it:**
|
||||
|
||||
```bash
|
||||
omnigent run path/to/agent.yaml
|
||||
```
|
||||
|
||||
The `callable` paths must be importable from where Omnigent runs the agent. Importing
|
||||
`my_agent.tools` triggers the `configure()` call above; alternatively, skip `configure()`
|
||||
and set `HINDSIGHT_API_KEY` / `HINDSIGHT_BANK_ID` in the agent's `os_env`.
|
||||
|
||||
## Configuration via environment variables
|
||||
|
||||
`configure()` is optional — the tools read these on first use:
|
||||
|
||||
| Variable | Description |
|
||||
| -------------------- | -------------------------------------------- |
|
||||
| `HINDSIGHT_API_URL` | Hindsight API URL (default: Hindsight Cloud) |
|
||||
| `HINDSIGHT_API_KEY` | API key for authentication |
|
||||
| `HINDSIGHT_BANK_ID` | Memory bank the tools read/write |
|
||||
|
||||
### Self-hosting (local development)
|
||||
|
||||
If you're running Hindsight locally with `./scripts/dev/start-api.sh`, point at it:
|
||||
|
||||
```python
|
||||
configure(hindsight_api_url="http://localhost:8888", bank_id="user-123")
|
||||
```
|
||||
|
||||
See the [installation guide](/developer/installation) for self-hosting setup.
|
||||
|
||||
## Seeding the prompt
|
||||
|
||||
Omnigent has no pre-turn context hook, so to start the agent already knowing what Hindsight
|
||||
remembers, pre-recall and splice it into the prompt:
|
||||
|
||||
```python
|
||||
from hindsight_omnigent import memory_instructions
|
||||
|
||||
context = memory_instructions(query="what we know about the user", max_results=5)
|
||||
prompt = f"You are a helpful assistant.\n\n{context}" if context else "You are a helpful assistant."
|
||||
```
|
||||
|
||||
## Selecting tools
|
||||
|
||||
Generate only the tools you need:
|
||||
|
||||
```python
|
||||
from hindsight_omnigent import tools_yaml
|
||||
|
||||
print(tools_yaml(enable_retain=True, enable_recall=True, enable_reflect=False))
|
||||
```
|
||||
|
||||
## `configure()` Reference
|
||||
|
||||
| Parameter | Default | Description |
|
||||
| ------------------- | ------------------------------------------------------ | ------------------------------------------ |
|
||||
| `hindsight_api_url` | `HINDSIGHT_API_URL` env, else Hindsight Cloud | Hindsight API URL |
|
||||
| `api_key` | `HINDSIGHT_API_KEY` env | API key for authentication |
|
||||
| `bank_id` | `HINDSIGHT_BANK_ID` env | Memory bank the tools read/write |
|
||||
| `budget` | `"mid"` | Recall/reflect budget level (low/mid/high) |
|
||||
| `max_tokens` | `4096` | Max tokens for recall results |
|
||||
| `tags` | `None` | Tags applied when storing memories |
|
||||
| `recall_tags` | `None` | Tags to filter when searching |
|
||||
| `recall_tags_match` | `"any"` | Tag matching mode |
|
||||
| `client` | `None` | Pre-built Hindsight client (overrides URL/key) |
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python >= 3.10
|
||||
- hindsight-client >= 0.4.0
|
||||
- A running Hindsight API server (or Hindsight Cloud)
|
||||
- [Omnigent](https://github.com/omnigent-ai/omnigent) (Python >= 3.12) to run the agent
|
||||
|
||||
:::note
|
||||
Omnigent is an early-stage (alpha) project and its agent spec is still evolving. This
|
||||
integration targets the `type: function` tool contract in Omnigent 0.1.x.
|
||||
:::
|
||||
@@ -1,5 +1,15 @@
|
||||
{
|
||||
"integrations": [
|
||||
{
|
||||
"id": "omnigent",
|
||||
"name": "Omnigent",
|
||||
"description": "Persistent memory for Omnigent agents. Hindsight's retain, recall, and reflect drop into an agent YAML as type: function tools.",
|
||||
"type": "official",
|
||||
"by": "hindsight",
|
||||
"category": "framework",
|
||||
"link": "/sdks/integrations/omnigent",
|
||||
"icon": "/img/icons/omnigent.png"
|
||||
},
|
||||
{
|
||||
"id": "litellm",
|
||||
"name": "LiteLLM",
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,9 @@
|
||||
.venv/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
.env
|
||||
@@ -0,0 +1,137 @@
|
||||
# hindsight-omnigent
|
||||
|
||||
Persistent memory for [Omnigent](https://github.com/omnigent-ai/omnigent) agents via Hindsight.
|
||||
Exposes Hindsight's **retain**, **recall**, and **reflect** operations as Omnigent
|
||||
`type: function` tools — plain Python callables your agent declares in its YAML and the LLM
|
||||
can call directly.
|
||||
|
||||
Omnigent invokes function tools with **no session context**, so the Hindsight bank and
|
||||
connection are configured once per agent process (via `configure()` or `HINDSIGHT_*` env
|
||||
vars) rather than per call.
|
||||
|
||||
## Features
|
||||
|
||||
- **Omnigent function tools** — `retain` / `recall` / `reflect` referenced by dotted path
|
||||
- **Drop-in YAML** — `tools_yaml()` emits a ready-to-paste `tools:` block with correct schemas
|
||||
- **Configure once** — set the URL, key, and bank globally or through env vars
|
||||
- **System-prompt injection** — `memory_instructions()` pre-recalls memories for the agent prompt
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install hindsight-omnigent
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
> **Recommended: [Hindsight Cloud](https://ui.hindsight.vectorize.io/signup)** — free tier, no self-hosting required.
|
||||
|
||||
**1. Configure Hindsight** in the Python module that hosts your agent's tools:
|
||||
|
||||
```python
|
||||
# my_agent/tools.py
|
||||
from hindsight_omnigent import configure
|
||||
|
||||
# Re-exports retain / recall / reflect so Omnigent can resolve them by dotted path.
|
||||
from hindsight_omnigent.tools import recall, reflect, retain # noqa: F401
|
||||
|
||||
configure(
|
||||
hindsight_api_url="https://api.hindsight.vectorize.io",
|
||||
api_key="hsk_...", # or set HINDSIGHT_API_KEY
|
||||
bank_id="user-123", # or set HINDSIGHT_BANK_ID
|
||||
)
|
||||
```
|
||||
|
||||
**2. Declare the tools** in your `agent.yaml`. Generate the block with:
|
||||
|
||||
```python
|
||||
from hindsight_omnigent import tools_yaml
|
||||
print(tools_yaml())
|
||||
```
|
||||
|
||||
```yaml
|
||||
name: memory_agent
|
||||
prompt: You are a helpful assistant with long-term memory. Use the Hindsight tools to remember and recall facts about the user.
|
||||
executor:
|
||||
harness: claude-sdk
|
||||
tools:
|
||||
hindsight_retain:
|
||||
type: function
|
||||
description: Store information in long-term memory for later retrieval.
|
||||
callable: hindsight_omnigent.tools.retain
|
||||
parameters: {"type": "object", "properties": {"content": {"type": "string", "description": "The information to store in long-term memory."}}, "required": ["content"]}
|
||||
hindsight_recall:
|
||||
type: function
|
||||
description: Search long-term memory for relevant information.
|
||||
callable: hindsight_omnigent.tools.recall
|
||||
parameters: {"type": "object", "properties": {"query": {"type": "string", "description": "The search query to find relevant memories."}}, "required": ["query"]}
|
||||
hindsight_reflect:
|
||||
type: function
|
||||
description: Synthesize a reasoned answer from long-term memories.
|
||||
callable: hindsight_omnigent.tools.reflect
|
||||
parameters: {"type": "object", "properties": {"query": {"type": "string", "description": "The question to reflect on using stored memories."}}, "required": ["query"]}
|
||||
```
|
||||
|
||||
**3. Run it:**
|
||||
|
||||
```bash
|
||||
omnigent run path/to/agent.yaml
|
||||
```
|
||||
|
||||
> The `callable` paths must be importable from where Omnigent runs the agent. Importing
|
||||
> `my_agent.tools` triggers the `configure()` call above; alternatively, skip `configure()`
|
||||
> entirely and set `HINDSIGHT_API_KEY` / `HINDSIGHT_BANK_ID` in the agent's `os_env`.
|
||||
|
||||
## Configuration via environment variables
|
||||
|
||||
`configure()` is optional — the tools read these on first use:
|
||||
|
||||
| Variable | Description |
|
||||
| -------------------- | -------------------------------------------- |
|
||||
| `HINDSIGHT_API_URL` | Hindsight API URL (default: Hindsight Cloud) |
|
||||
| `HINDSIGHT_API_KEY` | API key for authentication |
|
||||
| `HINDSIGHT_BANK_ID` | Memory bank the tools read/write |
|
||||
|
||||
## `configure()` reference
|
||||
|
||||
| Parameter | Default | Description |
|
||||
| ------------------- | ------------------------------------------------------ | ------------------------------------------ |
|
||||
| `hindsight_api_url` | `HINDSIGHT_API_URL` env, else Hindsight Cloud | Hindsight API URL |
|
||||
| `api_key` | `HINDSIGHT_API_KEY` env | API key for authentication |
|
||||
| `bank_id` | `HINDSIGHT_BANK_ID` env | Memory bank the tools read/write |
|
||||
| `budget` | `"mid"` | Recall/reflect budget level (low/mid/high) |
|
||||
| `max_tokens` | `4096` | Max tokens for recall results |
|
||||
| `tags` | `None` | Tags applied when storing memories |
|
||||
| `recall_tags` | `None` | Tags to filter when searching |
|
||||
| `recall_tags_match` | `"any"` | Tag matching mode |
|
||||
| `client` | `None` | Pre-built Hindsight client (overrides URL/key) |
|
||||
|
||||
## Seeding the prompt
|
||||
|
||||
Omnigent has no pre-turn context hook, so to start the agent already knowing what Hindsight
|
||||
remembers, pre-recall and splice it into the prompt:
|
||||
|
||||
```python
|
||||
from hindsight_omnigent import memory_instructions
|
||||
|
||||
context = memory_instructions(query="what we know about the user", max_results=5)
|
||||
prompt = f"You are a helpful assistant.\n\n{context}" if context else "You are a helpful assistant."
|
||||
```
|
||||
|
||||
## Selecting tools
|
||||
|
||||
Generate only the tools you need:
|
||||
|
||||
```python
|
||||
tools_yaml(enable_retain=True, enable_recall=True, enable_reflect=False)
|
||||
```
|
||||
|
||||
## Requirements
|
||||
|
||||
- Python >= 3.10
|
||||
- hindsight-client >= 0.4.0
|
||||
- A running Hindsight API server (or Hindsight Cloud)
|
||||
- [Omnigent](https://github.com/omnigent-ai/omnigent) (Python >= 3.12) to run the agent
|
||||
|
||||
> **Note:** Omnigent is an early-stage (alpha) project and its agent spec is still evolving.
|
||||
> This integration targets the `type: function` tool contract in Omnigent 0.1.x.
|
||||
@@ -0,0 +1,32 @@
|
||||
# Example Omnigent agent with Hindsight long-term memory.
|
||||
#
|
||||
# The `callable` paths resolve to hindsight_omnigent.tools.{retain,recall,reflect}.
|
||||
# Configure the Hindsight connection + bank either by importing a module that calls
|
||||
# hindsight_omnigent.configure(), or by setting HINDSIGHT_API_KEY / HINDSIGHT_BANK_ID
|
||||
# in the agent's environment.
|
||||
#
|
||||
# Run with: omnigent run examples/agent.yaml
|
||||
name: memory_agent
|
||||
prompt: |
|
||||
You are a helpful assistant with long-term memory.
|
||||
- Call hindsight_recall before answering, to check what you already know about the user.
|
||||
- Call hindsight_retain whenever the user shares a durable fact, preference, or decision.
|
||||
- Call hindsight_reflect when asked to summarize or reason about what you know.
|
||||
executor:
|
||||
harness: claude-sdk
|
||||
tools:
|
||||
hindsight_retain:
|
||||
type: function
|
||||
description: Store information in long-term memory for later retrieval.
|
||||
callable: hindsight_omnigent.tools.retain
|
||||
parameters: {"type": "object", "properties": {"content": {"type": "string", "description": "The information to store in long-term memory."}}, "required": ["content"]}
|
||||
hindsight_recall:
|
||||
type: function
|
||||
description: Search long-term memory for relevant information.
|
||||
callable: hindsight_omnigent.tools.recall
|
||||
parameters: {"type": "object", "properties": {"query": {"type": "string", "description": "The search query to find relevant memories."}}, "required": ["query"]}
|
||||
hindsight_reflect:
|
||||
type: function
|
||||
description: Synthesize a reasoned answer from long-term memories.
|
||||
callable: hindsight_omnigent.tools.reflect
|
||||
parameters: {"type": "object", "properties": {"query": {"type": "string", "description": "The question to reflect on using stored memories."}}, "required": ["query"]}
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Hindsight-Omnigent: persistent memory tools for Omnigent agents.
|
||||
|
||||
Exposes Hindsight's retain, recall, and reflect operations as Omnigent
|
||||
``type: function`` tools — plain Python callables referenced by dotted path from
|
||||
an agent YAML. The Hindsight bank and connection come from :func:`configure` (or
|
||||
``HINDSIGHT_*`` env vars), since Omnigent passes tool callables no session
|
||||
context.
|
||||
|
||||
Basic usage (in the module that hosts your agent's tools)::
|
||||
|
||||
from hindsight_omnigent import configure, tools_yaml
|
||||
|
||||
configure(
|
||||
hindsight_api_url="https://api.hindsight.vectorize.io",
|
||||
api_key="hsk_...", # or HINDSIGHT_API_KEY
|
||||
bank_id="user-123", # or HINDSIGHT_BANK_ID
|
||||
)
|
||||
|
||||
print(tools_yaml()) # paste into your agent.yaml under `tools:`
|
||||
"""
|
||||
|
||||
from .config import (
|
||||
HindsightOmnigentConfig,
|
||||
configure,
|
||||
get_config,
|
||||
reset_config,
|
||||
)
|
||||
from .errors import HindsightError
|
||||
from .tools import (
|
||||
OmnigentToolSpec,
|
||||
memory_instructions,
|
||||
recall,
|
||||
reflect,
|
||||
retain,
|
||||
tool_specs,
|
||||
tools_yaml,
|
||||
)
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
||||
__all__ = [
|
||||
"configure",
|
||||
"get_config",
|
||||
"reset_config",
|
||||
"HindsightOmnigentConfig",
|
||||
"HindsightError",
|
||||
"retain",
|
||||
"recall",
|
||||
"reflect",
|
||||
"memory_instructions",
|
||||
"tool_specs",
|
||||
"tools_yaml",
|
||||
"OmnigentToolSpec",
|
||||
]
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Global configuration for the Hindsight-Omnigent integration.
|
||||
|
||||
Omnigent invokes ``type: function`` tools as plain Python callables and passes
|
||||
them **no session context** (see ``omnigent/tools/local_callable.py`` —
|
||||
``invoke`` does ``del ctx``). So, unlike session-aware integrations, the
|
||||
Hindsight bank can't be derived per call; it comes from this module-level
|
||||
config (or the matching environment variables). The natural model is one bank
|
||||
per Omnigent agent process: set it once via :func:`configure` or env vars.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from hindsight_client import Hindsight
|
||||
|
||||
DEFAULT_HINDSIGHT_API_URL = "https://api.hindsight.vectorize.io"
|
||||
|
||||
HINDSIGHT_API_URL_ENV = "HINDSIGHT_API_URL"
|
||||
HINDSIGHT_API_KEY_ENV = "HINDSIGHT_API_KEY"
|
||||
HINDSIGHT_BANK_ID_ENV = "HINDSIGHT_BANK_ID"
|
||||
|
||||
Budget = Literal["low", "mid", "high"]
|
||||
TagsMatch = Literal["any", "all", "any_strict", "all_strict"]
|
||||
|
||||
|
||||
@dataclass
|
||||
class HindsightOmnigentConfig:
|
||||
"""Connection and default settings for the Omnigent integration.
|
||||
|
||||
Attributes:
|
||||
hindsight_api_url: URL of the Hindsight API server.
|
||||
api_key: API key for Hindsight authentication.
|
||||
bank_id: Hindsight memory bank the tool callables read/write.
|
||||
budget: Default recall/reflect budget level (low/mid/high).
|
||||
max_tokens: Default maximum tokens for recall results.
|
||||
tags: Default tags applied when storing memories.
|
||||
recall_tags: Default tags to filter when searching memories.
|
||||
recall_tags_match: Tag matching mode (any/all/any_strict/all_strict).
|
||||
client: Pre-built Hindsight client (overrides url/key when set).
|
||||
"""
|
||||
|
||||
hindsight_api_url: str = DEFAULT_HINDSIGHT_API_URL
|
||||
api_key: str | None = None
|
||||
bank_id: str | None = None
|
||||
budget: Budget = "mid"
|
||||
max_tokens: int = 4096
|
||||
tags: list[str] | None = None
|
||||
recall_tags: list[str] | None = None
|
||||
recall_tags_match: TagsMatch = "any"
|
||||
client: Hindsight | None = None
|
||||
|
||||
|
||||
_global_config: HindsightOmnigentConfig | None = None
|
||||
|
||||
|
||||
def configure(
|
||||
hindsight_api_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
bank_id: str | None = None,
|
||||
budget: Budget = "mid",
|
||||
max_tokens: int = 4096,
|
||||
tags: list[str] | None = None,
|
||||
recall_tags: list[str] | None = None,
|
||||
recall_tags_match: TagsMatch = "any",
|
||||
client: Hindsight | None = None,
|
||||
) -> HindsightOmnigentConfig:
|
||||
"""Configure the Hindsight connection and default settings.
|
||||
|
||||
Call this once at import time of the module that hosts your tool callables,
|
||||
so they resolve the same connection and bank on every invocation.
|
||||
|
||||
Args:
|
||||
hindsight_api_url: Hindsight API URL. Falls back to ``HINDSIGHT_API_URL``
|
||||
env var, then to Hindsight Cloud.
|
||||
api_key: API key. Falls back to ``HINDSIGHT_API_KEY`` env var.
|
||||
bank_id: Memory bank to read/write. Falls back to ``HINDSIGHT_BANK_ID``.
|
||||
budget: Default recall/reflect budget (low/mid/high).
|
||||
max_tokens: Default max tokens for recall.
|
||||
tags: Default tags for retain operations.
|
||||
recall_tags: Default tags to filter recall/search.
|
||||
recall_tags_match: Tag matching mode.
|
||||
client: Pre-built Hindsight client (overrides url/key when set).
|
||||
|
||||
Returns:
|
||||
The configured HindsightOmnigentConfig.
|
||||
"""
|
||||
global _global_config
|
||||
|
||||
resolved_url = hindsight_api_url or os.environ.get(HINDSIGHT_API_URL_ENV) or DEFAULT_HINDSIGHT_API_URL
|
||||
resolved_key = api_key or os.environ.get(HINDSIGHT_API_KEY_ENV)
|
||||
resolved_bank = bank_id or os.environ.get(HINDSIGHT_BANK_ID_ENV)
|
||||
|
||||
_global_config = HindsightOmnigentConfig(
|
||||
hindsight_api_url=resolved_url,
|
||||
api_key=resolved_key,
|
||||
bank_id=resolved_bank,
|
||||
budget=budget,
|
||||
max_tokens=max_tokens,
|
||||
tags=tags,
|
||||
recall_tags=recall_tags,
|
||||
recall_tags_match=recall_tags_match,
|
||||
client=client,
|
||||
)
|
||||
|
||||
return _global_config
|
||||
|
||||
|
||||
def get_config() -> HindsightOmnigentConfig:
|
||||
"""Return the active config, creating one from env vars on first use.
|
||||
|
||||
Tool callables call this with no prior :func:`configure`, so an agent can be
|
||||
wired up entirely through ``HINDSIGHT_*`` environment variables in its
|
||||
Omnigent ``os_env`` block.
|
||||
"""
|
||||
global _global_config
|
||||
if _global_config is None:
|
||||
_global_config = configure()
|
||||
return _global_config
|
||||
|
||||
|
||||
def reset_config() -> None:
|
||||
"""Reset global configuration to None."""
|
||||
global _global_config
|
||||
_global_config = None
|
||||
@@ -0,0 +1,7 @@
|
||||
"""Hindsight-Omnigent error types."""
|
||||
|
||||
|
||||
class HindsightError(Exception):
|
||||
"""Exception raised when a Hindsight memory operation fails."""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,327 @@
|
||||
"""Hindsight memory tools for Omnigent agents.
|
||||
|
||||
Omnigent (https://github.com/omnigent-ai/omnigent) runs ``type: function`` tools
|
||||
as plain, in-process Python callables resolved from a dotted import path. It
|
||||
invokes them with the LLM's JSON arguments parsed into keyword args and coerces
|
||||
the return value to a string (see ``omnigent/tools/local_callable.py``).
|
||||
|
||||
This module exposes Hindsight's retain / recall / reflect as exactly that shape:
|
||||
|
||||
tools:
|
||||
hindsight_recall:
|
||||
type: function
|
||||
callable: hindsight_omnigent.tools.recall
|
||||
parameters: {"type": "object", "properties": {"query": {"type": "string"}}, "required": ["query"]}
|
||||
|
||||
Because Omnigent passes the callable **no session context**, the Hindsight bank
|
||||
and connection come from :func:`hindsight_omnigent.configure` (or ``HINDSIGHT_*``
|
||||
env vars) — one bank per agent process. Use :func:`tools_yaml` to emit a ready
|
||||
``tools:`` block for an agent YAML.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from .config import get_config
|
||||
from .errors import HindsightError
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from hindsight_client import Hindsight
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Banks we've already ensured exist this process, so retain doesn't issue a
|
||||
# redundant create_bank on every call. Keyed by bank id.
|
||||
_created_banks: set[str] = set()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Connection / bank resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _client() -> Hindsight:
|
||||
"""Resolve a Hindsight client from the global config."""
|
||||
config = get_config()
|
||||
if config.client is not None:
|
||||
return config.client
|
||||
|
||||
from hindsight_client import Hindsight
|
||||
|
||||
kwargs: dict[str, Any] = {"base_url": config.hindsight_api_url, "timeout": 30.0}
|
||||
if config.api_key:
|
||||
kwargs["api_key"] = config.api_key
|
||||
return Hindsight(**kwargs)
|
||||
|
||||
|
||||
def _bank() -> str:
|
||||
"""Resolve the configured Hindsight bank, or raise if none is set."""
|
||||
bank = get_config().bank_id
|
||||
if not bank:
|
||||
raise HindsightError(
|
||||
"No Hindsight bank configured. Call configure(bank_id=...) or set "
|
||||
"the HINDSIGHT_BANK_ID environment variable."
|
||||
)
|
||||
return bank
|
||||
|
||||
|
||||
def _ensure_bank(client: Hindsight, bank: str) -> None:
|
||||
"""Create the bank once per process; tolerate it already existing."""
|
||||
if bank in _created_banks:
|
||||
return
|
||||
try:
|
||||
client.create_bank(bank_id=bank, name=bank)
|
||||
except Exception as e:
|
||||
# Bank likely already exists; treat as created either way. Logged at
|
||||
# debug so a real auth/network failure is visible here rather than only
|
||||
# surfacing later on the retain call.
|
||||
logger.debug(f"create_bank({bank!r}) failed (assuming it exists): {e}")
|
||||
_created_banks.add(bank)
|
||||
|
||||
|
||||
def _reset_created_banks() -> None:
|
||||
"""Clear the per-process bank cache (used by tests)."""
|
||||
_created_banks.clear()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tool callables — referenced by dotted path from an Omnigent agent YAML
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def retain(content: str) -> str:
|
||||
"""Store information in long-term memory for later retrieval.
|
||||
|
||||
Use this to save important facts, user preferences, decisions, or anything
|
||||
that should be remembered across conversations.
|
||||
"""
|
||||
config = get_config()
|
||||
try:
|
||||
client = _client()
|
||||
bank = _bank()
|
||||
_ensure_bank(client, bank)
|
||||
kwargs: dict[str, Any] = {"bank_id": bank, "content": content}
|
||||
if config.tags:
|
||||
kwargs["tags"] = config.tags
|
||||
client.retain(**kwargs)
|
||||
return "Stored to long-term memory."
|
||||
except HindsightError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Retain failed: {e}")
|
||||
raise HindsightError(f"Retain failed: {e}") from e
|
||||
|
||||
|
||||
def recall(query: str) -> str:
|
||||
"""Search long-term memory for relevant information.
|
||||
|
||||
Use this to find previously stored facts, preferences, or context. Returns
|
||||
the matching memories as a bullet list, or a note that none were found.
|
||||
"""
|
||||
config = get_config()
|
||||
try:
|
||||
client = _client()
|
||||
bank = _bank()
|
||||
kwargs: dict[str, Any] = {
|
||||
"bank_id": bank,
|
||||
"query": query,
|
||||
"budget": config.budget,
|
||||
"max_tokens": config.max_tokens,
|
||||
}
|
||||
if config.recall_tags:
|
||||
kwargs["tags"] = config.recall_tags
|
||||
kwargs["tags_match"] = config.recall_tags_match
|
||||
response = client.recall(**kwargs)
|
||||
results = response.results or []
|
||||
memories = [r.text for r in results]
|
||||
if not memories:
|
||||
return "No relevant memories found."
|
||||
return "\n".join(f"- {m}" for m in memories)
|
||||
except HindsightError:
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error(f"Recall failed: {e}")
|
||||
raise HindsightError(f"Recall failed: {e}") from e
|
||||
|
||||
|
||||
def reflect(query: str) -> str:
|
||||
"""Synthesize a reasoned 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.
|
||||
"""
|
||||
config = get_config()
|
||||
try:
|
||||
client = _client()
|
||||
bank = _bank()
|
||||
response = client.reflect(bank_id=bank, query=query, budget=config.budget)
|
||||
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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System-prompt pre-injection (Omnigent has no pre-turn context hook)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def memory_instructions(
|
||||
*,
|
||||
query: str = "relevant context about the user",
|
||||
bank_id: str | None = None,
|
||||
max_results: int = 5,
|
||||
prefix: str = "Relevant memories:\n",
|
||||
) -> str:
|
||||
"""Pre-recall memories for injection into an agent's ``prompt``.
|
||||
|
||||
Omnigent agents have a static YAML ``prompt`` and no pre-turn context hook,
|
||||
so to seed an agent with what Hindsight already knows, call this and splice
|
||||
the result into the prompt yourself. Returns an empty string if no bank is
|
||||
configured or nothing is found — never raises.
|
||||
|
||||
Args:
|
||||
query: The recall query used to find relevant memories.
|
||||
bank_id: Bank to recall from (defaults to the configured bank).
|
||||
max_results: Maximum number of memories to include.
|
||||
prefix: Text prepended before the memory list.
|
||||
"""
|
||||
config = get_config()
|
||||
bank = bank_id or config.bank_id
|
||||
if not bank:
|
||||
return ""
|
||||
try:
|
||||
client = _client()
|
||||
kwargs: dict[str, Any] = {
|
||||
"bank_id": bank,
|
||||
"query": query,
|
||||
"budget": "low",
|
||||
"max_tokens": config.max_tokens,
|
||||
}
|
||||
if config.recall_tags:
|
||||
kwargs["tags"] = config.recall_tags
|
||||
kwargs["tags_match"] = config.recall_tags_match
|
||||
response = client.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 ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent-YAML helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OmnigentToolSpec:
|
||||
"""A Hindsight tool declaration for an Omnigent agent YAML.
|
||||
|
||||
Attributes:
|
||||
name: Tool name advertised to the LLM (the YAML key).
|
||||
callable_path: Dotted import path Omnigent resolves and calls.
|
||||
description: One-line description shown to the LLM.
|
||||
parameters: OpenAI/JSON-Schema parameter block for the tool.
|
||||
"""
|
||||
|
||||
name: str
|
||||
callable_path: str
|
||||
description: str
|
||||
parameters: dict[str, Any]
|
||||
|
||||
|
||||
def _string_param(field: str, description: str) -> dict[str, Any]:
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {field: {"type": "string", "description": description}},
|
||||
"required": [field],
|
||||
}
|
||||
|
||||
|
||||
_RETAIN_SPEC = OmnigentToolSpec(
|
||||
name="hindsight_retain",
|
||||
callable_path="hindsight_omnigent.tools.retain",
|
||||
description="Store information in long-term memory for later retrieval.",
|
||||
parameters=_string_param("content", "The information to store in long-term memory."),
|
||||
)
|
||||
_RECALL_SPEC = OmnigentToolSpec(
|
||||
name="hindsight_recall",
|
||||
callable_path="hindsight_omnigent.tools.recall",
|
||||
description="Search long-term memory for relevant information.",
|
||||
parameters=_string_param("query", "The search query to find relevant memories."),
|
||||
)
|
||||
_REFLECT_SPEC = OmnigentToolSpec(
|
||||
name="hindsight_reflect",
|
||||
callable_path="hindsight_omnigent.tools.reflect",
|
||||
description="Synthesize a reasoned answer from long-term memories.",
|
||||
parameters=_string_param("query", "The question to reflect on using stored memories."),
|
||||
)
|
||||
|
||||
|
||||
def tool_specs(
|
||||
*,
|
||||
enable_retain: bool = True,
|
||||
enable_recall: bool = True,
|
||||
enable_reflect: bool = True,
|
||||
) -> list[OmnigentToolSpec]:
|
||||
"""Return the Hindsight tool specs to declare on an Omnigent agent.
|
||||
|
||||
Args:
|
||||
enable_retain: Include the retain (store) tool.
|
||||
enable_recall: Include the recall (search) tool.
|
||||
enable_reflect: Include the reflect (synthesize) tool.
|
||||
"""
|
||||
specs: list[OmnigentToolSpec] = []
|
||||
if enable_retain:
|
||||
specs.append(_RETAIN_SPEC)
|
||||
if enable_recall:
|
||||
specs.append(_RECALL_SPEC)
|
||||
if enable_reflect:
|
||||
specs.append(_REFLECT_SPEC)
|
||||
return specs
|
||||
|
||||
|
||||
def tools_yaml(
|
||||
*,
|
||||
enable_retain: bool = True,
|
||||
enable_recall: bool = True,
|
||||
enable_reflect: bool = True,
|
||||
indent: int = 2,
|
||||
) -> str:
|
||||
"""Render a ``tools:`` block for an Omnigent agent YAML.
|
||||
|
||||
Each tool's ``parameters`` is emitted as inline JSON (valid YAML flow style),
|
||||
so the schema round-trips exactly. Drop the result into your ``agent.yaml``.
|
||||
|
||||
Args:
|
||||
enable_retain: Include the retain (store) tool.
|
||||
enable_recall: Include the recall (search) tool.
|
||||
enable_reflect: Include the reflect (synthesize) tool.
|
||||
indent: Spaces per indent level.
|
||||
"""
|
||||
specs = tool_specs(
|
||||
enable_retain=enable_retain,
|
||||
enable_recall=enable_recall,
|
||||
enable_reflect=enable_reflect,
|
||||
)
|
||||
pad = " " * indent
|
||||
lines = ["tools:"]
|
||||
for spec in specs:
|
||||
lines.append(f"{pad}{spec.name}:")
|
||||
lines.append(f"{pad * 2}type: function")
|
||||
lines.append(f"{pad * 2}description: {spec.description}")
|
||||
lines.append(f"{pad * 2}callable: {spec.callable_path}")
|
||||
lines.append(f"{pad * 2}parameters: {json.dumps(spec.parameters)}")
|
||||
return "\n".join(lines) + "\n"
|
||||
@@ -0,0 +1,55 @@
|
||||
[project]
|
||||
name = "hindsight-omnigent"
|
||||
version = "0.1.0"
|
||||
description = "Omnigent integration for Hindsight - persistent memory tools for AI agents"
|
||||
requires-python = ">=3.10"
|
||||
license = { text = "MIT" }
|
||||
authors = [
|
||||
{ name = "Vectorize", email = "[email protected]" }
|
||||
]
|
||||
keywords = [
|
||||
"ai",
|
||||
"memory",
|
||||
"omnigent",
|
||||
"agents",
|
||||
"hindsight",
|
||||
"tools",
|
||||
]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Topic :: Scientific/Engineering :: Artificial Intelligence",
|
||||
]
|
||||
|
||||
dependencies = [
|
||||
"hindsight-client>=0.4.0",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/vectorize-io/hindsight"
|
||||
Documentation = "https://github.com/vectorize-io/hindsight/tree/main/hindsight-integrations/omnigent"
|
||||
Repository = "https://github.com/vectorize-io/hindsight"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["hindsight_omnigent"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
markers = [
|
||||
"requires_real_llm: end-to-end test that needs live external services (a running Hindsight server and/or real LLM provider keys). Excluded from the deterministic PR-CI bucket via -m 'not requires_real_llm'; run on its own via -m requires_real_llm.",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=9.0.2",
|
||||
"ruff>=0.8.0",
|
||||
]
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Unit tests for Hindsight Omnigent configuration."""
|
||||
|
||||
import pytest
|
||||
from hindsight_omnigent import (
|
||||
HindsightOmnigentConfig,
|
||||
configure,
|
||||
get_config,
|
||||
reset_config,
|
||||
)
|
||||
from hindsight_omnigent.config import DEFAULT_HINDSIGHT_API_URL
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_config():
|
||||
reset_config()
|
||||
yield
|
||||
reset_config()
|
||||
|
||||
|
||||
def test_defaults():
|
||||
cfg = configure()
|
||||
assert isinstance(cfg, HindsightOmnigentConfig)
|
||||
assert cfg.hindsight_api_url == DEFAULT_HINDSIGHT_API_URL
|
||||
assert cfg.api_key is None
|
||||
assert cfg.bank_id is None
|
||||
assert cfg.budget == "mid"
|
||||
assert cfg.max_tokens == 4096
|
||||
assert cfg.recall_tags_match == "any"
|
||||
|
||||
|
||||
def test_explicit_values_win():
|
||||
cfg = configure(
|
||||
hindsight_api_url="http://localhost:8888",
|
||||
api_key="hsk_x",
|
||||
bank_id="b1",
|
||||
budget="high",
|
||||
max_tokens=2048,
|
||||
tags=["t"],
|
||||
recall_tags=["r"],
|
||||
recall_tags_match="all_strict",
|
||||
)
|
||||
assert cfg.hindsight_api_url == "http://localhost:8888"
|
||||
assert cfg.api_key == "hsk_x"
|
||||
assert cfg.bank_id == "b1"
|
||||
assert cfg.budget == "high"
|
||||
assert cfg.max_tokens == 2048
|
||||
assert cfg.tags == ["t"]
|
||||
assert cfg.recall_tags == ["r"]
|
||||
assert cfg.recall_tags_match == "all_strict"
|
||||
|
||||
|
||||
def test_env_fallbacks(monkeypatch):
|
||||
monkeypatch.setenv("HINDSIGHT_API_URL", "http://env:9999")
|
||||
monkeypatch.setenv("HINDSIGHT_API_KEY", "hsk_env")
|
||||
monkeypatch.setenv("HINDSIGHT_BANK_ID", "env-bank")
|
||||
cfg = configure()
|
||||
assert cfg.hindsight_api_url == "http://env:9999"
|
||||
assert cfg.api_key == "hsk_env"
|
||||
assert cfg.bank_id == "env-bank"
|
||||
|
||||
|
||||
def test_explicit_overrides_env(monkeypatch):
|
||||
monkeypatch.setenv("HINDSIGHT_BANK_ID", "env-bank")
|
||||
cfg = configure(bank_id="explicit")
|
||||
assert cfg.bank_id == "explicit"
|
||||
|
||||
|
||||
def test_get_config_auto_creates_from_env(monkeypatch):
|
||||
monkeypatch.setenv("HINDSIGHT_BANK_ID", "lazy-bank")
|
||||
# No prior configure() call — get_config() should build one from env.
|
||||
cfg = get_config()
|
||||
assert cfg.bank_id == "lazy-bank"
|
||||
|
||||
|
||||
def test_reset_config():
|
||||
configure(bank_id="b")
|
||||
reset_config()
|
||||
# get_config rebuilds from env (none set) -> bank_id None
|
||||
assert get_config().bank_id is None
|
||||
@@ -0,0 +1,96 @@
|
||||
"""End-to-end tests for the Hindsight-Omnigent integration.
|
||||
|
||||
Exercises the retain/recall/reflect tool callables against a live Hindsight
|
||||
server. The callables talk to Hindsight directly (the server's LLM does fact
|
||||
extraction), so only a running Hindsight instance is required — no provider key.
|
||||
By default these tests are skipped; point ``HINDSIGHT_API_URL`` at a reachable
|
||||
server to enable them.
|
||||
|
||||
The whole module is the real-LLM bucket (``requires_real_llm``).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
from hindsight_client import Hindsight
|
||||
from hindsight_omnigent import configure, recall, reflect, reset_config, retain
|
||||
from hindsight_omnigent.tools import _reset_created_banks
|
||||
|
||||
HINDSIGHT_API_URL = os.getenv("HINDSIGHT_API_URL", "http://localhost:8888")
|
||||
_NO_MEMORIES_RECALL = "No relevant memories found."
|
||||
|
||||
|
||||
def _hindsight_available() -> bool:
|
||||
try:
|
||||
with urllib.request.urlopen(f"{HINDSIGHT_API_URL}/health", timeout=3) as r:
|
||||
return r.status == 200
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
requires_hindsight = pytest.mark.skipif(
|
||||
not _hindsight_available(),
|
||||
reason=f"Hindsight not reachable at {HINDSIGHT_API_URL}",
|
||||
)
|
||||
|
||||
pytestmark = [requires_hindsight, pytest.mark.requires_real_llm]
|
||||
|
||||
|
||||
def _recall_until_nonempty(query, attempts=12, delay=1.0):
|
||||
for _ in range(attempts):
|
||||
out = recall(query=query)
|
||||
if out and out != _NO_MEMORIES_RECALL:
|
||||
return out
|
||||
time.sleep(delay)
|
||||
pytest.fail(
|
||||
f"recall({query!r}) returned no memories after {attempts * delay:.0f}s — "
|
||||
"either retain failed to surface or the query no longer matches."
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def live():
|
||||
reset_config()
|
||||
_reset_created_banks()
|
||||
bank_id = f"omnigent-e2e-{uuid.uuid4().hex[:8]}"
|
||||
configure(hindsight_api_url=HINDSIGHT_API_URL, bank_id=bank_id)
|
||||
client = Hindsight(base_url=HINDSIGHT_API_URL)
|
||||
try:
|
||||
yield bank_id
|
||||
finally:
|
||||
try:
|
||||
client.delete_bank(bank_id)
|
||||
except Exception:
|
||||
pass
|
||||
reset_config()
|
||||
_reset_created_banks()
|
||||
|
||||
|
||||
class TestE2ETools:
|
||||
def test_retain_and_recall_roundtrip(self, live):
|
||||
assert retain(content="The team uses PostgreSQL 16 and deploys to us-east-1.") == (
|
||||
"Stored to long-term memory."
|
||||
)
|
||||
recalled = _recall_until_nonempty("What technologies does the team use?")
|
||||
lowered = recalled.lower()
|
||||
assert "postgresql" in lowered or "us-east-1" in lowered, (
|
||||
f"recall surfaced results but none referenced the stored content: {recalled}"
|
||||
)
|
||||
|
||||
def test_reflect_synthesizes_from_memory(self, live):
|
||||
retain(content="The team uses PostgreSQL 16 and deploys to us-east-1.")
|
||||
_recall_until_nonempty("What technologies does the team use?")
|
||||
answer = reflect(query="What do I know about the team's tech stack?")
|
||||
assert answer and answer != _NO_MEMORIES_RECALL, "reflect should synthesise non-empty text"
|
||||
lowered = answer.lower()
|
||||
assert "postgresql" in lowered or "us-east" in lowered, (
|
||||
f"reflect text didn't reference the stored memory: {answer[:300]}"
|
||||
)
|
||||
|
||||
def test_recall_empty_bank(self, live):
|
||||
assert recall(query="anything at all") == _NO_MEMORIES_RECALL
|
||||
@@ -0,0 +1,387 @@
|
||||
"""Unit tests for the Hindsight Omnigent tools.
|
||||
|
||||
The bulk of these tests drive the tool callables through ``_invoke_like_omnigent``
|
||||
— a faithful replica of how Omnigent's ``LocalCallableTool.invoke`` calls a
|
||||
``type: function`` tool (parse the LLM's JSON args into kwargs, call the
|
||||
callable, coerce the return to a string). That exercises the real framework
|
||||
contract without installing the (alpha, 3.12-only) ``omnigent`` package.
|
||||
|
||||
``TestRealOmnigentInvocation`` additionally loads the callables through the real
|
||||
``omnigent`` machinery when it's importable, guarding against contract drift.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from hindsight_omnigent import (
|
||||
OmnigentToolSpec,
|
||||
configure,
|
||||
memory_instructions,
|
||||
recall,
|
||||
reflect,
|
||||
retain,
|
||||
reset_config,
|
||||
tool_specs,
|
||||
tools_yaml,
|
||||
)
|
||||
from hindsight_omnigent.errors import HindsightError
|
||||
from hindsight_omnigent.tools import _reset_created_banks
|
||||
|
||||
try:
|
||||
from omnigent.spec.types import LocalToolInfo
|
||||
from omnigent.tools.local_callable import load_local_callable_tools
|
||||
|
||||
_HAS_OMNIGENT = True
|
||||
except Exception: # pragma: no cover - omnigent is an optional, 3.12-only dep
|
||||
_HAS_OMNIGENT = False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fakes / helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _stringify(value):
|
||||
"""Replica of ``omnigent.tools.local_callable._stringify``."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
try:
|
||||
return json.dumps(value)
|
||||
except (TypeError, ValueError):
|
||||
return repr(value)
|
||||
|
||||
|
||||
def _invoke_like_omnigent(fn, **arguments) -> str:
|
||||
"""Replicate ``LocalCallableTool.invoke``: JSON args -> kwargs -> stringify."""
|
||||
kwargs = json.loads(json.dumps(arguments)) # round-trip as the LLM payload would
|
||||
return _stringify(fn(**kwargs))
|
||||
|
||||
|
||||
def _mock_client():
|
||||
client = MagicMock()
|
||||
client.retain = MagicMock()
|
||||
client.recall = MagicMock()
|
||||
client.reflect = MagicMock()
|
||||
client.create_bank = MagicMock()
|
||||
return client
|
||||
|
||||
|
||||
def _mock_recall_response(texts):
|
||||
response = MagicMock()
|
||||
results = []
|
||||
for t in texts:
|
||||
r = MagicMock()
|
||||
r.text = t
|
||||
results.append(r)
|
||||
response.results = results
|
||||
return response
|
||||
|
||||
|
||||
def _mock_reflect_response(text):
|
||||
response = MagicMock()
|
||||
response.text = text
|
||||
return response
|
||||
|
||||
|
||||
class _Base:
|
||||
def setup_method(self):
|
||||
reset_config()
|
||||
_reset_created_banks()
|
||||
|
||||
def teardown_method(self):
|
||||
reset_config()
|
||||
_reset_created_banks()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bank / connection resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestBankResolution(_Base):
|
||||
def test_retain_uses_configured_bank(self):
|
||||
client = _mock_client()
|
||||
configure(client=client, bank_id="alice")
|
||||
out = _invoke_like_omnigent(retain, content="hi")
|
||||
assert out == "Stored to long-term memory."
|
||||
client.retain.assert_called_once_with(bank_id="alice", content="hi")
|
||||
client.create_bank.assert_called_once_with(bank_id="alice", name="alice")
|
||||
|
||||
def test_bank_from_env(self, monkeypatch):
|
||||
monkeypatch.setenv("HINDSIGHT_BANK_ID", "from-env")
|
||||
client = _mock_client()
|
||||
configure(client=client) # reads HINDSIGHT_BANK_ID
|
||||
_invoke_like_omnigent(retain, content="hi")
|
||||
assert client.retain.call_args[1]["bank_id"] == "from-env"
|
||||
|
||||
def test_missing_bank_raises(self):
|
||||
configure(client=_mock_client()) # no bank_id, no env
|
||||
with pytest.raises(HindsightError, match="No Hindsight bank configured"):
|
||||
retain(content="hi")
|
||||
|
||||
def test_bank_created_once(self):
|
||||
client = _mock_client()
|
||||
configure(client=client, bank_id="alice")
|
||||
retain(content="a")
|
||||
retain(content="b")
|
||||
client.create_bank.assert_called_once()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Retain
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRetain(_Base):
|
||||
def test_retain_with_tags(self):
|
||||
client = _mock_client()
|
||||
configure(client=client, bank_id="alice", tags=["env:test"])
|
||||
retain(content="c")
|
||||
assert client.retain.call_args[1]["tags"] == ["env:test"]
|
||||
|
||||
def test_retain_bank_already_exists(self):
|
||||
client = _mock_client()
|
||||
client.create_bank.side_effect = Exception("already exists")
|
||||
configure(client=client, bank_id="alice")
|
||||
assert retain(content="c") == "Stored to long-term memory."
|
||||
|
||||
def test_retain_failure_raises_and_logs(self, caplog):
|
||||
client = _mock_client()
|
||||
client.retain.side_effect = RuntimeError("network error")
|
||||
configure(client=client, bank_id="alice")
|
||||
with caplog.at_level(logging.ERROR), pytest.raises(HindsightError, match="Retain failed"):
|
||||
retain(content="c")
|
||||
assert "Retain failed" in caplog.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Recall
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRecall(_Base):
|
||||
def test_recall_returns_bullet_list(self):
|
||||
client = _mock_client()
|
||||
client.recall.return_value = _mock_recall_response(["f1", "f2"])
|
||||
configure(client=client, bank_id="alice")
|
||||
out = _invoke_like_omnigent(recall, query="q")
|
||||
assert out == "- f1\n- f2"
|
||||
|
||||
def test_recall_no_results(self):
|
||||
client = _mock_client()
|
||||
client.recall.return_value = _mock_recall_response([])
|
||||
configure(client=client, bank_id="alice")
|
||||
assert recall(query="q") == "No relevant memories found."
|
||||
|
||||
def test_recall_none_results(self):
|
||||
client = _mock_client()
|
||||
response = MagicMock()
|
||||
response.results = None
|
||||
client.recall.return_value = response
|
||||
configure(client=client, bank_id="alice")
|
||||
assert recall(query="q") == "No relevant memories found."
|
||||
|
||||
def test_recall_passes_budget_and_max_tokens(self):
|
||||
client = _mock_client()
|
||||
client.recall.return_value = _mock_recall_response(["f"])
|
||||
configure(client=client, bank_id="alice", budget="high", max_tokens=2048)
|
||||
recall(query="q")
|
||||
call = client.recall.call_args[1]
|
||||
assert call["budget"] == "high"
|
||||
assert call["max_tokens"] == 2048
|
||||
|
||||
def test_recall_with_tags(self):
|
||||
client = _mock_client()
|
||||
client.recall.return_value = _mock_recall_response(["f"])
|
||||
configure(client=client, bank_id="alice", recall_tags=["scope:global"], recall_tags_match="all")
|
||||
recall(query="q")
|
||||
call = client.recall.call_args[1]
|
||||
assert call["tags"] == ["scope:global"]
|
||||
assert call["tags_match"] == "all"
|
||||
|
||||
def test_recall_without_tags_omits_tag_kwargs(self):
|
||||
client = _mock_client()
|
||||
client.recall.return_value = _mock_recall_response(["f"])
|
||||
configure(client=client, bank_id="alice")
|
||||
recall(query="q")
|
||||
call = client.recall.call_args[1]
|
||||
assert "tags" not in call
|
||||
assert "tags_match" not in call
|
||||
|
||||
def test_recall_does_not_create_bank(self):
|
||||
client = _mock_client()
|
||||
client.recall.return_value = _mock_recall_response(["f"])
|
||||
configure(client=client, bank_id="alice")
|
||||
recall(query="q")
|
||||
client.create_bank.assert_not_called()
|
||||
|
||||
def test_recall_failure_raises(self):
|
||||
client = _mock_client()
|
||||
client.recall.side_effect = RuntimeError("network error")
|
||||
configure(client=client, bank_id="alice")
|
||||
with pytest.raises(HindsightError, match="Recall failed"):
|
||||
recall(query="q")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reflect
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestReflect(_Base):
|
||||
def test_reflect_returns_answer(self):
|
||||
client = _mock_client()
|
||||
client.reflect.return_value = _mock_reflect_response("Synthesized answer")
|
||||
configure(client=client, bank_id="alice")
|
||||
assert _invoke_like_omnigent(reflect, query="q") == "Synthesized answer"
|
||||
|
||||
def test_reflect_empty_text_returns_fallback(self):
|
||||
client = _mock_client()
|
||||
client.reflect.return_value = _mock_reflect_response("")
|
||||
configure(client=client, bank_id="alice")
|
||||
assert reflect(query="q") == "No relevant memories found."
|
||||
|
||||
def test_reflect_passes_budget(self):
|
||||
client = _mock_client()
|
||||
client.reflect.return_value = _mock_reflect_response("a")
|
||||
configure(client=client, bank_id="alice", budget="high")
|
||||
reflect(query="q")
|
||||
assert client.reflect.call_args[1]["budget"] == "high"
|
||||
|
||||
def test_reflect_failure_raises(self):
|
||||
client = _mock_client()
|
||||
client.reflect.side_effect = RuntimeError("network error")
|
||||
configure(client=client, bank_id="alice")
|
||||
with pytest.raises(HindsightError, match="Reflect failed"):
|
||||
reflect(query="q")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# memory_instructions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMemoryInstructions(_Base):
|
||||
def test_formats_results_with_prefix(self):
|
||||
client = _mock_client()
|
||||
client.recall.return_value = _mock_recall_response(["likes tea", "lives in NYC"])
|
||||
configure(client=client, bank_id="b")
|
||||
out = memory_instructions()
|
||||
assert out == "Relevant memories:\n\n1. likes tea\n2. lives in NYC"
|
||||
|
||||
def test_caps_at_max_results(self):
|
||||
client = _mock_client()
|
||||
client.recall.return_value = _mock_recall_response(["a", "b", "c", "d"])
|
||||
configure(client=client, bank_id="b")
|
||||
out = memory_instructions(max_results=2)
|
||||
assert "1. a" in out and "2. b" in out
|
||||
assert "c" not in out and "d" not in out
|
||||
|
||||
def test_explicit_bank_overrides_config(self):
|
||||
client = _mock_client()
|
||||
client.recall.return_value = _mock_recall_response(["x"])
|
||||
configure(client=client, bank_id="config-bank")
|
||||
memory_instructions(bank_id="explicit-bank")
|
||||
assert client.recall.call_args[1]["bank_id"] == "explicit-bank"
|
||||
|
||||
def test_returns_empty_without_bank(self):
|
||||
configure(client=_mock_client()) # no bank
|
||||
assert memory_instructions() == ""
|
||||
|
||||
def test_returns_empty_when_no_results(self):
|
||||
client = _mock_client()
|
||||
client.recall.return_value = _mock_recall_response([])
|
||||
configure(client=client, bank_id="b")
|
||||
assert memory_instructions() == ""
|
||||
|
||||
def test_returns_empty_on_failure(self):
|
||||
client = _mock_client()
|
||||
client.recall.side_effect = RuntimeError("boom")
|
||||
configure(client=client, bank_id="b")
|
||||
assert memory_instructions() == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent-YAML helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestToolSpecs(_Base):
|
||||
def test_default_three_specs(self):
|
||||
specs = tool_specs()
|
||||
assert [s.name for s in specs] == [
|
||||
"hindsight_retain",
|
||||
"hindsight_recall",
|
||||
"hindsight_reflect",
|
||||
]
|
||||
assert all(isinstance(s, OmnigentToolSpec) for s in specs)
|
||||
assert all(s.callable_path.startswith("hindsight_omnigent.tools.") for s in specs)
|
||||
|
||||
def test_enable_flags(self):
|
||||
specs = tool_specs(enable_retain=False, enable_reflect=False)
|
||||
assert [s.name for s in specs] == ["hindsight_recall"]
|
||||
|
||||
def test_tools_yaml_is_parseable_and_complete(self):
|
||||
text = tools_yaml(enable_reflect=False)
|
||||
assert text.startswith("tools:\n")
|
||||
# Each tool's parameters line is inline JSON (valid YAML) — load it back.
|
||||
for line in text.splitlines():
|
||||
stripped = line.strip()
|
||||
if stripped.startswith("parameters:"):
|
||||
payload = stripped[len("parameters:") :].strip()
|
||||
schema = json.loads(payload)
|
||||
assert schema["type"] == "object"
|
||||
assert schema["required"]
|
||||
assert "hindsight_omnigent.tools.retain" in text
|
||||
assert "hindsight_omnigent.tools.recall" in text
|
||||
assert "hindsight_omnigent.tools.reflect" not in text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Real Omnigent invocation (guards against the function-tool contract drifting)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _HAS_OMNIGENT, reason="omnigent not installed (3.12-only alpha)")
|
||||
class TestRealOmnigentInvocation(_Base):
|
||||
def _info(self, name, path, field):
|
||||
return LocalToolInfo(
|
||||
name=name,
|
||||
path=path,
|
||||
language="omnigent-python-callable",
|
||||
parameters={
|
||||
"type": "object",
|
||||
"properties": {field: {"type": "string"}},
|
||||
"required": [field],
|
||||
},
|
||||
)
|
||||
|
||||
def test_recall_through_local_callable_tool(self):
|
||||
client = _mock_client()
|
||||
client.recall.return_value = _mock_recall_response(["fact one", "fact two"])
|
||||
configure(client=client, bank_id="alice")
|
||||
|
||||
tools = load_local_callable_tools([self._info("hindsight_recall", "hindsight_omnigent.tools.recall", "query")])
|
||||
assert len(tools) == 1
|
||||
tool = tools[0]
|
||||
assert tool.name() == "hindsight_recall"
|
||||
assert tool.get_schema()["function"]["name"] == "hindsight_recall"
|
||||
|
||||
out = tool.invoke(json.dumps({"query": "what tech?"}), None)
|
||||
assert "fact one" in out and "fact two" in out
|
||||
assert client.recall.call_args[1]["bank_id"] == "alice"
|
||||
|
||||
def test_retain_through_local_callable_tool(self):
|
||||
client = _mock_client()
|
||||
configure(client=client, bank_id="alice")
|
||||
|
||||
tools = load_local_callable_tools(
|
||||
[self._info("hindsight_retain", "hindsight_omnigent.tools.retain", "content")]
|
||||
)
|
||||
out = tools[0].invoke(json.dumps({"content": "remember this"}), None)
|
||||
assert out == "Stored to long-term memory."
|
||||
client.retain.assert_called_once_with(bank_id="alice", content="remember this")
|
||||
Generated
+1089
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@ print_info() { echo -e "${GREEN}[INFO]${NC} $1"; }
|
||||
print_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
||||
print_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
||||
|
||||
VALID_INTEGRATIONS=("ag2" "agent-framework" "agentcore" "agno" "ai-sdk" "autogen" "chat" "claude-agent-sdk" "claude-code" "cline" "cloudflare-oauth-proxy" "codex" "crewai" "cursor" "cursor-cli" "dify" "flowise" "gemini-spark" "google-adk" "haystack" "langgraph" "litellm" "llamaindex" "n8n" "nemoclaw" "obsidian" "omo" "openai-agents" "openclaw" "opencode" "paperclip" "pipecat" "pydantic-ai" "roo-code" "smolagents" "strands" "superagent" "vapi")
|
||||
VALID_INTEGRATIONS=("ag2" "agent-framework" "agentcore" "agno" "ai-sdk" "autogen" "chat" "claude-agent-sdk" "claude-code" "cline" "cloudflare-oauth-proxy" "codex" "crewai" "cursor" "cursor-cli" "dify" "flowise" "gemini-spark" "google-adk" "haystack" "langgraph" "litellm" "llamaindex" "n8n" "nemoclaw" "obsidian" "omnigent" "omo" "openai-agents" "openclaw" "opencode" "paperclip" "pipecat" "pydantic-ai" "roo-code" "smolagents" "strands" "superagent" "vapi")
|
||||
|
||||
usage() {
|
||||
print_error "Usage: $0 <integration> <version>"
|
||||
|
||||
Reference in New Issue
Block a user