Wenchao An 1b76ab9060
feat: add opt-in task notes and compacted history recall (#5382)
* feat: add opt-in task notes and compacted history recall

* fix: validate task continuity state and preserve user answers

Honor explicit opt-out, preserve clarification replies and capture failure statuses, validate notebook writes, and clear branch archive references. Update the config version and audit optional LLM credentials, with regression and integration evidence.

* fix: align Helm config version with task continuity schema

* fix: preserve mixed task history and declare continuity policies

* fix: recover malformed history and evict archives atomically
2026-09-12 21:01:46 +08:00

100 lines
4.0 KiB
Python

"""Normalize checkpointed continuity metadata; notes remain model reports."""
import re
from collections.abc import Sequence
from langgraph.channels import BinaryOperatorAggregate
MAX_NOTES = 8
MAX_NOTE_CHARS = 750
MAX_NOTE_SOURCES = 4
NOTE_KEY_PATTERN = re.compile(r"[a-zA-Z0-9_-]{1,40}")
SOURCE_ID_PATTERN = re.compile(r"r[a-f0-9]{32}")
BATCH_ID_PATTERN = re.compile(r"[a-f0-9]{64}")
def normalize_task_history(value: object) -> dict:
"""Bound and validate persisted history before any reader uses it.
Keep valid references for diagnostics, but mark malformed history unavailable.
Scope authorization and physical source availability remain the archive's job.
"""
if value is None or (isinstance(value, dict) and not value):
return {}
if not isinstance(value, dict):
return {"batches": [], "omitted_records": 0, "status": "unavailable"}
invalid = False
scope = value.get("scope")
if scope is not None and (not isinstance(scope, str) or not scope):
scope, invalid = None, True
batches = value.get("batches", [])
if not isinstance(batches, list):
batches, invalid = [], True
valid_batches = [batch for batch in batches[-64:] if isinstance(batch, str) and BATCH_ID_PATTERN.fullmatch(batch)]
if len(valid_batches) != len(batches):
invalid = True
if valid_batches and scope is None:
valid_batches, invalid = [], True
omitted = value.get("omitted_records", 0)
if type(omitted) is not int or omitted < 0:
omitted, invalid = 0, True
status = value.get("status", "available")
if status not in ("available", "unavailable"):
invalid = True
return {
**({"scope": scope} if scope is not None else {}),
"batches": list(dict.fromkeys(valid_batches)),
"omitted_records": omitted,
"status": "unavailable" if invalid else status,
}
def normalize_task_notes(value: object) -> dict:
"""Drop malformed entries and canonicalize untrusted notes without endorsing them.
Used before external checkpoint writes and again when reading stored state:
Overwrite and direct integrations can bypass the reducer. Source IDs are
syntax-checked here; only the task_note tool checks their availability.
"""
if not isinstance(value, dict):
return {}
notes = {}
for key, note in value.items():
if not isinstance(key, str) or not NOTE_KEY_PATTERN.fullmatch(key) or not isinstance(note, dict):
continue
content = note.get("content")
sources = note.get("source_ids", [])
if not isinstance(content, str) or not content or len(content) > MAX_NOTE_CHARS:
continue
if not isinstance(sources, list) or len(sources) > MAX_NOTE_SOURCES or any(not isinstance(source, str) or not SOURCE_ID_PATTERN.fullmatch(source) for source in sources):
continue
notes[key] = {"content": content, "source_ids": list(sources), "authority": "model_report"}
if len(notes) > MAX_NOTES:
notes.pop(next(iter(notes)))
return notes
def merge_task_notes(left: dict | None, right: dict | None) -> dict:
merged = normalize_task_notes(left)
for key, value in (right if isinstance(right, dict) else {}).items():
if value is None:
merged.pop(key, None)
else:
merged.update(normalize_task_notes({key: value}))
# Tools reject new keys at capacity; also bound externally supplied state.
return dict(list(merged.items())[-MAX_NOTES:])
class TaskNotesChannel(BinaryOperatorAggregate[dict | None]):
"""Validate every checkpoint write, including first writes and Overwrite.
Keep the optional channel uninitialized until it receives a write so the
disabled feature does not add a notebook to ordinary state/SSE snapshots.
"""
def update(self, values: Sequence[dict | None]) -> bool:
changed = super().update(values)
if changed:
self.value = normalize_task_notes(self.value)
return changed