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()