@matatbread/matbot-plugin-api 0.4.17 → 0.4.19
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 +34 -12
- package/src/index.ts +2 -2
- package/src/invoke-tool.ts +5 -3
- package/src/notify.ts +18 -1
- package/src/plugin.ts +22 -0
- package/src/types/events.ts +12 -0
- package/src/types/hooks.ts +20 -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 +14 -0
- package/src/types.ts +1 -0
package/package.json
CHANGED
package/src/hooks.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
Hook, HookPoint, HookRegistrar, Message, MessageContent, Session, DeferredScreen,
|
|
2
|
+
Hook, HookPoint, HookRegistrar, Message, MessageContent, Session, DeferredScreen, TurnToolSource,
|
|
3
3
|
ScreenContext, ContributeContext, ToolCallContext, ToolCallResult, ToolResultContext, FollowupContext,
|
|
4
4
|
} from './types.js';
|
|
5
5
|
import { foldOntoUserTurn, createMessage } from './session.js';
|
|
@@ -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({
|
|
@@ -106,12 +120,13 @@ export class HookRegistry implements HookRegistrar {
|
|
|
106
120
|
|
|
107
121
|
// screen folds across hooks: each sees the session as shaped so far, accumulates ephemeral, and
|
|
108
122
|
// the first `abort` short-circuits (the partial session is returned so the caller can persist it).
|
|
109
|
-
async runScreen(ctx: Omit<ScreenContext, 'removeHook'>): Promise<{ session: Session; ephemeral: MessageContent[]; durable: MessageContent[]; markers: MessageContent[]; deferred: DeferredScreen[]; abort?: string }> {
|
|
123
|
+
async runScreen(ctx: Omit<ScreenContext, 'removeHook'>): Promise<{ session: Session; ephemeral: MessageContent[]; durable: MessageContent[]; markers: MessageContent[]; deferred: DeferredScreen[]; tools: TurnToolSource[]; abort?: string }> {
|
|
110
124
|
let session = ctx.session;
|
|
111
125
|
const ephemeral: MessageContent[] = [];
|
|
112
126
|
// Raced verdicts handed back by hooks: the runner polls each without gating the turn (see
|
|
113
127
|
// DeferredScreen). Usually zero or one; an array so several racing hooks compose.
|
|
114
128
|
const deferred: DeferredScreen[] = [];
|
|
129
|
+
const tools: TurnToolSource[] = [];
|
|
115
130
|
// Handler-returned markers: appended to the session here (so they persist) and accumulated so the
|
|
116
131
|
// runner can also emit them live — keeping a live draw and a reload identical.
|
|
117
132
|
const handlerMarkers: MessageContent[] = [];
|
|
@@ -134,20 +149,21 @@ export class HookRegistry implements HookRegistrar {
|
|
|
134
149
|
session = folded;
|
|
135
150
|
};
|
|
136
151
|
for (const hook of this.on('screen')) {
|
|
137
|
-
const r = await this.invoke(hook, () => hook.handler({ ...ctx, session, removeHook: () => this.removeOne('screen', hook) }));
|
|
152
|
+
const r = await this.invoke(hook, ctx.signal, () => hook.handler({ ...ctx, session, removeHook: () => this.removeOne('screen', hook) }));
|
|
138
153
|
if (!r) continue;
|
|
139
154
|
if (r.session) session = r.session;
|
|
140
155
|
if (r.ephemeral) ephemeral.push(...r.ephemeral);
|
|
141
156
|
if (r.durable && r.durable.length) foldDurable(r.durable);
|
|
142
157
|
if (r.markers && r.markers.length) appendMarkers(r.markers);
|
|
143
158
|
if (r.deferred) deferred.push(r.deferred);
|
|
159
|
+
if (r.tools) tools.push(r.tools);
|
|
144
160
|
if (r.abort) {
|
|
145
161
|
const drained = this.drainFailureMarkers(session);
|
|
146
|
-
return { session: drained.session, ephemeral, durable, markers: [...handlerMarkers, ...drained.markers], deferred, abort: r.abort };
|
|
162
|
+
return { session: drained.session, ephemeral, durable, markers: [...handlerMarkers, ...drained.markers], deferred, tools, abort: r.abort };
|
|
147
163
|
}
|
|
148
164
|
}
|
|
149
165
|
const drained = this.drainFailureMarkers(session);
|
|
150
|
-
return { session: drained.session, ephemeral, durable, markers: [...handlerMarkers, ...drained.markers], deferred };
|
|
166
|
+
return { session: drained.session, ephemeral, durable, markers: [...handlerMarkers, ...drained.markers], deferred, tools };
|
|
151
167
|
}
|
|
152
168
|
|
|
153
169
|
// contribute folds the outgoing array through each hook — a pure transform pipeline; the stored
|
|
@@ -155,7 +171,7 @@ export class HookRegistry implements HookRegistrar {
|
|
|
155
171
|
async runContribute(ctx: Omit<ContributeContext, 'removeHook'>): Promise<Message[]> {
|
|
156
172
|
let outgoing = ctx.outgoing as Message[];
|
|
157
173
|
for (const hook of this.on('contribute')) {
|
|
158
|
-
const r = await this.invoke(hook, () => hook.handler({ ...ctx, outgoing, removeHook: () => this.removeOne('contribute', hook) }));
|
|
174
|
+
const r = await this.invoke(hook, ctx.signal, () => hook.handler({ ...ctx, outgoing, removeHook: () => this.removeOne('contribute', hook) }));
|
|
159
175
|
if (r) outgoing = r;
|
|
160
176
|
}
|
|
161
177
|
return outgoing;
|
|
@@ -164,7 +180,7 @@ export class HookRegistry implements HookRegistrar {
|
|
|
164
180
|
// toolcall stops at the first hook that rejects or aborts; the rest don't run.
|
|
165
181
|
async runToolCall(ctx: Omit<ToolCallContext, 'removeHook'>): Promise<ToolCallResult> {
|
|
166
182
|
for (const hook of this.on('toolcall')) {
|
|
167
|
-
const r = await this.invoke(hook, () => hook.handler({ ...ctx, removeHook: () => this.removeOne('toolcall', hook) }));
|
|
183
|
+
const r = await this.invoke(hook, ctx.signal, () => hook.handler({ ...ctx, removeHook: () => this.removeOne('toolcall', hook) }));
|
|
168
184
|
if (r && (r.rejectTool || r.abort)) return r;
|
|
169
185
|
}
|
|
170
186
|
return {};
|
|
@@ -175,7 +191,7 @@ export class HookRegistry implements HookRegistrar {
|
|
|
175
191
|
async runToolResult(ctx: Omit<ToolResultContext, 'removeHook'>): Promise<unknown> {
|
|
176
192
|
let result = ctx.result;
|
|
177
193
|
for (const hook of this.on('toolresult')) {
|
|
178
|
-
const r = await this.invoke(hook, () => hook.handler({ ...ctx, result, removeHook: () => this.removeOne('toolresult', hook) }));
|
|
194
|
+
const r = await this.invoke(hook, ctx.signal, () => hook.handler({ ...ctx, result, removeHook: () => this.removeOne('toolresult', hook) }));
|
|
179
195
|
if (r) result = r.result;
|
|
180
196
|
}
|
|
181
197
|
return result;
|
|
@@ -195,7 +211,7 @@ export class HookRegistry implements HookRegistrar {
|
|
|
195
211
|
// asking to pop and re-run with nothing added was answered by doing nothing at all, silently.
|
|
196
212
|
let retracting = false;
|
|
197
213
|
for (const hook of this.on('followup')) {
|
|
198
|
-
const r = await this.invoke(hook, () => hook.handler({ ...ctx, removeHook: () => this.removeOne('followup', hook) }));
|
|
214
|
+
const r = await this.invoke(hook, ctx.signal, () => hook.handler({ ...ctx, removeHook: () => this.removeOne('followup', hook) }));
|
|
199
215
|
if (r?.resubmit) resubmits.push(r.resubmit.content);
|
|
200
216
|
if (r?.retractAndRerun) {
|
|
201
217
|
retracting = true;
|
|
@@ -207,3 +223,9 @@ export class HookRegistry implements HookRegistrar {
|
|
|
207
223
|
return { resubmits, markers, ...(retracting ? { retract: { context: retractCtx, durable: retractDur } } : {}) };
|
|
208
224
|
}
|
|
209
225
|
}
|
|
226
|
+
|
|
227
|
+
function describeThrown(value: unknown): string {
|
|
228
|
+
if (value instanceof Error) return `${value.name}: ${value.message}`;
|
|
229
|
+
if (typeof value === 'string') return JSON.stringify(value);
|
|
230
|
+
try { return JSON.stringify(value) ?? String(value); } catch { return String(value); }
|
|
231
|
+
}
|
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/invoke-tool.ts
CHANGED
|
@@ -58,11 +58,11 @@ export function bindGate(
|
|
|
58
58
|
*/
|
|
59
59
|
export type InvokeToolOptions =
|
|
60
60
|
Pick<ToolContext, 'session' | 'signal'>
|
|
61
|
-
& Partial<Pick<ToolContext, 'prompt' | 'provider' | 'callId'>>;
|
|
61
|
+
& Partial<Pick<ToolContext, 'prompt' | 'provider' | 'callId' | 'turnTools'>>;
|
|
62
62
|
|
|
63
63
|
/**
|
|
64
64
|
* Programmatically invoke a tool by name, the same way the harness does — resolve it off the
|
|
65
|
-
* machine's tool registry, build a `ToolContext` from the machine, and return its event stream.
|
|
65
|
+
* machine's tool registry (else the turn's own tools, when `opts` carries them), build a `ToolContext` from the machine, and return its event stream.
|
|
66
66
|
* The host-only bits a one-shot caller can't derive (the session under which the call runs, its
|
|
67
67
|
* abort signal, and an optional interactive `prompt`/`provider`) come in via `opts` — pass the
|
|
68
68
|
* calling tool's `ctx` to forward them all (see {@link InvokeToolOptions}); everything else (vault,
|
|
@@ -79,7 +79,8 @@ export function invokeTool<K extends string, const P>(
|
|
|
79
79
|
params: P,
|
|
80
80
|
opts: InvokeToolOptions,
|
|
81
81
|
): AsyncIterable<ToolEvent<ToolResultFor<K, P>>> {
|
|
82
|
-
|
|
82
|
+
// The registry first, as the runner does: a turn tool never shadows a registered one.
|
|
83
|
+
const tool = machine.tools.resolve(name) ?? opts.turnTools?.get(name) ?? null;
|
|
83
84
|
if (tool === null) throw new Error(`Tool "${name}" is not registered`);
|
|
84
85
|
// A call whose turn was cancelled never starts. Otherwise a composed body looping over `await tool.x()`
|
|
85
86
|
// goes on calling tools after the abort, each callee deciding for itself whether to honour the signal.
|
|
@@ -98,6 +99,7 @@ export function invokeTool<K extends string, const P>(
|
|
|
98
99
|
// non-interactive case the request's `fallback` exists to answer.
|
|
99
100
|
...bindGate(machine.PermissionGate, name, opts.prompt),
|
|
100
101
|
...(opts.provider !== undefined ? { provider: opts.provider } : {}),
|
|
102
|
+
...(opts.turnTools !== undefined ? { turnTools: opts.turnTools } : {}),
|
|
101
103
|
...(machine.workdir !== undefined ? { workdir: machine.workdir } : {}),
|
|
102
104
|
...(machine.configPath !== undefined ? { configPath: machine.configPath } : {}),
|
|
103
105
|
...(machine.files !== undefined ? { files: machine.files } : {}),
|
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/hooks.ts
CHANGED
|
@@ -24,6 +24,7 @@ export interface RunConfig {
|
|
|
24
24
|
* doesn't bust the cached prefix), `durable` context (the persisted, visible twin of
|
|
25
25
|
* `ephemeral`: folded onto this turn's user message as `origin: 'robo'` blocks and
|
|
26
26
|
* carried live as a `robo-user` event, so it survives into the next turn's history),
|
|
27
|
+
* turn-scoped `tools` (a source the runner asks each round — see {@link TurnToolSource}),
|
|
27
28
|
* or `abort`. Mix freely. This is where the durable-vs-ephemeral choice for incoming
|
|
28
29
|
* user input lives.
|
|
29
30
|
* contribute runner, before *every* provider call. Ephemeral by construction (it re-fires, so a
|
|
@@ -123,9 +124,28 @@ export interface ScreenResult {
|
|
|
123
124
|
* you just want to annotate (e.g. a fired trigger's silent tool recording what it did).
|
|
124
125
|
*/
|
|
125
126
|
markers?: MessageContent[];
|
|
127
|
+
/** Tools for this turn only — see {@link TurnToolSource}. */
|
|
128
|
+
tools?: TurnToolSource;
|
|
126
129
|
abort?: string;
|
|
127
130
|
}
|
|
128
131
|
|
|
132
|
+
/**
|
|
133
|
+
* Tools that exist for one turn of one session and nowhere else: advertised to the model, runnable by
|
|
134
|
+
* it, and runnable by any tool in the turn that forwards its context ({@link ToolContext.turnTools}) —
|
|
135
|
+
* but never registered, so no other session, no sessionless door (`POST /tools/:name`), the type index
|
|
136
|
+
* and the tool presenter never see them. The shape a session-scoped `tool_function` needs.
|
|
137
|
+
*
|
|
138
|
+
* A SOURCE rather than a list because the runner asks it at the top of EVERY round, with the session as
|
|
139
|
+
* it then stands: a turn's own tool calls change the session (a marker recording a definition lands at
|
|
140
|
+
* the end of its round), and a tool defined in round 2 must be callable in round 3, not next turn. So a
|
|
141
|
+
* source is called often and should cache whatever is costly to build.
|
|
142
|
+
*
|
|
143
|
+
* A turn tool may not shadow a registered one: a name the registry holds is dropped, with a warning, and
|
|
144
|
+
* the registered tool keeps it. Shadowing a tool by registration is a gated decision (`tools.overwrite`),
|
|
145
|
+
* and a hook must not be a way round it. A source wanting the model to hear about a clash says so itself.
|
|
146
|
+
*/
|
|
147
|
+
export type TurnToolSource = (session: Session) => readonly Tool[] | Promise<readonly Tool[]>;
|
|
148
|
+
|
|
129
149
|
export interface ContributeContext {
|
|
130
150
|
readonly outgoing: readonly Message[];
|
|
131
151
|
readonly session: Session;
|
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,19 @@ 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;
|
|
222
|
+
/**
|
|
223
|
+
* The tools that exist for this turn only ({@link TurnToolSource}), as the runner resolved them for
|
|
224
|
+
* this round. Never in the registry, so a tool calling another by name finds them only through here —
|
|
225
|
+
* which {@link invokeTool} does when handed this context, the way a forwarding tool already should.
|
|
226
|
+
* Absent ⇒ none, or a door that runs no turn (`POST /tools/:name`).
|
|
227
|
+
*/
|
|
228
|
+
turnTools?: ReadonlyMap<string, Tool>;
|
|
215
229
|
/** Prompt the user for input. The host provides a readline or form implementation. */
|
|
216
230
|
prompt: PromptFn;
|
|
217
231
|
/**
|
package/src/types.ts
CHANGED