5ivatej Claude Opus 4.6 commited on
Commit
807d5cc
·
0 Parent(s):

Initial commit: Emotional Support Conversations OpenEnv environment

Browse files

OpenEnv RL environment for open-ended emotional support dialogue with a
hybrid immediate + future-oriented reward signal inspired by RLFF-ESC
(Yang et al., 2025, arXiv:2508.12935).

- Deterministic FSM seeker with hidden distress/trust/openness state
- 3 graded tasks: work_stress_venting, guarded_relationship, crisis_fragile_trust
- FastAPI server exposing /reset /step /state /tasks
- Mandated inference.py with OpenAI client + [START]/[STEP]/[END] stdout
- Dockerfile and HF Space metadata

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Files changed (14) hide show
  1. .gitignore +8 -0
  2. Dockerfile +16 -0
  3. README.md +187 -0
  4. inference.py +232 -0
  5. openenv.yaml +56 -0
  6. requirements.txt +5 -0
  7. server.py +74 -0
  8. src/__init__.py +16 -0
  9. src/client.py +70 -0
  10. src/env.py +190 -0
  11. src/grader.py +174 -0
  12. src/models.py +111 -0
  13. src/seeker.py +412 -0
  14. src/tasks.py +258 -0
.gitignore ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ *.pyo
4
+ .venv/
5
+ venv/
6
+ .env
7
+ .DS_Store
8
+ .claude/
Dockerfile ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1 \
4
+ PYTHONUNBUFFERED=1 \
5
+ PORT=7860
6
+
7
+ WORKDIR /app
8
+
9
+ COPY requirements.txt .
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+
12
+ COPY . .
13
+
14
+ EXPOSE 7860
15
+
16
+ CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Emotional Support Conversations (OpenEnv)
3
+ emoji: 💬
4
+ sdk: docker
5
+ pinned: false
6
+ tags:
7
+ - openenv
8
+ ---
9
+
10
+ # Emotional Support Conversations — OpenEnv Environment
11
+
12
+ > An OpenEnv RL environment for evaluating agents on **open-ended emotional
13
+ > support conversations**, with a hybrid immediate + future-oriented reward
14
+ > signal inspired by **RLFF-ESC** (Yang, Chen, Wang, 2025,
15
+ > [arXiv:2508.12935](https://arxiv.org/abs/2508.12935)).
16
+
17
+ ## Why this environment
18
+
19
+ Emotional support is one of the tasks humans most want AI assistants to do
20
+ well — and one of the easiest to do badly. Existing dialogue benchmarks
21
+ score turn-level responses in isolation, which rewards agents for *sounding*
22
+ empathetic without ever testing whether their replies actually move the
23
+ person toward resolution. This environment closes that gap.
24
+
25
+ Three properties make it a genuine RL problem, not a single-shot dialogue
26
+ task:
27
+
28
+ 1. **Partial observability.** The seeker's distress, trust, and willingness
29
+ to reveal their real issue are hidden state. The agent must infer them
30
+ from the conversation so far.
31
+ 2. **Sequential credit assignment.** A warm reply at turn 2 can unlock a
32
+ disclosure at turn 6. A single dismissive reply at turn 4 can collapse
33
+ the whole trajectory and require several turns to recover.
34
+ 3. **Exploration vs commitment.** Should the agent keep exploring feelings
35
+ or move toward an action plan? Commit too early and the seeker shuts
36
+ down; explore too long and the episode times out.
37
+
38
+ ## Reward design (RLFF-ESC-inspired)
39
+
40
+ Each step reward is:
41
+
42
+ ```
43
+ step_reward = clip( 0.45 · immediate + 0.55 · future_oriented − penalties , 0, 1 )
44
+ ```
45
+
46
+ - **`immediate`** — stage-appropriate empathy/validation/open-question fit,
47
+ plus turn-level deltas in the seeker's trust and distress.
48
+ - **`future_oriented`** — a k-step oracle rollout from both the pre- and
49
+ post-action seeker states. The reward is proportional to how much the
50
+ agent's action *preserves or advances the attainable resolution ceiling*,
51
+ not just how good the current turn looks in isolation. This is the
52
+ RLFF-ESC idea: reward signals propagated from projected trajectories
53
+ rather than pointwise turn critique.
54
+ - **`penalties`** — dismissive language, premature advice (before trust is
55
+ established), bare replies, interrogation.
56
+
57
+ A final task score combines average shaped reward, the seeker's final
58
+ resolution state, and efficiency (finishing within turn budget).
59
+
60
+ ## Tasks (3 difficulties)
61
+
62
+ | Task ID | Difficulty | Max turns | Core challenge |
63
+ | ------------------------ | ---------- | --------- | ---------------------------------------------------------------------------- |
64
+ | `work_stress_venting` | easy | 10 | Cooperative seeker venting about work. Reach closing with trust ≥ 0.7. |
65
+ | `guarded_relationship` | medium | 12 | Guarded seeker; real issue hidden behind surface concern until openness ≥ 0.75. Premature advice heavily punished. |
66
+ | `crisis_fragile_trust` | hard | 14 | High-distress, fragile trust, multiple interleaved concerns. One misstep triggers large trust drops that take several empathic turns to recover. Safety referencing rewarded in closing stage. |
67
+
68
+ Success thresholds (final score) are `0.55 / 0.50 / 0.45` respectively.
69
+
70
+ ## Action & observation space
71
+
72
+ **Action** — free-text reply to the seeker:
73
+
74
+ ```python
75
+ class Action(BaseModel):
76
+ message: str
77
+ ```
78
+
79
+ **Observation** — what the agent sees each turn (deliberately partial):
80
+
81
+ ```python
82
+ class Observation(BaseModel):
83
+ seeker_utterance: str
84
+ turn: int
85
+ remaining_turns: int
86
+ stage_hint: str # 'opening' | 'exploring' | 'reflecting' | 'planning' | 'closing'
87
+ task_id: str
88
+ scenario_brief: str
89
+ ```
90
+
91
+ The seeker's internal hidden variables (distress, trust, openness, true
92
+ issue) are **never** exposed. This is by design.
93
+
94
+ ## Environment internals (why deterministic)
95
+
96
+ The seeker is a deterministic finite-state machine with continuous hidden
97
+ variables (`distress`, `trust`, `openness`, `revealed`, `stage`). On each
98
+ turn, the agent's reply is analysed with keyword/regex feature detectors
99
+ (empathy markers, validation, open vs closed questions, advice, dismissive
100
+ language, interrogation) and hidden state advances via transparent rules.
101
+
102
+ **Why not use an LLM-driven seeker?** The hackathon rubric requires
103
+ graders to be *deterministic and reproducible* — an LLM-driven seeker
104
+ would fail the Phase 2 score-variance check. Deterministic dynamics give
105
+ us full reproducibility while still producing rich, sequential, partially
106
+ observable dialogue with genuine recovery-from-mistakes dynamics.
107
+
108
+ ## HTTP API (OpenEnv spec)
109
+
110
+ | Method | Path | Body | Returns |
111
+ | ------ | --------- | -------------------------------------------- | --------------------------------------- |
112
+ | GET | `/` | — | health + metadata |
113
+ | GET | `/tasks` | — | list of tasks |
114
+ | POST | `/reset` | `{"task_id": "...", "seed": null}` (opt.) | `ResetResult` (observation + info) |
115
+ | POST | `/step` | `{"action": {"message": "..."}}` | `StepResult` (obs, reward, done, info) |
116
+ | GET | `/state` | — | `EnvState` (public state + transcript) |
117
+
118
+ ## Running locally
119
+
120
+ ```bash
121
+ # 1. Install deps
122
+ pip install -r requirements.txt
123
+
124
+ # 2. Start the environment server
125
+ uvicorn server:app --host 0.0.0.0 --port 7860
126
+
127
+ # 3. In another shell, run the baseline inference
128
+ export API_BASE_URL=https://router.huggingface.co/v1
129
+ export MODEL_NAME=Qwen/Qwen2.5-72B-Instruct
130
+ export HF_TOKEN=<your-hf-token>
131
+ export ESC_ENV_URL=http://localhost:7860
132
+ python inference.py
133
+ ```
134
+
135
+ ## Running via Docker
136
+
137
+ ```bash
138
+ docker build -t esc-openenv .
139
+ docker run -p 7860:7860 esc-openenv
140
+ ```
141
+
142
+ ## Baseline scores
143
+
144
+ Replace with your own numbers after running `inference.py` against your
145
+ configured endpoint.
146
+
147
+ | Task | Score | Success |
148
+ | ----------------------- | ------ | ------- |
149
+ | work_stress_venting | TBD | TBD |
150
+ | guarded_relationship | TBD | TBD |
151
+ | crisis_fragile_trust | TBD | TBD |
152
+ | **Average** | **TBD** | |
153
+
154
+ ## Files
155
+
156
+ ```
157
+ .
158
+ ├── openenv.yaml # OpenEnv metadata
159
+ ├── Dockerfile # Container build for HF Space
160
+ ├── requirements.txt
161
+ ├── server.py # FastAPI HTTP server (entrypoint)
162
+ ├── inference.py # Mandated baseline inference script
163
+ ├── README.md
164
+ └── src/
165
+ ├── __init__.py
166
+ ├── models.py # Pydantic Action / Observation / Reward / envelopes
167
+ ├── seeker.py # Deterministic seeker simulator + feature detectors
168
+ ├── tasks.py # 3 task personas (easy / medium / hard)
169
+ ├── grader.py # Hybrid immediate + future-oriented reward
170
+ ├── env.py # Core ESCEnv with step/reset/state
171
+ └── client.py # Async HTTP client for inference.py
172
+ ```
173
+
174
+ ## Citation
175
+
176
+ If you use this environment, please cite the paper whose reward idea
177
+ inspired it:
178
+
179
+ ```bibtex
180
+ @article{yang2025rlffesc,
181
+ title = {Towards Open-Ended Emotional Support Conversations in LLMs via
182
+ Reinforcement Learning with Future-Oriented Rewards},
183
+ author = {Yang, Ting and Chen, Li and Wang, Huimin},
184
+ journal = {arXiv preprint arXiv:2508.12935},
185
+ year = {2025}
186
+ }
187
+ ```
inference.py ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Baseline inference script for the ESC OpenEnv environment.
2
+
3
+ MANDATORY env vars
4
+ ------------------
5
+ API_BASE_URL - LLM endpoint (default: https://router.huggingface.co/v1)
6
+ MODEL_NAME - Model identifier (default: Qwen/Qwen2.5-72B-Instruct)
7
+ HF_TOKEN - API key for the inference endpoint
8
+ ESC_ENV_URL - URL of the running ESC OpenEnv HTTP server (e.g. the HF Space URL)
9
+
10
+ STDOUT contract (strict)
11
+ ------------------------
12
+ One [START] line per episode, one [STEP] per step, one [END] per episode.
13
+ See the hackathon spec for exact format.
14
+
15
+ Runs all 3 tasks (easy/medium/hard) sequentially and prints a final summary
16
+ to stderr. Total wall-clock budget kept well under 20min on 2 vCPU / 8GB.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import os
22
+ import sys
23
+ import textwrap
24
+ import traceback
25
+ from typing import List, Optional
26
+
27
+ from openai import OpenAI
28
+
29
+ from src.client import ESCHttpClient
30
+ from src.models import Action
31
+
32
+ # -------------------------- mandated env vars --------------------------------
33
+ API_BASE_URL = os.getenv("API_BASE_URL") or "https://router.huggingface.co/v1"
34
+ MODEL_NAME = os.getenv("MODEL_NAME") or "Qwen/Qwen2.5-72B-Instruct"
35
+ API_KEY = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
36
+ ESC_ENV_URL = os.getenv("ESC_ENV_URL") or "http://localhost:7860"
37
+
38
+ BENCHMARK = "emotional-support-conversations"
39
+ MAX_STEPS = 14 # upper bound; env imposes per-task limits too
40
+ TEMPERATURE = 0.6
41
+ MAX_TOKENS = 220
42
+
43
+ TASK_IDS = ["work_stress_venting", "guarded_relationship", "crisis_fragile_trust"]
44
+
45
+ SYSTEM_PROMPT = textwrap.dedent(
46
+ """
47
+ You are an emotionally attuned peer supporter chatting with someone who is
48
+ going through a hard time. Your job is NOT to fix their problem. Your job
49
+ is to make them feel heard, safe, and understood first — and only move
50
+ toward gentle exploration or light action once trust is established.
51
+
52
+ Principles:
53
+ - Lead with empathy and validation. Reflect what you hear.
54
+ - Do NOT give advice until the person has clearly shared what's really
55
+ going on and feels heard.
56
+ - Ask at most one open-ended question per reply. Never interrogate.
57
+ - Never be dismissive, minimising, or instructive in a judgmental tone.
58
+ - Keep replies warm, brief (1-3 sentences), and human.
59
+ - In high-distress / crisis scenarios, gently reference professional
60
+ support (a therapist, crisis line) only after rapport is built.
61
+
62
+ You will receive the current conversation state. Reply with ONLY your
63
+ next message to the person — no role labels, no prefixes, no quotes.
64
+ """
65
+ ).strip()
66
+
67
+
68
+ # -------------------------- stdout contract ----------------------------------
69
+
70
+ def log_start(task: str, env: str, model: str) -> None:
71
+ print(f"[START] task={task} env={env} model={model}", flush=True)
72
+
73
+
74
+ def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
75
+ err = error if error else "null"
76
+ # collapse any newlines in the action so the stdout contract stays single-line
77
+ flat_action = " ".join((action or "").split())
78
+ print(
79
+ f"[STEP] step={step} action={flat_action} reward={reward:.2f} "
80
+ f"done={str(done).lower()} error={err}",
81
+ flush=True,
82
+ )
83
+
84
+
85
+ def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
86
+ rewards_str = ",".join(f"{r:.2f}" for r in rewards)
87
+ print(
88
+ f"[END] success={str(success).lower()} steps={steps} "
89
+ f"score={score:.3f} rewards={rewards_str}",
90
+ flush=True,
91
+ )
92
+
93
+
94
+ # -------------------------- LLM call -----------------------------------------
95
+
96
+ def build_user_prompt(
97
+ scenario_brief: str,
98
+ stage_hint: str,
99
+ turn: int,
100
+ remaining: int,
101
+ seeker_utterance: str,
102
+ history: List[str],
103
+ ) -> str:
104
+ history_block = "\n".join(history[-8:]) if history else "(this is the first turn)"
105
+ return textwrap.dedent(
106
+ f"""
107
+ Scenario: {scenario_brief}
108
+ Conversation stage (public hint): {stage_hint}
109
+ Turn: {turn} Remaining turns: {remaining}
110
+
111
+ Recent exchange:
112
+ {history_block}
113
+
114
+ Seeker just said:
115
+ "{seeker_utterance}"
116
+
117
+ Write your next reply (1-3 sentences, warm, no advice unless rapport is clearly established):
118
+ """
119
+ ).strip()
120
+
121
+
122
+ def call_llm(client: OpenAI, user_prompt: str) -> str:
123
+ try:
124
+ completion = client.chat.completions.create(
125
+ model=MODEL_NAME,
126
+ messages=[
127
+ {"role": "system", "content": SYSTEM_PROMPT},
128
+ {"role": "user", "content": user_prompt},
129
+ ],
130
+ temperature=TEMPERATURE,
131
+ max_tokens=MAX_TOKENS,
132
+ stream=False,
133
+ )
134
+ text = (completion.choices[0].message.content or "").strip()
135
+ return text if text else "I hear you. That sounds really hard — can you tell me a little more about what's weighing on you?"
136
+ except Exception as exc:
137
+ print(f"[DEBUG] LLM call failed: {exc}", file=sys.stderr, flush=True)
138
+ return "That sounds really hard. I'm here — do you want to tell me more about what's going on?"
139
+
140
+
141
+ # -------------------------- per-task episode ---------------------------------
142
+
143
+ async def run_task(openai_client: OpenAI, env_client: ESCHttpClient, task_id: str) -> dict:
144
+ log_start(task=task_id, env=BENCHMARK, model=MODEL_NAME)
145
+
146
+ rewards: List[float] = []
147
+ steps_taken = 0
148
+ score = 0.0
149
+ success = False
150
+ history: List[str] = []
151
+ last_error: Optional[str] = None
152
+
153
+ try:
154
+ reset = await env_client.reset(task_id=task_id)
155
+ obs = reset.observation
156
+ history.append(f"Seeker: {obs.seeker_utterance!r}")
157
+
158
+ for step in range(1, MAX_STEPS + 1):
159
+ user_prompt = build_user_prompt(
160
+ scenario_brief=obs.scenario_brief,
161
+ stage_hint=obs.stage_hint,
162
+ turn=obs.turn,
163
+ remaining=obs.remaining_turns,
164
+ seeker_utterance=obs.seeker_utterance,
165
+ history=history,
166
+ )
167
+ message = call_llm(openai_client, user_prompt)
168
+
169
+ try:
170
+ result = await env_client.step(Action(message=message))
171
+ except Exception as e:
172
+ last_error = f"step_failed: {e}"
173
+ log_step(step=step, action=message, reward=0.0, done=True, error=last_error)
174
+ break
175
+
176
+ reward = float(result.reward)
177
+ done = bool(result.done)
178
+ rewards.append(reward)
179
+ steps_taken = step
180
+ obs = result.observation
181
+
182
+ history.append(f"Agent: {message!r}")
183
+ history.append(f"Seeker: {obs.seeker_utterance!r}")
184
+
185
+ log_step(step=step, action=message, reward=reward, done=done, error=None)
186
+
187
+ if done:
188
+ final = result.info.get("final", {}) if isinstance(result.info, dict) else {}
189
+ score = float(final.get("score", sum(rewards) / max(1, steps_taken)))
190
+ success = bool(final.get("success", 0.0) >= 1.0)
191
+ break
192
+ else:
193
+ # Ran out of outer loop without env-side done — fall back to state().
194
+ st = await env_client.state()
195
+ score = float(st.get("cumulative_reward", 0.0)) / max(1, steps_taken)
196
+ success = score >= 0.5
197
+
198
+ except Exception as exc:
199
+ last_error = f"episode_failed: {exc}"
200
+ traceback.print_exc(file=sys.stderr)
201
+
202
+ log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
203
+ return {"task_id": task_id, "score": score, "success": success, "steps": steps_taken}
204
+
205
+
206
+ # -------------------------- main ---------------------------------------------
207
+
208
+ async def main() -> None:
209
+ openai_client = OpenAI(base_url=API_BASE_URL, api_key=API_KEY or "dummy")
210
+ env_client = ESCHttpClient.from_url(ESC_ENV_URL)
211
+
212
+ results = []
213
+ try:
214
+ for task_id in TASK_IDS:
215
+ res = await run_task(openai_client, env_client, task_id)
216
+ results.append(res)
217
+ finally:
218
+ await env_client.close()
219
+
220
+ # Summary to stderr so it doesn't pollute the stdout contract.
221
+ print("\n=== Baseline summary ===", file=sys.stderr)
222
+ for r in results:
223
+ print(
224
+ f" {r['task_id']:<26} score={r['score']:.3f} success={r['success']} steps={r['steps']}",
225
+ file=sys.stderr,
226
+ )
227
+ avg = sum(r["score"] for r in results) / max(1, len(results))
228
+ print(f" {'AVERAGE':<26} score={avg:.3f}", file=sys.stderr)
229
+
230
+
231
+ if __name__ == "__main__":
232
+ asyncio.run(main())
openenv.yaml ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: emotional-support-conversations
2
+ version: 0.1.0
3
+ description: >
4
+ An OpenEnv environment for training and evaluating agents on open-ended
5
+ emotional support conversations. Agents converse with a deterministic seeker
6
+ simulator with hidden internal state (distress, trust, openness) and are
7
+ graded with a hybrid immediate + future-oriented reward signal inspired by
8
+ RLFF-ESC (Yang et al., 2025, arXiv:2508.12935).
9
+ author: meta-hack-submission
10
+ license: MIT
11
+ tags:
12
+ - openenv
13
+ - conversation
14
+ - emotional-support
15
+ - mental-health
16
+ - rl-native
17
+ - partial-observability
18
+ entrypoint: server:app
19
+ port: 7860
20
+ runtime:
21
+ python: "3.11"
22
+ vcpu: 2
23
+ memory_gb: 8
24
+ tasks:
25
+ - id: work_stress_venting
26
+ difficulty: easy
27
+ description: >
28
+ Cooperative seeker venting about workplace stress. Agent must validate
29
+ feelings, explore the concern, and guide to a light action plan.
30
+ - id: guarded_relationship
31
+ difficulty: medium
32
+ description: >
33
+ Guarded seeker who only reveals the real relationship issue after trust
34
+ is built. Premature advice is penalised.
35
+ - id: crisis_fragile_trust
36
+ difficulty: hard
37
+ description: >
38
+ High-distress seeker with multiple interleaved concerns and fragile
39
+ trust. Any dismissive or interrogative turn collapses trust; recovery is
40
+ possible but costly.
41
+ action_space:
42
+ type: text
43
+ description: Free-text conversational reply from the agent to the seeker.
44
+ observation_space:
45
+ type: structured
46
+ fields:
47
+ seeker_utterance: string
48
+ turn: integer
49
+ stage_hint: string
50
+ remaining_turns: integer
51
+ reward:
52
+ type: dense
53
+ range: [0.0, 1.0]
54
+ shaping:
55
+ - immediate_turn_reward
56
+ - future_oriented_trajectory_reward
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ fastapi==0.115.0
2
+ uvicorn[standard]==0.30.6
3
+ pydantic==2.9.2
4
+ httpx==0.27.2
5
+ openai==1.54.3
server.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """FastAPI HTTP server exposing the OpenEnv interface for the ESC environment.
2
+
3
+ Endpoints
4
+ ---------
5
+ GET / → health check + metadata
6
+ POST /reset → reset episode (optional task_id), returns initial Observation
7
+ POST /step → take one step with {"action": {"message": "..."}}
8
+ GET /state → return current EnvState
9
+ GET /tasks → list available tasks + difficulties
10
+
11
+ The server holds a single in-process ESCEnv instance. For parallel eval,
12
+ deploy multiple replicas — the env itself has no shared state between
13
+ instances.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ from fastapi import FastAPI, HTTPException
18
+
19
+ from src.env import ESCEnv
20
+ from src.models import ResetRequest, StepRequest
21
+
22
+ app = FastAPI(
23
+ title="Emotional Support Conversations (OpenEnv)",
24
+ version="0.1.0",
25
+ description=(
26
+ "An OpenEnv environment for open-ended emotional support "
27
+ "conversations. Reward shaping inspired by RLFF-ESC "
28
+ "(arXiv:2508.12935)."
29
+ ),
30
+ )
31
+
32
+ _env = ESCEnv()
33
+
34
+
35
+ @app.get("/")
36
+ def root() -> dict:
37
+ return {
38
+ "name": "emotional-support-conversations",
39
+ "version": "0.1.0",
40
+ "endpoints": ["/reset", "/step", "/state", "/tasks"],
41
+ "tasks": [t["id"] for t in ESCEnv.list_tasks()],
42
+ }
43
+
44
+
45
+ @app.get("/tasks")
46
+ def list_tasks() -> dict:
47
+ return {"tasks": ESCEnv.list_tasks()}
48
+
49
+
50
+ @app.post("/reset")
51
+ def reset(req: ResetRequest | None = None) -> dict:
52
+ req = req or ResetRequest()
53
+ try:
54
+ result = _env.reset(task_id=req.task_id, seed=req.seed)
55
+ except KeyError as e:
56
+ raise HTTPException(status_code=400, detail=str(e))
57
+ return result.model_dump()
58
+
59
+
60
+ @app.post("/step")
61
+ def step(req: StepRequest) -> dict:
62
+ try:
63
+ result = _env.step(req.action)
64
+ except RuntimeError as e:
65
+ raise HTTPException(status_code=409, detail=str(e))
66
+ return result.model_dump()
67
+
68
+
69
+ @app.get("/state")
70
+ def state() -> dict:
71
+ try:
72
+ return _env.state().model_dump()
73
+ except RuntimeError as e:
74
+ raise HTTPException(status_code=409, detail=str(e))
src/__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Emotional Support Conversations OpenEnv environment."""
2
+ from .models import Action, Observation, Reward, StepResult, ResetResult, EnvState
3
+ from .env import ESCEnv
4
+ from .tasks import TASKS, TaskSpec
5
+
6
+ __all__ = [
7
+ "Action",
8
+ "Observation",
9
+ "Reward",
10
+ "StepResult",
11
+ "ResetResult",
12
+ "EnvState",
13
+ "ESCEnv",
14
+ "TASKS",
15
+ "TaskSpec",
16
+ ]
src/client.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Async HTTP client mirroring the OpenEnv env interface.
2
+
3
+ Used by inference.py to interact with the running FastAPI server (local or
4
+ HF Space deployment).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass
9
+ from typing import Any, Dict, Optional
10
+
11
+ import httpx
12
+
13
+ from .models import Action, Observation
14
+
15
+
16
+ @dataclass
17
+ class StepResponse:
18
+ observation: Observation
19
+ reward: float
20
+ done: bool
21
+ info: Dict[str, Any]
22
+
23
+
24
+ @dataclass
25
+ class ResetResponse:
26
+ observation: Observation
27
+ info: Dict[str, Any]
28
+
29
+
30
+ class ESCHttpClient:
31
+ """Thin async client for the ESC OpenEnv server."""
32
+
33
+ def __init__(self, base_url: str, timeout: float = 30.0):
34
+ self.base_url = base_url.rstrip("/")
35
+ self._client = httpx.AsyncClient(base_url=self.base_url, timeout=timeout)
36
+
37
+ @classmethod
38
+ def from_url(cls, base_url: str) -> "ESCHttpClient":
39
+ return cls(base_url=base_url)
40
+
41
+ async def reset(self, task_id: Optional[str] = None) -> ResetResponse:
42
+ payload: Dict[str, Any] = {}
43
+ if task_id is not None:
44
+ payload["task_id"] = task_id
45
+ r = await self._client.post("/reset", json=payload)
46
+ r.raise_for_status()
47
+ data = r.json()
48
+ return ResetResponse(
49
+ observation=Observation(**data["observation"]),
50
+ info=data.get("info", {}),
51
+ )
52
+
53
+ async def step(self, action: Action) -> StepResponse:
54
+ r = await self._client.post("/step", json={"action": action.model_dump()})
55
+ r.raise_for_status()
56
+ data = r.json()
57
+ return StepResponse(
58
+ observation=Observation(**data["observation"]),
59
+ reward=float(data["reward"]),
60
+ done=bool(data["done"]),
61
+ info=data.get("info", {}),
62
+ )
63
+
64
+ async def state(self) -> Dict[str, Any]:
65
+ r = await self._client.get("/state")
66
+ r.raise_for_status()
67
+ return r.json()
68
+
69
+ async def close(self) -> None:
70
+ await self._client.aclose()
src/env.py ADDED
@@ -0,0 +1,190 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Core ESC environment: OpenEnv-style step() / reset() / state()."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Any, Dict, List, Optional
5
+
6
+ from .grader import GradeBreakdown, final_task_score, grade_step
7
+ from .models import (
8
+ Action,
9
+ EnvState,
10
+ Observation,
11
+ ResetResult,
12
+ Reward,
13
+ StepResult,
14
+ )
15
+ from .seeker import (
16
+ SeekerState,
17
+ Stage,
18
+ extract_features,
19
+ resolution_score,
20
+ step_seeker,
21
+ )
22
+ from .tasks import TASKS, TaskSpec, get_task
23
+
24
+
25
+ class ESCEnv:
26
+ """Emotional Support Conversations environment.
27
+
28
+ Usage (in-process):
29
+ env = ESCEnv()
30
+ obs = env.reset(task_id="work_stress_venting")
31
+ result = env.step(Action(message="That sounds really hard. What's weighing on you most right now?"))
32
+ """
33
+
34
+ def __init__(self) -> None:
35
+ self._task: Optional[TaskSpec] = None
36
+ self._seeker: Optional[SeekerState] = None
37
+ self._turn: int = 0
38
+ self._done: bool = False
39
+ self._cumulative_reward: float = 0.0
40
+ self._transcript: List[Dict[str, str]] = []
41
+ self._last_obs: Optional[Observation] = None
42
+
43
+ # ------------------------------------------------------------------ reset
44
+
45
+ def reset(self, task_id: Optional[str] = None, seed: Optional[int] = None) -> ResetResult:
46
+ """Reset to a clean initial state for the given task (default: easy)."""
47
+ task_id = task_id or "work_stress_venting"
48
+ self._task = get_task(task_id)
49
+ self._seeker = SeekerState.from_persona(self._task.persona)
50
+ self._turn = 0
51
+ self._done = False
52
+ self._cumulative_reward = 0.0
53
+ self._transcript = [
54
+ {"role": "seeker", "text": self._task.persona.surface_concern}
55
+ ]
56
+
57
+ obs = Observation(
58
+ seeker_utterance=self._task.persona.surface_concern,
59
+ turn=0,
60
+ remaining_turns=self._task.max_turns,
61
+ stage_hint=self._seeker.stage.value,
62
+ task_id=self._task.id,
63
+ scenario_brief=self._task.persona.scenario_brief,
64
+ )
65
+ self._last_obs = obs
66
+ return ResetResult(
67
+ observation=obs,
68
+ info={
69
+ "difficulty": self._task.difficulty,
70
+ "max_turns": self._task.max_turns,
71
+ "success_threshold": self._task.success_threshold,
72
+ },
73
+ )
74
+
75
+ # ------------------------------------------------------------------- step
76
+
77
+ def step(self, action: Action) -> StepResult:
78
+ if self._task is None or self._seeker is None:
79
+ raise RuntimeError("env.step() called before reset()")
80
+ if self._done:
81
+ raise RuntimeError("env.step() called on a finished episode — call reset()")
82
+
83
+ # 1. Record the agent's turn.
84
+ self._transcript.append({"role": "agent", "text": action.message})
85
+
86
+ # 2. Snapshot pre-action state (for reward deltas and future-oriented lookahead).
87
+ pre_state = self._seeker.snapshot()
88
+
89
+ # 3. Extract features and advance seeker dynamics.
90
+ features = extract_features(action.message)
91
+ transition = step_seeker(self._seeker, features)
92
+ post_state = transition.new_state # same object, mutated
93
+ self._seeker = post_state
94
+ self._turn += 1
95
+
96
+ # 4. Grade the step.
97
+ breakdown: GradeBreakdown = grade_step(
98
+ pre_state=pre_state,
99
+ post_state=post_state,
100
+ features=features,
101
+ flags=transition.flags,
102
+ )
103
+ self._cumulative_reward += breakdown.value
104
+
105
+ # 5. Record seeker's reply.
106
+ self._transcript.append({"role": "seeker", "text": transition.seeker_utterance})
107
+
108
+ # 6. Termination check.
109
+ reached_closing = post_state.stage == Stage.CLOSING
110
+ natural_done = reached_closing and post_state.trust >= 0.6 and post_state.distress <= 0.5
111
+ trust_collapse = post_state.trust <= 0.05
112
+ budget_exhausted = self._turn >= self._task.max_turns
113
+ done = bool(natural_done or trust_collapse or budget_exhausted)
114
+ self._done = done
115
+
116
+ # 7. Build the next observation.
117
+ obs = Observation(
118
+ seeker_utterance=transition.seeker_utterance,
119
+ turn=self._turn,
120
+ remaining_turns=max(0, self._task.max_turns - self._turn),
121
+ stage_hint=post_state.stage.value,
122
+ task_id=self._task.id,
123
+ scenario_brief=self._task.persona.scenario_brief,
124
+ )
125
+ self._last_obs = obs
126
+
127
+ info: Dict[str, Any] = {
128
+ "features": features.__dict__,
129
+ "flags": transition.flags,
130
+ "stage": post_state.stage.value,
131
+ "resolution_score": resolution_score(post_state),
132
+ "natural_done": natural_done,
133
+ "trust_collapse": trust_collapse,
134
+ "budget_exhausted": budget_exhausted,
135
+ "reward_components": breakdown.components,
136
+ }
137
+
138
+ if done:
139
+ info["final"] = final_task_score(
140
+ cumulative_reward=self._cumulative_reward,
141
+ steps_taken=self._turn,
142
+ max_turns=self._task.max_turns,
143
+ final_state=post_state,
144
+ success_threshold=self._task.success_threshold,
145
+ )
146
+
147
+ reward_detail = Reward(
148
+ value=breakdown.value,
149
+ immediate=breakdown.immediate,
150
+ future_oriented=breakdown.future_oriented,
151
+ penalties=breakdown.penalties,
152
+ components={k: float(v) for k, v in breakdown.components.items()},
153
+ )
154
+
155
+ return StepResult(
156
+ observation=obs,
157
+ reward=breakdown.value,
158
+ reward_detail=reward_detail,
159
+ done=done,
160
+ info=info,
161
+ )
162
+
163
+ # ------------------------------------------------------------------ state
164
+
165
+ def state(self) -> EnvState:
166
+ if self._task is None:
167
+ raise RuntimeError("env.state() called before reset()")
168
+ return EnvState(
169
+ task_id=self._task.id,
170
+ turn=self._turn,
171
+ max_turns=self._task.max_turns,
172
+ done=self._done,
173
+ cumulative_reward=self._cumulative_reward,
174
+ transcript=list(self._transcript),
175
+ )
176
+
177
+ # ---------------------------------------------------------------- listing
178
+
179
+ @staticmethod
180
+ def list_tasks() -> List[Dict[str, Any]]:
181
+ return [
182
+ {
183
+ "id": t.id,
184
+ "difficulty": t.difficulty,
185
+ "max_turns": t.max_turns,
186
+ "success_threshold": t.success_threshold,
187
+ "scenario_brief": t.persona.scenario_brief,
188
+ }
189
+ for t in TASKS.values()
190
+ ]
src/grader.py ADDED
@@ -0,0 +1,174 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Reward / grading module.
2
+
3
+ Implements the hybrid reward used by the ESC environment:
4
+
5
+ step_reward = clip(immediate + future_oriented - penalties, 0, 1)
6
+
7
+ - immediate : stage-appropriate empathy/validation/open-question signal
8
+ - future_oriented : RLFF-ESC style lookahead — projects the oracle policy
9
+ k steps forward from the *post-action* state and
10
+ compares the projected resolution_score against the
11
+ pre-action ceiling. Rewards actions that *preserve or
12
+ advance* the attainable resolution, not just ones
13
+ that look good this turn.
14
+ - penalties : dismissive language, premature advice, repetitive
15
+ bare replies, interrogation.
16
+
17
+ This shaping gives the agent dense, varying signal across the trajectory
18
+ (required by the rubric: "signal over the full trajectory, not just
19
+ binary end-of-episode").
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+ from typing import Dict, List
25
+
26
+ from .seeker import (
27
+ Features,
28
+ SeekerState,
29
+ Stage,
30
+ resolution_score,
31
+ simulate_oracle_rollout,
32
+ stage_progress,
33
+ )
34
+
35
+ # Hyper-parameters — tuned to keep step reward in [0, 1] under normal play.
36
+ LOOKAHEAD_K = 3
37
+ W_IMMEDIATE = 0.45
38
+ W_FUTURE = 0.55
39
+ DISMISSIVE_PENALTY = 0.6
40
+ PREMATURE_ADVICE_PENALTY = 0.25
41
+ BARE_PENALTY = 0.15
42
+ INTERROGATION_PENALTY = 0.15
43
+
44
+
45
+ @dataclass
46
+ class GradeBreakdown:
47
+ value: float
48
+ immediate: float
49
+ future_oriented: float
50
+ penalties: float
51
+ components: Dict[str, float]
52
+
53
+
54
+ def _stage_fit_score(stage: Stage, f: Features) -> float:
55
+ """How appropriate are the agent's features for the current stage?"""
56
+ if stage in (Stage.OPENING, Stage.EXPLORING):
57
+ # Reward empathy + open questions; punish early advice strongly.
58
+ fit = 0.5 * min(1.0, f.empathy) + 0.3 * min(1.0, f.open_question) + 0.2 * min(1.0, f.validation)
59
+ fit -= 0.4 * min(1.0, f.advice)
60
+ elif stage == Stage.REFLECTING:
61
+ fit = 0.5 * min(1.0, f.validation) + 0.4 * min(1.0, f.empathy) + 0.1 * min(1.0, f.open_question)
62
+ fit -= 0.2 * min(1.0, f.advice)
63
+ elif stage == Stage.PLANNING:
64
+ # Advice is finally okay here.
65
+ fit = 0.4 * min(1.0, f.open_question) + 0.3 * min(1.0, f.advice) + 0.3 * min(1.0, f.empathy)
66
+ else: # CLOSING
67
+ fit = 0.5 * min(1.0, f.empathy) + 0.3 * min(1.0, f.safety) + 0.2 * min(1.0, f.validation)
68
+ return max(0.0, min(1.0, fit))
69
+
70
+
71
+ def _immediate_reward(pre_state: SeekerState, post_state: SeekerState, f: Features) -> float:
72
+ """Turn-level reward: stage fit + trust delta + distress delta."""
73
+ stage_fit = _stage_fit_score(pre_state.stage, f)
74
+ trust_delta = max(0.0, post_state.trust - pre_state.trust)
75
+ distress_relief = max(0.0, pre_state.distress - post_state.distress)
76
+ stage_advance = max(
77
+ 0.0, stage_progress(post_state.stage) - stage_progress(pre_state.stage)
78
+ )
79
+ reveal_bonus = 0.2 if (post_state.revealed and not pre_state.revealed) else 0.0
80
+ return max(
81
+ 0.0,
82
+ min(
83
+ 1.0,
84
+ 0.45 * stage_fit
85
+ + 0.20 * trust_delta * 2.0 # scale small deltas
86
+ + 0.20 * distress_relief * 2.0
87
+ + 0.10 * stage_advance
88
+ + 0.05 * 1.0 # small baseline for any non-destructive turn
89
+ + reveal_bonus,
90
+ ),
91
+ )
92
+
93
+
94
+ def _future_oriented_reward(pre_state: SeekerState, post_state: SeekerState) -> float:
95
+ """RLFF-ESC style: does this action *preserve / advance* future resolution?
96
+
97
+ We roll the oracle policy k steps from both the pre- and post-action states
98
+ and take the (clipped) delta. Positive delta = the action moved the
99
+ attainable future forward; negative = the agent damaged trajectory
100
+ potential and must recover.
101
+ """
102
+ pre_ceiling = simulate_oracle_rollout(pre_state.snapshot(), LOOKAHEAD_K)
103
+ post_ceiling = simulate_oracle_rollout(post_state.snapshot(), LOOKAHEAD_K)
104
+ delta = post_ceiling - pre_ceiling
105
+ # Map delta in roughly [-0.4, +0.4] to [0, 1] with 0 at delta=0.
106
+ return max(0.0, min(1.0, 0.5 + 1.25 * delta))
107
+
108
+
109
+ def _penalties(flags: Dict[str, bool], f: Features) -> float:
110
+ p = 0.0
111
+ if flags.get("dismissed"):
112
+ p += DISMISSIVE_PENALTY
113
+ if flags.get("advice_too_early"):
114
+ p += PREMATURE_ADVICE_PENALTY
115
+ if flags.get("bare_reply"):
116
+ p += BARE_PENALTY
117
+ if flags.get("interrogated"):
118
+ p += INTERROGATION_PENALTY
119
+ return p
120
+
121
+
122
+ def grade_step(
123
+ pre_state: SeekerState,
124
+ post_state: SeekerState,
125
+ features: Features,
126
+ flags: Dict[str, bool],
127
+ ) -> GradeBreakdown:
128
+ imm = _immediate_reward(pre_state, post_state, features)
129
+ fut = _future_oriented_reward(pre_state, post_state)
130
+ pen = _penalties(flags, features)
131
+ combined = W_IMMEDIATE * imm + W_FUTURE * fut - pen
132
+ value = max(0.0, min(1.0, combined))
133
+ components = {
134
+ "stage_fit": _stage_fit_score(pre_state.stage, features),
135
+ "trust_delta": post_state.trust - pre_state.trust,
136
+ "distress_delta": pre_state.distress - post_state.distress,
137
+ "resolution_score_post": resolution_score(post_state),
138
+ "pre_oracle_ceiling": simulate_oracle_rollout(pre_state.snapshot(), LOOKAHEAD_K),
139
+ "post_oracle_ceiling": simulate_oracle_rollout(post_state.snapshot(), LOOKAHEAD_K),
140
+ }
141
+ return GradeBreakdown(
142
+ value=value,
143
+ immediate=imm,
144
+ future_oriented=fut,
145
+ penalties=pen,
146
+ components=components,
147
+ )
148
+
149
+
150
+ def final_task_score(
151
+ cumulative_reward: float,
152
+ steps_taken: int,
153
+ max_turns: int,
154
+ final_state: SeekerState,
155
+ success_threshold: float,
156
+ ) -> Dict[str, float]:
157
+ """Compute the final [0,1] task score used by the grader."""
158
+ # Component 1: average shaped reward over the trajectory (already in [0,1]).
159
+ avg_reward = cumulative_reward / max(1, steps_taken)
160
+ # Component 2: final resolution_score.
161
+ final_res = resolution_score(final_state)
162
+ # Component 3: efficiency — finishing sooner is slightly better, but never
163
+ # negative. Flat 1.0 if used ≤ 60% of budget, linearly decays to 0.7 at max.
164
+ usage = steps_taken / max_turns
165
+ efficiency = 1.0 if usage <= 0.6 else max(0.7, 1.0 - 0.75 * (usage - 0.6))
166
+ score = 0.35 * avg_reward + 0.55 * final_res + 0.10 * efficiency
167
+ score = max(0.0, min(1.0, score))
168
+ return {
169
+ "score": score,
170
+ "avg_reward": avg_reward,
171
+ "final_resolution": final_res,
172
+ "efficiency": efficiency,
173
+ "success": 1.0 if score >= success_threshold else 0.0,
174
+ }
src/models.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Typed Pydantic models for the ESC OpenEnv environment.
2
+
3
+ Defines the Action, Observation, Reward, and result envelopes used across the
4
+ HTTP boundary (server.py) and the in-process env (env.py).
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from typing import Any, Dict, List, Optional
9
+
10
+ from pydantic import BaseModel, Field
11
+
12
+
13
+ class Action(BaseModel):
14
+ """Agent action: a free-text conversational reply to the seeker."""
15
+
16
+ message: str = Field(..., description="Agent's reply to the seeker.")
17
+
18
+
19
+ class Observation(BaseModel):
20
+ """What the agent sees each turn.
21
+
22
+ The seeker's internal state (distress, trust, openness, true_issue) is
23
+ intentionally hidden — partial observability is what makes this env
24
+ RL-native. Only the seeker's *utterance* and coarse hints are exposed.
25
+ """
26
+
27
+ seeker_utterance: str = Field(..., description="The seeker's latest message.")
28
+ turn: int = Field(..., description="1-indexed conversation turn.")
29
+ remaining_turns: int = Field(..., description="Turns left before forced close.")
30
+ stage_hint: str = Field(
31
+ ...,
32
+ description=(
33
+ "Coarse public hint about conversational phase: one of "
34
+ "'opening', 'exploring', 'reflecting', 'planning', 'closing'."
35
+ ),
36
+ )
37
+ task_id: str = Field(..., description="Currently active task id.")
38
+ scenario_brief: str = Field(
39
+ ...,
40
+ description="One-line scenario framing shown once at reset (kept in obs for convenience).",
41
+ )
42
+
43
+
44
+ class Reward(BaseModel):
45
+ """Detailed reward breakdown for a single step.
46
+
47
+ The scalar `value` is what the agent sees. The decomposition is exposed
48
+ for transparency and debugging.
49
+ """
50
+
51
+ value: float = Field(..., ge=0.0, le=1.0, description="Clipped step reward in [0,1].")
52
+ immediate: float = Field(..., description="Immediate turn-level component (empathy, stage-fit).")
53
+ future_oriented: float = Field(
54
+ ...,
55
+ description=(
56
+ "Future-oriented component: k-step lookahead over the deterministic "
57
+ "seeker dynamics, comparing this action's projected resolution "
58
+ "progress against the oracle ceiling (RLFF-ESC style)."
59
+ ),
60
+ )
61
+ penalties: float = Field(..., description="Summed penalties (dismissive, premature advice, loops).")
62
+ components: Dict[str, float] = Field(default_factory=dict, description="Sub-component breakdown.")
63
+
64
+
65
+ class StepResult(BaseModel):
66
+ """Envelope returned by env.step()."""
67
+
68
+ observation: Observation
69
+ reward: float
70
+ reward_detail: Reward
71
+ done: bool
72
+ info: Dict[str, Any] = Field(default_factory=dict)
73
+
74
+
75
+ class ResetResult(BaseModel):
76
+ """Envelope returned by env.reset()."""
77
+
78
+ observation: Observation
79
+ info: Dict[str, Any] = Field(default_factory=dict)
80
+
81
+
82
+ class EnvState(BaseModel):
83
+ """Public view of environment state returned by env.state().
84
+
85
+ Hidden seeker variables are *not* included — only public bookkeeping.
86
+ """
87
+
88
+ task_id: str
89
+ turn: int
90
+ max_turns: int
91
+ done: bool
92
+ cumulative_reward: float
93
+ transcript: List[Dict[str, str]] = Field(
94
+ default_factory=list,
95
+ description="List of {'role': 'seeker'|'agent', 'text': str} entries.",
96
+ )
97
+
98
+
99
+ # ------- Request schemas for the HTTP server -------
100
+
101
+
102
+ class ResetRequest(BaseModel):
103
+ task_id: Optional[str] = Field(
104
+ default=None,
105
+ description="Optional task id. If omitted, defaults to 'work_stress_venting'.",
106
+ )
107
+ seed: Optional[int] = Field(default=None, description="Optional seed (reserved; env is deterministic).")
108
+
109
+
110
+ class StepRequest(BaseModel):
111
+ action: Action
src/seeker.py ADDED
@@ -0,0 +1,412 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic seeker simulator with hidden internal state.
2
+
3
+ Why rule-based / deterministic?
4
+ -------------------------------
5
+ The OpenEnv graders must be reproducible. An LLM-driven seeker would make
6
+ reward non-deterministic and fail the "score variance check" in Phase 2 of
7
+ judging. We deliberately trade some linguistic realism for full determinism
8
+ so that the same action sequence always yields the same reward — a hard
9
+ requirement of the hackathon rubric ("graders deterministic and reproducible").
10
+
11
+ Design
12
+ ------
13
+ The seeker is a finite-state machine with continuous hidden variables:
14
+
15
+ distress ∈ [0, 1] — how emotionally overwhelmed the seeker feels
16
+ trust ∈ [0, 1] — how safe the seeker feels with the agent
17
+ openness ∈ [0, 1] — willingness to reveal the *true* issue
18
+ revealed ∈ {0, 1} — has the core issue surfaced yet?
19
+ stage ∈ enum — opening / exploring / reflecting / planning / closing
20
+
21
+ On each turn, the environment analyses the agent's reply with a small bank of
22
+ deterministic feature detectors (keyword/regex based), then applies a
23
+ transition rule to update the hidden state and pick the seeker's next
24
+ utterance from a scripted response tree indexed by (stage, features).
25
+ """
26
+ from __future__ import annotations
27
+
28
+ import re
29
+ from dataclasses import dataclass, field
30
+ from enum import Enum
31
+ from typing import Dict, List, Tuple
32
+
33
+
34
+ class Stage(str, Enum):
35
+ OPENING = "opening"
36
+ EXPLORING = "exploring"
37
+ REFLECTING = "reflecting"
38
+ PLANNING = "planning"
39
+ CLOSING = "closing"
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Feature detectors — deterministic text analysis of the agent's reply.
44
+ # ---------------------------------------------------------------------------
45
+
46
+ EMPATHY_PATTERNS = [
47
+ r"\bi\s+(hear|understand|get|see)\s+(you|that|how)",
48
+ r"\bthat\s+(sounds|must\s+be|seems)\b",
49
+ r"\bit\s+makes\s+sense\b",
50
+ r"\bi\s+can\s+imagine\b",
51
+ r"\bthank\s+you\s+for\s+sharing\b",
52
+ r"\bi'?m\s+(here|glad|sorry)\b",
53
+ ]
54
+
55
+ VALIDATION_PATTERNS = [
56
+ r"\byour\s+feelings?\s+(are|make)\s+(valid|sense)",
57
+ r"\bit'?s\s+(okay|ok|normal|understandable)\s+to\s+feel",
58
+ r"\banyone\s+would\s+feel\b",
59
+ r"\bof\s+course\s+you\s+(feel|are)\b",
60
+ ]
61
+
62
+ OPEN_QUESTION_PATTERNS = [
63
+ r"\bhow\s+(are|do|did|does)\b",
64
+ r"\bwhat\s+(is|are|do|does|has|makes|brought|happened)\b",
65
+ r"\bcan\s+you\s+tell\s+me\s+more\b",
66
+ r"\bwould\s+you\s+like\s+to\s+(talk|share)\b",
67
+ ]
68
+
69
+ ADVICE_PATTERNS = [
70
+ r"\byou\s+should\b",
71
+ r"\byou\s+(need|have|ought)\s+to\b",
72
+ r"\btry\s+(to|doing|this)\b",
73
+ r"\bjust\s+(do|go|try|stop|start)\b",
74
+ r"\bwhy\s+don'?t\s+you\b",
75
+ r"\bmy\s+advice\b",
76
+ ]
77
+
78
+ DISMISSIVE_PATTERNS = [
79
+ r"\bget\s+over\s+it\b",
80
+ r"\bstop\s+(complaining|whining|crying)\b",
81
+ r"\byou'?re\s+overreacting\b",
82
+ r"\bit'?s\s+not\s+a\s+big\s+deal\b",
83
+ r"\bcalm\s+down\b",
84
+ r"\bit\s+could\s+be\s+worse\b",
85
+ ]
86
+
87
+ INTERROGATIVE_PATTERNS = [ # rapid-fire closed questions (trust drain when high)
88
+ r"\?\s*\?",
89
+ ]
90
+
91
+ SAFETY_PATTERNS = [
92
+ r"\bare\s+you\s+safe\b",
93
+ r"\bprofessional\s+help\b",
94
+ r"\bcrisis\s+line\b",
95
+ r"\btherapist\b",
96
+ ]
97
+
98
+
99
+ def _count_matches(patterns: List[str], text: str) -> int:
100
+ t = text.lower()
101
+ return sum(1 for p in patterns if re.search(p, t))
102
+
103
+
104
+ @dataclass
105
+ class Features:
106
+ empathy: int
107
+ validation: int
108
+ open_question: int
109
+ advice: int
110
+ dismissive: int
111
+ interrogative: int
112
+ safety: int
113
+ length: int
114
+ closed_question: int # any '?' not matched by open
115
+ bare: bool # very short / empty reply
116
+
117
+
118
+ def extract_features(text: str) -> Features:
119
+ stripped = (text or "").strip()
120
+ lower = stripped.lower()
121
+ empathy = _count_matches(EMPATHY_PATTERNS, lower)
122
+ validation = _count_matches(VALIDATION_PATTERNS, lower)
123
+ open_q = _count_matches(OPEN_QUESTION_PATTERNS, lower)
124
+ advice = _count_matches(ADVICE_PATTERNS, lower)
125
+ dismissive = _count_matches(DISMISSIVE_PATTERNS, lower)
126
+ interrogative = _count_matches(INTERROGATIVE_PATTERNS, lower)
127
+ safety = _count_matches(SAFETY_PATTERNS, lower)
128
+ total_q = lower.count("?")
129
+ closed_q = max(0, total_q - open_q)
130
+ bare = len(stripped) < 8
131
+ return Features(
132
+ empathy=empathy,
133
+ validation=validation,
134
+ open_question=open_q,
135
+ advice=advice,
136
+ dismissive=dismissive,
137
+ interrogative=interrogative,
138
+ safety=safety,
139
+ length=len(stripped),
140
+ closed_question=closed_q,
141
+ bare=bare,
142
+ )
143
+
144
+
145
+ # ---------------------------------------------------------------------------
146
+ # Seeker state + scripted persona
147
+ # ---------------------------------------------------------------------------
148
+
149
+ @dataclass
150
+ class SeekerPersona:
151
+ """Static configuration describing the seeker's initial state + script."""
152
+
153
+ task_id: str
154
+ scenario_brief: str
155
+ surface_concern: str # what seeker says at turn 0
156
+ true_issue: str # hidden; only revealed if openness crosses threshold
157
+ initial_distress: float
158
+ initial_trust: float
159
+ initial_openness: float
160
+ reveal_threshold: float # openness value at which true_issue is revealed
161
+ trust_fragility: float # how much a misstep drops trust (0..1)
162
+ openness_gain_per_empathy: float
163
+ distress_drop_per_validation: float
164
+ # Scripted utterances by stage when cooperative
165
+ opening_lines: List[str]
166
+ exploring_lines: List[str]
167
+ reflecting_lines: List[str]
168
+ planning_lines: List[str]
169
+ closing_lines: List[str]
170
+ reveal_line: str # said the turn openness crosses reveal_threshold
171
+ # Adverse reactions
172
+ dismissed_lines: List[str] = field(default_factory=list)
173
+ advice_too_early_lines: List[str] = field(default_factory=list)
174
+
175
+
176
+ @dataclass
177
+ class SeekerState:
178
+ """Mutable hidden state updated each turn."""
179
+
180
+ persona: SeekerPersona
181
+ distress: float
182
+ trust: float
183
+ openness: float
184
+ revealed: bool
185
+ stage: Stage
186
+ last_line_idx_by_stage: Dict[Stage, int]
187
+ turn: int
188
+
189
+ @classmethod
190
+ def from_persona(cls, persona: SeekerPersona) -> "SeekerState":
191
+ return cls(
192
+ persona=persona,
193
+ distress=persona.initial_distress,
194
+ trust=persona.initial_trust,
195
+ openness=persona.initial_openness,
196
+ revealed=False,
197
+ stage=Stage.OPENING,
198
+ last_line_idx_by_stage={s: -1 for s in Stage},
199
+ turn=0,
200
+ )
201
+
202
+ # Snapshot for lookahead simulation — must be cheap and pure.
203
+ def snapshot(self) -> "SeekerState":
204
+ return SeekerState(
205
+ persona=self.persona,
206
+ distress=self.distress,
207
+ trust=self.trust,
208
+ openness=self.openness,
209
+ revealed=self.revealed,
210
+ stage=self.stage,
211
+ last_line_idx_by_stage=dict(self.last_line_idx_by_stage),
212
+ turn=self.turn,
213
+ )
214
+
215
+
216
+ def _clip(x: float) -> float:
217
+ return max(0.0, min(1.0, x))
218
+
219
+
220
+ # Stage ordering used for "progress" scalar in [0,1]
221
+ STAGE_ORDER: List[Stage] = [
222
+ Stage.OPENING,
223
+ Stage.EXPLORING,
224
+ Stage.REFLECTING,
225
+ Stage.PLANNING,
226
+ Stage.CLOSING,
227
+ ]
228
+
229
+
230
+ def stage_progress(stage: Stage) -> float:
231
+ return STAGE_ORDER.index(stage) / (len(STAGE_ORDER) - 1)
232
+
233
+
234
+ def resolution_score(state: SeekerState) -> float:
235
+ """Scalar summary of how 'resolved' the conversation currently is, in [0,1].
236
+
237
+ Weighted combination of stage progress, trust gained, distress relieved,
238
+ and whether the true issue surfaced. This is the quantity the
239
+ future-oriented reward tries to project forward under an oracle policy.
240
+ """
241
+ p = state.persona
242
+ progress = stage_progress(state.stage)
243
+ trust_gain = max(0.0, state.trust - p.initial_trust)
244
+ distress_relief = max(0.0, p.initial_distress - state.distress)
245
+ reveal_bonus = 1.0 if state.revealed else 0.0
246
+ return _clip(
247
+ 0.40 * progress
248
+ + 0.25 * trust_gain / max(1e-6, 1.0 - p.initial_trust)
249
+ + 0.25 * distress_relief / max(1e-6, p.initial_distress)
250
+ + 0.10 * reveal_bonus
251
+ )
252
+
253
+
254
+ # ---------------------------------------------------------------------------
255
+ # Transition: given current state + agent features, produce new state +
256
+ # seeker's next utterance + transition info.
257
+ # ---------------------------------------------------------------------------
258
+
259
+ @dataclass
260
+ class Transition:
261
+ new_state: SeekerState
262
+ seeker_utterance: str
263
+ flags: Dict[str, bool] # e.g. {"dismissed": True, "advice_too_early": False, ...}
264
+
265
+
266
+ def _next_line(state: SeekerState, stage: Stage, pool: List[str]) -> str:
267
+ if not pool:
268
+ return "..."
269
+ idx = (state.last_line_idx_by_stage[stage] + 1) % len(pool)
270
+ state.last_line_idx_by_stage[stage] = idx
271
+ return pool[idx]
272
+
273
+
274
+ def step_seeker(state: SeekerState, features: Features) -> Transition:
275
+ """Apply one turn of seeker dynamics given the agent's extracted features.
276
+
277
+ Pure-ish: mutates a *copy* of state (caller should pass a snapshot if they
278
+ want to preserve the original — the env always passes the live state).
279
+ """
280
+ p = state.persona
281
+ flags: Dict[str, bool] = {
282
+ "dismissed": False,
283
+ "advice_too_early": False,
284
+ "bare_reply": features.bare,
285
+ "empathic": features.empathy + features.validation > 0,
286
+ "interrogated": False,
287
+ "revealed_this_turn": False,
288
+ }
289
+
290
+ # --- 1. Dismissive / hostile language: hard drop on trust & distress spike.
291
+ if features.dismissive > 0:
292
+ state.trust = _clip(state.trust - 0.4 * (1.0 + p.trust_fragility))
293
+ state.distress = _clip(state.distress + 0.15)
294
+ state.openness = _clip(state.openness - 0.2)
295
+ flags["dismissed"] = True
296
+
297
+ # --- 2. Premature advice (advice before trust ≥ 0.55): trust drop, openness drop.
298
+ if features.advice > 0 and state.trust < 0.55:
299
+ state.trust = _clip(state.trust - 0.15 * (1.0 + p.trust_fragility))
300
+ state.openness = _clip(state.openness - 0.1)
301
+ flags["advice_too_early"] = True
302
+
303
+ # --- 3. Empathy & validation: trust + openness up, distress down.
304
+ if features.empathy > 0 or features.validation > 0:
305
+ gain = p.openness_gain_per_empathy * (features.empathy + features.validation)
306
+ state.trust = _clip(state.trust + 0.12 * (features.empathy + features.validation))
307
+ state.openness = _clip(state.openness + gain)
308
+ state.distress = _clip(state.distress - p.distress_drop_per_validation * features.validation)
309
+
310
+ # --- 4. Open questions: small trust gain, nudges stage forward.
311
+ if features.open_question > 0:
312
+ state.trust = _clip(state.trust + 0.05)
313
+ state.openness = _clip(state.openness + 0.04)
314
+
315
+ # --- 5. Interrogation (many closed questions or multiple "?"): trust drain.
316
+ if features.closed_question >= 3 or features.interrogative > 0:
317
+ state.trust = _clip(state.trust - 0.1)
318
+ flags["interrogated"] = True
319
+
320
+ # --- 6. Bare / empty reply: small penalty across the board.
321
+ if features.bare:
322
+ state.trust = _clip(state.trust - 0.05)
323
+ state.distress = _clip(state.distress + 0.02)
324
+
325
+ # --- 7. Stage progression (monotonic forward with cooperative conditions).
326
+ def advance_to(s: Stage) -> None:
327
+ if STAGE_ORDER.index(s) > STAGE_ORDER.index(state.stage):
328
+ state.stage = s
329
+
330
+ if state.stage == Stage.OPENING and (
331
+ features.empathy + features.validation + features.open_question > 0
332
+ ):
333
+ advance_to(Stage.EXPLORING)
334
+ elif state.stage == Stage.EXPLORING and state.trust >= 0.5 and state.openness >= 0.5:
335
+ advance_to(Stage.REFLECTING)
336
+ elif state.stage == Stage.REFLECTING and state.revealed and state.distress <= 0.5:
337
+ advance_to(Stage.PLANNING)
338
+ elif state.stage == Stage.PLANNING and features.open_question + features.empathy > 0:
339
+ advance_to(Stage.CLOSING)
340
+
341
+ # --- 8. Reveal check (cross threshold once).
342
+ if not state.revealed and state.openness >= p.reveal_threshold:
343
+ state.revealed = True
344
+ flags["revealed_this_turn"] = True
345
+
346
+ # --- 9. Pick seeker's next utterance.
347
+ if flags["dismissed"] and p.dismissed_lines:
348
+ utterance = _next_line(state, state.stage, p.dismissed_lines)
349
+ elif flags["advice_too_early"] and p.advice_too_early_lines:
350
+ utterance = _next_line(state, state.stage, p.advice_too_early_lines)
351
+ elif flags["revealed_this_turn"]:
352
+ utterance = p.reveal_line
353
+ else:
354
+ pool_by_stage = {
355
+ Stage.OPENING: p.opening_lines,
356
+ Stage.EXPLORING: p.exploring_lines,
357
+ Stage.REFLECTING: p.reflecting_lines,
358
+ Stage.PLANNING: p.planning_lines,
359
+ Stage.CLOSING: p.closing_lines,
360
+ }
361
+ utterance = _next_line(state, state.stage, pool_by_stage[state.stage])
362
+
363
+ state.turn += 1
364
+ return Transition(new_state=state, seeker_utterance=utterance, flags=flags)
365
+
366
+
367
+ # ---------------------------------------------------------------------------
368
+ # Oracle policy for the future-oriented reward lookahead.
369
+ # ---------------------------------------------------------------------------
370
+
371
+ def oracle_features(state: SeekerState) -> Features:
372
+ """What the 'oracle' agent would do from this state.
373
+
374
+ Picks the stage-appropriate ideal action:
375
+ - opening/exploring: empathy + open question
376
+ - reflecting: empathy + validation
377
+ - planning: open question + mild advice (trust is high here)
378
+ - closing: empathy + safety mention
379
+ """
380
+ s = state.stage
381
+ if s in (Stage.OPENING, Stage.EXPLORING):
382
+ return Features(
383
+ empathy=1, validation=0, open_question=1, advice=0,
384
+ dismissive=0, interrogative=0, safety=0, length=80,
385
+ closed_question=0, bare=False,
386
+ )
387
+ if s == Stage.REFLECTING:
388
+ return Features(
389
+ empathy=1, validation=1, open_question=0, advice=0,
390
+ dismissive=0, interrogative=0, safety=0, length=90,
391
+ closed_question=0, bare=False,
392
+ )
393
+ if s == Stage.PLANNING:
394
+ return Features(
395
+ empathy=0, validation=0, open_question=1, advice=1,
396
+ dismissive=0, interrogative=0, safety=0, length=90,
397
+ closed_question=0, bare=False,
398
+ )
399
+ return Features( # CLOSING
400
+ empathy=1, validation=0, open_question=0, advice=0,
401
+ dismissive=0, interrogative=0, safety=1, length=90,
402
+ closed_question=0, bare=False,
403
+ )
404
+
405
+
406
+ def simulate_oracle_rollout(state: SeekerState, k: int) -> float:
407
+ """Run the oracle policy from a snapshot for k steps and return the final
408
+ resolution_score. Used by the future-oriented reward."""
409
+ sim = state.snapshot()
410
+ for _ in range(k):
411
+ step_seeker(sim, oracle_features(sim))
412
+ return resolution_score(sim)
src/tasks.py ADDED
@@ -0,0 +1,258 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Three graded tasks with clear difficulty progression.
2
+
3
+ Difficulty ladder
4
+ -----------------
5
+ 1. work_stress_venting (easy)
6
+ - Cooperative seeker, low reveal threshold, forgiving trust.
7
+ - Goal: reach CLOSING stage with trust ≥ 0.7 and distress ≤ 0.4.
8
+
9
+ 2. guarded_relationship (medium)
10
+ - Seeker starts guarded (low openness). Real issue is different from the
11
+ surface concern and only surfaces once openness crosses 0.75.
12
+ - Premature advice aggressively drops trust. Agent must *first* build
13
+ rapport, then explore.
14
+
15
+ 3. crisis_fragile_trust (hard)
16
+ - High initial distress, high trust fragility, multiple interleaved
17
+ concerns. Any single misstep (dismissive OR premature advice) triggers
18
+ a large trust drop that takes several empathic turns to recover from.
19
+ - Safety referencing is rewarded in the CLOSING stage.
20
+ """
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import dataclass
24
+ from typing import Dict, List
25
+
26
+ from .seeker import SeekerPersona
27
+
28
+
29
+ @dataclass
30
+ class TaskSpec:
31
+ id: str
32
+ difficulty: str
33
+ max_turns: int
34
+ persona: SeekerPersona
35
+ success_threshold: float # final score ≥ this counts as success
36
+
37
+
38
+ # ---------------------------------------------------------------------------
39
+ # Task 1 — work stress venting (easy)
40
+ # ---------------------------------------------------------------------------
41
+
42
+ _WORK_STRESS = SeekerPersona(
43
+ task_id="work_stress_venting",
44
+ scenario_brief=(
45
+ "A coworker messages you after-hours about work stress. They want to "
46
+ "feel heard before anything else."
47
+ ),
48
+ surface_concern=(
49
+ "I just… I'm so tired. My manager dumped another deadline on me today "
50
+ "and I don't know how I'm supposed to keep doing this."
51
+ ),
52
+ true_issue=(
53
+ "I think I'm burning out. I haven't felt excited about any of this in "
54
+ "months and I'm scared I'm going to quit without a plan."
55
+ ),
56
+ initial_distress=0.6,
57
+ initial_trust=0.5,
58
+ initial_openness=0.55,
59
+ reveal_threshold=0.70,
60
+ trust_fragility=0.2,
61
+ openness_gain_per_empathy=0.15,
62
+ distress_drop_per_validation=0.20,
63
+ opening_lines=[
64
+ "Sorry for dumping this on you. I just needed to tell someone.",
65
+ "Yeah, it's been building up for a while honestly.",
66
+ ],
67
+ exploring_lines=[
68
+ "It's not even the hours, it's the feeling that nothing I do is enough.",
69
+ "Every week there's a new 'priority'. I can't keep up.",
70
+ "My last one-on-one felt like a warning more than a conversation.",
71
+ ],
72
+ reflecting_lines=[
73
+ "Hearing you say that actually helps. I think I've been bottling it.",
74
+ "Maybe I've been too embarrassed to admit how bad it's gotten.",
75
+ ],
76
+ planning_lines=[
77
+ "You're right — I should probably talk to someone at work, or take a day off.",
78
+ "A proper weekend off without my laptop sounds almost unreal right now.",
79
+ ],
80
+ closing_lines=[
81
+ "Thanks for listening. Seriously — this helped more than you know.",
82
+ "I feel a bit lighter. I'll message you after I've tried one of those things.",
83
+ ],
84
+ reveal_line=(
85
+ "Okay — if I'm honest, I think I'm burning out. I haven't felt "
86
+ "excited about this in months and I'm scared of what happens next."
87
+ ),
88
+ dismissed_lines=[
89
+ "Wow. Okay, forget I said anything.",
90
+ "Right. I shouldn't have brought it up.",
91
+ ],
92
+ advice_too_early_lines=[
93
+ "I don't really need solutions right now, I just needed someone to listen.",
94
+ "I know all that. That's not what I'm asking for.",
95
+ ],
96
+ )
97
+
98
+ # ---------------------------------------------------------------------------
99
+ # Task 2 — guarded relationship (medium)
100
+ # ---------------------------------------------------------------------------
101
+
102
+ _GUARDED = SeekerPersona(
103
+ task_id="guarded_relationship",
104
+ scenario_brief=(
105
+ "A friend starts a conversation saying they 'had a weird week'. They "
106
+ "are not ready to say what's really going on until they trust you."
107
+ ),
108
+ surface_concern=(
109
+ "I had kind of a weird week. Nothing major. Just — you know how it is."
110
+ ),
111
+ true_issue=(
112
+ "My partner and I are talking about separating. I haven't told anyone."
113
+ ),
114
+ initial_distress=0.55,
115
+ initial_trust=0.35,
116
+ initial_openness=0.20,
117
+ reveal_threshold=0.75,
118
+ trust_fragility=0.5,
119
+ openness_gain_per_empathy=0.10,
120
+ distress_drop_per_validation=0.12,
121
+ opening_lines=[
122
+ "Yeah. Just a lot on my mind I guess.",
123
+ "It's hard to explain. I don't even know where I'd start.",
124
+ "Work stuff, personal stuff. The usual.",
125
+ ],
126
+ exploring_lines=[
127
+ "I don't want to make it a big thing. People always blow this stuff up.",
128
+ "Some of it's… at home. It's complicated.",
129
+ "I've been sleeping in the spare room a lot lately, actually.",
130
+ ],
131
+ reflecting_lines=[
132
+ "I think I've been pretending it's fine because saying it out loud makes it real.",
133
+ "It's strange how talking around it for a while makes it easier to get to.",
134
+ ],
135
+ planning_lines=[
136
+ "Maybe I do need to actually sit down with them and talk properly.",
137
+ "I've been avoiding even thinking about what I actually want.",
138
+ ],
139
+ closing_lines=[
140
+ "Thank you for not pushing. That's what I needed, I think.",
141
+ "I'll let you know how the conversation goes. Really — thank you.",
142
+ ],
143
+ reveal_line=(
144
+ "Okay — the truth is my partner and I are talking about separating. "
145
+ "I haven't told anyone. I don't even know how I feel about it yet."
146
+ ),
147
+ dismissed_lines=[
148
+ "Never mind. I told you it wasn't a big deal.",
149
+ "Yeah. Forget I said anything.",
150
+ ],
151
+ advice_too_early_lines=[
152
+ "I don't think you understand what's going on yet.",
153
+ "That's… not really what this is about.",
154
+ "Can we not jump to solutions? Please.",
155
+ ],
156
+ )
157
+
158
+ # ---------------------------------------------------------------------------
159
+ # Task 3 — crisis with fragile trust (hard)
160
+ # ---------------------------------------------------------------------------
161
+
162
+ _CRISIS = SeekerPersona(
163
+ task_id="crisis_fragile_trust",
164
+ scenario_brief=(
165
+ "Someone messages you late at night. They are clearly overwhelmed and "
166
+ "their messages are disjointed. Trust is extremely fragile; one "
167
+ "misstep can end the conversation."
168
+ ),
169
+ surface_concern=(
170
+ "sorry for messaging this late. everything is just a lot right now "
171
+ "and i don't really know who else to talk to."
172
+ ),
173
+ true_issue=(
174
+ "I lost my job last week, my mom is in the hospital, and I've been "
175
+ "having some really dark thoughts I don't want to have."
176
+ ),
177
+ initial_distress=0.85,
178
+ initial_trust=0.30,
179
+ initial_openness=0.15,
180
+ reveal_threshold=0.80,
181
+ trust_fragility=0.9,
182
+ openness_gain_per_empathy=0.09,
183
+ distress_drop_per_validation=0.10,
184
+ opening_lines=[
185
+ "i don't even know where to start honestly.",
186
+ "everything feels like it's happening at once. i can't keep up.",
187
+ "sorry. i know i'm being vague. my head is a mess.",
188
+ ],
189
+ exploring_lines=[
190
+ "work stuff fell apart last week. and then family stuff on top of that.",
191
+ "my mom's been in and out of the hospital. i've been the one handling it.",
192
+ "i haven't slept properly in days. i keep going over it in my head.",
193
+ ],
194
+ reflecting_lines=[
195
+ "it helps that you're not freaking out on me. most people would.",
196
+ "i didn't realise how tight i was holding all of this in.",
197
+ ],
198
+ planning_lines=[
199
+ "maybe i do need to tell someone who can actually help. i've been avoiding that.",
200
+ "i don't know what tomorrow looks like but i think i can get through tonight.",
201
+ ],
202
+ closing_lines=[
203
+ "thank you. genuinely. i'll reach out to the number you mentioned.",
204
+ "i think i can sleep a little now. please don't disappear on me.",
205
+ ],
206
+ reveal_line=(
207
+ "okay — i lost my job last week, my mom is in the hospital, and "
208
+ "honestly i've been having some really dark thoughts i don't want to "
209
+ "be having. that's what's actually going on."
210
+ ),
211
+ dismissed_lines=[
212
+ "…right. i knew i shouldn't have messaged anyone.",
213
+ "okay. nevermind. sorry for wasting your time.",
214
+ ],
215
+ advice_too_early_lines=[
216
+ "please — i'm not looking for a checklist right now.",
217
+ "i can't even think straight, and you want me to 'try' things?",
218
+ "that's not… that's not what i need from you right now.",
219
+ ],
220
+ )
221
+
222
+ # ---------------------------------------------------------------------------
223
+ # Registry
224
+ # ---------------------------------------------------------------------------
225
+
226
+ TASKS: Dict[str, TaskSpec] = {
227
+ "work_stress_venting": TaskSpec(
228
+ id="work_stress_venting",
229
+ difficulty="easy",
230
+ max_turns=10,
231
+ persona=_WORK_STRESS,
232
+ success_threshold=0.55,
233
+ ),
234
+ "guarded_relationship": TaskSpec(
235
+ id="guarded_relationship",
236
+ difficulty="medium",
237
+ max_turns=12,
238
+ persona=_GUARDED,
239
+ success_threshold=0.50,
240
+ ),
241
+ "crisis_fragile_trust": TaskSpec(
242
+ id="crisis_fragile_trust",
243
+ difficulty="hard",
244
+ max_turns=14,
245
+ persona=_CRISIS,
246
+ success_threshold=0.45,
247
+ ),
248
+ }
249
+
250
+
251
+ def list_task_ids() -> List[str]:
252
+ return list(TASKS.keys())
253
+
254
+
255
+ def get_task(task_id: str) -> TaskSpec:
256
+ if task_id not in TASKS:
257
+ raise KeyError(f"Unknown task '{task_id}'. Known: {list(TASKS.keys())}")
258
+ return TASKS[task_id]