@matatbread/matbot-core 0.2.3 → 0.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/index.ts +3 -0
- package/src/knowledge/lookup-knowledge-index.ts +109 -23
- package/src/runner.ts +17 -5
- package/src/session-runner.ts +4 -4
- package/src/session.ts +6 -2
- package/src/single-turn.ts +5 -3
- package/src/usage.ts +36 -0
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"node",
|
|
5
5
|
"browser"
|
|
6
6
|
],
|
|
7
|
-
"version": "0.2.
|
|
7
|
+
"version": "0.2.5",
|
|
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.2.
|
|
38
|
+
"@matatbread/matbot-plugin-api": "^0.2.5"
|
|
39
39
|
},
|
|
40
40
|
"devDependencies": {
|
|
41
41
|
"typescript": "^5.8.0"
|
package/src/index.ts
CHANGED
|
@@ -25,8 +25,11 @@ export {
|
|
|
25
25
|
installPrincipalCarrier, currentPrincipal, tryCurrentPrincipal,
|
|
26
26
|
runAs, enterPrincipal, createConstantPrincipalCarrier,
|
|
27
27
|
contextSwitch, onContextQuiesce, flushIfQuiescent,
|
|
28
|
+
installUsageCarrier, createSerialUsageCarrier, recordUsage, currentUsageSink, withUsageScope,
|
|
29
|
+
lastActivityAt,
|
|
28
30
|
} from '@matatbread/matbot-plugin-api';
|
|
29
31
|
export * from './session.js';
|
|
32
|
+
export * from './usage.js';
|
|
30
33
|
export * from './hooks.js';
|
|
31
34
|
export * from './runner.js';
|
|
32
35
|
export * from './session-runner.js';
|
|
@@ -1,9 +1,101 @@
|
|
|
1
1
|
import type { KnowledgeIndex, KnowledgeEntry } from '@matatbread/matbot-plugin-api';
|
|
2
2
|
|
|
3
|
-
// In-memory KnowledgeIndex for development and environments without a
|
|
4
|
-
// Entries are held in a Set; search scores each
|
|
5
|
-
//
|
|
6
|
-
//
|
|
3
|
+
// In-memory KnowledgeIndex for development and environments without a persistent/reranking
|
|
4
|
+
// backend. Entries are held in a Set; search scores each entry across its curated metadata
|
|
5
|
+
// (entities, tags, summary) as well as its content, then returns the clear winners.
|
|
6
|
+
//
|
|
7
|
+
// Scoring rationale — the metadata is the signal, the body is the tie-breaker. A curated entity
|
|
8
|
+
// match is the strongest evidence an entry is *about* a term; a heading is next; the summary is a
|
|
9
|
+
// tight, deliberately-written description; raw body text is the weakest (it is incidental and
|
|
10
|
+
// length-biased — a long document mentions everything). Body occurrences are therefore saturated
|
|
11
|
+
// (tf/(tf+k)), so a wordy entry can't win on sheer volume of passing mentions over a short entry
|
|
12
|
+
// whose curated entities name the term outright. This deliberately does NOT weight by
|
|
13
|
+
// procedural/informational class: that is not a relevance signal (a how-to about X is a fair match
|
|
14
|
+
// for X) — it lives on the entry as metadata for other consumers, not here.
|
|
15
|
+
const WEIGHT = {
|
|
16
|
+
entityExact: 10, // the term IS a curated entity — strongest evidence
|
|
17
|
+
entityFuzzy: 4, // the term is only a substring of an entity ("matt" in "plant matter") — weaker
|
|
18
|
+
tag: 4,
|
|
19
|
+
summary: 3, // per occurrence, capped (the summary is short, so this is naturally bounded)
|
|
20
|
+
headingH1: 8,
|
|
21
|
+
headingH2: 5,
|
|
22
|
+
headingH3: 3,
|
|
23
|
+
body: 2, // multiplied by a 0–1 saturation factor, so this is the per-term ceiling
|
|
24
|
+
} as const;
|
|
25
|
+
|
|
26
|
+
// Body term-frequency saturation: contribution = WEIGHT.body * tf/(tf + BODY_K). Diminishing
|
|
27
|
+
// returns past a couple of mentions keep long documents from dominating on raw count.
|
|
28
|
+
const BODY_K = 3;
|
|
29
|
+
|
|
30
|
+
// Returned window: the clear winners by cumulative-score coverage, but always offer at least MIN
|
|
31
|
+
// alternatives (when they exist) so a consumer can re-rank/filter, and never more than MAX.
|
|
32
|
+
const COVERAGE = 0.5;
|
|
33
|
+
const MIN_RESULTS = 3;
|
|
34
|
+
const MAX_RESULTS = 10;
|
|
35
|
+
|
|
36
|
+
function escapeRegExp(s: string): string {
|
|
37
|
+
return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Whole-word occurrence count, for free text (content, summary). Word-boundary matching avoids the
|
|
41
|
+
// substring trap — "matt" must not match "matter", "owl" must not match "fowl" — that plain
|
|
42
|
+
// indexOf() falls into. A multi-word term ("inner voice") is matched as a phrase.
|
|
43
|
+
function countWordMatches(haystack: string, term: string): number {
|
|
44
|
+
const m = haystack.match(new RegExp(`\\b${escapeRegExp(term)}\\b`, 'g'));
|
|
45
|
+
return m ? m.length : 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Looser, substring-in-either-direction match for CURATED fields (entities, tags), where fuzziness
|
|
49
|
+
// is wanted: "matt" should match the entity "Matthew Woolf", "volvo" the entity "my volvo". Guarded
|
|
50
|
+
// so the contained string is at least 3 chars, keeping "us" out of "user"/"bus".
|
|
51
|
+
function fuzzyMatch(a: string, b: string): boolean {
|
|
52
|
+
if (a === b) return true;
|
|
53
|
+
if (b.length >= 3 && a.includes(b)) return true;
|
|
54
|
+
if (a.length >= 3 && b.includes(a)) return true;
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function headingWeight(level: number): number {
|
|
59
|
+
if (level === 1) return WEIGHT.headingH1;
|
|
60
|
+
if (level === 2) return WEIGHT.headingH2;
|
|
61
|
+
return WEIGHT.headingH3;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function scoreContent(content: string, term: string): number {
|
|
65
|
+
let headingScore = 0;
|
|
66
|
+
let bodyTf = 0;
|
|
67
|
+
for (const line of content.split('\n')) {
|
|
68
|
+
const m = /^(#{1,3})(?!#)\s+(.+)/.exec(line);
|
|
69
|
+
if (m) {
|
|
70
|
+
if (countWordMatches(m[2]!.toLowerCase(), term) > 0) headingScore += headingWeight(m[1]!.length);
|
|
71
|
+
} else {
|
|
72
|
+
bodyTf += countWordMatches(line.toLowerCase(), term);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
const bodyScore = bodyTf > 0 ? WEIGHT.body * (bodyTf / (bodyTf + BODY_K)) : 0;
|
|
76
|
+
return headingScore + bodyScore;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function scoreEntry(entry: KnowledgeEntry, terms: Array<{ term: string }>): number {
|
|
80
|
+
const entities = entry.entities.map(e => e.toLowerCase());
|
|
81
|
+
const tags = entry.tags.map(t => t.toLowerCase());
|
|
82
|
+
const summary = entry.summary.toLowerCase();
|
|
83
|
+
let score = 0;
|
|
84
|
+
for (const { term } of terms) {
|
|
85
|
+
const t = term.toLowerCase().trim();
|
|
86
|
+
if (t.length < 2) continue;
|
|
87
|
+
// Best single entity match for this term: exact beats fuzzy-substring, and we don't stack
|
|
88
|
+
// multiple entity hits for one term (one entity naming the term is the signal, not how many).
|
|
89
|
+
if (entities.includes(t)) score += WEIGHT.entityExact;
|
|
90
|
+
else if (entities.some(e => fuzzyMatch(e, t))) score += WEIGHT.entityFuzzy;
|
|
91
|
+
if (tags.some(g => fuzzyMatch(g, t))) score += WEIGHT.tag;
|
|
92
|
+
const sf = countWordMatches(summary, t);
|
|
93
|
+
if (sf > 0) score += WEIGHT.summary * Math.min(sf, 2);
|
|
94
|
+
score += scoreContent(entry.content, t);
|
|
95
|
+
}
|
|
96
|
+
return score;
|
|
97
|
+
}
|
|
98
|
+
|
|
7
99
|
export class LookupKnowledgeIndex implements KnowledgeIndex {
|
|
8
100
|
readonly docs = new Set<KnowledgeEntry>();
|
|
9
101
|
|
|
@@ -23,35 +115,29 @@ export class LookupKnowledgeIndex implements KnowledgeIndex {
|
|
|
23
115
|
}
|
|
24
116
|
|
|
25
117
|
async search(
|
|
26
|
-
terms:
|
|
118
|
+
terms: Array<{ term: string; context?: string }>,
|
|
27
119
|
_signal: AbortSignal,
|
|
28
120
|
): Promise<KnowledgeEntry[]> {
|
|
29
|
-
|
|
121
|
+
if (terms.length === 0) return [];
|
|
30
122
|
|
|
123
|
+
const scored: Array<{ entry: KnowledgeEntry; score: number }> = [];
|
|
31
124
|
for (const entry of this.docs) {
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
for (const { term } of terms) {
|
|
35
|
-
const t = term.toLowerCase();
|
|
36
|
-
let pos = 0;
|
|
37
|
-
while ((pos = text.indexOf(t, pos)) !== -1) {
|
|
38
|
-
score++;
|
|
39
|
-
pos += t.length;
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
if (score > 0) results.push({ entry, score });
|
|
125
|
+
const score = scoreEntry(entry, terms);
|
|
126
|
+
if (score > 0) scored.push({ entry, score });
|
|
43
127
|
}
|
|
128
|
+
scored.sort((a, b) => b.score - a.score);
|
|
44
129
|
|
|
45
|
-
|
|
130
|
+
const total = scored.reduce((sum, r) => sum + r.score, 0);
|
|
131
|
+
if (total <= 0) return [];
|
|
46
132
|
|
|
47
|
-
const
|
|
48
|
-
const threshold = total * 0.5;
|
|
49
|
-
let cumulative = 0;
|
|
133
|
+
const target = total * COVERAGE;
|
|
50
134
|
const top: KnowledgeEntry[] = [];
|
|
51
|
-
|
|
135
|
+
let cumulative = 0;
|
|
136
|
+
for (const { entry, score } of scored) {
|
|
52
137
|
top.push(entry);
|
|
53
138
|
cumulative += score;
|
|
54
|
-
if (
|
|
139
|
+
if (top.length >= MAX_RESULTS) break;
|
|
140
|
+
if (top.length >= MIN_RESULTS && cumulative >= target) break;
|
|
55
141
|
}
|
|
56
142
|
return top;
|
|
57
143
|
}
|
package/src/runner.ts
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type {
|
|
2
|
-
Session, MessageContent,
|
|
2
|
+
Session, MessageContent, Usage,
|
|
3
3
|
PipelineEvent, RunConfig, ProviderAdapter, ProviderConfig,
|
|
4
4
|
Tool, ToolRegistry, ToolContext, Store, FileStore, SystemContextRegistry, Vault, PromptFn, FormField,
|
|
5
5
|
} from './types.js';
|
|
6
6
|
import type { MatbotPlugin } from './plugin.js';
|
|
7
|
+
import { currentUsageSink } from '@matatbread/matbot-plugin-api';
|
|
7
8
|
import { HookRegistry } from './hooks.js';
|
|
8
9
|
import { appendMessage, createMessage } from './session.js';
|
|
10
|
+
import { addUsage } from './usage.js';
|
|
9
11
|
|
|
10
12
|
export interface RunSessionOpts {
|
|
11
13
|
session: Session;
|
|
@@ -40,7 +42,7 @@ export interface RunSessionOpts {
|
|
|
40
42
|
* re-run of the originating user turn. Empty/absent for an ordinary turn.
|
|
41
43
|
*/
|
|
42
44
|
injectedEphemeral?: MessageContent[];
|
|
43
|
-
loadPlugin: (specifier: string, prompt?: PromptFn) => Promise<MatbotPlugin>;
|
|
45
|
+
loadPlugin: (specifier: string, prompt?: PromptFn, refresh?: boolean) => Promise<MatbotPlugin>;
|
|
44
46
|
unloadPlugin: (specifier: string) => Promise<boolean>;
|
|
45
47
|
}
|
|
46
48
|
|
|
@@ -130,6 +132,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
130
132
|
const pendingCalls: Array<{ id: string; name: string; input: unknown }> = [];
|
|
131
133
|
const assistantParts: MessageContent[] = [];
|
|
132
134
|
let textAcc = '';
|
|
135
|
+
let turnUsage: Usage | undefined;
|
|
133
136
|
|
|
134
137
|
// One provider call. The outgoing array (system context + history, with screen's ephemeral
|
|
135
138
|
// blocks appended onto the tail message) is assembled here and handed to `contribute` hooks for
|
|
@@ -172,6 +175,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
172
175
|
pendingCalls.push({ id: ev.id, name: ev.name, input: ev.input });
|
|
173
176
|
break;
|
|
174
177
|
case 'usage':
|
|
178
|
+
turnUsage = addUsage(turnUsage, ev);
|
|
175
179
|
yield { type: 'usage', inputTokens: ev.inputTokens, outputTokens: ev.outputTokens, traceId,
|
|
176
180
|
...(ev.costUsd !== undefined ? { costUsd: ev.costUsd } : {}),
|
|
177
181
|
...(ev.cacheReadTokens !== undefined ? { cacheReadTokens: ev.cacheReadTokens } : {}),
|
|
@@ -188,6 +192,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
188
192
|
if (assistantParts.length > 0) {
|
|
189
193
|
session = appendMessage(session, createMessage({
|
|
190
194
|
role: 'assistant', content: assistantParts, traceId, providerName: config.provider,
|
|
195
|
+
...(turnUsage !== undefined ? { usage: turnUsage } : {}),
|
|
191
196
|
}));
|
|
192
197
|
}
|
|
193
198
|
await store.set(session.id, session);
|
|
@@ -210,6 +215,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
210
215
|
content: assistantParts,
|
|
211
216
|
traceId,
|
|
212
217
|
providerName: config.provider,
|
|
218
|
+
...(turnUsage !== undefined ? { usage: turnUsage } : {}),
|
|
213
219
|
});
|
|
214
220
|
session = appendMessage(session, assistantMsg);
|
|
215
221
|
} else {
|
|
@@ -257,13 +263,17 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
257
263
|
|
|
258
264
|
let result: unknown;
|
|
259
265
|
let isError = false;
|
|
266
|
+
// Token accounting for completions this tool runs (via singleTurn/complete) accrues into the
|
|
267
|
+
// ambient turn sink; slice off whatever it appends during *this* call to attribute it here.
|
|
268
|
+
const usageSink = currentUsageSink();
|
|
269
|
+
const usageMark = usageSink?.length ?? 0;
|
|
260
270
|
const startedAt = Date.now();
|
|
261
271
|
|
|
262
272
|
const toolCtx: ToolContext = {
|
|
263
273
|
callId: tc.id, session, signal, vault,
|
|
264
274
|
provider: config.provider,
|
|
265
275
|
prompt: promptFn,
|
|
266
|
-
loadPlugin: (specifier: string) => opts.loadPlugin(specifier, promptFn),
|
|
276
|
+
loadPlugin: (specifier: string, refresh?: boolean) => opts.loadPlugin(specifier, promptFn, refresh),
|
|
267
277
|
unloadPlugin: opts.unloadPlugin,
|
|
268
278
|
...(opts.workdir !== undefined ? { workdir: opts.workdir } : {}),
|
|
269
279
|
...(opts.configPath !== undefined ? { configPath: opts.configPath } : {}),
|
|
@@ -278,7 +288,7 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
278
288
|
case 'file': yield { type: 'file', handle: toolEv.handle, traceId }; break;
|
|
279
289
|
case 'result': result = toolEv.value; break;
|
|
280
290
|
case 'marker': toolMarkers.push({ type: 'marker', creator: toolEv.creator, data: toolEv.data }); break;
|
|
281
|
-
case 'progress': break;
|
|
291
|
+
case 'progress': yield { type: 'tool:progress', callId: tc.id, pct: toolEv.pct, ...(toolEv.message !== undefined ? { message: toolEv.message } : {}), traceId }; break;
|
|
282
292
|
case 'error': result = {
|
|
283
293
|
error: toolEv.message,
|
|
284
294
|
...(toolEv.stdout !== undefined ? { stdout: toolEv.stdout } : {}),
|
|
@@ -299,7 +309,9 @@ export async function* runSession(opts: RunSessionOpts): AsyncIterable<PipelineE
|
|
|
299
309
|
tool, result, isError, durationMs: Date.now() - startedAt,
|
|
300
310
|
});
|
|
301
311
|
|
|
302
|
-
|
|
312
|
+
const callUsage = usageSink ? usageSink.slice(usageMark) : [];
|
|
313
|
+
toolResults.push({ type: 'tool-result', id: tc.id, result, isError,
|
|
314
|
+
...(callUsage.length > 0 ? { usage: callUsage } : {}) });
|
|
303
315
|
yield { type: 'tool:end', callId: tc.id, result, isError, traceId };
|
|
304
316
|
}
|
|
305
317
|
|
package/src/session-runner.ts
CHANGED
|
@@ -6,7 +6,7 @@ import type {
|
|
|
6
6
|
import type { MatbotPlugin } from './plugin.js';
|
|
7
7
|
import type { HookRegistry } from './hooks.js';
|
|
8
8
|
import { appendMessage, createMessage } from './session.js';
|
|
9
|
-
import { contextSwitch } from '@matatbread/matbot-plugin-api';
|
|
9
|
+
import { contextSwitch, withUsageScope } from '@matatbread/matbot-plugin-api';
|
|
10
10
|
import { runSession } from './runner.js';
|
|
11
11
|
|
|
12
12
|
export interface SessionRunnerDeps {
|
|
@@ -19,7 +19,7 @@ export interface SessionRunnerDeps {
|
|
|
19
19
|
files?: FileStore;
|
|
20
20
|
workdir?: string;
|
|
21
21
|
configPath?: string;
|
|
22
|
-
loadPlugin: (specifier: string, prompt?: PromptFn) => Promise<MatbotPlugin>;
|
|
22
|
+
loadPlugin: (specifier: string, prompt?: PromptFn, refresh?: boolean) => Promise<MatbotPlugin>;
|
|
23
23
|
unloadPlugin: (specifier: string) => Promise<boolean>;
|
|
24
24
|
}
|
|
25
25
|
|
|
@@ -219,7 +219,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
219
219
|
// the callback: an async iterator returned out of the scope would lose it before it pulls.
|
|
220
220
|
// contextSwitch (not bare runAs): a turn is the transactional unit, so a StorageBackend swap
|
|
221
221
|
// deferred during it lands at this scope's quiescent edge — never mid-CAS.
|
|
222
|
-
await contextSwitch(head.principal, async () => {
|
|
222
|
+
await contextSwitch(head.principal, () => withUsageScope(async () => {
|
|
223
223
|
for await (const ev of runSession({
|
|
224
224
|
session,
|
|
225
225
|
config: { provider: head.provider, traceId: head.traceId },
|
|
@@ -242,7 +242,7 @@ export function createSessionRunner(deps: SessionRunnerDeps): SessionRunner {
|
|
|
242
242
|
})) {
|
|
243
243
|
emit(s, ev);
|
|
244
244
|
}
|
|
245
|
-
});
|
|
245
|
+
}));
|
|
246
246
|
|
|
247
247
|
// followup — post-commit, in the queue owner. A hook reads the just-committed turn and may
|
|
248
248
|
// head-enqueue a robo follow-up (its own real turn, running next). Skipped on abort; runs
|
package/src/session.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { Session, Message, MessageContent, MessageRole, Principal } from './types.js';
|
|
1
|
+
import type { Session, Message, MessageContent, MessageRole, Principal, Usage } from './types.js';
|
|
2
2
|
|
|
3
3
|
export interface CreateSessionOpts {
|
|
4
4
|
ownerPrincipal: Principal;
|
|
@@ -32,10 +32,12 @@ export function createSession(opts: CreateSessionOpts): Session {
|
|
|
32
32
|
}
|
|
33
33
|
|
|
34
34
|
export function appendMessage(session: Session, message: Message): Session {
|
|
35
|
+
// updatedAt tracks conversational activity: the new last message's timestamp (the lastActivityAt
|
|
36
|
+
// invariant), not a fresh `now()` — so it stays consistent with structural edits that preserve the tail.
|
|
35
37
|
return {
|
|
36
38
|
...session,
|
|
37
39
|
messages: [...session.messages, message],
|
|
38
|
-
updatedAt:
|
|
40
|
+
updatedAt: message.createdAt,
|
|
39
41
|
version: crypto.randomUUID(),
|
|
40
42
|
};
|
|
41
43
|
}
|
|
@@ -45,6 +47,7 @@ export function createMessage(opts: {
|
|
|
45
47
|
content: MessageContent[];
|
|
46
48
|
traceId: string;
|
|
47
49
|
providerName?: string;
|
|
50
|
+
usage?: Usage;
|
|
48
51
|
metadata?: Record<string, unknown>;
|
|
49
52
|
}): Message {
|
|
50
53
|
return {
|
|
@@ -54,6 +57,7 @@ export function createMessage(opts: {
|
|
|
54
57
|
createdAt: new Date().toISOString(),
|
|
55
58
|
traceId: opts.traceId,
|
|
56
59
|
...(opts.providerName !== undefined ? { providerName: opts.providerName } : {}),
|
|
60
|
+
...(opts.usage !== undefined ? { usage: opts.usage } : {}),
|
|
57
61
|
...(opts.metadata !== undefined ? { metadata: opts.metadata } : {}),
|
|
58
62
|
};
|
|
59
63
|
}
|
package/src/single-turn.ts
CHANGED
|
@@ -33,7 +33,9 @@ export function createSingleTurnTool(services: MatbotMachine): Tool {
|
|
|
33
33
|
signal: ctx.signal,
|
|
34
34
|
...(typeof args.system === 'string' ? { system: args.system } : {}),
|
|
35
35
|
});
|
|
36
|
-
|
|
36
|
+
// Usage is accounting, not conversation: it is captured ambiently (the host's complete() reports
|
|
37
|
+
// it into the turn's usage sink, attributed to this tool call) — the model gets only the text.
|
|
38
|
+
yield { type: 'result', value: { text: res.text } };
|
|
37
39
|
},
|
|
38
40
|
};
|
|
39
41
|
|
|
@@ -42,13 +44,13 @@ export function createSingleTurnTool(services: MatbotMachine): Tool {
|
|
|
42
44
|
description:
|
|
43
45
|
'Run a single-turn completion against a configured provider and return its reply. This is a ' +
|
|
44
46
|
'one-shot call — not your own response: you send one `prompt` (and optional `system`), and get ' +
|
|
45
|
-
'back its text
|
|
47
|
+
'back its text. Use it to consult a different model — e.g. a second, ' +
|
|
46
48
|
'different-lineage model critiquing your draft, or any generation that should run on a specific ' +
|
|
47
49
|
'provider. `provider` is OPTIONAL: omit it to run on the current conversation\'s model, or name ' +
|
|
48
50
|
'a configured provider to switch models (list or add providers with the provider tool).\n\n' +
|
|
49
51
|
'Parameters (TypeScript):\n' +
|
|
50
52
|
'```ts\n' +
|
|
51
|
-
'{ provider?: string; prompt: string; system?: string } // -> { text
|
|
53
|
+
'{ provider?: string; prompt: string; system?: string } // -> { text }\n' +
|
|
52
54
|
'```',
|
|
53
55
|
inputSchema: {
|
|
54
56
|
type: 'object',
|
package/src/usage.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Message, Usage } from './types.js';
|
|
2
|
+
|
|
3
|
+
/** Fold one usage tally into a running total; optional fields are summed only when either side has them. */
|
|
4
|
+
export function addUsage(acc: Usage | undefined, next: Usage): Usage {
|
|
5
|
+
const a = acc ?? { inputTokens: 0, outputTokens: 0 };
|
|
6
|
+
const add = (x: number | undefined, y: number | undefined): number | undefined =>
|
|
7
|
+
x === undefined && y === undefined ? undefined : (x ?? 0) + (y ?? 0);
|
|
8
|
+
return {
|
|
9
|
+
inputTokens: a.inputTokens + next.inputTokens,
|
|
10
|
+
outputTokens: a.outputTokens + next.outputTokens,
|
|
11
|
+
...(((c) => c !== undefined ? { costUsd: c } : {}))(add(a.costUsd, next.costUsd)),
|
|
12
|
+
...(((c) => c !== undefined ? { cacheReadTokens: c } : {}))(add(a.cacheReadTokens, next.cacheReadTokens)),
|
|
13
|
+
...(((c) => c !== undefined ? { cacheCreationTokens: c } : {}))(add(a.cacheCreationTokens, next.cacheCreationTokens)),
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Aggregate the token accounting recorded on a set of messages, keyed by the provider billed: an
|
|
19
|
+
* assistant turn's own `Message.usage` (billed to its `providerName`) plus every `tool-result` block's
|
|
20
|
+
* `usage` records (each provider-tagged — a tool may run completions against several). Pass a single
|
|
21
|
+
* turn's messages (filtered by `traceId`) for a per-turn breakdown, or a whole session for its total.
|
|
22
|
+
*/
|
|
23
|
+
export function usageByProvider(messages: Iterable<Message>): Map<string, Usage> {
|
|
24
|
+
const out = new Map<string, Usage>();
|
|
25
|
+
for (const m of messages) {
|
|
26
|
+
if (m.usage !== undefined && m.providerName !== undefined) {
|
|
27
|
+
out.set(m.providerName, addUsage(out.get(m.providerName), m.usage));
|
|
28
|
+
}
|
|
29
|
+
for (const c of m.content) {
|
|
30
|
+
if (c.type === 'tool-result' && c.usage !== undefined) {
|
|
31
|
+
for (const r of c.usage) out.set(r.provider, addUsage(out.get(r.provider), r.usage));
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|