Skip to content
Draft
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
6 changes: 6 additions & 0 deletions docs/compositions/reach/operator.md
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,12 @@ procedure below first.

## Start Reachy and try its commands

The edge refers to itself by name in fixed operational notices such as refusals, timeouts and
permission errors. Set `agent_display_name` in the private policy to match the bot's Discord name;
it defaults to `Nadir`. It is presentation only: it is excluded from the compiled Reach policy and
its digest, and grants no identity or authority. Notices about the model say "the model" rather
than naming a particular one, because the model binding is configured separately.

On the first launch, explicitly register commands for **this policy's guild** and allow your
admitted user to run the read-only inspection command:

Expand Down
2 changes: 2 additions & 0 deletions examples/reach/policy.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ event_ttl_seconds = 300
max_question_chars = 2000
context_message_limit = 0
context_max_age_seconds = 600
# Name the bot uses for itself in fixed notices (errors, refusals). Presentation only.
agent_display_name = "Nadir"

# First matching user-or-role rule wins, including denial of a missing action.
[[rules]]
Expand Down
14 changes: 13 additions & 1 deletion scripts/reach_operator.py
Original file line number Diff line number Diff line change
Expand Up @@ -366,6 +366,11 @@ def load_images(path: Path) -> dict[str, str]:
return {"core": record["core"], "edge": record["edge"]}


def _prefixed_image_id(observed: str) -> str:
"""Podman prints a bare hex image id where Docker prints the ``sha256:`` form."""
return observed if observed.startswith("sha256:") else f"sha256:{observed}"


def _retain_image(engine: str, role: Literal["core", "edge"], identity: str, *, root: Path) -> None:
"""Keep the selected image addressable when a later build replaces its build tag."""
if IMAGE_ID.fullmatch(identity) is None:
Expand All @@ -381,6 +386,7 @@ def _retain_image(engine: str, role: Literal["core", "edge"], identity: str, *,
.decode("ascii")
.strip()
)
observed = _prefixed_image_id(observed)
if observed != identity:
_fail("operator_image_retention")

Expand All @@ -404,6 +410,7 @@ def build_images(engine: str, output: Path, *, root: Path = ROOT) -> None:
.decode()
.strip()
)
core = _prefixed_image_id(core)
if IMAGE_ID.fullmatch(core) is None:
_fail("operator_image_identity")
_retain_image(engine, "core", core, root=root)
Expand All @@ -415,6 +422,10 @@ def build_images(engine: str, output: Path, *, root: Path = ROOT) -> None:
_fail("operator_build_input")
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, target)
# COPY keeps these modes, and the edge runs as the operator's UID rather than the image's
# owner, so a private caller umask would leave its source unreadable inside the image.
for path in (context, *context.rglob("*")):
path.chmod(0o755 if path.is_dir() else 0o644)
(context / ".dockerignore").write_text("")
(context / ".containerignore.build").write_text("")
iidfile = context / "image.id"
Expand Down Expand Up @@ -498,7 +509,8 @@ def container_arguments(engine: str, image: str, cidfile: Path, *, offline: bool
"--cpus",
"1",
"--tmpfs",
"/tmp:rw,nosuid,nodev,size=256m", # noqa: S108 - isolated, size-limited container tmpfs
# Podman would otherwise copy the image's own /tmp into this bounded tmpfs and overflow it.
"/tmp:rw,nosuid,nodev,size=256m" + (",notmpcopyup" if engine == "podman" else ""), # noqa: S108
"--user",
f"{os.getuid()}:{os.getgid()}",
]
Expand Down
104 changes: 57 additions & 47 deletions src/lychd/interface/discord/delivery.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,75 +19,85 @@
from lychd.interface.discord.messages import split_message
from lychd.interface.discord.ports import TurnEffects
from lychd.interface.discord.rest import DiscordRestError
from lychd.interface.discord.schemas import DiscordPolicy
from lychd.interface.discord.schemas import DEFAULT_AGENT_DISPLAY_NAME, DiscordPolicy
from lychd.interface.observation import observation_context

_AGENT = "{agent}"


def _named(text: str, agent_name: str) -> str:
"""Insert the configured display name into one fixed notice; it is never a format string."""
return text.replace(_AGENT, agent_name)


# Templates: ``{agent}`` is replaced by the policy's display name when a notice is rendered.
WORKER_FAILURES = {
"budget": (
"My configured context budget was too small for this request. I read the available chat context, "
"but couldn't fit the instructions and selected sources needed to answer. Please narrow the question."
),
"model_unavailable": "The Qwen model service is unavailable. Please try again later; a maintainer should check it.",
"model_timeout": "The Qwen model timed out while preparing the answer. Please try again with a shorter request.",
"model_refused": "The Qwen model refused this request. A maintainer can check its diagnostics.",
"model_unavailable": "The model service is unavailable. Please try again later; a maintainer should check it.",
"model_timeout": "The model timed out while preparing the answer. Please try again with a shorter request.",
"model_refused": "The model refused this request. A maintainer can check its diagnostics.",
"model_protocol": (
"The Qwen model returned a response Nadir couldn't safely use. A maintainer should check the model adapter."
"The model returned a response {agent} couldn't safely use. A maintainer should check the model adapter."
),
"model_failed": ("The Qwen model failed while preparing the answer. A maintainer should check its diagnostics."),
"run_timeout": "Nadir timed out while preparing the answer. Please try again with a shorter request.",
"model_failed": ("The model failed while preparing the answer. A maintainer should check its diagnostics."),
"run_timeout": "{agent} timed out while preparing the answer. Please try again with a shorter request.",
"run_missing": (
"Nadir couldn't find the answer run it started (run_missing). A maintainer should check diagnostics."
"{agent} couldn't find the answer run it started (run_missing). A maintainer should check diagnostics."
),
"run_incomplete": "Nadir's answer run didn't complete (run_incomplete). A maintainer should check diagnostics.",
"run_incomplete": "{agent}'s answer run didn't complete (run_incomplete). A maintainer should check diagnostics.",
"result_binding": (
"Nadir received a mismatched answer result (result_binding). A maintainer should check diagnostics."
"{agent} received a mismatched answer result (result_binding). A maintainer should check diagnostics."
),
"run_failed": (
"An internal processing step failed (run_failed). Please try again later; a maintainer can check diagnostics."
),
"worker_unavailable": "Nadir's answer worker is unavailable or busy. Please try again shortly.",
"worker_unavailable": "{agent}'s answer worker is unavailable or busy. Please try again shortly.",
"permission_denied": (
"Discord denied access to earlier channel messages (403). Ask a maintainer to check Nadir's "
"Discord denied access to earlier channel messages (403). Ask a maintainer to check {agent}'s "
"View Channel and Read Message History permissions."
),
"conversation_not_allowed": (
"Nadir isn't enabled in this channel. Ask a maintainer to add it to the allowed channels."
"{agent} isn't enabled in this channel. Ask a maintainer to add it to the allowed channels."
),
"subject_not_allowed": (
"You're not included in Nadir's allowed user or role list. Ask a maintainer to check your access."
"You're not included in {agent}'s allowed user or role list. Ask a maintainer to check your access."
),
"action_not_allowed": "This type of request isn't enabled for your account or this channel.",
"action_not_supported": "Nadir doesn't support this request type.",
"trigger_not_allowed": "Nadir isn't configured to answer this kind of message here.",
"missing_agent_mention": "Please mention Nadir directly in this message.",
"action_not_supported": "{agent} doesn't support this request type.",
"trigger_not_allowed": "{agent} isn't configured to answer this kind of message here.",
"missing_agent_mention": "Please mention {agent} directly in this message.",
"wrong_platform": "This request came from an unsupported chat platform.",
"wrong_account": "This request came from an account Nadir isn't configured to serve.",
"wrong_space": "Nadir isn't configured for this server or workspace.",
"self_event": "Nadir can't answer a message sent by the bot itself.",
"automated_subject": "Nadir can't answer messages from another bot account.",
"event_expired": "This request expired before Nadir could process it. Please send it again.",
"wrong_account": "This request came from an account {agent} isn't configured to serve.",
"wrong_space": "{agent} isn't configured for this server or workspace.",
"self_event": "{agent} can't answer a message sent by the bot itself.",
"automated_subject": "{agent} can't answer messages from another bot account.",
"event_expired": "This request expired before {agent} could process it. Please send it again.",
"event_from_future": "This request has an invalid future timestamp. Please send it again.",
"question_empty": "Please include a question for Nadir.",
"question_too_large": "This question is over Nadir's configured input limit. Please shorten it.",
"question_invalid": "This question contains text Nadir can't safely process. Please edit and resend it.",
"context_disabled": "Nadir's chat history access isn't enabled for this request.",
"relationship_invalid": "Nadir couldn't verify that this reply belongs to the recent conversation.",
"question_empty": "Please include a question for {agent}.",
"question_too_large": "This question is over {agent}'s configured input limit. Please shorten it.",
"question_invalid": "This question contains text {agent} can't safely process. Please edit and resend it.",
"context_disabled": "{agent}'s chat history access isn't enabled for this request.",
"relationship_invalid": "{agent} couldn't verify that this reply belongs to the recent conversation.",
}


def worker_problem(reason: str | None, *, refused: bool = False) -> str:
def worker_problem(reason: str | None, *, refused: bool = False, agent_name: str = DEFAULT_AGENT_DISPLAY_NAME) -> str:
"""Render only closed worker reasons; never display exception or model text."""
if reason in WORKER_FAILURES:
return WORKER_FAILURES[reason]
return _named(WORKER_FAILURES[reason], agent_name)
if refused:
return (
return _named(
"I can't perform this request (request_refused). "
"Ask a maintainer to check Nadir's channel and access rules."
"Ask a maintainer to check {agent}'s channel and access rules.",
agent_name,
)
return WORKER_FAILURES["run_failed"]
return _named(WORKER_FAILURES["run_failed"], agent_name)


def failure_text(error: Exception) -> str:
def failure_text(error: Exception, *, agent_name: str = DEFAULT_AGENT_DISPLAY_NAME) -> str:
"""Translate known edge failures without exposing provider bodies or exception text."""
if isinstance(error, discord.Forbidden):
code = "http_403"
Expand All @@ -110,48 +120,48 @@ def failure_text(error: Exception) -> str:
code = error_code(error)

messages = {
"http_401": "Discord rejected Nadir's bot authentication (401). A maintainer should check the bot token.",
"http_401": "Discord rejected {agent}'s bot authentication (401). A maintainer should check the bot token.",
"http_403": (
"Discord denied one of Nadir's channel operations (403). Ask a maintainer to check View Channel, "
"Discord denied one of {agent}'s channel operations (403). Ask a maintainer to check View Channel, "
"Read Message History, and Send Messages permissions."
),
"http_404": (
"Discord couldn't find the configured channel or message (404). "
"Ask a maintainer to check the channel binding."
),
"network_error": (
"Nadir couldn't reach Discord. Please try again later; a maintainer should check connectivity."
"{agent} couldn't reach Discord. Please try again later; a maintainer should check connectivity."
),
"timeout": "Nadir timed out while preparing the answer. Please try again with a shorter request.",
"timeout": "{agent} timed out while preparing the answer. Please try again with a shorter request.",
"budget": WORKER_FAILURES["budget"],
"worker_unavailable": WORKER_FAILURES["worker_unavailable"],
}
return messages.get(
code,
fallback = (
f"I couldn't prepare an answer. Please try again later (diagnostic code: {code}). "
"A maintainer can check Nadir's logs.",
"A maintainer can check {agent}'s logs."
)
return _named(messages.get(code, fallback), agent_name)


def result_text(result: ReachWorkerResult) -> str:
def result_text(result: ReachWorkerResult, *, agent_name: str = DEFAULT_AGENT_DISPLAY_NAME) -> str:
"""Render worker answers and precise safe reasons without attempting an empty message."""
if result.text.strip():
return result.text
if result.status == "refused":
return worker_problem(result.reason, refused=True)
return worker_problem(result.reason, refused=True, agent_name=agent_name)
if result.status == "error":
return worker_problem(result.reason)
return worker_problem(result.reason, agent_name=agent_name)
return "I couldn't prepare an answer. Please try again later (diagnostic code: empty_result)."


def context_text(outcome: ContextTurnOutcome) -> str:
def context_text(outcome: ContextTurnOutcome, *, agent_name: str = DEFAULT_AGENT_DISPLAY_NAME) -> str:
"""Render a driver stop here, preserving unrelated native clarifications exactly."""
if outcome.stop is None:
return result_text(outcome.result)
return result_text(outcome.result, agent_name=agent_name)
notices = {
"permission_denied": (
"I couldn't read earlier messages in this channel because Discord denied history access (403). "
"Ask a maintainer to check Nadir's View Channel and Read Message History permissions, then try again."
"Ask a maintainer to check {agent}'s View Channel and Read Message History permissions, then try again."
),
"unavailable": "Earlier messages are unavailable right now. Please quote the relevant message or clarify your question.",
"disabled": "Chat history isn't enabled here. Please quote the relevant message or clarify your question.",
Expand All @@ -160,7 +170,7 @@ def context_text(outcome: ContextTurnOutcome) -> str:
"round_limit": "I've reached the history limit. Please quote the relevant part of the conversation.",
"no_progress": "I couldn't load more relevant messages. Please quote the missing material or clarify your question.",
}
return notices[outcome.stop]
return _named(notices[outcome.stop], agent_name)


class DiscordTurnDelivery:
Expand Down
29 changes: 21 additions & 8 deletions src/lychd/interface/discord/live.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,6 @@
from lychd.compositions.reach.schemas import ReachEvent
from lychd.interface.discord.admin import ChannelSnapshot, GuildSnapshot, snapshot_guild
from lychd.interface.discord.delivery import (
WORKER_FAILURES,
DiscordTurnDelivery,
context_text,
failure_text,
Expand Down Expand Up @@ -213,8 +212,9 @@ def event_from_message( # noqa: PLR0911 - cheap DM, mention and continuation re
or str(message.author.id) not in policy.allowed_dm_user_ids
or message.author.bot
or message.webhook_id is not None
or channel.recipient is None
or channel.recipient.id != message.author.id
# Without a member cache the SDK builds an uncached DM channel with no recipient.
# A non-bot author of a one-to-one DMChannel is then that recipient by construction.
or (channel.recipient is not None and channel.recipient.id != message.author.id)
):
return None
return ReachEvent(
Expand Down Expand Up @@ -593,7 +593,12 @@ async def _request(self, interaction: discord.Interaction, question: str, action
if not self.service.eligible(event):
decision = prepare_turn(self.service.shared_policy, event, now=datetime.now(UTC))
await interaction.response.send_message(
worker_problem(decision.reason if not decision.allowed else None, refused=True), ephemeral=True
worker_problem(
decision.reason if not decision.allowed else None,
refused=True,
agent_name=self.service.policy.agent_display_name,
),
ephemeral=True,
)
return
session = self.service.session(event)
Expand All @@ -606,11 +611,14 @@ async def _request(self, interaction: discord.Interaction, question: str, action
try:
outcome = await self.service.run_event(session, interaction.channel, progress=delivery.plan)
if outcome is not None and session.visible():
await delivery.final(context_text(outcome), result=outcome.result)
await delivery.final(
context_text(outcome, agent_name=self.service.policy.agent_display_name),
result=outcome.result,
)
except (ValueError, OSError, TimeoutError, discord.HTTPException, DiscordRestError) as error:
diagnostic("request_failed")
if session.can_report_failure():
await delivery.final(failure_text(error))
await delivery.final(failure_text(error, agent_name=self.service.policy.agent_display_name))
except TimeoutError:
diagnostic("request_expired")
finally:
Expand Down Expand Up @@ -689,7 +697,12 @@ async def on_message(self, message: discord.Message) -> None:
if not session.visible():
return
result = outcome.result if outcome is not None else None
text = context_text(outcome) if outcome is not None else WORKER_FAILURES["run_failed"]
agent_name = self.service.policy.agent_display_name
text = (
context_text(outcome, agent_name=agent_name)
if outcome is not None
else worker_problem(None, agent_name=agent_name)
)
delivered = await delivery.final(text, result=result)
if delivered and result is not None:
await delivery.expressions(result)
Expand All @@ -699,7 +712,7 @@ async def on_message(self, message: discord.Message) -> None:
if session.can_report_failure():
if event.trigger == "message":
await self.service.replies.bind_conversation(event)
await delivery.final(failure_text(error))
await delivery.final(failure_text(error, agent_name=self.service.policy.agent_display_name))
except TimeoutError:
diagnostic("request_expired")
finally:
Expand Down
Loading
Loading