Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .agents/skills/archify/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,8 @@ Suggested maps (inspect code; do not copy example facts):
| `lifecycle` | Session, conversation, or agent-run states |

Group wake is explicit (`llm_access.group`, `llm_access.reply_to_bot`,
continuation). Do not draw `platform_settings.group_wake_policy`.
continuation). Mentions are message-chain markers, not a wake policy.
Do not draw `platform_settings.group_wake_policy`.

## Verify

Expand Down
2 changes: 1 addition & 1 deletion .agents/skills/audit-product/REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ Violations are findings, not style nits:

- No legacy shims; no Python 3.10–3.13 branches
- Import boundaries in `tests/unit/test_import_boundaries.py`
- Group wake is explicit (`llm_access.group`, `llm_access.reply_to_bot`)
- Group wake is explicit (`llm_access.group`, `llm_access.reply_to_bot`, continuation); mention is not a wake policy
- Command identity is `command_id`; no fossil short-name lookup
- Dashboard bind defaults to `127.0.0.1`; MCP private-network default deny
- User-facing agent failures stay generic; redact secrets
Expand Down
8 changes: 4 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -243,10 +243,10 @@ The scheduler supports async stages and async-generator onion middleware.
Preserve stage ordering, stop-propagation, and cancellation semantics.

Group wake behavior is explicit. `llm_access.group`, `llm_access.reply_to_bot`,
and continuation state control whether mentioning or replying to the bot wakes
a group message, and `WakingCheckStage` records the selected `wake_reasons` on
the event. Do not restore `platform_settings.group_wake_policy` or implicit
mention/reply wakeups. Built-in command availability is stored
and continuation state control group LLM admission. Mentions are message-chain
markers, not a wake policy. `WakingCheckStage` records the selected
`wake_reasons` on the event. Do not restore `platform_settings.group_wake_policy`
or implicit mention/reply wakeups. Built-in command availability is stored
per handler in the command database; the removed `disable_builtin_commands`
field is not migrated or read by runtime code and must not become a pipeline
switch again. Command identity is `command_id`
Expand Down
31 changes: 17 additions & 14 deletions astrbot/builtin_stars/astrbot/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,26 +36,32 @@ async def handle_session_control_agent(self, event: AstrMessageEvent) -> None:

@filter.event_message_type(filter.EventMessageType.ALL, priority=maxsize - 1)
async def handle_empty_mention(self, event: AstrMessageEvent):
"""处理只有一个 @ 或仅有唤醒前缀的消息,并等待用户下一条内容。"""
"""Wait for the next message when the user sent only a command prefix."""
try:
messages = event.get_messages()
cfg = self.context.config.get(umo=event.unified_msg_origin)
p_settings = cfg["platform_settings"]
command_prefixes = cfg.get("command_prefixes", [])
if len(messages) != 1:
return
if not p_settings.get("empty_mention_waiting", True):
return

is_empty_mention = (
isinstance(messages[0], Comp.Mention)
and str(messages[0].target) == str(event.get_self_id())
and p_settings.get("empty_mention_waiting", True)
)
is_command_prefix_only = (
isinstance(messages[0], Comp.Plain)
and messages[0].text.strip() in command_prefixes
)
if not is_command_prefix_only:
return

if not (is_empty_mention or is_command_prefix_only):
llm_access = cfg.get("llm_access") or {}
mode = llm_access.get(
"private" if event.is_private_chat() else "group",
"prefix",
)
if mode not in {"open", "prefix", "off"}:
mode = "prefix"
if mode == "off":
return

if p_settings.get("empty_mention_waiting_need_reply", True):
Expand All @@ -78,7 +84,7 @@ async def handle_empty_mention(self, event: AstrMessageEvent):

yield event.request_llm(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

request_llm here is a plugin ProviderRequest. ProcessStage runs it whenever this handler is activated, independent of should_run_llm.

cfg is already loaded. If llm_access.private / group is off for this UMO, return before the courtesy prompt and before wait_for. Docs now call off the kill switch for this wait path; the follow-up stamp honors it, this yield does not.

Cover / + off so request_llm and wait_for are not called.

prompt=(
"注意,你正在社交媒体上中与用户进行聊天,用户只是通过@来唤醒你,但并未在这条消息中输入内容,他可能会在接下来一条发送他想发送的内容。"
"注意,你正在社交媒体上中与用户进行聊天,用户只发送了指令前缀,尚未输入内容,他可能会在接下来一条发送他想发送的内容。"
"你友好地询问用户想要聊些什么或者需要什么帮助,回复要符合人设,不要太过机械化。"
"请注意,你仅需要输出要回复用户的内容,不要输出其他任何东西"
),
Expand All @@ -91,25 +97,22 @@ async def handle_empty_mention(self, event: AstrMessageEvent):
logger.error(f"LLM response failed: {e!s}")
yield event.plain_result("想要问什么呢?😄")

async def empty_mention_waiter(
async def prefix_only_waiter(

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This waiter still submit()s the next inbound event through the bounded queue. The follow-up is a plain hello with no LLM prefix, no explicit_surface, and no coalesce window. Old code inserted Mention(self) so mention policy would admit it. After this PR, default prefix drops that follow-up, so the 60s wait cannot collect the actual prompt.

Stamp a one-shot admit on new_event (or stop waiting). Cover it with route_turn / WakingCheckStage, not only “no Mention synthesized”.

controller,
event: AstrMessageEvent,
) -> None:
if not event.message_str or not event.message_str.strip():
return
event.message_obj.message.insert(
0,
Comp.Mention(target=event.get_self_id(), name=event.get_self_id()),
)
new_event = copy.copy(event)
new_event.set_extra("explicit_surface", True)
self.context.messages.submit(new_event)
event.stop_event()
controller.stop()

try:
await self.context.messages.wait_for(
event,
empty_mention_waiter,
prefix_only_waiter,
timeout_seconds=60,
)
except TimeoutError:
Expand Down
2 changes: 1 addition & 1 deletion astrbot/core/config/astrbot_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,14 +56,14 @@ def __init__(

# 调用父类的 __setattr__ 方法,防止保存配置时将此属性写入配置文件
object.__setattr__(self, "config_path", config_path)
object.__setattr__(self, "default_config", default_config)
object.__setattr__(self, "schema", schema)
object.__setattr__(self, "_save_state_lock", threading.Lock())
object.__setattr__(self, "_save_commit_lock", threading.Lock())
object.__setattr__(self, "_save_revision", 0)
object.__setattr__(self, "_save_committed_revision", 0)

default_config = self._resolve_default_config(default_config, schema)
object.__setattr__(self, "default_config", default_config)
self._ensure_config_file(default_config)
conf = self._load_config_dict(config_path)
dashboard_conf = conf.get("dashboard")
Expand Down
14 changes: 6 additions & 8 deletions astrbot/core/config/default.py
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,7 @@
"command_prefixes": ["/"],
"llm_access": {
"prefixes": ["/"],
"private": "open",
"private": "prefix",
"group": "prefix",
"reply_to_bot": False,
},
Expand Down Expand Up @@ -1087,7 +1087,7 @@
},
"empty_mention_waiting": {
"type": "bool",
"hint": "启用后,当消息内容只有 @ 机器人时,会触发等待,在 60 秒内的该用户的任意一条消息均会唤醒机器人。这在某些平台不支持 @ 和语音/图片等消息同时发送时特别有用。",
"hint": "启用后,当消息内容只有指令前缀(例如单独一个 /)时,会等待该用户 60 秒内的下一条消息,并把它当作显式对准内置 AI。单独的前缀不会当成空提示词发给模型。空 @ 不会触发等待。llm_access 为 off 时不等待、不请求模型。",
},
"empty_mention_waiting_need_reply": {
"type": "bool",
Expand All @@ -1099,7 +1099,8 @@
},
"ignore_at_all": {
"type": "bool",
"hint": "启用后,机器人会忽略 @ 全体成员 的消息事件。",
"invisible": True,
"hint": "保留字段。MentionAll 只是消息链标记,不会单独放行或拦截内置 LLM。",
},
"segmented_reply": {
"type": "object",
Expand Down Expand Up @@ -3124,8 +3125,6 @@
"options": [
"open",
"prefix",
"mention",
"prefix_or_mention",
"off",
],
},
Expand Down Expand Up @@ -4227,8 +4226,6 @@
"options": [
"open",
"prefix",
"mention",
"prefix_or_mention",
"off",
],
},
Expand Down Expand Up @@ -4269,7 +4266,7 @@
"type": "int",
},
"platform_settings.empty_mention_waiting": {
"description": "只 @ 机器人是否触发等待",
"description": "只打指令前缀是否触发等待",
"type": "bool",
},
},
Expand Down Expand Up @@ -4399,6 +4396,7 @@
"platform_settings.ignore_at_all": {
"description": "是否忽略 @ 全体成员事件",
"type": "bool",
"invisible": True,
},
"platform_settings.no_permission_reply": {
"description": "用户权限不足时是否回复",
Expand Down
2 changes: 1 addition & 1 deletion astrbot/core/cron/events.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ def __init__(
# Ensure we use the original session for sending messages
self.session = session
self.context_obj = context
self.set_extra("adapter_preconfigured", True)
self.set_extra("explicit_surface", True)

if extras:
self._extras.update(extras)
Expand Down
95 changes: 38 additions & 57 deletions astrbot/core/pipeline/turn_router.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@
CommandResolution,
CommandResolutionKind,
)
from astrbot.core.message.components import Mention, MentionAll, Reply
from astrbot.core.message.components import Mention, Reply

RouteKind = Literal["ordinary", "passthrough", "turn_flush"]
PrivateAccess = Literal["open", "prefix", "off"]
GroupAccess = Literal["open", "prefix", "mention", "prefix_or_mention", "off"]
GroupAccess = Literal["open", "prefix", "off"]

INBOUND_FLUSH_KEYS = ("turn_flush", "turn_continuation")
MANAGER_FLUSH_TOKEN = "_turn_flush_token"
Expand All @@ -37,7 +37,7 @@ class LlmAccess:
"""LLM access policy for one configuration profile."""

prefixes: tuple[str, ...] = ("/",)
private: PrivateAccess = "open"
private: PrivateAccess = "prefix"
group: GroupAccess = "prefix"
reply_to_bot: bool = False

Expand All @@ -56,8 +56,7 @@ class TurnRouteInput:
is_notice_or_request: bool = False
has_open_window: bool = False
is_manager_flush: bool = False
adapter_preconfigured: bool = False
ignore_at_all: bool = False
explicit_surface: bool = False


@dataclass(frozen=True, slots=True)
Expand Down Expand Up @@ -126,11 +125,11 @@ def llm_access_from_config(config: dict) -> LlmAccess:
prefixes = tuple(
str(item) for item in raw.get("prefixes", ["/"]) if str(item).strip()
)
private = raw.get("private", "open")
private = raw.get("private", "prefix")
group = raw.get("group", "prefix")
if private not in {"open", "prefix", "off"}:
private = "open"
if group not in {"open", "prefix", "mention", "prefix_or_mention", "off"}:
private = "prefix"
if group not in {"open", "prefix", "off"}:
group = "prefix"
return LlmAccess(
prefixes=prefixes or ("/",),
Expand Down Expand Up @@ -231,16 +230,18 @@ def route_turn(inp: TurnRouteInput) -> TurnRouteResult:
resolution,
)

if inp.adapter_preconfigured:
llm_text, reasons = _llm_payload(inp, blocked_by_other_mention)
return TurnRouteResult(
False,
True,
"ordinary",
frozenset({"adapter_preconfigured", *reasons}),
llm_text,
False,
)
if inp.explicit_surface:

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

explicit_surface returns should_run_llm=True without _llm_gate. Command matching still wins, which is correct for WebChat /help, but private=off / group=off cannot stop WebChat, WeCom AI Bot, Discord slash, or cron.

Honor off here unless synthesized surfaces are documented as exempt; add that matrix test either way.

mode = inp.llm_access.private if inp.is_private else inp.llm_access.group
if mode != "off":
llm_text, reasons = _llm_payload(inp, blocked_by_other_mention)
return TurnRouteResult(
False,
True,
"ordinary",
frozenset({"explicit_surface", *reasons}),
llm_text,
False,
)

llm_ok, reasons = _llm_gate(inp, blocked_by_other_mention)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

resolve("") is UNKNOWN_ROOT, so a lone / is not stopped as a command and _llm_gate treats it as llm_prefix with empty message_str.

Default waiting + need_reply masks this because the plugin request_llm wins. If empty_mention_waiting is false, / still starts a built-in turn with an empty prompt. Prefix-only should not admit here; the waiter owns that UX.

Add route_turn("/") under default prefix.

if llm_ok:
Expand Down Expand Up @@ -287,60 +288,47 @@ def _first_mention_is_other(inp: TurnRouteInput) -> bool:
return str(first.target) != str(inp.self_id)


def _llm_prefix_has_payload(
inp: TurnRouteInput, blocked_by_other_mention: bool
) -> bool:
if blocked_by_other_mention:
return False
text = inp.message_str.strip(" \t")
prefix = longest_prefix_match(text, inp.llm_access.prefixes)
if prefix is None:
return False
return bool(text[len(prefix) :].strip(" \t"))


def _llm_gate(
inp: TurnRouteInput, blocked_by_other_mention: bool
) -> tuple[bool, set[str]]:
if inp.has_open_window:
return True, {"turn_continuation"}
mentioned_bot, mentioned_all, reply_to_bot = _mention_flags(inp)
if inp.is_private:
mode = inp.llm_access.private
if mode == "open":
return True, {"llm_open"}
if mode == "off":
return False, set()
if blocked_by_other_mention:
return False, set()
if longest_prefix_match(inp.message_str.strip(" \t"), inp.llm_access.prefixes):
if _llm_prefix_has_payload(inp, False):
return True, {"llm_prefix"}
return False, set()

reasons: set[str] = set()
base = False
mode = inp.llm_access.group
prefix_hit = False
if not blocked_by_other_mention:
prefix_hit = (
longest_prefix_match(inp.message_str.strip(" \t"), inp.llm_access.prefixes)
is not None
)
prefix_hit = _llm_prefix_has_payload(inp, blocked_by_other_mention)
if mode == "open":
base = True
reasons.add("llm_open")
elif mode == "prefix":
base = prefix_hit
if prefix_hit:
reasons.add("llm_prefix")
elif mode == "mention":
base = mentioned_bot or mentioned_all
if mentioned_bot:
reasons.add("mention_bot")
if mentioned_all:
reasons.add("mention_all")
elif mode == "prefix_or_mention":
base = prefix_hit or mentioned_bot or mentioned_all
if prefix_hit:
reasons.add("llm_prefix")
if mentioned_bot:
reasons.add("mention_bot")
if mentioned_all:
reasons.add("mention_all")
if inp.llm_access.reply_to_bot and reply_to_bot:
if inp.llm_access.reply_to_bot and _reply_to_bot(inp):
base = True
reasons.add("reply_to_bot")
if mode != "off" and mentioned_all:
base = True
reasons.add("mention_all")
return base, reasons


Expand All @@ -353,20 +341,13 @@ def _llm_payload(
prefix = longest_prefix_match(text, inp.llm_access.prefixes)
if prefix is None:
return text, set()
return text[len(prefix) :].strip(" \t"), {"llm_prefix"} if prefix else set()
return text[len(prefix) :].strip(" \t"), {"llm_prefix"}


def _mention_flags(inp: TurnRouteInput) -> tuple[bool, bool, bool]:
mentioned_bot = False
mentioned_all = False
reply_to_bot = False
def _reply_to_bot(inp: TurnRouteInput) -> bool:
for message in inp.messages:
if isinstance(message, Mention) and str(message.target) == str(inp.self_id):
mentioned_bot = True
if isinstance(message, MentionAll) and not inp.ignore_at_all:
mentioned_all = True
if isinstance(message, Reply) and str(
getattr(message, "sender_id", "") or ""
) == str(inp.self_id):
reply_to_bot = True
return mentioned_bot, mentioned_all, reply_to_bot
return True
return False
Loading
Loading