fix(trigger): hide external trigger inputs

This commit is contained in:
chengyongru
2026-07-02 13:32:46 +08:00
committed by Xubin Ren
parent b690a48336
commit 55b550ee01
14 changed files with 327 additions and 40 deletions
+2 -1
View File
@@ -78,7 +78,8 @@ nanobot trigger trg_8K4P2Q9X "Review PR #4502"
Replace `"Review PR #4502"` with the message you want nanobot to receive. The
trigger is bound to the session where it was created, so the message goes back
to that same chat. Keep `nanobot gateway` running so trigger messages can be
delivered.
delivered. The trigger message starts an automation turn; it is not shown in
the chat as a normal user message.
For longer or generated content, omit the message argument and pipe stdin:
+3 -5
View File
@@ -47,9 +47,6 @@ from nanobot.bus.runtime_events import (
)
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
from nanobot.cron.session_turns import (
cron_history_overrides,
)
from nanobot.providers.base import LLMProvider
from nanobot.providers.factory import ProviderSnapshot
from nanobot.security.workspace_access import (
@@ -58,6 +55,7 @@ from nanobot.security.workspace_access import (
reset_workspace_scope,
)
from nanobot.session import turn_continuation
from nanobot.session.automation_turns import automation_history_overrides
from nanobot.session.goal_state import (
goal_state_runtime_lines,
runner_wall_llm_timeout_s,
@@ -612,10 +610,10 @@ class AgentLoop:
extra: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
extra.update(kwargs)
text = msg.content if isinstance(msg.content, str) else ""
text_override, cron_extra = cron_history_overrides(msg.metadata)
text_override, automation_extra = automation_history_overrides(msg.metadata)
if text_override is not None:
text = text_override
extra.update(cron_extra)
extra.update(automation_extra)
session.add_message("user", text, **extra)
self._mark_pending_user_turn(session)
self.sessions.save(session)
+30 -18
View File
@@ -5,16 +5,43 @@ from __future__ import annotations
from typing import Any, Mapping
from nanobot.cron.types import CronJob
from nanobot.session.automation_turns import (
AutomationTurnSpec,
automation_history_overrides_for_spec,
automation_trigger,
)
CRON_TRIGGER_META = "_cron_trigger"
CRON_DEFER_UNTIL_IDLE_META = "_cron_defer_until_session_idle"
CRON_HISTORY_META = "_cron_turn"
def _cron_history_text(trigger: Mapping[str, Any]) -> str | None:
persist_content = trigger.get("persist_content")
return (
persist_content
if isinstance(persist_content, str) and persist_content.strip()
else None
)
CRON_AUTOMATION_SPEC = AutomationTurnSpec(
kind="cron",
trigger_meta_key=CRON_TRIGGER_META,
legacy_history_meta_key=CRON_HISTORY_META,
history_fields={
"cron_job_id": "job_id",
"cron_job_name": "job_name",
"cron_run_id": "run_id",
"cron_prompt_ref": "prompt_ref",
},
text_builder=_cron_history_text,
)
def cron_trigger(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None:
"""Return structured cron trigger metadata when present."""
raw = (metadata or {}).get(CRON_TRIGGER_META)
return raw if isinstance(raw, dict) else None
return automation_trigger(metadata, CRON_AUTOMATION_SPEC)
def is_cron_turn(metadata: Mapping[str, Any] | None) -> bool:
@@ -38,22 +65,7 @@ def cron_run_id(metadata: Mapping[str, Any] | None) -> str | None:
def cron_history_overrides(metadata: Mapping[str, Any] | None) -> tuple[str | None, dict[str, Any]]:
"""Return session-history text/metadata overrides for a cron turn."""
trigger = cron_trigger(metadata)
if not trigger:
return None, {}
persist_content = trigger.get("persist_content")
text = (
persist_content
if isinstance(persist_content, str) and persist_content.strip()
else None
)
return text, {
CRON_HISTORY_META: True,
"cron_job_id": trigger.get("job_id"),
"cron_job_name": trigger.get("job_name"),
"cron_run_id": trigger.get("run_id"),
"cron_prompt_ref": trigger.get("prompt_ref"),
}
return automation_history_overrides_for_spec(metadata, CRON_AUTOMATION_SPEC)
def is_bound_cron_job(job: CronJob) -> bool:
+90
View File
@@ -0,0 +1,90 @@
"""Shared handling for session-bound automation turns."""
from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass, field
from functools import lru_cache
from typing import Any
AUTOMATION_HISTORY_META = "_automation_turn"
@dataclass(frozen=True)
class AutomationTurnSpec:
"""Source-specific wiring for one session-bound automation turn type."""
kind: str
trigger_meta_key: str
legacy_history_meta_key: str | None = None
history_fields: Mapping[str, str] = field(default_factory=dict)
text_builder: Callable[[Mapping[str, Any]], str | None] | None = None
def automation_trigger(
metadata: Mapping[str, Any] | None,
spec: AutomationTurnSpec,
) -> dict[str, Any] | None:
"""Return source trigger metadata for *spec* when present."""
raw = (metadata or {}).get(spec.trigger_meta_key)
return raw if isinstance(raw, dict) else None
def automation_history_overrides_for_spec(
metadata: Mapping[str, Any] | None,
spec: AutomationTurnSpec,
) -> tuple[str | None, dict[str, Any]]:
"""Return hidden session-history text/metadata overrides for *spec*."""
trigger = automation_trigger(metadata, spec)
if not trigger:
return None, {}
details: dict[str, Any] = {"kind": spec.kind}
extra: dict[str, Any] = {AUTOMATION_HISTORY_META: details}
if spec.legacy_history_meta_key:
extra[spec.legacy_history_meta_key] = True
for history_key, trigger_key in spec.history_fields.items():
value = trigger.get(trigger_key)
extra[history_key] = value
details[history_key] = value
text = spec.text_builder(trigger) if spec.text_builder else None
return text, extra
@lru_cache(maxsize=1)
def _automation_specs() -> tuple[AutomationTurnSpec, ...]:
# Source modules import the generic helpers above, so keep spec loading lazy.
from nanobot.cron.session_turns import CRON_AUTOMATION_SPEC
from nanobot.triggers.session_turns import EXTERNAL_TRIGGER_AUTOMATION_SPEC
return (CRON_AUTOMATION_SPEC, EXTERNAL_TRIGGER_AUTOMATION_SPEC)
def automation_history_overrides(
metadata: Mapping[str, Any] | None,
) -> tuple[str | None, dict[str, Any]]:
"""Return session-history text/metadata overrides for supported automation turns."""
for spec in _automation_specs():
text, extra = automation_history_overrides_for_spec(metadata, spec)
if extra:
return text, extra
return None, {}
def is_automation_history_message(message: Mapping[str, Any] | None) -> bool:
"""True for hidden automation trigger records in session history."""
if not message:
return False
marker = message.get(AUTOMATION_HISTORY_META)
if marker is True or isinstance(marker, Mapping):
return True
return any(
spec.legacy_history_meta_key
and message.get(spec.legacy_history_meta_key) is True
for spec in _automation_specs()
)
def is_automation_kind(value: Any) -> bool:
return isinstance(value, str) and any(spec.kind == value for spec in _automation_specs())
+2 -2
View File
@@ -30,8 +30,8 @@ from nanobot.bus.runtime_events import (
TurnCompleted,
TurnRunStatusChanged,
)
from nanobot.cron.session_turns import CRON_HISTORY_META
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.manager import Session, SessionManager
from nanobot.utils.helpers import strip_think, truncate_text
@@ -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 message.get(CRON_HISTORY_META) is True:
if is_automation_history_message(message):
continue
role = message.get("role")
content = message.get("content")
+1 -2
View File
@@ -10,12 +10,11 @@ from loguru import logger
from nanobot.bus.events import InboundMessage
from nanobot.bus.queue import MessageBus
from nanobot.triggers.session_turns import EXTERNAL_TRIGGER_META
from nanobot.triggers.store import ExternalTriggerStore
from nanobot.triggers.types import ExternalTrigger, TriggerDelivery
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
EXTERNAL_TRIGGER_META = "_external_trigger"
async def run_external_trigger_queue(
*,
+51
View File
@@ -0,0 +1,51 @@
"""Shared metadata helpers for local external trigger session turns."""
from __future__ import annotations
from typing import Any, Mapping
from nanobot.session.automation_turns import (
AutomationTurnSpec,
automation_history_overrides_for_spec,
automation_trigger,
)
EXTERNAL_TRIGGER_META = "_external_trigger"
def _external_trigger_history_text(trigger: Mapping[str, Any]) -> str:
name = trigger.get("trigger_name")
trigger_id = trigger.get("trigger_id")
label = name if isinstance(name, str) and name.strip() else trigger_id
return (
f"External trigger received: {label}"
if isinstance(label, str) and label.strip()
else "External trigger received"
)
EXTERNAL_TRIGGER_AUTOMATION_SPEC = AutomationTurnSpec(
kind="trigger",
trigger_meta_key=EXTERNAL_TRIGGER_META,
history_fields={
"trigger_id": "trigger_id",
"trigger_name": "trigger_name",
"trigger_delivery_id": "delivery_id",
},
text_builder=_external_trigger_history_text,
)
def external_trigger(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None:
"""Return structured external trigger metadata when present."""
return automation_trigger(metadata, EXTERNAL_TRIGGER_AUTOMATION_SPEC)
def external_trigger_history_overrides(
metadata: Mapping[str, Any] | None,
) -> tuple[str | None, dict[str, Any]]:
"""Return session-history text/metadata overrides for an external trigger turn."""
return automation_history_overrides_for_spec(
metadata,
EXTERNAL_TRIGGER_AUTOMATION_SPEC,
)
+2 -2
View File
@@ -5,8 +5,8 @@ from __future__ import annotations
from collections.abc import Collection
from typing import Any, Protocol
from nanobot.cron.session_turns import CRON_HISTORY_META
from nanobot.cron.types import CronJob
from nanobot.session.automation_turns import is_automation_history_message
from nanobot.session.manager import _message_preview_text
from nanobot.triggers.types import ExternalTrigger
@@ -326,7 +326,7 @@ def _session_preview(messages: Any) -> str:
for message in messages:
if not isinstance(message, dict):
continue
if message.get(CRON_HISTORY_META) is True:
if is_automation_history_message(message):
continue
text = _message_preview_text(message)
if not text:
+6 -4
View File
@@ -16,7 +16,7 @@ from typing import Any
from loguru import logger
from nanobot.config.paths import get_webui_dir
from nanobot.cron.session_turns import CRON_HISTORY_META
from nanobot.session.automation_turns import is_automation_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 item.get(CRON_HISTORY_META) is True:
if is_automation_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 item.get(CRON_HISTORY_META) is True:
if is_automation_history_message(item):
return None
if item.get("role") not in _VISIBLE_TRANSCRIPT_ROLES:
return None
@@ -296,7 +296,9 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
):
preview_done = True
continue
if item.get(CRON_HISTORY_META) is True:
if item.get("_type") == "metadata":
continue
if is_automation_history_message(item):
continue
text = _message_preview_text(item)
if not text:
+14 -6
View File
@@ -17,7 +17,7 @@ from urllib.parse import unquote, urlparse
from loguru import logger
from nanobot.config.paths import get_webui_dir
from nanobot.cron.session_turns import CRON_HISTORY_META
from nanobot.session.automation_turns import is_automation_history_message, is_automation_kind
from nanobot.session.manager import SessionManager
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
@@ -598,9 +598,12 @@ def normalize_webui_turn_id(value: Any) -> str:
def webui_message_source(metadata: dict[str, Any] | None) -> dict[str, str] | None:
raw = (metadata or {}).get(WEBUI_MESSAGE_SOURCE_METADATA_KEY)
if not isinstance(raw, dict) or raw.get("kind") != "cron":
if not isinstance(raw, dict):
return None
source: dict[str, str] = {"kind": "cron"}
kind = raw.get("kind")
if not is_automation_kind(kind):
return None
source: dict[str, str] = {"kind": kind}
label = raw.get("label")
if isinstance(label, str) and label.strip():
source["label"] = label.strip()
@@ -779,6 +782,8 @@ 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):
continue
role = msg.get("role")
content = msg.get("content")
text = content if isinstance(content, str) else ""
@@ -855,7 +860,7 @@ def _session_user_event(
) -> dict[str, Any] | None:
if message.get("role") != "user":
return None
if message.get(CRON_HISTORY_META) is True:
if is_automation_history_message(message):
return None
content = message.get("content")
text = content if isinstance(content, str) else ""
@@ -1271,9 +1276,12 @@ def replay_transcript_to_ui_messages(
def _source_fields(rec: dict[str, Any]) -> dict[str, Any]:
source = rec.get("source")
if not isinstance(source, dict) or source.get("kind") != "cron":
if not isinstance(source, dict):
return {}
out: dict[str, Any] = {"source": {"kind": "cron"}}
kind = source.get("kind")
if not is_automation_kind(kind):
return {}
out: dict[str, Any] = {"source": {"kind": kind}}
label = source.get("label")
if isinstance(label, str) and label.strip():
out["source"]["label"] = label.strip()
+47
View File
@@ -18,6 +18,7 @@ from nanobot.bus.outbound_events import (
from nanobot.bus.queue import MessageBus
from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META
from nanobot.providers.base import LLMResponse
from nanobot.session.automation_turns import AUTOMATION_HISTORY_META
from nanobot.session.goal_state import GOAL_STATE_KEY
from nanobot.session.manager import Session, SessionManager
from nanobot.session.turn_continuation import (
@@ -33,6 +34,7 @@ from nanobot.session.webui_turns import (
clean_generated_title,
maybe_generate_webui_title,
)
from nanobot.triggers.session_turns import EXTERNAL_TRIGGER_META
from nanobot.utils.llm_runtime import LLMRuntime
@@ -101,6 +103,13 @@ def test_persist_cron_turn_uses_distinct_history_marker(tmp_path: Path) -> None:
assert persisted is True
message = session.messages[-1]
assert message["content"] == "Scheduled cron job triggered: Daily check"
assert message[AUTOMATION_HISTORY_META] == {
"kind": "cron",
"cron_job_id": "job-1",
"cron_job_name": "Daily check",
"cron_run_id": "job-1:1",
"cron_prompt_ref": prompt_ref,
}
assert message[CRON_HISTORY_META] is True
assert CRON_TRIGGER_META not in message
assert message["cron_job_id"] == "job-1"
@@ -109,6 +118,44 @@ def test_persist_cron_turn_uses_distinct_history_marker(tmp_path: Path) -> None:
assert message["cron_prompt_ref"] == prompt_ref
def test_persist_external_trigger_turn_uses_hidden_automation_marker(tmp_path: Path) -> None:
loop = _make_full_loop(tmp_path)
session = loop.sessions.get_or_create("websocket:auto")
persisted = loop._persist_user_message_early(
InboundMessage(
channel="websocket",
sender_id="trigger",
chat_id="auto",
content="Review PR #4502",
metadata={
EXTERNAL_TRIGGER_META: {
"trigger_id": "trg_123",
"trigger_name": "PR review",
"delivery_id": "tdel_456",
"created_at_ms": 1_700_000_000_000,
}
},
),
session,
)
assert persisted is True
message = session.messages[-1]
assert message["content"] == "External trigger received: PR review"
assert "Review PR #4502" not in message["content"]
assert message[AUTOMATION_HISTORY_META] == {
"kind": "trigger",
"trigger_id": "trg_123",
"trigger_name": "PR review",
"trigger_delivery_id": "tdel_456",
}
assert EXTERNAL_TRIGGER_META not in message
assert message["trigger_id"] == "trg_123"
assert message["trigger_name"] == "PR review"
assert message["trigger_delivery_id"] == "tdel_456"
def test_clean_generated_title_strips_reasoning_tags() -> None:
assert clean_generated_title("<think>reasoning</think> WebUI polish") == "WebUI polish"
assert clean_generated_title("Title: <think> The user said hello") == ""
+46
View File
@@ -2975,3 +2975,49 @@ def test_handle_webui_thread_get_does_not_backfill_cron_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"]] == ["提醒已经到期。"]
def test_handle_webui_thread_get_does_not_backfill_trigger_internal_prompt(
tmp_path,
monkeypatch,
) -> None:
from urllib.parse import quote
from websockets.datastructures import Headers
from websockets.http11 import Request
from nanobot.session.automation_turns import AUTOMATION_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-trigger"
session = sessions.get_or_create(key)
session.add_message(
"user",
"External trigger received: PR review",
**{AUTOMATION_HISTORY_META: {"kind": "trigger", "trigger_id": "trg_123"}},
)
session.add_message("assistant", "PR #4502 已经开始 review。")
sessions.save(session)
append_transcript_object(
key,
{"event": "message", "chat_id": "c-trigger", "text": "PR #4502 已经开始 review。"},
)
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"]] == ["PR #4502 已经开始 review。"]
+18
View File
@@ -473,6 +473,24 @@ def test_replay_reused_turn_id_after_turn_end_starts_new_turn(tmp_path, monkeypa
assert msgs[2]["source"] == {"kind": "cron", "label": "drink water"}
def test_replay_preserves_trigger_source_metadata(tmp_path, monkeypatch) -> None:
monkeypatch.setattr("nanobot.config.paths.get_data_dir", lambda: tmp_path)
key = "websocket:t-trigger-source"
append_transcript_object(
key,
{
"event": "message",
"chat_id": "t-trigger-source",
"text": "PR #4502 review started.",
"source": {"kind": "trigger", "label": "PR review"},
},
)
msgs = replay_transcript_to_ui_messages(read_transcript_lines(key))
assert msgs[0]["source"] == {"kind": "trigger", "label": "PR review"}
def test_build_response_restores_session_users_for_legacy_transcript(
tmp_path,
monkeypatch,
+15
View File
@@ -6,6 +6,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.manager import SessionManager
@@ -88,6 +89,20 @@ def test_webui_session_list_skips_cron_internal_user_preview(tmp_path: Path) ->
assert list_webui_sessions(manager)[0]["preview"] == "提醒已经到期。"
def test_webui_session_list_skips_trigger_internal_user_preview(tmp_path: Path) -> None:
manager = SessionManager(tmp_path)
session = manager.get_or_create("websocket:trigger-preview")
session.add_message(
"user",
"External trigger received: PR review",
**{AUTOMATION_HISTORY_META: {"kind": "trigger", "trigger_id": "trg_123"}},
)
session.add_message("assistant", "PR #4502 已经开始 review。")
manager.save(session)
assert list_webui_sessions(manager)[0]["preview"] == "PR #4502 已经开始 review。"
def test_webui_session_list_uses_webui_transcript_activity_for_sort(
tmp_path: Path,
monkeypatch,