Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
82c6db52ad | ||
|
|
475895f0a2 | ||
|
|
efc179f715 | ||
|
|
7b35d2c6f2 | ||
|
|
d4ac97d643 | ||
|
|
a2b018dce7 |
@@ -48,6 +48,12 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
|
||||
# HINDSIGHT_API_REFLECT_LLM_CACHE_AFFINITY=xai_conv_id
|
||||
# HINDSIGHT_API_CONSOLIDATION_LLM_CACHE_AFFINITY=none
|
||||
|
||||
# Ask litellm/litellmrouter/bedrock for structured output via a forced tool call
|
||||
# instead of response_format. Enable it for backends that reject response_format
|
||||
# outright -- e.g. Bedrock Claude in ap-southeast-2 ("Extra inputs are not permitted");
|
||||
# the same model in us-east-1 accepts response_format and needs nothing here.
|
||||
# HINDSIGHT_API_LLM_STRUCTURED_OUTPUT_FORCED_TOOL=false
|
||||
|
||||
# Diagnostic: on any LLM 4xx, log the exact assembled request ([LLM_4XX_DUMP]) --
|
||||
# serialized request config (message bodies stripped) + capped per-message previews.
|
||||
# For debugging otherwise-unreproducible rejected calls. Off by default.
|
||||
|
||||
@@ -43,6 +43,7 @@ jobs:
|
||||
integrations-continue: ${{ steps.filter.outputs.integrations-continue }}
|
||||
integrations-cursor-cli: ${{ steps.filter.outputs.integrations-cursor-cli }}
|
||||
integrations-zcode: ${{ steps.filter.outputs.integrations-zcode }}
|
||||
integrations-agent-plugin: ${{ steps.filter.outputs.integrations-agent-plugin }}
|
||||
integrations-copilot-cli: ${{ steps.filter.outputs.integrations-copilot-cli }}
|
||||
integrations-crewai: ${{ steps.filter.outputs.integrations-crewai }}
|
||||
integrations-litellm: ${{ steps.filter.outputs.integrations-litellm }}
|
||||
@@ -194,6 +195,8 @@ jobs:
|
||||
- 'hindsight-integrations/zed/**'
|
||||
integrations-zcode:
|
||||
- 'hindsight-integrations/zcode/**'
|
||||
integrations-agent-plugin:
|
||||
- 'hindsight-integrations/agent-plugin/**'
|
||||
integrations-n8n:
|
||||
- 'hindsight-integrations/n8n/**'
|
||||
integrations-zapier:
|
||||
@@ -796,6 +799,29 @@ jobs:
|
||||
working-directory: ./hindsight-integrations/zcode
|
||||
run: uv run pytest tests -v
|
||||
|
||||
test-agent-plugin-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
(github.event_name == 'workflow_dispatch' ||
|
||||
needs.detect-changes.outputs.integrations-agent-plugin == 'true' ||
|
||||
needs.detect-changes.outputs.ci == 'true')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.sha || '' }}
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Validate Agent Plugin manifests
|
||||
working-directory: ./hindsight-integrations/agent-plugin
|
||||
run: python3 validate.py
|
||||
|
||||
build-ai-sdk-integration:
|
||||
needs: [detect-changes]
|
||||
if: >-
|
||||
@@ -5217,6 +5243,7 @@ jobs:
|
||||
- test-codex-integration
|
||||
- test-cursor-cli-integration
|
||||
- test-zcode-integration
|
||||
- test-agent-plugin-integration
|
||||
- build-ai-sdk-integration
|
||||
- test-ai-sdk-integration-deno
|
||||
- test-opencode-integration
|
||||
|
||||
@@ -177,6 +177,11 @@ ENV_LLM_STRICT_SCHEMA_RETAIN = "HINDSIGHT_API_LLM_STRICT_SCHEMA_RETAIN"
|
||||
ENV_LLM_STRICT_SCHEMA_REFLECT = "HINDSIGHT_API_LLM_STRICT_SCHEMA_REFLECT"
|
||||
ENV_LLM_STRICT_SCHEMA_CONSOLIDATION = "HINDSIGHT_API_LLM_STRICT_SCHEMA_CONSOLIDATION"
|
||||
ENV_LLM_SUPPORTS_MAX_ITEMS = "HINDSIGHT_API_LLM_SUPPORTS_MAX_ITEMS"
|
||||
# Route structured output through a forced function tool instead of the
|
||||
# OpenAI-style ``response_format`` on the LiteLLM-backed providers (``litellm``,
|
||||
# ``litellmrouter``, ``bedrock``). Off by default; see
|
||||
# DEFAULT_LLM_STRUCTURED_OUTPUT_FORCED_TOOL.
|
||||
ENV_LLM_STRUCTURED_OUTPUT_FORCED_TOOL = "HINDSIGHT_API_LLM_STRUCTURED_OUTPUT_FORCED_TOOL"
|
||||
ENV_LLM_SEND_BANK_AS_USER = "HINDSIGHT_API_LLM_SEND_BANK_AS_USER"
|
||||
ENV_LLM_OLLAMA_NUM_CTX = "HINDSIGHT_API_LLM_OLLAMA_NUM_CTX"
|
||||
|
||||
@@ -911,6 +916,17 @@ DEFAULT_LLAMACPP_EXTRA_ARGS = None # Space-separated extra CLI args for llama.c
|
||||
DEFAULT_LLM_STRICT_SCHEMA = False
|
||||
DEFAULT_LLM_SUPPORTS_MAX_ITEMS = True
|
||||
|
||||
# True = ask LiteLLM-backed providers for structured output via a single forced
|
||||
# function tool (the response schema becomes the tool's parameters) instead of
|
||||
# the OpenAI-style ``response_format``. Needed where the backend rejects the
|
||||
# response_format route outright — notably Bedrock Claude, whose Converse layer
|
||||
# refuses the translated ``outputConfig`` ("Extra inputs are not permitted") while
|
||||
# accepting the identical schema as a tool (issue #3300). Verified region-dependent:
|
||||
# ap-southeast-2 / au.* rejects it, us-east-1 / us.* accepts it, so this is opt-in
|
||||
# rather than keyed off the provider. Default False keeps ``response_format``, which
|
||||
# every other LiteLLM backend handles natively.
|
||||
DEFAULT_LLM_STRUCTURED_OUTPUT_FORCED_TOOL = False
|
||||
|
||||
DEFAULT_LLM_MAX_CONCURRENT = 32
|
||||
DEFAULT_LLM_MAX_RETRIES = 3 # Max retry attempts for LLM API calls
|
||||
DEFAULT_LLM_INITIAL_BACKOFF = 1.0 # Initial backoff in seconds for retry exponential backoff
|
||||
@@ -2117,6 +2133,10 @@ class HindsightConfig:
|
||||
default=DEFAULT_LLM_SUPPORTS_MAX_ITEMS,
|
||||
kw_only=True,
|
||||
) # Whether structured-output schemas accept JSON Schema maxItems
|
||||
llm_structured_output_forced_tool: bool = field(
|
||||
default=DEFAULT_LLM_STRUCTURED_OUTPUT_FORCED_TOOL,
|
||||
kw_only=True,
|
||||
) # LiteLLM-backed providers: structured output via a forced tool call, not response_format
|
||||
# Tags outbound OpenAI-compatible LLM + embedding calls with `user=<bank_id>` for
|
||||
# per-bank cost attribution. Downstream cost gateways (OpenRouter usage accounting,
|
||||
# LiteLLM, Helicone) key attribution on the OpenAI `user` field. Opt-in; never
|
||||
@@ -3055,6 +3075,10 @@ class HindsightConfig:
|
||||
ENV_LLM_SUPPORTS_MAX_ITEMS,
|
||||
DEFAULT_LLM_SUPPORTS_MAX_ITEMS,
|
||||
),
|
||||
llm_structured_output_forced_tool=_parse_boolean_env(
|
||||
ENV_LLM_STRUCTURED_OUTPUT_FORCED_TOOL,
|
||||
DEFAULT_LLM_STRUCTURED_OUTPUT_FORCED_TOOL,
|
||||
),
|
||||
llm_send_bank_as_user=os.getenv(ENV_LLM_SEND_BANK_AS_USER, str(DEFAULT_LLM_SEND_BANK_AS_USER)).lower()
|
||||
in ("true", "1"),
|
||||
llm_ollama_num_ctx=_parse_optional_positive_int(
|
||||
|
||||
@@ -303,7 +303,7 @@ async def _dedup_reconcile_create(
|
||||
live_source_ids = await _filter_live_source_memories(conn, bank_id, create_source_ids)
|
||||
if not live_source_ids:
|
||||
return None
|
||||
if store.writes_memory_rows_in_sql:
|
||||
if store.writes_memory_rows_in_sql_for(bank_id):
|
||||
# Oracle-safe: _native_search_vector_update emits the to_tsvector clause only for a
|
||||
# native PG tsvector column, "" otherwise (see #3021 — the raw ::regconfig cast
|
||||
# breaks Oracle). RETURNING-gate on the twin's probe-time text so a concurrent
|
||||
@@ -385,7 +385,7 @@ async def _dedup_reconcile_update(
|
||||
store = get_memories()
|
||||
async with acquire_with_retry(pool) as conn:
|
||||
async with conn.transaction():
|
||||
if store.writes_memory_rows_in_sql:
|
||||
if store.writes_memory_rows_in_sql_for(bank_id):
|
||||
# Snapshot the updated row's sources with a PLAIN read (no FOR UPDATE). Lock order
|
||||
# must be sources-before-observation: _filter_live_source_memories below takes
|
||||
# FOR SHARE on the SOURCE rows first, then the fold UPDATE locks the observation
|
||||
@@ -583,7 +583,7 @@ async def _filter_live_source_memories(
|
||||
if not source_memory_ids:
|
||||
return []
|
||||
store = get_memories()
|
||||
if store.writes_memory_rows_in_sql:
|
||||
if store.writes_memory_rows_in_sql_for(bank_id):
|
||||
rows = await conn.fetch(
|
||||
f"SELECT id FROM {fq_table('memory_units')} WHERE id = ANY($1::uuid[]) AND bank_id = $2 FOR SHARE",
|
||||
source_memory_ids,
|
||||
@@ -612,7 +612,7 @@ async def _any_live_source_memory(
|
||||
if not source_memory_ids:
|
||||
return False
|
||||
store = get_memories()
|
||||
if store.writes_memory_rows_in_sql:
|
||||
if store.writes_memory_rows_in_sql_for(bank_id):
|
||||
found = await conn.fetchval(
|
||||
f"SELECT 1 FROM {fq_table('memory_units')} WHERE id = ANY($1::uuid[]) AND bank_id = $2 LIMIT 1",
|
||||
source_memory_ids,
|
||||
@@ -732,7 +732,7 @@ async def _count_observations_for_scope(
|
||||
Observations with no tags are not counted (the limit does not apply to them).
|
||||
"""
|
||||
store = get_memories()
|
||||
if store.writes_memory_rows_in_sql:
|
||||
if store.writes_memory_rows_in_sql_for(bank_id):
|
||||
return await conn.fetchval(
|
||||
f"SELECT COUNT(*) FROM {fq_table('memory_units')} "
|
||||
f"WHERE bank_id = $1 AND fact_type = 'observation' AND tags @> $2::varchar[]",
|
||||
@@ -2245,7 +2245,7 @@ async def _execute_update_action(
|
||||
merged_tags = list(existing_tags | source_tags)
|
||||
|
||||
t0 = time.time()
|
||||
if store.writes_memory_rows_in_sql:
|
||||
if store.writes_memory_rows_in_sql_for(bank_id):
|
||||
updated_rows = await conn.execute_rows_affected(
|
||||
f"""
|
||||
UPDATE {fq_table("memory_units")}
|
||||
@@ -2396,7 +2396,7 @@ async def _execute_delete_action(
|
||||
) -> None:
|
||||
"""Delete a superseded or contradicted observation."""
|
||||
store = get_memories()
|
||||
if store.writes_memory_rows_in_sql:
|
||||
if store.writes_memory_rows_in_sql_for(bank_id):
|
||||
await conn.execute(
|
||||
f"DELETE FROM {fq_table('memory_units')} WHERE id = $1 AND bank_id = $2 AND fact_type = 'observation'",
|
||||
uuid.UUID(observation_id),
|
||||
@@ -2782,7 +2782,7 @@ async def _create_observation_directly(
|
||||
source_memory_ids = live_source_memory_ids
|
||||
|
||||
t0 = time.time()
|
||||
if store.writes_memory_rows_in_sql:
|
||||
if store.writes_memory_rows_in_sql_for(bank_id):
|
||||
# Query varies based on text search backend.
|
||||
from ..schema import _is_oracle # noqa: PLC0415
|
||||
|
||||
|
||||
@@ -313,6 +313,7 @@ def create_llm_provider(
|
||||
timeout: float | None = None,
|
||||
ollama_num_ctx: int | None = None,
|
||||
cache_affinity: str | None = None,
|
||||
structured_output_forced_tool: bool = False,
|
||||
) -> Any: # Returns LLMInterface
|
||||
"""
|
||||
Factory function to create the appropriate LLM provider implementation.
|
||||
@@ -345,6 +346,11 @@ def create_llm_provider(
|
||||
OpenAI-compatible wire format): "none" (default), "xai_conv_id",
|
||||
"openai_prompt_cache_key", or "auto". Providers on other branches do their own
|
||||
cache work or none at all. See ``engine/cache_affinity.py``.
|
||||
structured_output_forced_tool: Ask the LiteLLM-backed providers (``litellm``,
|
||||
``litellmrouter``, ``bedrock``) for structured output via a forced tool call
|
||||
instead of ``response_format``. For backends that reject the response_format
|
||||
route — see ``HINDSIGHT_API_LLM_STRUCTURED_OUTPUT_FORCED_TOOL``. Other
|
||||
providers ignore it.
|
||||
vertexai_project_id: Vertex AI project ID (for VertexAI provider).
|
||||
vertexai_region: Vertex AI region (for VertexAI provider).
|
||||
vertexai_credentials: Vertex AI credentials object (for VertexAI provider).
|
||||
@@ -457,6 +463,7 @@ def create_llm_provider(
|
||||
extra_body=extra_body,
|
||||
default_headers=default_headers,
|
||||
timeout=timeout,
|
||||
structured_output_forced_tool=structured_output_forced_tool,
|
||||
)
|
||||
|
||||
elif provider_lower == "litellmrouter":
|
||||
@@ -477,6 +484,7 @@ def create_llm_provider(
|
||||
extra_body=extra_body,
|
||||
default_headers=default_headers,
|
||||
timeout=timeout,
|
||||
structured_output_forced_tool=structured_output_forced_tool,
|
||||
)
|
||||
|
||||
elif provider_lower == "bedrock":
|
||||
@@ -492,6 +500,7 @@ def create_llm_provider(
|
||||
default_headers=default_headers,
|
||||
bedrock_service_tier=bedrock_service_tier,
|
||||
timeout=timeout,
|
||||
structured_output_forced_tool=structured_output_forced_tool,
|
||||
)
|
||||
|
||||
elif provider_lower == "llamacpp":
|
||||
@@ -646,6 +655,7 @@ class LLMProvider:
|
||||
max_backoff: float | None = None,
|
||||
ollama_num_ctx: int | None = None,
|
||||
cache_affinity: str | None = None,
|
||||
structured_output_forced_tool: bool = False,
|
||||
):
|
||||
"""
|
||||
Initialize LLM provider.
|
||||
@@ -692,6 +702,9 @@ class LLMProvider:
|
||||
``max_retries``. ``None`` keeps each method's own fallback.
|
||||
max_backoff: Default maximum retry backoff (seconds), same resolution as
|
||||
``max_retries``. ``None`` keeps each method's own fallback.
|
||||
structured_output_forced_tool: Structured output via a forced tool call
|
||||
instead of ``response_format``, for the LiteLLM-backed providers - from
|
||||
config (``HINDSIGHT_API_LLM_STRUCTURED_OUTPUT_FORCED_TOOL``).
|
||||
|
||||
This constructor uses every argument as passed and does not read global
|
||||
``HindsightConfig``: resolving the server-level default for a ``None`` argument is the
|
||||
@@ -720,6 +733,9 @@ class LLMProvider:
|
||||
self.openai_service_tier = openai_service_tier
|
||||
self.bedrock_service_tier = bedrock_service_tier
|
||||
self.gemini_service_tier = gemini_service_tier
|
||||
# Structured-output transport for the LiteLLM-backed providers. Used verbatim —
|
||||
# the caller resolves the server-level default, like the fields above.
|
||||
self.structured_output_forced_tool = structured_output_forced_tool
|
||||
self.ollama_num_ctx = _validate_ollama_num_ctx(ollama_num_ctx)
|
||||
# Gemini safety settings (instance default; can be overridden per-request via context var)
|
||||
self.gemini_safety_settings = gemini_safety_settings
|
||||
@@ -875,6 +891,7 @@ class LLMProvider:
|
||||
ollama_num_ctx=self.ollama_num_ctx,
|
||||
timeout=self.timeout,
|
||||
cache_affinity=self.cache_affinity,
|
||||
structured_output_forced_tool=self.structured_output_forced_tool,
|
||||
)
|
||||
|
||||
# Backward compatibility: Keep mock provider properties
|
||||
@@ -1420,6 +1437,7 @@ class LLMProvider:
|
||||
DEFAULT_LLM_PROMPT_CACHE_ENABLED,
|
||||
DEFAULT_LLM_PROVIDER,
|
||||
DEFAULT_LLM_REASONING_EFFORT,
|
||||
DEFAULT_LLM_STRUCTURED_OUTPUT_FORCED_TOOL,
|
||||
DEFAULT_LLM_TIMEOUT,
|
||||
ENV_LLM_API_KEY,
|
||||
ENV_LLM_BASE_URL,
|
||||
@@ -1437,11 +1455,13 @@ class LLMProvider:
|
||||
ENV_LLM_PROMPT_CACHE_ENABLED,
|
||||
ENV_LLM_PROVIDER,
|
||||
ENV_LLM_REASONING_EFFORT,
|
||||
ENV_LLM_STRUCTURED_OUTPUT_FORCED_TOOL,
|
||||
ENV_LLM_TIMEOUT,
|
||||
ENV_LLM_VERTEXAI_PROJECT_ID,
|
||||
ENV_LLM_VERTEXAI_REGION,
|
||||
ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY,
|
||||
_get_default_model_for_provider,
|
||||
_parse_boolean_env,
|
||||
_parse_llm_router_config,
|
||||
_parse_optional_positive_int,
|
||||
parse_gemini_service_tier,
|
||||
@@ -1498,6 +1518,10 @@ class LLMProvider:
|
||||
vertexai_region=os.getenv(ENV_LLM_VERTEXAI_REGION) or None,
|
||||
vertexai_service_account_key=os.getenv(ENV_LLM_VERTEXAI_SERVICE_ACCOUNT_KEY) or None,
|
||||
timeout=float(os.getenv(ENV_LLM_TIMEOUT, str(DEFAULT_LLM_TIMEOUT))),
|
||||
structured_output_forced_tool=_parse_boolean_env(
|
||||
ENV_LLM_STRUCTURED_OUTPUT_FORCED_TOOL,
|
||||
DEFAULT_LLM_STRUCTURED_OUTPUT_FORCED_TOOL,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -138,7 +138,12 @@ class MaintenanceLoop:
|
||||
@staticmethod
|
||||
def _cross_store_recovery_enabled() -> bool:
|
||||
"""True when the memories store keeps memories outside SQL and therefore has
|
||||
cross-store write-group txns a crashed writer could leave undecided."""
|
||||
cross-store write-group txns a crashed writer could leave undecided.
|
||||
|
||||
Deliberately reads the PROCESS-LEVEL class attribute, not the per-bank
|
||||
``writes_memory_rows_in_sql_for(bank_id)`` — this only decides whether the recovery LOOP
|
||||
needs to run at all. A store that routes some banks outside SQL keeps the class attribute
|
||||
False so the loop runs, then ``recover_pending_txns`` is bank-scoped inside it."""
|
||||
try:
|
||||
from .memories import get_memories
|
||||
|
||||
|
||||
@@ -394,6 +394,20 @@ class MemoriesExtension(Extension, ABC):
|
||||
#: the inline SQL. Cold, never-searched, key-based — see docs/documents-chunks.md.
|
||||
owns_document_store: bool = False
|
||||
|
||||
def writes_memory_rows_in_sql_for(self, bank_id: str) -> bool:
|
||||
"""Per-bank form of :attr:`writes_memory_rows_in_sql`. Defaults to the class attribute, so a
|
||||
single-store extension needs no override. A store that keeps different banks in different
|
||||
backends (some in SQL, some not) overrides this to answer PER BANK; every *bank-scoped* call
|
||||
site consults this instead of the class attribute, so mixed banks each take the correct path.
|
||||
(The few process-level gates — e.g. "is cross-store txn recovery relevant at all" — keep
|
||||
reading the class attribute.)"""
|
||||
return self.writes_memory_rows_in_sql
|
||||
|
||||
def owns_document_store_for(self, bank_id: str) -> bool:
|
||||
"""Per-bank form of :attr:`owns_document_store`. Defaults to the class attribute; a store
|
||||
that keeps some banks in a separate backend overrides it. See :meth:`writes_memory_rows_in_sql_for`."""
|
||||
return self.owns_document_store
|
||||
|
||||
# ------------------------------------------------------------------ lifecycle
|
||||
|
||||
async def initialize(self) -> None:
|
||||
|
||||
@@ -523,6 +523,7 @@ def _member_to_llm(member: "LLMMemberConfig", config: HindsightConfig, defaults:
|
||||
cache_affinity=member.cache_affinity or config.llm_cache_affinity,
|
||||
ollama_num_ctx=config.llm_ollama_num_ctx,
|
||||
bedrock_service_tier=member.bedrock_service_tier,
|
||||
structured_output_forced_tool=config.llm_structured_output_forced_tool,
|
||||
gemini_service_tier=member.gemini_service_tier or config.llm_gemini_service_tier,
|
||||
gemini_safety_settings=_get_raw_config().llm_gemini_safety_settings,
|
||||
prompt_cache_enabled=config.llm_prompt_cache_enabled,
|
||||
@@ -616,6 +617,43 @@ class _SubBatchSplit:
|
||||
chunk_counts: list[int] = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RetainGroup:
|
||||
"""One document's slice of a retain batch.
|
||||
|
||||
``retain_batch_async`` folds items that share an explicit ``document_id``
|
||||
into a single document. Grouping is done up front (not left to the
|
||||
orchestrator) so the token splitter and its per-document ``chunk_index``
|
||||
bookkeeping only ever see one document at a time — they assume a split never
|
||||
interleaves two documents. ``origins`` records the indices the items
|
||||
occupied in the submitted batch so per-input results merge back in order.
|
||||
``document_id`` is ``None`` for an item that carried no explicit id (each
|
||||
such item is its own group and its own document).
|
||||
"""
|
||||
|
||||
document_id: str | None
|
||||
origins: list[int]
|
||||
contents: list[RetainContentDict]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _RetainExecutionResult:
|
||||
"""Outcome of running one batch through the retain token splitter and its
|
||||
sequential sub-batch loop (``MemoryEngine._run_retain_execution``).
|
||||
|
||||
``unit_ids`` is the per-input-content list of created unit ids.
|
||||
``processed_content_tokens`` follows ``RetainResult.processed_content_tokens``
|
||||
(``None`` when a sub-batch bypassed dedup). ``cancelled`` is True when the
|
||||
operation's bank was deleted mid-flight and the loop stopped early, so the
|
||||
caller skips the completion side effects.
|
||||
"""
|
||||
|
||||
unit_ids: list[list[str]]
|
||||
usage: "TokenUsage"
|
||||
processed_content_tokens: int | None
|
||||
cancelled: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class _RetainChunkingConfig:
|
||||
chunk_size: int
|
||||
@@ -1581,6 +1619,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
ollama_num_ctx=config.llm_ollama_num_ctx,
|
||||
litellmrouter_config=config.llm_litellmrouter_config,
|
||||
bedrock_service_tier=config.llm_bedrock_service_tier,
|
||||
structured_output_forced_tool=config.llm_structured_output_forced_tool,
|
||||
gemini_service_tier=config.llm_gemini_service_tier,
|
||||
groq_service_tier=config.llm_groq_service_tier,
|
||||
openai_service_tier=config.llm_openai_service_tier,
|
||||
@@ -1627,6 +1666,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
ollama_num_ctx=config.llm_ollama_num_ctx,
|
||||
litellmrouter_config=config.retain_llm_litellmrouter_config or config.llm_litellmrouter_config,
|
||||
bedrock_service_tier=config.llm_bedrock_service_tier,
|
||||
structured_output_forced_tool=config.llm_structured_output_forced_tool,
|
||||
gemini_service_tier=config.llm_gemini_service_tier,
|
||||
groq_service_tier=config.llm_groq_service_tier,
|
||||
openai_service_tier=config.llm_openai_service_tier,
|
||||
@@ -1667,6 +1707,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
ollama_num_ctx=config.llm_ollama_num_ctx,
|
||||
litellmrouter_config=config.reflect_llm_litellmrouter_config or config.llm_litellmrouter_config,
|
||||
bedrock_service_tier=config.llm_bedrock_service_tier,
|
||||
structured_output_forced_tool=config.llm_structured_output_forced_tool,
|
||||
gemini_service_tier=config.llm_gemini_service_tier,
|
||||
groq_service_tier=config.llm_groq_service_tier,
|
||||
openai_service_tier=config.llm_openai_service_tier,
|
||||
@@ -1707,6 +1748,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
ollama_num_ctx=config.llm_ollama_num_ctx,
|
||||
litellmrouter_config=config.consolidation_llm_litellmrouter_config or config.llm_litellmrouter_config,
|
||||
bedrock_service_tier=config.llm_bedrock_service_tier,
|
||||
structured_output_forced_tool=config.llm_structured_output_forced_tool,
|
||||
gemini_service_tier=config.llm_gemini_service_tier,
|
||||
groq_service_tier=config.llm_groq_service_tier,
|
||||
openai_service_tier=config.llm_openai_service_tier,
|
||||
@@ -4260,20 +4302,11 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
if "document_id" not in item:
|
||||
item["document_id"] = document_id
|
||||
|
||||
if outbox_callback is None and outbox_callback_factory is not None:
|
||||
outbox_callback = outbox_callback_factory(contents)
|
||||
|
||||
# Validate no duplicate document_ids in the batch
|
||||
# Having duplicate document_ids causes race conditions in document upserts during parallel processing
|
||||
doc_ids = [item.get("document_id") for item in contents if item.get("document_id")]
|
||||
if len(doc_ids) != len(set(doc_ids)):
|
||||
from collections import Counter
|
||||
|
||||
duplicates = [doc_id for doc_id, count in Counter(doc_ids).items() if count > 1]
|
||||
raise ValueError(
|
||||
f"Batch contains duplicate document_ids: {duplicates}. "
|
||||
f"Each content item in a batch must have a unique document_id to avoid race conditions."
|
||||
)
|
||||
# NOTE: items sharing a document_id are ALLOWED here and folded into one
|
||||
# document (see the grouping dispatch below). The synchronous in-process
|
||||
# path processes sub-batches sequentially, so same-document items cannot
|
||||
# race each other — unlike the queued path, which still rejects
|
||||
# duplicates (see submit_async_retain).
|
||||
|
||||
# Validate update_mode=append requires document_id
|
||||
for item in contents:
|
||||
@@ -4294,6 +4327,236 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
"document text is not stored and cannot be appended to. Use update_mode='replace' instead."
|
||||
)
|
||||
|
||||
# Fold items that share an explicit document_id into one document. On the
|
||||
# synchronous in-process path this is safe — sub-batches run sequentially,
|
||||
# so same-document items cannot race (unlike the queued path, which still
|
||||
# rejects duplicates; see submit_async_retain). Each shared-document group
|
||||
# is processed in ONE orchestrator pass rather than being token-split:
|
||||
# splitting one document across sub-batches that carry different bodies
|
||||
# trips the streaming pipeline's content-hash ownership check and silently
|
||||
# drops the later sub-batches (that path is safe only for an oversized
|
||||
# SINGLE item, whose slices all replay the same full body). The
|
||||
# orchestrator streams a large document chunk-batch by chunk-batch on its
|
||||
# own, so a single pass stays memory-bounded.
|
||||
explicit_doc_ids = [item.get("document_id") for item in contents if item.get("document_id")]
|
||||
has_shared_document = len(explicit_doc_ids) != len(set(explicit_doc_ids))
|
||||
|
||||
if not has_shared_document:
|
||||
# No document is shared, so distinct-document items may be packed and
|
||||
# token-split across sub-batches as before (the orchestrator keeps
|
||||
# genuinely distinct per-item document_ids separate within a pass).
|
||||
execution = await self._run_retain_execution(
|
||||
bank_id=bank_id,
|
||||
contents=contents,
|
||||
request_context=request_context,
|
||||
document_id=document_id,
|
||||
fact_type_override=fact_type_override,
|
||||
document_tags=document_tags,
|
||||
operation_id=operation_id,
|
||||
strategy=strategy,
|
||||
outbox_callback=outbox_callback,
|
||||
outbox_callback_factory=outbox_callback_factory,
|
||||
start_time=start_time,
|
||||
)
|
||||
result = execution.unit_ids
|
||||
total_usage = execution.usage
|
||||
total_processed_content_tokens = execution.processed_content_tokens
|
||||
cancelled = execution.cancelled
|
||||
else:
|
||||
# Group in first-appearance order: each shared document_id becomes one
|
||||
# group, and each item without an explicit document_id becomes its own
|
||||
# group (its own document).
|
||||
groups: list[_RetainGroup] = []
|
||||
groups_by_doc_id: dict[str, _RetainGroup] = {}
|
||||
for idx, item in enumerate(contents):
|
||||
item_doc_id = item.get("document_id")
|
||||
existing = groups_by_doc_id.get(item_doc_id) if item_doc_id is not None else None
|
||||
if existing is not None:
|
||||
existing.origins.append(idx)
|
||||
existing.contents.append(item)
|
||||
continue
|
||||
group = _RetainGroup(document_id=item_doc_id, origins=[idx], contents=[item])
|
||||
groups.append(group)
|
||||
if item_doc_id is not None:
|
||||
groups_by_doc_id[item_doc_id] = group
|
||||
|
||||
result = [[] for _ in contents]
|
||||
total_usage = TokenUsage()
|
||||
total_processed_content_tokens = 0
|
||||
cancelled = False
|
||||
for group_idx, group in enumerate(groups):
|
||||
# Checkpoint: abort if the operation was deleted (bank deleted)
|
||||
# between documents, mirroring the sub-batch loop's checkpoint.
|
||||
if operation_id and not await self._check_op_alive(operation_id):
|
||||
logger.info(
|
||||
f"[BATCH_RETAIN] bank={bank_id} operation {operation_id} cancelled (bank deleted), "
|
||||
f"stopping after {group_idx}/{len(groups)} documents"
|
||||
)
|
||||
cancelled = True
|
||||
break
|
||||
|
||||
set_stage(f"batch_retain.document.{group_idx + 1}")
|
||||
|
||||
# Per-document webhook rows come from the factory, rebuilt for each
|
||||
# group's contents. A raw pre-built callback (no factory) covers the
|
||||
# whole operation, so fire it once, on the last group.
|
||||
is_last_group = group_idx == len(groups) - 1
|
||||
if outbox_callback_factory is not None:
|
||||
group_outbox_callback = outbox_callback_factory(group.contents)
|
||||
else:
|
||||
group_outbox_callback = outbox_callback if is_last_group else None
|
||||
|
||||
group_result, group_usage, group_processed = await self._retain_batch_async_internal(
|
||||
bank_id=bank_id,
|
||||
contents=group.contents,
|
||||
request_context=request_context,
|
||||
document_id=group.document_id,
|
||||
is_first_batch=True,
|
||||
fact_type_override=fact_type_override,
|
||||
document_tags=document_tags,
|
||||
operation_id=operation_id,
|
||||
strategy=strategy,
|
||||
outbox_callback=group_outbox_callback,
|
||||
)
|
||||
for local_idx, origin_idx in enumerate(group.origins):
|
||||
if local_idx < len(group_result):
|
||||
result[origin_idx] = group_result[local_idx]
|
||||
total_usage = total_usage + group_usage
|
||||
if total_processed_content_tokens is None or group_processed is None:
|
||||
total_processed_content_tokens = None
|
||||
else:
|
||||
total_processed_content_tokens = total_processed_content_tokens + group_processed
|
||||
|
||||
# A cancelled run (bank deleted mid-flight) skips the completion side
|
||||
# effects, mirroring the pre-grouping early return from the sub-batch loop.
|
||||
if cancelled:
|
||||
if return_usage:
|
||||
return result, total_usage
|
||||
return result
|
||||
|
||||
await self._write_retain_outcome_metadata(operation_id, result)
|
||||
|
||||
# Call post-operation hook if validator is configured
|
||||
if self._operation_validator:
|
||||
from hindsight_api.extensions import RetainResult
|
||||
|
||||
result_ctx = RetainResult(
|
||||
bank_id=bank_id,
|
||||
contents=contents_copy,
|
||||
request_context=request_context,
|
||||
document_id=document_id,
|
||||
fact_type_override=fact_type_override,
|
||||
unit_ids=result,
|
||||
success=True,
|
||||
error=None,
|
||||
llm_input_tokens=total_usage.input_tokens,
|
||||
llm_output_tokens=total_usage.output_tokens,
|
||||
llm_total_tokens=total_usage.total_tokens,
|
||||
llm_cached_input_tokens=getattr(total_usage, "cached_tokens", 0) or 0,
|
||||
llm_thoughts_tokens=getattr(total_usage, "thoughts_tokens", 0) or 0,
|
||||
processed_content_tokens=total_processed_content_tokens,
|
||||
)
|
||||
try:
|
||||
await self._operation_validator.on_retain_complete(result_ctx)
|
||||
except Exception as e:
|
||||
logger.warning(f"Post-retain hook error (non-fatal): {e}")
|
||||
|
||||
# Same async side effects every fact insert triggers (retain or import).
|
||||
await self._submit_post_insert_maintenance(bank_id, request_context)
|
||||
|
||||
if return_usage:
|
||||
return result, total_usage
|
||||
return result
|
||||
|
||||
async def _submit_post_insert_maintenance(
|
||||
self,
|
||||
bank_id: str,
|
||||
request_context: "RequestContext",
|
||||
config: HindsightConfig | None = None,
|
||||
) -> None:
|
||||
"""Submit the async side effects that follow any fact insert (retain or import).
|
||||
|
||||
Shared by the retain pipeline and the document-import pipeline so imported
|
||||
documents aren't second-class citizens:
|
||||
* auto-consolidation (when observations + auto-consolidation are enabled
|
||||
for the bank) so freshly inserted facts get observations;
|
||||
* graph maintenance, which short-circuits when no cleanup work was
|
||||
enqueued, so a plain insert pays a single cheap indexed SELECT here.
|
||||
|
||||
Both are non-critical: failures are logged, never raised, so they can't
|
||||
fail the operation that produced the facts. Pass ``config`` when the caller
|
||||
already resolved it to avoid a redundant lookup.
|
||||
"""
|
||||
if config is None:
|
||||
config = await self._config_resolver.resolve_full_config(bank_id, request_context)
|
||||
if config.enable_observations and config.enable_auto_consolidation:
|
||||
try:
|
||||
await self.submit_async_consolidation(bank_id=bank_id, request_context=request_context)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to submit consolidation task for bank {bank_id}: {e}")
|
||||
try:
|
||||
await self.submit_async_graph_maintenance(bank_id=bank_id, request_context=request_context)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to submit graph maintenance task for bank {bank_id}: {e}")
|
||||
|
||||
async def _resolve_retain_config(
|
||||
self,
|
||||
bank_id: str,
|
||||
request_context: "RequestContext",
|
||||
strategy: str | None,
|
||||
) -> HindsightConfig:
|
||||
"""Resolve the config a retain runs under, strategy overrides applied.
|
||||
|
||||
Mirrors what ``_retain_batch_async_internal`` resolves before handing
|
||||
config to the orchestrator, so anything the splitting caller derives
|
||||
from it (chunk boundaries, Memory Defense screening) matches what the
|
||||
orchestrator then does with each sub-batch.
|
||||
"""
|
||||
from hindsight_api.config_resolver import apply_strategy
|
||||
|
||||
resolved_config = await self._config_resolver.resolve_full_config(bank_id, request_context)
|
||||
effective_strategy = strategy or resolved_config.retain_default_strategy
|
||||
if effective_strategy:
|
||||
resolved_config = apply_strategy(resolved_config, effective_strategy)
|
||||
return resolved_config
|
||||
|
||||
@staticmethod
|
||||
def _retain_chunking_config(config: HindsightConfig) -> _RetainChunkingConfig:
|
||||
"""The chunk boundaries ``config`` implies, as the retain pipeline uses them."""
|
||||
return _RetainChunkingConfig(
|
||||
chunk_size=getattr(config, "retain_chunk_size", DEFAULT_RETAIN_CHUNK_SIZE),
|
||||
structured_chunk_size=getattr(config, "retain_structured_chunk_size", None),
|
||||
)
|
||||
|
||||
async def _run_retain_execution(
|
||||
self,
|
||||
*,
|
||||
bank_id: str,
|
||||
contents: list[RetainContentDict],
|
||||
request_context: "RequestContext",
|
||||
document_id: str | None,
|
||||
fact_type_override: str | None,
|
||||
document_tags: list[str] | None,
|
||||
operation_id: str | None,
|
||||
strategy: str | None,
|
||||
outbox_callback: RetainOutboxCallback | None,
|
||||
outbox_callback_factory: RetainOutboxCallbackFactory | None,
|
||||
start_time: float,
|
||||
) -> _RetainExecutionResult:
|
||||
"""Run a batch with no shared document_id through the token splitter and
|
||||
the sequential sub-batch loop (or a single pass for a small batch).
|
||||
|
||||
The orchestrator still separates genuinely distinct per-item document_ids
|
||||
within one pass, and the only document that spans sub-batches here is an
|
||||
oversized SINGLE item, whose slices all replay the same body — so the
|
||||
per-document chunk_index offset stays valid. Shared-document batches are
|
||||
handled by ``retain_batch_async`` itself (one pass per document), never
|
||||
here, because splitting one document across differently-bodied sub-batches
|
||||
trips the streaming pipeline's content-hash ownership check.
|
||||
"""
|
||||
if outbox_callback is None and outbox_callback_factory is not None:
|
||||
outbox_callback = outbox_callback_factory(contents)
|
||||
|
||||
# Auto-chunk large batches by token count to avoid timeouts and memory issues
|
||||
# Calculate total token count
|
||||
total_tokens = sum(count_tokens(item.get("content", "")) for item in contents)
|
||||
@@ -4303,6 +4566,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# means that sub-batch bypassed dedup, so the aggregate is None
|
||||
# (see RetainResult.processed_content_tokens).
|
||||
total_processed_content_tokens: int | None = 0
|
||||
cancelled = False
|
||||
|
||||
# Get batch size threshold from config
|
||||
config = get_config()
|
||||
@@ -4398,9 +4662,8 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
logger.info(
|
||||
f"[BATCH_RETAIN] bank={bank_id} operation {operation_id} cancelled (bank deleted), stopping after {i - 1}/{len(sub_batches)} sub-batches"
|
||||
)
|
||||
if return_usage:
|
||||
return per_input_results, total_usage
|
||||
return per_input_results
|
||||
cancelled = True
|
||||
break
|
||||
|
||||
sub_batch_tokens = sum(count_tokens(item.get("content", "")) for item in sub_batch)
|
||||
logger.info(
|
||||
@@ -4412,10 +4675,11 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
set_stage(f"batch_retain.sub_batch.{i}")
|
||||
|
||||
# Resolve the document this sub-batch writes to so we can offset
|
||||
# its chunk_index past chunks already stored by earlier
|
||||
# sub-batches of the same document. Only the oversized-single-item
|
||||
# split shares a document_id across sub-batches; packed multi-item
|
||||
# sub-batches carry distinct document_ids (offset stays 0).
|
||||
# its chunk_index past chunks already stored by earlier sub-batches
|
||||
# of the same document. A grouped call passes ``document_id``, so
|
||||
# every sub-batch shares it; otherwise only the oversized-single-
|
||||
# item split shares a document_id across sub-batches (packed
|
||||
# multi-item sub-batches carry distinct document_ids, offset 0).
|
||||
sub_doc_id = document_id or (sub_batch[0].get("document_id") if len(sub_batch) == 1 else None)
|
||||
sub_offset = chunk_offsets.get(sub_doc_id, 0) if sub_doc_id else 0
|
||||
|
||||
@@ -4495,98 +4759,11 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# Progress for this path is emitted by the streaming pipeline as
|
||||
# "storing N/total chunks" via progress_callback (see _retain_batch_async_internal).
|
||||
|
||||
await self._write_retain_outcome_metadata(operation_id, result)
|
||||
|
||||
# Call post-operation hook if validator is configured
|
||||
if self._operation_validator:
|
||||
from hindsight_api.extensions import RetainResult
|
||||
|
||||
result_ctx = RetainResult(
|
||||
bank_id=bank_id,
|
||||
contents=contents_copy,
|
||||
request_context=request_context,
|
||||
document_id=document_id,
|
||||
fact_type_override=fact_type_override,
|
||||
unit_ids=result,
|
||||
success=True,
|
||||
error=None,
|
||||
llm_input_tokens=total_usage.input_tokens,
|
||||
llm_output_tokens=total_usage.output_tokens,
|
||||
llm_total_tokens=total_usage.total_tokens,
|
||||
llm_cached_input_tokens=getattr(total_usage, "cached_tokens", 0) or 0,
|
||||
llm_thoughts_tokens=getattr(total_usage, "thoughts_tokens", 0) or 0,
|
||||
processed_content_tokens=total_processed_content_tokens,
|
||||
)
|
||||
try:
|
||||
await self._operation_validator.on_retain_complete(result_ctx)
|
||||
except Exception as e:
|
||||
logger.warning(f"Post-retain hook error (non-fatal): {e}")
|
||||
|
||||
# Same async side effects every fact insert triggers (retain or import).
|
||||
await self._submit_post_insert_maintenance(bank_id, request_context)
|
||||
|
||||
if return_usage:
|
||||
return result, total_usage
|
||||
return result
|
||||
|
||||
async def _submit_post_insert_maintenance(
|
||||
self,
|
||||
bank_id: str,
|
||||
request_context: "RequestContext",
|
||||
config: HindsightConfig | None = None,
|
||||
) -> None:
|
||||
"""Submit the async side effects that follow any fact insert (retain or import).
|
||||
|
||||
Shared by the retain pipeline and the document-import pipeline so imported
|
||||
documents aren't second-class citizens:
|
||||
* auto-consolidation (when observations + auto-consolidation are enabled
|
||||
for the bank) so freshly inserted facts get observations;
|
||||
* graph maintenance, which short-circuits when no cleanup work was
|
||||
enqueued, so a plain insert pays a single cheap indexed SELECT here.
|
||||
|
||||
Both are non-critical: failures are logged, never raised, so they can't
|
||||
fail the operation that produced the facts. Pass ``config`` when the caller
|
||||
already resolved it to avoid a redundant lookup.
|
||||
"""
|
||||
if config is None:
|
||||
config = await self._config_resolver.resolve_full_config(bank_id, request_context)
|
||||
if config.enable_observations and config.enable_auto_consolidation:
|
||||
try:
|
||||
await self.submit_async_consolidation(bank_id=bank_id, request_context=request_context)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to submit consolidation task for bank {bank_id}: {e}")
|
||||
try:
|
||||
await self.submit_async_graph_maintenance(bank_id=bank_id, request_context=request_context)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to submit graph maintenance task for bank {bank_id}: {e}")
|
||||
|
||||
async def _resolve_retain_config(
|
||||
self,
|
||||
bank_id: str,
|
||||
request_context: "RequestContext",
|
||||
strategy: str | None,
|
||||
) -> HindsightConfig:
|
||||
"""Resolve the config a retain runs under, strategy overrides applied.
|
||||
|
||||
Mirrors what ``_retain_batch_async_internal`` resolves before handing
|
||||
config to the orchestrator, so anything the splitting caller derives
|
||||
from it (chunk boundaries, Memory Defense screening) matches what the
|
||||
orchestrator then does with each sub-batch.
|
||||
"""
|
||||
from hindsight_api.config_resolver import apply_strategy
|
||||
|
||||
resolved_config = await self._config_resolver.resolve_full_config(bank_id, request_context)
|
||||
effective_strategy = strategy or resolved_config.retain_default_strategy
|
||||
if effective_strategy:
|
||||
resolved_config = apply_strategy(resolved_config, effective_strategy)
|
||||
return resolved_config
|
||||
|
||||
@staticmethod
|
||||
def _retain_chunking_config(config: HindsightConfig) -> _RetainChunkingConfig:
|
||||
"""The chunk boundaries ``config`` implies, as the retain pipeline uses them."""
|
||||
return _RetainChunkingConfig(
|
||||
chunk_size=getattr(config, "retain_chunk_size", DEFAULT_RETAIN_CHUNK_SIZE),
|
||||
structured_chunk_size=getattr(config, "retain_structured_chunk_size", None),
|
||||
return _RetainExecutionResult(
|
||||
unit_ids=result,
|
||||
usage=total_usage,
|
||||
processed_content_tokens=total_processed_content_tokens,
|
||||
cancelled=cancelled,
|
||||
)
|
||||
|
||||
async def _retain_batch_async_internal(
|
||||
@@ -6016,7 +6193,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
from .memories import get_memories
|
||||
|
||||
_obs_store = get_memories()
|
||||
if observation_ids_ordered and not _obs_store.writes_memory_rows_in_sql:
|
||||
if observation_ids_ordered and not _obs_store.writes_memory_rows_in_sql_for(bank_id):
|
||||
# A store that keeps memories outside SQL: fetch each observation, then its
|
||||
# source memories, for their chunk_ids — the join the SQL branch does, walked
|
||||
# in observation-rank order so per-observation grouping is preserved.
|
||||
@@ -6098,7 +6275,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# row, so it selects one fewer column and keeps the asyncpg Records as-is — no
|
||||
# per-chunk ``dict`` allocation for an overlay it never runs.
|
||||
_chunk_store = get_memories()
|
||||
_owns_docs = _chunk_store.owns_document_store
|
||||
_owns_docs = _chunk_store.owns_document_store_for(bank_id)
|
||||
if _owns_docs:
|
||||
_chunk_cols = "chunk_id, chunk_text, chunk_index, document_id"
|
||||
else:
|
||||
@@ -6295,7 +6472,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# Resolve each observation's sources. This is a recall hot path, so the SQL
|
||||
# store reads only the two columns it needs rather than a full memory row; a
|
||||
# store that owns its rows answers from its own objects via one addressed read.
|
||||
if store.writes_memory_rows_in_sql:
|
||||
if store.writes_memory_rows_in_sql_for(bank_id):
|
||||
obs_rows = [
|
||||
{"id": str(r["id"]), "source_memory_ids": r["source_memory_ids"]}
|
||||
for r in await sf_conn.fetch(
|
||||
@@ -6332,7 +6509,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# needed, so the SQL store selects those (bank-scoped) instead of the full
|
||||
# 17-column memory row — the difference is measurable on this hot path.
|
||||
if source_ids_ordered:
|
||||
if store.writes_memory_rows_in_sql:
|
||||
if store.writes_memory_rows_in_sql_for(bank_id):
|
||||
source_row_by_id = {
|
||||
str(r["id"]): _source_fact_dict(
|
||||
uid=str(r["id"]),
|
||||
@@ -6705,7 +6882,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
from .memories import get_memories
|
||||
|
||||
_store = get_memories()
|
||||
if _store.writes_memory_rows_in_sql:
|
||||
if _store.writes_memory_rows_in_sql_for(bank_id):
|
||||
# Use a subquery for counts to avoid GROUP BY on CLOB columns
|
||||
# (Oracle cannot use CLOB types as comparison keys in GROUP BY).
|
||||
doc = await conn.fetchrow(
|
||||
@@ -6767,7 +6944,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# A store that owns the document store keeps the extracted text in
|
||||
# its own store, not in documents.original_text (which is NULL here). Overlay
|
||||
# it from the store so get_document still returns the body.
|
||||
if _store.owns_document_store:
|
||||
if _store.owns_document_store_for(bank_id):
|
||||
_rec = await _store.get_document_record(
|
||||
bank_id=bank_id, document_id=document_id, include_text=True
|
||||
)
|
||||
@@ -6844,7 +7021,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
from .memories import get_memories
|
||||
|
||||
_store = get_memories()
|
||||
if _store.writes_memory_rows_in_sql:
|
||||
if _store.writes_memory_rows_in_sql_for(bank_id):
|
||||
unit_rows = await conn.fetch(
|
||||
f"SELECT id FROM {fq_table('memory_units')} WHERE document_id = $1 AND fact_type IN ('experience', 'world')",
|
||||
document_id,
|
||||
@@ -6890,7 +7067,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# cascade to its memories (they are not SQL rows) — drop them through the store,
|
||||
# tagged with a write-group so the store tombstone commits atomically with the
|
||||
# Postgres document delete (a rolled-back delete must not orphan the memories).
|
||||
if deleted and not _store.writes_memory_rows_in_sql:
|
||||
if deleted and not _store.writes_memory_rows_in_sql_for(bank_id):
|
||||
_del_txn = await _store.begin_txn(conn=conn, fq_table=fq_table, bank_id=bank_id, mutating=True)
|
||||
await _store.delete_document(
|
||||
conn=conn, fq_table=fq_table, bank_id=bank_id, document_id=document_id, txn=_del_txn
|
||||
@@ -6899,7 +7076,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# extracted text + chunk bodies; the orphan sweep reclaims the blobs), under the
|
||||
# same write-group so it commits atomically with the Postgres document delete.
|
||||
# This is the EXPLICIT deletion — distinct from the re-ingest facts-delete above.
|
||||
if _store.owns_document_store:
|
||||
if _store.owns_document_store_for(bank_id):
|
||||
await _store.delete_document_record(bank_id=bank_id, document_id=document_id, txn=_del_txn)
|
||||
|
||||
# Invalidate observations referencing these (now-deleted) memories
|
||||
@@ -7012,7 +7189,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
from .memories import MemoryPatch, get_memories
|
||||
|
||||
_store = get_memories()
|
||||
if tags is not None and not _store.writes_memory_rows_in_sql:
|
||||
if tags is not None and not _store.writes_memory_rows_in_sql_for(bank_id):
|
||||
# A store that keeps memories outside SQL: retag the document's memories, then
|
||||
# invalidate the observations built on them and requeue their sources so the
|
||||
# next consolidation rebuilds them under the new tags (the cascade the SQL
|
||||
@@ -7186,7 +7363,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
from .memories import get_memories
|
||||
|
||||
_store = get_memories()
|
||||
if _store.writes_memory_rows_in_sql:
|
||||
if _store.writes_memory_rows_in_sql_for(bank_id):
|
||||
row = await conn.fetchrow(
|
||||
f"SELECT bank_id, fact_type FROM {fq_table('memory_units')} WHERE id = $1",
|
||||
str(unit_uuid),
|
||||
@@ -7215,7 +7392,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
# observations inserted concurrently by consolidation (otherwise a
|
||||
# racing insert committed between the sweep and the delete would
|
||||
# leave an orphan referencing this just-deleted source memory).
|
||||
if _store.writes_memory_rows_in_sql:
|
||||
if _store.writes_memory_rows_in_sql_for(bank_id):
|
||||
deleted = await conn.fetchval(
|
||||
f"DELETE FROM {fq_table('memory_units')} WHERE id = $1 RETURNING id", unit_id
|
||||
)
|
||||
@@ -7529,7 +7706,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
from .memories import get_memories as _get_memories_for_scope
|
||||
|
||||
_scope_store = _get_memories_for_scope()
|
||||
if _scope_store.writes_memory_rows_in_sql:
|
||||
if _scope_store.writes_memory_rows_in_sql_for(bank_id):
|
||||
unit_id_rows = await conn.fetch(
|
||||
f"SELECT id FROM {fq_table('memory_units')} WHERE bank_id = $1 AND fact_type = $2",
|
||||
bank_id,
|
||||
@@ -7679,7 +7856,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
from .memories import DeletePredicate, get_memories
|
||||
|
||||
store = get_memories()
|
||||
if not store.writes_memory_rows_in_sql:
|
||||
if not store.writes_memory_rows_in_sql_for(bank_id):
|
||||
if fact_type:
|
||||
await store.delete_where(bank_id, DeletePredicate(fact_types=[fact_type]))
|
||||
else:
|
||||
@@ -7729,7 +7906,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
backend = await self._get_backend()
|
||||
async with acquire_with_retry(backend) as conn:
|
||||
async with conn.transaction():
|
||||
if store.writes_memory_rows_in_sql:
|
||||
if store.writes_memory_rows_in_sql_for(bank_id):
|
||||
# Count observations before deletion
|
||||
count = await conn.fetchval(
|
||||
f"SELECT COUNT(*) FROM {fq_table('memory_units')} WHERE bank_id = $1 AND fact_type = 'observation'",
|
||||
@@ -7862,7 +8039,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
store = get_memories()
|
||||
backend = await self._get_backend()
|
||||
async with acquire_with_retry(backend) as conn:
|
||||
if store.writes_memory_rows_in_sql:
|
||||
if store.writes_memory_rows_in_sql_for(bank_id):
|
||||
count = await conn.fetchval(
|
||||
f"""
|
||||
SELECT COUNT(*) FROM {fq_table("memory_units")}
|
||||
@@ -7942,7 +8119,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
from .memories import get_memories
|
||||
|
||||
_store = get_memories()
|
||||
if _store.writes_memory_rows_in_sql:
|
||||
if _store.writes_memory_rows_in_sql_for(bank_id):
|
||||
await conn.execute(
|
||||
f"""
|
||||
UPDATE {fq_table("memory_units")}
|
||||
@@ -9390,7 +9567,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
from .memories import get_memories
|
||||
|
||||
_store = get_memories()
|
||||
if _store.owns_document_store:
|
||||
if _store.owns_document_store_for(chunk["bank_id"]):
|
||||
_t = await _store.get_chunk_text(
|
||||
bank_id=chunk["bank_id"],
|
||||
document_id=chunk["document_id"],
|
||||
@@ -9481,7 +9658,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
from .memories import get_memories
|
||||
|
||||
_store = get_memories()
|
||||
if _store.owns_document_store:
|
||||
if _store.owns_document_store_for(bank_id):
|
||||
_texts = await _store.list_chunk_texts(bank_id=bank_id, document_id=document_id)
|
||||
if _texts is not None:
|
||||
_texts_by_index = dict(enumerate(_texts))
|
||||
@@ -11631,7 +11808,7 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
from .memories import get_memories
|
||||
|
||||
_store = get_memories()
|
||||
if _store.writes_memory_rows_in_sql:
|
||||
if _store.writes_memory_rows_in_sql_for(bank_id):
|
||||
consolidation_row = await conn.fetchrow(
|
||||
f"""
|
||||
SELECT
|
||||
@@ -15758,16 +15935,22 @@ class MemoryEngine(MemoryEngineInterface):
|
||||
if replay is not None:
|
||||
return replay
|
||||
|
||||
# Validate no duplicate document_ids in the batch
|
||||
# Having duplicate document_ids causes race conditions in document upserts during parallel processing
|
||||
# Reject duplicate document_ids on the QUEUED path only. Children fan out
|
||||
# to workers that claim them in parallel with no per-document gate, and
|
||||
# append is a non-transactional read-modify-write, so concurrent appends
|
||||
# to one document lose updates. The synchronous path (async=false) folds
|
||||
# shared-document items into one document safely — sub-batches there run
|
||||
# sequentially — so a client that needs this should send async=false.
|
||||
doc_ids = [item.get("document_id") for item in contents if item.get("document_id")]
|
||||
if len(doc_ids) != len(set(doc_ids)):
|
||||
from collections import Counter
|
||||
|
||||
duplicates = [doc_id for doc_id, count in Counter(doc_ids).items() if count > 1]
|
||||
raise ValueError(
|
||||
f"Batch contains duplicate document_ids: {duplicates}. "
|
||||
f"Each content item in a batch must have a unique document_id to avoid race conditions."
|
||||
f"Batch contains duplicate document_ids: {duplicates}. Each content item in an "
|
||||
f"async batch must have a unique document_id to avoid races between the parallel "
|
||||
f"workers that process them. To fold several items into one document, send the "
|
||||
f"batch synchronously (async=false), which processes them sequentially."
|
||||
)
|
||||
|
||||
# Calculate total token count and determine if we need to split
|
||||
|
||||
@@ -40,6 +40,10 @@ from hindsight_api.worker.stage import set_stage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Name of the single tool used when structured output is routed through a forced
|
||||
# tool call instead of ``response_format`` (see ``structured_output_forced_tool``).
|
||||
_STRUCTURED_TOOL_NAME = "structured_response"
|
||||
|
||||
|
||||
def _usage_from_litellm_response(response: Any) -> LLMResponseUsage:
|
||||
"""Extract prompt/completion/cached token counts from a LiteLLM (OpenAI-shaped) usage block."""
|
||||
@@ -57,6 +61,22 @@ def _usage_from_litellm_response(response: Any) -> LLMResponseUsage:
|
||||
)
|
||||
|
||||
|
||||
def _forced_tool_arguments(message: Any) -> str | None:
|
||||
"""Return the structured-output tool call's arguments as a JSON string.
|
||||
|
||||
``None`` when the model answered with plain text instead — some gateways drop
|
||||
``tool_choice`` — so the caller can fall back to parsing the message content.
|
||||
"""
|
||||
for tool_call in message.tool_calls or []:
|
||||
if tool_call.function.name != _STRUCTURED_TOOL_NAME:
|
||||
continue
|
||||
# LiteLLM normalizes to the OpenAI shape (a JSON string), but some
|
||||
# providers hand back an already-decoded object.
|
||||
arguments = tool_call.function.arguments
|
||||
return arguments if isinstance(arguments, str) else json.dumps(arguments)
|
||||
return None
|
||||
|
||||
|
||||
class LiteLLMLLM(LLMInterface):
|
||||
"""
|
||||
LLM provider using the LiteLLM SDK for universal model support.
|
||||
@@ -82,6 +102,7 @@ class LiteLLMLLM(LLMInterface):
|
||||
extra_body: dict[str, Any] | None = None,
|
||||
bedrock_service_tier: str | None = None,
|
||||
default_headers: dict[str, Any] | None = None,
|
||||
structured_output_forced_tool: bool = False,
|
||||
**kwargs: Any,
|
||||
):
|
||||
super().__init__(provider, api_key, base_url, model, reasoning_effort, **kwargs)
|
||||
@@ -103,6 +124,12 @@ class LiteLLMLLM(LLMInterface):
|
||||
# copy is handed to each call below to avoid cross-request contamination.
|
||||
self._default_headers: dict[str, Any] = dict(default_headers or {})
|
||||
self.bedrock_service_tier = bedrock_service_tier
|
||||
# Ask for structured output via a single forced tool call instead of
|
||||
# ``response_format``. Opt-in (HINDSIGHT_API_LLM_STRUCTURED_OUTPUT_FORCED_TOOL)
|
||||
# for backends that reject the response_format route — Bedrock Claude's
|
||||
# Converse layer refuses the translated ``outputConfig`` in some regions but
|
||||
# accepts the same schema as a tool (#3300).
|
||||
self.structured_output_forced_tool = structured_output_forced_tool
|
||||
|
||||
try:
|
||||
import litellm
|
||||
@@ -243,16 +270,38 @@ class LiteLLMLLM(LLMInterface):
|
||||
call_kwargs = self._build_common_kwargs(messages, max_completion_tokens, temperature)
|
||||
|
||||
# Add JSON schema response format if provided
|
||||
use_forced_tool = False
|
||||
if response_format is not None and hasattr(response_format, "model_json_schema"):
|
||||
schema = strict_json_schema(response_format) if strict_schema else response_format.model_json_schema()
|
||||
call_kwargs["response_format"] = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": response_format.__name__ if hasattr(response_format, "__name__") else "response",
|
||||
"schema": schema,
|
||||
"strict": strict_schema,
|
||||
},
|
||||
}
|
||||
schema_name = response_format.__name__ if hasattr(response_format, "__name__") else "response"
|
||||
if self.structured_output_forced_tool:
|
||||
# The schema travels as the tool's parameters and the model is forced
|
||||
# to call it; the arguments are substituted for the message content
|
||||
# below, so the parse/validate, retry and usage paths are unchanged.
|
||||
use_forced_tool = True
|
||||
call_kwargs["tools"] = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": _STRUCTURED_TOOL_NAME,
|
||||
"description": f"Return the structured response ({schema_name}).",
|
||||
"parameters": schema,
|
||||
},
|
||||
}
|
||||
]
|
||||
call_kwargs["tool_choice"] = {
|
||||
"type": "function",
|
||||
"function": {"name": _STRUCTURED_TOOL_NAME},
|
||||
}
|
||||
else:
|
||||
call_kwargs["response_format"] = {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": schema_name,
|
||||
"schema": schema,
|
||||
"strict": strict_schema,
|
||||
},
|
||||
}
|
||||
|
||||
last_exception = None
|
||||
|
||||
@@ -269,10 +318,19 @@ class LiteLLMLLM(LLMInterface):
|
||||
# these tokens (#2387).
|
||||
stash_response_usage(_usage_from_litellm_response(response))
|
||||
|
||||
content = response.choices[0].message.content or ""
|
||||
message = response.choices[0].message
|
||||
content = message.content or ""
|
||||
finish_reason = response.choices[0].finish_reason
|
||||
model_name = self._resolve_completion_model(response)
|
||||
|
||||
if use_forced_tool:
|
||||
# Forced tool call: its arguments ARE the structured response.
|
||||
# Absent (a gateway that drops tool_choice) -> keep the text
|
||||
# content so the existing parse path still has a chance.
|
||||
forced_arguments = _forced_tool_arguments(message)
|
||||
if forced_arguments is not None:
|
||||
content = forced_arguments
|
||||
|
||||
# Check for length-limited output
|
||||
if finish_reason == "length":
|
||||
raise OutputTooLongError("LiteLLM response was truncated due to token limit")
|
||||
|
||||
@@ -406,7 +406,7 @@ async def tool_expand(
|
||||
from ..memories import get_memories
|
||||
|
||||
_store = get_memories()
|
||||
if _store.writes_memory_rows_in_sql:
|
||||
if _store.writes_memory_rows_in_sql_for(bank_id):
|
||||
memories = await conn.fetch(
|
||||
f"""
|
||||
SELECT id, text, chunk_id, document_id, fact_type, context
|
||||
|
||||
@@ -499,7 +499,7 @@ async def list_banks(pool) -> list:
|
||||
last_write = max(write_times) if write_times else None
|
||||
|
||||
fact_count = row["fact_count"]
|
||||
if not _store.writes_memory_rows_in_sql:
|
||||
if not _store.writes_memory_rows_in_sql_for(row["bank_id"]):
|
||||
fact_count = sum(
|
||||
(await _store.count_memories(conn=conn, fq_table=fq_table, bank_id=row["bank_id"])).values()
|
||||
)
|
||||
|
||||
@@ -14,6 +14,9 @@ from .types import ChunkMetadata
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Page size for walking the facts a chunk owns out of a store that keeps memories outside SQL.
|
||||
_OUTGOING_PAGE = 500
|
||||
|
||||
|
||||
def compute_chunk_hash(chunk_text: str) -> str:
|
||||
"""Compute SHA256 hash of chunk text for delta comparison."""
|
||||
@@ -55,7 +58,54 @@ async def load_existing_chunks(conn, bank_id: str, document_id: str) -> list[Exi
|
||||
]
|
||||
|
||||
|
||||
async def delete_chunks_by_ids(conn, chunk_ids: list[str], bank_id: str | None = None, txn=None) -> None:
|
||||
async def memory_ids_for_chunks(conn, bank_id: str, chunk_ids: list[str]) -> list[str]:
|
||||
"""Ids of the facts these chunks own, asked of whichever store holds them.
|
||||
|
||||
The SQL store keeps ``chunk_id`` as a column; a store that keeps memories outside SQL
|
||||
carries it in the metadata bag (the same key its ``delete_where`` predicate matches on),
|
||||
so the two are read differently. Only ``experience``/``world`` units are returned:
|
||||
observations are not chunk-scoped, and feeding one back as a *source* id would be
|
||||
meaningless. Paged to exhaustion — every id is about to be deleted, and a chunk whose
|
||||
facts overflow one page must not keep half of them.
|
||||
"""
|
||||
from ..memories import META_CHUNK_ID, get_memories
|
||||
|
||||
store = get_memories()
|
||||
if store.writes_memory_rows_in_sql:
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT id
|
||||
FROM {fq_table("memory_units")}
|
||||
WHERE bank_id = $1
|
||||
AND chunk_id = ANY($2::text[])
|
||||
AND fact_type IN ('experience', 'world')
|
||||
""",
|
||||
bank_id,
|
||||
chunk_ids,
|
||||
)
|
||||
return [str(row["id"]) for row in rows]
|
||||
|
||||
unit_ids: list[str] = []
|
||||
for chunk_id in chunk_ids:
|
||||
page_token = ""
|
||||
while True:
|
||||
page = await store.scan_memories(
|
||||
conn=conn,
|
||||
fq_table=fq_table,
|
||||
bank_id=bank_id,
|
||||
fact_types=["experience", "world"],
|
||||
metadata_equals={META_CHUNK_ID: chunk_id},
|
||||
limit=_OUTGOING_PAGE,
|
||||
page_token=page_token,
|
||||
)
|
||||
unit_ids.extend(m.unit_id for m in page.memories)
|
||||
page_token = page.next_page_token
|
||||
if not page_token:
|
||||
break
|
||||
return unit_ids
|
||||
|
||||
|
||||
async def delete_chunks_by_ids(conn, chunk_ids: list[str], bank_id: str | None = None, txn=None, ops=None) -> int:
|
||||
"""
|
||||
Delete specific chunks by their IDs.
|
||||
|
||||
@@ -66,9 +116,29 @@ async def delete_chunks_by_ids(conn, chunk_ids: list[str], bank_id: str | None =
|
||||
the store's tombstones must ride the same txn as the replacement writes so they commit
|
||||
(become visible) together — otherwise an aborted re-ingest could drop the old memories
|
||||
without landing the new ones.
|
||||
|
||||
``ops`` is the backend-specific DataAccessOps the observation sweep below needs to choose
|
||||
the PG (native array) vs Oracle (junction table) read path — pass ``pool.ops``.
|
||||
|
||||
Returns the number of observations invalidated by the sweep, so the caller can log it.
|
||||
"""
|
||||
if not chunk_ids:
|
||||
return
|
||||
return 0
|
||||
|
||||
# Delete the observations derived from the facts these chunks own, BEFORE the facts
|
||||
# themselves go. Nothing can reach those observations afterwards: consolidation batches
|
||||
# are built from facts, so an observation whose sources are all deleted is never selected
|
||||
# into a batch again, and it stays valid and recallable — stale knowledge from the previous
|
||||
# version of the document surviving the replace (issue #3294). The full-replace path does
|
||||
# this in ``handle_document_tracking``; the delta path deletes facts through this cascade
|
||||
# instead, which is why the sweep has to live here rather than at one of the call sites.
|
||||
invalidated = 0
|
||||
if bank_id:
|
||||
outgoing_unit_ids = await memory_ids_for_chunks(conn, bank_id, chunk_ids)
|
||||
if outgoing_unit_ids:
|
||||
from .fact_storage import delete_stale_observations_for_memories
|
||||
|
||||
invalidated = await delete_stale_observations_for_memories(conn, bank_id, outgoing_unit_ids, ops=ops)
|
||||
|
||||
# The chunks->memory_units FK cascade below does not reach a store that keeps memories
|
||||
# outside SQL (its memory_units is empty), so drop the memories carrying each deleted
|
||||
@@ -76,7 +146,7 @@ async def delete_chunks_by_ids(conn, chunk_ids: list[str], bank_id: str | None =
|
||||
from ..memories import META_CHUNK_ID, DeletePredicate, get_memories
|
||||
|
||||
_store = get_memories()
|
||||
if bank_id and not _store.writes_memory_rows_in_sql:
|
||||
if bank_id and not _store.writes_memory_rows_in_sql_for(bank_id):
|
||||
for _cid in chunk_ids:
|
||||
await _store.delete_where(bank_id, DeletePredicate(metadata_equals={META_CHUNK_ID: _cid}), txn=txn)
|
||||
|
||||
@@ -128,6 +198,7 @@ async def delete_chunks_by_ids(conn, chunk_ids: list[str], bank_id: str | None =
|
||||
""",
|
||||
chunk_ids,
|
||||
)
|
||||
return invalidated
|
||||
|
||||
|
||||
async def store_chunks_batch(
|
||||
@@ -168,7 +239,7 @@ async def store_chunks_batch(
|
||||
# same shape as store_document_text=False, and idempotency is unaffected (content_hash stays).
|
||||
from ..memories import get_memories
|
||||
|
||||
if get_memories().owns_document_store:
|
||||
if get_memories().owns_document_store_for(bank_id):
|
||||
store_text = False
|
||||
|
||||
# Prepare chunk data for batch insert
|
||||
|
||||
@@ -270,6 +270,14 @@ async def handle_document_tracking(
|
||||
f"[RETAIN] Document {document_id} re-ingested: invalidated "
|
||||
f"{invalidated} observation(s) derived from {len(existing_unit_ids)} outgoing memory_units"
|
||||
)
|
||||
else:
|
||||
# Logged even at zero: "the sweep matched nothing" and "the sweep never ran"
|
||||
# are the two candidates whenever orphan observations are reported, and
|
||||
# without this line they look identical from the outside (issue #3294).
|
||||
logger.debug(
|
||||
f"[RETAIN] Document {document_id} re-ingested: no observations derived from "
|
||||
f"{len(existing_unit_ids)} outgoing memory_units"
|
||||
)
|
||||
# Capture link-recompute victims BEFORE the cascade. Same staleness
|
||||
# applies on upsert as on explicit delete: surviving units in OTHER
|
||||
# documents that linked to these doomed units are about to lose
|
||||
@@ -372,7 +380,7 @@ async def _upsert_document_row(
|
||||
# the bulky body is written to the store up front (orchestrator._store_document_bodies).
|
||||
from ..memories import get_memories
|
||||
|
||||
if get_memories().owns_document_store:
|
||||
if get_memories().owns_document_store_for(bank_id):
|
||||
original_text = None
|
||||
await conn.execute(
|
||||
f"""
|
||||
@@ -414,7 +422,7 @@ async def update_memory_units_metadata_and_tags(
|
||||
from ..memories import MemoryPatch, get_memories
|
||||
|
||||
store = get_memories()
|
||||
if not store.writes_memory_rows_in_sql:
|
||||
if not store.writes_memory_rows_in_sql_for(bank_id):
|
||||
# A store that keeps memories outside SQL: page the document's memories and patch each
|
||||
# one's tags through the store — the UPDATE below is a no-op on its empty memory_units.
|
||||
page = await store.scan_memories(
|
||||
|
||||
@@ -1278,7 +1278,7 @@ async def _store_document_bodies(
|
||||
from ..memories import get_memories
|
||||
|
||||
store = get_memories()
|
||||
if not store.owns_document_store:
|
||||
if not store.owns_document_store_for(bank_id):
|
||||
return
|
||||
# The record's content_hash must equal what the SQL documents row stores, so a read is
|
||||
# consistent whichever it comes from: sanitize + sha256 the same combined_content. The
|
||||
@@ -2573,10 +2573,13 @@ async def _try_delta_retain(
|
||||
for idx in changed_indices + removed_indices
|
||||
if idx in existing_by_index
|
||||
]
|
||||
await chunk_storage.delete_chunks_by_ids(conn, chunks_to_delete, bank_id, txn=_group_txn)
|
||||
invalidated_obs = await chunk_storage.delete_chunks_by_ids(
|
||||
conn, chunks_to_delete, bank_id, txn=_group_txn, ops=pool.ops
|
||||
)
|
||||
log_buffer.append(
|
||||
f" Deleted {len(chunks_to_delete)} chunks "
|
||||
f"({len(changed_indices)} changed + {len(removed_indices)} removed) "
|
||||
f"({len(changed_indices)} changed + {len(removed_indices)} removed), "
|
||||
f"invalidated {invalidated_obs} observation(s) "
|
||||
f"in {time.time() - step_start:.3f}s"
|
||||
)
|
||||
|
||||
|
||||
@@ -46,21 +46,134 @@ async def test_duplicate_document_ids_rejected_async(memory, request_context):
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_document_ids_rejected_sync(memory, request_context):
|
||||
"""Test that sync retain also rejects batches with duplicate document_ids."""
|
||||
bank_id = "test_duplicate_sync"
|
||||
contents = [
|
||||
{"content": "First item", "document_id": "doc1"},
|
||||
{"content": "Second item", "document_id": "doc1"}, # Duplicate!
|
||||
]
|
||||
async def test_shared_document_id_folds_sync(memory, request_context):
|
||||
"""Sync retain accepts several items sharing one document_id and folds them
|
||||
into a single document, in request order (the documented RetainRequest
|
||||
example — see issue #3363)."""
|
||||
bank_id = f"test_shared_doc_sync_{uuid.uuid4().hex}"
|
||||
try:
|
||||
contents = [
|
||||
{"content": "Alice works at Google", "context": "work", "document_id": "conversation_123"},
|
||||
{"content": "Bob went hiking yesterday", "document_id": "conversation_123"},
|
||||
]
|
||||
|
||||
# Should raise ValueError due to duplicate document_ids
|
||||
with pytest.raises(ValueError, match="duplicate document_ids.*doc1"):
|
||||
await memory.retain_batch_async(
|
||||
# Must NOT raise (this used to be a 400).
|
||||
result = await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=contents,
|
||||
request_context=request_context,
|
||||
)
|
||||
# One result slot per input content is preserved.
|
||||
assert len(result) == 2
|
||||
|
||||
# The items folded into exactly one document.
|
||||
docs = await memory.list_documents(bank_id=bank_id, request_context=request_context)
|
||||
assert docs["total"] == 1
|
||||
assert docs["items"][0]["id"] == "conversation_123"
|
||||
|
||||
# Both items' content lands in that one document's body, in order.
|
||||
doc = await memory.get_document("conversation_123", bank_id, request_context=request_context)
|
||||
body = doc["original_text"]
|
||||
assert "Alice works at Google" in body
|
||||
assert "Bob went hiking yesterday" in body
|
||||
assert body.index("Alice works at Google") < body.index("Bob went hiking yesterday")
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_batch_level_document_id_folds_sync(memory, request_context):
|
||||
"""The deprecated batch-level document_id (applied to every item without its
|
||||
own) folds those items into one document instead of tripping the guard."""
|
||||
bank_id = f"test_batch_doc_id_sync_{uuid.uuid4().hex}"
|
||||
try:
|
||||
result = await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[
|
||||
{"content": "Alice works at Google"},
|
||||
{"content": "Bob loves Python"},
|
||||
],
|
||||
document_id="meeting-2024-01-15",
|
||||
request_context=request_context,
|
||||
)
|
||||
assert len(result) == 2
|
||||
|
||||
docs = await memory.list_documents(bank_id=bank_id, request_context=request_context)
|
||||
assert docs["total"] == 1
|
||||
assert docs["items"][0]["id"] == "meeting-2024-01-15"
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shared_and_distinct_document_ids_sync(memory, request_context):
|
||||
"""A batch mixing a shared document_id with a distinct one folds only the
|
||||
shared items, leaving the distinct document on its own."""
|
||||
bank_id = f"test_mixed_doc_ids_sync_{uuid.uuid4().hex}"
|
||||
try:
|
||||
result = await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=[
|
||||
{"content": "Alice works at Google", "document_id": "docA"},
|
||||
{"content": "Bob loves Python", "document_id": "docB"},
|
||||
{"content": "Alice also mentors interns", "document_id": "docA"},
|
||||
],
|
||||
request_context=request_context,
|
||||
)
|
||||
assert len(result) == 3
|
||||
|
||||
docs = await memory.list_documents(bank_id=bank_id, request_context=request_context)
|
||||
assert docs["total"] == 2
|
||||
|
||||
doc_a = await memory.get_document("docA", bank_id, request_context=request_context)
|
||||
assert "Alice works at Google" in doc_a["original_text"]
|
||||
assert "Alice also mentors interns" in doc_a["original_text"]
|
||||
|
||||
doc_b = await memory.get_document("docB", bank_id, request_context=request_context)
|
||||
assert "Bob loves Python" in doc_b["original_text"]
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shared_document_id_folds_sync_large_batch(memory, request_context):
|
||||
"""A shared-document batch large enough to exceed the auto-split token
|
||||
threshold still folds into one document with NO lost content. Splitting one
|
||||
document across sub-batches would trip the streaming pipeline's content-hash
|
||||
ownership check and drop later sub-batches, so shared groups take a single
|
||||
pass — this guards that decision (issue #3363)."""
|
||||
from hindsight_api.engine.memory_engine import count_tokens
|
||||
|
||||
bank_id = f"test_shared_doc_large_sync_{uuid.uuid4().hex}"
|
||||
try:
|
||||
# Two ~5.5k-token items sharing one document_id → ~11k tokens, over the
|
||||
# 10k default split threshold. Distinct markers pin each item's presence.
|
||||
filler = "The quick brown fox jumps over the lazy dog. " * 500
|
||||
contents = [
|
||||
{"content": f"MARKER_ALPHA at the start. {filler}", "document_id": "big_conversation"},
|
||||
{"content": f"{filler} MARKER_OMEGA at the end.", "document_id": "big_conversation"},
|
||||
]
|
||||
assert sum(count_tokens(c["content"]) for c in contents) > 10_000
|
||||
|
||||
result = await memory.retain_batch_async(
|
||||
bank_id=bank_id,
|
||||
contents=contents,
|
||||
request_context=request_context,
|
||||
)
|
||||
assert len(result) == 2
|
||||
|
||||
docs = await memory.list_documents(bank_id=bank_id, request_context=request_context)
|
||||
assert docs["total"] == 1
|
||||
|
||||
# Both items survive — neither sub-batch was dropped by a takeover abort.
|
||||
doc = await memory.get_document("big_conversation", bank_id, request_context=request_context)
|
||||
assert "MARKER_ALPHA" in doc["original_text"]
|
||||
assert "MARKER_OMEGA" in doc["original_text"]
|
||||
|
||||
chunks = await memory.list_document_chunks(bank_id, "big_conversation", request_context=request_context)
|
||||
assert chunks["total"] > 1
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"""End-to-end regression for issue #3294: delta retain must not orphan observations.
|
||||
|
||||
The reporter's sequence, driven through the public engine API rather than by calling
|
||||
the storage helpers directly:
|
||||
|
||||
retain(document) -> consolidate -> retain(same document_id, edited) -> consolidate
|
||||
|
||||
Before the fix, the second retain took the delta path, which deletes the changed
|
||||
chunks and lets the FK cascade drop their facts — with no observation sweep in
|
||||
between. The observations derived from those facts stayed behind, still valid and
|
||||
still recallable, pointing at ``source_memory_ids`` that no longer resolved. Nothing
|
||||
could reach them afterwards: consolidation batches are built from facts, so an
|
||||
observation whose sources are all gone is never selected into a batch again.
|
||||
|
||||
These tests assert the invariant the lifecycle documents ("removing a document: all
|
||||
observations derived from the document's memories are deleted") on the paths delta
|
||||
retain actually takes: an edit, a removal, and a no-op re-ingest. A rewrite of *every*
|
||||
chunk is deliberately not covered here — with no unchanged chunk left, delta declines
|
||||
and the full-replace path (already covered in ``test_observation_invalidation.py``)
|
||||
handles it.
|
||||
"""
|
||||
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from hindsight_api import RequestContext
|
||||
from hindsight_api.config import _get_raw_config
|
||||
from hindsight_api.engine.memories import FactRecord, get_memories
|
||||
from hindsight_api.engine.memory_engine import MemoryEngine, fq_table
|
||||
|
||||
# Delta retain works per chunk, so the document has to be big enough to produce several
|
||||
# (chunk size is 3000 chars) and at least one of them must come out unchanged — with no
|
||||
# unchanged chunk delta declines and falls back to a full replace. Keeping the FIRST
|
||||
# block byte-identical across a re-ingest is what guarantees that: chunking is greedy
|
||||
# from the start of the text, so an edit after chunk 0's boundary cannot move it.
|
||||
_BLOCK_A = " ".join(
|
||||
f"Alice shipped the Alpha{i} milestone at Google in the search infrastructure group." for i in range(40)
|
||||
)
|
||||
_BLOCK_B = " ".join(f"Bob reviewed the Beta{i} rollout at Microsoft in the Azure networking group." for i in range(40))
|
||||
_BLOCK_B_EDITED = " ".join(
|
||||
f"Bob reviewed the Beta{i} rollout at Amazon in the AWS networking group." for i in range(40)
|
||||
)
|
||||
|
||||
_DOCUMENT_V1 = f"{_BLOCK_A} {_BLOCK_B}"
|
||||
_DOCUMENT_V2_PARTIAL_EDIT = f"{_BLOCK_A} {_BLOCK_B_EDITED}"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def enable_observations():
|
||||
config = _get_raw_config()
|
||||
original = config.enable_observations
|
||||
config.enable_observations = True
|
||||
yield
|
||||
config.enable_observations = original
|
||||
|
||||
|
||||
async def _scan(memory: MemoryEngine, bank_id: str, fact_types: list[str]) -> list[FactRecord]:
|
||||
"""Every stored memory of these types, read through whichever store holds them."""
|
||||
store = get_memories()
|
||||
pool = await memory._get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
page = await store.scan_memories(
|
||||
conn=conn,
|
||||
fq_table=fq_table,
|
||||
bank_id=bank_id,
|
||||
fact_types=fact_types,
|
||||
limit=1_000_000,
|
||||
)
|
||||
return list(page.memories)
|
||||
|
||||
|
||||
async def _facts(memory: MemoryEngine, bank_id: str) -> list[FactRecord]:
|
||||
return await _scan(memory, bank_id, ["experience", "world"])
|
||||
|
||||
|
||||
async def _observations(memory: MemoryEngine, bank_id: str) -> list[FactRecord]:
|
||||
return await _scan(memory, bank_id, ["observation"])
|
||||
|
||||
|
||||
def _broken_source_refs(observations: list[FactRecord], live_fact_ids: set[str]) -> list[tuple[str, list[str]]]:
|
||||
"""The reporter's diagnostic: observations whose sources no longer resolve.
|
||||
|
||||
Returns ``(observation_id, unresolvable_source_ids)`` per affected row — what the
|
||||
bug report counted as "broken references" on their bank.
|
||||
"""
|
||||
broken = []
|
||||
for obs in observations:
|
||||
missing = [sid for sid in obs.source_memory_ids if sid not in live_fact_ids]
|
||||
if missing:
|
||||
broken.append((obs.unit_id, missing))
|
||||
return broken
|
||||
|
||||
|
||||
async def _assert_no_orphans(memory: MemoryEngine, bank_id: str, when: str) -> None:
|
||||
facts = await _facts(memory, bank_id)
|
||||
observations = await _observations(memory, bank_id)
|
||||
broken = _broken_source_refs(observations, {f.unit_id for f in facts})
|
||||
assert broken == [], (
|
||||
f"{when}: {len(broken)} of {len(observations)} observation(s) reference deleted source "
|
||||
f"memories (issue #3294 — delta retain cascaded the facts away without sweeping the "
|
||||
f"observations derived from them): {broken[:5]}"
|
||||
)
|
||||
|
||||
|
||||
async def _retain_document(
|
||||
memory: MemoryEngine, bank_id: str, document_id: str, content: str, request_context: RequestContext
|
||||
) -> None:
|
||||
await memory.retain_async(
|
||||
bank_id=bank_id,
|
||||
content=content,
|
||||
context="team roster",
|
||||
document_id=document_id,
|
||||
request_context=request_context,
|
||||
)
|
||||
|
||||
|
||||
def _facts_by_chunk(facts: list[FactRecord]) -> dict[str, set[str]]:
|
||||
by_chunk: dict[str, set[str]] = {}
|
||||
for fact in facts:
|
||||
if fact.chunk_id:
|
||||
by_chunk.setdefault(fact.chunk_id, set()).add(fact.unit_id)
|
||||
return by_chunk
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delta_retain_partial_edit_leaves_no_orphan_observations(
|
||||
memory: MemoryEngine, request_context: RequestContext
|
||||
):
|
||||
"""Editing the tail of a consolidated document orphans nothing.
|
||||
|
||||
Also pins the precision of the sweep: the untouched first chunk keeps its facts
|
||||
AND the observations derived only from them, so a small edit does not
|
||||
re-consolidate the whole document — the case delta retain exists for.
|
||||
"""
|
||||
bank_id = f"test_delta_orphan_partial_{uuid.uuid4().hex[:8]}"
|
||||
document_id = "roster-doc"
|
||||
|
||||
try:
|
||||
await _retain_document(memory, bank_id, document_id, _DOCUMENT_V1, request_context)
|
||||
await memory.run_consolidation(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
facts_v1 = await _facts(memory, bank_id)
|
||||
observations_v1 = await _observations(memory, bank_id)
|
||||
by_chunk_v1 = _facts_by_chunk(facts_v1)
|
||||
assert len(by_chunk_v1) >= 2, f"Setup: the document should span several chunks, got {list(by_chunk_v1)}"
|
||||
assert observations_v1, "Setup: consolidation should have produced observations to orphan"
|
||||
await _assert_no_orphans(memory, bank_id, "after the first retain")
|
||||
|
||||
first_chunk = sorted(by_chunk_v1)[0]
|
||||
kept_fact_ids = by_chunk_v1[first_chunk]
|
||||
edited_fact_ids = {fid for chunk, ids in by_chunk_v1.items() if chunk != first_chunk for fid in ids}
|
||||
assert kept_fact_ids and edited_fact_ids
|
||||
|
||||
obs_over_edited = {o.unit_id for o in observations_v1 if edited_fact_ids.intersection(o.source_memory_ids)}
|
||||
obs_only_over_kept = {
|
||||
o.unit_id
|
||||
for o in observations_v1
|
||||
if o.source_memory_ids and set(o.source_memory_ids).issubset(kept_fact_ids)
|
||||
}
|
||||
assert obs_over_edited, "Setup: the chunks being edited should have observations derived from them"
|
||||
assert obs_only_over_kept, "Setup: the unchanged chunk should have observations of its own"
|
||||
|
||||
# Re-ingest with only the tail changed — this is the delta path.
|
||||
await _retain_document(memory, bank_id, document_id, _DOCUMENT_V2_PARTIAL_EDIT, request_context)
|
||||
|
||||
surviving_fact_ids = {f.unit_id for f in await _facts(memory, bank_id)}
|
||||
# Delta really applied: the unchanged chunk's facts were preserved rather than
|
||||
# re-extracted under new ids (a full replace would have changed all of them).
|
||||
assert kept_fact_ids.issubset(surviving_fact_ids), (
|
||||
"Unchanged chunk's facts should survive the delta re-ingest — if they did not, this "
|
||||
"test fell back to the full-replace path and no longer covers the bug"
|
||||
)
|
||||
assert not edited_fact_ids.intersection(surviving_fact_ids), "The edited chunks' facts should be gone"
|
||||
|
||||
await _assert_no_orphans(memory, bank_id, "after the delta re-ingest")
|
||||
|
||||
observation_ids_v2 = {o.unit_id for o in await _observations(memory, bank_id)}
|
||||
assert not observation_ids_v2.intersection(obs_over_edited), (
|
||||
"Observations derived from the edited chunks' facts should have been invalidated"
|
||||
)
|
||||
assert obs_only_over_kept.issubset(observation_ids_v2), (
|
||||
"Observations derived only from the unchanged chunk must survive a partial edit"
|
||||
)
|
||||
|
||||
# And the follow-up consolidation the reporter ran — still no orphans.
|
||||
await memory.run_consolidation(bank_id=bank_id, request_context=request_context)
|
||||
await _assert_no_orphans(memory, bank_id, "after re-consolidating the edited document")
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delta_retain_removed_chunks_leave_no_orphan_observations(
|
||||
memory: MemoryEngine, request_context: RequestContext
|
||||
):
|
||||
"""Shortening a document orphans nothing either.
|
||||
|
||||
Delta deletes removed chunks through the same call as changed ones, so this
|
||||
covers the ``removed_indices`` half of that list — a document that shrinks loses
|
||||
facts without any replacement being extracted for them.
|
||||
"""
|
||||
bank_id = f"test_delta_orphan_shrink_{uuid.uuid4().hex[:8]}"
|
||||
document_id = "roster-doc"
|
||||
|
||||
try:
|
||||
await _retain_document(memory, bank_id, document_id, _DOCUMENT_V1, request_context)
|
||||
await memory.run_consolidation(bank_id=bank_id, request_context=request_context)
|
||||
|
||||
facts_v1 = await _facts(memory, bank_id)
|
||||
observations_v1 = await _observations(memory, bank_id)
|
||||
by_chunk_v1 = _facts_by_chunk(facts_v1)
|
||||
assert len(by_chunk_v1) >= 2, f"Setup: the document should span several chunks, got {list(by_chunk_v1)}"
|
||||
assert observations_v1, "Setup: consolidation should have produced observations to orphan"
|
||||
|
||||
first_chunk = sorted(by_chunk_v1)[0]
|
||||
kept_fact_ids = by_chunk_v1[first_chunk]
|
||||
dropped_fact_ids = {fid for chunk, ids in by_chunk_v1.items() if chunk != first_chunk for fid in ids}
|
||||
obs_over_dropped = {o.unit_id for o in observations_v1 if dropped_fact_ids.intersection(o.source_memory_ids)}
|
||||
assert obs_over_dropped, "Setup: the chunks being dropped should have observations derived from them"
|
||||
|
||||
# Re-ingest only the first block: every later chunk is removed outright.
|
||||
await _retain_document(memory, bank_id, document_id, _BLOCK_A, request_context)
|
||||
|
||||
surviving_fact_ids = {f.unit_id for f in await _facts(memory, bank_id)}
|
||||
assert kept_fact_ids.issubset(surviving_fact_ids), (
|
||||
"The retained chunk's facts should survive — if they did not, this test fell back "
|
||||
"to the full-replace path and no longer covers the bug"
|
||||
)
|
||||
assert not dropped_fact_ids.intersection(surviving_fact_ids), "The removed chunks' facts should be gone"
|
||||
|
||||
await _assert_no_orphans(memory, bank_id, "after shrinking the document")
|
||||
assert not {o.unit_id for o in await _observations(memory, bank_id)}.intersection(obs_over_dropped), (
|
||||
"Observations derived from the removed chunks' facts should have been invalidated"
|
||||
)
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delta_retain_unchanged_content_keeps_observations(memory: MemoryEngine, request_context: RequestContext):
|
||||
"""Re-submitting identical content deletes no chunk, so it sweeps no observation
|
||||
and requeues nothing for consolidation."""
|
||||
bank_id = f"test_delta_orphan_noop_{uuid.uuid4().hex[:8]}"
|
||||
document_id = "roster-doc"
|
||||
|
||||
try:
|
||||
await _retain_document(memory, bank_id, document_id, _DOCUMENT_V1, request_context)
|
||||
await memory.run_consolidation(bank_id=bank_id, request_context=request_context)
|
||||
observation_ids_v1 = {o.unit_id for o in await _observations(memory, bank_id)}
|
||||
assert observation_ids_v1
|
||||
|
||||
await _retain_document(memory, bank_id, document_id, _DOCUMENT_V1, request_context)
|
||||
|
||||
assert {o.unit_id for o in await _observations(memory, bank_id)} == observation_ids_v1, (
|
||||
"A no-op delta re-ingest must not touch existing observations"
|
||||
)
|
||||
assert all(f.consolidated_at is not None for f in await _facts(memory, bank_id)), (
|
||||
"A no-op delta re-ingest must not requeue facts for consolidation"
|
||||
)
|
||||
await _assert_no_orphans(memory, bank_id, "after a no-op re-ingest")
|
||||
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
@@ -0,0 +1,67 @@
|
||||
"""Regression test: list_banks must consult the per-bank capability with the
|
||||
*current row's* bank id, and source fact_count from the store when that bank
|
||||
keeps its memories outside SQL.
|
||||
|
||||
Bug (introduced with per-bank store capabilities, #3350): list_banks called
|
||||
``_store.writes_memory_rows_in_sql_for(bank_id)`` with a bare ``bank_id`` name
|
||||
that is not in scope inside the per-row loop (the row's id is ``row["bank_id"]``).
|
||||
Because the argument is evaluated before the call, this raised
|
||||
``NameError: name 'bank_id' is not defined`` for *every* org on the very first
|
||||
bank — i.e. GET /banks 500'd outright — regardless of the store's capability.
|
||||
|
||||
This test swaps in a store that reports ``writes_memory_rows_in_sql_for -> False``
|
||||
(the non-SQL branch the feature added), and asserts list_banks (a) does not raise,
|
||||
(b) calls the capability + count_memories with the correct per-bank id, and
|
||||
(c) surfaces the store's live count as fact_count.
|
||||
|
||||
Runs via: uv run pytest tests/test_list_banks_non_sql_store.py -v
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
import hindsight_api.engine.memories as memories_mod
|
||||
from hindsight_api.models import RequestContext
|
||||
|
||||
|
||||
class _NonSqlStore:
|
||||
"""A store that keeps memory rows outside SQL: list_banks must count via the store."""
|
||||
|
||||
def __init__(self):
|
||||
self.capability_calls: list[str] = []
|
||||
self.count_calls: list[str] = []
|
||||
|
||||
def writes_memory_rows_in_sql_for(self, bank_id: str) -> bool:
|
||||
self.capability_calls.append(bank_id)
|
||||
return False
|
||||
|
||||
async def count_memories(self, *, conn, fq_table, bank_id: str) -> dict:
|
||||
self.count_calls.append(bank_id)
|
||||
return {"world": 7}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_banks_counts_via_store_for_non_sql_bank(memory, monkeypatch):
|
||||
bank_id = "list_banks_non_sql_bank"
|
||||
request_context = RequestContext(api_key=None, api_key_id=None, tenant_id=None, internal=False)
|
||||
|
||||
store = _NonSqlStore()
|
||||
monkeypatch.setattr(memories_mod, "get_memories", lambda: store)
|
||||
|
||||
try:
|
||||
await memory.get_bank_profile(bank_id, request_context=request_context)
|
||||
|
||||
# Must not raise NameError; must reach the store's non-SQL count path.
|
||||
banks = await memory.list_banks(request_context=request_context)
|
||||
|
||||
entry = next((b for b in banks if b["bank_id"] == bank_id), None)
|
||||
assert entry is not None, f"bank {bank_id!r} not present in list_banks output"
|
||||
|
||||
# The capability + count were consulted with the row's real bank id.
|
||||
assert bank_id in store.capability_calls
|
||||
assert bank_id in store.count_calls
|
||||
# fact_count came from the store (sum of the per-type counts), not the empty SQL join.
|
||||
assert entry["fact_count"] == 7
|
||||
finally:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
@@ -0,0 +1,198 @@
|
||||
"""Structured output via a forced tool call on the LiteLLM-backed providers.
|
||||
|
||||
Covers the flag (HINDSIGHT_API_LLM_STRUCTURED_OUTPUT_FORCED_TOOL) end to end:
|
||||
config parsing, the config -> LLMProvider -> LiteLLMLLM wiring, the request shape
|
||||
it produces, and the response path that substitutes the tool call's arguments for
|
||||
the message content. Motivation: Bedrock Claude rejects the ``response_format``
|
||||
route outright (``output_config.format: Extra inputs are not permitted``, #3300)
|
||||
while accepting the identical schema as a tool.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from pydantic import BaseModel
|
||||
|
||||
from hindsight_api.config import ENV_LLM_STRUCTURED_OUTPUT_FORCED_TOOL, HindsightConfig
|
||||
from hindsight_api.engine.llm_wrapper import LLMConfig
|
||||
from hindsight_api.engine.providers.litellm_llm import LiteLLMLLM
|
||||
|
||||
_MESSAGES = [{"role": "user", "content": "hi"}]
|
||||
|
||||
|
||||
class _Facts(BaseModel):
|
||||
facts: list[str]
|
||||
|
||||
|
||||
def _make_litellm(*, forced_tool: bool) -> LiteLLMLLM:
|
||||
return LiteLLMLLM(
|
||||
provider="bedrock",
|
||||
api_key="",
|
||||
base_url="",
|
||||
model="bedrock/au.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
structured_output_forced_tool=forced_tool,
|
||||
)
|
||||
|
||||
|
||||
class _Function:
|
||||
def __init__(self, name: str, arguments: Any):
|
||||
self.name = name
|
||||
self.arguments = arguments
|
||||
|
||||
|
||||
class _ToolCall:
|
||||
def __init__(self, name: str, arguments: Any):
|
||||
self.id = "call_1"
|
||||
self.function = _Function(name, arguments)
|
||||
|
||||
|
||||
class _Message:
|
||||
def __init__(self, content: str | None, tool_calls: list[_ToolCall] | None = None):
|
||||
self.content = content
|
||||
self.tool_calls = tool_calls
|
||||
|
||||
|
||||
class _Choice:
|
||||
def __init__(self, message: _Message, finish_reason: str):
|
||||
self.message = message
|
||||
self.finish_reason = finish_reason
|
||||
|
||||
|
||||
class _Response:
|
||||
def __init__(self, message: _Message, finish_reason: str = "tool_calls"):
|
||||
self.choices = [_Choice(message, finish_reason)]
|
||||
self.usage = None
|
||||
|
||||
|
||||
async def _call_capturing_request(llm: LiteLLMLLM, response: _Response) -> dict[str, Any]:
|
||||
"""Run ``call`` against a stubbed completion and return the request kwargs."""
|
||||
completion = AsyncMock(return_value=response)
|
||||
llm._acompletion = completion # type: ignore[method-assign]
|
||||
result = await llm.call(messages=_MESSAGES, response_format=_Facts, max_retries=0)
|
||||
return {"kwargs": completion.await_args.kwargs, "result": result}
|
||||
|
||||
|
||||
# ── config ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_forced_tool_defaults_off(monkeypatch):
|
||||
monkeypatch.delenv(ENV_LLM_STRUCTURED_OUTPUT_FORCED_TOOL, raising=False)
|
||||
assert HindsightConfig.from_env().llm_structured_output_forced_tool is False
|
||||
|
||||
|
||||
def test_forced_tool_can_be_enabled(monkeypatch):
|
||||
monkeypatch.setenv(ENV_LLM_STRUCTURED_OUTPUT_FORCED_TOOL, "true")
|
||||
assert HindsightConfig.from_env().llm_structured_output_forced_tool is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["", "yes", "tru", "enabled"])
|
||||
def test_forced_tool_rejects_ambiguous_values(monkeypatch, value):
|
||||
monkeypatch.setenv(ENV_LLM_STRUCTURED_OUTPUT_FORCED_TOOL, value)
|
||||
|
||||
with pytest.raises(ValueError, match=ENV_LLM_STRUCTURED_OUTPUT_FORCED_TOOL):
|
||||
HindsightConfig.from_env()
|
||||
|
||||
|
||||
def test_llm_config_threads_flag_to_provider_impl():
|
||||
"""LLMConfig -> create_llm_provider -> LiteLLMLLM carries the flag.
|
||||
|
||||
Without this bridge the env var is inert: the provider silently keeps the
|
||||
default ``response_format`` transport.
|
||||
"""
|
||||
llm = LLMConfig(
|
||||
provider="bedrock",
|
||||
api_key="",
|
||||
base_url="",
|
||||
model="au.anthropic.claude-haiku-4-5-20251001-v1:0",
|
||||
structured_output_forced_tool=True,
|
||||
)
|
||||
assert llm._provider_impl.structured_output_forced_tool is True
|
||||
|
||||
|
||||
# ── request shape ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forced_tool_replaces_response_format_with_a_forced_tool():
|
||||
llm = _make_litellm(forced_tool=True)
|
||||
response = _Response(_Message(None, [_ToolCall("structured_response", '{"facts": ["the sky is blue"]}')]))
|
||||
|
||||
captured = await _call_capturing_request(llm, response)
|
||||
|
||||
kwargs = captured["kwargs"]
|
||||
assert "response_format" not in kwargs
|
||||
assert kwargs["tool_choice"] == {"type": "function", "function": {"name": "structured_response"}}
|
||||
assert len(kwargs["tools"]) == 1
|
||||
function = kwargs["tools"][0]["function"]
|
||||
assert function["name"] == "structured_response"
|
||||
assert function["parameters"] == _Facts.model_json_schema()
|
||||
assert captured["result"] == _Facts(facts=["the sky is blue"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_flag_off_keeps_response_format():
|
||||
llm = _make_litellm(forced_tool=False)
|
||||
response = _Response(_Message('{"facts": ["the sky is blue"]}'), finish_reason="stop")
|
||||
|
||||
captured = await _call_capturing_request(llm, response)
|
||||
|
||||
kwargs = captured["kwargs"]
|
||||
assert "tools" not in kwargs
|
||||
assert "tool_choice" not in kwargs
|
||||
assert kwargs["response_format"]["json_schema"]["schema"] == _Facts.model_json_schema()
|
||||
assert captured["result"] == _Facts(facts=["the sky is blue"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_plain_calls_are_untouched_by_the_flag():
|
||||
"""No ``response_format`` -> no tool is forced, so free-text calls still work."""
|
||||
llm = _make_litellm(forced_tool=True)
|
||||
completion = AsyncMock(return_value=_Response(_Message("hello"), finish_reason="stop"))
|
||||
llm._acompletion = completion # type: ignore[method-assign]
|
||||
|
||||
result = await llm.call(messages=_MESSAGES, max_retries=0)
|
||||
|
||||
assert result == "hello"
|
||||
assert "tools" not in completion.await_args.kwargs
|
||||
|
||||
|
||||
# ── response path ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forced_tool_accepts_already_decoded_arguments():
|
||||
"""Some providers hand back decoded arguments instead of a JSON string."""
|
||||
llm = _make_litellm(forced_tool=True)
|
||||
response = _Response(_Message(None, [_ToolCall("structured_response", {"facts": ["grass is green"]})]))
|
||||
|
||||
captured = await _call_capturing_request(llm, response)
|
||||
|
||||
assert captured["result"] == _Facts(facts=["grass is green"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_falls_back_to_text_when_the_tool_call_is_missing():
|
||||
"""A gateway that drops ``tool_choice`` must not hard-fail the call."""
|
||||
llm = _make_litellm(forced_tool=True)
|
||||
response = _Response(_Message('{"facts": ["parsed from text"]}'), finish_reason="stop")
|
||||
|
||||
captured = await _call_capturing_request(llm, response)
|
||||
|
||||
assert captured["result"] == _Facts(facts=["parsed from text"])
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_skip_validation_returns_the_raw_tool_arguments():
|
||||
llm = _make_litellm(forced_tool=True)
|
||||
response = _Response(_Message(None, [_ToolCall("structured_response", '{"facts": ["raw"]}')]))
|
||||
llm._acompletion = AsyncMock(return_value=response) # type: ignore[method-assign]
|
||||
|
||||
result = await llm.call(
|
||||
messages=_MESSAGES,
|
||||
response_format=_Facts,
|
||||
skip_validation=True,
|
||||
max_retries=0,
|
||||
)
|
||||
|
||||
assert result == {"facts": ["raw"]}
|
||||
@@ -195,6 +195,7 @@ def _make_router_provider(config: dict[str, Any], mock_router: Any) -> LiteLLMRo
|
||||
provider.reasoning_effort = "low"
|
||||
provider.timeout = 300.0
|
||||
provider._default_headers = {}
|
||||
provider.structured_output_forced_tool = False
|
||||
provider.config = config
|
||||
provider._litellm = fake_litellm
|
||||
provider._router = mock_router
|
||||
|
||||
@@ -601,6 +601,59 @@ async def test_maintenance_passes_are_optional(restore_default_store):
|
||||
await store.record_unit_entities(conn=None, ops=None, fq_table=None, unit_ids=["u"], entity_ids=["e"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-bank store capabilities. A store may route different banks to different
|
||||
# backends, so every BANK-SCOPED call site asks per bank —
|
||||
# writes_memory_rows_in_sql_for(bank_id) / owns_document_store_for(bank_id) —
|
||||
# rather than reading the process-global class attribute. The class attribute
|
||||
# stays the single-store default the _for methods fall back to.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_per_bank_capability_defaults_to_the_class_attribute():
|
||||
"""A single-store extension needs no override: the _for methods return the class attr, so
|
||||
every existing store keeps its exact behaviour for every bank."""
|
||||
pg = PostgresMemories({})
|
||||
assert (pg.writes_memory_rows_in_sql, pg.owns_document_store) == (True, False)
|
||||
assert pg.writes_memory_rows_in_sql_for("any-bank") is True
|
||||
assert pg.owns_document_store_for("any-bank") is False
|
||||
|
||||
mem = InMemoryMemories({}) # owns its rows AND its document store
|
||||
assert mem.writes_memory_rows_in_sql_for("any-bank") is False
|
||||
assert mem.owns_document_store_for("any-bank") is True
|
||||
|
||||
|
||||
def test_a_store_answers_capabilities_per_bank():
|
||||
"""The point of the _for methods: a store that keeps some banks in SQL and others in a
|
||||
separate store answers PER BANK, so mixed banks in one process each take the right path."""
|
||||
|
||||
class PerBankStore(InMemoryMemories):
|
||||
name = "per-bank"
|
||||
# The loop-level class attr stays False so cross-store txn recovery still runs; the
|
||||
# per-bank answer is what every bank-scoped site consults.
|
||||
writes_memory_rows_in_sql = False
|
||||
|
||||
def __init__(self, config=None):
|
||||
super().__init__(config)
|
||||
self.sql_banks = {"legacy-bank"}
|
||||
|
||||
def writes_memory_rows_in_sql_for(self, bank_id):
|
||||
return bank_id in self.sql_banks
|
||||
|
||||
def owns_document_store_for(self, bank_id):
|
||||
return bank_id not in self.sql_banks
|
||||
|
||||
store = PerBankStore({})
|
||||
# A SQL-backed bank looks like Postgres (host does inline SQL, keeps documents in SQL)...
|
||||
assert store.writes_memory_rows_in_sql_for("legacy-bank") is True
|
||||
assert store.owns_document_store_for("legacy-bank") is False
|
||||
# ...a store-backed bank owns its rows and its document store.
|
||||
assert store.writes_memory_rows_in_sql_for("new-bank") is False
|
||||
assert store.owns_document_store_for("new-bank") is True
|
||||
# The process-level gate (cross-store recovery loop) still fires off the class attr.
|
||||
assert store.writes_memory_rows_in_sql is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Interface conformance: the stub must stay a COMPLETE, signature-compatible
|
||||
# implementation of every MemoriesExtension method. This is the guard that keeps
|
||||
|
||||
@@ -33,6 +33,7 @@ async def _insert_memory(
|
||||
text: str,
|
||||
fact_type: str = "experience",
|
||||
document_id: str | None = None,
|
||||
chunk_id: str | None = None,
|
||||
) -> uuid.UUID:
|
||||
"""Seed one memory through the store, bypassing the LLM retain pipeline.
|
||||
|
||||
@@ -47,7 +48,7 @@ async def _insert_memory(
|
||||
tags=[],
|
||||
context=None,
|
||||
document_id=document_id,
|
||||
chunk_id=None,
|
||||
chunk_id=chunk_id,
|
||||
metadata=None,
|
||||
observation_scopes=None,
|
||||
entities=[],
|
||||
@@ -435,6 +436,187 @@ class TestDocumentUpsertObservationCleanup:
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: delta retain chunk delete (regression for orphan observations, #3294)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _seed_chunked_document(memory: MemoryEngine, conn, bank_id: str, chunk_texts: list[str]) -> tuple[str, list]:
|
||||
"""One document with ``len(chunk_texts)`` chunks, each owning one fact.
|
||||
|
||||
Returns the document id and, per chunk, the (chunk_id, fact_id) it owns.
|
||||
"""
|
||||
doc_id = str(uuid.uuid4())
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO documents (id, bank_id, original_text, content_hash, created_at, updated_at)
|
||||
VALUES ($1, $2, $3, 'hash-old', NOW(), NOW())
|
||||
""",
|
||||
doc_id,
|
||||
bank_id,
|
||||
"\n\n".join(chunk_texts),
|
||||
)
|
||||
seeded = []
|
||||
for idx, text in enumerate(chunk_texts):
|
||||
chunk_id = f"{bank_id}_{doc_id}_{idx}"
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO chunks (chunk_id, document_id, bank_id, chunk_index, chunk_text, content_hash)
|
||||
VALUES ($1, $2, $3, $4, $5, $6)
|
||||
""",
|
||||
chunk_id,
|
||||
doc_id,
|
||||
bank_id,
|
||||
idx,
|
||||
text,
|
||||
f"chunk-hash-{idx}",
|
||||
)
|
||||
fact_id = await _insert_memory(memory, conn, bank_id, text, "experience", document_id=doc_id, chunk_id=chunk_id)
|
||||
seeded.append((chunk_id, fact_id))
|
||||
return doc_id, seeded
|
||||
|
||||
|
||||
class TestDeltaChunkDeleteObservationCleanup:
|
||||
"""Regression: delta retain drops facts by deleting their chunks, and that
|
||||
cascade must invalidate the observations derived from them.
|
||||
|
||||
``handle_document_tracking`` (full replace) sweeps observations before its
|
||||
delete, but the delta path never calls it — it upserts the document row and
|
||||
deletes the changed/removed chunks directly, cascading to memory_units. Every
|
||||
delta re-ingest therefore used to leave the observations of the changed chunks
|
||||
behind, valid and recallable, pointing at ids that no longer exist. Nothing
|
||||
could reach them afterwards: consolidation batches are built from facts, so an
|
||||
observation whose sources are all gone is never selected into a batch again.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chunk_delete_removes_observations_from_outgoing_memories(
|
||||
self, memory: MemoryEngine, request_context: RequestContext
|
||||
):
|
||||
"""The observation of a deleted chunk's fact goes with it; its surviving
|
||||
co-source is requeued for re-consolidation."""
|
||||
from hindsight_api.engine.retain.chunk_storage import delete_chunks_by_ids
|
||||
|
||||
bank_id = f"test-delta-obs-cleanup-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(memory, bank_id, request_context)
|
||||
pool = await memory._get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
_doc_id, seeded = await _seed_chunked_document(
|
||||
memory, conn, bank_id, ["Alice works at Google.", "Bob works at Microsoft."]
|
||||
)
|
||||
(outgoing_chunk, outgoing_fact), (_kept_chunk, kept_fact) = seeded
|
||||
standalone_fact = await _insert_memory(memory, conn, bank_id, "Carol works at Netflix.")
|
||||
obs_id = await _insert_observation(
|
||||
memory,
|
||||
conn,
|
||||
bank_id,
|
||||
"The team is spread across Google, Microsoft and Netflix.",
|
||||
[outgoing_fact, kept_fact, standalone_fact],
|
||||
)
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
invalidated = await delete_chunks_by_ids(conn, [outgoing_chunk], bank_id, ops=memory._backend.ops)
|
||||
|
||||
assert invalidated == 1, "delete_chunks_by_ids should report the observation it invalidated"
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
assert str(obs_id) not in await _get_observation_ids(conn, bank_id), (
|
||||
"Observation derived from the deleted chunk's fact should have been invalidated "
|
||||
"(regression #3294: the delta path cascaded the fact away and left the orphan)"
|
||||
)
|
||||
assert await _count_surviving(conn, bank_id, [outgoing_fact]) == 0, "The chunk's fact is gone"
|
||||
# Both surviving co-sources lost an observation, so both are due for re-consolidation.
|
||||
for survivor in (kept_fact, standalone_fact):
|
||||
assert await _get_consolidated_at(conn, survivor, bank_id) is None, (
|
||||
"Surviving co-source should be reset for re-consolidation"
|
||||
)
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chunk_delete_keeps_observations_of_unchanged_chunks(
|
||||
self, memory: MemoryEngine, request_context: RequestContext
|
||||
):
|
||||
"""Precision: an observation sourced only from chunks that stay is untouched.
|
||||
|
||||
A sweep keyed on the document rather than on the deleted chunks would take
|
||||
this one too — and needlessly requeue the whole document for consolidation
|
||||
on every small edit, which is exactly the case delta retain exists for.
|
||||
"""
|
||||
from hindsight_api.engine.retain.chunk_storage import delete_chunks_by_ids
|
||||
|
||||
bank_id = f"test-delta-obs-keep-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(memory, bank_id, request_context)
|
||||
pool = await memory._get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
_doc_id, seeded = await _seed_chunked_document(
|
||||
memory, conn, bank_id, ["Alice works at Google.", "Bob works at Microsoft."]
|
||||
)
|
||||
(outgoing_chunk, _outgoing_fact), (_kept_chunk, kept_fact) = seeded
|
||||
kept_obs = await _insert_observation(memory, conn, bank_id, "Bob is at Microsoft.", [kept_fact])
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
invalidated = await delete_chunks_by_ids(conn, [outgoing_chunk], bank_id, ops=memory._backend.ops)
|
||||
|
||||
assert invalidated == 0, "No observation of the surviving chunk should have been touched"
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
assert str(kept_obs) in await _get_observation_ids(conn, bank_id), (
|
||||
"Observation of an unchanged chunk must survive the delta delete"
|
||||
)
|
||||
assert await _get_consolidated_at(conn, kept_fact, bank_id) is not None, (
|
||||
"An untouched fact must not be requeued for consolidation"
|
||||
)
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_chunk_delete_sweeps_every_deleted_chunk(self, memory: MemoryEngine, request_context: RequestContext):
|
||||
"""Multiple chunks in one call: each one's observations are swept.
|
||||
|
||||
The reporter's bank lost the observations of a whole document at once
|
||||
(25 fully orphaned from a single replace), so the sweep must cover the
|
||||
entire ``chunks_to_delete`` list, not just the first entry.
|
||||
"""
|
||||
from hindsight_api.engine.retain.chunk_storage import delete_chunks_by_ids
|
||||
|
||||
bank_id = f"test-delta-obs-multi-{uuid.uuid4().hex[:8]}"
|
||||
await _ensure_bank(memory, bank_id, request_context)
|
||||
pool = await memory._get_pool()
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
_doc_id, seeded = await _seed_chunked_document(
|
||||
memory,
|
||||
conn,
|
||||
bank_id,
|
||||
["Alice works at Google.", "Bob works at Microsoft.", "Dan works at Apple."],
|
||||
)
|
||||
observations = [
|
||||
await _insert_observation(memory, conn, bank_id, f"Observation of chunk {i}.", [fact])
|
||||
for i, (_chunk, fact) in enumerate(seeded)
|
||||
]
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
async with conn.transaction():
|
||||
invalidated = await delete_chunks_by_ids(
|
||||
conn, [chunk for chunk, _fact in seeded], bank_id, ops=memory._backend.ops
|
||||
)
|
||||
|
||||
assert invalidated == 3
|
||||
|
||||
async with pool.acquire() as conn:
|
||||
remaining = await _get_observation_ids(conn, bank_id)
|
||||
assert [o for o in observations if str(o) in remaining] == [], (
|
||||
"Every deleted chunk's observations should be swept, not just the first chunk's"
|
||||
)
|
||||
|
||||
await memory.delete_bank(bank_id, request_context=request_context)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: delete_bank with fact_type filter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -50,6 +50,9 @@ INTEGRATIONS: dict[str, IntegrationMeta] = {
|
||||
"nemoclaw": IntegrationMeta("@vectorize-io/hindsight-nemoclaw", "NemoClaw"),
|
||||
"strands": IntegrationMeta("hindsight-strands", "Strands"),
|
||||
"claude-code": IntegrationMeta("hindsight-memory", "Claude Code"),
|
||||
# Git-distributed plugin bundle (Agent Plugins standard), not a registry
|
||||
# package — its changelog links to the source tree (see _package_url).
|
||||
"agent-plugin": IntegrationMeta("hindsight-agent-plugin", "Agent Plugins"),
|
||||
"zcode": IntegrationMeta("hindsight-zcode", "ZCode"),
|
||||
"claude-agent-sdk": IntegrationMeta("hindsight-claude-agent-sdk", "Claude Agent SDK"),
|
||||
"llamaindex": IntegrationMeta("hindsight-llamaindex", "LlamaIndex"),
|
||||
@@ -657,8 +660,10 @@ def _get_package_name(integration: str) -> str:
|
||||
|
||||
|
||||
def _package_url(integration: str, package_name: str) -> str:
|
||||
if integration == "claude-code":
|
||||
return "https://github.com/vectorize-io/hindsight/tree/main/hindsight-integrations/claude-code"
|
||||
# Git-distributed plugin bundles have no npm/pypi package — link to the
|
||||
# source tree instead of a registry page.
|
||||
if integration in ("claude-code", "agent-plugin"):
|
||||
return f"https://github.com/vectorize-io/hindsight/tree/main/hindsight-integrations/{integration}"
|
||||
if package_name.startswith("@"):
|
||||
return f"https://www.npmjs.com/package/{package_name}"
|
||||
return f"https://pypi.org/project/{package_name}/"
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
---
|
||||
sidebar_position: 2
|
||||
title: "Agent Plugins Persistent Memory with Hindsight | Integration Guide"
|
||||
description: "Give any Agent Plugins client — Codex, Cursor, GitHub Copilot, Kiro, VS Code — long-term memory with Hindsight. One portable, standards-based plugin bundles the Hindsight MCP server and a memory skill for recall, retain, and reflect."
|
||||
---
|
||||
|
||||
# Agent Plugins
|
||||
|
||||
Portable long-term memory for any [Agent Plugins](https://agent-plugins.org) client, powered by [Hindsight](https://vectorize.io/hindsight).
|
||||
|
||||
[Agent Plugins](https://agent-plugins.org) is the vendor-neutral open standard (developed with Amazon, Cursor, Microsoft, OpenAI, and Vercel) for packaging **Agent Skills + MCP servers** into a single distributable plugin. Instead of a separate integration per tool, Hindsight ships **one** plugin that every compatible client can load — at launch: **ChatGPT / Codex, Cursor, GitHub Copilot, Kiro, and VS Code**.
|
||||
|
||||
## Quick Start
|
||||
|
||||
:::tip Recommended: Hindsight Cloud
|
||||
[Sign up free](https://ui.hindsight.vectorize.io/signup) for a Hindsight Cloud API key — no self-hosting, no local daemon to manage.
|
||||
:::
|
||||
|
||||
1. Get your `hsk_...` API key from [ui.hindsight.vectorize.io/connect](https://ui.hindsight.vectorize.io/connect).
|
||||
2. Set the environment variables the plugin reads:
|
||||
|
||||
```bash
|
||||
export HINDSIGHT_API_KEY="hsk_your_token"
|
||||
export HINDSIGHT_BANK_ID="my-project" # optional; defaults to "default"
|
||||
```
|
||||
|
||||
3. Install the plugin in your client (through its plugin/MCP UI, or by pointing it at the plugin directory — installation is client-specific per the standard).
|
||||
|
||||
Once installed, ask the agent something that depends on past context, or tell it a durable preference — it calls `recall` and `retain` automatically, guided by the bundled skill.
|
||||
|
||||
## What's in the plugin
|
||||
|
||||
The plugin is a thin, transport-only wrapper — all memory logic stays server-side in Hindsight. It follows the Agent Plugins `1.0.0` layout:
|
||||
|
||||
```
|
||||
agent-plugin/
|
||||
├── plugin.json # manifest ($schema + name + metadata)
|
||||
├── mcp.json # Hindsight MCP server (Streamable HTTP)
|
||||
└── skills/
|
||||
└── hindsight-memory/
|
||||
└── SKILL.md # teaches the agent when to recall / retain / reflect
|
||||
```
|
||||
|
||||
- **`mcp.json`** connects the client to Hindsight's built-in [MCP server](/developer/mcp-server) over Streamable HTTP.
|
||||
- **`skills/hindsight-memory/SKILL.md`** is loaded into the agent's context so it knows *when* to reach for memory, not just that the tools exist.
|
||||
|
||||
## Memory tools
|
||||
|
||||
Via the MCP server, the agent gets Hindsight's full memory surface. The three it reaches for most:
|
||||
|
||||
| Tool | When | What it does |
|
||||
|------|------|--------------|
|
||||
| `recall` | Before answering, when past context could help | Semantic + keyword + graph + temporal retrieval over the bank |
|
||||
| `retain` | After learning a durable, reusable fact | Stores the fact for future sessions |
|
||||
| `reflect` | When a lookup is too shallow and you need synthesized reasoning | Disposition-aware reasoning over everything remembered |
|
||||
|
||||
Additional tools (knowledge pages, mental models, documents, tags) are exposed too — see the [MCP Server reference](/developer/mcp-server).
|
||||
|
||||
## Configuration
|
||||
|
||||
The plugin reads two environment variables, interpolated into `mcp.json`:
|
||||
|
||||
| Setting | Env Var | Default | Description |
|
||||
|---------|---------|---------|-------------|
|
||||
| API key | `HINDSIGHT_API_KEY` | — | Your `hsk_...` key. Sent as `Authorization: Bearer`. Required for Hindsight Cloud. |
|
||||
| Memory bank | `HINDSIGHT_BANK_ID` | `default` | Bank to read from and write to (sent as `X-Bank-Id`). Use one bank per user, project, or team for isolation. |
|
||||
|
||||
:::note Env-var syntax varies by client
|
||||
Most clients substitute `${VAR}`; some (VS Code, Cursor) use `${env:VAR}`. If your client doesn't interpolate, paste the literal key and bank id into `mcp.json`.
|
||||
:::
|
||||
|
||||
**Self-hosting:** replace the host in `mcp.json` (`https://api.hindsight.vectorize.io`) with your deployment's URL. A local server with the MCP endpoint open needs no API key.
|
||||
|
||||
## Explicit tools vs. automatic capture
|
||||
|
||||
Agent Plugins `1.0.0` standardizes **Skills + MCP**, not session lifecycle hooks. This plugin therefore delivers **explicit, tool-driven** memory that works identically across every supported client.
|
||||
|
||||
For the fully automatic experience — recall injected before every prompt and transcripts retained on session end — use the native, hook-based integration built for your specific tool, such as [Claude Code](/sdks/integrations/claude-code) or [Codex](/sdks/integrations/codex). Both share the same Hindsight banks, so memory captured by the hook-based integration is recalled through the Agent Plugin, and vice versa.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**No memories recalled**: `recall` returns results only after something has been retained. Retain a fact first, or seed the bank via the [API](/developer/api/quickstart).
|
||||
|
||||
**401 Unauthorized**: Check `HINDSIGHT_API_KEY` is set and your client is interpolating it into the `Authorization` header (see the env-var syntax note above).
|
||||
|
||||
**Wrong or empty memory**: Confirm `HINDSIGHT_BANK_ID` points at the bank you expect. Different tools writing to different banks won't share memory.
|
||||
|
||||
## Learn more
|
||||
|
||||
- [Agent Plugins standard](https://agent-plugins.org)
|
||||
- [Hindsight MCP Server reference](/developer/mcp-server)
|
||||
- [Hindsight Cloud sign-up](https://ui.hindsight.vectorize.io/signup)
|
||||
@@ -198,6 +198,7 @@ For non-English banks (especially CJK) and the language/extraction-language trad
|
||||
| `HINDSIGHT_API_LLM_STRICT_SCHEMA_REFLECT` | Override `HINDSIGHT_API_LLM_STRICT_SCHEMA` for reflect's structured-output extraction only. | Inherits global |
|
||||
| `HINDSIGHT_API_LLM_STRICT_SCHEMA_CONSOLIDATION` | Override `HINDSIGHT_API_LLM_STRICT_SCHEMA` for consolidation only (both the batch consolidation call and observation dedup). | Inherits global |
|
||||
| `HINDSIGHT_API_LLM_SUPPORTS_MAX_ITEMS` | Whether the LLM backend accepts JSON Schema `maxItems` in structured-output schemas. Set to `false` for backends such as Bedrock Converse that reject this keyword; consolidation still enforces observation caps after parsing. | `true` |
|
||||
| `HINDSIGHT_API_LLM_STRUCTURED_OUTPUT_FORCED_TOOL` | Request structured output from the LiteLLM-backed providers (`litellm`, `litellmrouter`, `bedrock`) with a single forced tool call — the response schema becomes the tool's parameters — instead of `response_format`. Set to `true` for backends that reject `response_format` outright. This is region-dependent on Bedrock Claude: `ap-southeast-2` (`au.*` inference profiles) refuses the translated Converse `outputConfig` with `Extra inputs are not permitted`, while the same model in `us-east-1` (`us.*`) accepts it and needs nothing here. Verified against both. If the model answers without calling the tool, the reply is parsed as text as before. Other providers ignore it. | `false` |
|
||||
| `HINDSIGHT_API_LLM_OLLAMA_NUM_CTX` | Optional native Ollama `num_ctx` override for structured-output calls. Leave unset to use the model/server default; set a positive integer only when you need a larger context window. | Unset |
|
||||
| `HINDSIGHT_API_LLM_GEMINI_SAFETY_SETTINGS` | JSON-encoded list of `{category, threshold}` dicts for Gemini/VertexAI content safety filtering | `null` |
|
||||
| `HINDSIGHT_API_LLM_PROMPT_CACHE_ENABLED` | Reuse the fixed system prefix via the provider's explicit prompt cache, billed at the cached-input rate (Gemini/Vertex `CachedContent`). The cached prefix is shared across all banks and soft-fails to an uncached call. Set to `false` to disable. See [Models](./models#provider-capabilities). | `true` |
|
||||
|
||||
@@ -27,7 +27,9 @@ Used for fact extraction, entity resolution, mental model consolidation, and ans
|
||||
Also supports **any OpenAI-compatible API** (e.g., Azure OpenAI, Together AI, Fireworks) and **100+ providers via LiteLLM** (e.g., AWS Bedrock, Azure OpenAI, Together AI).
|
||||
|
||||
:::tip OpenAI-Compatible Providers
|
||||
Hindsight works with any provider that exposes an OpenAI-compatible API (e.g., Azure OpenAI). Simply set `HINDSIGHT_API_LLM_PROVIDER=openai` and configure `HINDSIGHT_API_LLM_BASE_URL` to point to your provider's endpoint.
|
||||
Hindsight works with any provider that exposes an OpenAI-compatible API. Set `HINDSIGHT_API_LLM_PROVIDER=openai` and point `HINDSIGHT_API_LLM_BASE_URL` at the endpoint that serves `/chat/completions` — for most providers that is the URL ending in `/v1`, **not** the account or resource root.
|
||||
|
||||
**Azure OpenAI does not serve the API at the resource root**, so `https://<resource>.openai.azure.com` on its own returns `404 Resource not found`. See [Azure OpenAI Setup](#azure-openai-setup) for the two URL shapes that work.
|
||||
|
||||
The `openai` provider talks to the **Chat Completions API** (`/v1/chat/completions`). For the newer **Responses API** (`/v1/responses`), use `HINDSIGHT_API_LLM_PROVIDER=openai-responses` — see the tip below. Both accept a custom `HINDSIGHT_API_LLM_BASE_URL`, so an OpenAI-compatible endpoint that exposes `/v1/responses` works the same way as a Chat Completions one.
|
||||
|
||||
@@ -576,6 +578,56 @@ one, run one replica on this provider and give the others an API-key lane.
|
||||
|
||||
---
|
||||
|
||||
### Azure OpenAI Setup
|
||||
|
||||
Azure OpenAI is reached through the **`openai`** provider — there is no `azure`
|
||||
provider, and setting one fails at startup with
|
||||
`Invalid LLM provider: azure`.
|
||||
|
||||
The one thing that trips people up is the base URL. Azure does not serve the
|
||||
OpenAI API at the resource root, so the endpoint shown in the Azure portal is
|
||||
not usable on its own:
|
||||
|
||||
| `HINDSIGHT_API_LLM_BASE_URL` | Result |
|
||||
|---|---|
|
||||
| `https://<resource>.openai.azure.com` | `404 Resource not found` |
|
||||
| `https://<resource>.openai.azure.com/openai/deployments/<deployment>` | `404 Resource not found` (no `api-version`) |
|
||||
| `https://<resource>.openai.azure.com/openai/v1` | works |
|
||||
| `https://<resource>.openai.azure.com/openai/deployments/<deployment>?api-version=<version>` | works |
|
||||
|
||||
**Recommended — the v1 surface:**
|
||||
|
||||
```bash
|
||||
export HINDSIGHT_API_LLM_PROVIDER=openai
|
||||
export HINDSIGHT_API_LLM_API_KEY=<azure-openai-resource-key>
|
||||
export HINDSIGHT_API_LLM_MODEL=<deployment-name>
|
||||
export HINDSIGHT_API_LLM_BASE_URL=https://<resource>.openai.azure.com/openai/v1
|
||||
```
|
||||
|
||||
**Or the deployment-scoped form.** Keep the `api-version` query string — Hindsight
|
||||
parses it out of the base URL and passes it to the SDK:
|
||||
|
||||
```bash
|
||||
export HINDSIGHT_API_LLM_BASE_URL=https://<resource>.openai.azure.com/openai/deployments/<deployment>?api-version=2025-01-01-preview
|
||||
```
|
||||
|
||||
**Important notes:**
|
||||
- `HINDSIGHT_API_LLM_MODEL` is your **deployment name**, not the model name. A
|
||||
`gpt-4o` deployed as `my-gpt4o` is configured as `my-gpt4o`.
|
||||
- The key is the Azure OpenAI **resource** key (`az cognitiveservices account
|
||||
keys list -n <resource> -g <group>`). An API Management subscription key is a
|
||||
different credential: with APIM in front, the base URL must be the APIM route
|
||||
and APIM has to forward the `api-key` header. Test against the Azure endpoint
|
||||
directly first to isolate which layer is failing.
|
||||
- Gateways and proxies must preserve the same path shape (`/openai/v1` or
|
||||
`/openai/deployments/...?api-version=`).
|
||||
- Azure accepts the `prompt_cache_key` field that
|
||||
[`HINDSIGHT_API_LLM_CACHE_AFFINITY`](./configuration#llm-provider) sends under
|
||||
`auto`, on every `api-version` from `2024-02-01` onward, so the default needs
|
||||
no adjustment for Azure.
|
||||
|
||||
---
|
||||
|
||||
### Vertex AI Setup (Google Cloud)
|
||||
|
||||
Google Cloud's Vertex AI provides access to Gemini models via the native Google GenAI SDK.
|
||||
|
||||
@@ -1,5 +1,15 @@
|
||||
{
|
||||
"integrations": [
|
||||
{
|
||||
"id": "agent-plugin",
|
||||
"name": "Agent Plugins",
|
||||
"description": "Portable long-term memory for any Agent Plugins client (Codex, Cursor, GitHub Copilot, Kiro, VS Code). One standards-based plugin bundles Hindsight's MCP server and a memory skill — recall, retain, and reflect everywhere.",
|
||||
"type": "official",
|
||||
"by": "hindsight",
|
||||
"category": "tool",
|
||||
"link": "/sdks/integrations/agent-plugin",
|
||||
"icon": "/img/icons/mcp.png"
|
||||
},
|
||||
{
|
||||
"id": "coding-agents",
|
||||
"name": "Coding Agents",
|
||||
|
||||
@@ -48,6 +48,12 @@ HINDSIGHT_API_LLM_BASE_URL=https://api.openai.com/v1
|
||||
# HINDSIGHT_API_REFLECT_LLM_CACHE_AFFINITY=xai_conv_id
|
||||
# HINDSIGHT_API_CONSOLIDATION_LLM_CACHE_AFFINITY=none
|
||||
|
||||
# Ask litellm/litellmrouter/bedrock for structured output via a forced tool call
|
||||
# instead of response_format. Enable it for backends that reject response_format
|
||||
# outright -- e.g. Bedrock Claude in ap-southeast-2 ("Extra inputs are not permitted");
|
||||
# the same model in us-east-1 accepts response_format and needs nothing here.
|
||||
# HINDSIGHT_API_LLM_STRUCTURED_OUTPUT_FORCED_TOOL=false
|
||||
|
||||
# Diagnostic: on any LLM 4xx, log the exact assembled request ([LLM_4XX_DUMP]) --
|
||||
# serialized request config (message bodies stripped) + capped per-message previews.
|
||||
# For debugging otherwise-unreproducible rejected calls. Off by default.
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
# Hindsight — Agent Plugin
|
||||
|
||||
A portable [Agent Plugin](https://agent-plugins.org) (spec `1.0.0`) that gives any
|
||||
compatible agent client long-term memory via [Hindsight](https://hindsight.vectorize.io).
|
||||
|
||||
Agent Plugins is the vendor-neutral standard (AWS, Cursor, GitHub/Microsoft, OpenAI,
|
||||
Vercel) for packaging **Agent Skills + MCP servers** into one distributable plugin. At
|
||||
launch it is supported by **ChatGPT/Codex, Cursor, GitHub Copilot, Kiro, and VS Code**.
|
||||
|
||||
This is the *portable* front door to Hindsight: one artifact, every supported client. It
|
||||
carries the same `retain` / `recall` / `reflect` memory as our per-IDE integrations, but
|
||||
as a single standards-based bundle instead of N hand-rolled configs.
|
||||
|
||||
## What's in the bundle
|
||||
|
||||
```
|
||||
agent-plugin/
|
||||
├── plugin.json # manifest ($schema + name + metadata)
|
||||
├── mcp.json # Hindsight MCP server (Streamable HTTP)
|
||||
└── skills/
|
||||
└── hindsight-memory/
|
||||
└── SKILL.md # teaches the agent when to recall/retain/reflect
|
||||
```
|
||||
|
||||
- **`mcp.json`** points the client at Hindsight's built-in MCP server (retain, recall,
|
||||
reflect, knowledge pages, and more — see [MCP Server docs](https://hindsight.vectorize.io/developer/mcp-server)).
|
||||
The plugin is transport-only; all memory logic stays server-side.
|
||||
- **`skills/hindsight-memory/SKILL.md`** is loaded into the agent's context so it knows
|
||||
*when* to reach for memory, not just that the tools exist.
|
||||
|
||||
## Configuration
|
||||
|
||||
The plugin reads two environment variables (values are interpolated into `mcp.json`):
|
||||
|
||||
| Variable | Required | Purpose |
|
||||
|----------|----------|---------|
|
||||
| `HINDSIGHT_API_KEY` | yes (Cloud) | Your `hsk_...` key from [ui.hindsight.vectorize.io/connect](https://ui.hindsight.vectorize.io/connect). Sent as `Authorization: Bearer`. |
|
||||
| `HINDSIGHT_BANK_ID` | no | Memory bank to scope to (sent as `X-Bank-Id`). Defaults to `default`. Use one bank per user/project/team. |
|
||||
|
||||
**Self-hosting:** replace the host in `mcp.json` (`https://api.hindsight.vectorize.io`)
|
||||
with your deployment's URL. A local server with the MCP endpoint open needs no API key.
|
||||
|
||||
> Env-var interpolation syntax varies by client. Most use `${VAR}`; some (VS Code,
|
||||
> Cursor) prefer `${env:VAR}`. If your client doesn't substitute, paste the literal key
|
||||
> and bank id into `mcp.json` instead.
|
||||
|
||||
## Install
|
||||
|
||||
Installation and distribution are intentionally left to each client by the spec. Common
|
||||
paths:
|
||||
|
||||
- **VS Code / GitHub Copilot / Cursor / Kiro** — add this plugin directory through the
|
||||
client's plugin/MCP UI, or drop it where the client discovers plugins, then set the
|
||||
two environment variables above.
|
||||
- **Codex / ChatGPT** — register the plugin per the client's Agent Plugins support.
|
||||
|
||||
Once installed, ask the agent something that depends on past context (or tell it a
|
||||
durable preference) and it will call `recall` / `retain` automatically, guided by the
|
||||
skill.
|
||||
|
||||
## Want automatic capture (hooks)?
|
||||
|
||||
Agent Plugins `1.0.0` standardizes **Skills + MCP**, not session lifecycle hooks. This
|
||||
plugin therefore delivers **explicit, tool-driven** memory that works identically
|
||||
everywhere. For the fully automatic experience — recall injected before every prompt and
|
||||
transcripts retained on session end — use the native, hook-based integration for your
|
||||
tool (e.g. [`hindsight-integrations/claude-code`](../claude-code)). The two share the
|
||||
same banks, so memory captured by one is recalled by the other.
|
||||
|
||||
## Validate the manifests
|
||||
|
||||
```bash
|
||||
python3 hindsight-integrations/agent-plugin/validate.py
|
||||
```
|
||||
|
||||
Checks that `plugin.json` and `mcp.json` parse and satisfy the Agent Plugins `1.0.0`
|
||||
required-field contract.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"$schema": "https://agent-plugins.org/schemas/1.0.0/mcp.schema.json",
|
||||
"mcpServers": {
|
||||
"hindsight": {
|
||||
"type": "streamable-http",
|
||||
"url": "https://api.hindsight.vectorize.io/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${HINDSIGHT_API_KEY}",
|
||||
"X-Bank-Id": "${HINDSIGHT_BANK_ID}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
|
||||
"name": "hindsight",
|
||||
"version": "1.0.0",
|
||||
"description": "Long-term memory for AI agents. Recalls relevant past context before you answer and retains durable facts as you work, backed by Hindsight's retain/recall/reflect memory engine.",
|
||||
"author": {
|
||||
"name": "Hindsight (Vectorize)",
|
||||
"url": "https://hindsight.vectorize.io"
|
||||
},
|
||||
"homepage": "https://hindsight.vectorize.io",
|
||||
"repository": "https://github.com/vectorize-io/hindsight",
|
||||
"license": "MIT",
|
||||
"keywords": [
|
||||
"memory",
|
||||
"long-term-memory",
|
||||
"recall",
|
||||
"retain",
|
||||
"reflect",
|
||||
"mcp",
|
||||
"agent-memory"
|
||||
],
|
||||
"extensions": {
|
||||
"io.vectorize.hindsight": {
|
||||
"bankSelection": "Set HINDSIGHT_BANK_ID to scope memory to a user, project, or team. The mcp.json endpoint is per-bank, so tools never need a bank_id argument.",
|
||||
"selfHosted": "Point HINDSIGHT_MCP_URL at your own deployment instead of Hindsight Cloud; the plugin is transport-only and stays otherwise identical.",
|
||||
"deepIntegration": "For automatic capture/injection (session hooks that recall before each prompt and retain transcripts on exit), see the native Claude Code plugin at hindsight-integrations/claude-code. This portable plugin exposes the same memory via explicit MCP tools."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
name: hindsight-memory
|
||||
description: Long-term memory for the agent via Hindsight. Use to recall relevant past context before answering, retain durable facts as you learn them, and reflect over accumulated memory for the "why" behind a decision. Load whenever continuity across sessions matters — the user refers to earlier work, states a lasting preference, or asks a question that prior context could answer.
|
||||
---
|
||||
|
||||
# Hindsight long-term memory
|
||||
|
||||
This plugin connects the agent to **Hindsight**, a long-term memory engine. Memory
|
||||
persists across sessions in a **bank** (scoped by `HINDSIGHT_BANK_ID`), so what you
|
||||
retain now is available to recall in future conversations.
|
||||
|
||||
The `hindsight` MCP server exposes the tools below. Prefer these over guessing from
|
||||
scratch when the answer might live in past context.
|
||||
|
||||
## When to recall (read memory)
|
||||
|
||||
Call **`recall`** at the start of a task, or whenever the user:
|
||||
|
||||
- refers to earlier work, a past decision, or "the thing we set up",
|
||||
- states a preference or constraint that may already be recorded,
|
||||
- asks a question that accumulated project/user context could answer.
|
||||
|
||||
```
|
||||
recall(query: "how do we deploy the API and which region")
|
||||
```
|
||||
|
||||
`recall` runs semantic + keyword + graph + temporal retrieval and returns the most
|
||||
relevant memories. Ground your answer in what comes back, and say when nothing
|
||||
relevant was found rather than inventing continuity.
|
||||
|
||||
## When to retain (write memory)
|
||||
|
||||
Call **`retain`** when you learn something **durable and reusable** — worth having in
|
||||
a future session, not just this one:
|
||||
|
||||
- stable user preferences ("prefers pnpm; deploys from `main` only"),
|
||||
- project facts and decisions ("staging DB is Postgres 16 on Neon"),
|
||||
- outcomes and gotchas ("the flaky test was a timezone bug, fixed in #482").
|
||||
|
||||
```
|
||||
retain(content: "The user deploys the API to us-east-1 via GitHub Actions on push to main.")
|
||||
```
|
||||
|
||||
Do **not** retain transient chatter, secrets, or anything the user asked you to keep
|
||||
out of memory. Retain the fact, not the whole transcript.
|
||||
|
||||
## When to reflect (reason over memory)
|
||||
|
||||
Call **`reflect`** when a single recall is too shallow and you need synthesized
|
||||
reasoning over everything remembered — the *why* behind a behavior, or a judgment that
|
||||
weighs many facts together:
|
||||
|
||||
```
|
||||
reflect(query: "What has repeatedly caused our CI to flake, and what should we standardize?")
|
||||
```
|
||||
|
||||
`reflect` is slower and disposition-aware; use it deliberately, not for lookups.
|
||||
|
||||
## Bank scope
|
||||
|
||||
All tools operate on the bank selected by the connection (`HINDSIGHT_BANK_ID`, default
|
||||
`default`). One bank = one memory store — keep a project's or a user's memory in its
|
||||
own bank so context stays isolated and relevant. You never pass a bank id to a tool;
|
||||
it is implicit from the endpoint.
|
||||
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate this Agent Plugin against the Agent Plugins 1.0.0 required-field contract.
|
||||
|
||||
Config-only plugin (no runtime code to unit-test), so the meaningful check is that the
|
||||
manifests parse and satisfy the spec's structural requirements. Runs as a script
|
||||
(`python3 validate.py`) and is imported by CI as a pytest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
PLUGIN_DIR = Path(__file__).resolve().parent
|
||||
SPEC = "1.0.0"
|
||||
PLUGIN_SCHEMA = f"https://agent-plugins.org/schemas/{SPEC}/plugin.schema.json"
|
||||
MCP_SCHEMA = f"https://agent-plugins.org/schemas/{SPEC}/mcp.schema.json"
|
||||
|
||||
# https://agent-plugins.org/schemas/1.0.0/plugin.schema.json — name pattern
|
||||
NAME_RE = re.compile(r"^(?!.*(?:--|\.\.))[a-z0-9](?:[a-z0-9.-]*[a-z0-9])?$")
|
||||
AUTHOR_KEYS = {"name", "email", "url"}
|
||||
SERVER_TYPES = {"stdio", "streamable-http", "sse"}
|
||||
|
||||
|
||||
def _load(name: str) -> dict:
|
||||
return json.loads((PLUGIN_DIR / name).read_text())
|
||||
|
||||
|
||||
def validate_plugin_manifest() -> None:
|
||||
m = _load("plugin.json")
|
||||
assert m.get("$schema") == PLUGIN_SCHEMA, f"plugin.json $schema must be {PLUGIN_SCHEMA}"
|
||||
name = m.get("name")
|
||||
assert isinstance(name, str) and 1 <= len(name) <= 64, "name must be a 1-64 char string"
|
||||
assert NAME_RE.match(name), f"name {name!r} violates the Agent Plugins name pattern"
|
||||
author = m.get("author")
|
||||
if author is not None:
|
||||
assert isinstance(author, dict), "author must be an object"
|
||||
assert set(author).issubset(AUTHOR_KEYS), f"author allows only {AUTHOR_KEYS}"
|
||||
extensions = m.get("extensions")
|
||||
if extensions is not None:
|
||||
assert isinstance(extensions, dict), "extensions must be an object"
|
||||
for key in extensions:
|
||||
assert "." in key, f"extension namespace {key!r} must be reverse-domain"
|
||||
|
||||
|
||||
def validate_mcp_manifest() -> None:
|
||||
m = _load("mcp.json")
|
||||
assert m.get("$schema") == MCP_SCHEMA, f"mcp.json $schema must be {MCP_SCHEMA}"
|
||||
servers = m.get("mcpServers")
|
||||
assert isinstance(servers, dict) and servers, "mcp.json needs a non-empty mcpServers object"
|
||||
for server_name, server in servers.items():
|
||||
stype = server.get("type")
|
||||
assert stype in SERVER_TYPES, f"{server_name}: type must be one of {SERVER_TYPES}"
|
||||
if stype == "stdio":
|
||||
assert server.get("command"), f"{server_name}: stdio server requires command"
|
||||
else: # streamable-http | sse
|
||||
assert server.get("url"), f"{server_name}: {stype} server requires url"
|
||||
headers = server.get("headers", {})
|
||||
assert all(isinstance(v, str) for v in headers.values()), (
|
||||
f"{server_name}: header values must be strings"
|
||||
)
|
||||
|
||||
|
||||
def validate_skills() -> None:
|
||||
skills_dir = PLUGIN_DIR / "skills"
|
||||
assert skills_dir.is_dir(), "skills/ directory is missing"
|
||||
skill_files = list(skills_dir.glob("*/SKILL.md"))
|
||||
assert skill_files, "expected at least one skills/<name>/SKILL.md"
|
||||
for skill in skill_files:
|
||||
text = skill.read_text()
|
||||
assert text.startswith("---"), f"{skill} must open with YAML frontmatter"
|
||||
assert "name:" in text and "description:" in text, (
|
||||
f"{skill} frontmatter needs name and description"
|
||||
)
|
||||
|
||||
|
||||
def test_plugin_manifest() -> None:
|
||||
validate_plugin_manifest()
|
||||
|
||||
|
||||
def test_mcp_manifest() -> None:
|
||||
validate_mcp_manifest()
|
||||
|
||||
|
||||
def test_skills() -> None:
|
||||
validate_skills()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
for check in (validate_plugin_manifest, validate_mcp_manifest, validate_skills):
|
||||
check()
|
||||
print(f"ok: {check.__name__}")
|
||||
print("Agent Plugin is valid against Agent Plugins 1.0.0 required fields.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -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" "aider" "ai-sdk" "autogen" "chat" "claude-agent-sdk" "claude-code" "cline" "cloudflare-oauth-proxy" "coding-agents" "codex" "composio" "continue" "copilot-cli" "crewai" "cursor" "cursor-cli" "devin-desktop" "dify" "eve" "flowise" "gemini-spark" "github-copilot" "google-adk" "haystack" "langgraph" "litellm" "llamaindex" "n8n" "nemoclaw" "obsidian" "omo" "openai-agents" "openclaw" "opencode" "openhands" "paperclip" "pipecat" "pydantic-ai" "roo-code" "smolagents" "strands" "superagent" "vapi" "zcode" "zed")
|
||||
VALID_INTEGRATIONS=("ag2" "agent-framework" "agent-plugin" "agentcore" "agno" "aider" "ai-sdk" "autogen" "chat" "claude-agent-sdk" "claude-code" "cline" "cloudflare-oauth-proxy" "coding-agents" "codex" "composio" "continue" "copilot-cli" "crewai" "cursor" "cursor-cli" "devin-desktop" "dify" "eve" "flowise" "gemini-spark" "github-copilot" "google-adk" "haystack" "langgraph" "litellm" "llamaindex" "n8n" "nemoclaw" "obsidian" "omo" "openai-agents" "openclaw" "opencode" "openhands" "paperclip" "pipecat" "pydantic-ai" "roo-code" "smolagents" "strands" "superagent" "vapi" "zcode" "zed")
|
||||
|
||||
usage() {
|
||||
print_error "Usage: $0 <integration> <version>"
|
||||
@@ -58,6 +58,8 @@ get_current_version() {
|
||||
grep '"version"' "$dir/package.json" | head -1 | sed 's/.*"version": "\(.*\)".*/\1/'
|
||||
elif [ -f "$dir/.claude-plugin/plugin.json" ]; then
|
||||
grep '"version"' "$dir/.claude-plugin/plugin.json" | head -1 | sed 's/.*"version": "\(.*\)".*/\1/'
|
||||
elif [ -f "$dir/plugin.json" ]; then
|
||||
grep '"version"' "$dir/plugin.json" | head -1 | sed 's/.*"version": "\(.*\)".*/\1/'
|
||||
elif [ -f "$dir/settings.json" ] && grep -q '"version"' "$dir/settings.json"; then
|
||||
grep '"version"' "$dir/settings.json" | head -1 | sed 's/.*"version": "\(.*\)".*/\1/'
|
||||
else
|
||||
@@ -155,6 +157,10 @@ elif [ -f "$INTEGRATION_DIR/.claude-plugin/plugin.json" ]; then
|
||||
print_info "Updating version in $INTEGRATION_DIR/.claude-plugin/plugin.json"
|
||||
sed -i.bak "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" "$INTEGRATION_DIR/.claude-plugin/plugin.json"
|
||||
rm "$INTEGRATION_DIR/.claude-plugin/plugin.json.bak"
|
||||
elif [ -f "$INTEGRATION_DIR/plugin.json" ]; then
|
||||
print_info "Updating version in $INTEGRATION_DIR/plugin.json"
|
||||
sed -i.bak "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" "$INTEGRATION_DIR/plugin.json"
|
||||
rm "$INTEGRATION_DIR/plugin.json.bak"
|
||||
elif [ -f "$INTEGRATION_DIR/settings.json" ] && grep -q '"version"' "$INTEGRATION_DIR/settings.json"; then
|
||||
print_info "Updating version in $INTEGRATION_DIR/settings.json"
|
||||
sed -i.bak "s/\"version\": \".*\"/\"version\": \"$VERSION\"/" "$INTEGRATION_DIR/settings.json"
|
||||
|
||||
@@ -198,6 +198,7 @@ For non-English banks (especially CJK) and the language/extraction-language trad
|
||||
| `HINDSIGHT_API_LLM_STRICT_SCHEMA_REFLECT` | Override `HINDSIGHT_API_LLM_STRICT_SCHEMA` for reflect's structured-output extraction only. | Inherits global |
|
||||
| `HINDSIGHT_API_LLM_STRICT_SCHEMA_CONSOLIDATION` | Override `HINDSIGHT_API_LLM_STRICT_SCHEMA` for consolidation only (both the batch consolidation call and observation dedup). | Inherits global |
|
||||
| `HINDSIGHT_API_LLM_SUPPORTS_MAX_ITEMS` | Whether the LLM backend accepts JSON Schema `maxItems` in structured-output schemas. Set to `false` for backends such as Bedrock Converse that reject this keyword; consolidation still enforces observation caps after parsing. | `true` |
|
||||
| `HINDSIGHT_API_LLM_STRUCTURED_OUTPUT_FORCED_TOOL` | Request structured output from the LiteLLM-backed providers (`litellm`, `litellmrouter`, `bedrock`) with a single forced tool call — the response schema becomes the tool's parameters — instead of `response_format`. Set to `true` for backends that reject `response_format` outright. This is region-dependent on Bedrock Claude: `ap-southeast-2` (`au.*` inference profiles) refuses the translated Converse `outputConfig` with `Extra inputs are not permitted`, while the same model in `us-east-1` (`us.*`) accepts it and needs nothing here. Verified against both. If the model answers without calling the tool, the reply is parsed as text as before. Other providers ignore it. | `false` |
|
||||
| `HINDSIGHT_API_LLM_OLLAMA_NUM_CTX` | Optional native Ollama `num_ctx` override for structured-output calls. Leave unset to use the model/server default; set a positive integer only when you need a larger context window. | Unset |
|
||||
| `HINDSIGHT_API_LLM_GEMINI_SAFETY_SETTINGS` | JSON-encoded list of `{category, threshold}` dicts for Gemini/VertexAI content safety filtering | `null` |
|
||||
| `HINDSIGHT_API_LLM_PROMPT_CACHE_ENABLED` | Reuse the fixed system prefix via the provider's explicit prompt cache, billed at the cached-input rate (Gemini/Vertex `CachedContent`). The cached prefix is shared across all banks and soft-fails to an uncached call. Set to `false` to disable. See [Models](./models#provider-capabilities). | `true` |
|
||||
|
||||
@@ -50,7 +50,9 @@ Also supports **any OpenAI-compatible API** (e.g., Azure OpenAI, Together AI, Fi
|
||||
|
||||
> **💡 OpenAI-Compatible Providers**
|
||||
>
|
||||
Hindsight works with any provider that exposes an OpenAI-compatible API (e.g., Azure OpenAI). Simply set `HINDSIGHT_API_LLM_PROVIDER=openai` and configure `HINDSIGHT_API_LLM_BASE_URL` to point to your provider's endpoint.
|
||||
Hindsight works with any provider that exposes an OpenAI-compatible API. Set `HINDSIGHT_API_LLM_PROVIDER=openai` and point `HINDSIGHT_API_LLM_BASE_URL` at the endpoint that serves `/chat/completions` — for most providers that is the URL ending in `/v1`, **not** the account or resource root.
|
||||
|
||||
**Azure OpenAI does not serve the API at the resource root**, so `https://<resource>.openai.azure.com` on its own returns `404 Resource not found`. See [Azure OpenAI Setup](#azure-openai-setup) for the two URL shapes that work.
|
||||
|
||||
The `openai` provider talks to the **Chat Completions API** (`/v1/chat/completions`). For the newer **Responses API** (`/v1/responses`), use `HINDSIGHT_API_LLM_PROVIDER=openai-responses` — see the tip below. Both accept a custom `HINDSIGHT_API_LLM_BASE_URL`, so an OpenAI-compatible endpoint that exposes `/v1/responses` works the same way as a Chat Completions one.
|
||||
|
||||
@@ -636,6 +638,56 @@ one, run one replica on this provider and give the others an API-key lane.
|
||||
|
||||
---
|
||||
|
||||
### Azure OpenAI Setup
|
||||
|
||||
Azure OpenAI is reached through the **`openai`** provider — there is no `azure`
|
||||
provider, and setting one fails at startup with
|
||||
`Invalid LLM provider: azure`.
|
||||
|
||||
The one thing that trips people up is the base URL. Azure does not serve the
|
||||
OpenAI API at the resource root, so the endpoint shown in the Azure portal is
|
||||
not usable on its own:
|
||||
|
||||
| `HINDSIGHT_API_LLM_BASE_URL` | Result |
|
||||
|---|---|
|
||||
| `https://<resource>.openai.azure.com` | `404 Resource not found` |
|
||||
| `https://<resource>.openai.azure.com/openai/deployments/<deployment>` | `404 Resource not found` (no `api-version`) |
|
||||
| `https://<resource>.openai.azure.com/openai/v1` | works |
|
||||
| `https://<resource>.openai.azure.com/openai/deployments/<deployment>?api-version=<version>` | works |
|
||||
|
||||
**Recommended — the v1 surface:**
|
||||
|
||||
```bash
|
||||
export HINDSIGHT_API_LLM_PROVIDER=openai
|
||||
export HINDSIGHT_API_LLM_API_KEY=<azure-openai-resource-key>
|
||||
export HINDSIGHT_API_LLM_MODEL=<deployment-name>
|
||||
export HINDSIGHT_API_LLM_BASE_URL=https://<resource>.openai.azure.com/openai/v1
|
||||
```
|
||||
|
||||
**Or the deployment-scoped form.** Keep the `api-version` query string — Hindsight
|
||||
parses it out of the base URL and passes it to the SDK:
|
||||
|
||||
```bash
|
||||
export HINDSIGHT_API_LLM_BASE_URL=https://<resource>.openai.azure.com/openai/deployments/<deployment>?api-version=2025-01-01-preview
|
||||
```
|
||||
|
||||
**Important notes:**
|
||||
- `HINDSIGHT_API_LLM_MODEL` is your **deployment name**, not the model name. A
|
||||
`gpt-4o` deployed as `my-gpt4o` is configured as `my-gpt4o`.
|
||||
- The key is the Azure OpenAI **resource** key (`az cognitiveservices account
|
||||
keys list -n <resource> -g <group>`). An API Management subscription key is a
|
||||
different credential: with APIM in front, the base URL must be the APIM route
|
||||
and APIM has to forward the `api-key` header. Test against the Azure endpoint
|
||||
directly first to isolate which layer is failing.
|
||||
- Gateways and proxies must preserve the same path shape (`/openai/v1` or
|
||||
`/openai/deployments/...?api-version=`).
|
||||
- Azure accepts the `prompt_cache_key` field that
|
||||
[`HINDSIGHT_API_LLM_CACHE_AFFINITY`](./configuration#llm-provider) sends under
|
||||
`auto`, on every `api-version` from `2024-02-01` onward, so the default needs
|
||||
no adjustment for Azure.
|
||||
|
||||
---
|
||||
|
||||
### Vertex AI Setup (Google Cloud)
|
||||
|
||||
Google Cloud's Vertex AI provides access to Gemini models via the native Google GenAI SDK.
|
||||
|
||||
Reference in New Issue
Block a user