openenv-support / tasks /task4_multiturn.py
Mahir
Fix Phase 2: clamp all grader scores to (0.0001, 0.9999) exclusive
1a24bd9
Raw
History Blame Contribute Delete
8.54 kB
"""
Task 4: Multi-Turn Conversation Resolution (VERY HARD)
Agent must maintain context across up to 8 back-and-forth dialogue turns with an increasingly agitated customer.
Tests context-window retention, empathetic de-escalation, and appropriate routing/escalation thresholds.
"""
from app.models import (
Action, Observation, Reward, Ticket, AgentAction, TicketCategory, TicketPriority
)
from typing import Dict, Any, Tuple, List
import uuid
import random
CONVERSATION_SCENARIOS = [
{
"category": TicketCategory.TECHNICAL,
"initial": "My API is returning 500 errors",
"followups": [
"I already tried restarting the server, still broken.",
"This is taking too long to resolve! I need this fixed NOW or we are moving providers.",
"Can I just speak to a senior engineer or your manager?",
"Hello??? Are you still there diagnosing this?"
],
"resolution_criteria": {
"target_turn": 3, # Should escalate by turn 3 (when asking for manager)
"must_escalate": True,
}
},
{
"category": TicketCategory.BILLING,
"initial": "I was charged $49.99 but my subscription should be on the free tier.",
"followups": [
"No, I didn't upgrade by accident. The UI just charged me when I clicked 'view plans'.",
"Are you calling me a liar? I demand a refund right now.",
"I will file a chargeback with my bank if you don't process this refund.",
"Fine. Thank you for issuing the refund."
],
"resolution_criteria": {
"target_turn": 4,
"must_resolve": True, # Should resolve it, not escalate
"must_escalate": False,
}
},
{
"category": TicketCategory.FEATURE_REQUEST,
"initial": "How do I connect this to Zapier?",
"followups": [
"What do you mean you don't have Zapier? Your pricing page says '100+ integrations'.",
"This is false advertising. I want to talk to sales about a custom integration.",
"If sales can't do it, give me a webhook workaround.",
"Okay, the webhook workaround works. You can close this."
],
"resolution_criteria": {
"target_turn": 4,
"must_resolve": True,
"must_escalate": False,
}
},
{
"category": TicketCategory.UNKNOWN,
"initial": "It's not working.",
"followups": [
"The app.",
"When I open it, it just gives me a white screen.",
"I am on iOS 17. Yes, I tried deleting and reinstalling.",
"Wait, it just started working after I turned off my VPN. Nevermind!"
],
"resolution_criteria": {
"target_turn": 4,
"must_resolve": True,
"must_escalate": False,
}
},
{
"category": TicketCategory.ABUSE,
"initial": "I'm being harassed by a user on your platform.",
"followups": [
"Their username is @badactor123.",
"They are sending me hundreds of spam messages a minute. Block them!",
"Thank you. Is my account safe now?",
"Great, thanks for the quick help."
],
"resolution_criteria": {
"target_turn": 4,
"must_resolve": True,
"must_escalate": False,
}
}
]
class MultiTurnTask:
TASK_ID = "multi_turn_conversation"
MAX_TURNS = 8
def __init__(self):
self.episode_id: str = ""
self.step_count: int = 0
self.scenario: dict = {}
self.history: List[Dict[str, str]] = []
self.conversation_turn: int = 0
self.ticket: Ticket = None
self.status = "ongoing" # ongoing, escalated, resolved, failed
self.score = 0.0
def reset(self) -> Observation:
self.episode_id = str(uuid.uuid4())
self.step_count = 0
self.conversation_turn = 0
self.history = []
self.status = "ongoing"
self.score = 0.0
self.scenario = random.choice(CONVERSATION_SCENARIOS)
self.ticket = Ticket(
ticket_id=f"TKT-MT-{uuid.uuid4().hex[:6].upper()}",
subject="Assistance Required",
body=self.scenario["initial"],
customer_id="CUST-MULTI",
customer_tier="pro",
created_at="2026-01-01T12:00:00Z",
sla_deadline="2026-01-02T12:00:00Z",
category=self.scenario["category"],
priority=TicketPriority.P2_HIGH
)
self.history.append({"role": "customer", "content": self.scenario["initial"]})
return self._make_observation()
def step(self, action: Action) -> Tuple[Observation, Reward, bool, Dict[str, Any]]:
self.step_count += 1
reward = 0.0
if action.action_type == AgentAction.ESCALATE:
if self.scenario["resolution_criteria"].get("must_escalate", False):
# Correct escalation
if self.conversation_turn == self.scenario["resolution_criteria"]["target_turn"] - 1:
reward = 1.0 # Perfect timing
else:
reward = 0.5 # Right action, wrong timing
self.status = "escalated_correctly"
else:
reward = -0.5 # Unnecessary escalation
self.status = "failed"
done = True
elif action.action_type == AgentAction.RESOLVE or action.action_type == AgentAction.CLOSE:
if self.scenario["resolution_criteria"].get("must_resolve", True):
if self.conversation_turn >= self.scenario["resolution_criteria"]["target_turn"] - 1:
reward = 1.0
else:
reward = 0.0 # Closed too early
else:
reward = -0.5 # Resolved instead of escalating
self.status = "resolved"
done = True
elif action.action_type == AgentAction.DRAFT_RESPONSE:
response = action.response_text or ""
self.history.append({"role": "agent", "content": response})
if self.conversation_turn < len(self.scenario["followups"]):
followup = self.scenario["followups"][self.conversation_turn]
self.history.append({"role": "customer", "content": followup})
self.ticket.body = followup # Update current message
self.conversation_turn += 1
# Small step reward for keeping the conversation going without failing
reward = 0.1
done = False
else:
# Customer stopped responding, resolution achieved implicitly
if self.scenario["resolution_criteria"].get("must_resolve", True):
reward = 1.0
else:
reward = -0.5 # Should have escalated
self.status = "resolved"
done = True
else:
reward = -0.1
done = False
self.ticket.previous_interactions = self.history.copy()
if done:
self.score = reward
obs = self._make_observation(done=done)
return obs, Reward(total=reward), done, {"status": self.status, "turn": self.conversation_turn}
def state(self) -> Dict[str, Any]:
return {
"task_id": self.TASK_ID,
"step": self.step_count,
"turn": self.conversation_turn,
"history_length": len(self.history)
}
def grader_score(self) -> Dict[str, Any]:
return {
"task_id": self.TASK_ID,
"episode_id": self.episode_id,
"final_score": max(0.0001, min(0.9999, self.score)),
"passed": self.score >= 0.7,
"metrics": {
"turns_survived": self.conversation_turn,
"end_status": self.status,
}
}
def _make_observation(self, done: bool = False) -> Observation:
return Observation(
task_id=self.TASK_ID,
step=self.step_count,
current_ticket=self.ticket,
valid_actions=[AgentAction.DRAFT_RESPONSE, AgentAction.ESCALATE, AgentAction.RESOLVE, AgentAction.CLOSE],
episode_done=done,
info={"current_turn": self.conversation_turn, "max_turns": self.MAX_TURNS}
)