fix(trigger): hide external trigger inputs
This commit is contained in:
@@ -78,7 +78,8 @@ nanobot trigger trg_8K4P2Q9X "Review PR #4502"
|
|||||||
Replace `"Review PR #4502"` with the message you want nanobot to receive. The
|
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
|
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
|
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:
|
For longer or generated content, omit the message argument and pipe stdin:
|
||||||
|
|
||||||
|
|||||||
@@ -47,9 +47,6 @@ from nanobot.bus.runtime_events import (
|
|||||||
)
|
)
|
||||||
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
|
from nanobot.command import CommandContext, CommandRouter, register_builtin_commands
|
||||||
from nanobot.config.schema import AgentDefaults, ModelPresetConfig
|
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.base import LLMProvider
|
||||||
from nanobot.providers.factory import ProviderSnapshot
|
from nanobot.providers.factory import ProviderSnapshot
|
||||||
from nanobot.security.workspace_access import (
|
from nanobot.security.workspace_access import (
|
||||||
@@ -58,6 +55,7 @@ from nanobot.security.workspace_access import (
|
|||||||
reset_workspace_scope,
|
reset_workspace_scope,
|
||||||
)
|
)
|
||||||
from nanobot.session import turn_continuation
|
from nanobot.session import turn_continuation
|
||||||
|
from nanobot.session.automation_turns import automation_history_overrides
|
||||||
from nanobot.session.goal_state import (
|
from nanobot.session.goal_state import (
|
||||||
goal_state_runtime_lines,
|
goal_state_runtime_lines,
|
||||||
runner_wall_llm_timeout_s,
|
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: dict[str, Any] = ({"media": list(media_paths)} if media_paths else {}) | agent_context.session_extra(msg.metadata)
|
||||||
extra.update(kwargs)
|
extra.update(kwargs)
|
||||||
text = msg.content if isinstance(msg.content, str) else ""
|
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:
|
if text_override is not None:
|
||||||
text = text_override
|
text = text_override
|
||||||
extra.update(cron_extra)
|
extra.update(automation_extra)
|
||||||
session.add_message("user", text, **extra)
|
session.add_message("user", text, **extra)
|
||||||
self._mark_pending_user_turn(session)
|
self._mark_pending_user_turn(session)
|
||||||
self.sessions.save(session)
|
self.sessions.save(session)
|
||||||
|
|||||||
@@ -5,16 +5,43 @@ from __future__ import annotations
|
|||||||
from typing import Any, Mapping
|
from typing import Any, Mapping
|
||||||
|
|
||||||
from nanobot.cron.types import CronJob
|
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_TRIGGER_META = "_cron_trigger"
|
||||||
CRON_DEFER_UNTIL_IDLE_META = "_cron_defer_until_session_idle"
|
CRON_DEFER_UNTIL_IDLE_META = "_cron_defer_until_session_idle"
|
||||||
CRON_HISTORY_META = "_cron_turn"
|
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:
|
def cron_trigger(metadata: Mapping[str, Any] | None) -> dict[str, Any] | None:
|
||||||
"""Return structured cron trigger metadata when present."""
|
"""Return structured cron trigger metadata when present."""
|
||||||
raw = (metadata or {}).get(CRON_TRIGGER_META)
|
return automation_trigger(metadata, CRON_AUTOMATION_SPEC)
|
||||||
return raw if isinstance(raw, dict) else None
|
|
||||||
|
|
||||||
|
|
||||||
def is_cron_turn(metadata: Mapping[str, Any] | None) -> bool:
|
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]]:
|
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."""
|
"""Return session-history text/metadata overrides for a cron turn."""
|
||||||
trigger = cron_trigger(metadata)
|
return automation_history_overrides_for_spec(metadata, CRON_AUTOMATION_SPEC)
|
||||||
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"),
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def is_bound_cron_job(job: CronJob) -> bool:
|
def is_bound_cron_job(job: CronJob) -> bool:
|
||||||
|
|||||||
@@ -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())
|
||||||
@@ -30,8 +30,8 @@ from nanobot.bus.runtime_events import (
|
|||||||
TurnCompleted,
|
TurnCompleted,
|
||||||
TurnRunStatusChanged,
|
TurnRunStatusChanged,
|
||||||
)
|
)
|
||||||
from nanobot.cron.session_turns import CRON_HISTORY_META
|
|
||||||
from nanobot.providers.base import LLMProvider
|
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.goal_state import goal_state_ws_blob
|
||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
from nanobot.utils.helpers import strip_think, truncate_text
|
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:
|
for message in session.messages:
|
||||||
if message.get("_command") is True:
|
if message.get("_command") is True:
|
||||||
continue
|
continue
|
||||||
if message.get(CRON_HISTORY_META) is True:
|
if is_automation_history_message(message):
|
||||||
continue
|
continue
|
||||||
role = message.get("role")
|
role = message.get("role")
|
||||||
content = message.get("content")
|
content = message.get("content")
|
||||||
|
|||||||
@@ -10,12 +10,11 @@ from loguru import logger
|
|||||||
|
|
||||||
from nanobot.bus.events import InboundMessage
|
from nanobot.bus.events import InboundMessage
|
||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
|
from nanobot.triggers.session_turns import EXTERNAL_TRIGGER_META
|
||||||
from nanobot.triggers.store import ExternalTriggerStore
|
from nanobot.triggers.store import ExternalTriggerStore
|
||||||
from nanobot.triggers.types import ExternalTrigger, TriggerDelivery
|
from nanobot.triggers.types import ExternalTrigger, TriggerDelivery
|
||||||
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
|
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(
|
async def run_external_trigger_queue(
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -5,8 +5,8 @@ from __future__ import annotations
|
|||||||
from collections.abc import Collection
|
from collections.abc import Collection
|
||||||
from typing import Any, Protocol
|
from typing import Any, Protocol
|
||||||
|
|
||||||
from nanobot.cron.session_turns import CRON_HISTORY_META
|
|
||||||
from nanobot.cron.types import CronJob
|
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.session.manager import _message_preview_text
|
||||||
from nanobot.triggers.types import ExternalTrigger
|
from nanobot.triggers.types import ExternalTrigger
|
||||||
|
|
||||||
@@ -326,7 +326,7 @@ def _session_preview(messages: Any) -> str:
|
|||||||
for message in messages:
|
for message in messages:
|
||||||
if not isinstance(message, dict):
|
if not isinstance(message, dict):
|
||||||
continue
|
continue
|
||||||
if message.get(CRON_HISTORY_META) is True:
|
if is_automation_history_message(message):
|
||||||
continue
|
continue
|
||||||
text = _message_preview_text(message)
|
text = _message_preview_text(message)
|
||||||
if not text:
|
if not text:
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ from typing import Any
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.config.paths import get_webui_dir
|
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 (
|
from nanobot.session.manager import (
|
||||||
_SESSION_LIST_PREVIEW_MAX_CHARS,
|
_SESSION_LIST_PREVIEW_MAX_CHARS,
|
||||||
_SESSION_LIST_PREVIEW_MAX_RECORDS,
|
_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
|
or scanned_chars > _SESSION_LIST_PREVIEW_MAX_CHARS
|
||||||
):
|
):
|
||||||
break
|
break
|
||||||
if item.get(CRON_HISTORY_META) is True:
|
if is_automation_history_message(item):
|
||||||
continue
|
continue
|
||||||
text = _message_preview_text(item)
|
text = _message_preview_text(item)
|
||||||
if not text:
|
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:
|
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
|
return None
|
||||||
if item.get("role") not in _VISIBLE_TRANSCRIPT_ROLES:
|
if item.get("role") not in _VISIBLE_TRANSCRIPT_ROLES:
|
||||||
return None
|
return None
|
||||||
@@ -296,7 +296,9 @@ def _scan_session_row(session_manager: SessionManager, path: Path) -> dict[str,
|
|||||||
):
|
):
|
||||||
preview_done = True
|
preview_done = True
|
||||||
continue
|
continue
|
||||||
if item.get(CRON_HISTORY_META) is True:
|
if item.get("_type") == "metadata":
|
||||||
|
continue
|
||||||
|
if is_automation_history_message(item):
|
||||||
continue
|
continue
|
||||||
text = _message_preview_text(item)
|
text = _message_preview_text(item)
|
||||||
if not text:
|
if not text:
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ from urllib.parse import unquote, urlparse
|
|||||||
from loguru import logger
|
from loguru import logger
|
||||||
|
|
||||||
from nanobot.config.paths import get_webui_dir
|
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.session.manager import SessionManager
|
||||||
from nanobot.webui.metadata import WEBUI_MESSAGE_SOURCE_METADATA_KEY, WEBUI_TURN_METADATA_KEY
|
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:
|
def webui_message_source(metadata: dict[str, Any] | None) -> dict[str, str] | None:
|
||||||
raw = (metadata or {}).get(WEBUI_MESSAGE_SOURCE_METADATA_KEY)
|
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
|
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")
|
label = raw.get("label")
|
||||||
if isinstance(label, str) and label.strip():
|
if isinstance(label, str) and label.strip():
|
||||||
source["label"] = 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)
|
target_chat_id = _chat_id_from_session_key(target_key)
|
||||||
rows: list[dict[str, Any]] = []
|
rows: list[dict[str, Any]] = []
|
||||||
for msg in messages:
|
for msg in messages:
|
||||||
|
if is_automation_history_message(msg):
|
||||||
|
continue
|
||||||
role = msg.get("role")
|
role = msg.get("role")
|
||||||
content = msg.get("content")
|
content = msg.get("content")
|
||||||
text = content if isinstance(content, str) else ""
|
text = content if isinstance(content, str) else ""
|
||||||
@@ -855,7 +860,7 @@ def _session_user_event(
|
|||||||
) -> dict[str, Any] | None:
|
) -> dict[str, Any] | None:
|
||||||
if message.get("role") != "user":
|
if message.get("role") != "user":
|
||||||
return None
|
return None
|
||||||
if message.get(CRON_HISTORY_META) is True:
|
if is_automation_history_message(message):
|
||||||
return None
|
return None
|
||||||
content = message.get("content")
|
content = message.get("content")
|
||||||
text = content if isinstance(content, str) else ""
|
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]:
|
def _source_fields(rec: dict[str, Any]) -> dict[str, Any]:
|
||||||
source = rec.get("source")
|
source = rec.get("source")
|
||||||
if not isinstance(source, dict) or source.get("kind") != "cron":
|
if not isinstance(source, dict):
|
||||||
return {}
|
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")
|
label = source.get("label")
|
||||||
if isinstance(label, str) and label.strip():
|
if isinstance(label, str) and label.strip():
|
||||||
out["source"]["label"] = label.strip()
|
out["source"]["label"] = label.strip()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from nanobot.bus.outbound_events import (
|
|||||||
from nanobot.bus.queue import MessageBus
|
from nanobot.bus.queue import MessageBus
|
||||||
from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META
|
from nanobot.cron.session_turns import CRON_HISTORY_META, CRON_TRIGGER_META
|
||||||
from nanobot.providers.base import LLMResponse
|
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.goal_state import GOAL_STATE_KEY
|
||||||
from nanobot.session.manager import Session, SessionManager
|
from nanobot.session.manager import Session, SessionManager
|
||||||
from nanobot.session.turn_continuation import (
|
from nanobot.session.turn_continuation import (
|
||||||
@@ -33,6 +34,7 @@ from nanobot.session.webui_turns import (
|
|||||||
clean_generated_title,
|
clean_generated_title,
|
||||||
maybe_generate_webui_title,
|
maybe_generate_webui_title,
|
||||||
)
|
)
|
||||||
|
from nanobot.triggers.session_turns import EXTERNAL_TRIGGER_META
|
||||||
from nanobot.utils.llm_runtime import LLMRuntime
|
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
|
assert persisted is True
|
||||||
message = session.messages[-1]
|
message = session.messages[-1]
|
||||||
assert message["content"] == "Scheduled cron job triggered: Daily check"
|
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 message[CRON_HISTORY_META] is True
|
||||||
assert CRON_TRIGGER_META not in message
|
assert CRON_TRIGGER_META not in message
|
||||||
assert message["cron_job_id"] == "job-1"
|
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
|
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:
|
def test_clean_generated_title_strips_reasoning_tags() -> None:
|
||||||
assert clean_generated_title("<think>reasoning</think> WebUI polish") == "WebUI polish"
|
assert clean_generated_title("<think>reasoning</think> WebUI polish") == "WebUI polish"
|
||||||
assert clean_generated_title("Title: <think> The user said hello") == ""
|
assert clean_generated_title("Title: <think> The user said hello") == ""
|
||||||
|
|||||||
@@ -2975,3 +2975,49 @@ def test_handle_webui_thread_get_does_not_backfill_cron_internal_prompt(
|
|||||||
body = json.loads(resp.body.decode())
|
body = json.loads(resp.body.decode())
|
||||||
assert [message["role"] for message in body["messages"]] == ["assistant"]
|
assert [message["role"] for message in body["messages"]] == ["assistant"]
|
||||||
assert [message["content"] for message in body["messages"]] == ["提醒已经到期。"]
|
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。"]
|
||||||
|
|||||||
@@ -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"}
|
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(
|
def test_build_response_restores_session_users_for_legacy_transcript(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
import nanobot.webui.session_list_index as session_list_index
|
import nanobot.webui.session_list_index as session_list_index
|
||||||
from nanobot.cron.session_turns import CRON_HISTORY_META
|
from nanobot.cron.session_turns import CRON_HISTORY_META
|
||||||
|
from nanobot.session.automation_turns import AUTOMATION_HISTORY_META
|
||||||
from nanobot.session.manager import SessionManager
|
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"] == "提醒已经到期。"
|
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(
|
def test_webui_session_list_uses_webui_transcript_activity_for_sort(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
monkeypatch,
|
monkeypatch,
|
||||||
|
|||||||
Reference in New Issue
Block a user