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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 29 additions & 11 deletions hindsight-api-slim/hindsight_api/engine/memory_engine.py
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,30 @@ def _is_oracledb_integrity_error(e: Exception) -> bool:
return isinstance(e, oracledb.IntegrityError)


def _is_invalid_embedding_dimension_error(e: Exception) -> bool:
"""Return True for deterministic embedding-dimension failures.

These errors come from either PR #1670's preflight validation
("embedding 0 has dimension 0; expected 384") or from pgvector itself
("different vector dimensions 384 and 0"). Retrying the same poisoned
embedding response only burns worker slots; a fresh retain request or a
fixed embedding backend is required.
"""
message = str(e).lower()
return "different vector dimensions" in message or (
"embedding" in message and "dimension" in message and "expected" in message
)


def _is_non_retryable_task_error(e: Exception) -> bool:
"""Classify deterministic task failures that should skip worker retry."""
return (
isinstance(e, asyncpg.exceptions.IntegrityConstraintViolationError)
or _is_oracledb_integrity_error(e)
or _is_invalid_embedding_dimension_error(e)
)


class Budget(str, Enum):
"""Budget levels for recall/reflect operations."""

Expand Down Expand Up @@ -1236,17 +1260,11 @@ async def execute_task(self, task_dict: dict[str, Any]):
logger.error(f"Not retrying task {task_type} (non-retryable), marking as failed")
if operation_id:
await self._mark_operation_failed(operation_id, str(e), error_traceback)
elif isinstance(e, asyncpg.exceptions.IntegrityConstraintViolationError) or (
_is_oracledb_integrity_error(e)
):
# Non-retryable: deterministic integrity violations (PG or Oracle)
# (UniqueViolationError, ForeignKeyViolationError, CheckViolationError,
# NotNullViolationError, ExclusionViolationError / ORA-00001, ORA-02291, etc.)
# will never succeed on retry — the offending row state is already committed.
# Retrying just burns worker capacity. See vectorize-io/hindsight#980.
logger.error(
f"Not retrying task {task_type} (integrity violation, deterministic): {type(e).__name__}"
)
elif _is_non_retryable_task_error(e):
# Non-retryable: deterministic task failures (integrity violations,
# invalid embedding dimensions, etc.) will not succeed by rerunning
# the same payload. Retrying just burns worker capacity.
logger.error(f"Not retrying task {task_type} (deterministic failure): {type(e).__name__}")
if task_type == "consolidation" and operation_id:
await self._fire_consolidation_webhook(
bank_id=task_dict.get("bank_id", ""),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,15 +306,19 @@ def __init__(
self.api_key = "local"

# Validate API key for cloud providers
if self.provider in (
"openai",
"groq",
"minimax",
"deepseek",
"openrouter",
"zai",
"opencode-go",
) and not self.api_key:
if (
self.provider
in (
"openai",
"groq",
"minimax",
"deepseek",
"openrouter",
"zai",
"opencode-go",
)
and not self.api_key
):
raise ValueError(f"API key is required for {self.provider}")

# Service tier configuration (from config, not env vars)
Expand Down
24 changes: 22 additions & 2 deletions hindsight-api-slim/tests/test_integrity_violation_not_retried.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,13 +151,33 @@ async def test_foreign_key_violation_also_not_retried(memory):
await pool.execute("DELETE FROM banks WHERE bank_id = $1", bank_id)


@pytest.mark.parametrize(
"message",
[
"embedding 0 has dimension 0; expected 384",
"different vector dimensions 384 and 0",
],
)
def test_invalid_embedding_dimension_error_is_non_retryable(message):
"""Embedding dimension mismatches are deterministic and must not be retried.

PR #1670 validates empty/mismatched embedding vectors before pgvector writes.
pgvector may also raise its own dimension-mismatch error if an invalid vector
reaches the database layer. In both cases, rerunning the same poisoned
embedding response only burns worker slots; a fresh retain request or fixed
embedding backend is required.
"""
from hindsight_api.engine.memory_engine import _is_non_retryable_task_error

assert _is_non_retryable_task_error(RuntimeError(message)) is True


@pytest.mark.asyncio
async def test_non_integrity_error_still_retried(memory):
"""
Sanity check: non-integrity errors (network errors, timeouts, value errors)
should STILL use the existing retry path — i.e., raise RetryTaskAt when
``_retry_count < 3``. Only integrity violations are the new non-retryable
class.
``_retry_count < 3``. Only deterministic task errors are non-retryable.
"""
bank_id = f"test-worker-{uuid.uuid4().hex[:8]}"
operation_id = uuid.uuid4()
Expand Down
1 change: 1 addition & 0 deletions skills/hindsight-docs/references/developer/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Used for fact extraction, entity resolution, mental model consolidation, and ans
- MiniMax
- DeepSeek
- z.ai
- opencode-go
- Volcano Engine
- OpenRouter
- OpenAI Codex
Expand Down
1 change: 1 addition & 0 deletions skills/hindsight-docs/references/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ Browse all supported integrations in the Integrations Hub.
- MiniMax
- DeepSeek
- z.ai
- opencode-go
- Volcano Engine
- OpenRouter
- OpenAI Codex
Expand Down