[agents] Fork completed agent session scrobbles on follow-up
This commit is contained in:
@ -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
|
||||
|
||||
@ -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"}
|
||||
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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):
|
||||
|
||||
@ -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", "")
|
||||
|
||||
@ -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 %}">← Forked from a previous session</a>
|
||||
</p>
|
||||
{% endif %}
|
||||
<h3 class="text-muted">{{ object.local_timestamp }}</h3>
|
||||
|
||||
{% if user.is_authenticated and object.user == user %}
|
||||
|
||||
Reference in New Issue
Block a user