Skip to content

Commit a5cbecb

Browse files
authored
Update persistence.py
1 parent 236427c commit a5cbecb

1 file changed

Lines changed: 97 additions & 0 deletions

File tree

‎python_agent_harness/persistence.py‎

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,3 +288,100 @@ def _parse_metadata_value(value: str) -> str:
288288
return str(parsed)
289289
except (ValueError, SyntaxError):
290290
return value.strip("\"'")
291+
292+
293+
def parse_saved_body(body: str) -> list:
294+
"""Parse a saved session body back into Message objects.
295+
296+
The save format is markdown with **role**: content blocks
297+
separated by blank lines.
298+
299+
``tool`` and ``system`` blocks are dropped: the saved markdown
300+
does not keep ``tool_call_id``/``name`` (assistant tool calls are
301+
flattened to plain text), so a restored ``role="tool"`` message
302+
would form an API-invalid payload (a tool message with no
303+
preceding assistant ``tool_calls``). A restored ``system``
304+
message would duplicate the live system prompt the client
305+
prepends on every request. The following assistant reply
306+
already summarizes the results, so dropping them loses no
307+
essential context.
308+
309+
Body lines that merely look like a block header are escaped by
310+
the renderer (see `escape_role_headers`) and unescaped here, so
311+
a message quoting this format no longer splits into extra
312+
messages. Sessions saved before escaping existed can still
313+
split — that ambiguity is in the file, not in this parser.
314+
"""
315+
from .attachments import reattach_images
316+
from .models import Message, TextPart
317+
318+
messages: list = []
319+
current_role: str | None = None
320+
current_lines: list[str] = []
321+
322+
def _flush(role: str, lines: list[str]) -> None:
323+
content = "\n".join(lines).strip()
324+
if not content:
325+
return
326+
# A leading image-attachment placeholder (written on save)
327+
# is re-attached when the file still exists, so the restored
328+
# message is multimodal again; otherwise it stays as text.
329+
new_content, parts = reattach_images(content)
330+
if parts:
331+
remainder = new_content.split("\n", 1)
332+
rest_text = remainder[1].strip() if len(remainder) > 1 else ""
333+
content_parts: list = []
334+
if rest_text:
335+
content_parts.append(TextPart(text=rest_text))
336+
content_parts.extend(parts)
337+
messages.append(Message(role=role, content=content_parts))
338+
else:
339+
messages.append(Message(role=role, content=content))
340+
341+
for line in body.splitlines():
342+
# Check for a role header: **user**: ... or **assistant**: ...
343+
header = split_role_header(line)
344+
if header is not None:
345+
role, rest = header
346+
# Save the previous block (tool blocks lose their
347+
# tool_call_id/name; system blocks would duplicate the
348+
# live system prompt the client prepends per request)
349+
if current_role is not None and current_role not in ("tool", "system"):
350+
_flush(current_role, current_lines)
351+
current_role = role
352+
current_lines = [unescape_role_header(rest)]
353+
continue
354+
current_lines.append(unescape_role_header(line))
355+
356+
# Don't forget the last block (tool/system blocks dropped, see above)
357+
if current_role is not None and current_role not in ("tool", "system"):
358+
_flush(current_role, current_lines)
359+
360+
return messages
361+
362+
363+
def find_session_by_title(query: str) -> str | None:
364+
"""Find a session file by title substring (case-insensitive).
365+
366+
Matches against the full filename, the filename without .md,
367+
and the derived title. Returns the most recent match, or None.
368+
"""
369+
query_lower = query.lower()
370+
# Strip .md from query if present, for cleaner substring matching
371+
query_stem = query_lower[:-3] if query_lower.endswith(".md") else query_lower
372+
files = SessionPersistence.list_sessions() # already sorted by mtime desc
373+
for f in files:
374+
basename = os.path.basename(f)
375+
basename_lower = basename.lower()
376+
# Exact basename match (with or without .md)
377+
if basename_lower == query_lower or basename_lower == query_lower + ".md":
378+
return f
379+
# Substring match against filename (minus .md)
380+
name_part = basename[:-3] if basename.endswith(".md") else basename
381+
if query_stem in name_part.lower():
382+
return f
383+
# Match against derived title (dashes → spaces)
384+
title = title_from_filename(f)
385+
if title and query_stem in title.lower():
386+
return f
387+
return None

0 commit comments

Comments
 (0)