@matatbread/matbot-core 0.4.14 → 0.4.16
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/package.json +2 -2
- package/src/config/loader.ts +33 -0
- package/src/config/yaml.ts +38 -2
- package/src/index.ts +3 -2
- package/src/knowledge/lookup-knowledge-index.ts +3 -2
- package/src/loader.ts +36 -19
- package/src/media.ts +6 -1
- package/src/registry.ts +23 -23
- package/src/runner.ts +32 -28
- package/src/session-runner.ts +276 -277
- package/src/session.ts +5 -18
- package/src/settings.ts +17 -1
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.4.
|
|
7
|
+
"version": "0.4.16",
|
|
8
8
|
"engines": {
|
|
9
9
|
"node": ">=24.0.0"
|
|
10
10
|
},
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@matatbread/matbot-plugin-api": "^0.4.
|
|
38
|
+
"@matatbread/matbot-plugin-api": "^0.4.16"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^6.0.3"
|
package/src/config/loader.ts
CHANGED
|
@@ -21,6 +21,12 @@ export interface MatbotConfig {
|
|
|
21
21
|
* config rather than data. A stored value always wins; `delete` reverts to the default here.
|
|
22
22
|
*/
|
|
23
23
|
defaultSettings?: ReadonlyMap<string, Readonly<Record<string, unknown>>>;
|
|
24
|
+
/**
|
|
25
|
+
* Longest a `tool_function` body may run synchronously — without awaiting — before it is stopped
|
|
26
|
+
* (`function_timeout_ms`). Absent ⇒ the host's default. `0` ⇒ no limit at all, for testing: bodies run
|
|
27
|
+
* directly, and a loop that never awaits freezes the process.
|
|
28
|
+
*/
|
|
29
|
+
functionTimeoutMs?: number;
|
|
24
30
|
}
|
|
25
31
|
|
|
26
32
|
function asString(v: YamlValue | undefined, label: string): string {
|
|
@@ -80,6 +86,21 @@ function toProviderConfig(name: string, raw: YamlMap): ProviderConfig {
|
|
|
80
86
|
return config;
|
|
81
87
|
}
|
|
82
88
|
|
|
89
|
+
/**
|
|
90
|
+
* Parse a config document, optionally over a `base` one it `extends:`.
|
|
91
|
+
*
|
|
92
|
+
* **The merge is per TOP-LEVEL KEY, and the derived document REPLACES rather than extends.** A child
|
|
93
|
+
* declaring `plugins:` supplies the whole list — the base's entries are not appended — and the same
|
|
94
|
+
* holds for `providers:` and `default_settings:`. There is no deep merge and no per-entry merge: the
|
|
95
|
+
* unit is the section.
|
|
96
|
+
*
|
|
97
|
+
* That is deliberately stated rather than deliberately chosen. Section-replacement is what the one-line
|
|
98
|
+
* spread has always done, and a caller reading "extends" reasonably expects the other reading, so the
|
|
99
|
+
* semantics are pinned by test (config-extends.test.ts) — change them on purpose, not by editing this
|
|
100
|
+
* line. Note also that `extends:` does not today give you a shared base: the CLI chdirs to the base's
|
|
101
|
+
* directory and rewrites `configPath`, so the base becomes the project (`.data`, `.env`, and every yaml
|
|
102
|
+
* write land there). See CLAUDE.md, *Default plugin settings*.
|
|
103
|
+
*/
|
|
83
104
|
export function parseConfig(
|
|
84
105
|
text: string,
|
|
85
106
|
base?: string,
|
|
@@ -123,6 +144,17 @@ export function parseConfig(
|
|
|
123
144
|
}
|
|
124
145
|
}
|
|
125
146
|
|
|
147
|
+
const functionTimeoutRaw = doc['function_timeout_ms'];
|
|
148
|
+
let functionTimeoutMs: number | undefined;
|
|
149
|
+
if (functionTimeoutRaw !== undefined && functionTimeoutRaw !== null) {
|
|
150
|
+
functionTimeoutMs = asNumber(functionTimeoutRaw, 'function_timeout_ms');
|
|
151
|
+
// Refused rather than clamped, like maxRounds: a negative or fractional limit is a typo, and `0` already
|
|
152
|
+
// means something of its own.
|
|
153
|
+
if (!Number.isInteger(functionTimeoutMs) || functionTimeoutMs < 0) {
|
|
154
|
+
throw new Error(`Config: "function_timeout_ms" must be a non-negative integer (milliseconds; 0 removes the limit), got ${functionTimeoutMs}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
126
158
|
const prompt = typeof doc['prompt'] === 'string' ? doc['prompt'] : undefined;
|
|
127
159
|
const ephemeral = doc['ephemeral'] === true ? true : undefined;
|
|
128
160
|
const defaultProvider = typeof doc['default_provider'] === 'string' ? doc['default_provider'] : undefined;
|
|
@@ -136,6 +168,7 @@ export function parseConfig(
|
|
|
136
168
|
...(defaultProvider !== undefined ? { defaultProvider } : {}),
|
|
137
169
|
...(principal !== undefined ? { principal } : {}),
|
|
138
170
|
...(defaultSettings.size > 0 ? { defaultSettings } : {}),
|
|
171
|
+
...(functionTimeoutMs !== undefined ? { functionTimeoutMs } : {}),
|
|
139
172
|
};
|
|
140
173
|
}
|
|
141
174
|
|
package/src/config/yaml.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
* - Block sequences (- item), including an item whose value is an indented block under a bare `-`
|
|
7
7
|
* - Scalar types: string, number, boolean, null
|
|
8
8
|
* - Quoted strings (single and double), as values and as keys
|
|
9
|
-
* - Comments (# ...)
|
|
9
|
+
* - Comments (# ...), outside quotes and at a line start or after whitespace (see stripComment)
|
|
10
10
|
*
|
|
11
11
|
* Does NOT support anchors, aliases, flow syntax, or ${NAME} expansion.
|
|
12
12
|
* ${NAME} placeholders are left intact for the Vault to resolve.
|
|
@@ -36,11 +36,47 @@ interface Token {
|
|
|
36
36
|
line: number;
|
|
37
37
|
}
|
|
38
38
|
|
|
39
|
+
// Where a `#` opens a comment, per YAML's own rule: outside a quoted scalar, and at the start of a
|
|
40
|
+
// line or after whitespace. Stripping every `#` truncated values that legitimately contain one —
|
|
41
|
+
// `github:owner/repo#path:sub` lost its subdirectory, and a quoted value lost everything from the `#`
|
|
42
|
+
// on, INCLUDING its closing quote, so it then failed to unquote and kept a stray `"`. Both were silent.
|
|
43
|
+
//
|
|
44
|
+
// A quote only opens a quoted scalar where a scalar can start (line start, or after the `:`/`-` that
|
|
45
|
+
// introduces a value); elsewhere it is an apostrophe in a plain scalar (`title: Matt's config`), and
|
|
46
|
+
// treating it as an opening quote would swallow a real trailing comment.
|
|
47
|
+
//
|
|
48
|
+
// Still unhandled, as before: a `#` inside a block scalar (`|`/`>`) body, and a backslash-escaped quote
|
|
49
|
+
// — this parser does not process escapes anywhere (see `unquote`).
|
|
50
|
+
function stripComment(line: string): string {
|
|
51
|
+
let quote: string | undefined;
|
|
52
|
+
for (let i = 0; i < line.length; i++) {
|
|
53
|
+
const ch = line[i]!;
|
|
54
|
+
if (quote !== undefined) {
|
|
55
|
+
if (ch === quote) quote = undefined;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if ((ch === '"' || ch === "'") && opensScalar(line, i)) { quote = ch; continue; }
|
|
59
|
+
if (ch === '#' && (i === 0 || line[i - 1] === ' ' || line[i - 1] === '\t')) return line.slice(0, i);
|
|
60
|
+
}
|
|
61
|
+
return line;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Is position `i` somewhere a scalar can begin — nothing but whitespace before it, or the `:`/`-` that
|
|
65
|
+
// introduces one? Keys count: `'@scope/pkg':` is a quoted scalar at line start.
|
|
66
|
+
function opensScalar(line: string, i: number): boolean {
|
|
67
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
68
|
+
const ch = line[j]!;
|
|
69
|
+
if (ch === ' ' || ch === '\t') continue;
|
|
70
|
+
return ch === ':' || ch === '-';
|
|
71
|
+
}
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
39
75
|
function tokenize(text: string): Token[] {
|
|
40
76
|
const tokens: Token[] = [];
|
|
41
77
|
const lines = text.split('\n');
|
|
42
78
|
for (let n = 0; n < lines.length; n++) {
|
|
43
|
-
const stripped = lines[n]
|
|
79
|
+
const stripped = stripComment(lines[n]!).trimEnd();
|
|
44
80
|
if (stripped.trim() === '') continue;
|
|
45
81
|
const indent = stripped.length - stripped.trimStart().length;
|
|
46
82
|
tokens.push({ indent, raw: stripped.trimStart(), line: n + 1 });
|
package/src/index.ts
CHANGED
|
@@ -23,7 +23,7 @@ export { applyCreateSecret, assertStorableKey, unreferenceableKey, REFERENCEABLE
|
|
|
23
23
|
// that apply one cannot differ on what a patch MEANS, and re-exported here so an app needs no direct
|
|
24
24
|
// plugin-api dependency to get it.
|
|
25
25
|
export { applyProviderPatch, patchedFields } from '@matatbread/matbot-plugin-api';
|
|
26
|
-
export { isTruncatedToolResult, notifyingStore, ItemChangeKind, RegistryChangeKind } from '@matatbread/matbot-plugin-api';
|
|
26
|
+
export { isTruncatedToolResult, notifyingStore, ItemChangeKind, RegistryChangeKind, FUNCTION_TIMEOUT } from '@matatbread/matbot-plugin-api';
|
|
27
27
|
// The canonical `type: 'confirm'` answers. Same reason as the patch policy above: an app implementing a
|
|
28
28
|
// PromptFn (or a test standing one in) has to speak them, and must not hard-code the rendered label.
|
|
29
29
|
export { CONFIRM_YES, CONFIRM_NO } from '@matatbread/matbot-plugin-api';
|
|
@@ -46,10 +46,11 @@ export {
|
|
|
46
46
|
contextSwitch, machineBusy, quiesced, onContextQuiesce, scheduleAtEdge,
|
|
47
47
|
installUsageCarrier, createSerialUsageCarrier, recordUsage, recordSpan, currentUsageSink,
|
|
48
48
|
currentUsageScope, currentUsageSite, withUsageScope, withUsageSite,
|
|
49
|
+
scopeIterator, scopeIterable,
|
|
49
50
|
} from '@matatbread/matbot-plugin-api/host';
|
|
50
51
|
export type {
|
|
51
52
|
Subscribable, Broadcaster, Routed, RoutedFilter, SwapFn, MountTable, PrincipalCarrier, UsageCarrier,
|
|
52
|
-
UsageScope, Quiescer,
|
|
53
|
+
UsageScope, Quiescer, ScopeFn,
|
|
53
54
|
} from '@matatbread/matbot-plugin-api/host';
|
|
54
55
|
export * from './session.js';
|
|
55
56
|
export * from './usage.js';
|
|
@@ -124,9 +124,10 @@ export class LookupKnowledgeIndex implements KnowledgeIndex {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
async search(
|
|
127
|
-
terms:
|
|
128
|
-
|
|
127
|
+
terms: Array<{ term: string; context?: string }>,
|
|
128
|
+
signal: AbortSignal,
|
|
129
129
|
): Promise<KnowledgeEntry[]> {
|
|
130
|
+
signal.throwIfAborted();
|
|
130
131
|
if (terms.length === 0) return [];
|
|
131
132
|
|
|
132
133
|
const scored: Array<{ entry: KnowledgeEntry; score: number }> = [];
|
package/src/loader.ts
CHANGED
|
@@ -4,9 +4,10 @@ import { incompatibleRuntimeError, notAPluginError } from '@matatbread/matbot-pl
|
|
|
4
4
|
import { registerPlugin, setupPlugin, unloadPlugin, recordFailedPlugin, clearFailedPlugin, announcePluginLoaded } from './registry.js';
|
|
5
5
|
|
|
6
6
|
/**
|
|
7
|
-
* The runtime this process is executing in
|
|
8
|
-
*
|
|
9
|
-
*
|
|
7
|
+
* The runtime this process is executing in — the one place this module decides what platform it is on.
|
|
8
|
+
* Read by the declarative pre-import gate (an absent `matbotRuntime` declaration bypasses it entirely)
|
|
9
|
+
* and by the import-rejection branch, which re-derived `typeof window` inline until the two could have
|
|
10
|
+
* disagreed.
|
|
10
11
|
*/
|
|
11
12
|
const CURRENT_RUNTIME: Runtime = typeof window !== 'undefined' ? 'browser' : 'node';
|
|
12
13
|
|
|
@@ -30,6 +31,23 @@ const FRESH_PARAM = 'mbfresh';
|
|
|
30
31
|
// produce the same stamp and re-hit the cache.
|
|
31
32
|
let freshSeq = 0;
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* One entry to load, for a host that has already done its own resolution — the object form of a plain
|
|
36
|
+
* specifier string. Every field is documented on {@link loadPlugins}, which is the only consumer.
|
|
37
|
+
*
|
|
38
|
+
* Named, because it was written out twice: once in the parameter list and once again in the `as` cast
|
|
39
|
+
* of the line that normalises the strings. Two spellings of one shape, where adding a field to the
|
|
40
|
+
* wrong one narrows what the loader will read from a host that is already passing it.
|
|
41
|
+
*/
|
|
42
|
+
export interface LoadRequest {
|
|
43
|
+
spec: string;
|
|
44
|
+
importSpec?: string;
|
|
45
|
+
name?: string;
|
|
46
|
+
version?: string;
|
|
47
|
+
runtimes?: readonly Runtime[];
|
|
48
|
+
notes?: readonly string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
33
51
|
/**
|
|
34
52
|
* Load plugins from the given specifiers, register them, then run setup().
|
|
35
53
|
*
|
|
@@ -100,13 +118,13 @@ let freshSeq = 0;
|
|
|
100
118
|
* only as a confusing empty-result error downstream — fail loudly with the reason instead.
|
|
101
119
|
*/
|
|
102
120
|
export async function loadPlugins(
|
|
103
|
-
specifiers: readonly (string |
|
|
121
|
+
specifiers: readonly (string | LoadRequest)[],
|
|
104
122
|
services: MatbotMachine,
|
|
105
123
|
bustCache = false,
|
|
106
124
|
prompt?: PromptFn,
|
|
107
125
|
onLoadError: 'skip' | 'throw' = 'skip',
|
|
108
126
|
): Promise<MatbotPlugin[]> {
|
|
109
|
-
const reqs = specifiers.map(s => (typeof s === 'string' ? { spec: s } : s)
|
|
127
|
+
const reqs: LoadRequest[] = specifiers.map(s => (typeof s === 'string' ? { spec: s } : s));
|
|
110
128
|
|
|
111
129
|
// A reason reaching a user, decorated with what the host noticed while resolving this spec. The
|
|
112
130
|
// failure a load reports is usually downstream of its cause, and the host is the only layer that
|
|
@@ -200,7 +218,7 @@ export async function loadPlugins(
|
|
|
200
218
|
|
|
201
219
|
if (result.status === 'rejected') {
|
|
202
220
|
const reason = result.reason instanceof Error ? result.reason.message : String(result.reason);
|
|
203
|
-
if (
|
|
221
|
+
if (CURRENT_RUNTIME === 'browser') {
|
|
204
222
|
// Browser: warn and skip — browser environments have no node_modules fallback.
|
|
205
223
|
console.warn(`[matbot] Could not load plugin "${spec}" (browser: use a URL path or configure an import map): ${reason}`);
|
|
206
224
|
recordFailedPlugin({ specifier: spec, error: reason });
|
|
@@ -307,19 +325,6 @@ export async function loadPlugins(
|
|
|
307
325
|
return loaded;
|
|
308
326
|
}
|
|
309
327
|
|
|
310
|
-
/**
|
|
311
|
-
* Stamp a plugin entry URL with a unique `${FRESH_PARAM}` value to force a fresh
|
|
312
|
-
* evaluation (and, with the node resolve hook, a fresh subtree — see FRESH_PARAM).
|
|
313
|
-
*
|
|
314
|
-
* Diagnostics: cache-busting silently degrades in two ways that are
|
|
315
|
-
* indistinguishable from "it worked" at the call site, so both are logged here.
|
|
316
|
-
* 1. import.meta.resolve throws (e.g. a cwd-relative spec that does not resolve
|
|
317
|
-
* relative to *this* module's URL) — we fall back to the bare spec, which
|
|
318
|
-
* re-imports the *cached* module. No busting happens at all.
|
|
319
|
-
* 2. Resolution succeeds but no resolve hook is installed: only the entry is
|
|
320
|
-
* re-evaluated, while everything it statically imports stays cached. We can
|
|
321
|
-
* detect (1) here; (2) is noted at the call site.
|
|
322
|
-
*/
|
|
323
328
|
/**
|
|
324
329
|
* Classify how a specifier resolves to code, from its shape alone (platform-neutral; no fs).
|
|
325
330
|
* github shorthand is only recognised via the explicit `github:` prefix — a bare `owner/repo`
|
|
@@ -379,6 +384,18 @@ function stampFresh(url: string): string {
|
|
|
379
384
|
return u.href;
|
|
380
385
|
}
|
|
381
386
|
|
|
387
|
+
/**
|
|
388
|
+
* Stamp a plugin entry URL with a unique `${FRESH_PARAM}` value to force a fresh evaluation (and, with
|
|
389
|
+
* the node resolve hook, a fresh subtree — see FRESH_PARAM).
|
|
390
|
+
*
|
|
391
|
+
* Diagnostics: cache-busting silently degrades in two ways that are indistinguishable from "it worked"
|
|
392
|
+
* at the call site, so both are logged.
|
|
393
|
+
* 1. import.meta.resolve throws (e.g. a cwd-relative spec that does not resolve relative to *this*
|
|
394
|
+
* module's URL) — we fall back to the bare spec, which re-imports the *cached* module. No busting
|
|
395
|
+
* happens at all. Detected here.
|
|
396
|
+
* 2. Resolution succeeds but no resolve hook is installed: only the entry is re-evaluated, while
|
|
397
|
+
* everything it statically imports stays cached. Noted at the call site.
|
|
398
|
+
*/
|
|
382
399
|
function toFreshUrl(spec: string): string {
|
|
383
400
|
try {
|
|
384
401
|
const fresh = stampFresh(import.meta.resolve(spec));
|
package/src/media.ts
CHANGED
|
@@ -151,8 +151,13 @@ export async function ingestMedia(
|
|
|
151
151
|
}
|
|
152
152
|
|
|
153
153
|
// Derived, not counted — see MAX_MEDIA_BYTES_PER_SESSION. One listing for the whole batch.
|
|
154
|
+
//
|
|
155
|
+
// Filtered by NAMESPACE as well as session: the media store is routinely the host's own file area, so
|
|
156
|
+
// a `sessionId`-tagged workspace file or a detached job's output shares the listing and was being
|
|
157
|
+
// charged to the media quota. The quota is over what THIS path stored, which is what the namespace
|
|
158
|
+
// names — and it is the same pair the per-ref ownership check below tests against.
|
|
154
159
|
let sessionBytes = 0;
|
|
155
|
-
for await (const held of store.list({ sessionId })) sessionBytes += held.size;
|
|
160
|
+
for await (const held of store.list({ sessionId, namespace: MEDIA_NAMESPACE })) sessionBytes += held.size;
|
|
156
161
|
|
|
157
162
|
const out: MessageContent[] = [];
|
|
158
163
|
for (const c of content) {
|
package/src/registry.ts
CHANGED
|
@@ -3,7 +3,7 @@ import { RegistryChangeKind } from '@matatbread/matbot-plugin-api';
|
|
|
3
3
|
import { scopedNotifier, askPermissionGate } from '@matatbread/matbot-plugin-api/host';
|
|
4
4
|
import type {
|
|
5
5
|
MatbotPlugin, MatbotMachine, MatbotRuntime, Mounted,
|
|
6
|
-
ProviderAdapterFactory,
|
|
6
|
+
ProviderAdapterFactory,
|
|
7
7
|
} from './plugin.js';
|
|
8
8
|
import { PLUGIN_API_VERSION, unifyServices } from './plugin.js';
|
|
9
9
|
import { makePluginSettings } from './settings.js';
|
|
@@ -18,7 +18,6 @@ import type { SettingsDoc } from './settings.js';
|
|
|
18
18
|
const state = {
|
|
19
19
|
plugins: [] as MatbotPlugin[],
|
|
20
20
|
providers: new Map<string, ProviderAdapterFactory>(),
|
|
21
|
-
storage: new Map<string, StoreFactory>(),
|
|
22
21
|
toolRegistry: undefined as ToolRegistry | undefined,
|
|
23
22
|
frontendPlugins: new Map<string, FrontendInfo>(), // pluginName → info, written by services.registerFrontend()
|
|
24
23
|
serviceKeys: new Map<string, string[]>(), // pluginName → MatbotMachine keys it registered
|
|
@@ -114,24 +113,11 @@ export function registerPlugin(plugin: MatbotPlugin): void {
|
|
|
114
113
|
throw new Error(`Provider "${plugin.name}" is already registered.`);
|
|
115
114
|
}
|
|
116
115
|
|
|
117
|
-
for (const type of Object.keys(plugin.storage ?? {})) {
|
|
118
|
-
if (state.storage.has(type)) {
|
|
119
|
-
const owner = state.plugins.find(p => p.storage?.[type] !== undefined)?.name ?? '?';
|
|
120
|
-
throw new Error(
|
|
121
|
-
`Storage type "${type}" is already registered by "${owner}". ` +
|
|
122
|
-
`"${plugin.name}" cannot register it again.`,
|
|
123
|
-
);
|
|
124
|
-
}
|
|
125
|
-
}
|
|
126
|
-
|
|
127
116
|
state.plugins.push(plugin);
|
|
128
117
|
|
|
129
118
|
if (plugin.provider !== undefined) {
|
|
130
119
|
state.providers.set(plugin.name, plugin.provider);
|
|
131
120
|
}
|
|
132
|
-
for (const [type, factory] of Object.entries(plugin.storage ?? {})) {
|
|
133
|
-
state.storage.set(type, factory);
|
|
134
|
-
}
|
|
135
121
|
}
|
|
136
122
|
|
|
137
123
|
// ── Resolution ────────────────────────────────────────────────────────────────
|
|
@@ -313,6 +299,9 @@ export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine,
|
|
|
313
299
|
// own settings — there is no way to name another's.
|
|
314
300
|
const ownSettings = makePluginSettings(services.createStore<SettingsDoc>('settings'), plugin.name);
|
|
315
301
|
|
|
302
|
+
// Plugin-scoped notifier, likewise built once. See the `Notifier` member below.
|
|
303
|
+
const ownNotifier = scopedNotifier(services.Notifier, plugin.name);
|
|
304
|
+
|
|
316
305
|
// Per-plugin `mounted`: a thin adapter over the host mount table that delivers *this plugin's* scoped
|
|
317
306
|
// machine (and scoped onUnmount) to handlers. The stable `scoped` object reads through the host's
|
|
318
307
|
// re-pointing proxies/registry, so `scoped[key]` is the host's live service by the time a transition
|
|
@@ -353,9 +342,11 @@ export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine,
|
|
|
353
342
|
// being a deliberate read of the concrete gate at that moment.
|
|
354
343
|
get PermissionGate() { return services.PermissionGate; },
|
|
355
344
|
// Everything this plugin publishes is attributed to it by default — the notification analogue of
|
|
356
|
-
// stamping `pluginName` on its tools.
|
|
357
|
-
//
|
|
358
|
-
|
|
345
|
+
// stamping `pluginName` on its tools. Wrapped ONCE per plugin (below), not per property read: this
|
|
346
|
+
// was a getter, so every `services.Notifier.notify(...)` minted a fresh three-method object. Swap
|
|
347
|
+
// safety is unaffected, because what is wrapped is the host's capture-safe proxy — both hosts put
|
|
348
|
+
// that stable object on the machine — so a registered distributed Notifier still takes effect.
|
|
349
|
+
Notifier: ownNotifier,
|
|
359
350
|
tools: {
|
|
360
351
|
register: registerTool,
|
|
361
352
|
remove: (name: string) => services.tools.remove(name),
|
|
@@ -420,16 +411,25 @@ export async function unloadPlugin(pluginName: string, services: MatbotMachine):
|
|
|
420
411
|
state.systemContextPlugins.delete(pluginName);
|
|
421
412
|
|
|
422
413
|
if (plugin.provider !== undefined) state.providers.delete(plugin.name);
|
|
423
|
-
for (const type of Object.keys(plugin.storage ?? {})) state.storage.delete(type);
|
|
424
414
|
|
|
425
415
|
state.frontendPlugins.delete(pluginName);
|
|
426
416
|
|
|
427
417
|
state.plugins.splice(idx, 1);
|
|
428
418
|
services.Notifier.notify({ kind: RegistryChangeKind, source: 'plugins', registry: 'plugins', name: pluginName, operation: 'removed' });
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
419
|
+
// The timer is cleared however the race settles. Left dangling it outlived every SUCCESSFUL unload,
|
|
420
|
+
// holding the event loop open for the rest of its 10s — so `plugin unload` followed by exit sat there
|
|
421
|
+
// with nothing to wait for.
|
|
422
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
423
|
+
try {
|
|
424
|
+
await Promise.race([
|
|
425
|
+
plugin.teardown?.(),
|
|
426
|
+
new Promise<void>((_, reject) => {
|
|
427
|
+
timer = setTimeout(() => reject(new Error(`Teardown timeout for plugin ${pluginName}`)), 10_000);
|
|
428
|
+
}),
|
|
429
|
+
]);
|
|
430
|
+
} finally {
|
|
431
|
+
if (timer !== undefined) clearTimeout(timer);
|
|
432
|
+
}
|
|
433
433
|
return true;
|
|
434
434
|
}
|
|
435
435
|
|
package/src/runner.ts
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
Session, Message, MessageContent, ModelContent, Usage, UsageSite, ProviderMeta, TruncatedToolResult,
|
|
3
3
|
TurnEvent, RunConfig, ProviderAdapter, ProviderConfig,
|
|
4
|
-
Tool, ToolRegistry, ToolContext, Store, FileStore, MediaStore, SystemContextRegistry, Vault, PromptFn,
|
|
4
|
+
Tool, ToolRegistry, ToolContext, Store, FileStore, MediaStore, SystemContextRegistry, Vault, PromptFn,
|
|
5
5
|
PermissionGate,
|
|
6
6
|
} from './types.js';
|
|
7
7
|
import type { MatbotPlugin } from './plugin.js';
|
|
8
8
|
import type { ToolPresenter } from '@matatbread/matbot-plugin-api';
|
|
9
|
-
import { recordSpan, recordUsage, withUsageSite } from '@matatbread/matbot-plugin-api/host';
|
|
9
|
+
import { recordSpan, recordUsage, withUsageSite, scopeIterable } from '@matatbread/matbot-plugin-api/host';
|
|
10
10
|
import { HookRegistry } from './hooks.js';
|
|
11
11
|
import { appendMessage, createMessage } from './session.js';
|
|
12
|
-
import { foldOntoUserTurn, bindPluginOps, bindGate } from '@matatbread/matbot-plugin-api';
|
|
12
|
+
import { foldOntoUserTurn, bindPluginOps, bindGate, defaultingPrompt } from '@matatbread/matbot-plugin-api';
|
|
13
13
|
import { addUsage } from './usage.js';
|
|
14
14
|
import { MEDIA_RESIDENCY_BYTES, resolveSessionMedia } from './media.js';
|
|
15
15
|
|
|
@@ -17,24 +17,13 @@ import { MEDIA_RESIDENCY_BYTES, resolveSessionMedia } from './media.js';
|
|
|
17
17
|
// runner is itself an async generator, so it suspends at every `yield` and resumes under the consumer's
|
|
18
18
|
// context, losing any scope entered around the loop body. Scoping each `next()` instead means the
|
|
19
19
|
// tool's body — and anything it kicks off while running — always resumes under its own site.
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
...(it.return ? { return: (v?: never) => withUsageSite(site, () => it.return!(v)) } : {}),
|
|
27
|
-
...(it.throw ? { throw: (e?: unknown) => withUsageSite(site, () => it.throw!(e)) } : {}),
|
|
28
|
-
};
|
|
29
|
-
},
|
|
30
|
-
};
|
|
31
|
-
}
|
|
20
|
+
//
|
|
21
|
+
// `scopeIterable` is the shared per-pull wrap, as used by `runAs` for the principal: this had its own
|
|
22
|
+
// object-literal copy, which replaced the iterator wholesale and so dropped a class-based tool
|
|
23
|
+
// iterator's own members, prototype and `instanceof`.
|
|
24
|
+
const siteScoped = <T>(site: UsageSite, src: AsyncIterable<T>): AsyncIterable<T> =>
|
|
25
|
+
scopeIterable(src, f => withUsageSite(site, f));
|
|
32
26
|
|
|
33
|
-
// Substituted for an errored tool result when the turn was aborted (e.g. a mid-turn steer interrupt):
|
|
34
|
-
// the tool was cut off, not genuinely faulty, and the raw abort reason ("Error: steer") is a leaked
|
|
35
|
-
// internal token that reads as a real failure. A steer's continuation turn is the one place a model
|
|
36
|
-
// reads this, so the wording tells it the call was interrupted and leaves re-running to its judgement —
|
|
37
|
-
// deliberately NOT "re-run it", so a side-effecting tool isn't reflexively repeated.
|
|
38
27
|
// Creator of the marker recording that a provider cut a response short. Marker-role, so it is durable
|
|
39
28
|
// and visible to a reader while elided from every submission: the model's own text is already truncated
|
|
40
29
|
// in the transcript, and a block telling it so invites narrating the cut-off rather than continuing.
|
|
@@ -45,6 +34,19 @@ const TRUNCATION_CREATOR = 'matbot-truncation';
|
|
|
45
34
|
const abortReason = (signal: AbortSignal): string =>
|
|
46
35
|
typeof signal.reason === 'string' ? signal.reason : 'user-abort';
|
|
47
36
|
|
|
37
|
+
/**
|
|
38
|
+
* A thrown value as a turn-terminal message, with its `cause` appended when it has one.
|
|
39
|
+
*
|
|
40
|
+
* A `throw` carries anything, so this narrows before reaching for `cause`: `'cause' in e` throws
|
|
41
|
+
* TypeError on a primitive, which made the catch handler ITSELF throw for a provider that rejected
|
|
42
|
+
* with a string — escaping the generator and skipping the `end()` commit that the branch calling this
|
|
43
|
+
* exists to perform. The one place in this package that took `any` rather than narrow.
|
|
44
|
+
*/
|
|
45
|
+
const errorText = (e: unknown): string => {
|
|
46
|
+
const cause = typeof e === 'object' && e !== null && 'cause' in e ? (e as { cause?: unknown }).cause : undefined;
|
|
47
|
+
return cause ? `${String(e)} (${String(cause)})` : String(e);
|
|
48
|
+
};
|
|
49
|
+
|
|
48
50
|
/** How long a tool executor may keep the turn alive AFTER the turn was aborted, before the runner stops
|
|
49
51
|
* reading it. An abort must abort: any executor can fail to end — a child process whose stdout was
|
|
50
52
|
* inherited by an orphan, a generator awaiting something that never settles, a bridged remote that went
|
|
@@ -73,6 +75,11 @@ function abandonDeadline(signal: AbortSignal, ms: number): { wait: Promise<typeo
|
|
|
73
75
|
};
|
|
74
76
|
}
|
|
75
77
|
|
|
78
|
+
// Substituted for an errored tool result when the turn was aborted (e.g. a mid-turn steer interrupt):
|
|
79
|
+
// the tool was cut off, not genuinely faulty, and the raw abort reason ("Error: steer") is a leaked
|
|
80
|
+
// internal token that reads as a real failure. A steer's continuation turn is the one place a model
|
|
81
|
+
// reads this, so the wording tells it the call was interrupted and leaves re-running to its judgement —
|
|
82
|
+
// deliberately NOT "re-run it", so a side-effecting tool isn't reflexively repeated.
|
|
76
83
|
const INTERRUPTED_TOOL_RESULT = {
|
|
77
84
|
error: 'Tool call interrupted before completion — the turn was interrupted while it was running. It may not have run to completion, and any side effect may or may not have occurred. Re-run it only if you still need its result.',
|
|
78
85
|
} as const;
|
|
@@ -137,12 +144,9 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
137
144
|
const { config, provider, providerConfig, store, signal } = opts;
|
|
138
145
|
const tools = opts.tools ?? new Map<string, Tool>();
|
|
139
146
|
const hookReg = opts.hooks ?? new HookRegistry();
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const label = typeof p === 'string' ? p : p.label;
|
|
144
|
-
return Promise.reject(new Error(`Non-interactive context: cannot prompt for "${label}"`));
|
|
145
|
-
}) as PromptFn);
|
|
147
|
+
// The stand-in answers with a field's own default where it has one — which is precisely why the gate
|
|
148
|
+
// below is handed `opts.prompt` raw instead (see bindGate).
|
|
149
|
+
const promptFn: PromptFn = opts.prompt ?? defaultingPrompt;
|
|
146
150
|
const vault: Vault = opts.vault ?? {
|
|
147
151
|
async createSecret() { throw new Error('No vault configured'); },
|
|
148
152
|
async writeSecret() { throw new Error('No vault configured'); },
|
|
@@ -408,7 +412,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
408
412
|
break;
|
|
409
413
|
}
|
|
410
414
|
}
|
|
411
|
-
} catch (e:
|
|
415
|
+
} catch (e: unknown) {
|
|
412
416
|
// A provider call cancelled by an in-situ restart (callAc, not the turn signal) is expected — fall
|
|
413
417
|
// through to the restart below rather than surfacing it as a turn abort or an error.
|
|
414
418
|
if (restart || (callAc.signal.aborted && !signal.aborted)) {
|
|
@@ -436,7 +440,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
436
440
|
// carries no session (a failure is not a transcript), so this yields the terminal itself rather
|
|
437
441
|
// than the session — but it commits first, exactly like every other exit.
|
|
438
442
|
bookRound();
|
|
439
|
-
yield* end({ type: 'error', error:
|
|
443
|
+
yield* end({ type: 'error', error: errorText(e), traceId });
|
|
440
444
|
return;
|
|
441
445
|
}
|
|
442
446
|
}
|
package/src/session-runner.ts
CHANGED
|
@@ -165,10 +165,16 @@ function createSink(dispose: () => void): Sink {
|
|
|
165
165
|
};
|
|
166
166
|
}
|
|
167
167
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
168
|
+
/**
|
|
169
|
+
* A tool's description with its wire contract appended — the text the model actually reads for every
|
|
170
|
+
* tool, every turn. Both blocks are fenced identically; the result block used to omit the newline after
|
|
171
|
+
* its label and before its fence, so the type opened inline against "TypeScript result:```" and the
|
|
172
|
+
* block never closed where a reader expects it.
|
|
173
|
+
*/
|
|
174
|
+
export function wireDescription(description: string, wc: { params: string; result: string } | undefined): string {
|
|
175
|
+
if (wc === undefined) return description;
|
|
176
|
+
const block = (label: string, type: string): string => `${label}:\n\`\`\`\n${type}\n\`\`\``;
|
|
177
|
+
return `${description}\n\n${block('TypeScript params', wc.params)}\n\n${block('TypeScript result', wc.result)}`;
|
|
172
178
|
}
|
|
173
179
|
|
|
174
180
|
export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
@@ -280,295 +286,288 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
280
286
|
// site, and spelling it here made it something the next caller of `machineBusy` could omit without
|
|
281
287
|
// any symptom — so it moved inside the hold it guards.
|
|
282
288
|
return machineBusy(async () => {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
289
|
+
try {
|
|
290
|
+
while (s.queue.length > 0) {
|
|
291
|
+
// Per-submission concat: the head always runs; if the head is a concat submission it absorbs
|
|
292
|
+
// the following submissions while they too are concat, stopping at the first non-concat (a turn
|
|
293
|
+
// boundary). Net effect: maximal runs of consecutive concat submissions merge into one turn,
|
|
294
|
+
// while every queued/robo submission stays its own ordered turn — concat and queued mix freely.
|
|
295
|
+
const head = s.queue.shift()!;
|
|
296
|
+
const batch = [head];
|
|
297
|
+
if (head.concatQueue) {
|
|
298
|
+
while (s.queue.length > 0 && s.queue[0]!.concatQueue) batch.push(s.queue.shift()!);
|
|
299
|
+
}
|
|
300
|
+
const content = batch.flatMap(i => i.content);
|
|
301
|
+
s.runningTraceId = head.traceId;
|
|
302
|
+
s.replay = [];
|
|
303
|
+
// Seed replay with the running turn's user message as a single merged `queued`, mirroring the
|
|
304
|
+
// message persisted just below. notify() (in open()) reaches only subscribers that are live at
|
|
305
|
+
// enqueue time; a GET /events/sessions/:id that connects after this synchronous preamble — the
|
|
306
|
+
// common case when the submit POST wins the race against the events stream — would otherwise
|
|
307
|
+
// find an empty queue and a cleared replay, and never render the user bubble. One merged event
|
|
308
|
+
// (not one per batch item) matches both stored history on reload and the live fold, so a late
|
|
309
|
+
// subscriber reconstructs exactly one bubble. A redo carries no new user message (and no
|
|
310
|
+
// content), so it seeds nothing — its retraction marker already went out at enqueue time.
|
|
311
|
+
if (head.redo === undefined) {
|
|
312
|
+
s.replay.push({ type: 'queued', content, queued: 0, concatQueue: false, traceId: head.traceId, rootTraceId: head.rootTraceId });
|
|
313
|
+
}
|
|
308
314
|
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
315
|
+
let session = await deps.store.get(id);
|
|
316
|
+
if (session === null) {
|
|
317
|
+
emit(s, { type: 'error', error: `Session "${id}" not found`, traceId: head.traceId });
|
|
318
|
+
continue;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// A redo re-runs the existing committed user turn — no title derivation, no new user message.
|
|
322
|
+
if (head.redo === undefined) {
|
|
323
|
+
if (!session.title && !session.messages.some(m => m.role === 'user')) {
|
|
324
|
+
// Falls back to the attachment names when the turn has no words of its own: an image-only
|
|
325
|
+
// first turn is a real submission, and got no title at all — which reads in the session
|
|
326
|
+
// list as a session that failed to start.
|
|
327
|
+
const text = content
|
|
328
|
+
.filter((c): c is Extract<MessageContent, { type: 'text' }> => c.type === 'text')
|
|
329
|
+
.map(c => c.text).join(' ').trim()
|
|
330
|
+
|| content
|
|
331
|
+
.filter((c): c is Extract<MessageContent, { type: 'file-ref' }> => c.type === 'file-ref')
|
|
332
|
+
.map(c => c.name).join(', ');
|
|
333
|
+
if (text) {
|
|
334
|
+
const words = text.split(/\s+/).slice(0, 8).join(' ');
|
|
335
|
+
session = { ...session, title: words.length > 60 ? `${words.slice(0, 60)}…` : words };
|
|
336
|
+
}
|
|
337
|
+
}
|
|
314
338
|
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
session = { ...session, title: words.length > 60 ? `${words.slice(0, 60)}…` : words };
|
|
339
|
+
// Persist-at-turn-start: the user message only hits the store when its turn begins, never
|
|
340
|
+
// while queued. That is what stops a mid-turn submit from clobbering session state. A robo
|
|
341
|
+
// resubmission's blocks already carry `origin: 'robo'` (stamped where it was enqueued).
|
|
342
|
+
session = appendMessage(session, createMessage({ role: 'user', content, traceId: head.traceId, providerName: head.provider }));
|
|
343
|
+
try {
|
|
344
|
+
await deps.store.set(session.id, session);
|
|
345
|
+
} catch (e) {
|
|
346
|
+
// A read-only store rejects the persist-at-turn-start write (e.g. a session shared read-only
|
|
347
|
+
// from another profile's partition). That is a per-turn condition, not a host fault: surface
|
|
348
|
+
// it as a turn error and drop this submission rather than let it escape the detached pump and
|
|
349
|
+
// crash the process. Any other write failure stays fatal.
|
|
350
|
+
if (!isReadOnlyError(e)) throw e;
|
|
351
|
+
emit(s, { type: 'error', error: e.message, traceId: head.traceId });
|
|
352
|
+
continue;
|
|
330
353
|
}
|
|
331
354
|
}
|
|
332
355
|
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
session = appendMessage(session, createMessage({ role: 'user', content, traceId: head.traceId, providerName: head.provider }));
|
|
337
|
-
try {
|
|
338
|
-
await deps.store.set(session.id, session);
|
|
339
|
-
} catch (e) {
|
|
340
|
-
// A read-only store rejects the persist-at-turn-start write (e.g. a session shared read-only
|
|
341
|
-
// from another profile's partition). That is a per-turn condition, not a host fault: surface
|
|
342
|
-
// it as a turn error and drop this submission rather than let it escape the detached pump and
|
|
343
|
-
// crash the process. Any other write failure stays fatal.
|
|
344
|
-
if (!isReadOnlyError(e)) throw e;
|
|
345
|
-
emit(s, { type: 'error', error: e.message, traceId: head.traceId });
|
|
356
|
+
const resolved = await deps.resolveProvider(head.provider);
|
|
357
|
+
if (resolved === null) {
|
|
358
|
+
emit(s, { type: 'error', error: `Unknown provider "${head.provider}"`, traceId: head.traceId });
|
|
346
359
|
continue;
|
|
347
360
|
}
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
const resolved = await deps.resolveProvider(head.provider);
|
|
351
|
-
if (resolved === null) {
|
|
352
|
-
emit(s, { type: 'error', error: `Unknown provider "${head.provider}"`, traceId: head.traceId });
|
|
353
|
-
continue;
|
|
354
|
-
}
|
|
355
361
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
try {
|
|
381
|
-
// Establish the submitter's principal for the whole turn here, not inside runSession:
|
|
382
|
-
// pump runs detached (`void pump`), so this scope — not the request that enqueued — is the
|
|
383
|
-
// turn's async root. Everything downstream (hooks, tools, and any Store/FileStore/Vault
|
|
384
|
-
// access they trigger) reads it via currentPrincipal(). The consumption is inside the callback
|
|
385
|
-
// because the pump owns it, not because it must be: runAs rescopes a returned iterator itself.
|
|
386
|
-
// runAs, not a context switch of its own: the pump holds the machine across the whole queue
|
|
387
|
-
// (see above), so a turn declares only its owner. Each item carries its own submitter.
|
|
388
|
-
// The terminal event this turn ended on. `followup` is post-COMMIT, and only `done` is a
|
|
389
|
-
// commit: an `aborted` turn was cut short (a steer, a user cancel, a screen/toolcall hook
|
|
390
|
-
// refusing it, a provider round ceiling) and an `error` turn produced no response at all, so
|
|
391
|
-
// in both cases a followup hook would judge history with no completed answer in it — and a
|
|
392
|
-
// `resubmit` would undo the very stop that just happened, handing a round ceiling a fresh
|
|
393
|
-
// budget. Tracked here rather than inferred from `ac.signal`, which only knows about the two
|
|
394
|
-
// signal-driven aborts and not the policy ones.
|
|
395
|
-
let terminal: PipelineEvent['type'] | undefined;
|
|
396
|
-
// One usage scope per pump ITERATION rather than per turn. `followup` is post-commit by
|
|
397
|
-
// definition and a detached classifier settles whenever it settles, so a scope that ended at
|
|
398
|
-
// the turn's commit would drop precisely the spend that is hardest to account for otherwise.
|
|
399
|
-
// The entries array is registered for flush before any work starts, so a late arrival lands
|
|
400
|
-
// in something already tracked and needs no coordination of its own.
|
|
401
|
-
await withUsageScope(async usage => {
|
|
402
|
-
s.pendingUsage.push(usage.entries);
|
|
403
|
-
await runAs(head.principal, async () => {
|
|
404
|
-
for await (const ev of runSession({
|
|
405
|
-
session,
|
|
406
|
-
config: { provider: head.provider, traceId: head.traceId },
|
|
407
|
-
provider: resolved.adapter,
|
|
408
|
-
providerConfig: resolved.config,
|
|
409
|
-
store: deps.store,
|
|
410
|
-
signal: ac.signal,
|
|
411
|
-
loadPlugin: deps.loadPlugin,
|
|
412
|
-
unloadPlugin: deps.unloadPlugin,
|
|
413
|
-
...(toolMap !== undefined ? { tools: toolMap } : {}),
|
|
414
|
-
...(deps.tools !== undefined ? { toolRegistry: deps.tools } : {}),
|
|
415
|
-
...(toolPresenter !== undefined ? { toolPresenter } : {}),
|
|
416
|
-
...(deps.hooks !== undefined ? { hooks: deps.hooks } : {}),
|
|
417
|
-
...(deps.systemContext !== undefined ? { systemContext: deps.systemContext } : {}),
|
|
418
|
-
...(deps.workdir !== undefined ? { workdir: deps.workdir } : {}),
|
|
419
|
-
...(deps.files !== undefined ? { files: deps.files } : {}),
|
|
420
|
-
...(mediaStore !== undefined ? { mediaStore } : {}),
|
|
421
|
-
...(permissionGate !== undefined ? { permissionGate } : {}),
|
|
422
|
-
...(deps.configPath !== undefined ? { configPath: deps.configPath } : {}),
|
|
423
|
-
...(deps.vault !== undefined ? { vault: deps.vault } : {}),
|
|
424
|
-
...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
|
|
425
|
-
...(inject !== undefined ? { injectedEphemeral: inject } : {}),
|
|
426
|
-
})) {
|
|
427
|
-
if (ev.type === 'done' || ev.type === 'aborted' || ev.type === 'error') terminal = ev.type;
|
|
428
|
-
emit(s, ev);
|
|
429
|
-
}
|
|
430
|
-
});
|
|
362
|
+
// A redo re-runs an existing turn with its ephemeral; a steer carries an interrupt's nudge. The
|
|
363
|
+
// two never coincide on one item, so either supplies runSession's injectedEphemeral.
|
|
364
|
+
const inject = head.redo?.ephemeral ?? head.injectEphemeral;
|
|
365
|
+
|
|
366
|
+
const ac = new AbortController();
|
|
367
|
+
s.ac = ac;
|
|
368
|
+
// Fold each tool's wire contract — the `params`/`result` text flattened from its single contract
|
|
369
|
+
// (a source tool's ToolContracts arms, or a source-less tool's `toolContract`) — into its description
|
|
370
|
+
// for this turn, so the model reasons about the real TS shapes, not just the loose inputSchema.
|
|
371
|
+
// Derived here because `services`/ToolTypeIndex are live at dispatch. Tools with only a loose
|
|
372
|
+
// inputSchema (no contract) are left as-is; absent ToolTypeIndex (browser) ⇒ plain descriptions.
|
|
373
|
+
const wire = await deps.toolTypeIndex?.()?.wireContracts();
|
|
374
|
+
const toolPresenter = deps.toolPresenter?.();
|
|
375
|
+
const mediaStore = deps.mediaStore?.();
|
|
376
|
+
const permissionGate = deps.permissionGate?.();
|
|
377
|
+
const toolMap = deps.tools !== undefined
|
|
378
|
+
? new Map<string, Tool>(deps.tools.list().map(t => {
|
|
379
|
+
const wc = wire?.[t.name];
|
|
380
|
+
return [t.name, wc !== undefined
|
|
381
|
+
? { ...t, description: wireDescription(t.description, wc) }
|
|
382
|
+
: t];
|
|
383
|
+
}))
|
|
384
|
+
: undefined;
|
|
431
385
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
386
|
+
try {
|
|
387
|
+
// Establish the submitter's principal for the whole turn here, not inside runSession:
|
|
388
|
+
// pump runs detached (`void pump`), so this scope — not the request that enqueued — is the
|
|
389
|
+
// turn's async root. Everything downstream (hooks, tools, and any Store/FileStore/Vault
|
|
390
|
+
// access they trigger) reads it via currentPrincipal(). The consumption is inside the callback
|
|
391
|
+
// because the pump owns it, not because it must be: runAs rescopes a returned iterator itself.
|
|
392
|
+
// runAs, not a context switch of its own: the pump holds the machine across the whole queue
|
|
393
|
+
// (see above), so a turn declares only its owner. Each item carries its own submitter.
|
|
394
|
+
// The terminal event this turn ended on. `followup` is post-COMMIT, and only `done` is a
|
|
395
|
+
// commit: an `aborted` turn was cut short (a steer, a user cancel, a screen/toolcall hook
|
|
396
|
+
// refusing it, a provider round ceiling) and an `error` turn produced no response at all, so
|
|
397
|
+
// in both cases a followup hook would judge history with no completed answer in it — and a
|
|
398
|
+
// `resubmit` would undo the very stop that just happened, handing a round ceiling a fresh
|
|
399
|
+
// budget. Tracked here rather than inferred from `ac.signal`, which only knows about the two
|
|
400
|
+
// signal-driven aborts and not the policy ones.
|
|
401
|
+
let terminal: PipelineEvent['type'] | undefined;
|
|
402
|
+
// One usage scope per pump ITERATION rather than per turn. `followup` is post-commit by
|
|
403
|
+
// definition and a detached classifier settles whenever it settles, so a scope that ended at
|
|
404
|
+
// the turn's commit would drop precisely the spend that is hardest to account for otherwise.
|
|
405
|
+
// The entries array is registered for flush before any work starts, so a late arrival lands
|
|
406
|
+
// in something already tracked and needs no coordination of its own.
|
|
407
|
+
await withUsageScope(async usage => {
|
|
408
|
+
s.pendingUsage.push(usage.entries);
|
|
441
409
|
await runAs(head.principal, async () => {
|
|
442
|
-
|
|
443
|
-
session
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
410
|
+
for await (const ev of runSession({
|
|
411
|
+
session,
|
|
412
|
+
config: { provider: head.provider, traceId: head.traceId },
|
|
413
|
+
provider: resolved.adapter,
|
|
414
|
+
providerConfig: resolved.config,
|
|
415
|
+
store: deps.store,
|
|
416
|
+
signal: ac.signal,
|
|
417
|
+
loadPlugin: deps.loadPlugin,
|
|
418
|
+
unloadPlugin: deps.unloadPlugin,
|
|
419
|
+
...(toolMap !== undefined ? { tools: toolMap } : {}),
|
|
420
|
+
...(deps.tools !== undefined ? { toolRegistry: deps.tools } : {}),
|
|
421
|
+
...(toolPresenter !== undefined ? { toolPresenter } : {}),
|
|
422
|
+
...(deps.hooks !== undefined ? { hooks: deps.hooks } : {}),
|
|
423
|
+
...(deps.systemContext !== undefined ? { systemContext: deps.systemContext } : {}),
|
|
424
|
+
...(deps.workdir !== undefined ? { workdir: deps.workdir } : {}),
|
|
425
|
+
...(deps.files !== undefined ? { files: deps.files } : {}),
|
|
426
|
+
...(mediaStore !== undefined ? { mediaStore } : {}),
|
|
427
|
+
...(permissionGate !== undefined ? { permissionGate } : {}),
|
|
428
|
+
...(deps.configPath !== undefined ? { configPath: deps.configPath } : {}),
|
|
429
|
+
...(deps.vault !== undefined ? { vault: deps.vault } : {}),
|
|
430
|
+
...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
|
|
431
|
+
...(inject !== undefined ? { injectedEphemeral: inject } : {}),
|
|
432
|
+
})) {
|
|
433
|
+
if (ev.type === 'done' || ev.type === 'aborted' || ev.type === 'error') terminal = ev.type;
|
|
434
|
+
emit(s, ev);
|
|
435
|
+
}
|
|
449
436
|
});
|
|
450
|
-
const resubmits = followup.resubmits;
|
|
451
|
-
|
|
452
|
-
// Durable markers a followup hook returned (e.g. a fired trigger's silent tool tracing
|
|
453
|
-
// what it did). Append-only to the just-committed session; the pump is the sole writer
|
|
454
|
-
// post-commit, so an unconditional set is safe. Emitted live too (post-`done`, like a
|
|
455
|
-
// `queued` event) so a live draw matches a reload — the engine surfaces everything it
|
|
456
|
-
// persists; a frontend filters if it wants to. Skipped when retracting: the pop rewrites
|
|
457
|
-
// the message tail, so these markers are folded into that single write instead (below)
|
|
458
|
-
// to keep ordering sane (a separate append here would be sliced into the popped region).
|
|
459
|
-
if (followup.markers.length > 0 && !followup.retract) {
|
|
460
|
-
await deps.store.set(id, {
|
|
461
|
-
...committed,
|
|
462
|
-
messages: [...committed.messages, {
|
|
463
|
-
id: crypto.randomUUID(),
|
|
464
|
-
role: 'marker',
|
|
465
|
-
createdAt: new Date().toISOString(),
|
|
466
|
-
traceId: head.traceId,
|
|
467
|
-
content: followup.markers,
|
|
468
|
-
}],
|
|
469
|
-
});
|
|
470
|
-
notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
|
|
471
|
-
}
|
|
472
|
-
// unshift in reverse so the hooks' order is preserved at the head of the queue. Stamp
|
|
473
|
-
// every block `origin: 'robo'` here — once — so it rides into both the live `queued`
|
|
474
|
-
// event and the persisted user message the pump builds from this item.
|
|
475
|
-
for (const raw of resubmits.reverse()) {
|
|
476
|
-
const rt = crypto.randomUUID();
|
|
477
|
-
// Every block, not just text: `origin` is authorship, orthogonal to what the block
|
|
478
|
-
// carries, and a resubmission that attached an image was silently presenting it as the
|
|
479
|
-
// user's own. The arms a stamp would be wrong on don't occur here — a hook returns
|
|
480
|
-
// content, not a thinking block — so a blanket stamp beats an arm-by-arm list.
|
|
481
|
-
const content = raw.map(c => ({ ...c, origin: 'robo' as const }));
|
|
482
|
-
s.queue.unshift({
|
|
483
|
-
traceId: rt,
|
|
484
|
-
rootTraceId: head.rootTraceId,
|
|
485
|
-
content,
|
|
486
|
-
provider: head.provider,
|
|
487
|
-
principal: head.principal,
|
|
488
|
-
concatQueue: false,
|
|
489
|
-
resubmitDepth: head.resubmitDepth + 1,
|
|
490
|
-
});
|
|
491
|
-
notify(s, { type: 'queued', content, queued: 0, concatQueue: false, traceId: rt, rootTraceId: head.rootTraceId });
|
|
492
|
-
}
|
|
493
437
|
|
|
494
|
-
//
|
|
495
|
-
//
|
|
496
|
-
//
|
|
497
|
-
//
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
//
|
|
519
|
-
//
|
|
520
|
-
//
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
438
|
+
// followup — post-commit, in the queue owner. A hook reads the just-committed turn and may
|
|
439
|
+
// head-enqueue a robo follow-up (its own real turn, running next). Runs only for a turn that
|
|
440
|
+
// committed (see `terminal`), and under the submitter's principal because a reactor may itself
|
|
441
|
+
// call complete() (a classifier).
|
|
442
|
+
const hooks = deps.hooks;
|
|
443
|
+
if (terminal === 'done' && hooks) {
|
|
444
|
+
const committed = await deps.store.get(id);
|
|
445
|
+
if (committed && head.resubmitDepth < MAX_RESUBMIT_DEPTH) {
|
|
446
|
+
let followup: { resubmits: MessageContent[][]; markers: MessageContent[]; retract?: { context: MessageContent[]; durable: MessageContent[] } } = { resubmits: [], markers: [] };
|
|
447
|
+
await runAs(head.principal, async () => {
|
|
448
|
+
followup = await hooks.runFollowup({
|
|
449
|
+
session: committed,
|
|
450
|
+
resubmitDepth: head.resubmitDepth,
|
|
451
|
+
config: { provider: head.provider, traceId: head.traceId },
|
|
452
|
+
signal: ac.signal,
|
|
453
|
+
...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
|
|
454
|
+
});
|
|
455
|
+
});
|
|
456
|
+
const resubmits = followup.resubmits;
|
|
457
|
+
|
|
458
|
+
// Durable markers a followup hook returned (e.g. a fired trigger's silent tool tracing
|
|
459
|
+
// what it did). Append-only to the just-committed session; the pump is the sole writer
|
|
460
|
+
// post-commit, so an unconditional set is safe. Emitted live too (post-`done`, like a
|
|
461
|
+
// `queued` event) so a live draw matches a reload — the engine surfaces everything it
|
|
462
|
+
// persists; a frontend filters if it wants to. Skipped when retracting: the pop rewrites
|
|
463
|
+
// the message tail, so these markers are folded into that single write instead (below)
|
|
464
|
+
// to keep ordering sane (a separate append here would be sliced into the popped region).
|
|
465
|
+
if (followup.markers.length > 0 && !followup.retract) {
|
|
466
|
+
await deps.store.set(id, {
|
|
467
|
+
...committed,
|
|
468
|
+
messages: [...committed.messages,
|
|
469
|
+
createMessage({ role: 'marker', content: followup.markers, traceId: head.traceId })],
|
|
470
|
+
});
|
|
471
|
+
notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
|
|
472
|
+
}
|
|
473
|
+
// unshift in reverse so the hooks' order is preserved at the head of the queue. Stamp
|
|
474
|
+
// every block `origin: 'robo'` here — once — so it rides into both the live `queued`
|
|
475
|
+
// event and the persisted user message the pump builds from this item.
|
|
476
|
+
for (const raw of resubmits.reverse()) {
|
|
477
|
+
const rt = crypto.randomUUID();
|
|
478
|
+
// Every block, not just text: `origin` is authorship, orthogonal to what the block
|
|
479
|
+
// carries, and a resubmission that attached an image was silently presenting it as the
|
|
480
|
+
// user's own. The arms a stamp would be wrong on don't occur here — a hook returns
|
|
481
|
+
// content, not a thinking block — so a blanket stamp beats an arm-by-arm list.
|
|
482
|
+
const content = raw.map(c => ({ ...c, origin: 'robo' as const }));
|
|
483
|
+
s.queue.unshift({
|
|
484
|
+
traceId: rt,
|
|
485
|
+
rootTraceId: head.rootTraceId,
|
|
486
|
+
content,
|
|
487
|
+
provider: head.provider,
|
|
488
|
+
principal: head.principal,
|
|
489
|
+
concatQueue: false,
|
|
490
|
+
resubmitDepth: head.resubmitDepth + 1,
|
|
491
|
+
});
|
|
492
|
+
notify(s, { type: 'queued', content, queued: 0, concatQueue: false, traceId: rt, rootTraceId: head.rootTraceId });
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
// retract-and-rerun: pop the just-committed turn back to (and excluding) the last user
|
|
496
|
+
// message, stash the popped messages in a durable retraction marker (LLM-elided like any
|
|
497
|
+
// marker — so the model never re-reads its superseded answer — but carried in `data` for
|
|
498
|
+
// a strike-through render and audit), then re-enqueue a redo of that same user turn at the
|
|
499
|
+
// head, delivering the trigger tool's output as ephemeral context. Unshifted last so it
|
|
500
|
+
// sits at the very head (runs next) even if a resubmit was also queued above.
|
|
501
|
+
if (followup.retract) {
|
|
502
|
+
const lastUserIdx = lastUserIndex(committed);
|
|
503
|
+
const popped = lastUserIdx >= 0 ? committed.messages.slice(lastUserIdx + 1) : [];
|
|
504
|
+
// `durable` correction (a contextual fire that landed post-commit) folds onto the user
|
|
505
|
+
// message we keep — persisted, so the redo AND every later turn see it in history, the
|
|
506
|
+
// exact durability the clean in-situ path gives. `context` stays ephemeral (redo only).
|
|
507
|
+
// The fold runs on the *truncated* session, whose last user message is the one we keep,
|
|
508
|
+
// so it goes through the same helper as the screen and raced-verdict folds.
|
|
509
|
+
const durable = followup.retract.durable ?? [];
|
|
510
|
+
const truncated = lastUserIdx >= 0
|
|
511
|
+
? { ...committed, messages: committed.messages.slice(0, lastUserIdx + 1) }
|
|
512
|
+
: committed;
|
|
513
|
+
const kept = foldOntoUserTurn(truncated, durable).messages;
|
|
514
|
+
const retractionMsg: Message = createMessage({
|
|
515
|
+
role: 'marker',
|
|
516
|
+
traceId: head.traceId,
|
|
517
|
+
// `retracted` is what was popped (the superseded answer); `injected` is the ephemeral
|
|
518
|
+
// context fed to the redo — neither is otherwise persisted, so the pair fully traces
|
|
519
|
+
// the swap for a post-mortem (and a frontend can render the strike-through + the cause).
|
|
520
|
+
content: [{ type: 'marker', creator: RETRACTION_CREATOR, data: { retracted: popped, injected: followup.retract.context ?? [], traceId: head.traceId } }],
|
|
521
|
+
});
|
|
522
|
+
// Any followup markers ride along as a trailing marker message (not folded into the
|
|
523
|
+
// retraction marker) so each creator's trace stays its own block.
|
|
524
|
+
const trailing: Message[] = followup.markers.length > 0
|
|
525
|
+
? [createMessage({ role: 'marker', content: followup.markers, traceId: head.traceId })]
|
|
526
|
+
: [];
|
|
527
|
+
await deps.store.set(id, { ...committed, messages: [...kept, retractionMsg, ...trailing] });
|
|
528
|
+
// Emit the durable fold live (as a robo-user on the kept user turn) before the retraction
|
|
529
|
+
// marker, mirroring the screen-time durable path's ordering.
|
|
530
|
+
if (durable.length > 0) notify(s, { type: 'robo-user', content: durable, traceId: head.traceId });
|
|
531
|
+
notify(s, { type: 'marker', content: retractionMsg.content, traceId: head.traceId });
|
|
532
|
+
if (trailing.length > 0) notify(s, { type: 'marker', content: followup.markers, traceId: head.traceId });
|
|
533
|
+
|
|
534
|
+
const rt = crypto.randomUUID();
|
|
535
|
+
s.queue.unshift({
|
|
536
|
+
traceId: rt,
|
|
537
|
+
rootTraceId: head.rootTraceId,
|
|
538
|
+
content: [],
|
|
539
|
+
provider: head.provider,
|
|
540
|
+
principal: head.principal,
|
|
541
|
+
concatQueue: false,
|
|
542
|
+
resubmitDepth: head.resubmitDepth + 1,
|
|
543
|
+
redo: { ephemeral: followup.retract.context ?? [] },
|
|
544
|
+
...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
}
|
|
547
548
|
}
|
|
548
|
-
}
|
|
549
|
+
}, { traceId: head.traceId, rootTraceId: head.rootTraceId });
|
|
550
|
+
} catch (e) {
|
|
551
|
+
emit(s, { type: 'error', error: String(e), traceId: head.traceId });
|
|
552
|
+
} finally {
|
|
553
|
+
s.ac = undefined;
|
|
549
554
|
}
|
|
550
|
-
}, { traceId: head.traceId, rootTraceId: head.rootTraceId });
|
|
551
|
-
} catch (e) {
|
|
552
|
-
emit(s, { type: 'error', error: String(e), traceId: head.traceId });
|
|
553
|
-
} finally {
|
|
554
|
-
s.ac = undefined;
|
|
555
555
|
}
|
|
556
|
+
} finally {
|
|
557
|
+
s.running = false;
|
|
558
|
+
s.runningTraceId = undefined;
|
|
559
|
+
s.replay = [];
|
|
560
|
+
// Accounting is flushed HERE — at the drained queue, not at a turn boundary. "The end of a turn"
|
|
561
|
+
// is not a well-defined moment to total anything at: steers terminate and resume, a retract
|
|
562
|
+
// re-enqueues the turn it just popped, followup enqueues resubmissions, and a detached classifier
|
|
563
|
+
// settles whenever it settles. The queue draining is unambiguous, and it is after all of them.
|
|
564
|
+
await flushUsage(id, s);
|
|
565
|
+
// Deterministic busy→idle signal: running is now false, so any subscriber draining the stream
|
|
566
|
+
// (a frontend's status tracker) reads an authoritative idle the moment it sees this — no racing
|
|
567
|
+
// the microtask on which `running` flipped. Not in `replay` (transient lifecycle, not history).
|
|
568
|
+
notify(s, { type: 'idle', sessionId: id });
|
|
569
|
+
maybeCleanup(id, s);
|
|
556
570
|
}
|
|
557
|
-
} finally {
|
|
558
|
-
s.running = false;
|
|
559
|
-
s.runningTraceId = undefined;
|
|
560
|
-
s.replay = [];
|
|
561
|
-
// Accounting is flushed HERE — at the drained queue, not at a turn boundary. "The end of a turn"
|
|
562
|
-
// is not a well-defined moment to total anything at: steers terminate and resume, a retract
|
|
563
|
-
// re-enqueues the turn it just popped, followup enqueues resubmissions, and a detached classifier
|
|
564
|
-
// settles whenever it settles. The queue draining is unambiguous, and it is after all of them.
|
|
565
|
-
await flushUsage(id, s);
|
|
566
|
-
// Deterministic busy→idle signal: running is now false, so any subscriber draining the stream
|
|
567
|
-
// (a frontend's status tracker) reads an authoritative idle the moment it sees this — no racing
|
|
568
|
-
// the microtask on which `running` flipped. Not in `replay` (transient lifecycle, not history).
|
|
569
|
-
notify(s, { type: 'idle', sessionId: id });
|
|
570
|
-
maybeCleanup(id, s);
|
|
571
|
-
}
|
|
572
571
|
});
|
|
573
572
|
};
|
|
574
573
|
|
package/src/session.ts
CHANGED
|
@@ -1,4 +1,8 @@
|
|
|
1
|
-
import type { Session, Message
|
|
1
|
+
import type { Session, Message } from './types.js';
|
|
2
|
+
|
|
3
|
+
// `createMessage` lives in plugin-api, where the hook registry can also reach it (core is downstream of
|
|
4
|
+
// that package). Re-exported here so it stays where every consumer already imports it from.
|
|
5
|
+
export { createMessage } from '@matatbread/matbot-plugin-api';
|
|
2
6
|
|
|
3
7
|
export interface CreateSessionOpts {
|
|
4
8
|
title?: string;
|
|
@@ -32,20 +36,3 @@ export function appendMessage(session: Session, message: Message): Session {
|
|
|
32
36
|
};
|
|
33
37
|
}
|
|
34
38
|
|
|
35
|
-
export function createMessage(opts: {
|
|
36
|
-
role: MessageRole;
|
|
37
|
-
content: MessageContent[];
|
|
38
|
-
traceId: string;
|
|
39
|
-
providerName?: string;
|
|
40
|
-
metadata?: Record<string, unknown>;
|
|
41
|
-
}): Message {
|
|
42
|
-
return {
|
|
43
|
-
id: crypto.randomUUID(),
|
|
44
|
-
role: opts.role,
|
|
45
|
-
content: opts.content,
|
|
46
|
-
createdAt: new Date().toISOString(),
|
|
47
|
-
traceId: opts.traceId,
|
|
48
|
-
...(opts.providerName !== undefined ? { providerName: opts.providerName } : {}),
|
|
49
|
-
...(opts.metadata !== undefined ? { metadata: opts.metadata } : {}),
|
|
50
|
-
};
|
|
51
|
-
}
|
package/src/settings.ts
CHANGED
|
@@ -70,6 +70,9 @@ export function settingsDefaultNamespaces(): readonly string[] {
|
|
|
70
70
|
* consumer filters `kind === ItemChangeKind && namespace === SETTINGS_NAMESPACE && id === slug(mine)`. */
|
|
71
71
|
export const SETTINGS_NAMESPACE = 'settings';
|
|
72
72
|
|
|
73
|
+
/** How many times a settings write re-reads and re-compares before giving up. See `update` below. */
|
|
74
|
+
const SETTINGS_CAS_ATTEMPTS = 8;
|
|
75
|
+
|
|
73
76
|
/**
|
|
74
77
|
* The bus settings writes announce on, installed by the host at boot alongside {@link installSettingsDefaults}.
|
|
75
78
|
*
|
|
@@ -134,8 +137,14 @@ export function makePluginSettings(rawStore: Store<SettingsDoc>, namespace: stri
|
|
|
134
137
|
|
|
135
138
|
// One CAS retry loop for both writers: read, apply `mutate`, write. A document that vanished between
|
|
136
139
|
// read and write (or never existed) is a plain `set`; otherwise `cas` guards the version we read.
|
|
140
|
+
//
|
|
141
|
+
// Bounded, because the loop cannot tell a lost race from a `cas` that will never succeed: a backend
|
|
142
|
+
// whose compare always fails (a version it does not mint, a medium generation it cannot reconcile)
|
|
143
|
+
// turned this into a hot spin with no error and no exit. A settings namespace is one document with few
|
|
144
|
+
// writers, so real contention resolves in one or two passes; exhausting the budget is a fault to
|
|
145
|
+
// report, not a state to keep retrying.
|
|
137
146
|
const update = async (mutate: (data: Record<string, unknown>) => Record<string, unknown> | undefined): Promise<void> => {
|
|
138
|
-
for (
|
|
147
|
+
for (let attempt = 1; ; attempt++) {
|
|
139
148
|
const doc = await getDoc();
|
|
140
149
|
const data = mutate(doc?.data ?? {});
|
|
141
150
|
if (data === undefined) return;
|
|
@@ -143,6 +152,13 @@ export function makePluginSettings(rawStore: Store<SettingsDoc>, namespace: stri
|
|
|
143
152
|
if (doc === null) { await store.set(id, next); return; }
|
|
144
153
|
const r = await store.cas(id, doc.version, next);
|
|
145
154
|
if (r.ok) return;
|
|
155
|
+
if (attempt >= SETTINGS_CAS_ATTEMPTS) {
|
|
156
|
+
throw new Error(
|
|
157
|
+
`Settings for "${namespace}" could not be written: ${SETTINGS_CAS_ATTEMPTS} compare-and-swap ` +
|
|
158
|
+
'attempts all lost. Either something is writing this namespace continuously, or the storage ' +
|
|
159
|
+
'backend is rejecting every compare.',
|
|
160
|
+
);
|
|
161
|
+
}
|
|
146
162
|
}
|
|
147
163
|
};
|
|
148
164
|
|