@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 +21 -0
- package/README.md +104 -0
- package/cordis.patch.yml +14 -0
- package/lib/index.js +163 -0
- package/lib/types/index.d.ts +102 -0
- package/package.json +58 -0
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
|
package/cordis.patch.yml
ADDED
|
@@ -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
|
+
}
|