mirror of
https://github.com/turnstonelabs/turnstone.git
synced 2026-08-13 07:22:24 -06:00
Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 5bcbcb73b9 | |||
| af6749421a | |||
| 4d6cb77075 | |||
| 5c225ef39b | |||
| f5a843f44a | |||
| cf44841624 | |||
| 7ffab6a272 | |||
| ba3bc9d989 |
+1
-1
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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,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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 = []
|
||||
|
||||
@@ -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.
|
||||
#
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"] = [
|
||||
{
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user