Port the ML Intern doctrine into the ML Assistant preset (#2526)
* Port the ML Intern doctrine into the ML Assistant preset
The preset shipped with a six-bullet prompt. This replaces it with the
doctrine ported from ml-intern's system prompt — re-costed against this
harness rather than copied, because the differences change what the rules
can say.
Three properties of chat-ui reshaped the port:
- Ten tool rounds per turn, not three hundred. A literature-first crawl
does not fit, so the doctrine grounds the claims it is about to act on
rather than everything it says. The budget is raised to 100 for the
preset alone; an ordinary conversation still stops at ten, and
exhausting the budget still ends the turn with an apology.
- A human is present, so the autonomous-mode block is the branch that
does not apply, and "ask instead of assuming" routes through
ask_user_question rather than hoping.
- No sandbox and no shell. Rules keyed to bash, read-before-edit or /app
paths are dead text here and are dropped; develop-small-launch-big
survives as a short job before a long one. A test asserts the prompt
names no tool this harness lacks.
In the mode, buildToolPreprompt SWAPS its doctrine paragraphs rather
than being joined by a second prompt: the generic restraint rule tells
the model not to call a tool unless it lacks a capability, and names
writing code as a case to answer directly, which is the inverse of the
preset's doctrine. Both in one system message is a contradiction no
amount of added doctrine fixes — and it is why #2524's GitHub tools
needed `exemptFromToolRestraint` at all. In the mode there is no blanket
restraint left for a tool to be exempt from.
It is a swap inside the one builder, not a second builder, deliberately.
Everything else that function sends — the tool list, the clock, the
per-builtin guidance from #2514 and #2524, the image rules — has to
reach the model either way, and a parallel copy silently stops carrying
whatever is added to the other one next. A test pins that builtin
guidance survives in the mode.
The session context line is stamped in resolvePreprompt, not onto the
tool preprompt, so it still reaches the model on the plain generation
path. It lands last, after the artifacts prompt, and says User=unknown
explicitly when there is no username: the namespace rule keys off that
exact value to decide whether to call hf_whoami or ask.
push_to_hub is stated twice for the reason ml-intern stated it three
times, and a test pins the repetition.
The composed system message is ~3,800 tokens and re-sent every round of
every turn. A test holds it under an 18k-character ceiling so the next
paragraph is a decision rather than a drift.
Peter's calls, for the record: the artifact/payload split for scripts is
in the prompt; MCP tool descriptions stay HF's, with doctrine in the
preprompt only; the research sub-agent is deferred.
* Put the job submission contract beside the job tool
The densest and most expensive-to-violate text in ml-intern's set is the
hf_jobs pre-submission contract, and it does not belong in the preset
prompt: it is sent only when the tool it governs is on offer, so a run
without hf_jobs never reads a contract for something it cannot do.
Mechanism note, because the plan assumed otherwise: #2514's builtin-tool
seam carries per-builtin guidance, but hf_jobs is an MCP tool served by
hf.co/mcp. So the doctrine is keyed on the tool name in the resolved tool
list instead — which means this does not depend on #2514, and extends to
any MCP tool worth a contract. hf_fs_write gets one too.
The rules are grounded against the current docs rather than recalled,
which is what the prompt tells the model to do:
- Pushing to the Hub from inside a job needs HF_TOKEN passed explicitly
as a secret. Leave it out and the run trains for an hour and fails at
the push. This is a harness-specific failure ml-intern never had, and
the most expensive one available here.
- The default flavor is cpu-basic. A training job that names no GPU
flavor does not fail, it crawls.
- Hardware guidance is a ladder, not a price table: t4-small for smoke
tests through a100-large for real training, with a pointer to
hf://docs/hub/jobs-pricing.md for rates. A table in a prompt goes
stale silently; a pointer does not. Tests pin the pointer.
hf_fs_write recovers the read-before-edit rule in a form this harness can
actually back: read the file, then pass the parent commit SHA, so a
concurrent change fails loudly instead of being silently clobbered.
Both surfaces used to tell the model to print a pre-flight checklist,
which would produce two. The prompt owns the instruction to print one;
the contract adds its lines to it.
The system message ceiling moves 18k -> 19k. Measured at ~17.4k with
every preset tool on offer, and the raise is the decision the ceiling
exists to force.
* Point the preset at the HF MCP endpoint that can authenticate
The preset hard-coded https://hf.co/mcp. `isStrictHfMcpLogin` matches on
the exact URL including `?login`, and it gates two things: whether the
signed-in user's HF token is forwarded to the server, and whether the
server card offers the login control at all. Without the parameter the
mode's Hub tools run anonymously — no whoami, no jobs, no writes — and
there is nothing in the UI to log into, which is how this was found.
Worse than getting itself wrong: the preset wins the name collision in
withMlAssistantServers, so it would have replaced the `?login` entry that
chart/env/prod.yaml and dev.yaml already configure. Turning the mode on
in production would have silently downgraded a logged-in HF MCP server to
an anonymous one.
A test now asserts the preset's HF URL satisfies the same predicate the
forwarding checks, rather than asserting the string.
* Stop persisting live-only tool progress updates
A conversation is one MongoDB document and Mongo caps a document at 16MB.
Past that ceiling every write fails — not slowly, permanently: the
conversation can never be added to again.
That ceiling is reachable, and one ML Assistant run got to 94.7% of it.
Polling a training job produced a single assistant message carrying
73,994 updates and 15.1MB, of which 59,487 were MCP progress
notifications — 80% of the volume, and 66.6% of the whole document.
Progress is live-only UI. Once the result is stored, a progress tick
tells a reader nothing the result does not, yet it was persisted forever
and replayed to the browser on every load. Dropping it takes that
conversation from 15,881,222 bytes to 5,297,794, and its update count
from 75,957 to 16,163.
Only what is persisted changes; everything still streams to the connected
client exactly as before.
Also adds a 5,000-update cap as a backstop for whatever the next
unbounded emitter turns out to be. Over the cap, stream length-markers go
first — they carry no text, that lives on the message, and only record
where a tool card sat relative to it. Tool calls and their results are
the transcript, so they go last, and the most recent survive.
compressUpdatesForStorage moves out of the route into
$lib/server/generation so it can be tested directly. replayRoundTrip
still passes, which is the contract that matters: it drives the real
persistence path end to end and would catch a field that no longer
survives the round trip.
This treats the symptom. The cause is that the agent has no way to wait
for a job, so "check back later" becomes 1,096 log calls at one every two
seconds. That fix belongs in the loop, not here.
* Add the parked-call record and a wait tool, still dark
Groundwork for server-woken background tasks. The agent currently has no
way to wait: it decides to check back later, the only action available is
to call the tool again, and "wait" becomes 1,096 log calls at one every
two seconds.
What this adds:
- `ParkedCall`: a tool call that ended its turn and expects to be resumed,
with `resumeAt` and a `kind` discriminator. Its own record rather than a
flag on the elicitation row, because MCP tasks are the expected second
kind and calling one a timer and the other an elicitation would read as
wrong the moment both exist. Indexed on `{status, resumeAt}` — that pair
is the sweep — with a 7-day TTL.
- The builtin seam carries conversation and owner identity. A resume runs
with no request to read an identity from, so a tool that parks has to
record whose turn it was, while there still is one; the resume then acts
as the user who parked rather than as whatever swept it.
- A `wait` tool that creates the record and parks, with the caps that stop
a wait-loop replacing the poll-loop: 15s floor (below it, just call the
tool again), 30-minute ceiling per hop, and 40 hops per conversation.
Park, resume, park again costs a turn each time and never returns to the
user, which is the same failure the repetition guard exists for.
The tool is deliberately NOT registered in getEnabledBuiltinTools. It
parks a turn, and nothing wakes one yet — enabling it before the sweeper
lands would strand runs rather than fix them.
Still to come: the sweeper, and the extraction it depends on. A resumed
turn has to produce a persisted message the same way an HTTP one does,
and today that logic lives in a closure inside the route's POST — it
merges pre-tool streamed content, sets interrupted, persists titles. That
is worth extracting rather than duplicating into a second implementation
that will drift, and it is a refactor of the hottest path in the app, so
it wants its own pass with the round-trip tests green.
* Extract the update accumulator out of the conversation route
Groundwork for waking a parked turn. A resumed turn has to produce a
persisted message the same way an HTTP turn does, and that logic lived in
a closure inside the route's POST — so the sweeper's only options were to
reuse it or to grow a second copy that drifts. Drift here means messages
that persist subtly wrong.
`applyUpdateToMessage` now owns the folding: stream tokens into content,
reasoning, the title strip, the final-answer merge, files, router
metadata, and the updates array. It does no I/O. The title write, the
generation writer, metrics, the wire padding and the SSE enqueue stay
with their callers, because those are exactly what differs between a turn
driven by a request and one driven by a sweep.
Two details preserved deliberately, both easy to lose in a rewrite:
- An empty stream token is dropped whole. It reaches neither the updates
array nor the log nor the client, so the extracted function reports
`skipped` rather than merely leaving content untouched.
- The final answer replaces only what THIS turn produced. `initialContent`
is the boundary, which is what lets a resumed turn continue a message
that already says something instead of overwriting it — the case the
wait tool depends on.
The pre-tool merge (A/B/C) was previously only covered through the
end-to-end round trip. It now has direct tests, including the case a
resumed turn hits. replayRoundTrip stays green, which is the check that
matters: it drives the real persistence path and would catch a fold that
stopped surviving the trip.
* Wake parked turns with a server-side sweeper
Turns the wait tool on. The agent can now stop and be resumed instead of
polling: it says how long and why, its turn ends, and a sweeper wakes it.
How a wake works. `claimDueCall` moves a due row out of `waiting` with the
status in the filter, so two pods racing on one row produce a winner and a
miss rather than two resumed turns. The claim then rebuilds the identity
the turn ran as — user, session token, settings — because there is no
request to read one from, which also means a resume can only ever act as
the user who parked it. It injects the tool result the parked call has
been missing, paired to the original call by uuid so replay puts it in the
model's history, and runs the turn through the same
`applyUpdateToMessage` an HTTP turn uses.
Notes on the parts that were decisions rather than mechanics:
- An expired session does not drop the turn. The token can easily expire
during a long wait, and silently failing every Hub call afterwards would
be worse than saying so — the model is told in the tool result, so it
can report it instead of retrying into 401s.
- A row that keeps failing is abandoned after three attempts rather than
retried forever. A parked turn that cannot resume is a turn nobody is
waiting on.
- The sweep interval is the floor on how late a wake can be, so it stays
well under the shortest wait the tool will accept.
Tested: the claim is exercised by two concurrent sweeps over three due
rows, asserting one claim each; plus the not-yet-due, conversation-gone
and gave-up paths, and the tool's caps and record shape.
Not yet covered by an automated test: a full resumed turn end to end,
which needs the model and the tool server scripted the way
replayRoundTrip does it. That is the next test to write, and until it
exists this wants exercising by hand before it goes anywhere near prod.
* Teach the preset to cost a run and to read what a paper builds on
Four gaps the first real run exposed, all prompt-only.
**Hardware was chosen once and never reasoned about.** Every job went to
t4-small — because the ladder said "smallest" and never said "how long".
The doctrine now reasons in cost to FINISH rather than cost per hour: a
GPU at twice the rate that trains three times as fast is both cheaper and
sooner, so cheapest-per-hour is rarely the right default for real
training. The smoke test already measures steps per second, so the real
run's wall-clock is arithmetic — it goes on the pre-flight list with what
it will cost, every time. Above about half an hour, or where a faster
flavor would materially change the estimate, the choice goes to the user
through ask_user_question with the numbers in the options. It read the
pricing table in that run and still never told the user a rate.
**It followed no citations.** Every research read in 1,166 calls was the
same paper. The whole latent-space-versus-image-space failure was a
misreading of the flow-matching target — standard prior work, one hop
away, ~40 minutes and several dead jobs to rediscover. A bounded hop is
now doctrine: read the one or two papers a method builds on before
implementing it. Cut originally because it did not fit ten rounds; at a
hundred, with `wait`, it does. ml-intern's attribution rule comes back
with it.
**It searched for a paper with hub_repo_search.** A repo search, twice,
concluding nothing was there. hf_fs now carries where papers live and
what hub_repo_search is actually for.
**Web search would have arrived unguided.** The mode replaces the generic
tool preprompt, SEARCH paragraph included, so configuring Exa would have
handed the model search tools with nothing said about them. It gets its
own block, keyed on the tool, so it appears only where the tool does.
The size ceiling fired at 19,774 against 19,000, which is what it is for.
Raised to 22k deliberately, with the reasons recorded, and the
measurement now composes every tool at once so the ceiling actually
binds.
* Stop the preset probing permissions it does not have
Four fixes, all from watching a real run thrash between calls 31 and 49
trying to find somewhere to put its artifacts. It even searched GitHub for
`huggingface/mcp-server` — going after the MCP server's own source to work
out how the tools behave, which is the model saying the doctrine does not
tell it enough.
**Create before writing.** `put` does not create a repo, and writing where
nothing exists fails with "Repository not found" — which reads like a
permissions problem and is not. Identified after the previous run and not
fixed then; it caused one of these two failures outright.
**Work in repos you created.** The other failure was an authorization
error on a bucket a different app had made. The token carries
`contribute-repos`, which is documented as "create repositories and access
those created by this app" — so a pre-existing repo or bucket is exactly
what it excludes. prod.yaml picks that scope deliberately over
`write-repos`, so this is a product boundary, not a misconfiguration. It
was simply invisible to the model.
**Permission errors are not retries**, as a named failure mode. Told 403,
it tried the same call, then a variant, then another tool needing the same
permission. A permission you do not have does not appear on the second
attempt.
**Sandboxes as an optimisation, never a dependency.** A sandbox gives
feedback in seconds where a job queues and pulls an image before
mentioning a typo, so it is the right place for smoke tests — but
availability depends on the account and the deployment, and `hf_sandbox`
403s here even though `jobs` is granted. So: use it if it works, fall back
to a small `hf_jobs` run the moment it does not, and never report the task
as blocked because of it. Keyed on the tool, so if the sandbox turns out
to need a scope we are not requesting, granting it is all that has to
change.
Whether that scope exists is still open. `jobs` IS granted (confirmed via
hf_whoami) and the sandbox still 403s; the public scope list has no
sandbox scope but is provably incomplete, since this token holds
`read-mcp`, which is not in it. HF does not advertise `scopes_supported`,
so it cannot be settled from outside.
The ceiling moves 22k -> 24k for headroom, not content: at 21,889 it fired
on every edit. ~5,500 tokens re-sent on every round of a hundred is the
number to watch.
* Don't let a made-up timezone take the turn down
From Codex review on #2526. `timezone` reaches the prompt from the request
body validated only as `z.optional(z.string())`, and `Intl.DateTimeFormat`
throws `RangeError` on a zone it does not know. Confirmed all three legs:
the schema, that `resolvePreprompt` runs at index.ts:66 with the try only
starting at line 80, and the throw itself.
So a client posting `{"timezone":"garbage"}` to an ML Assistant
conversation failed the whole generation, because the session-context
stamp is built before the generation's own error handling.
Both paths fall back to the server's zone now, and the stamp stops
claiming a zone it did not honour.
The generic tool preprompt had the same exposure and is fixed with it —
there the throw is caught upstream, so instead of failing it quietly
answered without tools, which is the same bug wearing a better disguise
and harder to notice.
* Drop comments that narrate the code they sit on
Comment hygiene pass over the branch. Almost everything survives: the
comments here carry reasons — why the preset swaps a paragraph rather than
appending, why progress is not persisted, why a claim puts the status in
the filter — and those are the ones that stop a future edit undoing a
decision.
The exceptions were all in `applyUpdate.ts`, and all inherited: they came
across verbatim when the accumulator was extracted from the route, where
they had labelled the branches of a long closure. In a function whose
shape is now visible at a glance, "Set the title" above the title branch
and "Add file" above the file branch say nothing the code does not.
One is kept as a reason rather than a description: a title is stripped of
think markers because a reasoning model will put them there, which is the
part a reader cannot see from the replace() itself.
* Fix five review findings, one of them a regression I introduced
From Copilot review on #2526. All five were real; the first is the one
that matters.
**The extraction dropped `writer.push` and the wire padding.** Cutting the
accumulation out of the route's update closure took two caller-only steps
with it. Every turn since has written nothing to the generation log — so
the reattach endpoint has no events to replay and `materializedSeq` never
leaves zero — and stream tokens went to the client without the
fixed-length padding that mitigates the packet-length side channel.
The evidence was in front of me and I read past it: every generation on
this branch since the extraction has `seq=0` and zero events, while runs
from a main-based worktree against the same database log normally. I had
noticed a `seq=0` during the sweeper smoke test and called it "odd but
consistent". It wasn't.
The log is now covered by a test in replayRoundTrip, which drives the real
route. Verified it fails without the fix ("expected 0 to be greater than
0") rather than assuming it would: nothing else in that suite reads the
log, which is why the regression passed 996 tests.
**A claim was a one-way door.** `claimDueCall` filtered on `waiting` only,
so a resume that died before its own error handling left the row in
`resuming` until the TTL, and the attempt ceiling could never apply. A
claim is now a five-minute lease, and an expired one is fair game.
**A parked turn was recorded as an error.** Timer parking emits no
elicitation request, so `showedPrompt` stayed false, and with no text
produced the no-output guard called a correctly-parked turn "No output was
generated". The guard now also asks whether the turn made a tool call. In
the sweeper, a resumed turn that parks again no longer gets a Finished
status stamped over it.
**A resumed run was unreachable.** The browser finds a running turn
through the last assistant message's `generationId`, and the sweeper left
the parked turn's id in place — so the resumed work only appeared on a
manual refresh. This is the mechanism behind the missing stop button. The
resumed run now claims the message before it starts producing.
**Each .env comment documented the other variable's setting**, having been
inserted between an existing comment and the line it described.