Name the voice of every progress note on the frame itself

A progress frame carries the model's own round narration and the host's notes
about the turn (checkpoints, fallback, plan, acceptance, nudge, transport,
density) under one type, so the only way a reader could tell them apart was to
match the note's wording, which BIBLE P5 forbids.

The worker now stamps the fact instead. `_emit_progress` takes `narration` and
writes `progress_meta.narration` on EVERY frame it emits, so absence means "an
older worker or a row written before the fact existed" rather than "a host
note". `_emit_round_progress` is the single producer that passes True, for both
of its emissions; the loop-level notifier and the whole ToolContext ABI
(`emit_progress_fn`, one positional argument) keep the default.

The key needs no transport work: the delivery seam already spreads progress_meta
onto the top level of the live frame and of the stored progress row. It joins
the ChatOutbound contract in both mirrors and the progress-meta whitelist, so a
reload replays the same voice the live frame carried. `cancelable`'s comment is
reflowed, without changing a word, to keep contracts.py at its 1600-line cap.

Co-authored-by: Ouroboros <311266734+ouroboros-agent@users.noreply.github.com>
This commit is contained in:
Ouroboros 2026-09-17 00:39:17 +03:00
parent fce6b350cb
commit 002013432e
7 changed files with 197 additions and 14 deletions

View file

@ -1088,9 +1088,18 @@ class OuroborosAgent:
self._current_task_type = None
def _emit_progress(self, text: str, *, incident: Optional[Dict[str, str]] = None,
executor_observation: Optional[Dict[str, Any]] = None) -> None:
executor_observation: Optional[Dict[str, Any]] = None,
narration: bool = False) -> None:
"""Owner-visible note; ``incident`` is the typed ``task_incident``/``toast_once``
pair the browser toasts once."""
pair the browser toasts once.
``narration`` is the VOICE of the note, not its text: only the model's own
round narration (``loop_messages._emit_round_progress``) is the turn's
speech. Every other caller — checkpoints, fallback and plan notes, the
acceptance, nudge and transport lines, and the whole ToolContext ABI
(``emit_progress_fn``) — is the HOST talking about the turn, so it keeps
the default. Both voices stay visible rows; the flag decides only whether
a note may claim the card title and the collapsed activity line."""
self._last_progress_ts = time.time()
if self._event_queue is None or self._current_chat_id is None:
return
@ -1113,8 +1122,11 @@ class OuroborosAgent:
)
if observation:
progress_meta["executor_observation"] = observation
if progress_meta:
event["progress_meta"] = progress_meta
# Stamped on EVERY frame, never inferred from the absence of other
# metadata: a reader that sees no key is reading an older worker or a
# row written before the fact existed, and keeps the legacy reading.
progress_meta["narration"] = bool(narration)
event["progress_meta"] = progress_meta
self._event_queue.put(event)
except Exception:
log.warning("Failed to emit progress event", exc_info=True)

View file

@ -191,13 +191,13 @@ class ChatOutbound(TypedDict):
task_group_id: NotRequired[str]
task_event: NotRequired[str]
status: NotRequired[str]
# v6.82 (P5): host-attested marker, stamped by the supervisor's delivery seam ONLY
# for a task POST /api/tasks/{id}/cancel will actually stop — a lineage-resolved
# pooled ROOT (its RUNNING row) or the live in-process direct-chat turn (resolved
# through the same ownership reader the endpoint uses, supervisor.workers.direct_chat_turn);
# never a subagent frame, never an ephemeral decision turn. Gates the UI "Cancel run" action.
# v6.82 (P5): host-attested marker, stamped by the supervisor's delivery seam ONLY for a task POST /api/tasks/{id}/cancel
# will actually stop — a lineage-resolved pooled ROOT (its RUNNING row) or the live in-process direct-chat turn (resolved
# through the same ownership reader the endpoint uses, supervisor.workers.direct_chat_turn); never a subagent frame, never
# an ephemeral decision turn. Gates the UI "Cancel run" action.
cancelable: NotRequired[bool]
_is_direct_chat: NotRequired[bool] # lane fact stamped on a direct turn's own frames
narration: NotRequired[bool] # progress VOICE: the model's own round narration (true) vs a host note (false); absent = legacy
initiator: NotRequired[str] # origin label: "consciousness" on a wake-up's frames/rows (and its roots); absent on an owner's turn
# Monetary projections are nullable when the physical-attempt ledger cannot
# be read. ``None`` is deliberately distinct from a confirmed $0 result.

View file

@ -100,6 +100,9 @@ _PROGRESS_META_FIELDS = (
# the pointer on reload while its outer task_id stays empty.
"lifecycle_pointer",
"initiator", # the turn's origin label (a consciousness wake-up)
# The frame's voice: a replayed host note must stay a host note, or a reload
# would hand the card title back to the very line live rendering refused it.
"narration",
)
_SKILL_REVIEW_STRING_FIELDS = (

View file

@ -371,13 +371,17 @@ def _emit_round_progress(content: Any, msg: Dict[str, Any], emit_progress, llm_t
Visible text is retained in ``reasoning_notes``. Provider reasoning stays
display-only; the native message and transcript remain unchanged.
Both emissions are the turn's OWN speech, so both carry ``narration=True``:
this function is the single producer of model narration, and the card takes
its title and collapsed activity line from that voice alone.
"""
visible_text = _visible_round_text(content)
if visible_text:
safe_text = sanitize_tool_result_for_log(visible_text)
emit_progress(safe_text)
emit_progress(safe_text, narration=True)
llm_trace["reasoning_notes"].append(safe_text)
elif str(runtime_setting("OUROBOROS_REASONING_SUMMARY", "auto")).strip().lower() != "off":
display_reasoning = LLMClient.extract_display_reasoning(msg)
if display_reasoning:
emit_progress(sanitize_tool_result_for_log(display_reasoning))
emit_progress(sanitize_tool_result_for_log(display_reasoning), narration=True)

View file

@ -5,6 +5,17 @@ touching the transcript or the round-trip-sensitive metadata."""
from ouroboros.llm import LLMClient
def _recorder(sink):
"""A progress sink shaped like the real emitter: ``_emit_round_progress``
names the round's voice with ``narration=True``, so a bare ``list.append``
would only prove the fake's signature."""
def emit(text, **meta):
sink.append(text)
emit.meta.append(meta)
emit.meta = []
return emit
def test_flat_reasoning_string():
assert LLMClient.extract_display_reasoning({"reasoning": " thinking about X "}) == "thinking about X"
@ -81,11 +92,15 @@ def test_round_progress_redacts_secret_shaped_model_text_before_trace_and_emit()
progress = []
trace = {"reasoning_notes": []}
_emit_round_progress(visible, {}, progress.append, trace)
emit = _recorder(progress)
_emit_round_progress(visible, {}, emit, trace)
assert candidate not in progress[0]
assert candidate not in trace["reasoning_notes"][0]
assert "***REDACTED***" in progress[0]
# The round's own text is the turn's voice, so the card may take its title
# from it; a redaction never demotes the line to a host note.
assert emit.meta == [{"narration": True}]
def test_final_text_response_redacts_before_delivery_and_trace():
@ -117,7 +132,7 @@ def test_round_and_final_prose_redact_all_observability_secret_classes():
trace = {"reasoning_notes": []}
content = f"Credential evidence: {candidate}"
_emit_round_progress(content, {}, progress.append, trace)
_emit_round_progress(content, {}, _recorder(progress), trace)
delivered, _, final_trace = _handle_text_response(content, {"reasoning_notes": []}, {})
assert candidate not in progress[0]
@ -128,7 +143,7 @@ def test_round_and_final_prose_redact_all_observability_secret_classes():
ordinary = "Edited tool.py and completed a fresh review."
progress = []
trace = {"reasoning_notes": []}
_emit_round_progress(ordinary, {}, progress.append, trace)
_emit_round_progress(ordinary, {}, _recorder(progress), trace)
delivered, _, final_trace = _handle_text_response(ordinary, {"reasoning_notes": []}, {})
assert progress == [ordinary]
assert trace["reasoning_notes"] == [ordinary]

View file

@ -0,0 +1,141 @@
"""The VOICE of a progress note survives the producer, delivery and history seams.
Host-authored notes (checkpoints, fallback, plan, acceptance, nudge, transport,
density) and the model's own round narration share one frame type, so the card
cannot tell them apart by text without string matching (BIBLE P5). The worker
stamps ``progress_meta.narration`` on every note it emits instead, and this
module pins that the fact is explicit at the producer, rides the live frame at
the TOP level, and replays through the progress-meta whitelist.
"""
import asyncio
import json
import queue
from functools import partial
from types import SimpleNamespace
import pytest
from ouroboros.agent import OuroborosAgent
from ouroboros.gateway.history import make_chat_history_endpoint
from supervisor import events_chat_delivery, message_bus
def _agent():
events = queue.Queue()
agent = SimpleNamespace(
_last_progress_ts=None, _event_queue=events, _current_chat_id=1,
_current_task_id="task-1", tools=SimpleNamespace(_ctx=SimpleNamespace(task_attempt=0)),
_subagent_progress_meta=lambda event: {},
)
return agent, events
def _emit(agent, text, **kwargs):
OuroborosAgent._emit_progress(agent, text, **kwargs)
def test_every_emitted_note_declares_its_voice_explicitly():
"""Absence must never be how a host note is recognised: the default is an
explicit False, so a reader can tell "host note" from "older worker"."""
agent, events = _agent()
_emit(agent, "Checkpoint 3 at round 12")
_emit(agent, "Thinking about the next step", narration=True)
_emit(agent, "⚡ Fallback: switching model lane",
incident={"task_incident": "model_lane_switch", "toast_once": "lane"})
host, narration, fallback = (events.get_nowait() for _ in range(3))
assert host["progress_meta"]["narration"] is False
assert narration["progress_meta"]["narration"] is True
# An incident note is still the host talking; the typed toast pair is untouched.
assert fallback["progress_meta"]["narration"] is False
assert fallback["progress_meta"]["task_incident"] == "model_lane_switch"
assert fallback["progress_meta"]["toast_once"] == "lane"
def test_the_tool_context_abi_stays_a_host_voice():
"""``ctx.emit_progress_fn`` takes a single positional argument, so every tool
note keeps the default without the ABI having to know the fact exists."""
agent, events = _agent()
ctx = SimpleNamespace(emit_progress_fn=partial(OuroborosAgent._emit_progress, agent))
ctx.emit_progress_fn("📐 plan_task: wave 1 dispatched")
assert events.get_nowait()["progress_meta"]["narration"] is False
@pytest.mark.parametrize("content, msg, expected", [
("The answer is 42.", {}, "The answer is 42."),
([{"type": "thinking", "thinking": "x"}], {"reasoning": "weighing the options"},
"weighing the options"),
])
def test_round_progress_is_the_only_narration_producer(content, msg, expected):
"""Both of its emissions — visible round text and display reasoning — are the
turn's own speech."""
from ouroboros.loop import _emit_round_progress
seen = []
def emit(text, **meta):
seen.append((text, meta))
_emit_round_progress(content, msg, emit, {"reasoning_notes": []})
assert seen == [(expected, {"narration": True})]
def test_voice_rides_the_live_frame_the_stored_row_and_the_replay(tmp_path, monkeypatch):
"""Producer -> supervisor delivery -> live WS frame / progress.jsonl / history.
The browser reads the key at the TOP level of the frame (the delivery seam
spreads progress_meta there, exactly as it does for cancelable), and a reload
must not hand the title back to a note live rendering refused it.
"""
agent, events = _agent()
_emit(agent, "Checkpoint 3 at round 12")
_emit(agent, "Reading the failing test first.", narration=True)
frames = [events.get_nowait() for _ in range(2)]
(tmp_path / "logs").mkdir()
(tmp_path / "logs" / "chat.jsonl").touch()
live = []
bridge = message_bus.LocalChatBridge()
bridge._broadcast_fn = live.append
monkeypatch.setattr(message_bus, "DATA_DIR", tmp_path)
monkeypatch.setattr(message_bus, "load_state", lambda: {"owner_id": 1})
monkeypatch.setattr(message_bus, "_BRIDGE", bridge)
monkeypatch.setattr(message_bus, "publish_event", lambda *_: None)
monkeypatch.setattr(events_chat_delivery, "_bound_project_chat_id", lambda *_: 0)
delivery = SimpleNamespace(
DRIVE_ROOT=tmp_path, RUNNING={"task-1": {"task": {"id": "task-1", "_attempt": 0}}},
send_with_budget=message_bus.send_with_budget,
append_jsonl=lambda *_: pytest.fail("delivery raised"),
)
for frame in frames:
events_chat_delivery._handle_send_message(frame, delivery)
stored = [json.loads(line) for line
in (tmp_path / "logs" / "progress.jsonl").read_text(encoding="utf-8").splitlines()]
response = asyncio.run(make_chat_history_endpoint(tmp_path)(
SimpleNamespace(query_params={"limit": "10"})))
replay = [row for row in json.loads(response.body)["messages"] if row.get("is_progress")]
for rows in (live, stored, replay):
assert [row["narration"] for row in rows] == [False, True], rows
# The voice is presentation only: the host note keeps its liveness semantics
# (that marker is supervisor-authored HOST_NARRATION, a different key).
assert all(events_chat_delivery.HOST_NARRATION not in row for row in live)
def test_a_stored_row_without_the_key_replays_as_a_legacy_frame(tmp_path):
"""An older worker's row carries no voice; history must not invent one, so the
browser can keep promoting it exactly as it did before the fact existed."""
logs = tmp_path / "logs"
logs.mkdir()
(logs / "chat.jsonl").touch()
(logs / "progress.jsonl").write_text(json.dumps({
"ts": "2026-09-16T00:00:00Z", "task_id": "task-1", "content": "Working on it.",
"is_progress": True, "direction": "out", "chat_id": 1,
}) + "\n", encoding="utf-8")
response = asyncio.run(make_chat_history_endpoint(tmp_path)(
SimpleNamespace(query_params={"limit": "10"})))
row, = json.loads(response.body)["messages"]
assert row["text"] == "Working on it."
assert "narration" not in row

View file

@ -358,6 +358,14 @@
* The lane fact of a direct conversation turn, stamped by the host on the
* turn's own progress/tool frames and on every task_done; the chat block
* reads it before any census lists the turn.
* @property {boolean=} narration
* The VOICE of a progress frame, stamped by the worker on every note it
* emits: true only for the model's own round narration, false for every
* host-authored note (checkpoints, fallback, plan, acceptance, nudge,
* transport, density). Both stay visible rows; only narration may claim the
* card title and the collapsed activity line. Absent = a frame that predates
* the fact (an older worker, a supervisor note, a stored row), which keeps
* the legacy reading that promoted every progress frame.
* @property {string=} initiator
* The turn's origin label: "consciousness" on every frame and row of a
* self-initiated wake-up (and the roots it starts); absent on an owner's turn.