@yolk-sdk/codemode 0.1.0-canary.96
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/LICENSE +21 -0
- package/README.md +132 -0
- package/dist/catalog.d.mts +60 -0
- package/dist/catalog.d.mts.map +1 -0
- package/dist/catalog.mjs +143 -0
- package/dist/catalog.mjs.map +1 -0
- package/dist/classifier-tool.d.mts +78 -0
- package/dist/classifier-tool.d.mts.map +1 -0
- package/dist/classifier-tool.mjs +125 -0
- package/dist/classifier-tool.mjs.map +1 -0
- package/dist/executor.d.mts +89 -0
- package/dist/executor.d.mts.map +1 -0
- package/dist/executor.mjs +1 -0
- package/dist/index.d.mts +8 -0
- package/dist/index.mjs +7 -0
- package/dist/node.d.mts +34 -0
- package/dist/node.d.mts.map +1 -0
- package/dist/node.mjs +215 -0
- package/dist/node.mjs.map +1 -0
- package/dist/output.d.mts +42 -0
- package/dist/output.d.mts.map +1 -0
- package/dist/output.mjs +184 -0
- package/dist/output.mjs.map +1 -0
- package/dist/search.d.mts +23 -0
- package/dist/search.d.mts.map +1 -0
- package/dist/search.mjs +58 -0
- package/dist/search.mjs.map +1 -0
- package/dist/store.d.mts +40 -0
- package/dist/store.d.mts.map +1 -0
- package/dist/store.mjs +61 -0
- package/dist/store.mjs.map +1 -0
- package/dist/tool.d.mts +75 -0
- package/dist/tool.d.mts.map +1 -0
- package/dist/tool.mjs +282 -0
- package/dist/tool.mjs.map +1 -0
- package/package.json +63 -0
- package/src/catalog.ts +274 -0
- package/src/classifier-tool.ts +227 -0
- package/src/executor.ts +103 -0
- package/src/index.ts +76 -0
- package/src/node.ts +334 -0
- package/src/output.ts +287 -0
- package/src/search.ts +113 -0
- package/src/store.ts +93 -0
- package/src/tool.ts +547 -0
package/src/output.ts
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ImagePart,
|
|
3
|
+
inlineBase64AttachmentSource,
|
|
4
|
+
TextPart,
|
|
5
|
+
type Content,
|
|
6
|
+
type NestedToolCallStatus
|
|
7
|
+
} from '@yolk-sdk/agent/protocol'
|
|
8
|
+
import type { CodeModeExecutionResult, CodeModeOutputItem } from './executor.ts'
|
|
9
|
+
|
|
10
|
+
/** Text or image piece of the model-visible result, in order. */
|
|
11
|
+
export type CodeModeResultSegment = CodeModeOutputItem
|
|
12
|
+
|
|
13
|
+
const isHighSurrogate = (code: number) => code >= 0xd800 && code <= 0xdbff
|
|
14
|
+
|
|
15
|
+
const isLowSurrogate = (code: number) => code >= 0xdc00 && code <= 0xdfff
|
|
16
|
+
|
|
17
|
+
// Cut points never split a surrogate pair.
|
|
18
|
+
const headCut = (text: string, length: number) =>
|
|
19
|
+
length > 0 && length < text.length && isHighSurrogate(text.charCodeAt(length - 1))
|
|
20
|
+
? length - 1
|
|
21
|
+
: length
|
|
22
|
+
|
|
23
|
+
const tailCut = (text: string, start: number) =>
|
|
24
|
+
start > 0 && start < text.length && isLowSurrogate(text.charCodeAt(start)) ? start + 1 : start
|
|
25
|
+
|
|
26
|
+
const textLength = (segments: ReadonlyArray<CodeModeResultSegment>) =>
|
|
27
|
+
segments.reduce(
|
|
28
|
+
(total, segment) => total + (segment.type === 'text' ? segment.text.length : 0),
|
|
29
|
+
0
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
const omissionMarker = (characters: number, images: number) =>
|
|
33
|
+
`\n\n[… ${characters} characters${images > 0 ? ` and ${images} image${images === 1 ? '' : 's'}` : ''} omitted …]\n\n`
|
|
34
|
+
|
|
35
|
+
const markerReserve = 80
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Keeps at most about `maxChars` characters of text: the head and the tail of the segments, with an
|
|
39
|
+
* omission marker in between. Images inside the omitted middle are dropped and counted.
|
|
40
|
+
*/
|
|
41
|
+
export const boundCodeModeSegments = (
|
|
42
|
+
segments: ReadonlyArray<CodeModeResultSegment>,
|
|
43
|
+
maxChars: number
|
|
44
|
+
): ReadonlyArray<CodeModeResultSegment> => {
|
|
45
|
+
const total = textLength(segments)
|
|
46
|
+
|
|
47
|
+
if (total <= maxChars) return segments
|
|
48
|
+
|
|
49
|
+
const budget = Math.max(0, maxChars - markerReserve)
|
|
50
|
+
let headBudget = Math.ceil(budget / 2)
|
|
51
|
+
let tailBudget = budget - headBudget
|
|
52
|
+
|
|
53
|
+
const head: Array<CodeModeResultSegment> = []
|
|
54
|
+
let headIndex = 0
|
|
55
|
+
let headPartial = 0
|
|
56
|
+
|
|
57
|
+
for (; headIndex < segments.length && headBudget > 0; headIndex++) {
|
|
58
|
+
const segment = segments[headIndex]
|
|
59
|
+
|
|
60
|
+
if (segment === undefined) break
|
|
61
|
+
|
|
62
|
+
if (segment.type === 'image') {
|
|
63
|
+
head.push(segment)
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (segment.text.length <= headBudget) {
|
|
68
|
+
head.push(segment)
|
|
69
|
+
headBudget -= segment.text.length
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
headPartial = headCut(segment.text, headBudget)
|
|
74
|
+
head.push({ type: 'text', text: segment.text.slice(0, headPartial) })
|
|
75
|
+
headBudget = 0
|
|
76
|
+
break
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const tail: Array<CodeModeResultSegment> = []
|
|
80
|
+
let tailIndex = segments.length - 1
|
|
81
|
+
let tailStart: number | undefined
|
|
82
|
+
|
|
83
|
+
for (; tailIndex >= headIndex && tailBudget > 0; tailIndex--) {
|
|
84
|
+
const segment = segments[tailIndex]
|
|
85
|
+
|
|
86
|
+
if (segment === undefined) break
|
|
87
|
+
|
|
88
|
+
if (segment.type === 'image') {
|
|
89
|
+
if (tailIndex === headIndex) break
|
|
90
|
+
tail.unshift(segment)
|
|
91
|
+
continue
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const available =
|
|
95
|
+
tailIndex === headIndex ? segment.text.length - headPartial : segment.text.length
|
|
96
|
+
|
|
97
|
+
if (available <= tailBudget && tailIndex !== headIndex) {
|
|
98
|
+
tail.unshift(segment)
|
|
99
|
+
tailBudget -= available
|
|
100
|
+
continue
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
tailStart = tailCut(segment.text, segment.text.length - Math.min(available, tailBudget))
|
|
104
|
+
tail.unshift({ type: 'text', text: segment.text.slice(tailStart) })
|
|
105
|
+
tailBudget = 0
|
|
106
|
+
break
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const keptText = textLength(head) + textLength(tail)
|
|
110
|
+
const keptImages = [...head, ...tail].filter(segment => segment.type === 'image').length
|
|
111
|
+
const allImages = segments.filter(segment => segment.type === 'image').length
|
|
112
|
+
|
|
113
|
+
return [
|
|
114
|
+
...head,
|
|
115
|
+
{ type: 'text', text: omissionMarker(total - keptText, allImages - keptImages) },
|
|
116
|
+
...tail
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const mergeText = (segments: ReadonlyArray<CodeModeResultSegment>) =>
|
|
121
|
+
segments.reduce<Array<CodeModeResultSegment>>((merged, segment) => {
|
|
122
|
+
const last = merged.at(-1)
|
|
123
|
+
|
|
124
|
+
if (segment.type === 'text' && last?.type === 'text') {
|
|
125
|
+
merged[merged.length - 1] = { type: 'text', text: last.text + segment.text }
|
|
126
|
+
} else if (segment.type === 'image' || segment.text.length > 0) {
|
|
127
|
+
merged.push(segment)
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
return merged
|
|
131
|
+
}, [])
|
|
132
|
+
|
|
133
|
+
/** Agent `Content`: a plain string without images, ordered text and image parts otherwise. */
|
|
134
|
+
export const codeModeSegmentsContent = (
|
|
135
|
+
segments: ReadonlyArray<CodeModeResultSegment>
|
|
136
|
+
): Content => {
|
|
137
|
+
const merged = mergeText(segments)
|
|
138
|
+
|
|
139
|
+
if (merged.every(segment => segment.type === 'text')) {
|
|
140
|
+
return merged.map(segment => (segment.type === 'text' ? segment.text : '')).join('')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return merged.map(segment =>
|
|
144
|
+
segment.type === 'text'
|
|
145
|
+
? TextPart.make({ text: segment.text })
|
|
146
|
+
: ImagePart.make({
|
|
147
|
+
source: inlineBase64AttachmentSource(segment.data),
|
|
148
|
+
mimeType: segment.mimeType
|
|
149
|
+
})
|
|
150
|
+
)
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export type CodeModeCallSummary = {
|
|
154
|
+
readonly name: string
|
|
155
|
+
readonly status: NestedToolCallStatus
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const statusOrder: ReadonlyArray<NestedToolCallStatus> = ['ok', 'error', 'cancelled']
|
|
159
|
+
|
|
160
|
+
/** `name: 2 ok, 1 error; other: 1 cancelled`, by first appearance. */
|
|
161
|
+
export const summarizeCodeModeCalls = (calls: ReadonlyArray<CodeModeCallSummary>): string => {
|
|
162
|
+
if (calls.length === 0) return 'none'
|
|
163
|
+
|
|
164
|
+
const byName = new Map<string, Map<NestedToolCallStatus, number>>()
|
|
165
|
+
|
|
166
|
+
for (const call of calls) {
|
|
167
|
+
const counts = byName.get(call.name) ?? new Map<NestedToolCallStatus, number>()
|
|
168
|
+
|
|
169
|
+
counts.set(call.status, (counts.get(call.status) ?? 0) + 1)
|
|
170
|
+
byName.set(call.name, counts)
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return [...byName]
|
|
174
|
+
.map(
|
|
175
|
+
([name, counts]) =>
|
|
176
|
+
`${name}: ${statusOrder
|
|
177
|
+
.flatMap(status => {
|
|
178
|
+
const count = counts.get(status)
|
|
179
|
+
|
|
180
|
+
return count === undefined ? [] : [`${count} ${status}`]
|
|
181
|
+
})
|
|
182
|
+
.join(', ')}`
|
|
183
|
+
)
|
|
184
|
+
.join('; ')
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const stackFrames = (stack: string | undefined) =>
|
|
188
|
+
stack === undefined
|
|
189
|
+
? []
|
|
190
|
+
: stack.split('\n').filter(line => /^\s+at\s/.test(line) || /^\s*\S+:\d+/.test(line))
|
|
191
|
+
|
|
192
|
+
const encodeValue = (value: unknown): string => {
|
|
193
|
+
try {
|
|
194
|
+
return JSON.stringify(value) ?? 'undefined'
|
|
195
|
+
} catch {
|
|
196
|
+
return '[unserializable value]'
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Default cap of images kept in one result. */
|
|
201
|
+
export const defaultCodeModeMaxImages = 8
|
|
202
|
+
|
|
203
|
+
/** Default cap of base64 image data kept in one result, in characters (4 MiB). */
|
|
204
|
+
export const defaultCodeModeMaxImageBytes = 4 * 1024 * 1024
|
|
205
|
+
|
|
206
|
+
const imageOmissionNote = (dropped: number, maxImages: number, maxImageBytes: number) =>
|
|
207
|
+
`\n\n[… ${dropped} image${dropped === 1 ? '' : 's'} omitted: a result keeps at most ${maxImages} images and ${maxImageBytes} characters of base64 image data …]`
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Model-visible segments of one execution: a `Script completed` / `Script failed` header with the
|
|
211
|
+
* wall time, the output in order, the JSON return value (omitted when undefined), and for failures
|
|
212
|
+
* the error and the tool calls already made. Images are kept in order while they fit `maxImages`
|
|
213
|
+
* (default 8) and `maxImageBytes` of base64 in total (default 4 MiB); an image that would exceed
|
|
214
|
+
* either is dropped and counted in an omission note after the output. The result is then bounded
|
|
215
|
+
* by `maxChars` with a head-and-tail cut, which also drops images in the omitted middle.
|
|
216
|
+
*/
|
|
217
|
+
export const codeModeResultSegments = (input: {
|
|
218
|
+
readonly result: CodeModeExecutionResult
|
|
219
|
+
readonly wallTimeMs: number
|
|
220
|
+
readonly calls: ReadonlyArray<CodeModeCallSummary>
|
|
221
|
+
readonly maxChars: number
|
|
222
|
+
readonly maxImages?: number
|
|
223
|
+
readonly maxImageBytes?: number
|
|
224
|
+
}): ReadonlyArray<CodeModeResultSegment> => {
|
|
225
|
+
const { result } = input
|
|
226
|
+
const maxImages = Math.max(0, Math.floor(input.maxImages ?? defaultCodeModeMaxImages))
|
|
227
|
+
const maxImageBytes = Math.max(0, Math.floor(input.maxImageBytes ?? defaultCodeModeMaxImageBytes))
|
|
228
|
+
const ms = Math.round(input.wallTimeMs)
|
|
229
|
+
const segments: Array<CodeModeResultSegment> = []
|
|
230
|
+
|
|
231
|
+
const text = (value: string) => {
|
|
232
|
+
segments.push({ type: 'text', text: value })
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
text(result.ok ? `Script completed in ${ms} ms.` : `Script failed after ${ms} ms.`)
|
|
236
|
+
|
|
237
|
+
if (result.output.length > 0) {
|
|
238
|
+
text('\n\nOutput:\n')
|
|
239
|
+
|
|
240
|
+
let keptImages = 0
|
|
241
|
+
let keptImageBytes = 0
|
|
242
|
+
let droppedImages = 0
|
|
243
|
+
|
|
244
|
+
result.output.forEach((item, index) => {
|
|
245
|
+
const previous = result.output[index - 1]
|
|
246
|
+
|
|
247
|
+
if (item.type === 'text') {
|
|
248
|
+
text(previous?.type === 'text' ? `\n${item.text}` : item.text)
|
|
249
|
+
} else if (keptImages < maxImages && keptImageBytes + item.data.length <= maxImageBytes) {
|
|
250
|
+
keptImages++
|
|
251
|
+
keptImageBytes += item.data.length
|
|
252
|
+
segments.push(item)
|
|
253
|
+
} else {
|
|
254
|
+
droppedImages++
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
if (droppedImages > 0) text(imageOmissionNote(droppedImages, maxImages, maxImageBytes))
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (result.ok && result.value !== undefined) {
|
|
262
|
+
text(`\n\nReturn value:\n${encodeValue(result.value)}`)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (!result.ok) {
|
|
266
|
+
const error = result.error ?? {
|
|
267
|
+
kind: 'sandbox',
|
|
268
|
+
message: 'The script failed without an error.'
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
text(`\n\nScript error: ${error.kind}: ${error.message}`)
|
|
272
|
+
|
|
273
|
+
const frames = stackFrames(error.stack)
|
|
274
|
+
|
|
275
|
+
if (frames.length > 0) text(`\n${frames.join('\n')}`)
|
|
276
|
+
|
|
277
|
+
text(
|
|
278
|
+
`\n\nTool calls made before the failure (they are not undone): ${summarizeCodeModeCalls(input.calls)}.`
|
|
279
|
+
)
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
if (result.ok && result.output.length === 0 && result.value === undefined) {
|
|
283
|
+
text(' No output and no return value.')
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return boundCodeModeSegments(mergeText(segments), input.maxChars)
|
|
287
|
+
}
|
package/src/search.ts
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { CodeModeCatalogTool } from './catalog.ts'
|
|
2
|
+
|
|
3
|
+
/** Default number of `searchTools` results. */
|
|
4
|
+
export const defaultCodeModeSearchLimit = 8
|
|
5
|
+
|
|
6
|
+
const k1 = 1.2
|
|
7
|
+
|
|
8
|
+
const b = 0.75
|
|
9
|
+
|
|
10
|
+
/** Lowercase word tokens; splits on non-alphanumerics and camelCase boundaries. */
|
|
11
|
+
export const codeModeSearchTokens = (text: string): ReadonlyArray<string> =>
|
|
12
|
+
text
|
|
13
|
+
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
|
|
14
|
+
.toLowerCase()
|
|
15
|
+
.split(/[^a-z0-9]+/)
|
|
16
|
+
.filter(token => token.length > 0)
|
|
17
|
+
|
|
18
|
+
type IndexedTool = {
|
|
19
|
+
readonly tool: CodeModeCatalogTool
|
|
20
|
+
readonly frequencies: ReadonlyMap<string, number>
|
|
21
|
+
readonly length: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const indexTool = (tool: CodeModeCatalogTool): IndexedTool => {
|
|
25
|
+
const tokens = codeModeSearchTokens(
|
|
26
|
+
[
|
|
27
|
+
tool.identifier,
|
|
28
|
+
tool.name,
|
|
29
|
+
tool.description,
|
|
30
|
+
tool.namespace,
|
|
31
|
+
tool.namespaceDescription ?? ''
|
|
32
|
+
].join(' ')
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
const frequencies = new Map<string, number>()
|
|
36
|
+
|
|
37
|
+
for (const token of tokens) {
|
|
38
|
+
frequencies.set(token, (frequencies.get(token) ?? 0) + 1)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
return { tool, frequencies, length: tokens.length }
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type CodeModeSearchOptions = {
|
|
45
|
+
readonly limit?: number
|
|
46
|
+
readonly namespace?: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type CodeModeSearchHit = {
|
|
50
|
+
/** The identifier scripts call as `tools.<name>(args)`. */
|
|
51
|
+
readonly name: string
|
|
52
|
+
readonly description: string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* BM25 over each tool's identifier, raw name, description, namespace, and namespace description. Ties keep catalog order.
|
|
57
|
+
* A query without words lists the (namespace-filtered) tools in catalog order.
|
|
58
|
+
*/
|
|
59
|
+
export const searchCodeModeTools = (
|
|
60
|
+
catalog: ReadonlyArray<CodeModeCatalogTool>,
|
|
61
|
+
query: string,
|
|
62
|
+
options: CodeModeSearchOptions = {}
|
|
63
|
+
): ReadonlyArray<CodeModeSearchHit> => {
|
|
64
|
+
const limit = Math.max(0, Math.floor(options.limit ?? defaultCodeModeSearchLimit))
|
|
65
|
+
|
|
66
|
+
const scoped =
|
|
67
|
+
options.namespace === undefined
|
|
68
|
+
? catalog
|
|
69
|
+
: catalog.filter(tool => tool.namespace === options.namespace)
|
|
70
|
+
|
|
71
|
+
const documents = scoped.map(indexTool)
|
|
72
|
+
const terms = [...new Set(codeModeSearchTokens(query))]
|
|
73
|
+
|
|
74
|
+
const hit = (tool: CodeModeCatalogTool): CodeModeSearchHit => ({
|
|
75
|
+
name: tool.identifier,
|
|
76
|
+
description: tool.description
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
if (terms.length === 0) {
|
|
80
|
+
return documents.slice(0, limit).map(document => hit(document.tool))
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
const averageLength =
|
|
84
|
+
documents.reduce((total, document) => total + document.length, 0) /
|
|
85
|
+
Math.max(1, documents.length)
|
|
86
|
+
|
|
87
|
+
const inverse = new Map(
|
|
88
|
+
terms.map((term): readonly [string, number] => {
|
|
89
|
+
const containing = documents.filter(document => document.frequencies.has(term)).length
|
|
90
|
+
|
|
91
|
+
return [term, Math.log(1 + (documents.length - containing + 0.5) / (containing + 0.5))]
|
|
92
|
+
})
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return documents
|
|
96
|
+
.map((document, index) => {
|
|
97
|
+
const score = terms.reduce((total, term) => {
|
|
98
|
+
const frequency = document.frequencies.get(term) ?? 0
|
|
99
|
+
|
|
100
|
+
if (frequency === 0) return total
|
|
101
|
+
|
|
102
|
+
const norm = k1 * (1 - b + (b * document.length) / Math.max(1, averageLength))
|
|
103
|
+
|
|
104
|
+
return total + ((inverse.get(term) ?? 0) * frequency * (k1 + 1)) / (frequency + norm)
|
|
105
|
+
}, 0)
|
|
106
|
+
|
|
107
|
+
return { document, index, score }
|
|
108
|
+
})
|
|
109
|
+
.filter(entry => entry.score > 0)
|
|
110
|
+
.sort((left, right) => right.score - left.score || left.index - right.index)
|
|
111
|
+
.slice(0, limit)
|
|
112
|
+
.map(entry => hit(entry.document.tool))
|
|
113
|
+
}
|
package/src/store.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { Option } from 'effect'
|
|
2
|
+
import * as Schema from 'effect/Schema'
|
|
3
|
+
import type { CodeModeStore, CodeModeStoreWrites } from './executor.ts'
|
|
4
|
+
import { codeModeToolName } from './tool.ts'
|
|
5
|
+
|
|
6
|
+
/** `structuredContent` of a code mode `ToolResult`. `storeWrites` is present only for successful
|
|
7
|
+
* scripts that changed the store.
|
|
8
|
+
*/
|
|
9
|
+
export type CodeModeStructuredContent = {
|
|
10
|
+
readonly codemode: {
|
|
11
|
+
readonly ok: boolean
|
|
12
|
+
readonly storeWrites?: CodeModeStoreWrites
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const StoreWritesSchema = Schema.Struct({
|
|
17
|
+
set: Schema.Record(Schema.String, Schema.Json),
|
|
18
|
+
delete: Schema.Array(Schema.String)
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
const CodeModeStructuredContentSchema = Schema.Struct({
|
|
22
|
+
codemode: Schema.Struct({
|
|
23
|
+
ok: Schema.Boolean,
|
|
24
|
+
storeWrites: Schema.optionalKey(StoreWritesSchema)
|
|
25
|
+
})
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
const decodeStructuredContent = Schema.decodeUnknownOption(CodeModeStructuredContentSchema)
|
|
29
|
+
|
|
30
|
+
/** Largest stored value: characters of JSON (256 KiB), as the pi engine counts them. */
|
|
31
|
+
export const maxCodeModeStoreValueChars = 256 * 1024
|
|
32
|
+
|
|
33
|
+
/** Largest store: characters of keys plus JSON values (1 MiB), as the pi engine counts them. */
|
|
34
|
+
export const maxCodeModeStoreTotalChars = 1024 * 1024
|
|
35
|
+
|
|
36
|
+
/** One prior tool result with the name of the tool that produced it. */
|
|
37
|
+
export type CodeModeToolResultEntry = {
|
|
38
|
+
readonly toolName: string
|
|
39
|
+
readonly result: { readonly structuredContent?: unknown }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Rebuilds the code mode store from prior tool results, oldest first: applies the `storeWrites` of
|
|
44
|
+
* each successful script of the code mode tool named `toolName` (default `codemode`) in order.
|
|
45
|
+
* Results of other tools (even when their `structuredContent` looks like a code mode result),
|
|
46
|
+
* failed scripts, and malformed contents are skipped. Transcript `ToolResultMessage`s carry no tool
|
|
47
|
+
* name; pair each with the name of its assistant tool call.
|
|
48
|
+
*
|
|
49
|
+
* The rebuilt store keeps the engine bounds: a write whose JSON value exceeds 256 Ki characters, or
|
|
50
|
+
* that would grow the store beyond 1 Mi characters (keys plus JSON values), is dropped and the key
|
|
51
|
+
* keeps its previous value. Deletions always apply. Pass the value from `loadStore`.
|
|
52
|
+
*/
|
|
53
|
+
export const codeModeStoreFromToolResults = (
|
|
54
|
+
entries: ReadonlyArray<CodeModeToolResultEntry>,
|
|
55
|
+
options: { readonly toolName?: string } = {}
|
|
56
|
+
): CodeModeStore => {
|
|
57
|
+
const toolName = options.toolName ?? codeModeToolName
|
|
58
|
+
const store = new Map<string, { readonly value: Schema.Json; readonly chars: number }>()
|
|
59
|
+
let total = 0
|
|
60
|
+
|
|
61
|
+
for (const entry of entries) {
|
|
62
|
+
if (entry.toolName !== toolName) continue
|
|
63
|
+
|
|
64
|
+
const content = decodeStructuredContent(entry.result.structuredContent)
|
|
65
|
+
|
|
66
|
+
if (Option.isNone(content) || !content.value.codemode.ok) continue
|
|
67
|
+
|
|
68
|
+
const writes = content.value.codemode.storeWrites
|
|
69
|
+
|
|
70
|
+
if (writes === undefined) continue
|
|
71
|
+
|
|
72
|
+
for (const key of writes.delete) {
|
|
73
|
+
total -= store.get(key)?.chars ?? 0
|
|
74
|
+
store.delete(key)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
for (const [key, value] of Object.entries(writes.set)) {
|
|
78
|
+
const json = JSON.stringify(value)
|
|
79
|
+
|
|
80
|
+
if (json.length > maxCodeModeStoreValueChars) continue
|
|
81
|
+
|
|
82
|
+
const chars = key.length + json.length
|
|
83
|
+
const next = total - (store.get(key)?.chars ?? 0) + chars
|
|
84
|
+
|
|
85
|
+
if (next > maxCodeModeStoreTotalChars) continue
|
|
86
|
+
|
|
87
|
+
store.set(key, { value, chars })
|
|
88
|
+
total = next
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
return Object.fromEntries([...store].map(([key, stored]) => [key, stored.value]))
|
|
93
|
+
}
|