@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 CHANGED
@@ -4,7 +4,7 @@
4
4
  "node",
5
5
  "browser"
6
6
  ],
7
- "version": "0.4.18",
7
+ "version": "0.4.19",
8
8
  "engines": {
9
9
  "node": ">=24.0.0"
10
10
  },
@@ -35,7 +35,7 @@
35
35
  "access": "public"
36
36
  },
37
37
  "dependencies": {
38
- "@matatbread/matbot-plugin-api": "^0.4.18"
38
+ "@matatbread/matbot-plugin-api": "^0.4.19"
39
39
  },
40
40
  "devDependencies": {
41
41
  "typescript": "^6.0.3"
@@ -1 +1,2 @@
1
1
  export { parseSSE } from './sse.js';
2
+ export { textDocument } from './text-document.js';
@@ -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
- const advertised = opts.toolPresenter
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
@@ -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 write-back: its submission and final reply, ready to place. Built once, when the
122
- // parallel turn settles; placed by whoever holds the session at that moment (see `merges`).
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
- messages: [Message, Message];
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 only it and its final reply come back (`ParallelReply`). The copy ends where the
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, never persisted: its tool calls belong to
889
- * the copy, which is dropped here. A nested runner rather than `runSession` so the turn is an ordinary
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
- // Only the reply's text crosses back: its tool calls stay in the copy (an unpaired one would break
945
- // every later turn), and its thinking was signed against a history that included those tool rounds.
946
- const last = hIdx >= 0 ? msgs.slice(hIdx + 1).findLast(m => m.role === 'assistant') : undefined;
947
- const answer = (last?.content ?? []).filter(c => c.type === 'text');
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, but with the content as the person submitted it.
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: p.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
- last !== undefined && answer.length > 0
962
- ? { ...last, content: answer }
963
- : createMessage({ role: 'assistant', content: [{ type: 'text', text: `(No reply — this message's parallel turn ${reason}.)` }],
964
- traceId: p.traceId, providerName: p.provider }),
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) {