JayeshCC commited on
Commit
0f67fc2
·
verified ·
1 Parent(s): b79033d

Upload folder using huggingface_hub

Browse files
Dockerfile ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ # Multi-stage build using openenv-base
8
+ # This Dockerfile is flexible and works for both:
9
+ # - In-repo environments (with local OpenEnv sources)
10
+ # - Standalone environments (with openenv from PyPI/Git)
11
+ # The build script (openenv build) handles context detection and sets appropriate build args.
12
+
13
+ ARG BASE_IMAGE=ghcr.io/meta-pytorch/openenv-base:latest
14
+ FROM ${BASE_IMAGE} AS builder
15
+
16
+ WORKDIR /app
17
+
18
+ # Ensure git is available (required for installing dependencies from VCS)
19
+ RUN apt-get update && \
20
+ apt-get install -y --no-install-recommends git && \
21
+ rm -rf /var/lib/apt/lists/*
22
+
23
+ # Build argument to control whether we're building standalone or in-repo
24
+ ARG BUILD_MODE=in-repo
25
+ ARG ENV_NAME=token_optimiser
26
+
27
+ # Copy environment code (always at root of build context)
28
+ COPY . /app/env
29
+
30
+ # For in-repo builds, openenv is already vendored in the build context
31
+ # For standalone builds, openenv will be installed via pyproject.toml
32
+ WORKDIR /app/env
33
+
34
+ # Ensure uv is available (for local builds where base image lacks it)
35
+ RUN if ! command -v uv >/dev/null 2>&1; then \
36
+ curl -LsSf https://astral.sh/uv/install.sh | sh && \
37
+ mv /root/.local/bin/uv /usr/local/bin/uv && \
38
+ mv /root/.local/bin/uvx /usr/local/bin/uvx; \
39
+ fi
40
+
41
+ # Install dependencies using uv sync
42
+ # If uv.lock exists, use it; otherwise resolve on the fly
43
+ RUN --mount=type=cache,target=/root/.cache/uv \
44
+ if [ -f uv.lock ]; then \
45
+ uv sync --frozen --no-install-project --no-editable; \
46
+ else \
47
+ uv sync --no-install-project --no-editable; \
48
+ fi
49
+
50
+ RUN --mount=type=cache,target=/root/.cache/uv \
51
+ if [ -f uv.lock ]; then \
52
+ uv sync --frozen --no-editable; \
53
+ else \
54
+ uv sync --no-editable; \
55
+ fi
56
+
57
+ # Final runtime stage
58
+ FROM ${BASE_IMAGE}
59
+
60
+ WORKDIR /app
61
+
62
+ # Copy the virtual environment from builder
63
+ COPY --from=builder /app/env/.venv /app/.venv
64
+
65
+ # Copy the environment code
66
+ COPY --from=builder /app/env /app/env
67
+
68
+ # Set PATH to use the virtual environment
69
+ ENV PATH="/app/.venv/bin:$PATH"
70
+
71
+ # Set PYTHONPATH so imports work correctly
72
+ ENV PYTHONPATH="/app/env:$PYTHONPATH"
73
+
74
+ # Health check
75
+ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
76
+ CMD curl -f http://localhost:8000/health || exit 1
77
+
78
+ # Run the FastAPI server
79
+ # The module path is constructed to work with the /app/env structure
80
+ ENV ENABLE_WEB_INTERFACE=true
81
+ CMD ["sh", "-c", "cd /app/env && uvicorn server.app:app --host 0.0.0.0 --port 8000"]
README.md CHANGED
@@ -1,10 +1,280 @@
1
  ---
2
- title: Token Optimiser
3
- emoji: 🦀
4
- colorFrom: red
5
- colorTo: blue
6
  sdk: docker
 
7
  pinned: false
 
8
  ---
9
 
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Token Optimiser Environment
3
+ emoji: 🔤
4
+ colorFrom: indigo
5
+ colorTo: purple
6
  sdk: docker
7
+ app_port: 8000
8
  pinned: false
9
+ base_path: /web
10
  ---
11
 
12
+ # 🔤 Prompt & Response Token Optimization Environment
13
+
14
+ > An OpenEnv-compatible RL environment that trains agents to minimize LLM API token usage while preserving semantic quality — reducing AI inference costs at scale.
15
+
16
+ ---
17
+
18
+ ## 📌 Introduction
19
+
20
+ Large Language Model APIs charge per token. Verbose prompts and unconstrained responses waste tokens and money. This environment trains an AI agent to **rewrite verbose prompts into concise, efficient versions** that:
21
+
22
+ - Use fewer input tokens
23
+ - Guide the LLM toward shorter, correctly-formatted responses
24
+ - Preserve the full semantic meaning and intent of the original request
25
+ - Respect output format constraints (free text, bullet points, JSON)
26
+
27
+ The agent learns real-world prompt engineering — a critical skill for production LLM systems where cost efficiency matters at scale.
28
+
29
+ ---
30
+
31
+ ## 🏗️ Architecture
32
+
33
+ ```
34
+ token_optimiser/
35
+ ├── inference.py # Hackathon evaluation script
36
+ ├── models.py # Pydantic data models (Action / Observation / State)
37
+ ├── client.py # Async WebSocket EnvClient
38
+ ├── openenv.yaml # OpenEnv deployment config
39
+ ├── Dockerfile # Root-level multi-stage build
40
+ ├── pyproject.toml # Package config & dependencies
41
+ ├── uv.lock # Locked dependencies
42
+ └── server/
43
+ ├── app.py # FastAPI server (WebSocket + HTTP)
44
+ ├── token_optimiser_environment.py # Core RL environment logic
45
+ └── requirements.txt # Server dependencies
46
+ ```
47
+
48
+ ---
49
+
50
+ ## 🧠 How It Works
51
+
52
+ ### Action Space
53
+ The agent submits a **`TokenOptimiserAction`** containing its optimized version of the original prompt:
54
+
55
+ ```python
56
+ class TokenOptimiserAction(Action):
57
+ optimized_prompt: str # Agent's rewritten, token-efficient prompt
58
+ ```
59
+
60
+ ### Observation Space
61
+ After each step, the agent receives a **`TokenOptimiserObservation`**:
62
+
63
+ ```python
64
+ class TokenOptimiserObservation(Observation):
65
+ llm_response: str # Actual LLM response to the optimized prompt
66
+ input_tokens: int # Token count of the optimized prompt
67
+ output_tokens: int # Token count of the LLM response
68
+ reward: float # Step reward (0.0 – 1.0)
69
+ ```
70
+
71
+ ### State Space
72
+ ```python
73
+ class TokenOptimiserState(State):
74
+ original_prompt: str # The verbose task prompt the agent must optimize
75
+ task_difficulty: str # "easy" | "medium" | "hard"
76
+ task_index: int # Index in task bank
77
+ ```
78
+
79
+ ---
80
+
81
+ ## 📋 Tasks
82
+
83
+ ### 🟢 Easy — Verbosity Reduction
84
+ **Original:** `"Can you please explain in a very detailed manner what machine learning is and how it works step by step?"`
85
+ **Goal:** Strip filler words, compress to core query
86
+ **Expected optimized:** `"Explain machine learning briefly."`
87
+ **Max output:** 50 tokens
88
+
89
+ ### 🟡 Medium — Format Constraint Addition
90
+ **Original:** `"I need a comprehensive analysis of the renewable energy market trends over the past decade, including solar, wind, and hydroelectric power growth rates..."`
91
+ **Goal:** Compress input AND add explicit output format constraints
92
+ **Expected optimized:** `"Summarize 2013-2023 renewable energy trends: solar, wind, hydro. In 5 bullet points."`
93
+ **Max output:** 100 tokens
94
+
95
+ ### 🔴 Hard — Multi-Intent Structured Output
96
+ **Original:** 82-word complex data science analysis request with 5 sub-tasks
97
+ **Goal:** Minimize total tokens (input + output) while producing structured JSON with all 5 required keys
98
+ **Expected optimized:** Compressed prompt specifying `JSON with keys: top_categories, growth_regions, responsive_segments, budget_allocation, risks_watch`
99
+ **Max output:** 200 tokens
100
+
101
+ ---
102
+
103
+ ## 🏆 Reward Function
104
+
105
+ **Hybrid grading** — combines token efficiency + LLM-as-judge semantic scoring:
106
+
107
+ | Component | Weight | Description |
108
+ |-----------|--------|-------------|
109
+ | Token Efficiency | 0.0 – 0.40 | Tokens saved vs. reference (input + output combined) |
110
+ | Semantic Quality | 0.0 – 0.30 | LLM judge rates response quality 0–10 |
111
+ | Format Compliance | 0.0 – 0.20 | Response matches required format (bullets / JSON / brief) |
112
+ | Length Penalty | −0.10 | Output exceeds 2× max token budget |
113
+
114
+ ```
115
+ reward = token_efficiency + (semantic_score × 0.3) + format_score + length_penalty
116
+ reward = clamp(reward, 0.0, 1.0)
117
+ ```
118
+
119
+ | Score | Meaning |
120
+ |-------|---------|
121
+ | 0.0 | Meaning lost, system failure, or no optimization |
122
+ | 0.3 – 0.5 | Token reduction achieved but quality degraded |
123
+ | 0.6 – 0.8 | Good balance of compression and quality |
124
+ | 0.9 – 1.0 | Optimal: minimal tokens, correct format, meaning preserved |
125
+
126
+ ---
127
+
128
+ ## ⚙️ Setup & Installation
129
+
130
+ ### Prerequisites
131
+ - Python 3.10+
132
+ - [`uv`](https://github.com/astral-sh/uv) (recommended) or `pip`
133
+ - A Hugging Face account with a token that has **Inference Providers** permission
134
+
135
+ ### 1. Clone and Install
136
+
137
+ ```bash
138
+ git clone <your-repo-url>
139
+ cd token_optimiser
140
+
141
+ # Install with uv (recommended)
142
+ uv sync
143
+
144
+ # Or with pip
145
+ pip install -e .
146
+ ```
147
+
148
+ ### 2. Set Environment Variables
149
+
150
+ ```bash
151
+ # Required
152
+ export HF_TOKEN="hf_your_token_here"
153
+
154
+ # Optional (these are the defaults)
155
+ export API_BASE_URL="https://router.huggingface.co/v1"
156
+ export MODEL_NAME="Qwen/Qwen2.5-72B-Instruct"
157
+ export SERVER_URL="http://localhost:8000"
158
+ ```
159
+
160
+ On Windows (PowerShell):
161
+ ```powershell
162
+ $env:HF_TOKEN = "hf_your_token_here"
163
+ ```
164
+
165
+ > **HF Token Permissions:** Your token must have **"Make calls to Inference Providers"** enabled.
166
+ > Create/edit at → https://huggingface.co/settings/tokens
167
+
168
+ ---
169
+
170
+ ## 🚀 Running Locally
171
+
172
+ ### Step 1 — Start the Environment Server
173
+
174
+ ```bash
175
+ # Terminal 1
176
+ uv run uvicorn server.app:app --host 0.0.0.0 --port 8000
177
+ ```
178
+
179
+ Expected output:
180
+ ```
181
+ INFO: Application startup complete.
182
+ INFO: Uvicorn running on http://0.0.0.0:8000
183
+ ```
184
+
185
+ ### Step 2 — Run the Inference Script
186
+
187
+ ```bash
188
+ # Terminal 2
189
+ uv run inference.py
190
+ ```
191
+
192
+ Expected output:
193
+ ```
194
+ [START] task=token_optimization env=token_optimiser model=Qwen/Qwen2.5-72B-Instruct
195
+ [STEP] step=1 action='Analyze renewable energy trends...' reward=0.62 done=false error=null
196
+ [STEP] step=2 action='Summarize 2013-2023 renewable energy...' reward=0.81 done=false error=null
197
+ [STEP] step=3 action='Renewable energy trends 2013-2023...' reward=0.84 done=false error=null
198
+ [STEP] step=4 action='Renewable energy 2013-2023 trends...' reward=0.82 done=false error=null
199
+ [STEP] step=5 action='Energy trends 2013-2023: solar, wind...' reward=0.81 done=true error=null
200
+ [END] success=true steps=5 score=0.780 rewards=0.62,0.81,0.84,0.82,0.81
201
+ ```
202
+
203
+ ### Step 3 — (Optional) Run via Docker
204
+
205
+ ```bash
206
+ # Build
207
+ docker build -t token-optimiser-env .
208
+
209
+ # Run
210
+ docker run -p 8000:8000 \
211
+ -e HF_TOKEN=$HF_TOKEN \
212
+ -e API_BASE_URL=$API_BASE_URL \
213
+ token-optimiser-env
214
+ ```
215
+
216
+ ---
217
+
218
+ ## 🐍 Using the Python Client
219
+
220
+ ```python
221
+ import asyncio
222
+ from token_optimiser import TokenOptimiserEnv, TokenOptimiserAction
223
+
224
+ async def main():
225
+ async with TokenOptimiserEnv(base_url="http://localhost:8000") as env:
226
+ # Reset — get the task
227
+ result = await env.reset()
228
+ state = await env.state()
229
+ print(f"Task: {state.original_prompt}")
230
+ print(f"Difficulty: {state.task_difficulty}")
231
+
232
+ # Agent submits an optimized prompt
233
+ result = await env.step(
234
+ TokenOptimiserAction(optimized_prompt="Explain machine learning briefly.")
235
+ )
236
+ print(f"LLM Response: {result.observation.llm_response}")
237
+ print(f"Reward: {result.reward}")
238
+ print(f"Tokens — in: {result.observation.input_tokens}, out: {result.observation.output_tokens}")
239
+
240
+ asyncio.run(main())
241
+ ```
242
+
243
+ ---
244
+
245
+ ## ☁️ Deployment
246
+
247
+ Deploy to Hugging Face Spaces using the OpenEnv CLI:
248
+
249
+ ```bash
250
+ openenv push
251
+ ```
252
+
253
+ ---
254
+
255
+ ## 🔧 Environment Variables Reference
256
+
257
+ | Variable | Default | Description |
258
+ |----------|---------|-------------|
259
+ | `HF_TOKEN` | *(required)* | Hugging Face API key with Inference Providers permission |
260
+ | `API_BASE_URL` | `https://router.huggingface.co/v1` | LLM endpoint URL |
261
+ | `MODEL_NAME` | `Qwen/Qwen2.5-72B-Instruct` | Model used for responses and judging |
262
+ | `SERVER_URL` | `http://localhost:8000` | Environment server URL (for inference.py) |
263
+ | `LOCAL_IMAGE_NAME` | *(optional)* | Docker image name — auto-spins container if set |
264
+
265
+ ---
266
+
267
+ ## 📦 Dependencies
268
+
269
+ - [`openenv-core`](https://github.com/meta-pytorch/OpenEnv) ≥ 0.2.2 — RL environment framework
270
+ - `openai` — LLM API client (routed through HF)
271
+ - `fastapi` + `uvicorn` — Environment server
272
+ - `pydantic` v2 — Data model validation
273
+
274
+ See [`pyproject.toml`](./pyproject.toml) for the full pinned dependency list.
275
+
276
+ ---
277
+
278
+ ## 📄 License
279
+
280
+ BSD License — see source files for details.
__init__.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Token Optimiser Environment."""
8
+
9
+ from .client import TokenOptimiserEnv
10
+ from .models import TokenOptimiserAction, TokenOptimiserObservation
11
+
12
+ __all__ = [
13
+ "TokenOptimiserAction",
14
+ "TokenOptimiserObservation",
15
+ "TokenOptimiserEnv",
16
+ ]
client.py ADDED
@@ -0,0 +1,101 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # source tree.
6
+
7
+ """Token Optimiser Environment Client."""
8
+
9
+ from typing import Dict
10
+
11
+ from openenv.core import EnvClient
12
+ from openenv.core.client_types import StepResult
13
+ from openenv.core.env_server.types import State
14
+
15
+ from .models import TokenOptimiserAction, TokenOptimiserObservation, TokenOptimiserState
16
+
17
+
18
+ class TokenOptimiserEnv(
19
+ EnvClient[TokenOptimiserAction, TokenOptimiserObservation, TokenOptimiserState]
20
+ ):
21
+ """
22
+ Client for the Token Optimiser Environment.
23
+
24
+ This client maintains a persistent WebSocket connection to the environment server,
25
+ enabling efficient multi-step interactions with lower latency.
26
+ Each client instance has its own dedicated environment session on the server.
27
+
28
+ Example:
29
+ >>> # Connect to a running server
30
+ >>> with TokenOptimiserEnv(base_url="http://localhost:8000") as client:
31
+ ... result = client.reset()
32
+ ... print(result.observation.llm_response)
33
+ ...
34
+ ... result = client.step(TokenOptimiserAction(optimized_prompt="Explain ML briefly"))
35
+ ... print(result.observation.llm_response)
36
+
37
+ Example with Docker:
38
+ >>> # Automatically start container and connect
39
+ >>> client = TokenOptimiserEnv.from_docker_image("token_optimiser-env:latest")
40
+ >>> try:
41
+ ... result = client.reset()
42
+ ... result = client.step(TokenOptimiserAction(optimized_prompt="Test prompt"))
43
+ ... finally:
44
+ ... client.close()
45
+ """
46
+
47
+ def _step_payload(self, action: TokenOptimiserAction) -> Dict:
48
+ """
49
+ Convert TokenOptimiserAction to JSON payload for step message.
50
+
51
+ Args:
52
+ action: TokenOptimiserAction instance
53
+
54
+ Returns:
55
+ Dictionary representation suitable for JSON encoding
56
+ """
57
+ return {
58
+ "optimized_prompt": action.optimized_prompt,
59
+ }
60
+
61
+ def _parse_result(self, payload: Dict) -> StepResult[TokenOptimiserObservation]:
62
+ """
63
+ Parse server response into StepResult[TokenOptimiserObservation].
64
+
65
+ Args:
66
+ payload: JSON response data from server
67
+
68
+ Returns:
69
+ StepResult with TokenOptimiserObservation
70
+ """
71
+ obs_data = payload.get("observation", {})
72
+ observation = TokenOptimiserObservation(
73
+ llm_response=obs_data.get("llm_response", ""),
74
+ input_tokens=obs_data.get("input_tokens", 0),
75
+ output_tokens=obs_data.get("output_tokens", 0),
76
+ reward=obs_data.get("reward", 0.0),
77
+ )
78
+
79
+ return StepResult(
80
+ observation=observation,
81
+ reward=payload.get("reward", 0.0),
82
+ done=payload.get("done", False),
83
+ )
84
+
85
+ def _parse_state(self, payload: Dict) -> TokenOptimiserState:
86
+ """
87
+ Parse server response into TokenOptimiserState object.
88
+
89
+ Args:
90
+ payload: JSON response from state request
91
+
92
+ Returns:
93
+ TokenOptimiserState object with episode information
94
+ """
95
+ return TokenOptimiserState(
96
+ episode_id=payload.get("episode_id"),
97
+ step_count=payload.get("step_count", 0),
98
+ original_prompt=payload.get("original_prompt", ""),
99
+ task_difficulty=payload.get("task_difficulty", "easy"),
100
+ task_index=payload.get("task_index", 0)
101
+ )
inference.py ADDED
@@ -0,0 +1,257 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Inference Script — Token Optimiser Environment
3
+ ================================================
4
+ STDOUT FORMAT (mandatory):
5
+ [START] task=<task> env=<env> model=<model>
6
+ [STEP] step=<n> action=<str> reward=<0.00> done=<true|false> error=<msg|null>
7
+ [END] success=<true|false> steps=<n> score=<0.000> rewards=<r1,r2,...>
8
+
9
+ Environment variables required:
10
+ HF_TOKEN — Hugging Face API key
11
+ API_BASE_URL — LLM endpoint (default: https://router.huggingface.co/v1)
12
+ MODEL_NAME — Model id (default: Qwen/Qwen2.5-72B-Instruct)
13
+ SERVER_URL — Running env server (default: http://localhost:8000)
14
+ LOCAL_IMAGE_NAME — Docker image name (optional; spins up container if set)
15
+ """
16
+
17
+ import asyncio
18
+ import os
19
+ import textwrap
20
+ from typing import List, Optional
21
+
22
+ from openai import OpenAI
23
+
24
+ from token_optimiser import TokenOptimiserEnv, TokenOptimiserAction
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Configuration
28
+ # ---------------------------------------------------------------------------
29
+ API_BASE_URL: str = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
30
+ MODEL_NAME: str = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
31
+ HF_TOKEN: Optional[str] = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
32
+ SERVER_URL: str = os.getenv("SERVER_URL", "http://localhost:8000")
33
+ LOCAL_IMAGE_NAME: Optional[str] = os.getenv("LOCAL_IMAGE_NAME")
34
+
35
+ TASK_NAME: str = "token_optimization"
36
+ BENCHMARK: str = "token_optimiser"
37
+ MAX_STEPS: int = 5
38
+ TEMPERATURE: float = 0.3
39
+ MAX_TOKENS: int = 200
40
+ SUCCESS_THRESHOLD: float = 0.6
41
+
42
+ SYSTEM_PROMPT = textwrap.dedent("""
43
+ You are a prompt optimization expert. Rewrite the given prompt to:
44
+ 1. Use the fewest possible tokens (concise language, no filler words)
45
+ 2. Preserve full semantic meaning and intent
46
+ 3. Add explicit output-format constraints (e.g., "in 5 bullet points", "as JSON with keys: …")
47
+ 4. Guide the responder toward a shorter, precise answer
48
+
49
+ Reply with ONLY the optimized prompt — no explanations, no prefixes, no quotes.
50
+ """).strip()
51
+
52
+ # ---------------------------------------------------------------------------
53
+ # Logging helpers (mandatory format)
54
+ # ---------------------------------------------------------------------------
55
+
56
+ def log_start(task: str, env: str, model: str) -> None:
57
+ print(f"[START] task={task} env={env} model={model}", flush=True)
58
+
59
+
60
+ def log_step(step: int, action: str, reward: float, done: bool, error: Optional[str]) -> None:
61
+ # Truncate action for readability but keep it on one line
62
+ action_short = action.replace("\n", " ")[:120]
63
+ error_val = error if error else "null"
64
+ print(
65
+ f"[STEP] step={step} action={action_short!r} "
66
+ f"reward={reward:.2f} done={str(done).lower()} error={error_val}",
67
+ flush=True,
68
+ )
69
+
70
+
71
+ def log_end(success: bool, steps: int, score: float, rewards: List[float]) -> None:
72
+ rewards_str = ",".join(f"{r:.2f}" for r in rewards)
73
+ print(
74
+ f"[END] success={str(success).lower()} steps={steps} "
75
+ f"score={score:.3f} rewards={rewards_str}",
76
+ flush=True,
77
+ )
78
+
79
+
80
+ # ---------------------------------------------------------------------------
81
+ # LLM helpers
82
+ # ---------------------------------------------------------------------------
83
+
84
+ def _build_user_message(original_prompt: str, step: int,
85
+ prev_reward: float, prev_response: str,
86
+ history: List[str]) -> str:
87
+ if step == 1:
88
+ return (
89
+ f"Optimize this prompt to minimize tokens while preserving all meaning:\n\n"
90
+ f"{original_prompt}"
91
+ )
92
+ history_block = "\n".join(history[-3:]) if history else "None"
93
+ return textwrap.dedent(f"""
94
+ Original prompt:
95
+ {original_prompt}
96
+
97
+ Your last optimized version got reward: {prev_reward:.2f}
98
+ LLM responded with: {prev_response!r}
99
+
100
+ Recent history:
101
+ {history_block}
102
+
103
+ Improve your optimization further. Reply with ONLY the new optimized prompt.
104
+ """).strip()
105
+
106
+
107
+ def get_optimized_prompt(
108
+ llm: OpenAI,
109
+ original_prompt: str,
110
+ step: int,
111
+ prev_reward: float,
112
+ prev_response: str,
113
+ history: List[str],
114
+ ) -> str:
115
+ user_msg = _build_user_message(original_prompt, step, prev_reward, prev_response, history)
116
+ try:
117
+ completion = llm.chat.completions.create(
118
+ model=MODEL_NAME,
119
+ messages=[
120
+ {"role": "system", "content": SYSTEM_PROMPT},
121
+ {"role": "user", "content": user_msg},
122
+ ],
123
+ temperature=TEMPERATURE,
124
+ max_tokens=MAX_TOKENS,
125
+ )
126
+ result = (completion.choices[0].message.content or "").strip()
127
+ return result if result else "Explain briefly."
128
+ except Exception as exc:
129
+ print(f"[DEBUG] LLM call failed: {exc}", flush=True)
130
+ return _rule_based_compress(original_prompt, step)
131
+
132
+
133
+ # Rule-based fallback compressor (used when LLM is unavailable)
134
+ _FILLER = {
135
+ "please", "kindly", "could", "you", "can", "i", "need", "want", "would",
136
+ "like", "very", "really", "just", "actually", "basically", "specifically",
137
+ "a", "an", "the", "in", "of", "to", "and", "that", "is", "are", "be",
138
+ "will", "should", "must", "have", "has", "do", "does", "for", "with",
139
+ "as", "at", "by", "on", "or", "but", "it", "its", "this",
140
+ }
141
+ _BREVITY = [
142
+ "", # step 1 — just strip fillers
143
+ " Be brief.", # step 2
144
+ " Limit response to 3 sentences.", # step 3
145
+ " Reply in one sentence.", # step 4+
146
+ ]
147
+
148
+
149
+ def _rule_based_compress(original_prompt: str, step: int = 1) -> str:
150
+ """Strip filler words and add a conciseness constraint."""
151
+ words = original_prompt.split()
152
+ compressed = [
153
+ w for w in words
154
+ if w.lower().rstrip(".,?!") not in _FILLER
155
+ ]
156
+ suffix = _BREVITY[min(step - 1, len(_BREVITY) - 1)]
157
+ result = " ".join(compressed) + suffix
158
+ return result if result.strip() else original_prompt
159
+
160
+
161
+ # ---------------------------------------------------------------------------
162
+ # Main episode loop
163
+ # ---------------------------------------------------------------------------
164
+
165
+ async def run_episode(llm: OpenAI) -> None:
166
+ rewards: List[float] = []
167
+ steps_taken = 0
168
+ score = 0.0
169
+ success = False
170
+
171
+ log_start(task=TASK_NAME, env=BENCHMARK, model=MODEL_NAME)
172
+
173
+ # Connect to environment
174
+ if LOCAL_IMAGE_NAME:
175
+ env = await TokenOptimiserEnv.from_docker_image(LOCAL_IMAGE_NAME)
176
+ else:
177
+ env = TokenOptimiserEnv(base_url=SERVER_URL)
178
+ await env.connect()
179
+
180
+ try:
181
+ # Reset — get initial observation
182
+ reset_result = await env.reset()
183
+
184
+ # Fetch original prompt from server state
185
+ env_state = await env.state()
186
+ original_prompt: str = env_state.original_prompt or "Explain machine learning briefly."
187
+
188
+ print(
189
+ f"[DEBUG] Task difficulty={env_state.task_difficulty} | "
190
+ f"Original prompt ({len(original_prompt.split())} words): {original_prompt[:80]}...",
191
+ flush=True,
192
+ )
193
+
194
+ prev_reward = 0.0
195
+ prev_response = ""
196
+ history: List[str] = []
197
+
198
+ for step in range(1, MAX_STEPS + 1):
199
+ # Ask LLM to optimize the prompt
200
+ optimized = get_optimized_prompt(
201
+ llm, original_prompt, step, prev_reward, prev_response, history
202
+ )
203
+
204
+ # Step the environment with the optimized prompt
205
+ error_msg: Optional[str] = None
206
+ reward = 0.0
207
+ done = False
208
+ try:
209
+ result = await env.step(TokenOptimiserAction(optimized_prompt=optimized))
210
+ obs = result.observation
211
+ reward = result.reward # server puts reward at top-level, not inside obs
212
+ done = result.done or (step >= MAX_STEPS)
213
+ prev_response = obs.llm_response
214
+ print(
215
+ f"[DEBUG] tokens in={obs.input_tokens} out={obs.output_tokens}",
216
+ flush=True,
217
+ )
218
+ except Exception as exc:
219
+ error_msg = str(exc)
220
+ done = True
221
+
222
+ rewards.append(reward)
223
+ steps_taken = step
224
+ prev_reward = reward
225
+ history.append(f"step={step} prompt={optimized!r:.60} reward={reward:.2f}")
226
+
227
+ log_step(step=step, action=optimized, reward=reward, done=done, error=error_msg)
228
+
229
+ if done:
230
+ break
231
+
232
+ # Score = average reward across steps, clamped to [0, 1]
233
+ score = sum(rewards) / len(rewards) if rewards else 0.0
234
+ score = max(0.0, min(1.0, score))
235
+ success = score >= SUCCESS_THRESHOLD
236
+
237
+ except Exception as exc:
238
+ print(f"[DEBUG] Episode error: {exc}", flush=True)
239
+ finally:
240
+ try:
241
+ await env.close()
242
+ except Exception:
243
+ pass
244
+ log_end(success=success, steps=steps_taken, score=score, rewards=rewards)
245
+
246
+
247
+ async def main() -> None:
248
+ if not HF_TOKEN:
249
+ print("[ERROR] HF_TOKEN environment variable not set. Exiting.", flush=True)
250
+ return
251
+
252
+ llm = OpenAI(base_url=API_BASE_URL, api_key=HF_TOKEN)
253
+ await run_episode(llm)
254
+
255
+
256
+ if __name__ == "__main__":
257
+ asyncio.run(main())
models.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # source tree.
6
+
7
+ """
8
+ Data models for the Prompt & Response Token Optimization Environment.
9
+ """
10
+
11
+ from openenv.core.env_server.types import Action, Observation, State
12
+ from pydantic import Field
13
+
14
+
15
+ class TokenOptimiserAction(Action):
16
+ """Action for the Token Optimiser environment - the optimized prompt."""
17
+
18
+ optimized_prompt: str = Field(..., description="The optimized prompt sent to the LLM")
19
+
20
+
21
+ class TokenOptimiserObservation(Observation):
22
+ """Observation from the Token Optimiser environment - LLM response and token metrics."""
23
+
24
+ llm_response: str = Field(default="", description="The response from the LLM")
25
+ input_tokens: int = Field(default=0, description="Number of tokens in the optimized prompt")
26
+ output_tokens: int = Field(default=0, description="Number of tokens in the LLM response")
27
+ reward: float = Field(default=0.0, description="Reward score for this step (0.0-1.0)")
28
+
29
+
30
+ class TokenOptimiserState(State):
31
+ """State for the Token Optimiser environment."""
32
+
33
+ original_prompt: str = Field(default="", description="The original user prompt/task")
34
+ task_difficulty: str = Field(default="easy", description="Current task difficulty: easy/medium/hard")
35
+ task_index: int = Field(default=0, description="Index of current task in task bank")
openenv.yaml ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ spec_version: 1
2
+ name: token_optimiser
3
+ type: space
4
+ runtime: fastapi
5
+ app: server.app:app
6
+ port: 8000
7
+
openenv_token_optimiser.egg-info/PKG-INFO ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ Metadata-Version: 2.4
2
+ Name: openenv-token_optimiser
3
+ Version: 0.1.0
4
+ Summary: Token Optimiser environment for OpenEnv
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: openenv-core[core]>=0.2.2
7
+ Provides-Extra: dev
8
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
9
+ Requires-Dist: pytest-cov>=4.0.0; extra == "dev"
openenv_token_optimiser.egg-info/SOURCES.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ README.md
2
+ pyproject.toml
3
+ ./__init__.py
4
+ ./client.py
5
+ ./inference.py
6
+ ./models.py
7
+ openenv_token_optimiser.egg-info/PKG-INFO
8
+ openenv_token_optimiser.egg-info/SOURCES.txt
9
+ openenv_token_optimiser.egg-info/dependency_links.txt
10
+ openenv_token_optimiser.egg-info/entry_points.txt
11
+ openenv_token_optimiser.egg-info/requires.txt
12
+ openenv_token_optimiser.egg-info/top_level.txt
13
+ server/__init__.py
14
+ server/app.py
15
+ server/token_optimiser_environment.py
openenv_token_optimiser.egg-info/dependency_links.txt ADDED
@@ -0,0 +1 @@
 
 
1
+
openenv_token_optimiser.egg-info/entry_points.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [console_scripts]
2
+ server = token_optimiser.server.app:main
openenv_token_optimiser.egg-info/requires.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ openenv-core[core]>=0.2.2
2
+
3
+ [dev]
4
+ pytest>=8.0.0
5
+ pytest-cov>=4.0.0
openenv_token_optimiser.egg-info/top_level.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ token_optimiser
pyproject.toml ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ [build-system]
8
+ requires = ["setuptools>=45", "wheel"]
9
+ build-backend = "setuptools.build_meta"
10
+
11
+ [project]
12
+ name = "openenv-token_optimiser"
13
+ version = "0.1.0"
14
+ description = "Token Optimiser environment for OpenEnv"
15
+ requires-python = ">=3.10"
16
+ dependencies = [
17
+ # Core OpenEnv runtime (provides FastAPI server + HTTP client types)
18
+ # install from github
19
+ # "openenv-core[core] @ git+https://github.com/meta-pytorch/OpenEnv.git",
20
+ "openenv-core[core]>=0.2.2",
21
+ # Environment-specific dependencies
22
+ # Add all dependencies needed for your environment here
23
+ # Examples:
24
+ # "numpy>=1.19.0",
25
+ # "torch>=2.0.0",
26
+ # "gymnasium>=0.29.0",
27
+ # "openspiel>=1.0.0",
28
+ # "smolagents>=1.22.0,<2",
29
+ ]
30
+
31
+ [project.optional-dependencies]
32
+ dev = [
33
+ "pytest>=8.0.0",
34
+ "pytest-cov>=4.0.0",
35
+ ]
36
+
37
+ [project.scripts]
38
+ # Server entry point - enables running via: uv run --project . server
39
+ # or: python -m token_optimiser.server.app
40
+ server = "token_optimiser.server.app:main"
41
+
42
+ [tool.setuptools]
43
+ include-package-data = true
44
+ packages = ["token_optimiser", "token_optimiser.server"]
45
+ package-dir = { "token_optimiser" = ".", "token_optimiser.server" = "server" }
server/__init__.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # LICENSE file in the root directory of this source tree.
6
+
7
+ """Token Optimiser environment server components."""
8
+
9
+ from .token_optimiser_environment import TokenOptimiserEnvironment
10
+
11
+ __all__ = ["TokenOptimiserEnvironment"]
server/app.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # source tree.
6
+
7
+ """
8
+ FastAPI application for the Token Optimiser Environment.
9
+
10
+ This module creates an HTTP server that exposes the TokenOptimiserEnvironment
11
+ over HTTP and WebSocket endpoints, compatible with EnvClient.
12
+
13
+ Endpoints:
14
+ - POST /reset: Reset the environment
15
+ - POST /step: Execute an action
16
+ - GET /state: Get current environment state
17
+ - GET /schema: Get action/observation schemas
18
+ - WS /ws: WebSocket endpoint for persistent sessions
19
+
20
+ Usage:
21
+ # Development (with auto-reload):
22
+ uvicorn server.app:app --reload --host 0.0.0.0 --port 8000
23
+
24
+ # Production:
25
+ uvicorn server.app:app --host 0.0.0.0 --port 8000 --workers 4
26
+
27
+ # Or run directly:
28
+ python -m server.app
29
+ """
30
+
31
+ try:
32
+ from openenv.core.env_server.http_server import create_app
33
+ except Exception as e: # pragma: no cover
34
+ raise ImportError(
35
+ "openenv is required for the web interface. Install dependencies with '\n uv sync\n'"
36
+ ) from e
37
+
38
+ # Import using absolute paths from package root
39
+ import sys
40
+ import os
41
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
42
+
43
+ from models import TokenOptimiserAction, TokenOptimiserObservation, TokenOptimiserState
44
+ from server.token_optimiser_environment import TokenOptimiserEnvironment
45
+
46
+
47
+ # Create the app with web interface and README integration
48
+ app = create_app(
49
+ TokenOptimiserEnvironment,
50
+ TokenOptimiserAction,
51
+ TokenOptimiserObservation,
52
+ env_name="token_optimiser",
53
+ max_concurrent_envs=10, # Increased for RL training concurrency
54
+ )
55
+
56
+
57
+ def main(host: str = "0.0.0.0", port: int = 8000):
58
+ """
59
+ Entry point for direct execution via uv run or python -m.
60
+
61
+ This function enables running the server without Docker:
62
+ uv run --project . server
63
+ uv run --project . server --port 8001
64
+ python -m token_optimiser.server.app
65
+
66
+ Args:
67
+ host: Host address to bind to (default: "0.0.0.0")
68
+ port: Port number to listen on (default: 8000)
69
+
70
+ For production deployments, consider using uvicorn directly with
71
+ multiple workers:
72
+ uvicorn token_optimiser.server.app:app --workers 4
73
+ """
74
+ import uvicorn
75
+
76
+ uvicorn.run(app, host=host, port=port)
77
+
78
+
79
+ if __name__ == "__main__":
80
+ import argparse
81
+
82
+ parser = argparse.ArgumentParser()
83
+ parser.add_argument("--port", type=int, default=8000)
84
+ args = parser.parse_args()
85
+ main(port=args.port)
server/requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ openenv[core]>=0.2.0
2
+ fastapi>=0.115.0
3
+ uvicorn>=0.24.0
4
+
5
+
6
+
server/token_optimiser_environment.py ADDED
@@ -0,0 +1,366 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright (c) Meta Platforms, Inc. and affiliates.
2
+ # All rights reserved.
3
+ #
4
+ # This source code is licensed under the BSD-style license found in the
5
+ # source tree.
6
+
7
+ """
8
+ Prompt & Response Token Optimization Environment Implementation.
9
+
10
+ A sandboxed LLM interaction environment where an AI agent optimizes both input prompts
11
+ and expected output responses to minimize total token usage while maintaining correctness.
12
+ """
13
+
14
+ import os
15
+ import random
16
+ from uuid import uuid4
17
+
18
+ try:
19
+ from openai import OpenAI
20
+ except ImportError:
21
+ OpenAI = None
22
+
23
+ from openenv.core.env_server.interfaces import Environment
24
+ from openenv.core.env_server.types import State
25
+
26
+ try:
27
+ from ..models import TokenOptimiserAction, TokenOptimiserObservation, TokenOptimiserState
28
+ except ImportError:
29
+ from models import TokenOptimiserAction, TokenOptimiserObservation, TokenOptimiserState
30
+
31
+
32
+ class TokenOptimiserEnvironment(Environment):
33
+ """
34
+ Prompt & Response Token Optimization Environment.
35
+
36
+ The agent receives a user prompt/task and must optimize it to reduce token usage
37
+ while guiding the LLM to produce correct, properly formatted responses.
38
+ """
39
+
40
+ # Enable concurrent WebSocket sessions - REQUIRED for RL training
41
+ SUPPORTS_CONCURRENT_SESSIONS: bool = True
42
+
43
+ def __init__(self):
44
+ """Initialize the token optimization environment."""
45
+ self._state = TokenOptimiserState(episode_id=str(uuid4()), step_count=0)
46
+ self._task_bank = self._load_task_bank()
47
+ self._current_task = None
48
+ self._reset_count = 0
49
+
50
+ # Hybrid LLM client — reads credentials from env vars at startup
51
+ api_key = os.getenv("HF_TOKEN") or os.getenv("API_KEY")
52
+ api_base = os.getenv("API_BASE_URL", "https://router.huggingface.co/v1")
53
+ self._model = os.getenv("MODEL_NAME", "Qwen/Qwen2.5-72B-Instruct")
54
+ if OpenAI and api_key:
55
+ self._llm = OpenAI(base_url=api_base, api_key=api_key)
56
+ else:
57
+ self._llm = None
58
+
59
+ def _load_task_bank(self):
60
+ """Load the bank of prompt optimization tasks."""
61
+ return [
62
+ # EASY TASK
63
+ {
64
+ "difficulty": "easy",
65
+ "prompt": "Can you please explain in a very detailed manner what machine learning is and how it works step by step?",
66
+ "expected_format": "brief explanation",
67
+ "reference_response": "Machine learning is a subset of AI that enables computers to learn from data without explicit programming. It works by identifying patterns in training data to make predictions or decisions on new data.",
68
+ "max_output_tokens": 50,
69
+ "description": "Reduce verbosity while preserving core concept"
70
+ },
71
+ # MEDIUM TASK
72
+ {
73
+ "difficulty": "medium",
74
+ "prompt": "I need a comprehensive analysis of the renewable energy market trends over the past decade, including solar, wind, and hydroelectric power growth rates, investment patterns, technological advancements, and policy impacts across different regions globally.",
75
+ "expected_format": "5 bullet points summarizing key trends",
76
+ "reference_response": "• Solar power capacity grew 22% annually avg. • Wind energy investments reached $140B in 2020 • Hydroelectric remains largest renewable source • Battery storage tech advancing rapidly • Policy incentives driving global adoption",
77
+ "max_output_tokens": 100,
78
+ "description": "Compress input + specify bullet point format + length limit"
79
+ },
80
+ # HARD TASK
81
+ {
82
+ "difficulty": "hard",
83
+ "prompt": "As a senior data scientist, I need you to analyze our Q3 sales performance dataset and provide actionable insights. The dataset contains: customer demographics, purchase history, product categories, regional sales data, marketing campaign ROI, seasonal trends, and competitor analysis. Please identify: 1) Our top 3 performing product categories and why, 2) Geographic regions with highest growth potential, 3) Customer segments most responsive to our email campaigns, 4) Optimal marketing budget allocation for Q4, and 5) Risks to watch based on economic indicators.",
84
+ "expected_format": "JSON with 5 keys: top_categories, growth_regions, responsive_segments, budget_allocation, risks_watch",
85
+ "reference_response": '{"top_categories": ["electronics", "software", "home_goods"], "growth_regions": ["SE Asia", "Latin America", "Africa"], "responsive_segments": ["young_professionals", "tech_enthusiasts"], "budget_allocation": {"email": 0.3, "social": 0.25, "search": 0.2, "tv": 0.15, "other": 0.1}, "risks_watch": ["inflation", "supply_chain", "labor_shortage"]}',
86
+ "max_output_tokens": 200,
87
+ "description": "Multi-intent optimization: compress complex request + specify JSON format + accuracy + length constraints"
88
+ }
89
+ ]
90
+
91
+ def reset(self) -> TokenOptimiserObservation:
92
+ """
93
+ Reset the environment with a random task from the task bank.
94
+
95
+ Returns:
96
+ TokenOptimiserObservation with initial state
97
+ """
98
+ # Select a random task
99
+ self._current_task = random.choice(self._task_bank)
100
+ self._state = TokenOptimiserState(
101
+ episode_id=str(uuid4()),
102
+ step_count=0,
103
+ original_prompt=self._current_task["prompt"],
104
+ task_difficulty=self._current_task["difficulty"],
105
+ task_index=self._task_bank.index(self._current_task)
106
+ )
107
+ self._reset_count += 1
108
+
109
+ return TokenOptimiserObservation(
110
+ llm_response="",
111
+ input_tokens=0,
112
+ output_tokens=0,
113
+ reward=0.0
114
+ )
115
+
116
+ def step(self, action: TokenOptimiserAction) -> TokenOptimiserObservation: # type: ignore[override]
117
+ """
118
+ Execute a step in the environment by evaluating the agent's optimized prompt.
119
+
120
+ Args:
121
+ action: TokenOptimiserAction containing the optimized prompt
122
+
123
+ Returns:
124
+ TokenOptimiserObservation with LLM response simulation and reward
125
+ """
126
+ self._state.step_count += 1
127
+ optimized_prompt = action.optimized_prompt
128
+ original_prompt = self._current_task["prompt"]
129
+
130
+ # 1. Call real LLM (or fallback) to get the actual response + token counts
131
+ llm_response, input_tokens, output_tokens = self._call_llm(optimized_prompt)
132
+
133
+ # 2. LLM-as-judge: semantic quality score (0.0-1.0)
134
+ semantic_score = self._judge_semantic_quality(original_prompt, llm_response)
135
+
136
+ # 3. Token efficiency: how much did we reduce vs the original prompt token count?
137
+ original_tokens = len(original_prompt.split()) * 1.3
138
+ ref_output_tokens = len(self._current_task["reference_response"].split()) * 1.3
139
+ ref_total = original_tokens + ref_output_tokens
140
+ actual_total = input_tokens + output_tokens
141
+ token_efficiency = max(0.0, min(0.4, (ref_total - actual_total) / max(ref_total, 1)))
142
+
143
+ # 4. Format compliance (0.0-0.2)
144
+ expected_fmt = self._current_task["expected_format"]
145
+ format_score = 0.0
146
+ if "bullet" in expected_fmt and any(c in llm_response for c in ("•", "*", "-", "\n")):
147
+ format_score = 0.2
148
+ elif "json" in expected_fmt.lower() and "{" in llm_response and "}" in llm_response:
149
+ format_score = 0.2
150
+ elif "brief" in expected_fmt and len(llm_response.split()) < 30:
151
+ format_score = 0.2
152
+
153
+ # 5. Length penalty if output way too long
154
+ max_out = self._current_task["max_output_tokens"]
155
+ length_penalty = -0.1 if output_tokens > max_out * 2 else 0.0
156
+
157
+ # Final reward: weighted hybrid
158
+ reward = (
159
+ token_efficiency # 0.0 - 0.4 (token saving)
160
+ + semantic_score * 0.3 # 0.0 - 0.3 (LLM judge quality)
161
+ + format_score # 0.0 - 0.2 (format compliance)
162
+ + length_penalty # 0.0 or -0.1 (penalty)
163
+ )
164
+ reward = max(0.0, min(1.0, reward))
165
+
166
+ print(
167
+ f"[ENV] tok_eff={token_efficiency:.2f} semantic={semantic_score:.2f} "
168
+ f"fmt={format_score:.2f} => reward={reward:.2f}",
169
+ flush=True,
170
+ )
171
+
172
+ return TokenOptimiserObservation(
173
+ llm_response=llm_response,
174
+ input_tokens=int(input_tokens),
175
+ output_tokens=int(output_tokens),
176
+ reward=reward
177
+ )
178
+
179
+ def _call_llm(self, prompt: str) -> tuple[str, int, int]:
180
+ """
181
+ Call the real LLM with the optimized prompt.
182
+ Returns (response_text, input_tokens, output_tokens).
183
+ Falls back to rule-based simulation if LLM is unavailable.
184
+ """
185
+ if self._llm is not None:
186
+ try:
187
+ resp = self._llm.chat.completions.create(
188
+ model=self._model,
189
+ messages=[{"role": "user", "content": prompt}],
190
+ max_tokens=200,
191
+ temperature=0.3,
192
+ )
193
+ text = (resp.choices[0].message.content or "").strip()
194
+ in_tok = resp.usage.prompt_tokens if resp.usage else len(prompt.split())
195
+ out_tok = resp.usage.completion_tokens if resp.usage else len(text.split())
196
+ return text, in_tok, out_tok
197
+ except Exception as e:
198
+ print(f"[ENV] LLM call failed, using fallback: {e}")
199
+
200
+ # Rule-based fallback
201
+ return self._fallback_simulate(prompt)
202
+
203
+ def _fallback_simulate(self, prompt: str) -> tuple[str, int, int]:
204
+ """Fast deterministic fallback when LLM is unavailable."""
205
+ if self._current_task is None:
206
+ text = "No task loaded."
207
+ return text, len(prompt.split()), len(text.split())
208
+
209
+ expected_format = self._current_task["expected_format"]
210
+ original_words = len(self._current_task["prompt"].split())
211
+ compression_ratio = len(prompt.split()) / max(original_words, 1)
212
+
213
+ if "brief explanation" in expected_format:
214
+ text = ("Machine learning is AI that learns from data to make predictions."
215
+ if compression_ratio <= 0.6
216
+ else "Machine learning enables systems to learn from experience and improve without explicit programming.")
217
+ elif "bullet" in expected_format:
218
+ text = ("• Solar power growing rapidly\n• Wind energy investments increasing\n• Hydroelectric power stable\n• Battery storage advancing\n• Global renewable adoption rising"
219
+ if compression_ratio <= 0.7
220
+ else "Renewable energy sectors are growing, led by solar and wind with strong policy support.")
221
+ elif "json" in expected_format.lower():
222
+ text = ('{"top_categories": ["electronics", "software"], "growth_regions": ["Asia", "Africa"], "responsive_segments": ["professionals"], "budget_allocation": {"email": 0.4, "social": 0.3}, "risks_watch": ["inflation"]}' # noqa
223
+ if compression_ratio <= 0.6
224
+ else "Key categories: electronics, software. Growth in Asia and Africa.")
225
+ else:
226
+ text = "I understand your request and will provide a helpful response."
227
+
228
+ in_tok = int(len(prompt.split()) * 1.3)
229
+ out_tok = int(len(text.split()) * 1.3)
230
+ return text, in_tok, out_tok
231
+
232
+ def _judge_semantic_quality(self, original_prompt: str, response: str) -> float:
233
+ """
234
+ LLM-as-judge: score how well the response answers the original prompt.
235
+ Returns a float 0.0-1.0.
236
+ """
237
+ if self._llm is None:
238
+ return self._keyword_fallback_score(original_prompt, response)
239
+
240
+ judge_prompt = (
241
+ f"Rate 0 to 10 how well the RESPONSE answers the ORIGINAL question. "
242
+ f"Consider accuracy and completeness. Reply with a single integer only.\n\n"
243
+ f"ORIGINAL: {original_prompt[:300]}\n\nRESPONSE: {response[:400]}"
244
+ )
245
+ try:
246
+ resp = self._llm.chat.completions.create(
247
+ model=self._model,
248
+ messages=[{"role": "user", "content": judge_prompt}],
249
+ max_tokens=5,
250
+ temperature=0.0,
251
+ )
252
+ raw = (resp.choices[0].message.content or "5").strip()
253
+ score = int("".join(c for c in raw if c.isdigit())[:2] or "5")
254
+ return min(max(score / 10.0, 0.0), 1.0)
255
+ except Exception:
256
+ return self._keyword_fallback_score(original_prompt, response)
257
+
258
+ def _keyword_fallback_score(self, original_prompt: str, response: str) -> float:
259
+ """Simple keyword overlap as semantic score when judge is unavailable."""
260
+ key_concepts = {
261
+ "machine", "learning", "ai", "data", "predict", "solar", "wind",
262
+ "energy", "renewable", "sales", "customer", "product", "market",
263
+ "budget", "analysis", "trend", "growth", "json", "bullet",
264
+ }
265
+ orig = set(original_prompt.lower().split()) & key_concepts
266
+ resp = set(response.lower().split()) & key_concepts
267
+ raw = (len(resp) / len(orig)) if orig else 0.5
268
+ return min(raw, 1.0)
269
+
270
+ def _calculate_reward(self, original_prompt: str, optimized_prompt: str,
271
+ llm_response: str, expected_format: str, reference_response: str,
272
+ input_tokens: int, output_tokens: int) -> float:
273
+ """
274
+ Calculate multi-component reward score (0.0-1.0).
275
+ """
276
+ # Component 1: Token Efficiency (0.0-0.4)
277
+ original_tokens = len(original_prompt.split()) * 1.3
278
+ optimized_input_tokens = input_tokens
279
+ output_tokens_estimate = output_tokens
280
+
281
+ # Reference token counts for comparison
282
+ ref_input_tokens = len(self._current_task["prompt"].split()) * 1.3
283
+ ref_output_tokens = len(self._current_task["reference_response"].split()) * 1.3
284
+ ref_total_tokens = ref_input_tokens + ref_output_tokens
285
+
286
+ actual_total_tokens = optimized_input_tokens + output_tokens_estimate
287
+ token_efficiency = max(0, (ref_total_tokens - actual_total_tokens) / ref_total_tokens)
288
+ token_efficiency = min(token_efficiency, 0.4) # Cap at 0.4
289
+
290
+ # Component 2: Semantic Preservation (0.0-0.3)
291
+ # Simple keyword-based similarity (in practice, would use embeddings)
292
+ original_keywords = set(original_prompt.lower().split())
293
+ response_keywords = set(llm_response.lower().split())
294
+
295
+ # Extract key concepts from original prompt
296
+ key_concepts = {"machine", "learning", "AI", "data", "predict", "solar", "wind",
297
+ "energy", "renewable", "sales", "customer", "product", "market",
298
+ "budget", "analysis", "trend", "growth", "json", "bullet", "point"}
299
+
300
+ original_key_concepts = original_keywords & key_concepts
301
+ response_key_concepts = response_keywords & key_concepts
302
+
303
+ if len(original_key_concepts) > 0:
304
+ semantic_similarity = len(response_key_concepts) / len(original_key_concepts)
305
+ else:
306
+ semantic_similarity = 0.5 # Neutral if no key concepts found
307
+
308
+ semantic_score = min(semantic_similarity, 0.3) # Cap at 0.3
309
+
310
+ # Component 3: Format Compliance (0.0-0.2)
311
+ format_score = 0.0
312
+ if "bullet point" in expected_format.lower() and ("•" in llm_response or "*" in llm_response or "-" in llm_response):
313
+ format_score = 0.2
314
+ elif "json" in expected_format.lower() and ("{" in llm_response and "}" in llm_response):
315
+ format_score = 0.2
316
+ elif "brief explanation" in expected_format.lower() and len(llm_response.split()) < 30:
317
+ format_score = 0.2
318
+
319
+ # Component 4: Length Appropriateness (0.0-0.1)
320
+ length_score = 0.0
321
+ max_expected = self._current_task["max_output_tokens"]
322
+ if output_tokens <= max_expected:
323
+ length_score = 0.1
324
+ elif output_tokens <= max_expected * 1.5: # Partial credit
325
+ length_score = 0.05
326
+
327
+ # Component 5: Cost Simulation Bonus (0.0-0.05)
328
+ # Reward for being under reference token count
329
+ cost_bonus = 0.0
330
+ if actual_total_tokens < ref_total_tokens:
331
+ cost_bonus = min(0.05, (ref_total_tokens - actual_total_tokens) / ref_total_tokens * 0.05)
332
+
333
+ # Component 6: Latency Penalty (penalty)
334
+ latency_penalty = 0.0
335
+ if output_tokens > max_expected * 2:
336
+ latency_penalty = -0.1
337
+
338
+ # Component 7: Context Window Penalty (penalty)
339
+ context_penalty = 0.0
340
+ # Simulate context window limit (e.g., 4096 tokens)
341
+ if input_tokens > 3000: # Assuming prompt + context
342
+ context_penalty = -0.1
343
+
344
+ # Calculate final reward
345
+ total_reward = (
346
+ token_efficiency +
347
+ semantic_score +
348
+ format_score +
349
+ length_score +
350
+ cost_bonus +
351
+ latency_penalty +
352
+ context_penalty
353
+ )
354
+
355
+ # Clamp to valid range
356
+ return max(0.0, min(1.0, total_reward))
357
+
358
+ @property
359
+ def state(self) -> TokenOptimiserState:
360
+ """
361
+ Get the current environment state.
362
+
363
+ Returns:
364
+ Current TokenOptimiserState
365
+ """
366
+ return self._state
uv.lock ADDED
The diff for this file is too large to render. See raw diff