@matatbread/matbot-core 0.4.7 → 0.4.8
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 +2 -2
- package/src/about.ts +36 -12
- package/src/index.ts +3 -1
- package/src/media.ts +266 -0
- package/src/registry.ts +1 -0
- package/src/runner.ts +25 -4
- package/src/session-runner.ts +42 -6
- package/src/single-turn.ts +71 -5
- package/src/system-context.ts +18 -4
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.4.
|
|
7
|
+
"version": "0.4.8",
|
|
8
8
|
"engines": {
|
|
9
9
|
"node": ">=24.0.0"
|
|
10
10
|
},
|
|
@@ -35,7 +35,7 @@
|
|
|
35
35
|
"access": "public"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@matatbread/matbot-plugin-api": "^0.4.
|
|
38
|
+
"@matatbread/matbot-plugin-api": "^0.4.8"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^6.0.3"
|
package/src/about.ts
CHANGED
|
@@ -1,24 +1,45 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
Tool, ToolExecutor, ToolContract, ToolResultOf, ToolContext, MatbotMachine, SystemContextPart,
|
|
3
|
+
} from '@matatbread/matbot-plugin-api';
|
|
2
4
|
|
|
3
5
|
declare module '@matatbread/matbot-plugin-api' {
|
|
4
6
|
interface ToolContracts {
|
|
5
|
-
about_matbot: ToolContract<{
|
|
7
|
+
about_matbot: ToolContract<{
|
|
8
|
+
version: string;
|
|
9
|
+
about: string;
|
|
10
|
+
currentProvider: string | undefined;
|
|
11
|
+
/** The system prompt in force, exactly as the model receives it — the joined `systemContext`
|
|
12
|
+
* texts. `null` when no contributor produced anything (there is then no system message at all). */
|
|
13
|
+
systemPrompt: string | null;
|
|
14
|
+
/** The same text kept apart and attributed, so a contribution can be traced to its plugin. */
|
|
15
|
+
systemContext: SystemContextPart[];
|
|
16
|
+
}, Record<string, never>>;
|
|
6
17
|
}
|
|
7
18
|
}
|
|
8
19
|
|
|
9
20
|
const ABOUT = 'Matbot composable LLM harness';
|
|
10
21
|
|
|
11
22
|
/**
|
|
12
|
-
* Reports what the model is running: the harness `version
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* own app package version.
|
|
23
|
+
* Reports what the model is running: the harness `version`, a one-line description, the provider
|
|
24
|
+
* driving this turn, and the system prompt in force. The harness is not a plugin (so it has no row in
|
|
25
|
+
* `plugin list`), and its version is a singleton fact rather than a per-plugin one — hence a dedicated
|
|
26
|
+
* tool. The factory lives in core (like `single_turn`) because it is the one cross-runtime home both the
|
|
27
|
+
* node app and the browser bundle register it from, each passing its own app package version.
|
|
28
|
+
*
|
|
29
|
+
* The system prompt is REBUILT here rather than read back: it is assembled once per submit and never
|
|
30
|
+
* persisted (see `runner`'s `system-context` event), so there is nothing on the session to read. Rebuilt
|
|
31
|
+
* against this turn's session it is the same text the turn was given, unless a contributor's own source
|
|
32
|
+
* changed mid-turn — a skill added, a plugin loaded — in which case it correctly reports what the NEXT
|
|
33
|
+
* call will carry.
|
|
17
34
|
*/
|
|
18
|
-
export function createAboutMatbotTool(version: string): Tool<ToolResultOf<'about_matbot'>> {
|
|
35
|
+
export function createAboutMatbotTool(version: string, services: MatbotMachine): Tool<ToolResultOf<'about_matbot'>> {
|
|
19
36
|
const executor: ToolExecutor<ToolResultOf<'about_matbot'>> = {
|
|
20
37
|
async *execute(_input: unknown, ctx: ToolContext) {
|
|
21
|
-
|
|
38
|
+
const systemContext = await services.systemContext.parts({ session: ctx.session, signal: ctx.signal });
|
|
39
|
+
// Joined exactly as the registry joins it for the wire — one source, so the reported prompt and the
|
|
40
|
+
// sent prompt cannot differ in their separators.
|
|
41
|
+
const systemPrompt = systemContext.length > 0 ? systemContext.map(p => p.text).join('\n\n') : null;
|
|
42
|
+
yield { type: 'result', value: { version, about: ABOUT, currentProvider: ctx.provider, systemPrompt, systemContext } };
|
|
22
43
|
},
|
|
23
44
|
};
|
|
24
45
|
|
|
@@ -26,9 +47,12 @@ export function createAboutMatbotTool(version: string): Tool<ToolResultOf<'about
|
|
|
26
47
|
name: 'about_matbot',
|
|
27
48
|
description:
|
|
28
49
|
'Report what you are running right now: the current model / LLM and provider profile powering THIS ' +
|
|
29
|
-
'conversation,
|
|
30
|
-
'
|
|
31
|
-
'
|
|
50
|
+
'conversation, the matbot harness version and a one-line description, and the SYSTEM PROMPT in force ' +
|
|
51
|
+
'for this conversation — both as one string (`systemPrompt`, exactly what you were given) and broken ' +
|
|
52
|
+
'down per contributing plugin (`systemContext`), which is how to answer "why are you being told that?" ' +
|
|
53
|
+
'or "what is in your system prompt?". Use it whenever asked "what model / LLM are you using?", "which ' +
|
|
54
|
+
'provider is this?", what version of matbot this is, what your instructions or system prompt are, or ' +
|
|
55
|
+
'for an "about" of the harness itself — distinct from the `provider` tool (which lists all configured ' +
|
|
32
56
|
'provider profiles) and the `plugin` tool (which lists loaded plugins).',
|
|
33
57
|
inputSchema: { type: 'object', properties: {} },
|
|
34
58
|
executor,
|
package/src/index.ts
CHANGED
|
@@ -10,11 +10,12 @@ export {
|
|
|
10
10
|
notAPluginError, isNotAPluginError,
|
|
11
11
|
promptCancelledError, isPromptCancelledError,
|
|
12
12
|
readOnlyError, isReadOnlyError,
|
|
13
|
+
mediaRejectedError, isMediaRejectedError,
|
|
13
14
|
StoreQueryError,
|
|
14
15
|
} from '@matatbread/matbot-plugin-api';
|
|
15
16
|
export type {
|
|
16
17
|
MissingSecretError, IncompatibleRuntimeError, NotAPluginError, PromptCancelledError, ReadOnlyError,
|
|
17
|
-
MatbotErrorKind,
|
|
18
|
+
MediaRejectedError, MatbotErrorKind,
|
|
18
19
|
} from '@matatbread/matbot-plugin-api';
|
|
19
20
|
export { applyCreateSecret } from '@matatbread/matbot-plugin-api';
|
|
20
21
|
export { isTruncatedToolResult, notifyingStore, ItemChangeKind, RegistryChangeKind } from '@matatbread/matbot-plugin-api';
|
|
@@ -49,6 +50,7 @@ export * from './tool-registry.js';
|
|
|
49
50
|
export * from './provider-registry.js';
|
|
50
51
|
export * from './system-context.js';
|
|
51
52
|
export * from './single-turn.js';
|
|
53
|
+
export * from './media.js';
|
|
52
54
|
export * from './about.js';
|
|
53
55
|
export * from './config/index.js';
|
|
54
56
|
export * from './security/index.js';
|
package/src/media.ts
ADDED
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import type { MediaStore, Message, MessageContent, MimeType, UserContent } from './types.js';
|
|
2
|
+
import { collectBytes, decodeBase64, encodeBase64, mediaRejectedError } from '@matatbread/matbot-plugin-api';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* User-supplied session media: the two ends of the one path. Bytes arrive by value at the submission
|
|
6
|
+
* boundary, are written through the `MediaStore` and replaced by a `file-ref` before anything is
|
|
7
|
+
* enqueued ({@link ingestMedia}); the runner resolves those refs back to inline bytes on the *outgoing
|
|
8
|
+
* copy only*, newest-first and inside a byte budget ({@link resolveSessionMedia}).
|
|
9
|
+
*
|
|
10
|
+
* Contrast the tool-media (`model-content`) path, which is wire-only in both directions and never had a
|
|
11
|
+
* store: there the model pulls, here a person pushed. What the two share is the invariant — no base64
|
|
12
|
+
* ever reaches a session document.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** Namespace session media is written under, so it is distinguishable from workspace files sharing a store. */
|
|
16
|
+
export const MEDIA_NAMESPACE = 'session-media';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* How many bytes of media the outgoing copy may carry. Walked newest-first, so the freshest attachment
|
|
20
|
+
* always makes it and an old one falls out; beyond the budget the `file-ref` is left alone and the
|
|
21
|
+
* converters degrade it to `[Attached file: x]` — honest, already written, and the file is still there.
|
|
22
|
+
*
|
|
23
|
+
* A byte budget rather than a turn count because the cost being bounded is denominated in bytes: three
|
|
24
|
+
* turns is a fine window for a thumbnail and a ruinous one for a 40MB PDF, and a count cannot tell them
|
|
25
|
+
* apart. One knob; measure before adding a second.
|
|
26
|
+
*/
|
|
27
|
+
export const MEDIA_RESIDENCY_BYTES = 8 * 1024 * 1024;
|
|
28
|
+
|
|
29
|
+
/** Per-file ceiling at the submission boundary. Refusing here — naming the file — is the point: the
|
|
30
|
+
* alternative is a provider 400 part-way through a turn the user already believes was sent.
|
|
31
|
+
*
|
|
32
|
+
* It IS the residency budget rather than a second, larger number, because a file bigger than the
|
|
33
|
+
* window can never be resident: `spent + size > budget` is true on the very first item with
|
|
34
|
+
* `spent === 0`, so such a file would be stored, charged to the session quota, and then degraded to
|
|
35
|
+
* `[Attached file: x]` on every turn for the rest of the session — accepted, permanently invisible,
|
|
36
|
+
* and with no boundary having said a word. Deriving it makes that state unrepresentable rather than a
|
|
37
|
+
* second check to remember; raise the window and the cap follows. */
|
|
38
|
+
export const MAX_MEDIA_BYTES_PER_FILE = MEDIA_RESIDENCY_BYTES;
|
|
39
|
+
|
|
40
|
+
/** Per-session total, derived by summing what the store already holds for the session rather than kept
|
|
41
|
+
* as a counter: a counter is wrong after a restart, a swap, or anything deleting a file behind it. */
|
|
42
|
+
export const MAX_MEDIA_BYTES_PER_SESSION = 50 * 1024 * 1024;
|
|
43
|
+
|
|
44
|
+
const INLINE_ARMS = new Set(['image', 'document', 'audio']);
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Image types a vision endpoint actually decodes.
|
|
48
|
+
*
|
|
49
|
+
* `image/*` is the one prefix that cannot be admitted by prefix alone: a provider TRIES to decode
|
|
50
|
+
* whatever the image arm carries and 400s on what it cannot, where an unrecognised document or audio
|
|
51
|
+
* type degrades to a text note instead. So HEIC (what an iPhone camera roll hands back, and what a
|
|
52
|
+
* `<input type="file">` with no `accept` will happily offer), SVG (XML — `read` gives the source, which
|
|
53
|
+
* is the better answer anyway), BMP and TIFF have to be refused at the boundary rather than routed to
|
|
54
|
+
* an arm that will fail: by the time the 400 arrives the `file-ref` is in persisted history, where it
|
|
55
|
+
* resolves into every subsequent outgoing copy and fails the session for good.
|
|
56
|
+
*
|
|
57
|
+
* Same list and same reasoning as `workspace`'s `showArm`. The two stay separate because they answer
|
|
58
|
+
* for separate stores; if a third ever needs it, this is the one to hoist.
|
|
59
|
+
*/
|
|
60
|
+
const DECODABLE_IMAGE: ReadonlySet<string> = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp']);
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Which inline arm a stored file comes back as, or null for a type that must not be sent at all. What
|
|
64
|
+
* each adapter then does with an arm differs: google inlines all three; anthropic inlines images and
|
|
65
|
+
* PDF/text documents and degrades the rest to a note; openai-compat inlines images only. A degraded arm
|
|
66
|
+
* becomes a text note, never nothing — which is precisely why only `image/*` needs gating.
|
|
67
|
+
*
|
|
68
|
+
* Exported because `single_turn` resolves attachments through the same mapping: a mime-routing rule
|
|
69
|
+
* with two copies is one that gets fixed in one of them.
|
|
70
|
+
*/
|
|
71
|
+
export function armFor(mimeType: string): 'image' | 'audio' | 'document' | null {
|
|
72
|
+
const base = mimeType.split(';')[0]!.trim().toLowerCase();
|
|
73
|
+
if (base.startsWith('image/')) return DECODABLE_IMAGE.has(base) ? 'image' : null;
|
|
74
|
+
if (base.startsWith('audio/')) return 'audio';
|
|
75
|
+
return 'document';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Leading bytes that unambiguously identify a format. Only the types where a mismatch is a GUARANTEED
|
|
80
|
+
* provider rejection — a model endpoint decodes these itself and 400s on anything it cannot parse.
|
|
81
|
+
* Nothing else is sniffed: for `text/*`, audio, or an octet-stream we genuinely do not know, and
|
|
82
|
+
* guessing would refuse valid files.
|
|
83
|
+
*/
|
|
84
|
+
const MAGIC: ReadonlyArray<{ mime: string; at: number; sig: readonly number[] }> = [
|
|
85
|
+
{ mime: 'image/png', at: 0, sig: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a] },
|
|
86
|
+
{ mime: 'image/jpeg', at: 0, sig: [0xff, 0xd8, 0xff] },
|
|
87
|
+
{ mime: 'image/gif', at: 0, sig: [0x47, 0x49, 0x46, 0x38] }, // "GIF8"
|
|
88
|
+
{ mime: 'image/webp', at: 0, sig: [0x52, 0x49, 0x46, 0x46] }, // "RIFF" (…WEBP at 8)
|
|
89
|
+
{ mime: 'image/webp', at: 8, sig: [0x57, 0x45, 0x42, 0x50] }, // "WEBP"
|
|
90
|
+
{ mime: 'application/pdf', at: 0, sig: [0x25, 0x50, 0x44, 0x46] }, // "%PDF"
|
|
91
|
+
];
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Does this file's content match the type it claims? Returns false only when we KNOW it does not.
|
|
95
|
+
*
|
|
96
|
+
* This is the boundary doing the one check it can: a provider decodes these formats itself and 400s
|
|
97
|
+
* on anything it cannot parse, and a 400 arrives mid-turn — after the message is persisted, with the
|
|
98
|
+
* `file-ref` now in history where it resolves into EVERY subsequent outgoing copy and fails the
|
|
99
|
+
* session for good. A renamed file, a truncated upload or a placeholder is caught here instead, while
|
|
100
|
+
* refusing still costs the user nothing but re-picking the file.
|
|
101
|
+
*
|
|
102
|
+
* It is not a validity check: a well-formed header on a structurally broken document still passes,
|
|
103
|
+
* because only the provider can know that.
|
|
104
|
+
*/
|
|
105
|
+
function contentMatchesType(bytes: Uint8Array, mimeType: string): boolean {
|
|
106
|
+
const base = mimeType.split(';')[0]!.trim().toLowerCase();
|
|
107
|
+
const rules = MAGIC.filter(m => m.mime === base);
|
|
108
|
+
if (rules.length === 0) return true; // nothing known about this type — don't guess
|
|
109
|
+
return rules.every(r =>
|
|
110
|
+
bytes.length >= r.at + r.sig.length && r.sig.every((b, i) => bytes[r.at + i] === b));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function describe(name: string | undefined, mimeType: string): string {
|
|
114
|
+
return name ?? `attachment.${mimeType.split('/')[1] ?? 'bin'}`;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function humanBytes(n: number): string {
|
|
118
|
+
if (n < 1024) return `${n} B`;
|
|
119
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
|
120
|
+
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The submission boundary, in one place so every frontend inherits it: rewrite each by-value media arm
|
|
125
|
+
* to a `file-ref` against the `MediaStore`, leave text untouched, and VET each ref a caller supplied
|
|
126
|
+
* itself.
|
|
127
|
+
*
|
|
128
|
+
* A text-only submission never touches the store, so a deployment with no `MediaStore` registered is
|
|
129
|
+
* completely unaffected — it only hears about one when someone actually attaches something, and then it
|
|
130
|
+
* is told why rather than silently dropping it.
|
|
131
|
+
*
|
|
132
|
+
* Vetting the refs belongs here and nowhere else. A submission crosses a wire boundary, so a `fileId`
|
|
133
|
+
* is the client's word about which bytes to send, and `resolveSessionMedia` inlines whatever it is
|
|
134
|
+
* handed — no session, no namespace, no `allowed` consulted — which made "describe this" a read of any
|
|
135
|
+
* file in the store. This is the only point that knows the session AND has a channel to say no: the
|
|
136
|
+
* runner could only drop the block silently, mid-turn, for something the user watched itself attach.
|
|
137
|
+
*/
|
|
138
|
+
export async function ingestMedia(
|
|
139
|
+
content: readonly UserContent[],
|
|
140
|
+
sessionId: string,
|
|
141
|
+
store: MediaStore | undefined,
|
|
142
|
+
): Promise<MessageContent[]> {
|
|
143
|
+
// A ref counts: it is the arm that needs CHECKING rather than rewriting, and one cannot be honoured
|
|
144
|
+
// without a store any more than a by-value arm can be stored without one.
|
|
145
|
+
if (!content.some(c => INLINE_ARMS.has(c.type) || c.type === 'file-ref')) return content as MessageContent[];
|
|
146
|
+
|
|
147
|
+
if (store === undefined) {
|
|
148
|
+
throw mediaRejectedError('no-store',
|
|
149
|
+
'This deployment cannot accept attachments: no MediaStore is registered, so there is nowhere to ' +
|
|
150
|
+
'put the bytes. Send the message as text, or register a media store.');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// Derived, not counted — see MAX_MEDIA_BYTES_PER_SESSION. One listing for the whole batch.
|
|
154
|
+
let sessionBytes = 0;
|
|
155
|
+
for await (const held of store.list({ sessionId })) sessionBytes += held.size;
|
|
156
|
+
|
|
157
|
+
const out: MessageContent[] = [];
|
|
158
|
+
for (const c of content) {
|
|
159
|
+
if (c.type === 'file-ref') {
|
|
160
|
+
// Ownership, not `allowed`: every put below sets `allowed: true`, so the flag the HTTP read route
|
|
161
|
+
// gates on cannot separate this session's media from another's. `sessionId` + namespace can, and
|
|
162
|
+
// together they are strictly the stronger check. Reported as unknown rather than forbidden, for
|
|
163
|
+
// the reason `GET /media/:id` gives: a refusal must not confirm that an id exists.
|
|
164
|
+
const held = await store.get(c.fileId).catch(() => null);
|
|
165
|
+
if (held === null || held.sessionId !== sessionId || held.namespace !== MEDIA_NAMESPACE) {
|
|
166
|
+
throw mediaRejectedError('unknown-ref',
|
|
167
|
+
`"${c.name}" is not media attached to this session. Attach the file itself, rather than a ` +
|
|
168
|
+
'reference to one.', c.name);
|
|
169
|
+
}
|
|
170
|
+
out.push(c as MessageContent);
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
if (!INLINE_ARMS.has(c.type)) { out.push(c as MessageContent); continue; }
|
|
174
|
+
const arm = c as Extract<UserContent, { data: string }>;
|
|
175
|
+
const name = describe(arm.name, arm.mimeType);
|
|
176
|
+
|
|
177
|
+
// Before the decode, because it needs no bytes and this is the cheapest of the three refusals.
|
|
178
|
+
if (armFor(arm.mimeType) === null) {
|
|
179
|
+
throw mediaRejectedError('unsupported-type',
|
|
180
|
+
`"${name}" is ${arm.mimeType}, which no model endpoint decodes — sending it would fail the ` +
|
|
181
|
+
'whole conversation, not just this message. Convert it to PNG or JPEG and attach that.', name);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
let bytes: Uint8Array;
|
|
185
|
+
try { bytes = decodeBase64(arm.data); }
|
|
186
|
+
catch { throw mediaRejectedError('unreadable', `"${name}" is not valid base64 and could not be read.`, name); }
|
|
187
|
+
|
|
188
|
+
if (!contentMatchesType(bytes, arm.mimeType)) {
|
|
189
|
+
throw mediaRejectedError('unreadable',
|
|
190
|
+
`"${name}" does not contain valid ${arm.mimeType} data — the file may be corrupt, truncated, or ` +
|
|
191
|
+
`misnamed. Sending it would fail the whole conversation, not just this message.`, name);
|
|
192
|
+
}
|
|
193
|
+
if (bytes.byteLength > MAX_MEDIA_BYTES_PER_FILE) {
|
|
194
|
+
throw mediaRejectedError('too-large',
|
|
195
|
+
`"${name}" is ${humanBytes(bytes.byteLength)}, over the ${humanBytes(MAX_MEDIA_BYTES_PER_FILE)} per-file limit.`, name);
|
|
196
|
+
}
|
|
197
|
+
sessionBytes += bytes.byteLength;
|
|
198
|
+
if (sessionBytes > MAX_MEDIA_BYTES_PER_SESSION) {
|
|
199
|
+
throw mediaRejectedError('session-quota',
|
|
200
|
+
`Attaching "${name}" (${humanBytes(bytes.byteLength)}) would take this session over its ` +
|
|
201
|
+
`${humanBytes(MAX_MEDIA_BYTES_PER_SESSION)} media limit. Start a new session, or attach less.`, name);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Anonymous put: a UUID id, so two uploads of "photo.jpg" in one session are two files rather than
|
|
205
|
+
// an upsert over each other. The display name rides on the `file-ref` block instead, which every
|
|
206
|
+
// FileStore therefore preserves without having to.
|
|
207
|
+
const handle = await store.put(undefined, arm.mimeType, (async function *() { yield bytes; })(),
|
|
208
|
+
{ sessionId, namespace: MEDIA_NAMESPACE, allowed: true });
|
|
209
|
+
out.push({ type: 'file-ref', fileId: handle.id, name, mimeType: arm.mimeType });
|
|
210
|
+
}
|
|
211
|
+
return out;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Turn-side resolution: which `file-ref`s get swapped for inline bytes on the outgoing copy. Returns
|
|
216
|
+
* message-id → (file-id → the inline arm to stand in for that ref); a message with nothing resolvable
|
|
217
|
+
* is absent from it, and so is a ref left outside the budget.
|
|
218
|
+
*
|
|
219
|
+
* Keyed per REF rather than returning each message's finished content array, because the array is not
|
|
220
|
+
* final when this runs: a raced `screen` verdict folds durable blocks onto the user message *inside* the
|
|
221
|
+
* round loop, and a caller splicing a whole array resolved at turn start would drop them from every
|
|
222
|
+
* later round — persisted and on screen, but never sent. Handing back the substitutions instead leaves
|
|
223
|
+
* the caller mapping blocks it still owns.
|
|
224
|
+
*
|
|
225
|
+
* Computed ONCE per turn rather than per round: the bytes cannot change mid-turn, and re-deciding each
|
|
226
|
+
* round would let a message drop out of the window between two provider calls — busting the prompt
|
|
227
|
+
* cache and leaving the model referring to something no longer there.
|
|
228
|
+
*/
|
|
229
|
+
export async function resolveSessionMedia(
|
|
230
|
+
messages: readonly Message[],
|
|
231
|
+
store: MediaStore | undefined,
|
|
232
|
+
budget = MEDIA_RESIDENCY_BYTES,
|
|
233
|
+
signal?: AbortSignal,
|
|
234
|
+
): Promise<Map<string, Map<string, MessageContent>>> {
|
|
235
|
+
const resolved = new Map<string, Map<string, MessageContent>>();
|
|
236
|
+
if (store === undefined) return resolved;
|
|
237
|
+
|
|
238
|
+
let spent = 0;
|
|
239
|
+
// Newest-first: the attachment the user is talking about right now is the one that must survive.
|
|
240
|
+
for (let i = messages.length - 1; i >= 0 && spent < budget; i--) {
|
|
241
|
+
const msg = messages[i]!;
|
|
242
|
+
if (!msg.content.some(c => c.type === 'file-ref')) continue;
|
|
243
|
+
|
|
244
|
+
const byFileId = new Map<string, MessageContent>();
|
|
245
|
+
for (const c of msg.content) {
|
|
246
|
+
if (c.type !== 'file-ref') continue;
|
|
247
|
+
// Left out of the map, the ref stays exactly as it is — the converters already render it as
|
|
248
|
+
// `[Attached file: x]`, which is true and the file is still fetchable by name. Three ways to
|
|
249
|
+
// land there: a type no endpoint decodes, a ref the store no longer has, and the budget.
|
|
250
|
+
const arm = armFor(c.mimeType);
|
|
251
|
+
if (arm === null) continue;
|
|
252
|
+
// One resolution per id: the same ref twice in a message must not be paid for twice, and both
|
|
253
|
+
// blocks render from the one entry.
|
|
254
|
+
if (byFileId.has(c.fileId)) continue;
|
|
255
|
+
const handle = spent < budget ? await store.get(c.fileId).catch(() => null) : null;
|
|
256
|
+
if (handle === null || spent + handle.size > budget) continue;
|
|
257
|
+
spent += handle.size;
|
|
258
|
+
const data = encodeBase64(await collectBytes(handle.stream(signal)));
|
|
259
|
+
byFileId.set(c.fileId, arm === 'document'
|
|
260
|
+
? { type: 'document', data, mimeType: c.mimeType as MimeType, name: c.name }
|
|
261
|
+
: { type: arm, data, mimeType: c.mimeType as MimeType });
|
|
262
|
+
}
|
|
263
|
+
if (byFileId.size > 0) resolved.set(msg.id, byFileId);
|
|
264
|
+
}
|
|
265
|
+
return resolved;
|
|
266
|
+
}
|
package/src/registry.ts
CHANGED
|
@@ -377,6 +377,7 @@ export async function setupPlugin(plugin: MatbotPlugin, services: MatbotMachine,
|
|
|
377
377
|
},
|
|
378
378
|
removeByPlugin: (name: string) => services.systemContext.removeByPlugin(name),
|
|
379
379
|
build: (ctx) => services.systemContext.build(ctx),
|
|
380
|
+
parts: (ctx) => services.systemContext.parts(ctx),
|
|
380
381
|
},
|
|
381
382
|
async register(key, svc) {
|
|
382
383
|
const keys = state.serviceKeys.get(plugin.name) ?? [];
|
package/src/runner.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
Session, Message, MessageContent, ModelContent, Usage, UsageSite, ProviderMeta, TruncatedToolResult,
|
|
3
3
|
TurnEvent, RunConfig, ProviderAdapter, ProviderConfig,
|
|
4
|
-
Tool, ToolRegistry, ToolContext, Store, FileStore, SystemContextRegistry, Vault, PromptFn, FormField,
|
|
4
|
+
Tool, ToolRegistry, ToolContext, Store, FileStore, MediaStore, SystemContextRegistry, Vault, PromptFn, FormField,
|
|
5
5
|
} from './types.js';
|
|
6
6
|
import type { MatbotPlugin } from './plugin.js';
|
|
7
7
|
import type { ToolPresenter } from '@matatbread/matbot-plugin-api';
|
|
@@ -10,6 +10,7 @@ import { HookRegistry } from './hooks.js';
|
|
|
10
10
|
import { appendMessage, createMessage } from './session.js';
|
|
11
11
|
import { foldOntoUserTurn, bindPluginOps } from '@matatbread/matbot-plugin-api';
|
|
12
12
|
import { addUsage } from './usage.js';
|
|
13
|
+
import { MEDIA_RESIDENCY_BYTES, resolveSessionMedia } from './media.js';
|
|
13
14
|
|
|
14
15
|
// Establish a usage call site across an async iteration. Wrapping the *loop* would not work: this
|
|
15
16
|
// runner is itself an async generator, so it suspends at every `yield` and resumes under the consumer's
|
|
@@ -79,6 +80,10 @@ export interface RunSessionOpts {
|
|
|
79
80
|
workdir?: string;
|
|
80
81
|
configPath?: string;
|
|
81
82
|
files?: FileStore;
|
|
83
|
+
/** Where user-attached session media lives. Resolved once per turn into the outgoing copy, newest-first
|
|
84
|
+
* and inside {@link MEDIA_RESIDENCY_BYTES}; absent ⇒ every `file-ref` stays a ref and the converters
|
|
85
|
+
* degrade it. Never read for anything else — the runner does not otherwise touch a file. */
|
|
86
|
+
mediaStore?: MediaStore;
|
|
82
87
|
/** Supply a prompt implementation to allow tools to ask interactive questions. */
|
|
83
88
|
prompt?: PromptFn;
|
|
84
89
|
/**
|
|
@@ -228,6 +233,14 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
228
233
|
// Turn-scoped and wire-only: it is spliced into the outgoing copy below and dies with this call.
|
|
229
234
|
const attachments = new Map<string, Message>();
|
|
230
235
|
|
|
236
|
+
// User-attached media, keyed by the message whose `file-ref` blocks it replaces. The mirror of
|
|
237
|
+
// `attachments` — same splice, different source and different placement: tool media is *pinned after*
|
|
238
|
+
// the tool message it answers, session media is swapped *in place*, so the model reads the image where
|
|
239
|
+
// the person actually put it. Resolved once here, not per round: the bytes cannot change mid-turn, and
|
|
240
|
+
// re-deciding each round could drop a message out of the byte budget between two calls — busting the
|
|
241
|
+
// prompt cache and leaving the model referring to something no longer there.
|
|
242
|
+
const sessionMedia = await resolveSessionMedia(session.messages, opts.mediaStore, MEDIA_RESIDENCY_BYTES, signal);
|
|
243
|
+
|
|
231
244
|
for (;;) {
|
|
232
245
|
// Respect an abort that arrived between turns (e.g. during tool execution).
|
|
233
246
|
if (signal.aborted) {
|
|
@@ -283,10 +296,18 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
283
296
|
// message can be a marker (e.g. a retract-redo leaves the retraction marker as the tail) — folding
|
|
284
297
|
// there would drop the ephemeral with it. -1 ⇒ nothing to fold onto (no non-marker history).
|
|
285
298
|
const foldIdx = ephemeral.length > 0 ? session.messages.findLastIndex(m => m.role !== 'marker') : -1;
|
|
286
|
-
const history = foldIdx >= 0 || attachments.size > 0
|
|
299
|
+
const history = foldIdx >= 0 || attachments.size > 0 || sessionMedia.size > 0
|
|
287
300
|
? session.messages.flatMap((m, i) => {
|
|
288
|
-
|
|
289
|
-
|
|
301
|
+
// Per BLOCK, never the whole array: `sessionMedia` was resolved once before this loop, and a
|
|
302
|
+
// raced verdict's `foldOntoUserTurn` (claimVerdicts, above) extends this same user message
|
|
303
|
+
// mid-turn. Substituting the array would put back the pre-fold copy on every later round —
|
|
304
|
+
// dropping durable robo-user blocks from the wire while leaving them persisted and on screen.
|
|
305
|
+
const refs = sessionMedia.get(m.id);
|
|
306
|
+
const inlined = refs
|
|
307
|
+
? { ...m, content: m.content.map(c => (c.type === 'file-ref' ? refs.get(c.fileId) ?? c : c)) }
|
|
308
|
+
: m;
|
|
309
|
+
const folded = i === foldIdx ? { ...inlined, content: [...inlined.content, ...ephemeral] } : inlined;
|
|
310
|
+
const media = attachments.get(m.id);
|
|
290
311
|
return media ? [folded, media] : [folded];
|
|
291
312
|
})
|
|
292
313
|
: session.messages;
|
package/src/session-runner.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
Session, Message, MessageContent, Principal, PromptFn, PipelineEvent,
|
|
3
|
-
Store, ToolRegistry, SystemContextRegistry, Vault, FileStore, Tool,
|
|
3
|
+
Store, ToolRegistry, SystemContextRegistry, Vault, FileStore, MediaStore, UserContent, Tool,
|
|
4
4
|
ProviderAdapter, ProviderConfig, SessionRunner, SessionView, OpenOpts, SubmitOpenOpts,
|
|
5
5
|
SteeringPolicy, SteeringDecision, TurnEntry,
|
|
6
6
|
} from './types.js';
|
|
@@ -11,6 +11,7 @@ import { appendMessage, createMessage } from './session.js';
|
|
|
11
11
|
import { isReadOnlyError, foldOntoUserTurn, lastUserIndex, runAs } from '@matatbread/matbot-plugin-api';
|
|
12
12
|
import { machineBusy, withUsageScope } from '@matatbread/matbot-plugin-api/host';
|
|
13
13
|
import { runSession } from './runner.js';
|
|
14
|
+
import { ingestMedia } from './media.js';
|
|
14
15
|
|
|
15
16
|
export interface SessionRunnerDeps {
|
|
16
17
|
store: Store<Session>;
|
|
@@ -28,6 +29,10 @@ export interface SessionRunnerDeps {
|
|
|
28
29
|
// disposed (queue vs interrupt) and supplies an interrupt's continuation nudge. Absent ⇒ the runner's
|
|
29
30
|
// own defaults (DEFAULT_STEERING_POLICY / DEFAULT_STEER_NUDGE).
|
|
30
31
|
steeringPolicy?: () => SteeringPolicy | undefined;
|
|
32
|
+
// Resolved live (a host registers its boot default, a plugin may replace it): where a submission's
|
|
33
|
+
// by-value media is written at the boundary, and where the runner reads it back from. Absent ⇒ an
|
|
34
|
+
// attachment is refused with a reason and a stored `file-ref` degrades to the converters' text note.
|
|
35
|
+
mediaStore?: () => MediaStore | undefined;
|
|
31
36
|
hooks?: HookRegistry;
|
|
32
37
|
systemContext?: SystemContextRegistry;
|
|
33
38
|
vault?: Vault;
|
|
@@ -306,9 +311,15 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
306
311
|
// A redo re-runs the existing committed user turn — no title derivation, no new user message.
|
|
307
312
|
if (head.redo === undefined) {
|
|
308
313
|
if (!session.title && !session.messages.some(m => m.role === 'user')) {
|
|
314
|
+
// Falls back to the attachment names when the turn has no words of its own: an image-only
|
|
315
|
+
// first turn is a real submission, and got no title at all — which reads in the session
|
|
316
|
+
// list as a session that failed to start.
|
|
309
317
|
const text = content
|
|
310
318
|
.filter((c): c is Extract<MessageContent, { type: 'text' }> => c.type === 'text')
|
|
311
|
-
.map(c => c.text).join(' ').trim()
|
|
319
|
+
.map(c => c.text).join(' ').trim()
|
|
320
|
+
|| content
|
|
321
|
+
.filter((c): c is Extract<MessageContent, { type: 'file-ref' }> => c.type === 'file-ref')
|
|
322
|
+
.map(c => c.name).join(', ');
|
|
312
323
|
if (text) {
|
|
313
324
|
const words = text.split(/\s+/).slice(0, 8).join(' ');
|
|
314
325
|
session = { ...session, title: words.length > 60 ? `${words.slice(0, 60)}…` : words };
|
|
@@ -351,6 +362,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
351
362
|
// inputSchema (no contract) are left as-is; absent ToolTypeIndex (browser) ⇒ plain descriptions.
|
|
352
363
|
const wire = await deps.toolTypeIndex?.()?.wireContracts();
|
|
353
364
|
const toolPresenter = deps.toolPresenter?.();
|
|
365
|
+
const mediaStore = deps.mediaStore?.();
|
|
354
366
|
const toolMap = deps.tools !== undefined
|
|
355
367
|
? new Map<string, Tool>(deps.tools.list().map(t => {
|
|
356
368
|
const wc = wire?.[t.name];
|
|
@@ -400,6 +412,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
400
412
|
...(deps.systemContext !== undefined ? { systemContext: deps.systemContext } : {}),
|
|
401
413
|
...(deps.workdir !== undefined ? { workdir: deps.workdir } : {}),
|
|
402
414
|
...(deps.files !== undefined ? { files: deps.files } : {}),
|
|
415
|
+
...(mediaStore !== undefined ? { mediaStore } : {}),
|
|
403
416
|
...(deps.configPath !== undefined ? { configPath: deps.configPath } : {}),
|
|
404
417
|
...(deps.vault !== undefined ? { vault: deps.vault } : {}),
|
|
405
418
|
...(head.prompt !== undefined ? { prompt: head.prompt } : {}),
|
|
@@ -455,7 +468,11 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
455
468
|
// event and the persisted user message the pump builds from this item.
|
|
456
469
|
for (const raw of resubmits.reverse()) {
|
|
457
470
|
const rt = crypto.randomUUID();
|
|
458
|
-
|
|
471
|
+
// Every block, not just text: `origin` is authorship, orthogonal to what the block
|
|
472
|
+
// carries, and a resubmission that attached an image was silently presenting it as the
|
|
473
|
+
// user's own. The arms a stamp would be wrong on don't occur here — a hook returns
|
|
474
|
+
// content, not a thinking block — so a blanket stamp beats an arm-by-arm list.
|
|
475
|
+
const content = raw.map(c => ({ ...c, origin: 'robo' as const }));
|
|
459
476
|
s.queue.unshift({
|
|
460
477
|
traceId: rt,
|
|
461
478
|
rootTraceId: head.rootTraceId,
|
|
@@ -565,9 +582,26 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
565
582
|
}
|
|
566
583
|
s = stateFor(opts.sessionId);
|
|
567
584
|
traceId = opts.traceId ?? crypto.randomUUID();
|
|
568
|
-
const {
|
|
585
|
+
const { provider, principal } = opts;
|
|
569
586
|
const mode = opts.mode ?? 'queue';
|
|
570
587
|
|
|
588
|
+
// Which turn a steer is FOR is fixed here, before the first await, and checked again before the
|
|
589
|
+
// abort. `s.running` alone cannot answer it: it is true across the pump's whole queue, so it
|
|
590
|
+
// says "something is running", not "still the one you meant". Every await below can outlive a
|
|
591
|
+
// turn — the classifier and the nudge could already, and media ingestion can hold for seconds
|
|
592
|
+
// on a large attachment — after which aborting on `s.running` kills whatever is running NOW,
|
|
593
|
+
// a turn the user never saw, and names the wrong `interruptedTraceId` on the way.
|
|
594
|
+
const steerTarget = s.runningTraceId;
|
|
595
|
+
|
|
596
|
+
// The media boundary, before anything else this submission touches: by-value attachments are
|
|
597
|
+
// written through the MediaStore and become `file-ref`s here, so nothing downstream — the
|
|
598
|
+
// steering classifier, the `queued`/`steer` events, the persisted user message — ever sees
|
|
599
|
+
// bytes. One place, and every frontend inherits it, which is what lets Telegram (where bytes
|
|
600
|
+
// arrive WITH the message and there is no upload-in-advance leg) work with no code of its own.
|
|
601
|
+
// Throws MediaRejectedError on a refusal; nothing is enqueued, so the caller has nothing to
|
|
602
|
+
// undo — it just tells the user which file and why.
|
|
603
|
+
const content = await ingestMedia(opts.content, opts.sessionId, deps.mediaStore?.());
|
|
604
|
+
|
|
571
605
|
// Decide interrupt vs queue for a submission arriving mid-turn. Steering is only meaningful
|
|
572
606
|
// while a turn is running; with nothing running it enqueues and runs next regardless of mode.
|
|
573
607
|
// The decision — and, on interrupt, the abort+unshift — happen here synchronously against
|
|
@@ -589,8 +623,10 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
589
623
|
? (committed ? await policy.classify({ session: committed, steer: content }) : DEFAULT_STEERING_POLICY)
|
|
590
624
|
: DEFAULT_STEERING_POLICY;
|
|
591
625
|
|
|
592
|
-
// Any await above may have outlived the
|
|
593
|
-
|
|
626
|
+
// Any await above may have outlived the turn this steer was for: interrupt only if THAT one
|
|
627
|
+
// is still the running turn. Otherwise fall through and queue — the turn they were steering
|
|
628
|
+
// has already committed, which makes this an ordinary follow-up rather than an interruption.
|
|
629
|
+
if (decision === 'interrupt' && s.running && s.runningTraceId === steerTarget) {
|
|
594
630
|
const nudge = policy?.nudge !== undefined && committed
|
|
595
631
|
? policy.nudge({ session: committed, steer: content })
|
|
596
632
|
: DEFAULT_STEER_NUDGE;
|
package/src/single-turn.ts
CHANGED
|
@@ -1,11 +1,50 @@
|
|
|
1
|
-
import type {
|
|
1
|
+
import type {
|
|
2
|
+
Tool, ToolExecutor, ToolContract, ToolResultOf, ToolContext, MatbotMachine, MimeType, UserContent, FileStore,
|
|
3
|
+
} from '@matatbread/matbot-plugin-api';
|
|
4
|
+
import { collectBytes, encodeBase64 } from '@matatbread/matbot-plugin-api';
|
|
5
|
+
// The arm mapping is the runner's, not a copy of it: it decides which types may be sent at all, and a
|
|
6
|
+
// rule with two copies is one that gets fixed in one of them.
|
|
7
|
+
import { armFor } from './media.js';
|
|
2
8
|
|
|
3
9
|
declare module '@matatbread/matbot-plugin-api' {
|
|
4
10
|
interface ToolContracts {
|
|
5
|
-
single_turn: ToolContract<{ text: string }, { provider?: string; prompt: string; system?: string }>; // the consulted provider's reply text
|
|
11
|
+
single_turn: ToolContract<{ text: string }, { provider?: string; prompt: string; system?: string; attach?: string[] }>; // the consulted provider's reply text
|
|
6
12
|
}
|
|
7
13
|
}
|
|
8
14
|
|
|
15
|
+
/**
|
|
16
|
+
* Resolve `attach` names/ids to inline media for the one-shot. Looked up by **id first, then name**,
|
|
17
|
+
* across the MediaStore and then the turn's own file store: an id is unambiguous and a name is what a
|
|
18
|
+
* model actually has to hand, and both stores are in play because session media and workspace files are
|
|
19
|
+
* separately owned. A one-shot has no session and so no residency window — whatever is named is sent,
|
|
20
|
+
* once, and the caller's own provider limits are the only ceiling.
|
|
21
|
+
*/
|
|
22
|
+
async function resolveAttachments(
|
|
23
|
+
names: readonly string[],
|
|
24
|
+
stores: ReadonlyArray<FileStore | undefined>,
|
|
25
|
+
signal: AbortSignal | undefined,
|
|
26
|
+
): Promise<{ content: UserContent[]; missing: string[]; unsupported: string[] }> {
|
|
27
|
+
const content: UserContent[] = [];
|
|
28
|
+
const missing: string[] = [];
|
|
29
|
+
const unsupported: string[] = [];
|
|
30
|
+
for (const ref of names) {
|
|
31
|
+
let handle = null;
|
|
32
|
+
for (const store of stores) {
|
|
33
|
+
if (store === undefined) continue;
|
|
34
|
+
handle = await store.get(ref).catch(() => null) ?? await store.getByName(ref).catch(() => null);
|
|
35
|
+
if (handle !== null) break;
|
|
36
|
+
}
|
|
37
|
+
if (handle === null) { missing.push(ref); continue; }
|
|
38
|
+
// A type no endpoint decodes is refused, not routed to an arm that will 400 the call. Reported
|
|
39
|
+
// apart from `missing`: "no such file" would send the model hunting for a file it named correctly.
|
|
40
|
+
const arm = armFor(handle.mimeType);
|
|
41
|
+
if (arm === null) { unsupported.push(`${ref} (${handle.mimeType})`); continue; }
|
|
42
|
+
const bytes = await collectBytes(handle.stream(signal));
|
|
43
|
+
content.push({ type: arm, data: encodeBase64(bytes), mimeType: handle.mimeType as MimeType, name: handle.name });
|
|
44
|
+
}
|
|
45
|
+
return { content, missing, unsupported };
|
|
46
|
+
}
|
|
47
|
+
|
|
9
48
|
/**
|
|
10
49
|
* Exposes {@link MatbotMachine.singleTurn} to the model: a one-shot completion against a configured
|
|
11
50
|
* provider, returning its reply. The intended use is consulting another model (e.g. a different-lineage
|
|
@@ -21,7 +60,7 @@ declare module '@matatbread/matbot-plugin-api' {
|
|
|
21
60
|
export function createSingleTurnTool(services: MatbotMachine): Tool<ToolResultOf<'single_turn'>> {
|
|
22
61
|
const executor: ToolExecutor<ToolResultOf<'single_turn'>> = {
|
|
23
62
|
async *execute(input: unknown, ctx: ToolContext) {
|
|
24
|
-
const args = input as { provider?: string; prompt?: string; system?: string };
|
|
63
|
+
const args = input as { provider?: string; prompt?: string; system?: string; attach?: unknown };
|
|
25
64
|
if (typeof args.prompt !== 'string') { yield { type: 'error', message: 'single_turn requires a string "prompt".' }; return; }
|
|
26
65
|
const provider = args.provider ?? ctx.provider;
|
|
27
66
|
if (!provider) {
|
|
@@ -33,9 +72,29 @@ export function createSingleTurnTool(services: MatbotMachine): Tool<ToolResultOf
|
|
|
33
72
|
yield { type: 'error', message: `Unknown provider "${provider}". Configured providers: ${known}.` };
|
|
34
73
|
return;
|
|
35
74
|
}
|
|
75
|
+
// Attachments make the prompt a UserContent[] rather than a string. Media the store doesn't have
|
|
76
|
+
// is REPORTED, not silently dropped: a consulted model answering about a file it never saw is the
|
|
77
|
+
// failure that looks like a bad answer rather than a bad call.
|
|
78
|
+
const attach = Array.isArray(args.attach) ? args.attach.filter((a): a is string => typeof a === 'string') : [];
|
|
79
|
+
let prompt: string | UserContent[] = args.prompt;
|
|
80
|
+
if (attach.length > 0) {
|
|
81
|
+
const { content, missing, unsupported } = await resolveAttachments(attach, [services.MediaStore, ctx.files], ctx.signal);
|
|
82
|
+
if (missing.length > 0) {
|
|
83
|
+
yield { type: 'error', message: `single_turn: no such file(s): ${missing.join(', ')}. Attachments are looked up by store id or by name.` };
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
if (unsupported.length > 0) {
|
|
87
|
+
yield { type: 'error', message:
|
|
88
|
+
`single_turn: no model endpoint decodes ${unsupported.join(', ')}, so sending it would fail ` +
|
|
89
|
+
'the call. Convert it to PNG or JPEG, or read the file as text and put that in the prompt.' };
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
prompt = [...content, { type: 'text', text: args.prompt }];
|
|
93
|
+
}
|
|
94
|
+
|
|
36
95
|
const res = await services.singleTurn({
|
|
37
96
|
provider,
|
|
38
|
-
prompt
|
|
97
|
+
prompt,
|
|
39
98
|
signal: ctx.signal,
|
|
40
99
|
...(typeof args.system === 'string' ? { system: args.system } : {}),
|
|
41
100
|
});
|
|
@@ -53,7 +112,13 @@ export function createSingleTurnTool(services: MatbotMachine): Tool<ToolResultOf
|
|
|
53
112
|
'back its text. Use it to consult a different model — e.g. a second, ' +
|
|
54
113
|
'different-lineage model critiquing your draft, or any generation that should run on a specific ' +
|
|
55
114
|
'provider. `provider` is OPTIONAL: omit it to run on the current conversation\'s model, or name ' +
|
|
56
|
-
'a configured provider to switch models (list or add providers with the provider tool).'
|
|
115
|
+
'a configured provider to switch models (list or add providers with the provider tool). ' +
|
|
116
|
+
'`attach` is an ARRAY OF STORED FILE NAMES OR IDS (e.g. ["chart.png"]) to send alongside the ' +
|
|
117
|
+
'prompt, for the consulted model to look at. It is not a content type and not a way to forward ' +
|
|
118
|
+
'media from THIS conversation — you can already see that; only name a file that exists in the ' +
|
|
119
|
+
'workspace or media store. Whether the consulted provider can read a given file is its business; ' +
|
|
120
|
+
'one it cannot take degrades to a text note. An image in a format no endpoint decodes (HEIC, ' +
|
|
121
|
+
'SVG, BMP, TIFF) is refused outright rather than sent — convert it first, or read it as text.',
|
|
57
122
|
inputSchema: {
|
|
58
123
|
type: 'object',
|
|
59
124
|
required: ['prompt'],
|
|
@@ -61,6 +126,7 @@ export function createSingleTurnTool(services: MatbotMachine): Tool<ToolResultOf
|
|
|
61
126
|
provider: { type: 'string', description: 'Name of a configured provider to run the completion against. Optional — defaults to the current turn\'s provider.' },
|
|
62
127
|
prompt: { type: 'string', description: 'The user message to send to that provider.' },
|
|
63
128
|
system: { type: 'string', description: 'Optional system prompt for the call.' },
|
|
129
|
+
attach: { type: 'array', items: { type: 'string' }, description: 'Optional array of stored FILE NAMES or store ids, e.g. ["diagram.png"]. Each is loaded and sent as inline media (image/document/audio) ahead of the prompt text. Not a content type — naming a file that does not exist is an error, not a request for one.' },
|
|
64
130
|
},
|
|
65
131
|
},
|
|
66
132
|
executor,
|
package/src/system-context.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Session, SystemContextContributor, SystemContextRegistry } from './types.js';
|
|
1
|
+
import type { Session, SystemContextContributor, SystemContextPart, SystemContextRegistry } from './types.js';
|
|
2
2
|
|
|
3
3
|
interface TaggedContributor {
|
|
4
4
|
fn: SystemContextContributor;
|
|
@@ -18,9 +18,23 @@ export class SystemContextRegistryImpl implements SystemContextRegistry {
|
|
|
18
18
|
}
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
async parts(ctx: { session: Session; signal: AbortSignal }): Promise<SystemContextPart[]> {
|
|
22
|
+
// Zipped, not indexed twice. Re-reading `_contributors` after the await pairs each text with
|
|
23
|
+
// whatever now sits at that index: a plugin unloading mid-await (`removeByPlugin` splices) shifts
|
|
24
|
+
// every survivor onto its predecessor's text and drops the last one, and since `build()` derives
|
|
25
|
+
// from this, that corrupts the prompt actually sent — not merely the breakdown reported.
|
|
26
|
+
const settled = await Promise.all(
|
|
27
|
+
this._contributors.map(async c => ({ c, text: await c.fn(ctx) })));
|
|
28
|
+
return settled.flatMap(({ c, text }) =>
|
|
29
|
+
typeof text === 'string' && text.length > 0
|
|
30
|
+
? [{ text, ...(c.pluginName !== undefined ? { plugin: c.pluginName } : {}) }]
|
|
31
|
+
: []);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The joined form is derived from the attributed one, so the prompt the model receives and the
|
|
35
|
+
// breakdown `about_matbot` reports cannot drift: there is one traversal and one filter.
|
|
21
36
|
async build(ctx: { session: Session; signal: AbortSignal }): Promise<string | null> {
|
|
22
|
-
const parts =
|
|
23
|
-
|
|
24
|
-
return parts.length > 0 ? parts.join('\n\n') : null;
|
|
37
|
+
const parts = await this.parts(ctx);
|
|
38
|
+
return parts.length > 0 ? parts.map(p => p.text).join('\n\n') : null;
|
|
25
39
|
}
|
|
26
40
|
}
|