@matatbread/matbot-core 0.3.8 → 0.4.1
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 +13 -2
- package/src/hooks.ts +1 -1
- package/src/index.ts +19 -14
- package/src/plugin.ts +4 -0
- package/src/registry.ts +47 -10
- package/src/runner.ts +191 -57
- package/src/session-runner.ts +22 -7
- package/src/session.ts +0 -2
- package/src/settings.ts +33 -27
- package/src/types.ts +0 -12
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.
|
|
7
|
+
"version": "0.4.1",
|
|
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.
|
|
38
|
+
"@matatbread/matbot-plugin-api": "^0.4.1"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^6.0.3"
|
package/src/config/loader.ts
CHANGED
|
@@ -26,6 +26,11 @@ function asRecord(v: YamlValue | undefined, label: string): YamlMap {
|
|
|
26
26
|
throw new Error(`Config: expected mapping for "${label}", got ${v === undefined ? 'undefined' : typeof v}`);
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
function asNumber(v: YamlValue | undefined, label: string): number {
|
|
30
|
+
if (typeof v === 'number' && Number.isFinite(v)) return v;
|
|
31
|
+
throw new Error(`Config: expected number for "${label}", got ${v === undefined ? 'undefined' : typeof v}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
29
34
|
function toModelParameters(raw: YamlMap): ModelParameters {
|
|
30
35
|
const params: ModelParameters = {};
|
|
31
36
|
for (const [k, v] of Object.entries(raw)) {
|
|
@@ -52,8 +57,14 @@ function toProviderConfig(name: string, raw: YamlMap): ProviderConfig {
|
|
|
52
57
|
if (raw['endpoint'] !== undefined) {
|
|
53
58
|
config.endpoint = asString(raw['endpoint'], `providers.${name}.endpoint`);
|
|
54
59
|
}
|
|
55
|
-
if (raw['
|
|
56
|
-
|
|
60
|
+
if (raw['maxRounds'] !== undefined) {
|
|
61
|
+
const rounds = asNumber(raw['maxRounds'], `providers.${name}.maxRounds`);
|
|
62
|
+
// Rejected at the boundary rather than clamped: 0 or a fraction is a typo, and silently treating it
|
|
63
|
+
// as "no turn may do anything" would look like the provider was broken.
|
|
64
|
+
if (!Number.isInteger(rounds) || rounds < 1) {
|
|
65
|
+
throw new Error(`Config: "providers.${name}.maxRounds" must be a positive integer, got ${rounds}`);
|
|
66
|
+
}
|
|
67
|
+
config.maxRounds = rounds;
|
|
57
68
|
}
|
|
58
69
|
if (raw['parameters'] !== undefined) {
|
|
59
70
|
config.parameters = toModelParameters(asRecord(raw['parameters'], `providers.${name}.parameters`));
|
package/src/hooks.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export { HookRegistry } from '@matatbread/matbot-plugin-api';
|
|
1
|
+
export { HookRegistry } from '@matatbread/matbot-plugin-api/host';
|
package/src/index.ts
CHANGED
|
@@ -2,33 +2,38 @@ export type * from './types.js';
|
|
|
2
2
|
// matbot's typed errors are duck-typed: factory `xError()` builders + `isXError()` brand guards are
|
|
3
3
|
// runtime values (re-exported here, since the `export type *` above would strip their value meaning),
|
|
4
4
|
// while the `XError` shapes are types (re-exported below). StoreQueryError is still a class value.
|
|
5
|
+
// Every branded error, uniformly: a partial list meant a consumer importing from core got some guards and
|
|
6
|
+
// silently missed others (readOnlyError/isReadOnlyError were absent, though the pump depends on the guard).
|
|
5
7
|
export {
|
|
6
8
|
missingSecretError, isMissingSecretError,
|
|
7
9
|
incompatibleRuntimeError, isIncompatibleRuntimeError,
|
|
8
10
|
notAPluginError, isNotAPluginError,
|
|
9
11
|
promptCancelledError, isPromptCancelledError,
|
|
12
|
+
readOnlyError, isReadOnlyError,
|
|
10
13
|
StoreQueryError,
|
|
11
14
|
} from '@matatbread/matbot-plugin-api';
|
|
12
15
|
export type {
|
|
13
|
-
MissingSecretError, IncompatibleRuntimeError, NotAPluginError, PromptCancelledError,
|
|
16
|
+
MissingSecretError, IncompatibleRuntimeError, NotAPluginError, PromptCancelledError, ReadOnlyError,
|
|
17
|
+
MatbotErrorKind,
|
|
14
18
|
} from '@matatbread/matbot-plugin-api';
|
|
15
19
|
export { applyCreateSecret } from '@matatbread/matbot-plugin-api';
|
|
16
|
-
|
|
17
|
-
export
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
//
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
export type { Subscribable, Broadcaster } from '@matatbread/matbot-plugin-api';
|
|
24
|
-
// Ambient security principal: interface is a type (carried by `export type *`); these are the values.
|
|
20
|
+
export { isTruncatedToolResult, notifyingStore, ItemChangeKind, RegistryChangeKind } from '@matatbread/matbot-plugin-api';
|
|
21
|
+
// Ambient security principal: the interface is a type (carried by `export type *`); these are the values.
|
|
22
|
+
export { currentPrincipal, tryCurrentPrincipal, runAs, lastActivityAt } from '@matatbread/matbot-plugin-api';
|
|
23
|
+
// Host boot assembly, from plugin-api's `/host` subpath. An embedding app gets these through core and
|
|
24
|
+
// needs no direct dependency on plugin-api; a *plugin* has no business with any of them, which is why
|
|
25
|
+
// they are not on the plugin-api root. Re-exported here (not `export type *`ed) so their value meaning
|
|
26
|
+
// survives.
|
|
25
27
|
export {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
unifyServices, forwardingProxy, makeSwappable, singleTurnRequest, createMountTable,
|
|
29
|
+
createBroadcaster, subscribable, createNotifier, scopedNotifier,
|
|
30
|
+
installPrincipalCarrier, enterPrincipal, createConstantPrincipalCarrier,
|
|
28
31
|
contextSwitch, onContextQuiesce, flushIfQuiescent,
|
|
29
32
|
installUsageCarrier, createSerialUsageCarrier, recordUsage, currentUsageSink, withUsageScope,
|
|
30
|
-
|
|
31
|
-
|
|
33
|
+
} from '@matatbread/matbot-plugin-api/host';
|
|
34
|
+
export type {
|
|
35
|
+
Subscribable, Broadcaster, Routed, RoutedFilter, SwapFn, MountTable, PrincipalCarrier, UsageCarrier,
|
|
36
|
+
} from '@matatbread/matbot-plugin-api/host';
|
|
32
37
|
export * from './session.js';
|
|
33
38
|
export * from './usage.js';
|
|
34
39
|
export * from './hooks.js';
|
package/src/plugin.ts
CHANGED
|
@@ -1 +1,5 @@
|
|
|
1
|
+
// Core's view of the plugin API is the whole of it: core *is* a host, so it needs the boot-assembly
|
|
2
|
+
// half (`/host`) as well as the author-facing root. Everything reached through this module is therefore
|
|
3
|
+
// available to core internals and, via core's index, to an embedding app.
|
|
1
4
|
export * from '@matatbread/matbot-plugin-api';
|
|
5
|
+
export * from '@matatbread/matbot-plugin-api/host';
|
package/src/registry.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
import type { Tool, ToolRegistry, Hook, PromptFn, FormField, FrontendInfo, ProviderAdapter, ProviderConfig } from './types.js';
|
|
2
|
-
import {
|
|
2
|
+
import { RegistryChangeKind } from '@matatbread/matbot-plugin-api';
|
|
3
|
+
import { scopedNotifier } from '@matatbread/matbot-plugin-api/host';
|
|
3
4
|
import type {
|
|
4
5
|
MatbotPlugin, MatbotMachine, MatbotRuntime, Mounted,
|
|
5
6
|
ProviderAdapterFactory, StoreFactory,
|
|
6
7
|
} from './plugin.js';
|
|
7
8
|
import { PLUGIN_API_VERSION, unifyServices } from './plugin.js';
|
|
8
|
-
import { HookRegistry } from './hooks.js';
|
|
9
9
|
import { makePluginSettings } from './settings.js';
|
|
10
10
|
import type { SettingsDoc } from './settings.js';
|
|
11
11
|
|
|
@@ -30,6 +30,7 @@ const state = {
|
|
|
30
30
|
serviceKeys: new Map<string, string[]>(), // pluginName → MatbotMachine keys it registered
|
|
31
31
|
hookPlugins: new Set<string>(), // plugins that registered at least one hook
|
|
32
32
|
systemContextPlugins: new Set<string>(), // plugins that registered a system-context contributor
|
|
33
|
+
lifecycles: new Map<string, AbortController>(), // pluginName → "this plugin is loaded" signal, aborted on unload
|
|
33
34
|
failedPlugins: [] as FailedPlugin[], // plugins the loader skipped, keyed by specifier (last failure wins)
|
|
34
35
|
overwriteAllTools: undefined as boolean | undefined, // persisted "overwrite on collision, this install" choice, loaded lazily
|
|
35
36
|
};
|
|
@@ -285,16 +286,43 @@ export function getSpecifierForPlugin(pluginName: string): string | undefined {
|
|
|
285
286
|
export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine, prompt?: PromptFn): Promise<void> {
|
|
286
287
|
state.toolRegistry ??= services.tools;
|
|
287
288
|
|
|
289
|
+
// This plugin's load extent. The mount table's only cleanup path is an aborted signal, and the
|
|
290
|
+
// option is optional — so an author who omits it left an interest that outlives the unload and
|
|
291
|
+
// keeps firing into a torn-down closure (once per reload generation). Ownership is the host's to
|
|
292
|
+
// know, not the author's to remember: every scoped observe() is bound to this signal, so `signal`
|
|
293
|
+
// in the options is a narrowing convenience ("stop earlier than my unload"), never load-bearing.
|
|
294
|
+
const previous = state.lifecycles.get(plugin.name);
|
|
295
|
+
previous?.abort(); // a reload that skipped unloadPlugin
|
|
296
|
+
const lifecycle = new AbortController();
|
|
297
|
+
state.lifecycles.set(plugin.name, lifecycle);
|
|
298
|
+
|
|
288
299
|
// Single choke point for every plugin tool registration (static `plugin.tools` and in-setup
|
|
289
300
|
// `services.tools.register`). Stamps ownership and resolves name collisions. The no-collision
|
|
290
301
|
// path runs synchronously (an async fn yields nothing before its first await), so fire-and-forget
|
|
291
|
-
// callers that don't await still get the tool registered in the same tick
|
|
302
|
+
// callers that don't await still get the tool registered in the same tick — which is the whole
|
|
303
|
+
// reason `ToolRegistry.register` returns `void`, and every one of its ~34 call sites relies on it.
|
|
304
|
+
//
|
|
305
|
+
// That makes the collision branch the one place with an await and no caller to own its outcome, so
|
|
306
|
+
// it owns its own: a throw from the settings read/write or the prompt (a non-interactive `PromptFn`
|
|
307
|
+
// rejects with PromptCancelledError) would otherwise be an unhandled rejection — process exit under
|
|
308
|
+
// Node's default — reached by the ordinary act of two plugins claiming one tool name. Keep-existing
|
|
309
|
+
// is the safe resolution, so a failure resolves to it. The abort check closes the other end: setup()
|
|
310
|
+
// has already returned by the time a slow collision resolves, so an unload (or a setup() throw and
|
|
311
|
+
// its rollback) can land first, and re-inserting the tool would revive one owned by a gone plugin.
|
|
292
312
|
const registerTool = async (tool: Tool): Promise<void> => {
|
|
293
313
|
const stamped: Tool = { ...tool, pluginName: plugin.name };
|
|
294
314
|
const existing = services.tools.resolve(stamped.name);
|
|
295
315
|
if (existing !== null && existing.pluginName !== plugin.name) {
|
|
296
|
-
const overwrite = await resolveToolCollision(services, stamped.name, existing.pluginName, plugin.name, prompt)
|
|
316
|
+
const overwrite = await resolveToolCollision(services, stamped.name, existing.pluginName, plugin.name, prompt)
|
|
317
|
+
.catch((e: unknown) => {
|
|
318
|
+
console.error(
|
|
319
|
+
`[matbot] Could not resolve the "${stamped.name}" tool collision for plugin "${plugin.name}" ` +
|
|
320
|
+
`— keeping the existing tool:`, e instanceof Error ? e.message : e,
|
|
321
|
+
);
|
|
322
|
+
return false;
|
|
323
|
+
});
|
|
297
324
|
if (!overwrite) return;
|
|
325
|
+
if (lifecycle.signal.aborted) return;
|
|
298
326
|
}
|
|
299
327
|
services.tools.register(stamped);
|
|
300
328
|
};
|
|
@@ -312,9 +340,11 @@ export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine,
|
|
|
312
340
|
observe(options, handler) {
|
|
313
341
|
// Forward to the host mount table but deliver *this plugin's* scoped machine — it reads through
|
|
314
342
|
// the same proxies/registry, so scoped[key] is the host's live service. onUnmount is scoped too.
|
|
315
|
-
const forwarded =
|
|
316
|
-
|
|
317
|
-
: options
|
|
343
|
+
const forwarded = {
|
|
344
|
+
...options,
|
|
345
|
+
signal: options.signal !== undefined ? AbortSignal.any([lifecycle.signal, options.signal]) : lifecycle.signal,
|
|
346
|
+
...(options.onUnmount !== undefined ? { onUnmount: () => options.onUnmount!(scoped) } : {}),
|
|
347
|
+
};
|
|
318
348
|
services.mounted.observe(forwarded, () => handler(scoped as never));
|
|
319
349
|
},
|
|
320
350
|
};
|
|
@@ -345,7 +375,7 @@ export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine,
|
|
|
345
375
|
services.hooks.register({ ...hook, pluginName: plugin.name } as Hook);
|
|
346
376
|
},
|
|
347
377
|
removeByPlugin: (name: string) => services.hooks.removeByPlugin(name),
|
|
348
|
-
}
|
|
378
|
+
},
|
|
349
379
|
systemContext: {
|
|
350
380
|
register(contributor) {
|
|
351
381
|
state.systemContextPlugins.add(plugin.name);
|
|
@@ -382,6 +412,10 @@ export async function unloadPlugin(pluginName: string, services: MatbotMachine):
|
|
|
382
412
|
services.tools.removeByPlugin(pluginName);
|
|
383
413
|
services.hooks.removeByPlugin(pluginName);
|
|
384
414
|
services.systemContext.removeByPlugin(pluginName);
|
|
415
|
+
// Drops this plugin's mount interests. Before teardown(), with the other synchronous removals: a
|
|
416
|
+
// mount handler firing between here and teardown would run against half-removed state.
|
|
417
|
+
state.lifecycles.get(pluginName)?.abort();
|
|
418
|
+
state.lifecycles.delete(pluginName);
|
|
385
419
|
|
|
386
420
|
for (const key of state.serviceKeys.get(pluginName) ?? []) {
|
|
387
421
|
services.unregister(key);
|
|
@@ -406,10 +440,13 @@ export async function unloadPlugin(pluginName: string, services: MatbotMachine):
|
|
|
406
440
|
|
|
407
441
|
/** Run each plugin's teardown() in reverse-registration order. Errors are logged, not thrown. */
|
|
408
442
|
export async function teardownPlugins(): Promise<void> {
|
|
409
|
-
|
|
443
|
+
// Reverse once and keep it: indexing the *unreversed* array to name a result reported the wrong
|
|
444
|
+
// plugin for every failure but the middle one.
|
|
445
|
+
const ordered = [...state.plugins].reverse();
|
|
446
|
+
const results = await Promise.allSettled(ordered.map(plugin => plugin.teardown?.()));
|
|
410
447
|
results.forEach((result, i) => {
|
|
411
448
|
if (result.status === 'rejected') {
|
|
412
|
-
console.error(`[matbot] teardown error in plugin "${
|
|
449
|
+
console.error(`[matbot] teardown error in plugin "${ordered[i]?.name}":`, result.reason);
|
|
413
450
|
}
|
|
414
451
|
});
|
|
415
452
|
}
|
package/src/runner.ts
CHANGED
|
@@ -1,13 +1,14 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
Session, MessageContent, Usage, ProviderMeta,
|
|
3
|
-
|
|
2
|
+
Session, Message, MessageContent, ModelContent, Usage, ProviderMeta, TruncatedToolResult,
|
|
3
|
+
TurnEvent, RunConfig, ProviderAdapter, ProviderConfig,
|
|
4
4
|
Tool, ToolRegistry, ToolContext, Store, FileStore, SystemContextRegistry, Vault, PromptFn, FormField,
|
|
5
5
|
} from './types.js';
|
|
6
6
|
import type { MatbotPlugin } from './plugin.js';
|
|
7
7
|
import type { ToolPresenter } from '@matatbread/matbot-plugin-api';
|
|
8
|
-
import { currentUsageSink } from '@matatbread/matbot-plugin-api';
|
|
8
|
+
import { currentUsageSink } from '@matatbread/matbot-plugin-api/host';
|
|
9
9
|
import { HookRegistry } from './hooks.js';
|
|
10
10
|
import { appendMessage, createMessage } from './session.js';
|
|
11
|
+
import { foldOntoUserTurn, bindPluginOps } from '@matatbread/matbot-plugin-api';
|
|
11
12
|
import { addUsage } from './usage.js';
|
|
12
13
|
|
|
13
14
|
// Substituted for an errored tool result when the turn was aborted (e.g. a mid-turn steer interrupt):
|
|
@@ -15,6 +16,16 @@ import { addUsage } from './usage.js';
|
|
|
15
16
|
// internal token that reads as a real failure. A steer's continuation turn is the one place a model
|
|
16
17
|
// reads this, so the wording tells it the call was interrupted and leaves re-running to its judgement —
|
|
17
18
|
// deliberately NOT "re-run it", so a side-effecting tool isn't reflexively repeated.
|
|
19
|
+
// Creator of the marker recording that a provider cut a response short. Marker-role, so it is durable
|
|
20
|
+
// and visible to a reader while elided from every submission: the model's own text is already truncated
|
|
21
|
+
// in the transcript, and a block telling it so invites narrating the cut-off rather than continuing.
|
|
22
|
+
const TRUNCATION_CREATOR = 'matbot-truncation';
|
|
23
|
+
|
|
24
|
+
/** An AbortSignal's reason as a turn-terminal string. `reason` is `any` by spec (a caller may abort with
|
|
25
|
+
* anything), so the two abort exits both had to narrow it; they narrowed it identically. */
|
|
26
|
+
const abortReason = (signal: AbortSignal): string =>
|
|
27
|
+
typeof signal.reason === 'string' ? signal.reason : 'user-abort';
|
|
28
|
+
|
|
18
29
|
const INTERRUPTED_TOOL_RESULT = {
|
|
19
30
|
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.',
|
|
20
31
|
} as const;
|
|
@@ -64,7 +75,9 @@ export interface RunSessionOpts {
|
|
|
64
75
|
unloadPlugin: (specifier: string) => Promise<boolean>;
|
|
65
76
|
}
|
|
66
77
|
|
|
67
|
-
|
|
78
|
+
// Yields TurnEvent, not PipelineEvent: a single turn never produces the session-level `idle` — that is
|
|
79
|
+
// the pump's, emitted once its queue drains. Saying so in the type is the point of the two-union split.
|
|
80
|
+
export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent> {
|
|
68
81
|
const { config, provider, providerConfig, store, signal } = opts;
|
|
69
82
|
const tools = opts.tools ?? new Map<string, Tool>();
|
|
70
83
|
const hookReg = opts.hooks ?? new HookRegistry();
|
|
@@ -83,15 +96,29 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
83
96
|
};
|
|
84
97
|
const traceId = config.traceId ?? crypto.randomUUID();
|
|
85
98
|
|
|
99
|
+
let session = opts.session;
|
|
100
|
+
|
|
101
|
+
// Every way a turn can end funnels through here: commit the session as it stands, then yield exactly
|
|
102
|
+
// one terminal event. Each exit has its own legitimately different preamble — screen markers, a
|
|
103
|
+
// streamed assistant partial, closing an unpaired tool_use — but the terminal itself must not vary,
|
|
104
|
+
// and twice it had not: both the `toolcall`-abort and failed-provider-call exits returned with no
|
|
105
|
+
// commit at all, silently discarding every completed round of the turn.
|
|
106
|
+
// Nothing is written mid-turn, so "commit" here means the whole turn or none of it.
|
|
107
|
+
// Closes over the reassigned `session` deliberately, so no exit can commit a stale copy.
|
|
108
|
+
async function* end(terminal: TurnEvent): AsyncIterable<TurnEvent> {
|
|
109
|
+
await store.set(session.id, session);
|
|
110
|
+
yield terminal;
|
|
111
|
+
}
|
|
112
|
+
|
|
86
113
|
// ── 1. screen — once per turn: shape/abort the incoming submission ──────────
|
|
87
114
|
|
|
88
|
-
const screen = await hookReg.runScreen({ session
|
|
115
|
+
const screen = await hookReg.runScreen({ session, config, signal, prompt: promptFn });
|
|
116
|
+
session = screen.session;
|
|
89
117
|
if (screen.abort) {
|
|
90
118
|
// Hook-failure (and any other screen-injected) markers carried live, even on abort, so a
|
|
91
119
|
// misconfigured hook surfaces this turn rather than only on a later reload.
|
|
92
120
|
if (screen.markers.length > 0) yield { type: 'marker', content: screen.markers, traceId };
|
|
93
|
-
|
|
94
|
-
yield { type: 'aborted', reason: screen.abort, session: screen.session, traceId };
|
|
121
|
+
yield* end({ type: 'aborted', reason: screen.abort, session, traceId });
|
|
95
122
|
return;
|
|
96
123
|
}
|
|
97
124
|
// Durable context a screen hook folded onto the user message (e.g. a fired `contextual` trigger),
|
|
@@ -107,7 +134,6 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
107
134
|
if (screen.markers.length > 0) {
|
|
108
135
|
yield { type: 'marker', content: screen.markers, traceId };
|
|
109
136
|
}
|
|
110
|
-
let session = screen.session;
|
|
111
137
|
|
|
112
138
|
// Turn-scoped ephemeral context from screen — appended to the TAIL of the outgoing messages
|
|
113
139
|
// (onto the content of the freshest history message, preserving its role), never persisted.
|
|
@@ -124,12 +150,19 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
124
150
|
// in here when it fires, and the loop re-runs with it.
|
|
125
151
|
let ephemeral = [...(opts.injectedEphemeral ?? []), ...screen.ephemeral];
|
|
126
152
|
|
|
127
|
-
// Raced screen verdicts (see DeferredScreen):
|
|
128
|
-
//
|
|
129
|
-
//
|
|
153
|
+
// Raced screen verdicts (see DeferredScreen): claimed — synchronously, never awaited — at each of the
|
|
154
|
+
// three loop edges (before generating, mid-stream, and pre-commit). Claiming is one step, so the three
|
|
155
|
+
// sites cannot disagree about it: take every verdict that has fired since the last claim (exactly once
|
|
156
|
+
// each), fold `ephemeral` in ahead of what is already queued so the re-run reads it, fold `durable`
|
|
157
|
+
// onto the user turn (persisted — the re-run and every later turn see it in history), and carry that
|
|
158
|
+
// durable half live as a `robo-user`, which is what preserves a contextual fire's visible persistence
|
|
159
|
+
// even though the verdict landed mid-turn.
|
|
160
|
+
//
|
|
161
|
+
// Returns whether anything was claimed. What the caller does with that is the part that legitimately
|
|
162
|
+
// differs — proceed, abort the in-flight provider call, or re-run the loop — so it stays at the site.
|
|
130
163
|
const deferred = screen.deferred;
|
|
131
|
-
|
|
132
|
-
if (deferred.length === 0) return
|
|
164
|
+
async function* claimVerdicts(): AsyncGenerator<TurnEvent, boolean, undefined> {
|
|
165
|
+
if (deferred.length === 0) return false;
|
|
133
166
|
const ephemeralC: MessageContent[] = [];
|
|
134
167
|
const durableC: MessageContent[] = [];
|
|
135
168
|
for (const d of deferred) {
|
|
@@ -138,24 +171,20 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
138
171
|
if (c.ephemeral) ephemeralC.push(...c.ephemeral);
|
|
139
172
|
if (c.durable) durableC.push(...c.durable);
|
|
140
173
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
if (idx >= 0) {
|
|
152
|
-
session = { ...session, messages: session.messages.map((m, i) =>
|
|
153
|
-
i === idx ? { ...m, content: [...m.content, ...c.durable!] } : m) };
|
|
154
|
-
return c.durable;
|
|
174
|
+
if (ephemeralC.length === 0 && durableC.length === 0) return false;
|
|
175
|
+
|
|
176
|
+
if (ephemeralC.length > 0) ephemeral = [...ephemeralC, ...ephemeral];
|
|
177
|
+
if (durableC.length > 0) {
|
|
178
|
+
const folded = foldOntoUserTurn(session, durableC);
|
|
179
|
+
// Identity-unchanged ⇒ no user message to attach to, so the blocks were dropped: nothing durable
|
|
180
|
+
// landed, hence nothing to carry live either.
|
|
181
|
+
if (folded !== session) {
|
|
182
|
+
session = folded;
|
|
183
|
+
yield { type: 'robo-user', content: durableC, traceId };
|
|
155
184
|
}
|
|
156
185
|
}
|
|
157
|
-
return
|
|
158
|
-
}
|
|
186
|
+
return true;
|
|
187
|
+
}
|
|
159
188
|
|
|
160
189
|
// ── 2. System context (built once per submit, never persisted) ─────────────
|
|
161
190
|
|
|
@@ -173,24 +202,44 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
173
202
|
|
|
174
203
|
// ── 3. Agentic loop ────────────────────────────────────────────────────────
|
|
175
204
|
|
|
205
|
+
// Rounds already run against this provider, where a round is one provider call plus the tool batch it
|
|
206
|
+
// asked for. Compared against the provider profile's own `maxRounds` — a spend ceiling denominated in
|
|
207
|
+
// the unit spend varies by. Absent ⇒ unbounded (`?? Infinity`), the historical behaviour.
|
|
208
|
+
let round = 0;
|
|
209
|
+
|
|
210
|
+
// Tool-supplied media, keyed by the tool message it answers — see the `model-content` ToolEvent.
|
|
211
|
+
// Turn-scoped and wire-only: it is spliced into the outgoing copy below and dies with this call.
|
|
212
|
+
const attachments = new Map<string, Message>();
|
|
213
|
+
|
|
176
214
|
for (;;) {
|
|
177
215
|
// Respect an abort that arrived between turns (e.g. during tool execution).
|
|
178
216
|
if (signal.aborted) {
|
|
179
|
-
|
|
180
|
-
yield { type: 'aborted', reason: typeof signal.reason === 'string' ? signal.reason : 'user-abort', session, traceId };
|
|
217
|
+
yield* end({ type: 'aborted', reason: abortReason(signal), session, traceId });
|
|
181
218
|
return;
|
|
182
219
|
}
|
|
183
220
|
|
|
221
|
+
// Checked HERE — before starting a round, not part-way through one — so the previous round's tool
|
|
222
|
+
// results are already appended and there is nothing to reconcile: the same persist-and-yield as the
|
|
223
|
+
// abort above. A turn stopped by the ceiling was cut short, not faulty, so it ends `aborted` rather
|
|
224
|
+
// than `error`, and the reason is machine-readable for a frontend that wants to say why.
|
|
225
|
+
if (round >= (providerConfig.maxRounds ?? Infinity)) {
|
|
226
|
+
const reason = `round-limit: provider "${config.provider}" allows ${providerConfig.maxRounds} rounds per turn`;
|
|
227
|
+
yield* end({ type: 'aborted', reason, session, traceId });
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
round += 1;
|
|
231
|
+
|
|
184
232
|
// A raced screen verdict that already fired (settled during setup, or while a previous tool round
|
|
185
233
|
// ran): fold its correction in before generating, so this call is informed directly rather than
|
|
186
234
|
// discarded. This is the no-waste path — the verdict landed before we spent any tokens.
|
|
187
|
-
|
|
188
|
-
if (early) { const dur = applyCorrection(early); if (dur.length > 0) yield { type: 'robo-user', content: dur, traceId }; }
|
|
235
|
+
yield* claimVerdicts();
|
|
189
236
|
|
|
190
|
-
const pendingCalls: Array<{ id: string; name: string; input: unknown; meta?: ProviderMeta
|
|
237
|
+
const pendingCalls: Array<{ id: string; name: string; input: unknown; meta?: ProviderMeta;
|
|
238
|
+
truncated?: { bytes: number; stopReason?: string } }> = [];
|
|
191
239
|
const assistantParts: MessageContent[] = [];
|
|
192
240
|
let textAcc = '';
|
|
193
241
|
let turnUsage: Usage | undefined;
|
|
242
|
+
let truncation: { reason: 'max-tokens' | 'stream-end'; raw?: string } | undefined;
|
|
194
243
|
// Set if a raced verdict fires mid-generation: discard this response and re-run the loop (below).
|
|
195
244
|
let restart = false;
|
|
196
245
|
|
|
@@ -201,9 +250,12 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
201
250
|
// message can be a marker (e.g. a retract-redo leaves the retraction marker as the tail) — folding
|
|
202
251
|
// there would drop the ephemeral with it. -1 ⇒ nothing to fold onto (no non-marker history).
|
|
203
252
|
const foldIdx = ephemeral.length > 0 ? session.messages.findLastIndex(m => m.role !== 'marker') : -1;
|
|
204
|
-
const history = foldIdx >= 0
|
|
205
|
-
? session.messages.
|
|
206
|
-
i === foldIdx ? { ...m, content: [...m.content, ...ephemeral] } : m
|
|
253
|
+
const history = foldIdx >= 0 || attachments.size > 0
|
|
254
|
+
? session.messages.flatMap((m, i) => {
|
|
255
|
+
const folded = i === foldIdx ? { ...m, content: [...m.content, ...ephemeral] } : m;
|
|
256
|
+
const media = attachments.get(m.id);
|
|
257
|
+
return media ? [folded, media] : [folded];
|
|
258
|
+
})
|
|
207
259
|
: session.messages;
|
|
208
260
|
const outgoing = await hookReg.runContribute({
|
|
209
261
|
outgoing: [...systemMsg, ...history],
|
|
@@ -224,12 +276,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
224
276
|
// A raced verdict firing mid-generation changes the premise: discard this doomed response and
|
|
225
277
|
// restart the loop with the correction folded in. Polled BEFORE the event is emitted, so a
|
|
226
278
|
// classifier faster than time-to-first-token is caught before any token reaches the frontend.
|
|
227
|
-
|
|
228
|
-
if (hit) {
|
|
229
|
-
const dur = applyCorrection(hit);
|
|
230
|
-
if (dur.length > 0) yield { type: 'robo-user', content: dur, traceId };
|
|
231
|
-
restart = true; callAc.abort('screen-restart'); break;
|
|
232
|
-
}
|
|
279
|
+
if (yield* claimVerdicts()) { restart = true; callAc.abort('screen-restart'); break; }
|
|
233
280
|
switch (ev.type) {
|
|
234
281
|
case 'text-delta':
|
|
235
282
|
textAcc += ev.delta;
|
|
@@ -252,7 +299,11 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
252
299
|
break;
|
|
253
300
|
case 'tool-call':
|
|
254
301
|
pendingCalls.push({ id: ev.id, name: ev.name, input: ev.input,
|
|
255
|
-
...(ev.meta
|
|
302
|
+
...(ev.meta !== undefined ? { meta: ev.meta } : {}),
|
|
303
|
+
...(ev.truncated !== undefined ? { truncated: ev.truncated } : {}) });
|
|
304
|
+
break;
|
|
305
|
+
case 'truncated':
|
|
306
|
+
truncation = { reason: ev.reason, ...(ev.raw !== undefined ? { raw: ev.raw } : {}) };
|
|
256
307
|
break;
|
|
257
308
|
case 'usage':
|
|
258
309
|
turnUsage = addUsage(turnUsage, ev);
|
|
@@ -279,11 +330,20 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
279
330
|
...(turnUsage !== undefined ? { usage: turnUsage } : {}),
|
|
280
331
|
}));
|
|
281
332
|
}
|
|
282
|
-
|
|
283
|
-
yield { type: 'aborted', reason: typeof signal.reason === 'string' ? signal.reason : 'user-abort', session, traceId };
|
|
333
|
+
yield* end({ type: 'aborted', reason: abortReason(signal), session, traceId });
|
|
284
334
|
return;
|
|
285
335
|
} else {
|
|
286
|
-
|
|
336
|
+
// A failed provider call still commits the rounds that succeeded. Nothing is written mid-turn, so
|
|
337
|
+
// returning without one discarded the whole turn — and the multi-round case is exactly the
|
|
338
|
+
// tool-using one, so a provider 500 or a dropped connection on round 3 threw away two completed
|
|
339
|
+
// rounds of assistant messages and tool results the frontend had already drawn, and which the
|
|
340
|
+
// next turn (which re-reads from the store) would then be missing.
|
|
341
|
+
//
|
|
342
|
+
// Nothing partial is appended here, unlike the abort branch above: `textAcc` and `assistantParts`
|
|
343
|
+
// belong to the call that failed, and the model never finished the thought. The `error` event
|
|
344
|
+
// carries no session (a failure is not a transcript), so this yields the terminal itself rather
|
|
345
|
+
// than the session — but it commits first, exactly like every other exit.
|
|
346
|
+
yield* end({ type: 'error', error: String(e) + (('cause' in e && e.cause) ? ' ('+String(e.cause)+')' : '' ), traceId });
|
|
287
347
|
return;
|
|
288
348
|
}
|
|
289
349
|
}
|
|
@@ -295,8 +355,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
295
355
|
|
|
296
356
|
// Pre-commit poll: the verdict may have settled exactly as the stream ended. Discard and re-run
|
|
297
357
|
// rather than commit-then-retract, keeping the correction on the cheaper in-situ path.
|
|
298
|
-
|
|
299
|
-
if (late) { const dur = applyCorrection(late); if (dur.length > 0) yield { type: 'robo-user', content: dur, traceId }; continue; }
|
|
358
|
+
if (yield* claimVerdicts()) continue;
|
|
300
359
|
|
|
301
360
|
// Build and store assistant message
|
|
302
361
|
if (textAcc) assistantParts.push({ type: 'text', text: textAcc });
|
|
@@ -314,7 +373,19 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
314
373
|
...(turnUsage !== undefined ? { usage: turnUsage } : {}),
|
|
315
374
|
});
|
|
316
375
|
session = appendMessage(session, assistantMsg);
|
|
317
|
-
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// The provider cut this response short rather than the model choosing to stop. Recorded whether or
|
|
379
|
+
// not a tool call was caught in it — the commoner case has none, and prose stopping mid-sentence
|
|
380
|
+
// previously left no trace anywhere. What to DO about it (continue, re-ask with a bigger budget) is
|
|
381
|
+
// a `followup` hook's call, not the harness's.
|
|
382
|
+
if (truncation) {
|
|
383
|
+
const marker: MessageContent = { type: 'marker', creator: TRUNCATION_CREATOR, data: truncation };
|
|
384
|
+
session = appendMessage(session, createMessage({ role: 'marker', content: [marker], traceId }));
|
|
385
|
+
yield { type: 'marker', content: [marker], traceId };
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
if (assistantParts.length === 0) {
|
|
318
389
|
// Diagnostic: the provider returned no text, no tool calls, no thinking — a genuinely empty
|
|
319
390
|
// completion. The turn will end with no assistant message, which reads as "the agent never
|
|
320
391
|
// replied". Logged here so a silent no-reply turn is traceable. (textAcc length is shown to
|
|
@@ -329,8 +400,9 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
329
400
|
|
|
330
401
|
const toolResults: MessageContent[] = [];
|
|
331
402
|
const toolMarkers: MessageContent[] = [];
|
|
403
|
+
const toolMedia: ModelContent[] = [];
|
|
332
404
|
|
|
333
|
-
for (const tc of pendingCalls) {
|
|
405
|
+
for (const [callIdx, tc] of pendingCalls.entries()) {
|
|
334
406
|
yield { type: 'tool:start', callId: tc.id, name: tc.name, input: tc.input, traceId };
|
|
335
407
|
|
|
336
408
|
const tool = opts.toolRegistry !== undefined ? opts.toolRegistry.resolve(tc.name) : tools.get(tc.name);
|
|
@@ -341,13 +413,68 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
341
413
|
continue;
|
|
342
414
|
}
|
|
343
415
|
|
|
416
|
+
// Arguments severed mid-stream (the provider hit its token limit part-way through the call).
|
|
417
|
+
// The call cannot run, but it must still be answered: the assistant message above carries a
|
|
418
|
+
// `tool-call` block for it, and an unpaired `tool_use` is rejected by the next submission. So it
|
|
419
|
+
// is recorded as a call that failed — no execution, no side effect — and the model reads the
|
|
420
|
+
// failure in the slot it expects and retries smaller, which is the ordinary self-correction the
|
|
421
|
+
// loop already does for a rejected or unknown tool. No retry counter: a model that keeps
|
|
422
|
+
// overflowing burns rounds and meets `maxRounds`, exactly as one repeatedly calling any failing
|
|
423
|
+
// tool does.
|
|
424
|
+
//
|
|
425
|
+
// `toolcall` is skipped — there is nothing to judge, the call cannot proceed whatever a hook
|
|
426
|
+
// says — but `toolresult` runs, because it owns the LLM-facing text and is where a consumer
|
|
427
|
+
// folds in advice the harness cannot have: which of THIS tool's parameters offers a cheaper
|
|
428
|
+
// edit. See `isTruncatedToolResult`.
|
|
429
|
+
if (tc.truncated) {
|
|
430
|
+
const truncated: TruncatedToolResult = {
|
|
431
|
+
error:
|
|
432
|
+
`Arguments for "${tc.name}" were cut off after ${tc.truncated.bytes} bytes and could not be parsed` +
|
|
433
|
+
`${tc.truncated.stopReason !== undefined ? ` (stop reason "${tc.truncated.stopReason}")` : ''}. ` +
|
|
434
|
+
'The call did NOT run and had no effect. Retry it smaller — fewer operations in one call, ' +
|
|
435
|
+
'or split the work across several calls.',
|
|
436
|
+
truncated: { tool: tc.name, bytes: tc.truncated.bytes,
|
|
437
|
+
...(tc.truncated.stopReason !== undefined ? { stopReason: tc.truncated.stopReason } : {}) },
|
|
438
|
+
};
|
|
439
|
+
const shaped = await hookReg.runToolResult({
|
|
440
|
+
session, config, signal,
|
|
441
|
+
toolCall: { id: tc.id, name: tc.name, input: tc.input },
|
|
442
|
+
tool, result: truncated, isError: true, durationMs: 0,
|
|
443
|
+
});
|
|
444
|
+
toolResults.push({ type: 'tool-result', id: tc.id, result: shaped, isError: true });
|
|
445
|
+
yield { type: 'tool:end', callId: tc.id, result: shaped, isError: true, traceId };
|
|
446
|
+
continue;
|
|
447
|
+
}
|
|
448
|
+
|
|
344
449
|
const decision = await hookReg.runToolCall({
|
|
345
450
|
session, config, signal,
|
|
346
451
|
toolCall: { id: tc.id, name: tc.name, input: tc.input },
|
|
347
452
|
tool,
|
|
348
453
|
});
|
|
349
454
|
if (decision.abort) {
|
|
350
|
-
|
|
455
|
+
// Commit the turn before leaving (`end`, above — nothing is written mid-turn, so an exit that
|
|
456
|
+
// skips it discards the whole turn: every earlier round's assistant message and tool results,
|
|
457
|
+
// plus this round's response, all of which the frontend had already drawn). This path is where
|
|
458
|
+
// that was first found and fixed.
|
|
459
|
+
//
|
|
460
|
+
// Committing requires closing the tool_use/tool_result pairing first: the assistant message
|
|
461
|
+
// above carries a `tool-call` block per pending call, and a `tool_use` left unpaired is
|
|
462
|
+
// rejected by the next submission (Anthropic 400s; nothing downstream reconciles them). The
|
|
463
|
+
// abort is a turn-level stop rather than a verdict on the tools, so the calls that never ran
|
|
464
|
+
// are recorded as not-run. `tool:end` is carried only for the call the hook actually judged —
|
|
465
|
+
// the rest never had a `tool:start`, so a frontend has no bubble to close for them.
|
|
466
|
+
const stopped = { error: `Tool call not executed — the turn was aborted (${decision.abort}).` };
|
|
467
|
+
toolResults.push({ type: 'tool-result', id: tc.id, result: stopped, isError: true });
|
|
468
|
+
yield { type: 'tool:end', callId: tc.id, result: stopped, isError: true, traceId };
|
|
469
|
+
for (const rest of pendingCalls.slice(callIdx + 1)) {
|
|
470
|
+
toolResults.push({ type: 'tool-result', id: rest.id, result: stopped, isError: true });
|
|
471
|
+
}
|
|
472
|
+
session = appendMessage(session, createMessage({ role: 'tool', content: toolResults, traceId }));
|
|
473
|
+
if (toolMarkers.length > 0) {
|
|
474
|
+
session = appendMessage(session, createMessage({ role: 'marker', content: toolMarkers, traceId }));
|
|
475
|
+
yield { type: 'marker', content: toolMarkers, traceId };
|
|
476
|
+
}
|
|
477
|
+
yield* end({ type: 'aborted', reason: decision.abort, session, traceId });
|
|
351
478
|
return;
|
|
352
479
|
}
|
|
353
480
|
if (decision.rejectTool) {
|
|
@@ -369,8 +496,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
369
496
|
callId: tc.id, session, signal, vault,
|
|
370
497
|
provider: config.provider,
|
|
371
498
|
prompt: promptFn,
|
|
372
|
-
|
|
373
|
-
unloadPlugin: opts.unloadPlugin,
|
|
499
|
+
...bindPluginOps(opts, promptFn),
|
|
374
500
|
...(opts.workdir !== undefined ? { workdir: opts.workdir } : {}),
|
|
375
501
|
...(opts.configPath !== undefined ? { configPath: opts.configPath } : {}),
|
|
376
502
|
...(opts.files !== undefined ? { files: opts.files } : {}),
|
|
@@ -384,6 +510,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
384
510
|
case 'file': yield { type: 'file', handle: toolEv.handle, traceId }; break;
|
|
385
511
|
case 'result': result = toolEv.value; break;
|
|
386
512
|
case 'marker': toolMarkers.push({ type: 'marker', creator: toolEv.creator, data: toolEv.data }); break;
|
|
513
|
+
case 'model-content': toolMedia.push(...toolEv.content); break;
|
|
387
514
|
case 'progress': yield { type: 'tool:progress', callId: tc.id, pct: toolEv.pct, ...(toolEv.message !== undefined ? { message: toolEv.message } : {}), traceId }; break;
|
|
388
515
|
case 'error': result = {
|
|
389
516
|
error: toolEv.message,
|
|
@@ -423,6 +550,15 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
423
550
|
const toolMsg = createMessage({ role: 'tool', content: toolResults, traceId });
|
|
424
551
|
session = appendMessage(session, toolMsg);
|
|
425
552
|
|
|
553
|
+
// Media the tools handed over for the model to look at. Held wire-side only — pinned directly
|
|
554
|
+
// after the tool message it came from (so it keeps a stable history position, and so the model
|
|
555
|
+
// reads it as the answer to that call) and spliced into `outgoing` on every remaining round of
|
|
556
|
+
// this turn. Never appended to `session`: the transcript records what the tool returned, not the
|
|
557
|
+
// bytes it showed.
|
|
558
|
+
if (toolMedia.length > 0) {
|
|
559
|
+
attachments.set(toolMsg.id, createMessage({ role: 'user', content: toolMedia, traceId }));
|
|
560
|
+
}
|
|
561
|
+
|
|
426
562
|
// Markers a tool emitted while running: persist as their own marker-role message (elided from
|
|
427
563
|
// provider submission) and carry live so a frontend can render them without a reload.
|
|
428
564
|
if (toolMarkers.length > 0) {
|
|
@@ -434,7 +570,5 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
434
570
|
// ── 4. Persist and finish ──────────────────────────────────────────────────
|
|
435
571
|
// `react` fires post-commit, in pump (the queue owner) — not here.
|
|
436
572
|
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
yield { type: 'done', session, traceId };
|
|
573
|
+
yield* end({ type: 'done', session, traceId });
|
|
440
574
|
}
|
package/src/session-runner.ts
CHANGED
|
@@ -8,7 +8,8 @@ import type { MatbotPlugin } from './plugin.js';
|
|
|
8
8
|
import type { HookRegistry } from './hooks.js';
|
|
9
9
|
import type { ToolTypeIndex, ToolPresenter } from '@matatbread/matbot-plugin-api';
|
|
10
10
|
import { appendMessage, createMessage } from './session.js';
|
|
11
|
-
import {
|
|
11
|
+
import { isReadOnlyError, foldOntoUserTurn, lastUserIndex } from '@matatbread/matbot-plugin-api';
|
|
12
|
+
import { contextSwitch, withUsageScope } from '@matatbread/matbot-plugin-api/host';
|
|
12
13
|
import { runSession } from './runner.js';
|
|
13
14
|
|
|
14
15
|
export interface SessionRunnerDeps {
|
|
@@ -289,6 +290,14 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
289
290
|
// the callback: an async iterator returned out of the scope would lose it before it pulls.
|
|
290
291
|
// contextSwitch (not bare runAs): a turn is the transactional unit, so a StorageBackend swap
|
|
291
292
|
// deferred during it lands at this scope's quiescent edge — never mid-CAS.
|
|
293
|
+
// The terminal event this turn ended on. `followup` is post-COMMIT, and only `done` is a
|
|
294
|
+
// commit: an `aborted` turn was cut short (a steer, a user cancel, a screen/toolcall hook
|
|
295
|
+
// refusing it, a provider round ceiling) and an `error` turn produced no response at all, so
|
|
296
|
+
// in both cases a followup hook would judge history with no completed answer in it — and a
|
|
297
|
+
// `resubmit` would undo the very stop that just happened, handing a round ceiling a fresh
|
|
298
|
+
// budget. Tracked here rather than inferred from `ac.signal`, which only knows about the two
|
|
299
|
+
// signal-driven aborts and not the policy ones.
|
|
300
|
+
let terminal: PipelineEvent['type'] | undefined;
|
|
292
301
|
await contextSwitch(head.principal, () => withUsageScope(async () => {
|
|
293
302
|
for await (const ev of runSession({
|
|
294
303
|
session,
|
|
@@ -311,15 +320,17 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
311
320
|
...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
|
|
312
321
|
...(inject !== undefined ? { injectedEphemeral: inject } : {}),
|
|
313
322
|
})) {
|
|
323
|
+
if (ev.type === 'done' || ev.type === 'aborted' || ev.type === 'error') terminal = ev.type;
|
|
314
324
|
emit(s, ev);
|
|
315
325
|
}
|
|
316
326
|
}));
|
|
317
327
|
|
|
318
328
|
// followup — post-commit, in the queue owner. A hook reads the just-committed turn and may
|
|
319
|
-
// head-enqueue a robo follow-up (its own real turn, running next).
|
|
320
|
-
// under the submitter's principal because a reactor may itself
|
|
329
|
+
// head-enqueue a robo follow-up (its own real turn, running next). Runs only for a turn that
|
|
330
|
+
// committed (see `terminal`), and under the submitter's principal because a reactor may itself
|
|
331
|
+
// call complete() (a classifier).
|
|
321
332
|
const hooks = deps.hooks;
|
|
322
|
-
if (
|
|
333
|
+
if (terminal === 'done' && hooks) {
|
|
323
334
|
const committed = await deps.store.get(id);
|
|
324
335
|
if (committed && head.resubmitDepth < MAX_RESUBMIT_DEPTH) {
|
|
325
336
|
let followup: { resubmits: MessageContent[][]; markers: MessageContent[]; retract?: { context: MessageContent[]; durable: MessageContent[] } } = { resubmits: [], markers: [] };
|
|
@@ -379,14 +390,18 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
379
390
|
// head, delivering the trigger tool's output as ephemeral context. Unshifted last so it
|
|
380
391
|
// sits at the very head (runs next) even if a resubmit was also queued above.
|
|
381
392
|
if (followup.retract) {
|
|
382
|
-
const lastUserIdx = committed
|
|
393
|
+
const lastUserIdx = lastUserIndex(committed);
|
|
383
394
|
const popped = lastUserIdx >= 0 ? committed.messages.slice(lastUserIdx + 1) : [];
|
|
384
395
|
// `durable` correction (a contextual fire that landed post-commit) folds onto the user
|
|
385
396
|
// message we keep — persisted, so the redo AND every later turn see it in history, the
|
|
386
397
|
// exact durability the clean in-situ path gives. `context` stays ephemeral (redo only).
|
|
398
|
+
// The fold runs on the *truncated* session, whose last user message is the one we keep,
|
|
399
|
+
// so it goes through the same helper as the screen and raced-verdict folds.
|
|
387
400
|
const durable = followup.retract.durable ?? [];
|
|
388
|
-
const
|
|
389
|
-
|
|
401
|
+
const truncated = lastUserIdx >= 0
|
|
402
|
+
? { ...committed, messages: committed.messages.slice(0, lastUserIdx + 1) }
|
|
403
|
+
: committed;
|
|
404
|
+
const kept = foldOntoUserTurn(truncated, durable).messages;
|
|
390
405
|
const retractionMsg: Message = {
|
|
391
406
|
id: crypto.randomUUID(),
|
|
392
407
|
role: 'marker',
|
package/src/session.ts
CHANGED
|
@@ -2,7 +2,6 @@ import type { Session, Message, MessageContent, MessageRole, Usage } from './typ
|
|
|
2
2
|
|
|
3
3
|
export interface CreateSessionOpts {
|
|
4
4
|
title?: string;
|
|
5
|
-
contexts?: string[];
|
|
6
5
|
parentSessionId?: string;
|
|
7
6
|
branchPointMessageId?: string;
|
|
8
7
|
}
|
|
@@ -16,7 +15,6 @@ export function createSession(opts: CreateSessionOpts = {}): Session {
|
|
|
16
15
|
...(opts.parentSessionId !== undefined ? { parentSessionId: opts.parentSessionId } : {}),
|
|
17
16
|
...(opts.branchPointMessageId !== undefined ? { branchPointMessageId: opts.branchPointMessageId } : {}),
|
|
18
17
|
status: 'active',
|
|
19
|
-
contexts: opts.contexts ?? [],
|
|
20
18
|
messages: [],
|
|
21
19
|
createdAt: now,
|
|
22
20
|
updatedAt: now,
|
package/src/settings.ts
CHANGED
|
@@ -22,45 +22,51 @@ export function slugSettingsNamespace(name: string): string {
|
|
|
22
22
|
return name.replace(/[^\w-]+/g, '_');
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Build a PluginSettings facade over the shared settings store, scoped to one document id.
|
|
27
|
+
*
|
|
28
|
+
* Every read used to also decide whether the document was in the pre-Store flat `{ key: value }` format
|
|
29
|
+
* and, if so, wrap it under a `version: '0'` sentinel that `set`/`delete` then had to recognise and
|
|
30
|
+
* upgrade with `set` instead of `cas` — a three-site migration paid for on every `settings.get()` by
|
|
31
|
+
* every plugin, forever. Dropped at 0.4.0: an unrecognised document is treated as absent, so a flat one
|
|
32
|
+
* would be re-created on first write rather than read. Nothing in the wild is still flat.
|
|
33
|
+
*/
|
|
26
34
|
export function makePluginSettings(store: Store<SettingsDoc>, namespace: string): PluginSettings {
|
|
27
35
|
const id = slugSettingsNamespace(namespace);
|
|
28
36
|
|
|
29
|
-
// Handles migration from the old flat-object format (pre-Store).
|
|
30
37
|
const getDoc = async (): Promise<SettingsDoc | null> => {
|
|
31
38
|
const raw = await store.get(id);
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
39
|
+
return raw !== null && isSettingsDoc(raw) ? raw : null;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
// One CAS retry loop for both writers: read, apply `mutate`, write. A document that vanished between
|
|
43
|
+
// read and write (or never existed) is a plain `set`; otherwise `cas` guards the version we read.
|
|
44
|
+
const update = async (mutate: (data: Record<string, unknown>) => Record<string, unknown> | undefined): Promise<void> => {
|
|
45
|
+
for (;;) {
|
|
46
|
+
const doc = await getDoc();
|
|
47
|
+
const data = mutate(doc?.data ?? {});
|
|
48
|
+
if (data === undefined) return;
|
|
49
|
+
const next: SettingsDoc = { id, version: Date.now().toString(), data };
|
|
50
|
+
if (doc === null) { await store.set(id, next); return; }
|
|
51
|
+
const r = await store.cas(id, doc.version, next);
|
|
52
|
+
if (r.ok) return;
|
|
53
|
+
}
|
|
36
54
|
};
|
|
37
55
|
|
|
38
56
|
return {
|
|
39
57
|
async get<T>(key: string): Promise<T | undefined> {
|
|
40
58
|
return (await getDoc())?.data[key] as T | undefined;
|
|
41
59
|
},
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
const doc = await getDoc();
|
|
45
|
-
const data = { ...(doc?.data ?? {}), [key]: value as unknown };
|
|
46
|
-
const next: SettingsDoc = { id, version: Date.now().toString(), data };
|
|
47
|
-
// version '0' means migrated-but-not-yet-written — use set to upgrade the file.
|
|
48
|
-
if (doc === null || doc.version === '0') { await store.set(id, next); return; }
|
|
49
|
-
const r = await store.cas(id, doc.version, next);
|
|
50
|
-
if (r.ok) return;
|
|
51
|
-
}
|
|
60
|
+
set<T>(key: string, value: T): Promise<void> {
|
|
61
|
+
return update(data => ({ ...data, [key]: value as unknown }));
|
|
52
62
|
},
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (doc.version === '0') { await store.set(id, next); return; }
|
|
61
|
-
const r = await store.cas(id, doc.version, next);
|
|
62
|
-
if (r.ok) return;
|
|
63
|
-
}
|
|
63
|
+
delete(key: string): Promise<void> {
|
|
64
|
+
return update(data => {
|
|
65
|
+
if (!(key in data)) return undefined;
|
|
66
|
+
const next = { ...data };
|
|
67
|
+
delete next[key];
|
|
68
|
+
return next;
|
|
69
|
+
});
|
|
64
70
|
},
|
|
65
71
|
};
|
|
66
72
|
}
|
package/src/types.ts
CHANGED
|
@@ -1,13 +1 @@
|
|
|
1
1
|
export type * from '@matatbread/matbot-plugin-api';
|
|
2
|
-
|
|
3
|
-
// ── Internal types (not part of the plugin API) ───────────────────────────────
|
|
4
|
-
|
|
5
|
-
import type {
|
|
6
|
-
ProviderAdapter,
|
|
7
|
-
} from '@matatbread/matbot-plugin-api';
|
|
8
|
-
|
|
9
|
-
export interface ProviderRegistry {
|
|
10
|
-
register(adapter: ProviderAdapter): void;
|
|
11
|
-
resolve(name: string): ProviderAdapter;
|
|
12
|
-
}
|
|
13
|
-
|