@argszero/dsh-cancel-cause-guard 0.0.0-stage → 0.2.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 +277 -2
- package/cordis.patch.yml +72 -0
- package/lib/backstop.js +222 -0
- package/lib/cause.js +205 -0
- package/lib/config.js +65 -0
- package/lib/guard.js +139 -0
- package/lib/index.js +251 -0
- package/lib/types/backstop.d.ts +104 -0
- package/lib/types/cause.d.ts +122 -0
- package/lib/types/config.d.ts +26 -0
- package/lib/types/guard.d.ts +80 -0
- package/lib/types/index.d.ts +115 -0
- package/lib/types/types.d.ts +116 -0
- package/lib/types.js +6 -0
- package/package.json +72 -4
package/lib/cause.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
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
|
+
/**
|
|
197
|
+
* Bound one reason so a hostile value cannot write a wall of text into the log.
|
|
198
|
+
* Shared with the durable backstop, which mints a reason of its own.
|
|
199
|
+
* @param text - the reason text.
|
|
200
|
+
* @param maxChars - the bound, in characters.
|
|
201
|
+
* @returns the text, cut to the bound with a trailing ellipsis when it had to be.
|
|
202
|
+
*/
|
|
203
|
+
export function truncate(text, maxChars) {
|
|
204
|
+
return text.length <= maxChars ? text : `${text.slice(0, maxChars - 1)}…`;
|
|
205
|
+
}
|
package/lib/config.js
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration resolution for the cancellation-cause guard.
|
|
3
|
+
*
|
|
4
|
+
* The surface is deliberately four fields. A guard that rewrites a value on
|
|
5
|
+
* paths as hot as cancellation and the session append has to be readable in one
|
|
6
|
+
* sitting, and every knob it does not have is a way for an operator to be
|
|
7
|
+
* surprised by what it did.
|
|
8
|
+
*
|
|
9
|
+
* An unknown key is refused rather than ignored: a misspelled `mode` that mounts
|
|
10
|
+
* and guards nothing is how an operator believes a guard is in force when it is
|
|
11
|
+
* not — the same refusal `dsh-tool-output-budget` makes, for the same reason.
|
|
12
|
+
*
|
|
13
|
+
* @module @argszero/dsh-cancel-cause-guard/config
|
|
14
|
+
*/
|
|
15
|
+
import { DEFAULT_MAX_REASON_CHARS, UNSPECIFIED_REASON } from "./cause.js";
|
|
16
|
+
/** Defaults for a guard whose only job is to keep a session readable. */
|
|
17
|
+
export const DEFAULTS = Object.freeze({
|
|
18
|
+
mode: 'normalize',
|
|
19
|
+
reason: undefined,
|
|
20
|
+
maxReasonChars: DEFAULT_MAX_REASON_CHARS,
|
|
21
|
+
backstop: true,
|
|
22
|
+
});
|
|
23
|
+
const CONFIG_KEYS = new Set(['mode', 'reason', 'maxReasonChars', 'backstop']);
|
|
24
|
+
const MODES = new Set(['normalize', 'observe']);
|
|
25
|
+
/**
|
|
26
|
+
* Resolve, validate and detach the guard configuration.
|
|
27
|
+
* @param config - raw plugin configuration, possibly empty.
|
|
28
|
+
* @returns a detached, frozen configuration.
|
|
29
|
+
* @throws {Error} when a key is unknown, a mode is not one this plugin has, a
|
|
30
|
+
* reason is not non-empty text, or the bound is not a positive integer.
|
|
31
|
+
*/
|
|
32
|
+
export function resolveConfig(config = {}) {
|
|
33
|
+
if (config === null || typeof config !== 'object' || Array.isArray(config)) {
|
|
34
|
+
throw new Error('cancel-cause-guard config: must be an object');
|
|
35
|
+
}
|
|
36
|
+
for (const key of Object.keys(config)) {
|
|
37
|
+
if (!CONFIG_KEYS.has(key)) {
|
|
38
|
+
throw new Error(`cancel-cause-guard config: unknown key "${key}" `
|
|
39
|
+
+ '(allowed: mode, reason, maxReasonChars, backstop)');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
const mode = config.mode ?? DEFAULTS.mode;
|
|
43
|
+
if (typeof mode !== 'string' || !MODES.has(mode)) {
|
|
44
|
+
throw new Error('cancel-cause-guard config: mode must be "normalize" or "observe"');
|
|
45
|
+
}
|
|
46
|
+
const maxReasonChars = config.maxReasonChars ?? DEFAULTS.maxReasonChars;
|
|
47
|
+
if (!Number.isSafeInteger(maxReasonChars) || maxReasonChars < 1) {
|
|
48
|
+
throw new Error('cancel-cause-guard config: maxReasonChars must be a positive integer');
|
|
49
|
+
}
|
|
50
|
+
// A reason override of `''` would make the normalized cause say nothing at
|
|
51
|
+
// all, which is the one outcome a *reason* field exists to prevent. `undefined`
|
|
52
|
+
// is the "name it after the value the caller passed" default; an empty string
|
|
53
|
+
// is a mistake, and is reported as one.
|
|
54
|
+
const reason = config.reason;
|
|
55
|
+
if (reason !== undefined && (typeof reason !== 'string' || reason.trim().length === 0)) {
|
|
56
|
+
throw new Error(`cancel-cause-guard config: reason must be non-empty text, or absent to name the cause `
|
|
57
|
+
+ `after the value that produced it (the default resolves to "${UNSPECIFIED_REASON}" only `
|
|
58
|
+
+ 'for a cause that carried no name)');
|
|
59
|
+
}
|
|
60
|
+
const backstop = config.backstop ?? DEFAULTS.backstop;
|
|
61
|
+
if (typeof backstop !== 'boolean') {
|
|
62
|
+
throw new Error('cancel-cause-guard config: backstop must be true or false');
|
|
63
|
+
}
|
|
64
|
+
return Object.freeze({ mode, reason, maxReasonChars, backstop });
|
|
65
|
+
}
|
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,251 @@
|
|
|
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 closes the defect at two depths, and the second one exists for the callers
|
|
37
|
+
* the first cannot see.
|
|
38
|
+
*
|
|
39
|
+
* **At the entry.** It listens to `agent/created` — the serial, awaited lifecycle
|
|
40
|
+
* edge that hands over the **live agent instance** — and shadows that instance's
|
|
41
|
+
* `cancel` with a wrapper that normalizes anything outside the union into
|
|
42
|
+
* `{ kind: 'hook', reason: <the value's own name> }` before the loop ever sees
|
|
43
|
+
* it. `hook` is the union member the harness reserves for plugins, so the
|
|
44
|
+
* rewrite is not a lie: a plugin did cancel the turn, and the reason names which
|
|
45
|
+
* one. One property write covers every instance-level caller — the loop's own
|
|
46
|
+
* `agent.cancel({ kind: 'user' })`, the client's stop button, and any
|
|
47
|
+
* third-party plugin — because `ReactLoopAgent.cancel` is a prototype method and
|
|
48
|
+
* the shadow is an own property.
|
|
49
|
+
*
|
|
50
|
+
* **At the write.** The same edge hands over the **live session**, and the
|
|
51
|
+
* plugin shadows that session's own `append` too, so a `turn/end` row whose
|
|
52
|
+
* reason no reader can dereference is committed instead as the row the loop meant
|
|
53
|
+
* to write: `{ kind: 'aborted', reason: { kind: 'hook', reason: <text> } }`. That
|
|
54
|
+
* arm is what covers the callers the entry guard cannot reach — one that goes
|
|
55
|
+
* through the class rather than the instance
|
|
56
|
+
* (`ReactLoopAgent.prototype.cancel.call(agent, …)`, or a second copy of the loop
|
|
57
|
+
* in the process), and an instance that refuses the own `cancel` property, where
|
|
58
|
+
* the entry guard simply reports that it is not in force. Set `backstop: false`
|
|
59
|
+
* to leave the append path exactly as the harness's.
|
|
60
|
+
*
|
|
61
|
+
* Every rewrite and every repaired row is logged, with the offending value
|
|
62
|
+
* rendered as JSON, so the producer can be found and fixed rather than merely
|
|
63
|
+
* tolerated.
|
|
64
|
+
*
|
|
65
|
+
* ## What it does not do
|
|
66
|
+
*
|
|
67
|
+
* It does not refuse a cancellation, does not touch the options, does not rewrite
|
|
68
|
+
* a cause that is already a member (that one is forwarded by reference), and
|
|
69
|
+
* cannot repair a session that already carries a `null` row — those rows are on
|
|
70
|
+
* disk, and this plugin reads no history. Mounting it bounds the defect from that
|
|
71
|
+
* point on.
|
|
72
|
+
*
|
|
73
|
+
* `mode: 'observe'` exists for the operator who wants to see the failure happen:
|
|
74
|
+
* both arms report and neither rewrites, and the log names what arrived.
|
|
75
|
+
*
|
|
76
|
+
* @module @argszero/dsh-cancel-cause-guard
|
|
77
|
+
*/
|
|
78
|
+
import { guardSessionAppend } from "./backstop.js";
|
|
79
|
+
import { resolveConfig } from "./config.js";
|
|
80
|
+
import { guardCancel } from "./guard.js";
|
|
81
|
+
export { DEFAULT_MAX_REASON_CHARS, describeValue, isCancelCause, normalizeCancelCause, reasonText, UNSPECIFIED_REASON } from "./cause.js";
|
|
82
|
+
export { APPEND_GUARD_MARK, UNSEEN_CAUSE_REASON, guardSessionAppend, isReadableTurnEndReason, unseenCauseReason } from "./backstop.js";
|
|
83
|
+
export { DEFAULTS, resolveConfig } from "./config.js";
|
|
84
|
+
export { GUARD_MARK, guardCancel } from "./guard.js";
|
|
85
|
+
/** Cordis plugin name, used by loader diagnostics. */
|
|
86
|
+
export const name = 'cancel-cause-guard';
|
|
87
|
+
/**
|
|
88
|
+
* No service is required.
|
|
89
|
+
*
|
|
90
|
+
* The whole seam is the agent instance the lifecycle edge hands over, and asking
|
|
91
|
+
* for a service this plugin does not use would only be a way for it to fail to
|
|
92
|
+
* mount — so the list is empty rather than absent, which keeps the declaration
|
|
93
|
+
* explicit for a reader and identical between the two export forms.
|
|
94
|
+
*/
|
|
95
|
+
export const inject = [];
|
|
96
|
+
/** The prefix of every diagnostic this plugin logs. */
|
|
97
|
+
const LOG_PREFIX = 'cancel-cause-guard:';
|
|
98
|
+
/**
|
|
99
|
+
* Install the guard.
|
|
100
|
+
* @param ctx - the plugin context; the listener and the restore are disposed
|
|
101
|
+
* with it.
|
|
102
|
+
* @param config - the configuration as mounted, possibly empty. Defaults are
|
|
103
|
+
* applied inside `resolveConfig` rather than in a plain object exported under
|
|
104
|
+
* the name `Config`, because the Loader reads that export as a schema and the
|
|
105
|
+
* plugin would then fail to mount.
|
|
106
|
+
* @throws when the configuration names an unknown key or an unusable value; the
|
|
107
|
+
* plugin refuses to mount rather than guarding with rules it cannot honour.
|
|
108
|
+
*/
|
|
109
|
+
export function apply(ctx, config = {}) {
|
|
110
|
+
const settings = resolveConfig(config);
|
|
111
|
+
/** The shadows this plugin installed and has not yet unwound, by agent instance. */
|
|
112
|
+
const guards = new Map();
|
|
113
|
+
// Registered before the listeners so that it is unwound *after* them: the
|
|
114
|
+
// fiber's effects are disposed in reverse, and unwinding a shadow while a
|
|
115
|
+
// listener could still install another would leave the last one behind.
|
|
116
|
+
ctx.effect(() => () => {
|
|
117
|
+
for (const shadows of guards.values())
|
|
118
|
+
restoreShadows(shadows);
|
|
119
|
+
guards.clear();
|
|
120
|
+
}, `${LOG_PREFIX}restore()`);
|
|
121
|
+
ctx.on('agent/created', ({ agent }) => {
|
|
122
|
+
const shadows = {};
|
|
123
|
+
const sessionId = agent.session.id;
|
|
124
|
+
const handle = guardCancel(agent, {
|
|
125
|
+
mode: settings.mode,
|
|
126
|
+
reason: settings.reason,
|
|
127
|
+
maxReasonChars: settings.maxReasonChars,
|
|
128
|
+
onNormalized: report => { reportNormalized(ctx, sessionId, report, settings.mode); },
|
|
129
|
+
});
|
|
130
|
+
switch (handle.outcome) {
|
|
131
|
+
case 'patched':
|
|
132
|
+
shadows.cancel = handle;
|
|
133
|
+
ctx.logger.debug(`${LOG_PREFIX} agent ${sessionId}: cancellation causes outside the union will be `
|
|
134
|
+
+ 'recorded as { kind: "hook", reason } instead of ending the turn with reason null');
|
|
135
|
+
break;
|
|
136
|
+
case 'already-guarded':
|
|
137
|
+
ctx.logger.debug(`${LOG_PREFIX} agent ${sessionId}: already guarded by this plugin, leaving the `
|
|
138
|
+
+ 'existing wrapper in place');
|
|
139
|
+
break;
|
|
140
|
+
case 'no-cancel':
|
|
141
|
+
ctx.logger.warn(`${LOG_PREFIX} agent ${sessionId} carries no cancel method, so an out-of-union `
|
|
142
|
+
+ 'cause from any plugin it hands itself to will still end its turn with reason null');
|
|
143
|
+
break;
|
|
144
|
+
/* v8 ignore next 4 -- the read-back check makes both write failures one outcome. */
|
|
145
|
+
case 'not-writable':
|
|
146
|
+
ctx.logger.warn(`${LOG_PREFIX} agent ${sessionId} refuses an own "cancel" property, so this guard `
|
|
147
|
+
+ 'is NOT in force for it: an out-of-union cause will end its turn with reason null');
|
|
148
|
+
}
|
|
149
|
+
// The durable half, installed after the entry guard so that the ordering is
|
|
150
|
+
// legible: what the entry guard rewrites never reaches this one, and what
|
|
151
|
+
// reaches this one is exactly the cancellation the entry guard could not see.
|
|
152
|
+
if (settings.backstop) {
|
|
153
|
+
const backstop = guardSessionAppend(agent.session, backstopOptions(ctx, sessionId, settings));
|
|
154
|
+
switch (backstop.outcome) {
|
|
155
|
+
case 'patched':
|
|
156
|
+
shadows.append = backstop;
|
|
157
|
+
ctx.logger.debug(`${LOG_PREFIX} agent ${sessionId}: a turn/end row that reached the log with no `
|
|
158
|
+
+ 'readable reason will be recorded as { kind: "aborted", reason: { kind: "hook", reason } }');
|
|
159
|
+
break;
|
|
160
|
+
case 'already-guarded':
|
|
161
|
+
ctx.logger.debug(`${LOG_PREFIX} agent ${sessionId}: the session is already backstopped by this plugin, `
|
|
162
|
+
+ 'leaving the existing wrapper in place');
|
|
163
|
+
break;
|
|
164
|
+
case 'no-append':
|
|
165
|
+
ctx.logger.warn(`${LOG_PREFIX} agent ${sessionId}: its session carries no append method, so the `
|
|
166
|
+
+ 'durable backstop is NOT in force for it');
|
|
167
|
+
break;
|
|
168
|
+
/* v8 ignore next 4 -- the read-back check makes both write failures one outcome. */
|
|
169
|
+
case 'not-writable':
|
|
170
|
+
ctx.logger.warn(`${LOG_PREFIX} agent ${sessionId}: its session refuses an own "append" property, so `
|
|
171
|
+
+ 'the durable backstop is NOT in force for it');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (shadows.cancel !== undefined || shadows.append !== undefined)
|
|
175
|
+
guards.set(agent, shadows);
|
|
176
|
+
});
|
|
177
|
+
ctx.on('agent/disposed', ({ agent }) => {
|
|
178
|
+
const shadows = guards.get(agent);
|
|
179
|
+
if (shadows === undefined)
|
|
180
|
+
return;
|
|
181
|
+
restoreShadows(shadows);
|
|
182
|
+
guards.delete(agent);
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Unwind every shadow of one agent.
|
|
187
|
+
* @param shadows - the handles this plugin installed for that agent.
|
|
188
|
+
*/
|
|
189
|
+
function restoreShadows(shadows) {
|
|
190
|
+
shadows.cancel?.restore();
|
|
191
|
+
shadows.append?.restore();
|
|
192
|
+
}
|
|
193
|
+
/**
|
|
194
|
+
* The backstop options for one agent.
|
|
195
|
+
* @param ctx - the plugin context, for logging.
|
|
196
|
+
* @param sessionId - the session the backstop reports against.
|
|
197
|
+
* @param settings - the resolved configuration.
|
|
198
|
+
* @returns the options, with the report callback bound.
|
|
199
|
+
*/
|
|
200
|
+
function backstopOptions(ctx, sessionId, settings) {
|
|
201
|
+
return {
|
|
202
|
+
mode: settings.mode,
|
|
203
|
+
reason: settings.reason,
|
|
204
|
+
maxReasonChars: settings.maxReasonChars,
|
|
205
|
+
onRepaired: report => { reportRepaired(ctx, sessionId, report, settings.mode); },
|
|
206
|
+
};
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Record one unreadable `turn/end` reason.
|
|
210
|
+
*
|
|
211
|
+
* A warning rather than an error, and deliberately a different sentence from
|
|
212
|
+
* {@link reportNormalized}: this row was not a cause the entry guard rewrote but
|
|
213
|
+
* a cancellation the entry guard never saw, and the operator has to be able to
|
|
214
|
+
* tell the two apart when working out who produced it.
|
|
215
|
+
* @param ctx - the plugin context, for logging.
|
|
216
|
+
* @param sessionId - the session whose row it was.
|
|
217
|
+
* @param report - what the row carried, and what it became.
|
|
218
|
+
* @param mode - whether the row was repaired or passed through.
|
|
219
|
+
*/
|
|
220
|
+
function reportRepaired(ctx, sessionId, report, mode) {
|
|
221
|
+
const outcome = mode === 'observe'
|
|
222
|
+
? 'it was committed unchanged (mode "observe"), so this session is now unreadable in the chat view'
|
|
223
|
+
: 'it was recorded as '
|
|
224
|
+
+ `{ "kind": "aborted", "reason": { "kind": "hook", "reason": ${JSON.stringify(report.normalized.reason)} } }`;
|
|
225
|
+
ctx.logger.warn(`${LOG_PREFIX} session ${sessionId}: a turn/end row for turn ${String(report.turn)} reached the `
|
|
226
|
+
+ `log with a reason no reader can dereference (${report.described}); ${outcome}. `
|
|
227
|
+
+ 'The cancellation never passed the entry guard, so its cause is not in this record: a caller '
|
|
228
|
+
+ 'reached the cancel method through the class rather than through the instance, a second copy '
|
|
229
|
+
+ 'of the loop aborted a signal of its own, or the instance refused the guard.');
|
|
230
|
+
}
|
|
231
|
+
/**
|
|
232
|
+
* Record one out-of-union cause.
|
|
233
|
+
*
|
|
234
|
+
* A warning rather than an error: what the reader needs is the *name* of the
|
|
235
|
+
* value — the only handle on whoever produced it — and the two modes differ
|
|
236
|
+
* only in what became of it.
|
|
237
|
+
* @param ctx - the plugin context, for logging.
|
|
238
|
+
* @param sessionId - the session whose agent was cancelled.
|
|
239
|
+
* @param report - what was seen, and what it became.
|
|
240
|
+
* @param mode - whether the cause was rewritten or passed through.
|
|
241
|
+
*/
|
|
242
|
+
function reportNormalized(ctx, sessionId, report, mode) {
|
|
243
|
+
const outcome = mode === 'observe'
|
|
244
|
+
? 'it was passed through unchanged (mode "observe"), so this turn ends with reason null'
|
|
245
|
+
: 'the turn was recorded as '
|
|
246
|
+
+ `{ "kind": "aborted", "reason": { "kind": "hook", "reason": ${JSON.stringify(report.normalized.reason)} } }`;
|
|
247
|
+
ctx.logger.warn(`${LOG_PREFIX} session ${sessionId}: a caller passed ${report.described} as a cancellation `
|
|
248
|
+
+ `cause, which is not a member of AgentCancelCause; ${outcome}. `
|
|
249
|
+
+ 'Which plugin passes it is the value itself: the reason carries its own name.');
|
|
250
|
+
}
|
|
251
|
+
export default { name, inject, apply };
|