Skip to content
Merged
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
10 changes: 10 additions & 0 deletions src/live_mem/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,16 @@ def _validate_config(self) -> "Settings":
"and LLMAAS_API_KEY or neither"
)

# LLM budget coherence (issue #32) : an output budget that consumes
# the whole context window can never produce a valid call — the
# backend rejects input + max_tokens > window before inference.
if self.llmaas_max_tokens >= self.llmaas_context_window:
errors.append(
f"LLMAAS_MAX_TOKENS={self.llmaas_max_tokens} must be strictly "
f"less than LLMAAS_CONTEXT_WINDOW={self.llmaas_context_window} "
"(output budget cannot consume the whole model context window)"
)

# Consolidation ranges
if self.consolidation_timeout < 10:
errors.append(
Expand Down
7 changes: 6 additions & 1 deletion src/live_mem/core/consolidation_queue.py
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,12 @@ async def progress_callback(progress: dict) -> None:
job.result = result
job.progress.update(
{
"phase": "done",
# Issue #32 — keep the lane phase consistent with
# the real outcome (a failed consolidation must
# not display a "done" phase).
"phase": (
"done" if result.get("status") == "ok" else "failed"
),
"batch_size": result.get(
"batch_size", job.progress.get("batch_size")
),
Expand Down
42 changes: 37 additions & 5 deletions src/live_mem/core/consolidator.py
Original file line number Diff line number Diff line change
Expand Up @@ -596,6 +596,11 @@ async def emit_progress(payload: dict) -> None:
total_prompt_tokens = 0
total_completion_tokens = 0
batches_completed = 0
# Issue #32 — a failed batch must surface in the final status.
# `batch_failure` keeps the upstream error message, `failed_batch`
# the 1-based index of the batch that stopped the pipeline.
batch_failure: str | None = None
failed_batch = 0
last_synthesis_size = 0
# Issue #17 — post-pass validation, accumulated over all batches
validation_unattributed = 0
Expand Down Expand Up @@ -677,11 +682,13 @@ async def emit_progress(payload: dict) -> None:
# Appeler le LLM
llm_result = await self._call_llm(messages)
if llm_result.get("status") == "error":
batch_failure = llm_result.get("message", "LLM call failed")
failed_batch = batch_idx
logger.error(
"Batch %d/%d LLM failed: %s — stopping (previous batches OK)",
batch_idx,
batch_count,
llm_result.get("message"),
batch_failure,
)
break

Expand All @@ -698,11 +705,13 @@ async def emit_progress(payload: dict) -> None:
)

if write_result.get("status") != "ok":
batch_failure = write_result.get("message", "Bank write failed")
failed_batch = batch_idx
logger.error(
"Batch %d/%d write failed: %s — stopping",
batch_idx,
batch_count,
write_result.get("message"),
batch_failure,
)
break

Expand Down Expand Up @@ -812,10 +821,25 @@ async def emit_progress(payload: dict) -> None:
bank_objects = await storage.list_objects(f"{space_id}/bank/")
total_bank = len([o for o in bank_objects if not o["Key"].endswith(".keep")])

# Issue #32 — the final status must reflect what actually happened.
# A batch failure with zero completed batches is an error, not a
# success: reporting "ok" here made the queue expose `succeeded`
# jobs while 100% of the notes were left unconsolidated.
if batch_failure is None:
status = "ok"
final_phase = "done"
elif batches_completed == 0:
status = "error"
final_phase = "failed"
else:
status = "partial"
final_phase = "failed"

duration = round(time.monotonic() - t0, 1)
logger.info(
"Consolidation done — space=%s agent=%s notes=%d batches=%d/%d "
"Consolidation %s — space=%s agent=%s notes=%d batches=%d/%d "
"created=%d updated=%d tokens=%d duration=%.1fs",
status,
space_id,
agent_label,
total_notes,
Expand All @@ -828,7 +852,7 @@ async def emit_progress(payload: dict) -> None:
)

result = {
"status": "ok",
"status": status,
"space_id": space_id,
"notes_processed": total_notes,
"bank_files_updated": total_updated,
Expand All @@ -845,9 +869,17 @@ async def emit_progress(payload: dict) -> None:
"batch_size": batch_size,
"duration_seconds": duration,
}
if batch_failure is not None:
result["failed_batch"] = failed_batch
remaining_notes = len(all_notes) - total_notes
result["message"] = (
f"Batch {failed_batch}/{batch_count} failed: {batch_failure} — "
f"{batches_completed} batch(es) applied, "
f"{remaining_notes} note(s) left in live/"
)
await emit_progress(
{
"phase": "done",
"phase": final_phase,
"batch_size": batch_size,
"notes_total": len(all_notes),
"notes_done": total_notes,
Expand Down
22 changes: 22 additions & 0 deletions tests/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,28 @@ def test_batch_size_zero(self):
_make_settings(consolidation_batch_size=0)


class TestLlmBudgetCoherence:
"""Issue #32 — max_tokens must never consume the whole context window."""

def test_max_tokens_equal_to_window_rejected(self):
with pytest.raises(ValueError, match="LLMAAS_MAX_TOKENS"):
_make_settings(llmaas_max_tokens=131072, llmaas_context_window=131072)

def test_max_tokens_above_window_rejected(self):
# Incident config shape: output budget bigger than a real backend
# window makes every large-prompt call impossible.
with pytest.raises(ValueError, match="LLMAAS_MAX_TOKENS"):
_make_settings(llmaas_max_tokens=200000, llmaas_context_window=131072)

def test_defaults_are_coherent(self):
s = _make_settings()
assert s.llmaas_max_tokens < s.llmaas_context_window

def test_sane_override_accepted(self):
s = _make_settings(llmaas_max_tokens=16384, llmaas_context_window=1048576)
assert s.llmaas_max_tokens == 16384


class TestTemperatureValidation:
def test_temperature_out_of_range(self):
with pytest.raises(ValueError, match="LLMAAS_TEMPERATURE"):
Expand Down
Loading
Loading