123 lines
4.0 KiB
Python
123 lines
4.0 KiB
Python
"""Derive settled todo snapshots for session GET responses.
|
|
|
|
The browser's Todos panel currently reconstructs state by reverse-scanning
|
|
loaded tool messages. That works only when the latest todo tool result is inside
|
|
the returned message window. This helper gives ``/api/session`` a compact,
|
|
explicit ``todo_state`` sidecar derived from the full settled transcript.
|
|
|
|
The detector intentionally mirrors ``run_agent.AIAgent._hydrate_todo_store``:
|
|
walk messages newest-first and use the first tool message whose JSON content has
|
|
a ``todos`` list. Empty ``todos`` is a valid snapshot so a cleared task list does
|
|
not fall through to an older non-empty write.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import Any, Iterable, Optional, Sequence
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
VERSION = 1
|
|
PAYLOAD_KEY = "todo_state"
|
|
|
|
|
|
def _normalize_snapshot(data: Any) -> Optional[dict]:
|
|
"""Return the canonical todo snapshot shape, or ``None`` for non-todo data."""
|
|
if not isinstance(data, dict):
|
|
return None
|
|
todos = data.get("todos")
|
|
if not isinstance(todos, list):
|
|
return None
|
|
summary = data.get("summary")
|
|
if not isinstance(summary, dict):
|
|
summary = {}
|
|
return {
|
|
"todos": todos,
|
|
"summary": summary,
|
|
"version": VERSION,
|
|
}
|
|
|
|
|
|
def parse_todo_tool_result(function_result: Any) -> Optional[dict]:
|
|
"""Parse a todo tool result JSON string or pre-parsed dict into a snapshot."""
|
|
data: Any = function_result
|
|
if isinstance(function_result, str):
|
|
try:
|
|
data = json.loads(function_result)
|
|
except (TypeError, ValueError):
|
|
return None
|
|
return _normalize_snapshot(data)
|
|
|
|
|
|
def _message_ts_float(ts_raw: Any) -> float:
|
|
"""Coerce a message ``timestamp`` field to a positive float, or 0.0."""
|
|
try:
|
|
return float(ts_raw) if ts_raw is not None else 0.0
|
|
except (TypeError, ValueError):
|
|
return 0.0
|
|
|
|
|
|
def _max_timestamp_through(messages: Sequence[Any], upto_idx: int) -> float:
|
|
"""Return the largest valid timestamp at or before ``upto_idx``."""
|
|
best = 0.0
|
|
end = min(upto_idx, len(messages) - 1)
|
|
for i in range(end, -1, -1):
|
|
msg = messages[i]
|
|
if not isinstance(msg, dict):
|
|
continue
|
|
best = max(best, _message_ts_float(msg.get("timestamp")))
|
|
return best
|
|
|
|
|
|
def derive_todo_state(messages: Optional[Iterable[dict]]) -> Optional[dict]:
|
|
"""Derive the latest settled todo snapshot from conversation history.
|
|
|
|
Returns ``None`` when the session has no todo writes. Malformed or
|
|
non-string tool contents are skipped so unrelated tool results never break
|
|
session loading.
|
|
"""
|
|
if not messages:
|
|
return None
|
|
if not isinstance(messages, (list, tuple)):
|
|
messages = list(messages)
|
|
|
|
for idx in range(len(messages) - 1, -1, -1):
|
|
msg = messages[idx]
|
|
if not isinstance(msg, dict) or msg.get("role") != "tool":
|
|
continue
|
|
content = msg.get("content", "")
|
|
if not isinstance(content, str) or '"todos"' not in content:
|
|
continue
|
|
snapshot = parse_todo_tool_result(content)
|
|
if snapshot is None:
|
|
continue
|
|
|
|
ts_val = _message_ts_float(msg.get("timestamp"))
|
|
if ts_val <= 0:
|
|
ts_val = _max_timestamp_through(messages, idx)
|
|
if ts_val > 0:
|
|
snapshot["ts"] = ts_val
|
|
return snapshot
|
|
return None
|
|
|
|
|
|
def attach_todo_state(payload: dict, messages: Optional[Iterable[dict]]) -> bool:
|
|
"""Attach ``todo_state`` to a session payload when one can be derived.
|
|
|
|
Mutates ``payload`` in place. Errors are swallowed deliberately: a malformed
|
|
historical tool message must not make ``/api/session`` fail.
|
|
"""
|
|
if not messages:
|
|
return False
|
|
try:
|
|
snapshot = derive_todo_state(messages)
|
|
if snapshot is None:
|
|
return False
|
|
payload[PAYLOAD_KEY] = snapshot
|
|
return True
|
|
except Exception:
|
|
logger.debug("todo_state attach failed", exc_info=True)
|
|
return False
|