srinjoyd commited on
Commit
8c26ecf
·
1 Parent(s): 47ef65c

add blog.md

Browse files
BLOG.md ADDED
@@ -0,0 +1,628 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Teaching a 7B Model to Be On-Call
2
+
3
+ ### An OpenEnv benchmark and a four-stage GRPO pipeline that turns Qwen2.5-7B into a working SRE triage agent
4
+
5
+ ---
6
+
7
+ > **TL;DR.** We built `incident_env` — an OpenEnv POMDP where an LLM agent has to diagnose a live, evolving production incident and then attribute it to a specific commit in a small repo. Then we trained **Qwen2.5-7B-Instruct** through a four-stage curriculum (baseline rollouts → LoRA SFT → online GRPO with `r_cross` → merge). The post-trained model reaches a **mean cumulative reward of ≈1.59 vs ≈0.49** for the base, **at less than half the steps**, with tighter variance and dominant CDF across the operating range.
8
+
9
+ <p align="center">
10
+ <img src="docs/diagrams/pipeline.svg" alt="SRE Triage Bot training pipeline" width="100%"/>
11
+ </p>
12
+
13
+ > 🧭 **One-line pitch.** *Most agent benchmarks freeze a repo and ask the model to fix it. Our environment refuses to sit still — memory climbs, alerts cascade, and the obvious symptom is almost never the cause.*
14
+
15
+ ---
16
+
17
+ ## 1 · Why this benchmark didn't exist yet
18
+
19
+ Pick any list of agentic LLM benchmarks today and you'll see two clusters:
20
+
21
+ | Cluster | Examples | What they miss |
22
+ | --- | --- | --- |
23
+ | **Frozen-repo coding** | SWE-bench, RepoBench, HumanEval | No evolving system, no observability, no alerts |
24
+ | **Tool-use chains** | AgentBench, ToolBench, τ-bench | Plenty of API calls, but no reactive simulator |
25
+
26
+ Neither cluster matches the workflow that consumes the most engineer-hours at any company running real systems: **on-call triage**. A pager fires. A graph is wrong. Three services look broken but only one *is* broken. Someone has to triangulate, propose a fix, and identify the offending commit — under SLA pressure, with partial information.
27
+
28
+ That gap is exactly what `incident_env` fills.
29
+
30
+ > ✦ **Capability gap.** Today's LLMs can read a static repo. They cannot yet diagnose a system whose state changes while they're looking at it.
31
+
32
+ ---
33
+
34
+ ## 2 · Environment at a glance
35
+
36
+ `incident_env` is an OpenEnv `Environment` — clean Gym-style `reset()` / `step()` / `state` plus a `/score` endpoint for the oracle-independent grader. Under the hood it is a **reactive, partially-observable, two-phase** simulator.
37
+
38
+ ### Topology — seven reactive services
39
+
40
+ ```
41
+ ┌─────────┐ ┌─────┐ ┌────────┐ ┌─────────┐
42
+ │ API GW │───▶│Auth │───▶│ Orders │───▶│ Payment │
43
+ └────┬────┘ └─────┘ └───┬────┘ └────┬────┘
44
+ ▼ ▼ ▼
45
+ ┌─────────┐ ┌─────────┐ ┌─────────┐
46
+ │ Cache │ │ DB │ │ Queue │
47
+ └─────────┘ └─────────┘ └─────────┘
48
+ ```
49
+
50
+ Each service has live metric history (CPU, memory, p50/p95/p99 latency, error rate, RPS), structured logs, deploy history, and a `healthy | degraded | down` status. Faults propagate along this graph each `tick()`. Restarting a downstream service buys minutes; rolling back the wrong deploy makes things worse.
51
+
52
+ ### The agent loop
53
+
54
+ <p align="center">
55
+ <img src="docs/diagrams/agent_loop.svg" alt="Agent loop POMDP" width="92%"/>
56
+ </p>
57
+
58
+ Per-step execution is `validate → mutate → tick → observe → reward`. Two facts make the loop interesting:
59
+
60
+ 1. The observation **never** exposes `fault_type`, the `is_bad` deploy flag, or any internal simulation state. The agent infers from symptoms.
61
+ 2. The action space is **hierarchical and masked**. `valid_actions[]` is recomputed every step, so illegal actions (e.g. rollback on a service with no deploy history) are flagged with a `-0.05` penalty.
62
+
63
+ ```mermaid
64
+ sequenceDiagram
65
+ participant Agent as LLM Agent
66
+ participant Env as incident_env
67
+ Agent->>Env: POST /reset {task, pool}
68
+ Env-->>Agent: observation, valid_actions
69
+ loop until done or budget
70
+ Agent->>Env: POST /step {action_type, target, params}
71
+ Note right of Env: validate → mutate<br/>→ tick → observe<br/>→ reward
72
+ Env-->>Agent: observation, reward, valid_actions
73
+ end
74
+ Agent->>Env: POST /score
75
+ Env-->>Agent: breakdown {final, p1_rca, patch_quality, r_cross, ...}
76
+ ```
77
+
78
+ ---
79
+
80
+ ## 3 · Two-phase action design (this is the novel bit)
81
+
82
+ Most environments give the agent one type of tool. Ours gives it two — and forces a deliberate transition between them.
83
+
84
+ ```mermaid
85
+ stateDiagram-v2
86
+ [*] --> Phase1
87
+ state Phase1 {
88
+ [*] --> Investigating
89
+ Investigating --> Investigating : view_alerts / query_logs / check_metrics<br/>check_dependencies / check_deploy_history<br/>run_health_check
90
+ Investigating --> Remediating : restart_service / rollback_deploy / scale_service
91
+ Remediating --> Investigating
92
+ Investigating --> Declared : declare_root_cause
93
+ }
94
+ Phase1 --> Phase2 : transition_to_phase2(belief)
95
+ state Phase2 {
96
+ [*] --> Exploring
97
+ Exploring --> Exploring : list_dir / read_file / search_code<br/>get_git_log / get_file_diff
98
+ Exploring --> Patched : propose_patch / declare_no_change
99
+ }
100
+ Patched --> [*]
101
+ Declared --> [*]
102
+ ```
103
+
104
+ ### Phase 1 — ops investigation
105
+
106
+ The same tools an SRE has at 3 AM, plus a `transition_to_phase2` control action that hands a structured `BeliefState` over to Phase 2:
107
+
108
+ | Action | Category | Purpose |
109
+ | --- | --- | --- |
110
+ | `view_alerts` | diagnostic | List firing alerts |
111
+ | `query_logs` | diagnostic | Filter by service/level/keyword |
112
+ | `check_metrics` | diagnostic | 30-min time series |
113
+ | `check_dependencies` | diagnostic | Up/downstream graph |
114
+ | `check_deploy_history` | diagnostic | Recent deploys |
115
+ | `run_health_check` | diagnostic | Ping a service |
116
+ | `restart_service` | remediation | Temporary fix |
117
+ | `rollback_deploy` | remediation | Real fix if root cause |
118
+ | `scale_service` | remediation | More replicas |
119
+ | `declare_root_cause` | terminal | Diagnosis string |
120
+ | `transition_to_phase2` | control | Hand off to code attribution |
121
+
122
+ ### Phase 2 — code attribution
123
+
124
+ When a scenario has a `code_context`, the env spins up a sandboxed `CodeWorkspace` over a bundled mini-repo:
125
+
126
+ ```
127
+ snapshots/<scenario>/
128
+ tree/ ← actual source files
129
+ git_log.json ← commits (sha, author, date, msg, files)
130
+ diffs/<sha>.patch ← unified diff per commit
131
+ ```
132
+
133
+ Five new actions appear, all sandboxed (no `..`, no symlinks, no real subprocess):
134
+
135
+ | Action | What it returns |
136
+ | --- | --- |
137
+ | `list_dir` | files + subdirs at a relative path |
138
+ | `read_file` | up to 64 KB of file contents |
139
+ | `search_code` | grep across the tree, capped at 50 hits |
140
+ | `get_git_log` | commit metadata for a path |
141
+ | `get_file_diff` | unified diff for `(commit_sha, path)` |
142
+ | `propose_patch` | terminal — submit a unified diff |
143
+ | `declare_no_change` | terminal — for spurious-issue scenarios |
144
+
145
+ > ✦ **Why two phases?** Real triage *is* two phases. Mixing them in one action soup forces the agent to learn a strategy: gather enough Phase-1 evidence to make Phase-2 cheap, but don't dawdle. This single design decision is what gives `r_cross` (Section 5) something meaningful to reward.
146
+
147
+ ---
148
+
149
+ ## 4 · Reward design — two layers, kept separate by design
150
+
151
+ ```
152
+ ┌───────────────────────────────────────────────────────────────┐
153
+ │ LAYER 1 · Per-step shaped reward (TRAINING ONLY) │
154
+ │ peeks at hidden state to give a useful gradient │
155
+ ├───────────────────────────────────────────────────────────────┤
156
+ │ diagnostic on involved svc +0.15 │
157
+ │ diagnostic on uninvolved svc +0.05 │
158
+ │ remediation on root-cause svc +0.30 │
159
+ │ correct root cause declaration +0.40 │
160
+ │ per-step efficiency cost −0.02 │
161
+ │ repeat / invalid −0.05 │
162
+ │ wrong-target remediation −0.15 │
163
+ └───────────────────────────────────────────────────────────────┘
164
+
165
+
166
+ ┌───────────────────────────────────────────────────────────────┐
167
+ │ LAYER 2 · Oracle-independent grader (EVALUATION) │
168
+ │ sees only the trajectory + declared patch │
169
+ ├───────────────────────────────────────────────────────────────┤
170
+ │ p1_rca 25 % keyword/AST match │
171
+ │ p1_efficiency 15 % fewer steps to declare │
172
+ │ patch_quality 35 % file overlap + AST + syntax │
173
+ │ no_change_detection 25 % spurious-issue scenarios │
174
+ │ p2_efficiency 25 % used when valid issue │
175
+ └───────────────────────────────────────────────────────────────┘
176
+ ```
177
+
178
+ Patch quality has three tiers: file overlap (Jaccard), AST-level hunk similarity, and syntax validity — none of which read hidden state. Saved trajectories can be re-graded months later from a JSONL file alone.
179
+
180
+ ### `r_cross` — the counterfactual that makes joint training work
181
+
182
+ ```math
183
+ r_cross(τ) = max(0, r_code(τ_2 | context(τ_1)) − r_code(τ_2 | ∅))
184
+ ```
185
+
186
+ **Where:**
187
+
188
+ | Symbol | Meaning |
189
+ | --- | --- |
190
+ | `τ` (tau) | A full episode trajectory (a sequence of observation–action–reward steps). |
191
+ | `τ_1` | The Phase-1 sub-trajectory of `τ` (ops investigation steps only). |
192
+ | `τ_2` | The Phase-2 sub-trajectory of `τ` (code-attribution steps only). |
193
+ | `r_code(...)` | The Phase-2 grader score (patch quality + no-change detection), in `[0, 1]`. |
194
+ | `context(τ_1)` | The structured belief handed off from Phase 1 to Phase 2 (suspected service, fault class, confidences, evidence gaps). |
195
+ | `∅` (null context) | An empty handoff — Phase 2 starts with no Phase-1 evidence. Score measured separately on Pool B. |
196
+ | `max(0, ·)` | Clamp to non-negative; we never *punish* Phase 1 for inherently hard bugs. |
197
+ | `−` | Counterfactual difference: *how much did Phase 1 actually help?* |
198
+
199
+ In English: *how much did Phase 1's investigation actually help the code agent vs. starting from a null context?* `r_cross` is what makes the joint training signal meaningful — without it, Phase 1 has no incentive to produce a *useful* handoff, only a *plausible* one. We will show in the ablations that turning `r_cross` off collapses ~80 % of the lift.
200
+
201
+ ---
202
+
203
+ ## 5 · Tasks and pools
204
+
205
+ ```mermaid
206
+ flowchart TD
207
+ Tasks["10 scenarios"] --> Easy["memory_leak"]
208
+ Tasks --> Med["cascading_failure"]
209
+ Tasks --> Hard["distributed_deadlock"]
210
+ Tasks --> A1["aliased_fault"]
211
+ Tasks --> A2["severity_inversion"]
212
+ Tasks --> A3["confidence_inversion"]
213
+ Tasks --> A4["info_ordering"]
214
+ Tasks --> A5["circuit_breaker_noop · no-change"]
215
+ Tasks --> H1["heldout_aliased_severity · compound"]
216
+ Tasks --> H2["heldout_confidence_ordering · compound"]
217
+
218
+ H1 --> PoolD
219
+ H2 --> PoolD
220
+
221
+ Easy --> PoolA & PoolB & PoolC
222
+ Med --> PoolA & PoolB & PoolC
223
+ Hard --> PoolA & PoolB & PoolC
224
+ A1 --> PoolA & PoolB & PoolC
225
+ A2 --> PoolA & PoolB & PoolC
226
+ A3 --> PoolA & PoolB & PoolC
227
+ A4 --> PoolA & PoolB & PoolC
228
+ A5 --> PoolA & PoolC
229
+
230
+ PoolA["Pool A · p1_only · ops bootstrap"]
231
+ PoolB["Pool B · p2_only · code bootstrap with oracle handoff"]
232
+ PoolC["Pool C · joint · full P1→P2 with r_cross"]
233
+ PoolD["Pool D · joint · held-out compounds"]
234
+ ```
235
+
236
+ > ✦ **Pool D is the integrity check.** Each component fault family appears during training, but the *combinations* never do. This is what answers "did the agent learn a strategy or memorise scenario fingerprints?"
237
+
238
+ ### Scenario flavours
239
+
240
+ | Task | Hidden lesson |
241
+ | --- | --- |
242
+ | `memory_leak` | Single service, noisy metric — restart only buys minutes |
243
+ | `cascading_failure` | Loud services aren't the cause — must walk the dep graph |
244
+ | `distributed_deadlock` | Three remediation actions, in a specific order |
245
+ | `aliased_fault` | Queue worker leaks like a memory leak — symptoms alias |
246
+ | `severity_inversion` | SEV1 page, two-line fix in `orders/auth_client.py` |
247
+ | `confidence_inversion` | Loud alerts on the wrong service; real bug is a lock-ordering issue |
248
+ | `info_ordering` | Decisive evidence shows up *late* — early committers lose |
249
+ | `circuit_breaker_noop` | Spurious issue; the right answer is `declare_no_change` |
250
+ | `heldout_*` (×2) | Compounds of the above; never seen during training |
251
+
252
+ ---
253
+
254
+ ## 6 · The training pipeline
255
+
256
+ ### Architecture — what GRPO is actually optimising
257
+
258
+ Before the stage-by-stage detail, here is the architectural view: a **three-level hierarchy** with an orchestrator routing policy on top, two specialised subagents below it, and segment-level GRPO with cross-phase reward propagation underneath both.
259
+
260
+ <p align="center">
261
+ <img src="docs/diagrams/hierarchical_rl_architecture.svg" alt="Hierarchical RL architecture — orchestrator + specialized subagents + segment-level GRPO with r_cross" width="78%"/>
262
+ </p>
263
+
264
+ Three things to notice in this picture:
265
+
266
+ - **The orchestrator owns the stopping criterion.** Deciding *when* Phase 1 has gathered enough evidence to hand off is a learned policy, not a rule. The orchestrator emits a structured `BeliefState` (`suspected_service`, `fault_class`, confidences, `evidence_gaps`) at every transition decision — making the criterion auditable and supervisable.
267
+ - **The subagents are specialised but share weights.** P1 (ops) and P2 (code) are the same Qwen2.5-7B-Instruct LoRA adapter prompted differently per phase. We train them in pool-isolated stages first, then jointly with `r_cross` switched on.
268
+ - **The reward signal is segment-level, not trajectory-level.** Episodes are 8–16 k tokens; one scalar reward over the whole thing dilutes credit. Each phase becomes its own GRPO group; `r_cross` is added to the Phase-1 group return *with stop-gradient on the Phase-2 path* (`training/segment_grpo.py`). That single architectural choice is what lets joint training avoid poisoning Phase-1 gradients with Phase-2 noise.
269
+
270
+ The big picture (rendered SVG at the top of the post) shows the *data* flow Base → SFT → GRPO → Merge. The diagram above shows the *gradient* flow that lives inside the GRPO box. Stage-by-stage detail below — kept tight.
271
+
272
+ ### Stage 1 · Baseline rollouts
273
+
274
+ `sre_finetune_collector.py` drives the deployed environment over the **HuggingFace Inference API** (`Qwen/Qwen2.5-7B-Instruct:fastest`). Episodes are sampled across all four pools with weights `A=0.35, B=0.20, C=0.35, D=0.10`. **Negative-reward episodes are kept** as hard negatives — there's no quality filter on rollouts.
275
+
276
+ Three artefacts written incrementally:
277
+
278
+ ```
279
+ sre_raw_trajectories.jsonl — full episode + score breakdown
280
+ sre_sft_dataset.jsonl — one row per (observation, action) step
281
+ sre_grpo_dataset.jsonl — (prompt, chosen, rejected) preference pairs
282
+ ```
283
+
284
+ ### Stage 2 · LoRA SFT (TRL)
285
+
286
+ Built on TRL's `SFTTrainer` with PEFT/LoRA — the minimum-requirements training stack named in RULES.md.
287
+
288
+ ```python
289
+ # sft.py
290
+ trainer = SFTTrainer(
291
+ model = model, # Qwen2.5-7B-Instruct
292
+ args = training_args, # bf16, packing on
293
+ train_dataset = dataset[script_args.dataset_train_split],
294
+ eval_dataset = dataset[script_args.dataset_test_split],
295
+ peft_config = get_peft_config(model_args), # LoRA: r=32, α=16
296
+ )
297
+ trainer.train()
298
+ ```
299
+
300
+ | Setting | Value |
301
+ | --- | --- |
302
+ | Base | `Qwen/Qwen2.5-7B-Instruct` |
303
+ | LoRA | `r=32, α=16, dropout=0.05` on `{q,k,v,o}_proj` |
304
+ | LR / epochs | `2e-4` / 1 |
305
+ | Effective batch | `2 × 8` accum = 16 |
306
+ | Precision | `bf16` + packing |
307
+ | Hardware | 1× A100-40GB |
308
+
309
+ > **LoRA notation.** `r` is the **rank** of the low-rank update matrices `A ∈ ℝ^{d×r}, B ∈ ℝ^{r×d}` injected into each target linear; the effective weight delta is `ΔW = (α/r) · B A`, so `α` is a **scaling coefficient** (not a learning rate). `dropout` is applied to `A` activations during training. Target modules `{q,k,v,o}_proj` are the four attention-projection linears in each transformer block.
310
+
311
+ ### Stage 3 · Post-SFT trajectories
312
+
313
+ Because the SFT model is *ours*, we provisioned an A100 manually and ran inference via plain `transformers` — no API. This produced the **n=64** Pool C trajectories used as the GRPO warm-start corpus and the SFT reference distribution in the CDF (Section 7, blue curve).
314
+
315
+ ### Stage 4 · Online GRPO
316
+
317
+ `training/grpo_train.py` implements **on-policy GRPO** (Group Relative Policy Optimisation): K=4 rollouts per prompt with the current policy → within-group reward standardisation → clipped PPO-style loss with a KL penalty against a frozen reference model.
318
+
319
+ ```python
320
+ # training/grpo_train.py — the actual update
321
+ ratio = torch.exp(plp - rlp.detach())
322
+ unclipped = ratio * adv
323
+ clipped = torch.clamp(ratio, 1 - clip, 1 + clip) * adv
324
+ pg_loss = -torch.min(unclipped, clipped)
325
+ kl_loss = beta * (rlp.detach() - plp)
326
+ loss = (pg_loss + kl_loss).sum() / n_tokens
327
+ ```
328
+
329
+ **Where:**
330
+
331
+ | Symbol | Meaning |
332
+ | --- | --- |
333
+ | `plp` | Per-token **log-probability** of the recorded assistant turn under the **policy** (current, trainable model). |
334
+ | `rlp` | Same per-token log-probability under the **reference** model (frozen base; `.detach()` blocks gradient). |
335
+ | `ratio = exp(plp − rlp)` | Importance-sampling ratio of policy / reference — equals `1.0` when they agree. |
336
+ | `adv` | The **advantage** for the segment, computed from the within-group return: `A_i = (R_i − μ_R) / (σ_R + ε)` where `R_i = terminal_reward + r_cross_i`, `μ_R, σ_R` are the mean/stdev of returns inside the K-rollout group, and `ε = 1e-6` for numerical stability. |
337
+ | `clip` (PPO ε) | Trust-region width: `0.2`. Caps how far `ratio` can move before the gradient is clipped. |
338
+ | `pg_loss` | Clipped policy-gradient loss (negative because we minimise). |
339
+ | `beta` (`β`) | KL penalty coefficient: `0.04`. Trades exploration vs. drift from the reference. |
340
+ | `kl_loss` | Per-token forward-KL approximation `β · (rlp − plp)`, pulling the policy toward the reference. |
341
+ | `n_tokens` | Total assistant tokens in the group — normalises so loss magnitude is independent of generation length. |
342
+
343
+ Curriculum:
344
+
345
+ | Stage | Pool | Mode | What gets trained |
346
+ | --- | --- | --- | --- |
347
+ | 2 | A | `p1_only` | Ops policy only |
348
+ | 3 | B | `p2_only` | Code policy only (oracle handoff) |
349
+ | 4 | C | `joint` | Full P1 → P2 with `r_cross` on |
350
+
351
+ Two safety scaffolds in `training/variance_gate.py`:
352
+
353
+ - **Variance gate** — Stage 4 doesn't open until ≥4 tasks show stable `r_code` variance (stdev ≤ 0.15 over 64 samples).
354
+ - **`r_cross` warmup** — linear ramp 0 → 1 over the first 500 Stage-4 steps.
355
+
356
+ | Setting | Value | What it controls |
357
+ | --- | --- | --- |
358
+ | LoRA | `r=16, α=32, dropout=0.05` on `{q,k,v,o}_proj` | Trainable adapter capacity (see Stage 2 box). |
359
+ | Learning rate | `1e-5` | AdamW step size on LoRA params only. |
360
+ | `β` (KL coeff) | `0.04` | Penalty pulling policy toward frozen reference; larger = more conservative. |
361
+ | `clip` (PPO ε) | `0.2` | Width of the trust region in the clipped surrogate. |
362
+ | Group size `K` | `4` | Rollouts per prompt used to compute within-group advantage. |
363
+ | Episodes / task | `64` | Per stage; split across the K-rollout groups. |
364
+
365
+ ### Stage 5 · Merge
366
+
367
+ The smallest file in the repo and the one that makes everything deployable:
368
+
369
+ ```python
370
+ # merge.py
371
+ base_model = "Qwen/Qwen2.5-7B-Instruct"
372
+ lora_model = "daemongg/qwen2.5-7b-sre-grpo"
373
+ output_repo = "Yaswanth-Bolla/qwen-merged"
374
+
375
+ model = AutoModelForCausalLM.from_pretrained(base_model, torch_dtype=torch.float16, device_map="auto")
376
+ model = PeftModel.from_pretrained(model, lora_model)
377
+ model = model.merge_and_unload()
378
+ model.push_to_hub(output_repo)
379
+ ```
380
+
381
+ The output is a vanilla causal LM that vLLM, TGI, or plain `transformers` can load with no idea it had adapters.
382
+
383
+ ---
384
+
385
+ ## 7 · Results
386
+
387
+ ### Figure 1 — Reward distribution (CDF)
388
+
389
+ <p align="center"><img src="docs/img/cdf.png" alt="reward CDF per source" width="80%"/></p>
390
+
391
+ > *Empirical CDF of cumulative reward — lower curve = better (more probability mass at high reward).*
392
+
393
+ - **Baseline** (green dashed, n=80): long left tail; ~40 % of rollouts under 0.75.
394
+ - **SFT** (blue, n=64): consistent — fewer catastrophes, modest median.
395
+ - **Posttrained RL** (red, n=100): dominates across nearly every quantile, with the steepest climb between 0.4 and 0.75 — that's where GRPO concentrated mass.
396
+
397
+ ### Figure 2 — Efficiency curve (reward vs. steps)
398
+
399
+ <p align="center"><img src="docs/img/efficiency.png" alt="efficiency curve" width="80%"/></p>
400
+
401
+ | Model | Mean reward by ~30 steps | Steps to plateau | σ at plateau |
402
+ | --- | --- | --- | --- |
403
+ | Baseline | ~0.20 | never within 60 steps | wide |
404
+ | SFT | ~0.95 | ~50 steps | medium |
405
+ | **Posttrained RL** | **~1.59** | **~25 steps** | **tight** |
406
+
407
+ > ✦ **The operationally meaningful number isn't the +1.10 reward — it's that the post-trained model gets there in *half the wall-clock steps*.** Fewer pages, less time-to-resolution.
408
+
409
+ ### Component breakdown — Pool C (oracle-independent grader, n ≈ 100)
410
+
411
+ | Metric | Base | RL | Δ |
412
+ | --- | --- | --- | --- |
413
+ | `mean_final` | 0.4495 | 0.4537 | ▲ 0.0042 |
414
+ | `mean_p1_steps` | 16.62 | 15.75 | ▼ 0.87 |
415
+ | `mean_p2_steps` | 5.62 | 6.50 | ▲ 0.88 |
416
+ | `mean_r_cross` | 0.4412 | 0.4662 | ▲ 0.025 |
417
+
418
+ > The per-step grader's `mean_final` moves only marginally on Pool C — the visible win is in **cumulative reward**, **CDF dominance**, and **`r_cross`** (+0.025), which is the actual training signal we cared about. The +0.88 P2-steps shift is intentional: the RL model learned to *use* the code workspace before patching, instead of one-shotting a wrong diff.
419
+
420
+ ### Held-out — Pool D (n ≈ 16)
421
+
422
+ | Metric | Base | RL | Δ |
423
+ | --- | --- | --- | --- |
424
+ | `mean_final` | 0.5565 | 0.5284 | ▼ 0.0281 |
425
+ | Pearson r (P2 breadth) | +0.4951 | −0.3637 | ▼ 0.8588 |
426
+
427
+ > ⚠ **We're flagging this honestly.** On the two compositional held-out scenarios, RL is slightly worse than baseline. The strong negative Pearson on P2 breadth tells us why: the RL model commits to a narrow code search early; on truly novel compounds, the base model's naïve breadth-first browsing is a better strategy. Fix path is in §9.
428
+
429
+ ---
430
+
431
+ ## 8 · Ablations
432
+
433
+ ### A · `r_cross` on vs. off — the most informative knob
434
+
435
+ | Condition | Δ `mean_final` (FT − Base) | Δ `mean_r_cross` |
436
+ | --- | --- | --- |
437
+ | `r_cross_on` | **▲ 0.0256** | ▲ 0.169 |
438
+ | `r_cross_off` | ▲ 0.0054 | 0 |
439
+
440
+ > Without the counterfactual reward, the fine-tuning gap shrinks ~80 %. Phase 1 has no incentive to produce a *useful* belief if you don't reward Phase 2 for using it.
441
+
442
+ ### B · Stopping behaviour shifts by allocation, not total
443
+
444
+ The fine-tuned model transitions to Phase 2 **0.87 steps earlier** and spends **0.88 steps more inside Phase 2**. Net step count is roughly flat — but the *budget allocation* improved. Less dashboard, more code.
445
+
446
+ ### C · Source-type contribution
447
+
448
+ | Source removed | Δ `mean_final` (Pool C) |
449
+ | --- | --- |
450
+ | Logs only | ▼ 0.04 |
451
+ | Metrics only | ▼ 0.07 |
452
+ | Git log + diffs | ▼ 0.13 |
453
+ | Mini-repo file tree | ▼ 0.18 |
454
+
455
+ > Code attribution is the single biggest contributor. Take away the repo and the agent loses ~40 % of its lift.
456
+
457
+ ### D · Convergence proxy
458
+
459
+ | Metric | Fine-tuned | Base |
460
+ | --- | --- | --- |
461
+ | Early-window mean_final | 0.7475 | 0.6425 |
462
+ | Late-window mean_final | 0.4255 | 0.4620 |
463
+
464
+ > Fine-tuned starts hotter and decays — has memorised some training-distribution heuristics. Consistent with the Pool D regression. This is the clearest place to push next.
465
+
466
+ ---
467
+
468
+ ## 9 · Limitations & honest caveats
469
+
470
+ - **Pool D regression.** RL underperforms base by 0.028 on held-out compounds. Fix: Pool-D-shaped curriculum data + entropy bonus.
471
+ - **Calibration regresses.** ECE 0.58 → 0.81 — RL is more confident without being more correct. The `BeliefState` aux-loss in `training/belief_aux_loss.py` is the place to wire it back in.
472
+ - **Sample sizes are honest, not heroic.** Baseline n=80, SFT n=64, RL n=100; held-out n=16. Take the held-out number as directional.
473
+ - **No code execution.** Phase 2 is read-only. Adding a sandboxed `pytest` action would close the largest fraction of remaining capability gap.
474
+ - **Minimal system prompt.** A more elaborate scratchpad/belief-state prompt likely closes the SFT→RL gap further. We'd consider that a *positive* signal for the environment.
475
+
476
+ ---
477
+
478
+ ## 10 · Roadmap
479
+
480
+ - [ ] `run_tests` Phase-2 action with sandboxed pytest
481
+ - [ ] Pool-E: real failures harvested from public post-mortems (Stripe, GitHub, CloudFlare)
482
+ - [ ] Held-out compound *generator* (replace the static pair)
483
+ - [ ] Plug `BeliefState` aux loss back into the GRPO loop
484
+ - [ ] 1.5B variant — within a few points of 7B would be operationally meaningful
485
+
486
+ ---
487
+
488
+ ## 11 · How this submission maps to the judging criteria
489
+
490
+ A direct read against `RULES.md`:
491
+
492
+ | Criterion (weight) | Where to look |
493
+ | --- | --- |
494
+ | **Environment Innovation (40 %)** | Two-phase POMDP that bridges ops + code (§3) · oracle-independent grader split from training reward (§4) · counterfactual `r_cross` (§4) · four-pool curriculum with variance gate (§5–6) · sandboxed `CodeWorkspace` over real mini-repos (§3) |
495
+ | **Storytelling (30 %)** | 3 AM hook · capability-gap framing (§1) · pipeline SVG · Mermaid agent loop / state machine / pool tree · honest Pool-D limitation (§7, §9) |
496
+ | **Reward Improvement (20 %)** | Figure 1 CDF dominance · Figure 2 efficiency curve (1.59 vs 0.49) · component-level breakdown · 4-condition ablation (§8) |
497
+ | **Training Pipeline (10 %)** | TRL `SFTTrainer` (RULES-named stack) · on-policy GRPO with KL · `merge_and_unload` for deploy · A100-40GB, reproducible commands |
498
+
499
+ Minimum requirements (RULES.md):
500
+
501
+ - ✅ OpenEnv (`Environment`, `reset`/`step`/`state`, `openenv.yaml`, no reserved tool names)
502
+ - ✅ Working training script (`sft.py` via TRL; `training/grpo_train.py`)
503
+ - ✅ Proof of training (Figure 1 CDF, Figure 2 efficiency, component table, 4 ablations)
504
+ - ✅ Short explanation (this blog)
505
+ - ✅ HF Space (`https://meta-hf-hackathon-updated-policy.hf.space`)
506
+ - ✅ README with results, env behaviour, and links
507
+
508
+ ---
509
+
510
+ ## 12 · Closing
511
+
512
+ We set out to answer one question: *can a small open model, trained against a faithful incident-response simulator, become competitively useful at SRE triage?*
513
+
514
+ On the training distribution: **yes, clearly.** On novel compounds: **not yet, but the training signal we built (`r_cross`) and the curriculum that uses it are correctly oriented toward fixing that.** And the most durable artefact from this submission isn't the score — it's the stack:
515
+
516
+ | Artefact | Where |
517
+ | --- | --- |
518
+ | OpenEnv environment | `incident_env` (this repo) |
519
+ | Hosted Space | `meta-hf-hackathon-updated-policy.hf.space` |
520
+ | LoRA adapter | `daemongg/qwen2.5-7b-sre-grpo` |
521
+ | Merged model | `Yaswanth-Bolla/qwen-merged` |
522
+ | Trajectories | `sre_*_dataset.jsonl` (in repo) |
523
+ | Training scripts | `sft.py`, `training/grpo_train.py`, `merge.py` |
524
+
525
+ Fork it. Run it. Beat it. Tell us where we got it wrong.
526
+
527
+ ---
528
+
529
+ ### Appendix A · Notation glossary
530
+
531
+ Every mathematical symbol used above, gathered for reference.
532
+
533
+ #### Greek letters
534
+
535
+ | Symbol | Reads as | Used for | Value(s) in this work |
536
+ | --- | --- | --- | --- |
537
+ | `α` | alpha | LoRA scaling coefficient — the `α/r` factor multiplies the low-rank update `B A`. **Not a learning rate.** | `α=16` (SFT), `α=32` (GRPO) |
538
+ | `β` | beta | KL-penalty coefficient in the GRPO loss; weights how strongly the policy is pulled toward the frozen reference. | `0.04` |
539
+ | `ε` | epsilon | (i) Numerical stabiliser added to `σ_R` when normalising advantages; (ii) PPO clip width — also written `clip`. | `1e-6`, `0.2` |
540
+ | `μ_R` | mu of R | Mean of the K within-group returns. | runtime |
541
+ | `σ_R` | sigma of R | Standard deviation of the K within-group returns. | runtime |
542
+ | `σ` | sigma | Generic standard deviation; used in plot error bars. | runtime |
543
+ | `τ` | tau | A full episode trajectory `(o_0, a_0, r_0, …, o_T, a_T, r_T)`. | runtime |
544
+ | `τ_1, τ_2` | tau-1, tau-2 | The Phase-1 / Phase-2 sub-trajectories of `τ`. | runtime |
545
+ | `Δ` | delta | Difference between two metrics (e.g. `Δ mean_final = RL − Base`). | reported per-row |
546
+ | `π` (`π_orch`) | pi | A policy. `π_orch` is the orchestrator's routing policy (see hierarchical-RL diagram). | learned |
547
+
548
+ #### Reward / return symbols
549
+
550
+ | Symbol | Meaning |
551
+ | --- | --- |
552
+ | `R_i` | Group-relative return for rollout `i`: `R_i = terminal_reward_i + r_cross_i`. |
553
+ | `A_i` | GRPO advantage: `A_i = (R_i − μ_R) / (σ_R + ε)`. Standardised within the K-rollout group. |
554
+ | `r_code(...)` | Phase-2 grader score in `[0, 1]` — patch quality (file overlap + AST + syntax) or no-change detection. |
555
+ | `r_cross(τ)` | Counterfactual cross-phase reward, defined in §4. |
556
+ | `final` | Top-level grader output in `[0, 1]`: weighted sum of `p1_rca`, `p1_efficiency`, `patch_quality`, `no_change_detection`, `p2_efficiency`. |
557
+
558
+ #### GRPO update symbols (per-token, per-segment)
559
+
560
+ | Symbol | Meaning |
561
+ | --- | --- |
562
+ | `plp` | Log-probability of an assistant token under the **policy** (current trainable model). |
563
+ | `rlp` | Log-probability of the same token under the **reference** model (frozen base). |
564
+ | `ratio` | `exp(plp − rlp)` — importance-sampling ratio. |
565
+ | `unclipped`, `clipped` | `ratio · A_i` and `clamp(ratio, 1−ε, 1+ε) · A_i` respectively. |
566
+ | `pg_loss` | `−min(unclipped, clipped)` — clipped surrogate (negated for minimisation). |
567
+ | `kl_loss` | `β · (rlp − plp)` — per-token forward-KL approximation. |
568
+
569
+ #### Hyperparameters by name
570
+
571
+ | Symbol | Meaning | Value |
572
+ | --- | --- | --- |
573
+ | `K` | GRPO group size (rollouts per prompt). | `4` |
574
+ | `r` | LoRA rank — width of the low-rank update. | `32` (SFT), `16` (GRPO) |
575
+ | `dropout` | Dropout on LoRA `A` activations. | `0.05` |
576
+ | `lr` | AdamW learning rate. | `2e-4` (SFT), `1e-5` (GRPO) |
577
+ | `max_steps` | Step budget per episode. | `40` |
578
+ | `n_tokens` | Total assistant tokens in a GRPO group (used as loss denominator). | runtime |
579
+
580
+ #### Stats / evaluation
581
+
582
+ | Term | Meaning |
583
+ | --- | --- |
584
+ | **CDF** | Empirical Cumulative Distribution Function of cumulative reward across rollouts (Figure 1). |
585
+ | **`stdev`** | Standard deviation; reported on `final` and as plot error bars (`σ at plateau`). |
586
+ | **`Pearson r`** | Linear correlation coefficient in `[−1, +1]`. Reported between Phase-2 *breadth* (number of unique files inspected) and `final` on Pool D — negative means narrowing search hurts on novel compounds. |
587
+ | **`ECE`** | **Expected Calibration Error.** Average gap between the agent's stated confidence and its empirical accuracy across confidence bins; lower is better. |
588
+ | **`stdev ≤ 0.15`** | Variance-gate threshold over a 64-sample window before Stage 4 opens. |
589
+
590
+ #### Misc symbols
591
+
592
+ | Symbol | Meaning |
593
+ | --- | --- |
594
+ | `→` | Process step or state transition (e.g. `Base → SFT → GRPO → Merge`). |
595
+ | `×` | Cartesian product / multiplication (e.g. `7 services × 10 actions`). |
596
+ | `·` | List separator in dense tables / captions; also dot product where unambiguous. |
597
+ | `≈` | Approximately equal. |
598
+ | `≤`, `≥` | At most / at least. |
599
+ | `▲ / ▼` | Increase / decrease in a Δ column (sign already encoded in the value). |
600
+ | `∅` | Null / empty context — a Phase-2 episode given no Phase-1 evidence. |
601
+ | `[a, b]` | Closed interval; e.g. component scores live in `[0, 1]`. |
602
+
603
+ ---
604
+
605
+ ### Appendix B · Diagram source files in this repo
606
+
607
+ | File | Used in | Notes |
608
+ | --- | --- | --- |
609
+ | `docs/diagrams/pipeline.svg` | §0 hero, §6 | Five-stage horizontal pipeline (data flow Base → SFT → GRPO → Merge). Edit text/colors directly in the file. |
610
+ | `docs/diagrams/agent_loop.svg` | §2 | Agent ↔ env loop with the partial-observation card. |
611
+ | `docs/diagrams/hierarchical_rl_architecture.svg` | §6 | Three-level hierarchy — orchestrator + subagents + segment-level GRPO with `r_cross`. The *gradient* view that complements pipeline.svg's *data* view. |
612
+ | Mermaid blocks (inline) | §2, §3, §5 | Render natively on GitHub / HF blogs. |
613
+
614
+ **To render the SVGs as PNGs (e.g. for Twitter / slide decks):**
615
+
616
+ ```bash
617
+ # Either:
618
+ npx svgexport docs/diagrams/pipeline.svg docs/img/pipeline.png 2x
619
+ # or:
620
+ rsvg-convert -z 2 -o docs/img/pipeline.png docs/diagrams/pipeline.svg
621
+ ```
622
+
623
+ **To replace the result figures**, drop your two charts at:
624
+
625
+ - `docs/img/cdf.png` — Figure 1 (reward distribution per source)
626
+ - `docs/img/efficiency.png` — Figure 2 (reward vs. steps)
627
+
628
+ The blog already links to those paths.
docs/diagrams/agent_loop.svg ADDED
docs/diagrams/hierarchical_rl_architecture.svg ADDED
docs/diagrams/pipeline.svg ADDED
inference_agent.py ADDED
@@ -0,0 +1,466 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ inference_agent.py — real-world SRE incident investigation.
3
+
4
+ Takes a GitHub issue (URL or raw text) + a local git repo, then drives the
5
+ trained GRPO model through the same Phase-2 action loop it was trained on
6
+ (list_dir → read_file → search_code → get_git_log → get_file_diff →
7
+ propose_patch / declare_root_cause).
8
+
9
+ The model sees an observation dict in exactly the format it was trained on;
10
+ the only change is that tool calls hit a real repository instead of a snapshot.
11
+
12
+ Usage:
13
+ # From a GitHub issue URL (requires `gh` CLI authenticated)
14
+ python inference_agent.py \\
15
+ --model srinjoyd/qwen2.5-7b-sre-grpo \\
16
+ --repo /path/to/cloned/repo \\
17
+ --issue https://github.com/owner/repo/issues/42
18
+
19
+ # From raw issue text
20
+ python inference_agent.py \\
21
+ --model srinjoyd/qwen2.5-7b-sre-grpo \\
22
+ --repo /path/to/cloned/repo \\
23
+ --issue-text "OrderService crashes with OOM after deploy abc1234"
24
+
25
+ # Clone the repo automatically
26
+ python inference_agent.py \\
27
+ --model srinjoyd/qwen2.5-7b-sre-grpo \\
28
+ --repo-url https://github.com/owner/repo \\
29
+ --issue https://github.com/owner/repo/issues/42
30
+ """
31
+
32
+ from __future__ import annotations
33
+
34
+ import argparse
35
+ import json
36
+ import os
37
+ import subprocess
38
+ import sys
39
+ import tempfile
40
+ from pathlib import Path
41
+ from typing import Any, Dict, List, Optional
42
+
43
+ import torch
44
+ from transformers import AutoModelForCausalLM, AutoTokenizer
45
+
46
+ from server.real_code_workspace import RealCodeWorkspace, RealCodeWorkspaceError
47
+
48
+
49
+ # ──────────────────────────────────────────────────────────────────────
50
+ # Issue fetching
51
+ # ──────────────────────────────────────────────────────────────────────
52
+
53
+ def fetch_github_issue(issue_url: str) -> str:
54
+ """
55
+ Use `gh issue view` to fetch the issue title + body.
56
+ Requires `gh` CLI authenticated (`gh auth login`).
57
+ """
58
+ # Parse owner/repo/number from URL
59
+ # https://github.com/owner/repo/issues/42
60
+ parts = issue_url.rstrip("/").split("/")
61
+ if len(parts) < 7 or parts[-2] != "issues":
62
+ raise ValueError(f"Cannot parse issue URL: {issue_url}")
63
+ owner_repo = f"{parts[-4]}/{parts[-3]}"
64
+ number = parts[-1]
65
+
66
+ result = subprocess.run(
67
+ ["gh", "issue", "view", number, "--repo", owner_repo,
68
+ "--json", "title,body,labels,state,createdAt,author"],
69
+ capture_output=True, text=True, timeout=30,
70
+ )
71
+ if result.returncode != 0:
72
+ raise RuntimeError(
73
+ f"gh issue view failed:\n{result.stderr}\n"
74
+ "Make sure `gh` is installed and authenticated (`gh auth login`)."
75
+ )
76
+ data = json.loads(result.stdout)
77
+ title = data.get("title", "")
78
+ body = (data.get("body") or "").strip()
79
+ labels = ", ".join(l.get("name", "") for l in data.get("labels", []))
80
+ author = data.get("author", {}).get("login", "unknown")
81
+
82
+ summary = f"Issue: {title}\nAuthor: {author}"
83
+ if labels:
84
+ summary += f"\nLabels: {labels}"
85
+ if body:
86
+ summary += f"\n\n{body[:3000]}" # cap body at 3 KB
87
+ return summary
88
+
89
+
90
+ def clone_repo(repo_url: str, target_dir: str) -> str:
91
+ """git clone repo_url into target_dir. Returns target_dir."""
92
+ print(f"Cloning {repo_url} → {target_dir}")
93
+ subprocess.run(
94
+ ["git", "clone", "--depth", "50", repo_url, target_dir],
95
+ check=True, timeout=120,
96
+ )
97
+ return target_dir
98
+
99
+
100
+ # ──────────────────────────────────────────────────────────────────────
101
+ # Minimal model inference
102
+ # ──────────────────────────────────────────────────────────────────────
103
+
104
+ _SYSTEM_PROMPT = """\
105
+ You are an SRE root-cause analyst. You will receive observations from a code \
106
+ investigation environment. At each step respond with ONE JSON action only — \
107
+ no prose, no markdown fences.
108
+
109
+ Valid actions:
110
+ {"action_type": "list_dir", "parameters": {"path": "<rel_path>"}}
111
+ {"action_type": "read_file", "parameters": {"path": "<rel_path>"}}
112
+ {"action_type": "search_code", "parameters": {"query": "<text>", "file_pattern": "*.py"}}
113
+ {"action_type": "get_git_log", "parameters": {"n_commits": 10, "path": ""}}
114
+ {"action_type": "get_file_diff", "parameters": {"commit_sha": "<sha>", "path": ""}}
115
+ {"action_type": "propose_patch", "parameters": {"diff": "<unified_diff>"}}
116
+ {"action_type": "declare_root_cause", "parameters": {"root_cause": "<explanation>"}}
117
+ {"action_type": "declare_no_change", "parameters": {}}
118
+
119
+ Investigation strategy:
120
+ 1. list_dir(".") to orient yourself.
121
+ 2. get_git_log to find recent commits.
122
+ 3. get_file_diff on suspicious commits.
123
+ 4. read_file / search_code to understand the bug.
124
+ 5. propose_patch with a minimal unified diff once you have the fix.
125
+ declare_no_change only if there is genuinely no code bug.\
126
+ """
127
+
128
+
129
+ def _parse_action(text: str) -> Dict[str, Any]:
130
+ text = text.strip().lstrip("`")
131
+ if text.startswith("json"):
132
+ text = text[4:].strip()
133
+ a, b = text.find("{"), text.rfind("}")
134
+ if a == -1 or b <= a:
135
+ return {"action_type": "declare_no_change", "parameters": {}}
136
+ try:
137
+ obj = json.loads(text[a : b + 1])
138
+ obj.setdefault("parameters", {})
139
+ return obj if isinstance(obj, dict) else {"action_type": "declare_no_change", "parameters": {}}
140
+ except Exception:
141
+ return {"action_type": "declare_no_change", "parameters": {}}
142
+
143
+
144
+ class LocalModelAgent:
145
+ """Loads a local / HF model and drives the investigation loop."""
146
+
147
+ def __init__(
148
+ self,
149
+ model_id: str,
150
+ load_in_4bit: bool = False,
151
+ max_new_tokens: int = 512,
152
+ temperature: float = 0.2,
153
+ max_history: int = 10,
154
+ ) -> None:
155
+ print(f"Loading model: {model_id}")
156
+ tok_kwargs: Dict[str, Any] = {"use_fast": True}
157
+ self._tok = AutoTokenizer.from_pretrained(model_id, **tok_kwargs)
158
+ if self._tok.pad_token is None:
159
+ self._tok.pad_token = self._tok.eos_token
160
+
161
+ model_kwargs: Dict[str, Any] = {"device_map": "auto"}
162
+ if load_in_4bit:
163
+ from transformers import BitsAndBytesConfig
164
+ model_kwargs["quantization_config"] = BitsAndBytesConfig(
165
+ load_in_4bit=True,
166
+ bnb_4bit_compute_dtype=torch.bfloat16,
167
+ bnb_4bit_quant_type="nf4",
168
+ )
169
+ else:
170
+ model_kwargs["torch_dtype"] = torch.bfloat16
171
+
172
+ self._model = AutoModelForCausalLM.from_pretrained(model_id, **model_kwargs)
173
+ self._model.eval()
174
+ self._max_new = max_new_tokens
175
+ self._temperature = temperature
176
+ self._max_history = max_history
177
+ self._msgs: List[Dict[str, str]] = []
178
+
179
+ def reset(self) -> None:
180
+ self._msgs = [{"role": "system", "content": _SYSTEM_PROMPT}]
181
+
182
+ def act(self, observation: Dict[str, Any]) -> Dict[str, Any]:
183
+ payload = json.dumps(observation, default=str)[:5000]
184
+ self._msgs.append({"role": "user", "content": payload})
185
+ self._trim()
186
+
187
+ prompt = self._format_chat()
188
+ try:
189
+ device = self._model.get_input_embeddings().weight.device
190
+ except Exception:
191
+ device = next(self._model.parameters()).device
192
+
193
+ inputs = self._tok(prompt, return_tensors="pt").to(device)
194
+ with torch.no_grad():
195
+ out = self._model.generate(
196
+ **inputs,
197
+ max_new_tokens=self._max_new,
198
+ do_sample=(self._temperature > 0),
199
+ temperature=self._temperature,
200
+ eos_token_id=self._tok.eos_token_id,
201
+ )
202
+ text = self._tok.decode(
203
+ out[0][inputs["input_ids"].shape[-1]:],
204
+ skip_special_tokens=True,
205
+ ).strip()
206
+
207
+ self._msgs.append({"role": "assistant", "content": text})
208
+ return _parse_action(text)
209
+
210
+ def _format_chat(self) -> str:
211
+ # Try the model's own chat template first
212
+ try:
213
+ return self._tok.apply_chat_template(
214
+ self._msgs, tokenize=False, add_generation_prompt=True
215
+ )
216
+ except Exception:
217
+ pass
218
+ # Fallback plain-text format
219
+ parts: List[str] = []
220
+ for m in self._msgs:
221
+ role = m["role"]
222
+ content = m["content"]
223
+ if role == "system":
224
+ parts.append(f"<|system|>\n{content}\n")
225
+ elif role == "user":
226
+ parts.append(f"<|user|>\n{content}\n")
227
+ else:
228
+ parts.append(f"<|assistant|>\n{content}\n")
229
+ parts.append("<|assistant|>\n")
230
+ return "".join(parts)
231
+
232
+ def _trim(self) -> None:
233
+ if len(self._msgs) > 1 + self._max_history * 2:
234
+ self._msgs = self._msgs[:1] + self._msgs[-(self._max_history * 2):]
235
+
236
+
237
+ # ──────────────────────────────────────────────────────────────────────
238
+ # Dispatch: action → RealCodeWorkspace call
239
+ # ──────────────────────────────────────────────────────────────────────
240
+
241
+ def _dispatch(
242
+ action: Dict[str, Any],
243
+ workspace: RealCodeWorkspace,
244
+ ) -> Dict[str, Any]:
245
+ atype = action.get("action_type", "")
246
+ params = action.get("parameters", {}) or {}
247
+
248
+ try:
249
+ if atype == "list_dir":
250
+ return workspace.list_dir(params.get("path", "."))
251
+
252
+ if atype == "read_file":
253
+ return workspace.read_file(params.get("path", ""))
254
+
255
+ if atype == "search_code":
256
+ return workspace.search_code(
257
+ query = params.get("query", ""),
258
+ file_pattern = params.get("file_pattern", "*.py"),
259
+ max_hits = params.get("max_hits"),
260
+ )
261
+
262
+ if atype == "get_git_log":
263
+ return workspace.get_git_log(
264
+ path = params.get("path", ""),
265
+ n_commits = int(params.get("n_commits", 10)),
266
+ )
267
+
268
+ if atype == "get_file_diff":
269
+ return workspace.get_file_diff(
270
+ commit_sha = params.get("commit_sha", "HEAD"),
271
+ path = params.get("path", ""),
272
+ )
273
+
274
+ # Terminal actions — handled in the main loop
275
+ return {}
276
+
277
+ except RealCodeWorkspaceError as e:
278
+ return {"error": str(e)}
279
+
280
+
281
+ # ──────────────────────────────────────────────────────────────────────
282
+ # Main investigation loop
283
+ # ──────────────────────────────────────────────────────────────────────
284
+
285
+ TERMINAL_ACTIONS = {"propose_patch", "declare_root_cause", "declare_no_change"}
286
+
287
+ VALID_ACTIONS = [
288
+ "list_dir", "read_file", "search_code",
289
+ "get_git_log", "get_file_diff",
290
+ "propose_patch", "declare_root_cause", "declare_no_change",
291
+ ]
292
+
293
+
294
+ def investigate(
295
+ agent: LocalModelAgent,
296
+ workspace: RealCodeWorkspace,
297
+ incident_summary: str,
298
+ max_steps: int = 20,
299
+ verbose: bool = True,
300
+ ) -> Dict[str, Any]:
301
+ """
302
+ Drive the agent through the investigation loop.
303
+
304
+ Returns a result dict with:
305
+ root_cause, proposed_patch, steps_taken, action_log
306
+ """
307
+ agent.reset()
308
+
309
+ # Seed observation — mirrors the format from Phase-2 training
310
+ obs: Dict[str, Any] = {
311
+ "current_phase": 2,
312
+ "incident_summary": incident_summary,
313
+ "bad_commit_sha": workspace.bad_commit_sha or "",
314
+ "valid_actions": VALID_ACTIONS,
315
+ "action_result": {},
316
+ "step": 0,
317
+ "repo_tree": workspace.file_tree(max_depth=2),
318
+ }
319
+
320
+ action_log: List[Dict[str, Any]] = []
321
+ root_cause: Optional[str] = None
322
+ proposed_patch: Optional[str] = None
323
+
324
+ for step in range(1, max_steps + 1):
325
+ obs["step"] = step
326
+ action = agent.act(obs)
327
+ atype = action.get("action_type", "")
328
+
329
+ if verbose:
330
+ params_str = json.dumps(action.get("parameters", {}))[:120]
331
+ print(f" step {step:2d} {atype:<22s} {params_str}")
332
+
333
+ action_log.append({"step": step, "action": action})
334
+
335
+ if atype == "declare_root_cause":
336
+ root_cause = action.get("parameters", {}).get("root_cause", "")
337
+ if verbose:
338
+ print(f"\nRoot cause declared:\n {root_cause}")
339
+ # Don't break yet — model may follow up with propose_patch
340
+ obs = {**obs, "action_result": {"acknowledged": True},
341
+ "valid_actions": ["propose_patch", "declare_no_change"]}
342
+ continue
343
+
344
+ if atype == "propose_patch":
345
+ proposed_patch = action.get("parameters", {}).get("diff", "")
346
+ if verbose:
347
+ print(f"\nPatch proposed ({len(proposed_patch)} chars)")
348
+ break
349
+
350
+ if atype == "declare_no_change":
351
+ if verbose:
352
+ print("\nAgent declared: no code change needed.")
353
+ break
354
+
355
+ # Execute the tool call
356
+ result = _dispatch(action, workspace)
357
+ obs = {
358
+ **obs,
359
+ "action_result": result,
360
+ }
361
+
362
+ return {
363
+ "root_cause": root_cause,
364
+ "proposed_patch": proposed_patch,
365
+ "steps_taken": step,
366
+ "action_log": action_log,
367
+ }
368
+
369
+
370
+ # ──────────────────────────────────────────────────────────────────────
371
+ # CLI
372
+ # ──────────────────────────────────────────────────────────────────────
373
+
374
+ def _parse_args() -> argparse.Namespace:
375
+ p = argparse.ArgumentParser(
376
+ description="Run the trained SRE agent on a real GitHub issue."
377
+ )
378
+ p.add_argument("--model", required=True,
379
+ help="HF model ID or local path of the GRPO checkpoint")
380
+ # Repo — one of these required
381
+ g = p.add_mutually_exclusive_group(required=True)
382
+ g.add_argument("--repo", help="Path to an already-cloned local repo")
383
+ g.add_argument("--repo-url", help="Git URL to clone (cloned to a temp dir)")
384
+ # Issue — one of these required
385
+ ig = p.add_mutually_exclusive_group(required=True)
386
+ ig.add_argument("--issue", help="GitHub issue URL (requires gh CLI)")
387
+ ig.add_argument("--issue-text", help="Raw issue description text")
388
+
389
+ p.add_argument("--bad-commit", default="",
390
+ help="SHA of the suspected bad commit (optional hint)")
391
+ p.add_argument("--max-steps", type=int, default=20)
392
+ p.add_argument("--max-new-tokens",type=int, default=512)
393
+ p.add_argument("--load-in-4bit", action="store_true", default=False)
394
+ p.add_argument("--output", default=None,
395
+ help="Write JSON result to this file")
396
+ p.add_argument("--quiet", action="store_true")
397
+ return p.parse_args()
398
+
399
+
400
+ def main() -> None:
401
+ args = _parse_args()
402
+ verbose = not args.quiet
403
+
404
+ # ── 1. Get incident summary ──────────────────────────────────────
405
+ if args.issue:
406
+ if verbose:
407
+ print(f"Fetching issue: {args.issue}")
408
+ incident_summary = fetch_github_issue(args.issue)
409
+ else:
410
+ incident_summary = args.issue_text
411
+
412
+ if verbose:
413
+ print("\n── Incident summary ──")
414
+ print(incident_summary[:600])
415
+ print()
416
+
417
+ # ── 2. Set up repo ───────────────────────────────────────────────
418
+ _tmpdir = None
419
+ if args.repo_url:
420
+ _tmpdir = tempfile.mkdtemp(prefix="sre_agent_")
421
+ repo_path = clone_repo(args.repo_url, _tmpdir)
422
+ else:
423
+ repo_path = args.repo
424
+
425
+ workspace = RealCodeWorkspace(repo_path, bad_commit_sha=args.bad_commit or "")
426
+
427
+ # ── 3. Load model ────────────────────────────────────────────────
428
+ agent = LocalModelAgent(
429
+ model_id = args.model,
430
+ load_in_4bit = args.load_in_4bit,
431
+ max_new_tokens = args.max_new_tokens,
432
+ )
433
+
434
+ # ── 4. Investigate ───────────────────────────────────────────────
435
+ if verbose:
436
+ print("── Investigation ──")
437
+
438
+ result = investigate(
439
+ agent=agent,
440
+ workspace=workspace,
441
+ incident_summary=incident_summary,
442
+ max_steps=args.max_steps,
443
+ verbose=verbose,
444
+ )
445
+
446
+ # ── 5. Output ────────────────────────────────────────────────────
447
+ print("\n" + "═" * 60)
448
+ print("ROOT CAUSE:")
449
+ print(result["root_cause"] or "(not declared)")
450
+ print("\nPROPOSED PATCH:")
451
+ print(result["proposed_patch"] or "(none)")
452
+ print(f"\n({result['steps_taken']} steps taken)")
453
+
454
+ if args.output:
455
+ with open(args.output, "w") as f:
456
+ json.dump(result, f, indent=2, default=str)
457
+ print(f"\nFull result written to {args.output}")
458
+
459
+ # Cleanup temp clone
460
+ if _tmpdir:
461
+ import shutil
462
+ shutil.rmtree(_tmpdir, ignore_errors=True)
463
+
464
+
465
+ if __name__ == "__main__":
466
+ main()
merge.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # /// script
2
+ # requires-python = ">=3.10"
3
+ # dependencies = [
4
+ # "transformers",
5
+ # "peft",
6
+ # "accelerate",
7
+ # "safetensors",
8
+ # "huggingface_hub"
9
+ # ]
10
+ # ///
11
+ from transformers import AutoModelForCausalLM, AutoTokenizer
12
+ from peft import PeftModel
13
+ import torch
14
+
15
+ base_model = "Qwen/Qwen2.5-7B-Instruct"
16
+ lora_model = "daemongg/qwen2.5-7b-sre-grpo"
17
+ output_repo = "Yaswanth-Bolla/qwen-merged"
18
+
19
+ tokenizer = AutoTokenizer.from_pretrained(base_model)
20
+
21
+ model = AutoModelForCausalLM.from_pretrained(
22
+ base_model,
23
+ torch_dtype=torch.float16,
24
+ device_map="auto"
25
+ )
26
+
27
+ model = PeftModel.from_pretrained(model, lora_model)
28
+
29
+ model = model.merge_and_unload()
30
+
31
+ model.push_to_hub(output_repo)
32
+ tokenizer.push_to_hub(output_repo)
sft.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copyright 2020-2026 The HuggingFace Team. All rights reserved.
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ # /// script
16
+ # dependencies = [
17
+ # "trl",
18
+ # "peft",
19
+ # "trackio",
20
+ # "kernels",
21
+ # ]
22
+ # ///
23
+
24
+ """
25
+ # Full training
26
+ ```
27
+ python trl/scripts/sft.py \
28
+ --model_name_or_path Qwen/Qwen2-0.5B \
29
+ --dataset_name trl-lib/Capybara \
30
+ --learning_rate 2.0e-5 \
31
+ --num_train_epochs 1 \
32
+ --packing \
33
+ --per_device_train_batch_size 2 \
34
+ --gradient_accumulation_steps 8 \
35
+ --eos_token '<|im_end|>' \
36
+ --eval_strategy steps \
37
+ --eval_steps 100 \
38
+ --output_dir Qwen2-0.5B-SFT \
39
+ --push_to_hub
40
+ ```
41
+
42
+ # LoRA
43
+ ```
44
+ python trl/scripts/sft.py \
45
+ --model_name_or_path Qwen/Qwen2-0.5B \
46
+ --dataset_name trl-lib/Capybara \
47
+ --learning_rate 2.0e-4 \
48
+ --num_train_epochs 1 \
49
+ --packing \
50
+ --per_device_train_batch_size 2 \
51
+ --gradient_accumulation_steps 8 \
52
+ --eos_token '<|im_end|>' \
53
+ --eval_strategy steps \
54
+ --eval_steps 100 \
55
+ --use_peft \
56
+ --lora_r 32 \
57
+ --lora_alpha 16 \
58
+ --output_dir Qwen2-0.5B-SFT \
59
+ --push_to_hub
60
+ ```
61
+ """
62
+
63
+ import argparse
64
+
65
+
66
+ def main(script_args, training_args, model_args, dataset_args):
67
+ from accelerate import logging
68
+ from datasets import load_dataset
69
+ from transformers import AutoConfig, AutoModelForCausalLM
70
+ from transformers.models.auto.modeling_auto import MODEL_FOR_IMAGE_TEXT_TO_TEXT_MAPPING_NAMES
71
+
72
+ from trl import SFTTrainer, get_dataset, get_kbit_device_map, get_peft_config, get_quantization_config
73
+
74
+ logger = logging.get_logger(__name__)
75
+
76
+ ################
77
+ # Model init kwargs
78
+ ################
79
+ model_kwargs = dict(
80
+ revision=model_args.model_revision,
81
+ trust_remote_code=model_args.trust_remote_code,
82
+ attn_implementation=model_args.attn_implementation,
83
+ dtype=model_args.dtype,
84
+ )
85
+ quantization_config = get_quantization_config(model_args)
86
+ if quantization_config is not None:
87
+ # Passing None would not be treated the same as omitting the argument, so we include it only when valid.
88
+ model_kwargs["device_map"] = get_kbit_device_map()
89
+ model_kwargs["quantization_config"] = quantization_config
90
+
91
+ # Create model
92
+ config = AutoConfig.from_pretrained(model_args.model_name_or_path)
93
+ valid_image_text_architectures = MODEL_FOR_IMAGE_TEXT_TO_TEXT_MAPPING_NAMES.values()
94
+
95
+ if config.architectures and any(arch in valid_image_text_architectures for arch in config.architectures):
96
+ from transformers import AutoModelForImageTextToText
97
+
98
+ model = AutoModelForImageTextToText.from_pretrained(model_args.model_name_or_path, **model_kwargs)
99
+ else:
100
+ model = AutoModelForCausalLM.from_pretrained(model_args.model_name_or_path, **model_kwargs)
101
+
102
+ # Load the dataset
103
+ if dataset_args.datasets and script_args.dataset_name:
104
+ logger.warning(
105
+ "Both `datasets` and `dataset_name` are provided. The `datasets` argument will be used to load the "
106
+ "dataset and `dataset_name` will be ignored."
107
+ )
108
+ dataset = get_dataset(dataset_args)
109
+ elif dataset_args.datasets and not script_args.dataset_name:
110
+ dataset = get_dataset(dataset_args)
111
+ elif not dataset_args.datasets and script_args.dataset_name:
112
+ dataset = load_dataset(
113
+ script_args.dataset_name, name=script_args.dataset_config, streaming=script_args.dataset_streaming
114
+ )
115
+ else:
116
+ raise ValueError("Either `datasets` or `dataset_name` must be provided.")
117
+
118
+ # Initialize the SFT trainer
119
+ trainer = SFTTrainer(
120
+ model=model,
121
+ args=training_args,
122
+ train_dataset=dataset[script_args.dataset_train_split],
123
+ eval_dataset=dataset[script_args.dataset_test_split] if training_args.eval_strategy != "no" else None,
124
+ peft_config=get_peft_config(model_args),
125
+ )
126
+
127
+ # Train the model
128
+ trainer.train()
129
+
130
+ # Log training complete
131
+ trainer.accelerator.print("✅ Training completed.")
132
+
133
+ # Save and push to Hub
134
+ trainer.save_model(training_args.output_dir)
135
+ trainer.accelerator.print(f"💾 Model saved to {training_args.output_dir}.")
136
+
137
+ if training_args.push_to_hub:
138
+ trainer.push_to_hub(dataset_name=script_args.dataset_name)
139
+ trainer.accelerator.print(f"🤗 Model pushed to the Hub in https://huggingface.co/{trainer.hub_model_id}.")
140
+
141
+
142
+ def make_parser(subparsers: argparse._SubParsersAction | None = None, prog: str | None = None):
143
+ from trl import DatasetMixtureConfig, ModelConfig, ScriptArguments, SFTConfig, TrlParser
144
+
145
+ dataclass_types = (ScriptArguments, SFTConfig, ModelConfig, DatasetMixtureConfig)
146
+ if subparsers is not None:
147
+ parser = subparsers.add_parser("sft", help="Run the SFT training script", dataclass_types=dataclass_types)
148
+ else:
149
+ parser = TrlParser(dataclass_types, prog=prog)
150
+ return parser
151
+
152
+
153
+ if __name__ == "__main__":
154
+ parser = make_parser()
155
+ script_args, training_args, model_args, dataset_args = parser.parse_args_and_config(fail_with_unknown_args=False)
156
+ main(script_args, training_args, model_args, dataset_args)