@matatbread/matbot-plugin-api 0.4.15 → 0.4.17
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/context-switch.ts +34 -59
- package/src/hooks.ts +30 -25
- package/src/host-machine.ts +16 -7
- package/src/host.ts +4 -0
- package/src/invoke-tool.ts +60 -18
- package/src/notify.ts +7 -3
- package/src/plugin.ts +36 -44
- package/src/principal-context.ts +3 -21
- package/src/scoped-iterator.ts +50 -0
- package/src/session.ts +28 -1
- package/src/types/hooks.ts +2 -1
- package/src/types/knowledge.ts +1 -0
package/package.json
CHANGED
package/src/context-switch.ts
CHANGED
|
@@ -104,15 +104,13 @@ export type Quiescer = (unregister: () => void) => void | Promise<void>;
|
|
|
104
104
|
* onContextQuiesce(() => { if (nothingToDo) return; … });
|
|
105
105
|
* ```
|
|
106
106
|
*
|
|
107
|
-
* **This or {@link scheduleAtEdge}?**
|
|
108
|
-
* when you ask twice.
|
|
107
|
+
* **This or {@link scheduleAtEdge}?** The difference is what happens when you ask twice.
|
|
109
108
|
*
|
|
110
109
|
* This is a *subscription*: each call adds a callback, and every callback added runs. Ask twice and the work
|
|
111
110
|
* happens twice — correct when each call carries its own work (two deferred session edits are two edits, and
|
|
112
|
-
* both must land). `scheduleAtEdge` is a *dirty flag*:
|
|
113
|
-
*
|
|
114
|
-
*
|
|
115
|
-
* in turn, and the work reads the slot at fire time so the last writer wins.
|
|
111
|
+
* both must land). `scheduleAtEdge` is a *dirty flag*: one scheduler, poked any number of times before an
|
|
112
|
+
* edge, runs once and reads a slot at fire time. Correct when the calls describe a **slot** rather than a
|
|
113
|
+
* queue — three `register('StorageBackend')` calls mean one backend to install, not three in turn.
|
|
116
114
|
*
|
|
117
115
|
* Picking wrongly is not subtle in either direction: a subscription where you wanted a flag re-applies stale
|
|
118
116
|
* intermediate values, and a flag where you wanted a subscription silently drops work.
|
|
@@ -125,36 +123,24 @@ export type Quiescer = (unregister: () => void) => void | Promise<void>;
|
|
|
125
123
|
*
|
|
126
124
|
* - Flushers are **invoked in registration order**, in one synchronous sweep; one that goes async is
|
|
127
125
|
* started in that order and then left to settle alongside the rest.
|
|
128
|
-
* - **The synchronous prefix lands within the call.** As long as flushers are synchronous nothing
|
|
129
|
-
*
|
|
130
|
-
* `flushIfQuiescent()` — in effect before the call returns.
|
|
126
|
+
* - **The synchronous prefix lands within the call.** As long as flushers are synchronous nothing yields at
|
|
127
|
+
* all, so a host can stage a mutation and have it in effect before the staging call returns.
|
|
131
128
|
* - **No second sweep begins until the current one has completely settled**, and a mutation staged
|
|
132
129
|
* meanwhile is turned away rather than applied.
|
|
133
130
|
* - A throwing flusher is isolated, and so is a rejecting one — neither may deny the others their
|
|
134
131
|
* completion, nor escape into the operation whose release happened to reach the edge.
|
|
132
|
+
* - **Exclusivity, for a flusher's whole extent**, because {@link machineBusy} bars entry while a flush is
|
|
133
|
+
* settling. Flushers still overlap *each other*, deliberately: sequencing them would remove one source of
|
|
134
|
+
* concurrent mutation while leaving every other in place, at the price of every flusher waiting on the
|
|
135
|
+
* slowest. Keeping a flusher synchronous is still worth it where the work allows.
|
|
135
136
|
*
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
*
|
|
143
|
-
* Exclusivity is a recent acquisition and the reasoning that preceded it is worth keeping, because it
|
|
144
|
-
* says what the barrier is really for. This edge used to be a counter, not a gate: `depth` was 0 while
|
|
145
|
-
* a flush settled, so an HTTP endpoint could accept a request and run a whole tool call inside a single
|
|
146
|
-
* `await`, and the conclusion drawn was that contention over a service is the *service's* to resolve —
|
|
147
|
-
* a `Store` answers it with compare-and-swap — and that the sweep's job was only to make contention
|
|
148
|
-
* rare rather than to pretend the machine stops.
|
|
149
|
-
*
|
|
150
|
-
* That is still true, and `mediumGuard` is the shipped form of it: a write whose read came from another
|
|
151
|
-
* `StorageBackend` fails on the version stamp, so coherence does not depend on this module at all.
|
|
152
|
-
* Which is precisely what makes the barrier safe to add. It is not the correctness mechanism; it is the
|
|
153
|
-
* **liveness** one. A counter cannot force `depth` to reach 0, so under continuously overlapping holds —
|
|
154
|
-
* several sessions on a busy server, each pump holding across its own queue — the edge simply never
|
|
155
|
-
* arrives and staged work waits for an idle moment that never comes. A deferred session edit that never
|
|
156
|
-
* lands, and a staged backend swap that never applies, are both failures with no symptom. Barring entry
|
|
157
|
-
* is the only way to make the drain reachable, and exclusivity falls out of it for free.
|
|
137
|
+
* **The barrier is for liveness, not coherence.** Coherence does not depend on this module at all:
|
|
138
|
+
* `mediumGuard` fails a write whose read came from another `StorageBackend` on the version stamp, and
|
|
139
|
+
* contention over a service is that service's to resolve (a `Store` answers it with compare-and-swap). What
|
|
140
|
+
* a counter *cannot* do is force `depth` to reach 0 — under continuously overlapping holds (several sessions
|
|
141
|
+
* on a busy server, each pump holding across its own queue) the edge never arrives, and a deferred session
|
|
142
|
+
* edit that never lands and a staged swap that never applies are both failures with no symptom. Barring
|
|
143
|
+
* entry is the only way to make the drain reachable.
|
|
158
144
|
*
|
|
159
145
|
* The keys that repoint *immediately* — `Vault`, `KnowledgeIndex`, `Notifier`, anything a plugin
|
|
160
146
|
* registers — carry no protection here or anywhere: they change under a running turn too, by the
|
|
@@ -165,16 +151,13 @@ export function onContextQuiesce(flush: Quiescer): () => void {
|
|
|
165
151
|
const unregister = (): void => { cs.quiescers.delete(flush); };
|
|
166
152
|
cs.quiescers.add(flush);
|
|
167
153
|
|
|
168
|
-
// **Registering IS announcing
|
|
169
|
-
//
|
|
170
|
-
//
|
|
171
|
-
// must be paired, where omitting the second is silent, is the shape this module exists to avoid.
|
|
154
|
+
// **Registering IS announcing**, because two calls that must be paired, where omitting the second is
|
|
155
|
+
// silent, is the shape this module exists to avoid: a stager that registered and forgot to ask for an
|
|
156
|
+
// edge left the barrier disengaged, and its work then depended on someone else happening to release.
|
|
172
157
|
//
|
|
173
158
|
// The barrier is raised synchronously (it invokes nothing) so it engages at once, while the edge ATTEMPT
|
|
174
159
|
// waits for a microtask — a callback must not run before the statement registering it has finished, or a
|
|
175
160
|
// one-shot reading anything initialised on that same line, `unregister` included, would see it missing.
|
|
176
|
-
// Nothing depends on registration landing synchronously; the host's inline-landing guarantee is attached
|
|
177
|
-
// to an explicit `flushIfQuiescent()`, which still has it.
|
|
178
161
|
if (cs.depth !== 0 || cs.flushing) cs.wanted = true;
|
|
179
162
|
void Promise.resolve().then(() => { if (cs.quiescers.has(flush)) flushIfQuiescent(); });
|
|
180
163
|
|
|
@@ -183,13 +166,8 @@ export function onContextQuiesce(flush: Quiescer): () => void {
|
|
|
183
166
|
|
|
184
167
|
/**
|
|
185
168
|
* Announce registered work and land it if the edge is already here — **private**, and the second half of
|
|
186
|
-
* {@link onContextQuiesce}.
|
|
187
|
-
*
|
|
188
|
-
* It was public, and announcing was then a call a stager had to remember to pair with its registration. The
|
|
189
|
-
* one plugin registering a one-shot never made it, so the barrier never engaged for the very work whose
|
|
190
|
-
* starvation motivated it. Registering announces now, which is the same guarantee with nothing to forget —
|
|
191
|
-
* and once that was true, nothing outside this module had any reason to call this. Exposing "force an edge"
|
|
192
|
-
* only offered a way to reason about firing that a caller should not have to have.
|
|
169
|
+
* {@link onContextQuiesce}. Private because registering announces, so nothing outside this module has any
|
|
170
|
+
* reason to force an edge; offering one only invites reasoning about firing that a caller should not need.
|
|
193
171
|
*
|
|
194
172
|
* It stays separate from the opportunistic sweep {@link machineBusy} performs at its own edges: only a
|
|
195
173
|
* registration knows work exists, and a sweep that raised `wanted` merely because it found the machine busy
|
|
@@ -277,10 +255,9 @@ function sweep(): Promise<void> | undefined {
|
|
|
277
255
|
* caller through here — which is all the mount contract has ever promised: eventual and ordered,
|
|
278
256
|
* never timed.
|
|
279
257
|
*
|
|
280
|
-
* **Largely redundant since the barrier
|
|
281
|
-
*
|
|
282
|
-
*
|
|
283
|
-
* does *not* want to hold the machine.
|
|
258
|
+
* **Largely redundant since the barrier**, which delivers this guarantee inside {@link machineBusy} where
|
|
259
|
+
* it cannot be forgotten. It remains for an operation that needs deferred work complete and does *not*
|
|
260
|
+
* want to hold the machine.
|
|
284
261
|
*/
|
|
285
262
|
export async function quiesced(): Promise<void> {
|
|
286
263
|
await sweep();
|
|
@@ -369,23 +346,21 @@ async function admit(): Promise<void> {
|
|
|
369
346
|
* off-limits to further ones until `fn` is done. When `fn` is async the hold stays up until its promise
|
|
370
347
|
* settles, so the count tracks the real operation rather than the synchronous call.
|
|
371
348
|
*
|
|
372
|
-
* **Always a promise, because entry can wait** ({@link admit}) —
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
*
|
|
377
|
-
* rather than threaded, and binds a mount interest to its plugin's load extent.
|
|
349
|
+
* **Always a promise, because entry can wait** ({@link admit}) — the barrier is in here rather than at the
|
|
350
|
+
* call site on purpose. An explicit "wait, then hold" pair reads better and puts the cost in view, but it
|
|
351
|
+
* makes the waiting *optional*, and a caller who omits it gets no error and no symptom: staged work simply
|
|
352
|
+
* stops landing. Same reasoning that makes the principal ambient rather than threaded, and binds a mount
|
|
353
|
+
* interest to its plugin's load extent.
|
|
378
354
|
*
|
|
379
355
|
* When nothing is staged, `fn` still runs **synchronously** within the call, before the returned promise
|
|
380
356
|
* is handed back: the barrier adds a hold, never a scheduling gap, so nothing can slip between the check
|
|
381
357
|
* and the hold. A synchronous throw becomes a rejection, so the two paths report failure identically.
|
|
382
358
|
*
|
|
383
359
|
* The hold is released on **every** exit — a throw, a rejected promise, a bare `return` from anywhere
|
|
384
|
-
* inside — because the release is bound to the call, not written at the exits. That is
|
|
385
|
-
*
|
|
386
|
-
*
|
|
387
|
-
*
|
|
388
|
-
* never happens.
|
|
360
|
+
* inside — because the release is bound to the call, not written at the exits. That is why this is a
|
|
361
|
+
* wrapper and not a `begin()`/`end()` pair: a caller with a dozen early returns (the pump has several per
|
|
362
|
+
* queue item) cannot leave the counter stuck, and a stuck counter is unrecoverable — every later flush
|
|
363
|
+
* would silently no-op forever, with no symptom but a deferred mutation that never happens.
|
|
389
364
|
*/
|
|
390
365
|
export function machineBusy<T>(fn: () => T | Promise<T>): Promise<T> {
|
|
391
366
|
if (!clear()) return admit().then(() => held(fn));
|
package/src/hooks.ts
CHANGED
|
@@ -2,7 +2,7 @@ import type {
|
|
|
2
2
|
Hook, HookPoint, HookRegistrar, Message, MessageContent, Session, DeferredScreen,
|
|
3
3
|
ScreenContext, ContributeContext, ToolCallContext, ToolCallResult, ToolResultContext, FollowupContext,
|
|
4
4
|
} from './types.js';
|
|
5
|
-
import { foldOntoUserTurn } from './session.js';
|
|
5
|
+
import { foldOntoUserTurn, createMessage } from './session.js';
|
|
6
6
|
import { withUsageSite } from './usage-context.js';
|
|
7
7
|
|
|
8
8
|
const HOOK_ERROR_CREATOR = 'matbot-hooks';
|
|
@@ -20,11 +20,20 @@ export class HookRegistry implements HookRegistrar {
|
|
|
20
20
|
private readonly markedFailures = new Set<Hook>();
|
|
21
21
|
private pendingFailureMarkers: { channel: HookPoint; pluginName?: string; message: string }[] = [];
|
|
22
22
|
|
|
23
|
+
// A fresh array, never an in-place push + sort, for the reason given at `removeOne`: a run loop
|
|
24
|
+
// iterating the old list across its awaits would otherwise visit a hook registered mid-run, or visit
|
|
25
|
+
// one twice after the sort moved it behind the cursor.
|
|
23
26
|
register(hook: Hook): void {
|
|
24
|
-
const list = this.hooks.get(hook.on) ?? [];
|
|
25
|
-
list.
|
|
26
|
-
|
|
27
|
-
|
|
27
|
+
const list = [...(this.hooks.get(hook.on) ?? []), hook];
|
|
28
|
+
this.hooks.set(hook.on, list.sort((a, b) => (a.priority ?? 50) - (b.priority ?? 50)));
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// The hooks registered on one channel, narrowed to that channel's arm. The map is keyed BY `on`, so
|
|
32
|
+
// its members are already the right arm; each run* method used to re-test `if (hook.on !== 'screen')
|
|
33
|
+
// continue` purely to tell TypeScript so — five guards that could never fire. Narrowing at the lookup
|
|
34
|
+
// says it once, where it is true by construction.
|
|
35
|
+
private on<P extends HookPoint>(point: P): Extract<Hook, { on: P }>[] {
|
|
36
|
+
return (this.hooks.get(point) ?? []) as Extract<Hook, { on: P }>[];
|
|
28
37
|
}
|
|
29
38
|
|
|
30
39
|
removeByPlugin(pluginName: string): void {
|
|
@@ -79,12 +88,10 @@ export class HookRegistry implements HookRegistrar {
|
|
|
79
88
|
// fallback path, not the only one).
|
|
80
89
|
private drainFailureMarkers(session: Session): { session: Session; markers: MessageContent[] } {
|
|
81
90
|
if (this.pendingFailureMarkers.length === 0) return { session, markers: [] };
|
|
82
|
-
const messages: Message[] = this.pendingFailureMarkers.map(f => ({
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
traceId: '',
|
|
87
|
-
content: [{
|
|
91
|
+
const messages: Message[] = this.pendingFailureMarkers.map(f => createMessage({
|
|
92
|
+
role: 'marker',
|
|
93
|
+
traceId: '',
|
|
94
|
+
content: [{
|
|
88
95
|
type: 'marker',
|
|
89
96
|
creator: HOOK_ERROR_CREATOR,
|
|
90
97
|
data: { channel: f.channel, ...(f.pluginName !== undefined ? { pluginName: f.pluginName } : {}), message: f.message },
|
|
@@ -110,9 +117,8 @@ export class HookRegistry implements HookRegistrar {
|
|
|
110
117
|
const handlerMarkers: MessageContent[] = [];
|
|
111
118
|
const appendMarkers = (blocks: MessageContent[]): void => {
|
|
112
119
|
handlerMarkers.push(...blocks);
|
|
113
|
-
session = { ...session, messages: [...session.messages,
|
|
114
|
-
|
|
115
|
-
}] };
|
|
120
|
+
session = { ...session, messages: [...session.messages,
|
|
121
|
+
createMessage({ role: 'marker', traceId: '', content: blocks })] };
|
|
116
122
|
};
|
|
117
123
|
// Handler-returned durable context: folded onto the running turn's user message (the last one
|
|
118
124
|
// with role 'user'), so it persists into history AND rides every subsequent provider call — the
|
|
@@ -127,8 +133,7 @@ export class HookRegistry implements HookRegistrar {
|
|
|
127
133
|
durable.push(...blocks);
|
|
128
134
|
session = folded;
|
|
129
135
|
};
|
|
130
|
-
for (const hook of this.
|
|
131
|
-
if (hook.on !== 'screen') continue;
|
|
136
|
+
for (const hook of this.on('screen')) {
|
|
132
137
|
const r = await this.invoke(hook, () => hook.handler({ ...ctx, session, removeHook: () => this.removeOne('screen', hook) }));
|
|
133
138
|
if (!r) continue;
|
|
134
139
|
if (r.session) session = r.session;
|
|
@@ -149,8 +154,7 @@ export class HookRegistry implements HookRegistrar {
|
|
|
149
154
|
// session is never touched.
|
|
150
155
|
async runContribute(ctx: Omit<ContributeContext, 'removeHook'>): Promise<Message[]> {
|
|
151
156
|
let outgoing = ctx.outgoing as Message[];
|
|
152
|
-
for (const hook of this.
|
|
153
|
-
if (hook.on !== 'contribute') continue;
|
|
157
|
+
for (const hook of this.on('contribute')) {
|
|
154
158
|
const r = await this.invoke(hook, () => hook.handler({ ...ctx, outgoing, removeHook: () => this.removeOne('contribute', hook) }));
|
|
155
159
|
if (r) outgoing = r;
|
|
156
160
|
}
|
|
@@ -159,8 +163,7 @@ export class HookRegistry implements HookRegistrar {
|
|
|
159
163
|
|
|
160
164
|
// toolcall stops at the first hook that rejects or aborts; the rest don't run.
|
|
161
165
|
async runToolCall(ctx: Omit<ToolCallContext, 'removeHook'>): Promise<ToolCallResult> {
|
|
162
|
-
for (const hook of this.
|
|
163
|
-
if (hook.on !== 'toolcall') continue;
|
|
166
|
+
for (const hook of this.on('toolcall')) {
|
|
164
167
|
const r = await this.invoke(hook, () => hook.handler({ ...ctx, removeHook: () => this.removeOne('toolcall', hook) }));
|
|
165
168
|
if (r && (r.rejectTool || r.abort)) return r;
|
|
166
169
|
}
|
|
@@ -171,8 +174,7 @@ export class HookRegistry implements HookRegistrar {
|
|
|
171
174
|
// a hook that returns nothing just observes (auditing). Returns the final result.
|
|
172
175
|
async runToolResult(ctx: Omit<ToolResultContext, 'removeHook'>): Promise<unknown> {
|
|
173
176
|
let result = ctx.result;
|
|
174
|
-
for (const hook of this.
|
|
175
|
-
if (hook.on !== 'toolresult') continue;
|
|
177
|
+
for (const hook of this.on('toolresult')) {
|
|
176
178
|
const r = await this.invoke(hook, () => hook.handler({ ...ctx, result, removeHook: () => this.removeOne('toolresult', hook) }));
|
|
177
179
|
if (r) result = r.result;
|
|
178
180
|
}
|
|
@@ -188,17 +190,20 @@ export class HookRegistry implements HookRegistrar {
|
|
|
188
190
|
const markers: MessageContent[] = [];
|
|
189
191
|
const retractCtx: MessageContent[] = [];
|
|
190
192
|
const retractDur: MessageContent[] = [];
|
|
191
|
-
|
|
192
|
-
|
|
193
|
+
// Whether a hook ASKED to retract, not how much it sent with the request. Deriving it from the
|
|
194
|
+
// payload size dropped a `retractAndRerun: {}` — type-legal, both fields being optional — so a hook
|
|
195
|
+
// asking to pop and re-run with nothing added was answered by doing nothing at all, silently.
|
|
196
|
+
let retracting = false;
|
|
197
|
+
for (const hook of this.on('followup')) {
|
|
193
198
|
const r = await this.invoke(hook, () => hook.handler({ ...ctx, removeHook: () => this.removeOne('followup', hook) }));
|
|
194
199
|
if (r?.resubmit) resubmits.push(r.resubmit.content);
|
|
195
200
|
if (r?.retractAndRerun) {
|
|
201
|
+
retracting = true;
|
|
196
202
|
if (r.retractAndRerun.context) retractCtx.push(...r.retractAndRerun.context);
|
|
197
203
|
if (r.retractAndRerun.durable) retractDur.push(...r.retractAndRerun.durable);
|
|
198
204
|
}
|
|
199
205
|
if (r?.markers) markers.push(...r.markers);
|
|
200
206
|
}
|
|
201
|
-
const retracting = retractCtx.length > 0 || retractDur.length > 0;
|
|
202
207
|
return { resubmits, markers, ...(retracting ? { retract: { context: retractCtx, durable: retractDur } } : {}) };
|
|
203
208
|
}
|
|
204
209
|
}
|
package/src/host-machine.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type {
|
|
|
2
2
|
CompletionRequest, MatbotMachine, MatbotServices, Mounted, MountConsumeOptions, SingleTurnRequest,
|
|
3
3
|
} from './plugin.js';
|
|
4
4
|
import { RegistryChangeKind } from './notify.js';
|
|
5
|
+
import { createMessage } from './session.js';
|
|
5
6
|
|
|
6
7
|
/*
|
|
7
8
|
* Host-side machine assembly — the wiring an *embedder* does once at boot, not API a plugin author
|
|
@@ -50,8 +51,15 @@ export type SwapFn<T extends object> = (next: T) => void;
|
|
|
50
51
|
* A capture-safe forwarding proxy: every trap routes to whatever `getCurrent()` returns *now*, so a
|
|
51
52
|
* reference captured before a register()-driven swap keeps resolving to the live impl. getPrototypeOf
|
|
52
53
|
* is forwarded so `instanceof` sees the real impl (the StorageBackend identity checks depend on it);
|
|
53
|
-
* ownKeys + getOwnPropertyDescriptor keep object spread faithful.
|
|
54
|
-
*
|
|
54
|
+
* ownKeys + getOwnPropertyDescriptor keep object spread faithful. A nullish current (an optional service
|
|
55
|
+
* with nothing registered yet) reads as empty.
|
|
56
|
+
*
|
|
57
|
+
* **Method identity is not stable**, and callers do need to know: a `get` binds to the impl current at
|
|
58
|
+
* that moment, so each read produces a NEW function and `store.get !== store.get`. That is what makes a
|
|
59
|
+
* captured method follow a swap instead of calling into the displaced impl — the point of the whole
|
|
60
|
+
* proxy — but it means anything keyed on a method's identity silently never matches: a `Set` of
|
|
61
|
+
* callbacks it is asked to remove one from, a memo keyed by function, an `===` comparison against a
|
|
62
|
+
* previously-read method. Capture the SERVICE and call through it; do not capture its methods.
|
|
55
63
|
*/
|
|
56
64
|
export function forwardingProxy<T extends object>(getCurrent: () => T | undefined): T {
|
|
57
65
|
return new Proxy({} as T, {
|
|
@@ -198,16 +206,17 @@ export function createMountTable(getMachine: () => MatbotMachine): MountTable {
|
|
|
198
206
|
|
|
199
207
|
/**
|
|
200
208
|
* Build the one-message CompletionRequest for a {@link MatbotRuntime.singleTurn} call, hiding the
|
|
201
|
-
* otherwise-mandatory
|
|
202
|
-
*
|
|
209
|
+
* otherwise-mandatory Message fields an out-of-band one-shot has no use for. The host invokes its own
|
|
210
|
+
* complete() with the result.
|
|
203
211
|
*/
|
|
204
212
|
export function singleTurnRequest(req: SingleTurnRequest): CompletionRequest {
|
|
205
213
|
return {
|
|
206
214
|
provider: req.provider,
|
|
207
|
-
messages: [{
|
|
208
|
-
|
|
215
|
+
messages: [createMessage({
|
|
216
|
+
role: 'user',
|
|
217
|
+
traceId: '',
|
|
209
218
|
content: typeof req.prompt === 'string' ? [{ type: 'text', text: req.prompt }] : req.prompt,
|
|
210
|
-
}],
|
|
219
|
+
})],
|
|
211
220
|
...(req.system !== undefined ? { system: req.system } : {}),
|
|
212
221
|
...(req.parameters !== undefined ? { parameters: req.parameters } : {}),
|
|
213
222
|
...(req.signal !== undefined ? { signal: req.signal } : {}),
|
package/src/host.ts
CHANGED
|
@@ -33,6 +33,10 @@ export * from './usage-context.js';
|
|
|
33
33
|
// The machine half of a context switch: quiescent-edge flushers and the deferred-swap application point.
|
|
34
34
|
export * from './context-switch.js';
|
|
35
35
|
|
|
36
|
+
// Re-entering an ambient scope on every pull of an async iterator — what both carriers need to cover a
|
|
37
|
+
// tool executor's deferred body. Host-side: a plugin gets this behaviour for free from `runAs`.
|
|
38
|
+
export * from './scoped-iterator.js';
|
|
39
|
+
|
|
36
40
|
// The boot `PermissionGate` a host installs as the base of the swap-member: ask through whatever
|
|
37
41
|
// channel is in scope, else answer the request's own `fallback`. A plugin supplying a *policy* is the
|
|
38
42
|
// author-facing half and needs only the `PermissionGate` type, which the root exports.
|
package/src/invoke-tool.ts
CHANGED
|
@@ -108,6 +108,21 @@ export function invokeTool<K extends string, const P>(
|
|
|
108
108
|
return tool.executor.execute(params, ctx) as AsyncIterable<ToolEvent<ToolResultFor<K, P>>>;
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
+
/**
|
|
112
|
+
* The one drain: consume a tool event stream, keeping the last `result` and throwing on the first
|
|
113
|
+
* `error`. Whether a stream that yielded NO result is a failure is the caller's question, and the only
|
|
114
|
+
* thing the three public drains ever disagreed on — so it is the only thing they pass in.
|
|
115
|
+
*/
|
|
116
|
+
async function drain<R>(events: AsyncIterable<ToolEvent<R>>): Promise<{ value: R | undefined; hadResult: boolean }> {
|
|
117
|
+
let value: R | undefined;
|
|
118
|
+
let hadResult = false;
|
|
119
|
+
for await (const ev of events) {
|
|
120
|
+
if (ev.type === 'result') { value = ev.value; hadResult = true; }
|
|
121
|
+
else if (ev.type === 'error') { throw new Error(ev.message); }
|
|
122
|
+
}
|
|
123
|
+
return { value, hadResult };
|
|
124
|
+
}
|
|
125
|
+
|
|
111
126
|
/**
|
|
112
127
|
* Drain a tool event stream (e.g. {@link invokeTool}'s return) to its raw `result` *value*, typed:
|
|
113
128
|
* paired with `invokeTool(machine, name, …)` it returns whatever `ToolContracts[name]` declares (or
|
|
@@ -116,14 +131,9 @@ export function invokeTool<K extends string, const P>(
|
|
|
116
131
|
* Stops and throws on the first `error` event, or if the tool finished without yielding a `result`.
|
|
117
132
|
*/
|
|
118
133
|
export async function toolResult<R>(events: AsyncIterable<ToolEvent<R>>): Promise<R> {
|
|
119
|
-
|
|
120
|
-
let hadResult = false;
|
|
121
|
-
for await (const ev of events) {
|
|
122
|
-
if (ev.type === 'result') { value = ev.value; hadResult = true; }
|
|
123
|
-
else if (ev.type === 'error') { throw new Error(ev.message); }
|
|
124
|
-
}
|
|
134
|
+
const { value, hadResult } = await drain(events);
|
|
125
135
|
if (!hadResult) throw new Error('Tool produced no result');
|
|
126
|
-
return value;
|
|
136
|
+
return value as R;
|
|
127
137
|
}
|
|
128
138
|
|
|
129
139
|
/**
|
|
@@ -133,13 +143,7 @@ export async function toolResult<R>(events: AsyncIterable<ToolEvent<R>>): Promis
|
|
|
133
143
|
* (the shape `skill_action` and other prose tools return) by its `content`, anything else as JSON.
|
|
134
144
|
*/
|
|
135
145
|
export async function toolText(events: AsyncIterable<ToolEvent>): Promise<string> {
|
|
136
|
-
|
|
137
|
-
let hadResult = false;
|
|
138
|
-
for await (const ev of events) {
|
|
139
|
-
if (ev.type === 'result') { result = ev.value; hadResult = true; }
|
|
140
|
-
else if (ev.type === 'error') { throw new Error(ev.message); }
|
|
141
|
-
}
|
|
142
|
-
if (!hadResult) throw new Error('Tool produced no result');
|
|
146
|
+
const result = await toolResult(events);
|
|
143
147
|
|
|
144
148
|
if (typeof result === 'string') return result;
|
|
145
149
|
if (result !== null && typeof result === 'object' && typeof (result as { content?: unknown }).content === 'string') {
|
|
@@ -148,9 +152,28 @@ export async function toolText(events: AsyncIterable<ToolEvent>): Promise<string
|
|
|
148
152
|
return JSON.stringify(result, null, 2);
|
|
149
153
|
}
|
|
150
154
|
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
155
|
+
const cannotPrompt = (p: string | FormField): Promise<string> =>
|
|
156
|
+
Promise.reject(new Error(`Non-interactive context: cannot prompt for "${typeof p === 'string' ? p : p.label}"`));
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* The stand-in for "there is nobody to ask": every request rejects, naming the field, which a tool's
|
|
160
|
+
* surrounding try/catch turns into an ordinary error event.
|
|
161
|
+
*
|
|
162
|
+
* Deliberately NOT what a gate is handed — see {@link bindGate}. A gate must be able to tell "nobody is
|
|
163
|
+
* here" (`ask === undefined`) from "a human answered", and a stand-in that answers makes that
|
|
164
|
+
* undecidable.
|
|
165
|
+
*/
|
|
166
|
+
export const rejectingPrompt: PromptFn = cannotPrompt as PromptFn;
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* The stand-in that answers with a field's OWN default where it has one, and rejects like
|
|
170
|
+
* {@link rejectingPrompt} where it does not — what `ToolContext.prompt` is when the host supplied no
|
|
171
|
+
* channel. The two stand-ins shared one message written out twice, in two packages; what actually
|
|
172
|
+
* separates them is this one line, so it is the only thing that differs now.
|
|
173
|
+
*/
|
|
174
|
+
export const defaultingPrompt: PromptFn = (((p: string | FormField, def?: string): Promise<string> => {
|
|
175
|
+
const fallback = typeof p === 'string' ? def : p.default;
|
|
176
|
+
return fallback !== undefined ? Promise.resolve(fallback) : cannotPrompt(p);
|
|
154
177
|
}) as PromptFn);
|
|
155
178
|
|
|
156
179
|
/** Compact a value to one trace line. */
|
|
@@ -161,6 +184,25 @@ function compactTrace(v: unknown): string {
|
|
|
161
184
|
return s.length > 240 ? `${s.slice(0, 240)}…` : s;
|
|
162
185
|
}
|
|
163
186
|
|
|
187
|
+
/**
|
|
188
|
+
* The {@link ToolProxy}'s drain. Unlike {@link toolResult}, a stream that ends with no `result` resolves
|
|
189
|
+
* to `undefined` instead of throwing — a tool whose work is a side effect yields nothing BY DESIGN
|
|
190
|
+
* (`function-tools` omits the event when a body returns nothing, and the triggers dispatcher fires only
|
|
191
|
+
* on a yielded result), and this proxy is the surface such a tool is called through. Throwing here made
|
|
192
|
+
* the one surface meant to be silent the one that shouted.
|
|
193
|
+
*
|
|
194
|
+
* A tool that declares a DATA result and yields nothing is still an error — but it is caught where the
|
|
195
|
+
* types are, not re-derived from a contract string on every call: the checker compiles a body against its
|
|
196
|
+
* declared return type, and `strict` rejects one that can fall through (TS2355 with no return at all,
|
|
197
|
+
* TS2366 when only some paths do). A body declaring `T | undefined` is deliberately NOT rejected —
|
|
198
|
+
* `undefined` IS its contract, and is exactly what this drain hands back. Two paths reach here unchecked
|
|
199
|
+
* and resolve to `undefined` rather than throwing: an explicit `noTypeCheck`, and the browser, where there
|
|
200
|
+
* is no `ToolTypeIndex`. Both are opted into.
|
|
201
|
+
*/
|
|
202
|
+
async function drainProxyResult<R>(events: AsyncIterable<ToolEvent<R>>): Promise<R | undefined> {
|
|
203
|
+
return (await drain(events)).value;
|
|
204
|
+
}
|
|
205
|
+
|
|
164
206
|
/**
|
|
165
207
|
* A factory for a context-overridden {@link ToolProxy}: `toolInContext({ provider }).some_tool(params)`
|
|
166
208
|
* runs `some_tool` with the given fields merged over the box's bound context (omitted fields inherited).
|
|
@@ -195,7 +237,7 @@ export function makeToolBox(
|
|
|
195
237
|
if (typeof prop !== 'string' || prop === 'then') return undefined;
|
|
196
238
|
return async (params?: unknown): Promise<unknown> => {
|
|
197
239
|
opts?.onEvent?.({ type: 'stdout', chunk: `→ ${prop}(${compactTrace(params)})\n` });
|
|
198
|
-
const value = await
|
|
240
|
+
const value = await drainProxyResult(invokeTool(machine, prop, params ?? {}, call));
|
|
199
241
|
opts?.onEvent?.({ type: 'stdout', chunk: `← ${prop}: ${compactTrace(value)}\n` });
|
|
200
242
|
return value;
|
|
201
243
|
};
|
package/src/notify.ts
CHANGED
|
@@ -193,6 +193,7 @@ export interface Notifier {
|
|
|
193
193
|
*/
|
|
194
194
|
export function createNotifier(defaultPlugin: string): Notifier {
|
|
195
195
|
const events = createBroadcaster<Notification>();
|
|
196
|
+
const warned = new Set<string>();
|
|
196
197
|
return {
|
|
197
198
|
notify(notification) {
|
|
198
199
|
const plugin = notification.plugin ?? defaultPlugin;
|
|
@@ -200,9 +201,12 @@ export function createNotifier(defaultPlugin: string): Notifier {
|
|
|
200
201
|
// matter most here: a plain-JS plugin, or a bridge injecting another instance's traffic. Warn, don't
|
|
201
202
|
// throw — `notify` is contractually non-throwing, and a badly-named kind is a hygiene fault, not a
|
|
202
203
|
// delivery failure. Only the shape is checkable; that the prefix names the *definer* is not, since
|
|
203
|
-
// the emitter legitimately differs from it.
|
|
204
|
-
|
|
205
|
-
|
|
204
|
+
// the emitter legitimately differs from it. Once per emitter and kind: the fault is in the source,
|
|
205
|
+
// so repeating it on every publish only buries the log.
|
|
206
|
+
const warning = `${plugin} published unqualified notification kind "${notification.kind}"`;
|
|
207
|
+
if (!notification.kind.includes('#') && !warned.has(warning)) {
|
|
208
|
+
warned.add(warning);
|
|
209
|
+
console.warn(`[matbot] ${warning} — expected <package-name>#<InterfaceName>`);
|
|
206
210
|
}
|
|
207
211
|
events.emit({ ...notification, plugin } as Notification);
|
|
208
212
|
},
|
package/src/plugin.ts
CHANGED
|
@@ -7,18 +7,16 @@ import type {
|
|
|
7
7
|
import type { Notifications, Notifier } from './notify.js';
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* The plugin API contract version: this package's `major.minor`, and nothing else
|
|
11
|
-
*
|
|
12
|
-
* version boundary — the point at which it is the only thing every third-party plugin hardcodes.
|
|
10
|
+
* The plugin API contract version: this package's `major.minor`, and nothing else — it must track the
|
|
11
|
+
* package, being the only thing every third-party plugin hardcodes.
|
|
13
12
|
*
|
|
14
13
|
* The gate (`checkApiVersion`, in core's registry) reads it as: **major must match exactly** — a
|
|
15
14
|
* mismatch is a hard load failure — and a plugin declaring a *newer minor* than the runtime warns and
|
|
16
15
|
* loads, since it may reach for something absent. Declare it as `apiVersion: PLUGIN_API_VERSION` and it
|
|
17
16
|
* stays right for free; that is what every plugin in this repo does.
|
|
18
17
|
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* loudly instead of loading against a contract it was never built for.
|
|
18
|
+
* It matters at 1.0.0, where a plugin still declaring `'0.x'` fails the major check loudly instead of
|
|
19
|
+
* loading against a contract it was never built for.
|
|
22
20
|
*/
|
|
23
21
|
export const PLUGIN_API_VERSION = '0.4';
|
|
24
22
|
|
|
@@ -247,26 +245,8 @@ export interface MatbotRuntime {
|
|
|
247
245
|
*/
|
|
248
246
|
createStore<T extends { id: string; version: string }>(namespace: string): Store<T>;
|
|
249
247
|
|
|
250
|
-
/**
|
|
251
|
-
*
|
|
252
|
-
* needs this iff its setup() reads another service's *current state* to build cached/derived state —
|
|
253
|
-
* skills/triggers cache the StorageBackend's documents; cognition seeds from the SkillManager. A pure
|
|
254
|
-
* map (no setup data; data arrives later as a tool call or hook) needs nothing — resolve the service
|
|
255
|
-
* per-invocation through its proxy/member instead.
|
|
256
|
-
*
|
|
257
|
-
* // cache the backend's documents; rebuild on every swap (own initial load was in setup())
|
|
258
|
-
* services.mounted.observe({ key: 'StorageBackend', signal }, () => this.load());
|
|
259
|
-
*
|
|
260
|
-
* // depend on a peer service that may not be present yet; seed now if it is, and on each (re)mount
|
|
261
|
-
* services.mounted.observe({ key: 'SkillManager', replay: true, signal }, m => seed(m));
|
|
262
|
-
*
|
|
263
|
-
* Contract guarantees only *eventual, ordered* delivery of each key's net presence transition — it
|
|
264
|
-
* says nothing about timing: a mount may fire synchronously-ish or batch to a later quiescent edge, so
|
|
265
|
-
* never assume a register() is observed inline or at a turn boundary. A reload (unregister+register
|
|
266
|
-
* before the edge) collapses to a single remount; an unregister not replaced by the edge is a
|
|
267
|
-
* committed unload, delivered to `onUnmount` (drop your captured ref there to let the gone plugin's
|
|
268
|
-
* working set be collected). Handlers may re-fire on later remounts, so they must be idempotent.
|
|
269
|
-
*/
|
|
248
|
+
/** Mount-table notifications: react to a registry service (re)mounting or being unloaded. See
|
|
249
|
+
* {@link Mounted} for when a plugin needs this, the two idioms, and what the contract guarantees. */
|
|
270
250
|
readonly mounted: Mounted;
|
|
271
251
|
|
|
272
252
|
/**
|
|
@@ -358,9 +338,29 @@ export interface MountConsumeOptions<K extends keyof MatbotServices> {
|
|
|
358
338
|
readonly onUnmount?: (machine: MatbotMachine) => void | Promise<void>;
|
|
359
339
|
}
|
|
360
340
|
|
|
361
|
-
/**
|
|
362
|
-
*
|
|
363
|
-
*
|
|
341
|
+
/**
|
|
342
|
+
* The mount-table consumer facet exposed on {@link MatbotRuntime.mounted}.
|
|
343
|
+
*
|
|
344
|
+
* A plugin needs this iff its `setup()` reads another service's *current state* to build cached/derived
|
|
345
|
+
* state — skills/triggers cache the StorageBackend's documents; cognition seeds from the SkillManager. A
|
|
346
|
+
* pure map (no setup data; data arrives later as a tool call or hook) needs nothing, and resolves the
|
|
347
|
+
* service per-invocation through its proxy/member instead.
|
|
348
|
+
*
|
|
349
|
+
* // cache the backend's documents; rebuild on every swap (own initial load was in setup())
|
|
350
|
+
* services.mounted.observe({ key: 'StorageBackend' }, () => this.load());
|
|
351
|
+
*
|
|
352
|
+
* // depend on a peer service that may not be present yet; seed now if it is, and on each (re)mount
|
|
353
|
+
* services.mounted.observe({ key: 'SkillManager', replay: true }, m => seed(m));
|
|
354
|
+
*
|
|
355
|
+
* The contract guarantees only *eventual, ordered* delivery of each key's net presence transition, and
|
|
356
|
+
* says nothing about timing — never assume a `register()` is observed inline or at a turn boundary. A
|
|
357
|
+
* reload (unregister+register before the edge) collapses to a single remount; an unregister not replaced
|
|
358
|
+
* by the edge is a committed unload, delivered to `onUnmount`. Handlers re-fire on a later remount, so
|
|
359
|
+
* they must be idempotent.
|
|
360
|
+
*
|
|
361
|
+
* `observe`, not `consume`, so the verb `consume` means exactly one thing repo-wide — the detached
|
|
362
|
+
* stream drain on {@link Subscribable}; this keyed, edge-batched delivery is its own paradigm.
|
|
363
|
+
*/
|
|
364
364
|
export interface Mounted {
|
|
365
365
|
observe<K extends keyof MatbotServices>(
|
|
366
366
|
options: MountConsumeOptions<K>,
|
|
@@ -374,11 +374,6 @@ export interface Mounted {
|
|
|
374
374
|
|
|
375
375
|
export type ProviderAdapterFactory = (config: ProviderConfig) => ProviderAdapter;
|
|
376
376
|
|
|
377
|
-
export type StoreFactory = (
|
|
378
|
-
kind: string,
|
|
379
|
-
options: Record<string, unknown>,
|
|
380
|
-
) => Store<{ id: string; version: string }>;
|
|
381
|
-
|
|
382
377
|
// ── Plugin manifest ───────────────────────────────────────────────────────────
|
|
383
378
|
|
|
384
379
|
export interface PluginManifest {
|
|
@@ -453,12 +448,11 @@ export interface VisibilityQuery {
|
|
|
453
448
|
* notification is visible to that connection's principal. Absent ⇒ no partitioning, and every connection
|
|
454
449
|
* sees everything.
|
|
455
450
|
*
|
|
456
|
-
* **The predicate is consulted for EVERY kind, and decides for itself which it filters
|
|
457
|
-
*
|
|
458
|
-
*
|
|
459
|
-
*
|
|
460
|
-
*
|
|
461
|
-
* which of its kinds want which. An implementation that has nothing to say about a kind returns `true`.
|
|
451
|
+
* **The predicate is consulted for EVERY kind, and decides for itself which it filters** — so a
|
|
452
|
+
* plugin-defined kind cannot be fanned out unfiltered with no hook capable of stopping it. Routing a
|
|
453
|
+
* partitioned namespace and addressing a notification to a recipient are two different policies, and only
|
|
454
|
+
* the implementation knows which of its kinds want which. One with nothing to say about a kind returns
|
|
455
|
+
* `true`.
|
|
462
456
|
*/
|
|
463
457
|
export interface WatchVisibility {
|
|
464
458
|
/**
|
|
@@ -488,9 +482,8 @@ export interface WatchVisibility {
|
|
|
488
482
|
* **The token is a partition, never a principal.** The two are not interchangeable: one principal may
|
|
489
483
|
* hold several profiles, and a profile may alias a namespace onto a third profile's partition, so the
|
|
490
484
|
* partition serving a principal's files is not derivable from the principal id. Minting the token from
|
|
491
|
-
* the identity in force
|
|
492
|
-
*
|
|
493
|
-
* under that name.
|
|
485
|
+
* the identity in force instead attaches an addressable partition to files that are in the shared base
|
|
486
|
+
* area, and points at an empty partition the moment one is created under that name.
|
|
494
487
|
*
|
|
495
488
|
* `current` and `enter` are one round trip and must stay each other's inverse: whatever `current`
|
|
496
489
|
* returns, `enter` puts a reader back into the area those bytes were written to. Both live here rather
|
|
@@ -518,7 +511,6 @@ export interface MatbotPluginSpec {
|
|
|
518
511
|
readonly apiVersion: string;
|
|
519
512
|
readonly manifest?: PluginManifest;
|
|
520
513
|
readonly provider?: ProviderAdapterFactory;
|
|
521
|
-
readonly storage?: Record<string, StoreFactory>;
|
|
522
514
|
readonly tools?: readonly Tool[];
|
|
523
515
|
/**
|
|
524
516
|
* When present, the runtime calls open(dotData) before creating the services object and uses the
|
package/src/principal-context.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Principal } from './types.js';
|
|
2
|
+
import { scopeIterator } from './scoped-iterator.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* Ambient carrier for the security {@link Principal} — the single mechanism by which any layer
|
|
@@ -115,32 +116,13 @@ function isAsyncIterator(value: unknown): value is AsyncIterator<unknown> & Asyn
|
|
|
115
116
|
&& typeof (value as AsyncIterator<unknown>).next === 'function';
|
|
116
117
|
}
|
|
117
118
|
|
|
119
|
+
// The per-pull re-entry itself is `scopeIterator` (./scoped-iterator.ts), shared with the usage carrier.
|
|
118
120
|
function scopedIterator<T>(
|
|
119
121
|
carrier: PrincipalCarrier,
|
|
120
122
|
principal: Principal,
|
|
121
123
|
source: AsyncIterator<T> & AsyncIterable<T>,
|
|
122
124
|
): AsyncIterableIterator<T> {
|
|
123
|
-
|
|
124
|
-
// A Proxy rather than a fresh object literal: the value crossing back out must still BE what it was —
|
|
125
|
-
// a custom iterator's own methods, its prototype, `instanceof` — so only the pull points are replaced.
|
|
126
|
-
// Anything else is forwarded bound to the target, which a class-based iterator holding internal state
|
|
127
|
-
// (or an object with internal slots) requires.
|
|
128
|
-
const pulls: Record<PropertyKey, unknown> = {
|
|
129
|
-
[Symbol.asyncIterator]: () => proxy,
|
|
130
|
-
next: (...args: [] | [undefined]) => inScope(() => source.next(...args)),
|
|
131
|
-
...(source.return !== undefined ? { return: (value?: unknown) => inScope(() => source.return!(value)) } : {}),
|
|
132
|
-
...(source.throw !== undefined ? { throw: (error?: unknown) => inScope(() => source.throw!(error)) } : {}),
|
|
133
|
-
};
|
|
134
|
-
const proxy = new Proxy(source, {
|
|
135
|
-
// `hasOwn`, not `in`: the latter reaches Object.prototype, which would serve `toString`/`constructor`
|
|
136
|
-
// off the lookup object instead of the iterator being wrapped.
|
|
137
|
-
get(target, prop) {
|
|
138
|
-
if (Object.hasOwn(pulls, prop)) return pulls[prop];
|
|
139
|
-
const value = Reflect.get(target, prop, target);
|
|
140
|
-
return typeof value === 'function' ? value.bind(target) : value;
|
|
141
|
-
},
|
|
142
|
-
}) as AsyncIterableIterator<T>;
|
|
143
|
-
return proxy;
|
|
125
|
+
return scopeIterator(source, f => carrier.run(principal, f));
|
|
144
126
|
}
|
|
145
127
|
|
|
146
128
|
/** Imperatively establish `principal` for the current flow (entry points only). */
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Re-enter an ambient scope on every pull of an async iterator.
|
|
3
|
+
*
|
|
4
|
+
* The problem is the same wherever it appears: an async generator's body does not begin until the first
|
|
5
|
+
* pull, so an iterator returned out of a scope carries its whole extent OUT of it — the scope covered
|
|
6
|
+
* the construction, and the work then runs in whatever flow happens to pull it. The tool ABI
|
|
7
|
+
* (`executor.execute()`) returns exactly that shape, so both of matbot's ambient carriers need this, and
|
|
8
|
+
* both had written it out: `runAs` for the principal, and the runner for the usage call site.
|
|
9
|
+
*
|
|
10
|
+
* The two copies did not agree. This one is the careful version: a **Proxy**, so the value crossing back
|
|
11
|
+
* out still IS what it was — a class-based iterator keeps its own members, its prototype and
|
|
12
|
+
* `instanceof` — with only the pull points replaced. The object-literal copy silently dropped all of
|
|
13
|
+
* that, which held only for as long as every tool was a plain async generator.
|
|
14
|
+
*
|
|
15
|
+
* Whether re-entering per pull is *sound* is the caller's question, not this helper's, and the answer
|
|
16
|
+
* differs: an identity is a re-entrant label, so `runAs` re-establishes it freely, while a hold or a
|
|
17
|
+
* roll-up settles when its `fn` does and must not be re-acquired per pull. See `machineBusy` and
|
|
18
|
+
* `withUsageScope`, which deliberately do not use this.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/** Run `f` inside the caller's ambient scope. The shape both `carrier.run(p, f)` and `withUsageSite(s, f)` take. */
|
|
22
|
+
export type ScopeFn = <R>(f: () => R) => R;
|
|
23
|
+
|
|
24
|
+
export function scopeIterator<T>(source: AsyncIterator<T>, inScope: ScopeFn): AsyncIterableIterator<T> {
|
|
25
|
+
const pulls: Record<PropertyKey, unknown> = {
|
|
26
|
+
[Symbol.asyncIterator]: () => proxy,
|
|
27
|
+
next: (...args: [] | [undefined]) => inScope(() => source.next(...args)),
|
|
28
|
+
...(source.return !== undefined ? { return: (value?: unknown) => inScope(() => source.return!(value)) } : {}),
|
|
29
|
+
...(source.throw !== undefined ? { throw: (error?: unknown) => inScope(() => source.throw!(error)) } : {}),
|
|
30
|
+
};
|
|
31
|
+
const proxy: AsyncIterableIterator<T> = new Proxy(source, {
|
|
32
|
+
// `hasOwn`, not `in`: the latter reaches Object.prototype, which would serve `toString`/`constructor`
|
|
33
|
+
// off the lookup object instead of the iterator being wrapped.
|
|
34
|
+
get(target, prop) {
|
|
35
|
+
if (Object.hasOwn(pulls, prop)) return pulls[prop];
|
|
36
|
+
const value = Reflect.get(target, prop, target);
|
|
37
|
+
return typeof value === 'function' ? value.bind(target) : value;
|
|
38
|
+
},
|
|
39
|
+
}) as AsyncIterableIterator<T>;
|
|
40
|
+
return proxy;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The iterable form: every iterator this hands out is scoped per pull. For a caller holding an
|
|
45
|
+
* `AsyncIterable` that is not itself iterator-shaped — legal, and what a tool returning a bare
|
|
46
|
+
* `{ [Symbol.asyncIterator]() {…} }` gives the runner.
|
|
47
|
+
*/
|
|
48
|
+
export function scopeIterable<T>(source: AsyncIterable<T>, inScope: ScopeFn): AsyncIterable<T> {
|
|
49
|
+
return { [Symbol.asyncIterator]: () => scopeIterator(source[Symbol.asyncIterator](), inScope) };
|
|
50
|
+
}
|
package/src/session.ts
CHANGED
|
@@ -1,4 +1,31 @@
|
|
|
1
|
-
import type { MessageContent, Session } from './types.js';
|
|
1
|
+
import type { Message, MessageContent, MessageRole, Session } from './types.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A `Message` with the fields every one carries minted for it — a fresh id, `createdAt` now — and the
|
|
5
|
+
* optional ones spread conditionally.
|
|
6
|
+
*
|
|
7
|
+
* Lives here rather than in core because a message is not core's to define: the hook registry builds
|
|
8
|
+
* marker messages in *this* package, and had its own copy of these five lines because core is
|
|
9
|
+
* downstream of it. There were five such copies in two packages, and every one of them is a place a
|
|
10
|
+
* message could quietly acquire a different shape.
|
|
11
|
+
*/
|
|
12
|
+
export function createMessage(opts: {
|
|
13
|
+
role: MessageRole;
|
|
14
|
+
content: MessageContent[];
|
|
15
|
+
traceId: string;
|
|
16
|
+
providerName?: string;
|
|
17
|
+
metadata?: Record<string, unknown>;
|
|
18
|
+
}): Message {
|
|
19
|
+
return {
|
|
20
|
+
id: crypto.randomUUID(),
|
|
21
|
+
role: opts.role,
|
|
22
|
+
content: opts.content,
|
|
23
|
+
createdAt: new Date().toISOString(),
|
|
24
|
+
traceId: opts.traceId,
|
|
25
|
+
...(opts.providerName !== undefined ? { providerName: opts.providerName } : {}),
|
|
26
|
+
...(opts.metadata !== undefined ? { metadata: opts.metadata } : {}),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
2
29
|
|
|
3
30
|
/**
|
|
4
31
|
* A session's `updatedAt` invariant: the timestamp of its last message, or its own `createdAt` when it
|
package/src/types/hooks.ts
CHANGED
|
@@ -195,7 +195,8 @@ export interface FollowupResult {
|
|
|
195
195
|
* `context` is folded EPHEMERALLY onto the redo (for-this-answer-only). `durable` is instead folded
|
|
196
196
|
* onto the re-run's user message — persisted, visible (mark it `origin: 'robo'`), carried live as a
|
|
197
197
|
* `robo-user` event — so a `contextual`-kind correction updates the conversation durably even when it
|
|
198
|
-
* lands post-commit.
|
|
198
|
+
* lands post-commit. Normally at least one is supplied; the RETRACT is driven by the presence of this
|
|
199
|
+
* object, so `{}` means "pop and re-run with nothing added" rather than being quietly ignored.
|
|
199
200
|
*/
|
|
200
201
|
retractAndRerun?: { context?: MessageContent[]; durable?: MessageContent[] };
|
|
201
202
|
/**
|
package/src/types/knowledge.ts
CHANGED
|
@@ -21,6 +21,7 @@ export interface KnowledgeIndex {
|
|
|
21
21
|
* index never inspects an entry's opaque `source`. The party that indexed an entry owns retracting
|
|
22
22
|
* it — e.g. a skill manager retracts a hidden/deleted skill rather than the index policing tenants. */
|
|
23
23
|
remove(id: string): Promise<void>;
|
|
24
|
+
/** Rejects with the signal's reason if it is already aborted; an abort during the search may reject too. */
|
|
24
25
|
search(terms: Array<{ term: string; context?: string }>, signal: AbortSignal): Promise<KnowledgeEntry[]>;
|
|
25
26
|
/** Enumerate all indexed entries. When present, register('KnowledgeIndex', …) drains these into the incoming backend. */
|
|
26
27
|
entries?(): Iterable<KnowledgeEntry>;
|