@argszero/dsh-cancel-cause-guard 0.0.0-stage → 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 +206 -2
- package/cordis.patch.yml +59 -0
- package/lib/cause.js +199 -0
- package/lib/config.js +59 -0
- package/lib/guard.js +139 -0
- package/lib/index.js +161 -0
- package/lib/types/cause.d.ts +114 -0
- package/lib/types/config.d.ts +25 -0
- package/lib/types/guard.d.ts +80 -0
- package/lib/types/index.d.ts +101 -0
- package/lib/types/types.d.ts +66 -0
- package/lib/types.js +6 -0
- package/package.json +72 -4
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
CHANGED
|
@@ -1,3 +1,207 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @argszero/dsh-cancel-cause-guard
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A prevention guard for the one `turn/end` row that makes a whole conversation view
|
|
4
|
+
unreadable.
|
|
5
|
+
|
|
6
|
+
The harness ships an `Agent.cancel(cause)` whose argument is a **closed** union —
|
|
7
|
+
`{ kind: 'user' }`, `{ kind: 'parent' }`, `{ kind: 'hook', reason }`,
|
|
8
|
+
`{ kind: 'disposed' }` — and that union is enforced by TypeScript alone. Nothing on
|
|
9
|
+
the write path refuses a value outside it, and one such value does not merely log
|
|
10
|
+
something odd: it ends the session's usefulness. This plugin normalizes the
|
|
11
|
+
out-of-union value at the last moment the harness still lets a plugin see it, so the
|
|
12
|
+
turn ends with a readable reason instead of `null`.
|
|
13
|
+
|
|
14
|
+
## The defect
|
|
15
|
+
|
|
16
|
+
`ReactLoopAgent.cancel` passes its argument straight to `AbortController.abort()`, so
|
|
17
|
+
the cause becomes `signal.reason`. The turn's `catch` reads it back through
|
|
18
|
+
`abortedCancelCause` (`dsh-agent-loop/src/agent.ts:80`):
|
|
19
|
+
|
|
20
|
+
```ts
|
|
21
|
+
const cause = signal.reason as AgentCancelCause
|
|
22
|
+
switch (cause.kind) {
|
|
23
|
+
case 'user': case 'parent': case 'disposed': return { kind: cause.kind }
|
|
24
|
+
case 'hook': return { kind: 'hook', reason: cause.reason }
|
|
25
|
+
default: return assertNever(cause)
|
|
26
|
+
}
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
That function is called **inside the `catch`**. A value outside the union therefore
|
|
30
|
+
throws before the catch body assigns `turnEnds`, which is still at its `null`
|
|
31
|
+
initializer, and the `finally` appends unconditionally:
|
|
32
|
+
|
|
33
|
+
```ts
|
|
34
|
+
let turnEnds: TurnEndReason | null = null // agent.ts:310
|
|
35
|
+
...
|
|
36
|
+
} finally {
|
|
37
|
+
this.session.append('turn/end', { turn, reason: turnEnds! }) // agent.ts:385
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The non-null assertion on that line is what makes it compile, and the row lands as
|
|
42
|
+
`{"type":"turn/end","data":{"turn":N,"reason":null}}`. Nothing rejects it on the way:
|
|
43
|
+
the append-time validator inspects surface message slots, `request/header` and
|
|
44
|
+
`tool/result`, so `reason` is a TypeScript-only invariant. Every reader that
|
|
45
|
+
dereferences it then throws — the chat client does `location.turn.end?.data.reason.kind`
|
|
46
|
+
while rendering — and one such row blanks the entire conversation area, for every
|
|
47
|
+
session, including newly created ones.
|
|
48
|
+
|
|
49
|
+
Reported three times: discussions **#8832**, **#9156** and **#9646**.
|
|
50
|
+
|
|
51
|
+
### The version boundary
|
|
52
|
+
|
|
53
|
+
A harness older than `0.1.7` had no such function. The turn's `catch` wrote
|
|
54
|
+
`{ kind: 'aborted', reason: signal.reason }` verbatim, so the same abuse produced a
|
|
55
|
+
malformed-but-readable row rather than a `null` one. From `0.1.7-rc.2` on, the same
|
|
56
|
+
abuse is fatal — which is why this is a **guard** and not a report: the shape of the
|
|
57
|
+
failure changed under callers that had not changed at all.
|
|
58
|
+
|
|
59
|
+
## What it does
|
|
60
|
+
|
|
61
|
+
It listens to `agent/created` — the serial, awaited lifecycle edge that hands over the
|
|
62
|
+
**live agent instance** (`dsh-agent/src/index.ts:550`) — and shadows that instance's
|
|
63
|
+
`cancel` with a wrapper that normalizes anything outside the union into
|
|
64
|
+
`{ kind: 'hook', reason: <the value's own name> }` before the loop ever sees it.
|
|
65
|
+
|
|
66
|
+
- **`hook` is the right member, not a convenient one.** It is the member the union
|
|
67
|
+
reserves for plugins: a plugin did cancel the turn, and the reason names which one.
|
|
68
|
+
- **One property write covers every instance-level caller.** `ReactLoopAgent.cancel`
|
|
69
|
+
is a prototype method, so an **own** property of that name shadows it for the loop's
|
|
70
|
+
own `agent.cancel({ kind: 'user' })`, the chat client's stop button, and any
|
|
71
|
+
third-party plugin alike.
|
|
72
|
+
- **A valid cause is forwarded by reference.** The object the loop reads back out of
|
|
73
|
+
the signal is the object the caller passed; nothing is copied and nothing is
|
|
74
|
+
reported.
|
|
75
|
+
- **A cancellation is never refused.** The wrapper normalizes, forwards and reports,
|
|
76
|
+
in that order, and touches nothing but the cause — a plugin that stopped a stop
|
|
77
|
+
would be worse than the defect.
|
|
78
|
+
- **Two shapes are covered, and only one of them looks like a mistake.** An object
|
|
79
|
+
whose `kind` is missing or unknown (`{ kind: 'thinking-loop' }`), and a value that
|
|
80
|
+
is not an object at all — most often `undefined`, from a caller whose own
|
|
81
|
+
declaration made the argument optional, where `cause.kind` throws one line earlier.
|
|
82
|
+
- **The wrapper binds the original to the agent it came from**, so a caller holding a
|
|
83
|
+
detached `const cancel = agent.cancel` still reaches the method with the receiver
|
|
84
|
+
its body requires.
|
|
85
|
+
|
|
86
|
+
Every rewrite is logged with the offending value rendered as JSON, so the producer can
|
|
87
|
+
be found and fixed rather than tolerated.
|
|
88
|
+
|
|
89
|
+
## Install
|
|
90
|
+
|
|
91
|
+
```sh
|
|
92
|
+
npm install @argszero/dsh-cancel-cause-guard
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Then mount it from your profile's bundle patch (`dsh.bundle.patch`):
|
|
96
|
+
|
|
97
|
+
```yaml
|
|
98
|
+
- insert:
|
|
99
|
+
- id: cancel-cause-guard
|
|
100
|
+
name: '@argszero/dsh-cancel-cause-guard'
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Or, if you compose `cordis.yml` by hand:
|
|
104
|
+
|
|
105
|
+
```yaml
|
|
106
|
+
- set:
|
|
107
|
+
- id: cancel-cause-guard
|
|
108
|
+
name: '@argszero/dsh-cancel-cause-guard'
|
|
109
|
+
config:
|
|
110
|
+
mode: normalize
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
## Configuration
|
|
114
|
+
|
|
115
|
+
| Key | Default | Meaning |
|
|
116
|
+
|---|---|---|
|
|
117
|
+
| `mode` | `'normalize'` | `normalize` rewrites an out-of-union cause to `{ kind: 'hook', reason }`; `observe` passes the caller's value through untouched and only records the producer. |
|
|
118
|
+
| `reason` | absent | Reason text to record in place of the value's own name. Absent names the cause after the value that produced it — the only handle on whoever produced it. |
|
|
119
|
+
| `maxReasonChars` | `200` | Bound on the minted reason, in characters. |
|
|
120
|
+
|
|
121
|
+
A configuration the plugin cannot honour is refused **at mount** rather than quietly
|
|
122
|
+
replaced: an unknown key, a mode this plugin does not have, a `reason` that would name
|
|
123
|
+
nothing, or a bound that is not a positive integer. A misspelled `mode` that mounts and
|
|
124
|
+
guards nothing is how an operator believes a guard is in force when it is not.
|
|
125
|
+
|
|
126
|
+
## Diagnostics
|
|
127
|
+
|
|
128
|
+
One warning per rewritten (or, in `observe` mode, merely reported) cause:
|
|
129
|
+
|
|
130
|
+
```
|
|
131
|
+
cancel-cause-guard: session <id>: a caller passed "thinking-loop" as a cancellation
|
|
132
|
+
cause, which is not a member of AgentCancelCause; the turn was recorded as
|
|
133
|
+
{ "kind": "aborted", "reason": { "kind": "hook", "reason": "thinking-loop" } }.
|
|
134
|
+
Which plugin passes it is the value itself: the reason carries its own name.
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
`observe` mode says the value was passed through instead, so the operator sees the
|
|
138
|
+
real failure and the log does not claim a repair.
|
|
139
|
+
|
|
140
|
+
Two refusals are warned about at the moment they matter, because a guard that is
|
|
141
|
+
silently absent is the thing an operator cannot see:
|
|
142
|
+
|
|
143
|
+
- an agent that carries no `cancel` method at all;
|
|
144
|
+
- an agent that refuses an own `cancel` property (a sealed or non-writable instance),
|
|
145
|
+
where the install is confirmed by **reading the property back**, not by the absence
|
|
146
|
+
of a throw.
|
|
147
|
+
|
|
148
|
+
## What it is not
|
|
149
|
+
|
|
150
|
+
- **It cannot repair a session that already carries a `null` row.** Those rows are on
|
|
151
|
+
disk and this plugin reads no history. Mounting it bounds the defect from that point
|
|
152
|
+
on; the sessions that already have one stay unreadable until the row is removed.
|
|
153
|
+
- **It does not cover a caller that reaches the class rather than the instance** —
|
|
154
|
+
`ReactLoopAgent.prototype.cancel.call(agent, …)`, or a second copy of the loop.
|
|
155
|
+
Nothing observable hands that path over, which is why `mode: 'observe'` exists: an
|
|
156
|
+
operator who wants to see the failure happen may mount this plugin as a reporter and
|
|
157
|
+
still get the producer named in the log.
|
|
158
|
+
- **It does not patch the union away.** Values that are already members are neither
|
|
159
|
+
copied, rewritten, nor reported.
|
|
160
|
+
- **It calls no model and needs no service.** `inject` is empty: the whole seam is the
|
|
161
|
+
agent instance the lifecycle edge delivers.
|
|
162
|
+
|
|
163
|
+
## Evidence
|
|
164
|
+
|
|
165
|
+
- `test/lifecycle.spec.mjs` — the plugin mounted through a real Cordis context, against
|
|
166
|
+
the real `AgentRegistry` and the real `agent/created` dispatch: the guard is installed
|
|
167
|
+
by the time the announce dispatch settles, it does not touch an agent published before
|
|
168
|
+
the mount, unmounting restores the instance (both when the fiber disposes and when the
|
|
169
|
+
agent does), and `world({ mounted: false })` is the control arm for each.
|
|
170
|
+
- `test/guard.spec.mjs` — the mechanism: an own property over a prototype method, the
|
|
171
|
+
options handed through untouched, the original bound to its agent, the mark a second
|
|
172
|
+
mount recognises, the undo that reveals the prototype method again rather than copying
|
|
173
|
+
it in, and every refusal.
|
|
174
|
+
- `test/cause.spec.mjs` — the pure half: what each shape becomes, what it is called, the
|
|
175
|
+
bound, and the values that must never throw inside `cancel`.
|
|
176
|
+
- `test/config.spec.mjs` — the defaults, detaching, and the five refusals.
|
|
177
|
+
- `scripts/witness-cancel-cause.mjs` — the consequence, proved against the **published**
|
|
178
|
+
`@deepseek-ai/dsh-agent-loop` sources rather than a stand-in: it extracts
|
|
179
|
+
`abortedCancelCause` and `assertNever` from the shipped tarball, shows a bare-string
|
|
180
|
+
cause throwing `unreachable variant: "thinking-loop"` unguarded and returning
|
|
181
|
+
`{ kind: 'hook', reason: 'thinking-loop' }` guarded, and records the `0.1.6-alpha.2`
|
|
182
|
+
boundary where no such function exists. Run it with
|
|
183
|
+
`npm run witness:cancel-cause`.
|
|
184
|
+
- `npm run test:inject` — mutates the source, rebuilds, and requires each mutation to be
|
|
185
|
+
caught by the suite. An arm whose mutation leaves the suite green proves nothing about
|
|
186
|
+
the test that was supposed to hold the line, and is reported as `SILENT`.
|
|
187
|
+
- `npm run probe:lines` — runs the suite once per released line, each with every harness
|
|
188
|
+
package pinned to that line. That measurement is where the supported-lines list below
|
|
189
|
+
comes from.
|
|
190
|
+
|
|
191
|
+
## Supported lines
|
|
192
|
+
|
|
193
|
+
The suite is measured green against each of these, with `peerDependencies` restricted to
|
|
194
|
+
the same three:
|
|
195
|
+
|
|
196
|
+
| Harness line | Cordis series |
|
|
197
|
+
|---|---|
|
|
198
|
+
| `0.1.7-rc.2` | `4.0.4` |
|
|
199
|
+
| `0.2.0-rc.2` | `4.0.4` |
|
|
200
|
+
| `0.2.1-alpha.2` | `~4.0.5-alpha.1` |
|
|
201
|
+
|
|
202
|
+
Earlier generations are **not** covered and are not claimed: `0.1.6-alpha.2` and below
|
|
203
|
+
have no `abortedCancelCause`, so there is nothing for this plugin to prevent there.
|
|
204
|
+
|
|
205
|
+
## License
|
|
206
|
+
|
|
207
|
+
MIT.
|
package/cordis.patch.yml
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
# The @argszero/dsh-cancel-cause-guard bundle patch: mount and go.
|
|
2
|
+
#
|
|
3
|
+
# One `turn/end` row can make a whole conversation view unreadable. When a caller
|
|
4
|
+
# cancels a turn with a value outside the closed AgentCancelCause union — a bare
|
|
5
|
+
# string such as `agent.cancel('thinking-loop')`, or an object whose `kind` the
|
|
6
|
+
# union does not have — ReactLoopAgent.cancel hands it to `AbortController.abort()`,
|
|
7
|
+
# so it becomes `signal.reason`. The turn's `catch` reads it back through
|
|
8
|
+
# `abortedCancelCause`, whose switch ends in `assertNever` **inside the catch body**:
|
|
9
|
+
# the assignment never runs, `turnEnds` stays at its `null` initializer, and the
|
|
10
|
+
# unconditional `finally` appends `turn/end` with that `null`
|
|
11
|
+
# (`dsh-agent-loop/src/agent.ts:385`, and the non-null assertion on the line is what
|
|
12
|
+
# makes it compile).
|
|
13
|
+
#
|
|
14
|
+
# Nothing on the write path refuses the row — the append-time validator inspects
|
|
15
|
+
# surface message slots, `request/header` and `tool/result`, and `turn/end.reason` is
|
|
16
|
+
# a TypeScript-only invariant. Every reader that dereferences it then throws, and the
|
|
17
|
+
# chat client does exactly that while rendering, so one such row blanks the
|
|
18
|
+
# conversation area. Reported three times: discussions #8832, #9156 and #9646.
|
|
19
|
+
#
|
|
20
|
+
# A harness older than `0.1.7` had no such function and recorded the same abuse as a
|
|
21
|
+
# malformed-but-readable row, which is why this is a guard: the failure changed shape
|
|
22
|
+
# under callers that had not changed at all.
|
|
23
|
+
#
|
|
24
|
+
# This plugin closes the seam that is left. It listens to `agent/created` — the
|
|
25
|
+
# serial, awaited lifecycle edge that hands over the **live agent instance**
|
|
26
|
+
# (`dsh-agent/src/index.ts:550`) — and shadows that instance's `cancel` with a wrapper
|
|
27
|
+
# that carries anything outside the union as `{ kind: 'hook', reason: <the value's own
|
|
28
|
+
# name> }` before the loop ever sees it. `hook` is the member the union reserves for
|
|
29
|
+
# plugins, so the rewrite is not a lie: a plugin did cancel the turn, and the reason
|
|
30
|
+
# names which one. One own-property write covers every instance-level caller, because
|
|
31
|
+
# `ReactLoopAgent.cancel` is a prototype method.
|
|
32
|
+
#
|
|
33
|
+
# It never refuses a cancellation, forwards an already-valid cause by reference, and
|
|
34
|
+
# cannot repair rows already on disk (it reads no history). Mounting it bounds the
|
|
35
|
+
# defect from that point on.
|
|
36
|
+
#
|
|
37
|
+
# Optional config:
|
|
38
|
+
#
|
|
39
|
+
# - set:
|
|
40
|
+
# - id: cancel-cause-guard
|
|
41
|
+
# config:
|
|
42
|
+
# # 'normalize' (default) rewrites an out-of-union cause to
|
|
43
|
+
# # { kind: 'hook', reason }. 'observe' passes the caller's value through
|
|
44
|
+
# # untouched — the turn fails exactly as it would have — and only records
|
|
45
|
+
# # the producer in the log.
|
|
46
|
+
# mode: normalize
|
|
47
|
+
# # Reason text to record in place of the offending value's own name.
|
|
48
|
+
# # Absent (the default) names the cause after the value that produced it,
|
|
49
|
+
# # which is the only handle on whoever produced it.
|
|
50
|
+
# # reason: plugin-bug
|
|
51
|
+
# # Bound on the minted reason, in characters. Defaults to 200.
|
|
52
|
+
# maxReasonChars: 200
|
|
53
|
+
#
|
|
54
|
+
# An unknown key is refused rather than ignored: a misspelled `mode` that mounts and
|
|
55
|
+
# guards nothing is how an operator believes a guard is in force when it is not.
|
|
56
|
+
|
|
57
|
+
- insert:
|
|
58
|
+
- id: cancel-cause-guard
|
|
59
|
+
name: '@argszero/dsh-cancel-cause-guard'
|
package/lib/cause.js
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The closed union the harness writes into `turn/end`, and the normalization
|
|
3
|
+
* that makes any other value a member of it.
|
|
4
|
+
*
|
|
5
|
+
* ## Why a value outside the union is not a cosmetic problem
|
|
6
|
+
*
|
|
7
|
+
* `ReactLoopAgent`'s turn body reads the cancellation cause back out of the
|
|
8
|
+
* signal and switches on it (`abortedCancelCause`, `agent-loop/src/agent.ts:80`):
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* const cause = signal.reason as AgentCancelCause
|
|
12
|
+
* switch (cause.kind) {
|
|
13
|
+
* case 'user': case 'parent': case 'disposed': return { kind: cause.kind }
|
|
14
|
+
* case 'hook': return { kind: 'hook', reason: cause.reason }
|
|
15
|
+
* default: return assertNever(cause)
|
|
16
|
+
* }
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* That function is called **inside the turn's `catch`**. A value outside the
|
|
20
|
+
* union therefore throws before the catch body assigns `turnEnds`, which is
|
|
21
|
+
* still its `null` initializer, and the `finally` appends unconditionally:
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* let turnEnds: TurnEndReason | null = null // agent.ts:310
|
|
25
|
+
* ...
|
|
26
|
+
* } finally {
|
|
27
|
+
* this.session.append('turn/end', { turn, reason: turnEnds! }) // agent.ts:385
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* The row lands on disk as `{"type":"turn/end","data":{"turn":N,"reason":null}}`.
|
|
32
|
+
* Nothing rejects it on the way: the append-time validator inspects surface
|
|
33
|
+
* message slots, `request/header` and `tool/result` only, so this field is a
|
|
34
|
+
* TypeScript-only invariant. Readers then dereference it — the chat client does
|
|
35
|
+
* `location.turn.end?.data.reason.kind`
|
|
36
|
+
* (`ui-chat/src/client/contract/turn-process.ts:72`) — and one such row throws
|
|
37
|
+
* while the conversation view renders, which blanks the whole chat area.
|
|
38
|
+
* Reported three times: discussions #8832, #9156 and #9646.
|
|
39
|
+
*
|
|
40
|
+
* ## Two shapes reach it, and only one of them looks like a mistake
|
|
41
|
+
*
|
|
42
|
+
* - an object whose `kind` is missing or unknown (`{ kind: 'thinking-loop' }`),
|
|
43
|
+
* which `assertNever` refuses; and
|
|
44
|
+
* - a value that is not an object at all — most often `undefined`, from a
|
|
45
|
+
* caller whose own declaration made the argument optional. `cause.kind` on
|
|
46
|
+
* `undefined` throws just as effectively, one line earlier.
|
|
47
|
+
*
|
|
48
|
+
* Both are normalized here. `hook` is the member the union reserves for
|
|
49
|
+
* plugins, so a normalized cause says what actually happened: a plugin
|
|
50
|
+
* cancelled the turn, and the reason names which one.
|
|
51
|
+
*
|
|
52
|
+
* @module @argszero/dsh-cancel-cause-guard/cause
|
|
53
|
+
*/
|
|
54
|
+
/** Bound on a minted reason when the configuration names none. */
|
|
55
|
+
export const DEFAULT_MAX_REASON_CHARS = 200;
|
|
56
|
+
/** The reason text standing in for a cause that carried no name at all. */
|
|
57
|
+
export const UNSPECIFIED_REASON = 'unspecified';
|
|
58
|
+
/** The union member a value from outside the union is carried under. */
|
|
59
|
+
export const CARRIER_KIND = 'hook';
|
|
60
|
+
/**
|
|
61
|
+
* Whether a value is already exactly one of the four union members.
|
|
62
|
+
*
|
|
63
|
+
* `hook` is held to the stricter test: its `reason` is part of the member's
|
|
64
|
+
* contract, and a `hook` whose reason is not a string is normalized rather than
|
|
65
|
+
* passed through, so that everything downstream of this plugin can rely on the
|
|
66
|
+
* field being text.
|
|
67
|
+
* @param cause - the value a caller passed to `Agent.cancel`.
|
|
68
|
+
* @returns whether the value can be recorded as-is.
|
|
69
|
+
*/
|
|
70
|
+
export function isCancelCause(cause) {
|
|
71
|
+
switch (readKind(cause)) {
|
|
72
|
+
case 'user':
|
|
73
|
+
case 'parent':
|
|
74
|
+
case 'disposed':
|
|
75
|
+
return true;
|
|
76
|
+
case 'hook':
|
|
77
|
+
return typeof readField(cause, 'reason') === 'string';
|
|
78
|
+
default:
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Turn any cancellation cause into a member of the closed union.
|
|
84
|
+
*
|
|
85
|
+
* A value that is already a member is returned **unchanged** (same reference),
|
|
86
|
+
* so the common path allocates nothing and an `{ kind: 'user' }` a caller may
|
|
87
|
+
* still be holding is the object the loop reads back.
|
|
88
|
+
* @param cause - the value a caller passed to `Agent.cancel`.
|
|
89
|
+
* @param options - the reason override and the reason bound.
|
|
90
|
+
* @returns a member of `AgentCancelCause`.
|
|
91
|
+
*/
|
|
92
|
+
export function normalizeCancelCause(cause, options = {}) {
|
|
93
|
+
if (isCancelCause(cause))
|
|
94
|
+
return cause;
|
|
95
|
+
const limit = options.maxReasonChars ?? DEFAULT_MAX_REASON_CHARS;
|
|
96
|
+
if (options.reason !== undefined && options.reason.length > 0) {
|
|
97
|
+
return { kind: CARRIER_KIND, reason: truncate(options.reason, limit) };
|
|
98
|
+
}
|
|
99
|
+
if (readKind(cause) === CARRIER_KIND) {
|
|
100
|
+
// The member is right; only its reason field is not text.
|
|
101
|
+
return { kind: CARRIER_KIND, reason: reasonText(readField(cause, 'reason'), limit) };
|
|
102
|
+
}
|
|
103
|
+
return { kind: CARRIER_KIND, reason: reasonText(cause, limit) };
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The reason text for one value, preferring whatever name it carried.
|
|
107
|
+
*
|
|
108
|
+
* The preference order is deliberate: a caller that passed
|
|
109
|
+
* `{ kind: 'thinking-loop' }` meant the token `thinking-loop`, and a reason
|
|
110
|
+
* reading `thinking-loop` is more useful to the person reading the session than
|
|
111
|
+
* the JSON it was embedded in.
|
|
112
|
+
* @param value - the value to name, or the reason field of a `hook` cause.
|
|
113
|
+
* @param maxChars - bound on the result.
|
|
114
|
+
* @returns a non-empty, bounded string.
|
|
115
|
+
*/
|
|
116
|
+
export function reasonText(value, maxChars = DEFAULT_MAX_REASON_CHARS) {
|
|
117
|
+
if (typeof value === 'string') {
|
|
118
|
+
return value.trim().length === 0 ? UNSPECIFIED_REASON : truncate(value, maxChars);
|
|
119
|
+
}
|
|
120
|
+
if (value === undefined || value === null)
|
|
121
|
+
return UNSPECIFIED_REASON;
|
|
122
|
+
const named = carriedKind(value);
|
|
123
|
+
if (named !== undefined)
|
|
124
|
+
return truncate(named, maxChars);
|
|
125
|
+
return truncate(plainText(value), maxChars);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* A descriptive rendering of a value for a log line.
|
|
129
|
+
*
|
|
130
|
+
* Separate from {@link reasonText} because the log is read by whoever has to
|
|
131
|
+
* find the producer, and there the JSON of the offending value is the useful
|
|
132
|
+
* form — including the `undefined` that {@link reasonText} deliberately hides
|
|
133
|
+
* behind `unspecified`.
|
|
134
|
+
* @param value - the value to describe.
|
|
135
|
+
* @param maxChars - bound on the result.
|
|
136
|
+
* @returns a bounded, single-line rendering.
|
|
137
|
+
*/
|
|
138
|
+
export function describeValue(value, maxChars = DEFAULT_MAX_REASON_CHARS) {
|
|
139
|
+
if (value === undefined)
|
|
140
|
+
return 'undefined';
|
|
141
|
+
if (value === null)
|
|
142
|
+
return 'null';
|
|
143
|
+
if (typeof value === 'string')
|
|
144
|
+
return truncate(JSON.stringify(value), maxChars);
|
|
145
|
+
return truncate(plainText(value), maxChars);
|
|
146
|
+
}
|
|
147
|
+
/** The `kind` a value carries, when that is a non-empty string. */
|
|
148
|
+
function carriedKind(value) {
|
|
149
|
+
const kind = readKind(value);
|
|
150
|
+
return typeof kind === 'string' && kind.trim().length > 0 ? kind : undefined;
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* One field of a value, read without letting a hostile accessor escape.
|
|
154
|
+
*
|
|
155
|
+
* It runs inside `cancel`: a guard that threw on the way to naming a cause would
|
|
156
|
+
* abort the turn in the guard rather than in its caller, which is strictly worse
|
|
157
|
+
* than the defect. A value whose `kind` cannot be read is therefore treated as a
|
|
158
|
+
* value with no `kind` at all.
|
|
159
|
+
*/
|
|
160
|
+
function readField(value, field) {
|
|
161
|
+
if (typeof value !== 'object' || value === null)
|
|
162
|
+
return undefined;
|
|
163
|
+
try {
|
|
164
|
+
return value[field];
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
/** The `kind` a value carries, read through {@link readField}. */
|
|
171
|
+
function readKind(value) {
|
|
172
|
+
return readField(value, 'kind');
|
|
173
|
+
}
|
|
174
|
+
/** A string for any value, never throwing on the ones JSON refuses. */
|
|
175
|
+
function plainText(value) {
|
|
176
|
+
if (typeof value === 'function')
|
|
177
|
+
return `function ${value.name ?? ''}`.trim();
|
|
178
|
+
try {
|
|
179
|
+
const json = JSON.stringify(value);
|
|
180
|
+
if (typeof json === 'string' && json.length > 0)
|
|
181
|
+
return json;
|
|
182
|
+
}
|
|
183
|
+
catch {
|
|
184
|
+
// A circular structure, a BigInt, or a throwing `toJSON`.
|
|
185
|
+
}
|
|
186
|
+
try {
|
|
187
|
+
return String(value);
|
|
188
|
+
}
|
|
189
|
+
catch {
|
|
190
|
+
// A proxy or an object whose `toString` is itself hostile. This module runs
|
|
191
|
+
// inside `cancel`, and a guard that threw while naming a cause would be the
|
|
192
|
+
// defect it exists to prevent.
|
|
193
|
+
return UNSPECIFIED_REASON;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
/** Bound one reason so a hostile value cannot write a wall of text into the log. */
|
|
197
|
+
function truncate(text, maxChars) {
|
|
198
|
+
return text.length <= maxChars ? text : `${text.slice(0, maxChars - 1)}…`;
|
|
199
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration resolution for the cancellation-cause guard.
|
|
3
|
+
*
|
|
4
|
+
* The surface is deliberately three fields. A guard that rewrites a value on a
|
|
5
|
+
* path as hot as cancellation has to be readable in one sitting, and every knob
|
|
6
|
+
* it does not have is a way for an operator to be surprised by what it did.
|
|
7
|
+
*
|
|
8
|
+
* An unknown key is refused rather than ignored: a misspelled `mode` that mounts
|
|
9
|
+
* and guards nothing is how an operator believes a guard is in force when it is
|
|
10
|
+
* not — the same refusal `dsh-tool-output-budget` makes, for the same reason.
|
|
11
|
+
*
|
|
12
|
+
* @module @argszero/dsh-cancel-cause-guard/config
|
|
13
|
+
*/
|
|
14
|
+
import { DEFAULT_MAX_REASON_CHARS, UNSPECIFIED_REASON } from "./cause.js";
|
|
15
|
+
/** Defaults for a guard whose only job is to keep a session readable. */
|
|
16
|
+
export const DEFAULTS = Object.freeze({
|
|
17
|
+
mode: 'normalize',
|
|
18
|
+
reason: undefined,
|
|
19
|
+
maxReasonChars: DEFAULT_MAX_REASON_CHARS,
|
|
20
|
+
});
|
|
21
|
+
const CONFIG_KEYS = new Set(['mode', 'reason', 'maxReasonChars']);
|
|
22
|
+
const MODES = new Set(['normalize', 'observe']);
|
|
23
|
+
/**
|
|
24
|
+
* Resolve, validate and detach the guard configuration.
|
|
25
|
+
* @param config - raw plugin configuration, possibly empty.
|
|
26
|
+
* @returns a detached, frozen configuration.
|
|
27
|
+
* @throws {Error} when a key is unknown, a mode is not one this plugin has, a
|
|
28
|
+
* reason is not non-empty text, or the bound is not a positive integer.
|
|
29
|
+
*/
|
|
30
|
+
export function resolveConfig(config = {}) {
|
|
31
|
+
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
|
|
32
|
+
throw new Error('cancel-cause-guard config: must be an object');
|
|
33
|
+
}
|
|
34
|
+
for (const key of Object.keys(config)) {
|
|
35
|
+
if (!CONFIG_KEYS.has(key)) {
|
|
36
|
+
throw new Error(`cancel-cause-guard config: unknown key "${key}" `
|
|
37
|
+
+ '(allowed: mode, reason, maxReasonChars)');
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const mode = config.mode ?? DEFAULTS.mode;
|
|
41
|
+
if (typeof mode !== 'string' || !MODES.has(mode)) {
|
|
42
|
+
throw new Error('cancel-cause-guard config: mode must be "normalize" or "observe"');
|
|
43
|
+
}
|
|
44
|
+
const maxReasonChars = config.maxReasonChars ?? DEFAULTS.maxReasonChars;
|
|
45
|
+
if (!Number.isSafeInteger(maxReasonChars) || maxReasonChars < 1) {
|
|
46
|
+
throw new Error('cancel-cause-guard config: maxReasonChars must be a positive integer');
|
|
47
|
+
}
|
|
48
|
+
// A reason override of `''` would make the normalized cause say nothing at
|
|
49
|
+
// all, which is the one outcome a *reason* field exists to prevent. `undefined`
|
|
50
|
+
// is the "name it after the value the caller passed" default; an empty string
|
|
51
|
+
// is a mistake, and is reported as one.
|
|
52
|
+
const reason = config.reason;
|
|
53
|
+
if (reason !== undefined && (typeof reason !== 'string' || reason.trim().length === 0)) {
|
|
54
|
+
throw new Error(`cancel-cause-guard config: reason must be non-empty text, or absent to name the cause `
|
|
55
|
+
+ `after the value that produced it (the default resolves to "${UNSPECIFIED_REASON}" only `
|
|
56
|
+
+ 'for a cause that carried no name)');
|
|
57
|
+
}
|
|
58
|
+
return Object.freeze({ mode, reason, maxReasonChars });
|
|
59
|
+
}
|
package/lib/guard.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One agent's `cancel`, shadowed so that a cause from outside the closed union
|
|
3
|
+
* never reaches the loop's `assertNever`.
|
|
4
|
+
*
|
|
5
|
+
* ## Why the seam is the instance
|
|
6
|
+
*
|
|
7
|
+
* There is no event on the cancellation path and no hook in the append-time
|
|
8
|
+
* validator: `validateSessionEventData` inspects surface message slots,
|
|
9
|
+
* `request/header` and `tool/result`, and `turn/end.reason` is a TypeScript-only
|
|
10
|
+
* invariant, so nothing on the write path can refuse the row that poisons a
|
|
11
|
+
* session. What the plugin does have is the live agent instance, handed over by
|
|
12
|
+
* `agent/created` — a `ctx.serial` dispatch that `AgentRegistry.announce`
|
|
13
|
+
* **awaits** (`packages/core/agent/src/index.ts:550`), so a listener that
|
|
14
|
+
* settles before its first turn can still change how that agent behaves:
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* await this.ctx.serial(entry.carrier, 'agent/created', { agent: entry.agent, source, signal })
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* `ReactLoopAgent.cancel` is a prototype method (`agent-loop/src/agent.ts:175`),
|
|
21
|
+
* so an **own** property of that name shadows it for every instance-level
|
|
22
|
+
* caller — the loop's own `agent.cancel({ kind: 'user' })`
|
|
23
|
+
* (`agent/src/archive-admission.ts:36`, `agent-loop/src/index.ts:789`), the chat
|
|
24
|
+
* client's stop button, and any third-party plugin. All of them are covered by
|
|
25
|
+
* one property write, which is why this is a guard rather than a per-producer
|
|
26
|
+
* patch.
|
|
27
|
+
*
|
|
28
|
+
* ## What the wrapper does, and what it deliberately does not
|
|
29
|
+
*
|
|
30
|
+
* It normalizes, forwards, and reports — in that order, and nothing else. It
|
|
31
|
+
* does not refuse a cancellation (a plugin that stopped a stop would be worse
|
|
32
|
+
* than the defect), does not touch the options, and does not inspect or rewrite
|
|
33
|
+
* anything but the cause. A value that is already a union member is forwarded
|
|
34
|
+
* **by reference**, so the object the loop reads back out of the signal is the
|
|
35
|
+
* object the caller passed.
|
|
36
|
+
*
|
|
37
|
+
* The wrapper binds the original to the agent it came from. A caller holding a
|
|
38
|
+
* detached `const cancel = agent.cancel` would otherwise have reached the
|
|
39
|
+
* original with no receiver at all; binding it is the reading the method's own
|
|
40
|
+
* body requires (`this.phase`, `this.inbox`), and it makes the shadow more
|
|
41
|
+
* faithful than the property it replaces.
|
|
42
|
+
*
|
|
43
|
+
* @module @argszero/dsh-cancel-cause-guard/guard
|
|
44
|
+
*/
|
|
45
|
+
import { describeValue, isCancelCause, normalizeCancelCause } from "./cause.js";
|
|
46
|
+
/**
|
|
47
|
+
* The brand an installed wrapper carries.
|
|
48
|
+
*
|
|
49
|
+
* `Symbol.for` rather than a module-local symbol: two copies of this plugin
|
|
50
|
+
* loaded into one process — the plugin mounted by two profile entries, or a
|
|
51
|
+
* second copy pulled in beside the first — would otherwise each install a
|
|
52
|
+
* wrapper, and the inner one would normalize a cause the outer had already
|
|
53
|
+
* normalized. With a registry-wide key the second mount recognises the first
|
|
54
|
+
* and leaves it alone.
|
|
55
|
+
*/
|
|
56
|
+
export const GUARD_MARK = Symbol.for('argszero.dsh-cancel-cause-guard/guarded');
|
|
57
|
+
/**
|
|
58
|
+
* Shadow one agent's `cancel` with a normalizing wrapper.
|
|
59
|
+
*
|
|
60
|
+
* Every refusal is reported as an {@link GuardOutcome} rather than thrown: an
|
|
61
|
+
* agent this plugin could not guard is a session that keeps the defect, not a
|
|
62
|
+
* reason to fail the lifecycle edge that delivered it.
|
|
63
|
+
* @param agent - the live agent instance, as handed over by `agent/created`.
|
|
64
|
+
* @param options - the guard's behaviour and its report callback.
|
|
65
|
+
* @returns the outcome and an undo.
|
|
66
|
+
*/
|
|
67
|
+
export function guardCancel(agent, options) {
|
|
68
|
+
const current = agent.cancel;
|
|
69
|
+
if (typeof current !== 'function')
|
|
70
|
+
return { outcome: 'no-cancel', restore: () => { } };
|
|
71
|
+
if (current[GUARD_MARK] === true) {
|
|
72
|
+
return { outcome: 'already-guarded', restore: () => { } };
|
|
73
|
+
}
|
|
74
|
+
const original = current;
|
|
75
|
+
// Whether the property was the instance's own before the guard: a prototype
|
|
76
|
+
// method must be revealed again by deleting the shadow, an own property by
|
|
77
|
+
// writing the old value back. Writing the prototype method back as an own
|
|
78
|
+
// property would look like a successful restore while changing the object.
|
|
79
|
+
const hadOwn = Object.prototype.hasOwnProperty.call(agent, 'cancel');
|
|
80
|
+
const wrapper = function guardedCancel(cause, cancelOptions) {
|
|
81
|
+
const decided = decide(cause, options);
|
|
82
|
+
Reflect.apply(original, agent, [decided, cancelOptions]);
|
|
83
|
+
};
|
|
84
|
+
wrapper[GUARD_MARK] = true;
|
|
85
|
+
try {
|
|
86
|
+
;
|
|
87
|
+
agent.cancel = wrapper;
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
// A sealed instance, or a `cancel` the agent defined without a setter.
|
|
91
|
+
return { outcome: 'not-writable', restore: () => { } };
|
|
92
|
+
}
|
|
93
|
+
// A refusal to write is not always a throw — a frozen object in sloppy mode
|
|
94
|
+
// would simply ignore it — so the install is confirmed by reading it back.
|
|
95
|
+
if (agent.cancel !== wrapper) {
|
|
96
|
+
return { outcome: 'not-writable', restore: () => { } };
|
|
97
|
+
}
|
|
98
|
+
let installed = true;
|
|
99
|
+
return {
|
|
100
|
+
outcome: 'patched',
|
|
101
|
+
restore: () => {
|
|
102
|
+
if (!installed)
|
|
103
|
+
return;
|
|
104
|
+
installed = false;
|
|
105
|
+
// Only unwind a shadow that is still ours: a later guard, or the agent
|
|
106
|
+
// itself, may have replaced it, and undoing that would be a third party's
|
|
107
|
+
// state being written by this one.
|
|
108
|
+
if (agent.cancel !== wrapper)
|
|
109
|
+
return;
|
|
110
|
+
if (hadOwn)
|
|
111
|
+
agent.cancel = original;
|
|
112
|
+
else
|
|
113
|
+
delete agent.cancel;
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
/**
|
|
118
|
+
* The cause to forward, and the report that says why.
|
|
119
|
+
* @param cause - the value the caller passed.
|
|
120
|
+
* @param options - the guard's behaviour and its report callback.
|
|
121
|
+
* @returns the value to hand the original `cancel`.
|
|
122
|
+
*/
|
|
123
|
+
function decide(cause, options) {
|
|
124
|
+
if (isCancelCause(cause))
|
|
125
|
+
return cause;
|
|
126
|
+
const normalized = normalizeCancelCause(cause, {
|
|
127
|
+
reason: options.reason,
|
|
128
|
+
maxReasonChars: options.maxReasonChars,
|
|
129
|
+
});
|
|
130
|
+
options.onNormalized({
|
|
131
|
+
original: cause,
|
|
132
|
+
normalized: normalized,
|
|
133
|
+
described: describeValue(cause, options.maxReasonChars),
|
|
134
|
+
});
|
|
135
|
+
// In `observe` mode the report is the whole contribution: the caller's value
|
|
136
|
+
// goes through untouched, so the turn fails exactly as it would have, and the
|
|
137
|
+
// only difference is that the log now names what produced it.
|
|
138
|
+
return options.mode === 'observe' ? cause : normalized;
|
|
139
|
+
}
|
package/lib/index.js
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A prevention guard for the one `turn/end` row that makes a whole conversation
|
|
3
|
+
* view unreadable.
|
|
4
|
+
*
|
|
5
|
+
* ## The defect
|
|
6
|
+
*
|
|
7
|
+
* A plugin calls `agent.cancel(cause)` with a value that is not a member of the
|
|
8
|
+
* closed `AgentCancelCause` union — a bare string such as
|
|
9
|
+
* `agent.cancel('thinking-loop')`, or an object whose `kind` the union does not
|
|
10
|
+
* have. `ReactLoopAgent.cancel` passes it straight to
|
|
11
|
+
* `AbortController.abort(cause)`, so the value becomes `signal.reason`; the
|
|
12
|
+
* turn's `catch` then reads it back through `abortedCancelCause`, whose `switch`
|
|
13
|
+
* ends in `assertNever` — **inside the catch body**. The assignment never runs,
|
|
14
|
+
* `turnEnds` stays at its `null` initializer, and the unconditional `finally`
|
|
15
|
+
* appends `turn/end` with that `null` (`agent-loop/src/agent.ts:385`; the
|
|
16
|
+
* non-null assertion on the line is what makes it compile).
|
|
17
|
+
*
|
|
18
|
+
* Nothing on the write path refuses the row: the append-time validator inspects
|
|
19
|
+
* surface message slots, `request/header` and `tool/result`, and
|
|
20
|
+
* `turn/end.reason` is a TypeScript-only invariant. Every reader that
|
|
21
|
+
* dereferences it then throws — `ui-chat`'s conversation view does
|
|
22
|
+
* `location.turn.end?.data.reason.kind` while rendering — and one such row
|
|
23
|
+
* blanks the entire chat area, for every session, including newly created ones.
|
|
24
|
+
*
|
|
25
|
+
* ## The version boundary
|
|
26
|
+
*
|
|
27
|
+
* A harness older than `0.1.7` had no such function: the turn's `catch` wrote
|
|
28
|
+
* `{ kind: 'aborted', reason: signal.reason }` verbatim, so the same abuse
|
|
29
|
+
* produced a malformed-but-readable row rather than a `null` one. From
|
|
30
|
+
* `0.1.7-rc.2` on, the same abuse is fatal. That is why this is a guard and not
|
|
31
|
+
* a report: the shape of the failure changed under callers that had not changed
|
|
32
|
+
* at all.
|
|
33
|
+
*
|
|
34
|
+
* ## What this plugin does
|
|
35
|
+
*
|
|
36
|
+
* It listens to `agent/created` — the serial, awaited lifecycle edge that hands
|
|
37
|
+
* over the **live agent instance** — and shadows that instance's `cancel` with a
|
|
38
|
+
* wrapper that normalizes anything outside the union into
|
|
39
|
+
* `{ kind: 'hook', reason: <the value's own name> }` before the loop ever sees
|
|
40
|
+
* it. `hook` is the union member the harness reserves for plugins, so the
|
|
41
|
+
* rewrite is not a lie: a plugin did cancel the turn, and the reason names which
|
|
42
|
+
* one. One property write covers every instance-level caller — the loop's own
|
|
43
|
+
* `agent.cancel({ kind: 'user' })`, the client's stop button, and any
|
|
44
|
+
* third-party plugin — because `ReactLoopAgent.cancel` is a prototype method and
|
|
45
|
+
* the shadow is an own property.
|
|
46
|
+
*
|
|
47
|
+
* Every rewrite is logged, with the offending value rendered as JSON, so the
|
|
48
|
+
* producer can be found and fixed rather than merely tolerated.
|
|
49
|
+
*
|
|
50
|
+
* ## What it does not do
|
|
51
|
+
*
|
|
52
|
+
* It does not refuse a cancellation, does not touch the options, does not
|
|
53
|
+
* rewrite a cause that is already a member (that one is forwarded by reference),
|
|
54
|
+
* and cannot repair a session that already carries a `null` row — those rows are
|
|
55
|
+
* on disk, and this plugin reads no history. Mounting it bounds the defect from
|
|
56
|
+
* that point on.
|
|
57
|
+
*
|
|
58
|
+
* It also cannot cover the one caller that reaches the class rather than the
|
|
59
|
+
* instance, which is why `mode: 'observe'` exists: an operator who wants to see
|
|
60
|
+
* the failure happen may mount this plugin as a reporter and still get the
|
|
61
|
+
* producer named in the log.
|
|
62
|
+
*
|
|
63
|
+
* @module @argszero/dsh-cancel-cause-guard
|
|
64
|
+
*/
|
|
65
|
+
import { resolveConfig } from "./config.js";
|
|
66
|
+
import { guardCancel } from "./guard.js";
|
|
67
|
+
export { DEFAULT_MAX_REASON_CHARS, describeValue, isCancelCause, normalizeCancelCause, reasonText, UNSPECIFIED_REASON } from "./cause.js";
|
|
68
|
+
export { DEFAULTS, resolveConfig } from "./config.js";
|
|
69
|
+
export { GUARD_MARK, guardCancel } from "./guard.js";
|
|
70
|
+
/** Cordis plugin name, used by loader diagnostics. */
|
|
71
|
+
export const name = 'cancel-cause-guard';
|
|
72
|
+
/**
|
|
73
|
+
* No service is required.
|
|
74
|
+
*
|
|
75
|
+
* The whole seam is the agent instance the lifecycle edge hands over, and asking
|
|
76
|
+
* for a service this plugin does not use would only be a way for it to fail to
|
|
77
|
+
* mount — so the list is empty rather than absent, which keeps the declaration
|
|
78
|
+
* explicit for a reader and identical between the two export forms.
|
|
79
|
+
*/
|
|
80
|
+
export const inject = [];
|
|
81
|
+
/** The prefix of every diagnostic this plugin logs. */
|
|
82
|
+
const LOG_PREFIX = 'cancel-cause-guard:';
|
|
83
|
+
/**
|
|
84
|
+
* Install the guard.
|
|
85
|
+
* @param ctx - the plugin context; the listener and the restore are disposed
|
|
86
|
+
* with it.
|
|
87
|
+
* @param config - the configuration as mounted, possibly empty. Defaults are
|
|
88
|
+
* applied inside `resolveConfig` rather than in a plain object exported under
|
|
89
|
+
* the name `Config`, because the Loader reads that export as a schema and the
|
|
90
|
+
* plugin would then fail to mount.
|
|
91
|
+
* @throws when the configuration names an unknown key or an unusable value; the
|
|
92
|
+
* plugin refuses to mount rather than guarding with rules it cannot honour.
|
|
93
|
+
*/
|
|
94
|
+
export function apply(ctx, config = {}) {
|
|
95
|
+
const settings = resolveConfig(config);
|
|
96
|
+
/** The guards this plugin installed and has not yet unwound, by agent instance. */
|
|
97
|
+
const guards = new Map();
|
|
98
|
+
// Registered before the listeners so that it is unwound *after* them: the
|
|
99
|
+
// fiber's effects are disposed in reverse, and unwinding a shadow while a
|
|
100
|
+
// listener could still install another would leave the last one behind.
|
|
101
|
+
ctx.effect(() => () => {
|
|
102
|
+
for (const handle of guards.values())
|
|
103
|
+
handle.restore();
|
|
104
|
+
guards.clear();
|
|
105
|
+
}, `${LOG_PREFIX}restore()`);
|
|
106
|
+
ctx.on('agent/created', ({ agent }) => {
|
|
107
|
+
const handle = guardCancel(agent, {
|
|
108
|
+
mode: settings.mode,
|
|
109
|
+
reason: settings.reason,
|
|
110
|
+
maxReasonChars: settings.maxReasonChars,
|
|
111
|
+
onNormalized: report => { reportNormalized(ctx, agent.session.id, report, settings.mode); },
|
|
112
|
+
});
|
|
113
|
+
switch (handle.outcome) {
|
|
114
|
+
case 'patched':
|
|
115
|
+
guards.set(agent, handle);
|
|
116
|
+
ctx.logger.debug(`${LOG_PREFIX} agent ${agent.session.id}: cancellation causes outside the union will be `
|
|
117
|
+
+ 'recorded as { kind: "hook", reason } instead of ending the turn with reason null');
|
|
118
|
+
return;
|
|
119
|
+
case 'already-guarded':
|
|
120
|
+
ctx.logger.debug(`${LOG_PREFIX} agent ${agent.session.id}: already guarded by this plugin, leaving the `
|
|
121
|
+
+ 'existing wrapper in place');
|
|
122
|
+
return;
|
|
123
|
+
case 'no-cancel':
|
|
124
|
+
ctx.logger.warn(`${LOG_PREFIX} agent ${agent.session.id} carries no cancel method, so an out-of-union `
|
|
125
|
+
+ 'cause from any plugin it hands itself to will still end its turn with reason null');
|
|
126
|
+
return;
|
|
127
|
+
/* v8 ignore next 4 -- the read-back check makes both write failures one outcome. */
|
|
128
|
+
case 'not-writable':
|
|
129
|
+
ctx.logger.warn(`${LOG_PREFIX} agent ${agent.session.id} refuses an own "cancel" property, so this guard `
|
|
130
|
+
+ 'is NOT in force for it: an out-of-union cause will end its turn with reason null');
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
ctx.on('agent/disposed', ({ agent }) => {
|
|
134
|
+
const handle = guards.get(agent);
|
|
135
|
+
if (handle === undefined)
|
|
136
|
+
return;
|
|
137
|
+
handle.restore();
|
|
138
|
+
guards.delete(agent);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Record one out-of-union cause.
|
|
143
|
+
*
|
|
144
|
+
* A warning rather than an error: what the reader needs is the *name* of the
|
|
145
|
+
* value — the only handle on whoever produced it — and the two modes differ
|
|
146
|
+
* only in what became of it.
|
|
147
|
+
* @param ctx - the plugin context, for logging.
|
|
148
|
+
* @param sessionId - the session whose agent was cancelled.
|
|
149
|
+
* @param report - what was seen, and what it became.
|
|
150
|
+
* @param mode - whether the cause was rewritten or passed through.
|
|
151
|
+
*/
|
|
152
|
+
function reportNormalized(ctx, sessionId, report, mode) {
|
|
153
|
+
const outcome = mode === 'observe'
|
|
154
|
+
? 'it was passed through unchanged (mode "observe"), so this turn ends with reason null'
|
|
155
|
+
: 'the turn was recorded as '
|
|
156
|
+
+ `{ "kind": "aborted", "reason": { "kind": "hook", "reason": ${JSON.stringify(report.normalized.reason)} } }`;
|
|
157
|
+
ctx.logger.warn(`${LOG_PREFIX} session ${sessionId}: a caller passed ${report.described} as a cancellation `
|
|
158
|
+
+ `cause, which is not a member of AgentCancelCause; ${outcome}. `
|
|
159
|
+
+ 'Which plugin passes it is the value itself: the reason carries its own name.');
|
|
160
|
+
}
|
|
161
|
+
export default { name, inject, apply };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The closed union the harness writes into `turn/end`, and the normalization
|
|
3
|
+
* that makes any other value a member of it.
|
|
4
|
+
*
|
|
5
|
+
* ## Why a value outside the union is not a cosmetic problem
|
|
6
|
+
*
|
|
7
|
+
* `ReactLoopAgent`'s turn body reads the cancellation cause back out of the
|
|
8
|
+
* signal and switches on it (`abortedCancelCause`, `agent-loop/src/agent.ts:80`):
|
|
9
|
+
*
|
|
10
|
+
* ```ts
|
|
11
|
+
* const cause = signal.reason as AgentCancelCause
|
|
12
|
+
* switch (cause.kind) {
|
|
13
|
+
* case 'user': case 'parent': case 'disposed': return { kind: cause.kind }
|
|
14
|
+
* case 'hook': return { kind: 'hook', reason: cause.reason }
|
|
15
|
+
* default: return assertNever(cause)
|
|
16
|
+
* }
|
|
17
|
+
* ```
|
|
18
|
+
*
|
|
19
|
+
* That function is called **inside the turn's `catch`**. A value outside the
|
|
20
|
+
* union therefore throws before the catch body assigns `turnEnds`, which is
|
|
21
|
+
* still its `null` initializer, and the `finally` appends unconditionally:
|
|
22
|
+
*
|
|
23
|
+
* ```ts
|
|
24
|
+
* let turnEnds: TurnEndReason | null = null // agent.ts:310
|
|
25
|
+
* ...
|
|
26
|
+
* } finally {
|
|
27
|
+
* this.session.append('turn/end', { turn, reason: turnEnds! }) // agent.ts:385
|
|
28
|
+
* }
|
|
29
|
+
* ```
|
|
30
|
+
*
|
|
31
|
+
* The row lands on disk as `{"type":"turn/end","data":{"turn":N,"reason":null}}`.
|
|
32
|
+
* Nothing rejects it on the way: the append-time validator inspects surface
|
|
33
|
+
* message slots, `request/header` and `tool/result` only, so this field is a
|
|
34
|
+
* TypeScript-only invariant. Readers then dereference it — the chat client does
|
|
35
|
+
* `location.turn.end?.data.reason.kind`
|
|
36
|
+
* (`ui-chat/src/client/contract/turn-process.ts:72`) — and one such row throws
|
|
37
|
+
* while the conversation view renders, which blanks the whole chat area.
|
|
38
|
+
* Reported three times: discussions #8832, #9156 and #9646.
|
|
39
|
+
*
|
|
40
|
+
* ## Two shapes reach it, and only one of them looks like a mistake
|
|
41
|
+
*
|
|
42
|
+
* - an object whose `kind` is missing or unknown (`{ kind: 'thinking-loop' }`),
|
|
43
|
+
* which `assertNever` refuses; and
|
|
44
|
+
* - a value that is not an object at all — most often `undefined`, from a
|
|
45
|
+
* caller whose own declaration made the argument optional. `cause.kind` on
|
|
46
|
+
* `undefined` throws just as effectively, one line earlier.
|
|
47
|
+
*
|
|
48
|
+
* Both are normalized here. `hook` is the member the union reserves for
|
|
49
|
+
* plugins, so a normalized cause says what actually happened: a plugin
|
|
50
|
+
* cancelled the turn, and the reason names which one.
|
|
51
|
+
*
|
|
52
|
+
* @module @argszero/dsh-cancel-cause-guard/cause
|
|
53
|
+
*/
|
|
54
|
+
import type { AgentCancelCause } from '@deepseek-ai/dsh-agent';
|
|
55
|
+
/** Bound on a minted reason when the configuration names none. */
|
|
56
|
+
export declare const DEFAULT_MAX_REASON_CHARS = 200;
|
|
57
|
+
/** The reason text standing in for a cause that carried no name at all. */
|
|
58
|
+
export declare const UNSPECIFIED_REASON = "unspecified";
|
|
59
|
+
/** The union member a value from outside the union is carried under. */
|
|
60
|
+
export declare const CARRIER_KIND = "hook";
|
|
61
|
+
/** How one normalization is parameterized. */
|
|
62
|
+
export interface NormalizeOptions {
|
|
63
|
+
/** Reason text to record, in place of the value's own name. */
|
|
64
|
+
readonly reason?: string | undefined;
|
|
65
|
+
/** Bound on the minted reason text. */
|
|
66
|
+
readonly maxReasonChars?: number | undefined;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Whether a value is already exactly one of the four union members.
|
|
70
|
+
*
|
|
71
|
+
* `hook` is held to the stricter test: its `reason` is part of the member's
|
|
72
|
+
* contract, and a `hook` whose reason is not a string is normalized rather than
|
|
73
|
+
* passed through, so that everything downstream of this plugin can rely on the
|
|
74
|
+
* field being text.
|
|
75
|
+
* @param cause - the value a caller passed to `Agent.cancel`.
|
|
76
|
+
* @returns whether the value can be recorded as-is.
|
|
77
|
+
*/
|
|
78
|
+
export declare function isCancelCause(cause: unknown): cause is AgentCancelCause;
|
|
79
|
+
/**
|
|
80
|
+
* Turn any cancellation cause into a member of the closed union.
|
|
81
|
+
*
|
|
82
|
+
* A value that is already a member is returned **unchanged** (same reference),
|
|
83
|
+
* so the common path allocates nothing and an `{ kind: 'user' }` a caller may
|
|
84
|
+
* still be holding is the object the loop reads back.
|
|
85
|
+
* @param cause - the value a caller passed to `Agent.cancel`.
|
|
86
|
+
* @param options - the reason override and the reason bound.
|
|
87
|
+
* @returns a member of `AgentCancelCause`.
|
|
88
|
+
*/
|
|
89
|
+
export declare function normalizeCancelCause(cause: unknown, options?: NormalizeOptions): AgentCancelCause;
|
|
90
|
+
/**
|
|
91
|
+
* The reason text for one value, preferring whatever name it carried.
|
|
92
|
+
*
|
|
93
|
+
* The preference order is deliberate: a caller that passed
|
|
94
|
+
* `{ kind: 'thinking-loop' }` meant the token `thinking-loop`, and a reason
|
|
95
|
+
* reading `thinking-loop` is more useful to the person reading the session than
|
|
96
|
+
* the JSON it was embedded in.
|
|
97
|
+
* @param value - the value to name, or the reason field of a `hook` cause.
|
|
98
|
+
* @param maxChars - bound on the result.
|
|
99
|
+
* @returns a non-empty, bounded string.
|
|
100
|
+
*/
|
|
101
|
+
export declare function reasonText(value: unknown, maxChars?: number): string;
|
|
102
|
+
/**
|
|
103
|
+
* A descriptive rendering of a value for a log line.
|
|
104
|
+
*
|
|
105
|
+
* Separate from {@link reasonText} because the log is read by whoever has to
|
|
106
|
+
* find the producer, and there the JSON of the offending value is the useful
|
|
107
|
+
* form — including the `undefined` that {@link reasonText} deliberately hides
|
|
108
|
+
* behind `unspecified`.
|
|
109
|
+
* @param value - the value to describe.
|
|
110
|
+
* @param maxChars - bound on the result.
|
|
111
|
+
* @returns a bounded, single-line rendering.
|
|
112
|
+
*/
|
|
113
|
+
export declare function describeValue(value: unknown, maxChars?: number): string;
|
|
114
|
+
//# sourceMappingURL=cause.d.ts.map
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration resolution for the cancellation-cause guard.
|
|
3
|
+
*
|
|
4
|
+
* The surface is deliberately three fields. A guard that rewrites a value on a
|
|
5
|
+
* path as hot as cancellation has to be readable in one sitting, and every knob
|
|
6
|
+
* it does not have is a way for an operator to be surprised by what it did.
|
|
7
|
+
*
|
|
8
|
+
* An unknown key is refused rather than ignored: a misspelled `mode` that mounts
|
|
9
|
+
* and guards nothing is how an operator believes a guard is in force when it is
|
|
10
|
+
* not — the same refusal `dsh-tool-output-budget` makes, for the same reason.
|
|
11
|
+
*
|
|
12
|
+
* @module @argszero/dsh-cancel-cause-guard/config
|
|
13
|
+
*/
|
|
14
|
+
import type { Config, ResolvedConfig } from './types.ts';
|
|
15
|
+
/** Defaults for a guard whose only job is to keep a session readable. */
|
|
16
|
+
export declare const DEFAULTS: ResolvedConfig;
|
|
17
|
+
/**
|
|
18
|
+
* Resolve, validate and detach the guard configuration.
|
|
19
|
+
* @param config - raw plugin configuration, possibly empty.
|
|
20
|
+
* @returns a detached, frozen configuration.
|
|
21
|
+
* @throws {Error} when a key is unknown, a mode is not one this plugin has, a
|
|
22
|
+
* reason is not non-empty text, or the bound is not a positive integer.
|
|
23
|
+
*/
|
|
24
|
+
export declare function resolveConfig(config?: Config): ResolvedConfig;
|
|
25
|
+
//# sourceMappingURL=config.d.ts.map
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One agent's `cancel`, shadowed so that a cause from outside the closed union
|
|
3
|
+
* never reaches the loop's `assertNever`.
|
|
4
|
+
*
|
|
5
|
+
* ## Why the seam is the instance
|
|
6
|
+
*
|
|
7
|
+
* There is no event on the cancellation path and no hook in the append-time
|
|
8
|
+
* validator: `validateSessionEventData` inspects surface message slots,
|
|
9
|
+
* `request/header` and `tool/result`, and `turn/end.reason` is a TypeScript-only
|
|
10
|
+
* invariant, so nothing on the write path can refuse the row that poisons a
|
|
11
|
+
* session. What the plugin does have is the live agent instance, handed over by
|
|
12
|
+
* `agent/created` — a `ctx.serial` dispatch that `AgentRegistry.announce`
|
|
13
|
+
* **awaits** (`packages/core/agent/src/index.ts:550`), so a listener that
|
|
14
|
+
* settles before its first turn can still change how that agent behaves:
|
|
15
|
+
*
|
|
16
|
+
* ```ts
|
|
17
|
+
* await this.ctx.serial(entry.carrier, 'agent/created', { agent: entry.agent, source, signal })
|
|
18
|
+
* ```
|
|
19
|
+
*
|
|
20
|
+
* `ReactLoopAgent.cancel` is a prototype method (`agent-loop/src/agent.ts:175`),
|
|
21
|
+
* so an **own** property of that name shadows it for every instance-level
|
|
22
|
+
* caller — the loop's own `agent.cancel({ kind: 'user' })`
|
|
23
|
+
* (`agent/src/archive-admission.ts:36`, `agent-loop/src/index.ts:789`), the chat
|
|
24
|
+
* client's stop button, and any third-party plugin. All of them are covered by
|
|
25
|
+
* one property write, which is why this is a guard rather than a per-producer
|
|
26
|
+
* patch.
|
|
27
|
+
*
|
|
28
|
+
* ## What the wrapper does, and what it deliberately does not
|
|
29
|
+
*
|
|
30
|
+
* It normalizes, forwards, and reports — in that order, and nothing else. It
|
|
31
|
+
* does not refuse a cancellation (a plugin that stopped a stop would be worse
|
|
32
|
+
* than the defect), does not touch the options, and does not inspect or rewrite
|
|
33
|
+
* anything but the cause. A value that is already a union member is forwarded
|
|
34
|
+
* **by reference**, so the object the loop reads back out of the signal is the
|
|
35
|
+
* object the caller passed.
|
|
36
|
+
*
|
|
37
|
+
* The wrapper binds the original to the agent it came from. A caller holding a
|
|
38
|
+
* detached `const cancel = agent.cancel` would otherwise have reached the
|
|
39
|
+
* original with no receiver at all; binding it is the reading the method's own
|
|
40
|
+
* body requires (`this.phase`, `this.inbox`), and it makes the shadow more
|
|
41
|
+
* faithful than the property it replaces.
|
|
42
|
+
*
|
|
43
|
+
* @module @argszero/dsh-cancel-cause-guard/guard
|
|
44
|
+
*/
|
|
45
|
+
import type { Agent } from '@deepseek-ai/dsh-agent';
|
|
46
|
+
import type { GuardHandle, GuardMode, NormalizedCause } from './types.ts';
|
|
47
|
+
/**
|
|
48
|
+
* The brand an installed wrapper carries.
|
|
49
|
+
*
|
|
50
|
+
* `Symbol.for` rather than a module-local symbol: two copies of this plugin
|
|
51
|
+
* loaded into one process — the plugin mounted by two profile entries, or a
|
|
52
|
+
* second copy pulled in beside the first — would otherwise each install a
|
|
53
|
+
* wrapper, and the inner one would normalize a cause the outer had already
|
|
54
|
+
* normalized. With a registry-wide key the second mount recognises the first
|
|
55
|
+
* and leaves it alone.
|
|
56
|
+
*/
|
|
57
|
+
export declare const GUARD_MARK: unique symbol;
|
|
58
|
+
/** How one guard is told to behave. */
|
|
59
|
+
export interface GuardOptions {
|
|
60
|
+
/** What to do with an out-of-union cause. */
|
|
61
|
+
readonly mode: GuardMode;
|
|
62
|
+
/** Reason text to record in place of the value's own name. */
|
|
63
|
+
readonly reason?: string | undefined;
|
|
64
|
+
/** Bound on the minted reason text. */
|
|
65
|
+
readonly maxReasonChars: number;
|
|
66
|
+
/** Called once per rewritten (or, in `observe` mode, merely reported) cause. */
|
|
67
|
+
readonly onNormalized: (report: NormalizedCause) => void;
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Shadow one agent's `cancel` with a normalizing wrapper.
|
|
71
|
+
*
|
|
72
|
+
* Every refusal is reported as an {@link GuardOutcome} rather than thrown: an
|
|
73
|
+
* agent this plugin could not guard is a session that keeps the defect, not a
|
|
74
|
+
* reason to fail the lifecycle edge that delivered it.
|
|
75
|
+
* @param agent - the live agent instance, as handed over by `agent/created`.
|
|
76
|
+
* @param options - the guard's behaviour and its report callback.
|
|
77
|
+
* @returns the outcome and an undo.
|
|
78
|
+
*/
|
|
79
|
+
export declare function guardCancel(agent: Agent, options: GuardOptions): GuardHandle;
|
|
80
|
+
//# sourceMappingURL=guard.d.ts.map
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A prevention guard for the one `turn/end` row that makes a whole conversation
|
|
3
|
+
* view unreadable.
|
|
4
|
+
*
|
|
5
|
+
* ## The defect
|
|
6
|
+
*
|
|
7
|
+
* A plugin calls `agent.cancel(cause)` with a value that is not a member of the
|
|
8
|
+
* closed `AgentCancelCause` union — a bare string such as
|
|
9
|
+
* `agent.cancel('thinking-loop')`, or an object whose `kind` the union does not
|
|
10
|
+
* have. `ReactLoopAgent.cancel` passes it straight to
|
|
11
|
+
* `AbortController.abort(cause)`, so the value becomes `signal.reason`; the
|
|
12
|
+
* turn's `catch` then reads it back through `abortedCancelCause`, whose `switch`
|
|
13
|
+
* ends in `assertNever` — **inside the catch body**. The assignment never runs,
|
|
14
|
+
* `turnEnds` stays at its `null` initializer, and the unconditional `finally`
|
|
15
|
+
* appends `turn/end` with that `null` (`agent-loop/src/agent.ts:385`; the
|
|
16
|
+
* non-null assertion on the line is what makes it compile).
|
|
17
|
+
*
|
|
18
|
+
* Nothing on the write path refuses the row: the append-time validator inspects
|
|
19
|
+
* surface message slots, `request/header` and `tool/result`, and
|
|
20
|
+
* `turn/end.reason` is a TypeScript-only invariant. Every reader that
|
|
21
|
+
* dereferences it then throws — `ui-chat`'s conversation view does
|
|
22
|
+
* `location.turn.end?.data.reason.kind` while rendering — and one such row
|
|
23
|
+
* blanks the entire chat area, for every session, including newly created ones.
|
|
24
|
+
*
|
|
25
|
+
* ## The version boundary
|
|
26
|
+
*
|
|
27
|
+
* A harness older than `0.1.7` had no such function: the turn's `catch` wrote
|
|
28
|
+
* `{ kind: 'aborted', reason: signal.reason }` verbatim, so the same abuse
|
|
29
|
+
* produced a malformed-but-readable row rather than a `null` one. From
|
|
30
|
+
* `0.1.7-rc.2` on, the same abuse is fatal. That is why this is a guard and not
|
|
31
|
+
* a report: the shape of the failure changed under callers that had not changed
|
|
32
|
+
* at all.
|
|
33
|
+
*
|
|
34
|
+
* ## What this plugin does
|
|
35
|
+
*
|
|
36
|
+
* It listens to `agent/created` — the serial, awaited lifecycle edge that hands
|
|
37
|
+
* over the **live agent instance** — and shadows that instance's `cancel` with a
|
|
38
|
+
* wrapper that normalizes anything outside the union into
|
|
39
|
+
* `{ kind: 'hook', reason: <the value's own name> }` before the loop ever sees
|
|
40
|
+
* it. `hook` is the union member the harness reserves for plugins, so the
|
|
41
|
+
* rewrite is not a lie: a plugin did cancel the turn, and the reason names which
|
|
42
|
+
* one. One property write covers every instance-level caller — the loop's own
|
|
43
|
+
* `agent.cancel({ kind: 'user' })`, the client's stop button, and any
|
|
44
|
+
* third-party plugin — because `ReactLoopAgent.cancel` is a prototype method and
|
|
45
|
+
* the shadow is an own property.
|
|
46
|
+
*
|
|
47
|
+
* Every rewrite is logged, with the offending value rendered as JSON, so the
|
|
48
|
+
* producer can be found and fixed rather than merely tolerated.
|
|
49
|
+
*
|
|
50
|
+
* ## What it does not do
|
|
51
|
+
*
|
|
52
|
+
* It does not refuse a cancellation, does not touch the options, does not
|
|
53
|
+
* rewrite a cause that is already a member (that one is forwarded by reference),
|
|
54
|
+
* and cannot repair a session that already carries a `null` row — those rows are
|
|
55
|
+
* on disk, and this plugin reads no history. Mounting it bounds the defect from
|
|
56
|
+
* that point on.
|
|
57
|
+
*
|
|
58
|
+
* It also cannot cover the one caller that reaches the class rather than the
|
|
59
|
+
* instance, which is why `mode: 'observe'` exists: an operator who wants to see
|
|
60
|
+
* the failure happen may mount this plugin as a reporter and still get the
|
|
61
|
+
* producer named in the log.
|
|
62
|
+
*
|
|
63
|
+
* @module @argszero/dsh-cancel-cause-guard
|
|
64
|
+
*/
|
|
65
|
+
import type { Context } from '@deepseek-ai/cordis';
|
|
66
|
+
import type { Config } from './types.ts';
|
|
67
|
+
export { DEFAULT_MAX_REASON_CHARS, describeValue, isCancelCause, normalizeCancelCause, reasonText, UNSPECIFIED_REASON } from './cause.ts';
|
|
68
|
+
export { DEFAULTS, resolveConfig } from './config.ts';
|
|
69
|
+
export { GUARD_MARK, guardCancel } from './guard.ts';
|
|
70
|
+
export type { GuardOptions } from './guard.ts';
|
|
71
|
+
export type { Config, GuardHandle, GuardMode, GuardOutcome, NormalizedCause, ResolvedConfig, } from './types.ts';
|
|
72
|
+
/** Cordis plugin name, used by loader diagnostics. */
|
|
73
|
+
export declare const name = "cancel-cause-guard";
|
|
74
|
+
/**
|
|
75
|
+
* No service is required.
|
|
76
|
+
*
|
|
77
|
+
* The whole seam is the agent instance the lifecycle edge hands over, and asking
|
|
78
|
+
* for a service this plugin does not use would only be a way for it to fail to
|
|
79
|
+
* mount — so the list is empty rather than absent, which keeps the declaration
|
|
80
|
+
* explicit for a reader and identical between the two export forms.
|
|
81
|
+
*/
|
|
82
|
+
export declare const inject: readonly string[];
|
|
83
|
+
/**
|
|
84
|
+
* Install the guard.
|
|
85
|
+
* @param ctx - the plugin context; the listener and the restore are disposed
|
|
86
|
+
* with it.
|
|
87
|
+
* @param config - the configuration as mounted, possibly empty. Defaults are
|
|
88
|
+
* applied inside `resolveConfig` rather than in a plain object exported under
|
|
89
|
+
* the name `Config`, because the Loader reads that export as a schema and the
|
|
90
|
+
* plugin would then fail to mount.
|
|
91
|
+
* @throws when the configuration names an unknown key or an unusable value; the
|
|
92
|
+
* plugin refuses to mount rather than guarding with rules it cannot honour.
|
|
93
|
+
*/
|
|
94
|
+
export declare function apply(ctx: Context, config?: Config): void;
|
|
95
|
+
declare const _default: {
|
|
96
|
+
name: string;
|
|
97
|
+
inject: readonly string[];
|
|
98
|
+
apply: typeof apply;
|
|
99
|
+
};
|
|
100
|
+
export default _default;
|
|
101
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shapes this plugin's configuration and its guard report take.
|
|
3
|
+
*
|
|
4
|
+
* @module @argszero/dsh-cancel-cause-guard/types
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* What the guard does with a cause that is not a member of the closed union.
|
|
8
|
+
*
|
|
9
|
+
* - `normalize` — the default, and the reason the plugin exists: the value is
|
|
10
|
+
* carried as a `{ kind: 'hook', reason }` cause, the turn ends normally, and
|
|
11
|
+
* the log records which value was rewritten.
|
|
12
|
+
* - `observe` — the value is passed through unchanged, so the turn fails exactly
|
|
13
|
+
* as it would without the plugin, and the log records what was seen. This is
|
|
14
|
+
* the arm for an operator who wants to find a producer before protecting the
|
|
15
|
+
* session from it: the plugin reports, and nothing else changes.
|
|
16
|
+
*/
|
|
17
|
+
export type GuardMode = 'normalize' | 'observe';
|
|
18
|
+
/** The plugin configuration as mounted; every field is optional. */
|
|
19
|
+
export interface Config {
|
|
20
|
+
/** What to do with an out-of-union cause. Defaults to `normalize`. */
|
|
21
|
+
readonly mode?: GuardMode | undefined;
|
|
22
|
+
/**
|
|
23
|
+
* The reason text to record for every cause the guard rewrites, in place of
|
|
24
|
+
* the value's own name. Defaults to the name the value carried (`'thinking-loop'`
|
|
25
|
+
* for `{ kind: 'thinking-loop' }`, a JSON rendering otherwise).
|
|
26
|
+
*/
|
|
27
|
+
readonly reason?: string | undefined;
|
|
28
|
+
/** Bound on the reason text the guard mints. Defaults to 200. */
|
|
29
|
+
readonly maxReasonChars?: number | undefined;
|
|
30
|
+
}
|
|
31
|
+
/** The configuration after defaults, validation and detaching. */
|
|
32
|
+
export interface ResolvedConfig {
|
|
33
|
+
readonly mode: GuardMode;
|
|
34
|
+
readonly reason: string | undefined;
|
|
35
|
+
readonly maxReasonChars: number;
|
|
36
|
+
}
|
|
37
|
+
/** What {@link guardCancel} found when it reached an agent. */
|
|
38
|
+
export type GuardOutcome =
|
|
39
|
+
/** The instance's own `cancel` now normalizes out-of-union causes. */
|
|
40
|
+
'patched'
|
|
41
|
+
/** Somebody's guard — this plugin's, mounted twice — is already in place. */
|
|
42
|
+
| 'already-guarded'
|
|
43
|
+
/** The instance carries no callable `cancel`; there is nothing to shadow. */
|
|
44
|
+
| 'no-cancel'
|
|
45
|
+
/** The instance refuses an own property (`cancel` is non-writable, or the object is sealed). */
|
|
46
|
+
| 'not-writable';
|
|
47
|
+
/** One cause the guard rewrote, as it was reported. */
|
|
48
|
+
export interface NormalizedCause {
|
|
49
|
+
/** The value the caller passed. */
|
|
50
|
+
readonly original: unknown;
|
|
51
|
+
/** The union member it was carried as. */
|
|
52
|
+
readonly normalized: {
|
|
53
|
+
readonly kind: 'hook';
|
|
54
|
+
readonly reason: string;
|
|
55
|
+
};
|
|
56
|
+
/** How the original was described for the log. */
|
|
57
|
+
readonly described: string;
|
|
58
|
+
}
|
|
59
|
+
/** The handle {@link guardCancel} returns: how it went, and an undo. */
|
|
60
|
+
export interface GuardHandle {
|
|
61
|
+
/** What happened when the guard was installed. */
|
|
62
|
+
readonly outcome: GuardOutcome;
|
|
63
|
+
/** Restore the instance's own `cancel`, when this guard installed one. */
|
|
64
|
+
readonly restore: () => void;
|
|
65
|
+
}
|
|
66
|
+
//# sourceMappingURL=types.d.ts.map
|
package/lib/types.js
ADDED
package/package.json
CHANGED
|
@@ -1,6 +1,74 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@argszero/dsh-cancel-cause-guard",
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"description": "A plugin that cancels an agent's turn with a cause outside the closed AgentCancelCause union — a bare string like agent.cancel('thinking-loop'), or an object whose kind the union does not have — does not merely log something odd: it makes the whole conversation view unreadable. ReactLoopAgent.cancel hands the value to AbortController.abort(), so it becomes signal.reason; the turn's catch reads it back through abortedCancelCause, whose switch ends in assertNever *inside the catch body*, so turnEnds stays at its null initializer and the unconditional finally appends turn/end with that null (agent-loop/src/agent.ts:385, and the non-null assertion on the line is what makes it compile). Nothing on the write path refuses it — the append-time validator inspects surface slots, request/header and tool/result, and turn/end.reason is a TypeScript-only invariant. Every reader that dereferences it then throws, and the chat view does exactly that while rendering (ui-chat/src/client/contract/turn-process.ts:72), so one such row blanks the conversation area for every session. Reported three times (#8832, #9156, #9646). This plugin closes the seam that is left: it listens to agent/created — the serial, awaited lifecycle edge that hands over the live agent instance (packages/core/agent/src/index.ts:550) — and shadows that instance's cancel with a wrapper that carries anything outside the union as { kind: 'hook', reason } before the loop ever sees it. hook is the member the union reserves for plugins, so the rewrite is not a lie: a plugin did cancel the turn, and the reason names which one. One property write covers every instance-level caller, because ReactLoopAgent.cancel is a prototype method and the shadow is an own property. Every rewrite is logged with the offending value rendered as JSON, so the producer can be found and fixed rather than tolerated. It never refuses a cancellation, forwards an already-valid cause by reference, and cannot repair rows already on disk. Zero runtime dependencies beyond the harness packages it declares.",
|
|
4
|
+
"version": "0.1.0",
|
|
5
|
+
"publishConfig": {
|
|
6
|
+
"access": "public"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "lib/index.js",
|
|
10
|
+
"types": "lib/types/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./lib/types/index.d.ts",
|
|
14
|
+
"default": "./lib/index.js"
|
|
15
|
+
},
|
|
16
|
+
"./package.json": "./package.json"
|
|
17
|
+
},
|
|
18
|
+
"files": [
|
|
19
|
+
"lib/**/*.js",
|
|
20
|
+
"lib/types/**/*.d.ts",
|
|
21
|
+
"cordis.patch.yml",
|
|
22
|
+
"README.md",
|
|
23
|
+
"LICENSE"
|
|
24
|
+
],
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/argszero/dsh-cancel-cause-guard.git"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/argszero/dsh-cancel-cause-guard#readme",
|
|
31
|
+
"bugs": {
|
|
32
|
+
"url": "https://github.com/argszero/dsh-cancel-cause-guard/issues"
|
|
33
|
+
},
|
|
34
|
+
"keywords": [
|
|
35
|
+
"cordis",
|
|
36
|
+
"deepseek-harness",
|
|
37
|
+
"dsh",
|
|
38
|
+
"plugin",
|
|
39
|
+
"agent-cancel",
|
|
40
|
+
"cancel-cause",
|
|
41
|
+
"turn-end",
|
|
42
|
+
"session-integrity",
|
|
43
|
+
"agent-lifecycle",
|
|
44
|
+
"guard"
|
|
45
|
+
],
|
|
46
|
+
"dsh": {
|
|
47
|
+
"bundle": {
|
|
48
|
+
"patch": "./cordis.patch.yml"
|
|
49
|
+
}
|
|
50
|
+
},
|
|
51
|
+
"peerDependencies": {
|
|
52
|
+
"@deepseek-ai/cordis": "^4.0.2 || ~4.0.5-alpha.1",
|
|
53
|
+
"@deepseek-ai/dsh-agent": ">=0.1.7-rc.2 <0.2.0 || >=0.2.0-rc.1 <0.2.1-alpha.2 || >=0.2.1-alpha.2 <0.3.0"
|
|
54
|
+
},
|
|
55
|
+
"devDependencies": {
|
|
56
|
+
"@deepseek-ai/cordis": "~4.0.5-alpha.1",
|
|
57
|
+
"@deepseek-ai/dsh-agent": "0.2.1-alpha.2",
|
|
58
|
+
"@deepseek-ai/dsh-session": "0.2.1-alpha.2",
|
|
59
|
+
"@types/node": "^24.19.1",
|
|
60
|
+
"typescript": "^5.9.3"
|
|
61
|
+
},
|
|
62
|
+
"scripts": {
|
|
63
|
+
"build": "tsc",
|
|
64
|
+
"pretest": "tsc",
|
|
65
|
+
"test": "node --test \"test/*.spec.mjs\"",
|
|
66
|
+
"prepublishOnly": "tsc",
|
|
67
|
+
"test:inject": "node scripts/inject-defects.mjs",
|
|
68
|
+
"probe:lines": "node scripts/probe-lines.mjs",
|
|
69
|
+
"witness:cancel-cause": "node scripts/witness-cancel-cause.mjs"
|
|
70
|
+
},
|
|
71
|
+
"engines": {
|
|
72
|
+
"node": "^22.19 || >=24"
|
|
73
|
+
}
|
|
74
|
+
}
|