@matatbread/matbot-plugin-api 0.2.4 → 0.2.5
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/index.ts +1 -0
- package/src/invoke-tool.ts +100 -0
- package/src/plugin.ts +4 -1
- package/src/types.ts +27 -4
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import type { MatbotMachine } from './plugin.js';
|
|
2
|
+
import type { ToolContext, ToolEvent, ToolResultOf, PromptFn, FormField } from './types.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Inputs to {@link invokeTool} that a one-shot caller can't derive from the machine. A tool
|
|
6
|
+
* forwarding a call to another tool should pass its own {@link ToolContext} straight through as
|
|
7
|
+
* `opts` — `session`, `signal`, `prompt` and crucially **`provider`** all propagate, so a callee
|
|
8
|
+
* that needs an LLM (e.g. `find_fact`, or anything using `singleTurn`) inherits the same model
|
|
9
|
+
* rather than finding no provider. Only spell out individual fields when starting a call from
|
|
10
|
+
* scratch or deliberately overriding one; never hand-pick a subset of `ctx` and drop the rest.
|
|
11
|
+
*/
|
|
12
|
+
export type InvokeToolOptions =
|
|
13
|
+
Pick<ToolContext, 'session' | 'signal'>
|
|
14
|
+
& Partial<Pick<ToolContext, 'prompt' | 'provider' | 'callId'>>;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Programmatically invoke a tool by name, the same way the harness does — resolve it off the
|
|
18
|
+
* machine's tool registry, build a `ToolContext` from the machine, and return its event stream.
|
|
19
|
+
* The host-only bits a one-shot caller can't derive (the session under which the call runs, its
|
|
20
|
+
* abort signal, and an optional interactive `prompt`/`provider`) come in via `opts` — pass the
|
|
21
|
+
* calling tool's `ctx` to forward them all (see {@link InvokeToolOptions}); everything else (vault,
|
|
22
|
+
* plugin (un)loading, workdir/configPath/files) is filled from `machine`.
|
|
23
|
+
*
|
|
24
|
+
* Throws synchronously if no tool is registered under `name`. When no `prompt` is supplied the tool
|
|
25
|
+
* runs non-interactively: any attempt to prompt rejects, which a tool surfaces as a normal error
|
|
26
|
+
* event. Pair with {@link toolText} to collapse the stream to its result string.
|
|
27
|
+
*/
|
|
28
|
+
export function invokeTool<K extends string>(
|
|
29
|
+
machine: MatbotMachine,
|
|
30
|
+
name: K,
|
|
31
|
+
params: unknown,
|
|
32
|
+
opts: InvokeToolOptions,
|
|
33
|
+
): AsyncIterable<ToolEvent<ToolResultOf<K>>> {
|
|
34
|
+
const tool = machine.tools.resolve(name);
|
|
35
|
+
if (tool === null) throw new Error(`Tool "${name}" is not registered`);
|
|
36
|
+
|
|
37
|
+
const prompt = opts.prompt ?? rejectingPrompt;
|
|
38
|
+
const ctx: ToolContext = {
|
|
39
|
+
callId: opts.callId ?? crypto.randomUUID(),
|
|
40
|
+
session: opts.session,
|
|
41
|
+
signal: opts.signal,
|
|
42
|
+
vault: machine.Vault,
|
|
43
|
+
prompt,
|
|
44
|
+
loadPlugin: (specifier, refresh) => machine.loadPlugin(specifier, prompt, refresh),
|
|
45
|
+
unloadPlugin: (specifier) => machine.unloadPlugin(specifier),
|
|
46
|
+
...(opts.provider !== undefined ? { provider: opts.provider } : {}),
|
|
47
|
+
...(machine.workdir !== undefined ? { workdir: machine.workdir } : {}),
|
|
48
|
+
...(machine.configPath !== undefined ? { configPath: machine.configPath } : {}),
|
|
49
|
+
...(machine.files !== undefined ? { files: machine.files } : {}),
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
// The registry is name-keyed and untyped (`ToolExecutor.execute` yields `ToolEvent<unknown>`); the
|
|
53
|
+
// `ToolResults` mapping is the call-site contract, asserted here so callers read a typed result.
|
|
54
|
+
return tool.executor.execute(params, ctx) as AsyncIterable<ToolEvent<ToolResultOf<K>>>;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Drain a tool event stream (e.g. {@link invokeTool}'s return) to its raw `result` *value*, typed:
|
|
59
|
+
* paired with `invokeTool(machine, name, …)` it returns whatever `ToolResults[name]` declares (or
|
|
60
|
+
* `unknown` for an unregistered tool). This is the structured counterpart to {@link toolText} — use it
|
|
61
|
+
* for any tool that returns data; use `toolText` when you want the result collapsed to a string.
|
|
62
|
+
* Stops and throws on the first `error` event, or if the tool finished without yielding a `result`.
|
|
63
|
+
*/
|
|
64
|
+
export async function toolResult<R>(events: AsyncIterable<ToolEvent<R>>): Promise<R> {
|
|
65
|
+
let value!: R;
|
|
66
|
+
let hadResult = false;
|
|
67
|
+
for await (const ev of events) {
|
|
68
|
+
if (ev.type === 'result') { value = ev.value; hadResult = true; }
|
|
69
|
+
else if (ev.type === 'error') { throw new Error(ev.message); }
|
|
70
|
+
}
|
|
71
|
+
if (!hadResult) throw new Error('Tool produced no result');
|
|
72
|
+
return value;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Drain a tool event stream (e.g. {@link invokeTool}'s return) to its result as text. Stops and
|
|
77
|
+
* throws on the first `error` event, or if the tool finished without yielding a `result`. The result
|
|
78
|
+
* value is rendered the way the model would see it: a string verbatim, a `{ content: string }`
|
|
79
|
+
* (the shape `skill_action` and other prose tools return) by its `content`, anything else as JSON.
|
|
80
|
+
*/
|
|
81
|
+
export async function toolText(events: AsyncIterable<ToolEvent>): Promise<string> {
|
|
82
|
+
let result: unknown;
|
|
83
|
+
let hadResult = false;
|
|
84
|
+
for await (const ev of events) {
|
|
85
|
+
if (ev.type === 'result') { result = ev.value; hadResult = true; }
|
|
86
|
+
else if (ev.type === 'error') { throw new Error(ev.message); }
|
|
87
|
+
}
|
|
88
|
+
if (!hadResult) throw new Error('Tool produced no result');
|
|
89
|
+
|
|
90
|
+
if (typeof result === 'string') return result;
|
|
91
|
+
if (result !== null && typeof result === 'object' && typeof (result as { content?: unknown }).content === 'string') {
|
|
92
|
+
return (result as { content: string }).content;
|
|
93
|
+
}
|
|
94
|
+
return JSON.stringify(result, null, 2);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const rejectingPrompt: PromptFn = (((p: string | FormField): Promise<string> => {
|
|
98
|
+
const label = typeof p === 'string' ? p : p.label;
|
|
99
|
+
return Promise.reject(new Error(`Non-interactive context: cannot prompt for "${label}"`));
|
|
100
|
+
}) as PromptFn);
|
package/src/plugin.ts
CHANGED
|
@@ -116,8 +116,11 @@ export interface MatbotRuntime {
|
|
|
116
116
|
* Hot-load a plugin by specifier into the running process. Returns the loaded plugin.
|
|
117
117
|
* `prompt`, when supplied, resolves tool-name collisions interactively during the new
|
|
118
118
|
* plugin's setup(); the runner injects the triggering session's prompt automatically.
|
|
119
|
+
* `refresh` (default false) forces a remote (github/http) plugin's `.plugins/` cache subtree
|
|
120
|
+
* to be evicted and re-downloaded — a reload of changed upstream source. Cache-first otherwise:
|
|
121
|
+
* a programmatic loader gets the cached copy and stays offline-tolerant.
|
|
119
122
|
*/
|
|
120
|
-
loadPlugin(specifier: string, prompt?: PromptFn): Promise<MatbotPlugin>;
|
|
123
|
+
loadPlugin(specifier: string, prompt?: PromptFn, refresh?: boolean): Promise<MatbotPlugin>;
|
|
121
124
|
|
|
122
125
|
/**
|
|
123
126
|
* Hot-unload a plugin by specifier, removing its tools, hooks, and system context contributions.
|
package/src/types.ts
CHANGED
|
@@ -438,11 +438,11 @@ export interface KnowledgeIndex {
|
|
|
438
438
|
|
|
439
439
|
// ── Tools ─────────────────────────────────────────────────────────────────────
|
|
440
440
|
|
|
441
|
-
export type ToolEvent =
|
|
441
|
+
export type ToolEvent<Result = unknown> =
|
|
442
442
|
| { type: 'stdout'; chunk: string }
|
|
443
443
|
| { type: 'stderr'; chunk: string }
|
|
444
444
|
| { type: 'progress'; pct: number; message?: string }
|
|
445
|
-
| { type: 'result'; value:
|
|
445
|
+
| { type: 'result'; value: Result }
|
|
446
446
|
| { type: 'file'; handle: FileHandle }
|
|
447
447
|
// A durable, LLM-invisible annotation the tool emits as it runs (a link, a status, a trace of a
|
|
448
448
|
// side-effect). Persisted as a `marker`-role message; elided from provider submission like any
|
|
@@ -451,6 +451,26 @@ export type ToolEvent =
|
|
|
451
451
|
| { type: 'marker'; creator: string; data: unknown }
|
|
452
452
|
| { type: 'error'; message: string; code?: number; stdout?: string; stderr?: string };
|
|
453
453
|
|
|
454
|
+
/**
|
|
455
|
+
* Per-tool result-type registry: maps a tool's `name` to the type of the `value` it yields in its
|
|
456
|
+
* `result` event. Augment it exactly like {@link MarkerData} / `MatbotServices`, so a caller gets the
|
|
457
|
+
* concrete result type back from {@link invokeTool} + `toolResult` instead of `unknown`:
|
|
458
|
+
*
|
|
459
|
+
* declare module '@matatbread/matbot-plugin-api' {
|
|
460
|
+
* interface ToolResults { find_fact: string[] | null }
|
|
461
|
+
* }
|
|
462
|
+
*
|
|
463
|
+
* The tool name is the discriminator: `invokeTool(machine, 'find_fact', …)` is typed
|
|
464
|
+
* `AsyncIterable<ToolEvent<string[] | null>>`, so `toolResult(…)` resolves to `string[] | null`. A
|
|
465
|
+
* name with no registered entry resolves to `unknown` (the caller must narrow). This is a pure
|
|
466
|
+
* type-level construct — no runtime validation; it pins, at the call site, the contract a tool's
|
|
467
|
+
* executor already produces. Keep the entry in sync with what the executor actually yields.
|
|
468
|
+
*/
|
|
469
|
+
export interface ToolResults {}
|
|
470
|
+
|
|
471
|
+
/** The `result` value a tool named `K` yields, or `unknown` when `K` isn't registered in {@link ToolResults}. */
|
|
472
|
+
export type ToolResultOf<K extends string> = K extends keyof ToolResults ? ToolResults[K] : unknown;
|
|
473
|
+
|
|
454
474
|
/**
|
|
455
475
|
* Ask the user a question and resolve with their answer. The host supplies the
|
|
456
476
|
* implementation — readline in the CLI, an SSE round-trip in the web frontend. This is
|
|
@@ -488,8 +508,10 @@ export interface ToolContext {
|
|
|
488
508
|
files?: FileStore;
|
|
489
509
|
/** Prompt the user for input. The host provides a readline or form implementation. */
|
|
490
510
|
prompt: PromptFn;
|
|
491
|
-
/** Hot-load a plugin by specifier without restarting the process. Returns the loaded plugin.
|
|
492
|
-
|
|
511
|
+
/** Hot-load a plugin by specifier without restarting the process. Returns the loaded plugin.
|
|
512
|
+
* `refresh` (default false) forces a remote (github/http) plugin to be re-downloaded rather than
|
|
513
|
+
* re-loaded from its `.plugins/` cache — pass true to pick up changed upstream source. */
|
|
514
|
+
loadPlugin(specifier: string, refresh?: boolean): Promise<MatbotPlugin>;
|
|
493
515
|
/**
|
|
494
516
|
* Hot-unload a plugin by specifier, removing its tools, hooks, and system context contributions.
|
|
495
517
|
* Resolves `true` if a plugin was actually resident and unloaded, `false` if there was nothing
|
|
@@ -649,6 +671,7 @@ export type PipelineEvent =
|
|
|
649
671
|
| { type: 'tool:start'; callId: string; name: string; input: unknown; traceId: string }
|
|
650
672
|
| { type: 'tool:stdout'; callId: string; chunk: string; traceId: string }
|
|
651
673
|
| { type: 'tool:stderr'; callId: string; chunk: string; traceId: string }
|
|
674
|
+
| { type: 'tool:progress'; callId: string; pct: number; message?: string; traceId: string }
|
|
652
675
|
| { type: 'tool:end'; callId: string; result: unknown; isError: boolean; traceId: string }
|
|
653
676
|
| { type: 'file'; handle: FileHandle; traceId: string }
|
|
654
677
|
| ({ type: 'usage'; traceId: string } & Usage)
|