[agents] Fork completed agent session scrobbles on follow-up
All checks were successful
ci / test (push) Successful in 2m15s
ci / build-and-deploy (push) Has been skipped

This commit is contained in:
2026-08-24 16:56:01 -04:00
parent 631b68ef55
commit 8f63fb27ba
7 changed files with 135 additions and 10 deletions

View File

@ -1105,8 +1105,8 @@ def test_agent_session_detail_view(mock_delay, client, user):
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
def test_manual_scrobble_agent_follow_up_appends_turn(mock_delay, user):
scrobble = _mk_scrobble(
def test_manual_scrobble_agent_follow_up_forks_completed_session(mock_delay, user):
original = _mk_scrobble(
user,
in_progress=False,
turns=[
@ -1118,6 +1118,42 @@ def test_manual_scrobble_agent_follow_up_appends_turn(mock_delay, user):
],
)
result = manual_scrobble_agent_follow_up("tell me more", original.id, user.id)
assert result.id != original.id
assert result.agent_session_id == original.agent_session_id
assert result.in_progress is True
assert result.log["title"] == original.log["title"]
assert result.log["forked_from_id"] == original.id
original.refresh_from_db()
assert len(original.log["turns"]) == 1
assert original.in_progress is False
turns = result.log["turns"]
assert len(turns) == 2
assert turns[0] == original.log["turns"][0]
assert turns[1]["prompt"] == "tell me more"
assert turns[1]["response"] is None
mock_delay.assert_called_once_with(result.id, turns[1]["prompt_id"])
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
def test_manual_scrobble_agent_follow_up_appends_turn_when_in_progress(
mock_delay, user
):
scrobble = _mk_scrobble(
user,
in_progress=True,
turns=[
{
"prompt_id": "abc-123",
"prompt": "hello",
"response": "hi back",
}
],
)
result = manual_scrobble_agent_follow_up("tell me more", scrobble.id, user.id)
assert result.id == scrobble.id
@ -1127,6 +1163,7 @@ def test_manual_scrobble_agent_follow_up_appends_turn(mock_delay, user):
assert turns[1]["prompt"] == "tell me more"
assert turns[1]["response"] is None
assert scrobble.in_progress is True
assert "forked_from_id" not in scrobble.log
mock_delay.assert_called_once_with(scrobble.id, turns[1]["prompt_id"])
@ -1137,8 +1174,8 @@ def test_manual_scrobble_agent_follow_up_unknown_scrobble(mock_delay, user):
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
def test_agent_session_followup_post_appends_turn(mock_delay, client, user):
scrobble = _mk_scrobble(
def test_agent_session_followup_post_forks_completed_session(mock_delay, client, user):
original = _mk_scrobble(
user,
in_progress=False,
turns=[
@ -1150,12 +1187,48 @@ def test_agent_session_followup_post_appends_turn(mock_delay, client, user):
],
)
client.force_login(user)
response = client.post(
reverse("scrobbles:agent-session-followup", args=[original.id]),
{"prompt": "tell me more"},
)
assert response.status_code == 200
assert response["HX-Redirect"] == reverse(
"scrobbles:detail", args=[response.context["object"].id]
)
forked = response.context["object"]
assert forked.id != original.id
assert b"tell me more" in response.content
assert b"Thinking" in response.content
assert b"every 5s" in response.content
original.refresh_from_db()
assert len(original.log["turns"]) == 1
assert original.in_progress is False
@patch("scrobbles.tasks.scrobble_agent_session_prompt.delay")
def test_agent_session_followup_post_appends_turn_when_in_progress(
mock_delay, client, user
):
scrobble = _mk_scrobble(
user,
in_progress=True,
turns=[
{
"prompt_id": "abc-123",
"prompt": "hello",
"response": "hi back",
}
],
)
client.force_login(user)
response = client.post(
reverse("scrobbles:agent-session-followup", args=[scrobble.id]),
{"prompt": "tell me more"},
)
assert response.status_code == 200
assert "HX-Redirect" not in response
assert b"tell me more" in response.content
assert b"Thinking" in response.content
assert b"every 5s" in response.content

View File

@ -50,6 +50,7 @@ class AgentSessionLogData(BaseLogData):
provider: Optional[str] = None
model: Optional[str] = None
turns: Optional[list] = None
forked_from_id: Optional[int] = None
_excluded_fields = {"turns"}

View File

@ -1,6 +1,7 @@
import logging
import re
import textwrap
from copy import deepcopy
from datetime import datetime, timedelta
from typing import Any, Optional
from uuid import uuid4
@ -1757,12 +1758,45 @@ def manual_scrobble_agent_session(
return scrobble
def _fork_agent_session_scrobble(scrobble, prompt: str, provider: str, model: str):
"""Copy a completed session into a new scrobble for a follow-up turn.
The new scrobble keeps the same agent model, title and prior turns so the
follow-up retains conversation context, while the original completed
scrobble is left untouched.
"""
new_scrobble = Scrobble.create_or_update(
scrobble.agent_session,
scrobble.user_id,
{
"user_id": scrobble.user_id,
"timestamp": timezone.now(),
"playback_position_seconds": 0,
"source": scrobble.source or "Vrobbler",
},
skip_in_progress_check=True,
)
log = deepcopy(scrobble.log if isinstance(scrobble.log, dict) else {})
log["forked_from_id"] = scrobble.id
new_scrobble.log = log
new_scrobble.save(update_fields=["log"])
prompt_id = _append_agent_turn(
new_scrobble, prompt, provider, model, set_in_progress=True
)
return new_scrobble, prompt_id
def manual_scrobble_agent_follow_up(
prompt: str,
scrobble_id: int,
user_id: int,
):
"""Append a follow-up turn to a specific agent session scrobble."""
"""Append a follow-up turn to a specific agent session scrobble.
Active (in-progress) sessions are extended in place. Completed sessions are
forked into a new scrobble that carries the prior conversation, so the new
response lands in a fresh session instead of resurrecting the old one.
"""
from django.conf import settings
scrobble = Scrobble.objects.filter(id=scrobble_id, user_id=user_id).first()
@ -1775,9 +1809,14 @@ def manual_scrobble_agent_follow_up(
settings.LLM_MODEL if provider in ("gemini", "openrouter") else provider
)
prompt_id = _append_agent_turn(
scrobble, prompt, provider, model, set_in_progress=True
)
if scrobble.in_progress:
prompt_id = _append_agent_turn(
scrobble, prompt, provider, model, set_in_progress=True
)
else:
scrobble, prompt_id = _fork_agent_session_scrobble(
scrobble, prompt, provider, model
)
logger.info(
"[scrobblers] manual agent session follow-up received",

View File

@ -39,7 +39,7 @@ SCROBBLES_WITHOUT_CHARTS = [
"geolocation",
]
AGENT_SESSION_AUTO_COMPLETE_SECONDS = 600
AGENT_SESSION_AUTO_COMPLETE_SECONDS = settings.AGENT_SESSION_AUTO_COMPLETE_SECONDS
@shared_task

View File

@ -454,12 +454,16 @@ class AgentSessionFollowUpView(LoginRequiredMixin, View):
messages.error(request, "Follow-up prompt is empty.")
return HttpResponseRedirect(scrobble.get_absolute_url())
original_id = scrobble.id
scrobble = manual_scrobble_agent_follow_up(prompt, scrobble.id, request.user.id)
return TemplateResponse(
response = TemplateResponse(
request,
self.template_name,
context={"object": scrobble},
)
if scrobble and scrobble.id != original_id:
response["HX-Redirect"] = scrobble.get_absolute_url()
return response
class AgentSessionRetryView(LoginRequiredMixin, View):

View File

@ -127,6 +127,9 @@ AGENT_PROVIDER_RETRY_BACKOFF_BASE = int(
AGENT_PROVIDER_RETRY_MAX_BACKOFF = int(
os.getenv("VROBBLER_AGENT_PROVIDER_RETRY_MAX_BACKOFF", "300")
)
AGENT_SESSION_AUTO_COMPLETE_SECONDS = int(
os.getenv("VROBBLER_AGENT_SESSION_AUTO_COMPLETE_SECONDS", "1200")
)
AMAZON_PAAPI_ACCESS_KEY = os.getenv("VROBBLER_AMAZON_PAAPI_ACCESS_KEY", "")
AMAZON_PAAPI_SECRET_KEY = os.getenv("VROBBLER_AMAZON_PAAPI_SECRET_KEY", "")

View File

@ -88,6 +88,11 @@
</p>
<h2>{{ object.logdata.title }}</h2>
{% endif %}
{% if object.media_type == "AgentSession" and object.logdata.forked_from_id %}
<p class="text-muted small mb-1">
<a href="{% url 'scrobbles:detail' object.logdata.forked_from_id %}">&larr; Forked from a previous session</a>
</p>
{% endif %}
<h3 class="text-muted">{{ object.local_timestamp }}</h3>
{% if user.is_authenticated and object.user == user %}