|
24 | 24 | from uuid import UUID, uuid4 |
25 | 25 |
|
26 | 26 | from django.conf import settings |
27 | | -from django.db import IntegrityError, transaction |
| 27 | +from django.db import IntegrityError, close_old_connections, transaction |
28 | 28 | from django.db.models import CharField, Count, Exists, F, Min, OuterRef, Q, QuerySet, Subquery |
29 | 29 | from django.db.models.fields.json import KeyTextTransform |
30 | 30 | from django.utils import timezone as django_timezone |
|
49 | 49 | is_custom_images_enabled, |
50 | 50 | read_spec_from_builder_sandbox, |
51 | 51 | ) |
52 | | -from products.tasks.backend.mentions import resolve_mentioned_user_ids |
| 52 | +from products.tasks.backend.mentions import format_mention_token, resolve_mentioned_user_ids |
53 | 53 | from products.tasks.backend.models import ( |
54 | 54 | Channel, |
55 | 55 | ChannelFeedMessage, |
@@ -4863,6 +4863,9 @@ def _thread_message_to_dto(message: TaskThreadMessage) -> contracts.TaskThreadMe |
4863 | 4863 | return contracts.TaskThreadMessageDTO( |
4864 | 4864 | id=message.id, |
4865 | 4865 | task=message.task_id, |
| 4866 | + author_kind=message.author_kind, |
| 4867 | + event=message.event, |
| 4868 | + payload=message.payload or {}, |
4866 | 4869 | content=message.content, |
4867 | 4870 | created_at=message.created_at, |
4868 | 4871 | author=_user_basic_info(message.author if message.author_id else None), |
@@ -5018,6 +5021,127 @@ def forward_thread_message( |
5018 | 5021 | return "ok", _thread_message_to_dto(message) |
5019 | 5022 |
|
5020 | 5023 |
|
| 5024 | +# Threads are a Channels (project-bluebird) surface, so agent-authored thread |
| 5025 | +# updates are gated on the same flag — evaluated for the task creator. |
| 5026 | +AGENT_THREAD_UPDATES_FLAG = "project-bluebird" |
| 5027 | + |
| 5028 | +# One turn-complete post per run within the window, so an SSE relay reconnect |
| 5029 | +# replaying the tail of the stream can't double-post the same end-of-turn. |
| 5030 | +_TURN_COMPLETE_COOLDOWN_SECONDS = 30 |
| 5031 | + |
| 5032 | +# Cap the relayed final message so one agent essay can't dwarf the thread. |
| 5033 | +_TURN_MESSAGE_MAX_CHARS = 4000 |
| 5034 | + |
| 5035 | + |
| 5036 | +def _create_agent_thread_message(task: Task, content: str, *, event: str, payload: dict | None = None) -> None: |
| 5037 | + """Write an agent-authored thread message and index its mentions. |
| 5038 | +
|
| 5039 | + ``content`` is the rendered text (older clients show it as-is); ``event`` + |
| 5040 | + ``payload`` are the structured record, mirroring ChannelFeedMessage, that |
| 5041 | + lets clients render agent rows natively and dedupe them against live views. |
| 5042 | + """ |
| 5043 | + message = TaskThreadMessage.objects.create( |
| 5044 | + team_id=task.team_id, |
| 5045 | + task_id=task.id, |
| 5046 | + author_id=None, |
| 5047 | + author_kind=TaskThreadMessage.AuthorKind.AGENT, |
| 5048 | + event=event, |
| 5049 | + payload=payload or {}, |
| 5050 | + content=content, |
| 5051 | + ) |
| 5052 | + try: |
| 5053 | + _index_thread_message_mentions(message) |
| 5054 | + except Exception: |
| 5055 | + logger.exception("Failed to index thread message mentions", extra={"message_id": str(message.id)}) |
| 5056 | + |
| 5057 | + |
| 5058 | +def _agent_thread_updates_enabled(creator: User | None) -> bool: |
| 5059 | + """Fail closed: no creator to key the flag on, or a flag-service error, means no post.""" |
| 5060 | + if creator is None: |
| 5061 | + return False |
| 5062 | + distinct_id = creator.distinct_id or f"user_{creator.id}" |
| 5063 | + try: |
| 5064 | + return bool( |
| 5065 | + posthoganalytics.feature_enabled(AGENT_THREAD_UPDATES_FLAG, distinct_id, send_feature_flag_events=False) |
| 5066 | + ) |
| 5067 | + except Exception: |
| 5068 | + logger.warning("Agent thread update flag check failed", extra={"user_id": creator.id}, exc_info=True) |
| 5069 | + return False |
| 5070 | + |
| 5071 | + |
| 5072 | +def post_canvas_created_thread_update( |
| 5073 | + task_id: str | UUID, team_id: int, *, acting_user_id: int | None, canvas_name: str, canvas_url: str | None |
| 5074 | +) -> None: |
| 5075 | + """Announce a freshly created canvas in the generating task's thread. |
| 5076 | +
|
| 5077 | + Posts "[name](url) has been created" as an agent message. Called on a canvas's |
| 5078 | + first publish only — the caller owns that once-guard. ``acting_user_id`` must be |
| 5079 | + the task's creator: the sandbox publishes with the creator's credentials, so this |
| 5080 | + binds the attributed task to the caller's identity — a same-team caller can't |
| 5081 | + plant agent messages in someone else's task thread by naming its id. Best-effort |
| 5082 | + and never raises: the publish must not fail because its announcement couldn't |
| 5083 | + be written. |
| 5084 | + """ |
| 5085 | + try: |
| 5086 | + task = Task.objects.select_related("created_by").filter(id=task_id, team_id=team_id).first() |
| 5087 | + if task is None or task.created_by_id is None or task.created_by_id != acting_user_id: |
| 5088 | + return |
| 5089 | + if not _agent_thread_updates_enabled(task.created_by): |
| 5090 | + return |
| 5091 | + # Brackets and newlines in the name would break the [label](url) token. |
| 5092 | + name = re.sub(r"[\[\]\n]", " ", canvas_name).strip() or "Canvas" |
| 5093 | + content = f"[{name}]({canvas_url}) has been created" if canvas_url else f"{name} has been created" |
| 5094 | + _create_agent_thread_message( |
| 5095 | + task, |
| 5096 | + content, |
| 5097 | + event="canvas_created", |
| 5098 | + payload={"canvas_name": name, "canvas_url": canvas_url}, |
| 5099 | + ) |
| 5100 | + except Exception: |
| 5101 | + logger.exception("Failed to post canvas-created thread update", extra={"task_id": str(task_id)}) |
| 5102 | + |
| 5103 | + |
| 5104 | +def post_turn_complete_thread_update( |
| 5105 | + run_id: str | UUID, task_id: str | UUID, team_id: int, *, message: str | None = None |
| 5106 | +) -> None: |
| 5107 | + """Post the agent's final turn message into the task's thread, @-mentioning the task creator. |
| 5108 | +
|
| 5109 | + Fires from the sandbox event relay on every end-of-turn of a channel task's |
| 5110 | + background run, so the update lands even with no client open. ``message`` is |
| 5111 | + the agent's closing prose for the turn; when the relay captured none, a plain |
| 5112 | + "Turn complete." stands in. Best-effort and never raises — a failed post must |
| 5113 | + not disturb the relay. |
| 5114 | + """ |
| 5115 | + try: |
| 5116 | + if not settings.TEST: |
| 5117 | + close_old_connections() |
| 5118 | + task = Task.objects.select_related("created_by").filter(id=task_id, team_id=team_id).first() |
| 5119 | + # Threads hang off a task's channel feed; a channel-less task has no audience. |
| 5120 | + if task is None or task.channel_id is None: |
| 5121 | + return |
| 5122 | + creator = task.created_by |
| 5123 | + if creator is None or not _agent_thread_updates_enabled(creator): |
| 5124 | + return |
| 5125 | + from products.tasks.backend.redis import get_tasks_cache # noqa: PLC0415 — keep redis off the api import path |
| 5126 | + |
| 5127 | + if not get_tasks_cache().add(f"thread_update:{run_id}:turn_complete", True, _TURN_COMPLETE_COOLDOWN_SECONDS): |
| 5128 | + return |
| 5129 | + body = (message or "").strip() or "Turn complete." |
| 5130 | + if len(body) > _TURN_MESSAGE_MAX_CHARS: |
| 5131 | + body = body[: _TURN_MESSAGE_MAX_CHARS - 1] + "…" |
| 5132 | + mention = format_mention_token(creator.get_full_name() or creator.email, creator.email) |
| 5133 | + # payload.run_id is the dedupe key: a client already rendering this run's |
| 5134 | + # live agent turns can suppress the durable row (or vice versa). |
| 5135 | + _create_agent_thread_message( |
| 5136 | + task, |
| 5137 | + f"{mention} {body}", |
| 5138 | + event="turn_complete", |
| 5139 | + payload={"run_id": str(run_id)}, |
| 5140 | + ) |
| 5141 | + except Exception: |
| 5142 | + logger.exception("Failed to post turn-complete thread update", extra={"task_id": str(task_id)}) |
| 5143 | + |
| 5144 | + |
5021 | 5145 | def respond_to_permission_request( |
5022 | 5146 | run_id: str | UUID, |
5023 | 5147 | task_id: str | UUID, |
|
0 commit comments