Repository navigation
feat(slack): Reply to @-mentions when Seer runs out of credits #130440
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
3d5b647
9cb5388
cd74b22
38ea1f0
3172d5d
46400a4
13ec451
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -23,6 +23,7 @@ | |
| from sentry.notifications.platform.service import NotificationService | ||
| from sentry.notifications.platform.templates.seer import ( | ||
| SeerAgentError, | ||
| SeerAgentOutOfCredits, | ||
| SeerAgentResponse, | ||
| SeerAgentWriteApproval, | ||
| SeerAutofixError, | ||
|
|
@@ -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 | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🤖 Reply-cache docstring doesn't list the new out-of-credits keysThe docstring on 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( | ||
|
|
@@ -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( | ||
| [ | ||
|
|
@@ -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. | ||
|
|
@@ -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: | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -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, | ||||||
|
|
@@ -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, | ||||||
|
|
@@ -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, | ||||||
|
|
@@ -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, | ||||||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 replyFor a top-level @-mention, The halt paths a few lines up already handle this with
Suggested change
(
Written by an AI agent and included in this review by its author. |
||||||
| ) | ||||||
|
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, | ||||||
|
|
@@ -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, | ||||||
|
|
||||||
There was a problem hiding this comment.
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.