Skip to content
Open
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
1 change: 1 addition & 0 deletions src/sentry/seer/agent/client_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,7 @@ class RepoPRState(BaseModel):
title: str | None = None
description: str | None = None
integration_id: str | None = None
idempotency_key: str | None = Field(default=None, exclude=True)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

this allows us to give each PR push attempt its own unique key, so we can send "out of credits" message again if the PR push fails, and user @ mentions again to do a PR push.


class Config:
extra = "ignore"
Expand Down
58 changes: 57 additions & 1 deletion src/sentry/seer/entrypoints/slack/entrypoint.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from sentry.notifications.platform.service import NotificationService
from sentry.notifications.platform.templates.seer import (
SeerAgentError,
SeerAgentOutOfCredits,
SeerAgentResponse,
SeerAgentWriteApproval,
SeerAutofixError,
Expand Down Expand Up @@ -798,6 +799,33 @@ def on_agent_update(
},
)

replies = SlackSeerAgentReplyCache.get(run_id=run_id)
# Keyed by push: a failed retry leaves the PR's number and commit unchanged.
pr_keys = [
key
for pr in _get_repo_pr_states(state)
if _is_pr_out_of_credits(pr)
and (key := f"pr_out_of_credits:{pr.repo_name}:{pr.idempotency_key}") not in replies

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

🤖 Reply-cache docstring doesn't list the new out-of-credits keys

The docstring on SlackSeerAgentReplyCache in cache.py (lines 68–72, outside this diff) lists every reply key: the answer's block id, approval:<input id>, approval_dm:<input id>, and error. It describes itself as the only record of what was posted. This PR adds out_of_credits:<turn's first block id> and pr_out_of_credits:<repo>:<push key>. Add them to that list so the next person changing dedupe logic sees every key kind in one place.

Written by an AI agent and included in this review by its author.

]
if pr_keys:
# A turn gets one notice in total, shared by its failed PRs and the run itself.
turn_key = _out_of_credits_key(state)
notice = replies.get(turn_key)
message_ts = (
notice["message_ts"]
if notice
else send_thread_update(
install=install,
thread=thread,
data=_agent_out_of_credits(cache_payload),
)
)
if message_ts:
for key in (turn_key, *pr_keys):
SlackSeerAgentReplyCache.set(
run_id=run_id, key=key, reply=SlackSeerAgentReply(message_ts=message_ts)
)

replies = SlackSeerAgentReplyCache.get(run_id=run_id)
answers = [answer for turn in state.get_turns() if (answer := turn.answer) is not None]
latest = next(
Expand Down Expand Up @@ -847,6 +875,28 @@ def _get_repo_pr_states(state: SeerRunState) -> list[RepoPRState]:
return [state.repo_pr_states[repo] for repo in sorted(state.repo_pr_states)]


def _agent_out_of_credits(cache_payload: SlackAgentCachePayload) -> SeerAgentOutOfCredits:
"""The out-of-credits notice, written for whoever sent the message that started the run."""
return SeerAgentOutOfCredits(
organization_id=cache_payload["organization_id"],
has_billing_access=slack_user_has_billing_access(
integration_id=cache_payload["integration_id"],
organization_id=cache_payload["organization_id"],
slack_user_id=cache_payload.get("slack_user_id"),
),
)


def _is_pr_out_of_credits(pr: RepoPRState) -> bool:
return pr.pr_creation_status == "error" and pr.pr_creation_error_code == OUT_OF_CREDITS


def _out_of_credits_key(state: SeerRunState) -> str:
"""The reply key of the latest turn's out-of-credits notice, shared by the run and its PRs."""
turns = state.get_turns()
return f"out_of_credits:{turns[-1].blocks[0].id}" if turns else "out_of_credits"


def _hash_pr_status(prs: Sequence[RepoPRState]) -> str:
payload = orjson.dumps(
[
Expand All @@ -865,7 +915,7 @@ def _next_agent_reply(
cache_payload: SlackAgentCachePayload,
state: SeerRunState,
replies: Mapping[str, SlackSeerAgentReply],
) -> tuple[str, SeerAgentError | SeerAgentResponse] | None:
) -> tuple[str, SeerAgentError | SeerAgentResponse | SeerAgentOutOfCredits] | None:
"""The reply to post and its cache key, or None if the thread is up to date.

Sends and records the write approval DM when a new approval is pending.
Expand Down Expand Up @@ -922,6 +972,12 @@ def _next_agent_reply(
"I sent you a message."
)
repo_pr_states: list[RepoPRState] = []
elif state.failure_reason == OUT_OF_CREDITS:
# Replace Seer's plain-text notice with the out-of-credits notification.
key = _out_of_credits_key(state)
if key in replies:
return None
return key, _agent_out_of_credits(cache_payload)
else:
turn = next((turn for turn in reversed(state.get_turns()) if turn.answer), None)
if turn is None or turn.answer is None:
Expand Down
41 changes: 39 additions & 2 deletions src/sentry/seer/entrypoints/slack/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
from sentry.integrations.services.integration.service import integration_service
from sentry.models.organization import Organization
from sentry.notifications.platform.slack.provider import SlackRenderable
from sentry.notifications.platform.templates.seer import SeerAgentOutOfCredits
from sentry.seer.credits import SeerFeature, SeerOperation, can_run_seer_operation
from sentry.seer.entrypoints.metrics import (
SlackEntrypointEventLifecycleMetric,
Expand All @@ -25,7 +26,12 @@
SlackSeerAgentResponded,
)
from sentry.seer.entrypoints.slack.cache import SlackSeerAgentMessageCache
from sentry.seer.entrypoints.slack.entrypoint import EntrypointSetupError, SlackAgentEntrypoint
from sentry.seer.entrypoints.slack.entrypoint import (
EntrypointSetupError,
SlackAgentEntrypoint,
SlackThreadDetails,
has_billing_access,
)
from sentry.seer.entrypoints.slack.mention import (
SlackMessageLink,
build_linked_messages_context,
Expand All @@ -37,6 +43,7 @@
from sentry.seer.entrypoints.slack.messaging import (
send_halt_message,
send_not_org_member_message,
send_thread_update,
)
from sentry.seer.entrypoints.slack.metrics import (
ProcessMentionFailureReason,
Expand Down Expand Up @@ -161,11 +168,17 @@ def process_mention_for_slack(
)
return

# Check credits before fetching context or starting a run.
# Check credits up front so an org without them gets an answer right away.
if not can_run_seer_operation(
organization, SeerFeature.EXPLORER, SeerOperation.AGENT_CHAT_MESSAGE
):
lifecycle.record_halt(ProcessMentionHaltReason.OUT_OF_SEER_CREDITS)
_send_out_of_credits_message(
entrypoint=entrypoint,
organization=organization,
user=user,
thread_ts=thread_ts or ts,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

I'm not sure if this is true or not

🤖 Top-level @-mentions likely never see the ephemeral out-of-credits reply

For a top-level @-mention, thread_ts is None. The ephemeral therefore goes out with thread_ts=ts, into a thread on the user's own message that has no replies yet. Slack's chat.postEphemeral docs say ephemeral messages in threads only show if the thread is already active. This path returns before anything public is posted, so that thread never becomes active. The person who mentioned @sentry would see the "is thinking..." status clear and nothing else. That's case 1 in the description, and probably the most common one.

The halt paths a few lines up already handle this with thread_ts if thread_ts else "" and the comment "In a thread, show the prompt in the thread; top-level, show in the channel". Doing the same here posts the notice in the channel for top-level mentions:

Suggested change
thread_ts=thread_ts or ts,
thread_ts=thread_ts if thread_ts else "",

(_send_out_of_credits_message's docstring says "in the thread", so it would need a small tweak too.)

TASK_KWARGS always sets thread_ts, so the new tests only cover mentions inside a thread. Add a case with thread_ts=None that asserts the ephemeral is sent with thread_ts="". If you've already seen this render for a top-level mention, ignore this comment.

Written by an AI agent and included in this review by its author.

)
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
# The webhook set "is thinking..." before queueing this task; clear it.
entrypoint.install.set_thread_status(
channel_id=channel_id,
Expand Down Expand Up @@ -408,6 +421,30 @@ def _resolve_linked_messages(
)


def _send_out_of_credits_message(
*,
entrypoint: SlackAgentEntrypoint,
organization: Organization,
user: RpcUser,
thread_ts: str,
) -> None:
"""Tell the user, in the thread and only to them, that the org is out of Seer credits."""
try:
send_thread_update(
install=entrypoint.install,
thread=SlackThreadDetails(thread_ts=thread_ts, channel_id=entrypoint.channel_id),
data=SeerAgentOutOfCredits(
organization_id=organization.id,
has_billing_access=has_billing_access(
organization_id=organization.id, user_id=user.id
),
),
ephemeral_user_id=entrypoint.slack_user_id,
)
except Exception as e:
_logger.warning("seer.slack.process_mention.send_out_of_credits_failed", exc_info=e)


def _send_inaccessible_links_prompt(
*,
entrypoint: SlackAgentEntrypoint,
Expand Down
Loading
Loading