@matatbread/matbot-plugin-api 0.4.17 → 0.4.18
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 +1 -1
- package/src/hooks.ts +28 -8
- package/src/index.ts +2 -2
- package/src/notify.ts +18 -1
- package/src/plugin.ts +22 -0
- package/src/types/events.ts +12 -0
- package/src/types/messages.ts +9 -9
- package/src/types/session-append.ts +47 -0
- package/src/types/session-runner.ts +53 -10
- package/src/types/steering.ts +3 -2
- package/src/types/tools.ts +7 -0
- package/src/types.ts +1 -0
package/package.json
CHANGED
package/src/hooks.ts
CHANGED
|
@@ -59,16 +59,30 @@ export class HookRegistry implements HookRegistrar {
|
|
|
59
59
|
// than per-channel because it is the one place every handler invocation passes through — and because
|
|
60
60
|
// the site must be in force when the handler *starts*, so work it kicks off detached stays attributed
|
|
61
61
|
// to it however late it settles.
|
|
62
|
-
private async invoke<R>(hook: Hook, run: () => R | Promise<R>): Promise<R | undefined> {
|
|
62
|
+
private async invoke<R>(hook: Hook, signal: AbortSignal, run: () => R | Promise<R>): Promise<R | undefined> {
|
|
63
63
|
try {
|
|
64
64
|
return await withUsageSite(
|
|
65
65
|
{ kind: 'hook', channel: hook.on, ...(hook.pluginName !== undefined ? { plugin: hook.pluginName } : {}) },
|
|
66
66
|
run,
|
|
67
67
|
);
|
|
68
68
|
} catch (err) {
|
|
69
|
-
const message = err instanceof Error ? err.message : String(err);
|
|
70
69
|
const owner = hook.pluginName !== undefined ? ` from "${hook.pluginName}"` : '';
|
|
71
|
-
|
|
70
|
+
// An aborted turn (a steer, a cancel) rejects whatever was awaiting its signal with the abort REASON —
|
|
71
|
+
// often a bare token like 'steer', not an Error — which logged as a failure whose whole message was
|
|
72
|
+
// "steer". The hook was cut off, not broken, so it is said as such and leaves no failure marker.
|
|
73
|
+
//
|
|
74
|
+
// Which is asked of the THROWN value, not of the signal alone. `aborted` stays true for the rest of
|
|
75
|
+
// the turn, so testing it alone reported every later throw as a cut-off — masking a genuine hook bug,
|
|
76
|
+
// marker and stack included, from the first steer onwards. Interrupt is the default disposition for a
|
|
77
|
+
// mid-turn message, so that window is the common case, not a corner of one. A cut-off throw is either
|
|
78
|
+
// the reason itself (what a signal rejects with) or the standard AbortError.
|
|
79
|
+
if (signal.aborted && (err === signal.reason || (err as { name?: unknown } | null)?.name === 'AbortError')) {
|
|
80
|
+
console.warn(`[matbot] ${hook.on} hook${owner} was cut off: its turn was aborted (reason: ${describeThrown(signal.reason)}).`);
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
const message = err instanceof Error ? err.message : `threw a non-Error value: ${describeThrown(err)}`;
|
|
84
|
+
// The Error itself, not its message, so the log carries the stack — the message alone rarely says where.
|
|
85
|
+
console.error(`[matbot] ${hook.on} hook${owner} threw; skipping it for this turn:`, err instanceof Error ? err : message);
|
|
72
86
|
if (!this.markedFailures.has(hook)) {
|
|
73
87
|
this.markedFailures.add(hook);
|
|
74
88
|
this.pendingFailureMarkers.push({
|
|
@@ -134,7 +148,7 @@ export class HookRegistry implements HookRegistrar {
|
|
|
134
148
|
session = folded;
|
|
135
149
|
};
|
|
136
150
|
for (const hook of this.on('screen')) {
|
|
137
|
-
const r = await this.invoke(hook, () => hook.handler({ ...ctx, session, removeHook: () => this.removeOne('screen', hook) }));
|
|
151
|
+
const r = await this.invoke(hook, ctx.signal, () => hook.handler({ ...ctx, session, removeHook: () => this.removeOne('screen', hook) }));
|
|
138
152
|
if (!r) continue;
|
|
139
153
|
if (r.session) session = r.session;
|
|
140
154
|
if (r.ephemeral) ephemeral.push(...r.ephemeral);
|
|
@@ -155,7 +169,7 @@ export class HookRegistry implements HookRegistrar {
|
|
|
155
169
|
async runContribute(ctx: Omit<ContributeContext, 'removeHook'>): Promise<Message[]> {
|
|
156
170
|
let outgoing = ctx.outgoing as Message[];
|
|
157
171
|
for (const hook of this.on('contribute')) {
|
|
158
|
-
const r = await this.invoke(hook, () => hook.handler({ ...ctx, outgoing, removeHook: () => this.removeOne('contribute', hook) }));
|
|
172
|
+
const r = await this.invoke(hook, ctx.signal, () => hook.handler({ ...ctx, outgoing, removeHook: () => this.removeOne('contribute', hook) }));
|
|
159
173
|
if (r) outgoing = r;
|
|
160
174
|
}
|
|
161
175
|
return outgoing;
|
|
@@ -164,7 +178,7 @@ export class HookRegistry implements HookRegistrar {
|
|
|
164
178
|
// toolcall stops at the first hook that rejects or aborts; the rest don't run.
|
|
165
179
|
async runToolCall(ctx: Omit<ToolCallContext, 'removeHook'>): Promise<ToolCallResult> {
|
|
166
180
|
for (const hook of this.on('toolcall')) {
|
|
167
|
-
const r = await this.invoke(hook, () => hook.handler({ ...ctx, removeHook: () => this.removeOne('toolcall', hook) }));
|
|
181
|
+
const r = await this.invoke(hook, ctx.signal, () => hook.handler({ ...ctx, removeHook: () => this.removeOne('toolcall', hook) }));
|
|
168
182
|
if (r && (r.rejectTool || r.abort)) return r;
|
|
169
183
|
}
|
|
170
184
|
return {};
|
|
@@ -175,7 +189,7 @@ export class HookRegistry implements HookRegistrar {
|
|
|
175
189
|
async runToolResult(ctx: Omit<ToolResultContext, 'removeHook'>): Promise<unknown> {
|
|
176
190
|
let result = ctx.result;
|
|
177
191
|
for (const hook of this.on('toolresult')) {
|
|
178
|
-
const r = await this.invoke(hook, () => hook.handler({ ...ctx, result, removeHook: () => this.removeOne('toolresult', hook) }));
|
|
192
|
+
const r = await this.invoke(hook, ctx.signal, () => hook.handler({ ...ctx, result, removeHook: () => this.removeOne('toolresult', hook) }));
|
|
179
193
|
if (r) result = r.result;
|
|
180
194
|
}
|
|
181
195
|
return result;
|
|
@@ -195,7 +209,7 @@ export class HookRegistry implements HookRegistrar {
|
|
|
195
209
|
// asking to pop and re-run with nothing added was answered by doing nothing at all, silently.
|
|
196
210
|
let retracting = false;
|
|
197
211
|
for (const hook of this.on('followup')) {
|
|
198
|
-
const r = await this.invoke(hook, () => hook.handler({ ...ctx, removeHook: () => this.removeOne('followup', hook) }));
|
|
212
|
+
const r = await this.invoke(hook, ctx.signal, () => hook.handler({ ...ctx, removeHook: () => this.removeOne('followup', hook) }));
|
|
199
213
|
if (r?.resubmit) resubmits.push(r.resubmit.content);
|
|
200
214
|
if (r?.retractAndRerun) {
|
|
201
215
|
retracting = true;
|
|
@@ -207,3 +221,9 @@ export class HookRegistry implements HookRegistrar {
|
|
|
207
221
|
return { resubmits, markers, ...(retracting ? { retract: { context: retractCtx, durable: retractDur } } : {}) };
|
|
208
222
|
}
|
|
209
223
|
}
|
|
224
|
+
|
|
225
|
+
function describeThrown(value: unknown): string {
|
|
226
|
+
if (value instanceof Error) return `${value.name}: ${value.message}`;
|
|
227
|
+
if (typeof value === 'string') return JSON.stringify(value);
|
|
228
|
+
try { return JSON.stringify(value) ?? String(value); } catch { return String(value); }
|
|
229
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -29,10 +29,10 @@ export { PLUGIN_API_VERSION } from './plugin.js';
|
|
|
29
29
|
// the host's job (`/host`).
|
|
30
30
|
export { currentPrincipal, tryCurrentPrincipal, runAs } from './principal-context.js';
|
|
31
31
|
|
|
32
|
-
// Notifications: publish, consume, and the
|
|
32
|
+
// Notifications: publish, consume, and the three kinds plugin-api itself defines. Minting the process
|
|
33
33
|
// notifier is the host's job (`/host`).
|
|
34
34
|
export type * from './notify.js';
|
|
35
|
-
export { ItemChangeKind, RegistryChangeKind, notifyingStore } from './notify.js';
|
|
35
|
+
export { ItemChangeKind, RegistryChangeKind, SessionAppendKind, notifyingStore } from './notify.js';
|
|
36
36
|
|
|
37
37
|
// The quiescent edge: defer work to the next moment it is safe to touch machine state. A plugin that has
|
|
38
38
|
// something to do after the current operation — a store edit the running turn would otherwise write over —
|
package/src/notify.ts
CHANGED
|
@@ -89,7 +89,22 @@ export interface RegistryChange extends NotificationBase {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/**
|
|
92
|
-
*
|
|
92
|
+
* Messages were appended to a session outside any turn (see `SessionAppender`) — not by the session's
|
|
93
|
+
* own pump, so nothing on that session's turn stream says so.
|
|
94
|
+
*
|
|
95
|
+
* The same write raises an {@link ItemChange} for the session; this exists beside it because it carries
|
|
96
|
+
* the one thing a re-read cannot recover: WHICH messages arrived this way. A turn adds messages too, so a
|
|
97
|
+
* consumer diffing the document would have to keep a copy of it to tell them apart. A frontend draws
|
|
98
|
+
* these into the open conversation; a chat bridge forwards them to the chat the session belongs to.
|
|
99
|
+
* Identity only, as ever — a consumer re-reads the session for the content.
|
|
100
|
+
*/
|
|
101
|
+
export interface SessionAppend extends NotificationBase {
|
|
102
|
+
readonly sessionId: string;
|
|
103
|
+
readonly messageIds: readonly string[];
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The wire tags for the kinds defined here. A `kind` is a globally-scoped token — unlike a type
|
|
93
108
|
* name, which an importer can rename out of a clash — so these exist to give one back: import the const
|
|
94
109
|
* under whatever local name suits (`import { ItemChangeKind as Changed }`) and use it as a `case` label
|
|
95
110
|
* or in a filter. `satisfies` ties each to a real key, so a typo fails the build here instead of
|
|
@@ -97,6 +112,7 @@ export interface RegistryChange extends NotificationBase {
|
|
|
97
112
|
*/
|
|
98
113
|
export const ItemChangeKind = '@matatbread/matbot-plugin-api#ItemChange' satisfies keyof Notifications;
|
|
99
114
|
export const RegistryChangeKind = '@matatbread/matbot-plugin-api#RegistryChange' satisfies keyof Notifications;
|
|
115
|
+
export const SessionAppendKind = '@matatbread/matbot-plugin-api#SessionAppend' satisfies keyof Notifications;
|
|
100
116
|
|
|
101
117
|
/**
|
|
102
118
|
* The registry of notification shapes, keyed by `kind` — augment it exactly like `MatbotServices` /
|
|
@@ -128,6 +144,7 @@ export const RegistryChangeKind = '@matatbread/matbot-plugin-api#RegistryChange'
|
|
|
128
144
|
export interface Notifications {
|
|
129
145
|
'@matatbread/matbot-plugin-api#ItemChange': ItemChange;
|
|
130
146
|
'@matatbread/matbot-plugin-api#RegistryChange': RegistryChange;
|
|
147
|
+
'@matatbread/matbot-plugin-api#SessionAppend': SessionAppend;
|
|
131
148
|
}
|
|
132
149
|
|
|
133
150
|
/** A notification as a consumer sees it: the arm, plus the `kind` its registry key supplies. Grafting
|
package/src/plugin.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type {
|
|
|
3
3
|
ProviderAdapter, ProviderConfig, ProviderRegistry, Tool, ToolRegistry, FrontendInfo,
|
|
4
4
|
Store, Session, SystemContextRegistry, KnowledgeIndex, PromptFn, SessionRunner, Usage, HookRegistrar,
|
|
5
5
|
TypeScriptStripper, FunctionRunner, ToolTypeIndex, ToolPresenter, SteeringPolicy, UserContent, PermissionGate,
|
|
6
|
+
SessionAppender, EphemeralRun,
|
|
6
7
|
} from './types.js';
|
|
7
8
|
import type { Notifications, Notifier } from './notify.js';
|
|
8
9
|
|
|
@@ -193,6 +194,11 @@ export interface MatbotServices {
|
|
|
193
194
|
* one; absent ⇒ such code runs unbounded, as it must where synchronous code cannot be interrupted.
|
|
194
195
|
* See {@link FunctionRunner}. */
|
|
195
196
|
readonly FunctionRunner?: FunctionRunner | undefined;
|
|
197
|
+
/** Appends messages to a session without running a turn, once no turn holds it. The host seeds
|
|
198
|
+
* one wherever it holds the session store; a background job registers one that forwards to its parent.
|
|
199
|
+
* Absent ⇒ this process has no safe way to append (a job spawned without that channel), and a caller
|
|
200
|
+
* refuses rather than writing the store itself. See {@link SessionAppender}. */
|
|
201
|
+
readonly SessionAppender?: SessionAppender | undefined;
|
|
196
202
|
}
|
|
197
203
|
|
|
198
204
|
/** The assembled machine: registry services wired to the fixed runtime — what `setup()` receives. */
|
|
@@ -294,6 +300,22 @@ export interface MatbotRuntime {
|
|
|
294
300
|
/** Per-session turn serialiser. Frontends submit and observe through this rather than calling
|
|
295
301
|
* runSession directly, so concurrent submits queue instead of clobbering the session. */
|
|
296
302
|
readonly run?: SessionRunner | undefined;
|
|
303
|
+
/**
|
|
304
|
+
* A private runner over its own in-memory session store, for a turn that should leave no trace: a
|
|
305
|
+
* background job's run, a demonstration whose transcript is analysed and thrown away. Nothing it holds
|
|
306
|
+
* is persisted or announced, and nothing outlives the returned object — so create the session in
|
|
307
|
+
* `sessions` before `run.open`, and drop both when done.
|
|
308
|
+
*
|
|
309
|
+
* Tools on this runner still reach the machine's real stores through what they captured at setup:
|
|
310
|
+
* `session_action list` sees the user's conversations, and `session_edit` of the ephemeral session
|
|
311
|
+
* refuses, finding nothing. `appender` is how such a turn reports somewhere real — it arrives as
|
|
312
|
+
* `ToolContext.appender`, and its `defaultSessionId` is where an append naming no session goes.
|
|
313
|
+
*
|
|
314
|
+
* Its turns hold the machine as any turn does. Its tools use the live machine, so a storage swap or an
|
|
315
|
+
* unload waits until the run ends. Its appends, and its edits of other sessions, do not: each waits only
|
|
316
|
+
* for a turn of the session it writes (`SessionRunner.write`).
|
|
317
|
+
*/
|
|
318
|
+
ephemeral?(opts?: { appender?: SessionAppender }): EphemeralRun;
|
|
297
319
|
readonly files?: FileStore;
|
|
298
320
|
readonly hooks: HookRegistrar;
|
|
299
321
|
readonly tools: ToolRegistry;
|
package/src/types/events.ts
CHANGED
|
@@ -55,6 +55,18 @@ export type TurnEvent =
|
|
|
55
55
|
// the pump's per-turn `queued` replay seed / committed store order — this is purely live delivery.
|
|
56
56
|
| { type: 'steer'; content: MessageContent[]; interruptedTraceId: string; traceId: string; rootTraceId: string }
|
|
57
57
|
|
|
58
|
+
// A mid-turn submission answered IN PARALLEL with the running turn (`mode: 'parallel'`), which is left
|
|
59
|
+
// untouched. It runs on a private copy of the session's completed turns — everything before
|
|
60
|
+
// `runningTraceId`'s user message — so its events (this one first, then its own turn's, under
|
|
61
|
+
// `traceId`) are live delivery only: its tool calls stay in that copy and are never persisted, and its
|
|
62
|
+
// terminal's `session` is the COPY, not this session. What survives is the submission and its final
|
|
63
|
+
// reply, written back as a user/assistant pair and announced by `merged`.
|
|
64
|
+
| { type: 'parallel'; content: MessageContent[]; runningTraceId: string; traceId: string; rootTraceId: string }
|
|
65
|
+
// A parallel turn's pair has been written into the session. `into` is the turn that was running when
|
|
66
|
+
// it landed: the pair went into that turn's history, ahead of its user message (where the copy it ran
|
|
67
|
+
// on ended), at a round boundary. Absent ⇒ nothing was running and it was appended.
|
|
68
|
+
| { type: 'merged'; traceId: string; into?: string }
|
|
69
|
+
|
|
58
70
|
// ── terminals: exactly one per turn ──
|
|
59
71
|
| { type: 'done'; session: Session; traceId: string }
|
|
60
72
|
| { type: 'aborted'; reason: string; session: Session; traceId: string }
|
package/src/types/messages.ts
CHANGED
|
@@ -181,13 +181,13 @@ export interface Session {
|
|
|
181
181
|
// Legacy: persisted sessions may still carry `ownerPrincipalId` / `actorPrincipalId` / `persona`, and
|
|
182
182
|
// `contexts` (a required-but-never-read `string[]`, dropped at 0.4.0 — system context is contributed by
|
|
183
183
|
// SystemContextContributor, not carried on the session). Never read — ownership-at-rest is structural
|
|
184
|
-
// (the storage partition), resolved via the backend, not a field.
|
|
185
|
-
//
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
createdAt:
|
|
192
|
-
updatedAt:
|
|
184
|
+
// (the storage partition), resolved via the backend, not a field. Likewise `parentSessionId` /
|
|
185
|
+
// `branchPointMessageId` (dropped at 0.4.18): a fork or split records its relation as a marker in the
|
|
186
|
+
// session (`peerSessionId` + `targetMsg`), which is what a frontend reads. Left undeclared; old data
|
|
187
|
+
// keeps them harmlessly as excess properties.
|
|
188
|
+
title?: string;
|
|
189
|
+
status: SessionStatus;
|
|
190
|
+
messages: Message[];
|
|
191
|
+
createdAt: ISODate;
|
|
192
|
+
updatedAt: ISODate;
|
|
193
193
|
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// ── Session appends ─────────────────────────────────────────────────────────────
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A message added to a session outside any turn — a background job's report, an answer merged back
|
|
5
|
+
* from elsewhere.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately narrow: plain text, user or assistant. An append arrives unasked into a history that is
|
|
8
|
+
* replayed to a provider on every later turn, so the arms that carry protocol state are excluded by
|
|
9
|
+
* construction — a `tool-use` without its result, or a `thinking` block whose signature it did not
|
|
10
|
+
* earn, would fail every turn after it rather than this one.
|
|
11
|
+
*/
|
|
12
|
+
export interface AppendMessage {
|
|
13
|
+
role: 'user' | 'assistant';
|
|
14
|
+
content: Array<{ type: 'text'; text: string; origin?: 'robo' }>;
|
|
15
|
+
metadata?: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface AppendResult {
|
|
19
|
+
sessionId: string;
|
|
20
|
+
/** The ids the appended messages will carry — minted at acceptance, so a caller can name them before
|
|
21
|
+
* the write has landed. */
|
|
22
|
+
messageIds: string[];
|
|
23
|
+
/** The write waits for a turn holding the session, so it has not landed yet. Absent ⇒ it has. */
|
|
24
|
+
deferred?: true;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Adds messages to the end of a session without running a turn.
|
|
29
|
+
*
|
|
30
|
+
* A running turn owns its session: the runner works on an in-memory copy and writes it back whole when
|
|
31
|
+
* the turn ends, which is what keeps the stored session a set of completed turns. So an append is written
|
|
32
|
+
* by the session's runner once no turn holds that session (`SessionRunner.write`), and announced then as a
|
|
33
|
+
* `SessionAppend` notification. When no turn holds it, that is at once, and the append settles once
|
|
34
|
+
* written, reporting a write that could not land. When one does, it settles once **accepted**: the caller
|
|
35
|
+
* may be that very turn, and awaiting the write from inside it would deadlock.
|
|
36
|
+
*
|
|
37
|
+
* The host seeds one in every process that holds the session store. A background job does not get
|
|
38
|
+
* one: it shares the store's medium with its parent but none of the parent's pumps, so a write it made
|
|
39
|
+
* would race them unseen. A job registers one that forwards to its parent instead; with neither, a
|
|
40
|
+
* caller refuses rather than writing.
|
|
41
|
+
*/
|
|
42
|
+
export interface SessionAppender {
|
|
43
|
+
/** Where an append naming no session goes, when this process has a destination of its own — a
|
|
44
|
+
* background job's: the conversation it reports to. Absent ⇒ the caller supplies its own session. */
|
|
45
|
+
readonly defaultSessionId?: string;
|
|
46
|
+
append(sessionId: string | undefined, messages: readonly AppendMessage[]): Promise<AppendResult>;
|
|
47
|
+
}
|
|
@@ -4,15 +4,16 @@ import type { MimeType } from './primitives.js';
|
|
|
4
4
|
import type { Principal } from './principal.js';
|
|
5
5
|
import type { SteeringMode } from './steering.js';
|
|
6
6
|
import type { PromptFn } from './tools.js';
|
|
7
|
+
import type { Store } from './storage.js';
|
|
7
8
|
|
|
8
9
|
// ── Session runner ──────────────────────────────────────────────────────────────
|
|
9
10
|
|
|
10
11
|
/**
|
|
11
12
|
* A view onto a session returned by `SessionRunner.open`. `session` is the authoritative
|
|
12
|
-
* server-side state — committed messages
|
|
13
|
-
*
|
|
14
|
-
* tap: accessing it subscribes to the turn event stream
|
|
15
|
-
* turn,
|
|
13
|
+
* server-side state — committed messages only, ending at the running turn's user message. The
|
|
14
|
+
* in-flight turn and any queued submissions are the delta, delivered over `events`, never overlaid
|
|
15
|
+
* here. `events` is a lazy, per-session live tap: accessing it subscribes to the turn event stream
|
|
16
|
+
* from now (replaying the in-flight turn, then the queue); never touching it costs nothing.
|
|
16
17
|
*/
|
|
17
18
|
export interface SessionView {
|
|
18
19
|
session: Session;
|
|
@@ -73,8 +74,9 @@ export interface SubmitOpenOpts extends OpenOpts {
|
|
|
73
74
|
concatQueue?: boolean;
|
|
74
75
|
/** Disposition for a submission arriving while a turn is running (see {@link SteeringPolicy}):
|
|
75
76
|
* 'queue' waits for the turn boundary (default), 'interrupt' stops the running turn — keeping its
|
|
76
|
-
* committed partial work — and runs this next, '
|
|
77
|
-
* the
|
|
77
|
+
* committed partial work — and runs this next, 'parallel' answers it now beside the running turn
|
|
78
|
+
* (see the `parallel` event), 'auto' defers to the registered SteeringPolicy (else the host
|
|
79
|
+
* default). Meaningless when nothing is running (degrades to a plain enqueue). */
|
|
78
80
|
mode?: SteeringMode;
|
|
79
81
|
/** Interactive prompt implementation for this submission's turn. The frontend owns delivery —
|
|
80
82
|
* it must target the frontend's per-session client connections, not a single request. */
|
|
@@ -89,13 +91,54 @@ export interface SubmitOpenOpts extends OpenOpts {
|
|
|
89
91
|
*/
|
|
90
92
|
export interface SessionRunner {
|
|
91
93
|
open(opts: OpenOpts | SubmitOpenOpts): Promise<SessionView>;
|
|
92
|
-
/** Abort the running turn (if any) and drop all queued submissions,
|
|
94
|
+
/** Abort the running turn (if any), any `parallel` turns beside it, and drop all queued submissions,
|
|
95
|
+
* emitting `cancelled` for each. */
|
|
93
96
|
abort(sessionId: string): void;
|
|
97
|
+
/** Stop ONE `parallel` turn, leaving the running turn, the queue and any other parallel turns alone.
|
|
98
|
+
* A parallel turn is the only kind that can be stopped individually: it runs on a nested runner with
|
|
99
|
+
* a stop of its own, where a queued submission has not started and the running turn is what `abort`
|
|
100
|
+
* and `cancelTurn` already address. It still settles and writes back its pair, carrying a note in
|
|
101
|
+
* place of the reply — the submission was made, and dropping it would lose that. A no-op if no
|
|
102
|
+
* parallel turn of the session carries this traceId. Deliberately its own method rather than an
|
|
103
|
+
* optional argument to `abort`: an implementation that ignored the argument would silently stop
|
|
104
|
+
* everything, which is the opposite of what the caller asked. */
|
|
105
|
+
abortParallel(sessionId: string, traceId: string): void;
|
|
94
106
|
/** Abandon the running turn (if any) WITHOUT touching the queue — `pump` advances to the next
|
|
95
107
|
* queued submission, or idles. The "give up on this turn" path (a prompt cancel); contrast
|
|
96
108
|
* `abort`, which also clears the queue. A no-op if nothing is running. */
|
|
97
109
|
cancelTurn(sessionId: string): void;
|
|
98
|
-
/** Snapshot of a session's live state: whether a turn is running
|
|
99
|
-
*
|
|
100
|
-
status(sessionId: string): { busy: boolean; running: boolean; queued: number };
|
|
110
|
+
/** Snapshot of a session's live state: whether a turn is running, how many submissions wait behind
|
|
111
|
+
* it, and how many `parallel` turns are in flight beside it. `busy` is any of the three. */
|
|
112
|
+
status(sessionId: string): { busy: boolean; running: boolean; queued: number; parallel: number };
|
|
113
|
+
/**
|
|
114
|
+
* Write session `sessionId` from outside its turns — an append, a rename, an edit of its history.
|
|
115
|
+
*
|
|
116
|
+
* A turn works on an in-memory copy of its session and writes it back whole when it ends, so a write
|
|
117
|
+
* landing during a turn is undone by that write-back. The runner is therefore the session's one writer:
|
|
118
|
+
* it runs `attempt` between that session's turns, in the order writes arrive, and no turn of the session
|
|
119
|
+
* starts until it has finished. Other sessions' turns do not delay it.
|
|
120
|
+
*
|
|
121
|
+
* `attempt` reads the session itself and writes it with compare-and-swap, which still answers any writer
|
|
122
|
+
* outside this runner (another process sharing the store). It resolves `false` only when that CAS lost,
|
|
123
|
+
* to be read and tried again, and `true` once finished, whether or not it wrote; it reports its own
|
|
124
|
+
* failures. `lost` is logged if every attempt loses. It runs as the principal in force at this call.
|
|
125
|
+
*
|
|
126
|
+
* It never runs before this call returns. See {@link SessionWrite} for when awaiting it is safe.
|
|
127
|
+
*/
|
|
128
|
+
write(sessionId: string, attempt: () => Promise<boolean>, lost: string): SessionWrite;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** A write handed to {@link SessionRunner.write}. */
|
|
132
|
+
export interface SessionWrite {
|
|
133
|
+
/** A turn of the session was in progress, so the write waits for it to end. Then never await `done`
|
|
134
|
+
* from that turn: its end is what the write is waiting for. */
|
|
135
|
+
deferred: boolean;
|
|
136
|
+
/** Settles once the write has run, however it ended (each failure is logged). Never rejects. */
|
|
137
|
+
done: Promise<void>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** A runner and the store it alone reads and writes — see {@link MatbotRuntime.ephemeral}. */
|
|
141
|
+
export interface EphemeralRun {
|
|
142
|
+
sessions: Store<Session>;
|
|
143
|
+
run: SessionRunner;
|
|
101
144
|
}
|
package/src/types/steering.ts
CHANGED
|
@@ -4,8 +4,9 @@ import type { MessageContent, Session } from './messages.js';
|
|
|
4
4
|
|
|
5
5
|
/** Disposition of a submission that arrives while a turn is running. `interrupt` stops the running
|
|
6
6
|
* turn (its committed partial work is preserved) and runs the new message next; `queue` waits for the
|
|
7
|
-
* turn boundary
|
|
8
|
-
|
|
7
|
+
* turn boundary; `parallel` leaves the running turn alone and answers the new message at once on a
|
|
8
|
+
* private copy of the session's completed turns, writing back only the message and its final reply. */
|
|
9
|
+
export type SteeringDecision = 'queue' | 'interrupt' | 'parallel';
|
|
9
10
|
/** The wire-level request (see `SubmitOpenOpts.mode`). `auto` defers the decision to the registered
|
|
10
11
|
* {@link SteeringPolicy} (else the host default). */
|
|
11
12
|
export type SteeringMode = SteeringDecision | 'auto';
|
package/src/types/tools.ts
CHANGED
|
@@ -4,6 +4,7 @@ import type { MatbotPlugin } from '../plugin.js';
|
|
|
4
4
|
import type { JSONSchema } from './primitives.js';
|
|
5
5
|
import type { PermissionRequest } from './permission.js';
|
|
6
6
|
import type { Vault } from './vault.js';
|
|
7
|
+
import type { SessionAppender } from './session-append.js';
|
|
7
8
|
|
|
8
9
|
// ── Tools ─────────────────────────────────────────────────────────────────────
|
|
9
10
|
|
|
@@ -212,6 +213,12 @@ export interface ToolContext {
|
|
|
212
213
|
workdir?: string;
|
|
213
214
|
configPath?: string;
|
|
214
215
|
files?: FileStore;
|
|
216
|
+
/**
|
|
217
|
+
* The appender belonging to the runner this turn is on, when it is not the machine's — an ephemeral
|
|
218
|
+
* run's ({@link MatbotRuntime.ephemeral}), whose session exists nowhere the machine's appender can
|
|
219
|
+
* reach. Prefer it over `services.SessionAppender`; absent ⇒ the machine's is the right one.
|
|
220
|
+
*/
|
|
221
|
+
appender?: SessionAppender;
|
|
215
222
|
/** Prompt the user for input. The host provides a readline or form implementation. */
|
|
216
223
|
prompt: PromptFn;
|
|
217
224
|
/**
|
package/src/types.ts
CHANGED