@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/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 };