Download app.py from danielrosehill/Agent-Task-Break-Detector: direct link, hf CLI and curl.
- Browser
- Download file 9.14 kB
-
https://huggingface.co/spaces/danielrosehill/Agent-Task-Break-Detector/resolve/main/app.py
- Command line
-
hf download hf://spaces/danielrosehill/Agent-Task-Break-Detector/app.py
-
curl -L -o app.py https://huggingface.co/spaces/danielrosehill/Agent-Task-Break-Detector/resolve/main/app.py
9.14 kB
| """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() | |