mirror of
https://github.com/razzant/ouroboros.git
synced 2026-10-03 04:07:04 +00:00
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:
parent
fce6b350cb
commit
002013432e
7 changed files with 197 additions and 14 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
141
tests/test_progress_narration_voice.py
Normal file
141
tests/test_progress_narration_voice.py
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue