"""Deterministic policy for same-gateway hosted-room Discussions. This module translates a frozen local member roster and the complete typed room log into one next driver task. It performs no I/O, starts no workers, and knows nothing about transports or model runtimes. Callers persist the returned task with :mod:`gateway.hosted_room_driver` and append publication plans with :mod:`gateway.hosted_rooms`. The unpublished driver payload intentionally remains unchanged. Discussion coordinates live in deterministic ``TaskIdentity`` values and typed terminal events; a restart can therefore reconstruct a task without widening the driver schema. Callers must reconcile terminal driver rows into publication plans before asking for the next task. """ from __future__ import annotations import hashlib import json import re from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass from typing import Any, Literal from gateway import hosted_room_driver as driver from gateway import hosted_rooms MAX_DISCUSSION_MEMBERS = 6 MIN_DISCUSSION_MEMBERS = 2 MAX_DISCUSSION_ROUNDS = 3 MAX_DISCUSSION_MESSAGES = 10 MAX_DISCUSSION_DELTA_LINES = 24 MAX_USER_TEXT_BYTES = 64 * 1024 MAX_MEMBER_TEXT_BYTES = 64 * 1024 _TRUNCATED_REPLY_NOTICE = ( "\n\n[Reply truncated. Ask the Bot to share the full result as a file.]" ) DecisionStatus = Literal["idle", "task", "settled", "bounded"] TerminalKind = Literal["settled", "failed", "cancelled", "deferred"] _IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]*$") _MENTION_RE = re.compile(r"@([A-Za-z0-9][A-Za-z0-9._:-]*)", re.IGNORECASE) _TURN_ID_RE = re.compile( r"^d(?P[1-9][0-9]*)\.r(?P[0-2])\." r"p(?P[0-5])\.s(?P[1-9][0-9]*)\." r"m(?P[0-9a-f]{24})$" ) _MEMBER_FIELDS = frozenset( {"member_id", "profile", "handle", "display_name", "target"} ) _LOCAL_TARGET_FIELDS = frozenset({"kind", "profile"}) _PEER_TARGET_FIELDS = frozenset( {"kind", "peer_id", "installation_id", "profile", "capability_digest"} ) _REMOTE_MEMBER_FIELDS = frozenset({ "connectionId", "connectionKind", "connectionLabel", "connection_id", "connection_kind", "connection_label", "remoteSource", "route", "sourceMissing", "sourceReachable", "sourceScoped", "targetProfile", "target_profile", }) _USER_PAYLOAD_FIELDS = frozenset({"text", "thread_id"}) _MEMBER_MESSAGE_FIELDS = frozenset({ "discussion_event_id", "member_id", "member_index", "round_index", "task_id", "text", "thread_id", "turn_id", }) _TERMINAL_COMMON_FIELDS = frozenset({ "discussion_event_id", "member_id", "member_index", "round_index", "seen_through_seq", "task_id", "thread_id", "turn_id", }) _TERMINAL_EXTRA_FIELDS = { "turn.settled": frozenset({"message_event_id", "passed"}), "turn.failed": frozenset({"error"}), "turn.cancelled": frozenset({"reason"}), "turn.deferred": frozenset({"execution_generation", "reason"}), } _TERMINAL_OPTIONAL_FIELDS = { "turn.failed": frozenset({"reason_code"}), } _TERMINAL_EVENT_KINDS = frozenset(_TERMINAL_EXTRA_FIELDS) _ROOM_ACTIVITY_FIELDS = frozenset({ "status", "reason_code", "thread_id", "discussion_event_id", }) _ROOM_STOP_FIELDS = frozenset({"cancel_id"}) class DiscussionPolicyError(ValueError): """Base class for invalid policy input or unreconstructable state.""" class DiscussionValidationError(DiscussionPolicyError): """Raised when a room, roster, payload, or typed event is malformed.""" class DiscussionReconstructionError(DiscussionPolicyError): """Raised when a persisted task cannot be reproduced from durable state.""" @dataclass(frozen=True) class DiscussionMember: """One immutable local or peer member of the hosted room.""" member_id: str profile: str handle: str display_name: str = "" target: Mapping[str, Any] | None = None @dataclass(frozen=True) class DiscussionRoom: """Validated policy projection of one active hosted room.""" room_id: str name: str members: tuple[DiscussionMember, ...] gateway_id: str authority_epoch: int @dataclass(frozen=True) class DiscussionTaskPlan: """One deterministic member turn compatible with the driver schema.""" identity: driver.TaskIdentity payload: Mapping[str, Any] discussion_event_id: str member: DiscussionMember member_index: int round_index: int seen_through_seq: int @dataclass(frozen=True) class DiscussionDecision: """Current result of replaying one room's Discussion policy.""" status: DecisionStatus reason: str discussion_event_id: str | None = None source_event_seq: int | None = None thread_id: str | None = None task: DiscussionTaskPlan | None = None @dataclass(frozen=True) class EventPlan: """One idempotent append for :func:`gateway.hosted_rooms.append_event`.""" event_id: str kind: str actor: Mapping[str, str] payload: Mapping[str, Any] authority_gateway_id: str authority_epoch: int def append_kwargs(self, room_id: str) -> dict[str, Any]: """Return keyword arguments accepted by ``append_event``.""" return { "room_id": room_id, "event_id": self.event_id, "kind": self.kind, "actor": dict(self.actor), "payload": dict(self.payload), "authority_gateway_id": self.authority_gateway_id, "authority_epoch": self.authority_epoch, } @dataclass(frozen=True) class PublicationPlan: """Ordered visible and terminal effects for one driver task.""" task_id: str terminal_kind: str events: tuple[EventPlan, ...] @dataclass(frozen=True) class _ValidatedEvent: raw: Mapping[str, Any] seq: int event_id: str kind: str actor: Mapping[str, Any] payload: Mapping[str, Any] def _identifier(value: Any, *, label: str) -> str: if not isinstance(value, str): raise DiscussionValidationError(f"{label} must be a string") normalized = value.strip() if ( not normalized or len(normalized) > driver.MAX_IDENTIFIER_CHARS or not _IDENTIFIER_RE.fullmatch(normalized) ): raise DiscussionValidationError(f"invalid {label}") return normalized def _positive_int(value: Any, *, label: str, maximum: int | None = None) -> int: if isinstance(value, bool) or not isinstance(value, int) or value < 1: raise DiscussionValidationError(f"{label} must be a positive integer") if maximum is not None and value > maximum: raise DiscussionValidationError(f"{label} must be at most {maximum}") return value def _zero_based_int(value: Any, *, label: str, maximum: int) -> int: if ( isinstance(value, bool) or not isinstance(value, int) or not 0 <= value <= maximum ): raise DiscussionValidationError( f"{label} must be an integer between 0 and {maximum}" ) return value def _exact_fields( value: Any, *, label: str, required: frozenset[str], optional: frozenset[str] = frozenset(), ) -> Mapping[str, Any]: if not isinstance(value, Mapping): raise DiscussionValidationError(f"{label} must be an object") keys = frozenset(value) missing = required - keys unknown = keys - required - optional if missing: raise DiscussionValidationError( f"{label} is missing fields: {', '.join(sorted(missing))}" ) if unknown: raise DiscussionValidationError( f"{label} has unknown fields: {', '.join(sorted(unknown))}" ) return value def validate_user_payload(value: Any) -> dict[str, Any]: """Validate and normalize the exact ``message.user`` Discussion payload.""" payload = _exact_fields( value, label="user payload", required=_USER_PAYLOAD_FIELDS, ) text = payload["text"] if not isinstance(text, str): raise DiscussionValidationError("user payload text must be a string") text = text.strip() if not text: raise DiscussionValidationError("user payload text must not be empty") if len(text.encode("utf-8")) > MAX_USER_TEXT_BYTES: raise DiscussionValidationError("user payload text is too large") thread_id = _identifier(payload["thread_id"], label="thread_id") return {"text": text, "thread_id": thread_id} def _validate_member_target( value: Any, *, profile: str, known_profiles: set[str], index: int, ) -> dict[str, Any]: if value is None: if profile not in known_profiles: raise DiscussionValidationError( f"member {index} profile '{profile}' is not local to this gateway" ) return {"kind": "local", "profile": profile} if not isinstance(value, Mapping): raise DiscussionValidationError(f"member {index} target must be an object") kind = value.get("kind") if kind == "local": target = _exact_fields( value, label=f"member {index} local target", required=_LOCAL_TARGET_FIELDS, ) target_profile = _identifier( target["profile"], label=f"member {index} target profile" ) if target_profile != profile or profile not in known_profiles: raise DiscussionValidationError( f"member {index} local target does not match a local profile" ) return {"kind": "local", "profile": profile} if kind == "peer": target = _exact_fields( value, label=f"member {index} peer target", required=_PEER_TARGET_FIELDS, ) target_profile = _identifier( target["profile"], label=f"member {index} target profile" ) if target_profile != profile: raise DiscussionValidationError( f"member {index} peer target profile does not match member profile" ) capability_digest = target["capability_digest"] if ( not isinstance(capability_digest, str) or not re.fullmatch(r"[0-9a-f]{64}", capability_digest) ): raise DiscussionValidationError( f"member {index} capability_digest must be a sha256 digest" ) return { "kind": "peer", "peer_id": _identifier( target["peer_id"], label=f"member {index} peer_id" ), "installation_id": _identifier( target["installation_id"], label=f"member {index} installation_id", ), "profile": target_profile, "capability_digest": capability_digest, } raise DiscussionValidationError( f"member {index} target kind must be local or peer" ) def validate_roster( value: Any, *, local_profiles: Iterable[str], ) -> tuple[DiscussionMember, ...]: """Validate a frozen 2-6 member roster of profiles on this gateway.""" if not isinstance(value, list): raise DiscussionValidationError("members must be a list") if not MIN_DISCUSSION_MEMBERS <= len(value) <= MAX_DISCUSSION_MEMBERS: raise DiscussionValidationError( f"members must contain between {MIN_DISCUSSION_MEMBERS} and " f"{MAX_DISCUSSION_MEMBERS} entries" ) known_profiles = { _identifier(profile, label="local profile") for profile in local_profiles } members: list[DiscussionMember] = [] targets: set[str] = set() handles: set[str] = set() member_ids: set[str] = set() for index, raw in enumerate(value): if not isinstance(raw, Mapping): raise DiscussionValidationError(f"member {index} must be an object") remote_fields = frozenset(raw) & _REMOTE_MEMBER_FIELDS if remote_fields: raise DiscussionValidationError( f"member {index} contains cross-gateway fields: " f"{', '.join(sorted(remote_fields))}" ) member = _exact_fields( raw, label=f"member {index}", required=frozenset({"member_id", "profile", "handle"}), optional=frozenset({"display_name", "target"}), ) member_id = _identifier(member["member_id"], label=f"member {index} id") profile = _identifier(member["profile"], label=f"member {index} profile") handle = _identifier(member["handle"], label=f"member {index} handle") target = _validate_member_target( member.get("target"), profile=profile, known_profiles=known_profiles, index=index, ) display_name = member.get("display_name", "") if not isinstance(display_name, str): raise DiscussionValidationError( f"member {index} display_name must be a string" ) display_name = display_name.strip() if len(display_name) > hosted_rooms.MAX_ACTOR_LABEL_CHARS: raise DiscussionValidationError(f"member {index} display_name is too long") target_key = json.dumps( target, sort_keys=True, separators=(",", ":"), ).casefold() handle_key = handle.casefold() member_key = member_id.casefold() if target_key in targets: if target.get("kind") == "local": raise DiscussionValidationError("member profiles must be unique") raise DiscussionValidationError("member targets must be unique") if handle_key in handles or handle_key in {"all", "everyone"}: raise DiscussionValidationError( "member handles must be unique and cannot reserve @all or @everyone" ) if member_key in member_ids: raise DiscussionValidationError("member ids must be unique") targets.add(target_key) handles.add(handle_key) member_ids.add(member_key) members.append( DiscussionMember( member_id=member_id, profile=profile, handle=handle, display_name=display_name, target=target, ) ) return tuple(members) def validate_room( value: Any, *, local_profiles: Iterable[str], ) -> DiscussionRoom: """Project a hosted-room row into the strict same-gateway policy shape.""" if not isinstance(value, Mapping): raise DiscussionValidationError("room must be an object") if value.get("disbanded_at") is not None: raise DiscussionValidationError("room is disbanded") room_id = _identifier(value.get("room_id"), label="room_id") name = value.get("name") if not isinstance(name, str) or not name.strip(): raise DiscussionValidationError("room name must be a non-empty string") name = name.strip() if len(name) > hosted_rooms.MAX_ROOM_NAME_CHARS: raise DiscussionValidationError("room name is too long") gateway_id = _identifier( value.get("authority_gateway_id"), label="authority_gateway_id", ) authority_epoch = _positive_int( value.get("authority_epoch"), label="authority_epoch", ) members = validate_roster(value.get("members"), local_profiles=local_profiles) return DiscussionRoom( room_id=room_id, name=name, members=members, gateway_id=gateway_id, authority_epoch=authority_epoch, ) def is_pass_text(value: Any) -> bool: """Return whether a settled member result is Discussion silence.""" text = str(value or "").strip() return ( not text or re.fullmatch(r"\(?\s*pass\s*\)?\.?", text, re.IGNORECASE) is not None ) def resolve_mentions( texts: Iterable[str], members: Sequence[DiscussionMember], *, default_all: bool = True, ) -> tuple[DiscussionMember, ...]: """Resolve member handles deterministically against the frozen roster.""" by_handle = {member.handle.casefold(): member for member in members} mentioned: set[str] = set() everyone = False for text in texts: for match in _MENTION_RE.finditer(str(text or "")): handle = match.group(1).casefold() if handle in {"all", "everyone"}: everyone = True elif handle in by_handle: mentioned.add(handle) if everyone or (default_all and not mentioned): return tuple(members) return tuple(member for member in members if member.handle.casefold() in mentioned) def _unaddressed_member_mentions( messages: Sequence[_ValidatedEvent], room: DiscussionRoom, ) -> tuple[DiscussionMember, ...]: """Return peers explicitly cited by a Bot and not heard from afterward.""" cited_at: dict[str, int] = {} last_post_at: dict[str, int] = {} for event in messages: if event.kind != "message.member": continue speaker_id = str(event.payload["member_id"]) last_post_at[speaker_id] = event.seq cited = resolve_mentions( (str(event.payload["text"]),), room.members, default_all=False, ) for member in cited: if member.member_id != speaker_id: cited_at[member.member_id] = event.seq return tuple( member for member in room.members if member.member_id in cited_at and last_post_at.get(member.member_id, 0) <= cited_at[member.member_id] ) def _validate_event( raw: Any, *, room: DiscussionRoom, previous_seq: int, ) -> _ValidatedEvent: if not isinstance(raw, Mapping): raise DiscussionValidationError("room event must be an object") if raw.get("room_id") != room.room_id: raise DiscussionValidationError("room event belongs to a different room") seq = _positive_int(raw.get("seq"), label="event seq") if seq <= previous_seq: raise DiscussionValidationError("room events must be in strict sequence order") event_id = _identifier(raw.get("event_id"), label="event_id") kind = raw.get("kind") if not isinstance(kind, str): raise DiscussionValidationError("event kind must be a string") actor = raw.get("actor") if not isinstance(actor, Mapping): raise DiscussionValidationError("event actor must be an object") payload = raw.get("payload") if not isinstance(payload, Mapping): raise DiscussionValidationError("event payload must be an object") if kind == "message.user": payload = validate_user_payload(payload) if actor.get("kind") != "user": raise DiscussionValidationError("message.user requires a user actor") elif kind == "message.member": if raw.get("authority_epoch") != room.authority_epoch: raise DiscussionValidationError( "message.member authority epoch does not match the room" ) _validate_member_message(payload, actor=actor, room=room) elif kind in _TERMINAL_EVENT_KINDS: if raw.get("authority_epoch") != room.authority_epoch: raise DiscussionValidationError( f"{kind} authority epoch does not match the room" ) _validate_terminal_event(kind, payload, actor=actor, room=room) elif kind == "room.activity": if raw.get("authority_epoch") != room.authority_epoch: raise DiscussionValidationError( "room.activity authority epoch does not match the room" ) _exact_fields( payload, label="room.activity payload", required=_ROOM_ACTIVITY_FIELDS, ) if payload.get("status") not in {"settled", "bounded"}: raise DiscussionValidationError("invalid room.activity status") _identifier(payload.get("reason_code"), label="reason_code") _identifier(payload.get("thread_id"), label="thread_id") _identifier(payload.get("discussion_event_id"), label="discussion_event_id") if actor.get("kind") != "gateway" or actor.get("id") != room.gateway_id: raise DiscussionValidationError("room.activity requires the room gateway") elif kind == "room.stop_requested": if raw.get("authority_epoch") != room.authority_epoch: raise DiscussionValidationError( "room.stop_requested authority epoch does not match the room" ) _exact_fields( payload, label="room.stop_requested payload", required=_ROOM_STOP_FIELDS, ) _identifier(payload.get("cancel_id"), label="cancel_id") if actor.get("kind") != "gateway" or actor.get("id") != room.gateway_id: raise DiscussionValidationError( "room.stop_requested requires the room gateway" ) return _ValidatedEvent( raw=raw, seq=seq, event_id=event_id, kind=kind, actor=actor, payload=payload, ) def _member_by_id(room: DiscussionRoom, member_id: Any) -> DiscussionMember: normalized = _identifier(member_id, label="member_id") for member in room.members: if member.member_id == normalized: return member raise DiscussionValidationError(f"unknown Discussion member '{normalized}'") def _validate_turn_coordinates( payload: Mapping[str, Any], room: DiscussionRoom ) -> None: _member_by_id(room, payload.get("member_id")) member_index = _zero_based_int( payload.get("member_index"), label="member_index", maximum=MAX_DISCUSSION_MEMBERS - 1, ) round_index = _zero_based_int( payload.get("round_index"), label="round_index", maximum=MAX_DISCUSSION_ROUNDS - 1, ) thread_id = _identifier(payload.get("thread_id"), label="thread_id") task_id = _identifier(payload.get("task_id"), label="task_id") turn_id = _identifier(payload.get("turn_id"), label="turn_id") discussion_event_id = _identifier( payload.get("discussion_event_id"), label="discussion_event_id", ) del member_index, round_index, thread_id, task_id, turn_id, discussion_event_id def _validate_member_message( payload: Mapping[str, Any], *, actor: Mapping[str, Any], room: DiscussionRoom, ) -> None: _exact_fields( payload, label="message.member payload", required=_MEMBER_MESSAGE_FIELDS, ) _validate_turn_coordinates(payload, room) text = payload.get("text") if not isinstance(text, str) or not text.strip() or is_pass_text(text): raise DiscussionValidationError("message.member text must be a non-pass string") member = _member_by_id(room, payload.get("member_id")) expected_connection = None if member.target and member.target.get("kind") == "peer": expected_connection = member.target.get("peer_id") if ( actor.get("kind") != "member" or actor.get("id") != member.member_id or actor.get("profile") != member.profile or actor.get("connection_id") != expected_connection ): raise DiscussionValidationError("message.member actor does not match roster") def _validate_terminal_event( kind: str, payload: Mapping[str, Any], *, actor: Mapping[str, Any], room: DiscussionRoom, ) -> None: required = _TERMINAL_COMMON_FIELDS | _TERMINAL_EXTRA_FIELDS[kind] _exact_fields( payload, label=f"{kind} payload", required=required, optional=_TERMINAL_OPTIONAL_FIELDS.get(kind, frozenset()), ) _validate_turn_coordinates(payload, room) _positive_int(payload.get("seen_through_seq"), label="seen_through_seq") if ( actor.get("kind") != "gateway" or actor.get("id") != room.gateway_id or actor.get("connection_id") is not None ): raise DiscussionValidationError(f"{kind} requires a gateway actor") if kind == "turn.settled": if not isinstance(payload.get("passed"), bool): raise DiscussionValidationError("turn.settled passed must be a boolean") message_event_id = payload.get("message_event_id") if payload["passed"]: if message_event_id is not None: raise DiscussionValidationError( "a passed turn cannot reference a member message" ) else: _identifier(message_event_id, label="message_event_id") else: field = "error" if kind == "turn.failed" else "reason" if not isinstance(payload.get(field), str) or not payload[field].strip(): raise DiscussionValidationError(f"{kind} {field} must be non-empty") if kind == "turn.deferred": _positive_int( payload.get("execution_generation"), label="execution_generation", ) if kind == "turn.failed" and "reason_code" in payload: from tools.bot_failure_reasons import ALL_REASONS if payload["reason_code"] not in ALL_REASONS: raise DiscussionValidationError( "turn.failed reason_code must use the shared failure vocabulary" ) def _validated_events( events: Sequence[Mapping[str, Any]], *, room: DiscussionRoom, ) -> tuple[_ValidatedEvent, ...]: validated: list[_ValidatedEvent] = [] previous_seq = 0 event_ids: set[str] = set() for raw in events: event = _validate_event(raw, room=room, previous_seq=previous_seq) if event.event_id in event_ids: raise DiscussionValidationError("room event ids must be unique") validated.append(event) previous_seq = event.seq event_ids.add(event.event_id) return tuple(validated) def _discussion_user_events( events: Sequence[_ValidatedEvent], ) -> tuple[_ValidatedEvent, ...]: return tuple(event for event in events if event.kind == "message.user") def _message_events( events: Sequence[_ValidatedEvent], *, thread_id: str | None = None, maximum_seq: int | None = None, ) -> tuple[_ValidatedEvent, ...]: result = [] for event in events: if event.kind not in {"message.user", "message.member"}: continue if thread_id is not None and event.payload.get("thread_id") != thread_id: continue if maximum_seq is not None and event.seq > maximum_seq: continue result.append(event) return tuple(result) def derive_member_watermarks( room_value: Any, events: Sequence[Mapping[str, Any]], *, local_profiles: Iterable[str], ) -> dict[tuple[str, str], int]: """Derive ``(thread_id, member_id)`` watermarks from terminal events.""" room = validate_room(room_value, local_profiles=local_profiles) validated = _validated_events(events, room=room) return _derive_member_watermarks(validated) def _derive_member_watermarks( events: Sequence[_ValidatedEvent], ) -> dict[tuple[str, str], int]: messages_by_id = { event.event_id: event for event in events if event.kind == "message.member" } terminal_by_task: dict[str, _ValidatedEvent] = {} watermarks: dict[tuple[str, str], int] = {} for event in events: if event.kind not in _TERMINAL_EVENT_KINDS: continue task_id = str(event.payload["task_id"]) previous = terminal_by_task.get(task_id) if previous is not None: if previous.kind != "turn.deferred": raise DiscussionValidationError( f"task '{task_id}' has more than one terminal room event" ) if event.kind == "turn.deferred" and int( event.payload["execution_generation"] ) <= int(previous.payload["execution_generation"]): raise DiscussionValidationError( f"task '{task_id}' deferral generation did not advance" ) terminal_by_task[task_id] = event key = (str(event.payload["thread_id"]), str(event.payload["member_id"])) watermark = int(event.payload["seen_through_seq"]) if event.kind == "turn.settled" and not event.payload["passed"]: message_id = str(event.payload["message_event_id"]) message = messages_by_id.get(message_id) if ( message is None or message.payload.get("task_id") != task_id or message.payload.get("member_id") != event.payload.get("member_id") or message.payload.get("thread_id") != event.payload.get("thread_id") ): raise DiscussionValidationError( "turn.settled references no matching member message" ) watermark = max(watermark, message.seq) watermarks[key] = max(watermarks.get(key, 0), watermark) return watermarks def _member_digest(member: DiscussionMember) -> str: target = json.dumps( member.target or {"kind": "local", "profile": member.profile}, sort_keys=True, separators=(",", ":"), ) seed = ( f"{member.member_id}\0{member.profile}\0{member.handle}\0{target}" ) return hashlib.sha256(seed.encode("utf-8")).hexdigest()[:24] def _rotate( members: Sequence[DiscussionMember], round_index: int ) -> tuple[DiscussionMember, ...]: if len(members) < 2: return tuple(members) shift = round_index % len(members) return tuple((*members[shift:], *members[:shift])) def _format_message(event: _ValidatedEvent, room: DiscussionRoom) -> str: text = str(event.payload["text"]) if event.kind == "message.user": return f"User (user): {text}" member = _member_by_id(room, event.payload["member_id"]) return f"@{member.handle}: {text}" def _truncate_utf8_text(value: Any, *, max_bytes: int, suffix: str = "") -> str: text = str(value or "") encoded = text.encode("utf-8") if len(encoded) <= max_bytes: return text suffix_bytes = suffix.encode("utf-8") prefix = encoded[: max(0, max_bytes - len(suffix_bytes))] while prefix: try: return prefix.decode("utf-8") + suffix except UnicodeDecodeError: prefix = prefix[:-1] return suffix.strip() def _build_prompt( *, room: DiscussionRoom, member: DiscussionMember, messages: Sequence[_ValidatedEvent], watermark: int, seen_through_seq: int, ) -> str: delta = [event for event in messages if watermark < event.seq <= seen_through_seq][ -MAX_DISCUSSION_DELTA_LINES: ] peers = ", ".join( f"@{candidate.handle}" for candidate in room.members if candidate.member_id != member.member_id ) opening = [ f'[Discussion: "{room.name}"] You are @{member.handle}, one participant ' f"with {peers or 'no other members'} and the user.", "", "New messages in this thread since your last turn (oldest first):", ] rules = [ "", "Rules for this Discussion:", "- Reply with one conversational message only when you have something new worth adding.", '- If you have nothing new to add, reply with exactly "(pass)".', "- Mention a teammate by handle to pull them into the next round; do not repeat points already made.", "- Never reveal content from private conversations. Your reply is published verbatim.", ] fixed_bytes = len("\n".join([*opening, *rules]).encode("utf-8")) available = max(0, driver.MAX_PROMPT_BYTES - fixed_bytes - 1) selected: list[str] = [] omitted = False for event in reversed(delta): line = f" {_format_message(event, room)}" line_bytes = len(line.encode("utf-8")) + 1 if line_bytes <= available: selected.append(line) available -= line_bytes continue if not selected and available > 32: selected.append(_truncate_utf8_text(line, max_bytes=available)) omitted = True break selected.reverse() if omitted: selected.insert(0, " [Earlier content omitted to fit this turn.]") prompt = "\n".join([*opening, *selected, *rules]) if len(prompt.encode("utf-8")) > driver.MAX_PROMPT_BYTES: raise DiscussionValidationError("Discussion prompt exceeds the driver limit") return prompt def _turn_id( *, source_event_seq: int, round_index: int, member_index: int, seen_through_seq: int, member: DiscussionMember, ) -> str: return ( f"d{source_event_seq}.r{round_index}.p{member_index}." f"s{seen_through_seq}.m{_member_digest(member)}" ) def _task_id( *, room: DiscussionRoom, discussion_event: _ValidatedEvent, member: DiscussionMember, member_index: int, round_index: int, seen_through_seq: int, prompt: str, ) -> str: seed = json.dumps( { "discussion_event_id": discussion_event.event_id, "member_id": member.member_id, "member_index": member_index, "prompt_sha256": hashlib.sha256(prompt.encode("utf-8")).hexdigest(), "room_id": room.room_id, "round_index": round_index, "seen_through_seq": seen_through_seq, "source_event_seq": discussion_event.seq, "thread_id": discussion_event.payload["thread_id"], }, ensure_ascii=True, sort_keys=True, separators=(",", ":"), ) return f"dtask:{hashlib.sha256(seed.encode('utf-8')).hexdigest()[:48]}" def _make_task_plan( *, room: DiscussionRoom, discussion_event: _ValidatedEvent, member: DiscussionMember, member_index: int, round_index: int, seen_through_seq: int, prompt: str, ) -> DiscussionTaskPlan: turn_id = _turn_id( source_event_seq=discussion_event.seq, round_index=round_index, member_index=member_index, seen_through_seq=seen_through_seq, member=member, ) task_id = _task_id( room=room, discussion_event=discussion_event, member=member, member_index=member_index, round_index=round_index, seen_through_seq=seen_through_seq, prompt=prompt, ) identity = driver.TaskIdentity( room_id=room.room_id, task_id=task_id, thread_id=str(discussion_event.payload["thread_id"]), turn_id=turn_id, ) payload = { "target_member_id": member.member_id, "target_profile": member.profile, "prompt": prompt, "source_event_seq": discussion_event.seq, } return DiscussionTaskPlan( identity=identity, payload=payload, discussion_event_id=discussion_event.event_id, member=member, member_index=member_index, round_index=round_index, seen_through_seq=seen_through_seq, ) def plan_next_task( room_value: Any, events: Sequence[Mapping[str, Any]], *, local_profiles: Iterable[str], initial_watermarks: Mapping[tuple[str, str], int] | None = None, ) -> DiscussionDecision: """Replay the complete room log and return at most one next member task.""" room = validate_room(room_value, local_profiles=local_profiles) validated = _validated_events(events, room=room) user_events = _discussion_user_events(validated) stopped_through_seq = max( (event.seq for event in validated if event.kind == "room.stop_requested"), default=0, ) completed_discussion_ids = { str(event.payload["discussion_event_id"]) for event in validated if event.kind == "room.activity" and event.payload.get("status") in {"settled", "bounded"} } latest_by_thread: dict[str, _ValidatedEvent] = {} for event in user_events: latest_by_thread[str(event.payload["thread_id"])] = event pending_user_events = tuple( event for event in sorted(latest_by_thread.values(), key=lambda item: item.seq) if event.seq > stopped_through_seq and event.event_id not in completed_discussion_ids ) if not pending_user_events: return DiscussionDecision(status="idle", reason="no_pending_user_event") discussion = pending_user_events[0] thread_id = str(discussion.payload["thread_id"]) committed_member_message_ids = { str(event.payload["message_event_id"]) for event in validated if event.kind == "turn.settled" and event.payload.get("message_event_id") is not None } # Publication writes the visible member message before the terminal event. # A crash in that gap leaves the message in the log, but it is not committed # policy input yet: ignoring it reproduces the original task coordinates so # the caller can inspect the terminal driver row and finish publication. thread_messages = tuple( event for event in _message_events(validated, thread_id=thread_id) if event.kind == "message.user" or event.event_id in committed_member_message_ids ) discussion_messages = tuple( event for event in thread_messages if event.seq >= discussion.seq ) member_messages = tuple( event for event in thread_messages if event.kind == "message.member" and event.payload.get("discussion_event_id") == discussion.event_id ) if len(member_messages) >= MAX_DISCUSSION_MESSAGES: return DiscussionDecision( status="bounded", reason="max_messages", discussion_event_id=discussion.event_id, source_event_seq=discussion.seq, thread_id=thread_id, ) terminals = { (int(event.payload["round_index"]), str(event.payload["member_id"])): event for event in validated if event.kind in _TERMINAL_EVENT_KINDS and event.payload.get("discussion_event_id") == discussion.event_id } watermarks = { (str(thread_id), str(member_id)): int(value) for (thread_id, member_id), value in (initial_watermarks or {}).items() if int(value) >= 0 } for key, value in _derive_member_watermarks(validated).items(): watermarks[key] = max(watermarks.get(key, 0), value) seen_through_seq = max(event.seq for event in thread_messages) for round_index in range(MAX_DISCUSSION_ROUNDS): # The user's message selects the first round, with no mention meaning # everyone. Later rounds are opt-in: only a peer explicitly cited by a # Bot and not heard from afterward gets another turn. Every member's # watermark remains intact, so a peer cited later still receives the # complete bounded transcript delta without consuming turns meanwhile. responders = ( resolve_mentions((str(discussion.payload["text"]),), room.members) if round_index == 0 else _unaddressed_member_mentions(discussion_messages, room) ) ordered = _rotate(responders, round_index) for member_index, member in enumerate(ordered): if (round_index, member.member_id) in terminals: continue watermark = watermarks.get((thread_id, member.member_id), 0) delta = [ event for event in thread_messages if watermark < event.seq <= seen_through_seq ] if not delta: continue prompt = _build_prompt( room=room, member=member, messages=thread_messages, watermark=watermark, seen_through_seq=seen_through_seq, ) task = _make_task_plan( room=room, discussion_event=discussion, member=member, member_index=member_index, round_index=round_index, seen_through_seq=seen_through_seq, prompt=prompt, ) return DiscussionDecision( status="task", reason="member_turn", discussion_event_id=discussion.event_id, source_event_seq=discussion.seq, thread_id=thread_id, task=task, ) spoke = any( int(event.payload["round_index"]) == round_index for event in member_messages ) if not spoke: return DiscussionDecision( status="settled", reason="silent_round", discussion_event_id=discussion.event_id, source_event_seq=discussion.seq, thread_id=thread_id, ) if round_index == MAX_DISCUSSION_ROUNDS - 1: return DiscussionDecision( status="bounded", reason="max_rounds", discussion_event_id=discussion.event_id, source_event_seq=discussion.seq, thread_id=thread_id, ) raise AssertionError("bounded Discussion loop exhausted unexpectedly") def reconstruct_task_plan( room_value: Any, events: Sequence[Mapping[str, Any]], task: Mapping[str, Any], *, local_profiles: Iterable[str], ) -> DiscussionTaskPlan: """Reconstruct and verify one persisted driver task after a restart.""" room = validate_room(room_value, local_profiles=local_profiles) validated = _validated_events(events, room=room) identity = task.get("identity") payload = task.get("payload") if not isinstance(identity, driver.TaskIdentity) or not isinstance( payload, Mapping ): raise DiscussionReconstructionError( "driver task has no valid identity or payload" ) required_payload = frozenset({ "target_profile", "prompt", "source_event_seq", }) if not required_payload <= frozenset(payload) or ( frozenset(payload) - required_payload - {"target_member_id"} ): raise DiscussionReconstructionError("driver task payload shape changed") match = _TURN_ID_RE.fullmatch(identity.turn_id) if match is None: raise DiscussionReconstructionError("turn_id is not a Discussion coordinate") source_event_seq = int(match.group("source")) round_index = int(match.group("round")) member_index = int(match.group("position")) seen_through_seq = int(match.group("seen")) if payload.get("source_event_seq") != source_event_seq: raise DiscussionReconstructionError("task source event does not match turn_id") discussion = next( ( event for event in validated if event.seq == source_event_seq and event.kind == "message.user" ), None, ) if discussion is None: raise DiscussionReconstructionError("task source user event is missing") if ( identity.room_id != room.room_id or identity.thread_id != discussion.payload["thread_id"] ): raise DiscussionReconstructionError( "task identity does not match its room thread" ) profile = payload.get("target_profile") target_member_id = payload.get("target_member_id") member = next( ( candidate for candidate in room.members if ( candidate.member_id == target_member_id if target_member_id is not None else candidate.profile == profile ) ), None, ) if member is not None and member.profile != profile: member = None if member is None or _member_digest(member) != match.group("member"): raise DiscussionReconstructionError("task target member does not match turn_id") prompt = payload.get("prompt") if not isinstance(prompt, str) or not prompt.strip(): raise DiscussionReconstructionError("task prompt is missing") if len(prompt.encode("utf-8")) > driver.MAX_PROMPT_BYTES: raise DiscussionReconstructionError("task prompt exceeds the driver limit") reconstructed = _make_task_plan( room=room, discussion_event=discussion, member=member, member_index=member_index, round_index=round_index, seen_through_seq=seen_through_seq, prompt=prompt, ) if reconstructed.identity != identity or dict(reconstructed.payload) != dict( payload ): raise DiscussionReconstructionError( "driver task failed deterministic reconstruction" ) return reconstructed def _terminal_text(result: Any, *, field: str, fallback: str) -> str: if isinstance(result, Mapping): value = result.get(field) if value is None and field == "error": value = result.get("text") else: value = result text = str(value or "").strip() return text or fallback def plan_publication( room_value: Any, events: Sequence[Mapping[str, Any]], task: DiscussionTaskPlan, *, status: TerminalKind, result: Any = None, execution_generation: int | None = None, local_profiles: Iterable[str], ) -> PublicationPlan: """Plan idempotent room effects for one terminal driver task. A newer user event in the same thread supersedes a late result. The task remains terminal in driver state, but only a deterministic cancellation is published, preventing stale prose and its watermark from hiding the newer user message. """ room = validate_room(room_value, local_profiles=local_profiles) validated = _validated_events(events, room=room) if task.identity.room_id != room.room_id: raise DiscussionValidationError("task belongs to a different room") if task.member not in room.members: raise DiscussionValidationError("task member is not in the frozen roster") if status not in {"settled", "failed", "cancelled", "deferred"}: raise DiscussionValidationError("invalid terminal publication status") if status == "deferred" and ( isinstance(execution_generation, bool) or not isinstance(execution_generation, int) or execution_generation < 1 ): raise DiscussionValidationError( "deferred publication requires an execution generation" ) newer_same_thread = any( event.kind == "message.user" and event.seq > task.seen_through_seq and event.payload.get("thread_id") == task.identity.thread_id for event in validated ) effective_status: TerminalKind = ( "cancelled" if newer_same_thread and status != "deferred" else status ) digest = task.identity.task_id.removeprefix("dtask:") message_event_id = f"dmessage:{digest}" terminal_event_id = ( f"ddeferred:{digest}:g{execution_generation}" if effective_status == "deferred" else f"dterminal:{digest}" ) common = { "discussion_event_id": task.discussion_event_id, "member_id": task.member.member_id, "member_index": task.member_index, "round_index": task.round_index, "seen_through_seq": task.seen_through_seq, "task_id": task.identity.task_id, "thread_id": task.identity.thread_id, "turn_id": task.identity.turn_id, } effects: list[EventPlan] = [] if effective_status == "settled": text = _truncate_utf8_text( _terminal_text(result, field="text", fallback=""), max_bytes=MAX_MEMBER_TEXT_BYTES, suffix=_TRUNCATED_REPLY_NOTICE, ) passed = is_pass_text(text) if not passed: member_actor = { "kind": "member", "id": task.member.member_id, "profile": task.member.profile, } if task.member.target and task.member.target.get("kind") == "peer": member_actor["connection_id"] = task.member.target["peer_id"] if task.member.display_name: member_actor["display_name"] = task.member.display_name effects.append( EventPlan( event_id=message_event_id, kind="message.member", actor=member_actor, payload={ "discussion_event_id": task.discussion_event_id, "member_id": task.member.member_id, "member_index": task.member_index, "round_index": task.round_index, "task_id": task.identity.task_id, "text": text, "thread_id": task.identity.thread_id, "turn_id": task.identity.turn_id, }, authority_gateway_id=room.gateway_id, authority_epoch=room.authority_epoch, ) ) terminal_payload = { **common, "message_event_id": None if passed else message_event_id, "passed": passed, } terminal_kind = "turn.settled" elif effective_status == "failed": error_text = _terminal_text( result, field="error", fallback="member turn failed", ) from tools.bot_failure_reasons import ALL_REASONS, classify_agent_error supplied_reason = ( str(result.get("reason_code") or result.get("reason") or "").strip() if isinstance(result, Mapping) else "" ) reason_code = ( supplied_reason if supplied_reason in ALL_REASONS else classify_agent_error(error_text) ) terminal_payload = { **common, "error": error_text, "reason_code": reason_code, } terminal_kind = "turn.failed" elif effective_status == "cancelled": terminal_payload = { **common, "reason": ( "superseded_by_newer_user_event" if newer_same_thread else _terminal_text( result, field="reason", fallback="member turn cancelled", ) ), } terminal_kind = "turn.cancelled" else: terminal_payload = { **common, "execution_generation": execution_generation, "reason": _terminal_text( result, field="reason", fallback="member_unavailable", ), } terminal_kind = "turn.deferred" effects.append( EventPlan( event_id=terminal_event_id, kind=terminal_kind, actor={"kind": "gateway", "id": room.gateway_id}, payload=terminal_payload, authority_gateway_id=room.gateway_id, authority_epoch=room.authority_epoch, ) ) return PublicationPlan( task_id=task.identity.task_id, terminal_kind=terminal_kind, events=tuple(effects), )