Files
hermes-agent/gateway/hosted_room_discussion.py
T

1063 lines
44 KiB
Python

"""Deterministic policy for same-gateway hosted-room Discussions.
Translates a frozen local member roster plus the complete typed room log into
one next driver task. Pure: no I/O, no workers, no transport or model
knowledge. Callers persist the task with :mod:`gateway.hosted_room_driver`
and append publication plans with :mod:`gateway.hosted_rooms`.
The driver payload is deliberately not widened: Discussion coordinates live
in deterministic ``TaskIdentity`` values and typed terminal events, so a
restart can reconstruct a task from durable state. 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 functools import partial
from typing import Any, Literal
from gateway import hosted_room_driver as driver
from gateway import hosted_rooms
from gateway import hosted_rooms_common as common
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"]
_MENTION_RE = re.compile(r"@([A-Za-z0-9][A-Za-z0-9._:-]*)", re.IGNORECASE)
_TURN_ID_RE = re.compile(
r"^d(?P<source>[1-9][0-9]*)\.r(?P<round>[0-2])\."
r"p(?P<position>[0-5])\.s(?P<seen>[1-9][0-9]*)\."
r"m(?P<member>[0-9a-f]{24})$"
)
_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"})
_TURN_COORDINATE_FIELDS = frozenset({
"discussion_event_id", "member_id", "member_index", "round_index",
"task_id", "thread_id", "turn_id",
})
_MEMBER_MESSAGE_FIELDS = _TURN_COORDINATE_FIELDS | {"text"}
_TERMINAL_COMMON_FIELDS = _TURN_COORDINATE_FIELDS | {"seen_through_seq"}
_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)
# Gateway-authored control events: kind -> (exact payload fields, identifier fields).
_GATEWAY_EVENT_FIELDS = {
"room.activity": (
frozenset({"status", "reason_code", "thread_id", "discussion_event_id"}),
("reason_code", "thread_id", "discussion_event_id"),
),
"room.stop_requested": (frozenset({"cancel_id"}), ("cancel_id",)),
}
_EPOCH_STAMPED_KINDS = _TERMINAL_EVENT_KINDS | {"message.member", *_GATEWAY_EVENT_FIELDS}
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]
_identifier = partial(
common.identifier, error=DiscussionValidationError, max_chars=driver.MAX_IDENTIFIER_CHARS
)
_exact_fields = partial(common.exact_fields, error=DiscussionValidationError)
def _positive_int(value: Any, *, label: str) -> int:
return common.positive_int(
value, error=DiscussionValidationError, message=f"{label} must be a positive integer"
)
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 _peer_target(member: DiscussionMember) -> Mapping[str, Any] | None:
target = member.target
return target if target and target.get("kind") == "peer" else None
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")
return {"text": text, "thread_id": _identifier(payload["thread_id"], label="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 not in ("local", "peer"):
raise DiscussionValidationError(f"member {index} target kind must be local or peer")
target = _exact_fields(
value, label=f"member {index} {kind} target",
required=_LOCAL_TARGET_FIELDS if kind == "local" else _PEER_TARGET_FIELDS,
)
target_profile = _identifier(target["profile"], label=f"member {index} target profile")
if kind == "local":
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 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,
}
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] = {"all", "everyone"} # reserved mention handles
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: {', '.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()
target_message = (
"member profiles must be unique" if target.get("kind") == "local"
else "member targets must be unique"
)
for key, seen, message in (
(target_key, targets, target_message),
(handle.casefold(), handles,
"member handles must be unique and cannot reserve @all or @everyone"),
(member_id.casefold(), member_ids, "member ids must be unique"),
):
if key in seen:
raise DiscussionValidationError(message)
seen.add(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 _require_gateway_actor(actor: Mapping[str, Any], room: DiscussionRoom, message: str) -> None:
if actor.get("kind") != "gateway" or actor.get("id") != room.gateway_id:
raise DiscussionValidationError(message)
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"))
_zero_based_int(payload.get("member_index"), label="member_index", maximum=MAX_DISCUSSION_MEMBERS - 1)
_zero_based_int(payload.get("round_index"), label="round_index", maximum=MAX_DISCUSSION_ROUNDS - 1)
for field in ("thread_id", "task_id", "turn_id", "discussion_event_id"):
_identifier(payload.get(field), label=field)
# -- per-kind event payload validators (dispatched by _validate_event) ---------
# Each takes (kind, payload, actor, room) and returns the payload to record.
def _validate_user_event(
kind: str, payload: Mapping[str, Any], actor: Mapping[str, Any], room: DiscussionRoom
) -> Mapping[str, Any]:
payload = validate_user_payload(payload)
if actor.get("kind") != "user":
raise DiscussionValidationError("message.user requires a user actor")
return payload
def _validate_member_message(
kind: str, payload: Mapping[str, Any], actor: Mapping[str, Any], room: DiscussionRoom
) -> Mapping[str, Any]:
_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"))
peer = _peer_target(member)
expected = {
"kind": "member",
"id": member.member_id,
"profile": member.profile,
"connection_id": peer.get("peer_id") if peer else None,
}
if any(actor.get(key) != value for key, value in expected.items()):
raise DiscussionValidationError("message.member actor does not match roster")
return payload
def _validate_terminal_event(
kind: str, payload: Mapping[str, Any], actor: Mapping[str, Any], room: DiscussionRoom
) -> Mapping[str, Any]:
_exact_fields(
payload, label=f"{kind} payload",
required=_TERMINAL_COMMON_FIELDS | _TERMINAL_EXTRA_FIELDS[kind],
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("connection_id") is not None:
raise DiscussionValidationError(f"{kind} requires a gateway actor")
_require_gateway_actor(actor, room, 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 not payload["passed"]:
_identifier(message_event_id, label="message_event_id")
elif message_event_id is not None:
raise DiscussionValidationError("a passed turn cannot reference a member message")
return payload
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"
)
return payload
def _validate_gateway_event(
kind: str, payload: Mapping[str, Any], actor: Mapping[str, Any], room: DiscussionRoom
) -> Mapping[str, Any]:
fields, identifier_fields = _GATEWAY_EVENT_FIELDS[kind]
_exact_fields(payload, label=f"{kind} payload", required=fields)
if kind == "room.activity" and payload.get("status") not in {"settled", "bounded"}:
raise DiscussionValidationError("invalid room.activity status")
for field in identifier_fields:
_identifier(payload.get(field), label=field)
_require_gateway_actor(actor, room, f"{kind} requires the room gateway")
return payload
_EVENT_VALIDATORS = {
"message.user": _validate_user_event,
"message.member": _validate_member_message,
**dict.fromkeys(_TERMINAL_EVENT_KINDS, _validate_terminal_event),
**dict.fromkeys(_GATEWAY_EVENT_FIELDS, _validate_gateway_event),
}
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, actor, payload = raw.get("kind"), raw.get("actor"), raw.get("payload")
for value, expected, message in (
(kind, str, "event kind must be a string"),
(actor, Mapping, "event actor must be an object"),
(payload, Mapping, "event payload must be an object"),
):
if not isinstance(value, expected):
raise DiscussionValidationError(message)
if kind in _EPOCH_STAMPED_KINDS and raw.get("authority_epoch") != room.authority_epoch:
raise DiscussionValidationError(f"{kind} authority epoch does not match the room")
validator = _EVENT_VALIDATORS.get(kind)
if validator is not None:
payload = validator(kind, payload, actor, room)
return _ValidatedEvent(raw=raw, seq=seq, event_id=event_id, kind=kind, actor=actor, payload=payload)
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 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)
return _derive_member_watermarks(_validated_events(events, room=room))
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 = messages_by_id.get(str(event.payload["message_event_id"]))
if message is None or any(
message.payload.get(field) != event.payload.get(field)
for field in ("task_id", "member_id", "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 _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 = (
f"d{discussion_event.seq}.r{round_index}.p{member_index}."
f"s{seen_through_seq}.m{_member_digest(member)}"
)
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=(",", ":"),
)
task_id = f"dtask:{hashlib.sha256(seed.encode('utf-8')).hexdigest()[:48]}"
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 _pending_discussion(validated: Sequence[_ValidatedEvent]) -> _ValidatedEvent | None:
"""Oldest latest-per-thread user message not stopped and not yet completed."""
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 validated:
if event.kind == "message.user":
latest_by_thread[str(event.payload["thread_id"])] = event
pending = [
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
]
return pending[0] if pending else None
def _thread_messages(
validated: Sequence[_ValidatedEvent], discussion: _ValidatedEvent
) -> tuple[tuple[_ValidatedEvent, ...], tuple[_ValidatedEvent, ...], tuple[_ValidatedEvent, ...]]:
"""Return (thread messages, messages since the discussion, this discussion's member messages)."""
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 validated
if event.payload.get("thread_id") == thread_id
and (
event.kind == "message.user"
or (event.kind == "message.member" and 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
)
return thread_messages, discussion_messages, member_messages
def _effective_watermarks(
validated: Sequence[_ValidatedEvent], initial_watermarks: Mapping[tuple[str, str], int] | None
) -> dict[tuple[str, str], int]:
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)
return watermarks
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)
discussion = _pending_discussion(validated)
if discussion is None:
return DiscussionDecision(status="idle", reason="no_pending_user_event")
thread_id = str(discussion.payload["thread_id"])
decide = partial(
DiscussionDecision, discussion_event_id=discussion.event_id,
source_event_seq=discussion.seq, thread_id=thread_id,
)
thread_messages, discussion_messages, member_messages = _thread_messages(validated, discussion)
if len(member_messages) >= MAX_DISCUSSION_MESSAGES:
return decide("bounded", "max_messages")
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 = _effective_watermarks(validated, initial_watermarks)
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)
)
for member_index, member in enumerate(_rotate(responders, round_index)):
if (round_index, member.member_id) in terminals:
continue
watermark = watermarks.get((thread_id, member.member_id), 0)
if not any(watermark < event.seq <= seen_through_seq for event in thread_messages):
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 decide("task", "member_turn", task=task)
if not any(int(event.payload["round_index"]) == round_index for event in member_messages):
return decide("settled", "silent_round")
if round_index == MAX_DISCUSSION_ROUNDS - 1:
return decide("bounded", "max_rounds")
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"))
if payload.get("source_event_seq") != source_event_seq:
raise DiscussionReconstructionError("task source event does not match turn_id")
discussion = next(
(e for e in validated if e.seq == source_event_seq and e.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=int(match.group("position")), round_index=int(match.group("round")),
seen_through_seq=int(match.group("seen")), 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
# -- per-status terminal payload builders (dispatched by plan_publication) -----
# Each returns (extra terminal payload fields, visible effects to publish first).
def _settled_effects(
result: Any, *, task: DiscussionTaskPlan, room: DiscussionRoom, message_event_id: str, **_: Any
) -> tuple[dict[str, Any], list[EventPlan]]:
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)
effects: list[EventPlan] = []
if not passed:
member_actor = {"kind": "member", "id": task.member.member_id, "profile": task.member.profile}
peer = _peer_target(task.member)
if peer:
member_actor["connection_id"] = peer["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,
))
return {"message_event_id": None if passed else message_event_id, "passed": passed}, effects
def _failed_effects(result: Any, **_: Any) -> tuple[dict[str, Any], list[EventPlan]]:
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)
return {"error": error_text, "reason_code": reason_code}, []
def _cancelled_effects(
result: Any, *, newer_same_thread: bool, **_: Any
) -> tuple[dict[str, Any], list[EventPlan]]:
reason = (
"superseded_by_newer_user_event" if newer_same_thread
else _terminal_text(result, field="reason", fallback="member turn cancelled")
)
return {"reason": reason}, []
def _deferred_effects(
result: Any, *, execution_generation: int | None, **_: Any
) -> tuple[dict[str, Any], list[EventPlan]]:
return {
"execution_generation": execution_generation,
"reason": _terminal_text(result, field="reason", fallback="member_unavailable"),
}, []
_TERMINAL_EFFECTS = {
"settled": _settled_effects,
"failed": _failed_effects,
"cancelled": _cancelled_effects,
"deferred": _deferred_effects,
}
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
stays terminal in driver state, but only a deterministic cancellation is
published so stale prose and its watermark cannot hide the newer 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 _TERMINAL_EFFECTS:
raise DiscussionValidationError("invalid terminal publication status")
if status == "deferred":
message = "deferred publication requires an execution generation"
common.positive_int(execution_generation, error=DiscussionValidationError, message=message)
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:")
terminal_event_id = (
f"ddeferred:{digest}:g{execution_generation}"
if effective_status == "deferred" else f"dterminal:{digest}"
)
common_payload = {
"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,
}
extra, effects = _TERMINAL_EFFECTS[effective_status](
result, task=task, room=room, message_event_id=f"dmessage:{digest}",
newer_same_thread=newer_same_thread, execution_generation=execution_generation,
)
terminal_kind = f"turn.{effective_status}"
effects.append(EventPlan(
event_id=terminal_event_id, kind=terminal_kind,
actor={"kind": "gateway", "id": room.gateway_id},
payload={**common_payload, **extra},
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)
)