@matatbread/matbot-plugin-api 0.4.14 → 0.4.16
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/context-switch.ts +34 -59
- package/src/hooks.ts +25 -21
- package/src/host-machine.ts +9 -2
- package/src/host.ts +4 -0
- package/src/index.ts +1 -1
- package/src/invoke-tool.ts +65 -19
- package/src/plugin.ts +41 -45
- package/src/principal-context.ts +4 -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/src/types/typescript.ts +20 -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';
|
|
@@ -27,6 +27,14 @@ export class HookRegistry implements HookRegistrar {
|
|
|
27
27
|
this.hooks.set(hook.on, list);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
// The hooks registered on one channel, narrowed to that channel's arm. The map is keyed BY `on`, so
|
|
31
|
+
// its members are already the right arm; each run* method used to re-test `if (hook.on !== 'screen')
|
|
32
|
+
// continue` purely to tell TypeScript so — five guards that could never fire. Narrowing at the lookup
|
|
33
|
+
// says it once, where it is true by construction.
|
|
34
|
+
private on<P extends HookPoint>(point: P): Extract<Hook, { on: P }>[] {
|
|
35
|
+
return (this.hooks.get(point) ?? []) as Extract<Hook, { on: P }>[];
|
|
36
|
+
}
|
|
37
|
+
|
|
30
38
|
removeByPlugin(pluginName: string): void {
|
|
31
39
|
for (const [point, list] of this.hooks) {
|
|
32
40
|
this.hooks.set(point, list.filter(h => h.pluginName !== pluginName));
|
|
@@ -79,12 +87,10 @@ export class HookRegistry implements HookRegistrar {
|
|
|
79
87
|
// fallback path, not the only one).
|
|
80
88
|
private drainFailureMarkers(session: Session): { session: Session; markers: MessageContent[] } {
|
|
81
89
|
if (this.pendingFailureMarkers.length === 0) return { session, markers: [] };
|
|
82
|
-
const messages: Message[] = this.pendingFailureMarkers.map(f => ({
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
traceId: '',
|
|
87
|
-
content: [{
|
|
90
|
+
const messages: Message[] = this.pendingFailureMarkers.map(f => createMessage({
|
|
91
|
+
role: 'marker',
|
|
92
|
+
traceId: '',
|
|
93
|
+
content: [{
|
|
88
94
|
type: 'marker',
|
|
89
95
|
creator: HOOK_ERROR_CREATOR,
|
|
90
96
|
data: { channel: f.channel, ...(f.pluginName !== undefined ? { pluginName: f.pluginName } : {}), message: f.message },
|
|
@@ -110,9 +116,8 @@ export class HookRegistry implements HookRegistrar {
|
|
|
110
116
|
const handlerMarkers: MessageContent[] = [];
|
|
111
117
|
const appendMarkers = (blocks: MessageContent[]): void => {
|
|
112
118
|
handlerMarkers.push(...blocks);
|
|
113
|
-
session = { ...session, messages: [...session.messages,
|
|
114
|
-
|
|
115
|
-
}] };
|
|
119
|
+
session = { ...session, messages: [...session.messages,
|
|
120
|
+
createMessage({ role: 'marker', traceId: '', content: blocks })] };
|
|
116
121
|
};
|
|
117
122
|
// Handler-returned durable context: folded onto the running turn's user message (the last one
|
|
118
123
|
// with role 'user'), so it persists into history AND rides every subsequent provider call — the
|
|
@@ -127,8 +132,7 @@ export class HookRegistry implements HookRegistrar {
|
|
|
127
132
|
durable.push(...blocks);
|
|
128
133
|
session = folded;
|
|
129
134
|
};
|
|
130
|
-
for (const hook of this.
|
|
131
|
-
if (hook.on !== 'screen') continue;
|
|
135
|
+
for (const hook of this.on('screen')) {
|
|
132
136
|
const r = await this.invoke(hook, () => hook.handler({ ...ctx, session, removeHook: () => this.removeOne('screen', hook) }));
|
|
133
137
|
if (!r) continue;
|
|
134
138
|
if (r.session) session = r.session;
|
|
@@ -149,8 +153,7 @@ export class HookRegistry implements HookRegistrar {
|
|
|
149
153
|
// session is never touched.
|
|
150
154
|
async runContribute(ctx: Omit<ContributeContext, 'removeHook'>): Promise<Message[]> {
|
|
151
155
|
let outgoing = ctx.outgoing as Message[];
|
|
152
|
-
for (const hook of this.
|
|
153
|
-
if (hook.on !== 'contribute') continue;
|
|
156
|
+
for (const hook of this.on('contribute')) {
|
|
154
157
|
const r = await this.invoke(hook, () => hook.handler({ ...ctx, outgoing, removeHook: () => this.removeOne('contribute', hook) }));
|
|
155
158
|
if (r) outgoing = r;
|
|
156
159
|
}
|
|
@@ -159,8 +162,7 @@ export class HookRegistry implements HookRegistrar {
|
|
|
159
162
|
|
|
160
163
|
// toolcall stops at the first hook that rejects or aborts; the rest don't run.
|
|
161
164
|
async runToolCall(ctx: Omit<ToolCallContext, 'removeHook'>): Promise<ToolCallResult> {
|
|
162
|
-
for (const hook of this.
|
|
163
|
-
if (hook.on !== 'toolcall') continue;
|
|
165
|
+
for (const hook of this.on('toolcall')) {
|
|
164
166
|
const r = await this.invoke(hook, () => hook.handler({ ...ctx, removeHook: () => this.removeOne('toolcall', hook) }));
|
|
165
167
|
if (r && (r.rejectTool || r.abort)) return r;
|
|
166
168
|
}
|
|
@@ -171,8 +173,7 @@ export class HookRegistry implements HookRegistrar {
|
|
|
171
173
|
// a hook that returns nothing just observes (auditing). Returns the final result.
|
|
172
174
|
async runToolResult(ctx: Omit<ToolResultContext, 'removeHook'>): Promise<unknown> {
|
|
173
175
|
let result = ctx.result;
|
|
174
|
-
for (const hook of this.
|
|
175
|
-
if (hook.on !== 'toolresult') continue;
|
|
176
|
+
for (const hook of this.on('toolresult')) {
|
|
176
177
|
const r = await this.invoke(hook, () => hook.handler({ ...ctx, result, removeHook: () => this.removeOne('toolresult', hook) }));
|
|
177
178
|
if (r) result = r.result;
|
|
178
179
|
}
|
|
@@ -188,17 +189,20 @@ export class HookRegistry implements HookRegistrar {
|
|
|
188
189
|
const markers: MessageContent[] = [];
|
|
189
190
|
const retractCtx: MessageContent[] = [];
|
|
190
191
|
const retractDur: MessageContent[] = [];
|
|
191
|
-
|
|
192
|
-
|
|
192
|
+
// Whether a hook ASKED to retract, not how much it sent with the request. Deriving it from the
|
|
193
|
+
// payload size dropped a `retractAndRerun: {}` — type-legal, both fields being optional — so a hook
|
|
194
|
+
// asking to pop and re-run with nothing added was answered by doing nothing at all, silently.
|
|
195
|
+
let retracting = false;
|
|
196
|
+
for (const hook of this.on('followup')) {
|
|
193
197
|
const r = await this.invoke(hook, () => hook.handler({ ...ctx, removeHook: () => this.removeOne('followup', hook) }));
|
|
194
198
|
if (r?.resubmit) resubmits.push(r.resubmit.content);
|
|
195
199
|
if (r?.retractAndRerun) {
|
|
200
|
+
retracting = true;
|
|
196
201
|
if (r.retractAndRerun.context) retractCtx.push(...r.retractAndRerun.context);
|
|
197
202
|
if (r.retractAndRerun.durable) retractDur.push(...r.retractAndRerun.durable);
|
|
198
203
|
}
|
|
199
204
|
if (r?.markers) markers.push(...r.markers);
|
|
200
205
|
}
|
|
201
|
-
const retracting = retractCtx.length > 0 || retractDur.length > 0;
|
|
202
206
|
return { resubmits, markers, ...(retracting ? { retract: { context: retractCtx, durable: retractDur } } : {}) };
|
|
203
207
|
}
|
|
204
208
|
}
|
package/src/host-machine.ts
CHANGED
|
@@ -50,8 +50,15 @@ export type SwapFn<T extends object> = (next: T) => void;
|
|
|
50
50
|
* A capture-safe forwarding proxy: every trap routes to whatever `getCurrent()` returns *now*, so a
|
|
51
51
|
* reference captured before a register()-driven swap keeps resolving to the live impl. getPrototypeOf
|
|
52
52
|
* is forwarded so `instanceof` sees the real impl (the StorageBackend identity checks depend on it);
|
|
53
|
-
* ownKeys + getOwnPropertyDescriptor keep object spread faithful.
|
|
54
|
-
*
|
|
53
|
+
* ownKeys + getOwnPropertyDescriptor keep object spread faithful. A nullish current (an optional service
|
|
54
|
+
* with nothing registered yet) reads as empty.
|
|
55
|
+
*
|
|
56
|
+
* **Method identity is not stable**, and callers do need to know: a `get` binds to the impl current at
|
|
57
|
+
* that moment, so each read produces a NEW function and `store.get !== store.get`. That is what makes a
|
|
58
|
+
* captured method follow a swap instead of calling into the displaced impl — the point of the whole
|
|
59
|
+
* proxy — but it means anything keyed on a method's identity silently never matches: a `Set` of
|
|
60
|
+
* callbacks it is asked to remove one from, a memo keyed by function, an `===` comparison against a
|
|
61
|
+
* previously-read method. Capture the SERVICE and call through it; do not capture its methods.
|
|
55
62
|
*/
|
|
56
63
|
export function forwardingProxy<T extends object>(getCurrent: () => T | undefined): T {
|
|
57
64
|
return new Proxy({} as T, {
|
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/index.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
export type * from './types.js';
|
|
12
|
-
export { CONFIRM_YES, CONFIRM_NO, optionValue, optionLabel, isTruncatedToolResult, renderToolCheck, renderToolCheckOmitted } from './types.js';
|
|
12
|
+
export { CONFIRM_YES, CONFIRM_NO, optionValue, optionLabel, isTruncatedToolResult, renderToolCheck, renderToolCheckOmitted, FUNCTION_TIMEOUT } from './types.js';
|
|
13
13
|
|
|
14
14
|
export * from './base64.js';
|
|
15
15
|
export * from './bytes.js';
|
package/src/invoke-tool.ts
CHANGED
|
@@ -68,7 +68,8 @@ export type InvokeToolOptions =
|
|
|
68
68
|
* calling tool's `ctx` to forward them all (see {@link InvokeToolOptions}); everything else (vault,
|
|
69
69
|
* plugin (un)loading, workdir/configPath/files) is filled from `machine`.
|
|
70
70
|
*
|
|
71
|
-
* Throws synchronously if no tool is registered under `name
|
|
71
|
+
* Throws synchronously if no tool is registered under `name`, or if `opts.signal` has already aborted.
|
|
72
|
+
* When no `prompt` is supplied the tool
|
|
72
73
|
* runs non-interactively: any attempt to prompt rejects, which a tool surfaces as a normal error
|
|
73
74
|
* event. Pair with {@link toolText} to collapse the stream to its result string.
|
|
74
75
|
*/
|
|
@@ -80,6 +81,9 @@ export function invokeTool<K extends string, const P>(
|
|
|
80
81
|
): AsyncIterable<ToolEvent<ToolResultFor<K, P>>> {
|
|
81
82
|
const tool = machine.tools.resolve(name);
|
|
82
83
|
if (tool === null) throw new Error(`Tool "${name}" is not registered`);
|
|
84
|
+
// A call whose turn was cancelled never starts. Otherwise a composed body looping over `await tool.x()`
|
|
85
|
+
// goes on calling tools after the abort, each callee deciding for itself whether to honour the signal.
|
|
86
|
+
if (opts.signal.aborted) throw new Error(`Tool "${name}" was not started: the call was cancelled.`);
|
|
83
87
|
|
|
84
88
|
const prompt = opts.prompt ?? rejectingPrompt;
|
|
85
89
|
const ctx: ToolContext = {
|
|
@@ -104,6 +108,21 @@ export function invokeTool<K extends string, const P>(
|
|
|
104
108
|
return tool.executor.execute(params, ctx) as AsyncIterable<ToolEvent<ToolResultFor<K, P>>>;
|
|
105
109
|
}
|
|
106
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
|
+
|
|
107
126
|
/**
|
|
108
127
|
* Drain a tool event stream (e.g. {@link invokeTool}'s return) to its raw `result` *value*, typed:
|
|
109
128
|
* paired with `invokeTool(machine, name, …)` it returns whatever `ToolContracts[name]` declares (or
|
|
@@ -112,14 +131,9 @@ export function invokeTool<K extends string, const P>(
|
|
|
112
131
|
* Stops and throws on the first `error` event, or if the tool finished without yielding a `result`.
|
|
113
132
|
*/
|
|
114
133
|
export async function toolResult<R>(events: AsyncIterable<ToolEvent<R>>): Promise<R> {
|
|
115
|
-
|
|
116
|
-
let hadResult = false;
|
|
117
|
-
for await (const ev of events) {
|
|
118
|
-
if (ev.type === 'result') { value = ev.value; hadResult = true; }
|
|
119
|
-
else if (ev.type === 'error') { throw new Error(ev.message); }
|
|
120
|
-
}
|
|
134
|
+
const { value, hadResult } = await drain(events);
|
|
121
135
|
if (!hadResult) throw new Error('Tool produced no result');
|
|
122
|
-
return value;
|
|
136
|
+
return value as R;
|
|
123
137
|
}
|
|
124
138
|
|
|
125
139
|
/**
|
|
@@ -129,13 +143,7 @@ export async function toolResult<R>(events: AsyncIterable<ToolEvent<R>>): Promis
|
|
|
129
143
|
* (the shape `skill_action` and other prose tools return) by its `content`, anything else as JSON.
|
|
130
144
|
*/
|
|
131
145
|
export async function toolText(events: AsyncIterable<ToolEvent>): Promise<string> {
|
|
132
|
-
|
|
133
|
-
let hadResult = false;
|
|
134
|
-
for await (const ev of events) {
|
|
135
|
-
if (ev.type === 'result') { result = ev.value; hadResult = true; }
|
|
136
|
-
else if (ev.type === 'error') { throw new Error(ev.message); }
|
|
137
|
-
}
|
|
138
|
-
if (!hadResult) throw new Error('Tool produced no result');
|
|
146
|
+
const result = await toolResult(events);
|
|
139
147
|
|
|
140
148
|
if (typeof result === 'string') return result;
|
|
141
149
|
if (result !== null && typeof result === 'object' && typeof (result as { content?: unknown }).content === 'string') {
|
|
@@ -144,9 +152,28 @@ export async function toolText(events: AsyncIterable<ToolEvent>): Promise<string
|
|
|
144
152
|
return JSON.stringify(result, null, 2);
|
|
145
153
|
}
|
|
146
154
|
|
|
147
|
-
const
|
|
148
|
-
|
|
149
|
-
|
|
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);
|
|
150
177
|
}) as PromptFn);
|
|
151
178
|
|
|
152
179
|
/** Compact a value to one trace line. */
|
|
@@ -157,6 +184,25 @@ function compactTrace(v: unknown): string {
|
|
|
157
184
|
return s.length > 240 ? `${s.slice(0, 240)}…` : s;
|
|
158
185
|
}
|
|
159
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
|
+
|
|
160
206
|
/**
|
|
161
207
|
* A factory for a context-overridden {@link ToolProxy}: `toolInContext({ provider }).some_tool(params)`
|
|
162
208
|
* runs `some_tool` with the given fields merged over the box's bound context (omitted fields inherited).
|
|
@@ -191,7 +237,7 @@ export function makeToolBox(
|
|
|
191
237
|
if (typeof prop !== 'string' || prop === 'then') return undefined;
|
|
192
238
|
return async (params?: unknown): Promise<unknown> => {
|
|
193
239
|
opts?.onEvent?.({ type: 'stdout', chunk: `→ ${prop}(${compactTrace(params)})\n` });
|
|
194
|
-
const value = await
|
|
240
|
+
const value = await drainProxyResult(invokeTool(machine, prop, params ?? {}, call));
|
|
195
241
|
opts?.onEvent?.({ type: 'stdout', chunk: `← ${prop}: ${compactTrace(value)}\n` });
|
|
196
242
|
return value;
|
|
197
243
|
};
|
package/src/plugin.ts
CHANGED
|
@@ -2,23 +2,21 @@ import type {
|
|
|
2
2
|
FileStore, MediaStore, Principal, Vault, Message, ModelParameters,
|
|
3
3
|
ProviderAdapter, ProviderConfig, ProviderRegistry, Tool, ToolRegistry, FrontendInfo,
|
|
4
4
|
Store, Session, SystemContextRegistry, KnowledgeIndex, PromptFn, SessionRunner, Usage, HookRegistrar,
|
|
5
|
-
TypeScriptStripper, ToolTypeIndex, ToolPresenter, SteeringPolicy, UserContent, PermissionGate,
|
|
5
|
+
TypeScriptStripper, FunctionRunner, ToolTypeIndex, ToolPresenter, SteeringPolicy, UserContent, PermissionGate,
|
|
6
6
|
} from './types.js';
|
|
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
|
|
|
@@ -191,6 +189,10 @@ export interface MatbotServices {
|
|
|
191
189
|
* `file-ref` degrades to the converters' `[Attached file: x]` note. A text-only deployment is
|
|
192
190
|
* unaffected either way. See {@link MediaStore}. */
|
|
193
191
|
readonly MediaStore?: MediaStore | undefined;
|
|
192
|
+
/** Optional bound on how long model-authored code may run without awaiting. The node host registers
|
|
193
|
+
* one; absent ⇒ such code runs unbounded, as it must where synchronous code cannot be interrupted.
|
|
194
|
+
* See {@link FunctionRunner}. */
|
|
195
|
+
readonly FunctionRunner?: FunctionRunner | undefined;
|
|
194
196
|
}
|
|
195
197
|
|
|
196
198
|
/** The assembled machine: registry services wired to the fixed runtime — what `setup()` receives. */
|
|
@@ -243,26 +245,8 @@ export interface MatbotRuntime {
|
|
|
243
245
|
*/
|
|
244
246
|
createStore<T extends { id: string; version: string }>(namespace: string): Store<T>;
|
|
245
247
|
|
|
246
|
-
/**
|
|
247
|
-
*
|
|
248
|
-
* needs this iff its setup() reads another service's *current state* to build cached/derived state —
|
|
249
|
-
* skills/triggers cache the StorageBackend's documents; cognition seeds from the SkillManager. A pure
|
|
250
|
-
* map (no setup data; data arrives later as a tool call or hook) needs nothing — resolve the service
|
|
251
|
-
* per-invocation through its proxy/member instead.
|
|
252
|
-
*
|
|
253
|
-
* // cache the backend's documents; rebuild on every swap (own initial load was in setup())
|
|
254
|
-
* services.mounted.observe({ key: 'StorageBackend', signal }, () => this.load());
|
|
255
|
-
*
|
|
256
|
-
* // depend on a peer service that may not be present yet; seed now if it is, and on each (re)mount
|
|
257
|
-
* services.mounted.observe({ key: 'SkillManager', replay: true, signal }, m => seed(m));
|
|
258
|
-
*
|
|
259
|
-
* Contract guarantees only *eventual, ordered* delivery of each key's net presence transition — it
|
|
260
|
-
* says nothing about timing: a mount may fire synchronously-ish or batch to a later quiescent edge, so
|
|
261
|
-
* never assume a register() is observed inline or at a turn boundary. A reload (unregister+register
|
|
262
|
-
* before the edge) collapses to a single remount; an unregister not replaced by the edge is a
|
|
263
|
-
* committed unload, delivered to `onUnmount` (drop your captured ref there to let the gone plugin's
|
|
264
|
-
* working set be collected). Handlers may re-fire on later remounts, so they must be idempotent.
|
|
265
|
-
*/
|
|
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. */
|
|
266
250
|
readonly mounted: Mounted;
|
|
267
251
|
|
|
268
252
|
/**
|
|
@@ -354,9 +338,29 @@ export interface MountConsumeOptions<K extends keyof MatbotServices> {
|
|
|
354
338
|
readonly onUnmount?: (machine: MatbotMachine) => void | Promise<void>;
|
|
355
339
|
}
|
|
356
340
|
|
|
357
|
-
/**
|
|
358
|
-
*
|
|
359
|
-
*
|
|
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
|
+
*/
|
|
360
364
|
export interface Mounted {
|
|
361
365
|
observe<K extends keyof MatbotServices>(
|
|
362
366
|
options: MountConsumeOptions<K>,
|
|
@@ -370,11 +374,6 @@ export interface Mounted {
|
|
|
370
374
|
|
|
371
375
|
export type ProviderAdapterFactory = (config: ProviderConfig) => ProviderAdapter;
|
|
372
376
|
|
|
373
|
-
export type StoreFactory = (
|
|
374
|
-
kind: string,
|
|
375
|
-
options: Record<string, unknown>,
|
|
376
|
-
) => Store<{ id: string; version: string }>;
|
|
377
|
-
|
|
378
377
|
// ── Plugin manifest ───────────────────────────────────────────────────────────
|
|
379
378
|
|
|
380
379
|
export interface PluginManifest {
|
|
@@ -449,12 +448,11 @@ export interface VisibilityQuery {
|
|
|
449
448
|
* notification is visible to that connection's principal. Absent ⇒ no partitioning, and every connection
|
|
450
449
|
* sees everything.
|
|
451
450
|
*
|
|
452
|
-
* **The predicate is consulted for EVERY kind, and decides for itself which it filters
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
* 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`.
|
|
458
456
|
*/
|
|
459
457
|
export interface WatchVisibility {
|
|
460
458
|
/**
|
|
@@ -484,9 +482,8 @@ export interface WatchVisibility {
|
|
|
484
482
|
* **The token is a partition, never a principal.** The two are not interchangeable: one principal may
|
|
485
483
|
* hold several profiles, and a profile may alias a namespace onto a third profile's partition, so the
|
|
486
484
|
* partition serving a principal's files is not derivable from the principal id. Minting the token from
|
|
487
|
-
* the identity in force
|
|
488
|
-
*
|
|
489
|
-
* 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.
|
|
490
487
|
*
|
|
491
488
|
* `current` and `enter` are one round trip and must stay each other's inverse: whatever `current`
|
|
492
489
|
* returns, `enter` puts a reader back into the area those bytes were written to. Both live here rather
|
|
@@ -514,7 +511,6 @@ export interface MatbotPluginSpec {
|
|
|
514
511
|
readonly apiVersion: string;
|
|
515
512
|
readonly manifest?: PluginManifest;
|
|
516
513
|
readonly provider?: ProviderAdapterFactory;
|
|
517
|
-
readonly storage?: Record<string, StoreFactory>;
|
|
518
514
|
readonly tools?: readonly Tool[];
|
|
519
515
|
/**
|
|
520
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,14 @@ 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) — the usage carrier needs the
|
|
120
|
+
// identical wrap for its call site, and had its own, less faithful copy.
|
|
118
121
|
function scopedIterator<T>(
|
|
119
122
|
carrier: PrincipalCarrier,
|
|
120
123
|
principal: Principal,
|
|
121
124
|
source: AsyncIterator<T> & AsyncIterable<T>,
|
|
122
125
|
): 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;
|
|
126
|
+
return scopeIterator(source, f => carrier.run(principal, f));
|
|
144
127
|
}
|
|
145
128
|
|
|
146
129
|
/** 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>;
|
package/src/types/typescript.ts
CHANGED
|
@@ -17,6 +17,26 @@ export interface TypeScriptStripper {
|
|
|
17
17
|
strip(source: string): string | Promise<string>;
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Optional, host-supplied: compiles already-stripped JavaScript into an async function whose SYNCHRONOUS
|
|
22
|
+
* execution is bounded. Model-authored code (a `tool_function` body) runs on the one event loop every
|
|
23
|
+
* session, frontend and schedule shares, so a loop that never awaits freezes all of them — and nothing
|
|
24
|
+
* in-process can stop it, an abort signal included, since stopping needs the loop to yield. Only the
|
|
25
|
+
* stretches between awaits are bounded: a body waiting on a slow tool call is not doing work.
|
|
26
|
+
*
|
|
27
|
+
* Absent ⇒ a consumer compiles with `new AsyncFunction` and runs unbounded, which is all a platform with
|
|
28
|
+
* no way to interrupt synchronous code (the browser) can do. An embedder may register its own.
|
|
29
|
+
*/
|
|
30
|
+
export interface FunctionRunner {
|
|
31
|
+
/** Compile `body` as the body of an async function taking `params`. Throws on a syntax error. A call
|
|
32
|
+
* stopped at the limit rejects with an error whose `code` is {@link FUNCTION_TIMEOUT}. */
|
|
33
|
+
compile(params: readonly string[], body: string): (...args: unknown[]) => Promise<unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The `code` on the error a {@link FunctionRunner} rejects with when it stops a run at its limit, so a
|
|
37
|
+
* consumer can tell a stopped runaway from an ordinary failure without matching message text. */
|
|
38
|
+
export const FUNCTION_TIMEOUT = 'FUNCTION_TIMEOUT';
|
|
39
|
+
|
|
20
40
|
/**
|
|
21
41
|
* One finding from {@link ToolTypeIndex.check} — the record, not a rendering of it.
|
|
22
42
|
*
|