Compare commits

...

8 Commits

Author SHA1 Message Date
Patrick Buckley 5bcbcb73b9 chore: bump version to 1.5.2 2026-04-30 03:15:59 -07:00
Patrick Buckley af6749421a fix(metacog): drop duplicate [repeat: tool()] info line
The themed ``tool_reminder`` bubble below the tool block already
shows the metacog text, and the tool block immediately above it
carries the tool name — so a separate gray ``[repeat: list_workstreams()
called with same arguments]`` info line was just duplicate visual
noise (operator-visible in the screenshot below the bubble).

Drop the ``ui.on_info`` call inside ``_apply_post_execute_advisories``
that emitted the diagnostic line.  Update the docstring to reflect
that the bubble is the canonical signal.  Rename
``test_emit_repeat_ui_line_on_streak_fire`` →
``test_no_legacy_repeat_info_line_on_streak_fire`` and invert the
assertion.
2026-04-30 03:15:22 -07:00
Patrick Buckley 4d6cb77075 fix(cli): add on_user_reminder + on_tool_reminder to TerminalUI
CI typecheck failed because ``WorkstreamTerminalUI(TerminalUI)``
inherits from ``SessionUI`` (the Protocol), and the Protocol's
``on_user_reminder`` / ``on_tool_reminder`` declarations have empty
bodies — mypy treats those as implicitly abstract, so the subclass
became un-instantiable.

Add real implementations on ``TerminalUI`` that render reminders as
``[metacognition · type] text`` lines in yellow.  This also restores
the metacog signal on the CLI surface (the legacy
``[metacognition: nudge injected — …]`` info-line went away with
``_emit_nudge_ping``; without this commit the CLI showed no signal
at all for metacog nudges).  Tool-channel and user-channel render
identically because terminal output is anchored by stdout flow
rather than by DOM anchor — the line lands directly after the
message it advises.
2026-04-30 03:15:22 -07:00
Patrick Buckley 5c225ef39b docs(metacog): align comments with side-channel + tool-channel scope
Address Copilot's review feedback on PR #456 — the docstrings and
inline comments hadn't all caught up with the architectural shift
across the branch:

  - ``_apply_reminders_for_provider`` docstring: "every user message"
    → role-agnostic, since tool messages also carry ``_reminders``
    (tool_error / repeat).
  - ``_mark_reminders_delivered`` docstring: same role-agnostic
    update; explicitly note both channels.
  - ``_append_user_turn`` callsite comment near
    ``_attach_pending_user_reminders``: still described splicing
    ``<system-reminder>`` blocks into user content; updated to
    reflect the side-channel attach + transient-copy splice at the
    provider boundary.
  - ``_build_history`` block comment: was user-message-only; now
    mentions tool messages and both ``user_reminder`` /
    ``tool_reminder`` SSE events.
  - ``_build_history`` propagation comment: same role-agnostic note
    on the per-entry surface.
  - ``app.js`` ``user_reminder`` SSE handler comment: said the
    bubble renders "above" the user message, but
    ``insertAdjacentElement('afterend', el)`` drops it BELOW.
  - ``app.js`` ``replayHistory`` comment: said "insertBefore drops
    the reminder directly above the just-rendered user bubble";
    same fix — bubble lands BELOW.

No behaviour change.
2026-04-30 03:15:22 -07:00
Patrick Buckley f5a843f44a fix(metacog): drop write-success-clear so sequential same-call streaks fire
The repeat-detection block in ``_apply_post_execute_advisories`` had
a leftover "clear streak when a write tool succeeded" branch from
when ``RepeatDetector`` tracked cumulative counts.  With the
consecutive-streak semantics introduced earlier in the branch the
branch became:

  1. Redundant — any different (name, args) signature already resets
     the streak via ``RepeatDetector.record``, so an intervening
     read/write naturally breaks the streak.
  2. Actively wrong — the clear runs ONCE at the top of each
     ``_apply_post_execute_advisories`` call, before the per-result
     loop records sigs.  In a single parallel batch
     ``[bash, bash, bash]`` the clear runs once and then three
     ``record`` calls accumulate to count=3 in the same call → fires.
     But across three sequential turns, each turn calls
     ``_apply_post_execute_advisories`` fresh, the clear runs at the
     top of each call, and only one ``record`` per call follows — so
     the count never gets above 1 and the canonical
     "small local model stuck on ``bash('echo test')``" pattern
     never triggered the nudge.

The asymmetry only existed for successful calls — failures don't
satisfy the ``not _tool_error_flags.get(tc["id"])`` predicate, so
the clear didn't fire and sequential failures already worked.  The
fix is to drop the clear entirely; ``RepeatDetector``'s
consecutive-streak semantics handle every case uniformly.

Tests:

  - ``test_successful_write_clears_streak`` →
    ``test_intervening_different_call_resets_streak`` —
    rewords the assertion to reflect the actual mechanism (any
    different sig resets, write-or-otherwise) since "writes clear"
    was the bug, not the contract.
  - ``test_failed_write_does_not_clear_streak`` →
    ``test_sequential_bash_failures_fire_repeat`` — same shape, just
    framing fixed.
  - New ``test_sequential_bash_same_command_fires_repeat`` —
    regression for the bug user hit (three sequential successful
    ``bash('echo test')`` calls now correctly fire the nudge).
2026-04-30 03:15:22 -07:00
Patrick Buckley cf44841624 feat(metacog): themed reminder bubble unifies user + tool channels
The yellow themed reminder card introduced for user-channel nudges
(correction / denial / resume / start / completion) now also fronts
tool-channel nudges (tool_error / repeat).  Pre-fix the tool channel
shipped its reminders inside the tool-result envelope via
``wrap_tool_result``, leaking the ``<system-reminder>`` block into
``self.messages`` content (same problem the user channel had before
the side-channel refactor) and surfacing the legacy gray
``[metacognition: nudge injected — …]`` info line as the only
operator-visible signal — duplicated alongside the new themed bubble
for user-channel nudges.

Tool-channel parity:

  - ``_collect_advisories`` now returns
    ``(persistent_advisories, metacog_reminders)``.  Persistent
    advisories (``GuardAdvisory`` / ``UserInterjection``) keep
    riding ``wrap_tool_result`` because they ARE conversation
    history.  Metacognitive reminders extract to the second tuple
    element; the caller attaches them to the tool message dict's
    ``_reminders`` side-channel and emits ``on_tool_reminder``.
  - ``_apply_reminders_for_provider`` already handles ``_reminders``
    on any role, so the tool-channel splice into wire content is
    free.  ``_build_history`` also already propagates
    ``entry["reminders"]`` regardless of role, so reload renders the
    bubble too.
  - ``SessionUI`` Protocol gains ``on_tool_reminder(reminders,
    tool_call_id)``; ``SessionUIBase`` enqueues a ``tool_reminder``
    SSE event with the ``tool_call_id`` anchor.
  - ``_emit_nudge_ping`` had no remaining callers and was removed —
    the themed bubble (live SSE + ``/history`` reload) is the
    canonical operator signal for both channels now.

UI polish (the four fixes the screenshot caught for the user
channel + their tool-channel mirror):

  - Bubble renders BELOW the message it advises (semantically: a
    hint to the model right before its turn).  ``addUserReminder``
    swaps ``insertBefore`` for ``insertAdjacentElement('afterend',
    el)``; ``addToolReminder`` anchors below the ``.ts-approval``
    block whose tool result triggered the batch's reminder.
  - Label uses the full feature name ``metacognition`` (was the
    ``metacog`` shorthand).
  - Card width / alignment inherits from the base ``.msg`` rule —
    ``align-self: flex-end`` and the explicit ``max-width`` are
    gone, so the card matches the user / assistant column instead
    of pinning right-aligned narrow.
  - The legacy ``[metacognition: nudge injected — …]`` gray info
    line is gone for both channels.

Frontend additions:

  - ``Pane.prototype.addToolReminder(reminders, toolCallId)``
    anchors below the ``.ts-approval`` block (live: by
    ``data-call-id``; replay: by "last block in messagesEl"
    fallback, which is correct because messages render in order).
  - SSE switch case ``"tool_reminder"`` calls ``addToolReminder``.
  - ``replayHistory``'s tool-message branch now calls
    ``addToolReminder`` when ``msg.reminders`` is present.
  - ``addUserReminder`` advances its anchor on each loop iteration
    so multiple reminders stack in queued order rather than
    reversed.

Coord console parity:

  - ``coordinator.js`` gains ``appendReminderBubble`` /
    ``appendUserReminderLive`` / ``appendToolReminderLive`` mirroring
    the interactive UI.  The tool-channel anchor walks
    ``toolRows[callId].batch`` to attach below the
    ``.coord-tool-batch`` construct (one bubble per dispatch turn,
    matching the "one nudge per batch even with many failing tools"
    drain).
  - SSE switch handles ``user_reminder`` and ``tool_reminder`` on
    the coord conversation surface.
  - ``/history`` replay propagates ``msg.reminders`` for user and
    tool messages — same wire shape as the interactive pane.
  - ``.msg.user-reminder`` styles moved to
    ``shared_static/chat.css`` so both surfaces inherit the same
    yellow themed bubble from the shared base.

Defensive read on ``_apply_reminders_for_provider`` (per Copilot
review on the closed PR): a malformed ``_reminders`` entry (string,
None, etc. — corruption / partial state) used to abort ``send`` via
AttributeError on the ``.get("text", "")`` call.  Filter to dicts
before building the block, mirroring the same filter
``_build_history`` already applies on the wire-out side; an
all-malformed list passes through as no-reminders.

Tests:

  - ``test_collect_advisories_drains_tool_buffer_on_last_result``
    rewritten to assert the ``(persistent, metacog)`` tuple shape
    and that ``MetacognitiveAdvisory`` no longer appears in the
    persistent list.
  - ``test_collect_advisories_holds_*`` and ``_drops_*`` updated for
    tuple return.
  - ``test_attach_emits_visibility_ping`` /
    ``test_collect_advisories_emits_visibility_ping`` inverted to
    assert the legacy gray line is gone on both channels.
  - ``TestSessionUIBaseToolReminderHook`` covers the new SSE event
    shape with the ``tool_call_id`` anchor.
  - ``test_malformed_reminders_filtered_out`` and
    ``test_all_malformed_reminders_passes_through`` cover the
    Copilot-flagged defensive filter.
2026-04-30 03:15:22 -07:00
Patrick Buckley 7ffab6a272 fix(session): metacog reminders ride a side-channel, not user content
User-channel metacognitive nudges (correction, denial, resume, start,
completion) used to be spliced into ``user_msg["content"]`` permanently,
which leaked the ``<system-reminder>`` envelope into every consumer of
``self.messages`` — UI replay (mitigated by a regex strip in /history),
compaction, title generation, and any future channel adapter that
echoes conversation context.  The /history strip was a band-aid;
compaction and title-gen still saw the raw spliced text.

Switch to a side-channel: ``_attach_pending_user_reminders`` writes the
rendered reminder list to ``user_msg["_reminders"]`` (sibling key,
leading-underscore convention shared with ``_attachments_meta`` /
``_provider_content``).  At the provider boundary, a new
``_apply_reminders_for_provider`` builds a transient shallow-copy with
the reminder spliced into ``content``; the original message dict
stays clean.  ``sanitize_messages`` drops the sibling key on the wire.

Once-per-session-not-per-turn semantics for the wire: after stream
success the loop calls ``_mark_reminders_delivered``, which flips a
``_reminders_delivered`` flag on every user message that carried
reminders into that call.  ``_apply_reminders_for_provider`` skips
already-delivered messages so the model sees each reminder exactly
once (the turn it advised).  ``_build_history`` ignores the delivered
flag entirely, so reconnecting tabs render the same nudge bubble the
originating tab saw via the live ``user_reminder`` SSE event.

UI surface:

  - ``SessionUIBase.on_user_reminder`` enqueues a
    ``{type: "user_reminder", reminders: [...]}`` SSE event with the
    same shape ``_build_history`` surfaces.
  - ``app.js`` renders a ``.msg.user-reminder`` bubble (yellow accent,
    pill-styled) anchored above the user message it advises, both
    live and on history replay.
  - ``replayHistory`` renders ``addUserMessage`` before
    ``addUserReminder`` so the anchor lookup finds the just-rendered
    turn (not a prior one).
  - Multi-tab caveat documented inline: non-originating tabs receive
    no ``user_message`` SSE event today, so a reminder may anchor to
    a stale prior bubble until ``/history`` reload corrects it.

Pre-existing bug surfaced by the audit: cancel handlers
(``GenerationCancelled`` / ``KeyboardInterrupt`` / generic
``Exception``) in ``ChatSession.send`` cleared
``_pending_tool_advisories`` but not the user-channel buffer.  Both
now drain through a shared ``_drain_pending_advisories`` helper.

Removed the ``/history`` regex strip — the side-channel approach
makes it redundant.  Hoisted ``escape_wrapper_tags`` +
``render_system_reminder`` imports to module top (called 2-3× per
turn).

Tests:

  - ``TestApplyRemindersForProvider`` — pass-through-by-reference,
    string + list content splice, escape on user-typed wrapper tags,
    multi-reminder ordering, source-untouched invariant, delivered
    flag skip path, fallback for unexpected content shape.
  - ``TestMarkRemindersDelivered`` — flag idempotency, no-reminders
    no-flag, only marks user messages with reminders.
  - ``TestUpdateTokenTableMsgsParam`` — calibration uses pre-built
    msgs when provided, falls back when not.
  - ``TestUserAdvisoryCancelClear`` — all three cancel branches drain
    the user buffer.
  - ``TestReminderSidechannelIsolation`` — compaction's
    ``_format_messages_for_summary`` and the title-gen extraction
    loop cannot see reminders by construction.
  - ``TestSessionUIBaseUserReminderHook`` — ``on_user_reminder``
    enqueues the right SSE shape.
  - ``TestBuildHistoryReminderPropagation`` — ``entry["reminders"]``
    propagation, absent / empty / multi / coexist-with-attachments
    cases, malformed input filtering, all-malformed elision.
  - ``test_sanitize_messages_strips_underscore_sibling_keys`` covers
    ``_reminders`` and ``_reminders_delivered``.
2026-04-30 03:15:22 -07:00
Patrick Buckley ba3bc9d989 fix(metacog): N>=3 streak detector + drop redundant error-prefix list
Cleanup pass on the metacognitive nudge stack — restores pre-split
errored-counts-toward-repeat behaviour and tightens the is_error
plumbing through the per-batch advisory hook.

The per-batch hook in ``_run_loop`` was duplicating the is_error
signal: ``self._tool_error_flags`` (set by ``_report_tool_result``)
and a string-prefix tuple (``Error`` / ``JSON parse error`` / …).
Two truth sources is what got us here — bash commands that exit
non-zero with normal stdout matched the flag but not the prefix,
the deny path matched the prefix but not the flag, and the result
was that stuck-loop detection silently broke for the most common
failure mode (the model bashing the same broken command).

Single source of truth now:

- ``_execute_tools.run_one`` deny branch routes through
  ``_report_tool_result(is_error=True)`` so denied calls populate
  ``_tool_error_flags`` like every other error path.
- The error-prefix tuple is gone; the write-success-clear gate and
  the tool-error-nudge gate both read ``_tool_error_flags`` only.

Repeat-detection state moves from a ``set[str]`` (fired on the second
identical call, ignored errors entirely) to a ``RepeatDetector``
helper in ``metacognition.py`` with consecutive-streak semantics:

- Threshold raised from 2 to 3 — two-in-a-row was noisy on
  legitimate transient retries; three is the cheapest stuck-loop
  signal.
- Recording a different signature resets the count, so [A, A, B, A]
  is two short streaks of 2 and not a streak of 4. Bounded by O(1)
  state regardless of session length.
- Errored calls now count toward the streak (the split into a
  separate metacog module unintentionally introduced a "skip errors"
  branch — restored).

While there:

- ``metacognition._COOLDOWN_SECS`` default aligned to 300s (matches
  ``MemoryConfig.nudge_cooldown`` and the ``memory.nudge_cooldown``
  config-store default; was set to 30 by an earlier investigation).
- The per-batch advisory block (~80 lines of mixed orchestration
  inside ``_run_loop``) is extracted to
  ``ChatSession._apply_post_execute_advisories`` so the wired
  behaviour is testable without driving ``_run_loop`` end-to-end.
  Producer extraction to a dedicated module is deferred to a
  follow-up; advisory producers all live on ``ChatSession`` for
  now per existing convention.
- Frontend ``appendToolOutput`` (turnstone/ui/static/app.js) now
  skips rendering when the parent approval block is denied or
  the output starts with ``Denied by user`` / ``Blocked``,
  mirroring the history-replay guard at ``_build_history``.
  Previously the live SSE path didn't need this guard because
  the deny path never emitted a ``tool_result`` event; the
  is_error routing change above means it does now, so without
  this guard the badge from ``resolveApproval`` and the SSE
  output would both render.

Tests: 8 unit tests for ``RepeatDetector`` covering streak,
threshold, clear, and intervening-sig reset; 9 integration tests
for ``_apply_post_execute_advisories`` covering the wired
behaviour (3-identical fires warning + advisory + UI line, errored
calls count toward streak as a regression guard, intervening sig
resets streak, successful write clears, failed write does not,
JSON outputs tracked but not inline-warned, tool_error nudge gates
on memory_count, repeat UI line emitted on streak fire).
2026-04-30 03:15:22 -07:00
17 changed files with 1871 additions and 300 deletions
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project]
name = "turnstone"
version = "1.5.1"
version = "1.5.2"
description = "Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."
readme = "README.md"
license = "BUSL-1.1"
+68
View File
@@ -8,6 +8,7 @@ from turnstone.core.metacognition import (
NUDGE_RESUME,
NUDGE_START,
NUDGE_TOOL_ERROR,
RepeatDetector,
detect_completion,
detect_correction,
format_nudge,
@@ -308,3 +309,70 @@ class TestRepeatNudge:
"""Repeat nudge should fire even with zero memories."""
state: dict[str, float] = {}
assert should_nudge("repeat", state, message_count=5, memory_count=0) is True
class TestRepeatDetector:
"""Repeat-detection streak machine — fires only when the same signature
is recorded ``threshold`` times *consecutively* (default 3). Recording
any different signature resets the streak, so an interrupted repeat
isn't flagged as a stuck loop."""
def test_below_threshold_does_not_fire(self):
det = RepeatDetector()
assert det.record("a") is False
assert det.record("a") is False # second call still under threshold
def test_at_threshold_fires(self):
det = RepeatDetector()
det.record("a")
det.record("a")
assert det.record("a") is True
def test_continues_to_fire_past_threshold(self):
# Caller is responsible for clearing after a fire — until they do,
# subsequent identical calls keep returning True.
det = RepeatDetector()
det.record("a")
det.record("a")
assert det.record("a") is True
assert det.record("a") is True
def test_clear_resets_count(self):
det = RepeatDetector()
det.record("a")
det.record("a")
det.clear()
assert det.record("a") is False # back to 1 after clear
def test_intervening_sig_resets_streak(self):
# The streak is consecutive: recording any other sig mid-streak
# discards the in-progress count. An alternating pattern like
# [A, A, B, A, A] is two short streaks of 2, not a streak of 4.
det = RepeatDetector()
det.record("a")
det.record("a")
assert det.record("b") is False # b at count 1; a's streak is gone
assert det.record("a") is False # a starts fresh at 1
assert det.record("a") is False # a at 2
assert det.record("a") is True # a hits 3 — fresh streak completes
def test_errored_signature_counts_toward_repeat(self):
# Regression: when metacog was split out of the system message,
# the error-output skip got reintroduced and stuck-loop detection
# silently broke for tools that kept failing. Detector itself is
# signature-only — error vs. success is the caller's policy.
det = RepeatDetector()
# Caller records an errored call's sig the same as a successful one;
# the streak is what matters.
for _ in range(3):
last = det.record("bash:ls /nonexistent")
assert last is True
def test_custom_threshold(self):
det = RepeatDetector(threshold=2)
assert det.record("a") is False
assert det.record("a") is True
def test_threshold_one_fires_immediately(self):
det = RepeatDetector(threshold=1)
assert det.record("a") is True
+19
View File
@@ -224,6 +224,25 @@ class TestOpenAIProvider:
sanitize_messages([original])
assert original["content"] is None
def test_sanitize_messages_strips_underscore_sibling_keys(self) -> None:
"""Internal sibling metadata (``_reminders``, ``_reminders_delivered``,
``_attachments_meta``, ``_provider_content``) must be stripped
before the wire — the OpenAI-compat APIs reject unknown fields."""
msgs = [
{
"role": "user",
"content": "hi",
"_reminders": [{"type": "correction", "text": "watch"}],
"_reminders_delivered": True,
"_attachments_meta": [{"kind": "image"}],
}
]
result = sanitize_messages(msgs)
assert result == [{"role": "user", "content": "hi"}]
assert "_reminders" not in result[0]
assert "_reminders_delivered" not in result[0]
assert "_attachments_meta" not in result[0]
# -- sanitize_messages: orphan detection -----------------------------------
def test_sanitize_orphaned_tool_call_synthesized(self) -> None:
+837 -114
View File
File diff suppressed because it is too large Load Diff
+132
View File
@@ -899,6 +899,138 @@ class TestHistoryInteractive:
assert client.get(base, params={"limit": 999}).status_code == 200
class TestBuildHistoryReminderPropagation:
"""``_build_history`` must surface the ``_reminders`` side-channel on
each entry so a tab reconnecting via ``/history`` renders the same
metacognitive nudge bubble the originating tab saw via the live
``user_reminder`` SSE event.
"""
def _session_with_messages(self, messages: list[dict]) -> MagicMock:
session = MagicMock()
session.messages = messages
return session
def test_reminders_sidechannel_surfaces_on_entry(self):
from turnstone.server import _build_history
session = self._session_with_messages(
[
{
"role": "user",
"content": "ah no",
"_reminders": [{"type": "correction", "text": "watch out"}],
}
]
)
history = _build_history(session)
assert history[0]["content"] == "ah no"
assert history[0]["reminders"] == [{"type": "correction", "text": "watch out"}]
def test_no_reminders_key_when_sidechannel_absent(self):
from turnstone.server import _build_history
session = self._session_with_messages([{"role": "user", "content": "just a message"}])
history = _build_history(session)
assert "reminders" not in history[0]
def test_no_reminders_key_when_sidechannel_empty(self):
from turnstone.server import _build_history
session = self._session_with_messages([{"role": "user", "content": "hi", "_reminders": []}])
history = _build_history(session)
assert "reminders" not in history[0]
def test_multiple_reminders_preserved_in_order(self):
from turnstone.server import _build_history
session = self._session_with_messages(
[
{
"role": "user",
"content": "x",
"_reminders": [
{"type": "denial", "text": "FIRST"},
{"type": "correction", "text": "SECOND"},
],
}
]
)
history = _build_history(session)
assert history[0]["reminders"] == [
{"type": "denial", "text": "FIRST"},
{"type": "correction", "text": "SECOND"},
]
def test_reminders_coexist_with_attachments(self):
from turnstone.server import _build_history
session = self._session_with_messages(
[
{
"role": "user",
"content": [
{"type": "text", "text": "look"},
{"type": "image_url", "image_url": {"url": "data:..."}},
],
"_reminders": [{"type": "correction", "text": "watch"}],
}
]
)
history = _build_history(session)
assert history[0]["content"] == "look"
assert history[0]["attachments"] == [{"kind": "image", "filename": "", "mime_type": ""}]
assert history[0]["reminders"] == [{"type": "correction", "text": "watch"}]
def test_malformed_reminders_filtered_out(self):
"""Defensive: a non-dict element in the list (corruption / bug)
is dropped rather than crashing the history serialisation."""
from turnstone.server import _build_history
session = self._session_with_messages(
[
{
"role": "user",
"content": "x",
"_reminders": [
{"type": "correction", "text": "ok"},
"not-a-dict",
{"type": "denial"}, # missing text
],
}
]
)
history = _build_history(session)
# Non-dicts dropped; missing-text fills with empty string.
assert history[0]["reminders"] == [
{"type": "correction", "text": "ok"},
{"type": "denial", "text": ""},
]
def test_clean_message_passes_through_unchanged(self):
"""No reminders, plain content — _build_history is a no-op for the
reminder field and ``content`` rides through verbatim."""
from turnstone.server import _build_history
session = self._session_with_messages(
[{"role": "user", "content": "just a normal message"}]
)
history = _build_history(session)
assert history[0]["content"] == "just a normal message"
assert "reminders" not in history[0]
def test_assistant_content_with_literal_reminder_tag_unchanged(self):
"""Assistant output may legitimately reference the tag (e.g. when
the model is explaining the reminder system itself). No
transformation should ever apply to assistant content."""
from turnstone.server import _build_history
content = "Here is a <system-reminder> tag in assistant output."
session = self._session_with_messages([{"role": "assistant", "content": content}])
history = _build_history(session)
assert history[0]["content"] == content
class TestDetailInteractive:
"""Interactive parity for the lifted ``GET /v1/api/workstreams/{ws_id}``.
+1 -1
View File
@@ -1,3 +1,3 @@
"""turnstone - Multi-node AI orchestration platform with tool use, agent routing, and cluster simulation."""
__version__ = "1.5.1"
__version__ = "1.5.2"
+23
View File
@@ -312,6 +312,29 @@ class TerminalUI(SessionUI):
sys.stdout.write(f"{RED}{message}{RESET}\n")
sys.stdout.flush()
def _print_reminder(self, reminders: list[dict[str, str]]) -> None:
"""Render a metacognitive reminder list as ``[metacognition · type] text``
lines in the terminal the CLI's equivalent of the web UI's
yellow themed bubble. Used by both ``on_user_reminder`` and
``on_tool_reminder``; the rendering is identical because
terminal output is anchor-by-flow rather than DOM-by-anchor.
"""
for r in reminders:
nt = str(r.get("type", "") or "")
text = str(r.get("text", "") or "")
label = "metacognition" + (f" · {nt}" if nt else "")
sys.stdout.write(f"{YELLOW}[{label}]{RESET} {text}\n")
sys.stdout.flush()
def on_user_reminder(self, reminders: list[dict[str, str]]) -> None:
self._print_reminder(reminders)
def on_tool_reminder(self, reminders: list[dict[str, str]], tool_call_id: str) -> None:
# tool_call_id ignored — the CLI anchors by output sequence
# (the line lands directly after the tool result that
# triggered the batch's reminder).
self._print_reminder(reminders)
def on_state_change(self, state: str) -> None:
pass # base TerminalUI ignores state changes
@@ -335,6 +335,73 @@
return appendMsg(role, esc(text), opts);
}
// Metacognitive reminder bubble (user-channel correction / denial /
// resume / start / completion AND tool-channel tool_error / repeat).
// Mirrors Pane.prototype.addUserReminder / addToolReminder in the
// interactive UI — yellow themed bubble slotted directly below the
// message it advises. ``anchor`` is the DOM element to anchor below;
// when null, append at the bottom of messagesEl.
function appendReminderBubble(reminders, anchor) {
if (!Array.isArray(reminders) || !reminders.length) return;
let cursor = anchor;
for (let i = 0; i < reminders.length; i++) {
const r = reminders[i] || {};
const el = document.createElement("div");
el.className = "msg user-reminder";
el.setAttribute("role", "article");
el.setAttribute("data-ts-role", "metacognition");
el.setAttribute("aria-label", "metacognition");
const body = document.createElement("div");
body.className = "msg-body";
const labelEl = document.createElement("span");
labelEl.className = "msg-user-reminder-label";
labelEl.textContent =
"metacognition" + (r.type ? " · " + String(r.type) : "");
const textEl = document.createElement("span");
textEl.className = "msg-user-reminder-text";
textEl.textContent = r.text || "";
body.appendChild(labelEl);
body.appendChild(textEl);
el.appendChild(body);
if (cursor) {
cursor.insertAdjacentElement("afterend", el);
cursor = el;
} else {
messagesEl.appendChild(el);
}
}
_scheduleScroll();
}
// Live SSE for user-channel reminders — anchors below the most
// recent user message. On a non-originating tab there may be no
// user message rendered yet; we append and the next /history reload
// corrects. (Same caveat as the interactive UI; tracked there.)
function appendUserReminderLive(reminders) {
const userMsgs = messagesEl.querySelectorAll(".msg.user");
const anchor = userMsgs.length ? userMsgs[userMsgs.length - 1] : null;
appendReminderBubble(reminders, anchor);
}
// Live SSE for tool-channel reminders — anchors below the
// .coord-tool-batch construct that produced the tool result. Looks
// up the row by data-call-id and walks to the parent batch; falls
// back to the most recent batch if not found.
function appendToolReminderLive(reminders, toolCallId) {
let anchor = null;
if (toolCallId) {
const entry = toolRows.get(toolCallId);
if (entry && entry.batch) {
anchor = entry.batch;
}
}
if (!anchor) {
const batches = messagesEl.querySelectorAll(".coord-tool-batch");
if (batches.length) anchor = batches[batches.length - 1];
}
appendReminderBubble(reminders, anchor);
}
// Build a tool-batch item from a persisted assistant
// tool_call. Live calls land here with header / preview already
// computed by ChatSession._prepare_tool; history replay never sees
@@ -1844,6 +1911,22 @@
// styling which mis-categorised them as tool calls.
appendText("info", ev.message || "", { label: "info" });
break;
case "user_reminder":
// Metacognitive user-channel nudge — render below the most
// recent user message as a yellow themed bubble. Same shape
// as the interactive UI's case.
if (Array.isArray(ev.reminders) && ev.reminders.length) {
appendUserReminderLive(ev.reminders);
}
break;
case "tool_reminder":
// Metacognitive tool-channel nudge — render below the
// .coord-tool-batch that produced the tool result identified
// by ev.tool_call_id.
if (Array.isArray(ev.reminders) && ev.reminders.length) {
appendToolReminderLive(ev.reminders, ev.tool_call_id || "");
}
break;
case "connected":
// First yield from _coord_events_replay — populates the
// status bar's model cell before any history arrives. Also
@@ -3543,6 +3626,12 @@
(callId && toolNameByCallId.get(callId)) || m.tool_name || "tool";
const isError = callOutcomes.get(callId) === "error";
appendToolResult(toolName, callId, content || "", isError);
// Tool-channel metacog reminders ride the same _reminders
// side-channel as the user channel; surface as a themed
// bubble below the .coord-tool-batch construct.
if (Array.isArray(m.reminders) && m.reminders.length) {
appendToolReminderLive(m.reminders, callId);
}
} else if (role === "assistant") {
// Empty content with tool_calls only means the assistant
// turn was just tool dispatch — the synthesized tool-call
@@ -3573,6 +3662,15 @@
// (appendReasoningToken uses textContent; user/system are
// typed verbatim and don't carry markdown structure).
appendText(role, content, { label: role });
// User-channel metacog reminders attach to the just-appended
// user bubble (the most recent .msg.user in messagesEl).
if (
role === "user" &&
Array.isArray(m.reminders) &&
m.reminders.length
) {
appendUserReminderLive(m.reminders);
}
}
});
// History alone can't tell whether an orphaned assistant
+44 -1
View File
@@ -5,7 +5,50 @@ from __future__ import annotations
import re
import time
_COOLDOWN_SECS = 300 # 5 minutes between nudges of the same type
# Default cooldown (s) between nudges of the same type. Production
# paths pass ``cooldown_secs`` explicitly from
# ``MemoryConfig.nudge_cooldown`` (config-store ``memory.nudge_cooldown``,
# default 300); this constant is the fallback for tests and unit-style
# callers without a ``MemoryConfig`` and is kept aligned with that
# canonical default so both paths behave the same.
_COOLDOWN_SECS = 300
# Repeat-detection threshold — number of *consecutive* identical tool
# calls (same name + same arguments) before a repeat warning fires.
# Two-in-a-row is too noisy because legitimate retries on transient
# failures look identical; three-in-a-row is the cheapest signal that
# the model is stuck on the same call.
_REPEAT_THRESHOLD = 3
class RepeatDetector:
"""Detect a streak of identical tool-call signatures.
``record(sig)`` returns ``True`` once *sig* has been recorded
``threshold`` times in a row (default 3). Recording a different
signature resets the streak interleaved tool calls aren't a
stuck loop, only repeated identical ones are. After a fire, the
caller is expected to call ``clear()`` to start a fresh streak.
"""
def __init__(self, threshold: int = _REPEAT_THRESHOLD) -> None:
self._threshold = threshold
self._sig: str | None = None
self._count = 0
def record(self, sig: str) -> bool:
"""Record *sig*; return ``True`` when the streak hits the threshold."""
if sig == self._sig:
self._count += 1
else:
self._sig = sig
self._count = 1
return self._count >= self._threshold
def clear(self) -> None:
self._sig = None
self._count = 0
# ---------------------------------------------------------------------------
# Nudge messages (brief, model-facing hints)
+390 -176
View File
@@ -81,6 +81,7 @@ from turnstone.core.memory_relevance import (
score_memories,
)
from turnstone.core.metacognition import (
RepeatDetector,
detect_completion,
detect_correction,
format_nudge,
@@ -90,6 +91,7 @@ from turnstone.core.providers import create_provider
from turnstone.core.safety import is_command_blocked, sanitize_command
from turnstone.core.sandbox import execute_math_sandboxed
from turnstone.core.storage._registry import get_storage
from turnstone.core.tool_advisory import escape_wrapper_tags, render_system_reminder
from turnstone.core.tool_search import ToolSearchManager
from turnstone.core.tools import (
AGENT_AUTO_TOOLS,
@@ -275,6 +277,8 @@ class SessionUI(Protocol):
def on_plan_review(self, content: str) -> str: ...
def on_info(self, message: str) -> None: ...
def on_error(self, message: str) -> None: ...
def on_user_reminder(self, reminders: list[dict[str, str]]) -> None: ...
def on_tool_reminder(self, reminders: list[dict[str, str]], tool_call_id: str) -> None: ...
def on_state_change(self, state: str) -> None: ...
def on_rename(self, name: str) -> None: ...
def on_intent_verdict(self, verdict: dict[str, Any]) -> None:
@@ -474,8 +478,12 @@ class ChatSession:
collections.OrderedDict()
)
self._queued_lock = threading.Lock()
# Repeat detection: track recent tool call signatures
self._recent_tool_sigs: set[str] = set()
# Repeat detection: streak counter over tool-call signatures.
# Fires when a (name, args) signature has been seen N times in
# a row; recording any different signature resets the streak.
# Also cleared after a write tool succeeds (state changed) or
# after a warning fires (clean slate, re-fire on the next streak).
self._repeat_detector = RepeatDetector()
# Tool error tracking: call_id → is_error for message persistence
self._tool_error_flags: dict[str, bool] = {}
# Cooperative cancellation: set from outside to stop generation
@@ -1293,7 +1301,7 @@ class ChatSession:
self._ws_id = ws_id
self.messages = messages
self._read_files.clear()
self._recent_tool_sigs.clear()
self._repeat_detector.clear()
self._last_usage = None
self._calibrated_msg_count = 0
self._title_generated = True # don't re-title resumed workstreams
@@ -1638,6 +1646,124 @@ class ChatSession:
"""System messages + conversation history."""
return self.system_messages + self.messages
def _apply_reminders_for_provider(
self,
messages: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Return a transient copy of *messages* with ``_reminders`` rendered
inline for the model.
Metacognitive nudges live on the message dict's ``_reminders``
side-channel regardless of role user messages carry
user-channel nudges (correction / denial / resume / start /
completion), tool messages carry tool-channel nudges
(tool_error / repeat). Both ride the same side-channel so
``self.messages`` and every downstream consumer (UI replay,
compaction, title gen, channel adapters, DB) see clean
``content``; only the wire-bound copy here carries the
rendered reminder.
For each message that has ``_reminders`` AND has not yet been
flagged delivered, build a shallow copy and splice the reminders
as ``<system-reminder>`` blocks onto the trailing edge of the
copy's ``content`` — string content gets a tail block, list
content gets the block on the trailing text part (or a new text
part if there isn't one). Messages without ``_reminders`` (or
already delivered) pass through unchanged (same object
reference) so the common case is allocation-free.
**Reminder lifecycle.** After a successful provider stream
``_mark_reminders_delivered`` flips ``_reminders_delivered`` to
``True`` on every message (user or tool) that carried reminders
into that call, so subsequent provider calls skip them the
model sees each reminder once, the turn it advised. The
``_reminders`` key itself stays on the message dict for the
lifetime of the in-memory session so ``/history`` (reconnecting
tabs, multi-tab live mirrors) still renders the same nudge
bubbles the originating tab saw; only the wire-side replay is
suppressed. Compaction is the natural full drain (it replaces
``self.messages`` wholesale).
``sanitize_messages`` later drops both leading-underscore sibling
keys (``_reminders`` and ``_reminders_delivered``) on the way to
the wire, so the provider sees only ``content`` with the
reminder spliced in.
**Read-only contract on the returned list.** The pass-through
path returns the original ``msg`` by reference; callers must
not mutate the returned dicts in place (today's only callers —
``sanitize_messages`` + provider conversion construct new
dicts, so the contract holds). Mutations on the spliced copy
are safe; mutations on a pass-through reference would bleed
back into ``self.messages``.
"""
out: list[dict[str, Any]] = []
for msg in messages:
raw_reminders = msg.get("_reminders")
if not raw_reminders or msg.get("_reminders_delivered"):
out.append(msg)
continue
# Defensive filter — only dict entries are valid; a string /
# None / other shape from corruption or partial state must
# not abort the whole send via an AttributeError on .get().
# Mirrors the same filter ``_build_history`` applies on the
# wire-out side.
reminders = [r for r in raw_reminders if isinstance(r, dict)]
if not reminders:
out.append(msg)
continue
block = "\n\n" + "\n\n".join(
render_system_reminder(r.get("text", "")) for r in reminders
)
copy = dict(msg)
content = copy.get("content")
if isinstance(content, str):
copy["content"] = escape_wrapper_tags(content) + block
elif isinstance(content, list):
# Shallow-copy the parts list and any text parts we'll
# mutate so the original list/dicts in self.messages stay
# untouched.
new_parts = [
dict(p) if isinstance(p, dict) and p.get("type") == "text" else p
for p in content
]
text_parts = [
p for p in new_parts if isinstance(p, dict) and p.get("type") == "text"
]
for part in text_parts:
part["text"] = escape_wrapper_tags(part.get("text", ""))
if text_parts:
text_parts[-1]["text"] = text_parts[-1]["text"] + block
else:
new_parts.append({"type": "text", "text": block})
copy["content"] = new_parts
else:
# Unexpected shape (None, etc.) — attach as a text-only
# content rather than dropping the reminder silently.
copy["content"] = block.lstrip()
out.append(copy)
return out
def _mark_reminders_delivered(self) -> None:
"""Flag every message's ``_reminders`` as delivered.
Role-agnostic both user-channel reminders (set by
``_attach_pending_user_reminders`` on user messages) and
tool-channel reminders (set by the per-result loop on tool
messages) ride the same ``_reminders`` side-channel and the
same delivered flag. Called after a successful provider
stream; subsequent calls to ``_apply_reminders_for_provider``
skip messages with this flag, so the model sees each reminder
exactly once (the turn it advised). The flag is a sibling key
like ``_reminders`` itself; ``sanitize_messages`` strips both
before the wire and ``_build_history`` ignores the delivered
flag entirely so UI replay parity is preserved across
reconnects.
"""
for msg in self.messages:
if msg.get("_reminders") and not msg.get("_reminders_delivered"):
msg["_reminders_delivered"] = True
def _emit_state(self, state: str) -> None:
"""Notify UI of a workstream state transition.
@@ -2113,12 +2239,13 @@ class ChatSession:
# Metacognitive user-channel drain: any nudges queued via
# _queue_user_advisory (correction/start/completion from this
# turn, denial from the previous tool batch, resume from
# rehydrate) splice in as <system-reminder> blocks at the
# trailing edge of the user content. The DB row stores
# ``user_input`` only (line below) so these blocks stay
# ephemeral — they advise the next assistant turn and do not
# persist across reloads.
self._splice_pending_user_advisories(user_msg)
# rehydrate) attach to the user message dict's ``_reminders``
# side-channel — content stays clean. The wire-side splice
# happens later in _apply_reminders_for_provider against a
# transient copy. The DB row stores ``user_input`` only (line
# below) so reminders stay in-memory only and don't persist
# across reloads.
self._attach_pending_user_reminders(user_msg)
self.messages.append(user_msg)
self._msg_tokens.append(max(1, int(self._msg_char_count(user_msg) / self._chars_per_token)))
# DB row stores the raw text only; attachments are joined back in
@@ -2198,7 +2325,7 @@ class ChatSession:
try:
while True:
self._check_cancelled(my_generation)
msgs = self._full_messages()
msgs = self._apply_reminders_for_provider(self._full_messages())
if self.debug:
self._debug_print_request(msgs)
@@ -2235,7 +2362,7 @@ class ChatSession:
self.ui.on_thinking_stop()
try:
self._compact_messages(auto=True)
msgs = self._full_messages()
msgs = self._apply_reminders_for_provider(self._full_messages())
self.ui.on_thinking_start()
stream = self._create_stream_with_retry(msgs)
except Exception:
@@ -2257,7 +2384,19 @@ class ChatSession:
if self._generation != my_generation:
return
self._update_token_table(assistant_msg)
# Reuse the wire-bound ``msgs`` we already built for the
# stream call instead of re-applying the reminder splice
# (perf-2). After mark-delivered runs below, a fresh
# _apply_reminders_for_provider would skip the
# just-rendered reminders and undercount; passing the
# already-rendered list keeps calibration char count
# aligned with what the provider actually counted.
self._update_token_table(assistant_msg, msgs=msgs)
# Reminders that rode this stream have now reached the
# model; flag delivered so the next provider call skips
# them (one-shot semantics for the wire; UI replay still
# surfaces ``_reminders`` for reconnect parity).
self._mark_reminders_delivered()
self._print_status_line() # Report usage for EVERY API call
self.messages.append(assistant_msg)
self._msg_tokens.append(
@@ -2332,92 +2471,10 @@ class ChatSession:
if self._generation != my_generation:
return
# Repeat detection: warn when a tool is called with identical args.
# Skip error outputs — retrying a failed tool is valid.
# Skip JSON outputs (MCP structured results) — appending
# text would corrupt the payload.
_tc_by_id = {c["id"]: c for c in tool_calls}
_repeat_detected = False
_error_prefixes = (
"Error",
"JSON parse error",
"Unknown tool",
"Command timed out",
"Blocked:",
"Denied",
)
# Clear dedup sigs when a write tool executed successfully —
# the state has changed so re-running a read tool is valid.
_write_tools = frozenset({"write_file", "edit_file", "bash"})
if any(
tc["function"]["name"] in _write_tools
and not any(
cid == tc["id"] and isinstance(out, str) and out.startswith(_error_prefixes)
for cid, out in results
)
for tc in tool_calls
):
self._recent_tool_sigs.clear()
for i, (tc_id, output) in enumerate(results):
tc = _tc_by_id.get(tc_id)
if tc and isinstance(output, str) and not output.startswith(_error_prefixes):
raw = tc["function"]["name"] + ":" + tc["function"]["arguments"]
sig = hashlib.sha256(raw.encode()).hexdigest()
is_json = output.lstrip().startswith(("{", "["))
if sig in self._recent_tool_sigs:
_repeat_detected = True
if not is_json:
output += (
"\n\n⚠ Warning: this is an identical repeat of a "
"previous tool call. The result is the same. "
"Try a different approach."
)
results[i] = (tc_id, output)
self.ui.on_info(
f"{GRAY}[repeat: {tc['function']['name']}() "
f"called with same arguments]{RESET}"
)
self._recent_tool_sigs.add(sig)
if _repeat_detected:
# Reset so the model gets a clean slate after the warning.
# If it repeats again, a new warning fires.
self._recent_tool_sigs.clear()
if self._mem_cfg.nudges and should_nudge(
"repeat",
self._metacog_state,
message_count=len(self.messages),
cooldown_secs=self._mem_cfg.nudge_cooldown,
):
self._queue_tool_advisory("repeat", format_nudge("repeat"))
# Tool-error nudge — checked here (pre-iteration) so the
# MetacognitiveAdvisory rides the same _collect_advisories
# drain pass that handles guard findings and user
# interjections. Cooldown gating in should_nudge keeps
# this to one nudge per batch even with many failing
# tools.
if (
self._mem_cfg.nudges
and any(
isinstance(out, str)
and (
out.startswith("Error")
or " error: " in out[:50]
or out.startswith("Command timed out")
or out.startswith("Unknown tool:")
)
for _, out in results
)
and should_nudge(
"tool_error",
self._metacog_state,
message_count=len(self.messages),
memory_count=self._visible_memory_count(),
cooldown_secs=self._mem_cfg.nudge_cooldown,
)
):
self._queue_tool_advisory("tool_error", format_nudge("tool_error"))
# Repeat-detection + tool-error nudge. Mutates *results*
# in place to inject inline warning text on identical
# repeats; queues advisories for the next drain pass.
self._apply_post_execute_advisories(tool_calls, results)
# Map tool_call_id → tool name for logging
from turnstone.core.tool_advisory import wrap_tool_result
@@ -2456,19 +2513,30 @@ class ChatSession:
# Capture raw output for DB storage before advisory wrapping
raw_output = output
# Advisory injection: wrap tool output with advisories
# (output guard findings, queued user messages, etc.)
advisories = self._collect_advisories(
# Advisory injection: persistent advisories (output
# guard findings, queued user interjections) wrap
# into the tool-result envelope and stay in
# self.messages. Metacognitive tool-channel
# reminders (tool_error / repeat) ride a side-channel
# — never inside content — so the model sees the
# splice only at the wire boundary via
# _apply_reminders_for_provider, while UI/replay
# surfaces them as a themed bubble below the tool
# result.
persistent_advisories, metacog_reminders = self._collect_advisories(
assessment, _tc_names.get(tc_id, ""), _ri == _last_idx
)
if isinstance(output, str):
output = wrap_tool_result(output, advisories)
elif isinstance(output, list) and advisories:
output = wrap_tool_result(output, persistent_advisories)
elif isinstance(output, list) and persistent_advisories:
# Structured/image output — append advisories as a
# text part so they aren't silently dropped.
output = [
*output,
{"type": "text", "text": wrap_tool_result("", advisories)},
{
"type": "text",
"text": wrap_tool_result("", persistent_advisories),
},
]
tool_msg: dict[str, Any] = {
@@ -2478,6 +2546,15 @@ class ChatSession:
}
if self._tool_error_flags.pop(tc_id, False):
tool_msg["is_error"] = True
if metacog_reminders:
tool_msg["_reminders"] = metacog_reminders
try:
self.ui.on_tool_reminder(metacog_reminders, tc_id)
except Exception:
log.warning(
"ui.on_tool_reminder failed; reminder still attached",
exc_info=True,
)
self.messages.append(tool_msg)
# Token estimation — image content uses a fixed heuristic
@@ -2583,10 +2660,7 @@ class ChatSession:
# Drain any queued user messages so they appear in the
# conversation and are visible on the next send().
self._flush_queued_messages()
# Tool-channel nudges queued earlier in this generation
# (tool_error, repeat) belong to the abandoned batch — drop
# them so they don't bleed into the next send()'s tool loop.
self._pending_tool_advisories.clear()
self._drain_pending_advisories()
# No need to clear _cancel_event — it's replaced per-generation
# in send(), so this generation's event is simply discarded.
self.ui.on_info("[Generation cancelled]")
@@ -2596,15 +2670,29 @@ class ChatSession:
except KeyboardInterrupt as exc:
self._synthesize_cancelled_results("Interrupted by user.")
self._flush_queued_messages()
self._pending_tool_advisories.clear()
self._drain_pending_advisories()
self._record_fatal_error(exc)
raise
except Exception as exc:
self._flush_queued_messages()
self._pending_tool_advisories.clear()
self._drain_pending_advisories()
self._record_fatal_error(exc)
raise
def _drain_pending_advisories(self) -> None:
"""Drop both advisory channels' pending buffers.
Both channels are scoped to the current generation: tool-channel
nudges (``tool_error``, ``repeat``) queued earlier in this batch
and user-channel nudges (``correction``, ``denial``, ) queued
during ``_check_metacognitive_nudge`` but not yet drained. When
a generation is abandoned (cancel, KeyboardInterrupt, unexpected
exception) both must drop so they don't bleed into the next
send's tool loop or next user turn.
"""
self._pending_tool_advisories.clear()
self._pending_user_advisories.clear()
def _synthesize_cancelled_results(self, reason: str) -> None:
"""Synthesize tool_result messages for orphaned tool_calls after cancel.
@@ -3137,8 +3225,24 @@ class ChatSession:
text_chars, images, doc_chars = self._msg_text_chars(msg)
return text_chars + doc_chars + int(images * self._IMAGE_TOKENS * self._chars_per_token)
def _update_token_table(self, assistant_msg: dict[str, Any]) -> None:
"""Update per-message token estimates using API usage data."""
def _update_token_table(
self,
assistant_msg: dict[str, Any],
*,
msgs: list[dict[str, Any]] | None = None,
) -> None:
"""Update per-message token estimates using API usage data.
*msgs* (optional) is the wire-bound message list already built
for the stream call passing it avoids a redundant
``_apply_reminders_for_provider`` walk and, more importantly,
ensures the char count matches the bytes the provider counted
even after ``_mark_reminders_delivered`` has flipped the flag
on the reminders that rode the stream. When *msgs* is None the
caller didn't pre-build (rare path) — fall back to applying
the splice on the fly, but be aware the result will be
reminder-free if delivered flags are already set.
"""
if not self._last_usage:
return
@@ -3148,8 +3252,16 @@ class ChatSession:
# Calibrate chars_per_token ratio from actual usage.
# Images get a fixed token budget (subtracted). Documents
# tokenize non-linearly depending on provider — excluded from
# calibration so they don't skew the text ratio.
all_msgs = self._full_messages() # system + self.messages (before append)
# calibration so they don't skew the text ratio. ``all_msgs``
# must reflect what the provider actually counted in
# ``prompt_tokens``: when called from the loop with the
# pre-built ``msgs``, that's exact; without it, fall back to
# applying the splice fresh (post-mark-delivered the result
# may undercount, but no caller currently takes this path
# after a successful stream).
all_msgs = (
msgs if msgs is not None else self._apply_reminders_for_provider(self._full_messages())
) # system + self.messages (before append)
active_tools = self._get_active_tools() or []
tool_def_chars = sum(len(json.dumps(t)) for t in active_tools)
text_chars = 0
@@ -3391,7 +3503,7 @@ class ChatSession:
self.messages = [summary_user, summary_asst]
# File contents are gone after compaction — force re-read before edit_file
self._read_files.clear()
self._recent_tool_sigs.clear()
self._repeat_detector.clear()
# Rebuild token table
su_tok = max(1, int(self._msg_char_count(summary_user) / self._chars_per_token))
@@ -3780,12 +3892,26 @@ class ChatSession:
assessment: OutputAssessment | None,
func_name: str,
is_last_in_batch: bool,
) -> list[ToolAdvisory]:
) -> tuple[list[ToolAdvisory], list[dict[str, str]]]:
"""Gather advisories to attach to a tool result message.
Returns an empty list when no advisories apply (common case).
Guard advisories attach per-result; user messages drain on the
last result in the batch only.
Returns ``(persistent, metacog_reminders)``:
- ``persistent`` guard findings + user interjections that ride
inside the tool-result envelope via ``wrap_tool_result``.
These are conversation history and must persist in
``self.messages``.
- ``metacog_reminders`` list of ``{"type", "text"}`` dicts for
``tool_error`` / ``repeat`` nudges that the caller attaches to
the tool message dict's ``_reminders`` side-channel. Like
user-channel reminders, they are spliced into ``content`` only
at the wire boundary by ``_apply_reminders_for_provider`` and
surfaced separately on the UI as a themed bubble below the
tool result.
Both lists are empty when no advisories apply (common case).
Guard advisories attach per-result; user messages and
metacognitive nudges drain on the last result in the batch only.
"""
from turnstone.core.tool_advisory import GuardAdvisory, UserInterjection
@@ -3801,26 +3927,25 @@ class ChatSession:
if is_last_in_batch:
self._pending_tool_advisories.clear()
self._flush_queued_messages()
return []
return [], []
advisories: list[ToolAdvisory] = []
persistent: list[ToolAdvisory] = []
metacog_reminders: list[dict[str, str]] = []
# Output guard advisory
# Output guard advisory — persists with the tool result.
if assessment is not None:
advisories.append(GuardAdvisory(assessment=assessment, func_name=func_name))
persistent.append(GuardAdvisory(assessment=assessment, func_name=func_name))
# Metacognitive tool-channel drain — fires once per batch on the
# last result. Queued by _queue_tool_advisory from the
# tool_error and repeat detection paths just before this loop.
# last result. Queued by _queue_tool_advisory from the
# tool_error / repeat detection paths just before this loop.
# Lands on the tool message dict's ``_reminders`` side-channel
# (caller's responsibility) so it stays out of persisted content
# and rides the wire only via the transient-copy splice.
if is_last_in_batch and self._pending_tool_advisories:
from turnstone.core.tool_advisory import MetacognitiveAdvisory
drained = list(self._pending_tool_advisories)
self._pending_tool_advisories.clear()
advisories.extend(
MetacognitiveAdvisory(nudge_type=nt, message=text) for nt, text in drained
)
self._emit_nudge_ping(nt for nt, _ in drained)
metacog_reminders.extend({"type": nt, "text": text} for nt, text in drained)
# Drain queued user messages on the last result in the batch.
# Attachment-bearing items fall back to a full multipart user
@@ -3834,7 +3959,7 @@ class ChatSession:
if att_ids:
attachment_items.append((queue_msg_id, msg, priority, att_ids))
else:
advisories.append(UserInterjection(message=msg, priority=priority))
persistent.append(UserInterjection(message=msg, priority=priority))
if attachment_items:
from turnstone.core.tool_advisory import PRIORITY_IMPORTANT
@@ -3845,7 +3970,7 @@ class ChatSession:
)
self._append_user_turn(text, resolved, send_id=queue_msg_id)
return advisories
return persistent, metacog_reminders
# -- Two-phase tool execution -----------------------------------------------
#
@@ -3974,7 +4099,14 @@ class ChatSession:
)
return item["call_id"], item["error"]
if item.get("denied"):
return item["call_id"], item.get("denial_msg", "Denied by user")
msg = item.get("denial_msg", "Denied by user")
self._report_tool_result(
item["call_id"],
item.get("func_name", "unknown"),
msg,
is_error=True,
)
return item["call_id"], msg
try:
result: tuple[str, str | list[dict[str, Any]]] = item["execute"](item)
return result
@@ -5377,59 +5509,52 @@ class ChatSession:
def _queue_user_advisory(self, nudge_type: str, text: str) -> None:
"""Queue a metacognitive nudge for the next user turn.
Drains in ``_append_user_turn`` as a ``<system-reminder>`` block
appended to the user message body. Used for nudges that respond
to user behaviour: ``correction``, ``denial``, ``resume``,
Drains in ``_append_user_turn`` onto the user message dict's
``_reminders`` side-channel. Used for nudges that respond to
user behaviour: ``correction``, ``denial``, ``resume``,
``start``, ``completion``.
"""
self._pending_user_advisories.append((nudge_type, text))
def _splice_pending_user_advisories(self, user_msg: dict[str, Any]) -> None:
"""Drain ``_pending_user_advisories`` into *user_msg*'s content.
def _attach_pending_user_reminders(self, user_msg: dict[str, Any]) -> None:
"""Drain ``_pending_user_advisories`` onto *user_msg*'s ``_reminders``
sibling key (a side-channel, never inside ``content``).
Mutates *user_msg* in place the caller appends it after.
Renders each queued nudge as a ``<system-reminder>`` block
(same envelope as ``wrap_tool_result``) and attaches them to
the trailing edge of the user content. Every text segment in
the user content is passed through ``escape_wrapper_tags``
first so a user typing literal ``<system-reminder>`` cannot
fabricate an envelope the model would treat as a
Turnstone-issued reminder. For attachment-bearing turns the
blocks land on the trailing text part so they stay glued to
the same multipart turn.
Mutates *user_msg* in place the caller appends it after. The
rendered ``<system-reminder>`` envelope is built later in
``_apply_reminders_for_provider`` against a transient copy, so
the model still sees the reminder spliced into ``content`` at
the wire boundary while ``self.messages`` and every downstream
consumer (UI replay, compaction, title gen, channel adapters,
DB) see clean user text.
``_reminders`` rides the leading-underscore convention used by
other internal sibling metadata (``_attachments_meta``,
``_provider_content``); ``sanitize_messages`` strips it before
the wire on its own pass.
Also fires the live ``on_user_reminder`` UI hook so any open
SSE consumers (other browser tabs, CLI mirrors, eventual
channel adapters) can render the reminder bubble in lockstep
with the originating tab's optimistic render. Hook failures
are logged and swallowed: the side-channel write is the
load-bearing op, and a UI implementation throwing here must
not abort the user's send (which would otherwise drop both the
user message and the queued nudges).
"""
if not self._pending_user_advisories:
return
from turnstone.core.tool_advisory import escape_wrapper_tags, render_system_reminder
items = list(self._pending_user_advisories)
self._pending_user_advisories.clear()
block = "\n\n" + "\n\n".join(render_system_reminder(text) for _, text in items)
content = user_msg["content"]
if isinstance(content, str):
user_msg["content"] = escape_wrapper_tags(content) + block
else:
text_parts = [p for p in content if isinstance(p, dict) and p.get("type") == "text"]
for part in text_parts:
part["text"] = escape_wrapper_tags(part.get("text", ""))
if text_parts:
text_parts[-1]["text"] = text_parts[-1]["text"] + block
else:
content.append({"type": "text", "text": block})
reminders = [{"type": nudge_type, "text": text} for nudge_type, text in items]
user_msg["_reminders"] = reminders
self._emit_nudge_ping(nudge_type for nudge_type, _ in items)
def _emit_nudge_ping(self, types: Iterable[str]) -> None:
"""Surface the ``[metacognition: nudge injected — …]`` UI line.
Centralised so both drain sites (tool channel via
``_collect_advisories``, user channel via
``_splice_pending_user_advisories``) emit the same wording.
"""
joined = ", ".join(types)
if joined:
self.ui.on_info(f"{GRAY}[metacognition: nudge injected — {joined}]{RESET}")
try:
self.ui.on_user_reminder(reminders)
except Exception:
log.warning("ui.on_user_reminder failed; reminder still attached", exc_info=True)
def _queue_tool_advisory(self, nudge_type: str, text: str) -> None:
"""Queue a metacognitive nudge for the next tool-result batch.
@@ -5441,6 +5566,95 @@ class ChatSession:
"""
self._pending_tool_advisories.append((nudge_type, text))
def _apply_post_execute_advisories(
self,
tool_calls: list[dict[str, Any]],
results: list[tuple[str, str | list[dict[str, Any]]]],
) -> None:
"""Run repeat detection + tool-error nudge over a freshly-executed batch.
Mutates *results* in place when an identical-repeat warning is
appended to a tool's text output. Updates ``self._repeat_detector``,
``self._pending_tool_advisories``, and ``self._metacog_state``
(cooldown timestamp via ``should_nudge``). The operator-visible
signal is the themed ``tool_reminder`` bubble below the tool
block emitted by the per-result loop downstream when the
drained metacog reminders attach to the tool message dict's
``_reminders`` side-channel.
Repeat detection's job is to nudge a flaky local model out of a
loop where it keeps making the same tool call ("``bash(cmd='echo
test')`` × 3" being the canonical example). It fires on the
consecutive-streak signal alone, with no regard for the tool's
success / failure / output content same (name, args) for N
turns in a row is by definition stuck. ``RepeatDetector.record``
already resets the streak on any different signature, so an
intervening tool call (read, write, anything different) breaks
the streak naturally without an explicit clear here.
``_tool_error_flags`` is the authoritative is_error signal
consumed below for the tool-error nudge gate; the per-result
loop in ``_run_loop`` ``.pop``s it after this returns.
"""
# Repeat detection: warn when a tool is called with identical
# args N times in a row. Independent of success/failure — the
# stuck-loop pattern is sig-driven, not state-driven. JSON
# outputs (MCP structured results) are tracked but exempt from
# the inline warning text (appending text would corrupt the
# payload).
_tc_by_id = {c["id"]: c for c in tool_calls}
_repeat_detected = False
for i, (tc_id, output) in enumerate(results):
tc = _tc_by_id.get(tc_id)
if tc and isinstance(output, str):
raw = tc["function"]["name"] + ":" + tc["function"]["arguments"]
sig = hashlib.sha256(raw.encode()).hexdigest()
is_json = output.lstrip().startswith(("{", "["))
if self._repeat_detector.record(sig):
_repeat_detected = True
if not is_json:
output += (
"\n\n⚠ Warning: this is an identical repeat of a "
"previous tool call. The result is the same. "
"Try a different approach."
)
results[i] = (tc_id, output)
# The themed ``tool_reminder`` bubble below the tool
# block carries the operator-visible signal; the
# tool-name context comes from the visible tool
# block immediately above the bubble, so a separate
# diagnostic info line would just duplicate it.
if _repeat_detected:
# Reset so the model gets a clean slate after the warning.
# If it repeats again, a new warning fires.
self._repeat_detector.clear()
if self._mem_cfg.nudges and should_nudge(
"repeat",
self._metacog_state,
message_count=len(self.messages),
cooldown_secs=self._mem_cfg.nudge_cooldown,
):
self._queue_tool_advisory("repeat", format_nudge("repeat"))
# Tool-error nudge — queued so the MetacognitiveAdvisory rides
# the same _collect_advisories drain pass as guard findings and
# user interjections. Cooldown gating in should_nudge keeps
# this to one nudge per batch even with many failing tools.
if (
self._mem_cfg.nudges
and any(self._tool_error_flags.get(tc_id) for tc_id, _ in results)
and should_nudge(
"tool_error",
self._metacog_state,
message_count=len(self.messages),
memory_count=self._visible_memory_count(),
cooldown_secs=self._mem_cfg.nudge_cooldown,
)
):
self._queue_tool_advisory("tool_error", format_nudge("tool_error"))
# ------------------------------------------------------------------
# Coordinator tools — reachable only when ``kind == "coordinator"``.
# All six dispatch through ``self._coord_client`` which is None when
@@ -9203,7 +9417,7 @@ class ChatSession:
elif cmd == "/clear":
self.messages.clear()
self._read_files.clear()
self._recent_tool_sigs.clear()
self._repeat_detector.clear()
self._last_usage = None
self._calibrated_msg_count = 0
self._msg_tokens = []
@@ -9214,7 +9428,7 @@ class ChatSession:
self.messages.clear()
self._read_files.clear()
self._recent_tool_sigs.clear()
self._repeat_detector.clear()
self._last_usage = None
self._calibrated_msg_count = 0
self._msg_tokens = []
+36
View File
@@ -1293,6 +1293,42 @@ class SessionUIBase:
def on_error(self, message: str) -> None:
self._enqueue({"type": "error", "message": message})
def on_user_reminder(self, reminders: list[dict[str, str]]) -> None:
"""Surface a metacognitive user-channel nudge as its own UI
element.
Reminders live on the user message dict's ``_reminders``
side-channel and are spliced into ``content`` only at the
provider boundary; this event is what lets every connected
SSE consumer (other browser tabs, CLI mirrors, future channel
adapters) render the reminder bubble in lockstep with the
originating tab. The history-replay path surfaces the same
shape via ``_build_history`` so a tab reconnecting later
renders the same bubble.
"""
self._enqueue({"type": "user_reminder", "reminders": reminders})
def on_tool_reminder(self, reminders: list[dict[str, str]], tool_call_id: str) -> None:
"""Surface a metacognitive tool-channel nudge (``tool_error`` /
``repeat``) as its own UI element below the tool result that
triggered it.
Tool-channel reminders ride the same ``_reminders``
side-channel pattern as the user channel kept out of
``content`` so compaction / title-gen / channel adapters never
see the nudge text, spliced into the wire only via
``_apply_reminders_for_provider``. ``tool_call_id`` is the
anchor the frontend uses to render the bubble below the
specific tool result that triggered the batch's reminder.
"""
self._enqueue(
{
"type": "tool_reminder",
"reminders": reminders,
"tool_call_id": tool_call_id,
}
)
# ------------------------------------------------------------------
# Broadcast hooks — kind-specific transport.
#
+6
View File
@@ -139,6 +139,12 @@ class NullUI:
def on_error(self, message: str) -> None:
pass
def on_user_reminder(self, reminders: list[dict[str, str]]) -> None:
pass
def on_tool_reminder(self, reminders: list[dict[str, str]], tool_call_id: str) -> None:
pass
def on_state_change(self, state: str) -> None:
pass
+28
View File
@@ -359,6 +359,16 @@ def _build_history(
issued the tool calls is also marked ``"denied": True`` so the
client can render the correct badge.
"""
# Metacognitive nudges live on the message dict's ``_reminders``
# side-channel — user messages carry user-channel nudges
# (correction / denial / resume / start / completion), tool
# messages carry tool-channel nudges (tool_error / repeat). Both
# are surfaced separately on each entry so the UI can render them
# as their own bubble (live via ``user_reminder`` /
# ``tool_reminder`` SSE events; replay via this propagation).
# ``content`` never carries the ``<system-reminder>`` envelope —
# that splice is transient, applied to a wire-bound copy in
# ``ChatSession._apply_reminders_for_provider``.
history = []
for msg in session.messages:
content = msg.get("content")
@@ -404,6 +414,24 @@ def _build_history(
entry = {"role": msg["role"], "content": content}
if attachments_meta:
entry["attachments"] = attachments_meta
# Surface the ``_reminders`` side-channel so a tab reconnecting
# via /history renders the same metacognitive nudge bubble the
# originating tab saw live (user-channel reminders via
# ``user_reminder`` SSE; tool-channel via ``tool_reminder``).
# Reminders are in-memory only (not persisted to DB), so this
# only fires for the originating session.
reminders = msg.get("_reminders")
if isinstance(reminders, list):
# Filter first so an all-malformed _reminders doesn't set the
# field to []; absent vs. empty-list should mean the same
# thing on the wire.
clean_reminders = [
{"type": str(r.get("type") or ""), "text": str(r.get("text") or "")}
for r in reminders
if isinstance(r, dict)
]
if clean_reminders:
entry["reminders"] = clean_reminders
if msg.get("tool_calls"):
entry["tool_calls"] = [
{
+21
View File
@@ -885,6 +885,27 @@
.msg.user {
border-left-color: var(--accent);
}
/* Metacognitive reminder slotted directly below the message it
advises (user message for correction/denial/etc., tool result for
tool_error/repeat). Yellow accent reads as "advisory metadata"
against the amber-ish user colour and the cyan tool cards;
deliberately quieter than the surrounding bubbles so it doesn't
compete for attention. Lives in the shared stylesheet so both
the interactive UI and the console coord viewer render the same
themed bubble. */
.msg.user-reminder {
border-left-color: var(--yellow);
color: var(--fg-dim);
font-size: 12px;
padding: 6px 10px;
white-space: pre-wrap;
}
.msg.user-reminder .msg-user-reminder-label {
color: var(--yellow);
font-weight: 600;
margin-right: 6px;
text-transform: lowercase;
}
.msg.assistant {
border-left-color: var(--hair-2);
}
+163 -6
View File
@@ -557,6 +557,44 @@ Pane.prototype.handleEvent = function (evt) {
this.addErrorMessage(evt.message);
break;
case "user_reminder":
// Metacognitive nudges — render as their own bubble below the
// user message they advise (semantically: a hint to the model
// right before its turn). The originating tab's optimistic
// addUserMessage already ran when the user clicked send, so by
// the time this SSE event arrives the just-sent user bubble is
// at the bottom of messagesEl and addUserReminder's "anchor to
// most recent .msg.user" lookup finds it correctly; the
// insertAdjacentElement('afterend', el) call drops the bubble
// immediately below.
//
// Multi-tab caveat: the server emits no user_message SSE event
// today, so a non-originating tab open on the same workstream
// sees the reminder without a paired user-message render — the
// anchor falls on a stale prior user bubble, mis-positioning
// the reminder. The next /history reload corrects it (the
// entry["reminders"] propagation in _build_history is
// anchor-stable because replayHistory runs addUserMessage first
// for every turn). Acceptable cost for stage 1; closing the
// gap is a follow-up that adds a user_message SSE event.
if (Array.isArray(evt.reminders) && evt.reminders.length) {
this.addUserReminder(evt.reminders);
}
break;
case "tool_reminder":
// Metacognitive tool-channel nudge (tool_error / repeat) —
// render as the same yellow themed bubble used for user-channel
// reminders, anchored below the .ts-approval block whose tool
// result triggered the batch's reminder. evt.tool_call_id
// identifies the specific tool element; addToolReminder walks
// up to its parent approval block and inserts the bubble
// immediately after.
if (Array.isArray(evt.reminders) && evt.reminders.length) {
this.addToolReminder(evt.reminders, evt.tool_call_id || "");
}
break;
case "message_queued":
// Confirmation from server that a queued message was accepted.
// The UI already showed the message optimistically in addQueuedMessage.
@@ -669,6 +707,97 @@ Pane.prototype.removeThinkingIndicator = function () {
if (el) el.remove();
};
Pane.prototype.addUserReminder = function (reminders) {
// Render each metacognitive reminder as its own bubble immediately
// BELOW the user message it advises — semantically the reminder is
// a hint to the model right before the assistant turn. Always
// called AFTER the corresponding addUserMessage (live: optimistic
// local render ran before the SSE event arrived; replay:
// replayHistory renders the user message first), so "most recent
// .msg.user" is always THIS turn's bubble — insertAdjacentElement
// afterend drops the reminder directly below it. When no .msg.user
// exists at all (e.g. a non-originating tab receiving a reminder
// before any user turn has rendered) we append; the next /history
// reload corrects any anchor anomaly.
this.removeEmptyState();
var userBubbles = this.messagesEl.querySelectorAll(".msg.user");
var anchor = userBubbles.length ? userBubbles[userBubbles.length - 1] : null;
for (var i = 0; i < reminders.length; i++) {
var r = reminders[i] || {};
var el = document.createElement("div");
el.className = "msg user-reminder";
var labelEl = document.createElement("span");
labelEl.className = "msg-user-reminder-label";
labelEl.textContent =
"metacognition" + (r.type ? " · " + String(r.type) : "");
var textEl = document.createElement("span");
textEl.className = "msg-user-reminder-text";
textEl.textContent = r.text || "";
el.appendChild(labelEl);
el.appendChild(textEl);
if (anchor) {
anchor.insertAdjacentElement("afterend", el);
// Anchor advances so multiple reminders stack below the user
// message in queued order (rather than each landing
// immediately-after the user msg, which would reverse them).
anchor = el;
} else {
this.messagesEl.appendChild(el);
}
}
this.scrollToBottom(true);
};
Pane.prototype.addToolReminder = function (reminders, toolCallId) {
// Render each metacognitive tool-channel reminder (tool_error /
// repeat) as the same yellow themed bubble used for user-channel
// reminders, anchored below the .ts-approval block that produced
// the tool result. toolCallId is the live-path anchor (SSE event
// carries it); during replay it's an empty string and we fall back
// to "last .ts-approval block in messagesEl", which is correct
// because messages render in order — the assistant block carrying
// the tool batch is always the most recent approval block by the
// time we hit the tool message that owns the reminder.
this.removeEmptyState();
var anchor = null;
if (toolCallId) {
var escapedId = CSS.escape(toolCallId);
var toolEl = this.messagesEl.querySelector(
'.ts-approval-tool[data-call-id="' + escapedId + '"]',
);
if (toolEl) {
anchor = toolEl.closest(".ts-approval");
}
}
if (!anchor) {
var blocks = this.messagesEl.querySelectorAll(".ts-approval");
if (blocks.length) anchor = blocks[blocks.length - 1];
}
for (var i = 0; i < reminders.length; i++) {
var r = reminders[i] || {};
var el = document.createElement("div");
// Same .msg.user-reminder class — visual treatment is shared
// across user and tool channels (both are metacog nudges).
el.className = "msg user-reminder";
var labelEl = document.createElement("span");
labelEl.className = "msg-user-reminder-label";
labelEl.textContent =
"metacognition" + (r.type ? " · " + String(r.type) : "");
var textEl = document.createElement("span");
textEl.className = "msg-user-reminder-text";
textEl.textContent = r.text || "";
el.appendChild(labelEl);
el.appendChild(textEl);
if (anchor) {
anchor.insertAdjacentElement("afterend", el);
anchor = el;
} else {
this.messagesEl.appendChild(el);
}
}
this.scrollToBottom(true);
};
Pane.prototype.addUserMessage = function (text, attachments) {
this.removeEmptyState();
var el = document.createElement("div");
@@ -911,7 +1040,16 @@ Pane.prototype.replayHistory = function (messages) {
for (var i = 0; i < messages.length; i++) {
var msg = messages[i];
if (msg.role === "user") {
// addUserMessage first so addUserReminder's "anchor to most
// recent .msg.user" lookup finds THIS message's bubble (not the
// previous user message's, which would associate the reminder
// with the wrong turn). addUserReminder then drops the bubble
// immediately below the just-rendered user message via
// insertAdjacentElement('afterend', el).
this.addUserMessage(msg.content || "", msg.attachments || null);
if (Array.isArray(msg.reminders) && msg.reminders.length) {
this.addUserReminder(msg.reminders);
}
lastToolBlock = null;
} else if (msg.role === "assistant") {
if (msg.tool_calls && msg.tool_calls.length) {
@@ -1016,6 +1154,15 @@ Pane.prototype.replayHistory = function (messages) {
appendToolErrorBadge(lastToolBlock);
}
}
// Tool-channel metacog reminders (tool_error / repeat) attach
// to the LAST tool message in a batch; on replay we render the
// bubble immediately below the .ts-approval block that owns
// the tool result. addToolReminder's empty-toolCallId fallback
// resolves to "last .ts-approval block" — which is exactly
// lastToolBlock here.
if (Array.isArray(msg.reminders) && msg.reminders.length) {
this.addToolReminder(msg.reminders, "");
}
}
}
this._attachRetryToLastAssistant();
@@ -1318,6 +1465,19 @@ Pane.prototype.appendToolOutput = function (callId, name, output, isError) {
var stripped = stripAnsi(output || "").trim();
if (!stripped) return;
// Skip rendering for denied/blocked tool results — the ✗ denied
// badge from resolveApproval already shows the denial reason; the
// SSE tool_result event would otherwise duplicate the text. Mirror
// the guard in the history-replay path (the live path used to be
// safe because no tool_result event was ever emitted for denied
// items, but we now emit one so _tool_error_flags gets set).
var parentBlock = target.closest(".ts-approval");
var isDenied =
(parentBlock && parentBlock.classList.contains("denied")) ||
/^Denied by user/.test(stripped) ||
/^Blocked/.test(stripped);
if (isDenied) return;
// Detect structured media output and render interactive embed
if (!isError) {
var media = tryParseMedia(stripped);
@@ -1332,12 +1492,9 @@ Pane.prototype.appendToolOutput = function (callId, name, output, isError) {
var out = renderToolOutput(stripped, isError);
// Mark the parent approval block as errored
if (isError) {
var parentBlock = target.closest(".ts-approval");
if (parentBlock && !parentBlock.classList.contains("denied")) {
parentBlock.classList.add("error");
appendToolErrorBadge(parentBlock);
}
if (isError && parentBlock && !parentBlock.classList.contains("denied")) {
parentBlock.classList.add("error");
appendToolErrorBadge(parentBlock);
}
if (out.textContent.split("\n").length > 10) {
+3
View File
@@ -642,6 +642,9 @@
.msg.user {
color: var(--fg-bright);
}
/* .msg.user-reminder lives in shared_static/chat.css so both the
interactive UI and the console coord viewer pick up the same
yellow themed bubble. */
/* .msg.assistant / .msg.info / .msg.error alignment + baseline visuals
come from shared_static/chat.css. Interactive UI adds a pre-wrap
override for info messages and a tightened tool-message shape with
Generated
+1 -1
View File
@@ -2533,7 +2533,7 @@ wheels = [
[[package]]
name = "turnstone"
version = "1.5.1"
version = "1.5.2"
source = { editable = "." }
dependencies = [
{ name = "alembic" },