@matatbread/matbot-plugin-api 0.3.8 → 0.4.0
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 +3 -2
- package/src/hooks.ts +6 -6
- package/src/host-machine.ts +191 -0
- package/src/host.ts +43 -0
- package/src/index.ts +27 -9
- package/src/invoke-tool.ts +25 -3
- package/src/notify.ts +3 -0
- package/src/plugin.ts +28 -195
- package/src/session.ts +30 -1
- package/src/types/events.ts +81 -0
- package/src/types/files.ts +46 -0
- package/src/types/frontend.ts +11 -0
- package/src/types/health.ts +6 -0
- package/src/types/hooks.ts +217 -0
- package/src/types/knowledge.ts +27 -0
- package/src/types/messages.ts +154 -0
- package/src/types/primitives.ts +5 -0
- package/src/types/principal.ts +10 -0
- package/src/types/provider.ts +157 -0
- package/src/types/registries.ts +84 -0
- package/src/types/session-runner.ts +69 -0
- package/src/types/steering.ts +33 -0
- package/src/types/storage.ts +16 -0
- package/src/types/system-context.ts +15 -0
- package/src/types/tools.ts +262 -0
- package/src/types/typescript.ts +50 -0
- package/src/types/vault.ts +37 -0
- package/src/types.ts +27 -1089
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import type { HealthStatus } from './health.js';
|
|
2
|
+
import type { Message } from './messages.js';
|
|
3
|
+
import type { Tool } from './tools.js';
|
|
4
|
+
|
|
5
|
+
// ── Provider ──────────────────────────────────────────────────────────────────
|
|
6
|
+
|
|
7
|
+
export interface ModelParameters {
|
|
8
|
+
temperature?: number;
|
|
9
|
+
maxTokens?: number;
|
|
10
|
+
topP?: number;
|
|
11
|
+
stopSequences?: string[];
|
|
12
|
+
[key: string]: unknown;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ProviderConfig {
|
|
16
|
+
name: string;
|
|
17
|
+
/** Plugin module specifier (npm name or file URL) that provides the adapter for this provider. */
|
|
18
|
+
module: string;
|
|
19
|
+
model: string;
|
|
20
|
+
credentials?: Record<string, string>;
|
|
21
|
+
endpoint?: string;
|
|
22
|
+
parameters?: ModelParameters;
|
|
23
|
+
/**
|
|
24
|
+
* Ceiling on the agentic rounds one turn may take against this provider — a round being one provider
|
|
25
|
+
* call plus the tool batch it asked for. Reaching it ends the turn (`aborted`, reason `round-limit`)
|
|
26
|
+
* rather than starting another round. Absent ⇒ unbounded, which is the historical behaviour.
|
|
27
|
+
*
|
|
28
|
+
* It lives here, per provider, rather than as one global or a per-call override because that is the
|
|
29
|
+
* unit spend is actually denominated in: a local model can afford to grind, a frontier model at 100×
|
|
30
|
+
* the rate cannot, and the same deployment runs both. Not in `parameters` — those are forwarded to
|
|
31
|
+
* the endpoint unmodified, and this never leaves matbot.
|
|
32
|
+
*/
|
|
33
|
+
maxRounds?: number;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Token (and optional cost) counters for one provider call — pure accounting data, never conversation
|
|
38
|
+
* content. Recorded onto the message stream for cost reckoning but elided from provider submission. The
|
|
39
|
+
* producing provider is recorded alongside at each storage site (an assistant turn carries it on the
|
|
40
|
+
* message's `providerName`; a tool-invoked completion pairs it explicitly — see `tool-result`), since a
|
|
41
|
+
* tool may run a completion against any configured provider, each with its own rates.
|
|
42
|
+
*/
|
|
43
|
+
export interface Usage {
|
|
44
|
+
inputTokens: number;
|
|
45
|
+
outputTokens: number;
|
|
46
|
+
costUsd?: number;
|
|
47
|
+
cacheReadTokens?: number;
|
|
48
|
+
cacheCreationTokens?: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** One provider call's usage, tagged with the provider billed — the unit a tool accrues (a tool may
|
|
52
|
+
* run completions against any provider, each with its own rates) and the element persisted on a
|
|
53
|
+
* `tool-result`'s `usage` addendum. See the ambient usage carrier (`recordUsage`/`currentUsageSink`). */
|
|
54
|
+
export interface UsageRecord {
|
|
55
|
+
provider: string;
|
|
56
|
+
usage: Usage;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Opaque, provider-specific round-trip metadata attached to a tool-call — captured from a completion,
|
|
61
|
+
* persisted, and re-sent verbatim when the call is replayed in history (e.g. a Gemini 3 "thought
|
|
62
|
+
* signature", which every historical `functionCall` must carry or the request 400s). The harness never
|
|
63
|
+
* interprets it: it stores, renders, or elides it. Empty by default — a provider package augments its
|
|
64
|
+
* OWN slice from its own module (namespaced by provider family, so slices from different providers in a
|
|
65
|
+
* mixed-provider session never collide), so adding a provider that needs round-trip state touches NO
|
|
66
|
+
* core code:
|
|
67
|
+
*
|
|
68
|
+
* declare module '@matatbread/matbot-plugin-api' {
|
|
69
|
+
* interface ProviderMeta { google?: { thoughtSignature?: string } }
|
|
70
|
+
* }
|
|
71
|
+
*
|
|
72
|
+
* A single session interleaves tool-calls from many providers, so this must be an open, additive union
|
|
73
|
+
* (interface augmentation) — a generic type parameter can bind only one provider per instantiation and
|
|
74
|
+
* so could not type a heterogeneous transcript.
|
|
75
|
+
*
|
|
76
|
+
* One of matbot's five open-registry augmentation points — same technique at each; see
|
|
77
|
+
* docs/DEVELOPING.md *Open-registry augmentation* for the shared shape and the rules that follow from it.
|
|
78
|
+
*/
|
|
79
|
+
export interface ProviderMeta {}
|
|
80
|
+
|
|
81
|
+
export type CompletionEvent =
|
|
82
|
+
| { type: 'text-delta'; delta: string }
|
|
83
|
+
| { type: 'tool-call'; id: string; name: string; input: unknown;
|
|
84
|
+
/** Provider-specific round-trip metadata for this call, opaque to the harness — see `ProviderMeta`. */
|
|
85
|
+
meta?: ProviderMeta;
|
|
86
|
+
/**
|
|
87
|
+
* Set when the call's arguments were severed mid-stream and never parsed — nearly always the
|
|
88
|
+
* response hitting its token limit part-way through a large call. `input` is then `{}` (the wire
|
|
89
|
+
* requires an object; the real arguments are unrecoverable), and the runner does NOT execute the
|
|
90
|
+
* call: it pairs it with an error result saying so, exactly as it does a rejected one, and the
|
|
91
|
+
* model self-corrects on the next round.
|
|
92
|
+
*
|
|
93
|
+
* Only adapters that accumulate arguments as a streamed JSON *string* can detect this — the
|
|
94
|
+
* Anthropic (`input_json_delta`) and OpenAI (`function.arguments` fragments) shapes. Gemini
|
|
95
|
+
* delivers each `functionCall` complete with `args` already an object, so there is nothing to
|
|
96
|
+
* sever and the field is never set there.
|
|
97
|
+
*/
|
|
98
|
+
truncated?: { bytes: number; stopReason?: string } }
|
|
99
|
+
| { type: 'tool-result'; id: string; result: unknown }
|
|
100
|
+
| { type: 'thinking'; delta: string }
|
|
101
|
+
| { type: 'thinking-block'; thinking: string; signature: string }
|
|
102
|
+
| { type: 'redacted-thinking'; data: string }
|
|
103
|
+
| { type: 'reasoning-block'; reasoning: string }
|
|
104
|
+
| { type: 'refusal'; text: string }
|
|
105
|
+
| { type: 'unknown-block'; blockType: string; raw: unknown }
|
|
106
|
+
/**
|
|
107
|
+
* The response was cut short — the model did not choose to stop. `max-tokens` is the provider saying
|
|
108
|
+
* so outright (`stop_reason: "max_tokens"` / `finish_reason: "length"` / `finishReason: "MAX_TOKENS"`);
|
|
109
|
+
* `stream-end` is the stream ending with no finish reason at all, e.g. a dropped connection.
|
|
110
|
+
*
|
|
111
|
+
* Emitted whether or not a tool call was caught in it, because the far commoner case has no tool call
|
|
112
|
+
* in it at all: prose stopping mid-sentence, which matbot previously did not surface anywhere. The
|
|
113
|
+
* runner records it as a durable `matbot-truncation` marker — LLM-invisible, so it informs the reader
|
|
114
|
+
* and the audit without the model narrating its own cut-off. Acting on it (continuing, re-asking with
|
|
115
|
+
* a larger budget) is a `followup` hook's business, not the harness's.
|
|
116
|
+
*/
|
|
117
|
+
| { type: 'truncated'; reason: 'max-tokens' | 'stream-end'; raw?: string }
|
|
118
|
+
| ({ type: 'usage' } & Usage)
|
|
119
|
+
| { type: 'done' };
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The result the runner records for a tool call whose arguments were truncated (see the `truncated`
|
|
123
|
+
* field on the `tool-call` event). The call did not run and has no side effect.
|
|
124
|
+
*
|
|
125
|
+
* The `truncated` addendum is what makes this distinguishable from an ordinary tool failure, so a
|
|
126
|
+
* `toolresult` hook can recognise it and fold in advice the harness has no business knowing — which
|
|
127
|
+
* argument to split, which of *this* tool's parameters offers a cheaper edit. Narrow with
|
|
128
|
+
* {@link isTruncatedToolResult} rather than duck-typing the shape:
|
|
129
|
+
*
|
|
130
|
+
* services.hooks.register({ on: 'toolresult', handler: ctx => {
|
|
131
|
+
* if (!isTruncatedToolResult(ctx.result)) return;
|
|
132
|
+
* return { result: { ...ctx.result, error: `${ctx.result.error} For a large edit, pass start_line/end_line instead.` } };
|
|
133
|
+
* }});
|
|
134
|
+
*/
|
|
135
|
+
export interface TruncatedToolResult {
|
|
136
|
+
error: string;
|
|
137
|
+
truncated: { tool: string; bytes: number; stopReason?: string };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function isTruncatedToolResult(result: unknown): result is TruncatedToolResult {
|
|
141
|
+
if (typeof result !== 'object' || result === null) return false;
|
|
142
|
+
const t = (result as { truncated?: unknown }).truncated;
|
|
143
|
+
return typeof t === 'object' && t !== null
|
|
144
|
+
&& typeof (t as { tool?: unknown }).tool === 'string'
|
|
145
|
+
&& typeof (t as { bytes?: unknown }).bytes === 'number';
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export interface ProviderAdapter {
|
|
149
|
+
readonly name: string;
|
|
150
|
+
complete(
|
|
151
|
+
messages: Message[],
|
|
152
|
+
config: ProviderConfig,
|
|
153
|
+
tools: readonly Tool[],
|
|
154
|
+
signal: AbortSignal
|
|
155
|
+
): AsyncIterable<CompletionEvent>;
|
|
156
|
+
health(): Promise<HealthStatus>;
|
|
157
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import type { Hook } from './hooks.js';
|
|
2
|
+
import type { Session } from './messages.js';
|
|
3
|
+
import type { ProviderConfig } from './provider.js';
|
|
4
|
+
import type { Tool } from './tools.js';
|
|
5
|
+
|
|
6
|
+
// ── Registries ────────────────────────────────────────────────────────────────
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What `services.hooks` is: the *registration* half of hook dispatch, and the whole of what a plugin can
|
|
10
|
+
* do with it. `MatbotRuntime.hooks` was typed as the `HookRegistry` class, so the plugin contract also
|
|
11
|
+
* advertised the host's dispatch surface (`runScreen`, `runContribute`, `runToolCall`, `runToolResult`,
|
|
12
|
+
* `runFollowup`) — none of which a plugin may call, and which the scoped per-plugin facade had to be
|
|
13
|
+
* `as unknown as`-cast to satisfy. The cast was the tell. Core still holds the class where it dispatches.
|
|
14
|
+
*/
|
|
15
|
+
export interface HookRegistrar {
|
|
16
|
+
register(hook: Hook): void;
|
|
17
|
+
removeByPlugin(pluginName: string): void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** Tool CRUD is observed on the {@link Notifier}, as a `RegistryChange` with `registry: 'tools'` — this
|
|
21
|
+
* registry had its own broadcaster over the same primitive, which is duplication, not layering. One
|
|
22
|
+
* notification per tool (removeByPlugin announces a `removed` per matched tool); the registering
|
|
23
|
+
* plugin's name rides in `detail`, advisory as ever — resolve the name for anything authoritative. */
|
|
24
|
+
export interface ToolRegistry {
|
|
25
|
+
/** Registers `tool` and returns — there is nothing to await. The name is free in the overwhelming
|
|
26
|
+
* case, and that path completes in the calling tick, so `setup()` can fire-and-forget. When a
|
|
27
|
+
* *different* plugin already owns the name the host may resolve it out of band (it can ask the user),
|
|
28
|
+
* in which case the tool lands after `setup()` has returned, or not at all if the user keeps the
|
|
29
|
+
* incumbent. The host owns that outcome — a failure to resolve keeps the existing tool, and neither
|
|
30
|
+
* case can reject into the caller. Registration order across plugins is not a contract; resolve by
|
|
31
|
+
* name at call time. */
|
|
32
|
+
register(tool: Tool): void;
|
|
33
|
+
remove(name: string): void;
|
|
34
|
+
resolve(name: string): Tool | null;
|
|
35
|
+
list(): readonly Tool[];
|
|
36
|
+
removeByPlugin(pluginName: string): void;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** What the runner tells a {@link ToolPresenter} about the call it's about to make. */
|
|
40
|
+
export interface PresentContext {
|
|
41
|
+
/** The session as of this provider call — the persisted user turn plus any assistant/tool turns
|
|
42
|
+
* taken so far this turn. A presenter ranks the tool set against this. */
|
|
43
|
+
session: Session;
|
|
44
|
+
/** The provider profile name this turn runs under, so a presenter can tailor to provider
|
|
45
|
+
* capabilities (e.g. Anthropic's in-context tool_reference expansion). */
|
|
46
|
+
provider: string;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Chooses which tools are advertised to the model for a single provider call — a read-only *view* over
|
|
51
|
+
* the turn's tool snapshot, NOT tool management. Presentation is orthogonal to the {@link ToolRegistry},
|
|
52
|
+
* which stays the single source of registration, resolution, and type-contracts (its fragile, load-bearing
|
|
53
|
+
* machinery is never a swap surface). The runner consults the presenter before *every* provider call, so
|
|
54
|
+
* an impl may present a subset (deferring a large library behind a search tool) and grow it mid-turn as the
|
|
55
|
+
* model discovers tools. Executor resolution is unaffected — a tool withheld here stays callable by name
|
|
56
|
+
* through the live registry — so presentation only ever changes what the model is *told about*.
|
|
57
|
+
*
|
|
58
|
+
* Optional {@link MatbotServices} member (offer-loosely): absent ⇒ the runner advertises the whole
|
|
59
|
+
* snapshot, byte-identical to today. `present` may return synchronously or async (a reranker).
|
|
60
|
+
*/
|
|
61
|
+
export interface ToolPresenter {
|
|
62
|
+
present(tools: readonly Tool[], ctx: PresentContext): readonly Tool[] | Promise<readonly Tool[]>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The named provider profiles the runtime resolves by name. A `ReadonlyMap` for the many read-only
|
|
67
|
+
* consumers (`services.providers.get`/`has`/`keys`/…), plus a sanctioned write path so a plugin can
|
|
68
|
+
* *contribute* profiles live — a storage backend replaying provider definitions it captured in its own
|
|
69
|
+
* medium, or the built-in `provider` tool — exactly as a plugin contributes tools via {@link ToolRegistry}.
|
|
70
|
+
* Keyed by `config.name`; `register` upserts.
|
|
71
|
+
*/
|
|
72
|
+
export interface ProviderRegistry extends ReadonlyMap<string, ProviderConfig> {
|
|
73
|
+
register(config: ProviderConfig): void;
|
|
74
|
+
/** Explicit delete (the `provider` tool's `remove`) — removes the profile outright. */
|
|
75
|
+
remove(name: string): boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Undo a contribution: restore the boot-baseline profile for `name` if one existed (a plugin-supplied
|
|
78
|
+
* profile that shadowed a configured one), otherwise delete it. A plugin that registers providers
|
|
79
|
+
* (e.g. a storage backend replaying them from its medium) calls this for each in its `teardown()`, so
|
|
80
|
+
* unloading it reverts the provider set to the host's boot condition — the multi-valued analogue of a
|
|
81
|
+
* swap-member reverting to its captured boot default.
|
|
82
|
+
*/
|
|
83
|
+
revert(name: string): void;
|
|
84
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import type { PipelineEvent } from './events.js';
|
|
2
|
+
import type { MessageContent, Session } from './messages.js';
|
|
3
|
+
import type { Principal } from './principal.js';
|
|
4
|
+
import type { SteeringMode } from './steering.js';
|
|
5
|
+
import type { PromptFn } from './tools.js';
|
|
6
|
+
|
|
7
|
+
// ── Session runner ──────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A view onto a session returned by `SessionRunner.open`. `session` is the authoritative
|
|
11
|
+
* server-side state — committed messages plus an overlay of any queued-but-not-yet-run
|
|
12
|
+
* submissions (each carrying `metadata.pending: true`). `events` is a lazy, per-session live
|
|
13
|
+
* tap: accessing it subscribes to the turn event stream from now (replaying the in-flight
|
|
14
|
+
* turn, if any); never touching it costs nothing.
|
|
15
|
+
*/
|
|
16
|
+
export interface SessionView {
|
|
17
|
+
session: Session;
|
|
18
|
+
/** Submissions waiting behind the current turn (does not count the running turn). */
|
|
19
|
+
queued: number;
|
|
20
|
+
/** Correlation id of the submission this call enqueued — present only when content was supplied. */
|
|
21
|
+
traceId?: string;
|
|
22
|
+
readonly events: AsyncIterable<PipelineEvent>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Observe a session without submitting anything. */
|
|
26
|
+
export interface OpenOpts {
|
|
27
|
+
sessionId: string;
|
|
28
|
+
signal: AbortSignal;
|
|
29
|
+
/** Optional caller-supplied correlation id; one is generated when absent. */
|
|
30
|
+
traceId?: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Observe a session AND enqueue a submission. The compiler enforces provider/principal here;
|
|
34
|
+
* a remote frontend deserializing a request body must still validate the wire input itself. */
|
|
35
|
+
export interface SubmitOpenOpts extends OpenOpts {
|
|
36
|
+
content: MessageContent[];
|
|
37
|
+
provider: string;
|
|
38
|
+
principal: Principal;
|
|
39
|
+
/** When true, this submission may be merged with others drained in the same batch. Default false
|
|
40
|
+
* (queue mode: one turn per submission). */
|
|
41
|
+
concatQueue?: boolean;
|
|
42
|
+
/** Disposition for a submission arriving while a turn is running (see {@link SteeringPolicy}):
|
|
43
|
+
* 'queue' waits for the turn boundary (default), 'interrupt' stops the running turn — keeping its
|
|
44
|
+
* committed partial work — and runs this next, 'auto' defers to the registered SteeringPolicy (else
|
|
45
|
+
* the host default). Meaningless when nothing is running (degrades to a plain enqueue). */
|
|
46
|
+
mode?: SteeringMode;
|
|
47
|
+
/** Interactive prompt implementation for this submission's turn. The frontend owns delivery —
|
|
48
|
+
* it must target the frontend's per-session client connections, not a single request. */
|
|
49
|
+
prompt?: PromptFn;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Serialises turns per session. A submission never executes concurrently with another for the
|
|
54
|
+
* same session; the in-memory queue (lost on process restart, by design) absorbs anything that
|
|
55
|
+
* arrives mid-turn. The server is the source of truth: a frontend renders whatever `open()`
|
|
56
|
+
* returns and treats the live `events` stream purely as an optimisation.
|
|
57
|
+
*/
|
|
58
|
+
export interface SessionRunner {
|
|
59
|
+
open(opts: OpenOpts | SubmitOpenOpts): Promise<SessionView>;
|
|
60
|
+
/** Abort the running turn (if any) and drop all queued submissions, emitting `cancelled` for each. */
|
|
61
|
+
abort(sessionId: string): void;
|
|
62
|
+
/** Abandon the running turn (if any) WITHOUT touching the queue — `pump` advances to the next
|
|
63
|
+
* queued submission, or idles. The "give up on this turn" path (a prompt cancel); contrast
|
|
64
|
+
* `abort`, which also clears the queue. A no-op if nothing is running. */
|
|
65
|
+
cancelTurn(sessionId: string): void;
|
|
66
|
+
/** Snapshot of a session's live state: whether a turn is running and how many submissions wait
|
|
67
|
+
* behind it. `busy` is `running || queued > 0`. */
|
|
68
|
+
status(sessionId: string): { busy: boolean; running: boolean; queued: number };
|
|
69
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { MessageContent, Session } from './messages.js';
|
|
2
|
+
|
|
3
|
+
// ── Steering ────────────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
/** Disposition of a submission that arrives while a turn is running. `interrupt` stops the running
|
|
6
|
+
* turn (its committed partial work is preserved) and runs the new message next; `queue` waits for the
|
|
7
|
+
* turn boundary. */
|
|
8
|
+
export type SteeringDecision = 'queue' | 'interrupt';
|
|
9
|
+
/** The wire-level request (see `SubmitOpenOpts.mode`). `auto` defers the decision to the registered
|
|
10
|
+
* {@link SteeringPolicy} (else the host default). */
|
|
11
|
+
export type SteeringMode = SteeringDecision | 'auto';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* How the runner disposes of a mid-turn submission under `mode: 'auto'`, and how an interrupt's
|
|
15
|
+
* continuation is nudged. An optional, registerable service ({@link MatbotServices}); absent ⇒ the
|
|
16
|
+
* runner uses its own defaults. Both members are optional so a plugin may override one, the other, or
|
|
17
|
+
* both. `classify` is deliberately NOT assumed to be an LLM — a regex or a semantic classifier is a
|
|
18
|
+
* first-class implementation (return synchronously); an LLM `singleTurn` returns a promise.
|
|
19
|
+
*/
|
|
20
|
+
export interface SteeringPolicy {
|
|
21
|
+
/**
|
|
22
|
+
* Consulted only for `mode: 'auto'` while a turn is running. `session` is the COMMITTED session
|
|
23
|
+
* (history up to the running turn's start — the in-flight partial is not reachable at submit time);
|
|
24
|
+
* `steer` is the incoming submission. Return synchronously (regex) or async (semantic / singleTurn).
|
|
25
|
+
* Absent ⇒ the host default disposition.
|
|
26
|
+
*/
|
|
27
|
+
classify?(ctx: { session: Session; steer: MessageContent[] }): SteeringDecision | Promise<SteeringDecision>;
|
|
28
|
+
/**
|
|
29
|
+
* The ephemeral "keep going, noting the above" context folded onto an interrupt's continuation turn
|
|
30
|
+
* (never persisted). Absent ⇒ the host default nudge.
|
|
31
|
+
*/
|
|
32
|
+
nudge?(ctx: { session: Session; steer: MessageContent[] }): MessageContent[];
|
|
33
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { QueryResult, StoreQuery } from '../store-query.js';
|
|
2
|
+
|
|
3
|
+
// ── Storage ───────────────────────────────────────────────────────────────────
|
|
4
|
+
// The query grammar (Filter AST, StoreQuery, QueryResult, StoreQueryError) lives in ./store-query.
|
|
5
|
+
|
|
6
|
+
export type CASResult<T> =
|
|
7
|
+
| { ok: true; doc: T }
|
|
8
|
+
| { ok: false; current: T | null };
|
|
9
|
+
|
|
10
|
+
export interface Store<T extends { id: string; version: string }> {
|
|
11
|
+
get(id: string): Promise<T | null>;
|
|
12
|
+
set(id: string, value: T): Promise<void>;
|
|
13
|
+
cas(id: string, expected: string, next: T): Promise<CASResult<T>>;
|
|
14
|
+
delete(id: string, expectedVersion?: string): Promise<boolean>;
|
|
15
|
+
query(q: StoreQuery): Promise<QueryResult<T>>;
|
|
16
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Session } from './messages.js';
|
|
2
|
+
|
|
3
|
+
// ── System context ────────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
export type SystemContextContributor = (ctx: {
|
|
6
|
+
session: Session;
|
|
7
|
+
signal: AbortSignal;
|
|
8
|
+
}) => string | null | Promise<string | null>;
|
|
9
|
+
|
|
10
|
+
export interface SystemContextRegistry {
|
|
11
|
+
register(contributor: SystemContextContributor, pluginName?: string): void;
|
|
12
|
+
removeByPlugin(pluginName: string): void;
|
|
13
|
+
/** Calls all contributors and joins non-null, non-empty results with double newlines. */
|
|
14
|
+
build(ctx: { session: Session; signal: AbortSignal }): Promise<string | null>;
|
|
15
|
+
}
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import type { FileHandle, FileStore } from './files.js';
|
|
2
|
+
import type { FormField, MessageContent, Session } from './messages.js';
|
|
3
|
+
import type { MatbotPlugin } from '../plugin.js';
|
|
4
|
+
import type { JSONSchema } from './primitives.js';
|
|
5
|
+
import type { Vault } from './vault.js';
|
|
6
|
+
|
|
7
|
+
// ── Tools ─────────────────────────────────────────────────────────────────────
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Media a tool hands to the model to *look at*, as opposed to the JSON it reads as a `result`. The
|
|
11
|
+
* inline arms of {@link MessageContent} — bytes plus a mime type, carrying their own meaning with no
|
|
12
|
+
* dependency on anything that could resolve a reference. Where the tool got the bytes (a `FileStore`,
|
|
13
|
+
* an HTTP fetch, a chart rendered in memory) is entirely the tool's business; the harness never asks.
|
|
14
|
+
*/
|
|
15
|
+
export type ModelContent = Extract<MessageContent, { type: 'image' | 'document' | 'audio' }>;
|
|
16
|
+
|
|
17
|
+
export type ToolEvent<Result = unknown> =
|
|
18
|
+
| { type: 'stdout'; chunk: string }
|
|
19
|
+
| { type: 'stderr'; chunk: string }
|
|
20
|
+
| { type: 'progress'; pct: number; message?: string }
|
|
21
|
+
| { type: 'result'; value: Result }
|
|
22
|
+
| { type: 'file'; handle: FileHandle }
|
|
23
|
+
// Media for the model's eyes, pinned to this tool call's result and carried on the wire for the
|
|
24
|
+
// REST OF THIS TURN only — never persisted. Independent of `result`: the durable record of what the
|
|
25
|
+
// tool did is its `result`, and a transcript that re-reads clean is the point. A later turn that
|
|
26
|
+
// needs the bytes again calls the tool again, which is the same pull the model already performed.
|
|
27
|
+
//
|
|
28
|
+
// Rest-of-turn rather than next-call-only because withdrawing content the model has already seen
|
|
29
|
+
// mid-history breaks the prompt cache from that point and leaves it referring to something no longer
|
|
30
|
+
// there. The corollary is cost: a large document is re-sent on every subsequent round of the turn,
|
|
31
|
+
// so a tool should hand over the smallest thing that answers the question (a page range, a thumbnail)
|
|
32
|
+
// and `maxRounds` is the ceiling on how often it is paid for.
|
|
33
|
+
| { type: 'model-content'; content: ModelContent[] }
|
|
34
|
+
// A durable, LLM-invisible annotation the tool emits as it runs (a link, a status, a trace of a
|
|
35
|
+
// side-effect). Persisted as a `marker`-role message; elided from provider submission like any
|
|
36
|
+
// marker. Independent of `result` — a tool may emit markers and no result (a silent side-effect,
|
|
37
|
+
// e.g. a trigger-fired tool), a result and no markers, or both.
|
|
38
|
+
| { type: 'marker'; creator: string; data: unknown }
|
|
39
|
+
| { type: 'error'; message: string; code?: number; stdout?: string; stderr?: string };
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Per-tool call-contract registry: maps a tool's `name` to a {@link ToolContract} arm (or a union of
|
|
43
|
+
* them) pairing the `value` it yields in its `result` event with the params that produce it. Augment it
|
|
44
|
+
* exactly like {@link MarkerData} / `MatbotServices`, so a caller gets the concrete result type back
|
|
45
|
+
* from {@link invokeTool} + `toolResult` instead of `unknown`:
|
|
46
|
+
*
|
|
47
|
+
* declare module '@matatbread/matbot-plugin-api' {
|
|
48
|
+
* interface ToolContracts {
|
|
49
|
+
* find_fact: ToolContract<string[] | null, { question: string; terms: { term: string }[] }>;
|
|
50
|
+
* }
|
|
51
|
+
* }
|
|
52
|
+
*
|
|
53
|
+
* The tool name is the discriminator: `invokeTool(machine, 'find_fact', …)` is typed
|
|
54
|
+
* `AsyncIterable<ToolEvent<string[] | null>>`, so `toolResult(…)` resolves to `string[] | null`. A
|
|
55
|
+
* name with no registered entry resolves to `unknown` (the caller must narrow). This is a pure
|
|
56
|
+
* type-level construct — no runtime validation; it pins, at the call site, the contract a tool's
|
|
57
|
+
* executor already produces. Keep the entry in sync with what the executor actually yields.
|
|
58
|
+
*
|
|
59
|
+
* Even a single-action tool declares an **arm**, never a bare `name: Result`. The bare form still
|
|
60
|
+
* type-checks — `ToolResultOf`/`ToolResultFor` keep a plain-entry path so an unregistered or foreign
|
|
61
|
+
* tool stays loose — but it carries no params, so {@link ToolProxy} derives no call signature from it
|
|
62
|
+
* and `await tool.find_fact(…)` is uncallable.
|
|
63
|
+
*
|
|
64
|
+
* **Multi-action tools** are a weird form of overloaded function: the same tool returns different
|
|
65
|
+
* shapes depending on its params. Register such a tool as a union of {@link ToolContract} *arms*, each
|
|
66
|
+
* pairing a result with the discriminating params *pattern* that selects it — `invokeTool` matches the
|
|
67
|
+
* call's literal params against the patterns and narrows the result to the matching arm:
|
|
68
|
+
*
|
|
69
|
+
* declare module '@matatbread/matbot-plugin-api' {
|
|
70
|
+
* interface ToolContracts {
|
|
71
|
+
* session_action:
|
|
72
|
+
* | ToolContract<Session, { action: 'get' }>
|
|
73
|
+
* | ToolContract<{ id: string; title: string }, { action: 'rename' }>;
|
|
74
|
+
* }
|
|
75
|
+
* }
|
|
76
|
+
* // invokeTool(machine, 'session_action', { action: 'get', sessionId }) → result is Session
|
|
77
|
+
*
|
|
78
|
+
* The pattern is *any* discriminating field(s), not just `action` (a tool keying on `interval`'s
|
|
79
|
+
* presence registers `ToolContract<…, { interval: string }>`). It is a *pattern*, not the full input:
|
|
80
|
+
* key only on the discriminant so a call carrying just that field still matches. When no arm's pattern
|
|
81
|
+
* matches (a non-literal discriminant, or an absence-discriminant the positive patterns can't express),
|
|
82
|
+
* the result falls back to the union of every arm — always sound, just less narrow.
|
|
83
|
+
*
|
|
84
|
+
* One of matbot's five open-registry augmentation points — same technique at each; see
|
|
85
|
+
* docs/DEVELOPING.md *Open-registry augmentation* for the shared shape and the rules that follow from it.
|
|
86
|
+
*/
|
|
87
|
+
export interface ToolContracts {}
|
|
88
|
+
|
|
89
|
+
// Phantom carriers for ToolContract's two parameters. `unique symbol` keys rather than `__result`/`__args`
|
|
90
|
+
// so the type is *uninhabitable*: a plugin author cannot name these keys, so no value of ToolContract can
|
|
91
|
+
// be written down by accident (or on purpose, to smuggle a shape past the checker) — where the previous
|
|
92
|
+
// `__`-prefixed properties were merely conventionally-private and constructible with a couple of casts.
|
|
93
|
+
// A cast is the one hole through which a hallucinated result shape survives to runtime, which is why the
|
|
94
|
+
// generated-code checker closes it deterministically; this closes the same hole in the type itself.
|
|
95
|
+
declare const RESULT: unique symbol;
|
|
96
|
+
declare const ARGS: unique symbol;
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* One overload arm of a multi-action tool registered in {@link ToolContracts}: the `Result` it yields
|
|
100
|
+
* for a call whose params match the discriminating pattern `Args`. `Args` defaults to `unknown` (an
|
|
101
|
+
* arm that matches any params). Purely type-level — the fields below are phantom and uninstantiable.
|
|
102
|
+
*/
|
|
103
|
+
export interface ToolContract<Result, Args = unknown> {
|
|
104
|
+
readonly [RESULT]: Result;
|
|
105
|
+
readonly [ARGS]: Args;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
type ToolResultArmed<E> = E extends ToolContract<unknown, unknown> ? true : false;
|
|
109
|
+
type ToolResultUnion<E> = E extends ToolContract<infer R, unknown> ? R : never;
|
|
110
|
+
type ToolResultMatched<E, P> = E extends ToolContract<infer R, infer A> ? (P extends A ? R : never) : never;
|
|
111
|
+
|
|
112
|
+
/** The full set of `result` values a tool named `K` may yield — the union over all its arms (for an
|
|
113
|
+
* arm-based entry), the entry itself (for a plain entry), or `unknown` when `K` is unregistered. This
|
|
114
|
+
* is what an executor binds against (`ToolExecutor<ToolResultOf<'my_tool'>>`): it must cover every arm. */
|
|
115
|
+
export type ToolResultOf<K extends string> =
|
|
116
|
+
K extends keyof ToolContracts
|
|
117
|
+
? ToolResultArmed<ToolContracts[K]> extends true ? ToolResultUnion<ToolContracts[K]> : ToolContracts[K]
|
|
118
|
+
: unknown;
|
|
119
|
+
|
|
120
|
+
/** The `result` a call to tool `K` with params `P` yields: for an arm-based entry, the arm whose
|
|
121
|
+
* pattern `P` matches (or the full union when none match — always sound); otherwise {@link ToolResultOf}. */
|
|
122
|
+
export type ToolResultFor<K extends string, P> =
|
|
123
|
+
K extends keyof ToolContracts
|
|
124
|
+
? ToolResultArmed<ToolContracts[K]> extends true
|
|
125
|
+
? ([ToolResultMatched<ToolContracts[K], P>] extends [never]
|
|
126
|
+
? ToolResultUnion<ToolContracts[K]>
|
|
127
|
+
: ToolResultMatched<ToolContracts[K], P>)
|
|
128
|
+
: ToolContracts[K]
|
|
129
|
+
: unknown;
|
|
130
|
+
|
|
131
|
+
// Distribute a union to its intersection: `A | B` → `A & B`. An intersection of function types is an
|
|
132
|
+
// overload set, so this turns a union of per-arm call signatures into an overloaded function.
|
|
133
|
+
type UnionToIntersection<U> =
|
|
134
|
+
(U extends unknown ? (k: U) => void : never) extends (k: infer I) => void ? I : never;
|
|
135
|
+
|
|
136
|
+
// One `ToolContracts` arm → its call signature. A fully-untyped arm (`ToolContract<unknown, unknown>`, the
|
|
137
|
+
// fallback for a foreign/MCP tool carrying only a JSON-Schema `inputSchema`) keeps a loose, optional-param
|
|
138
|
+
// signature so it stays callable; a typed arm requires its declared params.
|
|
139
|
+
type ArmCallSig<E> =
|
|
140
|
+
E extends ToolContract<infer R, infer P>
|
|
141
|
+
? (unknown extends P ? (params?: unknown) => Promise<R> : (params: P) => Promise<R>)
|
|
142
|
+
: never;
|
|
143
|
+
|
|
144
|
+
// The union of a tool's arm params — the parameter type of the trailing catch-all signature below.
|
|
145
|
+
type ArmArgsUnion<E> = E extends ToolContract<unknown, infer A> ? A : never;
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The type of the injected `tool` proxy that `function-tools` and compiled skills call
|
|
149
|
+
* (`await tool.name(params)`). Each key is derived from its {@link ToolContracts} entry: a multi-arm
|
|
150
|
+
* (multi-action) entry becomes an **overload set** — one `(params) => Promise<result>` signature per arm —
|
|
151
|
+
* so `await tool.name(params)` narrows its result by the params passed, exactly the contract a code
|
|
152
|
+
* generator needs to write a correct call first time. A single-arm entry is one signature. This is what the
|
|
153
|
+
* generated `dts()` declares `tool` as; it is *derived* — never hand-authored — from the same `ToolContracts`
|
|
154
|
+
* augmentation that types authors' `invokeTool`/`toolResult` calls, so the human editor and the LLM see one
|
|
155
|
+
* source of truth.
|
|
156
|
+
*
|
|
157
|
+
* The trailing catch-all signature (params union → result union) exists for soundness at the *meta-type*
|
|
158
|
+
* level, not for call sites: overload resolution is first-match, so a well-formed call still hits its
|
|
159
|
+
* specific arm and narrows precisely, and a malformed call matches nothing and still errors. What it
|
|
160
|
+
* changes: `ReturnType<typeof tool.x>` — which on a bare overload set resolves to an arbitrary (last) arm,
|
|
161
|
+
* a real-world codegen trap — now degrades to the sound full union; and a params value typed as a union of
|
|
162
|
+
* arms (dynamic multi-action dispatch) becomes callable, returning the result union.
|
|
163
|
+
*/
|
|
164
|
+
export type ToolProxy = { [K in keyof ToolContracts]:
|
|
165
|
+
UnionToIntersection<ArmCallSig<ToolContracts[K]>>
|
|
166
|
+
& ((params: ArmArgsUnion<ToolContracts[K]>) => Promise<ToolResultUnion<ToolContracts[K]>>) };
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Ask the user a question and resolve with their answer. The host supplies the
|
|
170
|
+
* implementation — readline in the CLI, an SSE round-trip in the web frontend. This is
|
|
171
|
+
* matbot's single mechanism for eliciting user input at runtime; it is injected both into
|
|
172
|
+
* tool execution (`ToolContext.prompt`) and into plugin loading (collision resolution in setup).
|
|
173
|
+
*
|
|
174
|
+
* Two call forms:
|
|
175
|
+
* - `(question, defaultValue?)` — free text; resolves to the typed string (or the default).
|
|
176
|
+
* - `(field: FormField)` — a single structured field (`select`/`confirm`/`password`/`text`),
|
|
177
|
+
* letting rich frontends render real controls (buttons, masked input). Frontends that can't
|
|
178
|
+
* render it fall back to `field.label` as plain text. Resolves to the chosen/typed value.
|
|
179
|
+
*
|
|
180
|
+
* Cancellation is the "we can't proceed — give up" path (distinct from a graceful "decline", which
|
|
181
|
+
* is simply one of the offered `options`). The host rejects the promise with `PromptCancelledError`
|
|
182
|
+
* and abandons the current turn, returning to idle; a caller's surrounding try/catch turns the
|
|
183
|
+
* rejection into a tool error that closes the tool call cleanly.
|
|
184
|
+
* This reuses `FormField` rather than a parallel type; it is a one-shot request/response and
|
|
185
|
+
* deliberately does NOT engage the session-bound `form`/`form-response` flow.
|
|
186
|
+
*/
|
|
187
|
+
export interface PromptFn {
|
|
188
|
+
(question: string, defaultValue?: string): Promise<string>;
|
|
189
|
+
(field: FormField): Promise<string>;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface ToolContext {
|
|
193
|
+
callId: string;
|
|
194
|
+
session: Session;
|
|
195
|
+
signal: AbortSignal;
|
|
196
|
+
vault: Vault;
|
|
197
|
+
/** The provider key driving the current turn (`RunConfig.provider`). A tool that spawns further
|
|
198
|
+
* work should default to this so the child inherits the same model rather than the config default. */
|
|
199
|
+
provider?: string;
|
|
200
|
+
workdir?: string;
|
|
201
|
+
configPath?: string;
|
|
202
|
+
files?: FileStore;
|
|
203
|
+
/** Prompt the user for input. The host provides a readline or form implementation. */
|
|
204
|
+
prompt: PromptFn;
|
|
205
|
+
/** Hot-load a plugin by specifier without restarting the process. Returns the loaded plugin.
|
|
206
|
+
* `refresh` (default false) forces a remote (github/http) plugin to be re-downloaded rather than
|
|
207
|
+
* re-loaded from its `.plugins/` cache — pass true to pick up changed upstream source. */
|
|
208
|
+
loadPlugin(specifier: string, refresh?: boolean): Promise<MatbotPlugin>;
|
|
209
|
+
/**
|
|
210
|
+
* Hot-unload a plugin by specifier, removing its tools, hooks, and system context contributions.
|
|
211
|
+
* Resolves `true` if a plugin was actually resident and unloaded, `false` if there was nothing
|
|
212
|
+
* to unload. A failed `teardown()` (e.g. timeout) still throws — the plugin was resident in that case.
|
|
213
|
+
*/
|
|
214
|
+
unloadPlugin(specifier: string): Promise<boolean>;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* The read-only facts about the call a composed function is running under, injected into a
|
|
219
|
+
* `tool_function` body as the ambient `context` binding beside `tool`/`toolInContext`. Deliberately
|
|
220
|
+
* narrower than {@link ToolContext}: the identity of the call, not its capabilities — `vault`,
|
|
221
|
+
* `files`, `prompt` and plugin (un)loading stay off a surface authored by a model and reachable only
|
|
222
|
+
* through the tool contracts. A tool with real source takes `ToolContext` and needs none of this.
|
|
223
|
+
*
|
|
224
|
+
* Not `ToolCallContext` — that name belongs to the `toolcall` hook's context; this is the composed
|
|
225
|
+
* caller's own view, not a hook's view of a call about to run.
|
|
226
|
+
*/
|
|
227
|
+
export interface ComposedCallContext {
|
|
228
|
+
readonly callId: string;
|
|
229
|
+
readonly sessionId: string;
|
|
230
|
+
/** The provider key driving the turn — the same one nested `tool.x(…)` calls inherit. */
|
|
231
|
+
readonly provider?: string;
|
|
232
|
+
readonly workdir?: string;
|
|
233
|
+
readonly signal: AbortSignal;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* A tool's runtime. `R` is the type of the `value` carried by its `result` event — declared once,
|
|
238
|
+
* at the source, so the executor's yields and the tool's {@link ToolContracts} registry entry can't
|
|
239
|
+
* silently drift. The `unknown` default keeps untyped executors compiling untouched, and covariance
|
|
240
|
+
* means a narrower `ToolExecutor<X>` still satisfies `ToolExecutor` (i.e. `ToolExecutor<unknown>`),
|
|
241
|
+
* so the heterogeneous registry boundary (`Tool[]`) accepts any executor. Bind `R` to the registry
|
|
242
|
+
* entry with `ToolExecutor<ToolResultOf<'my_tool'>>` so the single augmentation is the source of truth.
|
|
243
|
+
*/
|
|
244
|
+
export interface ToolExecutor<R = unknown> {
|
|
245
|
+
execute(input: unknown, ctx: ToolContext): AsyncIterable<ToolEvent<R>>;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
export interface Tool<R = unknown> {
|
|
249
|
+
name: string;
|
|
250
|
+
description: string;
|
|
251
|
+
inputSchema: JSONSchema;
|
|
252
|
+
/** The call contract as TypeScript source — a {@link ToolContract}`<Result, Params>` (or a union of arms
|
|
253
|
+
* for a multi-action tool), *identical in shape* to what a `ToolContracts` augmentation declares. Only a
|
|
254
|
+
* tool with **no scannable source** carries this — a `function-tools` function (whose signature supplies
|
|
255
|
+
* both halves), and any similarly runtime-defined tool: the host splices it verbatim into the generated
|
|
256
|
+
* dts's `ToolContracts`, and derives the wire `params`/`result` text from it. A tool WITH source declares
|
|
257
|
+
* its contract as a `ToolContracts` augmentation instead and omits this. Foreign tools (e.g. MCP proxies)
|
|
258
|
+
* that have neither fall back to the loose `inputSchema`. */
|
|
259
|
+
toolContract?: string;
|
|
260
|
+
executor: ToolExecutor<R>;
|
|
261
|
+
pluginName?: string;
|
|
262
|
+
}
|