fix(webui): hide subagent backfill payloads
This commit is contained in:
+15
-1
@@ -62,6 +62,7 @@ from nanobot.session.goal_state import (
|
||||
runner_wall_llm_timeout_s,
|
||||
sustained_goal_active,
|
||||
)
|
||||
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
||||
from nanobot.session.keys import UNIFIED_SESSION_KEY, session_key_for_channel
|
||||
from nanobot.session.manager import (
|
||||
Session,
|
||||
@@ -795,7 +796,20 @@ class AgentLoop:
|
||||
content, media = self._prepare_message_media(content, media)
|
||||
media = media or None
|
||||
user_content = self.context._build_user_content(content, media)
|
||||
return {"role": "user", "content": user_content}
|
||||
row: dict[str, Any] = {"role": "user", "content": user_content}
|
||||
metadata = pending_msg.metadata if isinstance(pending_msg.metadata, dict) else {}
|
||||
if (
|
||||
pending_msg.sender_id == "subagent"
|
||||
and metadata.get("injected_event") == "subagent_result"
|
||||
):
|
||||
marker: dict[str, Any] = {"kind": "subagent_result"}
|
||||
task_id = metadata.get("subagent_task_id")
|
||||
if isinstance(task_id, str) and task_id:
|
||||
marker["subagent_task_id"] = task_id
|
||||
row["subagent_task_id"] = task_id
|
||||
row[HIDDEN_HISTORY_META] = marker
|
||||
row["injected_event"] = "subagent_result"
|
||||
return row
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
while len(items) < limit:
|
||||
|
||||
@@ -20,6 +20,7 @@ from nanobot.agent.context_governance import (
|
||||
from nanobot.agent.hook import AgentHook, AgentHookContext, AgentRunHookContext
|
||||
from nanobot.agent.tools.registry import ToolRegistry, is_tool_error_result
|
||||
from nanobot.providers.base import LLMProvider, LLMResponse, ToolCallRequest
|
||||
from nanobot.session.history_visibility import is_hidden_history_message
|
||||
from nanobot.utils.file_edit_events import (
|
||||
StreamingFileEditTracker,
|
||||
build_file_edit_end_event,
|
||||
@@ -155,6 +156,8 @@ class AgentRunner:
|
||||
messages
|
||||
and injection.get("role") == "user"
|
||||
and messages[-1].get("role") == "user"
|
||||
and not is_hidden_history_message(injection)
|
||||
and not is_hidden_history_message(messages[-1])
|
||||
):
|
||||
merged = dict(messages[-1])
|
||||
merged["content"] = cls._merge_message_content(
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
"""Visibility helpers for persisted session history messages."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from nanobot.session.automation_turns import is_automation_history_message
|
||||
|
||||
HIDDEN_HISTORY_META = "_hidden_history"
|
||||
|
||||
|
||||
def _has_hidden_history_marker(message: Mapping[str, Any] | None) -> bool:
|
||||
if not message:
|
||||
return False
|
||||
marker = message.get(HIDDEN_HISTORY_META)
|
||||
return marker is True or isinstance(marker, Mapping)
|
||||
|
||||
|
||||
def is_hidden_history_message(message: Mapping[str, Any] | None) -> bool:
|
||||
"""True for persisted messages that should not be shown as chat turns."""
|
||||
return _has_hidden_history_marker(message) or is_automation_history_message(message)
|
||||
@@ -31,8 +31,8 @@ from nanobot.bus.runtime_events import (
|
||||
TurnRunStatusChanged,
|
||||
)
|
||||
from nanobot.providers.base import LLMProvider
|
||||
from nanobot.session.automation_turns import is_automation_history_message
|
||||
from nanobot.session.goal_state import goal_state_ws_blob
|
||||
from nanobot.session.history_visibility import is_hidden_history_message
|
||||
from nanobot.session.manager import Session, SessionManager
|
||||
from nanobot.utils.helpers import strip_think, truncate_text
|
||||
from nanobot.utils.llm_runtime import LLMRuntime
|
||||
@@ -77,7 +77,7 @@ def _title_inputs(session: Session) -> tuple[str, str]:
|
||||
for message in session.messages:
|
||||
if message.get("_command") is True:
|
||||
continue
|
||||
if is_automation_history_message(message):
|
||||
if is_hidden_history_message(message):
|
||||
continue
|
||||
role = message.get("role")
|
||||
content = message.get("content")
|
||||
|
||||
@@ -6,7 +6,7 @@ from collections.abc import Collection
|
||||
from typing import Any, Protocol
|
||||
|
||||
from nanobot.cron.types import CronJob
|
||||
from nanobot.session.automation_turns import is_automation_history_message
|
||||
from nanobot.session.history_visibility import is_hidden_history_message
|
||||
from nanobot.session.manager import _message_preview_text
|
||||
from nanobot.triggers.local_types import LocalTrigger
|
||||
|
||||
@@ -328,7 +328,7 @@ def _session_preview(messages: Any) -> str:
|
||||
for message in messages:
|
||||
if not isinstance(message, dict):
|
||||
continue
|
||||
if is_automation_history_message(message):
|
||||
if is_hidden_history_message(message):
|
||||
continue
|
||||
text = _message_preview_text(message)
|
||||
if not text:
|
||||
|
||||
@@ -16,7 +16,7 @@ from typing import Any
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.config.paths import get_webui_dir
|
||||
from nanobot.session.automation_turns import is_automation_history_message
|
||||
from nanobot.session.history_visibility import is_hidden_history_message
|
||||
from nanobot.session.manager import (
|
||||
_SESSION_LIST_PREVIEW_MAX_CHARS,
|
||||
_SESSION_LIST_PREVIEW_MAX_RECORDS,
|
||||
@@ -154,7 +154,7 @@ def _preview_from_messages(messages: list[dict[str, Any]]) -> str:
|
||||
or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
|
||||
):
|
||||
break
|
||||
if is_automation_history_message(item):
|
||||
if is_hidden_history_message(item):
|
||||
continue
|
||||
text = _message_preview_text(item)
|
||||
if not text:
|
||||
@@ -216,7 +216,7 @@ def _latest_updated_at(stored: str | None, activity: str | None) -> str | None:
|
||||
|
||||
|
||||
def _visible_message_timestamp(item: dict[str, Any]) -> str | None:
|
||||
if is_automation_history_message(item):
|
||||
if is_hidden_history_message(item):
|
||||
return None
|
||||
if item.get("role") not in _VISIBLE_TRANSCRIPT_ROLES:
|
||||
return None
|
||||
@@ -298,7 +298,7 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
|
||||
continue
|
||||
if item.get("_type") == "metadata":
|
||||
continue
|
||||
if is_automation_history_message(item):
|
||||
if is_hidden_history_message(item):
|
||||
continue
|
||||
text = _message_preview_text(item)
|
||||
if not text:
|
||||
|
||||
@@ -17,7 +17,8 @@ from urllib.parse import unquote, urlparse
|
||||
from loguru import logger
|
||||
|
||||
from nanobot.config.paths import get_webui_dir
|
||||
from nanobot.session.automation_turns import is_automation_history_message, is_automation_kind
|
||||
from nanobot.session.automation_turns import is_automation_kind
|
||||
from nanobot.session.history_visibility import is_hidden_history_message
|
||||
from nanobot.session.manager import SessionManager
|
||||
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
|
||||
|
||||
@@ -782,7 +783,7 @@ def write_session_messages_as_transcript(
|
||||
target_chat_id = _chat_id_from_session_key(target_key)
|
||||
rows: list[dict[str, Any]] = []
|
||||
for msg in messages:
|
||||
if is_automation_history_message(msg):
|
||||
if is_hidden_history_message(msg):
|
||||
continue
|
||||
role = msg.get("role")
|
||||
content = msg.get("content")
|
||||
@@ -854,13 +855,28 @@ def build_user_transcript_event(
|
||||
return event
|
||||
|
||||
|
||||
def _is_legacy_raw_subagent_result(message: dict[str, Any]) -> bool:
|
||||
content = message.get("content")
|
||||
if not isinstance(content, str):
|
||||
return False
|
||||
text = content.replace("\r\n", "\n").strip()
|
||||
return (
|
||||
text.startswith("[Subagent '")
|
||||
and "\n\nTask:" in text
|
||||
and "\n\nResult:" in text
|
||||
and "Summarize this naturally" in text
|
||||
)
|
||||
|
||||
|
||||
def _session_user_event(
|
||||
session_key: str,
|
||||
message: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
if message.get("role") != "user":
|
||||
return None
|
||||
if is_automation_history_message(message):
|
||||
if is_hidden_history_message(message):
|
||||
return None
|
||||
if _is_legacy_raw_subagent_result(message):
|
||||
return None
|
||||
content = message.get("content")
|
||||
text = content if isinstance(content, str) else ""
|
||||
|
||||
@@ -465,6 +465,68 @@ async def test_loop_injected_followup_preserves_image_media(tmp_path):
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_subagent_pending_injection_is_hidden_history_and_not_merged(tmp_path):
|
||||
from nanobot.agent.loop import AgentLoop
|
||||
from nanobot.bus.events import InboundMessage
|
||||
from nanobot.bus.queue import MessageBus
|
||||
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
||||
|
||||
bus = MessageBus()
|
||||
provider = MagicMock()
|
||||
provider.get_default_model.return_value = "test-model"
|
||||
call_count = {"n": 0}
|
||||
|
||||
async def chat_with_retry(*, messages, **kwargs):
|
||||
call_count["n"] += 1
|
||||
if call_count["n"] == 1:
|
||||
return LLMResponse(content="first answer", tool_calls=[], usage={})
|
||||
return LLMResponse(content="second answer", tool_calls=[], usage={})
|
||||
|
||||
provider.chat_with_retry = chat_with_retry
|
||||
loop = AgentLoop(bus=bus, provider=provider, workspace=tmp_path, model="test-model")
|
||||
loop.tools.get_definitions = MagicMock(return_value=[])
|
||||
|
||||
payload = (
|
||||
"[Subagent 'x' completed successfully]\n\n"
|
||||
"Task: t\n\n"
|
||||
"Result:\nr\n\n"
|
||||
"Summarize this naturally for the user."
|
||||
)
|
||||
pending_queue = asyncio.Queue()
|
||||
await pending_queue.put(InboundMessage(
|
||||
channel="cli",
|
||||
sender_id="user",
|
||||
chat_id="c",
|
||||
content="visible follow-up",
|
||||
))
|
||||
await pending_queue.put(InboundMessage(
|
||||
channel="system",
|
||||
sender_id="subagent",
|
||||
chat_id="cli:c",
|
||||
content=payload,
|
||||
metadata={"injected_event": "subagent_result", "subagent_task_id": "sub-1"},
|
||||
))
|
||||
|
||||
final_content, _, all_msgs, _, had_injections = await loop._run_agent_loop(
|
||||
[{"role": "user", "content": "hello"}],
|
||||
channel="cli",
|
||||
chat_id="c",
|
||||
pending_queue=pending_queue,
|
||||
)
|
||||
|
||||
assert final_content == "second answer"
|
||||
assert had_injections is True
|
||||
assert call_count["n"] == 2
|
||||
injected_users = [message for message in all_msgs if message.get("role") == "user"][-2:]
|
||||
assert [message["content"] for message in injected_users] == ["visible follow-up", payload]
|
||||
assert injected_users[1][HIDDEN_HISTORY_META] == {
|
||||
"kind": "subagent_result",
|
||||
"subagent_task_id": "sub-1",
|
||||
}
|
||||
assert injected_users[1]["injected_event"] == "subagent_result"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runner_merges_multiple_injected_user_messages_without_losing_media():
|
||||
"""Multiple injected follow-ups should not create lossy consecutive user messages."""
|
||||
|
||||
@@ -3021,3 +3021,49 @@ def test_handle_webui_thread_get_does_not_backfill_trigger_internal_prompt(
|
||||
body = json.loads(resp.body.decode())
|
||||
assert [message["role"] for message in body["messages"]] == ["assistant"]
|
||||
assert [message["content"] for message in body["messages"]] == ["PR #4502 已经开始 review。"]
|
||||
|
||||
|
||||
def test_handle_webui_thread_get_does_not_backfill_hidden_subagent_result(
|
||||
tmp_path,
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
from urllib.parse import quote
|
||||
|
||||
from websockets.datastructures import Headers
|
||||
from websockets.http11 import Request
|
||||
|
||||
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
||||
from nanobot.webui.transcript import append_transcript_object
|
||||
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
workspace = tmp_path / "workspace"
|
||||
sessions = SessionManager(workspace)
|
||||
key = "websocket:c-subagent"
|
||||
session = sessions.get_or_create(key)
|
||||
session.add_message(
|
||||
"user",
|
||||
"internal subagent result",
|
||||
**{HIDDEN_HISTORY_META: {"kind": "subagent_result", "subagent_task_id": "sub-1"}},
|
||||
)
|
||||
session.add_message("assistant", "subagent summary")
|
||||
sessions.save(session)
|
||||
append_transcript_object(
|
||||
key,
|
||||
{"event": "message", "chat_id": "c-subagent", "text": "subagent summary"},
|
||||
)
|
||||
|
||||
bus = MagicMock()
|
||||
channel = WebSocketChannel(
|
||||
{"enabled": True, "allowFrom": ["*"]},
|
||||
bus,
|
||||
gateway=_basic_handler(bus, session_manager=sessions, workspace_path=workspace),
|
||||
)
|
||||
channel.gateway.tokens.api_tokens["tok"] = time.monotonic() + 300.0
|
||||
enc = quote(key, safe="")
|
||||
req = Request(f"/api/sessions/{enc}/webui-thread", Headers([("Authorization", "Bearer tok")]))
|
||||
resp = channel.gateway.http._handle_webui_thread_get(req, enc)
|
||||
|
||||
assert resp.status_code == 200
|
||||
body = json.loads(resp.body.decode())
|
||||
assert [message["role"] for message in body["messages"]] == ["assistant"]
|
||||
assert [message["content"] for message in body["messages"]] == ["subagent summary"]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
||||
from nanobot.webui.transcript import (
|
||||
WEBUI_TRANSCRIPT_SCHEMA_VERSION,
|
||||
append_fork_marker,
|
||||
@@ -698,6 +699,47 @@ def test_backfill_does_not_misalign_when_session_only_has_transcript_tail(
|
||||
]
|
||||
|
||||
|
||||
def test_backfill_skips_internal_subagent_results(tmp_path, monkeypatch) -> None:
|
||||
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
|
||||
key = "websocket:t-subagent"
|
||||
for ev in (
|
||||
{"event": "message", "chat_id": "t-subagent", "text": "summary one"},
|
||||
{"event": "turn_end", "chat_id": "t-subagent"},
|
||||
{"event": "message", "chat_id": "t-subagent", "text": "summary two"},
|
||||
{"event": "turn_end", "chat_id": "t-subagent"},
|
||||
):
|
||||
append_transcript_object(key, ev)
|
||||
|
||||
legacy_raw = (
|
||||
"[Subagent 'legacy' completed successfully]\n\n"
|
||||
"Task: t\n\n"
|
||||
"Result:\nr\n\n"
|
||||
"Summarize this naturally for the user."
|
||||
)
|
||||
out = build_webui_thread_response(
|
||||
key,
|
||||
session_messages=[
|
||||
{"role": "user", "content": legacy_raw},
|
||||
{"role": "assistant", "content": "summary one"},
|
||||
{
|
||||
"role": "user",
|
||||
"content": "marked result",
|
||||
HIDDEN_HISTORY_META: {
|
||||
"kind": "subagent_result",
|
||||
"subagent_task_id": "sub-1",
|
||||
},
|
||||
},
|
||||
{"role": "assistant", "content": "summary two"},
|
||||
],
|
||||
)
|
||||
|
||||
assert out is not None
|
||||
assert [(message["role"], message["content"]) for message in out["messages"]] == [
|
||||
("assistant", "summary one"),
|
||||
("assistant", "summary two"),
|
||||
]
|
||||
|
||||
|
||||
def test_replay_infers_video_media_from_attachment_name() -> None:
|
||||
msgs = replay_transcript_to_ui_messages(
|
||||
[
|
||||
|
||||
@@ -7,6 +7,7 @@ from pathlib import Path
|
||||
import nanobot.webui.session_list_index as session_list_index
|
||||
from nanobot.cron.session_turns import CRON_HISTORY_META
|
||||
from nanobot.session.automation_turns import AUTOMATION_HISTORY_META
|
||||
from nanobot.session.history_visibility import HIDDEN_HISTORY_META
|
||||
from nanobot.session.manager import SessionManager
|
||||
|
||||
|
||||
@@ -103,6 +104,20 @@ def test_webui_session_list_skips_trigger_internal_user_preview(tmp_path: Path)
|
||||
assert list_webui_sessions(manager)[0]["preview"] == "PR #4502 已经开始 review。"
|
||||
|
||||
|
||||
def test_webui_session_list_skips_hidden_history_user_preview(tmp_path: Path) -> None:
|
||||
manager = SessionManager(tmp_path)
|
||||
session = manager.get_or_create("websocket:hidden-preview")
|
||||
session.add_message(
|
||||
"user",
|
||||
"internal subagent result",
|
||||
**{HIDDEN_HISTORY_META: {"kind": "subagent_result", "subagent_task_id": "sub-1"}},
|
||||
)
|
||||
session.add_message("assistant", "subagent summary")
|
||||
manager.save(session)
|
||||
|
||||
assert list_webui_sessions(manager)[0]["preview"] == "subagent summary"
|
||||
|
||||
|
||||
def test_webui_session_list_uses_webui_transcript_activity_for_sort(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
|
||||
Reference in New Issue
Block a user