@argszero/cordis-plugin-steer-preempt
v0.2.0
Published
Steer and new-output preemption for dsh: a new user input (next-step steer) or new output from the awaited job interrupts a blocking job_output(wait: true) read immediately instead of letting it block up to the job-wait cap, so the agent loop reaches its
Maintainers
Readme
@argszero/cordis-plugin-steer-preempt
Steer preemption for the DeepSeek Harness (dsh). When the agent blocks in a
job_output(wait: true) read, two things a model routinely waits for are
not wake sources for the wait:
- a new user message — a steer queued as a next-step input — is normally ignored until the wait expires;
- new job output — a streaming task that prints a progress line still leaves the call blocked for the whole timeout.
The tool-jobs wait cap is 600,000 ms, so a user's "stop, switch tasks" can sit unread for ten minutes, and a "check the progress" read returns nothing until the job ends. This plugin makes both interrupt the blocking wait early so the loop reaches its step boundary and honors the steer — and so the model sees the output it was waiting for. The background job is never killed.
The gaps it closes
Agent.steer() (and the session-controller's insert path) sends the input to
inbox.nextStep. The running step never looks at that queue — it is consumed
only at the next step boundary (preStep claim in the agent loop). A
blocking job read holds the step for up to the wait cap, so the steer is not
processed until the wait expires on its own. See
deepseek-ai/deepseek-harness discussion #6030.
The wait's only wake sources are job settlement and its own timeout, while the call is most often used for exactly the opposite: a mid-run progress check. Discussion #6285 reports output appearing 0.44 s into a wait whose call still blocks the full timeout.
| Mechanism | Behavior |
|---|---|
| Agent.steer() / next-step input | only queues into inbox.nextStep; consumed at the next step boundary |
| job_output(wait: true) | blocks up to the configured cap (max 600,000 ms), honoring exec.signal |
| Agent.cancel() | aborts the phase the same tick (too heavy: kills the turn) |
| this plugin | aborts only the tool-visible derived signal when a steer is pending or the job has produced output → wait returns early → step boundary arrives → steer is honored / output is read |
How it works
This plugin is a community-side fix that needs no harness patch. It registers
a tools/execute around-wrapper (the same seam the in-tree
guard/timeout-policy uses):
- Only blocking reads (
job_outputwithwait: true) are wrapped; every other tool call delegates with zero overhead. - For a blocking read, the wrapper swaps in a derived
AbortSignalfor the duration of the dispatch (the registry fuses it with the caller's signal, so a real turn cancel keeps its semantics — the caller signal is never touched). - A monitor polls the agent's public
inbox.nextStepqueue (read-only — it never consumes; the loop'spreStepclaim owns consumption). When a user steer has been pending for a short settle grace (default 1 s), it aborts the derived signal. The trigger is a per-messagemessage.source.kind === 'user'test, not a bare queue-length check: thenextStepqueue also carries plugin-injected notifications (agent.inject, e.g. a job-B completion notice while the model waits on job A, withsource.kind === 'plugin'). A plugin notice is not user input, so it must not fire a user-steer preemption. - The same monitor watches the job's output: the target job's read returning text — held for the same settle grace — arms the output trigger.
- The abort makes
jobs.waitreject ('wait aborted'); the wait logic uncounts the waiter but leaves the job running. The registry converts the tool throw into an error result. - The wrapper then replaces that error result with a structured
STEER_PREEMPTED/JOB_OUTPUT_RESUMEDresult telling the model the job is still running — so it finishes its turn and the steer is consumed at the step boundary, or it acts on the output it just received.
A job that settles inside the grace window still returns its real result through the normal path — only an actual abort is ever replaced.
The output trigger's one real constraint
The job registry's output cursor is one-way: read returns whatever was
produced since the previous read and moves the cursor, and onJobsChanged fires
only on visible-set changes (registration, stopping, settlement, removal) —
never on an append. There is no non-consuming way to ask whether new output
exists.
So the output trigger is the consuming read, which has one consequence worth knowing: the delta it observes is handed to the model as part of the resumed result. That is not a workaround, it is the property that makes the trigger worth having — the moment the plugin notices output, that output is on its way to the model, and nothing is discarded. A wait that observes nothing never reads.
Because the observation is what fires the trigger, an early return on the very first line of output is normal and cannot be tuned down: there is no "how interesting is this output" knob, since discarding a delta to keep waiting would throw away the only copy of it.
Verified against packages/core/tools/src/index.ts,
packages/jobs/jobs-local/src/index.ts, packages/jobs/tool-jobs/src/index.ts
and packages/core/agent/src/runtime-types.ts on
dsh 0.1.5-rc.2 / c291e7961a. (v0.1 was verified on 0.1.5-alpha.1; the
platform surface is unchanged.)
Install
Mount the plugin in your profile's bundle (see the dsh bundle/preset docs), or drop it into your plugins directory:
npm install @argszero/cordis-plugin-steer-preempt# cordis.patch.yml overlay
- insert:
- id: steer-preempt
name: '@argszero/cordis-plugin-steer-preempt'Or use the shipped patch by adding the package to your bundle dependencies.
Configuration
No config is required. Optional tuning (via a set layer):
| Field | Default | Meaning |
|---|---|---|
| pollMs | 200 | Poll interval for the pending next-step queue and the observed output while a blocking read is active |
| graceMs | 1000 | Settle grace: after a trigger is observed, how long to let a nearly-done job finish before aborting |
| deliver | 'wrap' | How the output trigger delivers its early return: 'wrap' (structured JOB_OUTPUT_RESUMED carrying the observed delta), 'cut' (leave the tool's own early-return result), 'off' (disable the output trigger) |
| resumeTimeoutMs | 5000 | Bounded wait re-armed in 'wrap' mode after an output resume, so the model resumes with a bounded wait instead of the full original budget. Never exceeds the caller's own timeout_ms |
The steer trigger is unaffected by deliver: a user message always wins and
always produces STEER_PREEMPTED.
Changelog
- v0.2.0: output trigger (#6285). A blocking
job_output(wait: true)now returns early when the job produces output, not only when it settles or times out. The observed delta is handed to the model in a structuredJOB_OUTPUT_RESUMEDresult (delivery mode is explicit — seedeliver), the job keeps running, and a settle inside the grace window still wins. There is deliberately no "how interesting is this output" knob: a sensitivity threshold could only be honored by consuming a delta and discarding it, which is strictly worse than handing the model the text it was waiting for. - v0.1.2: peer range fix.
>=0.1.2matched no published version — every dsh release is a prerelease and a semver comparator only admits prereleases sharing its ownmajor.minor.patchtuple, so installation failed outright withETARGET. The range is now>=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0(one comparator per supported tuple line — the single-comparator form still rejects the0.1.5line withERESOLVE). Atest/peer-range.test.jsregression prevents both forms from returning. - v0.1.1: preemption trigger narrowed from
nextStep.length > 0to a per-messagemessage.source.kind === 'user'test. Fixes a false preemption when a plugin-injected notification (agent.inject, e.g. a job-B completion notice withsource.kind === 'plugin') sits in the samenextStepqueue as a real user steer. Regression tests added. (Community feedback, discussion #6030.) - v0.1.0: initial release — preempt a blocking
job_output(wait: true)read when a user steer is queued.
What it does not do
- It does not kill or cancel the background job — the job keeps running and
a later
job_outputread returns its state. - It does not consume the steer — the loop's step boundary still owns that.
- It does not replace a successful wait result — only an abort-shaped error that this plugin's own trigger caused.
- It does not re-arm the tool's own wait after a resume — a
'wrap'resume reports the wait's remaining budget so the model can decide whether to keep waiting, and the job's completion notice still arrives on its own. - It targets
job_output(wait: true)only (the reported gaps). Other tools that honorexec.signalcan be added later.
Relationship to in-tree guards
| Guard | Hook | Catches |
|---|---|---|
| guard/timeout-policy | tools/execute | a tool call exceeding a declared timeoutMs |
| guard/repeat-tool-reminder | tools/post-execute | the model repeating the same tool-call chain |
| this plugin | tools/execute | a blocking wait that a user steer (or new job output) should preempt |
License
MIT
