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
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
37 changes: 37 additions & 0 deletions docs/product/periodic-reports.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,43 @@ Agent Session calendar reports are implemented. Installation,
an enabled subscription and a natural scheduled delivery require their own
readback evidence. Preparing an identity enables neither monitoring nor reports.

Markdown, the HTML report and its copied full text use the same source-link
rule. An unchanged, well-formed HTTP(S) address is a link. Opaque evidence
digests, native Task/artifact references and private relative paths stay source
references; they are never presented as working web links. Malformed addresses,
credentials in an address, control characters and non-web schemes remain inert.
Markdown escapes destination punctuation without inventing a different source.
This is presentation validation, not a claim that a URL is reachable, public or
authorized for another audience. Source collection, frozen evidence, redaction
and delivery authorization retain those responsibilities.

An already frozen report retains its original bytes and generation identity.
Validation can reconstruct the exact previous source-link presentation without
rewriting its artifacts, invoking generation or acknowledging publication.
The full frozen source, authored response, bundle, candidate and body must still
match; an unsupported or altered presentation remains unavailable. New reports
always use the shared source-link rule above.

The following synthetic reports use the production formatters. Text views use
the compiled App Markdown component and its stylesheet; HTML views use the
standalone report artifact. The same normalized records retain their outcomes,
counterevidence, next actions and source identities. These captures qualify
format rendering, separately from the full packaged App, installed scheduling,
live-model content and provider delivery. Desktop is 1440×1000; narrow views
are 390×844 viewport emulations.

| Text report before | Text report after |
| --- | --- |
| ![Opaque sources appeared as Markdown link syntax](../assets/personal-workspace/steward-reports/citations/before-desktop.png) | ![Opaque sources remain readable references](../assets/personal-workspace/steward-reports/citations/after-desktop.png) |

| Narrow text before | Narrow text after |
| --- | --- |
| ![Previous text report at a narrow viewport](../assets/personal-workspace/steward-reports/citations/before-narrow.png) | ![Source references wrap without horizontal overflow](../assets/personal-workspace/steward-reports/citations/after-narrow.png) |

| HTML report | Narrow HTML report |
| --- | --- |
| ![HTML retains the same source references and substantive report](../assets/personal-workspace/steward-reports/citations/html-desktop.png) | ![The standalone HTML report at a narrow viewport](../assets/personal-workspace/steward-reports/citations/html-narrow.png) |

The same local calendar can explicitly repeat an already completed native Task
in its original managed Session. This backend/configuration slice is described
below; arbitrary new tasks from natural language and the unified Agent UI
Expand Down
11 changes: 10 additions & 1 deletion loopx/capabilities/periodic_report/private_generation.py
Original file line number Diff line number Diff line change
Expand Up @@ -250,6 +250,13 @@ def validate_private_generation(folder: Path) -> dict[str, Any]:
bundle = _read(folder / "generation-bundle.json")
checked = build_periodic_report_generation_bundle(document=bundle["document"], artifacts=[
render_periodic_report_markdown(bundle["document"]), render_periodic_report_html(bundle["document"])])
if checked != bundle:
# Rendering can improve after a window was frozen. Reconstruct only the
# exact prior source-link presentation; never rewrite or regenerate it.
# Both candidates still require full bundle, source and body equality.
checked = build_periodic_report_generation_bundle(document=bundle["document"], artifacts=[
render_periodic_report_markdown(bundle["document"], _legacy_source_links=True),
render_periodic_report_html(bundle["document"], _legacy_source_links=True)])
candidate = _read(folder / "publication-candidate.json")
expected_candidate = build_periodic_report_publication_candidate(goal_id=window["goal_id"],
agent_id=window["agent_id"], generation_id=checked["generation_receipt"]["generation_id"],
Expand All @@ -264,6 +271,8 @@ def validate_private_generation(folder: Path) -> dict[str, Any]:
or receipt["publication_candidate_sha256"] != _canonical_digest(candidate)
or receipt["request_digest"] != request["request_digest"]
or receipt["body_sha256"] != hashlib.sha256(report_bytes).hexdigest()
or report_bytes.decode("utf-8") != render_periodic_report_markdown(bundle["document"])["content"]):
or report_bytes.decode("utf-8") != next(
artifact["content"] for artifact in checked["artifacts"]
if artifact["renderer_kind"] == "markdown")):
raise ValueError("frozen generation or full report body changed")
return receipt
29 changes: 16 additions & 13 deletions loopx/presentation/renderers/periodic_report_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@
from collections.abc import Mapping, Sequence
from html import escape
from typing import Any
from urllib.parse import urlparse

from ...capabilities.periodic_report.adapters import (
ARTIFACT_SCHEMA,
Expand All @@ -16,6 +15,7 @@
from ..public_safety import redact_public_text
from .periodic_report_markdown import (
periodic_report_labels,
periodic_report_source_url,
render_periodic_report_markdown,
)

Expand All @@ -38,12 +38,9 @@ def _safe_attr(value: object, *, maximum: int) -> str:
return escape(str(redact_public_text(value, limit=maximum)), quote=True)


def _safe_http_url(value: object) -> str | None:
raw = str(redact_public_text(value, limit=500))
parsed = urlparse(raw)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
return None
return escape(raw, quote=True)
def _safe_http_url(value: object, *, _legacy_source_links: bool = False) -> str | None:
url = periodic_report_source_url(value, _legacy_source_links=_legacy_source_links)
return escape(url, quote=True) if url else None


def _document_digest(document: Mapping[str, Any]) -> str:
Expand Down Expand Up @@ -207,13 +204,14 @@ def _render_item(
*,
index: int,
labels: Mapping[str, str],
_legacy_source_links: bool = False,
) -> str:
title = _safe_text(item.get("title"), maximum=240)
summary = _safe_text(item.get("summary"), maximum=1000)
status = _safe_text(item.get("status"), maximum=80)
next_action = _safe_text(item.get("next_action"), maximum=500)
source_ref = item.get("source_ref")
source_url = _safe_http_url(source_ref)
source_url = _safe_http_url(source_ref, _legacy_source_links=_legacy_source_links)
content_kind = _safe_attr(item.get("content_kind") or "unspecified", maximum=128)
search_text = " ".join(
str(item.get(field) or "")
Expand Down Expand Up @@ -271,12 +269,14 @@ def _render_section(
section_index: int,
item_start: int,
labels: Mapping[str, str],
_legacy_source_links: bool = False,
) -> tuple[str, int]:
section_id = _safe_attr(section.get("section_id"), maximum=128)
title = _safe_text(section.get("title"), maximum=160)
items = _primary_items(section)
rows = "".join(
_render_item(item, index=item_start + offset, labels=labels)
_render_item(item, index=item_start + offset, labels=labels,
_legacy_source_links=_legacy_source_links)
for offset, item in enumerate(items)
)
if not rows:
Expand All @@ -297,6 +297,7 @@ def _render_supporting_sections(
sections: Sequence[Mapping[str, Any]],
*,
labels: Mapping[str, str],
_legacy_source_links: bool = False,
) -> str:
groups = []
for section in sections:
Expand All @@ -313,7 +314,7 @@ def _render_supporting_sections(
)
label = " · ".join(value for value in (kind, status) if value)
next_action = _safe_text(item.get("next_action"), maximum=500)
source_url = _safe_http_url(item.get("source_ref"))
source_url = _safe_http_url(item.get("source_ref"), _legacy_source_links=_legacy_source_links)
source = (
f'<a href="{source_url}" target="_blank" '
'rel="noopener noreferrer">Source ↗</a>'
Expand Down Expand Up @@ -602,7 +603,7 @@ def _render_source_receipts(
"""


def render_periodic_report_html(document: Mapping[str, Any]) -> dict[str, Any]:
def render_periodic_report_html(document: Mapping[str, Any], *, _legacy_source_links: bool = False) -> dict[str, Any]:
"""Render one dense, self-contained report without changing its semantics."""

if document.get("schema_version") != DOCUMENT_SCHEMA:
Expand All @@ -624,7 +625,7 @@ def render_periodic_report_html(document: Mapping[str, Any]) -> dict[str, Any]:
item_count = sum(len(_primary_items(section)) for section in sections)
supporting_item_count = sum(len(_supporting_items(section)) for section in sections)
document_digest = _document_digest(document)
markdown_artifact = render_periodic_report_markdown(document)
markdown_artifact = render_periodic_report_markdown(document, _legacy_source_links=_legacy_source_links)

filters = [
'<a class="section-filter" href="#report-top" data-section-filter="all" '
Expand All @@ -645,6 +646,7 @@ def render_periodic_report_html(document: Mapping[str, Any]) -> dict[str, Any]:
section_index=section_index,
item_start=next_item_index,
labels=labels,
_legacy_source_links=_legacy_source_links,
)
rendered_sections.append(section_html)

Expand Down Expand Up @@ -680,7 +682,8 @@ def render_periodic_report_html(document: Mapping[str, Any]) -> dict[str, Any]:
f'<div class="fact"><strong>{len(sources)}</strong><span>{labels["sources"]}</span></div>'
"</div>"
)
supporting_sections = _render_supporting_sections(sections, labels=labels)
supporting_sections = _render_supporting_sections(sections, labels=labels,
_legacy_source_links=_legacy_source_links)
content = (
"<!doctype html>\n"
f'<html lang="{language}"><head><meta charset="utf-8">'
Expand Down
52 changes: 48 additions & 4 deletions loopx/presentation/renderers/periodic_report_markdown.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,11 @@

import hashlib
import json
import re
import unicodedata
from collections.abc import Mapping, Sequence
from typing import Any
from urllib.parse import quote, urlsplit

from ...capabilities.periodic_report.adapters import (
ARTIFACT_SCHEMA,
Expand Down Expand Up @@ -32,6 +35,44 @@ def _visibility(item: Mapping[str, Any]) -> str:
return str(item.get("visibility") or "primary")


def periodic_report_source_url(value: object, *, _legacy_source_links: bool = False) -> str | None:
"""A display link is an unchanged HTTP URL, not evidence of access or trust."""
if _legacy_source_links:
# Read-only reconstruction of an already frozen pre-fix artifact.
raw = _safe(value, maximum=500)
parsed = urlsplit(raw)
return raw if parsed.scheme in {"http", "https"} and parsed.netloc else None
if not isinstance(value, str) or not value or value != _safe(value, maximum=500):
return None
if any(char.isspace() or unicodedata.category(char).startswith("C")
or char in "<>\\`" for char in value):
return None
try:
parsed = urlsplit(value)
if (parsed.scheme not in {"http", "https"} or not parsed.hostname
or parsed.username is not None or parsed.password is not None):
return None
parsed.port # Reject malformed ports rather than linking a different address.
except ValueError:
return None
return value


def _markdown_source(value: object, labels: Mapping[str, str], *, _legacy_source_links: bool = False) -> str:
source = _safe(value, maximum=500)
if _legacy_source_links:
return f"[{labels['source']}]({source})"
url = periodic_report_source_url(value)
if url:
# Parentheses and quotes must not terminate a Markdown destination.
destination = quote(url, safe=":/?#[]@!$&'*+,;=%-._~")
return f"[{labels['source']}]({destination})"
# Retain opaque/private/malformed references as data, never fake links.
fence = "`" * (1 + max((len(run) for run in re.findall(r"`+", source)), default=0))
padding = " " if source.startswith("`") or source.endswith("`") else ""
return f"{labels['source']}{labels['label_separator']}{fence}{padding}{source}{padding}{fence}"


def periodic_report_labels(language: object) -> dict[str, str]:
"""Return the small built-in report UI vocabulary for one language."""

Expand Down Expand Up @@ -59,15 +100,16 @@ def _render_markdown_item(
item: Mapping[str, Any],
*,
labels: Mapping[str, str],
_legacy_source_links: bool = False,
) -> None:
item_title = _safe(item.get("title"), maximum=240)
source_ref = _safe(item.get("source_ref"), maximum=500)
source_ref = item.get("source_ref")
status = _safe(item.get("status"), maximum=80)
headline = f"- **{item_title}**"
if status:
headline += f" · `{status}`"
if source_ref:
headline += f" · [{labels['source']}]({source_ref})"
headline += f" · {_markdown_source(source_ref, labels, _legacy_source_links=_legacy_source_links)}"
lines.append(headline)
lines.append(f" - {_safe(item.get('summary'), maximum=1000)}")
for detail in _items(item.get("details")):
Expand All @@ -85,6 +127,8 @@ def _render_markdown_item(

def render_periodic_report_markdown(
document: Mapping[str, Any],
*,
_legacy_source_links: bool = False,
) -> dict[str, Any]:
"""Render normalized sections without changing ordering or business meaning."""

Expand Down Expand Up @@ -128,13 +172,13 @@ def render_periodic_report_markdown(
lines.append(f"- {labels['no_items']}")
continue
for item in section_items:
_render_markdown_item(lines, item, labels=labels)
_render_markdown_item(lines, item, labels=labels, _legacy_source_links=_legacy_source_links)
if supporting_sections:
lines.extend(["", f"## {labels['supporting_appendix']}"])
for section, section_items in supporting_sections:
lines.extend(["", f"### {_safe(section.get('title'), maximum=160)}", ""])
for item in section_items:
_render_markdown_item(lines, item, labels=labels)
_render_markdown_item(lines, item, labels=labels, _legacy_source_links=_legacy_source_links)
content = "\n".join(lines).rstrip() + "\n"
content_digest = hashlib.sha256(content.encode("utf-8")).hexdigest()
document_digest = hashlib.sha256(
Expand Down
71 changes: 68 additions & 3 deletions tests/capabilities/test_periodic_report_private_generation.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,13 @@
from __future__ import annotations

import hashlib
import json
from html import escape
from datetime import datetime, timezone
from pathlib import Path
from urllib.parse import urlparse

import pytest

from loopx.capabilities.periodic_report.cadence_journal import (
admit_cadence_window,
Expand All @@ -18,6 +23,7 @@
from loopx.capabilities.periodic_report.private_generation import (
attempt_directory,
freeze_private_report,
validate_private_generation,
)
from loopx.registry import atomic_write_json

Expand All @@ -31,9 +37,7 @@
}


def test_private_report_artifacts_use_canonical_utf8_on_windows_text_mode(
tmp_path: Path, monkeypatch
) -> None:
def _prepared_report(tmp_path: Path):
root = tmp_path / "runtime"
window = admit_cadence_window(
runtime_root=root,
Expand Down Expand Up @@ -154,6 +158,14 @@ def test_private_report_artifacts_use_canonical_utf8_on_windows_text_mode(
],
"sections": sections,
}
return root, window, response


def test_private_report_artifacts_use_canonical_utf8_on_windows_text_mode(
tmp_path: Path, monkeypatch
) -> None:
root, window, response = _prepared_report(tmp_path)
folder = attempt_directory(root, window)

real_write_text = Path.write_text
real_read_text = Path.read_text
Expand All @@ -176,3 +188,56 @@ def host_codec_read_text(path: Path, *args, **kwargs):
report_bytes = (folder / "report.md").read_bytes()
assert b"\r\n" not in report_bytes
assert receipt["body_sha256"] == hashlib.sha256(report_bytes).hexdigest()


@pytest.mark.parametrize("tamper", [None, "report", "html", "source", "response"])
def test_frozen_report_keeps_original_source_links_across_renderer_upgrade(tmp_path, monkeypatch, tamper):
from loopx.presentation.renderers import periodic_report_html as html
from loopx.presentation.renderers import periodic_report_markdown as markdown

root, window, response = _prepared_report(tmp_path)
folder = attempt_directory(root, window)

def previous_html_source(value, **_):
raw = markdown._safe(value, maximum=500)
parsed = urlparse(raw)
return escape(raw, quote=True) if parsed.scheme in {"http", "https"} and parsed.netloc else None

# Seed through the real freezer with the previous independently specified
# source rules, not through the new validator's compatibility switch.
with monkeypatch.context() as previous:
previous.setattr(markdown, "_markdown_source", lambda value, labels, **_:
f"[{labels['source']}]({markdown._safe(value, maximum=500)})")
previous.setattr(html, "_safe_http_url", previous_html_source)
receipt = freeze_private_report(root=root, window=window, response=response)
original = {path.name: path.read_bytes() for path in folder.iterdir() if path.is_file()}
assert "[来源](sha256:" in original["report.md"].decode("utf-8")
assert "[来源](sha256:" not in markdown.render_periodic_report_markdown(
json.loads(original["generation-bundle.json"])["document"])["content"]

if tamper == "report":
body = original["report.md"] + b"\nA different unapproved claim.\n"
(folder / "report.md").write_bytes(body)
atomic_write_json(folder / "private-generation.json", {
**receipt, "body_sha256": hashlib.sha256(body).hexdigest()})
elif tamper == "html":
bundle = json.loads(original["generation-bundle.json"])
artifact = next(item for item in bundle["artifacts"] if item["renderer_kind"] == "html")
artifact["content"] += "<p>A different unapproved claim.</p>"
artifact["content_digest"] = "sha256:" + hashlib.sha256(artifact["content"].encode()).hexdigest()
atomic_write_json(folder / "generation-bundle.json", bundle)
elif tamper in {"source", "response"}:
name = "source.json" if tamper == "source" else "editorial-response.json"
changed = json.loads(original[name])
changed["rows" if tamper == "source" else "title"] = [] if tamper == "source" else "Another title"
if tamper == "source":
changed["gaps"] = ["An unapproved source change"]
atomic_write_json(folder / name, changed)
if tamper:
with pytest.raises(ValueError):
validate_private_generation(folder)
return

assert validate_private_generation(folder) == receipt
assert validate_private_generation(folder) == receipt # Restart/readback is effect-free.
assert {path.name: path.read_bytes() for path in folder.iterdir() if path.is_file()} == original
Loading
Loading