@viveka10/cap-ai-token-optimizer 0.0.0-stage → 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +71 -0
- package/LICENSE +21 -0
- package/README.md +746 -2
- package/SECURITY.md +39 -0
- package/cds-plugin.js +50 -0
- package/db/token-usage-log.cds +44 -0
- package/docs/integration-guide.md +140 -0
- package/index.d.ts +291 -0
- package/index.js +36 -0
- package/lib/cache/index.js +67 -0
- package/lib/client/adapters.js +263 -0
- package/lib/client/instrument.js +124 -0
- package/lib/config.js +128 -0
- package/lib/exporters/console.js +18 -0
- package/lib/exporters/db.js +169 -0
- package/lib/exporters/http.js +143 -0
- package/lib/exporters/index.js +42 -0
- package/lib/exporters/otel.js +126 -0
- package/lib/metrics/classifier.js +175 -0
- package/lib/metrics/collector.js +206 -0
- package/lib/metrics/events.js +124 -0
- package/lib/optimizer.js +467 -0
- package/lib/pipeline/index.js +90 -0
- package/lib/pipeline/messages.js +62 -0
- package/lib/pipeline/steps/dedupe.js +103 -0
- package/lib/pipeline/steps/history.js +63 -0
- package/lib/pipeline/steps/normalize.js +88 -0
- package/lib/pipeline/steps/outputCap.js +28 -0
- package/lib/pipeline/steps/tools.js +157 -0
- package/lib/pipeline/text.js +166 -0
- package/lib/tokens/estimator.js +163 -0
- package/lib/util/cds.js +13 -0
- package/lib/util/hash.js +40 -0
- package/lib/util/log.js +37 -0
- package/lib/util/peer.js +55 -0
- package/package.json +131 -4
- package/srv/admin-service.cds +83 -0
- package/srv/admin-service.js +41 -0
- package/srv/admin-usage-log.cds +8 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* dedupe: remove exact duplicate instructions within one request.
|
|
4
|
+
* - messages: a system message identical to an earlier one is dropped; a user message
|
|
5
|
+
* identical to the user message right before it is dropped.
|
|
6
|
+
* - paragraphs: a paragraph (blank-line separated, at least `minParagraphLength` chars)
|
|
7
|
+
* that repeats a system paragraph is removed from later system and user messages;
|
|
8
|
+
* in user messages, an older copy of a paragraph that is repeated in a later user
|
|
9
|
+
* message is removed (the latest copy stays where it is).
|
|
10
|
+
* Messages with code fences, tool and assistant messages are never edited, and a message
|
|
11
|
+
* is never emptied.
|
|
12
|
+
*/
|
|
13
|
+
const { isLocked, isSystem } = require('../messages')
|
|
14
|
+
const { stableStringify } = require('../../util/hash')
|
|
15
|
+
|
|
16
|
+
const DEFAULTS = { messages: true, paragraphs: true, minParagraphLength: 30 }
|
|
17
|
+
const PARA_SPLIT = /\n[ \t]*\n/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* @param {import('../index').StepInput} input
|
|
21
|
+
* @param {object} options
|
|
22
|
+
*/
|
|
23
|
+
function dedupe ({ messages }, options) {
|
|
24
|
+
const opts = { ...DEFAULTS, ...options }
|
|
25
|
+
const notes = []
|
|
26
|
+
let out = messages
|
|
27
|
+
if (opts.messages) {
|
|
28
|
+
const before = out.length
|
|
29
|
+
out = dedupeMessages(out)
|
|
30
|
+
if (out.length < before) notes.push(`${before - out.length} duplicate message(s) removed`)
|
|
31
|
+
}
|
|
32
|
+
if (opts.paragraphs) {
|
|
33
|
+
const { messages: next, removed } = dedupeParagraphs(out, opts.minParagraphLength)
|
|
34
|
+
out = next
|
|
35
|
+
if (removed) notes.push(`${removed} duplicate paragraph(s) removed`)
|
|
36
|
+
}
|
|
37
|
+
return { messages: out, notes }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function dedupeMessages (messages) {
|
|
41
|
+
const seenSystem = new Set()
|
|
42
|
+
const out = []
|
|
43
|
+
for (const m of messages) {
|
|
44
|
+
const key = stableStringify({ role: m?.role, content: m?.content })
|
|
45
|
+
if (isSystem(m)) {
|
|
46
|
+
if (seenSystem.has(key) && !isLocked(m)) continue
|
|
47
|
+
seenSystem.add(key)
|
|
48
|
+
} else if (m?.role === 'user' && !isLocked(m)) {
|
|
49
|
+
const prev = out.at(-1)
|
|
50
|
+
if (prev?.role === 'user' && stableStringify({ role: prev.role, content: prev.content }) === key) continue
|
|
51
|
+
}
|
|
52
|
+
out.push(m)
|
|
53
|
+
}
|
|
54
|
+
return out
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const editable = (m) => typeof m?.content === 'string' && !isLocked(m) && !/^[ \t]*(```|~~~)/m.test(m.content)
|
|
58
|
+
const paragraphsOf = (text) => text.split(PARA_SPLIT)
|
|
59
|
+
const norm = (p) => p.trim()
|
|
60
|
+
|
|
61
|
+
function dedupeParagraphs (messages, minLength) {
|
|
62
|
+
const long = (p) => norm(p).length >= minLength
|
|
63
|
+
// paragraphs that appear in a system message, and in which user message a paragraph last appears
|
|
64
|
+
const systemParas = new Set()
|
|
65
|
+
const lastUserIndex = new Map()
|
|
66
|
+
messages.forEach((m, i) => {
|
|
67
|
+
if (typeof m?.content !== 'string') return
|
|
68
|
+
for (const p of paragraphsOf(m.content)) {
|
|
69
|
+
if (!long(p)) continue
|
|
70
|
+
if (isSystem(m)) systemParas.add(norm(p))
|
|
71
|
+
else if (m.role === 'user') lastUserIndex.set(norm(p), i)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
let removed = 0
|
|
76
|
+
const seenSystem = new Set()
|
|
77
|
+
const out = messages.map((m, i) => {
|
|
78
|
+
if (!editable(m) || !(isSystem(m) || m.role === 'user')) {
|
|
79
|
+
if (isSystem(m) && typeof m?.content === 'string') for (const p of paragraphsOf(m.content)) if (long(p)) seenSystem.add(norm(p))
|
|
80
|
+
return m
|
|
81
|
+
}
|
|
82
|
+
const seenHere = new Set()
|
|
83
|
+
const kept = []
|
|
84
|
+
let dropped = 0
|
|
85
|
+
for (const p of paragraphsOf(m.content)) {
|
|
86
|
+
const key = norm(p)
|
|
87
|
+
const drop = long(p) && (
|
|
88
|
+
seenHere.has(key) ||
|
|
89
|
+
(isSystem(m) ? seenSystem.has(key) : (systemParas.has(key) || lastUserIndex.get(key) > i))
|
|
90
|
+
)
|
|
91
|
+
if (long(p)) seenHere.add(key)
|
|
92
|
+
if (drop) dropped++
|
|
93
|
+
else kept.push(p)
|
|
94
|
+
}
|
|
95
|
+
if (isSystem(m)) for (const p of kept) if (long(p)) seenSystem.add(norm(p))
|
|
96
|
+
if (!dropped || !kept.some(p => p.trim())) return m
|
|
97
|
+
removed += dropped
|
|
98
|
+
return { ...m, content: kept.join('\n\n') }
|
|
99
|
+
})
|
|
100
|
+
return { messages: out, removed }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
module.exports = { dedupe, DEFAULTS }
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* history: keep system messages plus the last N conversation turns.
|
|
4
|
+
*
|
|
5
|
+
* A turn starts at a user message and includes the assistant and tool messages that follow,
|
|
6
|
+
* so tool calls are never separated from their results. Locked (orchestration template)
|
|
7
|
+
* messages are always kept.
|
|
8
|
+
*
|
|
9
|
+
* Pluggable: `strategy` may be a function `({ messages, maxTurns }) => messages`.
|
|
10
|
+
* Optional `summarizer`: `async ({ dropped, kept }) => message | null`; the returned
|
|
11
|
+
* message is inserted after the leading system messages. No summarizer is built in.
|
|
12
|
+
*/
|
|
13
|
+
const { isLocked, isSystem, ORIGIN } = require('../messages')
|
|
14
|
+
|
|
15
|
+
const DEFAULTS = { maxTurns: 10, strategy: 'sliding-window', summarizer: null }
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Default strategy.
|
|
19
|
+
* @param {{ messages: object[], maxTurns: number }} input
|
|
20
|
+
* @returns {object[]}
|
|
21
|
+
*/
|
|
22
|
+
function slidingWindow ({ messages, maxTurns }) {
|
|
23
|
+
// index where each turn starts
|
|
24
|
+
const starts = []
|
|
25
|
+
messages.forEach((m, i) => { if (m?.role === 'user' && !isSystem(m)) starts.push(i) })
|
|
26
|
+
if (starts.length <= maxTurns) return messages
|
|
27
|
+
const cut = starts[starts.length - maxTurns]
|
|
28
|
+
return messages.filter((m, i) => i >= cut || isSystem(m) || isLocked(m))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const STRATEGIES = { 'sliding-window': slidingWindow }
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @param {import('../index').StepInput} input
|
|
35
|
+
* @param {object} options
|
|
36
|
+
*/
|
|
37
|
+
async function history ({ messages, model }, options) {
|
|
38
|
+
const opts = { ...DEFAULTS, ...options }
|
|
39
|
+
if (!(opts.maxTurns > 0)) return { messages, notes: [] }
|
|
40
|
+
const strategy = typeof opts.strategy === 'function' ? opts.strategy : STRATEGIES[opts.strategy]
|
|
41
|
+
if (!strategy) throw new Error(`Unknown history strategy "${opts.strategy}"`)
|
|
42
|
+
|
|
43
|
+
const kept = strategy({ messages, maxTurns: opts.maxTurns, model })
|
|
44
|
+
if (!Array.isArray(kept)) throw new Error('history strategy must return an array of messages')
|
|
45
|
+
if (kept.length === messages.length) return { messages, notes: [] }
|
|
46
|
+
const keptSet = new Set(kept)
|
|
47
|
+
const dropped = messages.filter(m => !keptSet.has(m))
|
|
48
|
+
if (dropped.some(isLocked)) throw new Error('history strategy dropped a template message')
|
|
49
|
+
const notes = [`${dropped.length} message(s) outside the last ${opts.maxTurns} turns removed`]
|
|
50
|
+
|
|
51
|
+
if (typeof opts.summarizer === 'function') {
|
|
52
|
+
const summary = await opts.summarizer({ dropped, kept, model })
|
|
53
|
+
if (summary) {
|
|
54
|
+
const at = kept.findIndex(m => !isSystem(m))
|
|
55
|
+
const tagged = { ...summary, [ORIGIN]: dropped[0]?.[ORIGIN] }
|
|
56
|
+
kept.splice(at < 0 ? kept.length : at, 0, tagged)
|
|
57
|
+
notes.push('summary of removed turns inserted')
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return { messages: kept, notes }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
module.exports = { history, slidingWindow, STRATEGIES, DEFAULTS }
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* normalize: trim, collapse repeated spaces and blank lines, minify JSON blocks.
|
|
4
|
+
* Fenced code blocks, inline code and indented (code-like) lines keep their whitespace.
|
|
5
|
+
* JSON is minified on the text itself, so numbers, key order and duplicate keys are kept.
|
|
6
|
+
*/
|
|
7
|
+
const { splitFences, joinSegments, tryMinifyJson, splitJsonBlocks } = require('../text')
|
|
8
|
+
const { mapText, isLocked } = require('../messages')
|
|
9
|
+
|
|
10
|
+
const DEFAULTS = { minifyJson: true, minifyJsonFences: true, collapseSpaces: true }
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @param {import('../index').StepInput} input
|
|
14
|
+
* @param {object} options
|
|
15
|
+
*/
|
|
16
|
+
function normalize ({ messages }, options) {
|
|
17
|
+
const opts = { ...DEFAULTS, ...options }
|
|
18
|
+
let changed = 0
|
|
19
|
+
const out = messages.map(m => {
|
|
20
|
+
let next = mapText(m, text => normalizeText(text, opts))
|
|
21
|
+
if (opts.minifyJson && Array.isArray(next?.tool_calls) && !isLocked(next)) {
|
|
22
|
+
next = minifyToolCallArguments(next)
|
|
23
|
+
}
|
|
24
|
+
if (next !== m) changed++
|
|
25
|
+
return next
|
|
26
|
+
})
|
|
27
|
+
return { messages: out, notes: changed ? [`${changed} message(s) normalized`] : [] }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function minifyToolCallArguments (m) {
|
|
31
|
+
let changed = false
|
|
32
|
+
const toolCalls = m.tool_calls.map(tc => {
|
|
33
|
+
const args = tc?.function?.arguments
|
|
34
|
+
if (typeof args !== 'string') return tc
|
|
35
|
+
const min = tryMinifyJson(args)
|
|
36
|
+
if (min === null || min === args) return tc
|
|
37
|
+
changed = true
|
|
38
|
+
return { ...tc, function: { ...tc.function, arguments: min } }
|
|
39
|
+
})
|
|
40
|
+
return changed ? { ...m, tool_calls: toolCalls } : m
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* @param {string} text
|
|
45
|
+
* @param {typeof DEFAULTS} opts
|
|
46
|
+
*/
|
|
47
|
+
function normalizeText (text, opts = DEFAULTS) {
|
|
48
|
+
const all = splitFences(text)
|
|
49
|
+
const segments = all.map((seg, i) => {
|
|
50
|
+
if (seg.type === 'code') return opts.minifyJsonFences && /^json\b/i.test(seg.info ?? '') ? minifyJsonFence(seg) : seg
|
|
51
|
+
const parts = splitJsonBlocks(seg.text).map(p => {
|
|
52
|
+
if (p.type === 'json') return opts.minifyJson ? tryMinifyJson(p.text) : p.text
|
|
53
|
+
return normalizeProse(p.text, opts)
|
|
54
|
+
})
|
|
55
|
+
// blank lines are collapsed in prose only; code keeps its blank lines.
|
|
56
|
+
// Next to a fence, the newline added when segments are joined counts too.
|
|
57
|
+
let prose = parts.join('').replace(/\n{3,}/g, '\n\n')
|
|
58
|
+
if (i > 0) prose = prose.replace(/^\n{2,}/, '\n')
|
|
59
|
+
if (i < all.length - 1) prose = prose.replace(/\n{2,}$/, '\n')
|
|
60
|
+
return { ...seg, text: prose }
|
|
61
|
+
})
|
|
62
|
+
return joinSegments(segments).trim()
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function minifyJsonFence (seg) {
|
|
66
|
+
const lines = seg.text.split('\n')
|
|
67
|
+
if (lines.length < 3) return seg
|
|
68
|
+
const body = lines.slice(1, -1).join('\n')
|
|
69
|
+
const min = tryMinifyJson(body)
|
|
70
|
+
return min === null ? seg : { ...seg, text: `${lines[0].trim()}\n${min}\n${lines.at(-1).trim()}` }
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* @param {string} text
|
|
75
|
+
* @param {typeof DEFAULTS} opts
|
|
76
|
+
*/
|
|
77
|
+
function normalizeProse (text, opts) {
|
|
78
|
+
return text.split('\n').map(line => {
|
|
79
|
+
const trimmed = line.replace(/[ \t\r]+$/, '')
|
|
80
|
+
if (!opts.collapseSpaces || /^( {4,}|\t)/.test(trimmed)) return trimmed
|
|
81
|
+
const indent = /^[ \t]*/.exec(trimmed)[0]
|
|
82
|
+
// keep inline code spans as they are
|
|
83
|
+
const body = trimmed.slice(indent.length).split(/(`[^`\n]*`)/).map((part, i) => (i % 2 ? part : part.replace(/ {2,}/g, ' '))).join('')
|
|
84
|
+
return indent + body
|
|
85
|
+
}).join('\n')
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
module.exports = { normalize, normalizeText, DEFAULTS }
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* outputCap: set max_tokens from the (per-use-case) config when the caller set no limit.
|
|
4
|
+
* Reasoning models (o1/o3/o4, gpt-5) spend output tokens on hidden reasoning, so a cap can
|
|
5
|
+
* produce empty answers; they are skipped unless `reasoningModels: true`.
|
|
6
|
+
* Saves output tokens, which cannot be known before the call, so it reports 0 saved prompt tokens.
|
|
7
|
+
*/
|
|
8
|
+
const { baseModelName } = require('../../tokens/estimator')
|
|
9
|
+
|
|
10
|
+
const DEFAULTS = { default: 1024, reasoningModels: false }
|
|
11
|
+
const REASONING = /^(o\d|gpt-5)/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {import('../index').StepInput} input
|
|
15
|
+
* @param {object} options
|
|
16
|
+
*/
|
|
17
|
+
function outputCap ({ params, model }, options) {
|
|
18
|
+
const opts = { ...DEFAULTS, ...options }
|
|
19
|
+
const p = params ?? {}
|
|
20
|
+
if (p.max_tokens != null || p.max_completion_tokens != null) return { params, notes: [] }
|
|
21
|
+
if (!(opts.default > 0)) return { params, notes: [] }
|
|
22
|
+
if (REASONING.test(baseModelName(model)) && !opts.reasoningModels) {
|
|
23
|
+
return { params, notes: ['skipped: reasoning model'] }
|
|
24
|
+
}
|
|
25
|
+
return { params: { ...p, max_tokens: opts.default }, notes: [`max_tokens set to ${opts.default}`] }
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
module.exports = { outputCap, DEFAULTS }
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* tools: for agents and MCP servers with many tools.
|
|
4
|
+
* - When there are more than `maxTools` tool definitions, keep the most relevant ones.
|
|
5
|
+
* Always kept: tools named in `pinned`, tools already used in the conversation, and the
|
|
6
|
+
* tool forced by `tool_choice`. The rest are ranked by keyword overlap with the latest
|
|
7
|
+
* user message (ties keep the original order). The original order is preserved.
|
|
8
|
+
* - Descriptions longer than `maxDescriptionLength` are shortened (at a sentence end
|
|
9
|
+
* when possible), for the tool and for its parameters.
|
|
10
|
+
*
|
|
11
|
+
* Pluggable: `selector` may be a function `({ tools, messages, maxTools, keep }) => tools`
|
|
12
|
+
* where `keep` is the Set of names that must be kept.
|
|
13
|
+
*/
|
|
14
|
+
const { toolName } = require('../messages')
|
|
15
|
+
|
|
16
|
+
const DEFAULTS = { maxTools: 15, maxDescriptionLength: 600, pinned: [], selector: null }
|
|
17
|
+
|
|
18
|
+
const STOPWORDS = new Set(('the and for with that this from have what which when where will would could should about into ' +
|
|
19
|
+
'your you are was were been can not but all any our out get set use using please there their them then than ' +
|
|
20
|
+
'also just like make need want show give tell does did has had its it\'s let me my mine how who why').split(' '))
|
|
21
|
+
|
|
22
|
+
/** Lower-case keywords of a text, splitting snake_case and camelCase. */
|
|
23
|
+
function keywords (text) {
|
|
24
|
+
return new Set(String(text ?? '')
|
|
25
|
+
.replace(/([a-z])([A-Z])/g, '$1 $2')
|
|
26
|
+
.toLowerCase()
|
|
27
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
28
|
+
.filter(w => w.length >= 3 && !STOPWORDS.has(w)))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function toolKeywords (t) {
|
|
32
|
+
const fn = t?.function ?? t
|
|
33
|
+
const params = fn?.parameters?.properties ? Object.keys(fn.parameters.properties).join(' ') : ''
|
|
34
|
+
return keywords(`${toolName(t) ?? ''} ${fn?.description ?? ''} ${params}`)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Names of tools the conversation already used. */
|
|
38
|
+
function usedToolNames (messages) {
|
|
39
|
+
const names = new Set()
|
|
40
|
+
for (const m of messages) {
|
|
41
|
+
for (const tc of m?.tool_calls ?? []) if (tc?.function?.name) names.add(tc.function.name)
|
|
42
|
+
if (m?.role === 'tool' && m.name) names.add(m.name)
|
|
43
|
+
}
|
|
44
|
+
return names
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function contentText (content) {
|
|
48
|
+
if (typeof content === 'string') return content
|
|
49
|
+
if (Array.isArray(content)) return content.map(p => (typeof p === 'string' ? p : p?.text ?? '')).join(' ')
|
|
50
|
+
return ''
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Default selector: keyword overlap with the latest user message.
|
|
55
|
+
* @param {{ tools: object[], messages: object[], maxTools: number, keep: Set<string> }} input
|
|
56
|
+
*/
|
|
57
|
+
function keywordSelector ({ tools, messages, maxTools, keep }) {
|
|
58
|
+
const lastUser = [...messages].reverse().find(m => m?.role === 'user')
|
|
59
|
+
const query = keywords(contentText(lastUser?.content))
|
|
60
|
+
const scored = tools.map((t, i) => {
|
|
61
|
+
let score = 0
|
|
62
|
+
for (const w of toolKeywords(t)) if (query.has(w)) score++
|
|
63
|
+
return { t, i, score, must: keep.has(toolName(t)) || t?.type !== undefined && t.type !== 'function' }
|
|
64
|
+
})
|
|
65
|
+
const chosen = new Set(scored.filter(s => s.must).map(s => s.i))
|
|
66
|
+
const rest = scored.filter(s => !s.must).sort((a, b) => b.score - a.score || a.i - b.i)
|
|
67
|
+
for (const s of rest) {
|
|
68
|
+
if (chosen.size >= maxTools) break
|
|
69
|
+
chosen.add(s.i)
|
|
70
|
+
}
|
|
71
|
+
return tools.filter((_t, i) => chosen.has(i))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Shorten text to at most `max` characters, at a sentence end if one is in the second half.
|
|
76
|
+
* @param {string} text
|
|
77
|
+
* @param {number} max
|
|
78
|
+
*/
|
|
79
|
+
function shorten (text, max) {
|
|
80
|
+
if (typeof text !== 'string' || text.length <= max) return text
|
|
81
|
+
const head = text.slice(0, max)
|
|
82
|
+
const sentenceEnd = Math.max(head.lastIndexOf('. '), head.lastIndexOf('.\n'), head.lastIndexOf('! '), head.lastIndexOf('? '))
|
|
83
|
+
if (sentenceEnd >= max / 2) return head.slice(0, sentenceEnd + 1)
|
|
84
|
+
return `${head.slice(0, max - 1).trimEnd()}…`
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function shortenSchema (schema, max) {
|
|
88
|
+
if (!schema || typeof schema !== 'object' || Array.isArray(schema)) return schema
|
|
89
|
+
let changed = false
|
|
90
|
+
const out = { ...schema }
|
|
91
|
+
if (typeof schema.description === 'string') {
|
|
92
|
+
const d = shorten(schema.description, max)
|
|
93
|
+
if (d !== schema.description) { out.description = d; changed = true }
|
|
94
|
+
}
|
|
95
|
+
for (const key of ['properties', '$defs', 'definitions']) {
|
|
96
|
+
if (schema[key] && typeof schema[key] === 'object') {
|
|
97
|
+
const next = {}
|
|
98
|
+
let sub = false
|
|
99
|
+
for (const [k, v] of Object.entries(schema[key])) {
|
|
100
|
+
next[k] = shortenSchema(v, max)
|
|
101
|
+
if (next[k] !== v) sub = true
|
|
102
|
+
}
|
|
103
|
+
if (sub) { out[key] = next; changed = true }
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (schema.items) {
|
|
107
|
+
const items = shortenSchema(schema.items, max)
|
|
108
|
+
if (items !== schema.items) { out.items = items; changed = true }
|
|
109
|
+
}
|
|
110
|
+
return changed ? out : schema
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function shortenTool (t, max) {
|
|
114
|
+
const fn = t?.function
|
|
115
|
+
if (!fn) return t
|
|
116
|
+
const description = shorten(fn.description, max)
|
|
117
|
+
const parameters = shortenSchema(fn.parameters, max)
|
|
118
|
+
if (description === fn.description && parameters === fn.parameters) return t
|
|
119
|
+
return { ...t, function: { ...fn, ...(description !== undefined ? { description } : {}), ...(parameters !== undefined ? { parameters } : {}) } }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {import('../index').StepInput} input
|
|
124
|
+
* @param {object} options
|
|
125
|
+
*/
|
|
126
|
+
function tools ({ messages, tools: defs, params }, options) {
|
|
127
|
+
const opts = { ...DEFAULTS, ...options }
|
|
128
|
+
if (!Array.isArray(defs) || !defs.length) return { tools: defs, notes: [] }
|
|
129
|
+
const notes = []
|
|
130
|
+
let out = defs
|
|
131
|
+
|
|
132
|
+
if (opts.maxTools > 0 && defs.length > opts.maxTools) {
|
|
133
|
+
const keep = new Set([...(opts.pinned ?? []), ...usedToolNames(messages)])
|
|
134
|
+
const forced = params?.tool_choice?.function?.name
|
|
135
|
+
if (forced) keep.add(forced)
|
|
136
|
+
const selector = typeof opts.selector === 'function' ? opts.selector : keywordSelector
|
|
137
|
+
out = selector({ tools: defs, messages, maxTools: opts.maxTools, keep })
|
|
138
|
+
if (!Array.isArray(out)) throw new Error('tools selector must return an array')
|
|
139
|
+
const names = new Set(out.map(toolName))
|
|
140
|
+
const missing = [...keep].filter(n => defs.some(t => toolName(t) === n) && !names.has(n))
|
|
141
|
+
if (missing.length) throw new Error(`tools selector dropped required tools: ${missing.join(', ')}`)
|
|
142
|
+
if (out.length < defs.length) notes.push(`${defs.length - out.length} of ${defs.length} tools removed`)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (opts.maxDescriptionLength > 0) {
|
|
146
|
+
let shortened = 0
|
|
147
|
+
out = out.map(t => {
|
|
148
|
+
const s = shortenTool(t, opts.maxDescriptionLength)
|
|
149
|
+
if (s !== t) shortened++
|
|
150
|
+
return s
|
|
151
|
+
})
|
|
152
|
+
if (shortened) notes.push(`${shortened} tool description(s) shortened`)
|
|
153
|
+
}
|
|
154
|
+
return { tools: out, notes }
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
module.exports = { tools, keywordSelector, keywords, shorten, usedToolNames, DEFAULTS }
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
/**
|
|
3
|
+
* Text helpers for the pipeline: split text into code / JSON / prose segments and
|
|
4
|
+
* minify JSON without changing its value.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const FENCE_OPEN = /^[ \t]*(`{3,}|~{3,})(.*)$/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {{ type: 'code'|'prose', text: string, info?: string }} Segment
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Split text into fenced code blocks and prose. Fence lines belong to the code segment.
|
|
15
|
+
* An unclosed fence runs to the end of the text (as in CommonMark).
|
|
16
|
+
* @param {string} text
|
|
17
|
+
* @returns {Segment[]}
|
|
18
|
+
*/
|
|
19
|
+
function splitFences (text) {
|
|
20
|
+
const lines = text.split('\n')
|
|
21
|
+
const out = []
|
|
22
|
+
let buf = []
|
|
23
|
+
let fence = null
|
|
24
|
+
const push = (type, info) => {
|
|
25
|
+
if (!buf.length) return
|
|
26
|
+
out.push({ type, text: buf.join('\n'), ...(info !== undefined ? { info } : {}) })
|
|
27
|
+
buf = []
|
|
28
|
+
}
|
|
29
|
+
for (const line of lines) {
|
|
30
|
+
if (!fence) {
|
|
31
|
+
const m = FENCE_OPEN.exec(line)
|
|
32
|
+
if (m && !(m[1][0] === '`' && m[2].includes('`'))) {
|
|
33
|
+
push('prose')
|
|
34
|
+
fence = { char: m[1][0], len: m[1].length, info: m[2].trim() }
|
|
35
|
+
buf.push(line)
|
|
36
|
+
continue
|
|
37
|
+
}
|
|
38
|
+
buf.push(line)
|
|
39
|
+
} else {
|
|
40
|
+
buf.push(line)
|
|
41
|
+
const close = new RegExp(`^[ \\t]*${fence.char === '`' ? '`' : '~'}{${fence.len},}[ \\t]*$`)
|
|
42
|
+
if (close.test(line)) {
|
|
43
|
+
push('code', fence.info)
|
|
44
|
+
fence = null
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
push(fence ? 'code' : 'prose', fence?.info)
|
|
49
|
+
// re-joining with '\n' between segments restores the original text
|
|
50
|
+
return out
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Join segments produced by splitFences (or transformed copies of them). */
|
|
54
|
+
const joinSegments = (segments) => segments.map(s => s.text).join('\n')
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Remove insignificant whitespace from valid JSON. Works on the text, so numbers,
|
|
58
|
+
* key order and duplicate keys stay exactly as written.
|
|
59
|
+
* @param {string} json must already be valid JSON
|
|
60
|
+
*/
|
|
61
|
+
function minifyJson (json) {
|
|
62
|
+
let out = ''
|
|
63
|
+
let inString = false
|
|
64
|
+
let escaped = false
|
|
65
|
+
for (const ch of json) {
|
|
66
|
+
if (inString) {
|
|
67
|
+
out += ch
|
|
68
|
+
if (escaped) escaped = false
|
|
69
|
+
else if (ch === '\\') escaped = true
|
|
70
|
+
else if (ch === '"') inString = false
|
|
71
|
+
} else if (ch === '"') {
|
|
72
|
+
inString = true
|
|
73
|
+
out += ch
|
|
74
|
+
} else if (ch !== ' ' && ch !== '\t' && ch !== '\n' && ch !== '\r') {
|
|
75
|
+
out += ch
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return out
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Minify `text` if it is a JSON object or array; otherwise return null.
|
|
83
|
+
* @param {string} text
|
|
84
|
+
* @returns {string|null}
|
|
85
|
+
*/
|
|
86
|
+
function tryMinifyJson (text) {
|
|
87
|
+
const t = text.trim()
|
|
88
|
+
if (!(t.startsWith('{') || t.startsWith('['))) return null
|
|
89
|
+
let parsed
|
|
90
|
+
try { parsed = JSON.parse(t) } catch { return null }
|
|
91
|
+
if (parsed === null || typeof parsed !== 'object') return null
|
|
92
|
+
return minifyJson(t)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Find the end index (exclusive) of the JSON value that starts with a bracket at `start`,
|
|
97
|
+
* respecting strings. Returns -1 if the brackets never balance.
|
|
98
|
+
* @param {string} text
|
|
99
|
+
* @param {number} start
|
|
100
|
+
*/
|
|
101
|
+
function matchBracket (text, start) {
|
|
102
|
+
const stack = []
|
|
103
|
+
let inString = false
|
|
104
|
+
let escaped = false
|
|
105
|
+
for (let i = start; i < text.length; i++) {
|
|
106
|
+
const ch = text[i]
|
|
107
|
+
if (inString) {
|
|
108
|
+
if (escaped) escaped = false
|
|
109
|
+
else if (ch === '\\') escaped = true
|
|
110
|
+
else if (ch === '"') inString = false
|
|
111
|
+
continue
|
|
112
|
+
}
|
|
113
|
+
if (ch === '"') inString = true
|
|
114
|
+
else if (ch === '{' || ch === '[') stack.push(ch)
|
|
115
|
+
else if (ch === '}' || ch === ']') {
|
|
116
|
+
const open = stack.pop()
|
|
117
|
+
if ((ch === '}' && open !== '{') || (ch === ']' && open !== '[')) return -1
|
|
118
|
+
if (!stack.length) return i + 1
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return -1
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Split prose into JSON blocks and the text around them. A JSON block starts with `{` or `[`
|
|
126
|
+
* at the beginning of a line (after indentation), ends at the end of a line, and parses
|
|
127
|
+
* as a JSON object or array.
|
|
128
|
+
* @param {string} text
|
|
129
|
+
* @returns {Array<{ type: 'json'|'text', text: string }>}
|
|
130
|
+
*/
|
|
131
|
+
function splitJsonBlocks (text) {
|
|
132
|
+
const out = []
|
|
133
|
+
let last = 0
|
|
134
|
+
let i = 0
|
|
135
|
+
while (i < text.length) {
|
|
136
|
+
const lineStart = i === 0 || text[i - 1] === '\n'
|
|
137
|
+
if (lineStart) {
|
|
138
|
+
let j = i
|
|
139
|
+
while (text[j] === ' ' || text[j] === '\t') j++
|
|
140
|
+
if (text[j] === '{' || text[j] === '[') {
|
|
141
|
+
const end = matchBracket(text, j)
|
|
142
|
+
if (end > 0) {
|
|
143
|
+
let k = end
|
|
144
|
+
while (text[k] === ' ' || text[k] === '\t' || text[k] === '\r') k++
|
|
145
|
+
if (k >= text.length || text[k] === '\n') {
|
|
146
|
+
const candidate = text.slice(j, end)
|
|
147
|
+
if (tryMinifyJson(candidate) !== null) {
|
|
148
|
+
if (j > last) out.push({ type: 'text', text: text.slice(last, j) })
|
|
149
|
+
out.push({ type: 'json', text: candidate })
|
|
150
|
+
last = end
|
|
151
|
+
i = end
|
|
152
|
+
continue
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const nl = text.indexOf('\n', i)
|
|
159
|
+
if (nl < 0) break
|
|
160
|
+
i = nl + 1
|
|
161
|
+
}
|
|
162
|
+
if (last < text.length) out.push({ type: 'text', text: text.slice(last) })
|
|
163
|
+
return out
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = { splitFences, joinSegments, minifyJson, tryMinifyJson, splitJsonBlocks, matchBracket }
|