Skip to content

Commit ec3b415

Browse files
authored
feat(retain): optional fail-on-extraction-errors flag (#2721)
Add opt-in HINDSIGHT_API_FAIL_ON_EXTRACTION_ERRORS (default False, preserves behavior). When enabled and a retain accumulated extraction errors (extraction_errors_count > 0), the operation is marked failed instead of silently completed. Self-contained: config flag + _mark_operation_completed decision + docs + .env.example. Deferred follow-ups (status API field, completed_with_errors status, webhook field, metric) noted in the PR. Own change verified (config + status tests pass, verify-generated-files green, ruff/tsc clean). Remaining CI reds are unrelated: Core LLM + pg0 test-api flakes, a transient ts-client-oracle, and test-embed-windows (the #2676 regression already fixed on main by #2723; #2721 does not touch the embed daemon). Fixes #2700
1 parent 3a18897 commit ec3b415

8 files changed

Lines changed: 198 additions & 0 deletions

File tree

.env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ HINDSIGHT_API_LOG_LEVEL=info
8989
# Unset uses HINDSIGHT_API_RETAIN_CHUNK_SIZE as the structured-chunk limit.
9090
# HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE=
9191

92+
# When true, a retain operation that hit any fact-extraction errors is marked
93+
# 'failed' (not 'completed'), surfacing silently-dropped facts. Default false.
94+
# HINDSIGHT_API_FAIL_ON_EXTRACTION_ERRORS=false
95+
9296
# Dry-run extraction preview endpoint (POST /memories/dry-run-extract). Enabled by default; it makes
9397
# a real LLM call but stores nothing. Set to false to remove the endpoint (returns 404).
9498
# HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT=true

hindsight-api-slim/hindsight_api/config.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -665,6 +665,9 @@ def _resolve_operation_temperature(operation_env: str, default: float) -> float
665665
ENV_AUDIT_LOG_ACTIONS = "HINDSIGHT_API_AUDIT_LOG_ACTIONS"
666666
ENV_AUDIT_LOG_RETENTION_DAYS = "HINDSIGHT_API_AUDIT_LOG_RETENTION_DAYS"
667667

668+
# Retain reliability settings
669+
ENV_FAIL_ON_EXTRACTION_ERRORS = "HINDSIGHT_API_FAIL_ON_EXTRACTION_ERRORS"
670+
668671
# LLM request tracing settings
669672
ENV_LLM_TRACE_ENABLED = "HINDSIGHT_API_LLM_TRACE_ENABLED"
670673
ENV_LLM_TRACE_SCOPES = "HINDSIGHT_API_LLM_TRACE_SCOPES"
@@ -1105,6 +1108,11 @@ def _parse_strategy_boosts(raw: str | None) -> dict[str, str]:
11051108
DEFAULT_AUDIT_LOG_ACTIONS = "" # Empty = audit all eligible actions
11061109
DEFAULT_AUDIT_LOG_RETENTION_DAYS = -1 # -1 = keep forever
11071110

1111+
# Retain reliability defaults
1112+
DEFAULT_FAIL_ON_EXTRACTION_ERRORS = (
1113+
False # Preserve existing behavior: retain completes even if some chunks fail extraction
1114+
)
1115+
11081116
# LLM request tracing defaults
11091117
DEFAULT_LLM_TRACE_ENABLED = True # Enabled by default
11101118
DEFAULT_LLM_TRACE_SCOPES = "" # Empty = trace all call scopes
@@ -1958,6 +1966,11 @@ class HindsightConfig:
19581966
audit_log_actions: list[str] # Allowlist of action types (empty = all)
19591967
audit_log_retention_days: int # -1 = keep forever, >0 = delete after N days
19601968

1969+
# Retain reliability configuration (static - server-level only)
1970+
# When True, a retain operation that accumulated any fact-extraction errors is
1971+
# marked 'failed' instead of 'completed', surfacing silent fact loss to clients.
1972+
fail_on_extraction_errors: bool
1973+
19611974
# LLM request tracing configuration (static - server-level only)
19621975
llm_trace_enabled: bool # Master switch for per-bank LLM request tracing
19631976
llm_trace_scopes: list[str] # Allowlist of call scopes to trace (empty = all)
@@ -3048,6 +3061,11 @@ def from_env(cls) -> "HindsightConfig":
30483061
audit_log_retention_days=int(
30493062
os.getenv(ENV_AUDIT_LOG_RETENTION_DAYS, str(DEFAULT_AUDIT_LOG_RETENTION_DAYS))
30503063
),
3064+
# Retain reliability configuration (static, server-level only)
3065+
fail_on_extraction_errors=os.getenv(
3066+
ENV_FAIL_ON_EXTRACTION_ERRORS, str(DEFAULT_FAIL_ON_EXTRACTION_ERRORS)
3067+
).lower()
3068+
== "true",
30513069
# LLM request tracing configuration (static, server-level only)
30523070
llm_trace_enabled=os.getenv(ENV_LLM_TRACE_ENABLED, str(DEFAULT_LLM_TRACE_ENABLED)).lower() == "true",
30533071
llm_trace_scopes=[

hindsight-api-slim/hindsight_api/engine/memory_engine.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2374,11 +2374,57 @@ async def _mark_operation_completed(self, operation_id: str):
23742374

23752375
Also checks if this is a child operation and updates the parent if all siblings are done.
23762376
Uses a single transaction to avoid race conditions when multiple children complete simultaneously.
2377+
2378+
Opt-in escape hatch: when ``HINDSIGHT_API_FAIL_ON_EXTRACTION_ERRORS`` is set and the
2379+
operation's ``result_metadata`` recorded a non-zero ``extraction_errors_count`` (written by
2380+
``_write_retain_outcome_metadata`` before this call), the operation is marked ``failed``
2381+
instead of ``completed``. This surfaces silently-dropped facts as a hard failure rather
2382+
than a clean success. Default is off, so existing behavior is unchanged (see issue #2700).
23772383
"""
23782384
try:
23792385
backend = await self._get_backend()
23802386
async with acquire_with_retry(backend) as conn:
23812387
async with conn.transaction():
2388+
# Read the accumulated extraction-error count (persisted by
2389+
# _write_retain_outcome_metadata) to decide the terminal status.
2390+
meta_row = await conn.fetchrow(
2391+
f"SELECT result_metadata FROM {fq_table('async_operations')} WHERE operation_id = $1",
2392+
uuid.UUID(operation_id),
2393+
)
2394+
extraction_errors_count = 0
2395+
if meta_row is not None:
2396+
metadata = conn.parse_json(meta_row["result_metadata"]) or {}
2397+
extraction_errors_count = int(metadata.get("extraction_errors_count") or 0)
2398+
2399+
fail_on_errors = get_config().fail_on_extraction_errors
2400+
if fail_on_errors and extraction_errors_count > 0:
2401+
error_message = (
2402+
f"Retain completed with {extraction_errors_count} fact extraction error(s); "
2403+
"marked failed because HINDSIGHT_API_FAIL_ON_EXTRACTION_ERRORS is enabled. "
2404+
"See result_metadata.extraction_errors_sample for details."
2405+
)
2406+
row = await conn.fetchrow(
2407+
f"""
2408+
UPDATE {fq_table("async_operations")}
2409+
SET status = 'failed', error_message = $2, updated_at = NOW(), completed_at = NOW()
2410+
WHERE operation_id = $1
2411+
RETURNING operation_id
2412+
""",
2413+
uuid.UUID(operation_id),
2414+
error_message,
2415+
)
2416+
if row is None:
2417+
logger.info(
2418+
f"Operation {operation_id} no longer exists (bank deleted), skipping mark-failed"
2419+
)
2420+
return
2421+
logger.warning(
2422+
f"Marked async operation as failed due to {extraction_errors_count} "
2423+
f"extraction error(s): {operation_id}"
2424+
)
2425+
await self._maybe_update_parent_operation(operation_id, conn)
2426+
return
2427+
23822428
# Mark this operation as completed
23832429
row = await conn.fetchrow(
23842430
f"""

hindsight-api-slim/tests/test_async_batch_retain.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import asyncio
44
import json
5+
import os
56
import uuid
67

78
import pytest
@@ -515,6 +516,105 @@ async def empty_extract_facts_from_contents(
515516
assert "extraction_errors_sample" not in parent["result_metadata"]
516517

517518

519+
async def _seed_retain_op_with_errors(pool, bank_id: str, error_count: int) -> uuid.UUID:
520+
"""Insert a pending retain operation whose outcome metadata records extraction errors."""
521+
operation_id = uuid.uuid4()
522+
await pool.execute(
523+
"""
524+
INSERT INTO async_operations (operation_id, bank_id, operation_type, result_metadata, status)
525+
VALUES ($1, $2, $3, $4, $5)
526+
""",
527+
operation_id,
528+
bank_id,
529+
"retain",
530+
json.dumps(
531+
{
532+
"unit_ids_count": 3,
533+
"extraction_errors_count": error_count,
534+
"extraction_errors_sample": ["chunk 2 failed to parse"],
535+
}
536+
),
537+
"pending",
538+
)
539+
return operation_id
540+
541+
542+
async def _op_row(pool, operation_id: uuid.UUID):
543+
return await pool.fetchrow(
544+
"SELECT status, error_message FROM async_operations WHERE operation_id = $1",
545+
operation_id,
546+
)
547+
548+
549+
@pytest.mark.asyncio
550+
async def test_completion_marks_failed_when_flag_on_and_errors_present(memory):
551+
"""With the escape hatch on, a retain that dropped facts ends 'failed' (issue #2700)."""
552+
from hindsight_api.config import ENV_FAIL_ON_EXTRACTION_ERRORS, clear_config_cache
553+
554+
bank_id = "test_fail_on_extraction_errors_on"
555+
pool = await memory._get_pool()
556+
await _ensure_bank(pool, bank_id)
557+
operation_id = await _seed_retain_op_with_errors(pool, bank_id, error_count=2)
558+
559+
os.environ[ENV_FAIL_ON_EXTRACTION_ERRORS] = "true"
560+
clear_config_cache()
561+
try:
562+
await memory._mark_operation_completed(str(operation_id))
563+
finally:
564+
del os.environ[ENV_FAIL_ON_EXTRACTION_ERRORS]
565+
clear_config_cache()
566+
567+
row = await _op_row(pool, operation_id)
568+
assert row["status"] == "failed"
569+
assert row["error_message"] is not None
570+
assert "2" in row["error_message"]
571+
assert "extraction error" in row["error_message"].lower()
572+
573+
574+
@pytest.mark.asyncio
575+
async def test_completion_stays_completed_when_flag_off(memory):
576+
"""Default behavior is preserved: extraction errors still complete the operation."""
577+
from hindsight_api.config import ENV_FAIL_ON_EXTRACTION_ERRORS, clear_config_cache
578+
579+
bank_id = "test_fail_on_extraction_errors_off"
580+
pool = await memory._get_pool()
581+
await _ensure_bank(pool, bank_id)
582+
operation_id = await _seed_retain_op_with_errors(pool, bank_id, error_count=2)
583+
584+
os.environ.pop(ENV_FAIL_ON_EXTRACTION_ERRORS, None)
585+
clear_config_cache()
586+
try:
587+
await memory._mark_operation_completed(str(operation_id))
588+
finally:
589+
clear_config_cache()
590+
591+
row = await _op_row(pool, operation_id)
592+
assert row["status"] == "completed"
593+
assert row["error_message"] is None
594+
595+
596+
@pytest.mark.asyncio
597+
async def test_completion_completed_when_flag_on_but_no_errors(memory):
598+
"""The flag only fails operations that actually accumulated extraction errors."""
599+
from hindsight_api.config import ENV_FAIL_ON_EXTRACTION_ERRORS, clear_config_cache
600+
601+
bank_id = "test_fail_on_extraction_errors_none"
602+
pool = await memory._get_pool()
603+
await _ensure_bank(pool, bank_id)
604+
operation_id = await _seed_retain_op_with_errors(pool, bank_id, error_count=0)
605+
606+
os.environ[ENV_FAIL_ON_EXTRACTION_ERRORS] = "true"
607+
clear_config_cache()
608+
try:
609+
await memory._mark_operation_completed(str(operation_id))
610+
finally:
611+
del os.environ[ENV_FAIL_ON_EXTRACTION_ERRORS]
612+
clear_config_cache()
613+
614+
row = await _op_row(pool, operation_id)
615+
assert row["status"] == "completed"
616+
617+
518618
@pytest.mark.asyncio
519619
async def test_retain_records_user_provided_document_ids(memory, request_context):
520620
"""User-supplied document_ids land in child op result_metadata.document_ids."""

hindsight-api-slim/tests/test_config_validation.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,30 @@ def test_retain_structured_chunk_size_reads_from_env():
122122
assert config.retain_structured_chunk_size == 9000
123123

124124

125+
def test_fail_on_extraction_errors_defaults_to_false(monkeypatch):
126+
"""Silent-success behavior is preserved by default (issue #2700)."""
127+
from hindsight_api.config import ENV_FAIL_ON_EXTRACTION_ERRORS, HindsightConfig
128+
129+
monkeypatch.delenv(ENV_FAIL_ON_EXTRACTION_ERRORS, raising=False)
130+
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
131+
132+
config = HindsightConfig.from_env()
133+
134+
assert config.fail_on_extraction_errors is False
135+
136+
137+
def test_fail_on_extraction_errors_reads_true_from_env(monkeypatch):
138+
"""The opt-in escape hatch parses truthy values from the environment."""
139+
from hindsight_api.config import ENV_FAIL_ON_EXTRACTION_ERRORS, HindsightConfig
140+
141+
monkeypatch.setenv(ENV_FAIL_ON_EXTRACTION_ERRORS, "true")
142+
monkeypatch.setenv("HINDSIGHT_API_LLM_PROVIDER", "mock")
143+
144+
config = HindsightConfig.from_env()
145+
146+
assert config.fail_on_extraction_errors is True
147+
148+
125149
def test_llm_ollama_num_ctx_defaults_to_none(monkeypatch):
126150
"""Unset Ollama num_ctx override lets Ollama use its model/server default."""
127151
from hindsight_api.config import ENV_LLM_OLLAMA_NUM_CTX, HindsightConfig

hindsight-docs/docs/developer/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1106,6 +1106,7 @@ Controls the retain (memory ingestion) pipeline.
11061106
| `HINDSIGHT_API_RETAIN_DEFAULT_STRATEGY` | Default retain strategy name. When set, all retain calls without an explicit `strategy` parameter use this strategy. | - |
11071107
| `HINDSIGHT_API_RETAIN_BATCH_POLL_INTERVAL_SECONDS` | Batch API polling interval in seconds | `60` |
11081108
| `HINDSIGHT_API_STORE_DOCUMENT_TEXT` | Persist the raw source text alongside extracted memories. Set to `false` to skip storing it. Static, server-level. | `true` |
1109+
| `HINDSIGHT_API_FAIL_ON_EXTRACTION_ERRORS` | When `true`, a retain operation that accumulated any fact-extraction errors is marked `failed` (with an error message including the count) instead of `completed`, so silently-dropped facts surface as a hard failure. Default preserves existing behavior. Static, server-level. | `false` |
11091110

11101111
> **Batch-capable providers.** `HINDSIGHT_API_RETAIN_BATCH_ENABLED=true` only works with a retain LLM provider that implements a batch API: `openai`, `groq`, `gemini`, and `fireworks`. Batch always requires async retain (`async=true`); a sync retain with batch enabled errors. Other providers fail fast at startup.
11111112
>

hindsight-embed/hindsight_embed/env.example

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ HINDSIGHT_API_LOG_LEVEL=info
8989
# Unset uses HINDSIGHT_API_RETAIN_CHUNK_SIZE as the structured-chunk limit.
9090
# HINDSIGHT_API_RETAIN_STRUCTURED_CHUNK_SIZE=
9191

92+
# When true, a retain operation that hit any fact-extraction errors is marked
93+
# 'failed' (not 'completed'), surfacing silently-dropped facts. Default false.
94+
# HINDSIGHT_API_FAIL_ON_EXTRACTION_ERRORS=false
95+
9296
# Dry-run extraction preview endpoint (POST /memories/dry-run-extract). Enabled by default; it makes
9397
# a real LLM call but stores nothing. Set to false to remove the endpoint (returns 404).
9498
# HINDSIGHT_API_ENABLE_DRY_RUN_EXTRACT=true

skills/hindsight-docs/references/developer/configuration.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1106,6 +1106,7 @@ Controls the retain (memory ingestion) pipeline.
11061106
| `HINDSIGHT_API_RETAIN_DEFAULT_STRATEGY` | Default retain strategy name. When set, all retain calls without an explicit `strategy` parameter use this strategy. | - |
11071107
| `HINDSIGHT_API_RETAIN_BATCH_POLL_INTERVAL_SECONDS` | Batch API polling interval in seconds | `60` |
11081108
| `HINDSIGHT_API_STORE_DOCUMENT_TEXT` | Persist the raw source text alongside extracted memories. Set to `false` to skip storing it. Static, server-level. | `true` |
1109+
| `HINDSIGHT_API_FAIL_ON_EXTRACTION_ERRORS` | When `true`, a retain operation that accumulated any fact-extraction errors is marked `failed` (with an error message including the count) instead of `completed`, so silently-dropped facts surface as a hard failure. Default preserves existing behavior. Static, server-level. | `false` |
11091110

11101111
> **Batch-capable providers.** `HINDSIGHT_API_RETAIN_BATCH_ENABLED=true` only works with a retain LLM provider that implements a batch API: `openai`, `groq`, `gemini`, and `fireworks`. Batch always requires async retain (`async=true`); a sync retain with batch enabled errors. Other providers fail fast at startup.
11111112
>

0 commit comments

Comments
 (0)