@matatbread/matbot-core 0.4.18 → 0.4.19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/providers-base/index.ts +1 -0
- package/src/providers-base/text-document.ts +55 -0
- package/src/runner.ts +50 -3
- package/src/session-runner.ts +55 -17
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.4.
|
|
7
|
+
"version": "0.4.19",
|
|
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.19"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^6.0.3"
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A text-typed `document` arm, rendered as a plain text block.
|
|
3
|
+
*
|
|
4
|
+
* Every adapter needs this and none should use its protocol's document block for it. A document block
|
|
5
|
+
* buys a title and citation support on the one endpoint that natively implements it, and is DISCARDED
|
|
6
|
+
* by the Anthropic- and OpenAI-compatible shims most vendors front their models with: DeepSeek
|
|
7
|
+
* substitutes the literal "[Unsupported Document]" server-side, so the model is told a file is attached
|
|
8
|
+
* and shown nothing — strictly worse than either sending the bytes or refusing them at the boundary.
|
|
9
|
+
* A text block is the one content shape every endpoint implements, and for text it is lossless: the
|
|
10
|
+
* model reads the same characters either way.
|
|
11
|
+
*
|
|
12
|
+
* The name is folded into the framing because the alternative is a bare CSV dump with nothing saying
|
|
13
|
+
* it is a file, which is the other half of what went wrong — a model that cannot tell content from
|
|
14
|
+
* attachment goes looking for the attachment.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Structured-text types that are text without saying `text/`. A `+json`/`+xml` suffix (RFC 6839) is
|
|
19
|
+
* matched too, so `application/vnd.api+json` needs no entry.
|
|
20
|
+
*
|
|
21
|
+
* An unlisted type is NOT guessed at, `application/octet-stream` above all: the browser hands that over
|
|
22
|
+
* for any extension the OS could not place (`.yaml` on some installs), and sniffing it would mean
|
|
23
|
+
* deciding that binary is text on the strength of it happening to decode.
|
|
24
|
+
*/
|
|
25
|
+
const TEXTUAL: ReadonlySet<string> = new Set([
|
|
26
|
+
'application/json', 'application/ld+json', 'application/x-ndjson',
|
|
27
|
+
'application/xml', 'application/yaml', 'application/x-yaml',
|
|
28
|
+
'application/toml', 'application/csv', 'application/sql',
|
|
29
|
+
'application/javascript', 'application/x-javascript', 'application/ecmascript',
|
|
30
|
+
'application/x-sh', 'application/x-httpd-php', 'application/graphql',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
function isTextual(mimeType: string): boolean {
|
|
34
|
+
const base = mimeType.split(';')[0]!.trim().toLowerCase();
|
|
35
|
+
return base.startsWith('text/') || base.endsWith('+json') || base.endsWith('+xml') || TEXTUAL.has(base);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The text of a document arm that should be inlined as text, or null to leave the adapter's own
|
|
40
|
+
* degradation in place (a PDF, an unrecognised type, or bytes that are not valid UTF-8 — a mislabelled
|
|
41
|
+
* binary must not reach a prompt as mojibake, so the decode is `fatal`).
|
|
42
|
+
*
|
|
43
|
+
* `atob` yields one byte per char, so re-widen through TextDecoder rather than trusting it for anything
|
|
44
|
+
* outside ASCII.
|
|
45
|
+
*/
|
|
46
|
+
export function textDocument(doc: { mimeType: string; data: string; name?: string | undefined }): string | null {
|
|
47
|
+
if (!isTextual(doc.mimeType)) return null;
|
|
48
|
+
let text: string;
|
|
49
|
+
try {
|
|
50
|
+
text = new TextDecoder('utf-8', { fatal: true })
|
|
51
|
+
.decode(Uint8Array.from(atob(doc.data), ch => ch.charCodeAt(0)));
|
|
52
|
+
} catch { return null; }
|
|
53
|
+
const label = doc.name ?? doc.mimeType;
|
|
54
|
+
return `--- ${label} ---\n${text}\n--- end of ${label} ---`;
|
|
55
|
+
}
|
package/src/runner.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type {
|
|
|
5
5
|
PermissionGate,
|
|
6
6
|
} from './types.js';
|
|
7
7
|
import type { MatbotPlugin } from './plugin.js';
|
|
8
|
-
import type { ToolPresenter, SessionAppender } from '@matatbread/matbot-plugin-api';
|
|
8
|
+
import type { ToolPresenter, SessionAppender, TurnToolSource } from '@matatbread/matbot-plugin-api';
|
|
9
9
|
import { recordSpan, recordUsage, withUsageSite, scopeIterable } from '@matatbread/matbot-plugin-api/host';
|
|
10
10
|
import { HookRegistry } from './hooks.js';
|
|
11
11
|
import { appendMessage, createMessage } from './session.js';
|
|
@@ -83,6 +83,38 @@ const INTERRUPTED_TOOL_RESULT = {
|
|
|
83
83
|
error: 'Tool call interrupted before completion — the turn was interrupted while it was running. It may not have run to completion, and any side effect may or may not have occurred. Re-run it only if you still need its result.',
|
|
84
84
|
} as const;
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* The turn tools `screen` offered (see {@link TurnToolSource}), as they stand for this round. Asked every
|
|
88
|
+
* round because the turn itself changes the session they are derived from. A name the registry (or the
|
|
89
|
+
* turn-start snapshot) already holds is dropped: the registered tool keeps it, since shadowing one is a
|
|
90
|
+
* gated decision and a hook must not be a way round it. `warned` keeps that to one line per name a turn.
|
|
91
|
+
* A source that throws is skipped, like a throwing hook — it must not take the turn down with it.
|
|
92
|
+
*/
|
|
93
|
+
async function resolveTurnTools(
|
|
94
|
+
sources: readonly TurnToolSource[],
|
|
95
|
+
session: Session,
|
|
96
|
+
taken: (name: string) => boolean,
|
|
97
|
+
warned: Set<string>,
|
|
98
|
+
): Promise<Map<string, Tool>> {
|
|
99
|
+
const out = new Map<string, Tool>();
|
|
100
|
+
for (const source of sources) {
|
|
101
|
+
let offered: readonly Tool[];
|
|
102
|
+
try { offered = await source(session); }
|
|
103
|
+
catch (e) { console.warn(`[runner] a turn tool source failed: ${e instanceof Error ? e.message : String(e)}`); continue; }
|
|
104
|
+
for (const tool of offered) {
|
|
105
|
+
if (taken(tool.name)) {
|
|
106
|
+
if (!warned.has(tool.name)) {
|
|
107
|
+
warned.add(tool.name);
|
|
108
|
+
console.warn(`[runner] turn tool "${tool.name}" dropped: a registered tool has that name.`);
|
|
109
|
+
}
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
if (!out.has(tool.name)) out.set(tool.name, tool);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
116
|
+
}
|
|
117
|
+
|
|
86
118
|
export interface RunSessionOpts {
|
|
87
119
|
session: Session;
|
|
88
120
|
config: RunConfig;
|
|
@@ -238,6 +270,11 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
238
270
|
// Returns whether anything was claimed. What the caller does with that is the part that legitimately
|
|
239
271
|
// differs — proceed, abort the in-flight provider call, or re-run the loop — so it stays at the site.
|
|
240
272
|
const deferred = screen.deferred;
|
|
273
|
+
|
|
274
|
+
// Re-resolved at the top of every round (below), so a tool defined in one round is callable in the next.
|
|
275
|
+
const turnToolSources = screen.tools;
|
|
276
|
+
const turnToolWarned = new Set<string>();
|
|
277
|
+
let turnTools = new Map<string, Tool>();
|
|
241
278
|
async function* claimVerdicts(): AsyncGenerator<TurnEvent, boolean, undefined> {
|
|
242
279
|
if (deferred.length === 0) return false;
|
|
243
280
|
const ephemeralC: MessageContent[] = [];
|
|
@@ -325,6 +362,11 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
325
362
|
// discarded. This is the no-waste path — the verdict landed before we spent any tokens.
|
|
326
363
|
yield* claimVerdicts();
|
|
327
364
|
|
|
365
|
+
if (turnToolSources.length > 0) {
|
|
366
|
+
turnTools = await resolveTurnTools(turnToolSources, session,
|
|
367
|
+
name => tools.has(name) || (opts.toolRegistry?.resolve(name) ?? null) !== null, turnToolWarned);
|
|
368
|
+
}
|
|
369
|
+
|
|
328
370
|
const pendingCalls: Array<{ id: string; name: string; input: unknown; meta?: ProviderMeta;
|
|
329
371
|
truncated?: { bytes: number; stopReason?: string } }> = [];
|
|
330
372
|
const assistantParts: MessageContent[] = [];
|
|
@@ -381,10 +423,13 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
381
423
|
// tools stay callable — execution resolves against the live registry (opts.toolRegistry), not this.
|
|
382
424
|
// Scoped to the round: a presenter may itself run completions (deriving a tool's search terms), and
|
|
383
425
|
// that spend belongs to the round that asked for it — it is neither a tool call nor a hook.
|
|
384
|
-
|
|
426
|
+
// Turn tools go AFTER the presenter, never through it: they are few, made for this session, and
|
|
427
|
+
// absent from the registry a presenter's own search reads — one it windowed out could not come back.
|
|
428
|
+
const presented = opts.toolPresenter
|
|
385
429
|
? await withUsageSite({ kind: 'round', round },
|
|
386
430
|
() => opts.toolPresenter!.present([...tools.values()], { session, provider: config.provider }))
|
|
387
431
|
: [...tools.values()];
|
|
432
|
+
const advertised = turnTools.size > 0 ? [...presented, ...turnTools.values()] : presented;
|
|
388
433
|
// Per-call signal linked to the turn signal: an in-situ restart aborts THIS provider call (callAc)
|
|
389
434
|
// to cancel the in-flight request and stop backend generation, without aborting the whole turn.
|
|
390
435
|
const callAc = new AbortController();
|
|
@@ -525,7 +570,8 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
525
570
|
for (const [callIdx, tc] of pendingCalls.entries()) {
|
|
526
571
|
yield { type: 'tool:start', callId: tc.id, name: tc.name, input: tc.input, traceId };
|
|
527
572
|
|
|
528
|
-
const tool = opts.toolRegistry !== undefined ? opts.toolRegistry.resolve(tc.name) : tools.get(tc.name)
|
|
573
|
+
const tool = (opts.toolRegistry !== undefined ? opts.toolRegistry.resolve(tc.name) : tools.get(tc.name))
|
|
574
|
+
?? turnTools.get(tc.name);
|
|
529
575
|
if (!tool) {
|
|
530
576
|
const err = { error: `Unknown tool: ${tc.name}` };
|
|
531
577
|
toolResults.push({ type: 'tool-result', id: tc.id, result: err, isError: true });
|
|
@@ -627,6 +673,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<TurnEvent
|
|
|
627
673
|
...(opts.configPath !== undefined ? { configPath: opts.configPath } : {}),
|
|
628
674
|
...(opts.files !== undefined ? { files: opts.files } : {}),
|
|
629
675
|
...(opts.appender !== undefined ? { appender: opts.appender } : {}),
|
|
676
|
+
...(turnTools.size > 0 ? { turnTools } : {}),
|
|
630
677
|
};
|
|
631
678
|
|
|
632
679
|
// Iterated by hand rather than with `for await`, so the read can be bounded once the turn is
|
package/src/session-runner.ts
CHANGED
|
@@ -108,7 +108,7 @@ const DEFAULT_STEER_NUDGE: MessageContent[] = [{
|
|
|
108
108
|
// "how's it going?" mid-task sees a history that ends a turn early and answers as though nothing were
|
|
109
109
|
// running. Names the running request so the reply can be about it, and says what happens to the reply.
|
|
110
110
|
const PARALLEL_FRAMING_CAP = 2_000;
|
|
111
|
-
const parallelFraming = (running: string): UserContent => ({
|
|
111
|
+
const parallelFraming = (running: string): Extract<UserContent, { type: 'text' }> => ({
|
|
112
112
|
type: 'text',
|
|
113
113
|
origin: 'robo',
|
|
114
114
|
text: 'A separate turn of this conversation is still working on the request below, and you cannot see its '
|
|
@@ -118,11 +118,32 @@ const parallelFraming = (running: string): UserContent => ({
|
|
|
118
118
|
+ '\n</running-request>',
|
|
119
119
|
});
|
|
120
120
|
|
|
121
|
-
// A parallel turn's
|
|
122
|
-
//
|
|
121
|
+
// A parallel turn's span as it can be placed into shared history: trimmed back to the last point at
|
|
122
|
+
// which every tool-call in it has its result. An abort (or a failure mid-round) leaves a trailing
|
|
123
|
+
// tool-call with nothing answering it, and an unpaired call is not valid wire for any provider — it
|
|
124
|
+
// would break not just this placement but every later turn of the session.
|
|
125
|
+
const closedSpan = (msgs: Message[]): Message[] => {
|
|
126
|
+
const open = new Set<string>();
|
|
127
|
+
let closed = 0;
|
|
128
|
+
for (const [i, m] of msgs.entries()) {
|
|
129
|
+
for (const c of m.content) {
|
|
130
|
+
if (c.type === 'tool-call') open.add(c.id);
|
|
131
|
+
else if (c.type === 'tool-result') open.delete(c.id);
|
|
132
|
+
}
|
|
133
|
+
if (open.size === 0) closed = i + 1;
|
|
134
|
+
}
|
|
135
|
+
return msgs.slice(0, closed);
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
// A parallel turn's write-back: its submission and everything the turn produced, ready to place. Built
|
|
139
|
+
// once, when the parallel turn settles; placed by whoever holds the session at that moment (see
|
|
140
|
+
// `merges`).
|
|
123
141
|
interface ParallelReply {
|
|
124
142
|
traceId: string;
|
|
125
|
-
|
|
143
|
+
/** The turn's head first, then its whole span — thinking, tool calls, their results, its markers and
|
|
144
|
+
* its final reply. Non-empty by construction, which is what keeps `messages[0]` the head: every
|
|
145
|
+
* placement path dedupes on that id. */
|
|
146
|
+
messages: [Message, ...Message[]];
|
|
126
147
|
/** The id of the message this reply's copy was cut AT — the running turn's head when it was submitted,
|
|
127
148
|
* captured then. The pair belongs immediately ahead of it, because that is where the history the reply
|
|
128
149
|
* was generated against ended. Absent ⇒ the copy was cut at the end of the session (nothing was
|
|
@@ -881,12 +902,12 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
881
902
|
|
|
882
903
|
/**
|
|
883
904
|
* A parallel turn: the submission runs at once on a nested runner over a private copy of the session's
|
|
884
|
-
* completed turns, and
|
|
905
|
+
* completed turns, and its submission plus everything it produced come back (`ParallelReply`). The copy ends where the
|
|
885
906
|
* running turn's own messages begin (`head`), plus any replies already placed into that turn but not
|
|
886
907
|
* yet committed (`interjected`) — both captured at submit, before any await could outlive the turn.
|
|
887
908
|
*
|
|
888
|
-
* Its events are forwarded under its own traceId as progress
|
|
889
|
-
* the copy
|
|
909
|
+
* Its events are forwarded under its own traceId as progress; what persists is the span the write-back
|
|
910
|
+
* carries, read from the copy once the turn has settled. A nested runner rather than `runSession` so the turn is an ordinary
|
|
890
911
|
* one — screen, followup, accounting, media — with nothing re-implemented.
|
|
891
912
|
*/
|
|
892
913
|
const runParallel = async (
|
|
@@ -910,6 +931,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
910
931
|
const have = new Set(prefix.map(m => m.id));
|
|
911
932
|
const copy = { ...stored, messages: [...prefix, ...p.interjected.filter(m => !have.has(m.id))] };
|
|
912
933
|
const running = p.head?.request ?? '';
|
|
934
|
+
const framing = running ? parallelFraming(running) : undefined;
|
|
913
935
|
|
|
914
936
|
const store = new MemoryStore<Session>();
|
|
915
937
|
await store.set(id, copy);
|
|
@@ -941,10 +963,21 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
941
963
|
const msgs = final?.messages ?? [];
|
|
942
964
|
const hIdx = msgs.findIndex(m => m.role === 'user' && m.traceId === p.traceId);
|
|
943
965
|
const qHead = msgs[hIdx];
|
|
944
|
-
//
|
|
945
|
-
//
|
|
946
|
-
|
|
947
|
-
|
|
966
|
+
// The turn's WHOLE span crosses back, not just its final text: thinking, tool calls, their results
|
|
967
|
+
// and any markers. A history holding only the answer makes everything a parallel turn actually did
|
|
968
|
+
// unrecoverable the moment the live stream is gone — `determine_provenance` reads a real tool result
|
|
969
|
+
// as fabricated, and so does a person scrolling back. The span is placed exactly where the copy was
|
|
970
|
+
// cut, so it sits in the history it was generated against, and `closedSpan` is what keeps it valid
|
|
971
|
+
// wire. Thinking rides along persisted but unsubmitted (every adapter elides a historical block),
|
|
972
|
+
// and a foreign round-trip token is the adapters' to drop by `providerName`, which these messages
|
|
973
|
+
// carry — so nothing here depends on which provider ran the turn.
|
|
974
|
+
const span = hIdx >= 0 ? closedSpan(msgs.slice(hIdx + 1)) : [];
|
|
975
|
+
// Answered iff the span ENDS with assistant text. Not "contains some": a turn aborted after
|
|
976
|
+
// "let me check that" has said nothing, and a span ending on a tool result needs the note after it
|
|
977
|
+
// to close the turn on an assistant message.
|
|
978
|
+
const tail = span[span.length - 1];
|
|
979
|
+
const answer = tail?.role === 'assistant'
|
|
980
|
+
? tail.content.filter(c => c.type === 'text' && c.text.trim() !== '') : [];
|
|
948
981
|
const ended = rec.replay.findLast(e => e.type === 'done' || e.type === 'aborted' || e.type === 'error' || e.type === 'cancelled');
|
|
949
982
|
const reason = ended?.type === 'aborted' ? `was stopped (${ended.reason})`
|
|
950
983
|
: ended?.type === 'cancelled' ? 'was stopped before it started'
|
|
@@ -954,14 +987,19 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
954
987
|
traceId: p.traceId,
|
|
955
988
|
...(p.head !== undefined ? { head: p.head.id } : {}),
|
|
956
989
|
messages: [
|
|
957
|
-
// The copy's own head, carrying its accounting,
|
|
990
|
+
// The copy's own head, carrying its accounting, with the framing block taken back OFF — that
|
|
991
|
+
// block is this turn's own context and not something the person wrote. Subtracted rather than
|
|
992
|
+
// replaced with `p.content` wholesale: a `contextual` trigger folds its correction DURABLY onto
|
|
993
|
+
// the head, and overwriting the content dropped exactly that, so the correction informed the
|
|
994
|
+
// answer and then vanished from the history the answer sits in.
|
|
958
995
|
qHead !== undefined
|
|
959
|
-
? { ...qHead, content:
|
|
996
|
+
? { ...qHead, content: qHead.content.filter(c => !(framing !== undefined && c.type === 'text'
|
|
997
|
+
&& c.origin === framing.origin && c.text === framing.text)) }
|
|
960
998
|
: createMessage({ role: 'user', content: p.content, traceId: p.traceId, providerName: p.provider }),
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
999
|
+
...span,
|
|
1000
|
+
...(answer.length > 0 ? [] : [createMessage({
|
|
1001
|
+
role: 'assistant', content: [{ type: 'text', text: `(No reply — this message's parallel turn ${reason}.)` }],
|
|
1002
|
+
traceId: p.traceId, providerName: p.provider })]),
|
|
965
1003
|
],
|
|
966
1004
|
};
|
|
967
1005
|
} catch (e) {
|