@argszero/cordis-plugin-steer-preempt 0.1.0 → 0.1.2

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/README.md CHANGED
@@ -37,8 +37,13 @@ a `tools/execute` around-wrapper (the same seam the in-tree
37
37
  a real turn cancel keeps its semantics — the caller signal is never touched).
38
38
  3. A monitor polls the agent's public `inbox.nextStep` queue (read-only — it
39
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.
40
+ **user** steer has been pending for a short **settle grace** (default 1 s), it
41
+ aborts the derived signal. The trigger is a per-message
42
+ `message.source.kind === 'user'` test, **not** a bare queue-length check: the
43
+ `nextStep` queue also carries plugin-injected notifications (`agent.inject`,
44
+ e.g. a job-B completion notice while the model waits on job A, with
45
+ `source.kind === 'plugin'`). A plugin notice is not user input, so it must
46
+ not fire a user-steer preemption.
42
47
  4. The abort makes `jobs.wait` reject (`'wait aborted'`); the wait logic
43
48
  **uncounts the waiter but leaves the job running**. The registry converts the
44
49
  tool throw into an error result.
@@ -81,6 +86,17 @@ No config is required. Optional tuning (via a `set` layer):
81
86
  | `pollMs` | `200` | Poll interval for the pending next-step queue while a blocking read is active |
82
87
  | `graceMs` | `1000` | Settle grace: after a steer is observed, how long to let a nearly-done job finish before aborting |
83
88
 
89
+ ## Changelog
90
+
91
+ - **v0.1.1**: preemption trigger narrowed from `nextStep.length > 0` to a
92
+ per-message `message.source.kind === 'user'` test. Fixes a false preemption
93
+ when a plugin-injected notification (`agent.inject`, e.g. a job-B completion
94
+ notice with `source.kind === 'plugin'`) sits in the same `nextStep` queue as a
95
+ real user steer. Regression tests added. (Community feedback, discussion
96
+ #6030.)
97
+ - **v0.1.0**: initial release — preempt a blocking `job_output(wait: true)` read
98
+ when a user steer is queued.
99
+
84
100
  ## What it does not do
85
101
 
86
102
  - It does **not** kill or cancel the background job — the job keeps running and
package/lib/index.js CHANGED
@@ -16,11 +16,21 @@
16
16
  * for the duration of the dispatch, polls the agent's public
17
17
  * `inbox.nextStep` queue (read-only — it never consumes anything; the loop's
18
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
19
+ * pending **user** steer has survived a short settle grace. The abort makes the
20
+ * tool wait return early — the background job is **not** killed (the wait logic
21
21
  * uncounts the waiter and rejects; the job keeps running) — so the step ends,
22
22
  * the loop reaches its step boundary, and the queued steer is honored promptly.
23
23
  *
24
+ * Refinement (v0.1.1, #6030 feedback): the `nextStep` queue is shared by two
25
+ * producers — `agent.steer(input)` (user-initiated, `send('next-step', true)`)
26
+ * and `agent.inject(input)` (plugin-initiated notification, e.g. a job-B
27
+ * completion notice while the model waits on job A; `send('next-step', false)`).
28
+ * `Agent.inbox.nextStep` cannot distinguish them, so the plugin must filter by
29
+ * `message.source.kind === 'user'`. Counting `nextStep.length` alone would
30
+ * falsely preempt a job-A wait when a job-B completion notice is injected into
31
+ * the same queue — but that notice is not user input, so no user-steer abort
32
+ * should fire. The trigger is now a per-message `source.kind === 'user'` test.
33
+ *
24
34
  * Mechanism notes (verified against packages/core/tools/src/index.ts,
25
35
  * packages/jobs/jobs-local/src/index.ts, packages/jobs/tool-jobs/src/index.ts
26
36
  * and packages/core/agent/src/runtime-types.ts on dsh 0.1.5-alpha.1):
@@ -88,6 +98,16 @@ function preemptedResult(jobId) {
88
98
  function sleep(ms) {
89
99
  return new Promise(resolve => setTimeout(resolve, ms));
90
100
  }
101
+ /**
102
+ * True when the pending next-step queue contains a *user-initiated* steer
103
+ * (`message.source.kind === 'user'`). Plugin-injected notifications (job
104
+ * completion notices, `source.kind === 'plugin'`) share the same queue but must
105
+ * NOT trigger a user-steer preemption — a second job finishing while the model
106
+ * waits on first job is not user input.
107
+ */
108
+ function hasUserSteer(queue) {
109
+ return queue.some(message => message.source?.kind === 'user');
110
+ }
91
111
  /**
92
112
  * Watch the agent's pending next-step queue and abort `controller` once a
93
113
  * steer has been pending for `graceMs` (polling every `pollMs`). `isDone`
@@ -133,7 +153,7 @@ export async function runWrapper(config, exec, next) {
133
153
  const controller = new AbortController();
134
154
  let done = false;
135
155
  const isDone = () => done;
136
- const hasPendingSteer = () => nextStep.length > 0;
156
+ const hasPendingSteer = () => hasUserSteer(nextStep);
137
157
  void monitorSteers(hasPendingSteer, controller, config.pollMs, config.graceMs, isDone);
138
158
  const upstream = exec.signal;
139
159
  exec.signal = controller.signal;
@@ -16,11 +16,21 @@
16
16
  * for the duration of the dispatch, polls the agent's public
17
17
  * `inbox.nextStep` queue (read-only — it never consumes anything; the loop's
18
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
19
+ * pending **user** steer has survived a short settle grace. The abort makes the
20
+ * tool wait return early — the background job is **not** killed (the wait logic
21
21
  * uncounts the waiter and rejects; the job keeps running) — so the step ends,
22
22
  * the loop reaches its step boundary, and the queued steer is honored promptly.
23
23
  *
24
+ * Refinement (v0.1.1, #6030 feedback): the `nextStep` queue is shared by two
25
+ * producers — `agent.steer(input)` (user-initiated, `send('next-step', true)`)
26
+ * and `agent.inject(input)` (plugin-initiated notification, e.g. a job-B
27
+ * completion notice while the model waits on job A; `send('next-step', false)`).
28
+ * `Agent.inbox.nextStep` cannot distinguish them, so the plugin must filter by
29
+ * `message.source.kind === 'user'`. Counting `nextStep.length` alone would
30
+ * falsely preempt a job-A wait when a job-B completion notice is injected into
31
+ * the same queue — but that notice is not user input, so no user-steer abort
32
+ * should fire. The trigger is now a per-message `source.kind === 'user'` test.
33
+ *
24
34
  * Mechanism notes (verified against packages/core/tools/src/index.ts,
25
35
  * packages/jobs/jobs-local/src/index.ts, packages/jobs/tool-jobs/src/index.ts
26
36
  * and packages/core/agent/src/runtime-types.ts on dsh 0.1.5-alpha.1):
@@ -81,11 +91,23 @@ export interface WrapperExec {
81
91
  readonly arguments: unknown;
82
92
  readonly agent?: {
83
93
  readonly inbox?: {
84
- readonly nextStep?: readonly unknown[];
94
+ readonly nextStep?: readonly UserMessageLike[];
85
95
  };
86
96
  };
87
97
  signal: AbortSignal;
88
98
  }
99
+ /**
100
+ * Minimal structural view of a queued next-step entry. Only `source.kind` is
101
+ * inspected: a user-initiated steer carries `source.kind === 'user'`; a
102
+ * plugin-injected notification (e.g. a job completion notice) carries
103
+ * `source.kind === 'plugin'`. This is how the plugin distinguishes a real user
104
+ * steer from an injected context artifact sharing the same queue.
105
+ */
106
+ export interface UserMessageLike {
107
+ readonly source?: {
108
+ readonly kind?: string;
109
+ };
110
+ }
89
111
  /** Structural view of the delegated dispatch (`next()`). */
90
112
  export type WrapperNext = () => Promise<ToolExecutionResult>;
91
113
  /**
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@argszero/cordis-plugin-steer-preempt",
3
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",
4
+ "version": "0.1.2",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
7
7
  "types": "lib/types/index.d.ts",
@@ -38,7 +38,7 @@
38
38
  },
39
39
  "peerDependencies": {
40
40
  "@deepseek-ai/cordis": "^4.0.2",
41
- "@deepseek-ai/dsh-tools": ">=0.1.2"
41
+ "@deepseek-ai/dsh-tools": ">=0.1.2-rc.1 <0.2.0 || >=0.1.5-alpha.1 <0.2.0"
42
42
  },
43
43
  "dependencies": {
44
44
  "@deepseek-ai/schemastery": "^3.18.1"