fix(hindsight): overhaul hindsight memory plugin and memory setup wizard
- Dedicated asyncio event loop for Hindsight async calls (fixes aiohttp session leaks) - Client caching (reuse instead of creating per-call) - Local mode daemon management with config change detection and auto-restart - Memory mode support (hybrid/context/tools) and prefetch method (recall/reflect) - Proper shutdown with event loop and client cleanup - Disable HindsightEmbedded.__del__ to avoid GC loop errors - Update API URLs (app -> ui.hindsight.vectorize.io, api_url -> base_url) - Setup wizard: conditional fields (when clause), dynamic defaults (default_from) - Switch dependency install from pip to uv (correct for uv-based venvs) - Add hindsight-all to plugin.yaml and import mapping - 12 new tests for dispatch routing and setup field filtering Original PR #5044 by cdbartholomew.
This commit is contained in:
committed by
Teknium
parent
93aa01c71c
commit
28e1e210ee
@@ -151,6 +151,7 @@ def _install_dependencies(provider_name: str) -> None:
|
|||||||
"honcho-ai": "honcho",
|
"honcho-ai": "honcho",
|
||||||
"mem0ai": "mem0",
|
"mem0ai": "mem0",
|
||||||
"hindsight-client": "hindsight_client",
|
"hindsight-client": "hindsight_client",
|
||||||
|
"hindsight-all": "hindsight",
|
||||||
}
|
}
|
||||||
|
|
||||||
# Check which packages are missing
|
# Check which packages are missing
|
||||||
@@ -166,9 +167,18 @@ def _install_dependencies(provider_name: str) -> None:
|
|||||||
return
|
return
|
||||||
|
|
||||||
print(f"\n Installing dependencies: {', '.join(missing)}")
|
print(f"\n Installing dependencies: {', '.join(missing)}")
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
uv_path = shutil.which("uv")
|
||||||
|
if not uv_path:
|
||||||
|
print(f" ⚠ uv not found — cannot install dependencies")
|
||||||
|
print(f" Install uv: curl -LsSf https://astral.sh/uv/install.sh | sh")
|
||||||
|
print(f" Then re-run: hermes memory setup")
|
||||||
|
return
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
[sys.executable, "-m", "pip", "install", "--quiet"] + missing,
|
[uv_path, "pip", "install", "--python", sys.executable, "--quiet"] + missing,
|
||||||
check=True, timeout=120,
|
check=True, timeout=120,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
)
|
)
|
||||||
@@ -178,10 +188,10 @@ def _install_dependencies(provider_name: str) -> None:
|
|||||||
stderr = (e.stderr or b"").decode()[:200]
|
stderr = (e.stderr or b"").decode()[:200]
|
||||||
if stderr:
|
if stderr:
|
||||||
print(f" {stderr}")
|
print(f" {stderr}")
|
||||||
print(f" Run manually: pip install {' '.join(missing)}")
|
print(f" Run manually: uv pip install --python {sys.executable} {' '.join(missing)}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f" ⚠ Install failed: {e}")
|
print(f" ⚠ Install failed: {e}")
|
||||||
print(f" Run manually: pip install {' '.join(missing)}")
|
print(f" Run manually: uv pip install --python {sys.executable} {' '.join(missing)}")
|
||||||
|
|
||||||
# Also show external dependencies (non-pip) if any
|
# Also show external dependencies (non-pip) if any
|
||||||
ext_deps = meta.get("external_dependencies", [])
|
ext_deps = meta.get("external_dependencies", [])
|
||||||
@@ -275,7 +285,6 @@ def cmd_setup(args) -> None:
|
|||||||
|
|
||||||
schema = provider.get_config_schema() if hasattr(provider, "get_config_schema") else []
|
schema = provider.get_config_schema() if hasattr(provider, "get_config_schema") else []
|
||||||
|
|
||||||
# Provider config section
|
|
||||||
provider_config = config["memory"].get(name, {})
|
provider_config = config["memory"].get(name, {})
|
||||||
if not isinstance(provider_config, dict):
|
if not isinstance(provider_config, dict):
|
||||||
provider_config = {}
|
provider_config = {}
|
||||||
@@ -290,11 +299,25 @@ def cmd_setup(args) -> None:
|
|||||||
key = field["key"]
|
key = field["key"]
|
||||||
desc = field.get("description", key)
|
desc = field.get("description", key)
|
||||||
default = field.get("default")
|
default = field.get("default")
|
||||||
|
# Dynamic default: look up default from another field's value
|
||||||
|
default_from = field.get("default_from")
|
||||||
|
if default_from and isinstance(default_from, dict):
|
||||||
|
ref_field = default_from.get("field", "")
|
||||||
|
ref_map = default_from.get("map", {})
|
||||||
|
ref_value = provider_config.get(ref_field, "")
|
||||||
|
if ref_value and ref_value in ref_map:
|
||||||
|
default = ref_map[ref_value]
|
||||||
is_secret = field.get("secret", False)
|
is_secret = field.get("secret", False)
|
||||||
choices = field.get("choices")
|
choices = field.get("choices")
|
||||||
env_var = field.get("env_var")
|
env_var = field.get("env_var")
|
||||||
url = field.get("url")
|
url = field.get("url")
|
||||||
|
|
||||||
|
# Skip fields whose "when" condition doesn't match
|
||||||
|
when = field.get("when")
|
||||||
|
if when and isinstance(when, dict):
|
||||||
|
if not all(provider_config.get(k) == v for k, v in when.items()):
|
||||||
|
continue
|
||||||
|
|
||||||
if choices and not is_secret:
|
if choices and not is_secret:
|
||||||
# Use curses picker for choice fields
|
# Use curses picker for choice fields
|
||||||
choice_items = [(c, "") for c in choices]
|
choice_items = [(c, "") for c in choices]
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
# Hindsight Memory Provider
|
# Hindsight Memory Provider
|
||||||
|
|
||||||
Long-term memory with knowledge graph, entity resolution, and multi-strategy retrieval. Supports cloud and local modes.
|
Long-term memory with knowledge graph, entity resolution, and multi-strategy retrieval. Supports cloud and local (embedded) modes.
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
- Cloud: `pip install hindsight-client` + API key from [app.hindsight.vectorize.io](https://app.hindsight.vectorize.io)
|
- **Cloud:** API key from [ui.hindsight.vectorize.io](https://ui.hindsight.vectorize.io)
|
||||||
- Local: `pip install hindsight` + LLM API key for embeddings
|
- **Local:** API key for a supported LLM provider (OpenAI, Anthropic, Gemini, Groq, MiniMax, or Ollama). Embeddings and reranking run locally — no additional API keys needed.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
|
|
||||||
@@ -13,26 +13,86 @@ Long-term memory with knowledge graph, entity resolution, and multi-strategy ret
|
|||||||
hermes memory setup # select "hindsight"
|
hermes memory setup # select "hindsight"
|
||||||
```
|
```
|
||||||
|
|
||||||
Or manually:
|
The setup wizard will install dependencies automatically via `uv` and walk you through configuration.
|
||||||
|
|
||||||
|
Or manually (cloud mode with defaults):
|
||||||
```bash
|
```bash
|
||||||
hermes config set memory.provider hindsight
|
hermes config set memory.provider hindsight
|
||||||
echo "HINDSIGHT_API_KEY=your-key" >> ~/.hermes/.env
|
echo "HINDSIGHT_API_KEY=your-key" >> ~/.hermes/.env
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Cloud Mode
|
||||||
|
|
||||||
|
Connects to the Hindsight Cloud API. Requires an API key from [ui.hindsight.vectorize.io](https://ui.hindsight.vectorize.io).
|
||||||
|
|
||||||
|
### Local Mode
|
||||||
|
|
||||||
|
Runs an embedded Hindsight server with built-in PostgreSQL. Requires an LLM API key (e.g. Groq, OpenAI, Anthropic) for memory extraction and synthesis. The daemon starts automatically in the background on first use and stops after 5 minutes of inactivity.
|
||||||
|
|
||||||
|
Daemon startup logs: `~/.hermes/logs/hindsight-embed.log`
|
||||||
|
Daemon runtime logs: `~/.hindsight/profiles/<profile>.log`
|
||||||
|
|
||||||
## Config
|
## Config
|
||||||
|
|
||||||
Config file: `$HERMES_HOME/hindsight/config.json` (or `~/.hindsight/config.json` legacy)
|
Config file: `~/.hermes/hindsight/config.json`
|
||||||
|
|
||||||
|
### Connection
|
||||||
|
|
||||||
| Key | Default | Description |
|
| Key | Default | Description |
|
||||||
|-----|---------|-------------|
|
|-----|---------|-------------|
|
||||||
| `mode` | `cloud` | `cloud` or `local` |
|
| `mode` | `cloud` | `cloud` or `local` |
|
||||||
| `bank_id` | `hermes` | Memory bank identifier |
|
| `api_url` | `https://api.hindsight.vectorize.io` | API URL (cloud mode) |
|
||||||
| `budget` | `mid` | Recall thoroughness: `low`/`mid`/`high` |
|
| `api_url` | `http://localhost:8888` | API URL (local mode, unused — daemon manages its own port) |
|
||||||
|
|
||||||
|
### Memory
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|-----|---------|-------------|
|
||||||
|
| `bank_id` | `hermes` | Memory bank name |
|
||||||
|
| `budget` | `mid` | Recall thoroughness: `low` / `mid` / `high` |
|
||||||
|
|
||||||
|
### Integration
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|-----|---------|-------------|
|
||||||
|
| `memory_mode` | `hybrid` | How memories are integrated into the agent |
|
||||||
|
| `prefetch_method` | `recall` | Method for automatic context injection |
|
||||||
|
|
||||||
|
**memory_mode:**
|
||||||
|
- `hybrid` — automatic context injection + tools available to the LLM
|
||||||
|
- `context` — automatic injection only, no tools exposed
|
||||||
|
- `tools` — tools only, no automatic injection
|
||||||
|
|
||||||
|
**prefetch_method:**
|
||||||
|
- `recall` — injects raw memory facts (fast)
|
||||||
|
- `reflect` — injects LLM-synthesized summary (slower, more coherent)
|
||||||
|
|
||||||
|
### Local Mode LLM
|
||||||
|
|
||||||
|
| Key | Default | Description |
|
||||||
|
|-----|---------|-------------|
|
||||||
|
| `llm_provider` | `openai` | LLM provider: `openai`, `anthropic`, `gemini`, `groq`, `minimax`, `ollama` |
|
||||||
|
| `llm_model` | per-provider | Model name (e.g. `gpt-4o-mini`, `openai/gpt-oss-120b`) |
|
||||||
|
|
||||||
|
The LLM API key is stored in `~/.hermes/.env` as `HINDSIGHT_LLM_API_KEY`.
|
||||||
|
|
||||||
## Tools
|
## Tools
|
||||||
|
|
||||||
|
Available in `hybrid` and `tools` memory modes:
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `hindsight_retain` | Store information with auto entity extraction |
|
| `hindsight_retain` | Store information with auto entity extraction |
|
||||||
| `hindsight_recall` | Multi-strategy search (semantic + entity graph) |
|
| `hindsight_recall` | Multi-strategy search (semantic + entity graph) |
|
||||||
| `hindsight_reflect` | Cross-memory synthesis (LLM-powered) |
|
| `hindsight_reflect` | Cross-memory synthesis (LLM-powered) |
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
| Variable | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| `HINDSIGHT_API_KEY` | API key for Hindsight Cloud |
|
||||||
|
| `HINDSIGHT_LLM_API_KEY` | LLM API key for local mode |
|
||||||
|
| `HINDSIGHT_API_URL` | Override API endpoint |
|
||||||
|
| `HINDSIGHT_BANK_ID` | Override bank name |
|
||||||
|
| `HINDSIGHT_BUDGET` | Override recall budget |
|
||||||
|
| `HINDSIGHT_MODE` | Override mode (`cloud` / `local`) |
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"""Hindsight memory plugin — MemoryProvider interface.
|
"""Hindsight memory plugin — MemoryProvider interface.
|
||||||
|
|
||||||
Long-term memory with knowledge graph, entity resolution, and multi-strategy
|
Long-term memory with knowledge graph, entity resolution, and multi-strategy
|
||||||
retrieval. Supports cloud (API key) and local (embedded PostgreSQL) modes.
|
retrieval. Supports cloud (API key) and local modes.
|
||||||
|
|
||||||
Original PR #1811 by benfrank241, adapted to MemoryProvider ABC.
|
Original PR #1811 by benfrank241, adapted to MemoryProvider ABC.
|
||||||
|
|
||||||
@@ -18,10 +18,10 @@ Or via $HERMES_HOME/hindsight/config.json (profile-scoped), falling back to
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import queue
|
|
||||||
import threading
|
import threading
|
||||||
from typing import Any, Dict, List
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
@@ -30,30 +30,51 @@ from agent.memory_provider import MemoryProvider
|
|||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_DEFAULT_API_URL = "https://api.hindsight.vectorize.io"
|
_DEFAULT_API_URL = "https://api.hindsight.vectorize.io"
|
||||||
|
_DEFAULT_LOCAL_URL = "http://localhost:8888"
|
||||||
_VALID_BUDGETS = {"low", "mid", "high"}
|
_VALID_BUDGETS = {"low", "mid", "high"}
|
||||||
|
_PROVIDER_DEFAULT_MODELS = {
|
||||||
|
"openai": "gpt-4o-mini",
|
||||||
|
"anthropic": "claude-haiku-4-5",
|
||||||
|
"gemini": "gemini-2.5-flash",
|
||||||
|
"groq": "openai/gpt-oss-120b",
|
||||||
|
"minimax": "MiniMax-M2.7",
|
||||||
|
"ollama": "gemma3:12b",
|
||||||
|
"lmstudio": "local-model",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Thread helper (from original PR — avoids aiohttp event loop conflicts)
|
# Dedicated event loop for Hindsight async calls (one per process, reused).
|
||||||
|
# Avoids creating ephemeral loops that leak aiohttp sessions.
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
def _run_in_thread(fn, timeout: float = 30.0):
|
_loop: asyncio.AbstractEventLoop | None = None
|
||||||
result_q: queue.Queue = queue.Queue(maxsize=1)
|
_loop_thread: threading.Thread | None = None
|
||||||
|
_loop_lock = threading.Lock()
|
||||||
|
|
||||||
def _run():
|
|
||||||
import asyncio
|
|
||||||
asyncio.set_event_loop(None)
|
|
||||||
try:
|
|
||||||
result_q.put(("ok", fn()))
|
|
||||||
except Exception as exc:
|
|
||||||
result_q.put(("err", exc))
|
|
||||||
|
|
||||||
t = threading.Thread(target=_run, daemon=True, name="hindsight-call")
|
def _get_loop() -> asyncio.AbstractEventLoop:
|
||||||
t.start()
|
"""Return a long-lived event loop running on a background thread."""
|
||||||
kind, value = result_q.get(timeout=timeout)
|
global _loop, _loop_thread
|
||||||
if kind == "err":
|
with _loop_lock:
|
||||||
raise value
|
if _loop is not None and _loop.is_running():
|
||||||
return value
|
return _loop
|
||||||
|
_loop = asyncio.new_event_loop()
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
asyncio.set_event_loop(_loop)
|
||||||
|
_loop.run_forever()
|
||||||
|
|
||||||
|
_loop_thread = threading.Thread(target=_run, daemon=True, name="hindsight-loop")
|
||||||
|
_loop_thread.start()
|
||||||
|
return _loop
|
||||||
|
|
||||||
|
|
||||||
|
def _run_sync(coro, timeout: float = 120.0):
|
||||||
|
"""Schedule *coro* on the shared loop and block until done."""
|
||||||
|
loop = _get_loop()
|
||||||
|
future = asyncio.run_coroutine_threadsafe(coro, loop)
|
||||||
|
return future.result(timeout=timeout)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -161,9 +182,13 @@ class HindsightMemoryProvider(MemoryProvider):
|
|||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._config = None
|
self._config = None
|
||||||
self._api_key = None
|
self._api_key = None
|
||||||
|
self._api_url = _DEFAULT_API_URL
|
||||||
self._bank_id = "hermes"
|
self._bank_id = "hermes"
|
||||||
self._budget = "mid"
|
self._budget = "mid"
|
||||||
self._mode = "cloud"
|
self._mode = "cloud"
|
||||||
|
self._memory_mode = "hybrid" # "context", "tools", or "hybrid"
|
||||||
|
self._prefetch_method = "recall" # "recall" or "reflect"
|
||||||
|
self._client = None
|
||||||
self._prefetch_result = ""
|
self._prefetch_result = ""
|
||||||
self._prefetch_lock = threading.Lock()
|
self._prefetch_lock = threading.Lock()
|
||||||
self._prefetch_thread = None
|
self._prefetch_thread = None
|
||||||
@@ -178,10 +203,10 @@ class HindsightMemoryProvider(MemoryProvider):
|
|||||||
cfg = _load_config()
|
cfg = _load_config()
|
||||||
mode = cfg.get("mode", "cloud")
|
mode = cfg.get("mode", "cloud")
|
||||||
if mode == "local":
|
if mode == "local":
|
||||||
embed = cfg.get("embed", {})
|
return True
|
||||||
return bool(embed.get("llmApiKey") or os.environ.get("HINDSIGHT_LLM_API_KEY"))
|
has_key = bool(cfg.get("apiKey") or os.environ.get("HINDSIGHT_API_KEY", ""))
|
||||||
api_key = cfg.get("apiKey") or os.environ.get("HINDSIGHT_API_KEY", "")
|
has_url = bool(cfg.get("api_url") or os.environ.get("HINDSIGHT_API_URL", ""))
|
||||||
return bool(api_key)
|
return has_key or has_url
|
||||||
except Exception:
|
except Exception:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -204,49 +229,148 @@ class HindsightMemoryProvider(MemoryProvider):
|
|||||||
def get_config_schema(self):
|
def get_config_schema(self):
|
||||||
return [
|
return [
|
||||||
{"key": "mode", "description": "Cloud API or local embedded mode", "default": "cloud", "choices": ["cloud", "local"]},
|
{"key": "mode", "description": "Cloud API or local embedded mode", "default": "cloud", "choices": ["cloud", "local"]},
|
||||||
{"key": "api_key", "description": "Hindsight Cloud API key", "secret": True, "env_var": "HINDSIGHT_API_KEY", "url": "https://app.hindsight.vectorize.io"},
|
{"key": "api_url", "description": "Hindsight API URL", "default": _DEFAULT_API_URL, "when": {"mode": "cloud"}},
|
||||||
{"key": "bank_id", "description": "Memory bank identifier", "default": "hermes"},
|
{"key": "api_key", "description": "Hindsight Cloud API key", "secret": True, "env_var": "HINDSIGHT_API_KEY", "url": "https://ui.hindsight.vectorize.io", "when": {"mode": "cloud"}},
|
||||||
|
{"key": "llm_provider", "description": "LLM provider for local mode", "default": "openai", "choices": ["openai", "anthropic", "gemini", "groq", "minimax", "ollama"], "when": {"mode": "local"}},
|
||||||
|
{"key": "llm_api_key", "description": "LLM API key for local Hindsight", "secret": True, "env_var": "HINDSIGHT_LLM_API_KEY", "when": {"mode": "local"}},
|
||||||
|
{"key": "llm_model", "description": "LLM model for local mode", "default": "gpt-4o-mini", "default_from": {"field": "llm_provider", "map": _PROVIDER_DEFAULT_MODELS}, "when": {"mode": "local"}},
|
||||||
|
{"key": "bank_id", "description": "Memory bank name", "default": "hermes"},
|
||||||
{"key": "budget", "description": "Recall thoroughness", "default": "mid", "choices": ["low", "mid", "high"]},
|
{"key": "budget", "description": "Recall thoroughness", "default": "mid", "choices": ["low", "mid", "high"]},
|
||||||
{"key": "llm_provider", "description": "LLM provider for local mode", "default": "anthropic", "choices": ["anthropic", "openai", "groq", "ollama"]},
|
{"key": "memory_mode", "description": "Memory integration mode", "default": "hybrid", "choices": ["hybrid", "context", "tools"]},
|
||||||
{"key": "llm_api_key", "description": "LLM API key for local mode", "secret": True, "env_var": "HINDSIGHT_LLM_API_KEY"},
|
{"key": "prefetch_method", "description": "Auto-recall method", "default": "recall", "choices": ["recall", "reflect"]},
|
||||||
{"key": "llm_model", "description": "LLM model for local mode", "default": "claude-haiku-4-5-20251001"},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
def _make_client(self):
|
def _get_client(self):
|
||||||
"""Create a fresh Hindsight client (thread-safe)."""
|
"""Return the cached Hindsight client (created once, reused)."""
|
||||||
if self._mode == "local":
|
if self._client is None:
|
||||||
from hindsight import HindsightEmbedded
|
if self._mode == "local":
|
||||||
embed = self._config.get("embed", {})
|
from hindsight import HindsightEmbedded
|
||||||
return HindsightEmbedded(
|
# Disable __del__ on the class to prevent "attached to a
|
||||||
profile=embed.get("profile", "hermes"),
|
# different loop" errors during GC — we handle cleanup in
|
||||||
llm_provider=embed.get("llmProvider", ""),
|
# shutdown() instead.
|
||||||
llm_api_key=embed.get("llmApiKey", ""),
|
HindsightEmbedded.__del__ = lambda self: None
|
||||||
llm_model=embed.get("llmModel", ""),
|
self._client = HindsightEmbedded(
|
||||||
)
|
profile=self._config.get("profile", "hermes"),
|
||||||
from hindsight_client import Hindsight
|
llm_provider=self._config.get("llm_provider", ""),
|
||||||
return Hindsight(api_key=self._api_key, timeout=30.0)
|
llm_api_key=self._config.get("llmApiKey") or os.environ.get("HINDSIGHT_LLM_API_KEY", ""),
|
||||||
|
llm_model=self._config.get("llm_model", ""),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
from hindsight_client import Hindsight
|
||||||
|
kwargs = {"base_url": self._api_url, "timeout": 30.0}
|
||||||
|
if self._api_key:
|
||||||
|
kwargs["api_key"] = self._api_key
|
||||||
|
self._client = Hindsight(**kwargs)
|
||||||
|
return self._client
|
||||||
|
|
||||||
def initialize(self, session_id: str, **kwargs) -> None:
|
def initialize(self, session_id: str, **kwargs) -> None:
|
||||||
self._config = _load_config()
|
self._config = _load_config()
|
||||||
self._mode = self._config.get("mode", "cloud")
|
self._mode = self._config.get("mode", "cloud")
|
||||||
self._api_key = self._config.get("apiKey") or os.environ.get("HINDSIGHT_API_KEY", "")
|
self._api_key = self._config.get("apiKey") or os.environ.get("HINDSIGHT_API_KEY", "")
|
||||||
|
default_url = _DEFAULT_LOCAL_URL if self._mode == "local" else _DEFAULT_API_URL
|
||||||
|
self._api_url = self._config.get("api_url") or os.environ.get("HINDSIGHT_API_URL", default_url)
|
||||||
|
|
||||||
banks = self._config.get("banks", {}).get("hermes", {})
|
banks = self._config.get("banks", {}).get("hermes", {})
|
||||||
self._bank_id = banks.get("bankId", "hermes")
|
self._bank_id = self._config.get("bank_id") or banks.get("bankId", "hermes")
|
||||||
budget = banks.get("budget", "mid")
|
budget = self._config.get("budget") or banks.get("budget", "mid")
|
||||||
self._budget = budget if budget in _VALID_BUDGETS else "mid"
|
self._budget = budget if budget in _VALID_BUDGETS else "mid"
|
||||||
|
|
||||||
# Ensure bank exists
|
memory_mode = self._config.get("memory_mode", "hybrid")
|
||||||
try:
|
self._memory_mode = memory_mode if memory_mode in ("context", "tools", "hybrid") else "hybrid"
|
||||||
client = _run_in_thread(self._make_client)
|
|
||||||
_run_in_thread(lambda: client.create_bank(bank_id=self._bank_id, name=self._bank_id))
|
prefetch_method = self._config.get("prefetch_method", "recall")
|
||||||
except Exception:
|
self._prefetch_method = prefetch_method if prefetch_method in ("recall", "reflect") else "recall"
|
||||||
pass # Already exists
|
|
||||||
|
logger.info("Hindsight initialized: mode=%s, api_url=%s, bank=%s, budget=%s, memory_mode=%s, prefetch_method=%s",
|
||||||
|
self._mode, self._api_url, self._bank_id, self._budget, self._memory_mode, self._prefetch_method)
|
||||||
|
|
||||||
|
# For local mode, start the embedded daemon in the background so it
|
||||||
|
# doesn't block the chat. Redirect stdout/stderr to a log file to
|
||||||
|
# prevent rich startup output from spamming the terminal.
|
||||||
|
if self._mode == "local":
|
||||||
|
def _start_daemon():
|
||||||
|
import traceback
|
||||||
|
from pathlib import Path
|
||||||
|
log_dir = Path(os.environ.get("HERMES_HOME", os.path.expanduser("~/.hermes"))) / "logs"
|
||||||
|
log_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
log_path = log_dir / "hindsight-embed.log"
|
||||||
|
try:
|
||||||
|
# Redirect the daemon manager's Rich console to our log file
|
||||||
|
# instead of stderr. This avoids global fd redirects that
|
||||||
|
# would capture output from other threads.
|
||||||
|
import hindsight_embed.daemon_embed_manager as dem
|
||||||
|
from rich.console import Console
|
||||||
|
dem.console = Console(file=open(log_path, "a"), force_terminal=False)
|
||||||
|
|
||||||
|
client = self._get_client()
|
||||||
|
profile = self._config.get("profile", "hermes")
|
||||||
|
|
||||||
|
# Update the profile .env to match our current config so
|
||||||
|
# the daemon always starts with the right settings.
|
||||||
|
# If the config changed and the daemon is running, stop it.
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
profile_env = _Path.home() / ".hindsight" / "profiles" / f"{profile}.env"
|
||||||
|
current_key = self._config.get("llmApiKey") or os.environ.get("HINDSIGHT_LLM_API_KEY", "")
|
||||||
|
current_provider = self._config.get("llm_provider", "")
|
||||||
|
current_model = self._config.get("llm_model", "")
|
||||||
|
|
||||||
|
# Read saved profile config
|
||||||
|
saved = {}
|
||||||
|
if profile_env.exists():
|
||||||
|
for line in profile_env.read_text().splitlines():
|
||||||
|
if "=" in line and not line.startswith("#"):
|
||||||
|
k, v = line.split("=", 1)
|
||||||
|
saved[k.strip()] = v.strip()
|
||||||
|
|
||||||
|
config_changed = (
|
||||||
|
saved.get("HINDSIGHT_API_LLM_PROVIDER") != current_provider or
|
||||||
|
saved.get("HINDSIGHT_API_LLM_MODEL") != current_model or
|
||||||
|
saved.get("HINDSIGHT_API_LLM_API_KEY") != current_key
|
||||||
|
)
|
||||||
|
|
||||||
|
if config_changed:
|
||||||
|
# Write updated profile .env
|
||||||
|
profile_env.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
profile_env.write_text(
|
||||||
|
f"HINDSIGHT_API_LLM_PROVIDER={current_provider}\n"
|
||||||
|
f"HINDSIGHT_API_LLM_API_KEY={current_key}\n"
|
||||||
|
f"HINDSIGHT_API_LLM_MODEL={current_model}\n"
|
||||||
|
f"HINDSIGHT_API_LOG_LEVEL=info\n"
|
||||||
|
)
|
||||||
|
if client._manager.is_running(profile):
|
||||||
|
with open(log_path, "a") as f:
|
||||||
|
f.write("\n=== Config changed, restarting daemon ===\n")
|
||||||
|
client._manager.stop(profile)
|
||||||
|
|
||||||
|
client._ensure_started()
|
||||||
|
with open(log_path, "a") as f:
|
||||||
|
f.write("\n=== Daemon started successfully ===\n")
|
||||||
|
except Exception as e:
|
||||||
|
with open(log_path, "a") as f:
|
||||||
|
f.write(f"\n=== Daemon startup failed: {e} ===\n")
|
||||||
|
traceback.print_exc(file=f)
|
||||||
|
|
||||||
|
t = threading.Thread(target=_start_daemon, daemon=True, name="hindsight-daemon-start")
|
||||||
|
t.start()
|
||||||
|
|
||||||
def system_prompt_block(self) -> str:
|
def system_prompt_block(self) -> str:
|
||||||
|
if self._memory_mode == "context":
|
||||||
|
return (
|
||||||
|
f"# Hindsight Memory\n"
|
||||||
|
f"Active (context mode). Bank: {self._bank_id}, budget: {self._budget}.\n"
|
||||||
|
f"Relevant memories are automatically injected into context."
|
||||||
|
)
|
||||||
|
if self._memory_mode == "tools":
|
||||||
|
return (
|
||||||
|
f"# Hindsight Memory\n"
|
||||||
|
f"Active (tools mode). Bank: {self._bank_id}, budget: {self._budget}.\n"
|
||||||
|
f"Use hindsight_recall to search, hindsight_reflect for synthesis, "
|
||||||
|
f"hindsight_retain to store facts."
|
||||||
|
)
|
||||||
return (
|
return (
|
||||||
f"# Hindsight Memory\n"
|
f"# Hindsight Memory\n"
|
||||||
f"Active. Bank: {self._bank_id}, budget: {self._budget}.\n"
|
f"Active. Bank: {self._bank_id}, budget: {self._budget}.\n"
|
||||||
|
f"Relevant memories are automatically injected into context. "
|
||||||
f"Use hindsight_recall to search, hindsight_reflect for synthesis, "
|
f"Use hindsight_recall to search, hindsight_reflect for synthesis, "
|
||||||
f"hindsight_retain to store facts."
|
f"hindsight_retain to store facts."
|
||||||
)
|
)
|
||||||
@@ -262,12 +386,18 @@ class HindsightMemoryProvider(MemoryProvider):
|
|||||||
return f"## Hindsight Memory\n{result}"
|
return f"## Hindsight Memory\n{result}"
|
||||||
|
|
||||||
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
|
def queue_prefetch(self, query: str, *, session_id: str = "") -> None:
|
||||||
|
if self._memory_mode == "tools":
|
||||||
|
return
|
||||||
def _run():
|
def _run():
|
||||||
try:
|
try:
|
||||||
client = self._make_client()
|
client = self._get_client()
|
||||||
resp = client.recall(bank_id=self._bank_id, query=query, budget=self._budget)
|
if self._prefetch_method == "reflect":
|
||||||
if resp.results:
|
resp = _run_sync(client.areflect(bank_id=self._bank_id, query=query, budget=self._budget))
|
||||||
text = "\n".join(r.text for r in resp.results if r.text)
|
text = resp.text or ""
|
||||||
|
else:
|
||||||
|
resp = _run_sync(client.arecall(bank_id=self._bank_id, query=query, budget=self._budget))
|
||||||
|
text = "\n".join(r.text for r in resp.results if r.text) if resp.results else ""
|
||||||
|
if text:
|
||||||
with self._prefetch_lock:
|
with self._prefetch_lock:
|
||||||
self._prefetch_result = text
|
self._prefetch_result = text
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -282,11 +412,10 @@ class HindsightMemoryProvider(MemoryProvider):
|
|||||||
|
|
||||||
def _sync():
|
def _sync():
|
||||||
try:
|
try:
|
||||||
_run_in_thread(
|
client = self._get_client()
|
||||||
lambda: self._make_client().retain(
|
_run_sync(client.aretain(
|
||||||
bank_id=self._bank_id, content=combined, context="conversation"
|
bank_id=self._bank_id, content=combined, context="conversation"
|
||||||
)
|
))
|
||||||
)
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.warning("Hindsight sync failed: %s", e)
|
logger.warning("Hindsight sync failed: %s", e)
|
||||||
|
|
||||||
@@ -296,22 +425,29 @@ class HindsightMemoryProvider(MemoryProvider):
|
|||||||
self._sync_thread.start()
|
self._sync_thread.start()
|
||||||
|
|
||||||
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
def get_tool_schemas(self) -> List[Dict[str, Any]]:
|
||||||
|
if self._memory_mode == "context":
|
||||||
|
return []
|
||||||
return [RETAIN_SCHEMA, RECALL_SCHEMA, REFLECT_SCHEMA]
|
return [RETAIN_SCHEMA, RECALL_SCHEMA, REFLECT_SCHEMA]
|
||||||
|
|
||||||
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
|
def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str:
|
||||||
|
try:
|
||||||
|
client = self._get_client()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Hindsight client init failed: %s", e)
|
||||||
|
return json.dumps({"error": f"Hindsight client unavailable: {e}"})
|
||||||
|
|
||||||
if tool_name == "hindsight_retain":
|
if tool_name == "hindsight_retain":
|
||||||
content = args.get("content", "")
|
content = args.get("content", "")
|
||||||
if not content:
|
if not content:
|
||||||
return json.dumps({"error": "Missing required parameter: content"})
|
return json.dumps({"error": "Missing required parameter: content"})
|
||||||
context = args.get("context")
|
context = args.get("context")
|
||||||
try:
|
try:
|
||||||
_run_in_thread(
|
_run_sync(client.aretain(
|
||||||
lambda: self._make_client().retain(
|
bank_id=self._bank_id, content=content, context=context
|
||||||
bank_id=self._bank_id, content=content, context=context
|
))
|
||||||
)
|
|
||||||
)
|
|
||||||
return json.dumps({"result": "Memory stored successfully."})
|
return json.dumps({"result": "Memory stored successfully."})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.warning("hindsight_retain failed: %s", e)
|
||||||
return json.dumps({"error": f"Failed to store memory: {e}"})
|
return json.dumps({"error": f"Failed to store memory: {e}"})
|
||||||
|
|
||||||
elif tool_name == "hindsight_recall":
|
elif tool_name == "hindsight_recall":
|
||||||
@@ -319,16 +455,15 @@ class HindsightMemoryProvider(MemoryProvider):
|
|||||||
if not query:
|
if not query:
|
||||||
return json.dumps({"error": "Missing required parameter: query"})
|
return json.dumps({"error": "Missing required parameter: query"})
|
||||||
try:
|
try:
|
||||||
resp = _run_in_thread(
|
resp = _run_sync(client.arecall(
|
||||||
lambda: self._make_client().recall(
|
bank_id=self._bank_id, query=query, budget=self._budget
|
||||||
bank_id=self._bank_id, query=query, budget=self._budget
|
))
|
||||||
)
|
|
||||||
)
|
|
||||||
if not resp.results:
|
if not resp.results:
|
||||||
return json.dumps({"result": "No relevant memories found."})
|
return json.dumps({"result": "No relevant memories found."})
|
||||||
lines = [f"{i}. {r.text}" for i, r in enumerate(resp.results, 1)]
|
lines = [f"{i}. {r.text}" for i, r in enumerate(resp.results, 1)]
|
||||||
return json.dumps({"result": "\n".join(lines)})
|
return json.dumps({"result": "\n".join(lines)})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.warning("hindsight_recall failed: %s", e)
|
||||||
return json.dumps({"error": f"Failed to search memory: {e}"})
|
return json.dumps({"error": f"Failed to search memory: {e}"})
|
||||||
|
|
||||||
elif tool_name == "hindsight_reflect":
|
elif tool_name == "hindsight_reflect":
|
||||||
@@ -336,21 +471,43 @@ class HindsightMemoryProvider(MemoryProvider):
|
|||||||
if not query:
|
if not query:
|
||||||
return json.dumps({"error": "Missing required parameter: query"})
|
return json.dumps({"error": "Missing required parameter: query"})
|
||||||
try:
|
try:
|
||||||
resp = _run_in_thread(
|
resp = _run_sync(client.areflect(
|
||||||
lambda: self._make_client().reflect(
|
bank_id=self._bank_id, query=query, budget=self._budget
|
||||||
bank_id=self._bank_id, query=query, budget=self._budget
|
))
|
||||||
)
|
|
||||||
)
|
|
||||||
return json.dumps({"result": resp.text or "No relevant memories found."})
|
return json.dumps({"result": resp.text or "No relevant memories found."})
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.warning("hindsight_reflect failed: %s", e)
|
||||||
return json.dumps({"error": f"Failed to reflect: {e}"})
|
return json.dumps({"error": f"Failed to reflect: {e}"})
|
||||||
|
|
||||||
return json.dumps({"error": f"Unknown tool: {tool_name}"})
|
return json.dumps({"error": f"Unknown tool: {tool_name}"})
|
||||||
|
|
||||||
def shutdown(self) -> None:
|
def shutdown(self) -> None:
|
||||||
|
global _loop, _loop_thread
|
||||||
for t in (self._prefetch_thread, self._sync_thread):
|
for t in (self._prefetch_thread, self._sync_thread):
|
||||||
if t and t.is_alive():
|
if t and t.is_alive():
|
||||||
t.join(timeout=5.0)
|
t.join(timeout=5.0)
|
||||||
|
if self._client is not None:
|
||||||
|
try:
|
||||||
|
if self._mode == "local":
|
||||||
|
# Use the public close() API. The RuntimeError from
|
||||||
|
# aiohttp's "attached to a different loop" is expected
|
||||||
|
# and harmless — the daemon keeps running independently.
|
||||||
|
try:
|
||||||
|
self._client.close()
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
else:
|
||||||
|
_run_sync(self._client.aclose())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
self._client = None
|
||||||
|
# Stop the background event loop so no tasks are pending at exit
|
||||||
|
if _loop is not None and _loop.is_running():
|
||||||
|
_loop.call_soon_threadsafe(_loop.stop)
|
||||||
|
if _loop_thread is not None:
|
||||||
|
_loop_thread.join(timeout=5.0)
|
||||||
|
_loop = None
|
||||||
|
_loop_thread = None
|
||||||
|
|
||||||
|
|
||||||
def register(ctx) -> None:
|
def register(ctx) -> None:
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ version: 1.0.0
|
|||||||
description: "Hindsight — long-term memory with knowledge graph, entity resolution, and multi-strategy retrieval."
|
description: "Hindsight — long-term memory with knowledge graph, entity resolution, and multi-strategy retrieval."
|
||||||
pip_dependencies:
|
pip_dependencies:
|
||||||
- hindsight-client
|
- hindsight-client
|
||||||
|
- hindsight-all
|
||||||
requires_env:
|
requires_env:
|
||||||
- HINDSIGHT_API_KEY
|
- HINDSIGHT_API_KEY
|
||||||
hooks:
|
hooks:
|
||||||
|
|||||||
@@ -547,3 +547,253 @@ class TestPluginMemoryDiscovery:
|
|||||||
"""load_memory_provider returns None for unknown names."""
|
"""load_memory_provider returns None for unknown names."""
|
||||||
from plugins.memory import load_memory_provider
|
from plugins.memory import load_memory_provider
|
||||||
assert load_memory_provider("nonexistent_provider") is None
|
assert load_memory_provider("nonexistent_provider") is None
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Sequential dispatch routing tests
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSequentialDispatchRouting:
|
||||||
|
"""Verify that memory provider tools are correctly routed through
|
||||||
|
memory_manager.has_tool() and handle_tool_call().
|
||||||
|
|
||||||
|
This is a regression test for a bug where _execute_tool_calls_sequential
|
||||||
|
in run_agent.py had its own inline dispatch chain that skipped
|
||||||
|
memory_manager.has_tool(), causing all memory provider tools to fall
|
||||||
|
through to the registry and return "Unknown tool". The fix added
|
||||||
|
has_tool() + handle_tool_call() to the sequential path.
|
||||||
|
|
||||||
|
These tests verify the memory_manager contract that both dispatch
|
||||||
|
paths rely on: has_tool() returns True for registered provider tools,
|
||||||
|
and handle_tool_call() routes to the correct provider.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_has_tool_returns_true_for_provider_tools(self):
|
||||||
|
"""has_tool returns True for tools registered by memory providers."""
|
||||||
|
mgr = MemoryManager()
|
||||||
|
provider = FakeMemoryProvider("ext", tools=[
|
||||||
|
{"name": "ext_recall", "description": "Ext recall", "parameters": {}},
|
||||||
|
{"name": "ext_retain", "description": "Ext retain", "parameters": {}},
|
||||||
|
])
|
||||||
|
mgr.add_provider(provider)
|
||||||
|
|
||||||
|
assert mgr.has_tool("ext_recall")
|
||||||
|
assert mgr.has_tool("ext_retain")
|
||||||
|
|
||||||
|
def test_has_tool_returns_false_for_builtin_tools(self):
|
||||||
|
"""has_tool returns False for agent-level tools (terminal, memory, etc.)."""
|
||||||
|
mgr = MemoryManager()
|
||||||
|
provider = FakeMemoryProvider("ext", tools=[
|
||||||
|
{"name": "ext_recall", "description": "Ext", "parameters": {}},
|
||||||
|
])
|
||||||
|
mgr.add_provider(provider)
|
||||||
|
|
||||||
|
assert not mgr.has_tool("terminal")
|
||||||
|
assert not mgr.has_tool("memory")
|
||||||
|
assert not mgr.has_tool("todo")
|
||||||
|
assert not mgr.has_tool("session_search")
|
||||||
|
assert not mgr.has_tool("nonexistent")
|
||||||
|
|
||||||
|
def test_handle_tool_call_routes_to_provider(self):
|
||||||
|
"""handle_tool_call dispatches to the correct provider's handler."""
|
||||||
|
mgr = MemoryManager()
|
||||||
|
provider = FakeMemoryProvider("hindsight", tools=[
|
||||||
|
{"name": "hindsight_recall", "description": "Recall", "parameters": {}},
|
||||||
|
{"name": "hindsight_retain", "description": "Retain", "parameters": {}},
|
||||||
|
])
|
||||||
|
mgr.add_provider(provider)
|
||||||
|
|
||||||
|
result = json.loads(mgr.handle_tool_call("hindsight_recall", {"query": "alice"}))
|
||||||
|
assert result["handled"] == "hindsight_recall"
|
||||||
|
assert result["args"] == {"query": "alice"}
|
||||||
|
|
||||||
|
def test_handle_tool_call_unknown_returns_error(self):
|
||||||
|
"""handle_tool_call returns error for tools not in any provider."""
|
||||||
|
mgr = MemoryManager()
|
||||||
|
provider = FakeMemoryProvider("ext", tools=[
|
||||||
|
{"name": "ext_recall", "description": "Ext", "parameters": {}},
|
||||||
|
])
|
||||||
|
mgr.add_provider(provider)
|
||||||
|
|
||||||
|
result = json.loads(mgr.handle_tool_call("terminal", {"command": "ls"}))
|
||||||
|
assert "error" in result
|
||||||
|
|
||||||
|
def test_multiple_providers_route_to_correct_one(self):
|
||||||
|
"""Tools from different providers route to the right handler."""
|
||||||
|
mgr = MemoryManager()
|
||||||
|
builtin = FakeMemoryProvider("builtin", tools=[
|
||||||
|
{"name": "builtin_tool", "description": "Builtin", "parameters": {}},
|
||||||
|
])
|
||||||
|
external = FakeMemoryProvider("hindsight", tools=[
|
||||||
|
{"name": "hindsight_recall", "description": "Recall", "parameters": {}},
|
||||||
|
])
|
||||||
|
mgr.add_provider(builtin)
|
||||||
|
mgr.add_provider(external)
|
||||||
|
|
||||||
|
r1 = json.loads(mgr.handle_tool_call("builtin_tool", {}))
|
||||||
|
assert r1["handled"] == "builtin_tool"
|
||||||
|
|
||||||
|
r2 = json.loads(mgr.handle_tool_call("hindsight_recall", {"query": "test"}))
|
||||||
|
assert r2["handled"] == "hindsight_recall"
|
||||||
|
|
||||||
|
def test_tool_names_include_all_providers(self):
|
||||||
|
"""get_all_tool_names returns tools from all registered providers."""
|
||||||
|
mgr = MemoryManager()
|
||||||
|
builtin = FakeMemoryProvider("builtin", tools=[
|
||||||
|
{"name": "builtin_tool", "description": "B", "parameters": {}},
|
||||||
|
])
|
||||||
|
external = FakeMemoryProvider("ext", tools=[
|
||||||
|
{"name": "ext_recall", "description": "E1", "parameters": {}},
|
||||||
|
{"name": "ext_retain", "description": "E2", "parameters": {}},
|
||||||
|
])
|
||||||
|
mgr.add_provider(builtin)
|
||||||
|
mgr.add_provider(external)
|
||||||
|
|
||||||
|
names = mgr.get_all_tool_names()
|
||||||
|
assert names == {"builtin_tool", "ext_recall", "ext_retain"}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Setup wizard field filtering tests (when clause and default_from)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestSetupFieldFiltering:
|
||||||
|
"""Test the 'when' clause and 'default_from' logic used by the
|
||||||
|
memory setup wizard in hermes_cli/memory_setup.py.
|
||||||
|
|
||||||
|
These features are generic — any memory plugin can use them in
|
||||||
|
get_config_schema(). Currently used by the hindsight plugin.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _filter_fields(self, schema, provider_config):
|
||||||
|
"""Simulate the setup wizard's field filtering logic.
|
||||||
|
|
||||||
|
Returns list of (key, effective_default) for fields that pass
|
||||||
|
the 'when' filter.
|
||||||
|
"""
|
||||||
|
results = []
|
||||||
|
for field in schema:
|
||||||
|
key = field["key"]
|
||||||
|
default = field.get("default")
|
||||||
|
|
||||||
|
# Dynamic default
|
||||||
|
default_from = field.get("default_from")
|
||||||
|
if default_from and isinstance(default_from, dict):
|
||||||
|
ref_field = default_from.get("field", "")
|
||||||
|
ref_map = default_from.get("map", {})
|
||||||
|
ref_value = provider_config.get(ref_field, "")
|
||||||
|
if ref_value and ref_value in ref_map:
|
||||||
|
default = ref_map[ref_value]
|
||||||
|
|
||||||
|
# When clause
|
||||||
|
when = field.get("when")
|
||||||
|
if when and isinstance(when, dict):
|
||||||
|
if not all(provider_config.get(k) == v for k, v in when.items()):
|
||||||
|
continue
|
||||||
|
|
||||||
|
results.append((key, default))
|
||||||
|
return results
|
||||||
|
|
||||||
|
def test_when_clause_filters_fields(self):
|
||||||
|
"""Fields with 'when' are skipped if the condition doesn't match."""
|
||||||
|
schema = [
|
||||||
|
{"key": "mode", "default": "cloud"},
|
||||||
|
{"key": "api_url", "default": "https://api.example.com", "when": {"mode": "cloud"}},
|
||||||
|
{"key": "api_key", "default": None, "when": {"mode": "cloud"}},
|
||||||
|
{"key": "llm_provider", "default": "openai", "when": {"mode": "local"}},
|
||||||
|
{"key": "llm_model", "default": "gpt-4o-mini", "when": {"mode": "local"}},
|
||||||
|
{"key": "budget", "default": "mid"},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Cloud mode: should see mode, api_url, api_key, budget
|
||||||
|
cloud_fields = self._filter_fields(schema, {"mode": "cloud"})
|
||||||
|
cloud_keys = [k for k, _ in cloud_fields]
|
||||||
|
assert cloud_keys == ["mode", "api_url", "api_key", "budget"]
|
||||||
|
|
||||||
|
# Local mode: should see mode, llm_provider, llm_model, budget
|
||||||
|
local_fields = self._filter_fields(schema, {"mode": "local"})
|
||||||
|
local_keys = [k for k, _ in local_fields]
|
||||||
|
assert local_keys == ["mode", "llm_provider", "llm_model", "budget"]
|
||||||
|
|
||||||
|
def test_when_clause_no_condition_always_shown(self):
|
||||||
|
"""Fields without 'when' are always included."""
|
||||||
|
schema = [
|
||||||
|
{"key": "bank_id", "default": "hermes"},
|
||||||
|
{"key": "budget", "default": "mid"},
|
||||||
|
]
|
||||||
|
fields = self._filter_fields(schema, {"mode": "cloud"})
|
||||||
|
assert [k for k, _ in fields] == ["bank_id", "budget"]
|
||||||
|
|
||||||
|
def test_default_from_resolves_dynamic_default(self):
|
||||||
|
"""default_from looks up the default from another field's value."""
|
||||||
|
provider_models = {
|
||||||
|
"openai": "gpt-4o-mini",
|
||||||
|
"groq": "openai/gpt-oss-120b",
|
||||||
|
"anthropic": "claude-haiku-4-5",
|
||||||
|
}
|
||||||
|
schema = [
|
||||||
|
{"key": "llm_provider", "default": "openai"},
|
||||||
|
{"key": "llm_model", "default": "gpt-4o-mini",
|
||||||
|
"default_from": {"field": "llm_provider", "map": provider_models}},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Groq selected: model should default to groq's default
|
||||||
|
fields = self._filter_fields(schema, {"llm_provider": "groq"})
|
||||||
|
model_default = dict(fields)["llm_model"]
|
||||||
|
assert model_default == "openai/gpt-oss-120b"
|
||||||
|
|
||||||
|
# Anthropic selected
|
||||||
|
fields = self._filter_fields(schema, {"llm_provider": "anthropic"})
|
||||||
|
model_default = dict(fields)["llm_model"]
|
||||||
|
assert model_default == "claude-haiku-4-5"
|
||||||
|
|
||||||
|
def test_default_from_falls_back_to_static_default(self):
|
||||||
|
"""default_from falls back to static default if provider not in map."""
|
||||||
|
schema = [
|
||||||
|
{"key": "llm_model", "default": "gpt-4o-mini",
|
||||||
|
"default_from": {"field": "llm_provider", "map": {"groq": "openai/gpt-oss-120b"}}},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Unknown provider: should fall back to static default
|
||||||
|
fields = self._filter_fields(schema, {"llm_provider": "unknown_provider"})
|
||||||
|
model_default = dict(fields)["llm_model"]
|
||||||
|
assert model_default == "gpt-4o-mini"
|
||||||
|
|
||||||
|
def test_default_from_with_no_ref_value(self):
|
||||||
|
"""default_from keeps static default if referenced field is not set."""
|
||||||
|
schema = [
|
||||||
|
{"key": "llm_model", "default": "gpt-4o-mini",
|
||||||
|
"default_from": {"field": "llm_provider", "map": {"groq": "openai/gpt-oss-120b"}}},
|
||||||
|
]
|
||||||
|
|
||||||
|
# No provider set at all
|
||||||
|
fields = self._filter_fields(schema, {})
|
||||||
|
model_default = dict(fields)["llm_model"]
|
||||||
|
assert model_default == "gpt-4o-mini"
|
||||||
|
|
||||||
|
def test_when_and_default_from_combined(self):
|
||||||
|
"""when clause and default_from work together correctly."""
|
||||||
|
provider_models = {"groq": "openai/gpt-oss-120b", "openai": "gpt-4o-mini"}
|
||||||
|
schema = [
|
||||||
|
{"key": "mode", "default": "local"},
|
||||||
|
{"key": "llm_provider", "default": "openai", "when": {"mode": "local"}},
|
||||||
|
{"key": "llm_model", "default": "gpt-4o-mini",
|
||||||
|
"default_from": {"field": "llm_provider", "map": provider_models},
|
||||||
|
"when": {"mode": "local"}},
|
||||||
|
{"key": "api_url", "default": "https://api.example.com", "when": {"mode": "cloud"}},
|
||||||
|
]
|
||||||
|
|
||||||
|
# Local + groq: should see llm_model with groq default, no api_url
|
||||||
|
fields = self._filter_fields(schema, {"mode": "local", "llm_provider": "groq"})
|
||||||
|
keys = [k for k, _ in fields]
|
||||||
|
assert "llm_model" in keys
|
||||||
|
assert "api_url" not in keys
|
||||||
|
assert dict(fields)["llm_model"] == "openai/gpt-oss-120b"
|
||||||
|
|
||||||
|
# Cloud: should see api_url, no llm_model
|
||||||
|
fields = self._filter_fields(schema, {"mode": "cloud"})
|
||||||
|
keys = [k for k, _ in fields]
|
||||||
|
assert "api_url" in keys
|
||||||
|
assert "llm_model" not in keys
|
||||||
|
|||||||
Reference in New Issue
Block a user