File size: 23,168 Bytes
c0db7bb
1a65601
2a6078a
c0db7bb
 
 
81e1efb
c0db7bb
 
 
 
dfd1faa
c0db7bb
acfb96b
c0db7bb
dfd1faa
c0db7bb
dfd1faa
 
 
 
 
c0db7bb
dfd1faa
c0db7bb
dfd1faa
97fbf33
dfd1faa
97fbf33
dfd1faa
97fbf33
dfd1faa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0db7bb
 
 
dfd1faa
c0db7bb
dfd1faa
c0db7bb
dfd1faa
c0db7bb
dfd1faa
 
acfb96b
 
 
dfd1faa
acfb96b
dfd1faa
 
 
 
 
 
 
 
 
 
acfb96b
c0db7bb
 
dfd1faa
c0db7bb
dfd1faa
c0db7bb
dfd1faa
 
 
 
 
 
 
 
c0db7bb
acfb96b
dfd1faa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0db7bb
 
dfd1faa
c0db7bb
dfd1faa
c0db7bb
dfd1faa
c0db7bb
dfd1faa
c0db7bb
dfd1faa
c0db7bb
dfd1faa
c0db7bb
dfd1faa
c0db7bb
dfd1faa
 
acfb96b
c0db7bb
dfd1faa
 
 
 
 
 
 
c0db7bb
acfb96b
c0db7bb
dfd1faa
 
c0db7bb
 
dfd1faa
c0db7bb
dfd1faa
c0db7bb
dfd1faa
c0db7bb
dfd1faa
97fbf33
dfd1faa
97fbf33
dfd1faa
 
acfb96b
97fbf33
acfb96b
dfd1faa
 
 
 
c0db7bb
acfb96b
c0db7bb
dfd1faa
 
 
c0db7bb
dfd1faa
c0db7bb
dfd1faa
c0db7bb
dfd1faa
c0db7bb
dfd1faa
c0db7bb
dfd1faa
 
 
 
 
 
acfb96b
dfd1faa
acfb96b
 
dfd1faa
 
 
 
acfb96b
dfd1faa
 
 
 
 
acfb96b
dfd1faa
 
 
acfb96b
dfd1faa
 
 
acfb96b
dfd1faa
 
c0db7bb
 
dfd1faa
c0db7bb
dfd1faa
c0db7bb
dfd1faa
c0db7bb
dfd1faa
 
c0db7bb
dfd1faa
 
 
 
c0db7bb
 
dfd1faa
c0db7bb
dfd1faa
 
 
 
 
c0db7bb
dfd1faa
 
 
 
 
c0db7bb
 
dfd1faa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
acfb96b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
dfd1faa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0db7bb
dfd1faa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0db7bb
 
 
dfd1faa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c0db7bb
 
dfd1faa
c0db7bb
dfd1faa
 
 
c0db7bb
dfd1faa
c0db7bb
 
dfd1faa
 
 
 
 
c0db7bb
 
dfd1faa
c0db7bb
dfd1faa
 
 
 
 
 
 
c0db7bb
dfd1faa
c0db7bb
 
dfd1faa
 
 
 
 
c0db7bb
 
dfd1faa
 
 
 
 
 
 
 
 
 
 
c0db7bb
 
 
 
dfd1faa
 
 
 
 
 
97fbf33
dfd1faa
 
 
 
97fbf33
dfd1faa
 
 
 
97fbf33
dfd1faa
 
 
 
97fbf33
dfd1faa
 
c0db7bb
dfd1faa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
---
title: Anti Money Laundering RL Env
emoji: πŸ•΅οΈ
colorFrom: indigo
colorTo: red
sdk: docker
app_port: 7860
tags:
  - openenv
---

<div align="center">

# πŸ•΅οΈ AML Investigator β€” OpenEnv RL Environment

**A financial crime investigation environment for training and evaluating LLM agents**

[![OpenEnv](https://img.shields.io/badge/OpenEnv-compatible-6366f1?style=flat-square)](https://github.com/openenv)
[![FastAPI](https://img.shields.io/badge/FastAPI-async-009688?style=flat-square&logo=fastapi)](https://fastapi.tiangolo.com)
[![Pydantic](https://img.shields.io/badge/Pydantic-v2-e92063?style=flat-square)](https://docs.pydantic.dev)
[![Docker](https://img.shields.io/badge/Docker-ready-2496ED?style=flat-square&logo=docker)](https://www.docker.com)
[![HF Spaces](https://img.shields.io/badge/HuggingFace-Spaces-FFD21E?style=flat-square&logo=huggingface)](https://huggingface.co/spaces)

</div>

---

## What Is This?

Most RL benchmarks for language models test knowledge retrieval or reasoning in isolation. This environment tests something harder and more practical: **can an LLM agent act as a financial investigator?**

The agent is given a banking system alert and a budget of API calls. It must use tools to query transaction ledgers, search memo fields, pull KYC records, and finally submit a verdict β€” `FRAUD` or `CLEAR` β€” with evidence. The agent is rewarded for correctness and efficiency; it is penalized for every wasted call.

What makes this environment non-trivial:

- **The haystack is real noise.** 5,000+ transactions of legitimate payroll, utility bills, and vendor invoices surround every fraud signal.
- **Pagination is mandatory.** Corporate accounts hold 150–500 transactions. Dumping them all into context causes an OOM failure. The agent must learn to search and paginate strategically.
- **False flags are everywhere.** The hard task contains a $100 transfer to an entity with a watchlist name β€” designed specifically to bait the agent into wasting its budget.
- **KYC cross-referencing.** The hardest task cannot be solved by reading transactions alone. The agent must chain multiple `get_kyc_record` calls to trace hidden ownership loops.

---

## Architecture Overview

```mermaid
graph TD
    subgraph Agent["LLM Agent (inference.py)"]
        P[Prompt + Alert Details]
        T[Tool Selection via Pydantic JSON]
        C[Sliding Context Window]
    end

    subgraph Server["OpenEnv Server (FastAPI)"]
        E[AML Environment<br/>Reset / Step]
        G[Grader<br/>aml_easy, aml_medium, aml_hard]
    end

    subgraph Data["Mock Banking Database /data"]
        ENT[entities.json<br/>312 KYC Records]
        ACC[accounts.json<br/>410 Bank Accounts]
        TXN[transactions.json<br/>5,079 Transactions]
    end

    P -->|AmlAction JSON| E
    E -->|AmlObservation| C
    C --> T
    T --> P
    E <-->|O1 dict lookups| ENT
    E <-->|O1 dict lookups| ACC
    E <-->|O1 dict lookups| TXN
    E -->|submit_decision| G
    G -->|score 0.0-1.0| E

```

---

## The Episode Loop

Every investigation runs as a sequence of steps between agent and environment. The agent sees no state beyond what it has explicitly queried.

```mermaid
sequenceDiagram
    participant A as πŸ€– Agent
    participant E as βš™οΈ Environment
    participant D as πŸ—„οΈ Data Layer

    E-->>A: reset() β†’ AmlObservation<br/>(alert_details, budget=N)

    loop Until submit_decision or budget=0
        A->>E: step(AmlAction)
        E->>D: dict lookup (O(1))
        D-->>E: raw records
        E-->>A: AmlObservation<br/>(last_action_result, budget-=1, reward-=0.02)
    end

    A->>E: step(submit_decision, evidence=[...])
    E->>E: Run Grader
    E-->>A: AmlObservation<br/>(done=True, reward=0.0–1.0)
```

---

## Action Space

The agent communicates exclusively through **typed Pydantic actions**. No regex parsing. No free-form text commands. Every action dispatches to exactly one tool.

| Action | Key Parameters | Purpose |
|---|---|---|
| `query_transactions` | `account_id`, `limit=10`, `offset=0` | Paginated ledger history. **Must paginate** for corporate accounts. |
| `search_transactions` | `account_id`, `keyword` | Filter `memo_text` fields. Cuts noise without burning pagination budget. |
| `get_kyc_record` | `entity_id` | Retrieve address, entity type, and corporate directors. |
| `submit_decision` | `decision: FRAUD\|CLEAR`, `evidence_links: List[str]` | Terminal action. Ends the episode and triggers the grader. |

> **Why Pydantic?** The LLM is the router. Strict schemas with `Field(description="...")` mean the model reads the tool contract, not a prompt full of prose instructions. Malformed output is caught at validation, not execution β€” preventing silent failures and hallucinated account IDs from crashing the environment.

---

## Observation Space

Every `reset()` and `step()` returns an `AmlObservation` containing the agent's full situational picture.

```python
class AmlObservation(BaseModel):
    alert_details: str          # Investigation mission β€” constant per episode
    budget_remaining: int       # API calls left before forced termination
    last_action: str | None     # Name of the last tool called
    last_action_result: Any     # Exact payload returned by the last tool
    error_message: str | None   # Formatted error if the last call failed (not a crash)
    done: bool                  # Whether the episode has ended
    reward: float               # Cumulative reward signal
```

> **Errors are data, not exceptions.** If the agent hallucinates `ACC-9999`, the environment catches the `KeyError`, formats it as `"Account 'ACC-9999' not found"`, and returns it as `error_message`. The container never crashes. The agent can read the error and self-correct on the next step.

---

## The Three Tasks

The environment ships with three investigation scenarios of escalating difficulty, each targeting a distinct AML typology.

### Task 1 β€” The False Positive `aml_easy`

> **Alert:** `ACC-101` (local construction company) transferred $50,000 to `ACC-909`, a newly registered entity in a high-risk jurisdiction.

The trap is the jurisdiction flag. A naive model panics and submits `FRAUD`. A well-reasoned agent reads the memo, pulls the KYC record, and discovers a legitimate equipment supplier.

```mermaid
flowchart LR
    A([🚨 Alert:<br/>ACC-101 β†’ ACC-909<br/>$50,000]) --> B

    subgraph Investigation
        B[query_transactions<br/>ACC-101] --> C{Memo:<br/>'Heavy Machinery<br/>Purchase - Unit 4'}
        C --> D[get_kyc_record<br/>ACC-909]
        D --> E{Registered as:<br/>Global Tractor Sales Ltd}
        E --> F[query_transactions<br/>ACC-909]
        F --> G{50 inbound payments<br/>from global firms}
    end

    G --> H([βœ… submit_decision<br/>CLEAR])

    style A fill:#ef4444,color:#fff
    style H fill:#22c55e,color:#fff
```

**Reward:** `1.0` for `CLEAR`. The agent proves it can dismiss noise without over-indexing on surface-level signals.

---

### Task 2 β€” The Smurf Network `aml_medium`

> **Alert:** `ACC-200` (used car dealership) shows a spike in cash deposits over a 5-day window.

The agent must paginate through hundreds of normal car-sale transactions to surface 14 cash deposits β€” all for exactly $9,900 or $9,500, just below the $10,000 AML reporting threshold. The three sender accounts (`ACC-301`, `ACC-302`, `ACC-303`) were all opened on the same day with the same occupation listed: `Student`.

```mermaid
flowchart TD
    A([🚨 Alert:<br/>ACC-200 deposit velocity spike]) --> B

    subgraph Investigation["Paginate β†’ Spot β†’ Cross-Reference"]
        B[query_transactions<br/>ACC-200<br/>offset 0, 10, 20...] --> C{14 deposits<br/>$9,900 and $9,500<br/>below $10k threshold}
        C --> D[get_kyc_record<br/>ACC-301, ACC-302, ACC-303]
        D --> E{All 3 accounts:<br/>Opened same day<br/>Occupation: Student}
    end

    E --> F([🚨 submit_decision<br/>FRAUD<br/>evidence: ACC-301, ACC-302, ACC-303])

    style A fill:#f97316,color:#fff
    style F fill:#dc2626,color:#fff
```

**Partial credit scoring:** The grader awards proportional reward based on how many of the three smurf accounts are included in `evidence_links`. Identifying 1 of 3 scores higher than 0 but lower than the full `1.0`.

---

### Task 3 β€” The Corporate Mirage `aml_hard`

> **Alert:** `ACC-500` (major logistics firm) transferred $2.5M to `ACC-700` (generic consulting agency).

This is the full haystack. `ACC-500` has 500+ transactions. `ACC-700` has hundreds of outbound payments to vendors, charities, and payroll. Hidden inside: 48 hours after receiving $2.5M, `ACC-700` moves $2.4M offshore. The ownership chain requires three chained KYC lookups to resolve.

**The false flag trap:** `ACC-500` also made a $100 payment to an entity named `Al-Qaeda Watchlist Target`. This is deliberate bait. Agents that investigate the $100 transfer instead of the $2.5M loop receive a score of `0.05`.

```mermaid
flowchart TD
    A([🚨 Alert:<br/>ACC-500 β†’ ACC-700<br/>$2.5M]) --> B

    subgraph Trap["❌ The Bait  β€” Don't Take It"]
        X["$100 transfer<br/>to 'Watchlist Target'"]
    end

    subgraph Investigation["The Real Loop"]
        B --> C["search_transactions<br/>ACC-700<br/>keyword: 'consulting'"]
        C --> D{48hrs later:<br/>ACC-700 β†’ ACC-888<br/>$2.4M offshore}
        D --> E[get_kyc_record<br/>ACC-888]
        E --> F{Director:<br/>Robert House}
        F --> G[get_kyc_record<br/>ACC-500]
        G --> H{Director:<br/>Apex Management Corp}
        H --> I[get_kyc_record<br/>Apex Management Corp]
        I --> J{CEO:<br/>Robert House ← same person}
    end

    A -.->|naive agent wastes budget| X
    J --> K([🚨 submit_decision<br/>FRAUD<br/>evidence: ACC-500, ACC-700, ACC-888])

    style A fill:#ef4444,color:#fff
    style X fill:#6b7280,color:#fff,stroke-dasharray: 5 5
    style Trap fill:#1f2937,color:#9ca3af
    style K fill:#dc2626,color:#fff
    style J fill:#fbbf24,color:#000
```

**Scoring:** Full `1.0` for identifying all three accounts with the circular KYC loop documented. `0.05` if the agent chases the false flag instead.

---

## Reward Structure

```
Episode reward = Ξ£(step penalties) + terminal reward

Step penalty:    βˆ’0.02  per API call  (discourages random exploration)
FRAUD correct:   +0.4 to +1.0        (scales with evidence quality)
CLEAR correct:   +1.0                 (false positives must be dismissed confidently)
Budget exhaust:   0.0                 (no terminal reward β€” accumulated penalties only)
```

Budget scales with task difficulty:

| Task | Budget | Rationale |
|---|---|---|
| `aml_easy` | 5 calls | 4 tool calls are sufficient; any more suggests confusion |
| `aml_medium` | 12 calls | Pagination required; partial paths need room |
| `aml_hard` | 20 calls | Three KYC hops + pagination across two high-volume accounts |

---

## The Mock Knowledge Graph

The haystack is a procedurally generated slice of a fictional bank, seeded for reproducibility.

```
entities.json     312 records    80% Individual, 20% Corporate (with directors list)
accounts.json     410 records    95% Active, 5% Closed
transactions.json 5,079 rows     Procedural noise + 3 injected fraud scenarios
```

Transaction `memo_text` is typed by sender/receiver pair to simulate realistic commerce:

| Flow | Example Memos | Amount Range |
|---|---|---|
| Corporate β†’ Individual | `Payroll`, `Salary Q3`, `Expense Reimbursement` | $2,000–$10,000 |
| Corporate β†’ Corporate | `Server Hosting`, `Consulting Retainer`, `Invoice #XXXX` | $500–$50,000 |
| Individual β†’ Corporate | `Utility Bill`, `Gym Membership`, `Coffee` | $5–$200 |
| Individual β†’ Individual | `Dinner split`, `Rent share`, `Birthday gift` | $10–$500 |

Fraud scenarios are injected with camouflage: 5–10 "normal" bridging transactions connect each manual account to the procedural haystack so no fraud node appears as an isolated island in the graph.

---

## Baseline Results

> **Model:** `openai/gpt-oss-20b` Β· **CoT:** enabled Β· **Run:** single pass, no fine-tuning

| Task | Steps Used | Budget | Grader Score | Net Reward | Verdict | Result |
|---|---|---|---|---|---|---|
| `aml_easy` | 3 / 5 | 2 remaining | 0.75 | **+0.69** | `CLEAR` βœ“ | βœ… Pass |
| `aml_medium` | 6 / 12 | 6 remaining | 0.75 | **+0.63** | `FRAUD` βœ“ | βœ… Pass |
| `aml_hard` | 16 / 20 | 0 remaining | 0.00 | **βˆ’0.32** | none | ❌ Fail |

Net reward = grader score βˆ’ (steps Γ— 0.02)

### Per-Task Analysis

**`aml_easy` β€” Pass (0.75 / 1.0)**

The agent navigated the task in the minimum viable number of steps: one transaction query, one KYC lookup, then `CLEAR`. It correctly ignored the high-risk jurisdiction flag after reading the memo. The score stopped at `0.75` rather than `1.0` because `evidence_links` was submitted empty β€” the grader expects at least the cleared account ID as documented evidence of the reasoning chain.

```
[STEP] query_transactions  ACC-9001
[STEP] get_kyc_record      ENT-9001
[STEP] submit_decision     CLEAR  evidence=[]   ← missing evidence β†’ capped at 0.75
```

**`aml_medium` β€” Pass (0.75 / 1.0)**

The agent identified structuring activity and correctly returned a `FRAUD` verdict, but submitted only one of the three smurf accounts (`ACC-9010`) in evidence. The grader applies partial credit proportional to smurf accounts found β€” `1/3` identified yields `0.75`. The agent also issued a `search_transactions` call with keyword `"Invoice"` which was not relevant to the structuring pattern, suggesting mild reasoning noise before it converged on the correct account.

```
[STEP] query_transactions  ACC-9010 (offset 0)
[STEP] query_transactions  ACC-9011 (offset 0)
[STEP] get_kyc_record      ENT-9010
[STEP] search_transactions ACC-9010  keyword="Invoice"   ← off-path call
[STEP] get_kyc_record      ENT-0159
[STEP] submit_decision     FRAUD  evidence=["ACC-9010"]  ← found 1/3 smurfs β†’ 0.75
```

**`aml_hard` β€” Fail (0.00)**

The model completed two valid steps (paginating `ACC-9021` at offset 0 and 10), then entered a catastrophic failure loop. From step 3 onward, the model produced empty or non-JSON output on every turn, triggering the recovery action, which defaulted to `query_transactions(ACC-9021, offset=0)` β€” the same call, 14 times in a row. The budget was exhausted without a `submit_decision` ever being issued.

```
[STEP] query_transactions  ACC-9021  offset=0   ← valid
[STEP] query_transactions  ACC-9021  offset=10  ← valid
[DEBUG] Non-JSON/invalid model action Γ— 14      ← context collapse
[END]  score=0.00  budget exhausted
```

The root cause is context window pressure. By step 2, the sliding window already contained two large paginated transaction payloads.

### Failure Mode Summary

```mermaid
flowchart LR
    A[Step 2: Two large<br/>transaction payloads<br/>in context] --> B[Model outputs<br/>prose instead of JSON]
    B --> C[Recovery action:<br/>query_transactions<br/>offset=0]
    C --> D[Same large payload<br/>re-injected into context]
    D --> B
    D --> E{Budget = 0}
    E --> F([score = 0.00])

    style B fill:#ef4444,color:#fff
    style F fill:#7f1d1d,color:#fff
```

### What This Tells Us

The tasks are correctly difficulty-stratified.
The easy and medium tasks are solvable by an instruction-following model with chain-of-thought, but not perfectly β€” both runs left score on the table due to incomplete evidence submission.
The hard task exposes a genuine capability gap: multi-hop KYC cross-referencing under token pressure requires either a larger model, a tighter context compaction strategy, or both.

The `[DEBUG] Non-JSON/invalid model action` recovery path is functioning as designed β€” the environment did not crash, and each recovery action was logged and penalized correctly.

| Failure Mode | Observed In | Environment Response |
|---|---|---|
| Empty `evidence_links` on correct verdict | Easy, Medium | Grader caps score; no crash |
| Off-path tool calls | Medium | Step penalty applied; agent self-corrects |
| Context collapse β†’ non-JSON output | Hard | Recovery action fired; logged as `[DEBUG]` |
| Recovery loop exhausts budget | Hard | Episode terminates cleanly; score `0.00` |

---

## Core Engineering Principles

These principles govern how the environment is designed and why each decision was made.

<details>
<summary><strong>1. You don't design the control flow</strong></summary>

The `step()` function is a pure reactive state machine. If the agent queries the same account five times in a row, the environment returns the result five times. It never forces a sequence or nudges toward the solution path. The agent is in the driver's seat.

</details>

<details>
<summary><strong>2. Errors are data, not control flow</strong></summary>

Hallucinated account IDs, missing entity records, malformed queries β€” all are caught with `try/except`, formatted as human-readable strings, and returned as `error_message` in the observation. The container never crashes on bad agent output.

</details>

<details>
<summary><strong>3. The conversation is the database</strong></summary>

The environment is stateless between calls. The agent's only memory is the `AmlObservation` history it has accumulated. Every response includes `budget_remaining`, `last_action`, and the full `last_action_result` payload so nothing is lost between turns.

</details>

<details>
<summary><strong>4. No regex. Pydantic is the contract.</strong></summary>

Actions are strictly typed Pydantic models with `Field(description="...")` on every parameter. The LLM reads the schema to understand how to use each tool. Invalid JSON is caught at validation β€” not mid-execution.

</details>

<details>
<summary><strong>5. Pagination is an OOM prevention mechanism</strong></summary>

Corporate accounts have 150–500 transactions. Returning them all in one response would blow up the context window. The `query_transactions` tool enforces a `limit` parameter (default 10, max configurable). The agent must learn to paginate or use keyword search to find signals in high-volume accounts.

</details>

<details>
<summary><strong>6. Context compaction is layered</strong></summary>

The inference script maintains a sliding window over conversation history (last 4–5 steps). Internal chain-of-thought reasoning is routed to `stderr`, keeping `stdout` clean for the grader's `[START]`/`[STEP]`/`[END]` log parsing.

</details>

<details>
<summary><strong>7. The prompt is code, not config</strong></summary>

The `alert_details` string returned by `reset()` is the agent's mission statement. It defines the goal, names the flagged account, and sets the investigation frame. Vague alerts produce vague investigations.

</details>

---

## Quick Start

### Prerequisites

```bash
pip install faker  # for haystack generation
docker build -t aml-env:latest .
```

### Running an Episode

```python
from AML_env import AmlAction, AmlEnv

try:
    env = AmlEnv.from_docker_image("aml-env:latest")

    # Choose task: "aml_easy" | "aml_medium" | "aml_hard"
    obs = env.reset(task="aml_medium")
    print(f"Alert:  {obs.observation.alert_details}")
    print(f"Budget: {obs.observation.budget_remaining}")

    # Page through transactions
    result = env.step(AmlAction(action={
        "action_type": "query_transactions",
        "account_id": "ACC-200",
        "limit": 10,
        "offset": 0,
    }))
    print(result.observation.last_action_result)

    # Search by keyword to cut noise
    result = env.step(AmlAction(action={
        "action_type": "search_transactions",
        "account_id": "ACC-700",
        "keyword": "consulting",
    }))

    # Pull KYC record
    result = env.step(AmlAction(action={
        "action_type": "get_kyc_record",
        "entity_id": "ENT-0042",
    }))

    # Submit final verdict
    result = env.step(AmlAction(action={
        "action_type": "submit_decision",
        "decision": "FRAUD",
        "evidence_links": ["ACC-301", "ACC-302", "ACC-303"],
    }))
    print(f"Done: {result.done}  |  Reward: {result.reward:.3f}")

finally:
    env.close()
```

### Connect to an Existing Server

```python
env = AmlEnv(base_url="http://localhost:8760")
```

### Regenerate the Haystack

```bash
# Procedural noise only
python tools/haystack.py

# Inject hand-written fraud scenarios
python tools/haystack.py --inject tools/tasks.json --output-dir data/
```

---

## Deployment

### Local Development

```bash
uvicorn server.app:app --reload --port 8760
```

### Hugging Face Spaces

```bash
# From environment directory
openenv push

# Private space with custom repo
openenv push --repo-id my-org/aml-investigator --private
```

After deployment, the space exposes:

| Endpoint | Description |
|---|---|
| `/web` | Interactive UI for manual exploration |
| `/docs` | Swagger / OpenAPI interface |
| `/ws` | WebSocket endpoint for low-latency agent sessions |
| `/health` | Container health check |

---

## Project Structure

```
AML_env/
β”œβ”€β”€ Dockerfile                       # HF Spaces compliant; exposes port 8760
β”œβ”€β”€ openenv.yaml                     # Task manifest: aml_easy, aml_medium, aml_hard
β”œβ”€β”€ models.py                        # Pydantic AmlAction + AmlObservation schemas
β”œβ”€β”€ client.py                        # AmlEnv WebSocket client
β”œβ”€β”€ inference.py                     # Baseline agent: asyncio, sliding window, stderr CoT
β”‚
β”œβ”€β”€ data/
β”‚   β”œβ”€β”€ entities.json                # 312 KYC entity records
β”‚   β”œβ”€β”€ accounts.json                # 410 bank accounts
β”‚   └── transactions.json            # 5,079 transactions (haystack + fraud)
β”‚
β”œβ”€β”€ graders/
β”‚   β”œβ”€β”€ aml_easy.py                  # False positive β€” reward CLEAR, penalise over-flagging
β”‚   β”œβ”€β”€ aml_medium.py                # Smurf network β€” partial credit per smurf account found
β”‚   └── aml_hard.py                  # Corporate mirage β€” 0.05 if false-flag bait taken
β”‚
β”œβ”€β”€ server/
β”‚   β”œβ”€β”€ AML_env_environment.py       # Core state machine: reset(), step(), budget, grader dispatch
β”‚   β”œβ”€β”€ app.py                       # FastAPI wrapper with CORS
β”‚   └── requirements.txt
β”‚
└── tools/
    β”œβ”€β”€ haystack.py                  # Procedural KB generator (Faker + random)
    └── tasks.json                   # Hand-written fraud scenario definitions
```

---

## Evaluation Log Format

The inference script emits strict single-line logs to `stdout` for automated grading:

```
[START] {"task": "aml_hard", "budget": 20}
[STEP]  {"action": "query_transactions", "reward": -0.02, "done": false, "budget": 19}
[STEP]  {"action": "get_kyc_record",     "reward": -0.02, "done": false, "budget": 18}
[STEP]  {"action": "submit_decision",    "reward":  0.85, "done": true,  "budget": 17}
[END]   {"total_reward": 0.79, "steps": 3, "decision": "FRAUD"}
```

Internal chain-of-thought reasoning routes to `stderr` and is never visible to the grader.

---

<div align="center">

Built with [OpenEnv](https://github.com/openenv) Β· Deployed on [Hugging Face Spaces](https://huggingface.co/spaces)

</div>