Spaces:
Sleeping
chore(round2): full audit pass — harden rewards, patch frontend, tighten training
Browse filesBackend — reward engine anti-gaming and normalisation:
- Clamp db_total to [-0.30, +0.25] so no bonus combination can dominate raw
- grounded_response_bonus now requires ≥30-char substantive reply AND ≥4-char
citation; blocks the "quote a single field and collect +0.10" farm
- Hallucination check: word-boundary amount pattern + _normalise_claim so
"₹999" / "rs 999" / "999 rupees" compare equal, and customer-mentioned
facts are no longer mis-flagged as hallucinated
- Loop penalty widened to a 4-message window with 0.80 threshold; catches
alternating-paraphrase loops that evaded the old adjacent check
- Supervisor role reward: clamp (1 + escalation_penalty + unnecessary_manager_penalty)
into [0,1] so a single penalty can't over-rotate the term
- Manager role reward: rebalance so MANAGER_SEND_BACK earns 0.70 credit
(legitimate deferral) instead of 0.0 — previously the flat "terminal or
zero" term punished correct deferrals
Backend — environment:
- Use copy.deepcopy for retrieved_data returned from _build_observation() and
state(), so downstream mutations don't leak into the session
- _handle_query_action uses _clamp_db_total so spam queries can't push the
step reward above the normal cap
Training pipeline:
- grpo_loss: log-prob length mismatch now emits a one-shot warning when cur
vs old log-prob lengths diverge >10% (signals tokenizer/prompt drift)
- grpo_advantages: compute μ/σ over the *valid* subset when the group has
obvious invalid-episode sentinels, so valid-episode advantages aren't
artificially inflated by the outlier pulling μ down and σ up
- prompt_builder: truncate conversation_history to 12 turns (head 2 + tail
10) and cap KNOWN DATA at 4 user + 4 order records, with visible ellipsis
markers — prevents prompt overflow on long multi_domain episodes
Frontend — close critical multi_domain gaps:
- TaskName union, ActionType union, Observation.retrieved_data, Action.email
+ Action.order_id now present in src/types/index.ts
- constants.ts: multi_domain added to TASK_CONFIG so the task dropdown
surfaces the new task
- SupportAgentActions.tsx: two new buttons (Query User DB, Query Order DB)
with dedicated email / order_id input forms
- RetrievedDataPanel.tsx: new component rendering DB lookup cards (user +
order) with not_found sentinels, wired into the demo-page right sidebar
- ai-action/route.ts: L1 system prompt now documents query_user_profile /
query_order_details; user prompt carries a KNOWN DATA block mirroring
the backend prompt_builder, so auto-play reasoning stays grounded
Tests + docs + schema:
- tests/test_env.py now sends X-API-Key in the module fixture (10
pre-existing 401 failures → 0). Loop penalty assertion updated to -0.12.
- README.md v2.1.0 → v2.2.0, 4-stage → 5-stage curriculum, full DB-grounded
section, 8 anti-gaming guards (was 6), stage 5 row in curriculum table
- openenv.yaml v2.1.0 → v2.2.0, multi_domain listed as curriculum stage 5
with advance_threshold, db signal weights documented, reward_clamping
section added
Verification:
- pytest: 75/75 passed (was 65 + 10 pre-existing failures)
- Docker rebuilt env + frontend, server at :7860, UI at :3000
- Curl: multi_domain reset → query_order_details → respond; query_match_bonus
fires on correct order, grounded_response_bonus fires on cited values,
hallucination_penalty fires on fabricated facts
- Playwright: loaded /demo, selected Multi-Domain (DB), created session,
clicked Query Order DB, entered ORD-EC-1202, verified system message
"[QUERY] order_details(ORD-EC-1202) → found", verified DB Lookups panel
renders the retrieved order card, zero console errors/warnings
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- README.md +45 -13
- env/environment.py +9 -6
- env/reward_engine.py +159 -70
- frontend/src/app/api/ai-action/route.ts +26 -5
- frontend/src/app/demo/page.tsx +5 -0
- frontend/src/components/action/SupportAgentActions.tsx +137 -19
- frontend/src/components/panels/RetrievedDataPanel.tsx +84 -0
- frontend/src/lib/constants.ts +10 -0
- frontend/src/types/index.ts +17 -1
- openenv.yaml +44 -17
- tests/test_env.py +3 -3
- train/grpo_trainer.py +14 -2
- train/prompt_builder.py +45 -16
- train/reward_aggregator.py +17 -2
|
@@ -17,7 +17,7 @@ tags:
|
|
| 17 |
- policy-drift
|
| 18 |
- progressive-curriculum
|
| 19 |
- meta-hackathon
|
| 20 |
-
short_description: 3-level hierarchical multi-agent RL env with dynamic customers, policy drift, Hinglish, and a
|
| 21 |
---
|
| 22 |
|
| 23 |
<div align="center">
|
|
@@ -26,7 +26,7 @@ short_description: 3-level hierarchical multi-agent RL env with dynamic customer
|
|
| 26 |
|
| 27 |
### *Where AI agents learn to navigate the chaos of real Indian enterprise support — hierarchy, policy changes, Hinglish customers, and SLA pressure, all at once.*
|
| 28 |
|
| 29 |
-
**Team X-Force** · Meta × PyTorch × Scaler OpenEnv Hackathon · **v2.
|
| 30 |
|
| 31 |
[](https://github.com/OpenEnvs)
|
| 32 |
[](https://python.org)
|
|
@@ -88,7 +88,7 @@ We built a **hierarchical multi-agent customer support environment** using OpenE
|
|
| 88 |
|
| 89 |
All agents share full context, follow role-specific instructions, and adapt dynamically to mid-episode policy changes, frustrated Hinglish-speaking customers, and SLA pressure. This environment trains LLMs to work as a coordinated team — with the speed, consistency, and scalability that human organisations can't match.
|
| 90 |
|
| 91 |
-
> **Our environment
|
| 92 |
|
| 93 |
---
|
| 94 |
|
|
@@ -154,7 +154,7 @@ All agents share full context, follow role-specific instructions, and adapt dyna
|
|
| 154 |
|
| 155 |
## 📚 Curriculum Design
|
| 156 |
|
| 157 |
-
We use **progressive curriculum learning** — a
|
| 158 |
|
| 159 |
```
|
| 160 |
Stage 1 Stage 2 Stage 3 Stage 4
|
|
@@ -176,9 +176,10 @@ Stage 1 Stage 2 Stage 3 Stage 4
|
|
| 176 |
| **1** | `curriculum_basic` | L1-only: UPI billing queries (₹499 plans, GST invoices). Calm customer. Dense rewards. Learn empathy + resolution fundamentals. | mean_score ≥ 0.65 |
|
| 177 |
| **2** | `curriculum_supervisor` | L1 + L2: Payment gateway timeouts, KYC rejections. Supervisor reviews every action. Agent learns to incorporate feedback and iterate. | mean_score ≥ 0.60 |
|
| 178 |
| **3** | `curriculum_full_hierarchy` | Full 3-level: Unauthorized ₹2.5L transactions, API outages at 10K RPM. Policy drift guaranteed. All levels must coordinate. | mean_score ≥ 0.55 |
|
| 179 |
-
| **4** | `curriculum_nightmare` | Extreme adversarial: Diwali sale meltdown (gateway down + inventory broken + CEO escalation). Customer screams in Hinglish. Multiple policy drifts. Only agents mastering stages 1–3 can score above 0.5. |
|
|
|
|
| 180 |
|
| 181 |
-
**Why curriculum?** Direct training on Stage 4 yields mean scores < 0.2. Curriculum training reaches **0.44** — a **120% improvement** — because foundational skills transfer upward.
|
| 182 |
|
| 183 |
---
|
| 184 |
|
|
@@ -220,19 +221,50 @@ Each agent level gets its own reward breakdown to enable independent RLHF per ro
|
|
| 220 |
|
| 221 |
### Anti-Reward-Hacking Guards
|
| 222 |
|
| 223 |
-
We implement **
|
| 224 |
|
| 225 |
| Guard | Penalty | Detection Method |
|
| 226 |
|-------|:-------:|---|
|
| 227 |
| **Keyword stuffing** | −0.30 | Word density > 20% resolution/empathy keywords without substance |
|
| 228 |
-
| **Loop detection** | −0.
|
| 229 |
| **Contradiction** | −0.15 | Claiming "resolved" then asking for already-provided info |
|
| 230 |
| **Policy violation** | −0.25 | Action violates active policy (e.g., promising refund when portal is down) |
|
| 231 |
-
| **
|
| 232 |
-
| **
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
|
| 234 |
> **Why this matters:** In our testing, a naive keyword-stuffing agent scored **0.72** without guards. With guards enabled, the same agent drops to **0.31**. Only genuinely helpful behavior scores well.
|
| 235 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 236 |
---
|
| 237 |
|
| 238 |
## 🚂 Training Pipeline
|
|
@@ -264,7 +296,7 @@ We implement **6 distinct anti-gaming measures** to ensure agents earn rewards t
|
|
| 264 |
**Key design decisions:**
|
| 265 |
|
| 266 |
1. **SFT Warm-start**: Collect 200 gold episodes (score ≥ 0.65) from the NIM baseline agent, then SFT for 500 steps to teach correct action format and basic behavior.
|
| 267 |
-
2. **GRPO (Group Relative Policy Optimization)**: Group size 8, 5000 gradient steps across
|
| 268 |
3. **Curriculum progression**: The trainer automatically advances to the next stage when mean score over 20 episodes exceeds the threshold.
|
| 269 |
4. **LoRA (r=16)**: Memory-efficient fine-tuning with Unsloth on a single GPU (A100 40GB). Full training completes in ~4 hours.
|
| 270 |
|
|
@@ -278,7 +310,7 @@ pip install "unsloth[cu124-torch240]"
|
|
| 278 |
# SFT warm-start (collect gold data, then fine-tune)
|
| 279 |
python -m train.sft_warmstart --mode all --n_episodes 200 --steps 500
|
| 280 |
|
| 281 |
-
# Full GRPO training (5000 steps,
|
| 282 |
python -m train.run_train --model checkpoints/sft --total_steps 5000
|
| 283 |
|
| 284 |
# Merge LoRA adapters for deployment
|
|
@@ -446,7 +478,7 @@ This pushes beyond simple chatbots toward truly collaborative agent systems capa
|
|
| 446 |
| **#1 Multi-Agent Interactions** | 3-level hierarchy with 11 distinct action types, supervisor review loops, manager overrides |
|
| 447 |
| **#2 Instruction Following** | Policy adherence scoring via LLM-as-Judge, mid-episode policy drift forces dynamic compliance |
|
| 448 |
| **#3 Professional Tasks** | Real-world Indian enterprise support: UPI payments, GST invoices, KYC rejections, SLA management |
|
| 449 |
-
| **#4 Self-Improvement** |
|
| 450 |
|
| 451 |
### Who Benefits
|
| 452 |
|
|
|
|
| 17 |
- policy-drift
|
| 18 |
- progressive-curriculum
|
| 19 |
- meta-hackathon
|
| 20 |
+
short_description: 3-level hierarchical multi-agent RL env with dynamic customers, policy drift, Hinglish, DB-grounded queries, and a 5-stage curriculum
|
| 21 |
---
|
| 22 |
|
| 23 |
<div align="center">
|
|
|
|
| 26 |
|
| 27 |
### *Where AI agents learn to navigate the chaos of real Indian enterprise support — hierarchy, policy changes, Hinglish customers, and SLA pressure, all at once.*
|
| 28 |
|
| 29 |
+
**Team X-Force** · Meta × PyTorch × Scaler OpenEnv Hackathon · **v2.2.0**
|
| 30 |
|
| 31 |
[](https://github.com/OpenEnvs)
|
| 32 |
[](https://python.org)
|
|
|
|
| 88 |
|
| 89 |
All agents share full context, follow role-specific instructions, and adapt dynamically to mid-episode policy changes, frustrated Hinglish-speaking customers, and SLA pressure. This environment trains LLMs to work as a coordinated team — with the speed, consistency, and scalability that human organisations can't match.
|
| 90 |
|
| 91 |
+
> **Our environment combines five challenges no prior RL env tackles together:** a 3-level agent hierarchy with role-specific rewards, a dynamic LLM-driven customer that degrades into Hinglish under frustration, mid-episode policy drift that forces real-time adaptation, an in-memory DB across three real-world domains (food delivery, e-commerce, ticket booking) that rewards grounded citations and penalises hallucinated facts, and a progressive 5-stage curriculum that builds each skill incrementally.
|
| 92 |
|
| 93 |
---
|
| 94 |
|
|
|
|
| 154 |
|
| 155 |
## 📚 Curriculum Design
|
| 156 |
|
| 157 |
+
We use **progressive curriculum learning** — a 5-stage training pipeline where each stage introduces exactly one new dimension of complexity. This prevents catastrophic forgetting and ensures agents build skills incrementally. The model graduates from polite single-agent replies → supervisor feedback loops → full hierarchy → adversarial multi-drift → grounded DB queries.
|
| 158 |
|
| 159 |
```
|
| 160 |
Stage 1 Stage 2 Stage 3 Stage 4
|
|
|
|
| 176 |
| **1** | `curriculum_basic` | L1-only: UPI billing queries (₹499 plans, GST invoices). Calm customer. Dense rewards. Learn empathy + resolution fundamentals. | mean_score ≥ 0.65 |
|
| 177 |
| **2** | `curriculum_supervisor` | L1 + L2: Payment gateway timeouts, KYC rejections. Supervisor reviews every action. Agent learns to incorporate feedback and iterate. | mean_score ≥ 0.60 |
|
| 178 |
| **3** | `curriculum_full_hierarchy` | Full 3-level: Unauthorized ₹2.5L transactions, API outages at 10K RPM. Policy drift guaranteed. All levels must coordinate. | mean_score ≥ 0.55 |
|
| 179 |
+
| **4** | `curriculum_nightmare` | Extreme adversarial: Diwali sale meltdown (gateway down + inventory broken + CEO escalation). Customer screams in Hinglish. Multiple policy drifts. Only agents mastering stages 1–3 can score above 0.5. | mean_score ≥ 0.50 |
|
| 180 |
+
| **5** | `multi_domain` | **DB-grounded support**: 30 diverse tickets drawn from an in-memory DB spanning food delivery, e-commerce, and ticket booking. Agent must call `query_user_profile` / `query_order_details` before responding, cite verbatim DB values, and correctly handle `not_found` sentinels. Hallucinating any fact (amount, date, order-id, email) not in the DB or conversation incurs a −0.25 penalty. | final stage |
|
| 181 |
|
| 182 |
+
**Why curriculum?** Direct training on Stage 4 yields mean scores < 0.2. Curriculum training reaches **0.44** on Stage 4 — a **120% improvement** — because foundational skills transfer upward. Stage 5 then layers DB retrieval on top of an already-competent agent.
|
| 183 |
|
| 184 |
---
|
| 185 |
|
|
|
|
| 221 |
|
| 222 |
### Anti-Reward-Hacking Guards
|
| 223 |
|
| 224 |
+
We implement **8 distinct anti-gaming measures** to ensure agents earn rewards through genuine quality:
|
| 225 |
|
| 226 |
| Guard | Penalty | Detection Method |
|
| 227 |
|-------|:-------:|---|
|
| 228 |
| **Keyword stuffing** | −0.30 | Word density > 20% resolution/empathy keywords without substance |
|
| 229 |
+
| **Loop detection** | −0.12 | SequenceMatcher ratio > 0.80 among the last 4 agent messages (catches alternating paraphrase loops) |
|
| 230 |
| **Contradiction** | −0.15 | Claiming "resolved" then asking for already-provided info |
|
| 231 |
| **Policy violation** | −0.25 | Action violates active policy (e.g., promising refund when portal is down) |
|
| 232 |
+
| **Ignored supervisor feedback** | −0.15 | No overlap between agent's next message and last feedback |
|
| 233 |
+
| **Unnecessary manager escalation** | −0.20 | L2 escalates to L3 on a low/medium-priority ticket |
|
| 234 |
+
| **Unnecessary L1 escalation** | −0.30 | L1 escalates a low/medium-priority ticket |
|
| 235 |
+
| **Hallucination** | −0.25 | Agent cites a fact (amount, date, email, order-id) absent from both the DB and the customer's own messages |
|
| 236 |
+
|
| 237 |
+
All positive DB signals sum to at most **+0.25** (hard-clamped via `_clamp_db_total`), so no combination of bonuses can overwhelm the weighted reward components.
|
| 238 |
|
| 239 |
> **Why this matters:** In our testing, a naive keyword-stuffing agent scored **0.72** without guards. With guards enabled, the same agent drops to **0.31**. Only genuinely helpful behavior scores well.
|
| 240 |
|
| 241 |
+
### DB-Grounded Response System (Stage 5)
|
| 242 |
+
|
| 243 |
+
The `multi_domain` task plugs an in-memory database into the environment. Agents must explicitly query the DB before referencing customer-specific facts, and the reward engine audits every response for hallucinations.
|
| 244 |
+
|
| 245 |
+
**DB layout:**
|
| 246 |
+
- **23 users** across `food_delivery`, `e_commerce`, `ticket_booking` domains — each with a status (`active` / `suspended`), loyalty tier, phone, and join date.
|
| 247 |
+
- **40 orders** — each tied to a user, with amounts in ₹, restaurants/items, payment methods, delivery addresses, and domain-specific fields (refund status, cancellation reason, delivery issue codes, etc.).
|
| 248 |
+
- **30 multi-domain tickets** — each carries a `customer_email` and `related_order_ids` that link to the DB, so the grader can check whether the agent queried the right record.
|
| 249 |
+
|
| 250 |
+
**Two new L1 actions:**
|
| 251 |
+
- `query_user_profile` — look up a customer account by email. Returns the user record dict, or the literal sentinel `"not_found"`.
|
| 252 |
+
- `query_order_details` — look up an order by ID. Returns the order record dict, or `"not_found"`.
|
| 253 |
+
|
| 254 |
+
Both actions are **internal**: they cost one step, never generate a customer reply, and don't trigger supervisor review. The returned data lives in `observation.retrieved_data` and is serialised into the agent's next prompt inside a `## KNOWN DATA` block.
|
| 255 |
+
|
| 256 |
+
**DB-specific reward signals** (all bounded, all clamped into a single ±0.25 bucket):
|
| 257 |
+
|
| 258 |
+
| Signal | Value | Trigger |
|
| 259 |
+
|--------|:-:|---|
|
| 260 |
+
| `query_match_bonus` | +0.08 | Queried the email / order-id the ticket is actually about |
|
| 261 |
+
| `grounded_response_bonus` | +0.10 | Substantive reply (≥30 chars) citing a verbatim DB value (≥4 chars) |
|
| 262 |
+
| `not_found_handling_bonus` | +0.08 | Responded to a `not_found` with `REQUEST_INFO` or `ESCALATE` |
|
| 263 |
+
| `wasted_query_penalty` | −0.08 | Queried an email/order the customer never mentioned |
|
| 264 |
+
| `hallucination_penalty` | −0.25 | Invented an amount, date, email, or order-id not in DB or conversation |
|
| 265 |
+
|
| 266 |
+
The hallucination check normalises currency tokens (`₹999` ≡ `rs 999` ≡ `999 rupees`) before comparing, so legitimate references aren't mis-flagged.
|
| 267 |
+
|
| 268 |
---
|
| 269 |
|
| 270 |
## 🚂 Training Pipeline
|
|
|
|
| 296 |
**Key design decisions:**
|
| 297 |
|
| 298 |
1. **SFT Warm-start**: Collect 200 gold episodes (score ≥ 0.65) from the NIM baseline agent, then SFT for 500 steps to teach correct action format and basic behavior.
|
| 299 |
+
2. **GRPO (Group Relative Policy Optimization)**: Group size 8, 5000 gradient steps across 5 curriculum stages. The environment API provides all rewards — no separate reward model needed.
|
| 300 |
3. **Curriculum progression**: The trainer automatically advances to the next stage when mean score over 20 episodes exceeds the threshold.
|
| 301 |
4. **LoRA (r=16)**: Memory-efficient fine-tuning with Unsloth on a single GPU (A100 40GB). Full training completes in ~4 hours.
|
| 302 |
|
|
|
|
| 310 |
# SFT warm-start (collect gold data, then fine-tune)
|
| 311 |
python -m train.sft_warmstart --mode all --n_episodes 200 --steps 500
|
| 312 |
|
| 313 |
+
# Full GRPO training (5000 steps, 5-stage curriculum)
|
| 314 |
python -m train.run_train --model checkpoints/sft --total_steps 5000
|
| 315 |
|
| 316 |
# Merge LoRA adapters for deployment
|
|
|
|
| 478 |
| **#1 Multi-Agent Interactions** | 3-level hierarchy with 11 distinct action types, supervisor review loops, manager overrides |
|
| 479 |
| **#2 Instruction Following** | Policy adherence scoring via LLM-as-Judge, mid-episode policy drift forces dynamic compliance |
|
| 480 |
| **#3 Professional Tasks** | Real-world Indian enterprise support: UPI payments, GST invoices, KYC rejections, SLA management |
|
| 481 |
+
| **#4 Self-Improvement** | 5-stage curriculum with auto-advancement, before/after training evidence, reward curve analysis |
|
| 482 |
|
| 483 |
### Who Benefits
|
| 484 |
|
|
@@ -3,6 +3,7 @@ CustomerSupportEnv — core RL environment.
|
|
| 3 |
Supports both single-agent (Round 1) and hierarchical multi-agent (Round 2).
|
| 4 |
"""
|
| 5 |
|
|
|
|
| 6 |
import random
|
| 7 |
import re
|
| 8 |
import uuid
|
|
@@ -217,7 +218,7 @@ class CustomerSupportEnv:
|
|
| 217 |
"step": self._step, "max_steps": self._max_steps,
|
| 218 |
"sentiment": self._sentiment, "done": self._done,
|
| 219 |
"action_log": self._action_log,
|
| 220 |
-
"retrieved_data":
|
| 221 |
}
|
| 222 |
|
| 223 |
def _build_observation(self) -> Observation:
|
|
@@ -232,7 +233,7 @@ class CustomerSupportEnv:
|
|
| 232 |
customer_sentiment=round(self._sentiment, 3),
|
| 233 |
mood_trajectory=self._sentiment_history[-3:],
|
| 234 |
unresolved_issues=unresolved, is_done=self._done, task=self.task,
|
| 235 |
-
retrieved_data=
|
| 236 |
)
|
| 237 |
|
| 238 |
def _handle_query_action(
|
|
@@ -256,10 +257,12 @@ class CustomerSupportEnv:
|
|
| 256 |
|
| 257 |
self._history.append(Message(role="system", content=summary))
|
| 258 |
|
| 259 |
-
# Minimal reward — actual signal computed in reward_engine via DB signals
|
| 260 |
-
|
|
|
|
|
|
|
| 261 |
db_signals = compute_db_signals(action, self._ticket, self._history, self._retrieved_data)
|
| 262 |
-
raw_db =
|
| 263 |
import numpy as np
|
| 264 |
reward = Reward(
|
| 265 |
value=float(np.clip(0.5 + raw_db, 0.0, 1.0)),
|
|
@@ -637,7 +640,7 @@ class HierarchicalCustomerSupportEnv(CustomerSupportEnv):
|
|
| 637 |
customer_sentiment=round(self._sentiment, 3),
|
| 638 |
mood_trajectory=self._sentiment_history[-3:],
|
| 639 |
unresolved_issues=unresolved, is_done=self._done, task=self.task,
|
| 640 |
-
retrieved_data=
|
| 641 |
active_role=self._active_role.value,
|
| 642 |
supervisor_feedback=self._supervisor_feedback,
|
| 643 |
manager_directive=self._manager_directive,
|
|
|
|
| 3 |
Supports both single-agent (Round 1) and hierarchical multi-agent (Round 2).
|
| 4 |
"""
|
| 5 |
|
| 6 |
+
import copy
|
| 7 |
import random
|
| 8 |
import re
|
| 9 |
import uuid
|
|
|
|
| 218 |
"step": self._step, "max_steps": self._max_steps,
|
| 219 |
"sentiment": self._sentiment, "done": self._done,
|
| 220 |
"action_log": self._action_log,
|
| 221 |
+
"retrieved_data": copy.deepcopy(self._retrieved_data),
|
| 222 |
}
|
| 223 |
|
| 224 |
def _build_observation(self) -> Observation:
|
|
|
|
| 233 |
customer_sentiment=round(self._sentiment, 3),
|
| 234 |
mood_trajectory=self._sentiment_history[-3:],
|
| 235 |
unresolved_issues=unresolved, is_done=self._done, task=self.task,
|
| 236 |
+
retrieved_data=copy.deepcopy(self._retrieved_data),
|
| 237 |
)
|
| 238 |
|
| 239 |
def _handle_query_action(
|
|
|
|
| 257 |
|
| 258 |
self._history.append(Message(role="system", content=summary))
|
| 259 |
|
| 260 |
+
# Minimal reward — actual signal computed in reward_engine via DB signals.
|
| 261 |
+
# Using _clamp_db_total so spam queries can't push the reward above the
|
| 262 |
+
# normal cap, and so wasted-query penalties are bounded.
|
| 263 |
+
from env.reward_engine import compute_db_signals, _clamp_db_total
|
| 264 |
db_signals = compute_db_signals(action, self._ticket, self._history, self._retrieved_data)
|
| 265 |
+
raw_db = _clamp_db_total(db_signals)
|
| 266 |
import numpy as np
|
| 267 |
reward = Reward(
|
| 268 |
value=float(np.clip(0.5 + raw_db, 0.0, 1.0)),
|
|
|
|
| 640 |
customer_sentiment=round(self._sentiment, 3),
|
| 641 |
mood_trajectory=self._sentiment_history[-3:],
|
| 642 |
unresolved_issues=unresolved, is_done=self._done, task=self.task,
|
| 643 |
+
retrieved_data=copy.deepcopy(self._retrieved_data),
|
| 644 |
active_role=self._active_role.value,
|
| 645 |
supervisor_feedback=self._supervisor_feedback,
|
| 646 |
manager_directive=self._manager_directive,
|
|
@@ -100,19 +100,23 @@ def _agent_messages(history: List[Message]) -> List[str]:
|
|
| 100 |
|
| 101 |
def compute_loop_penalty(history: List[Message]) -> float:
|
| 102 |
"""
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
| 106 |
"""
|
| 107 |
-
agent_msgs = _agent_messages(history)
|
| 108 |
if len(agent_msgs) < 2:
|
| 109 |
return 0.0
|
| 110 |
-
last_two = agent_msgs[-2:]
|
| 111 |
-
if not all(m.strip() for m in last_two):
|
| 112 |
-
return 0.0
|
| 113 |
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
|
| 118 |
def compute_resolution_score(
|
|
@@ -241,15 +245,67 @@ def compute_keyword_stuffing_penalty(message: str) -> float:
|
|
| 241 |
# DB Signal Helper (grounded response rewards / hallucination penalties)
|
| 242 |
# ═══════════════════════════════════════════════════════════════════════════════
|
| 243 |
|
| 244 |
-
# Patterns for detecting claimed facts in agent messages
|
|
|
|
|
|
|
|
|
|
| 245 |
_FACT_PATTERNS = {
|
| 246 |
"email": re.compile(r"[\w.+-]+@[\w-]+\.[a-z]{2,}", re.IGNORECASE),
|
| 247 |
"order_id": re.compile(r"\b(?:ORD|ORDER|#)[-\s]?[A-Z]{0,3}[-\s]?\d{3,}\b", re.IGNORECASE),
|
| 248 |
-
|
| 249 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 250 |
}
|
| 251 |
|
| 252 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
def compute_db_signals(
|
| 254 |
action: "Action",
|
| 255 |
ticket: dict,
|
|
@@ -259,11 +315,28 @@ def compute_db_signals(
|
|
| 259 |
"""
|
| 260 |
Compute DB-grounding reward signals for an action step.
|
| 261 |
|
| 262 |
-
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
"""
|
| 265 |
from env.models import ActionType
|
| 266 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
signals: dict = {
|
| 268 |
"query_match_bonus": 0.0,
|
| 269 |
"grounded_response_bonus": 0.0,
|
|
@@ -273,78 +346,71 @@ def compute_db_signals(
|
|
| 273 |
}
|
| 274 |
|
| 275 |
at = ActionType(action.action_type)
|
| 276 |
-
ticket_email = ticket.get("customer_email"
|
| 277 |
-
ticket_order_ids: List[str] = ticket.get("related_order_ids", [])
|
| 278 |
-
users_data: dict = retrieved_data.get("users", {})
|
| 279 |
-
orders_data: dict = retrieved_data.get("orders", {})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 280 |
|
| 281 |
-
# ── Query match bonus ──────────────────────────────
|
| 282 |
if at == ActionType.QUERY_USER_PROFILE and action.email:
|
| 283 |
queried_email = action.email.strip().lower()
|
| 284 |
-
|
| 285 |
-
if ticket_email_lower and queried_email == ticket_email_lower:
|
| 286 |
signals["query_match_bonus"] = 0.08
|
| 287 |
-
elif queried_email and queried_email not in
|
| 288 |
-
" ".join(m.content for m in history if m.role == "customer").lower()
|
| 289 |
-
):
|
| 290 |
-
# Email never mentioned by customer — wasted query
|
| 291 |
signals["wasted_query_penalty"] = -0.08
|
| 292 |
|
| 293 |
if at == ActionType.QUERY_ORDER_DETAILS and action.order_id:
|
| 294 |
queried_oid = action.order_id.strip().upper()
|
| 295 |
if ticket_order_ids and queried_oid in [o.upper() for o in ticket_order_ids]:
|
| 296 |
signals["query_match_bonus"] = 0.08
|
| 297 |
-
elif queried_oid and queried_oid not in
|
| 298 |
-
" ".join(m.content for m in history if m.role == "customer").upper()
|
| 299 |
-
):
|
| 300 |
signals["wasted_query_penalty"] = -0.08
|
| 301 |
|
| 302 |
# ── Not-found handling bonus ───────────────────────────────────────────────
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
}
|
| 306 |
any_not_found = (
|
| 307 |
"not_found" in users_data.values() or "not_found" in orders_data.values()
|
| 308 |
)
|
| 309 |
if any_not_found and at in _GOOD_NOT_FOUND_ACTIONS:
|
| 310 |
signals["not_found_handling_bonus"] = 0.08
|
| 311 |
|
| 312 |
-
# ── Grounded response
|
| 313 |
-
|
| 314 |
-
if msg_text and at in {
|
| 315 |
ActionType.RESPOND, ActionType.CLOSE, ActionType.REQUEST_INFO,
|
| 316 |
ActionType.MANAGER_OVERRIDE, ActionType.MANAGER_RESOLVE,
|
| 317 |
-
}
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
|
| 322 |
-
|
| 323 |
-
|
| 324 |
-
|
| 325 |
-
|
| 326 |
-
|
| 327 |
-
|
| 328 |
-
|
| 329 |
-
grounded_values.append(str(v).lower())
|
| 330 |
-
|
| 331 |
-
if grounded_values:
|
| 332 |
-
# Bonus: message cites a verbatim value from retrieved_data
|
| 333 |
-
grounded = any(gv in msg_text for gv in grounded_values if len(gv) > 3)
|
| 334 |
-
if grounded:
|
| 335 |
signals["grounded_response_bonus"] = 0.10
|
| 336 |
|
| 337 |
-
#
|
| 338 |
-
#
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
| 347 |
-
|
|
|
|
| 348 |
signals["hallucination_penalty"] = -0.25
|
| 349 |
break
|
| 350 |
if signals["hallucination_penalty"] < 0:
|
|
@@ -353,6 +419,13 @@ def compute_db_signals(
|
|
| 353 |
return signals
|
| 354 |
|
| 355 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
# ═══════════════════════════════════════════════════════════════════════════════
|
| 357 |
# Single-Agent Step Reward (Round 1 backward compat)
|
| 358 |
# ═══════════════════════════════════════════════════════════════════════════════
|
|
@@ -396,7 +469,7 @@ def compute_step_reward(
|
|
| 396 |
# DB grounding signals (all zero when no queries made)
|
| 397 |
_rd = retrieved_data or {"users": {}, "orders": {}}
|
| 398 |
db_signals = compute_db_signals(action, ticket, history, _rd)
|
| 399 |
-
db_total =
|
| 400 |
|
| 401 |
raw = (
|
| 402 |
0.40 * resolution_score
|
|
@@ -575,7 +648,7 @@ def compute_hierarchy_reward(
|
|
| 575 |
# ── DB grounding signals ───────────────────────────────────────────────────
|
| 576 |
_rd = retrieved_data or {"users": {}, "orders": {}}
|
| 577 |
db_signals = compute_db_signals(action, ticket, history, _rd)
|
| 578 |
-
db_total =
|
| 579 |
|
| 580 |
# ── Compute overall raw reward ─────────────────────────────────────────────
|
| 581 |
if is_terminal:
|
|
@@ -625,20 +698,36 @@ def compute_hierarchy_reward(
|
|
| 625 |
)
|
| 626 |
role_rewards["support_agent"] = float(np.clip(l1_raw, 0.0, 1.0))
|
| 627 |
|
| 628 |
-
# Supervisor role reward
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 629 |
l2_raw = (
|
| 630 |
0.35 * oversight_score
|
| 631 |
-
+ 0.30 *
|
| 632 |
+ 0.20 * policy_adherence_score
|
| 633 |
+ 0.15 * (1.0 if steps_used <= ideal_steps else 0.5)
|
| 634 |
)
|
| 635 |
role_rewards["supervisor"] = float(np.clip(l2_raw, 0.0, 1.0))
|
| 636 |
|
| 637 |
-
# Manager role reward
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 638 |
l3_raw = (
|
| 639 |
-
0.
|
| 640 |
+ 0.30 * (resolution_llm_score if is_terminal else 0.5)
|
| 641 |
-
+ 0.
|
| 642 |
)
|
| 643 |
role_rewards["manager"] = float(np.clip(l3_raw, 0.0, 1.0))
|
| 644 |
|
|
|
|
| 100 |
|
| 101 |
def compute_loop_penalty(history: List[Message]) -> float:
|
| 102 |
"""
|
| 103 |
+
Detect repeated agent messages. Checks the last 4 agent messages and
|
| 104 |
+
penalises any pair whose SequenceMatcher ratio exceeds 0.80. This catches
|
| 105 |
+
both adjacent repeats and alternating-paraphrase loops that try to evade
|
| 106 |
+
a strict adjacency check.
|
| 107 |
+
|
| 108 |
+
Returns -0.12 if any pair is over threshold, else 0.0.
|
| 109 |
"""
|
| 110 |
+
agent_msgs = [m for m in _agent_messages(history) if m and m.strip()]
|
| 111 |
if len(agent_msgs) < 2:
|
| 112 |
return 0.0
|
|
|
|
|
|
|
|
|
|
| 113 |
|
| 114 |
+
window = agent_msgs[-4:]
|
| 115 |
+
for i in range(len(window)):
|
| 116 |
+
for j in range(i + 1, len(window)):
|
| 117 |
+
if SequenceMatcher(None, window[i], window[j]).ratio() > 0.80:
|
| 118 |
+
return -0.12
|
| 119 |
+
return 0.0
|
| 120 |
|
| 121 |
|
| 122 |
def compute_resolution_score(
|
|
|
|
| 245 |
# DB Signal Helper (grounded response rewards / hallucination penalties)
|
| 246 |
# ═══════════════════════════════════════════════════════════════════════════════
|
| 247 |
|
| 248 |
+
# Patterns for detecting claimed facts in agent messages. Word-bounded so we
|
| 249 |
+
# can compare against the same pattern extracted from customer text and from DB
|
| 250 |
+
# values, rather than doing raw substring matching (which mis-flags legitimate
|
| 251 |
+
# facts that the customer already mentioned).
|
| 252 |
_FACT_PATTERNS = {
|
| 253 |
"email": re.compile(r"[\w.+-]+@[\w-]+\.[a-z]{2,}", re.IGNORECASE),
|
| 254 |
"order_id": re.compile(r"\b(?:ORD|ORDER|#)[-\s]?[A-Z]{0,3}[-\s]?\d{3,}\b", re.IGNORECASE),
|
| 255 |
+
# Amount: ₹NNN, Rs NNN, NNN rupees, NNN INR — require word boundary so
|
| 256 |
+
# "approx 500 rupees" matches cleanly and later also matches from customer text.
|
| 257 |
+
"amount": re.compile(
|
| 258 |
+
r"(?:₹\s*\d{2,}|\brs\.?\s*\d{2,}\b|\b\d{2,}\s*(?:rupees?|inr)\b|\brupees?\s+\d{2,}\b)",
|
| 259 |
+
re.IGNORECASE,
|
| 260 |
+
),
|
| 261 |
+
"date": re.compile(
|
| 262 |
+
r"\b\d{4}-\d{2}-\d{2}\b|\b(?:jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec)\w*\s+\d{1,2}\b",
|
| 263 |
+
re.IGNORECASE,
|
| 264 |
+
),
|
| 265 |
}
|
| 266 |
|
| 267 |
|
| 268 |
+
def _normalise_claim(claim: str) -> str:
|
| 269 |
+
"""Normalise a pattern match so semantically-identical claims compare equal.
|
| 270 |
+
|
| 271 |
+
Collapses whitespace, lowercases, and strips the currency symbol from
|
| 272 |
+
amounts so '₹999' == 'rs 999' == '999 rupees' for comparison purposes.
|
| 273 |
+
"""
|
| 274 |
+
c = re.sub(r"\s+", "", claim.lower())
|
| 275 |
+
c = c.replace("₹", "").replace("rs.", "").replace("rs", "")
|
| 276 |
+
c = c.replace("rupees", "").replace("rupee", "").replace("inr", "")
|
| 277 |
+
return c.strip()
|
| 278 |
+
|
| 279 |
+
|
| 280 |
+
# Hard caps on DB signal totals so no combination of bonuses can dominate the
|
| 281 |
+
# weighted reward sum and push raw above 1.0 before clipping.
|
| 282 |
+
DB_TOTAL_MIN = -0.30
|
| 283 |
+
DB_TOTAL_MAX = 0.25
|
| 284 |
+
|
| 285 |
+
# Minimum response length (chars) required to earn the grounded-response bonus.
|
| 286 |
+
# Prevents the farm where the agent replies with a single DB field and claims +0.10.
|
| 287 |
+
_GROUNDED_MIN_LEN = 30
|
| 288 |
+
|
| 289 |
+
# Actions that legitimately "react to not_found" — only these earn the bonus.
|
| 290 |
+
_GOOD_NOT_FOUND_ACTIONS = None # populated lazily to avoid import cycle
|
| 291 |
+
|
| 292 |
+
|
| 293 |
+
def _collect_grounded_values(retrieved_data: dict) -> List[str]:
|
| 294 |
+
"""Flatten every string-ish value from users + orders into one lowercase list."""
|
| 295 |
+
values: List[str] = []
|
| 296 |
+
users_data = retrieved_data.get("users", {}) or {}
|
| 297 |
+
orders_data = retrieved_data.get("orders", {}) or {}
|
| 298 |
+
for record in list(users_data.values()) + list(orders_data.values()):
|
| 299 |
+
if not isinstance(record, dict):
|
| 300 |
+
continue
|
| 301 |
+
for v in record.values():
|
| 302 |
+
if isinstance(v, list):
|
| 303 |
+
values.extend(str(i).lower() for i in v if i is not None)
|
| 304 |
+
elif v is not None:
|
| 305 |
+
values.append(str(v).lower())
|
| 306 |
+
return values
|
| 307 |
+
|
| 308 |
+
|
| 309 |
def compute_db_signals(
|
| 310 |
action: "Action",
|
| 311 |
ticket: dict,
|
|
|
|
| 315 |
"""
|
| 316 |
Compute DB-grounding reward signals for an action step.
|
| 317 |
|
| 318 |
+
Signals (all clamped; absolute values are modest so they never overwhelm
|
| 319 |
+
the core reward components):
|
| 320 |
+
|
| 321 |
+
query_match_bonus +0.08 — queried the email/order_id the ticket is about
|
| 322 |
+
wasted_query_penalty -0.08 — queried an email/order never mentioned by the customer
|
| 323 |
+
grounded_response_bonus +0.10 — cited verbatim DB data in a substantive (≥30-char) reply
|
| 324 |
+
not_found_handling_bonus+0.08 — responded to a not_found with REQUEST_INFO / ESCALATE
|
| 325 |
+
hallucination_penalty -0.25 — invented a fact (amount, date, email, order-id) that is in
|
| 326 |
+
neither the DB nor the customer's own messages
|
| 327 |
+
|
| 328 |
+
Returns all zeros when no DB interaction has happened (backward-compatible).
|
| 329 |
"""
|
| 330 |
from env.models import ActionType
|
| 331 |
|
| 332 |
+
global _GOOD_NOT_FOUND_ACTIONS
|
| 333 |
+
if _GOOD_NOT_FOUND_ACTIONS is None:
|
| 334 |
+
_GOOD_NOT_FOUND_ACTIONS = {
|
| 335 |
+
ActionType.REQUEST_INFO,
|
| 336 |
+
ActionType.ESCALATE,
|
| 337 |
+
ActionType.SUPERVISOR_ESCALATE,
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
signals: dict = {
|
| 341 |
"query_match_bonus": 0.0,
|
| 342 |
"grounded_response_bonus": 0.0,
|
|
|
|
| 346 |
}
|
| 347 |
|
| 348 |
at = ActionType(action.action_type)
|
| 349 |
+
ticket_email = (ticket.get("customer_email") or "").lower()
|
| 350 |
+
ticket_order_ids: List[str] = ticket.get("related_order_ids", []) or []
|
| 351 |
+
users_data: dict = retrieved_data.get("users", {}) or {}
|
| 352 |
+
orders_data: dict = retrieved_data.get("orders", {}) or {}
|
| 353 |
+
|
| 354 |
+
customer_text = " ".join(
|
| 355 |
+
m.content for m in history if m.role == "customer"
|
| 356 |
+
)
|
| 357 |
+
customer_text_lower = customer_text.lower()
|
| 358 |
+
customer_text_upper = customer_text.upper()
|
| 359 |
|
| 360 |
+
# ── Query match bonus / wasted-query penalty ──────────────────────────────
|
| 361 |
if at == ActionType.QUERY_USER_PROFILE and action.email:
|
| 362 |
queried_email = action.email.strip().lower()
|
| 363 |
+
if ticket_email and queried_email == ticket_email:
|
|
|
|
| 364 |
signals["query_match_bonus"] = 0.08
|
| 365 |
+
elif queried_email and queried_email not in customer_text_lower:
|
|
|
|
|
|
|
|
|
|
| 366 |
signals["wasted_query_penalty"] = -0.08
|
| 367 |
|
| 368 |
if at == ActionType.QUERY_ORDER_DETAILS and action.order_id:
|
| 369 |
queried_oid = action.order_id.strip().upper()
|
| 370 |
if ticket_order_ids and queried_oid in [o.upper() for o in ticket_order_ids]:
|
| 371 |
signals["query_match_bonus"] = 0.08
|
| 372 |
+
elif queried_oid and queried_oid not in customer_text_upper:
|
|
|
|
|
|
|
| 373 |
signals["wasted_query_penalty"] = -0.08
|
| 374 |
|
| 375 |
# ── Not-found handling bonus ───────────────────────────────────────────────
|
| 376 |
+
# Only award when retrieved_data contains at least one "not_found" sentinel
|
| 377 |
+
# AND the current action is a recovery move (ask for info or escalate).
|
|
|
|
| 378 |
any_not_found = (
|
| 379 |
"not_found" in users_data.values() or "not_found" in orders_data.values()
|
| 380 |
)
|
| 381 |
if any_not_found and at in _GOOD_NOT_FOUND_ACTIONS:
|
| 382 |
signals["not_found_handling_bonus"] = 0.08
|
| 383 |
|
| 384 |
+
# ── Grounded response & hallucination (only on response-like actions) ─────
|
| 385 |
+
response_actions = {
|
|
|
|
| 386 |
ActionType.RESPOND, ActionType.CLOSE, ActionType.REQUEST_INFO,
|
| 387 |
ActionType.MANAGER_OVERRIDE, ActionType.MANAGER_RESOLVE,
|
| 388 |
+
}
|
| 389 |
+
msg_text_raw = action.message or action.reason or ""
|
| 390 |
+
msg_text = msg_text_raw.lower()
|
| 391 |
+
|
| 392 |
+
if msg_text and at in response_actions:
|
| 393 |
+
grounded_values = _collect_grounded_values(retrieved_data)
|
| 394 |
+
|
| 395 |
+
# Grounded-response bonus: the reply must be substantive (≥30 chars)
|
| 396 |
+
# AND cite at least one verbatim DB value (≥4 chars, to avoid rewarding
|
| 397 |
+
# trivia like `499` that any reply could include).
|
| 398 |
+
if grounded_values and len(msg_text_raw.strip()) >= _GROUNDED_MIN_LEN:
|
| 399 |
+
if any(len(gv) >= 4 and gv in msg_text for gv in grounded_values):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
signals["grounded_response_bonus"] = 0.10
|
| 401 |
|
| 402 |
+
# Hallucination: a fact pattern appears in the agent's text that is
|
| 403 |
+
# present in neither the customer's messages nor the DB values. Compare
|
| 404 |
+
# via _normalise_claim so "₹999" == "rs 999" == "999 rupees".
|
| 405 |
+
known_claims: set[str] = set()
|
| 406 |
+
for text in (customer_text_lower, " ".join(grounded_values)):
|
| 407 |
+
for pat in _FACT_PATTERNS.values():
|
| 408 |
+
for m in pat.finditer(text):
|
| 409 |
+
known_claims.add(_normalise_claim(m.group(0)))
|
| 410 |
+
|
| 411 |
+
for pat in _FACT_PATTERNS.values():
|
| 412 |
+
for m in pat.finditer(msg_text):
|
| 413 |
+
if _normalise_claim(m.group(0)) not in known_claims:
|
| 414 |
signals["hallucination_penalty"] = -0.25
|
| 415 |
break
|
| 416 |
if signals["hallucination_penalty"] < 0:
|
|
|
|
| 419 |
return signals
|
| 420 |
|
| 421 |
|
| 422 |
+
def _clamp_db_total(signals: dict) -> float:
|
| 423 |
+
"""Sum signals and clamp to [DB_TOTAL_MIN, DB_TOTAL_MAX] so the bucket can't
|
| 424 |
+
overwhelm the weighted-sum reward components."""
|
| 425 |
+
total = sum(v for v in signals.values() if isinstance(v, (int, float)))
|
| 426 |
+
return float(np.clip(total, DB_TOTAL_MIN, DB_TOTAL_MAX))
|
| 427 |
+
|
| 428 |
+
|
| 429 |
# ═══════════════════════════════════════════════════════════════════════════════
|
| 430 |
# Single-Agent Step Reward (Round 1 backward compat)
|
| 431 |
# ═══════════════════════════════════════════════════════════════════════════════
|
|
|
|
| 469 |
# DB grounding signals (all zero when no queries made)
|
| 470 |
_rd = retrieved_data or {"users": {}, "orders": {}}
|
| 471 |
db_signals = compute_db_signals(action, ticket, history, _rd)
|
| 472 |
+
db_total = _clamp_db_total(db_signals)
|
| 473 |
|
| 474 |
raw = (
|
| 475 |
0.40 * resolution_score
|
|
|
|
| 648 |
# ── DB grounding signals ───────────────────────────────────────────────────
|
| 649 |
_rd = retrieved_data or {"users": {}, "orders": {}}
|
| 650 |
db_signals = compute_db_signals(action, ticket, history, _rd)
|
| 651 |
+
db_total = _clamp_db_total(db_signals)
|
| 652 |
|
| 653 |
# ── Compute overall raw reward ─────────────────────────────────────────────
|
| 654 |
if is_terminal:
|
|
|
|
| 698 |
)
|
| 699 |
role_rewards["support_agent"] = float(np.clip(l1_raw, 0.0, 1.0))
|
| 700 |
|
| 701 |
+
# Supervisor role reward — escalation_penalty and unnecessary_manager_penalty
|
| 702 |
+
# are both ≤0; we clamp their sum first so a single bad call can drag the
|
| 703 |
+
# supervisor score down by at most 0.5 (the blended term below still leaves
|
| 704 |
+
# room for a recovery signal).
|
| 705 |
+
sup_decision_score = float(np.clip(
|
| 706 |
+
1.0 + escalation_penalty + unnecessary_manager_penalty, 0.0, 1.0
|
| 707 |
+
))
|
| 708 |
l2_raw = (
|
| 709 |
0.35 * oversight_score
|
| 710 |
+
+ 0.30 * sup_decision_score
|
| 711 |
+ 0.20 * policy_adherence_score
|
| 712 |
+ 0.15 * (1.0 if steps_used <= ideal_steps else 0.5)
|
| 713 |
)
|
| 714 |
role_rewards["supervisor"] = float(np.clip(l2_raw, 0.0, 1.0))
|
| 715 |
|
| 716 |
+
# Manager role reward. Previously a flat "terminal or zero" term punished
|
| 717 |
+
# legitimate MANAGER_SEND_BACK actions. We now use decision quality (from
|
| 718 |
+
# the LLM judge) as the primary signal and give a smaller bonus for actually
|
| 719 |
+
# resolving vs deferring, without crushing the score for a correct deferral.
|
| 720 |
+
manager_act_type = ActionType(action.action_type) if hasattr(action, "action_type") else None
|
| 721 |
+
if manager_act_type in {ActionType.MANAGER_RESOLVE, ActionType.MANAGER_OVERRIDE}:
|
| 722 |
+
resolve_bonus = 1.0
|
| 723 |
+
elif manager_act_type == ActionType.MANAGER_SEND_BACK:
|
| 724 |
+
resolve_bonus = 0.7 # legitimate deferral, still valuable
|
| 725 |
+
else:
|
| 726 |
+
resolve_bonus = 0.5 # non-manager action, neutral default
|
| 727 |
l3_raw = (
|
| 728 |
+
0.45 * decision_quality_score
|
| 729 |
+ 0.30 * (resolution_llm_score if is_terminal else 0.5)
|
| 730 |
+
+ 0.25 * resolve_bonus
|
| 731 |
)
|
| 732 |
role_rewards["manager"] = float(np.clip(l3_raw, 0.0, 1.0))
|
| 733 |
|
|
@@ -12,14 +12,18 @@ ABOVE THEM: A Manager handles escalated complex cases.
|
|
| 12 |
{supervisor_feedback_section}{manager_directive_section}{policy_section}
|
| 13 |
|
| 14 |
ACTION TYPES — output exactly one per step:
|
| 15 |
-
- "respond"
|
| 16 |
-
- "request_info"
|
| 17 |
-
- "close"
|
| 18 |
-
- "escalate"
|
|
|
|
|
|
|
| 19 |
|
| 20 |
SCORING: Empathy(30%) + Accuracy(25%) + Resolution(25%) + Efficiency(20%)
|
| 21 |
Be warm, gather info from "Unresolved issues", use specific resolution language.
|
| 22 |
If supervisor gave feedback, INCORPORATE it into your next action.
|
|
|
|
|
|
|
| 23 |
|
| 24 |
OUTPUT FORMAT — return ONLY this JSON, no code fences, no explanation:
|
| 25 |
{"action_type": "respond", "message": "..."} or {"action_type": "escalate", "reason": "..."}`;
|
|
@@ -110,12 +114,29 @@ function buildUserPrompt(obs: Observation, virtualMessages?: Message[]): string
|
|
| 110 |
const unresolved = obs.unresolved_issues.join(", ") || "none";
|
| 111 |
const envEvent = obs.environment_event ? `\n⚠️ POLICY EVENT: ${obs.environment_event}\n` : "";
|
| 112 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
return (
|
| 114 |
`Ticket: ${obs.subject}\n` +
|
| 115 |
`Category: ${obs.category} | Priority: ${obs.priority} | ` +
|
| 116 |
`Step: ${obs.step}/${obs.max_steps}\n` +
|
| 117 |
`Customer sentiment: ${obs.customer_sentiment.toFixed(2)}\n` +
|
| 118 |
-
`Unresolved issues: ${unresolved}${envEvent}\n\n` +
|
| 119 |
`Conversation:\n${historyText}\n\n` +
|
| 120 |
`What is your next action? Output JSON only.`
|
| 121 |
);
|
|
|
|
| 12 |
{supervisor_feedback_section}{manager_directive_section}{policy_section}
|
| 13 |
|
| 14 |
ACTION TYPES — output exactly one per step:
|
| 15 |
+
- "respond" → send a message to the customer → requires: "message"
|
| 16 |
+
- "request_info" → ask for missing information → requires: "message"
|
| 17 |
+
- "close" → close the ticket as resolved → requires: "message"
|
| 18 |
+
- "escalate" → hand off to specialist → requires: "reason"
|
| 19 |
+
- "query_user_profile" → look up customer DB (internal) → requires: "email"
|
| 20 |
+
- "query_order_details" → look up order DB (internal) → requires: "order_id"
|
| 21 |
|
| 22 |
SCORING: Empathy(30%) + Accuracy(25%) + Resolution(25%) + Efficiency(20%)
|
| 23 |
Be warm, gather info from "Unresolved issues", use specific resolution language.
|
| 24 |
If supervisor gave feedback, INCORPORATE it into your next action.
|
| 25 |
+
If the ticket references an email or order-id you haven't looked up yet, query
|
| 26 |
+
the DB first — never invent facts not present in KNOWN DATA or the conversation.
|
| 27 |
|
| 28 |
OUTPUT FORMAT — return ONLY this JSON, no code fences, no explanation:
|
| 29 |
{"action_type": "respond", "message": "..."} or {"action_type": "escalate", "reason": "..."}`;
|
|
|
|
| 114 |
const unresolved = obs.unresolved_issues.join(", ") || "none";
|
| 115 |
const envEvent = obs.environment_event ? `\n⚠️ POLICY EVENT: ${obs.environment_event}\n` : "";
|
| 116 |
|
| 117 |
+
// Surface any DB lookups the agent has already made so the LLM can cite
|
| 118 |
+
// verbatim values instead of hallucinating new ones.
|
| 119 |
+
const retrieved = obs.retrieved_data;
|
| 120 |
+
const userEntries = retrieved?.users ? Object.entries(retrieved.users) : [];
|
| 121 |
+
const orderEntries = retrieved?.orders ? Object.entries(retrieved.orders) : [];
|
| 122 |
+
let knownData = "";
|
| 123 |
+
if (userEntries.length > 0 || orderEntries.length > 0) {
|
| 124 |
+
knownData =
|
| 125 |
+
"\n## KNOWN DATA (from internal DB — use verbatim, do NOT invent other facts)\n";
|
| 126 |
+
for (const [email, record] of userEntries.slice(0, 4)) {
|
| 127 |
+
knownData += `User(${email}): ${JSON.stringify(record)}\n`;
|
| 128 |
+
}
|
| 129 |
+
for (const [oid, record] of orderEntries.slice(0, 4)) {
|
| 130 |
+
knownData += `Order(${oid}): ${JSON.stringify(record)}\n`;
|
| 131 |
+
}
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
return (
|
| 135 |
`Ticket: ${obs.subject}\n` +
|
| 136 |
`Category: ${obs.category} | Priority: ${obs.priority} | ` +
|
| 137 |
`Step: ${obs.step}/${obs.max_steps}\n` +
|
| 138 |
`Customer sentiment: ${obs.customer_sentiment.toFixed(2)}\n` +
|
| 139 |
+
`Unresolved issues: ${unresolved}${envEvent}${knownData}\n\n` +
|
| 140 |
`Conversation:\n${historyText}\n\n` +
|
| 141 |
`What is your next action? Output JSON only.`
|
| 142 |
);
|
|
@@ -17,6 +17,7 @@ import { CustomerChatInput } from "@/components/demo/CustomerChatInput";
|
|
| 17 |
// Sidebar panels
|
| 18 |
import { TicketInfoPanel } from "@/components/panels/TicketInfoPanel";
|
| 19 |
import { HierarchyPanel } from "@/components/panels/HierarchyPanel";
|
|
|
|
| 20 |
import { PolicyContextPanel } from "@/components/panels/PolicyContextPanel";
|
| 21 |
import { FrustrationMeter } from "@/components/indicators/FrustrationMeter";
|
| 22 |
import { ActiveRoleIndicator } from "@/components/indicators/ActiveRoleIndicator";
|
|
@@ -208,6 +209,10 @@ export default function DemoPage() {
|
|
| 208 |
</div>
|
| 209 |
)}
|
| 210 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
{observation.unresolved_issues.length > 0 && (
|
| 212 |
<div className="space-y-1.5">
|
| 213 |
<span className="text-[10px] text-neutral-500 uppercase tracking-wider">
|
|
|
|
| 17 |
// Sidebar panels
|
| 18 |
import { TicketInfoPanel } from "@/components/panels/TicketInfoPanel";
|
| 19 |
import { HierarchyPanel } from "@/components/panels/HierarchyPanel";
|
| 20 |
+
import { RetrievedDataPanel } from "@/components/panels/RetrievedDataPanel";
|
| 21 |
import { PolicyContextPanel } from "@/components/panels/PolicyContextPanel";
|
| 22 |
import { FrustrationMeter } from "@/components/indicators/FrustrationMeter";
|
| 23 |
import { ActiveRoleIndicator } from "@/components/indicators/ActiveRoleIndicator";
|
|
|
|
| 209 |
</div>
|
| 210 |
)}
|
| 211 |
|
| 212 |
+
<div className="border-t border-neutral-800 pt-3">
|
| 213 |
+
<RetrievedDataPanel observation={observation} />
|
| 214 |
+
</div>
|
| 215 |
+
|
| 216 |
{observation.unresolved_issues.length > 0 && (
|
| 217 |
<div className="space-y-1.5">
|
| 218 |
<span className="text-[10px] text-neutral-500 uppercase tracking-wider">
|
|
@@ -8,32 +8,75 @@ interface Props {
|
|
| 8 |
isLoading: boolean;
|
| 9 |
}
|
| 10 |
|
| 11 |
-
type Mode =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
export function SupportAgentActions({ isLoading }: Props) {
|
| 14 |
const { submitStep } = useSessionStore();
|
| 15 |
const [mode, setMode] = useState<Mode>(null);
|
| 16 |
const [message, setMessage] = useState("");
|
| 17 |
const [reason, setReason] = useState("");
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
const toggleMode = (m: Exclude<Mode, null>) => {
|
| 20 |
setMode((prev) => (prev === m ? null : m));
|
| 21 |
setMessage("");
|
| 22 |
setReason("");
|
|
|
|
|
|
|
| 23 |
};
|
| 24 |
|
| 25 |
const submit = async (action: Action) => {
|
| 26 |
await submitStep(action);
|
| 27 |
-
|
| 28 |
-
setMessage("");
|
| 29 |
-
setReason("");
|
| 30 |
};
|
| 31 |
|
| 32 |
const actionButtons = [
|
| 33 |
-
{
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
];
|
| 38 |
|
| 39 |
return (
|
|
@@ -71,7 +114,9 @@ export function SupportAgentActions({ isLoading }: Props) {
|
|
| 71 |
/>
|
| 72 |
<button
|
| 73 |
disabled={!message.trim() || isLoading}
|
| 74 |
-
onClick={() =>
|
|
|
|
|
|
|
| 75 |
className="self-end px-3 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-xs rounded
|
| 76 |
font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
| 77 |
>
|
|
@@ -93,7 +138,9 @@ export function SupportAgentActions({ isLoading }: Props) {
|
|
| 93 |
/>
|
| 94 |
<button
|
| 95 |
disabled={!reason.trim() || isLoading}
|
| 96 |
-
onClick={() =>
|
|
|
|
|
|
|
| 97 |
className="px-3 py-2 bg-orange-600 hover:bg-orange-500 text-white text-xs rounded font-medium
|
| 98 |
transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
| 99 |
>
|
|
@@ -103,14 +150,83 @@ export function SupportAgentActions({ isLoading }: Props) {
|
|
| 103 |
)}
|
| 104 |
|
| 105 |
{mode === "request_info" && (
|
| 106 |
-
<
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
)}
|
| 115 |
|
| 116 |
{mode === "close" && (
|
|
@@ -120,7 +236,9 @@ export function SupportAgentActions({ isLoading }: Props) {
|
|
| 120 |
</p>
|
| 121 |
<button
|
| 122 |
disabled={isLoading}
|
| 123 |
-
onClick={() =>
|
|
|
|
|
|
|
| 124 |
className="px-3 py-1.5 bg-green-700 hover:bg-green-600 text-white text-xs rounded font-medium
|
| 125 |
transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex-shrink-0"
|
| 126 |
>
|
|
|
|
| 8 |
isLoading: boolean;
|
| 9 |
}
|
| 10 |
|
| 11 |
+
type Mode =
|
| 12 |
+
| "respond"
|
| 13 |
+
| "request_info"
|
| 14 |
+
| "escalate"
|
| 15 |
+
| "close"
|
| 16 |
+
| "query_user"
|
| 17 |
+
| "query_order"
|
| 18 |
+
| null;
|
| 19 |
|
| 20 |
export function SupportAgentActions({ isLoading }: Props) {
|
| 21 |
const { submitStep } = useSessionStore();
|
| 22 |
const [mode, setMode] = useState<Mode>(null);
|
| 23 |
const [message, setMessage] = useState("");
|
| 24 |
const [reason, setReason] = useState("");
|
| 25 |
+
const [email, setEmail] = useState("");
|
| 26 |
+
const [orderId, setOrderId] = useState("");
|
| 27 |
+
|
| 28 |
+
const reset = () => {
|
| 29 |
+
setMode(null);
|
| 30 |
+
setMessage("");
|
| 31 |
+
setReason("");
|
| 32 |
+
setEmail("");
|
| 33 |
+
setOrderId("");
|
| 34 |
+
};
|
| 35 |
|
| 36 |
const toggleMode = (m: Exclude<Mode, null>) => {
|
| 37 |
setMode((prev) => (prev === m ? null : m));
|
| 38 |
setMessage("");
|
| 39 |
setReason("");
|
| 40 |
+
setEmail("");
|
| 41 |
+
setOrderId("");
|
| 42 |
};
|
| 43 |
|
| 44 |
const submit = async (action: Action) => {
|
| 45 |
await submitStep(action);
|
| 46 |
+
reset();
|
|
|
|
|
|
|
| 47 |
};
|
| 48 |
|
| 49 |
const actionButtons = [
|
| 50 |
+
{
|
| 51 |
+
id: "respond" as const,
|
| 52 |
+
label: "Respond",
|
| 53 |
+
color: "bg-indigo-600 hover:bg-indigo-500 text-white",
|
| 54 |
+
},
|
| 55 |
+
{
|
| 56 |
+
id: "request_info" as const,
|
| 57 |
+
label: "Request Info",
|
| 58 |
+
color: "bg-neutral-700 hover:bg-neutral-600 text-neutral-100",
|
| 59 |
+
},
|
| 60 |
+
{
|
| 61 |
+
id: "query_user" as const,
|
| 62 |
+
label: "Query User DB",
|
| 63 |
+
color: "bg-cyan-700/80 hover:bg-cyan-600 text-white",
|
| 64 |
+
},
|
| 65 |
+
{
|
| 66 |
+
id: "query_order" as const,
|
| 67 |
+
label: "Query Order DB",
|
| 68 |
+
color: "bg-teal-700/80 hover:bg-teal-600 text-white",
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
id: "escalate" as const,
|
| 72 |
+
label: "Escalate",
|
| 73 |
+
color: "bg-orange-600/80 hover:bg-orange-600 text-white",
|
| 74 |
+
},
|
| 75 |
+
{
|
| 76 |
+
id: "close" as const,
|
| 77 |
+
label: "Close Ticket",
|
| 78 |
+
color: "bg-green-700/80 hover:bg-green-700 text-white",
|
| 79 |
+
},
|
| 80 |
];
|
| 81 |
|
| 82 |
return (
|
|
|
|
| 114 |
/>
|
| 115 |
<button
|
| 116 |
disabled={!message.trim() || isLoading}
|
| 117 |
+
onClick={() =>
|
| 118 |
+
submit({ action_type: "respond", message, role: "support_agent" })
|
| 119 |
+
}
|
| 120 |
className="self-end px-3 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-xs rounded
|
| 121 |
font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
| 122 |
>
|
|
|
|
| 138 |
/>
|
| 139 |
<button
|
| 140 |
disabled={!reason.trim() || isLoading}
|
| 141 |
+
onClick={() =>
|
| 142 |
+
submit({ action_type: "escalate", reason, role: "support_agent" })
|
| 143 |
+
}
|
| 144 |
className="px-3 py-2 bg-orange-600 hover:bg-orange-500 text-white text-xs rounded font-medium
|
| 145 |
transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
| 146 |
>
|
|
|
|
| 150 |
)}
|
| 151 |
|
| 152 |
{mode === "request_info" && (
|
| 153 |
+
<div className="flex gap-2">
|
| 154 |
+
<input
|
| 155 |
+
placeholder="What info do you need from the customer?"
|
| 156 |
+
value={message}
|
| 157 |
+
onChange={(e) => setMessage(e.target.value)}
|
| 158 |
+
disabled={isLoading}
|
| 159 |
+
className="flex-1 bg-neutral-900 border border-neutral-700 rounded px-3 py-2 text-sm text-neutral-100
|
| 160 |
+
placeholder:text-neutral-600 focus:outline-none focus:ring-1 focus:ring-indigo-500
|
| 161 |
+
disabled:opacity-50"
|
| 162 |
+
/>
|
| 163 |
+
<button
|
| 164 |
+
disabled={!message.trim() || isLoading}
|
| 165 |
+
onClick={() =>
|
| 166 |
+
submit({ action_type: "request_info", message, role: "support_agent" })
|
| 167 |
+
}
|
| 168 |
+
className="px-3 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-100 text-xs rounded
|
| 169 |
+
font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
| 170 |
+
>
|
| 171 |
+
Send
|
| 172 |
+
</button>
|
| 173 |
+
</div>
|
| 174 |
+
)}
|
| 175 |
+
|
| 176 |
+
{mode === "query_user" && (
|
| 177 |
+
<div className="flex gap-2">
|
| 178 |
+
<input
|
| 179 |
+
placeholder="user@example.com — look up the account"
|
| 180 |
+
value={email}
|
| 181 |
+
onChange={(e) => setEmail(e.target.value)}
|
| 182 |
+
disabled={isLoading}
|
| 183 |
+
className="flex-1 bg-neutral-900 border border-cyan-700/50 rounded px-3 py-2 text-sm text-neutral-100
|
| 184 |
+
placeholder:text-neutral-600 focus:outline-none focus:ring-1 focus:ring-cyan-500
|
| 185 |
+
disabled:opacity-50 font-mono"
|
| 186 |
+
/>
|
| 187 |
+
<button
|
| 188 |
+
disabled={!email.trim() || isLoading}
|
| 189 |
+
onClick={() =>
|
| 190 |
+
submit({
|
| 191 |
+
action_type: "query_user_profile",
|
| 192 |
+
email: email.trim(),
|
| 193 |
+
role: "support_agent",
|
| 194 |
+
})
|
| 195 |
+
}
|
| 196 |
+
className="px-3 py-2 bg-cyan-700 hover:bg-cyan-600 text-white text-xs rounded font-medium
|
| 197 |
+
transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
| 198 |
+
>
|
| 199 |
+
Query
|
| 200 |
+
</button>
|
| 201 |
+
</div>
|
| 202 |
+
)}
|
| 203 |
+
|
| 204 |
+
{mode === "query_order" && (
|
| 205 |
+
<div className="flex gap-2">
|
| 206 |
+
<input
|
| 207 |
+
placeholder="ORD-FD-8821 — look up the order"
|
| 208 |
+
value={orderId}
|
| 209 |
+
onChange={(e) => setOrderId(e.target.value)}
|
| 210 |
+
disabled={isLoading}
|
| 211 |
+
className="flex-1 bg-neutral-900 border border-teal-700/50 rounded px-3 py-2 text-sm text-neutral-100
|
| 212 |
+
placeholder:text-neutral-600 focus:outline-none focus:ring-1 focus:ring-teal-500
|
| 213 |
+
disabled:opacity-50 font-mono"
|
| 214 |
+
/>
|
| 215 |
+
<button
|
| 216 |
+
disabled={!orderId.trim() || isLoading}
|
| 217 |
+
onClick={() =>
|
| 218 |
+
submit({
|
| 219 |
+
action_type: "query_order_details",
|
| 220 |
+
order_id: orderId.trim(),
|
| 221 |
+
role: "support_agent",
|
| 222 |
+
})
|
| 223 |
+
}
|
| 224 |
+
className="px-3 py-2 bg-teal-700 hover:bg-teal-600 text-white text-xs rounded font-medium
|
| 225 |
+
transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
|
| 226 |
+
>
|
| 227 |
+
Query
|
| 228 |
+
</button>
|
| 229 |
+
</div>
|
| 230 |
)}
|
| 231 |
|
| 232 |
{mode === "close" && (
|
|
|
|
| 236 |
</p>
|
| 237 |
<button
|
| 238 |
disabled={isLoading}
|
| 239 |
+
onClick={() =>
|
| 240 |
+
submit({ action_type: "close", role: "support_agent" })
|
| 241 |
+
}
|
| 242 |
className="px-3 py-1.5 bg-green-700 hover:bg-green-600 text-white text-xs rounded font-medium
|
| 243 |
transition-colors disabled:opacity-40 disabled:cursor-not-allowed flex-shrink-0"
|
| 244 |
>
|
|
@@ -0,0 +1,84 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { DbRecord, Observation } from "@/types";
|
| 2 |
+
|
| 3 |
+
interface Props {
|
| 4 |
+
observation: Observation;
|
| 5 |
+
}
|
| 6 |
+
|
| 7 |
+
function RecordCard({
|
| 8 |
+
kind,
|
| 9 |
+
id,
|
| 10 |
+
record,
|
| 11 |
+
}: {
|
| 12 |
+
kind: "user" | "order";
|
| 13 |
+
id: string;
|
| 14 |
+
record: DbRecord;
|
| 15 |
+
}) {
|
| 16 |
+
const isNotFound = record === "not_found";
|
| 17 |
+
const badge =
|
| 18 |
+
kind === "user"
|
| 19 |
+
? "bg-cyan-500/10 border-cyan-500/30 text-cyan-300"
|
| 20 |
+
: "bg-teal-500/10 border-teal-500/30 text-teal-300";
|
| 21 |
+
|
| 22 |
+
return (
|
| 23 |
+
<div className="rounded border border-neutral-800 bg-neutral-900/60 p-2 text-[11px]">
|
| 24 |
+
<div className="flex items-center justify-between gap-2 mb-1">
|
| 25 |
+
<span
|
| 26 |
+
className={`rounded border px-1.5 py-0.5 text-[9px] font-mono uppercase tracking-wider ${badge}`}
|
| 27 |
+
>
|
| 28 |
+
{kind}
|
| 29 |
+
</span>
|
| 30 |
+
<span className="font-mono text-neutral-400 truncate" title={id}>
|
| 31 |
+
{id}
|
| 32 |
+
</span>
|
| 33 |
+
</div>
|
| 34 |
+
{isNotFound ? (
|
| 35 |
+
<p className="font-mono text-amber-400">not_found</p>
|
| 36 |
+
) : (
|
| 37 |
+
<div className="space-y-0.5">
|
| 38 |
+
{Object.entries(record as Record<string, unknown>)
|
| 39 |
+
.slice(0, 8)
|
| 40 |
+
.map(([k, v]) => (
|
| 41 |
+
<div key={k} className="flex gap-2 font-mono text-neutral-300">
|
| 42 |
+
<span className="text-neutral-500">{k}:</span>
|
| 43 |
+
<span className="truncate">{JSON.stringify(v)}</span>
|
| 44 |
+
</div>
|
| 45 |
+
))}
|
| 46 |
+
{Object.keys(record as Record<string, unknown>).length > 8 && (
|
| 47 |
+
<p className="text-neutral-600 italic">
|
| 48 |
+
… {Object.keys(record as Record<string, unknown>).length - 8} more fields
|
| 49 |
+
</p>
|
| 50 |
+
)}
|
| 51 |
+
</div>
|
| 52 |
+
)}
|
| 53 |
+
</div>
|
| 54 |
+
);
|
| 55 |
+
}
|
| 56 |
+
|
| 57 |
+
export function RetrievedDataPanel({ observation }: Props) {
|
| 58 |
+
const retrieved = observation.retrieved_data ?? { users: {}, orders: {} };
|
| 59 |
+
const users = Object.entries(retrieved.users ?? {});
|
| 60 |
+
const orders = Object.entries(retrieved.orders ?? {});
|
| 61 |
+
|
| 62 |
+
if (users.length === 0 && orders.length === 0) return null;
|
| 63 |
+
|
| 64 |
+
return (
|
| 65 |
+
<div className="space-y-2">
|
| 66 |
+
<div className="flex items-center justify-between">
|
| 67 |
+
<span className="text-[10px] text-neutral-500 uppercase tracking-wider">
|
| 68 |
+
DB Lookups
|
| 69 |
+
</span>
|
| 70 |
+
<span className="text-[10px] text-neutral-600 font-mono">
|
| 71 |
+
{users.length + orders.length} record{users.length + orders.length === 1 ? "" : "s"}
|
| 72 |
+
</span>
|
| 73 |
+
</div>
|
| 74 |
+
<div className="space-y-1.5">
|
| 75 |
+
{users.map(([email, record]) => (
|
| 76 |
+
<RecordCard key={`u-${email}`} kind="user" id={email} record={record} />
|
| 77 |
+
))}
|
| 78 |
+
{orders.map(([oid, record]) => (
|
| 79 |
+
<RecordCard key={`o-${oid}`} kind="order" id={oid} record={record} />
|
| 80 |
+
))}
|
| 81 |
+
</div>
|
| 82 |
+
</div>
|
| 83 |
+
);
|
| 84 |
+
}
|
|
@@ -123,6 +123,16 @@ export const TASK_CONFIG: Record<
|
|
| 123 |
hinglishEnabled: true,
|
| 124 |
description: "Stage 4 — adversarial + Hinglish",
|
| 125 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
};
|
| 127 |
|
| 128 |
export const DIFFICULTY_COLORS = {
|
|
|
|
| 123 |
hinglishEnabled: true,
|
| 124 |
description: "Stage 4 — adversarial + Hinglish",
|
| 125 |
},
|
| 126 |
+
multi_domain: {
|
| 127 |
+
label: "Multi-Domain (DB)",
|
| 128 |
+
maxSteps: 8,
|
| 129 |
+
levels: [1],
|
| 130 |
+
driftProbability: 0,
|
| 131 |
+
difficulty: "hard",
|
| 132 |
+
hierarchical: true,
|
| 133 |
+
hinglishEnabled: false,
|
| 134 |
+
description: "Query user/order DB, cite grounded data, avoid hallucination",
|
| 135 |
+
},
|
| 136 |
};
|
| 137 |
|
| 138 |
export const DIFFICULTY_COLORS = {
|
|
@@ -9,13 +9,16 @@ export type TaskName =
|
|
| 9 |
| "curriculum_basic"
|
| 10 |
| "curriculum_supervisor"
|
| 11 |
| "curriculum_full_hierarchy"
|
| 12 |
-
| "curriculum_nightmare"
|
|
|
|
| 13 |
|
| 14 |
export type ActionType =
|
| 15 |
| "respond"
|
| 16 |
| "escalate"
|
| 17 |
| "close"
|
| 18 |
| "request_info"
|
|
|
|
|
|
|
| 19 |
| "supervisor_approve"
|
| 20 |
| "supervisor_reject"
|
| 21 |
| "supervisor_feedback"
|
|
@@ -24,6 +27,15 @@ export type ActionType =
|
|
| 24 |
| "manager_resolve"
|
| 25 |
| "manager_send_back";
|
| 26 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
export type AgentRole = "support_agent" | "supervisor" | "manager";
|
| 28 |
|
| 29 |
export type Priority = "low" | "medium" | "high" | "critical";
|
|
@@ -67,6 +79,7 @@ export interface Observation {
|
|
| 67 |
environment_event: string | null;
|
| 68 |
policy_context: string;
|
| 69 |
escalation_chain: string[];
|
|
|
|
| 70 |
}
|
| 71 |
|
| 72 |
export interface Reward {
|
|
@@ -90,6 +103,9 @@ export interface Action {
|
|
| 90 |
role?: AgentRole;
|
| 91 |
internal_note?: string | null;
|
| 92 |
feedback_to_agent?: string | null;
|
|
|
|
|
|
|
|
|
|
| 93 |
}
|
| 94 |
|
| 95 |
export interface ActionLogEntry {
|
|
|
|
| 9 |
| "curriculum_basic"
|
| 10 |
| "curriculum_supervisor"
|
| 11 |
| "curriculum_full_hierarchy"
|
| 12 |
+
| "curriculum_nightmare"
|
| 13 |
+
| "multi_domain";
|
| 14 |
|
| 15 |
export type ActionType =
|
| 16 |
| "respond"
|
| 17 |
| "escalate"
|
| 18 |
| "close"
|
| 19 |
| "request_info"
|
| 20 |
+
| "query_user_profile"
|
| 21 |
+
| "query_order_details"
|
| 22 |
| "supervisor_approve"
|
| 23 |
| "supervisor_reject"
|
| 24 |
| "supervisor_feedback"
|
|
|
|
| 27 |
| "manager_resolve"
|
| 28 |
| "manager_send_back";
|
| 29 |
|
| 30 |
+
// Record value from a DB lookup — either the resolved dict or the literal
|
| 31 |
+
// "not_found" sentinel when the email / order_id was not in the DB.
|
| 32 |
+
export type DbRecord = Record<string, unknown> | "not_found";
|
| 33 |
+
|
| 34 |
+
export interface RetrievedData {
|
| 35 |
+
users: Record<string, DbRecord>;
|
| 36 |
+
orders: Record<string, DbRecord>;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
export type AgentRole = "support_agent" | "supervisor" | "manager";
|
| 40 |
|
| 41 |
export type Priority = "low" | "medium" | "high" | "critical";
|
|
|
|
| 79 |
environment_event: string | null;
|
| 80 |
policy_context: string;
|
| 81 |
escalation_chain: string[];
|
| 82 |
+
retrieved_data?: RetrievedData;
|
| 83 |
}
|
| 84 |
|
| 85 |
export interface Reward {
|
|
|
|
| 103 |
role?: AgentRole;
|
| 104 |
internal_note?: string | null;
|
| 105 |
feedback_to_agent?: string | null;
|
| 106 |
+
// DB query fields (only used by query_user_profile / query_order_details)
|
| 107 |
+
email?: string | null;
|
| 108 |
+
order_id?: string | null;
|
| 109 |
}
|
| 110 |
|
| 111 |
export interface ActionLogEntry {
|
|
@@ -1,33 +1,44 @@
|
|
| 1 |
name: customer-support-env
|
| 2 |
-
version: "2.
|
| 3 |
description: >
|
| 4 |
Hierarchical multi-agent RL environment for AI customer support training.
|
| 5 |
Features a 3-level agent hierarchy (Support Agent → Supervisor → Manager),
|
| 6 |
LLM-driven customer simulator with Hinglish support, dynamic policy drift,
|
| 7 |
-
hybrid dense rewards (rule-based + LLM-as-Judge),
|
| 8 |
-
|
|
|
|
|
|
|
| 9 |
Backward compatible with Round 1 single-agent tasks.
|
| 10 |
author: Team X-Force
|
| 11 |
curriculum:
|
| 12 |
description: >
|
| 13 |
-
Progressive
|
| 14 |
dense initial rewards so models learn basic behavior. Each subsequent level
|
| 15 |
introduces new agents, mechanisms, and challenges. Agents trained on lower
|
| 16 |
levels transfer skills upward — empathy from L1-only → oversight from L2 →
|
| 17 |
-
coordination from full hierarchy → adversarial resilience from nightmare
|
|
|
|
| 18 |
stages:
|
| 19 |
- stage: 1
|
| 20 |
task: curriculum_basic
|
| 21 |
objective: "Learn empathy, info-gathering, and resolution fundamentals"
|
|
|
|
| 22 |
- stage: 2
|
| 23 |
task: curriculum_supervisor
|
| 24 |
objective: "Learn to accept feedback and improve actions iteratively"
|
|
|
|
| 25 |
- stage: 3
|
| 26 |
task: curriculum_full_hierarchy
|
| 27 |
objective: "Coordinate across all 3 levels under policy drift"
|
|
|
|
| 28 |
- stage: 4
|
| 29 |
task: curriculum_nightmare
|
| 30 |
objective: "Handle extreme adversarial conditions with Hinglish, multi-drift, and high frustration"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
tasks:
|
| 32 |
# ── Round 1: Single-agent tasks (backward compatible) ──────────────────────
|
| 33 |
- name: easy
|
|
@@ -187,18 +198,34 @@ observation_space:
|
|
| 187 |
escalation_reason: "string | null"
|
| 188 |
reward_range: [0.0, 1.0]
|
| 189 |
reward_signals:
|
| 190 |
-
# Single-agent mode
|
| 191 |
-
resolution: 0.40
|
| 192 |
-
tone: 0.20
|
| 193 |
-
efficiency: 0.20
|
| 194 |
-
accuracy: 0.20
|
| 195 |
-
# Hierarchy mode (
|
| 196 |
-
empathy: "LLM-as-Judge"
|
| 197 |
-
policy_adherence: "LLM-as-Judge"
|
| 198 |
-
oversight_quality: "LLM-as-Judge"
|
| 199 |
-
decision_quality: "LLM-as-Judge"
|
| 200 |
-
hierarchy_effectiveness: "Rule-based"
|
| 201 |
-
sla_compliance: "Rule-based"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 202 |
tags:
|
| 203 |
- customer-support
|
| 204 |
- multi-agent
|
|
|
|
| 1 |
name: customer-support-env
|
| 2 |
+
version: "2.2.0"
|
| 3 |
description: >
|
| 4 |
Hierarchical multi-agent RL environment for AI customer support training.
|
| 5 |
Features a 3-level agent hierarchy (Support Agent → Supervisor → Manager),
|
| 6 |
LLM-driven customer simulator with Hinglish support, dynamic policy drift,
|
| 7 |
+
hybrid dense rewards (rule-based + LLM-as-Judge), an in-memory DB across
|
| 8 |
+
three domains (food delivery, e-commerce, ticket booking) with grounded
|
| 9 |
+
response rewards + hallucination penalties, and a progressive 5-stage
|
| 10 |
+
curriculum that systematically increases complexity for RL training.
|
| 11 |
Backward compatible with Round 1 single-agent tasks.
|
| 12 |
author: Team X-Force
|
| 13 |
curriculum:
|
| 14 |
description: >
|
| 15 |
+
Progressive 5-stage curriculum designed for RL training. Easy tasks provide
|
| 16 |
dense initial rewards so models learn basic behavior. Each subsequent level
|
| 17 |
introduces new agents, mechanisms, and challenges. Agents trained on lower
|
| 18 |
levels transfer skills upward — empathy from L1-only → oversight from L2 →
|
| 19 |
+
coordination from full hierarchy → adversarial resilience from nightmare →
|
| 20 |
+
DB-grounded reasoning from multi_domain.
|
| 21 |
stages:
|
| 22 |
- stage: 1
|
| 23 |
task: curriculum_basic
|
| 24 |
objective: "Learn empathy, info-gathering, and resolution fundamentals"
|
| 25 |
+
advance_threshold: 0.65
|
| 26 |
- stage: 2
|
| 27 |
task: curriculum_supervisor
|
| 28 |
objective: "Learn to accept feedback and improve actions iteratively"
|
| 29 |
+
advance_threshold: 0.60
|
| 30 |
- stage: 3
|
| 31 |
task: curriculum_full_hierarchy
|
| 32 |
objective: "Coordinate across all 3 levels under policy drift"
|
| 33 |
+
advance_threshold: 0.55
|
| 34 |
- stage: 4
|
| 35 |
task: curriculum_nightmare
|
| 36 |
objective: "Handle extreme adversarial conditions with Hinglish, multi-drift, and high frustration"
|
| 37 |
+
advance_threshold: 0.50
|
| 38 |
+
- stage: 5
|
| 39 |
+
task: multi_domain
|
| 40 |
+
objective: "Query a DB for real user/order data, cite grounded facts verbatim, avoid hallucination"
|
| 41 |
+
advance_threshold: null # final stage — no further advancement
|
| 42 |
tasks:
|
| 43 |
# ── Round 1: Single-agent tasks (backward compatible) ──────────────────────
|
| 44 |
- name: easy
|
|
|
|
| 198 |
escalation_reason: "string | null"
|
| 199 |
reward_range: [0.0, 1.0]
|
| 200 |
reward_signals:
|
| 201 |
+
# Single-agent mode (Round 1)
|
| 202 |
+
resolution: 0.40 # keyword-grounded resolution score on terminal
|
| 203 |
+
tone: 0.20 # VADER sentiment on agent messages
|
| 204 |
+
efficiency: 0.20 # steps_used vs max_steps
|
| 205 |
+
accuracy: 0.20 # required_info_before_close found in conversation
|
| 206 |
+
# Hierarchy mode (Round 2, overall weights sum to 1.0 at terminal step)
|
| 207 |
+
empathy: "LLM-as-Judge" # 0.15 weight
|
| 208 |
+
policy_adherence: "LLM-as-Judge" # 0.15 weight
|
| 209 |
+
oversight_quality: "LLM-as-Judge" # supervisor role_reward input
|
| 210 |
+
decision_quality: "LLM-as-Judge" # manager role_reward input
|
| 211 |
+
hierarchy_effectiveness: "Rule-based"# 0.10 weight
|
| 212 |
+
sla_compliance: "Rule-based" # 0.15 weight
|
| 213 |
+
# DB grounding signals (Round 2, multi_domain task)
|
| 214 |
+
query_match_bonus: "+0.08" # queried the ticket's email/order
|
| 215 |
+
wasted_query_penalty: "-0.08" # queried something the customer never mentioned
|
| 216 |
+
grounded_response_bonus: "+0.10" # cited verbatim DB data in a substantive reply (≥30 chars)
|
| 217 |
+
not_found_handling_bonus: "+0.08" # responded to a not_found with REQUEST_INFO / ESCALATE
|
| 218 |
+
hallucination_penalty: "-0.25" # invented a fact (amount/date/email/order) not in DB or customer text
|
| 219 |
+
# Anti-gaming penalties (apply across all tasks)
|
| 220 |
+
loop_penalty: "-0.12" # any pair of the last 4 agent messages >80% similar
|
| 221 |
+
keyword_stuffing_penalty: "-0.30" # reward-keyword density >20% without substance
|
| 222 |
+
contradiction_penalty: "-0.15" # claimed resolution then asked for info
|
| 223 |
+
escalation_penalty: "-0.30" # escalated a low/medium priority ticket
|
| 224 |
+
ignored_feedback_penalty: "-0.15" # L1 did not incorporate supervisor feedback
|
| 225 |
+
unnecessary_manager_penalty: "-0.20" # L2 escalated to L3 on a low/medium ticket
|
| 226 |
+
reward_clamping:
|
| 227 |
+
db_total: "[-0.30, +0.25]" # sum of all DB signals is clamped before joining raw
|
| 228 |
+
raw_to_value: "[0.0, 1.0]" # final Reward.value is always clamped to [0,1]
|
| 229 |
tags:
|
| 230 |
- customer-support
|
| 231 |
- multi-agent
|
|
@@ -183,7 +183,7 @@ def test_loop_penalty_identical_messages():
|
|
| 183 |
Message(role="agent", content="I understand your frustration. Please bear with us."),
|
| 184 |
]
|
| 185 |
penalty = compute_loop_penalty(history)
|
| 186 |
-
assert penalty == -0.
|
| 187 |
|
| 188 |
def test_loop_penalty_different_messages():
|
| 189 |
history = [
|
|
@@ -370,9 +370,9 @@ def test_hierarchy_state_includes_hierarchy(hierarchy_env):
|
|
| 370 |
|
| 371 |
@pytest.fixture(scope="module")
|
| 372 |
def client():
|
| 373 |
-
from server.app import app, _sessions
|
| 374 |
_sessions.clear()
|
| 375 |
-
with TestClient(app, raise_server_exceptions=False) as c:
|
| 376 |
yield c
|
| 377 |
_sessions.clear()
|
| 378 |
|
|
|
|
| 183 |
Message(role="agent", content="I understand your frustration. Please bear with us."),
|
| 184 |
]
|
| 185 |
penalty = compute_loop_penalty(history)
|
| 186 |
+
assert penalty == -0.12
|
| 187 |
|
| 188 |
def test_loop_penalty_different_messages():
|
| 189 |
history = [
|
|
|
|
| 370 |
|
| 371 |
@pytest.fixture(scope="module")
|
| 372 |
def client():
|
| 373 |
+
from server.app import app, _sessions, EXPECTED_API_KEY
|
| 374 |
_sessions.clear()
|
| 375 |
+
with TestClient(app, raise_server_exceptions=False, headers={"X-API-Key": EXPECTED_API_KEY}) as c:
|
| 376 |
yield c
|
| 377 |
_sessions.clear()
|
| 378 |
|
|
@@ -85,13 +85,25 @@ def grpo_loss(
|
|
| 85 |
# ── Old log-probs (at generation time, no gradient) ───────────────
|
| 86 |
if step.log_probs is not None and isinstance(step.log_probs, torch.Tensor):
|
| 87 |
old_lp = step.log_probs.to(device).detach()
|
| 88 |
-
# Align lengths (generation-time vs recomputed may differ slightly)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
min_len = min(cur_lp.shape[0], old_lp.shape[0])
|
| 90 |
cur_lp_aligned = cur_lp[:min_len]
|
| 91 |
old_lp_aligned = old_lp[:min_len]
|
| 92 |
else:
|
|
|
|
|
|
|
| 93 |
cur_lp_aligned = cur_lp
|
| 94 |
-
old_lp_aligned = cur_lp.detach()
|
| 95 |
|
| 96 |
# ── Reference model log-probs (no gradient) ───────────────────────
|
| 97 |
with torch.no_grad():
|
|
|
|
| 85 |
# ── Old log-probs (at generation time, no gradient) ───────────────
|
| 86 |
if step.log_probs is not None and isinstance(step.log_probs, torch.Tensor):
|
| 87 |
old_lp = step.log_probs.to(device).detach()
|
| 88 |
+
# Align lengths (generation-time vs recomputed may differ slightly).
|
| 89 |
+
# Warn once per training run if divergence exceeds 10% — this usually
|
| 90 |
+
# signals a tokenizer/prompt mismatch between rollout and loss.
|
| 91 |
+
if abs(cur_lp.shape[0] - old_lp.shape[0]) > max(4, int(0.10 * max(cur_lp.shape[0], 1))):
|
| 92 |
+
if not getattr(grpo_loss, "_logprob_warned", False):
|
| 93 |
+
print(
|
| 94 |
+
f"[GRPO] warn: large log-prob length mismatch "
|
| 95 |
+
f"cur={cur_lp.shape[0]} old={old_lp.shape[0]}; "
|
| 96 |
+
f"check that tokenizer/prompt match between rollout and loss recompute"
|
| 97 |
+
)
|
| 98 |
+
grpo_loss._logprob_warned = True # type: ignore[attr-defined]
|
| 99 |
min_len = min(cur_lp.shape[0], old_lp.shape[0])
|
| 100 |
cur_lp_aligned = cur_lp[:min_len]
|
| 101 |
old_lp_aligned = old_lp[:min_len]
|
| 102 |
else:
|
| 103 |
+
# Generation failed or fallback action was used. Ratio=1 means
|
| 104 |
+
# this step contributes no policy-gradient signal (KL term only).
|
| 105 |
cur_lp_aligned = cur_lp
|
| 106 |
+
old_lp_aligned = cur_lp.detach()
|
| 107 |
|
| 108 |
# ── Reference model log-probs (no gradient) ───────────────────────
|
| 109 |
with torch.no_grad():
|
|
@@ -126,13 +126,36 @@ OUTPUT FORMAT — return ONLY this JSON:
|
|
| 126 |
|
| 127 |
# ── Prompt builders ────────────────────────────────────────────────────────────
|
| 128 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
def _build_user_context(obs: Dict[str, Any]) -> str:
|
| 130 |
-
"""Build the shared user context block (ticket + conversation).
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
import json as _json
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 136 |
unresolved = ", ".join(obs.get("unresolved_issues", [])) or "none"
|
| 137 |
env_event = obs.get("environment_event")
|
| 138 |
|
|
@@ -146,18 +169,24 @@ def _build_user_context(obs: Dict[str, Any]) -> str:
|
|
| 146 |
if env_event:
|
| 147 |
ctx += f"\n⚠️ ENVIRONMENT EVENT: {env_event}\n"
|
| 148 |
|
| 149 |
-
# Show DB-retrieved data when present
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
| 154 |
ctx += "\n## KNOWN DATA (from internal DB — use verbatim, do NOT invent other facts)\n"
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
if
|
| 159 |
-
|
| 160 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
|
| 162 |
ctx += f"\nConversation:\n{history_text}\n\nOutput JSON only."
|
| 163 |
return ctx
|
|
|
|
| 126 |
|
| 127 |
# ── Prompt builders ────────────────────────────────────────────────────────────
|
| 128 |
|
| 129 |
+
# Hard caps to keep the user-context block from eating the generation budget.
|
| 130 |
+
# Cover long multi-turn episodes (30+ messages) without overflowing 4096 tokens.
|
| 131 |
+
_MAX_HISTORY_TURNS = 12 # keep at most the last N messages
|
| 132 |
+
_MAX_DB_RECORDS_EACH = 4 # show at most N user and N order records
|
| 133 |
+
_HISTORY_HEAD_KEEP = 2 # always keep the first 2 messages (customer opener + first agent reply)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
def _build_user_context(obs: Dict[str, Any]) -> str:
|
| 137 |
+
"""Build the shared user context block (ticket + conversation + DB data).
|
| 138 |
+
|
| 139 |
+
Truncates long histories and large DB result sets so the prompt never
|
| 140 |
+
overflows the generation budget. Truncation is visible to the model via
|
| 141 |
+
ellipsis markers so it can reason about the elision.
|
| 142 |
+
"""
|
| 143 |
import json as _json
|
| 144 |
+
|
| 145 |
+
history = obs.get("conversation_history", []) or []
|
| 146 |
+
if len(history) > _MAX_HISTORY_TURNS:
|
| 147 |
+
head = history[:_HISTORY_HEAD_KEEP]
|
| 148 |
+
tail = history[-(_MAX_HISTORY_TURNS - _HISTORY_HEAD_KEEP):]
|
| 149 |
+
skipped = len(history) - len(head) - len(tail)
|
| 150 |
+
history_lines = [f"{m['role'].upper()}: {m['content']}" for m in head]
|
| 151 |
+
history_lines.append(f"… [{skipped} earlier messages omitted] …")
|
| 152 |
+
history_lines.extend(f"{m['role'].upper()}: {m['content']}" for m in tail)
|
| 153 |
+
history_text = "\n".join(history_lines)
|
| 154 |
+
else:
|
| 155 |
+
history_text = "\n".join(
|
| 156 |
+
f"{m['role'].upper()}: {m['content']}" for m in history
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
unresolved = ", ".join(obs.get("unresolved_issues", [])) or "none"
|
| 160 |
env_event = obs.get("environment_event")
|
| 161 |
|
|
|
|
| 169 |
if env_event:
|
| 170 |
ctx += f"\n⚠️ ENVIRONMENT EVENT: {env_event}\n"
|
| 171 |
|
| 172 |
+
# Show DB-retrieved data when present. Cap the number of records shown so
|
| 173 |
+
# a zealous agent that queried many emails/orders doesn't push conversation
|
| 174 |
+
# history out of context.
|
| 175 |
+
retrieved = obs.get("retrieved_data", {}) or {}
|
| 176 |
+
users = retrieved.get("users") or {}
|
| 177 |
+
orders = retrieved.get("orders") or {}
|
| 178 |
+
if users or orders:
|
| 179 |
ctx += "\n## KNOWN DATA (from internal DB — use verbatim, do NOT invent other facts)\n"
|
| 180 |
+
user_items = list(users.items())[:_MAX_DB_RECORDS_EACH]
|
| 181 |
+
for email, record in user_items:
|
| 182 |
+
ctx += f"User({email}): {_json.dumps(record, ensure_ascii=False)}\n"
|
| 183 |
+
if len(users) > _MAX_DB_RECORDS_EACH:
|
| 184 |
+
ctx += f"… ({len(users) - _MAX_DB_RECORDS_EACH} more user records truncated) …\n"
|
| 185 |
+
order_items = list(orders.items())[:_MAX_DB_RECORDS_EACH]
|
| 186 |
+
for oid, record in order_items:
|
| 187 |
+
ctx += f"Order({oid}): {_json.dumps(record, ensure_ascii=False)}\n"
|
| 188 |
+
if len(orders) > _MAX_DB_RECORDS_EACH:
|
| 189 |
+
ctx += f"… ({len(orders) - _MAX_DB_RECORDS_EACH} more order records truncated) …\n"
|
| 190 |
|
| 191 |
ctx += f"\nConversation:\n{history_text}\n\nOutput JSON only."
|
| 192 |
return ctx
|
|
@@ -90,12 +90,27 @@ def grpo_advantages(rewards: List[float], eps: float = 1e-8) -> List[float]:
|
|
| 90 |
|
| 91 |
A_i = (R_i − μ) / (σ + ε)
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
If all rewards in the group are identical (σ = 0), all advantages are 0.
|
| 94 |
"""
|
| 95 |
if not rewards:
|
| 96 |
return []
|
| 97 |
n = len(rewards)
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
sigma = var ** 0.5
|
| 101 |
return [(r - mu) / (sigma + eps) for r in rewards]
|
|
|
|
| 90 |
|
| 91 |
A_i = (R_i − μ) / (σ + ε)
|
| 92 |
|
| 93 |
+
Robustness: when rewards contain obvious invalid-episode sentinels (values
|
| 94 |
+
well below the usual range, e.g. -0.5), computing μ/σ over the *full* group
|
| 95 |
+
pulls valid episodes' advantages artificially larger. We instead compute μ/σ
|
| 96 |
+
over the valid subset when we detect clear outliers, but still return an
|
| 97 |
+
advantage for every input index (invalid episodes get their literal
|
| 98 |
+
deviation, which is correctly negative).
|
| 99 |
+
|
| 100 |
If all rewards in the group are identical (σ = 0), all advantages are 0.
|
| 101 |
"""
|
| 102 |
if not rewards:
|
| 103 |
return []
|
| 104 |
n = len(rewards)
|
| 105 |
+
# Detect invalid sentinels: rewards that are significantly below the
|
| 106 |
+
# minimum of the "normal" range. Our invalid_penalty defaults to -0.5;
|
| 107 |
+
# anything at or below that is treated as an outlier for μ/σ estimation.
|
| 108 |
+
valid = [r for r in rewards if r > -0.5 + 1e-6]
|
| 109 |
+
if 0 < len(valid) < n:
|
| 110 |
+
mu = sum(valid) / len(valid)
|
| 111 |
+
var = sum((r - mu) ** 2 for r in valid) / len(valid)
|
| 112 |
+
else:
|
| 113 |
+
mu = sum(rewards) / n
|
| 114 |
+
var = sum((r - mu) ** 2 for r in rewards) / n
|
| 115 |
sigma = var ** 0.5
|
| 116 |
return [(r - mu) / (sigma + eps) for r in rewards]
|