File size: 9,137 Bytes
7d90fa8 | 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 | """Explainer and demo Space for danielrosehill/Agent-Task-Break-Detector-ModernBERT.
Runs the int8 ONNX export on CPU with the same input format as model/common.py:fmt_pair
(last 4 exchanges, newest first, "AGENT: ...\nUSER: ...", truncation="only_second", max 512).
"""
import json
from pathlib import Path
import gradio as gr
import numpy as np
import onnxruntime as ort
from huggingface_hub import hf_hub_download
from tokenizers import Tokenizer
REPO = "danielrosehill/Agent-Task-Break-Detector-ModernBERT"
cfg = json.load(open(hf_hub_download(REPO, "new_task_split.json")))
THRESHOLD = cfg["threshold"]
MAX_LEN = cfg["max_len"]
MAX_TURNS = cfg["max_turns"]
tok = Tokenizer.from_file(hf_hub_download(REPO, cfg["tokenizer"]))
tok.enable_truncation(MAX_LEN, strategy="only_second")
sess = ort.InferenceSession(hf_hub_download(REPO, cfg["onnx"]), providers=["CPUExecutionProvider"])
EXAMPLES = json.load(open(Path(__file__).parent / "examples.json"))
def parse_context(text):
"""Lines starting USER: / AGENT:, oldest first. Continuation lines join the previous turn."""
turns, cur, role = [], {}, None
for line in text.splitlines():
s = line.strip()
if not s:
continue
up = s.upper()
if up.startswith("USER:"):
if cur.get("user") is not None:
turns.append(cur)
cur = {}
role, cur["user"] = "user", s[5:].strip()
elif up.startswith("AGENT:"):
role, cur["agent"] = "agent", s[6:].strip()
elif role:
cur[role] += " " + s
if cur:
turns.append(cur)
return [{"user": t.get("user", ""), "agent": t.get("agent", "")} for t in turns]
def format_context(turns):
return "\n".join(f"USER: {t['user']}\nAGENT: {t['agent']}" for t in turns)
def model_input(turns):
recent = turns[-MAX_TURNS:][::-1]
return "\n".join(f"AGENT: {t['agent']}\nUSER: {t['user']}" for t in recent)
def classify(message, context_text):
if not message.strip():
return {}, "Type a new user message first."
turns = parse_context(context_text)
enc = tok.encode(message.strip(), model_input(turns)) if turns else tok.encode(message.strip())
ids = np.array([enc.ids], dtype=np.int64)
mask = np.array([enc.attention_mask], dtype=np.int64)
logits = sess.run(None, {"input_ids": ids, "attention_mask": mask})[0][0]
p = np.exp(logits - logits.max())
p = p / p.sum()
p_new = float(p[1])
if p_new >= THRESHOLD:
verdict = f"### New task → start a fresh session\n`p(new_task)` = **{p_new:.3f}**, at or above the threshold of {THRESHOLD}."
else:
verdict = f"### Continue → keep the current session\n`p(new_task)` = **{p_new:.3f}**, below the threshold of {THRESHOLD}."
verdict += f"\n\nThe model read {min(len(turns), MAX_TURNS)} of {len(turns)} exchanges and {len(enc.ids)} tokens."
return {"new_task": p_new, "continue": 1 - p_new}, verdict
def example_rows():
rows = []
for e in EXAMPLES:
rows.append([e["message"], format_context(e["context"])])
return rows
INTRO = f"""
# Agent Task Break Detector
You are deep in a session with a coding agent, and you type something unrelated: a different bug, an idea, an errand.
The agent now carries all the earlier history into a task that doesn't need it. That costs tokens, and stale context
makes its answers worse. The tidy fix is a fresh session (`/clear`, `/new`) for each new task, but doing it by hand
breaks your flow.
This model makes that call on **every user turn**:
> Does this new message need the conversation so far, or could a fresh session handle it just as well?
It is a 150M-parameter [ModernBERT-base](https://huggingface.co/answerdotai/ModernBERT-base) cross-encoder. It reads
the new message together with the recent exchanges and returns `p(new_task)`. It is small enough to run locally on
every prompt, for example from a Claude Code `UserPromptSubmit` hook. This demo runs the **int8 ONNX** build on a free CPU.
**Status: v0.1 (2026-10-24), experimental.** Trained only on synthetic data and not yet checked against real sessions.
"""
HOW = f"""
## How it works
**Input.** A pair of text segments, as in training:
1. the new user message;
2. the last {MAX_TURNS} exchanges, **newest first**, each written as `AGENT: …` then `USER: …`.
The context is truncated first if the pair runs past {MAX_LEN} tokens, so the message is always read in full.
**Output.** Two logits (`continue`, `new_task`), passed through softmax to give `p(new_task)`.
**Decision.** Split only when `p(new_task) ≥ {THRESHOLD}`. The errors are not equal. A wrong split throws away the
agent's working context, while a missed split only costs some extra context. So the threshold is set for
**precision ≥ 0.95 on `new_task`** (int8 validation: precision 0.952, recall 0.921).
### Why context matters: matched pairs
The training data includes *matched pairs*: the **same message**, once in a session where it is a new task and once
in a session where it continues the work. The message alone can't separate them, so the model has to read the
context. The examples in the *Try it* tab are two such pairs from the held-out test split. Load both halves of a pair
and compare the scores.
### Training
- Data: [danielrosehill/Agent-Task-Break-Detector](https://huggingface.co/datasets/danielrosehill/Agent-Task-Break-Detector) v0.2.
Sessions were generated by Claude Haiku 5.5 and labelled independently by Claude Sonnet 5.5. Only rows where the two
agreed are used: 5,104 training rows, including 1,025 matched pairs.
- Categories: `far` (unrelated task), `same_workspace` (different task, same repo), `subtle_pivot`, `follow_up`,
`implicit_reference` ("now do the same for the other one"), `hard_negative`, `counterfactual_continue`.
- 3 epochs, lr 5e-5, batch 16, bf16, on one NVIDIA L4 (6.9 minutes).
- Code: [Agent-Context-Harness](https://github.com/danielrosehill/Agent-Context-Harness), `model/train.py`.
"""
RESULTS = """
## Results (synthetic test split)
n = 608 rows, including 115 matched pairs. *Pair accuracy* counts a pair as correct only when both halves are classified correctly.
| Model | Accuracy | AUC-PR | `new_task` recall @ precision 0.95 | Pair accuracy |
|---|---|---|---|---|
| **ModernBERT, fp32** | **0.951** | **0.986** | **0.902** | **0.791** |
| ModernBERT, int8 ONNX (this demo) | 0.929 | 0.982 | 0.888 | — |
| TF-IDF, message only | 0.793 | 0.758 | 0.037 | — |
| TF-IDF, message + context | 0.760 | 0.781 | 0.140 | — |
The TF-IDF baselines are almost useless at high precision: phrasing alone doesn't tell you whether a task is new.
**Weakest categories:** `same_workspace` (0.867), a different task in the same repo, and `counterfactual_continue`
(0.896), a message that sounds self-contained but actually continues the work.
int8 probabilities run about 0.14 lower than fp32, which is why the int8 threshold (0.76) is calibrated separately.
## Limitations
- Synthetic data from a single generator. Real sessions are longer, messier and multilingual. English only.
- Training data is roughly 50/50. In real use most turns are continuations, so expect more false splits than the test set shows.
- Before you let it act unattended, pick your own threshold on your own sessions.
## Links
- Model: [danielrosehill/Agent-Task-Break-Detector-ModernBERT](https://huggingface.co/danielrosehill/Agent-Task-Break-Detector-ModernBERT)
- Dataset: [danielrosehill/Agent-Task-Break-Detector](https://huggingface.co/datasets/danielrosehill/Agent-Task-Break-Detector)
- Project and hook: [github.com/danielrosehill/Agent-Context-Harness](https://github.com/danielrosehill/Agent-Context-Harness)
"""
with gr.Blocks(title="Agent Task Break Detector", theme=gr.themes.Soft()) as demo:
gr.Markdown(INTRO)
with gr.Tab("Try it"):
with gr.Row():
with gr.Column(scale=3):
context = gr.Textbox(
label="Session so far (oldest first; one USER: / AGENT: line per turn)",
lines=12,
placeholder="USER: the payments migration fails on the currency column\n"
"AGENT: Added a default of 'USD' in migration 0042 and re-ran it; it passes.",
)
message = gr.Textbox(label="New user message", lines=2)
btn = gr.Button("Classify", variant="primary")
with gr.Column(scale=2):
label = gr.Label(label="Prediction", num_top_classes=2)
verdict = gr.Markdown()
gr.Examples(
examples=example_rows(),
inputs=[message, context],
label="Matched pairs from the test split: each message appears twice, once as a new task and once as a continuation",
)
btn.click(classify, [message, context], [label, verdict])
message.submit(classify, [message, context], [label, verdict])
with gr.Tab("How it works"):
gr.Markdown(HOW)
with gr.Tab("Results and limits"):
gr.Markdown(RESULTS)
if __name__ == "__main__":
demo.launch()
|