lebiraja Claude Opus 4.7 commited on
Commit
1fa04c3
·
1 Parent(s): 33c927a

chore(round2): full audit pass — harden rewards, patch frontend, tighten training

Browse files

Backend — 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 CHANGED
@@ -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 4-stage curriculum
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.1.0**
30
 
31
  [![OpenEnv](https://img.shields.io/badge/OpenEnv-Compliant-brightgreen?style=for-the-badge)](https://github.com/OpenEnvs)
32
  [![Python 3.11+](https://img.shields.io/badge/Python-3.11+-blue?style=for-the-badge&logo=python)](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 is the first to combine all four challenges:** 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, and a progressive 4-stage curriculum that builds each skill incrementally.
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 4-stage training pipeline where each stage introduces exactly one new dimension of complexity. This prevents catastrophic forgetting and ensures agents build skills incrementally.
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 **6 distinct anti-gaming measures** to ensure agents earn rewards through genuine quality:
224
 
225
  | Guard | Penalty | Detection Method |
226
  |-------|:-------:|---|
227
  | **Keyword stuffing** | −0.30 | Word density > 20% resolution/empathy keywords without substance |
228
- | **Loop detection** | −0.10 | SequenceMatcher ratio > 0.85 between consecutive responses |
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
- | **Hostile tone** | ×0.4 | VADER negative sentiment on agent message |
232
- | **Injection attempt** | −0.50 | Prompt injection patterns detected in agent output |
 
 
 
 
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 4 curriculum stages. The environment API provides all rewards — no separate reward model needed.
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, 4-stage curriculum)
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** | 4-stage curriculum with auto-advancement, before/after training evidence, reward curve analysis |
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
  [![OpenEnv](https://img.shields.io/badge/OpenEnv-Compliant-brightgreen?style=for-the-badge)](https://github.com/OpenEnvs)
32
  [![Python 3.11+](https://img.shields.io/badge/Python-3.11+-blue?style=for-the-badge&logo=python)](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
 
env/environment.py CHANGED
@@ -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": dict(self._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=dict(self._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
- from env.reward_engine import compute_db_signals
 
 
261
  db_signals = compute_db_signals(action, self._ticket, self._history, self._retrieved_data)
262
- raw_db = sum(db_signals.values())
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=dict(self._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,
env/reward_engine.py CHANGED
@@ -100,19 +100,23 @@ def _agent_messages(history: List[Message]) -> List[str]:
100
 
101
  def compute_loop_penalty(history: List[Message]) -> float:
102
  """
103
- Detects repeated agent messages via TF-IDF cosine similarity
104
- AND character-level SequenceMatcher ratio.
105
- Returns -0.1 if either threshold exceeded.
 
 
 
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
- char_sim = SequenceMatcher(None, last_two[0], last_two[1]).ratio()
115
- return -0.1 if char_sim > 0.85 else 0.0
 
 
 
 
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
- "amount": re.compile(r"\s*\d+|\brs\.?\s*\d+|\brupees?\s*\d+|\d+\s*(?:rupees?|inr)\b", re.IGNORECASE),
249
- "date": re.compile(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", re.IGNORECASE),
 
 
 
 
 
 
 
 
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
- Returns a dict of signal_name float (positive = bonus, negative = penalty).
263
- Returns all zeros when no DB queries have been made (backward-compatible).
 
 
 
 
 
 
 
 
 
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
- ticket_email_lower = ticket_email.lower() if ticket_email else ""
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
- _GOOD_NOT_FOUND_ACTIONS = {
304
- ActionType.REQUEST_INFO, ActionType.ESCALATE, ActionType.SUPERVISOR_ESCALATE,
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 bonus & hallucination penalty ────────────────────────
313
- msg_text = (action.message or action.reason or "").lower()
314
- if msg_text and at in {
315
  ActionType.RESPOND, ActionType.CLOSE, ActionType.REQUEST_INFO,
316
  ActionType.MANAGER_OVERRIDE, ActionType.MANAGER_RESOLVE,
317
- }:
318
- # Collect all verbatim field values from retrieved_data
319
- grounded_values: List[str] = []
320
- for user_record in users_data.values():
321
- if isinstance(user_record, dict):
322
- grounded_values.extend(str(v).lower() for v in user_record.values())
323
- for order_record in orders_data.values():
324
- if isinstance(order_record, dict):
325
- for v in order_record.values():
326
- if isinstance(v, list):
327
- grounded_values.extend(str(i).lower() for i in v)
328
- else:
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
- # Penalty: message contains a specific fact pattern NOT in retrieved_data
338
- # and NOT mentioned by the customer
339
- customer_text = " ".join(
340
- m.content for m in history if m.role == "customer"
341
- ).lower()
342
- all_known_text = customer_text + " " + " ".join(grounded_values)
343
-
344
- for pat_name, pat in _FACT_PATTERNS.items():
345
- for match in pat.finditer(msg_text):
346
- claimed = match.group(0).lower()
347
- if claimed not in all_known_text:
 
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 = sum(db_signals.values())
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 = sum(db_signals.values())
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 * (1.0 + escalation_penalty + unnecessary_manager_penalty)
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.40 * decision_quality_score
640
  + 0.30 * (resolution_llm_score if is_terminal else 0.5)
641
- + 0.30 * (1.0 if is_terminal else 0.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
 
frontend/src/app/api/ai-action/route.ts CHANGED
@@ -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" → 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
 
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
  );
frontend/src/app/demo/page.tsx CHANGED
@@ -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">
frontend/src/components/action/SupportAgentActions.tsx CHANGED
@@ -8,32 +8,75 @@ interface Props {
8
  isLoading: boolean;
9
  }
10
 
11
- type Mode = "respond" | "request_info" | "escalate" | "close" | null;
 
 
 
 
 
 
 
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
- setMode(null);
28
- setMessage("");
29
- setReason("");
30
  };
31
 
32
  const actionButtons = [
33
- { id: "respond" as const, label: "Respond", color: "bg-indigo-600 hover:bg-indigo-500 text-white" },
34
- { id: "request_info" as const, label: "Request Info", color: "bg-neutral-700 hover:bg-neutral-600 text-neutral-100" },
35
- { id: "escalate" as const, label: "Escalate", color: "bg-orange-600/80 hover:bg-orange-600 text-white" },
36
- { id: "close" as const, label: "Close Ticket", color: "bg-green-700/80 hover:bg-green-700 text-white" },
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
37
  ];
38
 
39
  return (
@@ -71,7 +114,9 @@ export function SupportAgentActions({ isLoading }: Props) {
71
  />
72
  <button
73
  disabled={!message.trim() || isLoading}
74
- onClick={() => submit({ action_type: "respond", message, role: "support_agent" })}
 
 
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={() => submit({ action_type: "escalate", reason, role: "support_agent" })}
 
 
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
- <button
107
- disabled={isLoading}
108
- onClick={() => submit({ action_type: "request_info", role: "support_agent" })}
109
- className="px-3 py-2 bg-neutral-700 hover:bg-neutral-600 text-neutral-100 text-xs rounded
110
- font-medium transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
111
- >
112
- Send Information Request
113
- </button>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
114
  )}
115
 
116
  {mode === "close" && (
@@ -120,7 +236,9 @@ export function SupportAgentActions({ isLoading }: Props) {
120
  </p>
121
  <button
122
  disabled={isLoading}
123
- onClick={() => submit({ action_type: "close", role: "support_agent" })}
 
 
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
  >
frontend/src/components/panels/RetrievedDataPanel.tsx ADDED
@@ -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
+ }
frontend/src/lib/constants.ts CHANGED
@@ -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 = {
frontend/src/types/index.ts CHANGED
@@ -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 {
openenv.yaml CHANGED
@@ -1,33 +1,44 @@
1
  name: customer-support-env
2
- version: "2.1.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), and a progressive
8
- 4-stage curriculum that systematically increases complexity for RL training.
 
 
9
  Backward compatible with Round 1 single-agent tasks.
10
  author: Team X-Force
11
  curriculum:
12
  description: >
13
- Progressive 4-stage curriculum designed for RL training. Easy tasks provide
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 (additional)
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
tests/test_env.py CHANGED
@@ -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.1
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
 
train/grpo_trainer.py CHANGED
@@ -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() # ratio = 1, no update
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():
train/prompt_builder.py CHANGED
@@ -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
- history_text = "\n".join(
133
- f"{m['role'].upper()}: {m['content']}"
134
- for m in obs.get("conversation_history", [])
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
- retrieved = obs.get("retrieved_data", {})
151
- has_users = bool(retrieved.get("users"))
152
- has_orders = bool(retrieved.get("orders"))
153
- if has_users or has_orders:
 
 
154
  ctx += "\n## KNOWN DATA (from internal DB — use verbatim, do NOT invent other facts)\n"
155
- if has_users:
156
- for email, record in retrieved["users"].items():
157
- ctx += f"User({email}): {_json.dumps(record, ensure_ascii=False)}\n"
158
- if has_orders:
159
- for oid, record in retrieved["orders"].items():
160
- ctx += f"Order({oid}): {_json.dumps(record, ensure_ascii=False)}\n"
 
 
 
 
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
train/reward_aggregator.py CHANGED
@@ -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
- mu = sum(rewards) / n
99
- var = sum((r - mu) ** 2 for r in rewards) / n
 
 
 
 
 
 
 
 
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]