@matatbread/matbot-plugin-api 0.4.18 → 0.4.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.4.18",
7
+ "version": "0.4.19",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
package/src/hooks.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type {
2
- Hook, HookPoint, HookRegistrar, Message, MessageContent, Session, DeferredScreen,
2
+ Hook, HookPoint, HookRegistrar, Message, MessageContent, Session, DeferredScreen, TurnToolSource,
3
3
  ScreenContext, ContributeContext, ToolCallContext, ToolCallResult, ToolResultContext, FollowupContext,
4
4
  } from './types.js';
5
5
  import { foldOntoUserTurn, createMessage } from './session.js';
@@ -120,12 +120,13 @@ export class HookRegistry implements HookRegistrar {
120
120
 
121
121
  // screen folds across hooks: each sees the session as shaped so far, accumulates ephemeral, and
122
122
  // the first `abort` short-circuits (the partial session is returned so the caller can persist it).
123
- async runScreen(ctx: Omit<ScreenContext, 'removeHook'>): Promise<{ session: Session; ephemeral: MessageContent[]; durable: MessageContent[]; markers: MessageContent[]; deferred: DeferredScreen[]; abort?: string }> {
123
+ async runScreen(ctx: Omit<ScreenContext, 'removeHook'>): Promise<{ session: Session; ephemeral: MessageContent[]; durable: MessageContent[]; markers: MessageContent[]; deferred: DeferredScreen[]; tools: TurnToolSource[]; abort?: string }> {
124
124
  let session = ctx.session;
125
125
  const ephemeral: MessageContent[] = [];
126
126
  // Raced verdicts handed back by hooks: the runner polls each without gating the turn (see
127
127
  // DeferredScreen). Usually zero or one; an array so several racing hooks compose.
128
128
  const deferred: DeferredScreen[] = [];
129
+ const tools: TurnToolSource[] = [];
129
130
  // Handler-returned markers: appended to the session here (so they persist) and accumulated so the
130
131
  // runner can also emit them live — keeping a live draw and a reload identical.
131
132
  const handlerMarkers: MessageContent[] = [];
@@ -155,13 +156,14 @@ export class HookRegistry implements HookRegistrar {
155
156
  if (r.durable && r.durable.length) foldDurable(r.durable);
156
157
  if (r.markers && r.markers.length) appendMarkers(r.markers);
157
158
  if (r.deferred) deferred.push(r.deferred);
159
+ if (r.tools) tools.push(r.tools);
158
160
  if (r.abort) {
159
161
  const drained = this.drainFailureMarkers(session);
160
- return { session: drained.session, ephemeral, durable, markers: [...handlerMarkers, ...drained.markers], deferred, abort: r.abort };
162
+ return { session: drained.session, ephemeral, durable, markers: [...handlerMarkers, ...drained.markers], deferred, tools, abort: r.abort };
161
163
  }
162
164
  }
163
165
  const drained = this.drainFailureMarkers(session);
164
- return { session: drained.session, ephemeral, durable, markers: [...handlerMarkers, ...drained.markers], deferred };
166
+ return { session: drained.session, ephemeral, durable, markers: [...handlerMarkers, ...drained.markers], deferred, tools };
165
167
  }
166
168
 
167
169
  // contribute folds the outgoing array through each hook — a pure transform pipeline; the stored
@@ -58,11 +58,11 @@ export function bindGate(
58
58
  */
59
59
  export type InvokeToolOptions =
60
60
  Pick<ToolContext, 'session' | 'signal'>
61
- & Partial<Pick<ToolContext, 'prompt' | 'provider' | 'callId'>>;
61
+ & Partial<Pick<ToolContext, 'prompt' | 'provider' | 'callId' | 'turnTools'>>;
62
62
 
63
63
  /**
64
64
  * Programmatically invoke a tool by name, the same way the harness does — resolve it off the
65
- * machine's tool registry, build a `ToolContext` from the machine, and return its event stream.
65
+ * machine's tool registry (else the turn's own tools, when `opts` carries them), build a `ToolContext` from the machine, and return its event stream.
66
66
  * The host-only bits a one-shot caller can't derive (the session under which the call runs, its
67
67
  * abort signal, and an optional interactive `prompt`/`provider`) come in via `opts` — pass the
68
68
  * calling tool's `ctx` to forward them all (see {@link InvokeToolOptions}); everything else (vault,
@@ -79,7 +79,8 @@ export function invokeTool<K extends string, const P>(
79
79
  params: P,
80
80
  opts: InvokeToolOptions,
81
81
  ): AsyncIterable<ToolEvent<ToolResultFor<K, P>>> {
82
- const tool = machine.tools.resolve(name);
82
+ // The registry first, as the runner does: a turn tool never shadows a registered one.
83
+ const tool = machine.tools.resolve(name) ?? opts.turnTools?.get(name) ?? null;
83
84
  if (tool === null) throw new Error(`Tool "${name}" is not registered`);
84
85
  // A call whose turn was cancelled never starts. Otherwise a composed body looping over `await tool.x()`
85
86
  // goes on calling tools after the abort, each callee deciding for itself whether to honour the signal.
@@ -98,6 +99,7 @@ export function invokeTool<K extends string, const P>(
98
99
  // non-interactive case the request's `fallback` exists to answer.
99
100
  ...bindGate(machine.PermissionGate, name, opts.prompt),
100
101
  ...(opts.provider !== undefined ? { provider: opts.provider } : {}),
102
+ ...(opts.turnTools !== undefined ? { turnTools: opts.turnTools } : {}),
101
103
  ...(machine.workdir !== undefined ? { workdir: machine.workdir } : {}),
102
104
  ...(machine.configPath !== undefined ? { configPath: machine.configPath } : {}),
103
105
  ...(machine.files !== undefined ? { files: machine.files } : {}),
@@ -24,6 +24,7 @@ export interface RunConfig {
24
24
  * doesn't bust the cached prefix), `durable` context (the persisted, visible twin of
25
25
  * `ephemeral`: folded onto this turn's user message as `origin: 'robo'` blocks and
26
26
  * carried live as a `robo-user` event, so it survives into the next turn's history),
27
+ * turn-scoped `tools` (a source the runner asks each round — see {@link TurnToolSource}),
27
28
  * or `abort`. Mix freely. This is where the durable-vs-ephemeral choice for incoming
28
29
  * user input lives.
29
30
  * contribute runner, before *every* provider call. Ephemeral by construction (it re-fires, so a
@@ -123,9 +124,28 @@ export interface ScreenResult {
123
124
  * you just want to annotate (e.g. a fired trigger's silent tool recording what it did).
124
125
  */
125
126
  markers?: MessageContent[];
127
+ /** Tools for this turn only — see {@link TurnToolSource}. */
128
+ tools?: TurnToolSource;
126
129
  abort?: string;
127
130
  }
128
131
 
132
+ /**
133
+ * Tools that exist for one turn of one session and nowhere else: advertised to the model, runnable by
134
+ * it, and runnable by any tool in the turn that forwards its context ({@link ToolContext.turnTools}) —
135
+ * but never registered, so no other session, no sessionless door (`POST /tools/:name`), the type index
136
+ * and the tool presenter never see them. The shape a session-scoped `tool_function` needs.
137
+ *
138
+ * A SOURCE rather than a list because the runner asks it at the top of EVERY round, with the session as
139
+ * it then stands: a turn's own tool calls change the session (a marker recording a definition lands at
140
+ * the end of its round), and a tool defined in round 2 must be callable in round 3, not next turn. So a
141
+ * source is called often and should cache whatever is costly to build.
142
+ *
143
+ * A turn tool may not shadow a registered one: a name the registry holds is dropped, with a warning, and
144
+ * the registered tool keeps it. Shadowing a tool by registration is a gated decision (`tools.overwrite`),
145
+ * and a hook must not be a way round it. A source wanting the model to hear about a clash says so itself.
146
+ */
147
+ export type TurnToolSource = (session: Session) => readonly Tool[] | Promise<readonly Tool[]>;
148
+
129
149
  export interface ContributeContext {
130
150
  readonly outgoing: readonly Message[];
131
151
  readonly session: Session;
@@ -219,6 +219,13 @@ export interface ToolContext {
219
219
  * reach. Prefer it over `services.SessionAppender`; absent ⇒ the machine's is the right one.
220
220
  */
221
221
  appender?: SessionAppender;
222
+ /**
223
+ * The tools that exist for this turn only ({@link TurnToolSource}), as the runner resolved them for
224
+ * this round. Never in the registry, so a tool calling another by name finds them only through here —
225
+ * which {@link invokeTool} does when handed this context, the way a forwarding tool already should.
226
+ * Absent ⇒ none, or a door that runs no turn (`POST /tools/:name`).
227
+ */
228
+ turnTools?: ReadonlyMap<string, Tool>;
222
229
  /** Prompt the user for input. The host provides a readline or form implementation. */
223
230
  prompt: PromptFn;
224
231
  /**