@argszero/cordis-plugin-steer-preempt 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 argszero
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # @argszero/cordis-plugin-steer-preempt
2
+
3
+ Steer preemption for the DeepSeek Harness (`dsh`). When the agent blocks in a
4
+ **`job_output(wait: true)`** read, a new user message — a *steer* that is queued
5
+ as a next-step input — is normally ignored until the wait expires. The tool-jobs
6
+ wait cap is **600,000 ms**, so a user's "stop, switch tasks" can sit unread for
7
+ ten minutes. This plugin makes a pending steer **interrupt the blocking wait
8
+ immediately** so the loop reaches its step boundary and honors the steer. The
9
+ background job is **never killed**.
10
+
11
+ ## The gap it closes
12
+
13
+ `Agent.steer()` (and the session-controller's insert path) sends the input to
14
+ `inbox.nextStep`. The running step never looks at that queue — it is consumed
15
+ only at the **next step boundary** (`preStep` claim in the agent loop). A
16
+ blocking job read holds the step for up to the wait cap, so the steer is not
17
+ processed until the wait expires on its own. See
18
+ [deepseek-ai/deepseek-harness discussion #6030](https://github.com/deepseek-ai/deepseek-harness/discussions/6030).
19
+
20
+ | Mechanism | Behavior |
21
+ |---|---|
22
+ | `Agent.steer()` / next-step input | only *queues* into `inbox.nextStep`; consumed at the next step boundary |
23
+ | `job_output(wait: true)` | blocks up to the configured cap (max 600,000 ms), honoring `exec.signal` |
24
+ | `Agent.cancel()` | aborts the phase the same tick (too heavy: kills the turn) |
25
+ | **this plugin** | aborts **only the tool-visible derived signal** when a steer is pending → wait returns early → step boundary arrives → steer is honored |
26
+
27
+ ## How it works
28
+
29
+ This plugin is a **community-side fix** that needs no harness patch. It registers
30
+ a `tools/execute` around-wrapper (the same seam the in-tree
31
+ `guard/timeout-policy` uses):
32
+
33
+ 1. Only **blocking** reads (`job_output` with `wait: true`) are wrapped; every
34
+ other tool call delegates with zero overhead.
35
+ 2. For a blocking read, the wrapper swaps in a **derived `AbortSignal`** for the
36
+ duration of the dispatch (the registry fuses it with the caller's signal, so
37
+ a real turn cancel keeps its semantics — the caller signal is never touched).
38
+ 3. A monitor polls the agent's public `inbox.nextStep` queue (read-only — it
39
+ **never consumes**; the loop's `preStep` claim owns consumption). When a
40
+ steer has been pending for a short **settle grace** (default 1 s), it aborts
41
+ the derived signal.
42
+ 4. The abort makes `jobs.wait` reject (`'wait aborted'`); the wait logic
43
+ **uncounts the waiter but leaves the job running**. The registry converts the
44
+ tool throw into an error result.
45
+ 5. The wrapper then replaces that error result with a structured
46
+ `STEER_PREEMPTED` result telling the model the job is still running in the
47
+ background and a user message is queued — so it finishes its turn and the
48
+ steer is consumed at the step boundary.
49
+
50
+ A job that settles inside the grace window still returns its **real result**
51
+ through the normal path — only an actual abort is ever replaced.
52
+
53
+ Verified against `packages/core/tools/src/index.ts`,
54
+ `packages/jobs/jobs-local/src/index.ts`, `packages/jobs/tool-jobs/src/index.ts`
55
+ and `packages/core/agent/src/runtime-types.ts` on **dsh 0.1.5-alpha.1**.
56
+
57
+ ## Install
58
+
59
+ Mount the plugin in your profile's bundle (see the dsh bundle/preset docs), or
60
+ drop it into your plugins directory:
61
+
62
+ ```sh
63
+ npm install @argszero/cordis-plugin-steer-preempt
64
+ ```
65
+
66
+ ```yaml
67
+ # cordis.patch.yml overlay
68
+ - insert:
69
+ - id: steer-preempt
70
+ name: '@argszero/cordis-plugin-steer-preempt'
71
+ ```
72
+
73
+ Or use the shipped patch by adding the package to your `bundle` dependencies.
74
+
75
+ ## Configuration
76
+
77
+ No config is required. Optional tuning (via a `set` layer):
78
+
79
+ | Field | Default | Meaning |
80
+ |---|---|---|
81
+ | `pollMs` | `200` | Poll interval for the pending next-step queue while a blocking read is active |
82
+ | `graceMs` | `1000` | Settle grace: after a steer is observed, how long to let a nearly-done job finish before aborting |
83
+
84
+ ## What it does not do
85
+
86
+ - It does **not** kill or cancel the background job — the job keeps running and
87
+ a later `job_output` read returns its state.
88
+ - It does **not** consume the steer — the loop's step boundary still owns that.
89
+ - It does **not** replace a successful wait result — only an abort-shaped error
90
+ that this plugin's own trigger caused.
91
+ - v0.1 targets `job_output(wait: true)` only (the reported gap). Other tools
92
+ that honor `exec.signal` can be added later.
93
+
94
+ ## Relationship to in-tree guards
95
+
96
+ | Guard | Hook | Catches |
97
+ |---|---|---|
98
+ | `guard/timeout-policy` | `tools/execute` | a tool call exceeding a declared `timeoutMs` |
99
+ | `guard/repeat-tool-reminder` | `tools/post-execute` | the model repeating the same tool-call chain |
100
+ | **this plugin** | `tools/execute` | a **blocking wait** that a user steer should preempt |
101
+
102
+ ## License
103
+
104
+ MIT
@@ -0,0 +1,14 @@
1
+ # The @argszero/cordis-plugin-steer-preempt bundle patch: no deployment-specific
2
+ # config is needed to mount (defaults poll the pending next-step queue every
3
+ # 200ms and preempt a blocking job wait after a 1s settle grace once a steer is
4
+ # observed). Tune via a profile layer if desired:
5
+ #
6
+ # - set:
7
+ # - id: steer-preempt
8
+ # config:
9
+ # pollMs: 200
10
+ # graceMs: 1000
11
+
12
+ - insert:
13
+ - id: steer-preempt
14
+ name: '@argszero/cordis-plugin-steer-preempt'
package/lib/index.js ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * Steer preemption for the dsh harness.
3
+ *
4
+ * Closes an agent-loop latency gap that #6030 describes: while the agent blocks
5
+ * in a `job_output(wait: true)` read (which waits up to the tool-jobs configured
6
+ * cap — the tool-jobs maximum is 600,000 ms), a user steer
7
+ * (`Agent.send(input, 'next-step')`, i.e. the session-controller's insert path)
8
+ * only *queues* into `Agent.inbox.nextStep`; the running step never looks at it,
9
+ * and the queue is consumed only at the next step boundary (`preStep` claim in
10
+ * the agent loop). So a mid-wait steer is ignored until the wait expires — up to
11
+ * ten minutes of the user's message sitting unread.
12
+ *
13
+ * This plugin is the community-side fix. It wraps `tools/execute` (the same
14
+ * around-dispatch seam the in-tree `guard/timeout-policy` uses). When the called
15
+ * tool is a **blocking** `job_output` read, it swaps in a derived `AbortSignal`
16
+ * for the duration of the dispatch, polls the agent's public
17
+ * `inbox.nextStep` queue (read-only — it never consumes anything; the loop's
18
+ * `preStep` claim owns consumption), and aborts the derived signal once a
19
+ * pending steer has survived a short settle grace. The abort makes the tool
20
+ * wait return early — the background job is **not** killed (the wait logic
21
+ * uncounts the waiter and rejects; the job keeps running) — so the step ends,
22
+ * the loop reaches its step boundary, and the queued steer is honored promptly.
23
+ *
24
+ * Mechanism notes (verified against packages/core/tools/src/index.ts,
25
+ * packages/jobs/jobs-local/src/index.ts, packages/jobs/tool-jobs/src/index.ts
26
+ * and packages/core/agent/src/runtime-types.ts on dsh 0.1.5-alpha.1):
27
+ * - `tools/execute` is a Cordis waterfall; a wrapper may replace `exec.signal`
28
+ * for its delegated lifetime and the registry fuses every replacement with
29
+ * the captured caller signal (`ToolDispatchExecution` doc). The caller/turn
30
+ * signal is never touched, so a real cancel keeps its semantics.
31
+ * - `job_output(wait: true)` calls `jobs.wait(id, timeout, exec.agent,
32
+ * exec.signal)`; `jobs-local` honors the signal: a pre-aborted signal throws
33
+ * `'wait aborted'` immediately, and a mid-wait abort rejects with the same
34
+ * error after uncounting the waiter. Settlement releases waiters before any
35
+ * abort can fire, so a job that finishes during the settle grace returns its
36
+ * real result through the normal path.
37
+ * - A rejected wait makes the tool `execute` throw; the registry converts tool
38
+ * throws into `isError` results (`toolErrorResult`), so the wrapper sees a
39
+ * resolved error result — never a thrown one.
40
+ * - `Agent.inbox.nextStep` is a public readonly view of the pending steering
41
+ * queue (`runtime-types.ts`); this plugin only reads `.length` per poll.
42
+ *
43
+ * Replacement semantics: when the wrapper's own trigger fired AND the tool
44
+ * result is an error (i.e. the wait was aborted, not completed), the error
45
+ * result is replaced with a structured `STEER_PREEMPTED` result that tells the
46
+ * model the job is still running and a user message is queued. A genuine
47
+ * success (the job settled inside the grace window) always passes through
48
+ * untouched.
49
+ *
50
+ * @module @argszero/cordis-plugin-steer-preempt
51
+ */
52
+ import { Context } from '@deepseek-ai/cordis';
53
+ import z from '@deepseek-ai/schemastery';
54
+ /** Structured error code of the replacement result (mirrors `TOOL_TIMEOUT`). */
55
+ export const STEER_PREEMPTED = 'STEER_PREEMPTED';
56
+ /** Cordis plugin name used by loader diagnostics. */
57
+ export const name = 'steer-preempt';
58
+ /** The tool registry service this plugin wraps (`tools/execute`). */
59
+ export const inject = ['tools'];
60
+ export const Config = z.object({
61
+ pollMs: z.number().min(20).max(5000).default(200),
62
+ graceMs: z.number().min(0).max(30000).default(1000),
63
+ });
64
+ /** The only tool this plugin preempts in v0.1: a blocking background-job read. */
65
+ const BLOCKING_TOOL = 'job_output';
66
+ function jobIdOf(args) {
67
+ if (args === null || typeof args !== 'object')
68
+ return '';
69
+ const id = args.job_id;
70
+ return typeof id === 'string' ? id : '';
71
+ }
72
+ function isBlockingWait(args) {
73
+ return args !== null && typeof args === 'object' && args.wait === true;
74
+ }
75
+ /** The structured result substituted when this wrapper's abort wins the race. */
76
+ function preemptedResult(jobId) {
77
+ const id = jobId.length > 0 ? ` ${jobId}` : '';
78
+ const message = `Blocking wait for job${id} was interrupted because a new user message arrived. `
79
+ + `The job keeps running in the background — a non-blocking job_output read returns its current state. `
80
+ + `The user message is queued and will be processed when this turn ends; do not start another `
81
+ + `blocking job_output wait.`;
82
+ return {
83
+ content: [{ type: 'text', text: `Error: ${message}` }],
84
+ isError: true,
85
+ error: { message, info: { name: 'SteerPreemptedError', code: STEER_PREEMPTED } },
86
+ };
87
+ }
88
+ function sleep(ms) {
89
+ return new Promise(resolve => setTimeout(resolve, ms));
90
+ }
91
+ /**
92
+ * Watch the agent's pending next-step queue and abort `controller` once a
93
+ * steer has been pending for `graceMs` (polling every `pollMs`). `isDone`
94
+ * returns true as soon as the delegated dispatch settled, which stops the
95
+ * monitor without aborting — a finished wait keeps its real result.
96
+ */
97
+ async function monitorSteers(hasPendingSteer, controller, pollMs, graceMs, isDone) {
98
+ // Phase 1: wait for a steer to appear (or the dispatch to settle).
99
+ while (!isDone() && !hasPendingSteer()) {
100
+ await sleep(pollMs);
101
+ }
102
+ if (isDone())
103
+ return;
104
+ // Phase 2: steer pending — let a nearly-settled job finish within graceMs.
105
+ const deadline = Date.now() + graceMs;
106
+ while (!isDone()) {
107
+ if (Date.now() >= deadline) {
108
+ controller.abort();
109
+ return;
110
+ }
111
+ await sleep(pollMs);
112
+ }
113
+ }
114
+ /**
115
+ * The pure around-dispatch logic, exported separately from {@link apply} so it
116
+ * is testable without a live Cordis context. Returns the dispatch result to
117
+ * pass on (either the tool's own result or the structured replacement).
118
+ */
119
+ export async function runWrapper(config, exec, next) {
120
+ // Only a blocking job read holds the step for up to the wait cap. Everything
121
+ // else (and every agent-less execution) delegates unchanged.
122
+ if (exec.name !== BLOCKING_TOOL || exec.agent === undefined)
123
+ return next();
124
+ const nextStep = exec.agent.inbox?.nextStep;
125
+ if (nextStep === undefined)
126
+ return next();
127
+ const args = exec.arguments;
128
+ if (!isBlockingWait(args))
129
+ return next();
130
+ const jobId = jobIdOf(args);
131
+ // Derived signal: aborting it interrupts the tool's wait without touching the
132
+ // caller/turn signal (the registry fuses replacements with the caller signal).
133
+ const controller = new AbortController();
134
+ let done = false;
135
+ const isDone = () => done;
136
+ const hasPendingSteer = () => nextStep.length > 0;
137
+ void monitorSteers(hasPendingSteer, controller, config.pollMs, config.graceMs, isDone);
138
+ const upstream = exec.signal;
139
+ exec.signal = controller.signal;
140
+ try {
141
+ const result = await next();
142
+ // Stop the monitor BEFORE reading controller state so a success that raced
143
+ // the abort is never replaced.
144
+ done = true;
145
+ if (controller.signal.aborted && result.isError) {
146
+ return preemptedResult(jobId);
147
+ }
148
+ return result;
149
+ }
150
+ finally {
151
+ done = true;
152
+ controller.abort();
153
+ exec.signal = upstream;
154
+ }
155
+ }
156
+ /**
157
+ * Register the wrapper. {@link runWrapper}'s structural {@link WrapperExec} is a
158
+ * subset of the registry's own `ToolDispatchExecution`, so the registration is
159
+ * a plain delegate with no casts.
160
+ */
161
+ export function apply(ctx, config) {
162
+ ctx.on('tools/execute', (exec, next) => runWrapper(config, exec, next));
163
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Steer preemption for the dsh harness.
3
+ *
4
+ * Closes an agent-loop latency gap that #6030 describes: while the agent blocks
5
+ * in a `job_output(wait: true)` read (which waits up to the tool-jobs configured
6
+ * cap — the tool-jobs maximum is 600,000 ms), a user steer
7
+ * (`Agent.send(input, 'next-step')`, i.e. the session-controller's insert path)
8
+ * only *queues* into `Agent.inbox.nextStep`; the running step never looks at it,
9
+ * and the queue is consumed only at the next step boundary (`preStep` claim in
10
+ * the agent loop). So a mid-wait steer is ignored until the wait expires — up to
11
+ * ten minutes of the user's message sitting unread.
12
+ *
13
+ * This plugin is the community-side fix. It wraps `tools/execute` (the same
14
+ * around-dispatch seam the in-tree `guard/timeout-policy` uses). When the called
15
+ * tool is a **blocking** `job_output` read, it swaps in a derived `AbortSignal`
16
+ * for the duration of the dispatch, polls the agent's public
17
+ * `inbox.nextStep` queue (read-only — it never consumes anything; the loop's
18
+ * `preStep` claim owns consumption), and aborts the derived signal once a
19
+ * pending steer has survived a short settle grace. The abort makes the tool
20
+ * wait return early — the background job is **not** killed (the wait logic
21
+ * uncounts the waiter and rejects; the job keeps running) — so the step ends,
22
+ * the loop reaches its step boundary, and the queued steer is honored promptly.
23
+ *
24
+ * Mechanism notes (verified against packages/core/tools/src/index.ts,
25
+ * packages/jobs/jobs-local/src/index.ts, packages/jobs/tool-jobs/src/index.ts
26
+ * and packages/core/agent/src/runtime-types.ts on dsh 0.1.5-alpha.1):
27
+ * - `tools/execute` is a Cordis waterfall; a wrapper may replace `exec.signal`
28
+ * for its delegated lifetime and the registry fuses every replacement with
29
+ * the captured caller signal (`ToolDispatchExecution` doc). The caller/turn
30
+ * signal is never touched, so a real cancel keeps its semantics.
31
+ * - `job_output(wait: true)` calls `jobs.wait(id, timeout, exec.agent,
32
+ * exec.signal)`; `jobs-local` honors the signal: a pre-aborted signal throws
33
+ * `'wait aborted'` immediately, and a mid-wait abort rejects with the same
34
+ * error after uncounting the waiter. Settlement releases waiters before any
35
+ * abort can fire, so a job that finishes during the settle grace returns its
36
+ * real result through the normal path.
37
+ * - A rejected wait makes the tool `execute` throw; the registry converts tool
38
+ * throws into `isError` results (`toolErrorResult`), so the wrapper sees a
39
+ * resolved error result — never a thrown one.
40
+ * - `Agent.inbox.nextStep` is a public readonly view of the pending steering
41
+ * queue (`runtime-types.ts`); this plugin only reads `.length` per poll.
42
+ *
43
+ * Replacement semantics: when the wrapper's own trigger fired AND the tool
44
+ * result is an error (i.e. the wait was aborted, not completed), the error
45
+ * result is replaced with a structured `STEER_PREEMPTED` result that tells the
46
+ * model the job is still running and a user message is queued. A genuine
47
+ * success (the job settled inside the grace window) always passes through
48
+ * untouched.
49
+ *
50
+ * @module @argszero/cordis-plugin-steer-preempt
51
+ */
52
+ import { Context } from '@deepseek-ai/cordis';
53
+ import z from '@deepseek-ai/schemastery';
54
+ import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools';
55
+ /** Structured error code of the replacement result (mirrors `TOOL_TIMEOUT`). */
56
+ export declare const STEER_PREEMPTED = "STEER_PREEMPTED";
57
+ /** Cordis plugin name used by loader diagnostics. */
58
+ export declare const name = "steer-preempt";
59
+ /** The tool registry service this plugin wraps (`tools/execute`). */
60
+ export declare const inject: string[];
61
+ /** Plugin configuration. */
62
+ export interface Config {
63
+ /**
64
+ * Poll interval for the agent's pending next-step queue while a blocking
65
+ * job read is active. Default `200` ms.
66
+ */
67
+ pollMs?: number;
68
+ /**
69
+ * Settle grace: once a pending steer is observed, how long to keep waiting
70
+ * before aborting the read, so a job that finishes right about then still
71
+ * returns its real result. Default `1000` ms.
72
+ */
73
+ graceMs?: number;
74
+ }
75
+ /** Resolved config: every field carries its validated default. */
76
+ export type ResolvedConfig = Required<Config>;
77
+ export declare const Config: z<Config>;
78
+ /** Structural view of the exec object this wrapper needs. */
79
+ export interface WrapperExec {
80
+ readonly name: string;
81
+ readonly arguments: unknown;
82
+ readonly agent?: {
83
+ readonly inbox?: {
84
+ readonly nextStep?: readonly unknown[];
85
+ };
86
+ };
87
+ signal: AbortSignal;
88
+ }
89
+ /** Structural view of the delegated dispatch (`next()`). */
90
+ export type WrapperNext = () => Promise<ToolExecutionResult>;
91
+ /**
92
+ * The pure around-dispatch logic, exported separately from {@link apply} so it
93
+ * is testable without a live Cordis context. Returns the dispatch result to
94
+ * pass on (either the tool's own result or the structured replacement).
95
+ */
96
+ export declare function runWrapper(config: ResolvedConfig, exec: WrapperExec, next: WrapperNext): Promise<ToolExecutionResult>;
97
+ /**
98
+ * Register the wrapper. {@link runWrapper}'s structural {@link WrapperExec} is a
99
+ * subset of the registry's own `ToolDispatchExecution`, so the registration is
100
+ * a plain delegate with no casts.
101
+ */
102
+ export declare function apply(ctx: Context, config: ResolvedConfig): void;
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@argszero/cordis-plugin-steer-preempt",
3
+ "description": "Steer preemption for dsh: a new user input (next-step steer) 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 step boundary and honors the steer. The background job is never killed. Compatible with dsh 0.1.5-alpha.1 (job_output wait + Agent.inbox.nextStep).",
4
+ "version": "0.1.0",
5
+ "type": "module",
6
+ "main": "lib/index.js",
7
+ "types": "lib/types/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/types/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ },
13
+ "./src/*": "./src/*",
14
+ "./package.json": "./package.json"
15
+ },
16
+ "files": [
17
+ "lib/index.js",
18
+ "lib/types/**/*.d.ts",
19
+ "cordis.patch.yml",
20
+ "README.md",
21
+ "LICENSE"
22
+ ],
23
+ "license": "MIT",
24
+ "keywords": [
25
+ "cordis",
26
+ "deepseek-harness",
27
+ "dsh",
28
+ "plugin",
29
+ "steer",
30
+ "preempt",
31
+ "jobs",
32
+ "wait"
33
+ ],
34
+ "dsh": {
35
+ "bundle": {
36
+ "patch": "./cordis.patch.yml"
37
+ }
38
+ },
39
+ "peerDependencies": {
40
+ "@deepseek-ai/cordis": "^4.0.2",
41
+ "@deepseek-ai/dsh-tools": ">=0.1.2"
42
+ },
43
+ "dependencies": {
44
+ "@deepseek-ai/schemastery": "^3.18.1"
45
+ },
46
+ "devDependencies": {
47
+ "@deepseek-ai/cordis": "^4.0.2",
48
+ "@deepseek-ai/dsh-agent": "0.1.5-alpha.1",
49
+ "@deepseek-ai/dsh-tools": "0.1.5-alpha.1",
50
+ "@types/node": "^26.5.0",
51
+ "typescript": "^5.5.0"
52
+ },
53
+ "scripts": {
54
+ "build": "tsc",
55
+ "test": "tsc && node --test \"test/*.test.js\"",
56
+ "prepublishOnly": "tsc"
57
+ }
58
+ }